Files
clickstream-data-platform/compose.yaml
T
ddadmin 54686651df fix(dq): устранено переполнение памяти проверок
- Зачем:
  - параллельный DQ выгружал деловые ключи в Python и исчерпывал 640 МиБ планировщика.
- Что:
  - четыре сверки агрегируют дневные итоги и ограниченную диагностику в ClickHouse.
  - коробка планировщика поднята до 1,5 ГиБ, локальные пределы задач удалены.
  - измерения и граница между SQL и Python записаны в документации.
- Проверка:
  - make lint, make config-test, make smoke, make check-clickhouse, make check-services.
  - полный etl_pipeline, красный и восстановительный прогоны dq_check.
2026-08-27 11:25:19 -04:00

439 lines
19 KiB
YAML

name: ${COMPOSE_PROJECT_NAME:?Скопируйте .env.example в .env}
x-clickhouse-nofile: &clickhouse-nofile
nofile:
soft: 262144
hard: 262144
x-clickhouse-common: &clickhouse-common
image: clickhouse/clickhouse-server:26.3.17.56
restart: unless-stopped
environment:
# Пользователей объявляет access.xml; настройка учётки образом не нужна.
CLICKHOUSE_SKIP_USER_SETUP: "1"
# Каталог товаров смонтирован в user_files только для чтения. Entrypoint
# иначе пытается сделать ему chown и первый запуск ноды завершается ошибкой.
CLICKHOUSE_DO_NOT_CHOWN: "1"
CLICKHOUSE_CLUSTER_SECRET: ${CLICKHOUSE_CLUSTER_SECRET:?Скопируйте .env.example в .env}
CLICKHOUSE_DEFAULT_PASSWORD: ${CLICKHOUSE_DEFAULT_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env}
depends_on:
clickhouse-keeper:
condition: service_healthy
ulimits: *clickhouse-nofile
# Гигабайта не хватало: половину съедали страницы самого бинарника (ADR 0004).
mem_limit: 4g
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --user default --password \"$$CLICKHOUSE_DEFAULT_PASSWORD\" --query 'SELECT 1' >/dev/null 2>&1"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
# Факты генератора и пульта мира названы по одному разу — иначе они однажды
# разойдутся, и заметит это не проверка, а менти с пустым топиком.
x-generator-image: &generator-image clickstream-generator:local
x-orders-topic: &orders-topic orders
x-kafka-common: &kafka-common
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
x-kafka-target: &kafka-target
<<: *kafka-common
KAFKA_TOPIC: hits
x-world-starting-days: &world-starting-days "8"
x-airflow-common: &airflow-common
image: clickstream-airflow:local
build:
context: ./infra/airflow
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__PARALLELISM: "4"
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
AIRFLOW__CORE__AUTH_MANAGER: airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}:admin"
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/auth/simple_auth_manager_passwords.json
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?Скопируйте .env.example в .env}
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:?Скопируйте .env.example в .env}
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:?Скопируйте .env.example в .env}
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: "postgresql+psycopg2://${AIRFLOW_METADATA_USER:?Скопируйте .env.example в .env}:${AIRFLOW_METADATA_PASSWORD:?Скопируйте .env.example в .env}@postgres-metadata:5432/airflow"
AIRFLOW__SCHEDULER__ENABLE_HEALTH_CHECK: "true"
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
# Пульт мира поднимает генератор отдельным контейнером. Имя сети собирается
# из имени проекта: у второй копии стенда оно другое.
<<: *kafka-target
GENERATOR_IMAGE: *generator-image
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
WORLD_STARTING_DAYS: *world-starting-days
KAFKA_ORDERS_TOPIC: *orders-topic
volumes:
- ./dags:/opt/airflow/dags:ro
# Запросы дагов: даг называет файл, а обработчик DAG при разборе загружает
# его текст. Каталог тот же, что применяет DDL, — вся SQL стенда живёт в sql/.
- ./sql:/opt/airflow/sql:ro
# Инициализация мира собирает канонический образ из того же контекста, что
# ручной generator. Docker SDK архивирует эти файлы внутри задачи.
- ./generator:/opt/airflow/world/generator:ro
- ./data:/opt/airflow/world/data:ro
- ./.dockerignore:/opt/airflow/world/.dockerignore:ro
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
- airflow_logs:/opt/airflow/logs
- airflow_auth:/opt/airflow/auth
x-generator-common: &generator-common
image: *generator-image
build:
context: .
dockerfile: generator/Dockerfile
restart: "no"
environment:
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
# от того, кто запускает: у службы нет позиции на оси мира.
<<: *kafka-target
x-superset-common: &superset-common
image: clickstream-superset:local
build:
context: ./infra/superset
environment:
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
SUPERSET__SQLALCHEMY_DATABASE_URI: "postgresql+psycopg2://${SUPERSET_METADATA_USER:?Скопируйте .env.example в .env}:${SUPERSET_METADATA_PASSWORD:?Скопируйте .env.example в .env}@postgres-metadata:5432/superset"
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:?Скопируйте .env.example в .env}
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
volumes:
- ./infra/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
- ./infra/superset/init.sh:/app/infra/init.sh:ro
- ./infra/superset/import:/app/import:ro
- superset_home:/app/superset_home
services:
clickhouse-keeper:
image: clickhouse/clickhouse-server:26.3.17.56
restart: unless-stopped
user: clickhouse
ulimits: *clickhouse-nofile
entrypoint:
- clickhouse
- keeper
- --config-file=/etc/clickhouse-keeper/keeper_config.xml
volumes:
- ./infra/clickhouse/keeper/keeper_config.xml:/etc/clickhouse-keeper/keeper_config.xml:ro
- clickhouse_keeper_data:/var/lib/clickhouse
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "clickhouse keeper-client --host 127.0.0.1 --port 9181 --query ruok | grep -q imok"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
# Инициатор DDL и точка подключения Airflow.
clickhouse-01:
<<: *clickhouse-common
hostname: clickhouse-01
ports:
- "127.0.0.1:${CLICKHOUSE_01_HTTP_PORT:?Скопируйте .env.example в .env}:8123"
- "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:?Скопируйте .env.example в .env}:9000"
volumes:
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
- ./infra/clickhouse/config.d/macros-01.xml:/etc/clickhouse-server/config.d/macros.xml:ro
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
- ./data/catalog:/var/lib/clickhouse/user_files/catalog:ro
- clickhouse_01_data:/var/lib/clickhouse
# Точка подключения Superset.
clickhouse-02:
<<: *clickhouse-common
hostname: clickhouse-02
ports:
- "127.0.0.1:${CLICKHOUSE_02_HTTP_PORT:?Скопируйте .env.example в .env}:8123"
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:?Скопируйте .env.example в .env}:9000"
volumes:
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
- ./infra/clickhouse/config.d/macros-02.xml:/etc/clickhouse-server/config.d/macros.xml:ro
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
- ./data/catalog:/var/lib/clickhouse/user_files/catalog:ro
- clickhouse_02_data:/var/lib/clickhouse
kafka:
image: apache/kafka:4.3.1
hostname: kafka
restart: unless-stopped
ports:
- "127.0.0.1:${KAFKA_EXTERNAL_PORT:?Скопируйте .env.example в .env}:29092"
environment:
KAFKA_NODE_ID: "1"
KAFKA_PROCESS_ROLES: "broker,controller"
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093"
KAFKA_LISTENERS: "INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:29092"
KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_EXTERNAL_PORT:?Скопируйте .env.example в .env}"
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT"
KAFKA_INTER_BROKER_LISTENER_NAME: "INTERNAL"
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1"
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1"
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1"
KAFKA_SHARE_COORDINATOR_STATE_TOPIC_REPLICATION_FACTOR: "1"
KAFKA_SHARE_COORDINATOR_STATE_TOPIC_MIN_ISR: "1"
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: "0"
KAFKA_NUM_PARTITIONS: "1"
KAFKA_LOG_DIRS: "/var/lib/kafka/data"
KAFKA_HEAP_OPTS: "-Xmx512m -Xms256m"
CLUSTER_ID: "4L6g3nShT-eMCtK--X86sw"
volumes:
- kafka_data:/var/lib/kafka/data
mem_limit: 1g
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list >/dev/null 2>&1"]
interval: 15s
timeout: 10s
retries: 20
start_period: 30s
kafka-exporter:
image: danielqsj/kafka-exporter:v1.9.0
restart: unless-stopped
depends_on:
kafka:
condition: service_healthy
command: ["--kafka.server=kafka:9092"]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9308/metrics >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 15
start_period: 10s
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
# Под профилем — чтобы обычный подъём стенда её не трогал.
generator:
<<: *generator-common
profiles: ["generator"]
command: ["batch"]
postgres-metadata:
image: postgres:16-alpine
restart: unless-stopped
command:
- postgres
- -c
- shared_buffers=64MB
- -c
- max_connections=50
environment:
POSTGRES_USER: ${POSTGRES_ADMIN_USER:?Скопируйте .env.example в .env}
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
POSTGRES_DB: postgres
AIRFLOW_METADATA_USER: ${AIRFLOW_METADATA_USER:?Скопируйте .env.example в .env}
AIRFLOW_METADATA_PASSWORD: ${AIRFLOW_METADATA_PASSWORD:?Скопируйте .env.example в .env}
SUPERSET_METADATA_USER: ${SUPERSET_METADATA_USER:?Скопируйте .env.example в .env}
SUPERSET_METADATA_PASSWORD: ${SUPERSET_METADATA_PASSWORD:?Скопируйте .env.example в .env}
volumes:
- ./infra/postgres/init/10-metadata-databases.sh:/docker-entrypoint-initdb.d/10-metadata-databases.sh:ro
- postgres_metadata_data:/var/lib/postgresql/data
mem_limit: 256m
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d postgres >/dev/null"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
airflow-init:
<<: *airflow-common
user: "0:0"
restart: "no"
depends_on:
postgres-metadata:
condition: service_healthy
clickhouse-01:
condition: service_healthy
entrypoint: ["/bin/bash"]
command: ["/opt/airflow/init.sh"]
airflow-apiserver:
<<: *airflow-common
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
command: api-server
ports:
- "127.0.0.1:${AIRFLOW_PORT:?Скопируйте .env.example в .env}:8080"
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/api/v2/monitor/health | python -c 'import json,sys; sys.exit(0 if json.load(sys.stdin)[\"metadatabase\"][\"status\"] == \"healthy\" else 1)'"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
airflow-scheduler:
<<: *airflow-common
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
command: scheduler
# Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660
# и группа `docker`, чей GID на каждой машине свой и живёт в `.env`.
group_add:
- ${DOCKER_GID:?Скопируйте .env.example в .env}
# Тома перечислены заново: список службы общий не дополняет, а заменяет.
# Появится новый том у остальных служб Airflow — добавьте и сюда.
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./dags:/opt/airflow/dags:ro
- ./sql:/opt/airflow/sql:ro
- ./generator:/opt/airflow/world/generator:ro
- ./data:/opt/airflow/world/data:ro
- ./.dockerignore:/opt/airflow/world/.dockerignore:ro
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
- airflow_logs:/opt/airflow/logs
- airflow_auth:/opt/airflow/auth
# LocalExecutor запускает здесь до четырех процессов задач одновременно.
# 640 МиБ дали OOM, а 1 ГиБ - давление на memory.max при штатном dq_check;
# 1,5 ГиБ оставляет запас при AIRFLOW__CORE__PARALLELISM=4.
mem_limit: 1536m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
airflow-dag-processor:
<<: *airflow-common
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
airflow-apiserver:
condition: service_healthy
command: dag-processor
mem_limit: 384m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://airflow-apiserver:8080/api/v2/monitor/health | python -c 'import json,sys; sys.exit(0 if json.load(sys.stdin)[\"dag_processor\"][\"status\"] == \"healthy\" else 1)'"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
superset-init:
<<: *superset-common
restart: "no"
healthcheck:
disable: true
depends_on:
postgres-metadata:
condition: service_healthy
clickhouse-02:
condition: service_healthy
entrypoint: ["/bin/bash"]
command: ["/app/infra/init.sh"]
superset:
<<: *superset-common
restart: unless-stopped
depends_on:
superset-init:
condition: service_completed_successfully
ports:
- "127.0.0.1:${SUPERSET_PORT:?Скопируйте .env.example в .env}:8088"
mem_limit: 1g
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8088/health >/dev/null"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
prometheus:
image: prom/prometheus:v3.13.2
restart: unless-stopped
depends_on:
clickhouse-keeper:
condition: service_healthy
clickhouse-01:
condition: service_healthy
clickhouse-02:
condition: service_healthy
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --storage.tsdb.retention.size=512MB
volumes:
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
ports:
- "127.0.0.1:${PROMETHEUS_PORT:?Скопируйте .env.example в .env}:9090"
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9090/-/ready >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 15
start_period: 10s
grafana:
image: clickstream-grafana:local
build:
context: ./infra/grafana
restart: unless-stopped
depends_on:
prometheus:
condition: service_healthy
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:?Скопируйте .env.example в .env}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_NEWS_NEWS_FEED_ENABLED: "false"
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env}
volumes:
- ./infra/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./infra/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- grafana_data:/var/lib/grafana
ports:
- "127.0.0.1:${GRAFANA_PORT:?Скопируйте .env.example в .env}:3000"
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3000/api/health >/dev/null"]
interval: 10s
timeout: 5s
retries: 15
start_period: 15s
volumes:
clickhouse_keeper_data:
clickhouse_01_data:
clickhouse_02_data:
kafka_data:
postgres_metadata_data:
airflow_logs:
airflow_auth:
superset_home:
prometheus_data:
grafana_data: