上一篇《“如果今天服务器炸了怎么办?“》里,我把五台服务器的备份统一收敛到了 Restic + DigitalOcean Spaces。
本以为这套体系能安稳跑很久,结果不到两个月,DigitalOcean 一封邮件:

GitHub Student Developer Pack 的 credits 在 2026 年 7 月 31 日后到期,未用余额不结转,账户转为标准计费。
只要支付方式有效,Spaces $5/月 照跑,备份任务不会因为额度到期自动停。
真正的问题是 DigitalOcean 上那台业务机 DigitalOcean-SGP1 也得跟着退役,继续养着不划算。
于是两件事凑到了一起:
- 把 new-api 加上一套数据库迁到 Oracle ARM
- 再把四台现役机器的 Restic 仓库从 Spaces 整体搬到 Cloudflare R2
本次省流:
- 迁移期间停写约数分钟换一次性切换
- 四个 R2 仓库合计从 14.9 GB 瘦到 2.5 GB
- 备份脚本重写一遍
- 顺带捡出一个漏了一年多的 Docker 卷
先搬业务,再搬备份
两条链路串着做:
- 先把业务停写、导出最终源库、切 DNS、公网 200
- 再冻结备份仓库做原样复制
否则两件事打架,复制到的就是过渡态的库。
业务迁移流程
业务顺利切完并验证通过后,再启动备份端的迁移:
备份迁移流程
先把业务搬走
DigitalOcean-SGP1 上跑着 new-api / cli-proxy / 3x-ui,外加内部的 PostgreSQL 15 / Redis。
只搬真正在用的:new-api、PostgreSQL、Redis、cli-proxy。
3x-ui 不迁,域名证书也只处理实际用到的。
切换当天没追求零中断,接受数分钟停写换一致性。顺序大致是:
- Oracle ARM 先建好预热副本,证书从旧机原样复制
- DNS TTL 降到 300s 后把 A 记录切到 Oracle IP
- 停 DigitalOcean 上的 new-api 和 cli-proxy 断掉写入
- 短暂停 Oracle 上的 new-api 防止分叉
- 比对两边数据库指纹决定覆盖方向——从已停写的 DigitalOcean 源库做最终导出
- Oracle 恢复后启动容器,本地
curl --resolve和公网 HTTPS 同时验证 200 - 旧机保留 48 到 72 小时作回滚点,不重启旧业务容器
切换瞬间有可能已经有请求写到了 Oracle 新库。比对了两边数据库指纹:
- Oracle 多出来的 5 张表是新版容器启动时自动完成的结构迁移,无业务写入
- DigitalOcean 源库比 Oracle 多 36 条日志和 3 条额度记录
所以以已经停写的 DigitalOcean 源库做最终覆盖最安全,Oracle 启动时会重新补齐那 5 张结构表。
切换完的状态:
- new-api 主域和 www 子域 HTTPS 200、证书正常
- cli-proxy 公网入口 HTTP 200
- PostgreSQL 源库全部表和完整历史日志已恢复
- Redis 重新创建,无需迁移
业务稳了,备份这边才敢动手。
备份后端换成 R2
Restic 可以直接用 R2 的 S3-compatible endpoint,迁移主要改了 endpoint、桶名和凭证,其余参数一行没动。
四个仓库迁完后合计只有 2.5 GB,落在账户级 10 GB 免费额度内(R2 价格);恢复快照的出站流量也不收费,对我这套个人服务器足够。
R2 的免费额度大概是这样的:
- Standard 存储每月前 10 GB 免费
- Class A 操作(写/列表)每月 100 万次
- Class B 操作(读/HEAD)每月 1000 万次
- 公网出站流量免费
超出后存储约 $0.015/GB/月。
现役仓库用 Standard——Restic 每天读写、每周 prune 会反复重写对象,Infrequent Access 的检索费和操作费这套访问模式不划算。
拆成四个桶,每桶一组凭证
之前在 Spaces 用的是”一个 Space + 五个前缀”。
这次直接拆成一台机器一个桶,每个桶配一组独立的 Object Read & Write Token:
- restic-oracle-amd1
- restic-oracle-amd2
- restic-oracle-arm
- restic-aliyun
(实际桶名带了更完整的机型描述。)
DigitalOcean-SGP1 即将退役,那台的历史仓库不迁,归档桶也直接删掉。
每个桶统一:
- 关闭 Public access 和 r2.dev
- 不配 Custom domain
- Storage class 用 Standard
- Bucket Lock 不开启(原因见后文)
- Object expiration 不配
- Lifecycle 默认靠 R2 自身对未完成分段上传的 7 天自动中止(迁移后我又加了一条 1 天的显式清理,见后文)
env 切到这种形式(敏感值由 /etc/restic/env 管理,不入文章):
export AWS_ACCESS_KEY_ID="<R2 Access Key ID>"export AWS_SECRET_ACCESS_KEY="<R2 Secret>"export AWS_DEFAULT_REGION="auto"export RESTIC_REPOSITORY="s3:https://<ACCOUNT_ID>.r2.cloudflarestorage.com/<对应桶>"AWS_DEFAULT_REGION=auto 是 R2 的正式区域值。
空值和 us-east-1 也会被映射到 auto,所以不是绝对硬性要求;但显式写上能把”我连的是 R2”直接表达在配置里,省得日后读 env 时再回头翻文档(R2 S3 兼容 API)。
一台机器一个桶,是因为 R2 的长期 Token 只能做桶级授权,不能按前缀做同等程度的永久隔离。
共用一个桶、一组凭证的话,任意一台泄露,攻击者就能删光整桶。
每台一个桶、每桶一组独立 Token 后,单机泄露最多影响自己的仓库,爆炸半径小一个数量级。
把历史快照原样搬过去
用 rclone copy 整体复制仓库,历史快照也随之保留——Restic 仓库本质是 data/、index/、snapshots/、keys/ 下互相关联的对象,整体复制过去就等于历史全在。
迁移期间的冻结流程:
四台都没装 rclone,而阿里云国内 IP 被官方下载站屏蔽,所以最后是先把官方静态二进制下到本机过 SHA256,再 SCP 推上去,避免不同发行版包源差异。
复制参数:
rclone copy "do:${old_path}" "r2:${bucket}" \ --config /etc/restic/rclone-migration.conf \ --immutable \ --fast-list \ --transfers 8 \ --checkers 16 \ --stats 1m --stats-one-line阿里云链路不稳时调到 --transfers 4 --checkers 8。
然后两边一致性比对:
rclone size "do:${old_path}" --jsonrclone size "r2:${bucket}" --jsonrclone check "do:${old_path}" "r2:${bucket}" \ --size-only --one-way--size-only 只比较路径和大小、不比较哈希;--one-way 还会忽略目标端多出来的对象(rclone check 文档)。
所以四个仓库输出 0 differences found 只能说明”源端对象在目标端都存在、大小一致”,不是哈希级校验。
完整的证据链是后面继续跑的 restic check --read-data-subset=1G 和抽样恢复——这两个过了,才能说仓库搬过去了没坏。
迁移完:
/etc/restic/rclone-migration.conf这种含双端凭证的临时文件必须物理删/etc/restic/env改完后旧配置留一份env.pre-r2-20260716,等 R2 稳定几天再删
给仓库减肥
复制前先看了一眼四个仓库的实际对象大小,差点被劝退:
- Oracle AMD1 约 286 MB
- Oracle AMD2 约 562 MB
- Oracle ARM 约 12.79 GB
- Aliyun 约 1.26 GB
合计约 14.9 GB,超出 10 GB 免费额度。
膨胀的来源不是当前数据,而是 Oracle ARM 一年多攒下来的历史快照。
按快照分组算了一下,四台”最新快照”实际存储量合计只有约 2.05 GB,剩下 12.85 GB 基本都是旧 pack、旧快照和可重建的内容。
仓库变大先别急着买更大的存储。先跑一次 restic stats 按快照分组看看,是”当前数据大”还是”历史保留太大”——前者要改备份范围,后者只要改保留策略,代价差很多。
调保留窗口
原本四台都是 daily 7 / weekly 4 / monthly 6,之前为了省空间临时缩到 daily 3 / weekly 2 / monthly 1。
但迁完 R2 实际只有 2 GB,离 10 GB 还有 8 GB 的余量,继续牺牲恢复窗口没意义——激进保留意味着两三周后才发现误删,可能已经没有合适的恢复点。
调回 7/4/3,等仓库真长到 8 GB 再说。
砍掉可重建的内容
AMD2 和 Aliyun 不再备份可从 Git 重新部署的 /www/wwwroot/MyBlog。
Oracle ARM 新增排除了:
- HTML 图片缓存(
/opt/qq-bot/data/html_imgs) - emoji 缩略图 and Pixiv 爬虫插件的图片下载缓存
仍然保留的是:
- qq-bot SQLite 一致性快照
- new-api PostgreSQL 未压缩 SQL
- qq-bot emoji 与 images
- QQ 数据和会话
- 配置、插件、证书
- Nginx、Compose、cli-proxy auths
prune 的代价
改完保留窗口和排除路径后,要释放空间必须跑 restic forget --prune。
ARM 这一步特别刺激:先从 Spaces 下载约 3.73 GB 旧 pack,拆出仍在被引用的数据块,重新打包成新 pack,再上传。CPU、内存、网络三头吃紧。
我盯着进度看了快 25 分钟,中途 ARM 还因为负载过高出现 SSH 抖动。
给 prune 进程发 USR1 拿实时进度:
kill -USR1 <restic_pid>ARM 仓库从 12.79 GB 瘦到 1.33 GB。
| 服务器 | 优化前(Spaces) | 优化后(R2) | 说明 |
|---|---|---|---|
| Oracle AMD1 | 285.65 MB | 285.65 MB | 无大变化 |
| Oracle AMD2 | 562.08 MB | 562.99 MB | 无大变化 |
| Oracle ARM | 12.79 GB | 1.33 GB | 瘦身约 11.5 GB |
| Aliyun | 1.26 GB | 104.48 KB | 剔除 MyBlog 备份 |
| 合计 | 14.90 GB | 2.03 GB | 远低于 10 GB 阈值 |
后来补上 AMD1 那一年多的漏备(见后文),实际占用约 2.51 GB。
仪表盘多出的 11 GB
迁移结束后,Restic 看到的仓库只有 1.33 GB,R2 仪表盘却显示 12.79 GB。
差额主要来自之前被中断的未完成 multipart upload(没专门跑 ListMultipartUploads 一一对照,只是从”差额 11.5 GB + 清后排回到 1.33 GB”推断的)。
R2 默认 7 天后会自动中止未完成的分段上传(文档),我不想等,给四个桶配了”1 天后中止”的生命周期规则。
短期残留不可怕,折算一下费用:
- 11 GB × 1 天 ÷ 30 天 = 0.36 GB-月,落在免费额度内
- 即便没有免费额度,
0.36 × $0.015 ≈ $0.0054,合人民币不到 4 分钱
可怕的是不清理,让残留长期占着额度可见性。
顺手把备份脚本重写一遍
复制是一次性动作,长期跑的还是靠 systemd timer。
趁这次迁移把四台机器的 8 个脚本(4 daily + 4 weekly)拉下来整体看了一遍,顺手改了几处。
分级并发
四台机器差距很大:
- Oracle AMD1 和 AMD2 都是 1H1G
- Aliyun 2H2G
- Oracle ARM 4H24G
Restic 默认会吃满所有 CPU 核并开 5 条 S3 连接,1H1G 上跑一次大 prune 很容易把宿主机打满甚至触发 OOM Killer。
所以分级设置:
- AMD1、AMD2、Aliyun 用
GOMAXPROCS=1加s3.connections=2 - ARM 用
GOMAXPROCS=2加s3.connections=3
GOMAXPROCS=1 主要约束 Restic 用几个 Go 线程核心,让 CPU 占用降下来,内存峰值略压一点,但不是严格内存上限。
systemd 的 Nice=10 + IOSchedulingClass=best-effort + IOSchedulingPriority=7 这套调度用来降对业务的 CPU 和 IO 争抢,让 prune 跑得”更礼貌”——CPU 和 IO 都能被压一压,但内存这条控制不了。
真要不 OOM,得加 MemoryMax= / MemoryHigh=,或者用 s3.connections 在源头把并发量压住。这次主要靠后者。
Daily / Weekly 职责分层
原本四个 daily 脚本都跑了 restic backup、restic forget 和 restic check --no-cache。
Weekly 已经在做 forget --prune + check --read-data-subset=1G,daily 再 check 就是重复请求 R2、抢锁、拉长每日窗口。
改成:
- daily:只做 backup +
snapshots --latest 5+ 通知 - weekly:负责
forget --prune + check --read-data-subset=1G + --cleanup-cache - monthly:可选全量
restic check --read-data
—exclude 外置
原本每台机器的排除项是一长串 --exclude 塞在脚本里,像噪声。
抽到每台独立的 /etc/restic/excludes(权限 600 root:root),脚本里改用 --exclude-file。
改排除项不用动脚本,可读性好很多。
阿里云的中继做成主备
阿里云国内机不能直连 api.telegram.org,原本只有 Oracle AMD2 一个 SSH forced-command 中继,是单点。
改成先主后备:
- 主通道走 AMD2 的 SSH 强制命令中继
- 失败后自动重试备用通道 AMD1
两条中继脚本都走 restrict,command=... 的 authorized_keys 锁死。
中继断了不等于备份断了——脚本里中继失败只写 WARN: Telegram SSH 通知中继失败,Restic 快照照样保存成功。
但反过来也成立:通知没断只能说明”任务跑过”,不能说明”服务器还活着”。
timer 根本被 systemd 跳过的情况,中继没机会报。
真正的”机器死了”要靠外部心跳(Healthchecks / Better Stack / 自建 Cloudflare Worker ping),不要指望崩溃的服务器自己发失败通知。
几条差点抄进脚本的错误参数
脚本改完并没有一次通过。
有几个参数我都已经写进 diff 了,Restic 一跑就报错;还有几条干脆就是我和帮我出方案的 AI 说得像那么回事,其实纯属想象。
s3.retry-attempts 不是 Restic 选项
最初给的 diff 里有 restic backup ... -o s3.retry-attempts=5,实际跑下来直接因 unknown/invalid option 失败。
Restic 有很多有效 S3 选项(s3.region、s3.bucket-lookup、s3.list-objects-v1……),但就是没有 s3.retry-attempts。
网络重试由后端默认行为处理,真要调的是 --stuck-request-timeout,不是虚构一个重试次数(Restic FAQ)。
为了”看起来更稳”堆参数,最坏的情况是在备份窗口里让脚本因为 unknown option 直接退出,把”备份数据”变成”白跑一趟”。
每条 Restic 参数都要能对应到官方文档。
restic check --no-cache 改成 restic check 也没用
简单把 --no-cache 删掉并不会让 check 复用长期缓存。
Restic 0.16 起 check 会自动为每次校验创建新的临时缓存,要真正复用现有缓存必须显式 --with-cache(Restic 0.16 完整性检查说明)。
最干净的做法是每日根本不跑 check,校验整个交给 Weekly。
--compression auto 是默认值
仓库格式 v2 下 auto 本来就是默认,加它只是增加命令噪声(Restic 调优参数)。
还有一条描述错误
最初的优化报告说”废弃 SSH 通知中继”时,描述成”AMD1/AMD2/ARM 三台机器都通过 AMD2 做 SSH 中继”。
实际只有 Aliyun 用中继,AMD1/AMD2/ARM 都是直接 curl Telegram。
这种描述错误如果不纠正,会让人把通知代理改成全局 HTTPS_PROXY,结果把 Restic 到 R2 的流量也送进 Telegram 代理,把备份自己搞挂。
只在 Telegram 的 curl 局部指定 curl --proxy "$TELEGRAM_PROXY" ...,Restic 命令完全不碰代理环境变量。
那个漏了一年的 Docker 卷
脚本审查时翻出的最严重的问题。
Oracle AMD1 上三个 Docker named volume 根本没进 Restic 快照,看样子漏了一年多。
问题出在脚本里这段反向判断:
if [ -n "$mountpoint" ] && [ ! -e "/var/lib/docker/volumes/$volume" ]; then add_path "$mountpoint"fi逻辑是”如果标准目录不存在,才加进备份”。
但这三个卷都位于标准 Docker 数据目录,条件永远不成立,全部被跳过:
- qq-bot-core_data 48 MB
- qq-bot-core_config 956 MB
- qq-bot-core_cache 4 KB
它们都正被 qq-bot-core 容器挂着,而宿主机上的 /home/ubuntu/qq-bot-core 目录并不包含这些卷的数据。
约 1 GB 的 QQ 机器人账号状态和身份数据长期没有进入快照——炸机恢复等于从一年前的旧状态重新上线。
反向判断最容易误伤正常路径
if condition; then add这种写法,condition 一旦写反,正常路径会被静默跳过,而备份脚本照样报”成功”。审脚本看到”否定式判断 + 默认放过”,一定要重点核对:尤其要区分”容器挂着的数据”和”宿主机目录里的数据”是不是一回事。
修正很直接:把条件改成”容器在挂这个卷且卷路径存在就纳入备份”,不再用反向判断。
修完后新快照在 R2 端只增量占用 332 MiB(Restic 去重 + ZSTD 压缩后),整个仓库约 617 MB,仍远低于阈值。
容器启停一致性
卷里有十几个 SQLite 文件。
直接备份运行中的 SQLite 不一定坏,但 WAL 模式下恢复不可靠——备份到一半可能正把 WAL 合并回主库,文件就是不一致的。
备份前短暂停止容器。trap 必须先安装,再执行 docker stop,否则中间会留一段”容器已经停止、但 trap 还没生效”的盲区:
WAS_RUNNING=$(docker inspect -f '{{.State.Running}}' qq-bot-core 2>/dev/null)
cleanup() { if [ "$WAS_RUNNING" = "true" ]; then docker start qq-bot-core >/dev/null 2>&1 || true fi}trap cleanup EXIT INT TERM
# trap 已装好,再停容器docker stop qq-bot-core >/dev/null 2>&1
restic backup "${BACKUP_PATHS[@]}" ...WAS_RUNNING 记录执行前状态,避免把”原本就没跑的容器”误拉起。
trap ... EXIT INT TERM 覆盖正常退出、异常退出、SIGINT、SIGTERM,但不包括 SIGKILL。
我只抽查了 reactions.db,PRAGMA integrity_check 返回 ok。
这不能证明卷里每个 SQLite 都验过,但容器在整个备份窗口里是停的,数据库都没在写;至少确认了这条恢复链路能走通。
trap 救不回 SIGKILL 和掉电Shell 的
trap捕获不到SIGKILL(kill -9、部分 OOM Kill)、主机掉电、内核崩溃。只靠 trap 兜底不够:
restart: unless-stopped处理的是 daemon 自己重启时的自动恢复,Docker 官方写明手动docker stop过的容器不会被该策略在 daemon 重启后重新拉起(Docker restart policy)- 第二层兜底更靠谱的是独立 watchdog 或 systemd 恢复单元
- cleanup 函数要幂等,收到 INT/TERM 后明确退出,不要清理完继续往下跑
顺便一起收掉的小问题:
/var/backups/db不再整个清空,只删明确的导出文件(sub-bot.sqlite/qq-bot.sqlite/new-api-postgres.sql及对应.tmp),保护恢复清单、成功时间戳等静态文件- 数据库导出加了
test -s非空校验,导出失败或空文件直接失败报警,不静默用旧副本”假成功” /etc/restic/excludes纳入备份路径,恢复后脚本能跑,排除文件不能丢flock冲突不再exit 0,改成记录 + Telegram 报警”任务因锁冲突跳过”- Telegram 失败通知日志从最后 40 行截到 2500 字符,防止超 4096 字符上限被 Telegram 拒收
- systemd
Description从 “DigitalOcean Spaces” 改为 “Cloudflare R2”,过时的显示信息容易误导排查
Bucket Lock 留给冷备桶
四个现役桶没有开启 Bucket Lock——restic forget --prune 需要删除旧对象并重打包,锁上以后每周维护会直接失败(Cloudflare Bucket Lock、Restic prune 原理)。
真正缺的是一份”服务器自己删不掉的不可变副本”,属于 3-2-1-1-0 里的那个”1 不可变”。
日常现役服务器备份写入各自隔离的 R2 桶:
而在安全防御层面,通过离线拉取与锁定保障数据安全:
思路是冷备桶放在独立 Cloudflare 账号或另一家对象存储,每月用 rclone copy(非 sync,不传播删除)从现役桶拉一份,按月份前缀命名如 2026-07/。
验证通过后再锁。
生产服务器不得持有冷备凭证,本地只持现役桶只读凭证加冷备写入凭证。
不过这部分目前还停留在设计阶段,这次真正上线的只有四个现役 R2 桶,等真做完第一次月度恢复再回来补。
这次踩到的坑
AWS_DEFAULT_REGION=auto 显式写省心。
R2 不分具体区域,空值和 us-east-1 也会被映射到 auto,不写不是绝对跑不起来。
但显式写上能避免某些 SDK 版本在缺省条件下走默认 us-east-1,排查起来很折腾。算好习惯而不是硬要求。
中断的 rclone 会留下分段上传残留。
rclone copy 中途网络断开,已上传的分段不会在 R2 端即时清理,而是按 7 天策略自动中止。
它不会被 rclone size 算进可见体积,只在仪表盘上出现。
配”未完成分段上传 1 天后清理”的生命周期能把仪表盘提前调准。
别给 restic backup 外面套重试。
有些人会在 Restic 命令外包一层 for i in 1 2 3; do restic backup; done。
这对 backup 是危险的:第一次跑可能其实已经保存了快照,因为末尾某个无关错误返回非 0,重试一遍就可能产生两个重复快照。
Restic 后端本身就会对网络错误重试,外层包重试属于画蛇添足。
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}"。
阿里云不能直连官方下载站。
rclone 官方下载站在国内 IP 被 403,没法在阿里云上直接 curl 下来装。
最后是把官方二进制下到本机过 SHA256,再 SCP 推上去。
这种”网络能不能直连”在迁移前必须先确认,别等跑到一半才发现卡在某台机器装不上工具。
还没结束
第二天再打开 R2,四个仓库加起来只有 2.5 GB。
Daily timer 已经开始往新桶写快照,DigitalOcean 上的旧 new-api / cli-proxy 一直保持停止。
这次把几个被掩盖了很久的隐患一次性揪出来:
- 一个反向判断坑了一年的 1 GB 漏备
- 每天重复跑 check 浪费 R2 请求的写法
- 激进保留换来虚假安全感
s3.retry-attempts这种不存在的参数差点抄进 diff- 还有差点把 Bucket Lock 加到活跃仓库上
这次真正记到的几条
- 业务迁移和备份迁移分两步:先停写做最终一致性导出、切 DNS,再冻结备份仓库做原样复制,避免备份里有一份过时的库。
- R2 计费看 GB-月,短留的残留不贵,但不清理就贵:看到仪表盘异常先算一遍,再配生命周期规则,不要慌。
- Bucket Lock 锁的是冷备不是现役:活跃 Restic 仓库必须能删能重写,锁了它就锁死了 prune;真正的不可变副本要拿到独立账号的冷备桶里去做,验证后才锁。
冷备桶、独立账号和 Bucket Lock 还在待办列表里。
等真做完第一次月度恢复,我再回来补这一段。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时