From 3768aa2b5d00410e019067cc13a5d1b06dec9fa5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 27 Jul 2026 00:34:38 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/devops):=20post-deploy=20health=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5=D0=B3=D0=BE=20=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D0=B0=20+=20grace=20period=20+=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. deploy-tradein.yml: health-check раньше щупал только backend — сломанный frontend (404 из-за basePath), упавший browser или crash-loop tgbot/scraper помечались успешным деплоем без отката. Добавлены: frontend (curl изнутри backend-контейнера на /trade-in/, basePath запечён в prod-образ), browser (/health, живёт даже без поднятых camoufox-инстансов), tgbot/scraper (docker inspect state=running с двойной проверкой — у них нет HTTP, bare python-процессы). Проверяются только сервисы, реально входящие в $SERVICES текущего деплоя. 2. docker-compose.prod.yml: backend (60s) и browser (90s) получили stop_grace_period — раньше дефолтные docker 10s SIGKILL'или PDF-экспорт (WeasyPrint write_pdf, sync route → Starlette threadpool, uvicorn timeout_graceful_shutdown=None по умолчанию — единственный backstop это Docker) и headless-навигацию browser (aiohttp shutdown_timeout=60s по умолчанию + время на закрытие Firefox-инстансов в _on_cleanup) прямо посреди работы. 3. _manifest_applied.txt: дописаны 173-186 (давно применены на проде, contract-тест их не защищал от rename/rm). 187/188 (веб-чат поддержки) НАМЕРЕННО не добавлены — прецедент в этом же репо (commit 5eadae1e, L5): миграция, добавленная в manifest ДО того как осела на проде в финальном виде, блокирует легитимный rename; фича ещё активно дорабатывается параллельно. --- .forgejo/workflows/deploy-tradein.yml | 91 +++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 28 ++++++ tradein-mvp/docker-compose.prod.yml | 28 ++++++ 3 files changed, 147 insertions(+) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 4ee694eb..d2425ca4 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -592,6 +592,97 @@ jobs: fi echo "→ backend healthy на /health." + # Frontend health check — раньше проверялся ТОЛЬКО backend: сломанный + # фронт (500/белый экран после build, или контейнер упавший на старте) + # помечался успешным деплоем, отката не происходило (см. заголовок + # секции выше). Проверяем изнутри backend-контейнера — он в одной + # tradein-net сети с frontend, и curl там уже есть (в отличие от + # node:alpine рантайм-образа frontend, где нет ни curl, ни wget — + # добавлять их туда ради healthcheck не стали, backend достаточно). + # Путь ОБЯЗАН включать /trade-in: basePath запечён в prod-образ на + # build (NEXT_PUBLIC_BASE_PATH=/trade-in, см. build-frontend job) — + # голый "/" внутри Next вернёт 404, а не что-то живое. "/trade-in/" + # редиректит (307) на /trade-in/v2 — curl -f не считает 3xx ошибкой, + # так что это чистая liveness-проверка (процесс жив и роутит), + # без привязки к тому, что именно сейчас показывает витрина. + frontend_healthy="" + for i in $(seq 1 30); do + if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \ + exec -T backend curl -fsS http://frontend:3000/trade-in/ >/dev/null 2>&1; then + frontend_healthy="yes"; break + fi + sleep 1 + done + if [ -z "$frontend_healthy" ]; then + echo "ERROR: frontend не ответил на /trade-in/ за 30s — деплой FAILED" + exit 1 + fi + echo "→ frontend healthy на /trade-in/." + + # Browser health check — /health в browser/server.py всегда 200, пока + # жив сам aiohttp-процесс (см. health_handler: "compose НЕ имеет + # healthcheck на browser, только depends_on: service_started" — до + # этой правки browser вообще не проверялся никаким деплой-шагом). + # Это liveness процесса, НЕ readiness camoufox-инстансов конкретных + # источников (те поднимаются лениво на первый /fetch) — но упавший + # при старте контейнер (например, битый образ) здесь ловится сразу, + # а не молча остаётся мёртвым до первого реального /fetch scraper'ом. + browser_healthy="" + for i in $(seq 1 30); do + if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \ + exec -T backend curl -fsS http://browser:3000/health >/dev/null 2>&1; then + browser_healthy="yes"; break + fi + sleep 1 + done + if [ -z "$browser_healthy" ]; then + echo "ERROR: browser не ответил на /health за 30s — деплой FAILED" + exit 1 + fi + echo "→ browser healthy на /health." + + # tgbot/scraper — те же backend-образ и Dockerfile, но bare python- + # процессы БЕЗ ASGI/HTTP-сервера (см. комментарии в tgbot_main.py / + # scheduler_main.py: "здесь нет ASGI-приложения"), поэтому HTTP- + # healthcheck для них невозможен в принципе. Liveness проверяем по + # состоянию контейнера через docker inspect: упавший на старте + # процесс (например, ImportError в новом коде) restart-policy + # unless-stopped уводит в бесконечный crash-loop — раньше это НИКАК + # не блокировало деплой (маркер писался, даже если tgbot/scraper + # были мертвы). Двойная проверка (running → пауза → снова running) + # снижает шанс поймать контейнер ровно в момент between-restarts + # промежуточного "running" внутри crash-loop. + # tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES); + # scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому + # проверяем только то, что реально входит в текущий $SERVICES. + for svc in tgbot scraper; do + case " $SERVICES " in + *" $svc "*) ;; + *) continue ;; + esac + container_ok="" + state="unknown" + for i in $(seq 1 15); do + state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown") + if [ "$state" = "running" ]; then + container_ok="yes"; break + fi + sleep 1 + done + if [ -n "$container_ok" ]; then + sleep 3 + state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown") + if [ "$state" != "running" ]; then + container_ok="" + fi + fi + if [ -z "$container_ok" ]; then + echo "ERROR: tradein-$svc не в стабильном состоянии running (state='$state') — деплой FAILED" + exit 1 + fi + echo "→ tradein-$svc running." + done + # Cleanup старых образов for repo in ghcr.io/lekss361/gendesign-tradein-backend \ ghcr.io/lekss361/gendesign-tradein-frontend; do diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 0d8889dc..7d8d5a0f 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -176,3 +176,31 @@ 169_osm_poi_ekb_local.sql 170_scrape_schedules_seed_osm_poi_ekb_refresh.sql 172_trade_in_leads.sql +173_scrape_proxies_add_domclick_affinity.sql +174_domclick_session_cookies.sql +175_scrape_schedules_seed_domclick_detail_backfill.sql +176_domrf_kapremont.sql +177_deals_city_region.sql +178_deal_city_price_bands.sql +179_scrape_schedules_seed_oblast_city_sweeps.sql +180_seed_sber_freshness_monitor.sql +181_clamp_bad_listing_dates.sql +182_trade_in_leads_consent_proof.sql +183_reenable_deactivate_stale_domklik.sql +184_user_events.sql +185_account_quota_overrides.sql +186_tg_support.sql +# +# 187_web_support_chat.sql / 188_tg_support_chat_id_scope.sql — НАМЕРЕННО НЕ +# добавлены (2026-07-27, devops-аудит). Прецедент из ЭТОГО же репо: +# commit 5eadae1e (fix(tradein/support): address deep-review ... L5) добавил +# и тут же убрал "187_web_support_chat.sql" из этого файла с формулировкой +# "keeping an unmerged migration name out of it preserves the option to +# rename before merge without tripping the "can't rename applied +# migrations" test". Обе миграции — часть веб-чата поддержки (#2532/#2533), +# который на момент этой правки ещё активно дорабатывается в параллельной +# сессии/окне (тот же фиче-набор, соседняя задача). Дописывать их сюда сейчас +# повторило бы именно ту ошибку, которую L5 исправил: заморозить имя файла +# ДО того как он гарантированно осел на проде в финальном виде. Когда фича +# стабилизируется и подтверждено, что 187/188 применены (_schema_migrations +# на проде) — дописать одной строкой в отдельном PR. diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 72dc4c8b..4d346c26 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -44,6 +44,20 @@ services: # (грубо ~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 @@ -97,6 +111,20 @@ services: # наложение 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-наблюдатель + риск