fix(ops): бэкап без выгрузки в S3 падает, а не рапортует успех
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

Проверка хостов за шесть дней до переезда показала, что на Poincare пустой
crontab: ни бэкапов, ни сторожей, ни конфигов. Сегодня это неважно — базы ещё
на Beget, где всё работает. Тридцатого августа они переезжают, и бэкапы там
просто не начнутся.

Хуже, что отказ был бы тихим. Выгрузка в S3 была опциональной: без четырёх
переменных скрипт писал "backup stays local only" и выходил с кодом ноль.
Не ошибка, не алерт — успешный прогон. Сторож свежести на новом хосте тоже
не установлен, так что промолчали бы оба.

Теперь отсутствие любой из S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY /
S3_SECRET_KEY роняет прогон с ненулевым кодом — до снятия дампа, потому что
незачем тратить время и место на дамп, которому некуда уехать. Образец
поведения взят из backup-forgejo.sh, где так было с самого начала.

BACKUP_ALLOW_LOCAL_ONLY=1 оставляет прежний путь для машины без ключей.

Ревью показало, что первая версия этой форточки возвращала ровно ту дыру,
которую чинит PR: локальный прогон всё равно писал сентинел и выходил нулём,
поэтому сторож свежести оставался зелёным, а единственным следом был WARNING
в логе, который никто не читает. Достаточно было раскомментировать одну
строку в /etc/default — и «настроено» выглядело бы неотличимо от рабочего.
Теперь такой прогон сентинел НЕ пишет и сразу зовёт notify().

Попутно найден живой баг: в lib-backup.sh внутри блока после ||
литеральное `\n` разбиралось шеллом как команда `n`, поэтому запасной канал
оповещения по почте не срабатывал никогда. То есть при недоступности Telegram
мы теряли уведомления молча — тот же класс тишины.

restore-drill.sh поставлен в расписание Poincare: он существовал, но не
запускался нигде, и восстановимость дампов автоматически не проверялась ни
разу. gzip -t и трейлер pg_dump говорят «файл не битый», а это не то же
самое, что «база поднимается». У самой дрели своего сторожа нет и MAILTO в
cron не задан, поэтому её падение теперь тоже уходит в notify(). Заодно
docker rm -f получил -v: регулярный запуск иначе оставлял бы dangling-том
размером с базу до воскресной чистки.

Имя бакета в примере исправлено на gendsgn-backups — на хостах настроен
именно он, forgejo пишет туда же под префиксом forgejo/.

Две находки ревью намеренно не закрыты. Фолбэк на общий конфиг в
tradein-скрипте остался elif: если появится /etc/default/tradein-backup без
S3-переменных, прогон упадёт вместо чтения общего файла — под новой политикой
это и есть желаемое поведение. Дрель проверяет только серию gendesign:
restore-drill.sh не умеет батч, а расширять его в этом PR значит смешивать
задачи.

Хостовая часть — установка crontab и конфига на Poincare — в PR не входит.
Конфиг уже положен вручную и выгрузка с Poincare проверена живым объектом;
crontab ставится в окно переезда, потому что продуктового postgres там пока
нет и ночные прогоны падали бы впустую.

Refs #3085, #3008
This commit is contained in:
bot-backend 2026-08-24 20:48:45 +03:00
parent 17d23cfaae
commit 1cb14c6a35
6 changed files with 122 additions and 18 deletions

View file

@ -24,10 +24,12 @@
# Usage (cron — note `bash`, not a bare path, so +x is irrelevant):
# 30 3 * * * bash /opt/gendesign/ops/backup.sh >> /var/log/gendesign-backup.log 2>&1
#
# Optional S3 upload — set these in /etc/default/gendesign-backup (root-owned,
# chmod 600, NOT in git). Without them, dumps stay local only:
# S3 upload — REQUIRED by default (#3085). Set these in /etc/default/gendesign-backup
# (root-owned, chmod 600, NOT in git). Without all four, the script fails
# LOUDLY before taking a dump — set BACKUP_ALLOW_LOCAL_ONLY=1 to explicitly
# accept a local-only backup instead (e.g. local testing):
# S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru # Selectel S3
# S3_BUCKET=gendesign-backups
# S3_BUCKET=gendsgn-backups
# S3_ACCESS_KEY=...
# S3_SECRET_KEY=...
# A redacted template lives at ops/gendesign-backup.default.example.
@ -64,6 +66,28 @@ SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}"
# Optional S3 env. Loaded from /etc/default/gendesign-backup if present.
[[ -f /etc/default/gendesign-backup ]] && source /etc/default/gendesign-backup
# --- guard: S3 upload is REQUIRED by default (#3085). A backup that only
# lives on the same disk as the DB it protects is not a backup once
# Poincare has no configured fallback host to copy from — before this
# guard, a missing/incomplete /etc/default/gendesign-backup made the
# script log "backup stays local only" and exit 0, indistinguishable
# from a real off-box backup in the cron log. Fail LOUD, before the dump
# is even taken (no point spending time/disk on a dump that can't leave
# the box). BACKUP_ALLOW_LOCAL_ONLY=1 is the explicit escape hatch for
# running on a box without S3 creds on purpose (e.g. local testing) —
# it still warns loudly so it can't be forgotten silently. ---
if [[ -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" || -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" ]]; then
if [[ "${BACKUP_ALLOW_LOCAL_ONLY:-0}" == "1" ]]; then
log "WARNING: S3 vars not fully set — BACKUP_ALLOW_LOCAL_ONLY=1, proceeding with a LOCAL-ONLY backup. This copy will NOT survive loss of this VM." >&2
BACKUP_LOCAL_ONLY_USED=1
else
log "ERROR: S3 upload is not configured — missing one or more of S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY." >&2
log "ERROR: fill them into /etc/default/gendesign-backup (see ops/gendesign-backup.default.example)," >&2
log "ERROR: or set BACKUP_ALLOW_LOCAL_ONLY=1 to explicitly accept a local-only backup." >&2
exit 1
fi
fi
# Integrity check beyond "non-empty"/"big enough": a dump truncated mid-write
# (disk full, OOM-kill, docker exec dropped) can still gzip into a structurally
# valid, non-tiny .gz — neither `-s` nor MIN_DUMP_BYTES catch that reliably.
@ -161,7 +185,8 @@ fi
log "Globals dump OK: ${globals_out} ($(du -h "$globals_out" | cut -f1))"
# --- optional S3 upload (only if all four vars present) — both the main dump
# --- S3 upload (only if all four vars present — guaranteed unless the
# BACKUP_ALLOW_LOCAL_ONLY escape hatch above was used) — both the main dump
# and the globals dump go up, same bucket, same layout. ---
if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" && -n "${S3_SECRET_KEY:-}" ]]; then
for f in "$out" "$globals_out"; do
@ -212,5 +237,16 @@ log "Backup done. Local dumps retained: ${#remaining[@]} data + ${#remaining_glo
# guard above (dump/globals integrity, retention, and — since this whole
# script runs under `set -euo pipefail` — any S3 upload) has passed. See
# ops/check-backup-staleness.sh for the piece that actually alerts if
# this stops being refreshed. ---
write_sentinel "$SENTINEL_FILE"
# this stops being refreshed. A LOCAL-ONLY run (BACKUP_ALLOW_LOCAL_ONLY=1
# escape hatch above) does NOT count as a verified backup for this
# purpose — writing the sentinel here would leave the staleness watchdog
# permanently green while the box has no off-box copy at all (#3085
# reopened via the escape hatch). Instead: skip the sentinel (watchdog
# alerts within max_age_hours) and notify() immediately so it isn't only
# a WARNING buried in a log nobody tails. ---
if [[ "${BACKUP_LOCAL_ONLY_USED:-0}" == "1" ]]; then
log "Skipping sentinel write — this was a LOCAL-ONLY backup (BACKUP_ALLOW_LOCAL_ONLY=1), not a verified off-box backup." >&2
notify "⚠️ gendesign main backup: LOCAL-ONLY run (S3 not configured, BACKUP_ALLOW_LOCAL_ONLY=1). Dump ${out} is NOT off-box. Sentinel NOT updated — staleness watchdog will alert if this isn't fixed."
else
write_sentinel "$SENTINEL_FILE"
fi

View file

@ -1,9 +1,20 @@
# crontab ПРИНИМАЮЩЕГО хоста (Selectel Poincare, 188.246.224.93) — после 30.08.
#
# Устанавливать СРАЗУ после того, как стек поднялся и смоук прошёл:
# mkdir -p /opt/gendesign/logs
# crontab /opt/gendesign/ops/crontab-poincare.cron
# crontab -l # сверить
#
# Перед установкой в /etc/default/ должны лежать (root-owned, chmod 600,
# НЕ в git, переносятся руками с предыдущего хоста или создаются заново):
# /etc/default/gendesign-backup — S3_ENDPOINT/S3_BUCKET/S3_ACCESS_KEY/S3_SECRET_KEY
# (см. ops/gendesign-backup.default.example)
# /etc/default/tradein-backup — свои S3_*, либо отсутствует (тогда
# backup-tradein-db.sh падает на общий
# /etc/default/gendesign-backup выше)
# Без них ops/backup.sh и backup-tradein-db.sh теперь падают громко (#3085),
# а не молча пишут локальный бэкап.
#
# ⚠️ САМЫЙ ОПАСНЫЙ ПРОПУСК ВСЕГО ПЕРЕЕЗДА. Если этот файл не установить,
# на новом хосте НЕ БУДЕТ НИ ОДНОГО БЭКАПА — ни tradein, ни gendesign. И узнать
# об этом будет неоткуда: сторож пропущенных прогонов (check-backup-staleness)
@ -17,8 +28,9 @@
# ── Бэкапы обоих кластеров ──────────────────────────────────────────────────
# Off-box выгрузка в S3 включается через /etc/default/tradein-backup и
# /etc/default/gendesign-backup — файлы НЕ переезжают сами (в git их нет и не
# должно быть), их нужно перенести руками. Без них дампы останутся локальными,
# то есть на том же диске, что и БД, — ровно то, из-за чего заводился #2203.
# должно быть), их нужно перенести руками. Без них оба скрипта падают ГРОМКО
# (exit 1, дамп не снимается вовсе) — см. #3085; они больше НЕ пишут молча
# локальный бэкап.
30 3 * * * bash /opt/gendesign/ops/backup.sh >> /opt/gendesign/logs/gendesign-backup.log 2>&1
30 4 * * * bash /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /opt/gendesign/logs/tradein-backup.log 2>&1
@ -28,6 +40,15 @@
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/.last_success 26 "gendesign main backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/tradein/.last_success 26 "tradein backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# ── Restore-дрель, раз в месяц (#3085) ───────────────────────────────────────
# Восстановимость дампа никогда не проверялась автоматически — только
# ручными прогонами. 1-е число месяца, 02:15 — до ночных бэкапов (03:30/04:30)
# и до обогащения/геокодинга (05:00-05:45), не пересекается ни с чем.
# Берёт САМЫЙ СВЕЖИЙ на тот момент main-дамп; ops/restore-drill.sh сам найдёт
# рядом лежащий globals-файл и сам снесёт свой временный контейнер — прод не
# трогает никогда (см. заголовок скрипта).
15 2 1 * * bash -c 'f=$(ls -t /opt/gendesign/backups/gendesign_[0-9]*.sql.gz 2>/dev/null | head -1); if [ -n "$f" ]; then bash /opt/gendesign/ops/restore-drill.sh "$f"; else echo "no gendesign dump found to drill"; exit 1; fi' >> /opt/gendesign/logs/restore-drill.log 2>&1
# ── Обогащение и геокодирование (ходят в tradein-backend) ───────────────────
0 5 * * * cd /opt/gendesign/tradein-mvp && docker exec tradein-backend python -m scripts.backfill_houses_dadata --limit 100 --priority both >> /opt/gendesign/logs/dadata-backfill.log 2>&1
30 5 * * * cd /opt/gendesign/tradein-mvp && docker exec tradein-backend python -m scripts.geocode_deals_from_houses --limit 50000 >> /opt/gendesign/logs/deals-geocode.log 2>&1

View file

@ -33,9 +33,11 @@
# Without these two set, ops/check-backup-staleness.sh still logs, just
# doesn't send a Telegram alert.
# --- S3 off-site upload (Selectel S3-compatible). All four required to enable. ---
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
# without them, backup.sh and backup-tradein-db.sh refuse to run unless
# BACKUP_ALLOW_LOCAL_ONLY=1 is also set (see below). ---
#S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
#S3_BUCKET=gendesign-backups
#S3_BUCKET=gendsgn-backups
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
@ -44,6 +46,9 @@
#TELEGRAM_CHAT_ID=123456789
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and
# accept a local-only backup (still logs a loud
# WARNING each run). Leave unset in prod.
#KEEP=7 # how many recent local dumps to retain (applies to both
# the main dump series and the globals dump series)
#MIN_DUMP_BYTES=51200 # sanity floor for the MAIN dump; a dump smaller than

View file

@ -51,7 +51,7 @@ notify() {
-d "disable_web_page_preview=true" \
--data-urlencode "text=${text}" \
>/dev/null 2>&1 \
|| { log "WARN: telegram sendMessage failed — пробую запасной канал"; \n notify_fallback_mail "$text"; }
|| { log "WARN: telegram sendMessage failed — пробую запасной канал"; notify_fallback_mail "$text"; }
}
# --- запасной канал оповещения (#3059) ----------------------------------

View file

@ -25,6 +25,17 @@
set -euo pipefail
# notify() on failure (reviewer finding): unlike backup.sh/backup-tradein-db.sh
# this drill has no sentinel/watchdog of its own — its cron entry has no
# MAILTO, so a non-zero exit previously vanished into restore-drill.log with
# nobody looking. Reuse the same Telegram/mail channel as the backups so an
# unrestorable dump is loud, not discovered during an actual incident. Wired
# into cleanup() below (not its own trap) — bash only honours the LAST trap
# registered for a given signal, and cleanup() already owns EXIT.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./lib-backup.sh
source "$SCRIPT_DIR/lib-backup.sh"
IMAGE="${RESTORE_DRILL_IMAGE:-postgis/postgis:16-3.4}"
READY_TIMEOUT="${RESTORE_DRILL_READY_TIMEOUT:-60}" # seconds to wait for postgres startup
DRILL_DB="drill"
@ -96,8 +107,12 @@ IFS=',' read -r -a TABLES <<< "$tables_csv"
# single-use, bound to 127.0.0.1, destroyed on exit. ---
CONTAINER="restore-drill-$$-$(date -u +%s)"
cleanup() {
local rc=$?
log "Cleaning up container ${CONTAINER}"
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker rm -f -v "$CONTAINER" >/dev/null 2>&1 || true
if [[ $rc -ne 0 ]]; then
notify "🔴 restore-drill FAILED (exit ${rc}) for ${DUMP_FILE:-<unset>} — dump may not be restorable. Check restore-drill.log on the VM." || true
fi
}
trap cleanup EXIT

View file

@ -15,12 +15,13 @@
# docker exec -i tradein-postgres psql -U tradein -d tradein
# Restore-дрель (безопасно, не трогает прод): ops/restore-drill.sh.
#
# Опциональная выгрузка в S3 — переменные в /etc/default/tradein-backup
# (root-owned, chmod 600, НЕ в git); если файла нет — фолбэк на
# /etc/default/gendesign-backup (общие креды с основным бэкапом). Без
# переменных поведение прежнее: дамп остаётся только локально.
# Выгрузка в S3 ОБЯЗАТЕЛЬНА по умолчанию (#3085) — переменные в
# /etc/default/tradein-backup (root-owned, chmod 600, НЕ в git); если файла
# нет — фолбэк на /etc/default/gendesign-backup (общие креды с основным
# бэкапом). Без всех четырёх переменных скрипт падает ДО снятия дампа —
# BACKUP_ALLOW_LOCAL_ONLY=1 явно разрешает прежнее локальное поведение.
# S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
# S3_BUCKET=gendesign-backups
# S3_BUCKET=gendsgn-backups
# S3_ACCESS_KEY=...
# S3_SECRET_KEY=...
#
@ -51,6 +52,27 @@ elif [[ -f /etc/default/gendesign-backup ]]; then
source /etc/default/gendesign-backup
fi
# --- guard: S3-выгрузка обязательна по умолчанию (#3085). Без неё дамп живёт
# только на том же диске, что и БД, — не бэкап, если хост потерян. До
# этой проверки отсутствие/неполнота env-файла давали в логе «дамп
# остаётся только локально» и код возврата 0, неотличимо от настоящего
# off-box бэкапа. Падаем ГРОМКО, до снятия дампа — незачем тратить время
# и место на дамп, которому всё равно некуда уехать. BACKUP_ALLOW_LOCAL_ONLY=1 —
# явная форточка (та же переменная, что у ops/backup.sh) для намеренного
# локального прогона; всё равно шумит предупреждением, чтобы это не
# забылось молча. ---
if [[ -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" || -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" ]]; then
if [[ "${BACKUP_ALLOW_LOCAL_ONLY:-0}" == "1" ]]; then
log "ПРЕДУПРЕЖДЕНИЕ: S3-переменные заданы не полностью — BACKUP_ALLOW_LOCAL_ONLY=1, продолжаю с ЛОКАЛЬНЫМ бэкапом. Эта копия НЕ переживёт потерю этой VM." >&2
BACKUP_LOCAL_ONLY_USED=1
else
log "ОШИБКА: выгрузка в S3 не настроена — не хватает одной или нескольких из S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY." >&2
log "ОШИБКА: заполни их в /etc/default/tradein-backup (или /etc/default/gendesign-backup) —" >&2
log "ОШИБКА: см. ops/gendesign-backup.default.example, — либо выстави BACKUP_ALLOW_LOCAL_ONLY=1, чтобы явно принять локальный бэкап." >&2
exit 1
fi
fi
# Проверка целостности сверх «не пустой»: битый посреди записи дамп (диск
# кончился, OOM-kill, оборвался docker exec) может дать структурно валидный,
# не крошечный .gz — ни `-s`, ни MIN_DUMP_BYTES это не ловят. Две проверки,
@ -169,4 +191,9 @@ log "backup ok: $out ($size), копий хранится: $count данных +
# Детект пропущенного запуска (#2203): отмечаем успех только тут, после всех
# проверок выше (и — под `set -euo pipefail` — после S3-выгрузки, если она
# включена). См. ops/check-backup-staleness.sh.
write_sentinel "$SENTINEL_FILE"
if [[ "${BACKUP_LOCAL_ONLY_USED:-0}" == "1" ]]; then
log "Sentinel не пишется — это ЛОКАЛЬНЫЙ прогон (BACKUP_ALLOW_LOCAL_ONLY=1), не подтверждённый off-box бэкап." >&2
notify "⚠️ tradein backup: ЛОКАЛЬНЫЙ прогон (S3 не настроен, BACKUP_ALLOW_LOCAL_ONLY=1). Дамп ${out} НЕ ушёл off-box. Sentinel не обновлён — сторож свежести подаст сигнал, если это не поправить."
else
write_sentinel "$SENTINEL_FILE"
fi