mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
4604 字
12 分钟
“如果今天服务器炸了怎么办?”:我的五台服务器 Restic 自动备份落地记
2026-05-24

之前我的几台服务器一直处于一种很典型的个人项目状态:

服务越堆越多,配置散落各处,Docker compose、Nginx、证书、数据库、机器人数据、博客静态目录都各有各的历史包袱。

平时一切正常,但只要认真想一下“如果某台机子今天炸了,我多久能恢复”,心里就会出现一种很不妙的沉默。

所以这次我干脆给五台服务器搭了一套统一的备份体系。整体方案不追求企业级灾备,不搞复杂的多地热备,也不做每周恢复演练这种会让个人项目变成工作的流程。目标非常朴素:

数据库是主菜
配置文件是筷子
依赖、镜像、虚拟环境这些到时候照清单重装

也就是说,只备那些真正不可替代、或者重建成本很高的东西:

数据库一致性快照
Docker compose 文件
服务配置
Nginx 配置
证书
博客静态目录
关键应用数据
备份脚本自己
systemd timer/service

最终结果是:五台机器各自拥有一个独立的 Restic 仓库,后端统一使用 DigitalOcean Spaces;每天自动备份,每周 prune/check;成功、异常和失败都会发 Telegram HTML 通知;阿里云国内机器不直连 Telegram,而是通过第二台海外机器做 SSH 消息中继。


最终架构总览#

这次备份体系由四层组成:

  1. 数据采集层:各机器本地生成软件清单、Docker 清单、compose 展开配置、数据库一致性快照。
  2. 备份执行层:Restic 负责加密、切块、去重、增量上传。
  3. 对象存储层:DigitalOcean Spaces 作为 S3-compatible 后端,按机器拆分仓库。
  4. 通知与巡检层:systemd timer 定时触发,Telegram 推送 HTML 通知,阿里云通过 SSH 中继通知。

整体拓扑如下:

flowchart TD subgraph Servers[五台服务器] S1[Server 1<br>snowluma / napcat] S2[Server 2<br>MyBlog / Komari 面板] S3[Server 3<br>MaiBot] S4[Server 4<br>new-api / qwen2API / napcat / cli-proxy] S5[Server 5<br>阿里云博客国内节点 / 宝塔 Nginx] end subgraph LocalJobs[本地备份任务] DUMP[数据库一致性快照<br>SQLite backup API / pg_dumpall] LIST[软件清单 / Docker 清单 / compose config] RESTIC[Restic backup<br>加密 / 切块 / 去重 / 增量] TIMER[systemd timer<br>daily + weekly] end subgraph DO[DigitalOcean Spaces] BKT[example-backups] R1[repo: Oracle-X86-1H1G-01] R2[repo: Oracle-X86-1H1G-02] R3[repo: Oracle-ARM-4H24G] R4[repo: DigitalOcean1H1G] R5[repo: 阿里云2H2G] end subgraph Notify[通知链路] TG[Telegram Bot] Relay[Server 2<br>Telegram SSH relay] end S1 --> DUMP S2 --> DUMP S3 --> DUMP S4 --> DUMP S5 --> LIST DUMP --> RESTIC LIST --> RESTIC TIMER --> RESTIC RESTIC --> BKT BKT --> R1 BKT --> R2 BKT --> R3 BKT --> R4 BKT --> R5 S1 --> TG S2 --> TG S3 --> TG S4 --> TG S5 -->|relay-only| Relay Relay --> TG
备份系统的核心判断

个人项目备份最重要的不是把整台机器原封不动复制下来,而是要明确:哪些东西无法重建,哪些东西只需要一份清单。Docker 镜像、Python 虚拟环境、npm 缓存这些可以重拉重装;数据库、配置、证书、业务数据才是命根子。


实施时间线#

这次备份体系的搭建与优化历时两天(2026-05-23 至 2026-05-24)。第一天重点是抢救误锁端口的 Server 1、梳理备份范围、部署轻量自托管监控 Komari 面板及 Agent,并完成首轮备份初始化与 SQLite 去重优化;第二天则是完成站点域名迁移与旧证书清理,并对 5 台服务器的备份脚本、锁机制及 Telegram HTML 通知进行全局升级重构,包含实施阿里云国内机的 SSH forced-command 消息中继。

gantt title 五台服务器备份体系实施时间线 (2026-05-23 至 2026-05-24) dateFormat YYYY-MM-DD HH:mm axisFormat %m-%d %H:%M section 方案确认与抢救 (05-23) 意外排查与 Server 1 磁盘挂载离线抢救 :done, s1_rescue, 2026-05-23 15:19, 1h 10m 备份范围梳理与 Spaces 规划 :done, plan, 2026-05-23 16:47, 27m Komari 面板部署与 5台 Agent 批量注册 :done, komari, 2026-05-23 18:01, 46m section 第一轮备份实施 (05-23) Server 1 首备实验与 Telegram 通知定制 :done, s1_backup, 2026-05-23 20:46, 17m Server 2 部署、SQLite 快照及脚本备份 :done, s2_backup, 2026-05-23 21:03, 9m 锁与 timer 机制运维验收验证 :done, lock_verify, 2026-05-23 21:12, 4m Server 3 (MaiBot 5.3G) 首备 :done, s3_first, 2026-05-23 21:16, 9m 去重优化:切换为未压缩 SQLite 快照 :done, db_optimize, 2026-05-23 21:25, 25m section 域名迁移与架构重构 (05-24) 站点 xxx.codes 迁移与旧证书清理 :done, migration, 2026-05-24 17:07, 25m 5台机器全局备份脚本与 systemd 重构 :done, refactor_sys, 2026-05-24 17:59, 20m Telegram HTML 通知格式升级与变量转义 :done, tg_html, 2026-05-24 18:19, 20m 阿里云 SSH 中继 (relay-only) 实施与验收:done, relay_opt, 2026-05-24 18:39, 20m

为什么选择 Restic + DigitalOcean Spaces#

一开始其实也考虑过 s3cmdrclone 或者直接 tar.gz 上传对象存储。DigitalOcean 官方也有 s3cmd 文档,确实适合上传大文件。但备份不是简单搬文件,核心需求是:

加密
增量
去重
快照
保留策略
校验
按路径恢复

这些正好是 Restic 的强项。

最终选择:

备份工具:Restic
对象存储:DigitalOcean Spaces
区域:SFO3
权限:Private / Restrict file listing
仓库数量:每台机器一个 repo
Spaces Key:每台机器一组独立 key
通知:Telegram Bot
调度:systemd timer

Spaces 里最终是这样的结构:

example-backups/
Oracle-X86-1H1G-01/
Oracle-X86-1H1G-02/
Oracle-ARM-4H24G/
DigitalOcean1H1G/
阿里云2H2G/
为什么每台机器一组 Spaces Key

如果五台机器共用一组 Access Key,那么任意一台机器泄露,都可能影响全部仓库。拆成五组 key 后,单机泄露最多影响自己的备份仓库,爆炸半径小很多。


五台机器的备份范围#

这次不是把一套脚本无脑扔到所有机器上,而是先盘点每台机器的实际情况,再按角色定制备份范围。

为了避免泄露不必要的信息,下面的公网 IP 均做脱敏处理,机器名保留。

Server 1:Oracle-X86-1H1G-01#

用途:snowluma / napcat 相关 Docker 服务。

实际纳入备份:

/etc/letsencrypt
/etc/nginx
/etc/systemd/system
/etc/systemd/system/daily-restic-backup.service
/etc/systemd/system/daily-restic-backup.timer
/etc/systemd/system/weekly-restic-maintenance.service
/etc/systemd/system/weekly-restic-maintenance.timer
/home/ubuntu/snowluma
/opt/komari-agent/auto-discovery.json
/usr/local/bin/daily-restic-backup.sh
/usr/local/bin/weekly-restic-maintenance.sh
/var/backups/db
/var/lib/docker/volumes/snowluma_snowluma-data
/var/lib/docker/volumes/snowluma_snowluma-qq-config
/var/lib/docker/volumes/snowluma_snowluma-qq-data

这一台有个小插曲:一开始按 /var/lib/docker/volumes/... 检查时显示不存在,但 docker volume ls 又能看到 volume。后来用 docker volume inspect 确认真实挂载点,发现父目录和 _data 目录的关系需要小心处理。最终脚本支持通过 Docker 查询 mountpoint,但如果标准路径存在,就只备父目录,避免 _data 重复出现在快照路径里。

🚨 经典血泪教训:被“交叉错位”锁在门外的 Server 1 离线抢救#

在第一天初始化备份前,这台服务器还发生了一次非常经典的运维事故 —— 修改 SSH 端口与防火墙规则时的“交叉错位”导致彻底失联

本来我想把系统 SSH 端口从默认的 22 收紧到高位安全端口 55522,但在操作时由于粗心,犯了一个极其容易让人抓狂的“交叉错位”错误:

  • sshd 服务端配置:改为仅监听高端口 Port 55522
  • iptables 规则配置:却只放行了默认的 Port 22,随后的默认链规则为 REJECT

这就造成了完美的死锁局面:

  • 连 22 端口:虽然 iptables 防火墙大开绿灯予以放行,但宿主机上 sshd 并没有在 22 端口监听,系统直接返回 Connection refused
  • 连 55522 端口:虽然 sshd 正在此端口静静等候握手,但外部入站流量直接被 iptables 防火墙的拦截规则拒之门外,表现为 Connection timeoutNo route to host

当时我还尝试通过 Server 1 上原本部署的那个 noVNC 远程桌面去修复端口,甚至费了半天劲在里面找到了运行中的终端。但我很快绝望地发现,这个 noVNC 实际上仅仅运行在一个被隔离的 containerd 应用容器环境内部(提示符是 root@xxxxxxxxxxxxxxx:/app/snowluma-data#),容器内没有任何宿主机管理权限,既没有挂载宿主机的 /var/run/docker.sock,又无法访问 /dev 下的磁盘设备,连 dockeripss 等系统命令都没有执行权限。在没有宿主机 root 权限和逃逸口的“容器孤岛”里,即使进了终端也根本无法直接对宿主机的 SSH 监听和防火墙实施救回。

救援方案: 我最后不得不采用“离线挂载磁盘”的重兵器打法:

  1. 停止 Server 1 实例,将它的 Boot Volume分离;
  2. 挂载到同一局域网内健康的 Server 3 (Oracle-ARM-4H24G) 上,识别为 /dev/sdb1
  3. 将坏机磁盘分区挂载到 /mnt/rescue
  4. 离线编辑其 /mnt/rescue/etc/ssh/sshd_config.d/99-recovery-port.conf 临时放行双监听(Port 22Port 55522);
  5. 离线编辑其 /mnt/rescue/etc/iptables/rules.v4 防火墙持久化配置,在 REJECT 动作前同时插入放行 2255522 的 ACCEPT 规则;
  6. 执行 sync 并安全卸载 /mnt/rescue
  7. 将启动盘安全分离并挂载回 Server 1 启动。

最终,Server 1 成功复活,SSH 通道重回掌控!这个经典的低级失误不仅耽误了首备进度,也再次敲响了警钟 —— 修改 SSH 等核心端口前,防火墙规则和 sshd 配置必须确保两端对齐,且新会话未测试通过前,千万不要关闭当前已连接的活动终端!

Server 2:Oracle-X86-1H1G-02#

用途:MyBlog / Komari 面板 / Komari agent

实际纳入备份:

/etc/letsencrypt
/etc/nginx
/etc/systemd/system/daily-restic-backup.service
/etc/systemd/system/daily-restic-backup.timer
/etc/systemd/system/komari-agent.service
/etc/systemd/system/weekly-restic-maintenance.service
/etc/systemd/system/weekly-restic-maintenance.timer
/opt/komari-agent/auto-discovery.json
/opt/komari/README-deploy.txt
/opt/komari/data
/opt/komari/docker-compose.yml
/usr/local/bin/daily-restic-backup.sh
/usr/local/bin/weekly-restic-maintenance.sh
/var/backups/db
/www/wwwroot/MyBlog

Komari 的 SQLite 数据库使用未压缩一致性快照:

/var/backups/db/komari.sqlite
/var/backups/db/komari-YYYY-MM-DD.sqlite

脚本通过 Python 的 sqlite3.backup() 在线生成一致性快照,并执行:

pragma integrity_check;

确认结果为 ok 后再交给 Restic。

Server 3:Oracle-ARM-4H24G#

用途:MaiBot

这台是整个备份体系里最重的一台,/opt/MaiBot/data 约 3.6GiB,主库 MaiBot.db 约 2.7GiB,并且是 WAL 模式。

实际纳入备份:

/etc/letsencrypt
/etc/nginx
/etc/systemd/system/daily-restic-backup.service
/etc/systemd/system/daily-restic-backup.timer
/etc/systemd/system/maibot.service
/etc/systemd/system/weekly-restic-maintenance.service
/etc/systemd/system/weekly-restic-maintenance.timer
/opt/MaiBot/config
/opt/MaiBot/data
/opt/MaiBot/docker-config
/opt/MaiBot/plugins
/usr/local/bin/daily-restic-backup.sh
/usr/local/bin/weekly-restic-maintenance.sh
/var/backups/db

同时显式排除在线热库:

/opt/MaiBot/data/MaiBot.db
/opt/MaiBot/data/MaiBot.db-wal
/opt/MaiBot/data/MaiBot.db-shm

改为备份一致性快照:

/var/backups/db/maibot.sqlite
/var/backups/db/maibot-YYYY-MM-DD.sqlite
为什么排除在线热库

SQLite WAL 模式下,db + wal + shm 是一组在线状态文件。直接备份不一定损坏,但恢复时没有一致性快照直观可靠。更重要的是,如果同时备“在线热库 + 一致性快照”,就会把几 GB 的数据库备两份。最终选择:在线热库排除,一致性快照纳入 Restic

Server 4:DigitalOcean1H1G#

用途:new-api / qwen2API / napcat / cli-proxy

实际纳入备份:

/etc/letsencrypt
/etc/nginx
/etc/systemd/system/daily-restic-backup.service
/etc/systemd/system/daily-restic-backup.timer
/etc/systemd/system/weekly-restic-maintenance.service
/etc/systemd/system/weekly-restic-maintenance.timer
/opt/napcat
/opt/services/cli-proxy-plus-2/auths
/opt/services/cli-proxy-plus-2/config.yaml
/opt/services/cli-proxy-plus-2/docker-compose.yml
/opt/services/new-api/data
/opt/services/new-api/docker-compose.yml
/opt/services/qwen2API/<ENV_FILE>
/opt/services/qwen2API/data
/opt/services/qwen2API/docker-compose.yml
/usr/local/bin/daily-restic-backup.sh
/usr/local/bin/weekly-restic-maintenance.sh
/var/backups/db

new-api 的 PostgreSQL 是这台的核心:

/var/backups/db/docker-postgres-all.sql
/var/backups/db/docker-postgres-all-YYYY-MM-DD.sql

生成方式:

cd /opt/services/new-api
docker compose exec -T postgres pg_dumpall -U root > /var/backups/db/docker-postgres-all.sql.tmp
mv /var/backups/db/docker-postgres-all.sql.tmp /var/backups/db/docker-postgres-all.sql

这里也不压缩,让 Restic 对未压缩 .sql 做块级去重。

Server 5:阿里云2H2G#

用途:博客国内节点、宝塔 Nginx、Komari agent。

实际纳入备份:

/etc/systemd/system/daily-restic-backup.service
/etc/systemd/system/daily-restic-backup.timer
/etc/systemd/system/komari-agent.service
/etc/systemd/system/weekly-restic-maintenance.service
/etc/systemd/system/weekly-restic-maintenance.timer
/opt/komari-agent/auto-discovery.json
/usr/local/bin/daily-restic-backup.sh
/usr/local/bin/weekly-restic-maintenance.sh
/var/backups/db
/www/server/nginx/conf
/www/server/panel/vhost/cert
/www/server/panel/vhost/letsencrypt
/www/server/panel/vhost/nginx
/www/server/panel/vhost/ssl
/www/wwwroot/MyBlog

这一台没有数据库,重点就是博客目录、宝塔 Nginx 配置、vhost、证书和 Komari agent。


Restic 配置与敏感信息隔离#

每台机器都有一个 /etc/restic/env

export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
export RESTIC_PASSWORD="..."
export RESTIC_REPOSITORY="s3:https://sfo3.digitaloceanspaces.com/example-backups/..."
export TELEGRAM_BOT_TOKEN="..."
export TELEGRAM_CHAT_ID="..."
export TELEGRAM_PARSE_MODE="HTML"
export TELEGRAM_NOTIFY_SUCCESS="1"
export BACKUP_ALIAS="Oracle-ARM-4H24G"
export BACKUP_INSTANCE="Oracle-ARM-4H24G"
export BACKUP_WARN_UPLOAD_MIB="1024"
export BACKUP_WARN_DURATION_MIN="20"
export BACKUP_WARN_NEW_FILES="500"
export BACKUP_WARN_CHANGED_FILES="1000"
export BACKUP_DB_PATH="/var/backups/db/maibot.sqlite"
export BACKUP_DB_LABEL="SQLite"

其中 BACKUP_ALIAS 是 Telegram 通知里的友好名称,BACKUP_INSTANCE 是云厂商实例名或机器标识。没有数据库一致性快照的机器不设置 BACKUP_DB_PATH,通知里也不会出现数据库行。

权限锁死:

sudo mkdir -p /etc/restic
sudo chmod 700 /etc/restic
sudo chmod 600 /etc/restic/env
绝不把 /etc/restic/env 纳入备份

/etc/restic/env 里有 Spaces Access Key、Restic Password、Telegram Bot Token。这个文件默认被 Restic 排除。炸机后应该从离线保存的配置重新写入,而不是从备份仓库里恢复。

Restic 初始化:

sudo bash -c 'source /etc/restic/env && export HOME=/root XDG_CACHE_HOME=/root/.cache && restic init'

每日备份脚本设计#

每日脚本的职责不是简单执行 restic backup,而是一个完整流程:

flowchart TD A[systemd timer 触发] --> B[读取 /etc/restic/env] B --> C[获取 flock 锁] C --> D[生成软件清单和 Docker 清单] D --> E[导出 docker compose config] E --> F{是否有数据库} F -->|SQLite| G[sqlite3 backup API<br>生成未压缩一致性快照] F -->|PostgreSQL| H[pg_dumpall 导出未压缩 SQL] F -->|无数据库| I[跳过 dump] G --> J[收集存在的核心路径] H --> J I --> J J --> K[restic backup] K --> L[restic forget<br>不 prune] L --> M[restic check --no-cache] M --> N[写 last-success / snapshots] N --> O[发送 Telegram HTML 通知]

核心锁:

exec 9>/run/restic-backup.lock
flock -n 9 || {
echo "Restic backup or maintenance already running"
exit 0
}

这个锁同时被每日备份和每周维护共用,避免 backupprune 撞车。

为什么锁文件在 /run

/run 是运行时目录,重启后自然清空。更重要的是,真正生效的是进程持有的 flock,不是那个 0 字节文件本身。进程结束后锁自动释放,不会形成“锁文件残留导致永久死锁”。


systemd timer:重启后也能继续跑#

每日备份 service:

[Unit]
Description=Daily Restic Backup to DigitalOcean Spaces
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/daily-restic-backup.sh
TimeoutStartSec=6h
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7

每日备份 timer:

[Unit]
Description=Run Daily Restic Backup
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target

每周维护同理,只是执行:

restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
restic check --read-data-subset=1G

为什么每日不 prune?

daily:backup + forget + check --no-cache
weekly:forget --prune + check --read-data-subset=1G

prune 会对对象存储做较多删除和重排操作,没有必要每天跑。每周维护一次就够了,也能降低与日常备份互相抢锁的概率。


重要优化:减少压缩,把增量能力还给 Restic#

一开始数据库快照采用了 .sqlite.gz / .sql.gz。这看起来合理:压缩节省空间。但很快发现,对 Restic 来说这不是最优。

问题在于:gzip 是流式压缩。源文件前面一小段变化,后面的压缩流也可能大面积变化。于是一个每天只变化一点的数据库,在 Restic 看来可能像一个每天都在变化的大压缩包。

最终优化原则:

给 Restic 的长期备份:尽量不压缩
给人手动下载的临时归档:可以压缩

当前策略:

普通目录:
直接交给 Restic
不 tar
不 gzip
SQLite:
生成未压缩 .sqlite 一致性快照
Restic 对 .sqlite 做切块去重
PostgreSQL:
生成未压缩 .sql dump
Restic 对 .sql 做切块去重
可选压缩:
export BACKUP_CREATE_COMPRESSED_DB="1"
使用 pigz 并发压缩
但 .gz 默认排除,不进入 Restic 主备份

第三台 MaiBot 优化效果最明显。首备时,压缩包和在线库都参与备份,仓库新增约 5.3GiB。切换为未压缩 SQLite 快照并排除在线热库后,下一次增量只新增约 85MiB。

优化前:大压缩包削弱去重
优化后:Restic 对未压缩 .sqlite 做块级增量
不要为了“压缩更快”而解决错问题

pigz 可以让 gzip 吃满多核,但如果压缩包本身破坏增量,那么跑满 CPU 只是更快地制造一个“不利于去重的大文件”。长期备份的核心优化不是并发压缩,而是让 Restic 能看见未压缩数据的稳定块。


Telegram HTML 通知#

通知后来又做了一次瘦身:成功通知必须短,只放真正需要扫一眼的信息;失败通知可以详细,带阶段、退出码和最近 40 行日志。格式统一使用 Telegram HTML,不再使用 MarkdownV2。

成功通知大概长这样:

<b>✅ 备份成功|Oracle-ARM-4H24G</b>
<code>Oracle-ARM-4H24G</code>
<blockquote>🟢 状态:OK
数据库:SQLite,2.6G
📌 快照:<SNAPSHOT_ID></blockquote>
<b>摘要</b>
<pre>时间 05-24 18:37 CST
耗时 2 分钟
上传 504.7 MiB / 38.1 MiB
总量 8.54 GiB
变化 +31 / ~29</pre>

预览图:image-20260524201223239

如果没有数据库快照,数据库:... 这一行不会显示。成功通知里也不再放仓库路径、恢复命令、核心路径清单、目录变化、restic stats 之类的长内容,避免每天刷屏。

异常不是“成功通知里写一行异常:无”,而是真的触发阈值才变成异常通知。当前规则包括:

上传量 > BACKUP_WARN_UPLOAD_MIB
耗时 > BACKUP_WARN_DURATION_MIN
新增文件数 > BACKUP_WARN_NEW_FILES
修改文件数 > BACKUP_WARN_CHANGED_FILES
SNAPSHOT_ID 为空或未知
BACKUP_DB_PATH 已设置但文件不存在

失败通知则保留更多上下文:

<b>❌ 备份失败|Oracle-ARM-4H24G</b>
<code>Oracle-ARM-4H24G</code>
<blockquote>阶段:上传 Restic 快照
退出码:1</blockquote>
<b>最近日志</b>
<pre>Fatal: unable to save snapshot
...</pre>

通知失败不会让备份失败。备份成功和通知成功是两件事,不能因为 Telegram 抽风就把 Restic 备份标红。


阿里云国内机的 Telegram SSH 中继#

第五台阿里云机器无法稳定直连 api.telegram.org。这很正常,国内网络环境下不能假设 Telegram API 可达。

最终方案不是给阿里云折腾代理,而是让第二台海外服务器做一个极小的通知中继:

sequenceDiagram participant Ali as 阿里云 Server 5 participant S2 as Server 2 通知中继 participant TG as Telegram Bot API Ali->>S2: SSH forced command 发送 HTML 通知文本 S2->>TG: curl Telegram Bot API TG-->>S2: HTTP 200

中继脚本在 Server 2:

/usr/local/bin/telegram-relay-send.sh

它从 SSH_ORIGINAL_COMMAND 或 stdin 读取消息,然后按 HTML 模式发送:

curl -fsS --retry 3 --connect-timeout 10 --max-time 30 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
-d "parse_mode=${TELEGRAM_PARSE_MODE:-HTML}" \
-d "disable_web_page_preview=true" \
--data-urlencode "text=${message}" >/dev/null

阿里云保存专用私钥:

/etc/restic/telegram_relay_ed25519

第二台的 authorized_keys 对该 key 做了限制:

restrict,command="/usr/local/bin/telegram-relay-send.sh" ssh-ed25519 ...

这意味着这把 key:

只能执行通知中继脚本
不能拿交互 shell
不能随便跑命令

一开始阿里云脚本还是“先直连 Telegram,失败后再走中继”。后来确认国内机直连 Bot API 没意义,于是改成 relay-only:阿里云 daily/weekly 的 send_telegram() 直接把消息通过 stdin 管道交给 SSH 中继。

这里踩过一个很小但很致命的坑:不能写 ssh -n-n 会让 SSH 从 /dev/null 读取 stdin,导致管道里的通知正文根本没送到中继脚本。最终写法是:

printf '%s' "$message" \
| ssh -i /etc/restic/telegram_relay_ed25519 -p 22 \
-o BatchMode=yes \
-o ConnectTimeout=8 \
-o StrictHostKeyChecking=accept-new \
<SSH_USER>@<中继服务器_IP> >/dev/null 2>&1 \
|| echo "WARN: Telegram SSH 通知中继失败" >&2
中继 key 也不要备份

/etc/restic/telegram_relay_ed25519 默认不纳入 Restic。它和 /etc/restic/env 一样属于敏感材料,应该离线保存,炸机后手动写回。


验收流程#

每台机器部署完都做了以下检查。

1. 首备成功#

sudo systemctl start daily-restic-backup.service
journalctl -u daily-restic-backup.service -n 120 --no-pager

确认:

snapshot xxx saved
no errors were found
Backup finished

2. 快照查看#

sudo bash -c 'source /etc/restic/env && export HOME=/root XDG_CACHE_HOME=/root/.cache && restic snapshots --latest 5'

3. 恢复小样测试#

不做全量恢复,但每台至少抽一个关键文件恢复到 /tmp,再 diff

示例:恢复 new-api compose 和 PostgreSQL dump:

sudo mkdir -p /tmp/restore-test-server4
sudo bash -c 'source /etc/restic/env && export HOME=/root XDG_CACHE_HOME=/root/.cache && \
restic restore latest \
--include /opt/services/new-api/docker-compose.yml \
--include /var/backups/db/docker-postgres-all.sql \
--target /tmp/restore-test-server4'
sudo diff -q \
/opt/services/new-api/docker-compose.yml \
/tmp/restore-test-server4/opt/services/new-api/docker-compose.yml

4. 并发锁测试#

人为占用锁,再启动 daily/weekly,确认都快速退出 0:

sudo bash -c '
exec 8>/run/restic-backup.lock
flock -n 8
echo outer_lock_acquired
set +e
/usr/local/bin/daily-restic-backup.sh
echo daily_code:$?
/usr/local/bin/weekly-restic-maintenance.sh
echo weekly_code:$?
'

预期:

Backup already running
Backup or maintenance already running

5. timer 重载和持久化检查#

sudo systemctl daemon-reload
sudo systemctl restart daily-restic-backup.timer weekly-restic-maintenance.timer
systemctl is-enabled daily-restic-backup.timer weekly-restic-maintenance.timer
systemctl is-active daily-restic-backup.timer weekly-restic-maintenance.timer
systemctl list-timers daily-restic-backup.timer weekly-restic-maintenance.timer --no-pager

确认:

enabled
enabled
active
active

6. Restic 仓库锁检查#

sudo bash -c 'source /etc/restic/env && export HOME=/root XDG_CACHE_HOME=/root/.cache && restic list locks'

正常情况下为空。


五台机器最终快照记录#

这次首轮部署和优化后,各机器都有成功快照。

机器角色最新验证快照备注
Server 1snowluma / napcat<SNAPSHOT_ID>备份 Docker volume、Nginx、证书、脚本
Server 2MyBlog / Komari<SNAPSHOT_ID>Komari SQLite 未压缩一致性快照
Server 3MaiBot<SNAPSHOT_ID>MaiBot SQLite 未压缩快照,排除在线热库
Server 4new-api / qwen2API<SNAPSHOT_ID>PostgreSQL 未压缩 SQL dump
Server 5阿里云博客节点<SNAPSHOT_ID>Telegram 只通过 Server 2 SSH 中继
快照 ID 会持续变化

表里的快照 ID 只是本次部署验收时的记录。后续每天定时任务都会生成新的快照,以 restic snapshots --latest 5 的结果为准。


成本与流量评估#

DigitalOcean Spaces 标准套餐包含:

250GiB 存储
1TiB 出站流量
入站上传不计入出站

日常备份是从服务器上传到 Spaces,本质上是 Spaces 入站,所以主要关注存储容量,而不是 DO 的出站流量。

Oracle OCI Always Free 的出站免费额度是 10TB 量级。当前备份规模远远够用。真正需要注意的是恢复时从 Spaces 下载,或者频繁全量拉取大快照。

当前实际量级:

Server 1:几百 MiB
Server 2:几十 MiB
Server 3:首备数 GiB,优化后增量明显变小
Server 4:几百 MiB,PostgreSQL dump 约几十 MiB
Server 5:约 2GiB 博客与宝塔配置

只要坚持“不把大数据库 gzip 后再每天上传”,250GiB 对这套个人备份来说非常充裕。


恢复思路#

如果某台机器炸了,恢复流程大体一致:

1. 新开实例
2. 安装基础系统包
3. 安装 Restic
4. 写回 /etc/restic/env
5. restic restore latest --target /
6. 检查 nginx / docker compose / systemd
7. 恢复数据库
8. 启动服务

MaiBot SQLite 恢复#

sudo systemctl stop maibot
sudo cp /var/backups/db/maibot.sqlite /opt/MaiBot/data/MaiBot.db
sudo chown ubuntu:ubuntu /opt/MaiBot/data/MaiBot.db
sudo rm -f /opt/MaiBot/data/MaiBot.db-wal /opt/MaiBot/data/MaiBot.db-shm
sudo systemctl start maibot

Komari SQLite 恢复#

cd /opt/komari
docker compose down
sudo cp /var/backups/db/komari.sqlite /opt/komari/data/komari.db
docker compose up -d

new-api PostgreSQL 恢复#

cd /opt/services/new-api
docker compose up -d postgres
docker compose exec -T postgres psql -U root < /var/backups/db/docker-postgres-all.sql
docker compose up -d

整机路径恢复#

sudo mkdir -p /tmp/restore-test
sudo bash -c 'source /etc/restic/env && export HOME=/root XDG_CACHE_HOME=/root/.cache && restic restore latest --target /tmp/restore-test'

确认无误后再恢复到 /

sudo bash -c 'source /etc/restic/env && export HOME=/root XDG_CACHE_HOME=/root/.cache && restic restore latest --target /'
恢复到根目录前先做小样

不要上来就 --target /。先恢复关键文件到 /tmp/restore-test,确认路径、权限、快照时间都对,再做真正恢复。个人项目的恢复最怕“手滑把新环境覆盖成旧错误状态”。


踩坑记录#

1. systemd 环境没有 HOME#

Restic 在 systemd 里运行时可能报:

unable to open cache: unable to locate cache directory: neither $XDG_CACHE_HOME nor $HOME are defined

解决:

export HOME="${HOME:-/root}"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/root/.cache}"

2. PowerShell 远程传脚本容易乱展开#

从 Windows PowerShell 远程传 bash 脚本时,$、引号、here-string 都容易出事故。后来统一改用 base64 传输远端脚本:

Terminal window
$remote = @'
set -euo pipefail
hostname
'@
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($remote))
ssh user@host "echo $b64 | base64 -d | bash"

3. 阿里云源里没有 restic#

Alibaba Cloud Linux 3 的 yum/dnf 源里没有 restic 包。最终做法是下载 Restic 官方 GitHub release 的静态二进制,在本地校验 SHA256 后传到服务器:

sudo install -m 755 /tmp/restic /usr/local/bin/restic
sudo ln -sf /usr/local/bin/restic /usr/bin/restic

创建 /usr/bin/restic 软链接是为了兼容 sudo 的安全 PATH。

4. Telegram 通知不能阻塞备份#

通知命令必须设置超时:

curl -fsS --retry 3 --connect-timeout 10 --max-time 30 ...

海外机器可以直接 curl Telegram Bot API。阿里云国内机则不再尝试直连,直接走 SSH forced-command 中继;如果中继失败,只写 warning,不影响备份成功。

另一个细节是:通过管道把消息传给 SSH 中继时,不要加 ssh -n-n 会丢掉 stdin,让中继脚本收不到正文,命令却可能仍然返回 0。

5. 备份脚本自己也要备#

一开始只备了业务目录和配置,后来意识到:

/usr/local/bin/daily-restic-backup.sh
/usr/local/bin/weekly-restic-maintenance.sh
/etc/systemd/system/daily-restic-backup.service
/etc/systemd/system/daily-restic-backup.timer
/etc/systemd/system/weekly-restic-maintenance.service
/etc/systemd/system/weekly-restic-maintenance.timer

这些也应该纳入备份。炸机后恢复脚本,再补 /etc/restic/env,整套流程就能复用。


最终总结#

这次备份体系最终形成了一个比较适合个人多服务器环境的形态:

一个 DigitalOcean Space
五个 Restic 仓库
五组 Spaces Key
每日 systemd timer
每周 prune/check
SQLite 未压缩一致性快照
PostgreSQL 未压缩 SQL dump
Restic 加密、切块、去重、增量
Telegram HTML 短成功通知 / 详细失败通知
阿里云通过 SSH 中继发 Telegram,不直连 Bot API
备份脚本和 systemd 单元文件也纳入备份
敏感 env/key 不入库,离线保存

我最满意的不是“备份跑起来了”,而是这套系统现在有明确的边界:

什么该备
什么不该备
什么靠清单重装
什么要一致性快照
什么不能压缩
什么不能进备份仓库

这比单纯把整机打包上传可靠得多,也省得多。

最后再重复一次这次最关键的经验:

Restic 备份的大原则

长期自动备份不要迷信大压缩包。
把普通文件和未压缩数据库快照交给 Restic,让它做自己最擅长的切块、去重和增量。
压缩包适合人工下载和临时搬运,不适合作为每天自动增量备份的主对象。

至此,五台机器终于从“出事以后靠记忆和运气恢复”,升级成了“有快照、有清单、有数据库一致性、有通知、有恢复测试”的状态。个人项目不一定要上企业级灾备,但至少应该让未来的自己少熬几次夜。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

“如果今天服务器炸了怎么办?”:我的五台服务器 Restic 自动备份落地记
https://github.com/Dawn6666666/MyBlog
作者
黎明
发布于
2026-05-24
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录