fix(tradein/ops): health-check валит деплой + mem-лимиты + log-rotation (#2214)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
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

- deploy-tradein.yml: health-цикл через флаг; 30 фейлов подряд → exit 1 ДО
  записи success-маркера .tradein-deployed-sha (мёртвый backend больше не
  помечается задеплоенным; changes-job следующего прогона берёт верную базу)
- docker-compose.prod.yml: mem_limit/memswap_limit всем 5 сервисам от
  прод-замеров (postgres 3g, browser 2560m/3g swap-cushion, backend 768m,
  scraper 640m, frontend 384m; сумма потолков 6.4g на 12G-коробке);
  memswap==mem запрещает tradein-контейнерам свап (фикс swap-находки);
  restart: unless-stopped у всех → OOM-kill = авто-recovery
- log-rotation: json-file 20m×3 каждому сервису (YAML anchor)
- premise аудита «коробка 4GB» устарел: фактически 11.96G (замер в diff)

Первый деплой пересоздаст все tradein-контейнеры (включая scraper —
прервёт бегущий sweep, stop_grace_period 120s; допустимо разово).

Refs #2214
This commit is contained in:
bot-backend 2026-07-03 00:19:39 +03:00
parent 0793571c4a
commit c7eaecdb37
2 changed files with 75 additions and 6 deletions

View file

@ -479,12 +479,28 @@ jobs:
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile || true
# Health check
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (#2214).
# Раньше цикл после 30 неуспешных попыток молча продолжал скрипт и
# доходил до записи success-маркера → мёртвый backend помечался
# «задеплоено». Теперь: флаг healthy выставляется ТОЛЬКО при HTTP 200
# на /health; после цикла — hard exit 1, если флаг пуст. exit 1
# происходит ДО записи .tradein-deployed-sha (маркер пишется последним,
# ниже) → следующий прогон changes-job возьмёт корректную базу.
# NB set -e: curl стоит в условии `if` (exempt из errexit) — неуспешная
# попытка НЕ фатальна, а лишь провоцирует следующую итерацию цикла.
healthy=""
for i in $(seq 1 30); do
docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://localhost:8000/health && break
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ backend healthy на /health."
# Cleanup старых образов
for repo in ghcr.io/lekss361/gendesign-tradein-backend \

View file

@ -11,14 +11,40 @@
# - Backend/frontend подключены к network gendesign_shared чтобы основной
# Caddy мог проксировать /trade-in/* → tradein-frontend
# - Frontend строится с basePath=/trade-in (см. next.config.ts)
#
# ── Observability hardening (#2214) ───────────────────────────────────────────
# mem_limit/memswap_limit: до этого лимитов НЕ было — при runaway любой tradein-
# контейнер мог съесть весь хост (замер 2026-07-03: коробка 11.96G, ~1.7G в свопе).
# memswap_limit == mem_limit ⇒ контейнеру запрещён свап → tradein перестаёт
# докидывать в общий свап хоста (у browser оставлен небольшой swap-cushion).
# Лимиты — ПОТОЛКИ безопасности (×2-3 от фактического рабочего набора), а не
# ожидаемое потребление. Обоснование каждого — рядом с сервисом (замер docker stats).
# ⚠️ ПЕРВЫЙ деплой после мержа этого файла (infra-путь → compose up -d)
# ПЕРЕСОЗДАСТ ВСЕ tradein-контейнеры, включая scraper — прервёт бегущий sweep
# (stop_grace_period 120s даёт unit'у до-checkpoint'иться). Допустимо, разово.
#
# logging: json-file с ротацией (20m × 3 = ≤60M на сервис). До этого драйвер по
# умолчанию рос без границ. Общий anchor ниже.
x-logging: &default-logging
driver: json-file
options:
max-size: "20m"
max-file: "3"
services:
browser:
image: ghcr.io/lekss361/gendesign-tradein-browser:${IMAGE_TAG:-latest}
container_name: tradein-browser
# 5g: N параллельных headless-Firefox страниц (BROWSER_CONCURRENCY, default 4)
# — каждая ~0.8-1.5GB. VPS имеет ~6.9G свободно. Было 2.5g под 1-за-раз (#884/#905).
mem_limit: 5g
# mem: каждая headless-Firefox страница ~0.8-1.5G. Прод-замер 2026-07-03:
# BROWSER_CONCURRENCY=1 (ops выставил в .env.runtime), idle 29MiB. 2.5g —
# исторически валидированный потолок для «1-за-раз» (#884/#905); было 5g под
# рассчёт на concurrency=4, но прод работает 1 → 5g избыточно. memswap +0.5g:
# единственный сервис со swap-cushion, чтобы не OOM-killнуть карточку mid-flight.
# ⚠️ Поднимаешь BROWSER_CONCURRENCY в .env.runtime — подними и mem_limit
# (грубо ~2.5g на каждую доп. параллельную страницу).
mem_limit: 2560m
memswap_limit: 3g
logging: *default-logging
env_file:
- path: ./backend/.env.runtime
required: false
@ -36,6 +62,14 @@ services:
postgres:
image: postgis/postgis:16-3.4
container_name: tradein-postgres
# mem: прод-замер 2026-07-03 = 656MiB. Самый большой относительный запас (×3):
# OOM-kill БД — худший исход (порванные коннекты, recovery). memswap==mem ⇒
# без свапа (shared_buffers не должны уходить в swap-thrash).
# ревью #2214: 3g (idle 656MiB — БД дороже всего при ложном OOM; work_mem-спайки
# тяжёлых PostGIS-сортировок бэктеста/эстиматора + autovacuum)
mem_limit: 3g
memswap_limit: 3g
logging: *default-logging
environment:
POSTGRES_DB: tradein
POSTGRES_USER: ${TRADEIN_POSTGRES_USER:-tradein}
@ -56,6 +90,14 @@ services:
backend:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-backend
# mem: прод-замер 2026-07-03 = 100MiB. 640m (×6) — запас под спайки экспорта
# (WeasyPrint PDF / ezdxf / openpyxl держат документ в памяти). memswap==mem ⇒
# без свапа (single-worker uvicorn, предсказуемый event-loop).
# ревью #2214: 768m (WeasyPrint PDF разово берёт 200-500MB поверх ~100MiB idle;
# наложение export + бэкфилл при 640m было бы впритык)
mem_limit: 768m
memswap_limit: 768m
logging: *default-logging
# Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск
# reload в середине запроса. Single worker сохраняется для предсказуемости
@ -112,6 +154,12 @@ services:
scraper:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-scraper
# mem: прод-замер 2026-07-03 = 11MiB idle. 640m (×~1.5 от backend-профиля) —
# тяжёлый парсинг делает browser-контейнер, здесь только оркестрация + JSON-
# payload'ы sweep'ов. memswap==mem ⇒ без свапа.
mem_limit: 640m
memswap_limit: 640m
logging: *default-logging
command: ["python", "-m", "app.scheduler_main"]
env_file:
- path: ./backend/.env.runtime
@ -140,6 +188,11 @@ services:
frontend:
image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest}
container_name: tradein-frontend
# mem: прод-замер 2026-07-03 = 32MiB. 384m (×12) — Next.js standalone SSR,
# лёгкий; потолок с большим запасом. memswap==mem ⇒ без свапа.
mem_limit: 384m
memswap_limit: 384m
logging: *default-logging
environment:
NODE_ENV: production
# Next.js standalone server.js читает HOSTNAME для bind-адреса.