diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 8b8edc7a..a6ce13a3 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -1037,6 +1037,155 @@ jobs: fi echo "→ backend healthy на /health." + # ── Гейт деплоя (#3324) ──────────────────────────────────────────── + # ДО этого блока весь гейт ПТИЦЫ = один `curl backend /health` выше: + # worker/beat не проверялись вообще (у них и healthcheck'а в compose не + # было), у frontend была только TCP-проба внутри контейнера, которую + # деплой не читал. То есть crash-loop воркера, вставший beat и фронт, + # отдающий 500, уезжали ЗЕЛЁНЫМ деплоем. Дисциплина перенесена из + # deploy-tradein.yml (health каждого сервиса + HTTP фронта + сверка + # образов), сюда добавлено чтение docker-health, потому что у ПТИЦЫ + # пробы теперь описаны в compose. + # `up -d --wait` НЕ используется намеренно: подъём здесь разбит на + # несколько `up` (bulk без worker'а → guard #3029 → caddy → forwarder), + # и общий --wait ждал бы ещё и профильные/инфраструктурные сервисы, + # ломая порядок «миграции до подъёма кода». Читаем состояние явно. + # Все проверки выполняются ДО выхода (не падаем на первой) — один + # прогон обязан показать ВСЕ поломанные сервисы, а не первый по списку. + cid() { docker compose -p gendesign -f docker-compose.prod.yml ps -aq "$1" 2>/dev/null || true; } + + diagnose() { # $1 сервис, $2 id контейнера (может быть пустым) + local svc="$1" c="$2" + echo "── ДИАГНОЗ $svc ──" + if [ -z "$c" ]; then + echo " контейнера нет вообще (docker compose ps -aq $svc пусто)" + return 0 + fi + docker inspect -f ' state={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}{{end}} restarts={{.RestartCount}} exit_code={{.State.ExitCode}} image={{.Image}}' "$c" || true + echo " healthcheck log:" + docker inspect -f '{{json .State.Health}}' "$c" 2>/dev/null | head -c 2000 || true + echo "" + echo " последние 40 строк логов $svc:" + docker logs --tail 40 "$c" 2>&1 | sed 's/^/ /' || true + } + + wait_healthy() { # $1 сервис, $2 таймаут, с + local svc="$1" deadline="$2" c="" status="" alive="" waited=0 + while [ "$waited" -lt "$deadline" ]; do + c="$(cid "$svc")" + if [ -n "$c" ]; then + status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none:{{.State.Status}}{{end}}' "$c" 2>/dev/null || echo '')" + case "$status" in + healthy) + echo "→ $svc healthy (за ${waited}s)" + return 0 + ;; + none:running) + # Контейнер без healthcheck-конфига = создан ДО этой правки + # compose и в этом прогоне не пересоздавался (штатный случай — + # worker, пропущенный guard'ом #3029). Валить деплой за это + # нельзя, но и молчать нельзя: падаем на «стабильный running» + # (двойное чтение, как tgbot/scraper в deploy-tradein.yml). + sleep 3 + alive="$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo unknown)" + if [ "$alive" = "running" ]; then + echo "→ $svc: healthcheck не сконфигурирован (контейнер не пересоздавался), running стабилен" + return 0 + fi + ;; + esac + fi + waited=$((waited + 3)) + sleep 3 + done + echo "ERROR (#3324): $svc не стал healthy за ${deadline}s (последний статус: '${status:-<контейнера нет>}') — деплой FAILED" + diagnose "$svc" "$c" + return 1 + } + + health_rc=0 + for gate_svc in backend worker beat frontend; do + wait_healthy "$gate_svc" 240 || health_rc=$? + done + + # Фронт: HTTP-СТАТУС, а не только «порт слушает». Compose-проба фронта + # намеренно TCP-only (в node:alpine нет ни curl, ни wget), и она не + # отличает живой Next.js от процесса, отдающего 500 на каждый запрос. + # Тянем с хоста через опубликованный 127.0.0.1:3000. basePath у ПТИЦЫ + # нет (frontend/next.config.*), корень — настоящий маршрут приложения. + # Годным считаем 2xx/3xx: редирект middleware'а на логин — это живой + # роутинг, а не поломка (тот же критерий, что `curl -f` в tradein). + fe_rc=0 + fe_code=000 + for i in $(seq 1 5); do + fe_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://localhost:3000/ || echo 000)" + case "$fe_code" in + 2*|3*) break ;; + esac + sleep 3 + done + case "$fe_code" in + 2*|3*) echo "→ frontend отвечает HTTP $fe_code на /." ;; + *) + echo "ERROR (#3324): frontend на http://localhost:3000/ вернул '$fe_code' (000 = соединения нет) — деплой FAILED" + diagnose frontend "$(cid frontend)" + fe_rc=1 + ;; + esac + + # Сверка образов (приём #2679 из deploy-tradein.yml, адаптирован под + # ПТИЦУ). Здесь не одно «backend-семейство»: backend и beat бегут один + # образ gendesign-backend, worker и frontend — свои. Поэтому эталон не + # «образ backend'а», а то, что реально лежит локально под тегом + # $IMAGE_TAG после pull'а: контейнер, оставшийся на другом id, работает + # на старом коде при зелёном деплое. + # Гард свежести самого :latest в registry — отдельный шаг выше + # (scripts/check-latest-image-revision.sh, #2950); здесь проверяется + # следующее звено: доехал ли уже скачанный образ до контейнера. + check_image() { # $1 сервис, $2 репозиторий образа + local svc="$1" repo="$2" want run c + want="$(docker image inspect -f '{{.Id}}' "$repo:$IMAGE_TAG" 2>/dev/null || echo '')" + c="$(cid "$svc")" + run="$(docker inspect -f '{{.Image}}' "$c" 2>/dev/null || echo '')" + if [ -z "$want" ]; then + echo "ERROR (#3324): локально нет образа $repo:$IMAGE_TAG — сверять не с чем (pull не отработал?)" + return 1 + fi + if [ -z "$run" ]; then + echo "ERROR (#3324): контейнера сервиса $svc НЕТ — это не «отставший образ», а неполный стек" + return 1 + fi + if [ "$want" != "$run" ]; then + echo "ERROR (#3324): $svc ОТСТАЛ: работает на $run, а $repo:$IMAGE_TAG — это $want" + echo " лечение: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps $svc" + diagnose "$svc" "$c" + return 1 + fi + echo "→ $svc на свежем $repo:$IMAGE_TAG ($run)" + } + + image_rc=0 + check_image backend ghcr.io/lekss361/gendesign-backend || image_rc=$? + check_image beat ghcr.io/lekss361/gendesign-backend || image_rc=$? + check_image frontend ghcr.io/lekss361/gendesign-frontend || image_rc=$? + # worker сверяем ТОЛЬКО если этот прогон его пересоздавал: guard #3029 + # намеренно оставляет worker на старом образе, пока идёт живой прогон + # скрейпа, и это уже отражено WARNING'ом выше. Падать здесь означало бы + # красить деплой за штатное поведение guard'а. + case " $WORKER_SERVICES " in + *" worker "*) check_image worker ghcr.io/lekss361/gendesign-worker || image_rc=$? ;; + *) echo "→ сверка образа worker'а пропущена: guard #3029 не пересоздавал его в этом прогоне (см. WARNING выше)" ;; + esac + + # Явный rc: «зелёная сводка» ниже печатается ДО выхода, поэтому итог + # обязан быть числом в логе, а не выводом из отсутствия ERROR-строк. + gate_rc=0 + [ "$health_rc" = 0 ] || gate_rc=1 + [ "$fe_rc" = 0 ] || gate_rc=1 + [ "$image_rc" = 0 ] || gate_rc=1 + echo "Гейт деплоя (#3324): health_rc=$health_rc frontend_rc=$fe_rc image_rc=$image_rc → rc=$gate_rc" + exit "$gate_rc" + # Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:` # молча (result=skipped), когда build падает (например, битый blob в # buildcache роняет `docker/build-push-action` — до ретрая выше, #2841). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8f869960..76e9dd00 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -313,6 +313,25 @@ services: # /data/anton-sqlite/analysis.db). - /opt/gendesign/site-finder:/data/anton-sqlite:ro command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=8", "--queues=celery,scrape_kn,geo"] + # #3324: до этого у worker'а healthcheck'а не было ВООБЩЕ — контейнер в + # crash-loop'е (ImportError в новом коде, протухший uv.lock) уезжал зелёным + # деплоем: деплой смотрел только `curl backend /health`. + # Проба — `inspect ping` ИМЕННО В ЭТОТ узел (`-d celery@$(hostname)`, у нас + # nodename дефолтный: в command нет `-n`). Без `-d` ping вернул бы OK на + # ответ ЛЮБОГО воркера на брокере — мёртвый контейнер выглядел бы живым. + # Ответ на ping = жив parent-процесс и держится соединение с Redis, т.е. + # ровно та связность, без которой очереди не разбираются. `$$` — экранировка + # для compose (в контейнер уезжает литеральное `$(hostname)`). + # Интервал 60s (не 30s как у backend): каждая проба — отдельный запуск + # celery-CLI с импортом приложения, дешёвым его не назовёшь. + # start_period 90s: холодный старт worker'а с Chromium-образа заметно + # медленнее backend'а. 3 промаха подряд (~3 мин) → unhealthy. + healthcheck: + test: ["CMD-SHELL", "celery -A app.workers.celery_app inspect ping -d celery@$$(hostname) -t 10 >/dev/null 2>&1"] + interval: 60s + timeout: 30s + retries: 3 + start_period: 90s # #976 cross-DB ETL tradein→gendesign: worker запускает etl_newbuilding_crossload task, # которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared. # default — обязательно явно, иначе сервис выпадет из дефолтной сети. @@ -336,6 +355,25 @@ services: # хранит только last_run_at для periodic tasks — потеря на restart OK, # beat перестроит из `celery_app.conf.beat_schedule` на старте. command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"] + # #3324: beat тоже жил без healthcheck'а. На `inspect ping` beat НЕ отвечает + # (remote control — свойство воркера, не планировщика), поэтому проба другая: + # СВЕЖЕСТЬ shelve-файла расписания. Celery beat синкует его на диск не реже + # чем раз в `Scheduler.sync_every` = 180 с — но только когда в этом окне была + # отправлена задача. У нас в расписании есть поминутная (nspd-geo-zombie- + # cleanup, `* * * * *`) и двухминутная задачи, так что живой beat обновляет + # mtime примерно каждые 3 минуты, а вставший — не обновляет вовсе. Порог 10 + # минут = 3× запас к этой каденции. + # Почему glob `celerybeat-schedule*`: имя на диске зависит от того, какой + # backend выберет shelve/dbm в образе (gnu → тот же файл, dumb → .dat/.dir). + # Почему не `pgrep`/`true`: PID-1 процесс жив ровно пока жив контейнер — + # такая проба повторяет `State.Running` и не ловит подвисший планировщик. + # `grep -q .`: сам find возвращает 0 и когда не нашёл ничего. + healthcheck: + test: ["CMD-SHELL", "find /tmp -maxdepth 1 -name 'celerybeat-schedule*' -mmin -10 | grep -q ."] + interval: 60s + timeout: 10s + retries: 3 + start_period: 120s # ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ──────── # Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.124.37.140),