gendesign/tradein-mvp/docker-compose.prod.yml
lekss361 8ea52b9b7a
All checks were successful
Deploy Trade-In / build-browser (push) Successful in 32s
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Successful in 34s
Deploy Trade-In / test (push) Successful in 1m40s
Deploy Trade-In / build-backend (push) Successful in 26s
Deploy Trade-In / deploy (push) Successful in 45s
fix(tradein/ops): health-check валит деплой + mem-лимиты + log-rotation (#2214) (#2231)
2026-07-02 21:29:30 +00:00

224 lines
13 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
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
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).
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"
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
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