All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
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 / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s
Замер на проде 2026-08-21: Авито за QRATOR отдаёт JavaScript proof-of-work
челлендж (startPow → кука pow_solved → self-reload через window.location).
curl_cffi не исполняет JS и пройти его не может в принципе.
Что это давало в цифрах (counters прогонов avito_detail_backfill):
run 4508: enriched 3 / attempted 46
run 4394: enriched 2 / attempted 47
run 4348: enriched 4 / attempted 53
~6% успеха. Браузерный путь на том же проде — ~74% (17/23 в инлайн-обогащении
свипа, 2/3 в точечной проверке после #3048). То есть задание работало почти
вхолостую, а поля дома, которые чинил #3048, до карточек просто не доходили.
Исходное обоснование AVITO_DETAIL_BACKFILL_USE_CURL=true (browser открывает
десятки коннектов и превышает cap прокси-аккаунта auv) больше не действует:
браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect
SCRAPER_PROXY_URL, что и curl-путь.
Переключение сделано в docker-compose.prod.yml (environment перекрывает
env_file), а не правкой рантайм-env на хосте — чтобы изменение осталось в
репозитории и пережило deploy без дрейфа конфигурации. Дефолт в config.py
оставлен True: другие окружения не трогаем вслепую, но устаревшее обоснование
там помечено.
438 lines
30 KiB
YAML
438 lines
30 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:
|
||
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
|