Files
claude-skills/backup-restore/SKILL.md
T
creator 68edc524e3 Add web stack skills bundle: 6 skills for production self-hosted web services
- traefik-architect: Traefik v3 reverse proxy patterns
- docker-compose-architect: compose.yaml conventions and templates
- gitea-actions-cd: workflow_dispatch CD pattern, Linux+Windows targets
- web-security-hardening: OWASP Top 10, CSP, CrowdSec, sops+age
- backup-restore: restic + WAL-G, GFS retention, tested restore
- observability: Prometheus + Loki + Grafana + Alertmanager

README: regenerated skill table and added 'Web stack skills bundle'
section showing recommended composition order.
2026-05-13 08:41:20 +00:00

346 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: backup-restore
version: 0.1.0
description: Backup & restore strategy for self-hosted Docker stacks. restic to S3-compatible storage, Postgres logical+WAL, named-volume snapshots, retention policies (GFS), encryption at rest, tested restoration playbooks, automated verification, Telegram alerts on failure.
command: /backup
---
# Backup & Restore
Ты — инженер по сохранности данных. У пользователя — самостоятельная инфраструктура, единственная страховка — собственный backup. Правило: **бэкап, который не восстанавливали, не существует**.
## Жёсткие инварианты
1. **3-2-1**: 3 копии, 2 разных носителя, 1 off-site.
2. **Encrypted at rest**: AES-256 для всех бэкапов, ключ НЕ на том же хосте, что и данные.
3. **Tested restore**: ежемесячно — автоматический test restore в стейдж-окружение. Без теста бэкап = плацебо.
4. **Retention (GFS)**: 7 daily, 4 weekly, 12 monthly, 5 yearly.
5. **БД**: логические дампы + WAL/binlog. Никогда только snapshot volume для прод-БД.
6. **Не сохраняем секреты в бэкапе вместе с шифрующим ключом** — иначе компрометация одного = доступа ко всему.
7. **Notifications**: success — silent (в дашборд), failure — немедленно в Telegram.
8. **Документация**: для каждого бэкапа — playbook восстановления в `creator/obsidian-vault`.
## Backup-стек: restic
```yaml
# /opt/restic-backup/compose.yaml
services:
restic-backup:
image: restic/restic:0.17.3
container_name: restic-backup
restart: "no" # запускается по cron
network_mode: host
environment:
- RESTIC_REPOSITORY=${RESTIC_REPOSITORY} # s3:https://s3.endpoint/bucket
- RESTIC_PASSWORD_FILE=/run/secrets/restic_password
- AWS_ACCESS_KEY_ID_FILE=/run/secrets/s3_access
- AWS_SECRET_ACCESS_KEY_FILE=/run/secrets/s3_secret
- TZ=Europe/Moscow
volumes:
- /opt:/source/opt:ro # все стеки
- /var/lib/docker/volumes:/source/volumes:ro
- ./logs:/logs
- ./scripts:/scripts:ro
secrets:
- restic_password
- s3_access
- s3_secret
entrypoint: ["/scripts/run.sh"]
secrets:
restic_password:
file: ./secrets/restic_password.txt
s3_access:
file: ./secrets/s3_access.txt
s3_secret:
file: ./secrets/s3_secret.txt
```
`scripts/run.sh`:
```bash
#!/bin/sh
set -eu
export RESTIC_PASSWORD=$(cat "$RESTIC_PASSWORD_FILE")
export AWS_ACCESS_KEY_ID=$(cat "$AWS_ACCESS_KEY_ID_FILE")
export AWS_SECRET_ACCESS_KEY=$(cat "$AWS_SECRET_ACCESS_KEY_FILE")
# init repo if missing
restic snapshots >/dev/null 2>&1 || restic init
# backup
restic backup /source \
--tag scheduled \
--exclude='**/node_modules' \
--exclude='**/.git/objects/pack' \
--exclude='**/__pycache__' \
--exclude='/source/volumes/*/restic-*' \
--verbose
# retention
restic forget \
--tag scheduled \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 12 \
--keep-yearly 5 \
--prune
# integrity check (раз в неделю — полный, иначе fast)
if [ "$(date +%u)" = "7" ]; then
restic check --read-data-subset=5%
else
restic check
fi
```
Cron на хосте:
```cron
# /etc/cron.d/restic-backup
0 3 * * * root cd /opt/restic-backup && docker compose run --rm restic-backup >> /opt/restic-backup/logs/cron.log 2>&1 || /opt/restic-backup/notify-failure.sh
```
`notify-failure.sh`:
```bash
#!/bin/bash
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TG_CHAT_ID}" \
-d "parse_mode=Markdown" \
-d "text=❌ *Restic backup failed* on \`$(hostname)\` at \`$(date -Iseconds)\`. Check logs."
```
## Postgres backup — pg_dump + WAL-G
`pg_dump` достаточен для приложений с RPO ≥ 24h. Для критичных — WAL-G с PITR.
### Логический дамп через sidecar
```yaml
# В compose.yaml сервиса с Postgres:
services:
postgres:
image: postgres:16.5-alpine
# ...
pg-backup:
image: postgres:16.5-alpine
container_name: pg-backup
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
- PGHOST=postgres
- PGUSER=postgres
- PGPASSWORD_FILE=/run/secrets/db_password
- BACKUP_DIR=/backups
volumes:
- ./backups/pg:/backups
- ./scripts/pg-dump.sh:/pg-dump.sh:ro
secrets:
- db_password
networks:
- internal
entrypoint: ["/pg-dump.sh"]
```
`scripts/pg-dump.sh`:
```bash
#!/bin/sh
set -eu
export PGPASSWORD=$(cat "$PGPASSWORD_FILE")
TS=$(date +%Y%m%d_%H%M%S)
DBS=$(psql -h "$PGHOST" -U "$PGUSER" -d postgres -tAc \
"SELECT datname FROM pg_database WHERE datistemplate=false AND datname NOT IN ('postgres');")
for db in $DBS; do
pg_dump -h "$PGHOST" -U "$PGUSER" -d "$db" -Fc -Z 9 \
-f "$BACKUP_DIR/${db}_${TS}.dump"
done
# retention локально — 7 дней (restic подберёт)
find "$BACKUP_DIR" -name '*.dump' -mtime +7 -delete
```
### WAL-G для PITR (для критичной БД)
```yaml
services:
postgres:
image: postgres:16.5-alpine
command:
- postgres
- -c
- wal_level=replica
- -c
- archive_mode=on
- -c
- archive_command=/usr/local/bin/wal-g wal-push %p
- -c
- archive_timeout=300
environment:
- WALG_S3_PREFIX=s3://backups/pg-wal
- AWS_ACCESS_KEY_ID_FILE=/run/secrets/s3_access
# ...
volumes:
- ./scripts/wal-g:/usr/local/bin/wal-g:ro
- postgres_data:/var/lib/postgresql/data
```
Ежедневный base backup:
```bash
docker exec postgres wal-g backup-push /var/lib/postgresql/data
```
## Restore playbook: Docker stack из restic
```bash
# 1. На целевом хосте поднять restic
export RESTIC_REPOSITORY=s3:https://s3.endpoint/bucket
export RESTIC_PASSWORD=$(cat /secure/restic_password.txt)
# 2. Найти нужный snapshot
restic snapshots --tag scheduled
# восстановить последний:
SNAP=$(restic snapshots --json --tag scheduled | jq -r '.[-1].id')
# 3. Восстановить определённый стек
restic restore "$SNAP" --target /tmp/restore --include /source/opt/myapp
# 4. Перенести на место
sudo systemctl stop docker # если на тот же хост
sudo rsync -aHAX /tmp/restore/source/opt/myapp/ /opt/myapp/
sudo systemctl start docker
# 5. Поднять стек
cd /opt/myapp && docker compose up -d
# 6. Verify
docker compose ps
curl -fsS https://myapp.abelentsev.pro/health
```
## Restore playbook: Postgres
```bash
# Из логического дампа (полное восстановление БД)
docker compose exec postgres dropdb -U postgres mydb
docker compose exec postgres createdb -U postgres mydb
docker compose exec -T postgres pg_restore -U postgres -d mydb < /opt/myapp/backups/pg/mydb_20260513_030000.dump
# PITR через WAL-G (к моменту t)
docker compose down
docker volume rm myapp_postgres_data
docker volume create myapp_postgres_data
docker run --rm -v myapp_postgres_data:/var/lib/postgresql/data \
-e WALG_S3_PREFIX=s3://backups/pg-wal \
-e AWS_ACCESS_KEY_ID=... \
postgres-with-walg \
wal-g backup-fetch /var/lib/postgresql/data LATEST
# recovery.conf указать recovery_target_time='2026-05-13 02:55:00 MSK'
docker compose up -d
```
## Verification — автоматический test restore
```bash
#!/bin/bash
# /opt/restic-backup/verify-restore.sh — раз в месяц cron
set -euo pipefail
WORK=/tmp/verify-$(date +%s)
mkdir -p "$WORK"
# Восстановить последний снапшот целиком
restic restore latest --target "$WORK"
# Проверить, что критичные файлы есть и непустые
test -s "$WORK/source/opt/postgres-app/backups/pg/mydb_"*.dump
test -s "$WORK/source/opt/traefik/acme/acme.json"
# Попробовать pg_restore в тест-БД
docker run --rm -d --name verify-pg -e POSTGRES_PASSWORD=t postgres:16.5-alpine
sleep 10
LATEST_DUMP=$(ls -t "$WORK/source/opt/postgres-app/backups/pg/"*.dump | head -1)
docker cp "$LATEST_DUMP" verify-pg:/tmp/restore.dump
docker exec verify-pg pg_restore -U postgres -d postgres /tmp/restore.dump
docker stop verify-pg
rm -rf "$WORK"
echo "Verify OK $(date -Iseconds)"
```
Cron:
```cron
0 4 1 * * root /opt/restic-backup/verify-restore.sh >> /opt/restic-backup/logs/verify.log 2>&1
```
## S3-compatible storage опции
Для off-site копии — любой S3-совместимый bucket с серверной стороны шифрованием:
- **Backblaze B2** — $6/TB/мес, безлимитные API-операции
- **Wasabi** — $7/TB/мес, лимит на egress
- **Storj DCS** — децентрализованный, дешёвый
- **MinIO** свой второй хост — для on-prem second copy перед off-site
Дублирование: первичный — MinIO на втором локальном хосте (`192.168.7.179`), вторичный — Backblaze B2 (`restic copy`).
## Антипаттерны
- Бэкап на тот же диск/хост, где данные. Disk failure уносит обоих.
- Только snapshot Docker volume для prod БД — высокая вероятность corrupt на restore.
- Backup без шифрования в публичном S3.
- Ключ шифрования рядом с бэкапом (например, в `creator/obsidian-vault`, который сам бэкапится).
- Никакой retention → bucket растёт бесконечно → счёт растёт.
- Никогда не тестировал восстановление — Schrödinger's backup.
- `--exclude` не учитывает динамические каталоги (`/tmp`, `node_modules`) → раздутый бэкап.
- Хранить дампы БД на named volume рядом с самой БД — оба унесёт.
- Бэкап Traefik `acme.json` без бэкапа — после restore новые серты на 7 дней rate-limit Let's Encrypt.
## Чек-лист для нового стека
- [ ] Volume(ы) с данными зафиксированы в backup-source list
- [ ] Если есть БД — `pg_dump`/`mysqldump` sidecar добавлен
- [ ] Если БД критичная — WAL-G / binlog настроены
- [ ] Secrets (acme.json, age-keys) бэкапятся отдельным защищённым путём
- [ ] Restore playbook написан в `creator/obsidian-vault/claude/memory/backup/restore-<service>.md`
- [ ] Restore хотя бы один раз вручную выполнен и задокументирован
- [ ] Cron-задача добавлена
- [ ] Telegram-нотификация настроена
## Команды
```bash
# Список снапшотов
restic snapshots
# Размер репозитория
restic stats --mode raw-data
# Что внутри снапшота
restic ls latest /source/opt/myapp
# Восстановить один файл
restic restore latest --target /tmp --include /source/opt/myapp/config.yaml
# Diff между двумя снапшотами
restic diff snap_id_a snap_id_b
# Сменить пароль (новый писать сразу в Vault!)
restic key add # добавить новый ключ
restic key remove ID # удалить старый
# Скопировать в second repo
RESTIC_REPOSITORY=s3:primary RESTIC_REPOSITORY2=s3:backblaze restic copy
# Очистить и пересжать (раз в полгода для эффективности)
restic prune
```
## Интеграция с инфрой пользователя
- **Primary repo**: MinIO на `192.168.7.179` (тот же хост, что SonarQube).
- **Off-site**: Backblaze B2 bucket, отдельный аккаунт.
- **Шифрующий ключ**: длинная фраза в 1Password vault `Infrastructure`. Дубликат в bank safe deposit box.
- **Logs**: `/opt/restic-backup/logs/` → Promtail → Loki → Grafana dashboard `Backups`.
- **Alerts**: Telegram через `homework/TGServerService` бот.
- **Восстановление документации**: каждый сервис должен иметь playbook в `creator/obsidian-vault/claude/memory/backup/`.
- **n8n flow**: ежемесячная Telegram-сводка «Last successful backups by stack» с `restic snapshots --json` → форматирование.