# 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). # #3471: TELEGRAM_RELAY_BASE_URL/TELEGRAM_RELAY_SECRET (клиент резервного # ретранслятора tg-relay на инфраструктурном хосте) заводятся тем же # способом — строкой в backend/.env.runtime на ЭТОМ (продуктовом, Selectel) # хосте. deploy-tradein.yml секреты приложения в CI не инжектит вовсе — # только читает уже лежащий на хосте .env.runtime, поэтому Forgejo-секрет # TELEGRAM_RELAY_SECRET сюда сам по себе не долетит: файл на хосте нужно # дополнить вручную (см. README-АДМИНУ.md). 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= → сервер слушает # только свой 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