#!/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 `, а не сырым путём — тогда потеря бита +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=... # # Детект пропущенного запуска (#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 # Проверка целостности сверх «не пустой»: битый посреди записи дамп (диск # кончился, 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. write_sentinel "$SENTINEL_FILE"