gendesign/tradein-mvp/deploy/backup-tradein-db.sh
bot-backend 770492b8b3 fix(ops): бэкап не теряет роли, не глотает ошибки и умеет уезжать с машины (#2203)
- ops/backup.sh и tradein-mvp/deploy/backup-tradein-db.sh теперь дампят
  globals (pg_dumpall --globals-only) отдельным файлом с той же ретенцией
  и той же S3-выгрузкой — pg_dump по определению не включает роли/GRANT.
- Обе выгрузки проходят gzip -t + проверку трейлера дампа перед тем как
  считаться успешными; при провале файл удаляется, ретенция не трогается,
  выход ненулевой.
- Убран 2>/dev/null у pg_dump в обоих скриптах — ошибка дампа теперь
  видна в логе, а не глотается молча.
- tradein-backup.sh получил S3-выгрузку (по образцу ops/backup.sh, те же
  4 переменные, тот же способ через aws-cli контейнер) и env-переопределяемый
  порог минимального размера дампа; источник переменных —
  /etc/default/tradein-backup с фолбэком на /etc/default/gendesign-backup.
- Новый ops/restore-drill.sh — учебное восстановление в одноразовый
  postgis-контейнер без прод-томов, никогда не трогает боевую БД (в отличие
  от ops/restore.sh, который восстанавливает В БОЕВУЮ базу).
2026-08-20 22:13:58 +03:00

151 lines
7.6 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 UTC):
# 30 4 * * * /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 — переменные в /etc/default/tradein-backup
# (root-owned, chmod 600, НЕ в git); если файла нет — фолбэк на
# /etc/default/gendesign-backup (общие креды с основным бэкапом). Без
# переменных поведение прежнее: дамп остаётся только локально.
# S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
# S3_BUCKET=gendesign-backups
# S3_ACCESS_KEY=...
# S3_SECRET_KEY=...
set -euo pipefail
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 — пустая/битая
# схема заведомо меньше, живая
# БД — на порядки больше.
# 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
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# Проверка целостности сверх «не пустой»: битый посреди записи дамп (диск
# кончился, 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")"
docker run --rm \
-e AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
-e AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
-v "$BACKUP_DIR":/backup:ro \
amazon/aws-cli:latest \
--endpoint-url "$S3_ENDPOINT" \
s3 cp "/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 ' ')
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] backup ok: $out ($size), копий хранится: $count данных + $globals_count globals"