之前我的几台服务器一直处于一种很典型的个人项目状态:服务越堆越多,配置散落各处,Docker compose、Nginx、证书、数据库、机器人数据、博客静态目录都各有各的历史包袱。
平时一切正常,但只要认真想一下“如果某台机子今天炸了,我多久能恢复”,心里就会出现一种很不妙的沉默。
所以这次我干脆给五台服务器搭了一套统一的备份体系——方案设计和脚本审查是让 Codex 帮忙出的,我负责审一遍、改几处不靠谱的地方、然后在真机上跑。
整体方案不追求企业级灾备,不搞复杂的多地热备,也不做每周恢复演练这种会让个人项目变成工作的流程。
目标非常朴素:数据库是主菜,配置文件是筷子,依赖、镜像、虚拟环境这些到时候照清单重装。
也就是说,只备那些真正不可替代、或者重建成本很高的东西:
- 数据库一致性快照
- Docker compose 文件
- 服务配置
- Nginx 配置
- 证书
- 博客静态目录
- 关键应用数据
- 备份脚本自己和 systemd timer/service
最终结果是:
- 五台机器各自拥有一个独立的 Restic 仓库,后端统一使用 DigitalOcean Spaces
- 每天自动备份,每周 prune/check
- 成功、异常和失败都会发 Telegram HTML 通知
- 阿里云国内机器不直连 Telegram,而是通过第二台海外机器做 SSH 消息中继
最终架构总览
这次备份体系由四层组成:
- 数据采集层:各机器本地生成软件清单、Docker 清单、compose 展开配置、数据库一致性快照
- 备份执行层:Restic 负责加密、切块、去重、增量上传
- 对象存储层:DigitalOcean Spaces 作为 S3-compatible 后端,按机器拆分仓库
- 通知与巡检层:systemd timer 定时触发,Telegram 推送 HTML 通知,阿里云通过 SSH 中继通知
个人项目备份最重要的不是把整台机器原封不动复制下来,而是要明确哪些东西无法重建、哪些东西只需要一份清单。
Docker 镜像、Python 虚拟环境、npm 缓存这些可以重拉重装;数据库、配置、证书、业务数据才是命根子。
实施时间线
整个搭建历时两天(2026-05-23 至 2026-05-24)。
- 第一天:重点是抢救误锁端口的 Server 1、梳理备份范围、部署 status-monitor 面板及 Agent,并完成首轮备份初始化与 SQLite 去重优化
- 第二天:完成站点域名迁移与旧证书清理,并对 5 台服务器的备份脚本、锁机制及 Telegram HTML 通知进行全局升级,包含实施阿里云国内机的 SSH forced-command 消息中继
为什么选择 Restic + DigitalOcean Spaces
一开始也考虑过 s3cmd、rclone 或者直接 tar.gz 上传对象存储。
但备份不是简单搬文件,加密、增量、去重、快照、保留策略、校验、按路径恢复这些需求,正好是 Restic 的强项。
最终选择:
- Restic 做备份工具
- DigitalOcean Spaces SFO3 区域做后端
- 每台机器一个独立 repo
- 每台机器一组独立 Spaces Key(权限 Private / Restrict file listing)
- 通知走 Telegram Bot
- 调度用 systemd timer
每台机器一组 Key 是因为:如果五台共用一组 Access Key,任意一台泄露就可能影响全部仓库。
拆成五组后,单机泄露最多影响自己的备份仓库,爆炸半径小很多。
Spaces 里最终是五个前缀目录对应五台机器。
成本方面,Spaces 标准套餐包含 250GiB 存储和 1TiB 出站流量,入站上传不计入出站。
日常备份是从服务器上传到 Spaces,本质上是入站,所以主要关注存储容量。
Oracle OCI Always Free 的出站免费额度是 10TB 量级,当前规模远远够用。
真正需要注意的是恢复时从 Spaces 下载。
当前实际量级从几十 MiB 到几 GiB 不等,只要坚持把未压缩数据库快照交给 Restic 做去重,250GiB 对这套个人备份来说非常充裕。
五台机器的备份范围
这次不是把一套脚本无脑扔到所有机器上,而是先盘点每台机器的实际情况,再按角色定制备份范围。
为了避免泄露不必要的信息,下面的公网 IP 均做脱敏处理,机器名保留。
Server 1:Oracle-X86-1H1G-01
用途是 qq-bot-core / qq-bot-api 相关 Docker 服务。
备份覆盖了:
- 证书、Nginx、systemd 单元
- compose 目录
- status-monitor agent 配置
- 备份脚本本身
- 三个 Docker named volume(
qq-bot-core_data、qq-bot-core_config、qq-bot-core_cache)
这一台有个小插曲:一开始按 /var/lib/docker/volumes/... 检查时显示不存在,但 docker volume ls 又能看到 volume。
后来用 docker volume inspect 确认真实挂载点,发现父目录和 _data 目录的关系需要小心处理。
最终脚本支持通过 Docker 查询 mountpoint,但如果标准路径存在,就只备父目录,避免 _data 重复出现在快照路径里。
经典血泪教训:被“交叉错位”锁在门外的离线抢救
在第一天初始化备份前,这台服务器还发生了一次非常经典的运维事故——修改 SSH 端口与防火墙规则时的“交叉错位”导致彻底失联。
本来我想把系统 SSH 端口从默认的 22 收紧到高位安全端口 55522,但在操作时由于粗心,犯了一个极其容易让人抓狂的“交叉错位”错误:
- sshd 服务端配置改为仅监听
Port 55522 - 但 iptables 规则却只放行了默认的
Port 22 - 随后的默认链规则为
REJECT
这就造成了完美的死锁:
- 连 22 端口,防火墙放行但 sshd 没在监听,返回
Connection refused - 连 55522 端口,sshd 在等但防火墙把入站流量拦了,表现为
Connection timeout
当时我还尝试通过 Server 1 上原本部署的 noVNC 远程桌面去修复端口,甚至费了半天劲在里面找到了运行中的终端。
但我很绝望地发现,这个 noVNC 实际上运行在一个被隔离的 containerd 应用容器环境内部(提示符是 root@xxxxxxxxxxxxxxx:/app/qq-bot-core_data#):
- 容器内没有任何宿主机管理权限
- 既没有挂载
/var/run/docker.sock - 又无法访问
/dev下的磁盘设备 - 连
docker、ip、ss等系统命令都没有执行权限
在没有宿主机 root 权限和逃逸口的“容器孤岛”里,即使进了终端也根本无法修复 SSH 和防火墙。
最后不得不采用“离线挂载磁盘”的重兵器打法:
- 停止 Server 1 实例
- 将 Boot Volume 分离
- 挂载到同一局域网内健康的 Server 3 上识别为
/dev/sdb1 - 离线编辑
sshd_config.d/99-recovery-port.conf临时放行双监听(Port 22与Port 55522) - 离线编辑
iptables/rules.v4在REJECT前同时插入放行两条端口的 ACCEPT 规则 - 执行
sync后安全卸载 - 再把启动盘挂回 Server 1 启动
最终 Server 1 成功复活。
这个低级失误耽误了首备进度,也再次敲响了警钟——修改 SSH 等核心端口前,防火墙规则和 sshd 配置必须确保两端对齐,且新会话未测试通过前,千万不要关闭当前已连接的活动终端。
Server 2:Oracle-X86-1H1G-02
用途是博客和 status-monitor 面板。
备份覆盖证书、Nginx、systemd 单元、博客目录、status-monitor 数据和 compose 文件。
status-monitor 的 SQLite 数据库使用未压缩一致性快照:脚本通过 Python 的 sqlite3.backup() 在线生成,并执行 pragma integrity_check; 确认结果为 ok 后再交给 Restic。
Server 3:Oracle-ARM-4H24G
这台是整个备份体系里最重的一台。
/opt/qq-bot/data约 3.6GiB- 主库
qq-bot.db约 2.7GiB - 并且是 WAL 模式
备份覆盖证书、Nginx、systemd 单元、qq-bot 的 config/data/docker-config/plugins 和备份脚本。
但显式排除了在线热库(qq-bot.db、qq-bot.db-wal、qq-bot.db-shm),改为备份未压缩的一致性快照 /var/backups/db/qq-bot.sqlite。
SQLite WAL 模式下,db + wal + shm 是一组在线状态文件。直接备份不一定损坏,但恢复时没有一致性快照直观可靠。
更重要的是,如果同时备“在线热库 + 一致性快照”,就会把几 GB 的数据库备两份。
所以最终选择是在线热库排除,一致性快照纳入 Restic。
Server 4:DigitalOcean1H1G
用途是 api-hub / llm-api / qq-bot-api / cli-proxy。
备份覆盖证书、Nginx、systemd 单元、各服务的 compose 文件和关键数据目录。
这台的核心是 api-hub 的 PostgreSQL。通过 pg_dumpall -U root 导出未压缩 SQL 到 /var/backups/db/docker-postgres-all.sql,同样不压缩,让 Restic 对未压缩 .sql 做块级去重。
Server 5:阿里云2H2G
博客国内节点、宝塔 Nginx、status-monitor agent。
这一台没有数据库,重点就是博客目录、宝塔 Nginx 配置、vhost、证书和 status-monitor agent。
Restic 配置与敏感信息隔离
每台机器都有一个 /etc/restic/env,里面放:
- Spaces Access Key/Secret
- Restic Password
- Telegram Bot Token/Chat ID
- 通知模式(HTML)
- 备份别名
- 各种告警阈值(上传量、耗时、新增/修改文件数等)
没有数据库一致性快照的机器不设置 BACKUP_DB_PATH,通知里也不会出现数据库行。
权限锁死到 700 /etc/restic、600 /etc/restic/env。
Restic 初始化时 source 这个 env 并设置 HOME=/root 和 XDG_CACHE_HOME=/root/.cache(否则 systemd 环境下会报找不到缓存目录)。
绝不把 /etc/restic/env 纳入备份——里面有 Spaces Access Key、Restic Password、Telegram Bot Token,这个文件默认被 Restic 排除。
炸机后应该从离线保存的配置重新写入,而不是从备份仓库里恢复。
每日备份脚本与调度
每日脚本不是简单执行 restic backup,而是一个完整流程:
核心锁用的是 flock -n 对 /run/restic-backup.lock,每日备份和每周维护共用同一把锁,避免 backup 和 prune 撞车。
锁文件放在 /run 是因为它是运行时目录,重启后自然清空——真正生效的是进程持有的 flock,不是那个 0 字节文件本身,进程结束后锁自动释放。
调度用 systemd timer:
- 每日
OnCalendar=*-*-* 03:30:00 - 加
RandomizedDelaySec=30m和Persistent=true,保证重启后也能补跑 - Service 设置
Nice=10、IOSchedulingClass=best-effort、IOSchedulingPriority=7,让备份跑得不那么“抢”
为什么每日不 prune?
prune 会对对象存储做较多删除和重排操作,没必要每天跑。
weekly 统一做 forget --prune + check --read-data-subset=1G,也能降低与日常备份互相抢锁的概率。
重要优化:减少压缩,把增量能力还给 Restic
一开始数据库快照采用了 .sqlite.gz / .sql.gz。
这看起来合理:压缩节省空间。但很快发现,对 Restic 来说这不是最优。
问题在于:gzip 是流式压缩。源文件前面一小段变化,后面的压缩流也可能大面积变化。
于是一个每天只变化一点的数据库,在 Restic 看来可能像一个每天都在变化的大压缩包。
最终优化原则很简单:
- 给 Restic 的长期备份尽量不压缩
- 给人手动下载的临时归档可以压缩
具体做法:
- 普通目录直接交给 Restic,不 tar 不 gzip
- SQLite 生成未压缩
.sqlite一致性快照 - PostgreSQL 生成未压缩
.sqldump - 可选的
.gz归档用 pigz 并发压缩但默认排除,不进入 Restic 主备份
第三台 qq-bot 优化效果最明显:
- 首备时压缩包和在线库都参与备份,仓库新增约 5.3GiB
- 切换为未压缩 SQLite 快照并排除在线热库后,下一次增量只新增约 85MiB
pigz 可以让 gzip 吃满多核,但如果压缩包本身破坏增量,那么跑满 CPU 只是更快地制造一个“不利于去重的大文件”。
长期备份的核心优化不是并发压缩,而是让 Restic 能看见未压缩数据的稳定块。
Telegram HTML 通知
成功通知必须短,只放真正需要扫一眼的信息;失败通知可以详细,带阶段、退出码和最近 40 行日志。
格式统一使用 Telegram HTML,不再使用 MarkdownV2。
成功通知包含:
- 机器名
- 状态(OK/异常)
- 数据库类型和大小
- 快照 ID
- 耗时
- 上传量、总量和文件变化数
如果没有数据库快照,数据库:... 这一行不会显示。
成功通知里也不再放仓库路径、恢复命令、核心路径清单之类的长内容,避免每天刷屏。
异常不是“成功通知里写一行异常:无”,而是真的触发阈值才变成异常通知:
- 上传量、耗时、新增/修改文件数超过告警阈值
- 或者 SNAPSHOT_ID 为空
- 或者数据库文件不存在
通知失败不会让备份失败。备份成功和通知成功是两件事,不能因为 Telegram 抽风就把 Restic 备份标红。
预览图:

阿里云国内机的 Telegram SSH 中继
第五台阿里云机器无法稳定直连 api.telegram.org,国内网络环境下不能假设 Telegram API 可达。
最终方案不是给阿里云折腾代理,而是让第二台海外服务器做一个极小的通知中继。
中继脚本 /usr/local/bin/telegram-relay-send.sh 在 Server 2 上,从 SSH_ORIGINAL_COMMAND 或 stdin 读取消息然后按 HTML 模式 curl Telegram。
阿里云保存专用私钥 /etc/restic/telegram_relay_ed25519,Server 2 的 authorized_keys 对该 key 做了 restrict,command=... 限制——这把 key 只能执行通知中继脚本,不能拿交互 shell,不能随便跑命令。
一开始阿里云脚本还是“先直连 Telegram,失败后再走中继”。
后来确认国内机直连 Bot API 没意义,于是改成 relay-only:阿里云 daily/weekly 的 send_telegram() 直接把消息通过 stdin 管道交给 SSH 中继。
这里踩过一个很小但很致命的坑:不能写 ssh -n。
-n 会让 SSH 从 /dev/null 读取 stdin,导致管道里的通知正文根本没送到中继脚本,但命令可能仍然返回 0。
最终写法是不带 -n,用管道把消息直接送进去,失败只写 warning 不影响备份。
中继私钥 /etc/restic/telegram_relay_ed25519 和 /etc/restic/env 一样属于敏感材料,不纳入 Restic,离线保存,炸机后手动写回。
恢复思路
如果某台机器炸了,恢复流程大体一致:
- 新开实例
- 安装基础系统包
- 安装 Restic
- 写回
/etc/restic/env restic restore latest --target /tmp/restore-test做小样- 确认无误后恢复到
/ - 检查 nginx / docker compose / systemd
- 恢复数据库
- 启动服务
恢复到根目录前先做小样。 不要上来就 --target /。
先恢复关键文件到 /tmp/restore-test,确认路径、权限、快照时间都对,再做真正恢复。
个人项目的恢复最怕“手滑把新环境覆盖成旧错误状态”。
数据库恢复比较直接:
- SQLite 类(qq-bot、status-monitor):停服务,把一致性快照 cp 到数据目录替代在线库,删掉 wal/shm 文件,再启动
- PostgreSQL 类(api-hub):先起 postgres 容器,
psql -U root < dump.sql恢复,再启动其余容器
踩坑记录
systemd 环境没有 HOME。
Restic 在 systemd 里跑报 unable to locate cache directory,因为 $XDG_CACHE_HOME 和 $HOME 都没定义。
加上 export HOME="${HOME:-/root}" 和 export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/root/.cache}" 就行。
PowerShell 远程传脚本容易乱展开。
从 Windows PowerShell 远程传 bash 脚本时,$、引号、here-string 都容易出事故。
后来统一改用 base64 传输远端脚本:ssh user@host "echo $b64 | base64 -d | bash"。
阿里云源里没有 restic。
Alibaba Cloud Linux 3 的 yum/dnf 源里没有 restic 包。
最终是下载官方 GitHub release 的静态二进制,在本地校验 SHA256 后传到服务器,装到 /usr/local/bin/restic,再建一个 /usr/bin/restic 软链接兼容 sudo 的安全 PATH。
Telegram 通知不能阻塞备份。
通知命令必须设超时(--connect-timeout 10 --max-time 30)。
海外机器直接 curl Telegram,阿里云不再尝试直连,直接走中继。
中继失败只写 warning,不影响备份成功。
备份脚本自己也要备。
一开始只备了业务目录和配置,后来意识到 /usr/local/bin/ 下的备份脚本和 /etc/systemd/system/ 下的 timer/service 文件也应该纳入备份。
炸机后恢复脚本,再补 /etc/restic/env,整套流程就能复用。
还没结束
至此,五台机器终于从“出事以后靠记忆和运气恢复”,升级成了“有快照、有清单、有数据库一致性、有通知、有恢复测试”的状态。
我最满意的不是“备份跑起来了”,而是这套系统现在有明确的边界——什么该备、什么不该备、什么靠清单重装、什么要一致性快照、什么不能压缩、什么不能进备份仓库——这比单纯把整机打包上传可靠得多,也省得多。
个人项目不一定要上企业级灾备,但至少应该让未来的自己少熬几次夜。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时