DealDocumentScreening/docs/DEPLOY.md
febux 85db70887d Fix Vector config and OpenObserve auth for live E2E verification
Ticket 10 verification surfaced runtime issues that static validation
missed; all verified against running Docker stack (Vector 0.43 + OO 0.92.2):

- Rewrite VRL for Vector 0.43: no coalesce/default funcs, merge!/replace!
  for guarded fallible calls, no two-value parse_json destructuring
- Quote env-interpolated sink credentials (empty env left a bare YAML null)
- Fix logs sink URI: OO needs /api/{org}/{stream}/_json (stream segment
  was missing, causing 404); logs now land in contract_check stream
- Replace OPENOBSERVE_AUTH_TOKEN with root email/password Basic auth:
  prometheus_remote_write ignores request.headers, so remote-write got 401
- Update .env.example, DEPLOY.md, ARCHITECTURE.md, ticket 05 accordingly

Verified live: logs with service + correlation_id searchable in
OpenObserve; contract_check_* metrics queryable via its Prometheus API;
no outbound port 4318 connections from app containers.
2026-09-06 19:41:04 +03:00

914 lines
41 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.

# Руководство по развёртыванию «Контракт-чек»
> Целевая аудитория: DevOps / разработчик, поднимающий стек на одном VPS или локально.
> Предполагается: Docker + Docker Compose установлены, есть SSH-доступ к VPS.
> Полная архитектура: [`ARCHITECTURE.md`](ARCHITECTURE.md).
---
## Содержание
1. [Требования](#1-требования)
2. [Быстрый старт локально](#2-быстрый-старт-локально)
3. [Конфигурация через `.env`](#3-конфигурация-через-env)
4. [Docker Compose — полный стек](#4-docker-compose--полный-стек)
5. [Первичная инициализация](#5-первичная-инициализация)
6. [Проверка работоспособности](#6-проверка-работоспособности)
7. [Telegram-бот: первый запуск](#7-telegram-бот-первый-запуск)
8. [B2B API: первый API-ключ](#8-b2b-api-первый-api-ключ)
9. [Резервное копирование](#9-резервное-копирование)
10. [Обновление (zero-downtime-ish)](#10-обновление)
11. [Масштабирование](#11-масштабирование)
12. [Troubleshooting](#12-troubleshooting)
13. [Edge proxy (Nginx + certbot)](#13-edge-proxy-nginx--certbot)
14. [Telegram-бот на отдельном сервере](#14-telegram-бот-на-отдельном-сервере)
---
## 1. Требования
| Компонент | Минимум | Рекомендуемое |
|---|---|---|
| CPU | 2 vCPU | 4 vCPU (worker-extract CPU-bound) |
| RAM | 2 GB | 4 GB |
| Disk | 20 GB SSD | 40 GB SSD (MinIO + WAL-archive) |
| OS | Ubuntu 22.04+ / Debian 12+ | Ubuntu 24.04 LTS |
| Docker | 24.0+ | 25.0+ |
| Docker Compose | v2 | v2 |
| Интернет | нужен для Ollama Cloud | — |
> **Важно:** Ollama Cloud инферит в США. Если 152-ФЗ data-residency критичен —
> рассмотрите self-hosted Ollama (GPU) или GigaChat-адаптер (`core/llm/factory.py`).
---
## 2. Быстрый старт локально
### 2.1 Клонирование и env
```bash
git clone <repo> contract-check
cd contract-check
cp .env.example .env
# Отредактируйте .env — минимум: OLLAMA_HOST, OLLAMA_API_KEY, BOT_TOKEN,
# TELEGRAM_BOT_TOKEN, JWT_SECRET
```
### 2.2 Инфраструктура (только Postgres + Redis + RabbitMQ + MinIO)
```bash
docker compose up -d
# Ждём healthy:
docker compose ps
# Ожидаемый результат: postgres, redis, rabbitmq, minio, minio-init — Up (healthy)
```
Порты на хосте (смещены, чтобы не конфликтовать):
- Postgres: `15432`
- Redis: `17379`
- RabbitMQ AMQP: `5672`, Management UI: `http://localhost:15672`
- MinIO S3 API: `9000`, Console: `http://localhost:9001`
### 2.3 Миграции
```bash
# Локально (нужен uv + dev-группа):
uv sync --group dev
uv run alembic upgrade head
# Или через временный api-контейнер:
docker compose --profile services run --rm api alembic upgrade head
```
### 2.4 Сервисы (api + workers) и бот
```bash
# api + workers (без бота):
docker compose --profile services up -d --build
# Telegram-бот отдельно (можно на этом же или на другом хосте):
docker compose --profile bot up -d --build
# Или сразу весь стек:
docker compose --profile services --profile bot up -d --build
# Ждём healthy:
docker compose --profile services --profile bot ps
```
---
## 3. Конфигурация через `.env`
Все секреты — в `.env` (не коммитить!). Ключевые переменные:
```bash
# --- LLM (обязательно) ---
OLLAMA_HOST=https://api.ollama.com
OLLAMA_API_KEY=sk-xxxxxxxx
OLLAMA_MODEL=qwen2.5:14b
OLLAMA_FALLBACK_MODEL=qwen2.5:7b
# --- Telegram + auth (обязательно) ---
BOT_TOKEN=123456789:ABCDEF... # для aiogram бота
TELEGRAM_BOT_TOKEN=$BOT_TOKEN # тот же токен; API использует для проверки Login Widget / Mini App
BOT_SERVICE_TOKEN=bot-prod-secret-xxx # см. §5.3
JWT_SECRET=$(openssl rand -hex 32) # HS256 secret для подписи JWT
# --- WebUI auth + admin panel (опционально, defaults ниже) ---
WEB_AUTH_ENABLED=true # /api/v1/auth/register,login,...
WEB_APP_BASE_URL=http://localhost:5173 # SPA — ссылки для сброса пароля
WEB_ADMIN_ENABLED=true # панель управления по /admin
ADMIN_REQUIRED_ROLE=admin # users.role, которое допущено в /admin
PASSWORD_MIN_LENGTH=8
POSTGRES_USER=contract_check
POSTGRES_PASSWORD=changeme-strong-password
POSTGRES_DB=contract_check
# --- RabbitMQ ---
RABBITMQ_USER=contract_check
RABBITMQ_PASS=changeme-strong-password
# --- MinIO ---
S3_ACCESS_KEY=contract_check
S3_SECRET_KEY=changeme-strong-password
S3_BUCKET=contract-check-docs
# --- Billing ---
REFUND_POLICY=all # или infra_only
PLANS_ENABLED=false # квоты подписок (миграция 0011); false = только кредиты
YOOKASSA_ENABLED=false # выключено → checkout/refund отвечают 503
PRICE_PER_DOC_KOPECKS=19900 # 199 ₽ за документ без подписки
# Полный список биллинг-env: см. .env.example и ARCHITECTURE.md §11
# --- Observability (опционально) ---
SENTRY_DSN=https://...@sentry.io/...
# Vector (профиль `observer`) авторизуется в OpenObserve с этими кредами
# (совпадают с root-пользователем OpenObserve).
OPENOBSERVE_ROOT_USER_EMAIL=root@example.com
OPENOBSERVE_ROOT_USER_PASSWORD=Complexpass#123
# OTEL_EXPORTER_OTLP_ENDPOINT больше не используется: приложение не шлёт OTLP.
```
> Полный список: см. `.env.example` и `ARCHITECTURE.md §11`. Включение
> платежей (ЮKassa) — §4a ниже.
### 4a. ЮKassa: включение платежей
1. Получите `shopId` и секретный ключ в личном кабинете ЮKassa
(Настройки → API-ключи; для старта можно «тестовый» магазин).
2. Пропишите в `.env`:
```bash
YOOKASSA_ENABLED=true
YOOKASSA_SHOP_ID=812345
YOOKASSA_SECRET_KEY=live_Ax... # или test_...
YOOKASSA_RETURN_BASE_URL=https://<ваш-домен>/pay/{invoice_id}
PLANS_ENABLED=true # включить квоты подписок
BILLING_RETURN_JWT_SECRET=$(openssl rand -hex 32) # или пусто = JWT_SECRET
```
3. В личном кабинете ЮKassa настройте HTTP-уведомления (webhook) на
`https://<ваш-домен>/api/v1/webhooks/yookassa` для событий
`payment.succeeded`, `payment.canceled`, `refund.succeeded`.
API аутентифицирует уведомления по Basic auth (`shopId:secretKey`) и
**всегда перезапрашивает платёж через REST** — сумме из push не доверяем.
4. `docker compose --profile services up -d --build` — изменения подхватятся
без правок compose (12-factor).
5. Поднимите `worker-billing` (входит в профиль `services`): продления
подписок, expiry/past_due, сверка «зависших» pending-счетов старше 15 мин.
**Degraded-режим** (`YOOKASSA_ENABLED=false`, дефолт): каталог тарифов
`GET /api/v1/billing/plans` читается, мутации checkout/refund отвечают `503`,
webhook-и игнорируются с warning-логом. Отключение платежей не требует
отката миграций.
**Тюнинг каталога**: тарифы сидируются миграцией 0011 (free/lite/pro/max).
Правьте таблицу `plans` напрямую (цены — в копейках):
```bash
make shell-db # UPDATE plans SET price_kopecks = 59000 WHERE code = 'lite';
```
---
## 4. Docker Compose — полный стек
### 4.1 Профили
| Профиль | Что поднимает |
|---|---|
| *(default)* | `postgres`, `redis`, `rabbitmq`, `minio`, `minio-init` |
| `services` | `api`, `worker-extract`, `worker-prescreen`, `worker-analyze`, `worker-billing`, `worker-notify` |
| `bot` | `bot` — Telegram-адаптер; можно поднять на этом же хосте или на отдельном сервере |
| `obs` | `loki`, `promtail`, `grafana`, `prometheus` (logs + metrics; traces — позже) |
| `observer` | `openobserve`, `vector` (logs + metrics; приложение не шлёт OTLP) |
| `edge` | `nginx`, `certbot` |
```bash
# Инфра:
docker compose up -d
# api + workers (без бота):
docker compose --profile services up -d --build
# Только бот (например, на отдельном сервере или после api + workers):
docker compose --profile bot up -d --build
# Полный стек на одном хосте:
docker compose --profile services --profile bot up -d --build
# + observability Grafana/Loki/Prometheus (логи + метрики):
docker compose --profile services --profile bot --profile obs up -d --build
# + observability OpenObserve (логи + метрики через Vector; приложение не шлёт OTLP):
docker compose --profile services --profile bot --profile observer up -d --build
# + edge (nginx reverse proxy + TLS; deploy/nginx готов):
docker compose --profile services --profile bot --profile observer --profile edge up -d --build
```
### 4.2 depends_on и healthchecks
`api` ждёт `postgres`, `rabbitmq`, `minio-init` (healthy / completed).
Workers ждут `postgres`, `rabbitmq`, `minio-init`.
`bot` не имеет `depends_on` — он находится в отдельном профиле `bot` и может
запускаться на другом хосте. На старте бот проверяет доступность API через
встроенный healthcheck и перезапускается при необходимости (`restart: unless-stopped`).
---
## 5. Первичная инициализация
### 5.1 Миграции
```bash
# Способ A: локально (если uv установлен)
uv run alembic upgrade head
# Способ B: через api-контейнер (если uv недоступен на хосте)
docker compose --profile services run --rm api alembic upgrade head
```
Проверка:
```bash
docker compose exec postgres psql -U contract_check -d contract_check -c "\dt"
# Должны быть: api_key_requests, api_keys, documents, invoices, jobs, reports, service_tokens, users
```
### 5.2 Seed-token для адаптеров (bot / web / cli)
Бот аутентифицируется в api через `Authorization: Bearer <BOT_SERVICE_TOKEN>`.
Этот токен нужно положить в `service_tokens`.
```bash
# Генерируем секрет
BOT_SERVICE_TOKEN=$(openssl rand -hex 32)
echo "BOT_SERVICE_TOKEN=$BOT_SERVICE_TOKEN" >> .env
# Записываем hash в БД через временный api-контейнер
docker compose --profile services run --rm api \
python -c "
import asyncio, os, sys
os.chdir('/app')
sys.path.insert(0, 'src')
from contract_check.core.config import get_settings
from contract_check.core.tokens import hash_token
from contract_check.core.db.session import create_session_factory
from sqlalchemy import text
async def seed():
factory = create_session_factory()
async with factory() as s:
h = hash_token('$BOT_SERVICE_TOKEN')
await s.execute(text('INSERT INTO service_tokens (name, token_hash, adapter) VALUES (:n, :h, :a) ON CONFLICT (name) DO UPDATE SET token_hash = EXCLUDED.token_hash'), {'n': 'bot-prod', 'h': h, 'a': 'bot'})
await s.commit()
print('seeded bot-prod')
asyncio.run(seed())
"
```
> В production используйте `python -m contract_check.api seed-token` CLI (если реализовано).
### 5.3 Перезапуск бота с новым токеном
```bash
docker compose --profile bot restart bot
# Проверка логов:
docker compose --profile bot logs -f bot
```
---
## 6. Проверка работоспособности
### 6.1 Health endpoints
```bash
curl http://localhost:8000/healthz # liveness — 200
curl http://localhost:8000/readyz # readiness — 200 (PG + Rabbit + MinIO)
# /metrics доступен на loopback по умолчанию; снаружи хоста — только через
# edge/nginx или по туннелю. Для проверки внутри контейнера:
docker compose exec api curl -s http://localhost:8000/metrics
```
### 6.2 RabbitMQ Management UI
Откройте `http://localhost:15672` (guest/guest или credentials из `.env`).
Проверьте:
- Exchange `contracts.x` (direct)
- Queues: `extract.q`, `analyze.q` (quorum), `extract.retry.q`, `analyze.retry.q`, DLQ
- Connections/consumers от worker-ов
### 6.3 MinIO Console
`http://localhost:9001` (root user = `S3_ACCESS_KEY` / `S3_SECRET_KEY`).
Бакет `contract-check-docs` должен появиться после `minio-init`.
### 6.4 End-to-end smoke-тест (через curl)
```bash
# 1. Сначала нужен JWT пользователя. В dev можно обменять telegram_id через
# service-token endpoint /auth/telegram/bot (в production этим занимается бот):
curl -X POST -H "Authorization: Bearer $BOT_SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"telegram_id": 123456}' \
http://localhost:8000/api/v1/auth/telegram/bot
# → {"access_token":"eyJ...", "user_id":"...", "telegram_id":123456, ...}
USER_JWT="eyJ..." # скопируйте access_token
# 2. Проверить /me
curl -H "Authorization: Bearer $USER_JWT" http://localhost:8000/api/v1/me
# → {"telegram_id":123456, "credits_left":0}
# 3. Загрузить документ (нужен credit — пока 0, но проверим путь)
curl -X POST -H "Authorization: Bearer $USER_JWT" \
-F "file=@contract.pdf" http://localhost:8000/api/v1/documents
# → 402 Payment Required (нормально — нет кредитов)
# 4. Добавить кредит вручную (dev):
docker compose exec postgres psql -U contract_check -d contract_check \
-c "UPDATE users SET credits_left = 10 WHERE telegram_id = 123456;"
```
---
## 7. Telegram-бот: первый запуск
### 7.1 Создание бота в BotFather
1. Напишите `@BotFather` → `/newbot`
2. Скопируйте токен (`BOT_TOKEN`) в `.env`
3. Режим доставки updates выбирается переменной `BOT_UPDATE_MODE`:
по умолчанию `polling` (ничего настраивать не нужно). Для webhook-режима
смотрите §14.4 — вручную вызывать `setWebhook` через curl не нужно, бот
сам регистрирует webhook на старте и снимает его при остановке.
### 7.2 Запуск
```bash
docker compose --profile bot up -d --build
docker compose --profile bot logs -f bot
```
Ожидаемый вывод при `/start`:
```
Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах...
Осталось проверок: 0.
```
### 7.3 Добавление кредитов пользователю
```bash
# Найти telegram_id пользователя (из логов бота или таблицы users)
docker compose exec postgres psql -U contract_check -d contract_check \
-c "UPDATE users SET credits_left = credits_left + 5 WHERE telegram_id = <tg_id>;"
```
---
## 8. B2B API: первый API-ключ
### 8.1 Создание ключа (через user JWT)
Управление B2B-ключами теперь требует пользовательский JWT. Получите JWT через
`/auth/telegram/bot` (как в §6.4) и выполните:
```bash
curl -X POST -H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{"name": "integration-test", "rate_limit_rps": 3, "monthly_quota": 100}' \
http://localhost:8000/api/v1/b2b/keys
# → {"api_key": "cc_abc123...", "id": "...", ...}
# Сохраните api_key — он показывается ТОЛЬКО ОДИН РАЗ
```
### 8.2 Анализ документа через B2B
```bash
API_KEY="cc_abc123..."
curl -X POST -H "X-API-Key: $API_KEY" \
-F "file=@contract.pdf" \
http://localhost:8000/api/v1/analyze
# → 202 {"document_id": "...", "correlation_id": "..."}
```
### 8.3 Проверка usage
```bash
curl -H "X-API-Key: $API_KEY" http://localhost:8000/api/v1/b2b/usage
```
---
## 9. Резервное копирование
### 9.1 Postgres
```bash
# Ручной backup
docker compose exec postgres pg_dump -U contract_check -d contract_check \
> backup_$(date +%Y%m%d_%H%M%S).sql
# Автоматический backup (cron на хосте)
# 0 2 * * * cd /opt/contract-check && docker compose exec -T postgres pg_dump -U contract_check -d contract_check | gzip > backups/pg_$(date +\%Y\%m\%d).sql.gz
```
### 9.2 WAL-архивы (PITR-ready)
Postgres настроен с `wal_level=replica`, `archive_mode=on`.
WAL-сегменты пишутся в volume `pgwal` (mount `/walarchive`).
Для полноценного PITR настройте `pg_backrest` или репликацию (см. `ARCHITECTURE.md §10`).
### 9.3 MinIO
MinIO хранит raw-документы и extracted `.txt`. ILM-правило истекает объекты
через `DOC_RETENTION_DAYS` (по умолчанию 7 дней). **Отчёты живут в Postgres** — они
выживут после истечения raw-документов. Backup MinIO не обязателен для бизнес-логики.
---
## 10. Обновление
### 10.1 Rolling update (без остановки всего)
```bash
# 1. Pull изменений
git pull origin main
# 2. Rebuild + recreate api + workers (Compose пересоздаёт только изменённые контейнеры)
docker compose --profile services up -d --build
# 3. Пересоздать бота (если он запущен на этом хосте)
docker compose --profile bot up -d --build
# 4. Миграции (если есть новые)
docker compose --profile services run --rm api alembic upgrade head
# 4. Проверка:
curl http://localhost:8000/readyz
```
### 10.2 Graceful shutdown workers
Workers получают `SIGTERM` → завершают текущее сообщение → `SIGKILL` после grace period.
Compose `stop_grace_period` по умолчанию 10s; для долгих контрактов можно увеличить.
---
## 11. Масштабирование
### 11.1 На одном VPS (вертикальное)
- Увеличьте `MQ_PREFETCH_EXTRACT` до числа CPU
- Увеличьте `MQ_PREFETCH_ANALYZE` до конкарренси Ollama (3 на Pro, 10 на Max)
- Масштабируйте RAM под размер контрактов
### 11.2 Горизонтальное (несколько worker-ов)
```yaml
# docker-compose.override.yml
services:
worker-extract:
deploy:
replicas: 2
worker-analyze:
deploy:
replicas: 2
```
```bash
docker compose --profile services up -d --scale worker-extract=2 --scale worker-analyze=2
```
> Workers stateless — горизонтальное масштабирование бесплатно. Единственное ограничение:
> конкарренси Ollama Cloud (3 на Pro). Не масштабируйте `worker-analyze` выше,
> чем позволяет квота провайдера — иначе получите 429.
### 11.3 Многонодовая HA
См. `ARCHITECTURE.md §10`. Путь к HA — только инфраструктурный (Compose → кластер
RabbitMQ 3 nodes + Patroni Postgres + distributed MinIO). **Код `core/` не меняется.**
---
## 12. Troubleshooting
### 12.1 `docker compose up` зависает — сервисы не стартуют
**Причина:** `depends_on: condition: service_healthy` — кто-то не прошёл healthcheck.
```bash
# Диагностика:
docker compose ps
docker compose logs <service>
# Частые причины:
# - Postgres ещё не готов: подождите 15-20s после первого запуска
# - minio-init не выполнился: проверьте docker compose logs minio-init
# - RabbitMQ не отвечает: docker compose logs rabbitmq
```
### 12.2 `POST /documents` → 402 Payment Required
Пользователю не хватает `credits_left`. В dev добавьте вручную:
```bash
docker compose exec postgres psql -U contract_check -d contract_check \
-c "UPDATE users SET credits_left = credits_left + 1 WHERE telegram_id = <id>;"
```
### 12.3 Worker-extract падает с `ExtractionError`
Документ — скан/PDF без текстового слоя. Worker должен автоматически перейти к OCR.
Если и OCR падает — `documents.status=failed`, credit refunded (если refundable).
Проверьте:
```bash
docker compose --profile services logs worker-extract
# Или в Postgres:
docker compose exec postgres psql -U contract_check -d contract_check \
-c "SELECT id, status, stage, last_failure_class FROM documents ORDER BY created_at DESC LIMIT 5;"
```
### 12.4 Worker-analyze: постоянные 429 / `llm_quota`
- Превышена квота Ollama Cloud. Проверьте usage в dashboard ollama.com.
- Уменьшите `MQ_PREFETCH_ANALYZE` или перейдите на Enterprise тариф.
- Проверьте fallback-model (`OLLAMA_FALLBACK_MODEL`) — он должен сработать на 429.
### 12.5 Bot: `Unauthorized` / 401
- `BOT_SERVICE_TOKEN` не совпадает с `service_tokens.token_hash` в БД.
- Бот не смог получить user JWT через `/api/v1/auth/telegram/bot` (проверьте логи бота и api).
- `TELEGRAM_BOT_TOKEN` / `JWT_SECRET` не заданы в `.env` для сервиса `api`.
- Пересоздайте токен через §5.2.
### 12.6 Бот не отвечает
```bash
# Проверка логов (если бот на этом хосте)
docker compose --profile bot logs -f bot
# Если бот на отдельном сервере — смотрите логи там:
# docker compose -f docker-compose.bot.yml logs -f
# Проверка режима доставки:
# Бот использует polling по умолчанию (BOT_UPDATE_MODE не задан или polling).
# В webhook-режиме проверьте: /healthz на ботовом сервере отвечает 200,
# edge маршрутизирует BOT_WEBHOOK_PATH на бот, секретный заголовок совпадает
# (см. §14.4.3).
```
## 13. Edge proxy (Nginx + certbot)
### 13.1 Профиль `edge`
Профиль поднимает Nginx (443/80) и certbot-renewal sidecar.
```bash
# .env
NGINX_SERVER_NAME=contract-check.example.com
```
> **Важно:** nginx зависит от `api` (`service_healthy`), а `api` находится в
> профиле `services` — поэтому nginx/certbot всегда запускайте хотя бы с
> профилями `services` и `edge`. Бот (`profile: bot`) можно добавить по желанию.
> Запуск только `--profile edge` падает с `service "nginx" depends on
> undefined service "api"`.
```bash
# Запуск edge вместе со стеком api + workers (без бота):
docker compose --profile services --profile edge up -d
# Запуск edge вместе с полным стеком (api + workers + бот на этом хосте):
docker compose --profile services --profile bot --profile edge up -d
```
### 13.2 Первый запуск и получение сертификата
DNS A-запись должна указывать на IP сервера **до** запуска certbot.
`NGINX_SERVER_NAME` в `.env` должен совпадать с доменом.
Nginx **не может стартовать без сертификата** — рендеренный конфиг ссылается на
`/etc/letsencrypt/live/<domain>/fullchain.pem`, и nginx падает на старте, если
файла нет. Поэтому первый сертификат получается в `--standalone` режиме
(certbot сам слушает порт 80, nginx в этот момент не запущен), и только затем
nginx стартует. Всё это делает `certbot-init.sh`:
```bash
# 1. Запускаем основной стек (nginx пока НЕ стартуем).
# Добавьте --profile bot, если бот должен работать на этом же хосте.
docker compose --profile services up -d
# 2. Получаем первый сертификат (standalone, порт 80) и стартуем nginx + renew-sidecar
chmod +x deploy/nginx/certbot-init.sh
./deploy/nginx/certbot-init.sh contract-check.example.com admin@example.com
# 3. Проверяем
curl https://contract-check.example.com/healthz
```
При повторном запуске скрипт делает renew через webroot (nginx уже отдаёт
челленджи) и `nginx -s reload`.
По умолчанию `nginx.conf` проксирует:
- `/api/v1/*`, `/admin/*`
- `/healthz`, `/readyz`
- `/metrics` → проксируется наружу; настройте `METRICS_BEARER_TOKEN` и ограничьте доступ
файрволом/edge, чтобы метрики не утекали наружу. Worker-метрики доступны только внутри сети compose.
- `/webhook/*``/api/v1/webhooks/`
- `${BOT_WEBHOOK_PATH}``bot:8080` (Telegram-бот в webhook-режиме, профиль `bot`; см. §14.4)
- `/grafana/*``grafana:3000` (когда поднят профиль `obs`)
Grafana под путём `/grafana`:
```bash
# Открыть логи через основной домен (нужны профили services/bot/obs/edge)
docker compose --profile services --profile bot --profile obs --profile edge up -d
# https://contract-check.example.com/grafana/d/contract-check-logs
```
> **Безопасность:** Grafana под `/grafana` доступна всем, у кого есть доступ к домену.
> На проде добавьте basic auth, IP whitelist или вынесите Grafana на отдельный
> поддомен с отдельным ingress/VPN.
### 13.3 Обновление сертификата
Certbot-контейнер уже запущен с cron-подобным циклом `renew` каждые 12 часов.
Проверка вручную:
```bash
docker compose --profile edge exec certbot certbot renew --dry-run
docker compose --profile edge exec nginx nginx -s reload
```
### 13.4 MinIO: файлы не видны / bucket не создан
```bash
# Ручной init (если minio-init не отработал)
docker compose run --rm minio-init
# Или проверьте через mc:
docker compose run --rm minio-init mc ls local/
```
### 13.5 Миграции: `alembic` не видит таблицы / revision conflict
```bash
# Проверка текущей HEAD:
docker compose --profile services run --rm api alembic current
# Принудительный upgrade (осторожно — только dev!):
docker compose --profile services run --rm api alembic stamp head
```
### 13.6 Полный сброс (dev only!)
```bash
# Удалить ВСЕ данные (тома + контейнеры):
docker compose --profile services down -v
docker compose --profile bot down -v
docker compose down -v
# Затем пересоздать с нуля: §2 + §5
```
---
## 14. Telegram-бот на отдельном сервере
Бот изначально спроектирован как тонкий HTTP-адаптер: он не импортирует `core.db`/`core.s3`/`core.mq`/`core.llm` и не хранит состояние. Поэтому его можно вынести на отдельный хост без изменения кода — только настроив `API_URL` на центральный API.
Типичные причины вынести бота:
- **Безопасность**: на ботовом сервере нет секретов БД/MinIO/RabbitMQ/LLM.
- **Сетевая изоляция**: боту нужен только outbound HTTPS к Telegram и к API.
- **Масштабирование**: можно запустить несколько ботов за одним токеном (aiogram polling не допускает несколько активных polling-инстансов с одним токеном; для HA используйте webhook + балансировку, см. §14.4).
### 14.1 Сетевая связность
| Направление | Протокол | Порт | Комментарий |
|---|---|---|---|
| bot → Telegram | outbound HTTPS | 443 | Polling updates / API-вызовы `api.telegram.org`; в webhook-режиме — только API-вызовы |
| bot → API | HTTPS (рекомендуется) | 443 / 8000 | `API_URL` без trailing slash, например `https://contract-check.example.com` |
| bot → Redis (опц.) | Redis | 6379 | Только внутри приватной сети/VPN; иначе оставьте `REDIS_URL` пустым |
| edge → bot (webhook-режим) | HTTP | 8080 | Только с TLS-edge (nginx) на приватном интерфейсе/loopback; наружу порт не публикуется |
На firewall ботового сервера открывайте только **outbound** 443 (и 22 для SSH).
В polling-режиме входящих портов бот не требует. В webhook-режиме порт 8080
публикуется compose-файлом только на `127.0.0.1` (см. `BOT_WEBHOOK_BIND_HOST`)
— наружу бот доступен исключительно через TLS-edge.
### 14.2 Подготовка центрального API
Убедитесь, что API доступен ботовому серверу по домену/IP и что `service_tokens` содержит запись для бота (§5.2):
```bash
# На сервере с API — убедитесь, что токен засеян:
docker compose --profile services exec api python -m src.contract_check.api seed-token bot-prod bot
```
Если API закрыт файрволом по IP, разрешите входящие от IP ботового сервера на порт API/Nginx.
### 14.3 Развёртывание бота на отдельном сервере
1. Склонируйте репозиторий на ботовый сервер (нужны только `srv/bot/Dockerfile`, `docker-compose.bot.yml`, `pyproject.toml`, `uv.lock`, `src/`, `.env.bot.example`).
2. Создайте `.env` из `.env.bot.example` с минимальным набором переменных:
```bash
cp .env.bot.example .env
# отредактируйте .env
```
```bash
ENV=prod
LOG_LEVEL=INFO
LOG_FORMAT=json
BOT_TOKEN=123456789:ABCDEF... # тот же токен из @BotFather
BOT_SERVICE_TOKEN=bot-prod-secret-xxx # должен совпадать с service_tokens.name='bot-prod'
API_URL=https://contract-check.example.com # публичный адрес центрального API, без trailing slash
REDIS_URL=redis://redis:6379/0 # локальный Redis из docker-compose.bot.yml
BOT_UPDATE_MODE=polling # webhook-режим: см. §14.4
```
`docker-compose.bot.yml` поднимает собственный Redis-контейнер (только для
rate-limit, не для очереди). Чтобы использовать in-memory бэкенд, задайте
`REDIS_URL=` (пустое значение).
3. Запустите:
```bash
make bot-remote-up
# или напрямую:
docker compose -f docker-compose.bot.yml up -d --build
```
4. Проверьте логи:
```bash
make bot-remote-logs
```
Ожидаемое сообщение:
```
bot_started username=<...> api_url=https://contract-check.example.com
```
5. Проверьте healthcheck контейнера:
```bash
docker compose -f docker-compose.bot.yml ps
docker compose -f docker-compose.bot.yml exec bot python -c \
"import urllib.request, os; print(urllib.request.urlopen(os.environ['API_URL'] + '/healthz').read())"
```
### 14.4 Webhook вместо polling (встроенный режим)
Бот поддерживает два режима доставки updates — переключение одной переменной:
```bash
BOT_UPDATE_MODE=polling # по умолчанию: long-polling (локальная разработка)
BOT_UPDATE_MODE=webhook # продакшн: HTTP-сервер + регистрация webhook в Telegram
```
В webhook-режиме бот поднимает маленький aiohttp-сервер, который:
- принимает Telegram updates по секретному пути `/tg-webhook/<digest>` и
проверяет заголовок `X-Telegram-Bot-Api-Secret-Token` (сравнение в
constant-time); неправильный или отсутствующий токен → **403** до
диспетчеризации, поэтому подделанные updates не могут тратить кредиты
пользователей;
- отвечает `GET /healthz` (200) — compose healthcheck и оркестратор
перезапускают умерший процесс;
- на старте регистрирует webhook (`set_webhook` с `drop_pending_updates`),
при graceful shutdown снимает его (`delete_webhook`) — стейджинг и прод
никогда не дерутся за один токен, и на ботовом сервере не остаётся
устаревший webhook.
#### 14.4.1 Переменные окружения webhook-режима
| Переменная | Обязательна | Описание |
|---|---|---|
| `BOT_UPDATE_MODE` | — | `polling` (default) / `webhook` |
| `BOT_WEBHOOK_PUBLIC_BASE_URL` | в webhook-режиме | Публичный https-базовый URL TLS-edge без trailing slash, напр. `https://contract-check.example.com` |
| `BOT_WEBHOOK_SECRET_TOKEN` | в webhook-режиме | Секрет для `X-Telegram-Bot-Api-Secret-Token` и деривации пути. Только `A-Z a-z 0-9 _ -`, до 256 символов; генерируйте `openssl rand -hex 32` |
| `BOT_WEBHOOK_BIND_HOST` | — | Куда публиковать порт в `docker-compose.bot.yml` (default `127.0.0.1`); используйте адрес приватного/VPN-интерфейса, не публичный |
| `BOT_WEBHOOK_PORT` | — | Хостовый порт webhook-сервера в `docker-compose.bot.yml` (default `8080`; внутри контейнера всегда 8080) |
Polling-режим не требует ни одной из них и остаётся ровно тем же кодом, что и
до появления webhook-режима.
#### 14.4.2 Секретный путь и маршрутизация через edge
Путь вебхука вычисляется детерминированно из секретов —
HMAC-SHA256(секрет, ключ = токен бота), первые 32 hex-символа:
```bash
make bot-webhook-path
# → /tg-webhook/81440d4113c56ead9fa711fce29bc371
```
Знание домена (или чтение шаблона nginx в репозитории) не раскрывает путь.
Telegram шлёт updates на `BOT_WEBHOOK_PUBLIC_BASE_URL` + этот путь, и даже
угадавший путь получает 403 без правильного секретного заголовка.
Настройка edge:
- **Бот на том же хосте, что и стек** (профили `bot` + `edge`): задайте в
`.env` `BOT_WEBHOOK_PATH` (значение из `make bot-webhook-path`) — nginx уже
умеет проксировать этот путь на `bot:8080` внутри Docker-сети. Порт наружу
не публикуется: только 443 у edge.
- **Бот на отдельном сервере**: `docker-compose.bot.yml` публикует webhook-порт
на `127.0.0.1:${BOT_WEBHOOK_PORT:-8080}`. Поставьте TLS-edge (nginx/caddy)
на ботовом серверере, проксируйте секретный путь на `127.0.0.1:8080` и
укажите этот домен в `BOT_WEBHOOK_PUBLIC_BASE_URL`. Альтернатива —
`BOT_WEBHOOK_BIND_HOST` с адресом интерфейса VPN.
Минимальный `.env` webhook-режима на ботовом сервере:
```bash
BOT_TOKEN=123456789:ABCDEF...
BOT_SERVICE_TOKEN=bot-prod-secret-xxx
API_URL=https://contract-check.example.com
BOT_UPDATE_MODE=webhook
BOT_WEBHOOK_PUBLIC_BASE_URL=https://bot.example.com
BOT_WEBHOOK_SECRET_TOKEN=<openssl rand -hex 32>
```
#### 14.4.3 Проверка
```bash
# Healthcheck бота (webhook-режим): compose проверяет сам процесс бота
docker compose -f docker-compose.bot.yml ps # STATUS: Up (healthy)
# Ручная проверка /healthz
curl http://127.0.0.1:8080/healthz # → {"status": "ok"}
# Секретный путь без токена → 403
curl -i -X POST https://<edge>/tg-webhook/<digest> # → HTTP/1.1 403 Forbidden
```
Для HA за балансировщиком помните: webhook-эндпоинт обрабатывает update до
ответа; при нескольких репликах балансировщик должен направлять запросы
Telegram на активную реплику (Telegram сам ретраит неотвеченные updates).
> Для большинства production-развёртываний polling на выделенном ботовом
> сервере достаточен; webhook-режим нужен при ограничительных сетях (где
> outbound long-polling нестабилен) и для healthcheck-надзора за процессом.
### 14.5 Управление и обновление
```bash
# Пересобрать и перезапустить бота после git pull:
make bot-remote-up
# Остановить:
make bot-remote-down
# Посмотреть статус:
make bot-remote-ps
```
---
## Чек-лист перед production
- [ ] `.env` заполнен, `.env.example` не содержит реальных секретов
- [ ] `POSTGRES_PASSWORD`, `RABBITMQ_PASS`, `S3_SECRET_KEY` — strong random
- [ ] `.env` заполнен (включая `TELEGRAM_BOT_TOKEN`, `JWT_SECRET`)
- [ ] `BOT_SERVICE_TOKEN` засеян в `service_tokens`
- [ ] Миграции накатаны (`alembic upgrade head`)
- [ ] `docker compose --profile services --profile bot ps` показывает все healthy (или `services` — если бот не на этом хосте)
- [ ] `/healthz` и `/readyz` отвечают 200
- [ ] Telegram-бот отвечает на `/start`
- [ ] Тестовый PDF проходит pipeline: upload → extract → analyze → report
- [ ] B2B-ключ создаётся через `/api/v1/b2b/keys` с user JWT
- [ ] Backup cron настроен
- [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP)
- [ ] Если бот на отдельном сервере: `API_URL` указывает на центральный API, `BOT_SERVICE_TOKEN` засеян, ботовый сервер имеет outbound HTTPS
- [ ] Если бот в webhook-режиме: `BOT_UPDATE_MODE=webhook`, `BOT_WEBHOOK_PUBLIC_BASE_URL`/`BOT_WEBHOOK_SECRET_TOKEN` заданы, edge маршрутизирует `BOT_WEBHOOK_PATH` на бота, webhook-порт не опубликован в интернет
---
## Ссылки
- [ARCHITECTURE.md](ARCHITECTURE.md) — полная архитектура, схемы БД, RabbitMQ topology, конфиг
- [TICKETS.md](TICKETS.md) — текущие статусы задач
- [README.md](../README.md) — структура, quick start, API overview