DealDocumentScreening/docs/DEPLOY.md
febux 6f22b0368a
Some checks failed
ci / Lint & typecheck (push) Successful in 33s
ci / Unit tests (push) Successful in 1m7s
ci / Integration tests (push) Failing after 25s
Fix CI workflows. Extend make commands. Fix Nomad deploy doc.
2026-09-13 23:54:06 +03:00

45 KiB
Raw Blame History

Руководство по развёртыванию «Контракт-чек»

Целевая аудитория: DevOps / разработчик, поднимающий стек на одном VPS или локально. Предполагается: Docker + Docker Compose установлены, есть SSH-доступ к VPS. Полная архитектура: ARCHITECTURE.md.


Содержание

  1. Требования
  2. Быстрый старт локально
  3. Конфигурация через .env
  4. Docker Compose — полный стек
  5. Первичная инициализация
  6. Проверка работоспособности
  7. Telegram-бот: первый запуск
  8. B2B API: первый API-ключ
  9. Резервное копирование
  10. Обновление (zero-downtime-ish)
  11. Масштабирование
  12. Troubleshooting
  13. Edge proxy (Nginx + certbot)
  14. Telegram-бот на отдельном сервере
  15. Nomad: app-сервисы в продакшене

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

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)

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 Миграции

# Локально (нужен 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) и бот

# 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 (не коммитить!). Ключевые переменные:

# --- 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:

    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 напрямую (цены — в копейках):

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
# Инфра:
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 Миграции

# Способ A: локально (если uv установлен)
uv run alembic upgrade head

# Способ B: через api-контейнер (если uv недоступен на хосте)
docker compose --profile services run --rm api alembic upgrade head

Проверка:

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.

# Генерируем секрет
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 Перезапуск бота с новым токеном

docker compose --profile bot restart bot
# Проверка логов:
docker compose --profile bot logs -f bot

6. Проверка работоспособности

6.1 Health endpoints

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)

# 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 Запуск

docker compose --profile bot up -d --build
docker compose --profile bot logs -f bot

Ожидаемый вывод при /start:

Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах...
Осталось проверок: 0.

7.3 Добавление кредитов пользователю

# Найти 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) и выполните:

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

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

curl -H "X-API-Key: $API_KEY" http://localhost:8000/api/v1/b2b/usage

9. Резервное копирование

9.1 Postgres

# Ручной 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 (без остановки всего)

# 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-ов)

# docker-compose.override.yml
services:
  worker-extract:
    deploy:
      replicas: 2
  worker-analyze:
    deploy:
      replicas: 2
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.

# Диагностика:
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 добавьте вручную:

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). Проверьте:

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 Бот не отвечает

# Проверка логов (если бот на этом хосте)
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.

# .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".

# Запуск 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:

# 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:

# Открыть логи через основной домен (нужны профили 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 часов. Проверка вручную:

docker compose --profile edge exec certbot certbot renew --dry-run
docker compose --profile edge exec nginx nginx -s reload

13.4 MinIO: файлы не видны / bucket не создан

# Ручной init (если minio-init не отработал)
docker compose run --rm minio-init
# Или проверьте через mc:
docker compose run --rm minio-init mc ls local/

13.5 Миграции: alembic не видит таблицы / revision conflict

# Проверка текущей 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!)

# Удалить ВСЕ данные (тома + контейнеры):
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):

# На сервере с 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 с минимальным набором переменных:

    cp .env.bot.example .env
    # отредактируйте .env
    
    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. Запустите:

    make bot-remote-up
    # или напрямую:
    docker compose -f docker-compose.bot.yml up -d --build
    
  4. Проверьте логи:

    make bot-remote-logs
    

    Ожидаемое сообщение:

    bot_started username=<...> api_url=https://contract-check.example.com
    
  5. Проверьте healthcheck контейнера:

    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 — переключение одной переменной:

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-символа:

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-режима на ботовом сервере:

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 Проверка

# 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 Управление и обновление

# Пересобрать и перезапустить бота после git pull:
make bot-remote-up

# Остановить:
make bot-remote-down

# Посмотреть статус:
make bot-remote-ps

15. Nomad: app-сервисы в продакшене

Полный runbook (установка агента, TLS, ACL, секреты, firewall): deploy/nomad/README.md.

Разделение ответственности на VPS:

Слой Чем управляется
api + 5 воркеров (extract/analyze/prescreen/billing/notify) Nomad (job contract-check, одиночный агент server+client)
postgres, redis, rabbitmq, minio docker compose (профиль infra)
edge-каскад: system nginx → compose nginx без изменений (§13, deploy/vps/)
наблюдаемость (Vector/Promtail + …) docker compose, скрейпит docker-сокет — контейнеры Nomad видит автоматически

Задачи Nomad ходят в инфраструктуру compose через хостовый шлюз docker0 172.17.0.1 по опубликованным портам (15432/17379/5672/9000).

15.1 CI/CD

Push в main (Forgejo) → .forgejo/workflows/deploy.yml:

  1. сборка 6 образов из srv/*/Dockerfile;
  2. push в registry p2gnl.mu-dungeon.xyz/admin-git/contract-check-<name>:<git-sha>;
  3. nomad job run (образ-тег подставляется CLI-шаблоном IMAGE_TAG).

Деплой неуспешен в CI, если health-checks не прошли — на стороне Nomad сработает auto_revert (откат на предыдущую версию job). Воркеры обновляются canary-стратегией (новая версия рядом со старой); api — rolling (max_parallel=1, секундный разрыв на деплой).

15.2 Повседневные операции

make nomad-status                          # группы, аллокации, деплои
make nomad-logs G=api                      # хвост логов группы
make nomad-scale G=worker-extract N=3      # масштабирование воркера
make nomad-revert V=<version>              # ручной откат

Web UI (логи/exec/scale/deployments): ssh -L 4646:127.0.0.1:4646 <vps>https://localhost:4646/ui (сертификат self-signed — предупреждение норма).

15.3 Секреты

Где Что
Nomad Variables nomad/jobs/contract-check connection strings, ключи LLM/SMTP/JWT, токен registry (pull)
Forgejo repo secrets REGISTRY_TOKEN (push, write:package), NOMAD_TOKEN (CI ACL), NOMAD_CACERT (CA-сертификат)
Forgejo repo variables NOMAD_ADDR_HOST (публичный IP VPS с Nomad)

Обновление секрета: nomad var put ... → контейнеры перезапустятся (change_mode = restart).

15.4 Отказоустойчивость (одиночный сервер)

Агент упал → контейнеры продолжают работать (docker их не убивает); недоступны только деплои/скейлинг до systemctl restart nomad. Состояние — в /var/lib/nomad (raft).

15.5 Порядок перевода со compose на Nomad

  1. Воркеры по одному: остановить compose-сервис → убедиться, что группа Nomad поднялась и потребители вернулись в очереди RabbitMQ (rabbitmqctl list_queues name consumers);
  2. api: поднять группу Nomad (порт 18000) → проверить /healthz → переключить deploy/nginx/templates/contract-check-http.conf.template (set $api http://api:8000http://<compose-gateway>:18000) → docker compose --profile services down;
  3. Профиль infra (postgres/redis/rabbitmq/minio), edge и наблюдаемость не трогать.

Чек-лист перед 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)
  • Nomad: агент healthy (nomad server members / nomad node status), 4646 открыт только IP Forgejo, 4647/4648 закрыты
  • Nomad Variables nomad/jobs/contract-check заполнены (включая registry-токен)
  • Forgejo secrets заданы: REGISTRY_TOKEN, NOMAD_TOKEN, NOMAD_CACERT + переменная NOMAD_ADDR_HOST
  • Если бот на отдельном сервере: 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 — полная архитектура, схемы БД, RabbitMQ topology, конфиг
  • TICKETS.md — текущие статусы задач
  • README.md — структура, quick start, API overview