Files
vidconf/docs/deploy/DEPLOYMENT.md
Max Ronzhin 3847476798
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
chore(deploy): render-templates.sh умеет читать другой файл значений
Путь к файлу со значениями был жёстко зашит как `<корень>/.env`. На
машине разработчика корневой `.env` держит боевые адреса
(LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), поэтому рендерить из
него конфиги для локального стенда нельзя, а подменить нечем — локальные
сессии дважды повторяли логику скрипта вручную через envsubst, что
означало расхождение с реальным рендером при первой же правке шаблонов.

Теперь источник значений задаётся переменной ENV_FILE:

    ENV_FILE=.env.local ./deploy/render-templates.sh

Поведение по умолчанию не меняется — тот же корневой `.env`. install.sh
свою переменную ENV_FILE не экспортирует, так что она сюда не протекает;
вызов из install.sh и рендер на сервере работают как раньше. Скрипт
дополнительно печатает, из какого файла взяты значения, и подсказывает
про ENV_FILE, если файл не найден.
2026-08-03 18:45:20 +03:00

793 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Развёртывание на боевом сервере — от голой Ubuntu до `https://<домен>`
Пошаговое воспроизводимое руководство: чистый сервер Ubuntu 24.04 → рабочий
VidConf на реальном домене с TLS. Путь проверен вживую (снос + чистый деплой
+ полное тестирование стека), детали инцидентов и находок — в разделе
«Траблшутинг» ниже.
Для локальной разработки этот документ не нужен — см.
[docs/deploy/dev-setup.md](dev-setup.md). Здесь — только боевой сценарий.
## Содержание
1. [Предусловия](#1-предусловия)
2. [Клонирование репозитория](#2-клонирование-репозитория)
3. [Настройка `.env`](#3-настройка-env)
4. [Первый подъём стека (bootstrap с самоподписанным сертификатом)](#4-первый-подъём-стека-bootstrap-с-самоподписанным-сертификатом)
5. [Выпуск SSL и автопродление](#5-выпуск-ssl-и-автопродление)
6. [Профиль monitoring](#6-профиль-monitoring)
7. [Проверка после деплоя](#7-проверка-после-деплоя)
8. [TURN — опционально, для экстремального NAT](#8-turn--опционально-для-экстремального-nat)
9. [Обновление / редеплой](#9-обновление--редеплой)
10. [Бэкап и restore БД](#10-бэкап-и-restore-бд)
11. [Траблшутинг](#11-траблшутинг)
12. [Как мониторить сервис](#12-как-мониторить-сервис)
---
## 1. Предусловия
### DNS
Для КАЖДОГО домена, который вы впишете в `NGINX_SERVER_NAMES` (см. шаг 3),
должна существовать A-запись, указывающая на публичный IP сервера, ДО выпуска
SSL (шаг 5) — certbot проверяет владение доменом HTTP-запросом на этот IP.
### Сервер
- Ubuntu 24.04 (проверено; другие современные дистрибутивы с Docker,
вероятно, тоже подойдут, но не проверялись).
- Публичный IP на сетевом интерфейсе (без NAT) — так проверялось `ICE`
(см. `LIVEKIT_NODE_IP` в шаге 3). Если сервер за NAT, читайте раздел 8.
- Ресурсы — по выбранному пресету инсталлятора (`./install.sh --help` или
[docs/architecture/adr/004-ai-tier-matrix.md](../architecture/adr/004-ai-tier-matrix.md)):
пресеты 12 (без AI) нетребовательны (2 vCPU / 4 ГБ RAM с запасом хватает),
пресеты 35 — см. таблицу ADR-004 (до 16+ vCPU / 64 ГБ RAM / GPU для max).
### Место на диске
**Критично проверить заранее** — на боевом инстансе с профилями
`transcribe`+`llm` (пресеты 35) стек реально упирался в 99% занятого диска.
Ориентиры:
| Набор профилей | Место под образы/модели |
|---|---|
| `media,monitoring` (пресеты 12, без AI) | ~5 ГБ |
| + `transcribe,llm` (пресет 3, уровень min) | ещё ~58 ГБ (веса Qwen3.5-4B ~2,8 ГБ + faster-whisper small) |
| + `transcribe,llm` (пресет 4, medium) | ещё ~1015 ГБ (Qwen3.5-9B ~6,2 ГБ) |
| + `transcribe-gpu,llm-gpu` (пресет 5, max) | ещё ~25+ ГБ (Qwen3.5-35B-A3B ~2022 ГБ) |
Плюс место под записи (`recordings`, если включена транскрибация) и рост БД
со временем. Для пресетов 12 достаточно диска от 20 ГБ, для 35 — 100/150/250 ГБ
(см. `install.sh`, таблица пресетов).
### Docker + Compose plugin
```bash
curl -fsSL https://get.docker.com | sh
# Проверка (нужен именно compose PLUGIN — "docker compose", не отдельный docker-compose):
docker compose version
```
### Firewall (ufw)
Открыть строго то, что реально нужно снаружи — избыточные правила вводят
в заблуждение и расширяют поверхность атаки без пользы (см. раздел
«Траблшутинг», инцидент с открытым Redis, — конкретно эти порты он не
касался, но принцип «минимум наружу» из него и вырос):
```bash
ufw allow 22/tcp # SSH — сузьте до вашей сети, если возможно
ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
ufw allow 443/tcp # HTTPS
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
ufw allow 54000/udp # LiveKit WebRTC media (ICE, мультиплекс), см. docker-compose.yml
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
# сам релей не работает — клиент получает кандидата и не может им
# воспользоваться, а в логах coturn при этом тишина.
# ufw allow 3478/tcp
# ufw allow 3478/udp
# ufw allow 49160:49200/udp
# Мониторинг (профиль `monitoring`): node-exporter работает в host-сети —
# иначе он отдаёт сетевые метрики собственного контейнера вместо метрик
# сервера (`/proc/net` — симлинк на `self/net`, bind-mount `/proc` этого не
# обходит; см. комментарий у сервиса в deploy/docker-compose.yml). Порт
# слушается на хосте, поэтому Prometheus в docker-сети упирается в
# политику ufw по умолчанию. Правило разрешает скрейп ТОЛЬКО из внутренних
# docker-подсетей — снаружи 9100 остаётся закрыт (172.16.0.0/12 не
# маршрутизируется в интернете):
ufw allow from 172.16.0.0/12 to any port 9100 proto tcp comment 'node-exporter: скрейп Prometheus из docker-сети'
ufw enable
```
**НЕ открывайте** `7880/tcp` (LiveKit signaling — публикуется только на
`127.0.0.1`, клиенты идут через `wss://<домен>/livekit/`, т.е. через
80/443), `5432`/`6379` (Postgres/Redis — тоже только `127.0.0.1`), `9090`/
`3001`/`8080`/`8081` (мониторинг/LLM — тоже `127.0.0.1`, доступ через
SSH-туннель, см. раздел 12). Все эти сервисы уже публикуются
`docker-compose.yml` только на loopback — открывать их портом наружу не
нужно и не следует (см. `.forcc`-заметки внутренних сессий деплоя — именно
так на этом проекте был скомпрометирован открытый в интернет Redis).
---
## 2. Клонирование репозитория
```bash
git clone <URL вашего репозитория> /opt/vidconf
cd /opt/vidconf
```
Дальше все команды — из `/opt/vidconf`, если не указано иное.
---
## 3. Настройка `.env`
```bash
cp .env.example .env
chmod 600 .env # секреты внутри — только root может читать
```
### Что install.sh сделает САМ (не трогайте руками)
При первой установке `install.sh` (шаг 4) сам генерирует случайные секреты
для этих ключей — не прописывайте их вручную, они всё равно будут
перезаписаны при первом запуске (генерация — только если значение пусто,
см. `ensure_secret` в `install.sh`):
`JWT_SECRET`, `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `TURN_STATIC_AUTH_SECRET`,
`LIVEKIT_API_SECRET`, `GRAFANA_ADMIN_PASSWORD`, `SEED_ADMIN_PASSWORD`
(человекочитаемый — сообщается в конце установки, для первого входа).
### Что ОБЯЗАТЕЛЬНО прописать руками ДО `./install.sh`
`.env.example` в git хранит непустые dev-плейсхолдеры (`example.com`,
`ws://localhost:7880`, `devkey` и т.п.) — `install.sh` их НЕ трогает
(генерация секретов срабатывает только на пустых значениях). Начиная с
этой версии `install.sh` **сам остановится с понятной ошибкой**, если
что-то из списка ниже осталось плейсхолдером на реальном домене
(`validate_prod_env` в `install.sh`) — но проще сразу заполнить верно:
| Переменная | Значение для прода | Почему |
|---|---|---|
| `NGINX_SERVER_NAMES` | `<домен> www.<домен>` (через пробел, все домены из DNS) | nginx `server_name`; плейсхолдер `example.com` не обслужит реальный трафик |
| `NGINX_CERT_NAME` | `<домен>` (основной домен из certbot-лайнеджа) | каталог сертификата в `/etc/letsencrypt/live/` |
| `LIVEKIT_PUBLIC_URL` | `wss://<домен>/livekit/` | **критично**: любой `ws://` на HTTPS-странице = mixed-content, браузер молча режет соединение — конференции не стартуют. Safari/Yandex ловят это жёстче Chrome |
| `LIVEKIT_NODE_IP` | реальный внешний IP сервера | без него ICE-кандидаты недостижимы извне — работает только «у меня», не у remote-участников |
| `LIVEKIT_USE_EXTERNAL_IP` | `false` (дефолт, не трогать) | связка `false` + реальный `LIVEKIT_NODE_IP` — рабочая комбинация, `true` вместе с `node_ip` избыточна и шумит в логах |
| `LIVEKIT_API_KEY` | сгенерируйте свой (НЕ `devkey`) | `devkey` — публично известный идентификатор из документации LiveKit |
| `FRONTEND_URL` | `https://<домен>` | ссылки подтверждения email иначе ведут на `localhost` |
| `AUTH_COOKIE_SECURE` | `true` | без `Secure` на HTTPS — небезопасно, а часть браузеров такие cookie просто не сохранит |
| `TURN_EXTERNAL_IP` | реальный внешний IP (или оставьте `127.0.0.1`, если не включаете TURN) | нужен только разделу 8 (TURN опционален) |
Остальные переменные (email/SMTP, пресет AI, RECORDINGS_DIR и т.д.) —
справочник по каждой: [docs/deploy/env.md](env.md).
---
## 4. Первый подъём стека (bootstrap с самоподписанным сертификатом)
Реального сертификата ещё нет — это ожидаемо на первом запуске.
`deploy/nginx/docker-entrypoint-certs.sh` сгенерирует самоподписанный
сертификат, если `/etc/letsencrypt/live/<NGINX_CERT_NAME>` не смонтирован
или пуст — nginx стартует штатно, HTTPS работает (с предупреждением
браузера о недоверенном сертификате — это временно, до шага 5).
```bash
./install.sh --preset 2 --monitoring --yes
# --preset N — см. таблицу пресетов (docs/deploy/install.md);
# --monitoring — сразу поднять Grafana/Prometheus (раздел 6), опционально;
# --yes — без интерактивных подтверждений.
```
Что делает скрипт (подробности — [docs/deploy/install.md](install.md)):
проверяет `.env` на dev-плейсхолдеры (шаг 3), рендерит `deploy/coturn/`,
`deploy/livekit/`, `deploy/egress/` из `*.template` (`deploy/render-templates.sh`),
собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic +
seed, поднимает остальной стек (`up -d --wait`).
По умолчанию `render-templates.sh` читает корневой `.env`. Другой файл
значений задаётся переменной окружения — это нужно, когда рядом с рабочим
`.env` (боевые адреса) поднимается локальный стенд:
```bash
ENV_FILE=.env.local ./deploy/render-templates.sh
```
**Не запускайте `docker compose` вручную без `--env-file .env`** — без него
compose не подхватывает корневой `.env` (файл на уровень выше
`deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого
`docker-compose.yml` (`ws://localhost:7880` и т.п.) — это и было
первопричиной mixed-content на этом проекте (см. раздел 11).
`install.sh` передаёт `--env-file` сам во всех вызовах — используйте его,
а не голый `docker compose up`.
---
## 5. Выпуск SSL и автопродление
Стек уже поднят (шаг 4) и слушает 80/443 с самоподписанным сертификатом —
именно поэтому webroot-challenge сработает: `location ^~
/.well-known/acme-challenge/` в nginx уже проксирует на volume
`deploy/certbot-webroot`, ничего дополнительно поднимать не нужно.
```bash
# certbot (snap — стандартный путь на Ubuntu 24.04):
snap install --classic certbot
ln -s /snap/bin/certbot /usr/bin/certbot
# -d — по одному флагу на КАЖДЫЙ домен из NGINX_SERVER_NAMES:
certbot certonly --webroot -w /opt/vidconf/deploy/certbot-webroot \
-d <домен> -d www.<домен>
# Подхватить новый сертификат (docker-entrypoint-certs.sh копирует его
# в контейнер только при СТАРТЕ — reload конфига недостаточно):
docker compose -f deploy/docker-compose.yml --env-file .env restart nginx
```
Откройте `https://<домен>` — предупреждение браузера должно исчезнуть.
### Автопродление
`snap install certbot` сам ставит systemd-таймер (`snap.certbot.renew.timer`,
дважды в сутки) — руками ничего планировать не нужно. Но **обязательно**
добавьте deploy-hook, который перезапускает nginx-контейнер после
продления — без него `certbot renew` обновит файлы в `/etc/letsencrypt`,
а nginx продолжит отдавать старый (истекающий) сертификат до следующего
пересоздания контейнера, потому что `docker-entrypoint-certs.sh` копирует
сертификат только при старте:
```bash
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
#!/bin/bash
# certbot renew копирует новый сертификат в /etc/letsencrypt/live/<домен>/,
# но и nginx, и coturn держат СВОИ копии (docker-entrypoint-certs.sh и
# coturn-certs-init в deploy/docker-compose.yml) — обе копируются только
# при СТАРТЕ/пересоздании соответствующего контейнера. Reload недостаточен
# ни для того, ни для другого — нужен restart.
docker restart vidconf-nginx-1
# TURN over TLS (см. docs/deploy/DEPLOYMENT.md §8) — только если включён
# (TURN_TLS_HOST задан в .env). Без coturn-certs-init coturn продолжит
# держать в памяти старый сертификат ещё ~60 дней, до следующего продления,
# и TLS-хендшейк начнёт падать с ошибкой валидации сертификата у клиентов.
cd /opt/vidconf || exit 1
if grep -qE '^TURN_TLS_HOST=.+' .env; then
docker compose -f deploy/docker-compose.yml --env-file .env up -d coturn-certs-init
docker restart vidconf-coturn-1
fi
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
# Проверка без реального продления:
certbot renew --dry-run
```
`/etc/letsencrypt` живёт **вне** `/opt/vidconf` — полный снос каталога
проекта (`rm -rf /opt/vidconf`, например, перед чистым передеплоем) сертификаты
не затрагивает, повторно выпускать их не придётся.
---
## 6. Профиль monitoring
Не входит в пресеты `install.sh` по умолчанию. Поднять сразу вместе со
стеком:
```bash
./install.sh --preset 2 --monitoring --yes
```
Или отдельной командой после (стек уже установлен):
```bash
docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring up -d
```
Доступ, панели дашборда, алерты — раздел 12.
---
## 7. Проверка после деплоя
```bash
# 1. Все сервисы healthy
docker compose -f deploy/docker-compose.yml --env-file .env ps
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
# плюс 3478 tcp+udp и 49160:49200/udp, если включили TURN, плюс 5349/tcp,
# если включили TURN over TLS — раздел 8)
ss -ltnp
# 3. Redis требует пароль (НЕ должен пускать без него)
docker exec vidconf-redis-1 redis-cli PING
# Ожидается: NOAUTH Authentication required.
# 4. Backend получил ПРАВИЛЬНЫЙ LIVEKIT_PUBLIC_URL (не localhost, не ws://)
docker exec vidconf-backend-1 env | grep LIVEKIT_PUBLIC_URL
# Ожидается: LIVEKIT_PUBLIC_URL=wss://<домен>/livekit/
docker exec vidconf-backend-1 env | grep AUTH_COOKIE_SECURE
# Ожидается: AUTH_COOKIE_SECURE=true
# 5. HTTP редиректит на HTTPS, HTTPS отвечает
curl -sI http://<домен> # 301
curl -sI https://<домен> # 200
curl -s https://<домен>/api/health
# Ожидается: {"status":"ok","db":true,"redis":true,...}
# 6. LiveKit signaling проксируется и апгрейдит WebSocket
curl -sI https://<домен>/livekit/ # 200
docker logs vidconf-nginx-1 | grep "livekit/rtc"
# Ожидается: строки со статусом 101 (WS upgrade) при реальных заходах
```
**Ручная проверка в браузере (обязательна, автотесты этого не ловят):**
войдите, создайте мгновенную конференцию, откройте DevTools → Console —
не должно быть `[blocked] insecure content` / `ws://localhost`; в Network
запрос `wss://<домен>/livekit/rtc/...` должен получить `101 Switching
Protocols`. Проверьте **гостевой вход** (`/j/<slug>` в приватном/инкогнито
окне без активной сессии) — должна показаться форма «Как вас зовут?», а не
редирект на логин.
---
## 8. TURN — опционально, для экстремального NAT
**Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в
разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN
клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный
`LIVEKIT_NODE_IP` + проброшенный UDP-порт `54000` (шаг 1,
firewall) хватает для подавляющего большинства сетей. Включайте этот
раздел только если у вас есть конкретные пользователи за CGNAT или
жёстким корпоративным firewall, которые не могут установить медиа-соединение
(характерный симптом — конференция подключается по signaling, `connection
state: connected`, но собеседник не видит видео/не слышит звук).
**С версии 0.0.14 LiveKit анонсирует coturn клиентам** — секция
`rtc.turn_servers` в `deploy/livekit/livekit.yaml.template` (UDP и TCP на
3478, credentials по механизму TURN REST API из общего
`TURN_STATIC_AUTH_SECRET`). Встроенный TURN LiveKit при этом остаётся
выключенным (`turn.enabled: false`), чтобы не поднимать два TURN-сервера.
⚠️ **Чем это было до 0.0.14, если вы обновляетесь со старой версии.** coturn
поднимался и был healthy, но клиенты о нём не знали: в конфиге LiveKit
внешний TURN объявлен не был, а фронтенд `iceServers` не задаёт. За всё
время работы в логах coturn не было ни одного ALLOCATE — то есть relay не
использовался никогда, и участники из сетей с жёстким NAT просто теряли
соединение (`PEER_CONNECTION_DISCONNECTED`).
Что нужно проверить на своей инсталляции:
1. **Порты в ufw — оба пункта** (см. шаг 1): `3478/tcp` + `3478/udp` для
сигнализации и `49160:49200/udp` для relay-аллокаций. Диапазон должен
совпадать с `min-port`/`max-port` в
`deploy/coturn/turnserver.conf.template`. Без него TURN отвечает на
запросы, но релей не работает — самый неприятный вариант, потому что в
логах coturn при этом тишина.
2. **`TURN_EXTERNAL_IP` в `.env`** — реальный внешний IP или домен сервера.
Именно это значение уезжает клиентам как адрес TURN-сервера, поэтому
`127.0.0.1` из dev-дефолта сделает анонс бесполезным.
3. После правок — `./deploy/render-templates.sh` (перерендерит конфиги из
шаблонов), затем `docker compose ... up -d --force-recreate livekit`.
⚠️ Перезапуск LiveKit **разрывает все активные конференции** — выбирайте
окно.
4. Проверка, что релей заработал: провести звонок из проблемной сети и
убедиться, что в логах появились аллокации:
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
смотрите ufw и `TURN_EXTERNAL_IP`.
⚠️ **Эта проверка работает только с `verbose` в
`deploy/coturn/turnserver.conf.template`** (включён по умолчанию). Без
этого флага coturn пишет в лог только служебные строки старта
(листенеры, `Total auth threads`) и НИКОГДА не логирует
ALLOCATE/CreatePermission/Refresh — `grep -ci allocate` даёт `0` даже
когда relay реально обслуживает звонок. Это не гипотеза: на релизе
0.0.22 именно так и обнаружили — рабочий relay-звонок (LiveKit
`connectionType: turn`, реальные relay-кандидаты в `49160-49200`) при
дефолтном `simple-log` без `verbose` дал `grep -ci allocate` = `0`,
подтверждение пришлось брать из логов LiveKit. Если ваш конфиг старее
и `verbose` в нём нет — добавьте флаг, перерендерите
(`./deploy/render-templates.sh`) и пересоздайте `coturn`, прежде чем
доверять этой проверке.
### TURN over TLS (порт 5349)
Самый надёжный фолбэк: в жёстких корпоративных сетях наружу часто разрешён
только `443/tcp`, и TLS-соединение на нестандартный порт (5349) выглядит для
firewall как обычный HTTPS. UDP/TCP на 3478 такие сети режут целиком.
**443 вместо 5349 невозможен без доп. усложнений**: `443/tcp` на хосте уже
занят nginx (Docker port-publish биндит хостовый сокет), а coturn слушает в
`network_mode: host`оба не могут забрать один и тот же порт без
SNI-мультиплексора перед ними. Такой мультиплексор — отдельная, более
сложная система; в этом проекте её нет, и заводить её только ради 443 не
оправдано, пока 5349 проходит через те же firewall, что и 443.
**Включение — один флаг, `TURN_TLS_HOST` в `.env`:**
```bash
# Домен сертификата (НЕ IP — см. предупреждение ниже), тот же, что в
# NGINX_CERT_NAME:
TURN_TLS_HOST=vidconf.ru
```
Пусто (dev-дефолт) — TLS выключен полностью и без следов: `render-templates.sh`
вырезает cert/pkey из `turnserver.conf` и запись `protocol: tls` из
`rtc.turn_servers` в `livekit.yaml` (маркеры `BEGIN-TLS-*`/`END-TLS-*` в
`.template`-файлах). Непустое значение включает оба сразу — TLS без анонса
клиентам (и наоборот) не бывает, ровно один переключатель на всё.
⚠️ **`TURN_TLS_HOST` обязан быть ДОМЕНОМ, не IP** — в отличие от
`TURN_EXTERNAL_IP`, который остаётся IP-адресом для udp/tcp-записей. Браузер
проверяет TLS-сертификат TURN-сервера по имени хоста в `turns:`-URL, а
Let's Encrypt выписывает сертификат на домен. С IP в этом поле TLS-хендшейк
упадёт на проверке имени сертификата — внешне это будет выглядеть как ещё
один вариант «coturn healthy, а relay не работает», только на новом порту.
**Права на приватный ключ.** coturn (`nobody:nogroup` внутри контейнера, без
root-фазы в entrypoint — не то что у nginx, где `docker-entrypoint-certs.sh`
выполняется от root) физически не может прочитать
`/etc/letsencrypt/live/<домен>/privkey.pem` (root, обычно `0600`). Решение —
одноразовый init-контейнер `coturn-certs-init` (busybox, дефолтный root,
образец — уже существующий `recordings-init`/`llm-models-init` в этом же
compose-файле): копирует `fullchain.pem`/`privkey.pem` в свой volume
`coturn-certs` под правами `644`. Это копия, не оригинал — права на ключ на
хосте не меняются. coturn просто монтирует `coturn-certs:/etc/coturn/certs:ro`
и ждёт (`depends_on: condition: service_completed_successfully`), пока init
отработает.
**Обновление сертификата.** certbot продлевает Let's Encrypt раз в ~60 дней
и обновляет файлы в `/etc/letsencrypt`, но и nginx, и coturn держат свои
копии, которые перечитываются только при (пере)старте контейнера — reload
недостаточен ни для одного из них. Deploy-hook (см. шаг 5 выше,
`/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh`) после `nginx`
дополнительно пересоздаёт `coturn-certs-init` (перекопировать свежий
сертификат в volume) и перезапускает `coturn`. Без этого шага TLS-TURN
тихо остановится обслуживать новые TLS-хендшейки примерно через два
месяца — coturn будет держать в памяти сертификат, у которого истёк срок
действия, и клиенты начнут получать ошибку валидации сертификата при
попытке TLS-хендшейка.
⚠️ Перезапуск `coturn` (и hook, и ручной после включения TLS) может задеть
активные звонки, идущие через relay, — как и с `livekit` (см. выше),
выбирайте окно или закладывайтесь на автопродление certbot (раз в ~60 дней,
непредсказуемое время суток).
Порядок включения:
1. `TURN_TLS_HOST=<домен>` в `.env`.
2. `./deploy/render-templates.sh` (перерендерит `turnserver.conf` и
`livekit.yaml` с TLS-блоками).
3. Открыть `5349/tcp` в ufw (IPv4 и IPv6).
4. `docker compose … up -d --force-recreate coturn-certs-init coturn`
пересоздать (не просто restart: новый volume/depends_on).
5. Проверить, что TLS реально отвечает:
`openssl s_client -connect <домен>:5349 -servername <домен>` — должен
показать сертификат Let's Encrypt (`issuer=Let's Encrypt`), а не ошибку
соединения.
6. `docker compose … up -d --force-recreate livekit` — подхватить новую
запись `rtc.turn_servers`. ⚠️ Разрывает активные конференции.
7. Обновить deploy-hook certbot (см. шаг 5) и проверить
`certbot renew --dry-run`.
8. Провести звонок, принудительно загнав клиента в relay-режим (ICE
transport policy `relay` в браузере), и убедиться, что аллокации в
`docker logs vidconf-coturn-1` растут именно через TLS-соединение, а
обычный TURN на 3478 продолжает работать для остальных клиентов
(с `verbose`, см. предупреждение в п.4 выше, каждая аллокация видна
отдельной строкой `ALLOCATE processed, success` — можно отличить
TLS-сессию от обычной по времени и по тому, что порт входящего
соединения — 5349).
---
## 9. Обновление / редеплой
```bash
cd /opt/vidconf
git pull
./install.sh --preset N --yes # тот же пресет, что уже установлен —
# см. COMPOSE_PROFILES в .env, если забыли
```
`install.sh` идемпотентен: секреты и ручные правки `.env` сохраняются,
конфиги coturn/LiveKit/egress перерендериваются из шаблонов, образы
пересобираются, миграции применяются, стек поднимается с `--wait`. Если
меняли `.env` вручную (например, домен) без смены пресета — тоже
достаточно повторного `./install.sh --preset N --yes`, шаблоны
перерендерятся автоматически.
**Полный снос и чистый передеплой** (например, после серьёзного инцидента
или для проверки, что репозиторий — единственный источник истины):
1. Снимите бэкап БД (раздел 10) и, если нужно, `.env` (секреты).
2. `docker compose -f deploy/docker-compose.yml --env-file .env down -v`
(`-v` удаляет volumes, включая БД!) или `rm -rf /opt/vidconf` целиком.
3. `/etc/letsencrypt` при этом не трогается (живёт вне `/opt/vidconf`,
раздел 5) — сертификат переживает снос, повторно выпускать (раздел 5,
`certbot certonly`) не нужно. `docker-entrypoint-certs.sh` сам найдёт
существующий сертификат на первом же старте nginx и заберёт его вместо
самоподписанного — просто пропустите раздел 5 целиком.
4. `git clone` заново → шаги 24 этого документа (шаг 4 сразу поднимется
с боевым сертификатом, см. п.3 выше) → restore БД (раздел 10)
вместо/после seed.
---
## 10. Бэкап и restore БД
Значения `POSTGRES_USER`/`POSTGRES_DB` — из `.env` (по умолчанию `vidconf`/`vidconf`).
### Бэкап
```bash
docker exec vidconf-postgres-1 pg_dump -U vidconf vidconf | gzip > db-$(date +%F).sql.gz
```
Снимайте перед любым рискованным действием (передеплой, снос, крупное
обновление схемы) — держите вне `/opt/vidconf` (например, `/root/backups/`),
чтобы полный снос каталога проекта не унёс с собой и бэкап.
### Restore на СВЕЖЕЙ установке
Стек только что поднят `install.sh` — миграции и seed уже применились.
Восстанавливаем поверх, не через seed:
```bash
gunzip -c db-2026-07-25.sql.gz | docker exec -i vidconf-postgres-1 psql -U vidconf vidconf
# Догнать миграции, если бэкап снят на более старой версии кода:
docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run --no-sync alembic upgrade head
docker compose -f deploy/docker-compose.yml --env-file .env restart backend worker
```
### Restore в АВАРИЙНОЙ ситуации (живой инстанс, данные повреждены)
```bash
docker compose -f deploy/docker-compose.yml --env-file .env stop backend worker worker-transcriber nginx
docker exec vidconf-postgres-1 psql -U vidconf -c "DROP DATABASE vidconf;"
docker exec vidconf-postgres-1 psql -U vidconf -c "CREATE DATABASE vidconf OWNER vidconf;"
gunzip -c db-2026-07-25.sql.gz | docker exec -i vidconf-postgres-1 psql -U vidconf vidconf
docker compose -f deploy/docker-compose.yml --env-file .env up -d --wait
```
---
## 11. Траблшутинг
Формат: симптом → причина → фикс. Все пункты — реальные находки/инциденты
этого проекта при первом воспроизводимом деплое.
### `[blocked] insecure content ws://…` в консоли браузера / конференция не стартует
**Причина:** `LIVEKIT_PUBLIC_URL` содержит `ws://` (не `wss://`) — на
HTTPS-странице браузер режет небезопасное WebSocket-соединение (Safari и
Yandex — строже Chrome). Часто это не значение из `.env`, а ДЕФОЛТ
`docker-compose.yml` (`${LIVEKIT_PUBLIC_URL:-ws://localhost:7880}`),
подставляемый, когда compose запущен БЕЗ `--env-file` — тогда даже
корректное значение в `.env` не подхватывается.
**Фикс:** `LIVEKIT_PUBLIC_URL=wss://<домен>/livekit/` в `.env` (шаг 3) +
всегда запускайте `docker compose` с `--env-file .env` (шаг 4). Проверка:
`docker exec vidconf-backend-1 env | grep LIVEKIT_PUBLIC_URL`.
### `WARN: LIVEKIT_API_KEY not set` при `docker compose up`
**Причина:** ровно та же, что и выше — команда запущена без `--env-file`,
compose не видит корневой `.env` (он на уровень выше
`deploy/docker-compose.yml`, автоматический поиск `.env` compose ищет
только рядом с самим compose-файлом/в текущей директории).
**Фикс:** `docker compose -f deploy/docker-compose.yml --env-file
/opt/vidconf/.env ...` — либо используйте `./install.sh`, он передаёт флаг
сам.
### `READONLY You can't write against a read only replica` в логах LiveKit
**Причина:** на этом проекте был реальный инцидент — Redis публиковался
на `0.0.0.0` без пароля (`ports: "6379:6379"` вместо `"127.0.0.1:6379:6379"`),
Docker пишет DNAT-правила в обход `ufw` (правило в `ufw status` могло
отсутствовать, порт всё равно был доступен из интернета). Redis был
скомпрометирован — злоумышленник периодически переводил его в `SLAVEOF`
(read-only реплика) через открытый порт, что ломало регистрацию LiveKit-ноды
именно в эти окна (криптомайнер-пейлоад через cron-инъекцию — контейнерная
изоляция спасла хост, но не сам Redis).
**Фикс (уже в репозитории, не требует действий):** `deploy/docker-compose.yml`
публикует `redis`/`postgres` ТОЛЬКО на `127.0.0.1`, `redis` запускается с
обязательным `--requirepass` (`${REDIS_PASSWORD:?}` — без пароля контейнер
не стартует). Если видите эту ошибку на инстансе, развёрнутом из текущего
репозитория, — проверьте, что `ports:` в `docker-compose.yml` не были
отредактированы вручную обратно на `0.0.0.0`.
### nginx-контейнер `unhealthy`
**Причина:** healthcheck обращается к `http://127.0.0.1:80/`, а после
добавления HTTPS-редиректа (`server { listen 80; ... return 301
https://...; }`) корень `/` на порту 80 отдаёт `301`, а не `200`
healthcheck интерпретирует это как отказ.
**Фикс (уже в репозитории):** healthcheck ходит на `/healthz` (не `/`) —
отдельный `location`, отвечающий `200` без редиректа. Если пишете свой
healthcheck поверх — используйте тот же путь.
### «У меня работает, у других — нет»
**Причина:** чаще всего — заход по `http://<IP>` вместо `https://<домен>`.
На голом IP по HTTP браузер не применяет те же строгие проверки
mixed-content, что и на HTTPS-домене — локально «работает», а у всех
остальных, кто честно заходит на `https://<домен>`, ловит блокировку.
**Фикс:** тестируйте строго на `https://<домен>` — только это отражает
реальный опыт пользователей.
### Прочие грабли, закрытые кодом текущего репозитория
- **install.sh молча принимал плейсхолдеры `.env.example` на боевом
домене** (эта самая ошибка выше, только не в логах, а в `.env`) — теперь
`install.sh` сам останавливается с понятной ошибкой (`validate_prod_env`),
см. шаг 3.
- **`install.sh` не поднимал `monitoring`** ни в одном пресете (пресет
перезаписывает `COMPOSE_PROFILES` целиком) — флаг `--monitoring`
(раздел 6).
- **`/openapi.json` отдавал HTML фронта** вместо схемы API — SPA-фолбэк
nginx (`location /`) перехватывал путь раньше, чем он доходил до
backend. Добавлен точный `location = /openapi.json`, проксирующий на
`backend:8000`.
- **Правило ufw `7880:7881/tcp`** было шире необходимого (7880 давно
публикуется только на `127.0.0.1`) — сузьте до `7881/tcp` (шаг 1).
---
## 12. Как мониторить сервис
### 12a. Доступ к Grafana и Prometheus
Оба слушают **только** `127.0.0.1` на сервере (Grafana `127.0.0.1:3001`
порт контейнера `3000`, Prometheus `127.0.0.1:9090`) — наружу закрыты
намеренно (харденинг после инцидента с открытым Redis, раздел 11). Доступ
— только через SSH-туннель:
```bash
ssh -L 3001:127.0.0.1:3001 -L 9090:127.0.0.1:9090 <user>@<host>
```
Разбор синтаксиса `-L`: `-L <локальный_порт>:<адрес_сочки_зрения_сервера>:<порт>`.
То есть `-L 3001:127.0.0.1:3001` говорит вашему ssh-клиенту: «слушай на
МОЁМ localhost:3001, всё, что туда придёт, перешли на удалённый сервер
и там доставь на 127.0.0.1:3001 — то есть на порт, который слушает
Grafana-контейнер С ТОЧКИ ЗРЕНИЯ САМОГО СЕРВЕРА». Именно поэтому в
команде указан `127.0.0.1`, а не IP вашего компьютера — этот адрес
резолвится УЖЕ НА СЕРВЕРЕ, после того как ssh доставит туда трафик.
Оставьте эту сессию открытой (или добавьте `-N`, если оболочка не нужна —
только проброс портов, без интерактивного shell).
Дальше в браузере **у себя, локально**: `http://localhost:3001` (Grafana),
`http://localhost:9090` (Prometheus) — именно `http`, не `https`: на
`localhost` нет и не нужно TLS-сертификата, это туннель, а не публичный
хост.
Логин Grafana: `GRAFANA_ADMIN_USER` / `GRAFANA_ADMIN_PASSWORD` из
`/opt/vidconf/.env` на сервере.
**Заминка:** `bind: Address already in use` при запуске туннеля — порт
уже занят на ВАШЕМ компьютере (например, у вас локально что-то своё
слушает 3001). Смените ЛЕВУЮ цифру: `-L 3300:127.0.0.1:3001`, откройте
`http://localhost:3300`.
Закрыть доступ — `exit` или закрыть окно/сессию туннеля.
### 12b. Панели дашборда «Пайплайны пост-обработки»
Провижинится автоматически из `deploy/monitoring/grafana/dashboards/pipelines.json`
— появляется в Grafana сразу после подъёма профиля `monitoring`, без
ручной настройки. Шесть панелей:
| Панель | Метрика | Норма | Когда бить тревогу |
|---|---|---|---|
| **Латентность API (p50/p95/p99 по маршрутам)** | `vidconf_http_request_duration_seconds` (histogram) | p50/p95 — низкие и стабильные (десятки–сотни мс для большинства маршрутов) | **Главный индикатор здоровья.** Устойчивый рост p95/p99 (не разовый всплеск) — проблема на бэкенде или БД. Смотрите, растёт ли ВЕЗДЕ или на конкретном маршруте (`{{path}}` в легенде) — второе указывает на конкретный тяжёлый эндпоинт/запрос |
| **Глубина очередей Celery** | `vidconf_celery_queue_depth{queue=...}` (`transcription`/`summarize`/`notify`/`celery`, redis `LLEN`) | около нуля, кратковременные всплески при пиковой нагрузке — нормально | Устойчивый РОСТ 15 минут подряд — воркеры не успевают за потоком задач. Соответствует алерту `QueueGrowing` (порог >10 задач). Решение — [docs/deploy/scaling.md](scaling.md) (вынос очереди в отдельную реплику) |
| **Сеансы по статусу пайплайна** | `vidconf_pipeline_sessions{status=...}` (gauge) | распределение по `recording→transcribing→summarizing→notified`, `failed` — единицы или ноль | Заметный/растущий `failed` — сбой в пайплайне пост-обработки (транскрибация/суммаризация), смотрите логи `worker`/`worker-transcriber` |
| **Сеансы failed (текущее число)** | `vidconf_pipeline_sessions{status="failed"}` (stat, снимок) | `0` | Любое значение >0, не обнуляющееся между проверками — соответствует алерту `PipelineFailed` (срабатывает на РОСТЕ за 15 минут, не на самом факте наличия) |
| **Длительность шагов пайплайна (p95)** | `vidconf_pipeline_step_duration_seconds` | — | **Заготовка**: метрика ещё не инструментирована в коде (`backend/api/metrics.py` пока не пишет её) — панель осознанно показывает «No data» до появления соответствующей инструментации в будущей версии |
| **LLM-сервер доступен (`up{job="llm"}`)** | `up{job="llm"}` (stat: UP/DOWN) | `UP` (1) — на инсталляциях с профилем `llm`/`llm-gpu` | `DOWN` дольше 2 минут = алерт `LlmDown`, суммаризация встанет. **На пресетах 12 (без AI) эта панель ВСЕГДА «No data» — это ожидаемо**, см. 12c |
Алерты (`deploy/monitoring/alerts.yml`): `PipelineFailed` (critical),
`QueueGrowing` (warning), `LlmDown` (critical, закомментирован по
умолчанию — см. 12c/12d). Полный список условий и проверка искусственным
падением сервиса — [docs/deploy/monitoring.md](monitoring.md).
### 12c. Что активно на профилях без LLM/транскрипции
Панели латентности API, очередей Celery и статусов сеансов питаются от
job `backend`, который скрейпится **всегда** (не зависит от профиля) —
они наполнятся сами, как только пайплайн реально обработает хотя бы один
сеанс. На пресетах 12 (без транскрибации/суммаризации) панели статусов/
очередей пайплайна будут пустыми не потому что что-то сломано, а потому
что пайплайн просто не запускается — событий нет.
Job `llm` в `prometheus.yml` и алерт `LlmDown` в `alerts.yml`
**закомментированы по умолчанию** — целевой набор профилей
(`media,monitoring` из `.env.example`) не включает `llm`/`llm-gpu`, и
адрес `llm:8080` не резолвится вовсе; активный scrape на несуществующий
таргет означал бы постоянные ошибки в логах Prometheus и вечно `firing`
алерт. Это ожидаемое, документированное в самих файлах поведение — не
баг.
### 12d. Runbook: включить транскрибацию/суммаризацию и вернуть их мониторинг
Включение — на ДВУХ независимых уровнях: инфраструктура (контейнеры) и
приложение (настройки инстанса). Плюс отдельно — ручной шаг возврата
LLM-мониторинга.
**1. Инфраструктура — поднять контейнеры:**
```bash
# Через install.sh (пересчитает и COMPOSE_PROFILES, и модели уровня AI):
./install.sh --preset 3 --yes # min: faster-whisper small + Qwen3.5-4B
./install.sh --preset 4 --yes # medium
./install.sh --preset 5 --yes # max, GPU обязателен
# Или вручную поверх уже поднятого стека — добавить профили в .env
# (COMPOSE_PROFILES=media,monitoring,transcribe,llm) и:
docker compose -f deploy/docker-compose.yml --env-file .env --profile transcribe --profile llm up -d
```
Проверьте место на диске заранее (раздел 1) — веса моделей `transcribe`+`llm`
занимают ощутимо больше, чем базовый стек, боевой инстанс с этими
профилями упирался в 99% занятого диска на недооценённом объёме.
**2. Приложение — настройки инстанса:**
`install.sh` синхронизирует их сам по выбранному пресету
(`BOOTSTRAP_TRANSCRIPTION_ENABLED=true`, `BOOTSTRAP_AI_LEVEL=min|medium|max`
в `.env` — применяются при первом старте backend; на уже работающей
инсталляции `install.sh` спросит «обновить настройки модулей под
пресет?» и применит через `scripts.apply_preset_settings`). Вручную —
админка → Настройки, либо `PUT /api/v1/admin/settings`.
**3. Вернуть мониторинг LLM** (иначе панель «LLM-сервер доступен» и
`LlmDown` молчат навсегда, даже когда LLM реально поднят):
Раскомментировать ОБА файла ВМЕСТЕ (порознь — рассинхрон: активный алерт
на несуществующую метрику или наоборот):
- `deploy/monitoring/prometheus.yml`, блок `job_name: llm` (~строки 5153)
- `deploy/monitoring/alerts.yml`, правило `LlmDown` (~строки 51+)
```bash
docker restart vidconf-prometheus-1
# или: перезагрузить конфиг без рестарта контейнера (если включён
# --web.enable-lifecycle у Prometheus — по умолчанию в этом проекте НЕ
# включён, поэтому restart — рабочий путь по умолчанию)
```
Это осознанно ручной шаг: Prometheus использует статический
scrape-конфиг (файл), он не видит текущие профили `docker compose`
раскомментировать нужно именно тогда, когда профиль `llm`/`llm-gpu`
реально поднят, иначе см. 12c (постоянные ошибки скрейпа + вечный alert).
**4. Проверка:**
- Проведите тестовый сеанс конференции (запись → транскрибация →
суммаризация) — панели «Сеансы по статусу», «Глубина очередей»
наполнятся реальными данными.
- `up{job="llm"}` = `1` в Prometheus (`http://localhost:9090` через
туннель, вкладка Graph).
- Целевой тест алерта (искусственно остановить LLM и убедиться, что
`LlmDown`/`QueueGrowing`/`PipelineFailed` срабатывают по цепочке) —
[docs/deploy/monitoring.md](monitoring.md), раздел «Алерты».
---
## Ссылки
- [docs/deploy/install.md](install.md) — пресеты, что делает `install.sh` пошагово
- [docs/deploy/env.md](env.md) — полный справочник переменных `.env`
- [docs/deploy/monitoring.md](monitoring.md) — компоненты, метрики, алерты подробно
- [docs/deploy/scaling.md](scaling.md) — горизонтальное масштабирование воркеров
- [docs/deploy/dev-setup.md](dev-setup.md) — локальная разработка (НЕ этот документ)
- [docs/architecture/adr/004-ai-tier-matrix.md](../architecture/adr/004-ai-tier-matrix.md) — модели, кванты, требования железа по уровням AI