diff --git a/deploy/food-market-backup.service b/deploy/food-market-backup.service new file mode 100644 index 0000000..d0ca245 --- /dev/null +++ b/deploy/food-market-backup.service @@ -0,0 +1,15 @@ +[Unit] +Description=food-market: бэкап БД и загруженных файлов +Documentation=https://github.com/nurdotnet/food-market/blob/main/docs/backup-restore.md +Wants=docker.service +After=docker.service + +[Service] +Type=oneshot +# Опциональные переопределения FM_* (см. шапку скрипта). Знак "-" — файл не +# обязателен. Путь скорректировать под фактический каталог деплоя. +EnvironmentFile=-/opt/food-market/deploy/.env +ExecStart=/opt/food-market/deploy/food-market-backup.sh +# Бэкап не должен мешать основной нагрузке. +Nice=10 +IOSchedulingClass=idle diff --git a/deploy/food-market-backup.sh b/deploy/food-market-backup.sh new file mode 100755 index 0000000..2ec0359 --- /dev/null +++ b/deploy/food-market-backup.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# food-market: ежедневный бэкап БД + загруженных файлов с ротацией. +# +# Дампит Postgres (контейнер food-market-postgres) в custom-формат pg_dump +# (-Fc, пригоден для pg_restore с параллелизмом/выборочным восстановлением) и +# архивирует каталог uploads. Удаляет бэкапы старше RETENTION_DAYS дней. +# +# Запускается из systemd-таймера food-market-backup.timer (ежедневно), либо +# вручную. Конфигурируется переменными окружения (значения по умолчанию +# совпадают с deploy/docker-compose.yml): +# +# FM_PG_CONTAINER имя контейнера Postgres (food-market-postgres) +# FM_PG_DB имя БД (food_market) +# FM_PG_USER пользователь БД (food_market) +# FM_BACKUP_DIR куда складывать бэкапы (/opt/food-market-data/backups) +# FM_UPLOADS_DIR каталог изображений (/opt/food-market-data/uploads) +# FM_BACKUP_RETENTION_DAYS срок хранения, дней (30) +# +set -euo pipefail + +CONTAINER="${FM_PG_CONTAINER:-food-market-postgres}" +DB="${FM_PG_DB:-food_market}" +DB_USER="${FM_PG_USER:-food_market}" +BACKUP_DIR="${FM_BACKUP_DIR:-/opt/food-market-data/backups}" +UPLOADS_DIR="${FM_UPLOADS_DIR:-/opt/food-market-data/uploads}" +RETENTION_DAYS="${FM_BACKUP_RETENTION_DAYS:-30}" + +TS="$(date +%Y%m%d-%H%M%S)" +DB_FILE="$BACKUP_DIR/db-$TS.dump" +UPLOADS_FILE="$BACKUP_DIR/uploads-$TS.tgz" + +log() { echo "[$(date -Is)] $*"; } + +mkdir -p "$BACKUP_DIR" + +if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then + log "ОШИБКА: контейнер '$CONTAINER' не запущен — бэкап невозможен." >&2 + exit 1 +fi + +log "Дамп БД '$DB' → $DB_FILE" +# Дамп пишем во временный файл и переименовываем по успеху — частичный/битый +# дамп при падении pg_dump не попадёт в ротацию как валидный. +if docker exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB" -Fc > "$DB_FILE.tmp"; then + mv "$DB_FILE.tmp" "$DB_FILE" + log "Готово: $(du -h "$DB_FILE" | cut -f1)" +else + rm -f "$DB_FILE.tmp" + log "ОШИБКА: pg_dump завершился с ошибкой." >&2 + exit 1 +fi + +if [ -d "$UPLOADS_DIR" ]; then + log "Архив uploads '$UPLOADS_DIR' → $UPLOADS_FILE" + tar czf "$UPLOADS_FILE.tmp" -C "$(dirname "$UPLOADS_DIR")" "$(basename "$UPLOADS_DIR")" \ + && mv "$UPLOADS_FILE.tmp" "$UPLOADS_FILE" + log "Готово: $(du -h "$UPLOADS_FILE" | cut -f1)" +else + log "Каталог uploads '$UPLOADS_DIR' отсутствует — пропуск." +fi + +log "Ротация: удаляю бэкапы старше $RETENTION_DAYS дн." +find "$BACKUP_DIR" -maxdepth 1 -type f -name 'db-*.dump' -mtime +"$RETENTION_DAYS" -print -delete +find "$BACKUP_DIR" -maxdepth 1 -type f -name 'uploads-*.tgz' -mtime +"$RETENTION_DAYS" -print -delete + +log "Бэкап завершён." diff --git a/deploy/food-market-backup.timer b/deploy/food-market-backup.timer new file mode 100644 index 0000000..6ad0d8b --- /dev/null +++ b/deploy/food-market-backup.timer @@ -0,0 +1,14 @@ +[Unit] +Description=food-market: ежедневный бэкап (03:00) +Documentation=https://github.com/nurdotnet/food-market/blob/main/docs/backup-restore.md + +[Timer] +# Каждый день в 03:00 локального времени сервера. +OnCalendar=*-*-* 03:00:00 +# Догнать пропущенный запуск, если сервер был выключен в момент срабатывания. +Persistent=true +# Небольшой разброс — на случай нескольких таймеров одновременно. +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/docs/backup-restore.md b/docs/backup-restore.md new file mode 100644 index 0000000..f92afbe --- /dev/null +++ b/docs/backup-restore.md @@ -0,0 +1,101 @@ +# Бэкап и восстановление + +Артефакты в репозитории (`deploy/`): + +- `food-market-backup.sh` — скрипт бэкапа БД + uploads с ротацией. +- `food-market-backup.service` — systemd oneshot-юнит, запускающий скрипт. +- `food-market-backup.timer` — ежедневный таймер (03:00, с догоном пропущенных). + +> Установку на prod-vm выполняет отдельный деплой-шаг (см. ниже) — здесь только +> подготовленные артефакты. + +## Что бэкапится + +| Что | Как | Файл | +|---|---|---| +| База данных | `pg_dump -Fc` из контейнера `food-market-postgres` | `db-.dump` (custom-format) | +| Загруженные файлы (картинки товаров) | `tar czf` каталога uploads | `uploads-.tgz` | + +Папка назначения по умолчанию — `/opt/food-market-data/backups`. Хранение — +30 дней (`FM_BACKUP_RETENTION_DAYS`), старые удаляются ротацией. Конфиг — +переменными `FM_*` (см. шапку `food-market-backup.sh`). + +## Установка таймера на сервере (деплой-шаг) + +Предполагается, что репозиторий выложен в `/opt/food-market` (иначе скорректировать +`ExecStart`/`EnvironmentFile` в `.service` и пути ниже). + +```bash +sudo install -m 0755 /opt/food-market/deploy/food-market-backup.sh /opt/food-market/deploy/food-market-backup.sh +sudo cp /opt/food-market/deploy/food-market-backup.service /etc/systemd/system/ +sudo cp /opt/food-market/deploy/food-market-backup.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now food-market-backup.timer + +# Проверить расписание и последний запуск +systemctl list-timers food-market-backup.timer +# Прогнать бэкап немедленно (разово) +sudo systemctl start food-market-backup.service +journalctl -u food-market-backup.service --no-pager | tail -20 +``` + +## Ручной бэкап + +```bash +sudo /opt/food-market/deploy/food-market-backup.sh +# или с переопределением каталога: +FM_BACKUP_DIR=/mnt/backups sudo -E /opt/food-market/deploy/food-market-backup.sh +``` + +## Восстановление БД + +> ⚠️ Восстановление перезаписывает данные. Сначала остановить API, чтобы не было +> записи во время восстановления. + +```bash +cd /opt/food-market/deploy +docker compose stop api web + +DUMP=/opt/food-market-data/backups/db-YYYYMMDD-HHMMSS.dump + +# Скопировать дамп внутрь контейнера БД +docker cp "$DUMP" food-market-postgres:/tmp/restore.dump + +# Вариант A — восстановить в чистую БД (рекомендуется): +docker exec food-market-postgres psql -U food_market -d postgres -c \ + "DROP DATABASE IF EXISTS food_market WITH (FORCE); CREATE DATABASE food_market OWNER food_market;" +docker exec food-market-postgres pg_restore -U food_market -d food_market --no-owner /tmp/restore.dump + +# Вариант B — в существующую БД, заменив объекты (без пересоздания БД): +# docker exec food-market-postgres pg_restore -U food_market -d food_market --clean --if-exists --no-owner /tmp/restore.dump + +docker exec food-market-postgres rm -f /tmp/restore.dump +docker compose start api web +``` + +После старта API применит миграции (`Migrate()` идемпотентен) и поднимется. Проверить: + +```bash +curl -fsS http://localhost:8080/health/ready +``` + +## Восстановление uploads + +```bash +TGZ=/opt/food-market-data/backups/uploads-YYYYMMDD-HHMMSS.tgz +# tar содержит каталог uploads/ — распаковать в родителя смонтированного пути +sudo tar xzf "$TGZ" -C /opt/food-market-data/ +``` + +(Каталог `/opt/food-market-data/uploads` смонтирован в контейнер api как `/app/uploads`.) + +## Проверка дампа без восстановления + +```bash +docker cp food-market-postgres:/tmp/v.dump +docker exec food-market-postgres pg_restore --list /tmp/v.dump | head # TOC валидного архива +docker exec food-market-postgres rm -f /tmp/v.dump +``` + +Скрипт и формат проверены локально (2026-05-27): дамп `PGDMP`, custom-format, +248 TOC-записей, `pg_restore --list` читает.