fix(tradein/ops): health-check валит деплой + mem-лимиты + log-rotation (#2214) (#2231)
All checks were successful
Deploy Trade-In / build-browser (push) Successful in 32s
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Successful in 34s
Deploy Trade-In / test (push) Successful in 1m40s
Deploy Trade-In / build-backend (push) Successful in 26s
Deploy Trade-In / deploy (push) Successful in 45s

This commit is contained in:
lekss361 2026-07-02 21:29:30 +00:00
parent 9f28092356
commit 8ea52b9b7a
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 \ docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile || true 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 for i in $(seq 1 30); do
docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \ if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://localhost:8000/health && break exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
sleep 1 sleep 1
done done
if [ -z "$healthy" ]; then
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ backend healthy на /health."
# Cleanup старых образов # Cleanup старых образов
for repo in ghcr.io/lekss361/gendesign-tradein-backend \ for repo in ghcr.io/lekss361/gendesign-tradein-backend \

View file

@ -11,14 +11,40 @@
# - Backend/frontend подключены к network gendesign_shared чтобы основной # - Backend/frontend подключены к network gendesign_shared чтобы основной
# Caddy мог проксировать /trade-in/* → tradein-frontend # Caddy мог проксировать /trade-in/* → tradein-frontend
# - Frontend строится с basePath=/trade-in (см. next.config.ts) # - 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: services:
browser: browser:
image: ghcr.io/lekss361/gendesign-tradein-browser:${IMAGE_TAG:-latest} image: ghcr.io/lekss361/gendesign-tradein-browser:${IMAGE_TAG:-latest}
container_name: tradein-browser container_name: tradein-browser
# 5g: N параллельных headless-Firefox страниц (BROWSER_CONCURRENCY, default 4) # mem: каждая headless-Firefox страница ~0.8-1.5G. Прод-замер 2026-07-03:
# — каждая ~0.8-1.5GB. VPS имеет ~6.9G свободно. Было 2.5g под 1-за-раз (#884/#905). # BROWSER_CONCURRENCY=1 (ops выставил в .env.runtime), idle 29MiB. 2.5g —
mem_limit: 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: env_file:
- path: ./backend/.env.runtime - path: ./backend/.env.runtime
required: false required: false
@ -36,6 +62,14 @@ services:
postgres: postgres:
image: postgis/postgis:16-3.4 image: postgis/postgis:16-3.4
container_name: tradein-postgres 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: environment:
POSTGRES_DB: tradein POSTGRES_DB: tradein
POSTGRES_USER: ${TRADEIN_POSTGRES_USER:-tradein} POSTGRES_USER: ${TRADEIN_POSTGRES_USER:-tradein}
@ -56,6 +90,14 @@ services:
backend: backend:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest} image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-backend 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, # Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск # где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск
# reload в середине запроса. Single worker сохраняется для предсказуемости # reload в середине запроса. Single worker сохраняется для предсказуемости
@ -118,6 +160,12 @@ services:
scraper: scraper:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest} image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-scraper 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"] command: ["python", "-m", "app.scheduler_main"]
env_file: env_file:
- path: ./backend/.env.runtime - path: ./backend/.env.runtime
@ -146,6 +194,11 @@ services:
frontend: frontend:
image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest} image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest}
container_name: tradein-frontend 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: environment:
NODE_ENV: production NODE_ENV: production
# Next.js standalone server.js читает HOSTNAME для bind-адреса. # Next.js standalone server.js читает HOSTNAME для bind-адреса.