#!/usr/bin/env bash # Резервные копии PostgreSQL (docs/deployment.md#бэкапы). Работает в контейнере backup (compose.yaml) и в CI; # подключение — стандартные PGHOST, PGUSER, PGPASSWORD, PGDATABASE. # backup.sh loop — каждый день в BACKUP_HOUR (UTC): копия и ротация; раз в неделю — проверка восстановления # backup.sh once — копия сейчас; печатает путь к файлу # backup.sh verify FILE — восстановить FILE во временную базу и сверить таблицы и число строк # backup.sh restore FILE — заменить рабочую базу копией (сервисы, пишущие в базу, должны быть остановлены); # прежняя база остаётся рядом как _before_restore_<время> set -euo pipefail DIR="${BACKUP_DIR:-/backups}" DB="${PGDATABASE:-pipu}" KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}" KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}" log() { echo "$(date -u +%FT%TZ) backup: $*" >&2; } # Число строк в каждой таблице базы $1: «таблицастрок», по алфавиту. counts() { psql -X -d "$1" -Atq -F $'\t' -c " SELECT table_name, (xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '')))[1]::text FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY 1" } rotate() { # shellcheck disable=SC2012 # имена файлов наши, без пробелов ls -1t "$DIR"/daily/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_DAILY + 1)) | xargs -r rm -f -- # shellcheck disable=SC2012 ls -1t "$DIR"/weekly/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_WEEKLY + 1)) | xargs -r rm -f -- find "$DIR/daily" "$DIR/weekly" -name '*.counts' | while read -r c; do [ -f "${c%.counts}.dump" ] || rm -f -- "$c" done } once() { mkdir -p "$DIR/daily" "$DIR/weekly" local name file name="pipu-$(date -u +%Y%m%d-%H%M%S)" file="$DIR/daily/$name.dump" pg_dump -Fc -d "$DB" -f "$file.part" pg_restore -l "$file.part" >/dev/null # оглавление читается — архив не обрезан counts "$DB" >"$file.counts" # для verify: что было в базе в момент копии mv "$file.part" "$file" # Еженедельная копия — первая за 7 дней (не зависит от того, в какой день сервер был выключен). if ! find "$DIR/weekly" -name 'pipu-*.dump' -mtime -7 | grep -q .; then cp "$file" "$DIR/weekly/$name.dump" cp "$file.counts" "$DIR/weekly/$name.dump.counts" fi rotate log "$file ($(du -h "$file" | cut -f1))" echo "$file" } verify() { local file="$1" tmpdb="${DB}_verify" got want [ -f "$file" ] || { log "нет файла $file"; return 1; } dropdb --if-exists "$tmpdb" createdb "$tmpdb" if ! pg_restore --no-owner --exit-on-error -d "$tmpdb" "$file"; then dropdb --if-exists "$tmpdb" log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file не восстанавливается" return 1 fi got="$(counts "$tmpdb")" dropdb "$tmpdb" want="$(cat "$file.counts" 2>/dev/null || counts "$DB")" # Таблица пропала или опустела — копия негодна. Расхождение в числе строк — рабочая база менялась во время # копии (предупреждение); VERIFY_EXACT=1 (CI, база в покое) — тоже ошибка. local bad=0 table n m while IFS=$'\t' read -r table n; do m="$(printf '%s\n' "$got" | awk -F'\t' -v t="$table" '$1 == t { print $2 }')" if [ -z "$m" ]; then log "в копии нет таблицы $table" bad=1 elif [ "$m" = 0 ] && [ "$n" != 0 ]; then log "в копии пуста таблица $table (было $n)" bad=1 elif [ "$m" != "$n" ]; then log "$table: $m строк в копии, $n при копировании" [ "${VERIFY_EXACT:-}" = 1 ] && bad=1 fi done <<<"$want" if [ "$bad" = 1 ]; then log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file" return 1 fi log "проверено: $file восстанавливается ($(printf '%s\n' "$got" | wc -l) таблиц)" date -u +%FT%TZ >"$DIR/.last-verify" } restore() { local file="$1" old verify "$file" old="${DB}_before_restore_$(date -u +%Y%m%d%H%M%S)" psql -X -d postgres -Atqc "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB' AND pid <> pg_backend_pid()" >/dev/null psql -X -d postgres -qc "ALTER DATABASE \"$DB\" RENAME TO \"$old\"" createdb "$DB" if ! pg_restore --no-owner --exit-on-error -d "$DB" "$file"; then dropdb "$DB" psql -X -d postgres -qc "ALTER DATABASE \"$old\" RENAME TO \"$DB\"" log "восстановление не удалось — рабочая база не тронута" return 1 fi log "база $DB восстановлена из $file; прежняя сохранена как $old (удалить: dropdb $old)" } loop() { local hour="${BACKUP_HOUR:-3}" now next file log "ежедневная копия в $hour:30 UTC, хранится $KEEP_DAILY ежедневных и $KEEP_WEEKLY еженедельных" while true; do now="$(date -u +%s)" next="$(date -u -d "today $hour:30" +%s)" [ "$next" -gt "$now" ] || next="$(date -u -d "tomorrow $hour:30" +%s)" sleep $((next - now)) if ! file="$(once)"; then log "КОПИЯ НЕ СДЕЛАНА" continue fi if [ -z "$(find "$DIR" -maxdepth 1 -name .last-verify -mtime -7)" ]; then verify "$file" || true fi done } case "${1:-}" in loop) loop ;; once) once ;; verify) verify "${2:?укажите файл копии}" ;; restore) restore "${2:?укажите файл копии}" ;; *) sed -n '2,9p' "$0" >&2 exit 2 ;; esac