From dacd298b21095d65dfe44bfc0ed8e7c5e52c9108 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 00:44:46 +0500 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20=D0=BF=D0=BE=D0=B4=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=20?= =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=AB=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=BE?= =?UTF-8?q?=D0=B9=20=E2=80=94=20=D0=BE=D0=BA=D0=BD=D0=BE=2030=E2=80=9390?= =?UTF-8?q?=20=D1=81=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публичный лендинг meraocenka.ru лежал 30–90 с на КАЖДОМ деплое (#3274). Причина не в скорости подмены контейнера: она стоит полсекунды. `docker compose up -d` со СПИСКОМ сервисов работает в две фазы — сначала create (старый контейнер каждого сервиса останавливается и УДАЛЯЕТСЯ, иначе занято container_name), потом start, в порядке зависимостей и с ожиданием их условий. Между фазами старого фронта уже нет, а новый ещё не запущен. Прод, 10.09, два деплоя подряд (docker inspect .Created/.StartedAt): пачка сервисов: tradein-backend создан 15:01:40 → запущен 15:02:10 (30 с), в логе Caddy три 503 на лендинге: 15:01:46/:52 и 15:02:06; ОДИН сервис: tradein-frontend создан 16:42:17.5 → запущен 16:42:18.0 (0,5 с), 503 в логе нет ни одного. Тот же двухфазный порядок воспроизведён на стенде (реальный образ фронта + Caddy 2): соседи создаются сразу, стартуют через 41 с. Поэтому frontend убран из общего `up -d $SERVICES` и пересоздаётся своей командой после пачки: в его графе один сервис, create и start идут подряд. Остаток ~0,5 с добирает ретрай подключения в Caddy — отдельным коммитом, он мержится своим путём (caddy_only → graceful reload, без пересборки). Проба для замера на живом деплое — scripts/probe-deploy-window.sh: считает коды и САМУЮ ДЛИННУЮ серию не-200 в секундах, 000 отдельной строкой (его даёт и отбой периметра, не только простой). Запускать НА хосте прода: с внешнего адреса частая серия сама ловит 30–50 % 000. Refs #3274 --- .forgejo/workflows/deploy-tradein.yml | 41 ++++++++++- scripts/probe-deploy-window.sh | 99 +++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100755 scripts/probe-deploy-window.sh diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 4cab5fa7..9282b3fa 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -1137,7 +1137,31 @@ jobs: # tradein-mvp/backend/** включает app/tgbot_main.py), никакого # in-flight state вроде scrape_runs → пересоздаётся безусловно вместе # с browser/backend/frontend, отдельного graceful-drain не требует. - SERVICES="browser backend frontend tgbot" + # + # ── FRONTEND ЗДЕСЬ НЕТ. ЭТО И ЕСТЬ ЛЕЧЕНИЕ #3274 ───────────────── + # Публичный лендинг лежал 30–90 с на КАЖДОМ деплое. Причина не в + # том, что подмена контейнера медленная — она занимает полсекунды. + # Причина в том, что `docker compose up -d` со СПИСКОМ сервисов + # работает в две фазы: сначала create (старый контейнер каждого + # сервиса останавливается и УДАЛЯЕТСЯ — иначе занято container_name), + # и только потом start, в порядке зависимостей и с ожиданием их + # условий. Между фазами фронта уже нет, а нового ещё нет. + # + # Замер на проде (docker inspect, 10.09, оба деплоя МЕРЫ): + # пачка сервисов: tradein-backend создан 15:01:40 → запущен + # 15:02:10 = 30 с (и 503 на лендинге в + # 15:01:46/15:01:52/15:02:06 — ровно окно); + # ОДИН сервис: tradein-frontend создан 16:42:17.5 → запущен + # 16:42:18.0 = 0,5 с, 503 в логе нет вообще. + # Тот же двухфазный порядок воспроизведён на стенде по меткам + # .Created/.StartedAt: соседи создаются сразу, стартуют через 41 с. + # + # Поэтому фронт пересоздаётся ОТДЕЛЬНОЙ командой ниже, после этой + # пачки: в его графе один сервис, фазы create и start идут подряд. + # Остаток (~0,5 с) добирает ретрай подключения в Caddy — см. + # снипет (tradein_frontend_retry) в caddy/sites/apps.caddy. + # Гейт на обе половины: scripts/check-frontend-swap-window.py. + SERVICES="browser backend tgbot" SCRAPER_STOP_TS="" scraper_stale="" if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then @@ -1233,6 +1257,21 @@ jobs: " || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper" fi + # (4b) Фронт — ОТДЕЛЬНОЙ командой, один сервис в графе (#3274). + # Обоснование и прод-замеры — у SERVICES выше. Здесь важен ПОРЯДОК: + # эта команда идёт ПОСЛЕ пачки (backend уже поднят — новый SSR сразу + # ходит в новый бэкенд) и ДО `caddy reload` ниже, чтобы reload, как + # и раньше, оставался последним касанием прокси. + # + # ЗАЧЕМ ЖДАТЬ ПОСЛЕ КОМАНДЫ. `up -d` возвращает управление, когда + # контейнер ЗАПУЩЕН, а не когда Next начал слушать (на проде между + # ними ~0,1 с, см. journald «Ready in 110ms», но это не гарантия). + # Полноценная проверка фронта — health-check ниже по файлу, он же + # валит деплой при неудаче; здесь только короткая пауза, чтобы + # ретрай Caddy (2 с) не пришёлся на ещё не слушающий порт. + docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps frontend + sleep 1 + # (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216). # История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД # миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с diff --git a/scripts/probe-deploy-window.sh b/scripts/probe-deploy-window.sh new file mode 100755 index 00000000..38095231 --- /dev/null +++ b/scripts/probe-deploy-window.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# Проба окна недоступности при деплое (#3274). +# +# ЗАЧЕМ. Статус джобы деплоя зелёный независимо от того, видел ли посетитель +# ошибку: подмена контейнера происходит ВНУТРИ успешного прогона. Единственный +# честный замер — непрерывный опрос публичного адреса во время деплоя и подсчёт +# САМОЙ ДЛИННОЙ СЕРИИ подряд идущих не-200. Приёмка #3274 сформулирована именно +# так (комментарий от 05.09): «серия не-2xx/000 на / в окне мержа < 2 с». +# +# ГДЕ ЗАПУСКАТЬ — НА САМОМ ХОСТЕ (ssh poincare), не с ноутбука. Боевой периметр +# отбивает частые серии с одного внешнего адреса: замер 11.09 дал 30–50 % +# `000` с внешнего IP при интервале 0 и ноль при 2 с, а с самого хоста — 0/10 +# без пауз. Иначе защита периметра читается как простой сервиса. +# +# ПОЧЕМУ 000 СЧИТАЕТСЯ ОТДЕЛЬНО. curl отдаёт `000`, когда HTTP-ответа не было +# вообще (TCP/TLS не встал, таймаут). Это может быть и простой (Caddy +# пересоздан), и отбой периметра — сваливать его в одну кучу с 502/503 нельзя, +# иначе своя же защита попадёт в числа простоя. +# +# Запуск: +# scripts/probe-deploy-window.sh [URL] [ИНТЕРВАЛ_С] [ДЛИТЕЛЬНОСТЬ_С] [ФАЙЛ] +# scripts/probe-deploy-window.sh --summary <ФАЙЛ> # пересчитать сводку +# scripts/probe-deploy-window.sh --selftest # проверка счётчика серий +# +# Пример (окно деплоя, 10 минут с шагом 200 мс): +# ssh poincare 'nohup /opt/gendesign/scripts/probe-deploy-window.sh \ +# https://meraocenka.ru/ 0.2 600 /tmp/probe-mera.tsv >/dev/null 2>&1 &' +set -eu + +# Сводка по TSV (epochISO-времякодсекунды): сколько чего, и главное — самая +# длинная серия подряд идущих не-200 в СЕКУНДАХ (по меткам времени самих +# образцов, а не «число образцов × интервал»: curl с таймаутом растягивает шаг, +# и умножение занизило бы реальную длину окна). +summarize() { + awk -F'\t' ' + # Серия закрывается первым 200. Длина в секундах считается ТОЛЬКО в END: + # средний шаг известен лишь после прохода, а внутри цикла он ещё 0 — из-за + # этого первая версия занижала окно ровно на один шаг (поймал --selftest). + { total++; code[$3]++ + if ($3 == "200") { + if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at } + streak = 0 + } else { + if (!streak) { first_bad_ts = $1; first_bad_at = $2 } + streak++; last_bad_ts = $1 + } + if (prev_ts && $1 - prev_ts < 60) { gaps += $1 - prev_ts; gapn++ } + prev_ts = $1 + } + END { + step = (gapn ? gaps / gapn : 0) + if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at } + printf "образцов: %d, шаг ~%.2f с\n", total, step + for (c in code) printf " %s: %d\n", c, code[c] + printf "максимальная серия не-200: %d образцов ≈ %.1f с (начало %s)\n", \ + max_n, (max_n ? max_last - max_first + step : 0), (max_at ? max_at : "-") + } + ' "$1" +} + +if [ "${1:-}" = "--summary" ]; then + summarize "$2" + exit 0 +fi + +if [ "${1:-}" = "--selftest" ]; then + tmp=$(mktemp) + # 10 образцов с шагом 1 с: три подряд не-200 (2-я…4-я секунды) → серия ≈ 3 с. + printf '1000\tT0\t200\t0.01\n1001\tT1\t502\t0.01\n1002\tT2\t000\t5.00\n1003\tT3\t503\t0.01\n1004\tT4\t200\t0.01\n1005\tT5\t200\t0.01\n1006\tT6\t502\t0.01\n1007\tT7\t200\t0.01\n' >"$tmp" + out=$(summarize "$tmp") + rm -f "$tmp" + echo "$out" + echo "$out" | grep -q "максимальная серия не-200: 3 образцов ≈ 3.0 с" || { + echo "SELFTEST FAILED: серия посчитана неверно" >&2 + exit 1 + } + echo "$out" | grep -q " 000: 1" || { echo "SELFTEST FAILED: 000 не выделен отдельно" >&2; exit 1; } + echo "SELFTEST OK" + exit 0 +fi + +URL=${1:-https://meraocenka.ru/} +INTERVAL=${2:-1} +DURATION=${3:-900} +OUT=${4:-/tmp/probe-deploy-window.tsv} + +: >"$OUT" +echo "проба: $URL каждые ${INTERVAL}с в течение ${DURATION}с → $OUT" +end=$(( $(date +%s) + DURATION )) +while [ "$(date +%s)" -lt "$end" ]; do + # --max-time 5: зависший запрос не должен растягивать шаг пробы на минуты. + # -o /dev/null: тело не нужно, важны код и время. + line=$(curl -sS -o /dev/null --max-time 5 -w '%{http_code}\t%{time_total}' "$URL" 2>/dev/null || echo "000 0") + printf '%s\t%s\t%s\n' "$(date +%s.%N)" "$(date -u +%H:%M:%SZ)" "$line" >>"$OUT" + sleep "$INTERVAL" +done + +echo "── сводка ─────────────────────────────────────────" +summarize "$OUT"