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