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

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

备份策略总览

备份目录: /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

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。

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 命名卷里,必须单独备份:

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

备份方式是启动一个临时 Alpine 容器,挂载目标卷(只读)和备份目录,用 tar czf 打包:

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

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

哪些不备份?为什么

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

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

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

决策框架:

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

安全加固:umask 077

备份产物包含数据库全量 dump(含 mysql.user 表的密码哈希),必须严格控制权限:

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

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

恢复脚本设计

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

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. 逐表记录数比对——这是最硬的验证手段:
# 生产环境
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
  1. 验证服务能正常启动、页面能打开、数据一致。

演练中常见的三类问题

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

健康检查脚本 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(数据卷)。两者都有才算完整。

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 监控体系的搭建。