diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 0e86e40b..13b41fa9 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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 \ diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 88dadf38..a27b6b8e 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -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 сохраняется для предсказуемости @@ -118,6 +160,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 @@ -146,6 +194,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-адреса.