name: vidconf # Глубина логов рассчитана на РАЗБОР ИНЦИДЕНТОВ, а не только на просмотр # последних сообщений. При 10 МБ × 3 (прежнее значение) логи LiveKit на # конференции в полсотни человек перезаписывались за часы — а именно по ним # восстанавливаются вещи, которых нет в метриках: сколько камер было включено # одновременно, кого и почему отключило, какие события congestion шли. # 50 МБ × 5 = 250 МБ на контейнер; на сервере с 15 ГБ свободного места это # незаметно, зато ретроспектива живёт неделями. x-logging: &default-logging driver: json-file options: max-size: "50m" max-file: "5" services: postgres: image: postgres:16 restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-vidconf} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vidconf} POSTGRES_DB: ${POSTGRES_DB:-vidconf} # Публикуем ТОЛЬКО на loopback: postgres нужен внутри compose-сети # (backend/worker обращаются по имени `postgres:5432`). Доступ снаружи — # через SSH-туннель. Публикация на 0.0.0.0 открывала БД в интернет. ports: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vidconf} -d ${POSTGRES_DB:-vidconf}"] interval: 5s timeout: 5s retries: 10 logging: *default-logging # NOTE: btree_gist extension is created by an Alembic migration, not here. redis: image: redis:7 restart: unless-stopped # requirepass обязателен: сам по себе loopback-биндинг порта (ниже) не # защищает от взлома через скомпрометированный/зловредный контейнер в # той же compose-сети — только внешнюю экспозицию. Инцидент безопасности # (см. .forcc/deploy/SESSION2-FINDINGS.md) начался именно с redis без # пароля. `${REDIS_PASSWORD:?...}` — без секрета в .env контейнер не # стартует, а не тихо поднимается без аутентификации. command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}"] # Публикуем ТОЛЬКО на loopback: redis используется исключительно внутри # compose-сети (backend/worker/livekit/egress по имени `redis:6379`). # Публикация на 0.0.0.0 без пароля = открытый redis в интернет — вектор # cron/replication-RCE (см. .forcc/deploy/SESSION2-FINDINGS.md). ports: - "127.0.0.1:6379:6379" volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}", "--no-auth-warning", "ping"] interval: 5s timeout: 5s retries: 10 logging: *default-logging backend: build: context: ../backend dockerfile: Dockerfile restart: unless-stopped env_file: - ../.env environment: DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf} REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0 PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml} LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env} LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET не задан в .env} LIVEKIT_PUBLIC_URL: ${LIVEKIT_PUBLIC_URL:-ws://localhost:7880} # Внутренний server-to-server адрес LiveKit (RoomService/Egress API). # Жёстко перекрывает значение из .env: там LIVEKIT_URL=ws://localhost:7880 # для запуска backend на хосте, а изнутри контейнера localhost — это сам # backend, и start_track_egress молча деградирует (warning, записи нет). LIVEKIT_URL: ws://livekit:7880 # Общий с egress/worker-transcriber путь на volume `recordings` — # backend формирует по нему filepath для start_track_egress, сам # том backend'у не примонтирован (файлы пишет контейнер egress). RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings} # Каталог загруженных аватаров — общий volume с nginx, который # раздаёт его напрямую по `location /media/` (alias), в обход backend. MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} # Версия инстанса (релиз v0.0.1) — install.sh копирует значение # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.30} # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # проверьте бюджет соединений с БД: каждый воркер держит свой пул # (DB_POOL_SIZE + DB_MAX_OVERFLOW), а у Postgres есть max_connections. UVICORN_WORKERS: ${UVICORN_WORKERS:-2} # config/ лежит в корне репозитория и не попадает в образ (контекст сборки — # только backend/), поэтому plugins.yaml монтируется отдельно. volumes: - ../config:/app/config:ro - media:/app/media # Публикуем ТОЛЬКО на loopback: backend нужен снаружи compose-сети # только для локального curl-дебага на сервере — nginx проксирует # запросы по имени `backend:8000` внутри docker-сети, публичный доступ # идёт исключительно через nginx (80/443). ports: - "127.0.0.1:8000:8000" depends_on: postgres: condition: service_healthy redis: condition: service_healthy healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] interval: 10s timeout: 5s retries: 10 start_period: 15s logging: *default-logging # --- Celery worker + beat: та же backend-сборка, но с примонтированным # пакетом workers/ (workers/ импортирует модели backend). --- # `-Q celery,summarize,notify`: на малых пресетах # поставки (1–3) один контейнер обслуживает суммаризацию, уведомления и # обслуживающие задачи (`celery` — дефолтная очередь); `transcription` # сюда не входит — её слушает только `worker-transcriber`(-gpu), см. их # комментарий ниже. Вынос очередей в отдельные реплики при масштабировании # — `docs/deploy/scaling.md`. worker: build: context: ../backend dockerfile: Dockerfile restart: unless-stopped # `--no-sync` во ВСЕХ вызовах `uv run` в этом файле: окружение собрано на # этапе build образа (`uv sync --frozen --no-dev`, backend/Dockerfile), а # без флага `uv run` синхронизирует venv заново при каждом запуске — и # тянет dev-группу (ruff, mypy, pytest), которой в проде делать нечего. # Для healthcheck'ов это особенно дорого: они дёргаются каждые 15 секунд # всю жизнь контейнера. command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker", "-B", "-Q", "celery,summarize,notify", "--loglevel=info"] env_file: - ../.env environment: DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf} REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0 PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml} PYTHONPATH: /app volumes: - ../workers:/app/workers:ro # plugins.yaml из корня репозитория (см. комментарий у сервиса backend) - ../config:/app/config:ro # tokenizer.json модели Qwen (профиль `llm`, скачан llm-model-init) — # нужен QwenTokenCounter для подсчёта токенов при чанкинге транскрипта # (backend/core/summarization/tokens.py). Без профиля `llm` том пуст — # QwenTokenCounter уходит в фолбэк-эвристику len(text)//3. - llm-models:/models/qwen:ro depends_on: redis: condition: service_healthy # Сервис worker использует тот же backend-образ (см. build выше), поэтому # без переопределения он наследует HEALTHCHECK из backend/Dockerfile # (curl /api/health), который здесь бессмысленен — у celery-контейнера # нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect # ping`, как рекомендует документация Celery. healthcheck: test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"] interval: 15s timeout: 10s retries: 5 start_period: 20s logging: *default-logging # --- Профили transcribe/transcribe-gpu: том whisper-cache создаётся # Docker root:root при первом использовании — случайно совпадает с uid # backend-образа (в backend/Dockerfile НЕТ директивы USER, процессы внутри # выполняются от root, uid 0), но фиксируем владельца явно, по образцу # `recordings-init` — страховка от смены дефолтного uid образа/докера # в будущем. whisper-cache-init: image: busybox:1.36 command: ["chown", "-R", "0:0", "/models/whisper"] volumes: - whisper-cache:/models/whisper restart: "no" profiles: ["transcribe", "transcribe-gpu"] logging: *default-logging # Разовая предзагрузка модели faster-whisper уровня AI (иначе первая # транскрибация после старта воркера сама тянет веса из HuggingFace Hub — # тот же дефект, что и с правами тома выше). `WHISPER_MODEL` # пишет install.sh по выбранному пресету (3 → small, 4 → medium, # 5 → large-v3, см. ADR-004); путь кэша — `/models/whisper/<модель>`, # КОНТРАКТ с `backend/services/ai_tiers.py::WHISPER_MODELS_ROOT` (см. # докстринг `deploy/whisper/download-model.py`) — используется и профилем # `transcribe` (CPU, пресеты 3/4), и `transcribe-gpu` (пресет 5, max). whisper-model-init: build: context: ../backend dockerfile: Dockerfile entrypoint: ["uv", "run", "--no-sync", "python", "/download-model.py"] environment: WHISPER_MODEL: ${WHISPER_MODEL:-small} WHISPER_MODELS_ROOT: /models/whisper volumes: - ./whisper/download-model.py:/download-model.py:ro - whisper-cache:/models/whisper restart: "no" depends_on: whisper-cache-init: condition: service_completed_successfully profiles: ["transcribe", "transcribe-gpu"] logging: *default-logging # --- Профиль transcribe: отдельный celery-воркер очереди `transcription` # (faster-whisper, CPU — уровни AI `min`/`medium`, ADR-004: `medium` # остаётся CPU-only в матрице `backend/services/ai_tiers.py`, GPU для него — # ручная настройка администратора вне детекта). Изолирован от базового # `worker`, потому что ctranslate2/faster-whisper несовместимы с # prefork-пулом Celery (fork процесса после инициализации нативных # библиотек небезопасен) — здесь `--pool=solo --concurrency=1`, модель — # синглтон на процесс. Базовый `worker` очередь `transcription` не # слушает (см. его command выше — явный `-Q` без `transcription`). # Start with: docker compose --profile transcribe up -d # (livekit из профиля `media` должен быть поднят отдельно/вместе). worker-transcriber: build: context: ../backend dockerfile: Dockerfile restart: unless-stopped # Фиксированное имя узла (`--hostname`) нужно, чтобы healthcheck ниже # мог обратиться именно к этому воркеру: broker/redis общий с базовым # `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ # кластер — упавший worker-transcriber остался бы "healthy", потому что # ответил бы базовый worker. command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker", "-Q", "transcription", "--pool=solo", "--concurrency=1", "--hostname=worker-transcriber@localhost", "--loglevel=info"] env_file: - ../.env environment: DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf} REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0 PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml} RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings} PYTHONPATH: /app volumes: - ../workers:/app/workers:ro # plugins.yaml из корня репозитория (см. комментарий у сервиса backend) - ../config:/app/config:ro - recordings:/recordings:ro - whisper-cache:/models/whisper # Лимиты ресурсов CPU-bound транскрибации (faster-whisper на CPU). # `cpus`/`mem_limit` — прямые атрибуты Compose (применяются и без # swarm), в отличие от `deploy.resources`, который вне `docker stack # deploy` игнорируется. cpus: "4" mem_limit: 6g depends_on: redis: condition: service_healthy whisper-model-init: condition: service_completed_successfully # HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname # в command выше), а не первого ответившего в общем кластере. healthcheck: test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"] interval: 15s timeout: 10s retries: 5 start_period: 20s profiles: ["transcribe"] logging: *default-logging # --- Профиль transcribe-gpu: GPU-вариант транскрайбера, ТОЛЬКО уровень AI # `max` (ADR-004, `backend/services/ai_tiers.py`: `medium` в матрице # остаётся CPU-only, GPU для него — ручная настройка вне детекта, поэтому # отдельного GPU-профиля для пресета 4 нет — см. комментарий у # worker-transcriber выше). Собран из того же Dockerfile backend с # extras-группой `gpu` (`nvidia-cublas-cu12`, `nvidia-cudnn-cu12==9.*` — # объявлена в `backend/pyproject.toml`); действующий провайдер # `faster_whisper_gpu` выбирается конфигом (TIERS["max"], не этим файлом). # Взаимоисключаем c `worker-transcriber` через профили: одновременно оба # профиля не поднимаются (install.sh выбирает ровно один по пресету). worker-transcriber-gpu: build: context: ../backend dockerfile: Dockerfile args: WITH_GPU_EXTRA: "true" restart: unless-stopped command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker", "-Q", "transcription", "--pool=solo", "--concurrency=1", "--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"] env_file: - ../.env environment: DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf} REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0 PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml} RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings} PYTHONPATH: /app # cuBLAS/cuDNN9, поставленные extras-группой `gpu` в venv образа (не # системные библиотеки CUDA) — CTranslate2 находит их только через # LD_LIBRARY_PATH, см. ADR-004 (faster-whisper README). LD_LIBRARY_PATH: /app/.venv/lib/python3.12/site-packages/nvidia/cublas/lib:/app/.venv/lib/python3.12/site-packages/nvidia/cudnn/lib volumes: - ../workers:/app/workers:ro - ../config:/app/config:ro - recordings:/recordings:ro - whisper-cache:/models/whisper deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] depends_on: redis: condition: service_healthy whisper-model-init: condition: service_completed_successfully healthcheck: test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"] interval: 15s timeout: 10s retries: 5 start_period: 30s profiles: ["transcribe-gpu"] logging: *default-logging nginx: # Собственный образ: nginx со вкомпилированной статикой фронтенд-SPA # (frontend/Dockerfile, multi-stage: Vite-сборка → nginx). Раздаётся во # ВСЕХ пресетах (сервис без profiles), поэтому http://localhost/ отдаёт # рабочий фронт, а не заглушку. build: context: ../frontend dockerfile: Dockerfile args: # Инлайнится Vite на этапе сборки (см. frontend/Dockerfile) — сейчас # фронтенд получает LiveKit URL в рантайме от backend (join.livekit_url, # см. backend/services/conference_access.py: LIVEKIT_PUBLIC_URL), эти # build-args пока ни на что не влияют (см. комментарий в # frontend/Dockerfile), переданы про запас на будущее. VITE_LIVEKIT_URL: ${VITE_LIVEKIT_URL:-} NEXT_PUBLIC_LIVEKIT_URL: ${NEXT_PUBLIC_LIVEKIT_URL:-} image: vidconf-nginx:latest restart: unless-stopped environment: # Подставляются штатным entrypoint'ом образа nginx (envsubst-on-templates, # см. deploy/nginx/nginx.conf.template) — список доменов и имя каталога # сертификата вынесены в параметры, а не хардкод. NGINX_SERVER_NAMES: ${NGINX_SERVER_NAMES:?NGINX_SERVER_NAMES не задан в .env (список доменов через пробел)} NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env (каталог в /etc/letsencrypt/live/)} volumes: - ./nginx/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro # Готовит /etc/nginx/certs/{fullchain,privkey}.pem до старта nginx: либо # копирует боевой сертификат из /etc/letsencrypt, либо (если его нет — # локальная разработка) генерирует самоподписанный. См. сам скрипт. - ./nginx/docker-entrypoint-certs.sh:/docker-entrypoint.d/15-vidconf-certs.sh:ro # Аватары: тот же volume, что у backend — nginx раздаёт файлы # напрямую (`location /media/`, alias), в обход backend-процесса. - media:/media:ro # Реальные сертификаты Let's Encrypt — с хоста, только на чтение (в dev # без ./certbot-webroot Docker создаст /etc/letsencrypt пустым — скрипт # выше в этом случае сгенерирует самоподписанный сертификат). - /etc/letsencrypt:/etc/letsencrypt:ro # Webroot для ACME http-01 challenge (certbot renew). - ./certbot-webroot:/var/www/certbot:rw ports: - "80:80" - "443:443" depends_on: backend: condition: service_healthy healthcheck: # /healthz (не /) — после добавления HTTP→HTTPS редиректа (443) `/` # на порту 80 отдаёт 301 вместо 200, что уронило бы healthcheck; # /healthz отвечает 200 без редиректа. test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:80/healthz"] interval: 10s timeout: 5s retries: 5 logging: *default-logging # --- Media profile: LiveKit SFU + TURN. Disabled by default. --- # Start with: docker compose --profile media up -d livekit: image: livekit/livekit-server:latest restart: unless-stopped command: --config /etc/livekit.yaml # Реальный livekit.yaml генерируется из шаблона перед стартом # (deploy/render-templates.sh, вызывается install.sh) — use_external_ip/ # node_ip/webhook.api_key статичны и не читают env напрямую, в отличие # от LIVEKIT_KEYS ниже (см. комментарий в livekit.yaml.template). volumes: - ./livekit/livekit.yaml:/etc/livekit.yaml:ro environment: # Формат LIVEKIT_KEYS СТРОГО "key: secret" (двоеточие + пробел), # иначе LiveKit падает в crash-loop с "Could not parse keys". Значение # обязательно в кавычках: YAML запрещает ": " внутри неквотированного # plain-скаляра. LIVEKIT_KEYS: "${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET не задан в .env}" # 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени # `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры # снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не # нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются # публичными. ports: - "127.0.0.1:7880:7880" # HTTP/WebSocket signaling - "7881:7881" # RTC TCP fallback # Один порт вместо диапазона (был 54000-54100/udp) — LiveKit # мультиплексирует все ICE-сессии через него (rtc.udp_port в # livekit.yaml.template), а не открывает по порту на участника. # На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт — # 101 порт держали 101 лишний userland-процесс на медиапути. # 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на # macOS заняты Steam/системными процессами и эфемерными QUIC. - "54000:54000/udp" # WebRTC media (ICE, мультиплекс) depends_on: redis: condition: service_healthy healthcheck: test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:7880/"] interval: 10s timeout: 5s retries: 10 start_period: 10s profiles: ["media"] logging: *default-logging # Образ coturn/coturn — Dockerfile прописывает `USER nobody:nogroup`, и это # НЕ runtime-привилегия, которую можно сбросить: Docker exec'ает entrypoint # сразу от этого uid, root-фазы внутри контейнера нет вовсе (в отличие от # официального образа nginx, который стартует entrypoint от root и только # nginx-воркеры позже понижают права по директиве в конфиге — см. # deploy/nginx/docker-entrypoint-certs.sh). Значит coturn физически не может # сам прочитать приватный ключ Let's Encrypt (root:root, обычно 0600) — # никакой volume-опцией это не обойти, не ослабляя права на ключ на хосте. # # Решение — по образцу уже существующего `recordings-init`/`llm-models-init` # в этом файле: отдельный init-контейнер (busybox, дефолтный root) читает # /etc/letsencrypt (той же ro-монтировкой, что и у nginx) и копирует # fullchain/privkey в СВОЙ volume под правами 644 — это копия, а не # оригинал, оригинальный ключ на хосте прав не меняет. Копия достаточно # открыта, чтобы её прочитал nobody:nogroup внутри coturn. # # Если /etc/letsencrypt/live/<домен> не существует (dev, нет реальных # сертификатов) — команда ниже просто ничего не копирует и завершается # успешно; coturn стартует как раньше, без TLS (см. TURN_TLS_HOST в # render-templates.sh — вторая половина того же переключателя). coturn-certs-init: image: busybox:1.36 command: > sh -c ' SRC="/etc/letsencrypt/live/$$NGINX_CERT_NAME"; if [ -f "$$SRC/fullchain.pem" ] && [ -f "$$SRC/privkey.pem" ]; then cp "$$SRC/fullchain.pem" /certs/cert.pem; cp "$$SRC/privkey.pem" /certs/key.pem; chmod 644 /certs/cert.pem /certs/key.pem; echo "[coturn-certs-init] сертификат $$SRC скопирован в volume coturn-certs"; else echo "[coturn-certs-init] $$SRC не найден — TLS для coturn не настроен (норма для dev без TURN_TLS_HOST)"; fi ' environment: NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env} volumes: - /etc/letsencrypt:/etc/letsencrypt:ro - coturn-certs:/certs restart: "no" profiles: ["media"] logging: *default-logging coturn: image: coturn/coturn:latest restart: unless-stopped command: -c /etc/coturn/turnserver.conf # Секреты (static-auth-secret/realm/external-ip) НЕ передаются через # environment — coturn читает их ТОЛЬКО из файла конфигурации, запущенного # через `-c` (при CLI-флагах он умеет $(VAR), при файле — нет, см. # комментарий в deploy/coturn/turnserver.conf.template). Реальный # turnserver.conf генерируется из шаблона перед стартом # (deploy/render-templates.sh, вызывается install.sh). volumes: - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro - coturn-certs:/etc/coturn/certs:ro depends_on: coturn-certs-init: condition: service_completed_successfully network_mode: host # Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/ # curl/wget, поэтому проверка процесса по имени не работает # ("pgrep: not found" → healthcheck всегда unhealthy). Вместо этого # опрашиваем сам TURN-сервер STUN-запросом через штатную утилиту # turnutils_stunclient, которая есть в образе. healthcheck: test: ["CMD-SHELL", "turnutils_stunclient -p 3478 127.0.0.1 || exit 1"] interval: 10s timeout: 5s retries: 10 start_period: 10s profiles: ["media"] logging: *default-logging # --- Профиль transcribe: LiveKit Egress — запись per-track аудио для # последующей транскрибации (см. ADR-002). Egress запускает обработчик каждой записи как отдельный # процесс внутри своего же контейнера (не Docker-in-Docker — проверено # по официальной документации livekit/egress), поэтому доступ к # /var/run/docker.sock не требуется. Записи и без транскрибации никому # не нужны — profiles совпадает с worker-transcriber; livekit (профиль # `media`) при этом должен быть поднят отдельно/вместе. # Start with: docker compose --profile media --profile transcribe up -d # Именованный том recordings при первом создании принадлежит root (0:0, # drwxr-xr-x), а процесс egress работает от uid 1001 (gid 0) и не может # создавать в нём каталоги сеансов. Однократный init-контейнер отдаёт том # владельцу-egress до старта записи (иначе Track Egress падает с # "Local upload failed: mkdir ... permission denied"). recordings-init: image: busybox:1.36 command: ["chown", "-R", "1001:0", "/recordings"] volumes: - recordings:/recordings restart: "no" profiles: ["transcribe"] logging: *default-logging egress: image: livekit/egress:latest restart: unless-stopped volumes: - ./egress/egress.yaml:/etc/egress.yaml:ro - recordings:/recordings environment: EGRESS_CONFIG_FILE: /etc/egress.yaml # api_key/api_secret/ws_url — обязательные переменные окружения # (см. deploy/egress/egress.yaml); ws_url — внутренний адрес сервиса # livekit в docker-сети, НЕ LIVEKIT_PUBLIC_URL для браузера. LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env} LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET не задан в .env} LIVEKIT_WS_URL: ${LIVEKIT_WS_URL:-ws://livekit:7880} depends_on: # required: false — livekit живёт в профиле `media`, а не # `transcribe`; без этого `docker compose --profile transcribe up` # (без media) падает с ошибкой "service livekit ... is disabled". # На практике egress без livekit бесполезен — профили запускают # вместе (см. комментарий выше), но это не должно ломать валидацию # конфигурации, если кто-то поднимает профили по отдельности. livekit: condition: service_healthy required: false redis: condition: service_healthy recordings-init: condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-sf", "http://127.0.0.1:8081/healthz"] interval: 10s timeout: 5s retries: 10 start_period: 10s profiles: ["transcribe"] logging: *default-logging # --- Профили llm/llm-gpu: том llm-models создаётся Docker root:root при # первом использовании — по тем же соображениям, что и whisper-cache-init # выше (backend-образ и curlimages/curl оба в итоге пишут # от root — см. `user: "0:0"` у llm-model-init ниже), явный init-контейнер # фиксирует владельца, по образцу `recordings-init`. llm-models-init: image: busybox:1.36 command: ["chown", "-R", "0:0", "/models/qwen"] volumes: - llm-models:/models/qwen restart: "no" profiles: ["llm", "llm-gpu"] logging: *default-logging # --- Профили llm/llm-gpu: локальный LLM-сервер для плагина суммаризации # QwenLocal (см. ADR-004). Один и тот же # init-контейнер обслуживает оба профиля — уровень AI (`LLM_MODEL_*`) # определяет install.sh при выборе пресета 3/4/5, а не профиль CPU/GPU. # Start with: docker compose --profile llm up -d # # Разовое скачивание GGUF-модели уровня AI и tokenizer.json в общий volume # `llm-models`. Идемпотентен — при повторном запуске (файлы уже в volume) # ничего не перекачивает, см. deploy/llm/download-model.sh. llm-model-init: image: curlimages/curl:8.10.1 entrypoint: ["/bin/sh", "/download-model.sh"] environment: LLM_MODEL_FILE: ${LLM_MODEL_FILE:-qwen3.5-4b-instruct-q4_k_m.gguf} LLM_MODEL_URL: ${LLM_MODEL_URL:-https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf} LLM_MODEL_MIN_SIZE: ${LLM_MODEL_MIN_SIZE:-2000000000} LLM_TOKENIZER_FILE: ${LLM_TOKENIZER_FILE:-qwen3.5-4b-instruct.tokenizer.json} LLM_TOKENIZER_URL: ${LLM_TOKENIZER_URL:-https://huggingface.co/Qwen/Qwen3.5-4B/resolve/main/tokenizer.json} # Образ curlimages/curl по умолчанию работает от непривилегированного # curl_user (uid 100) — записать .part-файл в /models/qwen не получится # без явного root (том фиксирован под 0:0 в llm-models-init выше). user: "0:0" volumes: - ./llm/download-model.sh:/download-model.sh:ro - llm-models:/models/qwen restart: "no" depends_on: llm-models-init: condition: service_completed_successfully profiles: ["llm", "llm-gpu"] logging: *default-logging # LLM-сервер (CPU) на базе официального образа llama.cpp (OpenAI- # совместимый /v1/chat/completions, используется QwenLocal через # OpenAICompatClient). Конфигурация — исключительно через переменные # окружения LLAMA_ARG_* (штатный механизм образа, см. # tools/server/README.md проекта llama.cpp). Тег закреплён по номеру # сборки (не плавающий `:server`) — см. ADR-004, «Сводка рисков», # п.1; актуальный тег проверен по реестру ghcr.io. llm: image: ghcr.io/ggml-org/llama.cpp:server-b10068 restart: unless-stopped environment: LLAMA_ARG_MODEL: /models/qwen/${LLM_MODEL_FILE:-qwen3.5-4b-instruct-q4_k_m.gguf} # 8k токенов на чанк (верхняя граница чанкера) + промпт + до 1536 # токенов вывода (max-уровень reduce), с запасом на служебные токены. LLAMA_ARG_CTX_SIZE: 16384 LLAMA_ARG_HOST: 0.0.0.0 LLAMA_ARG_PORT: 8080 # Отключение thinking-режима (ADR-004: семейство Qwen3.5 Small — по # умолчанию выключен, но фиксируем явно). `--chat-template-kwargs # '{"enable_thinking":false}'`, упомянутый в ADR-004, на актуальной # версии llama.cpp помечен деприкейтед в пользу `--reasoning off` # (проверено по common/arg.cpp: одинаковый эффект — # `default_template_kwargs["enable_thinking"]="false"`, но без # предупреждения в логе на каждый запуск). LLAMA_ARG_REASONING: "off" # Экспозиция /metrics для Prometheus (job `llm`, deploy/monitoring/prometheus.yml). LLAMA_ARG_ENDPOINT_METRICS: 1 volumes: - llm-models:/models/qwen:ro # Loopback-only: admin/debug доступ по ssh-туннелю, наружу не публикуется. ports: - "127.0.0.1:8080:8080" depends_on: llm-model-init: condition: service_completed_successfully # /health отдаёт 503 ("Loading model"), пока модель грузится в память, # и 200 ("status": "ok"), когда сервер готов принимать запросы — # штатный эндпоинт образа llama.cpp (curl есть в базовом слое образа, # см. .devops/cpu.Dockerfile проекта llama.cpp — используется как в # фирменном HEALTHCHECK образа). healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"] interval: 10s timeout: 5s retries: 30 start_period: 30s profiles: ["llm"] logging: *default-logging # --- Профиль llm-gpu: GPU-вариант LLM-сервера, ТОЛЬКО уровень AI `max` # (ADR-004: единственный уровень с `base_url: http://llm-gpu:8080/v1` в # `backend/services/ai_tiers.py`). Официальный CUDA-образ llama.cpp # (CUDA 12); тег закреплён по номеру сборки, синхронно с `llm` выше. llm-gpu: image: ghcr.io/ggml-org/llama.cpp:server-cuda-b10068 restart: unless-stopped environment: LLAMA_ARG_MODEL: /models/qwen/${LLM_MODEL_FILE:-qwen3.5-35b-a3b-instruct-q4_k_m.gguf} LLAMA_ARG_CTX_SIZE: 16384 LLAMA_ARG_HOST: 0.0.0.0 LLAMA_ARG_PORT: 8080 # Полный offload в VRAM (ADR-004: max — обязательный GPU ≥16 ГБ, # рекомендовано 24 ГБ; 999 — общепринятое в доках llama.cpp значение # «офлоадить все слои», сервер сам ограничивает реальным числом слоёв # модели, если их меньше). LLAMA_ARG_N_GPU_LAYERS: 999 LLAMA_ARG_REASONING: "off" LLAMA_ARG_ENDPOINT_METRICS: 1 volumes: - llm-models:/models/qwen:ro # Loopback-only: admin/debug доступ по ssh-туннелю, наружу не публикуется. ports: - "127.0.0.1:8081:8080" # Алиас `llm` в сети compose (в дополнение к штатному `llm-gpu`) — # `llm` и `llm-gpu` взаимоисключающи по профилю (install.sh включает # ровно один), поэтому Prometheus (deploy/monitoring/prometheus.yml, # job `llm`) всегда скрейпит один и тот же адрес `llm:8080` независимо # от того, какой из двух реально поднят — без дублирования job'ов и # вечно "красного" targets для неиспользуемого профиля. networks: default: aliases: - llm deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] depends_on: llm-model-init: condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"] interval: 10s timeout: 5s retries: 30 start_period: 30s profiles: ["llm-gpu"] logging: *default-logging # --- Профиль monitoring: Prometheus + Grafana + exporter'ы. # Дашборд «Пайплайны пост-обработки» — deploy/monitoring/grafana/. # Start with: docker compose --profile monitoring up -d # (можно вместе с любыми другими профилями — независимый набор сервисов). prometheus: # Тег закреплён по версии (не `:latest`) — та же причина, что и у llm-образов. image: prom/prometheus:v3.13.1 restart: unless-stopped # Первые два флага — дефолт образа; повторяем их явно, потому что # `command` перекрывает CMD целиком. Третий — срок хранения: дефолтных # 15 суток мало, когда нагрузку набирают неделями (наблюдение за # реальными конференциями вместо разового теста), и разбирать её потом # приходится задним числом. 30 суток при нынешних 100 МБ TSDB стоят # копеек — база растёт медленнее, чем кажется. command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--storage.tsdb.retention.time=30d' volumes: - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro - prometheus_data:/prometheus # node-exporter живёт в host-сети (см. комментарий у него) и по имени # сервиса в docker-сети больше не резолвится. `host-gateway` — штатный # способ дать контейнеру адрес хоста, не завязываясь на конкретный IP # docker-моста. extra_hosts: - "host.docker.internal:host-gateway" # Loopback-only: админ-доступ по ssh-туннелю, наружу не публикуется. ports: - "127.0.0.1:9090:9090" healthcheck: test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9090/-/healthy || exit 1"] interval: 10s timeout: 5s retries: 10 start_period: 10s profiles: ["monitoring"] logging: *default-logging postgres-exporter: image: quay.io/prometheuscommunity/postgres-exporter:v0.20.1 restart: unless-stopped environment: DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}?sslmode=disable" depends_on: postgres: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9187/metrics >/dev/null || exit 1"] interval: 10s timeout: 5s retries: 10 start_period: 10s profiles: ["monitoring"] logging: *default-logging redis-exporter: image: oliver006/redis_exporter:v1.87.0-alpine restart: unless-stopped environment: REDIS_ADDR: "redis://redis:6379" REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env} depends_on: redis: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9121/metrics >/dev/null || exit 1"] interval: 10s timeout: 5s retries: 10 start_period: 10s profiles: ["monitoring"] logging: *default-logging node-exporter: # Метрики железа хоста (CPU, память, диск, сеть, load average) — то, # чего нет ни в одном из приложенческих экспортеров выше. # # `network_mode: host` ОБЯЗАТЕЛЕН, и вот почему (проверено 2026-07-28, # до этого экспортер работал в bridge-сети и отдавал неверные данные). # Bind-mount'а `/proc` достаточно для CPU, памяти и диска, но НЕ для сети: # `/proc/net` — это симлинк на `self/net`, который резолвится в сетевом # namespace ЧИТАЮЩЕГО процесса. В bridge-сети экспортер видел собственные # `lo` и `eth0` (56 МБ трафика) вместо хостового `enp3s0` (39.8 ГБ), то # есть `node_network_*` показывал трафик контейнера, а не сервера. При # разборе нагрузочного теста 28.07 сетевых метрик хоста не оказалось # вовсе — см. .forcc/LOAD-FINDINGS.md. # # Порт 9100 при этом слушается на хосте. Наружу он не торчит: ufw # пропускает только 22/80/443/3478/7881/51820 и UDP-диапазон LiveKit # (проверено `ufw status`). Prometheus обращается к нему через # `host.docker.internal` (см. `extra_hosts` у сервиса prometheus и # таргет `node` в deploy/monitoring/prometheus.yml). image: prom/node-exporter:v1.8.2 restart: unless-stopped pid: host network_mode: host volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro - /:/rootfs:ro command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' - '--path.rootfs=/rootfs' - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' # Секции `ports` нет и с host-сетью быть не может: контейнер слушает # прямо на интерфейсах хоста. От внешнего мира порт закрывает ufw. healthcheck: test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"] interval: 10s timeout: 5s retries: 10 start_period: 10s profiles: ["monitoring"] logging: *default-logging # Метрики по контейнерам (CPU/память/сеть каждого отдельно) — замена # cAdvisor, который на сервере `1gb` несовместим с Docker Engine # (containerd-снапшоттер вместо classic overlay2 — подробности и # перепробованные варианты см. `.forcc/JOURNAL.md`, раздел релиза 0.0.9). # Вместо интроспекции graph-driver'а — Docker Engine API напрямую # (`GET /containers/json` + `/stats`), он не зависит от storage-driver. # # Доступ к докер-сокету равносилен root на хосте (`:ro` при монтировании # ограничивает права на файл-ноду, а не на протокол — через сокет можно # поднять привилегированный контейнер и получить хост). Поэтому сокет # монтируется СЮДА, в доверенный прокси-образ, а НЕ в наш собственный # `container-exporter` ниже — прокси разрешает ему только # `GET /containers/json` и `GET /containers/*/stats` (`CONTAINERS=1`), # любые изменяющие запросы заблокированы (`POST=0`). Полная компрометация # `container-exporter` не даёт управлять Docker. Обоснование и разбор — # `docs/deploy/monitoring.md`. docker-socket-proxy: image: tecnativa/docker-socket-proxy:v0.5.0 restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock:ro environment: CONTAINERS: 1 # POST=0 — дефолт образа; выписан явно, потому что от него зависит # безопасность всей схемы (полагаться на невыписанный дефолт для # критичного флага не стоит). POST: 0 EVENTS: 0 # Не публикуем порт наружу вообще — container-exporter достаёт API # по имени сервиса во внутренней сети compose, тот же принцип, что и # у node-exporter/postgres-exporter выше. healthcheck: test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1:2375/_ping"] interval: 10s timeout: 5s retries: 10 start_period: 10s profiles: ["monitoring"] logging: *default-logging container-exporter: build: context: ./monitoring/container-exporter dockerfile: Dockerfile restart: unless-stopped environment: DOCKER_API_BASE: "http://docker-socket-proxy:2375" depends_on: docker-socket-proxy: condition: service_healthy # Без ports — тот же принцип, что и у прокси выше; healthcheck # прописан в самом Dockerfile экспортера (как у backend/worker). profiles: ["monitoring"] logging: *default-logging grafana: image: grafana/grafana:13.1.0 restart: unless-stopped environment: GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} # Дефолт только для dev — install.sh генерирует секрет при первой # установке (секреты только в .env). GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-change-me-grafana} GF_USERS_ALLOW_SIGN_UP: "false" volumes: - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana_data:/var/lib/grafana # Loopback-only: админ-доступ по ssh-туннелю, наружу не публикуется. ports: - "127.0.0.1:3001:3000" depends_on: prometheus: condition: service_healthy healthcheck: test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3000/api/health || exit 1"] interval: 10s timeout: 5s retries: 10 start_period: 15s profiles: ["monitoring"] logging: *default-logging volumes: postgres_data: redis_data: # Общий том между egress (пишет) и worker-transcriber (читает :ro): # per-track .ogg-записи (profile transcribe). recordings: # Кэш весов faster-whisper — переживает пересоздание контейнера. Каждый # уровень AI хранит модель в подкаталоге `/<модель>` (`small`/ # `medium`/`large-v3`) — КОНТРАКТ с `backend/services/ai_tiers.py` # (WHISPER_MODELS_ROOT) и детектом доступности уровня, см. # `deploy/whisper/download-model.py`. whisper-cache: # GGUF-модель Qwen3.5 уровня AI + tokenizer.json (профили `llm`/`llm-gpu`, # скачивает llm-model-init) — общий том между `llm`/`llm-gpu` (инференс) и # `worker` (подсчёт токенов чанкером через QwenTokenCounter). Имена файлов # — КОНТРАКТ с `backend/services/ai_tiers.py` (QWEN_MODELS_ROOT). llm-models: # Загруженные пользователями файлы (аватары) — общий том между # backend (запись при загрузке) и nginx (раздача статики, `location /media/`). media: # Копия fullchain/privkey Let's Encrypt под правами 644 для coturn # (nobody:nogroup) — источник в /etc/letsencrypt не трогаем, см. # coturn-certs-init выше. Обновляется при каждом перезапуске # coturn-certs-init (deploy-hook certbot делает это при продлении). coturn-certs: # Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера. prometheus_data: # Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание. grafana_data: