# 自托管服务备份体系设计：每日自动备份与恢复演练

> 作者: Lazybones | 日期: 2026-09-07

# 自托管服务备份体系设计：每日自动备份与恢复演练

> 「只备份不演练等于没备份」。本文讲每日自动备份脚本的设计、备份覆盖范围的选择，以及定期恢复演练中容易暴露的常见问题。

## 备份策略总览

```text
备份目录: /var/backups/stacks/
├── daily/                           # 每日备份（保留 7 天）
│   ├── backup_20260907_030000.tar.gz      # 配置 + 全库 mysqldump
│   ├── vol_app_mariadb-data_*.tar.gz      # MariaDB 数据卷
│   ├── vol_app_redis-data_*.tar.gz        # Redis 数据卷
│   ├── vol_app_db_*.tar.gz                # 应用 SQLite 卷
│   └── vol_app_media_*.tar.gz             # 应用媒体文件卷
└── weekly/                          # 每周备份（保留 28 天，周日生成）
    ├── backup_20260907_030000.tar.gz      # 同 daily，多保留一份
    └── app-static-src-2026-09-07.tar.gz   # 静态资源源文件
```

典型的做法是在凌晨 3 点由 crontab 触发，整个过程约 5 分钟。

## 备份脚本五步走

### 第1步：MariaDB 全库 dump

```bash
docker exec mariadb mysqldump -uroot -p"$DB_ROOT_PASSWORD" \
    --all-databases --single-transaction --routines --triggers \
    > "${STAGING_DIR}/mysql_all_databases_${DATE}.sql"
```

`--single-transaction` 保证 InnoDB 表的一致性快照，不锁表。`--routines --triggers` 连存储过程和触发器一起导出。

**安全要点**：数据库密码从 `.env` 文件读取（600 权限，不进 git），脚本内绝不硬编码。读不到就报错退出，不降级继续跑——备份缺了数据库等于没备份。

### 第2步：打包所有 compose 配置

遍历 `/srv/stacks/*/` 下所有服务目录，复制 compose 文件和配置文件（`.yml`、`.conf`、`.env`、`.toml`、证书等），排除代码目录、`node_modules`、`vendor`、`.git`。

```bash
for dir in /srv/stacks/*/; do
    service=$(basename "$dir")
    [[ "$service" =~ ^(backups|scripts|logs)$ ]] && continue
    if [ -f "${dir}docker-compose.yml" ]; then
        # 复制 compose + 配置文件
        find "$dir" -maxdepth 3 \
            \( -name "*.yml" -o -name "*.env" -o -name "*.conf" ... \) \
            -not -path "*/node_modules/*" -not -path "*/.git/*" \
            | while read -r f; do cp "$f" "${svc_dir}/${rel}"; done
    fi
done
```

### 第3步：打包每日备份

把数据库 dump + 所有配置打包成一个 `backup_*.tar.gz`。

### 第4步：备份 Docker 数据卷

关键数据在 Docker 命名卷里，必须单独备份：

```bash
VOLUMES=(
    "app_mariadb-data"     # MariaDB 数据目录
    "app_redis-data"       # Redis 持久化数据
    "app_db"               # 应用 SQLite 数据库
    "app_media"            # 应用上传的媒体文件
)
```

备份方式是启动一个临时 Alpine 容器，挂载目标卷（只读）和备份目录，用 `tar czf` 打包：

```bash
docker run --rm \
    -v "${vol}":/source:ro \
    -v "${BACKUP_ROOT}":/backup \
    alpine tar czf "/backup/daily/vol_${vol}_${DATE}.tar.gz" -C /source .
```

### 第5步：清理旧备份 + 每周归档

- **日备**：`daily/` 下超过 7 天的文件自动删除。
- **周备**：每周日把当日备份复制到 `weekly/`，额外归档应用静态资源源目录，保留 28 天。

## 哪些不备份？为什么

一个关键决策是**不备份静态资源产物数据卷**。

这个卷是 `collectstatic` 的产物——应用每次容器启动时，`entrypoint.sh` 会自动跑 `collectstatic` 从源码重新生成。它是可再生数据，不需要备份。而且带哈希文件名的压缩存储会生成额外的副本，体积明显翻倍，每日备份纯属浪费空间。

但源文件（`app/static/` 目录）不在 git 里（被 `.gitignore` 排除了），也不在数据卷里（卷里只有产物），所以改为每周归档源目录作为兜底。

**决策框架**：

| 数据类型 | 备份策略 | 理由 |
|----------|----------|------|
| 数据库（MariaDB/SQLite/Redis） | 每日 | 不可再生，丢失即灾难 |
| 配置文件 | 每日 | 改了就丢了 |
| 媒体文件（用户上传） | 每日 | 不可再生 |
| 静态资源产物 | 不备份 | collectstatic 自动重建 |
| 静态资源源文件 | 每周 | 不在 git 里，需要兜底 |
| 代码目录 | 不备份 | 在 git 仓库里 |

## 安全加固：umask 077

备份产物包含数据库全量 dump（含 `mysql.user` 表的密码哈希），必须严格控制权限：

```bash
# 脚本开头设置 umask，确保所有生成的文件 600、目录 700
umask 077
```

默认 umask 022 会生成 644/755 权限的文件，任何用户都能读。收紧到 077 后，只有 root 能读写备份文件。

## 恢复脚本设计

`restore.sh` 根据文件名自动判断备份类型：

```bash
FILENAME=$(basename "$BACKUP_SOURCE")

if [[ "$FILENAME" == mysql_* ]]; then
    # MariaDB SQL dump → docker exec -i mariadb mysql
elif [[ "$FILENAME" == vol_* ]]; then
    # Docker 数据卷 → alpine tar xzf 到目标卷
elif [[ "$FILENAME" == backup_*.tar.gz ]]; then
    # 完整配置备份 → 交互式选择恢复配置/数据库/全部
fi
```

完整配置备份的恢复支持三种模式：

1. **仅恢复配置文件**：把 `service-configs/` 下的配置复制回各服务目录。
2. **仅恢复数据库**：从 SQL dump 导入。
3. **全部恢复**：配置 + 数据库一起恢复。

每种模式都有确认提示（`--force` 可跳过），防止手滑覆盖生产数据。

## 恢复演练：容易暴露的问题

定期做恢复演练是备份体系的关键环节。一个可复用的演练流程：

1. 把备份文件拉到一台干净环境（或本地虚拟机）。
2. 按文档一步步还原：创建 Docker 卷 → 恢复数据卷 → 恢复数据库 → 启动服务。
3. **逐表记录数比对**——这是最硬的验证手段：

```bash
# 生产环境
docker exec mariadb mysql -uroot -p -e "SELECT table_name, table_rows FROM information_schema.tables WHERE table_schema='appdb';" > prod_counts.txt

# 恢复环境
docker exec mariadb mysql -uroot -p -e "..." > restored_counts.txt

# 比对
diff prod_counts.txt restored_counts.txt
```

4. 验证服务能正常启动、页面能打开、数据一致。

### 演练中常见的三类问题

**问题一：备份目录路径不匹配**

健康检查脚本 `health-check.sh` 里的 `BACKUP_DIR` 指向的目录，和备份脚本实际产出的目录不一致（例如一个写死 `/srv/stacks/backups/`，实际产物在 `/var/backups/stacks/daily/`）。结果健康检查一直报「最近 24h 备份未找到」——不是没备份，是检查的目录不对。**统一备份目录常量、让检查脚本和备份脚本引用同一个来源，是最简单的规避方式。**

**问题二：数据卷清单遗漏**

备份脚本里手工维护的数据卷列表，很容易漏掉后来新增的卷——例如某个后来新增的有状态服务的数据卷。演练时才发现对应文件在恢复后丢失，才补进清单。**每次新增有状态服务，都要同步更新备份卷清单。**

**问题三：凭据硬编码风险**

一种常见风险是：把数据库 root 密码硬编码进 `backup.sh` / `restore.sh` 作为回退值。如果这些脚本处于 git 跟踪范围内，凭据就可能随代码一起被推送出去。正确做法是**只从 `.env` 文件读取，读不到就报错退出，绝不降级**；一旦怀疑凭据外泄，应立即轮换。

**教训**：凭据唯一来源应该是权限受控的 `.env` 文件（600 root:root，被 `.gitignore` 排除），脚本里读不到就报错退出，绝不降级。

## 备份完整性监控

健康检查脚本每天凌晨 2:10 跑一次（备份脚本 3:00 跑），检查 `BACKUP_DIR` 下 24 小时内是否有 `backup_*.tar.gz`（配置 + 数据库 dump）和至少一个 `vol_*.tar.gz`（数据卷）。两者都有才算完整。

```bash
cfg_count=$(find "$BACKUP_DIR" -name 'backup_*.tar.gz' -mtime -1 | wc -l)
vol_count=$(find "$BACKUP_DIR" -name 'vol_*.tar.gz' -mtime -1 | wc -l)

if (( cfg_count > 0 && vol_count > 0 )); then
    result ok "最近24h备份" "配置 ${cfg_count} 个 + 数据卷 ${vol_count} 个"
else
    result warn "最近24h备份" "产物不完整"
fi
```

## 小结

备份体系的五条原则：

1. **备份要全覆盖**：数据库 + 配置 + 数据卷 + 不可再生的用户数据，一个都不能漏。
2. **备份要能恢复**：定期演练，逐表记录数比对确认零丢失。
3. **权限要收紧**：`umask 077`，备份文件只有 root 可读。
4. **凭据不硬编码**：从 `.env` 读，读不到就报错，不降级。
5. **可再生数据不备份**：静态资源产物、代码目录等，避免浪费存储。

下一篇讲 Uptime Kuma 监控体系的搭建。
