fix(tradein/devops): проверка всего стека после деплоя + запас на остановку (#2540)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 35s
Deploy Trade-In / build-frontend (push) Successful in 37s
Deploy Trade-In / test (push) Successful in 4m55s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 1m12s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 35s
Deploy Trade-In / build-frontend (push) Successful in 37s
Deploy Trade-In / test (push) Successful in 4m55s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 1m12s
This commit is contained in:
parent
a450aed71b
commit
bcb903cfa2
3 changed files with 147 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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-наблюдатель + риск
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue