gendesign/tradein-mvp/deploy/backup-tradein-db.sh
lekss361 2feb0de446
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 2s
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 45s
Deploy / deploy (push) Successful in 1m45s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m35s
Deploy Trade-In / test (push) Successful in 3m53s
Deploy Trade-In / build-backend (push) Successful in 34s
Deploy Trade-In / deploy (push) Successful in 2m21s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
fix(ops): бэкап без выгрузки в S3 падает, а не рапортует успех (#3086)
2026-08-24 17:50:18 +00:00

199 lines
12 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Бэкап БД tradein-postgres — pg_dump по cron + retention (#397).
# Hardening для #2203: globals, integrity-check, S3-выгрузка.
#
# Запускается из cron на прод-хосте. pg_dump идёт через `docker exec`
# (локальный сокет внутри контейнера — пароль не нужен).
#
# Crontab (ежедневно в 04:30 по времени хоста = 01:30 UTC; на проде именно так, #3005).
# Вызывать через `bash <path>`, а не сырым путём — тогда потеря бита +x не сломает
# бэкап молча (инцидент #71 у основного бэкапа):
# 30 4 * * * bash /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /tmp/tradein-backup.log 2>&1
#
# Restore:
# gunzip -c tradein-YYYYMMDD-HHMMSS.sql.gz | \
# docker exec -i tradein-postgres psql -U tradein -d tradein
# Restore-дрель (безопасно, не трогает прод): ops/restore-drill.sh.
#
# Выгрузка в 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=gendsgn-backups
# S3_ACCESS_KEY=...
# S3_SECRET_KEY=...
#
# Детект пропущенного запуска (#2203): по успеху пишется sentinel-файл
# (SENTINEL_FILE) — отдельный cron-запуск ops/check-backup-staleness.sh
# следит за его возрастом и шлёт алерт, если он не свежел (см. заголовок
# того скрипта — канал алертов и cron-строка). Сам этот скрипт не алертит,
# только отмечает успех.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../ops/lib-backup.sh
source "$SCRIPT_DIR/../../ops/lib-backup.sh"
BACKUP_DIR="${BACKUP_DIR:-/opt/gendesign/backups/tradein}"
KEEP="${KEEP:-7}" # сколько копий хранить
PG_CONTAINER="${PG_CONTAINER:-tradein-postgres}"
MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-10240}" # 10 KiB floor — пустая/битая
# схема заведомо меньше, живая
# БД — на порядки больше.
SENTINEL_FILE="${SENTINEL_FILE:-${BACKUP_DIR}/.last_success}"
# S3-переменные: свой env-файл, а если его нет — общий с main-бэкапом.
if [[ -f /etc/default/tradein-backup ]]; then
source /etc/default/tradein-backup
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 это не ловят. Две проверки,
# сначала дешёвая:
# 1. gzip -t — ловит битый/оборванный gzip-поток как таковой.
# 2. trailer — pg_dump/pg_dumpall всегда пишут фиксированный комментарий
# «я дописал до конца» последней строкой потока; при обрыве записи
# трейлера не будет, даже если gzip-обёртка выглядит нормально.
# Проверяется через `gunzip -c | tail -N`, чтобы не распаковывать весь дамп
# на диск ради последних строк.
verify_dump_integrity() {
local file="$1" trailer="$2" label="$3"
if ! gzip -t "$file" 2>/dev/null; then
log "ОШИБКА: ${label} не прошёл проверку gzip -> $file" >&2
return 1
fi
if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then
log "ОШИБКА: ${label} без трейлера '${trailer}' -> $file (дамп оборван?)" >&2
return 1
fi
return 0
}
mkdir -p "$BACKUP_DIR"
ts=$(date -u +'%Y%m%d-%H%M%S')
out="$BACKUP_DIR/tradein-$ts.sql.gz"
globals_out="$BACKUP_DIR/tradein-globals-$ts.sql.gz"
# --clean --if-exists → дамп самодостаточен для восстановления «с нуля».
# --no-owner → restore не требует тех же ролей.
# Stderr больше не глотается (было `2>/dev/null` до #2203) — ошибка pg_dump
# должна попасть в лог, а не исчезнуть молча. `set -o pipefail` уже роняет
# весь пайп при ошибке pg_dump, так что поведение при сбое не меняется —
# меняется только видимость причины.
docker exec "$PG_CONTAINER" pg_dump -U tradein -d tradein \
--no-owner --clean --if-exists | gzip -6 > "$out"
if [[ ! -s "$out" ]]; then
log "ОШИБКА: дамп пустой — $out" >&2
rm -f "$out"
exit 1
fi
dump_bytes=$(wc -c < "$out" | tr -d ' ')
if (( dump_bytes < MIN_DUMP_BYTES )); then
log "ОШИБКА: дамп всего ${dump_bytes} байт (< ${MIN_DUMP_BYTES} floor) — похоже на неудачный дамп." >&2
log "Удаляю подозрительный дамп, старые хорошие копии не трогаю." >&2
rm -f "$out"
exit 1
fi
if ! verify_dump_integrity "$out" "-- PostgreSQL database dump complete" "основной дамп"; then
log "Удаляю подозрительный дамп, старые хорошие копии не трогаю." >&2
rm -f "$out"
exit 1
fi
log "Дамп ok: $out ($(du -h "$out" | cut -f1), ${dump_bytes} байт)"
# --- globals (роли/GRANT) — pg_dump их не включает по определению; без
# этого восстановленная база остаётся без владеющих ролей/привилегий. ---
log "Дамплю globals (роли/GRANT) -> $globals_out"
docker exec "$PG_CONTAINER" pg_dumpall -U tradein --globals-only \
| gzip -6 > "$globals_out"
if [[ ! -s "$globals_out" ]]; then
log "ОШИБКА: дамп globals пустой — $globals_out" >&2
rm -f "$globals_out"
exit 1
fi
if ! verify_dump_integrity "$globals_out" "-- PostgreSQL database cluster dump complete" "globals-дамп"; then
rm -f "$globals_out"
exit 1
fi
log "Globals ok: $globals_out ($(du -h "$globals_out" | cut -f1))"
# --- опциональная выгрузка в S3 (только если заданы все четыре переменные) ---
if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" && -n "${S3_SECRET_KEY:-}" ]]; then
for f in "$out" "$globals_out"; do
log "Заливаю в s3://${S3_BUCKET}/$(basename "$f")"
# У aws-cli v2 свой CA-бандл (вшит в botocore), а не системное хранилище —
# в нём нет корня, которым подписан сертификат Selectel. Без переменной
# ниже выгрузка падает на CERTIFICATE_VERIFY_FAILED; системное хранилище
# контейнера этот корень содержит.
docker run --rm \
-e AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
-e AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
-e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
-v "$BACKUP_DIR":/backup:ro \
amazon/aws-cli:latest \
--endpoint-url "$S3_ENDPOINT" \
s3 cp --no-progress "/backup/$(basename "$f")" "s3://${S3_BUCKET}/"
done
log "Выгрузка в S3 ok"
else
log "S3-переменные не заданы — дамп остаётся только локально"
fi
# Retention — оставляем KEEP самых свежих, остальные удаляем. Основные дампы
# и globals-дампы — раздельные серии (иначе они смешаются в общей mtime-
# сортировке и retention посчитает их вместе).
ls -1t "$BACKUP_DIR"/tradein-[0-9]*.sql.gz 2>/dev/null \
| tail -n +"$((KEEP + 1))" \
| xargs -r rm -f
ls -1t "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null \
| tail -n +"$((KEEP + 1))" \
| xargs -r rm -f
size=$(du -h "$out" | cut -f1)
count=$(ls -1 "$BACKUP_DIR"/tradein-[0-9]*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
globals_count=$(ls -1 "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
log "backup ok: $out ($size), копий хранится: $count данных + $globals_count globals"
# Детект пропущенного запуска (#2203): отмечаем успех только тут, после всех
# проверок выше (и — под `set -euo pipefail` — после S3-выгрузки, если она
# включена). См. ops/check-backup-staleness.sh.
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