gendesign/tradein-mvp/docker-compose.prod.yml
bot-backend 3768aa2b5d
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m13s
fix(tradein/devops): post-deploy health для всего стека + grace period + manifest
1. deploy-tradein.yml: health-check раньше щупал только backend — сломанный
   frontend (404 из-за basePath), упавший browser или crash-loop tgbot/scraper
   помечались успешным деплоем без отката. Добавлены: frontend (curl изнутри
   backend-контейнера на /trade-in/, basePath запечён в prod-образ), browser
   (/health, живёт даже без поднятых camoufox-инстансов), tgbot/scraper
   (docker inspect state=running с двойной проверкой — у них нет HTTP,
   bare python-процессы). Проверяются только сервисы, реально входящие в
   $SERVICES текущего деплоя.

2. docker-compose.prod.yml: backend (60s) и browser (90s) получили
   stop_grace_period — раньше дефолтные docker 10s SIGKILL'или PDF-экспорт
   (WeasyPrint write_pdf, sync route → Starlette threadpool, uvicorn
   timeout_graceful_shutdown=None по умолчанию — единственный backstop это
   Docker) и headless-навигацию browser (aiohttp shutdown_timeout=60s по
   умолчанию + время на закрытие Firefox-инстансов в _on_cleanup) прямо
   посреди работы.

3. _manifest_applied.txt: дописаны 173-186 (давно применены на проде,
   contract-тест их не защищал от rename/rm). 187/188 (веб-чат поддержки)
   НАМЕРЕННО не добавлены — прецедент в этом же репо (commit 5eadae1e, L5):
   миграция, добавленная в manifest ДО того как осела на проде в финальном
   виде, блокирует легитимный rename; фича ещё активно дорабатывается
   параллельно.
2026-07-27 00:34:38 +03:00

305 lines
19 KiB
YAML
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.

# Trade-In MVP — production overlay для деплоя ВНУТРИ gendesign-стека.
#
# Запускается из /opt/gendesign/tradein-mvp/ на bot-server:
# docker compose -p gendesign \
# -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# Особенности vs локального docker-compose.yml:
# - НЕТ своего Caddy и postgres-данных в bind mount
# (postgres контейнер один на всю машину, но изолированная БД tradein_mvp)
# - Образы тянутся из GHCR (build делает GitHub Actions)
# - Backend/frontend подключены к network gendesign_shared чтобы основной
# Caddy мог проксировать /trade-in/* → tradein-frontend
# - Frontend строится с basePath=/trade-in (см. next.config.ts)
#
# ── Observability hardening (#2214) ───────────────────────────────────────────
# mem_limit/memswap_limit: до этого лимитов НЕ было — при runaway любой tradein-
# контейнер мог съесть весь хост (замер 2026-07-03: коробка 11.96G, ~1.7G в свопе).
# memswap_limit == mem_limit ⇒ контейнеру запрещён свап → tradein перестаёт
# докидывать в общий свап хоста (у browser оставлен небольшой swap-cushion).
# Лимиты — ПОТОЛКИ безопасности (×2-3 от фактического рабочего набора), а не
# ожидаемое потребление. Обоснование каждого — рядом с сервисом (замер docker stats).
# ⚠️ ПЕРВЫЙ деплой после мержа этого файла (infra-путь → compose up -d)
# ПЕРЕСОЗДАСТ ВСЕ tradein-контейнеры, включая scraper — прервёт бегущий sweep
# (stop_grace_period 120s даёт unit'у до-checkpoint'иться). Допустимо, разово.
#
# logging: json-file с ротацией (20m × 3 = ≤60M на сервис). До этого драйвер по
# умолчанию рос без границ. Общий anchor ниже.
x-logging: &default-logging
driver: json-file
options:
max-size: "20m"
max-file: "3"
services:
browser:
image: ghcr.io/lekss361/gendesign-tradein-browser:${IMAGE_TAG:-latest}
container_name: tradein-browser
# mem: каждая headless-Firefox страница ~0.8-1.5G. Прод-замер 2026-07-03:
# BROWSER_CONCURRENCY=1 (ops выставил в .env.runtime), idle 29MiB. 2.5g —
# исторически валидированный потолок для «1-за-раз» (#884/#905); было 5g под
# рассчёт на concurrency=4, но прод работает 1 → 5g избыточно. memswap +0.5g:
# единственный сервис со swap-cushion, чтобы не OOM-killнуть карточку mid-flight.
# ⚠️ Поднимаешь BROWSER_CONCURRENCY в .env.runtime — подними и mem_limit
# (грубо ~2.5g на каждую доп. параллельную страницу).
mem_limit: 2560m
memswap_limit: 3g
# stop_grace_period: browser/server.py — bare aiohttp web.run_app(), которое
# само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний
# shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие
# in-flight соединений ПЕРЕД тем как _on_cleanup закроет camoufox-инстансы.
# Без stop_grace_period Docker бы SIGKILL'ил через дефолтные 10s — это убивало
# бы headless-страницу (комментарий выше: /fetch карточка ~15-27s, из
# scraper stop_grace_period #1951) на середине навигации/скрейпа задолго до
# того как aiohttp вообще успеет начать свой собственный graceful-путь.
# 90s = 60s aiohttp shutdown_timeout + ~30s запас на закрытие Firefox-
# инстансов в _on_cleanup (дороже обычного process.kill — camoufox — полноценный
# Firefox-профиль). Не 120s как у scraper/tgbot: у browser нет
# многочасовых unit'ов (единица работы — одна страница, секунды-десятки
# секунд), 120s был бы избыточным запасом без code-level обоснования.
stop_grace_period: 90s
logging: *default-logging
env_file:
- path: ./backend/.env.runtime
required: false
environment:
BROWSER_PORT: "3000"
# Сколько /fetch параллельно. Дефолт 4; ops поднимет до 8 в .env.runtime после смоука.
BROWSER_CONCURRENCY: ${BROWSER_CONCURRENCY:-4}
# SCRAPER_PROXY_URL читается из .env.runtime (см. env_file выше)
expose:
- "3000"
restart: unless-stopped
networks:
- tradein-net
postgres:
image: postgis/postgis:16-3.4
container_name: tradein-postgres
# mem: прод-замер 2026-07-03 = 656MiB. Самый большой относительный запас (×3):
# OOM-kill БД — худший исход (порванные коннекты, recovery). memswap==mem ⇒
# без свапа (shared_buffers не должны уходить в swap-thrash).
# ревью #2214: 3g (idle 656MiB — БД дороже всего при ложном OOM; work_mem-спайки
# тяжёлых PostGIS-сортировок бэктеста/эстиматора + autovacuum)
mem_limit: 3g
memswap_limit: 3g
logging: *default-logging
environment:
POSTGRES_DB: tradein
POSTGRES_USER: ${TRADEIN_POSTGRES_USER:-tradein}
POSTGRES_PASSWORD: ${TRADEIN_POSTGRES_PASSWORD:?required}
volumes:
- tradein-postgres-data:/var/lib/postgresql/data
- ./backend/data/sql:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${TRADEIN_POSTGRES_USER:-tradein}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- tradein-net
- gendesign_shared
backend:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-backend
# mem: прод-замер 2026-07-03 = 100MiB. 640m (×6) — запас под спайки экспорта
# (WeasyPrint PDF / ezdxf / openpyxl держат документ в памяти). memswap==mem ⇒
# без свапа (single-worker uvicorn, предсказуемый event-loop).
# ревью #2214: 768m (WeasyPrint PDF разово берёт 200-500MB поверх ~100MiB idle;
# наложение export + бэкфилл при 640m было бы впритык)
mem_limit: 768m
memswap_limit: 768m
# stop_grace_period: uvicorn command ниже не задаёт --timeout-graceful-shutdown,
# т.е. используется uvicorn-дефолт None (безлимитно ждёт in-flight запросы на
# SIGTERM — verified в uvicorn docs, Server.shutdown() без timeout зависает до
# завершения задач). Единственный реальный backstop — Docker'овский
# stop_grace_period; дефолтные 10s SIGKILL'или бы синхронный PDF-экспорт
# (/estimate/{id}/pdf — sync-def route, значит выполняется в Starlette
# threadpool: WeasyPrint write_pdf() + url_fetcher timeout=10s на встроенные
# SVG/шрифты, см. app/services/exporters/trade_in_pdf.py) прямо посреди
# рендера. 60s — щедрый запас над этим (fetcher максимум 10s + рендер
# исторически секунды, не минуты); не 120s как у scraper/tgbot — там код
# сам ограничивает свой drain через _DRAIN_TIMEOUT_S=100s (cooperative
# shutdown handler), здесь такого code-level таймера нет и заводить его
# ради одного PDF-эндпоинта — за рамками этого fix'а.
stop_grace_period: 60s
logging: *default-logging
# Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск
# reload в середине запроса. Single worker сохраняется для предсказуемости
# (один process = единственный asyncio event loop, нет shared-state race).
# Scheduler переехал в tradein-scraper (#1182) — SCHEDULER_ENABLE=false ниже.
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# .env.runtime пишется вручную на VPS (см. DEPLOY.md / scripts/README.md).
# Контейнер читает YANDEX_GEOCODER_API_KEY, COOKIE_ENCRYPTION_KEY и пр. из
# этого файла — без него backfill scripts падают с "key required".
# required: false — compose не падает если файла нет (первый деплой).
env_file:
- path: ./backend/.env.runtime
required: false
environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
PUBLIC_URL: "https://gendsgn.ru/trade-in"
CORS_ORIGINS: '["https://gendsgn.ru"]'
ENVIRONMENT: "production"
CONTACT_EMAIL: "${TRADEIN_CONTACT_EMAIL:-tradein@gendsgn.ru}"
# Yandex Geocoder API key — читается из backend/.env.runtime (env_file).
# Раньше дублировалось здесь как ${YANDEX_GEOCODER_API_KEY:-} — убрано
# т.к. environment: overrides env_file и при пустой host-env стирает значение.
# GlitchTip DSN — мониторинг ошибок (#396). Пусто = выключено.
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
# Encryption key for Cian session cookies (pgp_sym_encrypt). Required for
# Valuation Calculator (Stage 9). Empty = Calculator scraper disabled +
# cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`.
COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}"
# Scheduler отключён в API-процессе — он живёт в tradein-scraper (#1182).
# Это предотвращает двойной запуск sweep'ов при наличии обоих контейнеров.
SCHEDULER_ENABLE: "false"
# #2213 defense-in-depth: общий секрет с Caddy. ЗАДАН → backend требует
# валидный X-Internal-Auth-Secret на каждом запросе с X-Authenticated-User
# (иначе 401). Пусто = защита не активна (fail-open до провижининга).
# Значение ДОЛЖНО совпадать с TRADEIN_INTERNAL_AUTH_SECRET в .env главного
# (Caddy) стека. Читается также из backend/.env.runtime (env_file выше).
TRADEIN_INTERNAL_AUTH_SECRET: "${TRADEIN_INTERNAL_AUTH_SECRET:-}"
depends_on:
browser:
condition: service_started
postgres:
condition: service_healthy
volumes:
# RBAC roles config — single source of truth (../auth/roles.yaml в репо,
# это /opt/gendesign/auth/roles.yaml на VPS). MIRROR of main backend mount.
# app/core/auth.py читает /app/auth/roles.yaml для middleware + /me.
- ../auth/roles.yaml:/app/auth/roles.yaml:ro
restart: unless-stopped
networks:
- tradein-net
- gendesign_shared # чтобы Caddy gendesign-стека достучался
# Отдельный scraper-контейнер (#1182): scheduler_loop() живёт здесь, а не в API.
# Мотивация: docker restart tradein-backend убивал бегущие sweep'ы (avito/cian/
# rosreestr могут идти часами). Теперь backend-деплой не трогает scraper.
# Образ тот же что у backend (один Dockerfile), команда другая.
# НЕ пересоздаётся при каждом backend-деплое — селективный up в deploy-tradein.yml
# поднимает scraper только при изменениях scraper/infra путей (см. SCRAPER_CHANGED).
# #1951: когда scraper ВСЁ ЖЕ пересоздаётся, deploy-tradein.yml (1) ждёт до 5 мин
# graceful drain'а активных scrape_runs, (2) поднимает его АТОМАРНО одним compose
# invocation вместе с browser/backend/frontend (а не отдельной командой позже), и
# (3) сразу после recreate помечает 'cancelled' любые осиротевшие 'running'-строки
# вместо ожидания 6h zombie-reaper'а. Все три меры — в deploy-скрипте, не здесь.
scraper:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-scraper
# mem: прод-замер 2026-07-03 = 11MiB idle. 640m (×~1.5 от backend-профиля) —
# тяжёлый парсинг делает browser-контейнер, здесь только оркестрация + JSON-
# payload'ы sweep'ов. memswap==mem ⇒ без свапа.
mem_limit: 640m
memswap_limit: 640m
logging: *default-logging
command: ["python", "-m", "app.scheduler_main"]
env_file:
- path: ./backend/.env.runtime
required: false
environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
ENVIRONMENT: "production"
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}"
SCHEDULER_ENABLE: "true"
# Активация scraper-kit + proxy-pool (#2126/#2160): kit-scheduler вместо
# legacy sweep-путей; curl/browser-фетчи берут прокси из пула scrape_proxies.
USE_KIT_SCHEDULER: "true"
USE_PROXY_POOL_CURL: "true"
USE_PROXY_POOL_BROWSER: "true"
depends_on:
postgres:
condition: service_healthy
browser:
condition: service_started
restart: unless-stopped
# Phase 1: give the in-flight scrape unit (browser card ~15-27s) time to finish
# + checkpoint on SIGTERM before Docker SIGKILLs. Default grace is only 10s, which
# SIGKILLs a running card mid-flight. (Cooperative-drain handler lands in a later phase.)
stop_grace_period: 120s
stop_signal: SIGTERM
networks:
- tradein-net
# Telegram support-bot bridge: long-polling worker (asyncio/httpx), тот же
# backend-образ что и scraper/backend (один Dockerfile, другая команда).
# Ничего не слушает (long-polling исходящий к Telegram API) → без expose/ports.
# НЕ подписан на gendesign_shared — не проксируется Caddy, только tradein-net
# (нужен доступ к postgres). TELEGRAM_* переменные — ТОЛЬКО из backend/.env.runtime
# (env_file ниже); пусто/нет токена = бот молча не стартует (см. tgbot_main.py).
# ⚠️ НЕ дублировать TELEGRAM_* в блоке environment: (см. предупреждение у backend
# выше про environment: перекрывающий env_file при пустой host-env).
tgbot:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-tgbot
# mem: нет прод-замера (новый сервис, 2026-07-16) — консервативный старт по
# аналогии с профилем scraper (лёгкая asyncio-оркестрация, без headless-браузера
# и без больших payload'ов). Пересмотреть после первого docker stats на проде.
mem_limit: 256m
memswap_limit: 256m
logging: *default-logging
command: ["python", "-m", "app.tgbot_main"]
env_file:
- path: ./backend/.env.runtime
required: false
environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
ENVIRONMENT: "production"
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
# Этот процесс — не scheduler_main; false на всякий случай, если общий
# Settings()-объект образа где-то читает флаг при импорте (defense-in-depth,
# аналогично backend). tgbot_main.py не должен зависеть от этого значения.
SCHEDULER_ENABLE: "false"
depends_on:
postgres:
condition: service_healthy
# unless-stopped (как и весь остальной стек): только always/unless-stopped
# гарантируют автозапуск после ребута VPS. Kill-switch при пустом
# TELEGRAM_BOT_TOKEN реализован idle-блокировкой в tgbot_main.py, а НЕ exit(0) —
# иначе эта политика дала бы рестарт-луп (перезапускает независимо от кода).
restart: unless-stopped
# >_DRAIN_TIMEOUT_S (100s) в tgbot_main.py, иначе docker убьёт по дефолтным 10с
# раньше, чем докрутится long-poll (до 30с), и кооперативный drain не сработает.
stop_grace_period: 120s
networks:
- tradein-net
frontend:
image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest}
container_name: tradein-frontend
# mem: прод-замер 2026-07-03 = 32MiB. 384m (×12) — Next.js standalone SSR,
# лёгкий; потолок с большим запасом. memswap==mem ⇒ без свапа.
mem_limit: 384m
memswap_limit: 384m
logging: *default-logging
environment:
NODE_ENV: production
# Next.js standalone server.js читает HOSTNAME для bind-адреса.
# Docker по умолчанию ставит HOSTNAME=<container-id> → сервер слушает
# только свой hostname-интерфейс и недоступен как tradein-frontend:3000.
# Принудительно 0.0.0.0 чтобы Caddy достучался.
HOSTNAME: "0.0.0.0"
BACKEND_URL: "http://backend:8000" # internal SSR
depends_on: [backend]
restart: unless-stopped
networks:
- tradein-net
- gendesign_shared
volumes:
tradein-postgres-data:
name: tradein-postgres-data
networks:
tradein-net:
name: tradein-net
gendesign_shared:
external: true