# Развёртывание на боевом сервере — от голой 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)): пресеты 1–2 (без AI) нетребовательны (2 vCPU / 4 ГБ RAM с запасом хватает), пресеты 3–5 — см. таблицу ADR-004 (до 16+ vCPU / 64 ГБ RAM / GPU для max). ### Место на диске **Критично проверить заранее** — на боевом инстансе с профилями `transcribe`+`llm` (пресеты 3–5) стек реально упирался в 99% занятого диска. Ориентиры: | Набор профилей | Место под образы/модели | |---|---| | `media,monitoring` (пресеты 1–2, без AI) | ~5 ГБ | | + `transcribe,llm` (пресет 3, уровень min) | ещё ~5–8 ГБ (веса Qwen3.5-4B ~2,8 ГБ + faster-whisper small) | | + `transcribe,llm` (пресет 4, medium) | ещё ~10–15 ГБ (Qwen3.5-9B ~6,2 ГБ) | | + `transcribe-gpu,llm-gpu` (пресет 5, max) | ещё ~25+ ГБ (Qwen3.5-35B-A3B ~20–22 ГБ) | Плюс место под записи (`recordings`, если включена транскрибация) и рост БД со временем. Для пресетов 1–2 достаточно диска от 20 ГБ, для 3–5 — 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 /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 стартует штатно, 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`). **Не запускайте `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/` в приватном/инкогнито окне без активной сессии) — должна показаться форма «Как вас зовут?», а не редирект на логин. --- ## 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` заново → шаги 2–4 этого документа (шаг 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://` вместо `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 @ ``` Разбор синтаксиса `-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`, суммаризация встанет. **На пресетах 1–2 (без 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`, который скрейпится **всегда** (не зависит от профиля) — они наполнятся сами, как только пайплайн реально обработает хотя бы один сеанс. На пресетах 1–2 (без транскрибации/суммаризации) панели статусов/ очередей пайплайна будут пустыми не потому что что-то сломано, а потому что пайплайн просто не запускается — событий нет. 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` (~строки 51–53) - `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