- 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.
346 lines
13 KiB
Markdown
346 lines
13 KiB
Markdown
---
|
||
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` → форматирование.
|