All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 / 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 4m42s
Замер на проде 2026-08-22, разбивка одного расчёта по логам:
10.659 старт
10.827 дом найден 0.17 с
11.565 аналоги, 49 кандидатов 0.74 с
11.579 ДКП-коридор 0.01 с
17.576 yandex_valuation ← 6.0 с
19.159 cian_valuation ← 1.5 с
19.252 готово итого 8.6 с
Семь с половиной секунд из восьми с половиной — ожидание чужих HTTP. Наша база
и сам расчёт укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с,
по новому — 8-12.4 с. Геокодинг ни при чём: с готовыми координатами те же 7.5-10.8.
Публикация в РБК 30.08 приведёт аудиторию на НОВЫЕ адреса, то есть мимо кэша.
Масштабирование контейнеров тут не помогает: время уходит на ожидание чужого
ответа, а не на наши вычисления.
Что сделано: у обоих источников появился режим «только кэш» (fetch_on_miss).
При включённом ESTIMATE_EXTERNAL_SOURCES_BACKGROUND запрос делает лишь чтение
кэша (локальный запрос на миллисекунды), а свежая загрузка уходит в фон и
наполняет кэш к следующему обращению по тому же адресу.
Почему это безопасно: деградация источника в None — НЕ новое состояние ответа.
Ровно так же ведёт себя таймаут estimate_*_valuation_timeout_s, и этот путь
работает в проде сегодня. Контракт API не меняется.
Фоновая задача берёт СВОЮ сессию: сессия запроса закрывается вместе с ответом,
а обе функции источников делают внутри себя db.commit() — переиспользование
чужой сессии зафиксировало бы её незавершённую работу. По той же причине
отвергнут наивный asyncio.gather двух источников на одной сессии.
Очередь догрузки ограничена восемью задачами. Без потолка всплеск по новым
адресам — ровно тот случай, ради которого режим и сделан — породил бы сотни
параллельных задач с сессиями и HTTP-клиентами при max_connections 100 и
mem_limit 768m у backend, то есть отказ вместо ускорения.
Дефолт в коде False: поведение других окружений не меняется. На проде режим
включён через docker-compose.prod.yml у сервиса backend.
Отдельно НЕ сделано, хотя предлагалось: снижение таймаутов до 4 с. Замер
показал, что свежий запрос к Яндексу занимает 6 с — таймаут 4 обрывал бы его
почти всегда, кэш бы не наполнялся, и источник оказался бы тихо отключён.
Таймаут здесь страховка от патологии, а не регулятор задержки.
Тесты: 7 штук на режим «только кэш», собственную сессию, гашение ошибок,
удержание ссылки на задачу и потолок очереди. Фальсифицированы — на неизменённом
коде падают 5 из 7 (проходит только сторож неизменности дефолта). Смежные
тесты оценщика (29 штук: бюджет ЦИАН, клиентские координаты, аудит) зелёные.
445 lines
31 KiB
YAML
445 lines
31 KiB
YAML
# 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)
|
||
mem_limit: 3g
|
||
memswap_limit: 3g
|
||
# ⚠️ При переезде на выделенный сервер (#2989) mem_limit поднимать ДО правки
|
||
# shared_buffers, а не после: лимит контейнера срабатывает РАНЬШЕ postgresql.conf,
|
||
# и shared_buffers=16GB при mem_limit=3g даёт OOM-kill на старте. Целевое на
|
||
# 64 ГБ — 40-48g, а не 64g: нужен запас под page cache ВНЕ контейнера.
|
||
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.
|
||
# После переезда пересчитать под 64 ГБ.
|
||
command:
|
||
- postgres
|
||
# Память. 768MB shared_buffers — 6× от стоковых 128MB, с запасом внутри 3g
|
||
# (idle-замер контейнера был 292 МБ, work_mem-спайки и autovacuum сверху).
|
||
- -c
|
||
- shared_buffers=768MB
|
||
# effective_cache_size — подсказка планировщику, НЕ аллокация. На хосте
|
||
# MemAvailable 6,1 ГБ, поэтому 6GB честно отражает доступный page cache.
|
||
- -c
|
||
- effective_cache_size=6GB
|
||
- -c
|
||
- work_mem=16MB
|
||
# maintenance_work_mem: autovacuum по listings идёт 193 раза в сутки,
|
||
# с 64MB каждый проход перечитывает индексы лишними итерациями.
|
||
- -c
|
||
- 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=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
|
||
- ./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}@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"
|
||
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"
|
||
# Транспорт 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}@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
|