gendesign/tradein-mvp/deploy/backup-tradein-db.sh
bot-backend c7263b4088
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m57s
CI / backend-tests (pull_request) Successful in 18m1s
fix(ops): бэкапы — +x на deploy-скриптах при деплое tradein, тихий s3 cp (#3005)
Почему: cron на проде звал оба бэкап-скрипта сырым путём (и tradein, и —
вопреки комментарию в deploy.yml — основной ops/backup.sh), то есть
держался только на git-режиме файла; потеря бита = молчаливый отказ
бэкапа, как в #71. Cron-строки переведены на `bash <path>` на самом
хосте (21.08, копия старого crontab ~/crontab.bak.20260821); здесь —
вторая линия защиты по образцу deploy.yml:541 и честная шапка скрипта.

Попутно: `aws s3 cp` без --no-progress пишет в лог по ~300 строк
прогресса за каждый прогон (/tmp/tradein-backup.log), за ними не видно
итоговых строк — убрано в обоих скриптах.

Refs #3005
2026-08-21 15:13:25 +05:00

158 lines
8.4 KiB
Bash
Executable file
Raw 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 — переменные в /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")"
# У 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 ' ')
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] backup ok: $out ($size), копий хранится: $count данных + $globals_count globals"