gendesign/tradein-mvp/docker-compose.prod.yml
bot-backend b65f75c1fc
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (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 / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
fix(tradein/compose): USE_PROXY_POOL_* и для backend, не только scraper
`USE_PROXY_POOL_CURL`/`USE_PROXY_POOL_BROWSER` объявлены только у сервиса
scraper (#2126/#2160). В процессе backend оба флага оставались False, из-за
чего `_kit_proxy_provider()` возвращал None, а `curl_proxy_url` /
`BrowserFetcher._acquire_lease` игнорировали переданный proxy_provider и
уходили на env-фолбэк `SCRAPER_PROXY_URL` — на проде это выключенный узел
(407) → camoufox InvalidIP, 83 провала запуска сайдкара за 3 ч 05.09.

Правка — только объявление тех же двух переменных в `environment:` backend.
`environment:` перекрывает `env_file` (backend/.env.runtime), значений
USE_PROXY_POOL_* в runtime-env нет, так что конфликта нет.

Гейты: yaml.safe_load rc=0, scripts/check-compose-ambiguous-hosts.py rc=0.

Refs: issue «tradein-backend без USE_PROXY_POOL_* → браузерные скрейпы на мёртвый env-прокси»

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 03:16:26 +05:00

501 lines
37 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: journald (#2741). Было json-file 20m × 3 — оно решало только размер,
# и ценой того, что лог ЖИВЁТ В КОНТЕЙНЕРЕ: `docker rm` уносит его целиком.
# Деплоев ~20/сутки, скрейперы работают ночью, разбор идёт утром → окно жизни
# лога почти никогда не покрывает интересное (#2695, #2698, #2676 — три разбора
# подряд уперлись в «логов уже нет»).
#
# journald: демон отдаёт stdout/stderr в /var/log/journal (persistent, на хосте),
# запись переживает пересоздание контейнера и читается ПО ВРЕМЕНИ, а не «сколько
# осталось от последнего рестарта».
#
# КАК ЧИТАТЬ (проверено на проде 2026-08-06):
# docker logs tradein-scraper # как и раньше: только текущий контейнер
# # история через пересоздания — журнал принадлежит root, а deploy-юзер
# # gendesign состоит в docker, но НЕ в adm/systemd-journal, и sudo просит пароль,
# # поэтому голый `journalctl` у него выдаёт «No entries». Рабочий однострочник:
# docker run --rm -v /:/host:ro alpine chroot /host sh -c \
# 'TZ=UTC journalctl -t tradein-scraper -o short-iso \
# --since "2026-08-07 00:39" --until "2026-08-07 01:40"'
# # TZ=UTC — не украшение: `--since/--until` разбираются в ЛОКАЛЬНОМ времени хоста
# # (а он в +03), и флаг `--utc` на это НЕ влияет — он меняет только вывод. Без
# # TZ=UTC окно уезжает на 3 часа и молча отдаёт чужой кусок ночи. Проверено.
# # То же по метке контейнера: CONTAINER_NAME=tradein-scraper (-o cat -f для tail -f)
# Владельцу стоит разово выдать `sudo usermod -aG adm gendesign` — после этого
# journalctl работает напрямую, без docker-обёртки (host-config, не этот файл).
#
# Ротация: журнал общий на хост, режется самим journald по размеру (SystemMaxUse)
# и НЕ обнуляется пересозданием контейнера — то есть глубина истории меряется
# сутками, а не «сколько прошло с последнего деплоя» (это и был баг #2741/#2715:
# 60 МБ json-file при флуде прокручивались за минуты, а деплой обнулял и их).
# Жёсткий потолок по возрасту (MaxRetentionSec) — при желании host drop-in.
# Замер 2026-08-06: /var/log/journal =
# 2.3G за 103 дня (~22 МБ/сутки системных) при дефолтном потолке SystemMaxUse=4G;
# tradein добавляет единицы-десятки МБ/сутки (5.8k карточек за сутки по
# scrape_runs, ~153 Б/строку) → до вытеснения старого ещё недели, требуемые
# «сутки-двое» покрыты с запасом. Дисковый риск нулевой: 4G — это ПОТОЛОК, при
# его достижении journald сам удаляет старейшее.
# Ceiling: journald рейт-лимитит (дефолт 10000 сообщений / 30s на сервис) — при
# превышении в журнал попадёт «Suppressed N messages». Июльский флуд postgres
# (40 МБ за 6 часов ≈ 12 строк/с) от лимита в ~25 раз ниже, но если такое
# появится — это host-config (drop-in journald.conf.d), не этот файл.
# tag: имя контейнера вместо ID — SYSLOG_IDENTIFIER стабилен между пересозданиями.
x-logging: &default-logging
driver: journald
options:
tag: "{{.Name}}"
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 выше)
# #2616 шаг 1: server.py IS_PROD-guard (отказ 503 вместо direct-IP camoufox при
# отсутствии прокси) активен только при ENVIRONMENT=production — без этой
# строки guard молча спит (дефолт "dev").
ENVIRONMENT: production
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)
#
# ── Переезд на выделенный сервер (#2989, репетиция 2026-08-23 на Poincare:
# 12 потоков / 62 ГиБ / NVMe RAID1) ─────────────────────────────────────────
# TRADEIN_PG_MEM_LIMIT — ОДНА переменная на mem_limit И memswap_limit
# намеренно: связка «без свапа» (memswap == mem, см. обоснование выше) обязана
# держаться и после правки, а не только на дефолте. Дефолт = ровно текущее
# прод-значение Beget (3g) — до задания переменной поведение стека НЕ меняется.
# ⚠️ mem_limit — потолок КОНТЕЙНЕРА, он срабатывает РАНЬШЕ postgresql.conf.
# На новом хосте поднимать TRADEIN_PG_MEM_LIMIT ДО правки
# TRADEIN_PG_SHARED_BUFFERS ниже — иначе shared_buffers=12GB при mem_limit=3g
# убьёт контейнер OOM-kill'ом на самом старте. Целевое на 62 ГиБ — 44g, а
# не 64g: нужен запас под page cache ВНЕ контейнера (это то, что реально
# отражает effective_cache_size — не shared_buffers).
mem_limit: ${TRADEIN_PG_MEM_LIMIT:-3g}
memswap_limit: ${TRADEIN_PG_MEM_LIMIT:-3g}
# TRADEIN_PG_SHM_SIZE: дефолт = текущее прод-значение (512m). Сюда ложатся
# только DSM-сегменты параллельных планов (не буферный кеш), поэтому рост
# /dev/shm не следует из тех же расчётов, что shared_buffers ниже — трогать
# только если после переезда параллельные PostGIS-сортировки начнут падать
# с «could not resize shared memory segment» (аналог #2812 в корневом
# docker-compose.prod.yml).
shm_size: ${TRADEIN_PG_SHM_SIZE:-512m}
# /dev/shm по умолчанию 64 МБ. Параллельные воркеры кладут туда shared memory
# segments; на тяжёлых PostGIS-сортировках это «could not resize shared memory».
#
# ── Конфигурация Postgres (#2991) ─────────────────────────────────────────
# До этого контейнер шёл на ПОЛНОСТЬЮ стоковых настройках. Замер прода 2026-08-20:
# 169 млрд blks_read за 91,75 сут ≈ 175 МБ/с мимо кеша при shared_buffers=128 МБ.
#
# Значения подобраны под ТЕКУЩИЙ сервер и НЕ выходят за mem_limit=3g.
#
# ── Параметризация под переезд (#2989, репетиция 2026-08-23 на Poincare) ──
# Пять параметров ниже вынесены в переменные ИМЕННО потому что они зависят
# от размера хоста; остальные (checkpoint_timeout, wal_compression,
# random_page_cost, pg_stat_statements и пр. ниже) от размера хоста НЕ
# зависят — оставлены как есть. Дефолты = буквально текущие прод-значения
# на Beget, так что до задания переменных поведение НЕ меняется ни на бит.
# Целевые значения для нового хоста (62 ГиБ) — задавать в окружении деплоя,
# НЕ здесь и НЕ на Beget (там 11 ГиБ, контейнер просто не поднимется):
# TRADEIN_PG_MEM_LIMIT=44g (сначала он — см. предупреждение выше)
# TRADEIN_PG_SHARED_BUFFERS=12GB
# TRADEIN_PG_EFFECTIVE_CACHE_SIZE=36GB
# TRADEIN_PG_WORK_MEM=64MB
# TRADEIN_PG_MAINTENANCE_WORK_MEM=2GB
# TRADEIN_PG_MAX_WAL_SIZE=16GB
# Порядок обязателен: сначала TRADEIN_PG_MEM_LIMIT (контейнерный потолок,
# см. выше), потом эти пять — иначе OOM-kill на старте.
command:
- postgres
# Память. 768MB shared_buffers — 6× от стоковых 128MB, с запасом внутри 3g
# (idle-замер контейнера был 292 МБ, work_mem-спайки и autovacuum сверху).
- -c
- shared_buffers=${TRADEIN_PG_SHARED_BUFFERS:-768MB}
# effective_cache_size — подсказка планировщику, НЕ аллокация. На хосте
# MemAvailable 6,1 ГБ, поэтому 6GB честно отражает доступный page cache.
- -c
- effective_cache_size=${TRADEIN_PG_EFFECTIVE_CACHE_SIZE:-6GB}
- -c
- work_mem=${TRADEIN_PG_WORK_MEM:-16MB}
# maintenance_work_mem: autovacuum по listings идёт 193 раза в сутки,
# с 64MB каждый проход перечитывает индексы лишними итерациями.
- -c
- maintenance_work_mem=${TRADEIN_PG_MAINTENANCE_WORK_MEM:-256MB}
# Чекпойнты. ГЛАВНОЕ: 288 чекпойнтов в сутки — это ровно 24ч/288 = 5 минут,
# то есть дефолтный checkpoint_timeout, а НЕ max_wal_size. При WAL 7 ГБ/сут
# лимит в 1 ГБ дал бы 7 чекпойнтов, а не 288. Поэтому поднимаем ИМЕННО
# timeout — иначе правка max_wal_size ничего бы не изменила.
# Реже чекпойнты → реже full-page images (сейчас 55-86% всего объёма WAL).
- -c
- checkpoint_timeout=30min
- -c
- checkpoint_completion_target=0.9
# max_wal_size=4GB, а не 8GB: на диске сейчас всего 38 ГБ свободно, а pg_wal
# растёт до этого значения. При timeout=30min и 7 ГБ/сут между чекпойнтами
# накапливается ~0,15 ГБ, так что 4GB — потолок с большим запасом.
- -c
- max_wal_size=${TRADEIN_PG_MAX_WAL_SIZE:-4GB}
- -c
- min_wal_size=1GB
# Самая дешёвая победа при доле FPI 55-86%. zstd доступен с PG15, у нас 16.
- -c
- wal_compression=zstd
# Диск NVMe, а стоковый random_page_cost=4 — настройка под HDD: планировщик
# систематически недооценивает индексные сканы и уходит в Seq Scan.
- -c
- random_page_cost=1.1
- -c
- effective_io_concurrency=200
# pg_stat_statements: расширение есть в образе, но не подключено. Без него
# следующий разбор снова придётся вести по косвенным признакам.
# shared_preload_libraries требует рестарта — поэтому именно здесь.
- -c
- shared_preload_libraries=pg_stat_statements
- -c
- pg_stat_statements.max=5000
- -c
- pg_stat_statements.track=top
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
# TRADEIN_PG_INITDB_DIR (#2989): на СВЕЖЕМ томе postgres прогоняет ВСЁ из
# этого каталога как initdb-миграции ДО восстановления дампа — 249 миграций
# отработают первыми и засеют данные (`scrape_schedules` 157 строк,
# `tradein_users` 13, `deals` 80 — `003_seed_deals`/`193_tradein_users_seed`),
# а дамп идёт с `--clean --if-exists` и ляжет поверх (см. репетицию переноса
# 2026-08-23, vault fixes/Bug_Migration_Window_Operational_Blockers_OPEN п.2).
# На первом старте нового хоста задать переменную на ПУСТОЙ каталог — схема
# тогда приезжает восстановлением дампа, а не initdb-цепочкой; после
# restore переменную снять, дальше работает как обычно.
# Дефолт = текущий прод-путь, поведение Beget не меняется.
- ${TRADEIN_PG_INITDB_DIR:-./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:
# Внешние оценки (Yandex/Cian) не ждать в запросе — догружать в фоне.
# Замер 2026-08-22: расчёт по новому адресу 8-12 с, из них ~6 с Yandex и
# ~1.5 с Cian, собственный расчёт < 1 с. Публикация в РБК 30.08 приведёт
# аудиторию на НОВЫЕ адреса, то есть мимо суточного кэша.
# Деградация источника в None — существующее состояние ответа (так же
# ведёт себя таймаут), контракт API не меняется.
ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true"
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-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:-}"
# #2709. Дефолт в коде — redis://localhost:6379/0, и внутри контейнера это
# мгновенный ConnectionRefused (замер: 16.6 мс) → кэш не работал ни дня.
# ⚠️ ПОРЯДОК КРИТИЧЕН, и цена ошибки измерена тем же клиентом, что в
# app/services/cache.py (socket_timeout=2.0, socket_connect_timeout=2.0):
# localhost, отказ соединения 16.6 мс ← как было
# имя не резолвится (NXDOMAIN) 123.5 мс
# имя резолвится, но не отвечает 2003.7 мс ← вот эта яма
# get/set глотают исключение в logger.warning и возвращают промах, поэтому
# яма МОЛЧАЛИВАЯ: 2 с на GET + 2 с на SET на каждый запрос, без единой
# ошибки наверх. Отсюда правило: сначала связность, потом переменная.
# Здесь она задаётся только потому, что связность уже проверена на проде
# ДО этой строки (throwaway-redis на gendesign_shared, из tradein-backend:
# PING 19 мс, SET 0.35 мс, GET 0.31 мс) — см. описание PR.
# db1 — не 0 и не 2: 0 занят celery-брокером gendesign, 2 — glitchtip'ом.
REDIS_URL: "redis://gendesign-redis:6379/1"
# Proxy-pool и здесь, а не только у scraper (issue «tradein-backend без
# USE_PROXY_POOL_*»): без флагов процесс backend игнорирует переданный
# proxy_provider и уходит на env-фолбэк SCRAPER_PROXY_URL — на проде это
# выключенный узел, т.е. гарантированный отказ (camoufox InvalidIP), а не
# деградация. У scraper те же значения с #2126/#2160.
USE_PROXY_POOL_CURL: "true"
USE_PROXY_POOL_BROWSER: "true"
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}@tradein-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"
# Транспорт detail_backfill: браузер, НЕ curl (замер 2026-08-21).
# Авито за QRATOR отдаёт JavaScript proof-of-work челлендж (startPow →
# кука pow_solved → self-reload). curl_cffi его не решает в принципе:
# прогоны 4348/4394/4508 дали 3-4 обогащённых из 46-53 попыток (~6%).
# Браузерный путь (camoufox + ожидание челленджа, #3045/#3046) на том же
# проде даёт ~74% (17/23 в свипе, 2/3 в точечной проверке).
# Исходное обоснование use_curl=True (browser открывает десятки коннектов
# и превышает cap прокси-аккаунта auv) больше не действует: браузер
# сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect
# SCRAPER_PROXY_URL, что и curl-путь.
# environment: перекрывает env_file — значение из runtime-env игнорируется.
AVITO_DETAIL_BACKFILL_USE_CURL: "false"
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}@tradein-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`, и оба подключены к общей сети gendesign_shared —
# изнутри фронта `backend` резолвится в ДВА адреса (tradein 172.18.0.6
# и ПТИЦА 172.18.0.9), клиент берёт любой. Попав в ПТИЦУ, SSR получает
# 401 и рендерит лендинг БЕЗ витрины — без ленты, строк сверки и
# подписи разброса, то есть страница про точность без доказательств.
# Отказ тихий: fetch не бросает, приходит валидный 401.
# `tradein-backend` — container_name, он однозначен.
BACKEND_URL: "http://tradein-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