fix(tradein/devops): post-deploy health для всего стека + grace period + manifest
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
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 5m13s

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; фича ещё активно дорабатывается
   параллельно.
This commit is contained in:
bot-backend 2026-07-27 00:34:38 +03:00
parent a0647a53a9
commit 3768aa2b5d
3 changed files with 147 additions and 0 deletions

View file

@ -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

View file

@ -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.

View file

@ -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-наблюдатель + риск