All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 13s
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 / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
aws-cli v2 (образ amazon/aws-cli:latest) использует собственный набор корневых сертификатов из botocore, а не системное хранилище контейнера. В нём нет корня, которым подписан сертификат Selectel S3 (s3.ru-1.storage.selcloud.ru), поэтому docker run падал на SSL validation failed / CERTIFICATE_VERIFY_FAILED: self-signed certificate in certificate chain — и ночной S3-upload не проходил каждый раз, хотя ключи и bucket были верны. Проверено на проде: без AWS_CA_BUNDLE — SSL validation failed; с AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt (системное хранилище контейнера, содержит нужный GlobalSign-корень) — upload 11 МБ проходит, код возврата 0, объект подтверждён чтением вторым ключом. Правка — одна переменная окружения в docker run в обоих скриптах: - ops/backup.sh (main DB backup) - tradein-mvp/deploy/backup-tradein-db.sh (tradein DB backup, #3004)
156 lines
8.1 KiB
Bash
Executable file
156 lines
8.1 KiB
Bash
Executable file
#!/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")"
|
||
# У 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 "/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"
|