之前我的几台服务器一直处于一种很典型的个人项目状态:
服务越堆越多,配置散落各处,Docker compose、Nginx、证书、数据库、机器人数据、博客静态目录都各有各的历史包袱。
平时一切正常,但只要认真想一下“如果某台机子今天炸了,我多久能恢复”,心里就会出现一种很不妙的沉默。
所以这次我干脆给五台服务器搭了一套统一的备份体系。整体方案不追求企业级灾备,不搞复杂的多地热备,也不做每周恢复演练这种会让个人项目变成工作的流程。目标非常朴素:
数据库是主菜配置文件是筷子依赖、镜像、虚拟环境这些到时候照清单重装也就是说,只备那些真正不可替代、或者重建成本很高的东西:
数据库一致性快照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、梳理备份范围、部署轻量自托管监控 Komari 面板及 Agent,并完成首轮备份初始化与 SQLite 去重优化;第二天则是完成站点域名迁移与旧证书清理,并对 5 台服务器的备份脚本、锁机制及 Telegram HTML 通知进行全局升级重构,包含实施阿里云国内机的 SSH forced-command 消息中继。
为什么选择 Restic + DigitalOcean Spaces
一开始其实也考虑过 s3cmd、rclone 或者直接 tar.gz 上传对象存储。DigitalOcean 官方也有 s3cmd 文档,确实适合上传大文件。但备份不是简单搬文件,核心需求是:
加密增量去重快照保留策略校验按路径恢复这些正好是 Restic 的强项。
最终选择:
备份工具:Restic对象存储:DigitalOcean Spaces区域:SFO3权限:Private / Restrict file listing仓库数量:每台机器一个 repoSpaces Key:每台机器一组独立 key通知:Telegram Bot调度:systemd timerSpaces 里最终是这样的结构:
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 timeout或No route to host。
当时我还尝试通过 Server 1 上原本部署的那个 noVNC 远程桌面去修复端口,甚至费了半天劲在里面找到了运行中的终端。但我很快绝望地发现,这个 noVNC 实际上仅仅运行在一个被隔离的 containerd 应用容器环境内部(提示符是 root@xxxxxxxxxxxxxxx:/app/snowluma-data#),容器内没有任何宿主机管理权限,既没有挂载宿主机的 /var/run/docker.sock,又无法访问 /dev 下的磁盘设备,连 docker、ip、ss 等系统命令都没有执行权限。在没有宿主机 root 权限和逃逸口的“容器孤岛”里,即使进了终端也根本无法直接对宿主机的 SSH 监听和防火墙实施救回。
救援方案: 我最后不得不采用“离线挂载磁盘”的重兵器打法:
- 停止 Server 1 实例,将它的 Boot Volume分离;
- 挂载到同一局域网内健康的 Server 3 (
Oracle-ARM-4H24G) 上,识别为/dev/sdb1; - 将坏机磁盘分区挂载到
/mnt/rescue; - 离线编辑其
/mnt/rescue/etc/ssh/sshd_config.d/99-recovery-port.conf临时放行双监听(Port 22与Port 55522); - 离线编辑其
/mnt/rescue/etc/iptables/rules.v4防火墙持久化配置,在REJECT动作前同时插入放行22与55522的 ACCEPT 规则; - 执行
sync并安全卸载/mnt/rescue; - 将启动盘安全分离并挂载回 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/MyBlogKomari 的 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/dbnew-api 的 PostgreSQL 是这台的核心:
/var/backups/db/docker-postgres-all.sql/var/backups/db/docker-postgres-all-YYYY-MM-DD.sql生成方式:
cd /opt/services/new-apidocker compose exec -T postgres pg_dumpall -U root > /var/backups/db/docker-postgres-all.sql.tmpmv /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/resticsudo chmod 700 /etc/resticsudo 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,而是一个完整流程:
核心锁:
exec 9>/run/restic-backup.lockflock -n 9 || { echo "Restic backup or maintenance already running" exit 0}这个锁同时被每日备份和每周维护共用,避免 backup 和 prune 撞车。
为什么锁文件在/run
/run是运行时目录,重启后自然清空。更重要的是,真正生效的是进程持有的flock,不是那个 0 字节文件本身。进程结束后锁自动释放,不会形成“锁文件残留导致永久死锁”。
systemd timer:重启后也能继续跑
每日备份 service:
[Unit]Description=Daily Restic Backup to DigitalOcean SpacesWants=network-online.targetAfter=network-online.target
[Service]Type=oneshotExecStart=/usr/local/bin/daily-restic-backup.shTimeoutStartSec=6hNice=10IOSchedulingClass=best-effortIOSchedulingPriority=7每日备份 timer:
[Unit]Description=Run Daily Restic Backup
[Timer]OnCalendar=*-*-* 03:30:00RandomizedDelaySec=30mPersistent=true
[Install]WantedBy=timers.target每周维护同理,只是执行:
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prunerestic check --read-data-subset=1G为什么每日不 prune?
daily:backup + forget + check --no-cacheweekly:forget --prune + check --read-data-subset=1Gprune 会对对象存储做较多删除和重排操作,没有必要每天跑。每周维护一次就够了,也能降低与日常备份互相抢锁的概率。
重要优化:减少压缩,把增量能力还给 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>预览图:
如果没有数据库快照,数据库:... 这一行不会显示。成功通知里也不再放仓库路径、恢复命令、核心路径清单、目录变化、restic stats 之类的长内容,避免每天刷屏。
异常不是“成功通知里写一行异常:无”,而是真的触发阈值才变成异常通知。当前规则包括:
上传量 > BACKUP_WARN_UPLOAD_MIB耗时 > BACKUP_WARN_DURATION_MIN新增文件数 > BACKUP_WARN_NEW_FILES修改文件数 > BACKUP_WARN_CHANGED_FILESSNAPSHOT_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 可达。
最终方案不是给阿里云折腾代理,而是让第二台海外服务器做一个极小的通知中继:
中继脚本在 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.servicejournalctl -u daily-restic-backup.service -n 120 --no-pager确认:
snapshot xxx savedno errors were foundBackup finished2. 快照查看
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-server4sudo 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.yml4. 并发锁测试
人为占用锁,再启动 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 runningBackup or maintenance already running5. timer 重载和持久化检查
sudo systemctl daemon-reloadsudo systemctl restart daily-restic-backup.timer weekly-restic-maintenance.timer
systemctl is-enabled daily-restic-backup.timer weekly-restic-maintenance.timersystemctl is-active daily-restic-backup.timer weekly-restic-maintenance.timersystemctl list-timers daily-restic-backup.timer weekly-restic-maintenance.timer --no-pager确认:
enabledenabledactiveactive6. Restic 仓库锁检查
sudo bash -c 'source /etc/restic/env && export HOME=/root XDG_CACHE_HOME=/root/.cache && restic list locks'正常情况下为空。
五台机器最终快照记录
这次首轮部署和优化后,各机器都有成功快照。
| 机器 | 角色 | 最新验证快照 | 备注 |
|---|---|---|---|
| Server 1 | snowluma / napcat | <SNAPSHOT_ID> | 备份 Docker volume、Nginx、证书、脚本 |
| Server 2 | MyBlog / Komari | <SNAPSHOT_ID> | Komari SQLite 未压缩一致性快照 |
| Server 3 | MaiBot | <SNAPSHOT_ID> | MaiBot SQLite 未压缩快照,排除在线热库 |
| Server 4 | new-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:几百 MiBServer 2:几十 MiBServer 3:首备数 GiB,优化后增量明显变小Server 4:几百 MiB,PostgreSQL dump 约几十 MiBServer 5:约 2GiB 博客与宝塔配置只要坚持“不把大数据库 gzip 后再每天上传”,250GiB 对这套个人备份来说非常充裕。
恢复思路
如果某台机器炸了,恢复流程大体一致:
1. 新开实例2. 安装基础系统包3. 安装 Restic4. 写回 /etc/restic/env5. restic restore latest --target /6. 检查 nginx / docker compose / systemd7. 恢复数据库8. 启动服务MaiBot SQLite 恢复
sudo systemctl stop maibotsudo cp /var/backups/db/maibot.sqlite /opt/MaiBot/data/MaiBot.dbsudo chown ubuntu:ubuntu /opt/MaiBot/data/MaiBot.dbsudo rm -f /opt/MaiBot/data/MaiBot.db-wal /opt/MaiBot/data/MaiBot.db-shmsudo systemctl start maibotKomari SQLite 恢复
cd /opt/komaridocker compose downsudo cp /var/backups/db/komari.sqlite /opt/komari/data/komari.dbdocker compose up -dnew-api PostgreSQL 恢复
cd /opt/services/new-apidocker compose up -d postgresdocker compose exec -T postgres psql -U root < /var/backups/db/docker-postgres-all.sqldocker compose up -d整机路径恢复
sudo mkdir -p /tmp/restore-testsudo 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 传输远端脚本:
$remote = @'set -euo pipefailhostname'@$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/resticsudo 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/checkSQLite 未压缩一致性快照PostgreSQL 未压缩 SQL dumpRestic 加密、切块、去重、增量Telegram HTML 短成功通知 / 详细失败通知阿里云通过 SSH 中继发 Telegram,不直连 Bot API备份脚本和 systemd 单元文件也纳入备份敏感 env/key 不入库,离线保存我最满意的不是“备份跑起来了”,而是这套系统现在有明确的边界:
什么该备什么不该备什么靠清单重装什么要一致性快照什么不能压缩什么不能进备份仓库这比单纯把整机打包上传可靠得多,也省得多。
最后再重复一次这次最关键的经验:
Restic 备份的大原则长期自动备份不要迷信大压缩包。
把普通文件和未压缩数据库快照交给 Restic,让它做自己最擅长的切块、去重和增量。
压缩包适合人工下载和临时搬运,不适合作为每天自动增量备份的主对象。
至此,五台机器终于从“出事以后靠记忆和运气恢复”,升级成了“有快照、有清单、有数据库一致性、有通知、有恢复测试”的状态。个人项目不一定要上企业级灾备,但至少应该让未来的自己少熬几次夜。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时


















