fix(tradein/deploy): не уходить в drain, когда пересоздавать нечего (#2679)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
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

`SCRAPER_RECREATE` истинно и на infra-правках (compose / workflow / deploy/**),
а те почти всегда собирают тот же образ по кэшу: digest не меняется, `up -d`
выходит no-op — и за него платили пятиминутным ожиданием слива scrape_runs,
прерывая сбор. Теперь после `docker compose pull` (порядок важен: до pull'а под
:latest ещё старый образ) сравниваем ID подтянутого образа с тем, на котором
бежит tradein-scraper. Совпало — печатаем «пересоздавать нечего» и идём дальше
без drain'а; не совпало или контейнера/тега нет — drain как раньше.

Заодно закрыт ложный startup-reap: чекпоинт и reap завязаны на тот же признак и
больше не выполняются, когда recreate'а не было. Иначе прогон, переживший
таймаут drain'а в НЕ пересозданном контейнере, помечался бы 'cancelled',
продолжая работать.

scraper остаётся в $SERVICES в обоих случаях — при совпавшем образе `up -d`
no-op, но правка самого compose (env/лимиты сервиса) так всё же доезжает.

Сверка образов: «контейнера нет» и «контейнер отстал» теперь разные сообщения —
это разные аварии и чинятся по-разному; отсутствие tradein-backend (эталона)
тоже отдельная строка.

Refs #2679
This commit is contained in:
bot-backend 2026-08-06 01:58:34 +05:00
parent 15ca70ad70
commit bb6e5c7e41

View file

@ -294,6 +294,9 @@ jobs:
uses: appleboy/ssh-action@v1.0.3
env:
IMAGE_TAG: latest
# Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не
# уходить в drain, когда пересоздавать нечего (см. ниже, #2679).
IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }}
GHCR_PAT: ${{ secrets.GHCR_PAT }}
# #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ
# gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса,
@ -316,7 +319,7 @@ jobs:
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }}
envs: IMAGE_TAG,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
script: |
set -euo pipefail
cd /opt/gendesign
@ -497,8 +500,42 @@ jobs:
# с browser/backend/frontend, отдельного graceful-drain не требует.
SERVICES="browser backend frontend tgbot"
SCRAPER_STOP_TS=""
scraper_stale=""
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
echo "→ backend-образ мог пересобраться — scraper пересоздаётся вместе с backend (#2679);"
# Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE
# истинно и на infra-правках (compose / workflow / deploy/**), а те
# почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется,
# `up -d` выходит no-op — и платить за него пятиминутным drain'ом,
# прерывая многочасовой сбор, не за что. Сравниваем, на том ли
# образе бежит scraper, что уже лежит в локальном демоне.
# ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) —
# до pull'а под тегом :latest ещё старый образ, сравнение всегда
# «совпало» и drain пропускался бы как раз тогда, когда он нужен.
# Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на
# ЭТОТ же признак и больше не выполняются, когда recreate'а не было
# (иначе живой прогон с heartbeat старше чекпоинта помечался бы
# 'cancelled', продолжая работать).
pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "")
running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "")
if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then
echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего,"
echo " drain пропускаем, in-flight прогоны не трогаем"
else
scraper_stale="yes"
fi
# scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d`
# — no-op, но правка самого compose (env/лимиты сервиса) так всё же
# доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а
# страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку
# прогона подчистит периодический 6h zombie-reaper.
SERVICES="$SERVICES scraper"
else
echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем"
echo " (сверка образов ниже всё равно проверит, что он не отстал)"
fi
if [ -n "$scraper_stale" ]; then
echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);"
echo " ждём слива in-flight scrape_runs (до 5 мин)"
drained=""
for i in $(seq 1 30); do
@ -536,16 +573,11 @@ jobs:
SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS=""
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
SERVICES="$SERVICES scraper"
else
echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем"
echo " (сверка образов ниже всё равно проверит, что он не отстал)"
fi
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
if [ "${SCRAPER_RECREATE:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
@ -707,20 +739,31 @@ jobs:
# узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт
# ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же
# базу и пересоберёт всё накопленное (тот же приём, что в health-check).
backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "missing")
# «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся
# по-разному, поэтому сообщения различаются явно.
backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "")
image_mismatch=""
if [ -z "$backend_image" ]; then
echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем."
image_mismatch="yes"
fi
for svc in scraper tgbot; do
svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "missing")
if [ "$svc_image" != "$backend_image" ]; then
echo "ERROR: tradein-$svc на образе $svc_image, tradein-backend — на $backend_image"
svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "")
if [ -z "$svc_image" ]; then
echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший"
echo " образ, а неполный стек: сервис не работает вообще."
image_mismatch="yes"
elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then
echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image"
image_mismatch="yes"
fi
done
if [ -n "$image_mismatch" ]; then
echo "ERROR: образы backend-семейства разошлись — деплой FAILED (#2679)."
echo " Лечение вручную: docker compose -p gendesign-tradein \\"
echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\"
echo " up -d --force-recreate --no-deps scraper tgbot"
echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)."
echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):"
echo " docker compose -p gendesign-tradein \\"
echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\"
echo " up -d --force-recreate --no-deps backend scraper tgbot"
exit 1
fi
echo "→ образы совпадают: backend/scraper/tgbot на $backend_image."