Files
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

13 KiB
Raw Permalink Blame History

name, version, description, command
name version description command
backup-restore 0.1.0 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. /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

# /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:

#!/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 на хосте:

# /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:

#!/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

# В 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:

#!/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 (для критичной БД)

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:

docker exec postgres wal-g backup-push /var/lib/postgresql/data

Restore playbook: Docker stack из restic

# 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

# Из логического дампа (полное восстановление БД)
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

#!/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:

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-нотификация настроена

Команды

# Список снапшотов
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 → форматирование.