Первоначальная версия VidConf
This commit is contained in:
0
deploy/.gitkeep
Normal file
0
deploy/.gitkeep
Normal file
38
deploy/coturn/turnserver.conf
Normal file
38
deploy/coturn/turnserver.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
# Dev config for coturn. Verified against the official documentation
|
||||
# (github.com/coturn/coturn/wiki/turnserver, docker/coturn/README.md).
|
||||
# Runs with network_mode: host in docker-compose.yml (recommended by coturn
|
||||
# docs for large UDP relay port ranges).
|
||||
#
|
||||
# Secrets (realm / static-auth-secret) come from .env via TURN_REALM /
|
||||
# TURN_STATIC_AUTH_SECRET; coturn substitutes $(VAR) at startup when invoked
|
||||
# through the image's docker-entrypoint.sh, which evaluates each CLI arg.
|
||||
# Since we pass a config file instead of CLI flags, keep real secrets in the
|
||||
# .env and inject them here through docker-compose "environment" + a
|
||||
# lightweight envsubst step if/when TLS certs are added; for the dev/plain
|
||||
# profile the plaintext defaults below are fine (`change-me` values only).
|
||||
|
||||
listening-port=3478
|
||||
# Set to 443 in production (TURN over TLS) once real TLS certs are mounted.
|
||||
tls-listening-port=5349
|
||||
|
||||
# Relay port range for TURN allocations.
|
||||
min-port=49160
|
||||
max-port=49200
|
||||
|
||||
# --- Long-term credential mechanism via shared secret (TURN REST API) ---
|
||||
use-auth-secret
|
||||
static-auth-secret=change-me-turn-secret
|
||||
realm=vidconf.local
|
||||
|
||||
# Required by WebRTC clients (adds STUN FINGERPRINT attribute).
|
||||
fingerprint
|
||||
|
||||
# No CLI/telnet admin interface in this dev deployment.
|
||||
no-cli
|
||||
|
||||
# Uncomment and mount real certs to enable TURN over TLS on 443:
|
||||
# cert=/etc/coturn/certs/cert.pem
|
||||
# pkey=/etc/coturn/certs/key.pem
|
||||
|
||||
log-file=stdout
|
||||
simple-log
|
||||
696
deploy/docker-compose.yml
Normal file
696
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,696 @@
|
||||
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}
|
||||
ports:
|
||||
- "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
|
||||
ports:
|
||||
- "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:-devkey}
|
||||
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-change-me-livekit-secret}
|
||||
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
|
||||
image: vidconf-nginx:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
# Аватары: тот же volume, что у backend — nginx раздаёт файлы
|
||||
# напрямую (`location /media/`, alias), в обход backend-процесса.
|
||||
- media:/media:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:80/"]
|
||||
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
|
||||
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:-devkey}: ${LIVEKIT_API_SECRET:-change-me-livekit-secret}"
|
||||
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
|
||||
volumes:
|
||||
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||
environment:
|
||||
TURN_REALM: ${TURN_REALM:-vidconf.local}
|
||||
TURN_STATIC_AUTH_SECRET: ${TURN_STATIC_AUTH_SECRET:-change-me-turn-secret}
|
||||
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:-devkey}
|
||||
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-change-me-livekit-secret}
|
||||
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:
|
||||
34
deploy/egress/egress.yaml
Normal file
34
deploy/egress/egress.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Конфиг LiveKit Egress — запись per-track аудио для последующей
|
||||
# транскрибации (см. ADR-002).
|
||||
# Проверено по официальной документации livekit/egress
|
||||
# (github.com/livekit/egress: README.md, _autodocs/api-reference-config.md,
|
||||
# _autodocs/deployment-and-operations.md) — 2026-07-17.
|
||||
#
|
||||
# api_key/api_secret/ws_url НЕ хранятся в этом файле: egress поддерживает
|
||||
# их как обязательные переменные окружения LIVEKIT_API_KEY/
|
||||
# LIVEKIT_API_SECRET/LIVEKIT_WS_URL (см. сервис egress в docker-compose.yml)
|
||||
# — тот же подход, что и в deploy/livekit/livekit.yaml (LIVEKIT_KEYS),
|
||||
# секреты только через .env.
|
||||
|
||||
log_level: info
|
||||
|
||||
# Обязателен для egress (координация запущенных записей, см. комментарий
|
||||
# в deploy/livekit/livekit.yaml).
|
||||
redis:
|
||||
address: redis:6379
|
||||
|
||||
# HTTP-эндпоинт /healthz для healthcheck контейнера.
|
||||
health_port: 8081
|
||||
|
||||
# Метрики Prometheus (Ф7).
|
||||
prometheus_port: 8888
|
||||
|
||||
# ws_url внутри docker-сети (ws://livekit:7880) — не TLS, поэтому строгая
|
||||
# проверка сертификата не нужна; insecure=true допустим только для
|
||||
# внутреннего dev/self-hosted контура, не для публичного wss://.
|
||||
insecure: true
|
||||
|
||||
# Предохранитель от зависшей записи (обрыв room_finished/egress_ended):
|
||||
# файловый egress принудительно завершается через 6 часов.
|
||||
session_limits:
|
||||
file_output_max_duration: 6h
|
||||
60
deploy/livekit/livekit.yaml
Normal file
60
deploy/livekit/livekit.yaml
Normal file
@@ -0,0 +1,60 @@
|
||||
# Dev config for LiveKit SFU. Verified against the official documentation
|
||||
# (github.com/livekit/livekit config-sample.yaml + configuration.md).
|
||||
# Real API key/secret must come from .env (LIVEKIT_API_KEY / LIVEKIT_API_SECRET);
|
||||
# this file intentionally omits `keys:` so it can be supplied via the
|
||||
# LIVEKIT_KEYS env var (format "key:secret") set in docker-compose.yml.
|
||||
|
||||
port: 7880
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде
|
||||
# расширить и синхронизировать с пробросом портов.
|
||||
port_range_start: 54000
|
||||
port_range_end: 54100
|
||||
# use_external_ip: false + node_ip — dev-режим по докам LiveKit
|
||||
# (rtc.node_ip / use_external_ip в config-sample.yaml): use_external_ip
|
||||
# определяет публичный IP через STUN, что в контейнере Docker Desktop на
|
||||
# macOS даёт недостижимый изнутри хоста внутренний IP (172.18.x.x) — из-за
|
||||
# этого DTLS-хендшейк по reliable/lossy data-каналам не проходит (см.
|
||||
# "dtls timeout" в логах). node_ip: 127.0.0.1 работает, потому что порты
|
||||
# 7881/tcp и 54000-54100/udp проброшены Docker Desktop на loopback хоста,
|
||||
# а браузер-клиент запускается на том же хосте. В проде (клиенты снаружи
|
||||
# хоста) node_ip заменить на реальный внешний IP/домен либо вернуть
|
||||
# use_external_ip: true, если сервер не за NAT с пробросом портов 1:1.
|
||||
use_external_ip: false
|
||||
node_ip: 127.0.0.1
|
||||
|
||||
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
|
||||
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
||||
# без него egress не может получать room/track-события от LiveKit
|
||||
# (проверено по официальной документации livekit/egress, раздел "Running
|
||||
# locally"). LiveKit сам по себе тоже использует redis для координации
|
||||
# между узлами кластера (здесь один узел, но сервис оставлен включённым).
|
||||
redis:
|
||||
address: redis:6379
|
||||
|
||||
# TURN is handled by the standalone coturn service (deploy/coturn) in the
|
||||
# `media` profile. When exposing LiveKit publicly, put coturn/TURN-TLS on 443
|
||||
# and keep this section disabled to avoid double TURN servers.
|
||||
turn:
|
||||
enabled: false
|
||||
|
||||
# Webhook-приёмник backend'а: события room_started/participant_joined/
|
||||
# participant_left/room_finished подписываются ключом api_key, который
|
||||
# должен совпадать с одним из ключей в LIVEKIT_KEYS (см. выше).
|
||||
webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- http://backend:8000/api/v1/livekit/webhook
|
||||
# Если backend запускается на хосте (uvicorn вне docker-compose, а
|
||||
# LiveKit — внутри), используйте вместо этого:
|
||||
# - http://host.docker.internal:8000/api/v1/livekit/webhook
|
||||
|
||||
logging:
|
||||
level: info
|
||||
json: false
|
||||
|
||||
# Prometheus metrics (scraped by the monitoring profile).
|
||||
prometheus:
|
||||
port: 6789
|
||||
99
deploy/llm/download-model.sh
Executable file
99
deploy/llm/download-model.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/bin/sh
|
||||
# Идемпотентное скачивание GGUF-модели для локального LLM-сервера
|
||||
# (llama.cpp), см. ADR-004 (`docs/architecture/adr/004-ai-tier-matrix.md`).
|
||||
#
|
||||
# Генерализовано под 3 уровня AI (min/medium/max, разные модели семейства
|
||||
# Qwen3.5) — конкретную модель задают переменные окружения, которые пишет
|
||||
# `install.sh` при выборе пресета 3/4/5 (см. `docs/deploy/llm-setup.md`):
|
||||
# LLM_MODEL_FILE — имя файла модели внутри $MODEL_DIR (ПОД ЭТИМ именем
|
||||
# сохраняется локально — не обязано совпадать с
|
||||
# именем файла в источнике, см. GGUF_URL/GGUF_FILE_NAME)
|
||||
# LLM_MODEL_URL — прямая ссылка на GGUF (resolve/main/...)
|
||||
# LLM_MODEL_MIN_SIZE — ожидаемый минимальный размер файла в байтах
|
||||
# (страховка от оборванной/битой докачки)
|
||||
# LLM_TOKENIZER_FILE — имя файла токенизатора внутри $MODEL_DIR
|
||||
# LLM_TOKENIZER_URL — ссылка на tokenizer.json базовой модели (нужен
|
||||
# QwenTokenCounter, backend/core/summarization/tokens.py)
|
||||
#
|
||||
# Имена файлов (LLM_MODEL_FILE/LLM_TOKENIZER_FILE) — КОНТРАКТ с
|
||||
# `backend/services/ai_tiers.py` (см. докстринг модуля): детект доступности
|
||||
# уровня AI и сам плагин `qwen_local` (`tokenizer_path`/`model` в
|
||||
# `config/plugins.yaml`) ищут файлы по путям `{QWEN_MODELS_ROOT}/{model}.gguf`
|
||||
# и `{QWEN_MODELS_ROOT}/{model}.tokenizer.json` из этого модуля — install.sh
|
||||
# обязан передавать сюда ИМЕННО эти имена, не имена файлов в источнике.
|
||||
#
|
||||
# Дефолты ниже соответствуют уровню `min` (Qwen3.5-4B-Instruct, ADR-004) —
|
||||
# скрипт можно запускать вручную без install.sh (см. `docs/deploy/llm-setup.md`).
|
||||
#
|
||||
# Запускается init-контейнером `llm-model-init` (образ curlimages/curl,
|
||||
# профили compose `llm`/`llm-gpu`) перед стартом сервиса `llm`/`llm-gpu`.
|
||||
#
|
||||
# Идемпотентность: перед скачиванием каждого файла проверяется его
|
||||
# наличие и размер (не меньше ожидаемого минимума) — повторный запуск
|
||||
# на уже заполненном томе (в т.ч. после смены пресета на модель ДРУГОГО
|
||||
# уровня, ранее уже скачанную в этот же volume) ничего не перекачивает.
|
||||
set -eu
|
||||
|
||||
MODEL_DIR="${MODEL_DIR:-/models/qwen}"
|
||||
|
||||
GGUF_URL="${LLM_MODEL_URL:-https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf}"
|
||||
GGUF_FILE_NAME="${LLM_MODEL_FILE:-qwen3.5-4b-instruct-q4_k_m.gguf}"
|
||||
GGUF_FILE="${MODEL_DIR}/${GGUF_FILE_NAME}"
|
||||
# Порог занижен относительно реального размера файла — не ловить ложные
|
||||
# срабатывания на разных ревизиях модели, но отсекать битые/оборванные
|
||||
# докачки (пустой или сильно урезанный файл). install.sh передаёт более
|
||||
# точный порог для выбранного уровня.
|
||||
GGUF_MIN_SIZE="${LLM_MODEL_MIN_SIZE:-2000000000}"
|
||||
|
||||
TOKENIZER_URL="${LLM_TOKENIZER_URL:-https://huggingface.co/Qwen/Qwen3.5-4B/resolve/main/tokenizer.json}"
|
||||
TOKENIZER_FILE_NAME="${LLM_TOKENIZER_FILE:-qwen3.5-4b-instruct.tokenizer.json}"
|
||||
TOKENIZER_FILE="${MODEL_DIR}/${TOKENIZER_FILE_NAME}"
|
||||
# tokenizer.json семейства Qwen3.5 занимает ~12 МБ (словарь + merges) —
|
||||
# порог просто отсекает пустой/HTML-ответ вместо файла.
|
||||
TOKENIZER_MIN_SIZE=$((1 * 1000 * 1000))
|
||||
|
||||
# Размер существующего файла в байтах (0, если файла нет).
|
||||
file_size() {
|
||||
if [ -f "$1" ]; then
|
||||
wc -c < "$1" | tr -d ' '
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Скачать файл по URL, если он отсутствует или меньше минимального размера.
|
||||
download_if_missing() {
|
||||
url="$1"
|
||||
dest="$2"
|
||||
min_size="$3"
|
||||
name="$4"
|
||||
|
||||
size=$(file_size "$dest")
|
||||
if [ "$size" -ge "$min_size" ]; then
|
||||
echo "[download-model] ${name}: уже скачан (${dest}, ${size} байт) — пропуск"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[download-model] ${name}: скачивание из ${url} в ${dest}"
|
||||
tmp="${dest}.part"
|
||||
# -f — считать HTTP-ошибки (4xx/5xx) провалом; -L — следовать
|
||||
# редиректам HuggingFace на CDN; --retry — устойчивость к обрывам сети.
|
||||
curl -fL --retry 5 --retry-delay 5 -o "$tmp" "$url"
|
||||
|
||||
new_size=$(file_size "$tmp")
|
||||
if [ "$new_size" -lt "$min_size" ]; then
|
||||
echo "[download-model] ${name}: скачанный файл подозрительно мал (${new_size} байт) — прерывание" >&2
|
||||
rm -f "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv "$tmp" "$dest"
|
||||
echo "[download-model] ${name}: готово (${dest}, ${new_size} байт)"
|
||||
}
|
||||
|
||||
mkdir -p "$MODEL_DIR"
|
||||
|
||||
download_if_missing "$GGUF_URL" "$GGUF_FILE" "$GGUF_MIN_SIZE" "GGUF-модель (${GGUF_FILE_NAME})"
|
||||
download_if_missing "$TOKENIZER_URL" "$TOKENIZER_FILE" "$TOKENIZER_MIN_SIZE" "${TOKENIZER_FILE_NAME}"
|
||||
|
||||
echo "[download-model] всё готово: ${MODEL_DIR}"
|
||||
59
deploy/monitoring/alerts.yml
Normal file
59
deploy/monitoring/alerts.yml
Normal file
@@ -0,0 +1,59 @@
|
||||
# Правила алертинга Prometheus. Подключены через
|
||||
# `rule_files` в prometheus.yml. Метрики — из `backend/api/metrics.py`
|
||||
# (см. комментарий в prometheus.yml про контракт имён).
|
||||
#
|
||||
# Проверка: алерт на искусственно заваленном пайплайне — остановить `llm`
|
||||
# при активном `summarize_session` (см. `docs/deploy/monitoring.md`).
|
||||
groups:
|
||||
- name: vidconf-pipeline
|
||||
rules:
|
||||
# Растёт число сеансов, застрявших в статусе `failed`
|
||||
# (`conference_sessions.pipeline_status`).
|
||||
# `delta()` — корректная функция PromQL именно для gauge (не
|
||||
# `increase()`, которая рассчитана на монотонные counter'ы).
|
||||
- alert: PipelineFailed
|
||||
expr: delta(vidconf_pipeline_sessions{status="failed"}[15m]) > 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Растёт число упавших сеансов пайплайна пост-обработки"
|
||||
description: >-
|
||||
За последние 15 минут число сеансов в статусе failed выросло
|
||||
на {{ $value }}. Смотреть логи worker/worker-transcriber и
|
||||
таблицу conference_sessions (pipeline_status).
|
||||
|
||||
# Глубина хотя бы одной из очередей Celery устойчиво растёт 15 минут
|
||||
# подряд — воркеры не успевают за потоком задач (см.
|
||||
# docs/deploy/scaling.md — вынос очереди в отдельную реплику).
|
||||
- alert: QueueGrowing
|
||||
expr: delta(vidconf_celery_queue_depth[15m]) > 0 and vidconf_celery_queue_depth > 10
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Очередь Celery «{{ $labels.queue }}» растёт 15 минут подряд"
|
||||
description: >-
|
||||
Текущая глубина очереди {{ $labels.queue }}: {{ $value }} задач,
|
||||
рост не прекращается 15 минут — см. docs/deploy/scaling.md
|
||||
(вынос очереди в отдельную реплику/масштабирование).
|
||||
|
||||
# LLM-сервер (llama.cpp, job `llm` — см. комментарий в prometheus.yml
|
||||
# про сетевой алиас `llm`/`llm-gpu`) недоступен. Актуально ТОЛЬКО на
|
||||
# инсталляциях с включённым профилем `llm`/`llm-gpu` (пресеты 3–5,
|
||||
# `install.sh`) — на пресетах 1/2 (без AI) таргет `llm:8080` в принципе
|
||||
# не резолвится, и этот алерт будет постоянно активен, если профиль
|
||||
# `monitoring` включён без AI-профиля; для таких инсталляций правило
|
||||
# можно отключить (закомментировать) в локальной копии alerts.yml.
|
||||
- alert: LlmDown
|
||||
expr: up{job="llm"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "LLM-сервер (llama.cpp) недоступен"
|
||||
description: >-
|
||||
Prometheus не может достучаться до llm:8080 дольше 2 минут —
|
||||
суммаризация встанет (задачи будут копиться в очереди summarize,
|
||||
см. также алерт QueueGrowing). Проверить
|
||||
`docker compose ps llm` / `llm-gpu` и `docker compose logs llm`.
|
||||
150
deploy/monitoring/grafana/dashboards/pipelines.json
Normal file
150
deploy/monitoring/grafana/dashboards/pipelines.json
Normal file
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"title": "Пайплайны пост-обработки",
|
||||
"uid": "vidconf-pipelines",
|
||||
"editable": false,
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"time": { "from": "now-6h", "to": "now" },
|
||||
"refresh": "30s",
|
||||
"tags": ["vidconf", "pipeline"],
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Сеансы по статусу пайплайна",
|
||||
"description": "conference_sessions.pipeline_status (recording→transcribing→summarizing→notified|failed), метрика vidconf_pipeline_sessions.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "vidconf_pipeline_sessions",
|
||||
"legendFormat": "{{status}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Глубина очередей Celery",
|
||||
"description": "vidconf_celery_queue_depth{queue=...} — transcription/summarize/notify/celery (redis LLEN). Порог алерта QueueGrowing см. deploy/monitoring/alerts.yml.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "vidconf_celery_queue_depth",
|
||||
"legendFormat": "{{queue}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Латентность API (p50/p95/p99 по маршрутам)",
|
||||
"description": "vidconf_http_request_duration_seconds — histogram латентности HTTP по шаблону маршрута.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 5 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.50, sum(rate(vidconf_http_request_duration_seconds_bucket[5m])) by (le, path))",
|
||||
"legendFormat": "p50 {{path}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, sum(rate(vidconf_http_request_duration_seconds_bucket[5m])) by (le, path))",
|
||||
"legendFormat": "p95 {{path}}",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.99, sum(rate(vidconf_http_request_duration_seconds_bucket[5m])) by (le, path))",
|
||||
"legendFormat": "p99 {{path}}",
|
||||
"refId": "C"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Длительность шагов пайплайна (p95)",
|
||||
"description": "Ожидает метрику vidconf_pipeline_step_duration_seconds (histogram, label step) — пока не реализована (backend/api/metrics.py содержит только латентность HTTP и gauge'и статусов/очередей). Панель — заготовка под будущую инструментацию шагов transcribing/summarizing/notified; до её появления показывает «No data».",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 5 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, sum(rate(vidconf_pipeline_step_duration_seconds_bucket[15m])) by (le, step))",
|
||||
"legendFormat": "p95 {{step}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "LLM-сервер доступен (job=llm)",
|
||||
"description": "up{job=\"llm\"} — 1, если Prometheus успешно скрейпит llama.cpp (алерт LlmDown, deploy/monitoring/alerts.yml). Актуально только на инсталляциях с профилем llm/llm-gpu (пресеты 3–5).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }
|
||||
],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "up{job=\"llm\"}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Сеансы failed (текущее число)",
|
||||
"description": "vidconf_pipeline_sessions{status=\"failed\"} — алерт PipelineFailed срабатывает на росте за 15 минут, здесь — снимок текущего значения.",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "vidconf_pipeline_sessions{status=\"failed\"}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Провижининг дашбордов Grafana (devops) — файлы из
|
||||
# deploy/monitoring/grafana/dashboards/ (том :ro в контейнере grafana).
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: vidconf
|
||||
orgId: 1
|
||||
folder: VidConf
|
||||
type: file
|
||||
disableDeletion: false
|
||||
allowUiUpdates: false
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
@@ -0,0 +1,12 @@
|
||||
# Провижининг источника данных Grafana (devops) — Prometheus
|
||||
# внутри той же docker-сети compose (профиль `monitoring`).
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
45
deploy/monitoring/prometheus.yml
Normal file
45
deploy/monitoring/prometheus.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
# Конфигурация Prometheus (devops) — сбор метрик backend,
|
||||
# PostgreSQL, Redis и локального LLM-сервера. Поднимается compose-профилем
|
||||
# `monitoring` (deploy/docker-compose.yml, сервис `prometheus`).
|
||||
#
|
||||
# Имена метрик backend (`vidconf_http_request_duration_seconds`,
|
||||
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с
|
||||
# `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально —
|
||||
# при переименовании метрик в backend поправить оба файла одновременно.
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
rule_files:
|
||||
- /etc/prometheus/alerts.yml
|
||||
|
||||
scrape_configs:
|
||||
# Backend FastAPI: латентность HTTP по маршрутам + gauge'и пайплайна и
|
||||
# очередей Celery (см. GET /metrics, `backend/api/metrics.py`).
|
||||
- job_name: backend
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["backend:8000"]
|
||||
|
||||
# PostgreSQL (профиль monitoring — сервис postgres-exporter).
|
||||
- job_name: postgres
|
||||
static_configs:
|
||||
- targets: ["postgres-exporter:9187"]
|
||||
|
||||
# Redis (профиль monitoring — сервис redis-exporter): также источник для
|
||||
# алерта LlmDown нет, но по нему видно состояние брокера Celery отдельно
|
||||
# от глубины очередей (та берётся из backend, не отсюда).
|
||||
- job_name: redis
|
||||
static_configs:
|
||||
- targets: ["redis-exporter:9121"]
|
||||
|
||||
# Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес
|
||||
# `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от
|
||||
# выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни
|
||||
# min/medium) или `llm-gpu` (GPU, профиль `llm-gpu`, уровень max, у
|
||||
# которого в сети compose есть сетевой алиас `llm`, см. его определение в
|
||||
# deploy/docker-compose.yml) — эти профили взаимоисключающи, поэтому один
|
||||
# job без дублирования и без вечно недоступного второго таргета.
|
||||
- job_name: llm
|
||||
static_configs:
|
||||
- targets: ["llm:8080"]
|
||||
120
deploy/nginx/nginx.conf
Normal file
120
deploy/nginx/nginx.conf
Normal file
@@ -0,0 +1,120 @@
|
||||
# Dev reverse proxy: backend API + раздача фронтенд-SPA (статика в образе).
|
||||
# Завершение TLS для production документировано в docs/deploy/dev-setup.md
|
||||
# и должно добавляться отдельным server-блоком (443) с реальными сертификатами.
|
||||
#
|
||||
# ВАЖНО (stale DNS): статический `upstream { server backend:8000; }` nginx
|
||||
# резолвит один раз при старте/reload и держит IP в памяти. После пересоздания
|
||||
# контейнера backend (`docker compose up -d --force-recreate backend`) Docker
|
||||
# выдаёт ему новый IP, а nginx продолжает стучаться по старому → 502, пока
|
||||
# nginx не перезапустят. Чтобы резолвить имя заново на каждый запрос,
|
||||
# используем embedded DNS Docker (127.0.0.11) через directive `resolver` и
|
||||
# ПЕРЕМЕННУЮ в proxy_pass — переменные nginx не кэшируются на старте и
|
||||
# резолвятся заново по истечении `valid=`.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# --- Backend API ---
|
||||
# Проброс через переменную: proxy_pass с переменной НЕ выполняет
|
||||
# автоматическую подстановку URI (в отличие от статического
|
||||
# `proxy_pass http://upstream/prefix/;`), поэтому URI-часть в proxy_pass
|
||||
# не указываем — nginx передаёт исходный URI запроса как есть (path +
|
||||
# query), что и сохраняет прежний маппинг /api/ -> backend:8000/api/
|
||||
# (префикс совпадает 1-в-1, подмены не требовалось и раньше).
|
||||
# Upgrade-заголовки нужны и обычным HTTP-запросам (без `Upgrade` в запросе
|
||||
# `map $http_upgrade $connection_upgrade` выше подставляет `close`, что не
|
||||
# ломает keep-alive обычных ответов), и WS-эндпоинту чата конференции
|
||||
# (`WS /api/v1/conferences/{id}/chat`) — он живёт под тем же
|
||||
# префиксом /api/, а не под отдельным /ws/.
|
||||
location /api/ {
|
||||
set $backend_upstream http://backend:8000;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# --- WebSocket upgrade для чата и LiveKit signaling через nginx ---
|
||||
location /ws/ {
|
||||
set $backend_upstream http://backend:8000;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# --- LiveKit SFU signaling (WebSocket) ---
|
||||
# Доступен только когда стек запущен с профилем `media`
|
||||
# (см. docker-compose.yml) — без него resolver вернёт NXDOMAIN на первый
|
||||
# запрос к этому location, что ожидаемо.
|
||||
# Здесь префикс /livekit/ нужно СНЯТЬ (как раньше делал
|
||||
# `proxy_pass http://livekit_upstream/;`). С переменной в proxy_pass
|
||||
# автоматическая подмена недоступна, поэтому переписываем URI явно через
|
||||
# rewrite ... break — nginx передаст на upstream уже переписанный $uri.
|
||||
# ВАЖНО: `set` должен идти ДО `rewrite ... break` — break прерывает
|
||||
# выполнение всех последующих директив модуля rewrite (включая set) в
|
||||
# этом location, иначе переменная останется неинициализированной.
|
||||
location /livekit/ {
|
||||
set $livekit_upstream http://livekit:7880;
|
||||
rewrite ^/livekit/(.*)$ /$1 break;
|
||||
proxy_pass $livekit_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# --- Метрики backend: `GET /metrics` без авторизации
|
||||
# внутри приложения (backend/api/metrics.py) — снаружи периметра нарочно
|
||||
# закрываем явным запретом. Это ОБЯЗАТЕЛЬНО именно exact-match
|
||||
# (`location = /metrics`): ниже есть catch-all `location /` (раздача
|
||||
# фронтенд-SPA), который иначе отдал бы на /metrics файл фронта (или
|
||||
# index.html через try_files). Exact-match в nginx приоритетнее любого
|
||||
# prefix-location, поэтому 403 срабатывает раньше SPA-раздачи.
|
||||
# Prometheus (профиль compose `monitoring`) ходит в backend НАПРЯМУЮ по
|
||||
# внутренней docker-сети (`backend:8000/metrics`), минуя nginx.
|
||||
location = /metrics {
|
||||
return 403;
|
||||
}
|
||||
|
||||
# --- Медиа (аватары): раздача напрямую из volume, в обход backend. ---
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# --- Frontend SPA (React + Vite): статика вкомпилирована в образ nginx
|
||||
# (frontend/Dockerfile) в /usr/share/nginx/html. `try_files` с
|
||||
# history-fallback на /index.html нужен для клиентского роутинга
|
||||
# react-router (deep-ссылки вида /conferences/<id>, /admin/settings —
|
||||
# при перезагрузке страницы отдаётся index.html, дальше роутит SPA).
|
||||
# Более специфичные location выше (/api/, /media/, = /metrics, /livekit/,
|
||||
# /ws/) матчатся раньше этого catch-all, поэтому API и метрики не
|
||||
# перехватываются фронтом.
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /healthz {
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
54
deploy/whisper/download-model.py
Normal file
54
deploy/whisper/download-model.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Идемпотентная предзагрузка модели faster-whisper.
|
||||
|
||||
Без предзагрузки первая транскрибация после старта воркера сама тянет веса
|
||||
модели из HuggingFace Hub в рантайме (минуты простоя пайплайна на первом
|
||||
сеансе) — раньше предзагрузки не было вовсе.
|
||||
|
||||
Использует ТУ ЖЕ функцию, что и сам faster-whisper при ленивой загрузке
|
||||
модели (`faster_whisper.utils.download_model`, см.
|
||||
`backend/core/plugins/faster_whisper.py::_get_model` — `WhisperModel(...,
|
||||
download_root=...)` внутри вызывает её с `cache_dir=download_root`), поэтому
|
||||
создаёт кэш `huggingface_hub`, полностью совместимый с тем, что ожидает
|
||||
плагин в `WHISPER_CACHE_DIR` (`config/plugins.yaml`,
|
||||
`transcriber.options.download_root`) — повторный запуск (в т.ч. сам запуск
|
||||
воркера) ничего не перекачивает: `snapshot_download` идемпотентен по факту
|
||||
наличия файлов в кэше.
|
||||
|
||||
Запускается init-контейнером `whisper-model-init` (сборка backend-образа,
|
||||
профили compose `transcribe`/`transcribe-gpu`, см. `deploy/docker-compose.yml`)
|
||||
— faster-whisper уже есть в зависимостях backend/worker (`pyproject.toml`).
|
||||
|
||||
Путь кэша — КОНТРАКТ с `backend/services/ai_tiers.py` (см. докстринг модуля):
|
||||
детект доступности уровня AI (`services/ai_levels.py`) и сам плагин
|
||||
транскрибера (`transcriber.options.download_root` в `config/plugins.yaml`)
|
||||
ожидают модель именно по пути `{WHISPER_MODELS_ROOT}/{модель}` (например,
|
||||
`/models/whisper/small`) — НЕ по плоскому корню тома. `WHISPER_MODELS_ROOT`
|
||||
здесь должен буквально совпадать со значением одноимённой константы в
|
||||
`ai_tiers.py`.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "small")
|
||||
WHISPER_MODELS_ROOT = os.environ.get("WHISPER_MODELS_ROOT", "/models/whisper")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from faster_whisper.utils import download_model
|
||||
|
||||
cache_dir = f"{WHISPER_MODELS_ROOT}/{WHISPER_MODEL}"
|
||||
print(
|
||||
f"[download-whisper-model] модель '{WHISPER_MODEL}' -> {cache_dir}",
|
||||
flush=True,
|
||||
)
|
||||
path = download_model(WHISPER_MODEL, cache_dir=cache_dir)
|
||||
print(f"[download-whisper-model] готово: {path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[download-whisper-model] ошибка: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user