# 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 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" 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= → сервер слушает # только свой 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