Бэкап: полный оффсайт-бэкап прода на VPS (БД + uploads + achievements) + восстановление

- scripts/backup.sh: консистентный снимок SQLite (.backup) + архив fs_<TS>.tar.gz с uploads и
  achievements в backups/, локальная ротация; при заданном BACKUP_VPS_HOST — push на VPS (scp
  отдельным ключом/юзером) с удалённой ротацией. Настройки читаются из .env (BACKUP_*).
- scripts/restore.sh: восстановление архива в прод (stop app → очистка тома → docker cp → start).
- .env.example: блок BACKUP_* (host/user/dir/key/keep). .gitignore: id_backup* (ключ бэкапа).
- deploy/pi/README, deploy/vps/README: настройка ключа и юзера backup, /srv/fs-backups, cron, restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 23:36:58 +03:00
co-authored by Claude Opus 4.8
parent 1e19038b7c
commit 32c74abc43
6 changed files with 155 additions and 17 deletions
Regular → Executable
+61 -15
View File
@@ -1,28 +1,74 @@
#!/usr/bin/env bash
# Консистентный онлайн-бэкап SQLite из работающего контейнера.
# Запуск по cron на хосте Pi, например ежедневно в 04:00:
# Полный консистентный онлайн-бэкап прода (БД + uploads + achievements) с работающего
# контейнера + оффсайт-копия на VPS. Запуск по cron на хосте Pi, например ежедневно в 04:00:
# 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1
#
# Настройки берутся из корневого .env (BACKUP_*), их можно переопределить переменными
# окружения. Если BACKUP_VPS_HOST пуст — делается только локальная копия в backups/.
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}"
KEEP="${KEEP:-14}"
COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml"
SERVICE="app"
TS="$(date +%Y%m%d_%H%M%S)"
# Безопасно прочитать ОДИН ключ из .env (НЕ source: в .env есть спецсимволы # & и т.п.).
read_env() { # $1=ключ $2=дефолт
local val
val="$(grep -E "^$1=" "$PROJECT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r')"
val="${val%\"}"; val="${val#\"}" # снять обрамляющие кавычки, если есть
printf '%s' "${val:-$2}"
}
BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}"
KEEP_LOCAL="${BACKUP_KEEP_LOCAL:-$(read_env BACKUP_KEEP_LOCAL 14)}"
KEEP_REMOTE="${BACKUP_KEEP_REMOTE:-$(read_env BACKUP_KEEP_REMOTE 30)}"
VPS_HOST="${BACKUP_VPS_HOST:-$(read_env BACKUP_VPS_HOST "")}"
VPS_USER="${BACKUP_VPS_USER:-$(read_env BACKUP_VPS_USER backup)}"
VPS_DIR="${BACKUP_VPS_DIR:-$(read_env BACKUP_VPS_DIR /srv/fs-backups)}"
VPS_KEY="${BACKUP_VPS_KEY:-$(read_env BACKUP_VPS_KEY "$PROJECT_DIR/deploy/backup/id_backup")}"
mkdir -p "$BACKUP_DIR"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# .backup внутри контейнера (sqlite3 модуль Python) → консистентная копия в /data.
docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T "$SERVICE" \
python -c "import sqlite3; s=sqlite3.connect('/data/forbidden_stars.db'); d=sqlite3.connect('/data/backup_tmp.db'); s.backup(d); d.close(); s.close()"
CID="$($COMPOSE ps -q "$SERVICE")"
if [ -z "$CID" ]; then
echo "[backup] ОШИБКА: контейнер '$SERVICE' не запущен." >&2
exit 1
fi
# Копируем во вне контейнера и сжимаем.
CID="$(docker compose -f "$PROJECT_DIR/docker-compose.yml" ps -q "$SERVICE")"
docker cp "$CID:/data/backup_tmp.db" "$BACKUP_DIR/fs_$TS.db"
docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T "$SERVICE" rm -f /data/backup_tmp.db
gzip -f "$BACKUP_DIR/fs_$TS.db"
echo "[backup] Консистентный снимок БД…"
$COMPOSE exec -T "$SERVICE" python -c "import sqlite3; s=sqlite3.connect('/data/forbidden_stars.db'); d=sqlite3.connect('/data/backup_tmp.db'); s.backup(d); d.close(); s.close()"
docker cp "$CID:/data/backup_tmp.db" "$STAGE/forbidden_stars.db"
$COMPOSE exec -T "$SERVICE" rm -f /data/backup_tmp.db
# Ротация: оставить последние $KEEP.
ls -1t "$BACKUP_DIR"/fs_*.db.gz 2>/dev/null | tail -n +"$((KEEP + 1))" | xargs -r rm -f
echo "[backup] Копирование медиа и титулов…"
docker cp "$CID:/data/uploads" "$STAGE/uploads"
docker cp "$CID:/data/achievements" "$STAGE/achievements"
echo "[backup] Готово: $BACKUP_DIR/fs_$TS.db.gz"
ARCHIVE="$BACKUP_DIR/fs_$TS.tar.gz"
tar -czf "$ARCHIVE" -C "$STAGE" forbidden_stars.db uploads achievements
echo "[backup] Локальный архив: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
# Локальная ротация: оставить последние $KEEP_LOCAL (архив только что создан → ≥1 файл).
ls -1t "$BACKUP_DIR"/fs_*.tar.gz 2>/dev/null | tail -n +"$((KEEP_LOCAL + 1))" | xargs -r rm -f
# Оффсайт-копия на VPS (push: Pi за CGNAT, входящих нет).
if [ -n "$VPS_HOST" ]; then
if [ ! -f "$VPS_KEY" ]; then
echo "[backup] ОШИБКА: ключ $VPS_KEY не найден — оффсайт-копия пропущена." >&2
exit 1
fi
echo "[backup] Отправка на VPS $VPS_USER@$VPS_HOST:$VPS_DIR …"
SSH_OPTS=(-i "$VPS_KEY" -o StrictHostKeyChecking=accept-new -o BatchMode=yes)
scp "${SSH_OPTS[@]}" "$ARCHIVE" "$VPS_USER@$VPS_HOST:$VPS_DIR/"
# Удалённая ротация (sh на VPS без pipefail → пустой ls не уронит команду).
ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" \
"ls -1t '$VPS_DIR'/fs_*.tar.gz 2>/dev/null | tail -n +$((KEEP_REMOTE + 1)) | xargs -r rm -f"
echo "[backup] Оффсайт-копия отправлена."
else
echo "[backup] BACKUP_VPS_HOST не задан — только локальная копия."
fi
echo "[backup] Готово."