Пул создавался с дефолтом SQLAlchemy (5 + 10) и под нагрузкой выгребался за секунды. Теперь параметры заданы явно и вынесены в настройки: DB_POOL_SIZE=10, DB_MAX_OVERFLOW=10, DB_POOL_TIMEOUT=10. Таймаут снижен с дефолтных 30 секунд намеренно — пусть запрос падает быстро и показывает проблему, а не висит полминуты. Backend запускался одним процессом uvicorn: любой блокирующий вызов останавливал и параллельные запросы, и WS-чат всех участников. Добавлен UVICORN_WORKERS с дефолтом 2 — не по числу ядер, потому что на четырёхъядерном сервере ядра делятся с LiveKit, а медиа важнее API. Бюджет соединений считается на весь инстанс: каждый воркер держит свой пул, поэтому UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW) должно оставаться заметно ниже max_connections у Postgres. Многопроцессность безопасна: бутстрап настроек в lifespan идемпотентен (INSERT ... ON CONFLICT DO NOTHING), а WS-чат разносит сообщения через Redis pub/sub и состояния в памяти процесса не держит.
849 lines
46 KiB
YAML
849 lines
46 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
|
||
# 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.12}
|
||
# Число процессов 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
|
||
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://:${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", "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://:${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", "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://:${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", "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
|
||
# Узкий диапазон для 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
|
||
# 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
|
||
volumes:
|
||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||
- prometheus_data:/prometheus
|
||
# 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` (не нужен: читаем /proc,/sys,/ хоста через
|
||
# bind-mount, а Prometheus достаёт их по имени сервиса во внутренней
|
||
# сети compose — так безопаснее, не расширяет сетевой доступ контейнера).
|
||
image: prom/node-exporter:v1.8.2
|
||
restart: unless-stopped
|
||
pid: 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)($$|/)'
|
||
# Не публикуем порт наружу вообще (не 127.0.0.1:9100, а совсем без
|
||
# ports) — Prometheus ходит к нему по внутренней сети compose
|
||
# (`node-exporter:9100`), публикация на хост для этого не нужна.
|
||
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:
|
||
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
|
||
prometheus_data:
|
||
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
|
||
grafana_data:
|