From aacf55679a61e999988820b99914b3a9757048ed Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:59:27 +0300 Subject: [PATCH] feat(deploy): nightly database backups with verified restore (M7) A backup container dumps the database every night, keeps 14 daily and 8 weekly copies and once a week restores a copy into a scratch database to check tables and row counts. update.sh takes a copy before updating, restore.sh brings one back and keeps the old database aside. CI runs the dump, verify and restore cycle on the database the end-to-end journey filled. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/ci-db.yml | 25 +++++++ deploy/backup.sh | 139 +++++++++++++++++++++++++++++++++++++ deploy/compose.yaml | 20 ++++++ deploy/restore.sh | 34 +++++++++ deploy/update.sh | 6 ++ docs/deployment.md | 16 ++++- docs/install-guide.md | 13 +++- 7 files changed, 250 insertions(+), 3 deletions(-) create mode 100755 deploy/backup.sh create mode 100755 deploy/restore.sh diff --git a/.gitea/workflows/ci-db.yml b/.gitea/workflows/ci-db.yml index 0458749..78e6f06 100644 --- a/.gitea/workflows/ci-db.yml +++ b/.gitea/workflows/ci-db.yml @@ -9,11 +9,13 @@ on: paths: - 'services/api/**' - 'services/recommender/**' + - 'deploy/backup.sh' - '.gitea/workflows/ci-db.yml' pull_request: paths: - 'services/api/**' - 'services/recommender/**' + - 'deploy/backup.sh' - '.gitea/workflows/ci-db.yml' concurrency: @@ -64,3 +66,26 @@ jobs: run: | uv sync --frozen uv run pytest -q tests/test_db_integration.py + # База уже наполнена сценариями выше — проверяем настоящий цикл копия → проверка → восстановление. + - name: Бэкап, проверка и восстановление (deploy/backup.sh) + env: + PGHOST: postgres + PGUSER: pipu + PGPASSWORD: pipu + PGDATABASE: pipu + BACKUP_DIR: /tmp/backups + VERIFY_EXACT: "1" + run: | + SUDO=$(command -v sudo || true) + $SUDO install -d /usr/share/postgresql-common/pgdg + $SUDO curl -fsSLo /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc + echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list >/dev/null + $SUDO apt-get update -qq && $SUDO apt-get install -y -qq postgresql-client-17 >/dev/null + export PATH=/usr/lib/postgresql/17/bin:$PATH + before=$(psql -Atc "SELECT count(*) FROM tracks") + file=$(bash deploy/backup.sh once) + bash deploy/backup.sh verify "$file" + bash deploy/backup.sh restore "$file" + after=$(psql -Atc "SELECT count(*) FROM tracks") + echo "tracks: $before → $after" + test "$before" = "$after" diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..fc75c24 --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,139 @@ +#!/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 diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 5de5675..32252d8 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -142,6 +142,26 @@ services: interval: 5s retries: 20 + # Ежедневная копия базы и еженедельная проверка восстановления (deploy/backup.sh, docs/deployment.md#бэкапы). + backup: + <<: *restart + image: pgvector/pgvector:0.8.7-pg17 + entrypoint: ["bash", "/backup.sh"] + command: ["loop"] + environment: + PGHOST: postgres + PGUSER: pipu + PGPASSWORD: ${POSTGRES_PASSWORD} + PGDATABASE: pipu + BACKUP_HOUR: ${BACKUP_HOUR:-3} + BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14} + BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8} + volumes: + - ./backup.sh:/backup.sh:ro + - ${PIPU_DATA:-/srv/pipu}/backups:/backups + depends_on: + postgres: { condition: service_healthy } + redis: <<: *restart image: redis:7.4-alpine diff --git a/deploy/restore.sh b/deploy/restore.sh new file mode 100755 index 0000000..6a13220 --- /dev/null +++ b/deploy/restore.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Восстановление базы из резервной копии (docs/deployment.md#бэкапы). +# ./restore.sh — список копий +# ./restore.sh daily/pipu-20261020-033000.dump +# Сервисы, пишущие в базу, останавливаются на время восстановления; прежняя база сохраняется рядом. +set -euo pipefail +cd "$(dirname "$0")" + +[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; } +DATA="$(sed -n 's/^PIPU_DATA=//p' .env)" +DATA="${DATA:-/srv/pipu}" + +if [ -z "${1:-}" ]; then + echo "Копии в $DATA/backups (новые сверху):" + (cd "$DATA/backups" && ls -1t daily/*.dump weekly/*.dump 2>/dev/null) || echo " копий нет" + exit 0 +fi + +rel="${1#"$DATA/backups/"}" +[ -f "$DATA/backups/$rel" ] || { echo "нет файла $DATA/backups/$rel" >&2; exit 1; } + +echo "→ восстановление из $rel: api, рекомендации и анализ будут остановлены на время восстановления" +read -r -p "Продолжить? [y/N] " yes +[ "$yes" = y ] || [ "$yes" = Y ] || exit 1 + +docker compose stop api recommender analyzer storage-agent +if docker compose run --rm backup restore "/backups/$rel"; then + status=0 +else + status=1 +fi +docker compose up -d +[ "$status" = 0 ] || { echo "восстановление не удалось — база не изменена" >&2; exit 1; } +echo "готово. Поиск переиндексируется при старте api (или вручную: «Администрирование → Библиотека»)." diff --git a/deploy/update.sh b/deploy/update.sh index e1e7c4b..348aec5 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -53,6 +53,12 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] && fi fi +# Копия базы перед обновлением: миграции назад не откатываются (deploy/restore.sh — вернуть). +if [ -n "$(docker compose ps -q --status running postgres 2>/dev/null)" ]; then + echo "→ резервная копия базы" + docker compose run --rm --no-deps backup once >/dev/null +fi + echo "→ переключаемся на $REF" if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then git checkout --quiet "$REF" diff --git a/docs/deployment.md b/docs/deployment.md index 0cc78be..df8dd09 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -97,7 +97,21 @@ ## Бэкапы -- `pg_dump` ежедневно (cron-контейнер `backup`), хранить 14 дней + еженедельные 8 недель → `/srv/pipu/backups`. +- Контейнер `backup` (`deploy/backup.sh loop`): каждый день в `BACKUP_HOUR`:30 UTC (по умолчанию 3:30) — + `pg_dump -Fc` → `/srv/pipu/backups/daily/`, хранится `BACKUP_KEEP_DAILY` = 14 последних; первая копия за неделю + копируется в `weekly/` (`BACKUP_KEEP_WEEKLY` = 8). Рядом с копией — `*.counts`: число строк в каждой таблице. +- Раз в неделю копия **проверяется восстановлением**: разворачивается во временную базу `pipu_verify`, сверяются + таблицы и число строк, база удаляется; время последней успешной проверки — `backups/.last-verify`, ошибки — + в `docker compose logs backup` («ПРОВЕРКА НЕ ПРОЙДЕНА»). Тот же цикл копия → проверка → восстановление + выполняется в CI (`ci / db`) на базе после сквозного сценария. +- `update.sh` перед обновлением делает внеочередную копию. +- Вручную: `docker compose run --rm backup once` — копия сейчас; `docker compose run --rm backup verify /backups/daily/<файл>`. +- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/pipu-….dump` — останавливает api, рекомендации и + анализ, проверяет копию, переименовывает текущую базу в `pipu_before_restore_<время>` (страховка; удалить — + `docker compose exec postgres dropdb -U pipu <имя>`), восстанавливает и запускает сервисы; поиск переиндексируется + при старте api. Redis (сессии волны, очереди) не восстанавливается — пользователи просто начнут новую волну. +- Копии лежат на том же сервере: для защиты от потери диска синхронизируйте `/srv/pipu/backups` на другую машину + (например, `rclone sync` или `rsync` по cron). - Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ). - Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен. - Сама музыка — на NFS, вне зоны ответственности приложения. diff --git a/docs/install-guide.md b/docs/install-guide.md index 383ab9e..28bde0e 100644 --- a/docs/install-guide.md +++ b/docs/install-guide.md @@ -314,12 +314,21 @@ cd /opt/pipu-music/deploy && ./update.sh пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются автоматически при старте API. -Бэкап базы (всё остальное восстанавливается само — см. [deployment.md](deployment.md#бэкапы)): +Бэкапы базы делаются сами: каждую ночь в `/srv/pipu/backups`, раз в неделю копия проверяется восстановлением, +перед каждым `update.sh` — внеочередная копия. Всё остальное восстанавливается само — см. +[deployment.md](deployment.md#бэкапы). Копия сейчас и восстановление: ```bash -docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump +cd /opt/pipu-music/deploy && docker compose run --rm backup once ``` +```bash +cd /opt/pipu-music/deploy && ./restore.sh +``` + +Без аргумента `restore.sh` показывает список копий; с именем файла (`./restore.sh daily/pipu-20261020-033000.dump`) +восстанавливает базу, сохранив прежнюю рядом. + Остановить сервис, сохранив данные: ```bash