fix(tradein/devops): проверка всего стека после деплоя + запас на остановку #2540

Merged
lekss361 merged 1 commit from fix/tradein-audit-devops into main 2026-07-26 22:57:42 +00:00
3 changed files with 147 additions and 0 deletions

View file

@ -592,6 +592,97 @@ jobs:
fi fi
echo "→ backend healthy на /health." 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 старых образов # Cleanup старых образов
for repo in ghcr.io/lekss361/gendesign-tradein-backend \ for repo in ghcr.io/lekss361/gendesign-tradein-backend \
ghcr.io/lekss361/gendesign-tradein-frontend; do ghcr.io/lekss361/gendesign-tradein-frontend; do

View file

@ -176,3 +176,31 @@
169_osm_poi_ekb_local.sql 169_osm_poi_ekb_local.sql
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql 170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
172_trade_in_leads.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 на каждую доп. параллельную страницу). # (грубо ~2.5g на каждую доп. параллельную страницу).
mem_limit: 2560m mem_limit: 2560m
memswap_limit: 3g 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 logging: *default-logging
env_file: env_file:
- path: ./backend/.env.runtime - path: ./backend/.env.runtime
@ -97,6 +111,20 @@ services:
# наложение export + бэкфилл при 640m было бы впритык) # наложение export + бэкфилл при 640m было бы впритык)
mem_limit: 768m mem_limit: 768m
memswap_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 logging: *default-logging
# Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload, # Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск # где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск