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
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:
parent
a0647a53a9
commit
3768aa2b5d
3 changed files with 147 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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-наблюдатель + риск
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue