Бэкап: полный оффсайт-бэкап прода на 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:
@@ -61,6 +61,17 @@ PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
|
||||
DEV_UPLOAD_DIR=./data/dev/uploads
|
||||
PROD_UPLOAD_DIR=/data/uploads
|
||||
|
||||
# ─── БЭКАП (оффсайт-копия на VPS; читает scripts/backup.sh на хосте Pi) ───────
|
||||
# Полный бэкап (БД + uploads + achievements) кладётся локально в backups/, и — если задан
|
||||
# BACKUP_VPS_HOST — копируется на VPS (Pi → VPS push, т.к. Pi за CGNAT). Пусто = только локально.
|
||||
# Ключ бэкапа — deploy/backup/id_backup (в git НЕ идёт); его pubkey → authorized_keys у backup@VPS.
|
||||
BACKUP_VPS_HOST=
|
||||
BACKUP_VPS_USER=backup
|
||||
BACKUP_VPS_DIR=/srv/fs-backups
|
||||
BACKUP_VPS_KEY=deploy/backup/id_backup
|
||||
BACKUP_KEEP_LOCAL=14
|
||||
BACKUP_KEEP_REMOTE=30
|
||||
|
||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
||||
APP_TZ_OFFSET_HOURS=3
|
||||
|
||||
@@ -23,6 +23,7 @@ backend/dev.db*
|
||||
*.pem
|
||||
*.key
|
||||
id_tunnel*
|
||||
id_backup*
|
||||
deploy/**/certs/
|
||||
|
||||
# Node / фронт
|
||||
|
||||
+20
-2
@@ -38,8 +38,26 @@ git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Бэкап БД
|
||||
`scripts/backup.sh` (консистентная онлайн-копия SQLite; по cron на хосте). Том БД — `db-data`.
|
||||
## Бэкап (полный + оффсайт на VPS)
|
||||
`scripts/backup.sh` делает консистентный онлайн-снимок и собирает **полный** архив
|
||||
`fs_<TS>.tar.gz` (БД + `uploads/` + `achievements/`) в `backups/` с ротацией. Если в `.env`
|
||||
задан `BACKUP_VPS_HOST` — копия дополнительно уходит на VPS (оффсайт; Pi → VPS push, т.к. CGNAT).
|
||||
|
||||
Настройка оффсайта (один раз):
|
||||
```bash
|
||||
# 1) отдельный ключ бэкапа + его pubkey на VPS (юзер backup — см. deploy/vps/README.md):
|
||||
ssh-keygen -t ed25519 -f deploy/backup/id_backup -N ""
|
||||
ssh-copy-id -i deploy/backup/id_backup.pub backup@186.246.51.17
|
||||
# 2) в .env заполнить BACKUP_VPS_HOST=186.246.51.17 (по желанию — BACKUP_VPS_USER/DIR/KEEP_*)
|
||||
# 3) разовый прогон и проверка, что архив доехал на VPS:
|
||||
scripts/backup.sh
|
||||
# 4) cron (ежедневно в 04:00):
|
||||
crontab -e
|
||||
# 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1
|
||||
```
|
||||
|
||||
Восстановление: `scripts/restore.sh backups/fs_<TS>.tar.gz` (ДЕСТРУКТИВНО — заменяет текущие
|
||||
данные; сначала проверь на тест-контуре). Тома — `db-data` / `uploads-data` / `achievements-data`.
|
||||
|
||||
Логи: `docker compose logs -f app` / `... tunnel`. Если `https://forbiddenstars.ru` отдаёт
|
||||
502 — значит туннель не поднят: смотри `docker compose logs tunnel` (чаще всего ключ не в
|
||||
|
||||
@@ -108,3 +108,18 @@ systemctl reload caddy
|
||||
(HTTP 503), это ожидаемо.
|
||||
|
||||
Логи Caddy: `journalctl -u caddy -f`.
|
||||
|
||||
## 8. Приём бэкапов с Pi (оффсайт-копии)
|
||||
Pi ежедневно шлёт полный архив (`scripts/backup.sh`) на VPS отдельным пользователем `backup`
|
||||
(не путать с `tunnel`). Каталог — **вне** веб-корней Caddy, права 700.
|
||||
```bash
|
||||
useradd -m -s /bin/bash backup
|
||||
install -d -m 700 -o backup -g backup /home/backup/.ssh
|
||||
install -m 600 -o backup -g backup /dev/null /home/backup/.ssh/authorized_keys
|
||||
install -d -m 700 -o backup -g backup /srv/fs-backups
|
||||
# Публичный ключ бэкапа с Pi (deploy/backup/id_backup.pub) добавь в authorized_keys:
|
||||
# echo '<содержимое id_backup.pub>' >> /home/backup/.ssh/authorized_keys
|
||||
```
|
||||
> Ротацию на VPS делает сам скрипт с Pi (`BACKUP_KEEP_REMOTE`, по умолч. 30). Каталог
|
||||
> `/srv/fs-backups` не отдаётся Caddy (нет `root`/`file_server` на него) → бэкапы не публичны.
|
||||
> Проверка приёма: после `scripts/backup.sh` на Pi → `ls -1 /srv/fs-backups/` на VPS.
|
||||
|
||||
Regular → Executable
+61
-15
@@ -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] Готово."
|
||||
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Восстановление прода из архива бэкапа (fs_<TS>.tar.gz): БД + uploads + achievements.
|
||||
# ДЕСТРУКТИВНО — заменяет текущие данные тома. Сначала проверьте на тест-контуре!
|
||||
# Использование:
|
||||
# scripts/restore.sh backups/fs_YYYYmmdd_HHMMSS.tar.gz [--yes]
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml"
|
||||
SERVICE="app"
|
||||
|
||||
ARCHIVE="${1:-}"
|
||||
CONFIRM="${2:-}"
|
||||
if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then
|
||||
echo "Использование: $0 <путь к fs_*.tar.gz> [--yes]" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CONFIRM" != "--yes" ]; then
|
||||
printf "ВНИМАНИЕ: текущие БД/uploads/achievements будут заменены из %s\nПродолжить? [y/N] " "$ARCHIVE"
|
||||
read -r ans
|
||||
case "$ans" in y|Y|yes|да|Да) ;; *) echo "Отменено."; exit 1 ;; esac
|
||||
fi
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
tar -xzf "$ARCHIVE" -C "$STAGE"
|
||||
if [ ! -f "$STAGE/forbidden_stars.db" ]; then
|
||||
echo "ОШИБКА: в архиве нет forbidden_stars.db." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[restore] Остановка приложения…"
|
||||
$COMPOSE stop "$SERVICE"
|
||||
|
||||
echo "[restore] Очистка тома (старая БД, WAL/SHM, медиа, титулы)…"
|
||||
$COMPOSE run --rm --no-deps -T --entrypoint sh "$SERVICE" -c \
|
||||
"rm -f /data/forbidden_stars.db /data/forbidden_stars.db-wal /data/forbidden_stars.db-shm; rm -rf /data/uploads/* /data/achievements/*"
|
||||
|
||||
CID="$($COMPOSE ps -aq "$SERVICE")"
|
||||
echo "[restore] Запись данных в контейнер…"
|
||||
docker cp "$STAGE/forbidden_stars.db" "$CID:/data/forbidden_stars.db"
|
||||
[ -d "$STAGE/uploads" ] && docker cp "$STAGE/uploads/." "$CID:/data/uploads"
|
||||
[ -d "$STAGE/achievements" ] && docker cp "$STAGE/achievements/." "$CID:/data/achievements"
|
||||
|
||||
echo "[restore] Запуск приложения…"
|
||||
$COMPOSE start "$SERVICE"
|
||||
echo "[restore] Готово. Проверьте https://forbiddenstars.ru и логи: $COMPOSE logs -f app"
|
||||
Reference in New Issue
Block a user