Both are only used inside the compose network (services reach them by name, postgres:5432 / redis:6379). Publishing on 0.0.0.0 exposed them to the internet — Docker's DNAT rules bypass ufw, so the ports were reachable despite the firewall having no allow rule for them. Bind the published ports to 127.0.0.1 so external access requires an SSH tunnel.
738 lines
38 KiB
YAML
738 lines
38 KiB
YAML
name: vidconf
|
||
|
||
x-logging: &default-logging
|
||
driver: json-file
|
||
options:
|
||
max-size: "10m"
|
||
max-file: "3"
|
||
|
||
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
|
||
# Публикуем ТОЛЬКО на 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", "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_URL:-redis://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.1}
|
||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||
volumes:
|
||
- ../config:/app/config:ro
|
||
- media:/app/media
|
||
ports:
|
||
- "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
|
||
command: ["uv", "run", "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_URL:-redis://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", "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", "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", "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_URL:-redis://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", "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", "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_URL:-redis://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", "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}"
|
||
ports:
|
||
- "7880:7880" # HTTP/WebSocket signaling
|
||
- "7881:7881" # RTC TCP fallback
|
||
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда
|
||
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop.
|
||
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+)
|
||
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения.
|
||
- "54000-54100:54000-54100/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:
|
||
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
|
||
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
|
||
ports:
|
||
- "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
|
||
ports:
|
||
- "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
|
||
volumes:
|
||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||
- prometheus_data:/prometheus
|
||
ports:
|
||
- "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"
|
||
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
|
||
|
||
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
|
||
ports:
|
||
- "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:
|
||
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
|
||
prometheus_data:
|
||
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
|
||
grafana_data:
|