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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d91ff89c64
commit
aacf55679a
7 files changed
+250
-3
No files matched your search
@@ -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"
|
||||
Executable
+139
@@ -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 — заменить рабочую базу копией (сервисы, пишущие в базу, должны быть остановлены);
|
||||
# прежняя база остаётся рядом как <db>_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: «таблица<TAB>строк», по алфавиту.
|
||||
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
|
||||
@@ -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
|
||||
|
||||
Executable
+34
@@ -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 (или вручную: «Администрирование → Библиотека»)."
|
||||
@@ -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"
|
||||
|
||||
+15
-1
@@ -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, вне зоны ответственности приложения.
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user