Files
vidconf/deploy/docker-compose.yml
Max Ronzhin 9bc8d6174d feat(coturn): TURN over TLS на 5349 — сертификаты, монтирование, анонс клиентам
coturn (nobody:nogroup, без root-фазы в entrypoint) не может сам прочитать
приватный ключ Let's Encrypt — coturn-certs-init (по образцу
recordings-init/llm-models-init) копирует fullchain/privkey в отдельный
volume под правами 644, не трогая права на ключ на хосте.

TURN_TLS_HOST в .env — единственный переключатель фичи: пусто (dev-дефолт)
вырезает TLS-блоки из turnserver.conf и rtc.turn_servers целиком (маркеры
BEGIN/END-TLS-* в *.template, render-templates.sh), непустое значение
включает оба сразу — TLS без анонса LiveKit клиентам не имеет смысла
(история 0.0.14: coturn работал healthy, но клиенты о нём не знали, и не
было ни одной аллокации). Значение обязано быть доменом сертификата, а не
IP — иначе браузер не пройдёт TLS-валидацию по имени хоста для turns:.

TLS-запись в rtc.turn_servers стоит последней в списке (фолбэк дороже
прямого UDP/TCP).
2026-08-02 20:26:56 +03:00

946 lines
53 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.21}
# Число процессов 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`: на малых пресетах
# поставки (13) один контейнер обслуживает суммаризацию, уведомления и
# обслуживающие задачи (`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 хранит модель в подкаталоге `<volume>/<модель>` (`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: