#!/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 ОБЯЗАТЕЛЬНА по умолчанию (#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