mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
mobile wallpaper 5
mobile wallpaper 6
5586 字
15 分钟
Restic 仓库迁移记
2026-07-16 22:00:00
2026-07-17 12:00:00

上一篇《“如果今天服务器炸了怎么办?“》里,我把五台服务器的备份统一收敛到了 Restic + DigitalOcean Spaces

本以为这套体系能安稳跑很久,结果不到两个月,DigitalOcean 一封邮件:

Digital Ocean Spaces 到期邮件

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 卷

先搬业务,再搬备份#

两条链路串着做:

  1. 先把业务停写、导出最终源库、切 DNS、公网 200
  2. 再冻结备份仓库做原样复制

否则两件事打架,复制到的就是过渡态的库。

业务迁移流程#

flowchart LR A1["1. 预备副本<br/>建目录与拉取 ARM 镜像<br/>复制配置与证书"] --> A2["2. 准备切换<br/>TTL 降至 300s<br/>DNS 切到 Oracle IP"] A2 --> A3["3. 业务停写<br/>停止源容器防数据分叉<br/>短暂停止新容器"] A3 --> A4["4. 逻辑迁移<br/>最终源库导出与覆盖恢复<br/>启动新容器自动迁移表"] A4 --> A5["5. 运行验证<br/>公网 HTTPS 200 测试<br/>旧机保留以防回滚"]

业务顺利切完并验证通过后,再启动备份端的迁移:

备份迁移流程#

flowchart LR B1["1. 瘦身准备<br/>剔除可重建缓存与静态副本<br/>prune 清理 Spaces 历史数据"] --> B2["2. 冻结迁移<br/>暂停定时任务<br/>rclone 复制仓库对象"] B2 --> B3["3. 配置切换<br/>桶级 rw 凭证授权<br/>修改环境变量配置"] B3 --> B4["4. 校验与验证<br/>restic check 校验一致性<br/>抽样恢复数据测试"] B4 --> B5["5. 解冻恢复<br/>重启定时任务<br/>确认新备份写入 R2"]

先把业务搬走#

DigitalOcean-SGP1 上跑着 new-api / cli-proxy / 3x-ui,外加内部的 PostgreSQL 15 / Redis

只搬真正在用的:new-api、PostgreSQL、Redis、cli-proxy。

3x-ui 不迁,域名证书也只处理实际用到的。

切换当天没追求零中断,接受数分钟停写换一致性。顺序大致是:

  1. Oracle ARM 先建好预热副本,证书从旧机原样复制
  2. DNS TTL 降到 300s 后把 A 记录切到 Oracle IP
  3. 停 DigitalOcean 上的 new-api 和 cli-proxy 断掉写入
  4. 短暂停 Oracle 上的 new-api 防止分叉
  5. 比对两边数据库指纹决定覆盖方向——从已停写的 DigitalOcean 源库做最终导出
  6. Oracle 恢复后启动容器,本地 curl --resolve 和公网 HTTPS 同时验证 200
  7. 旧机保留 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/ 下互相关联的对象,整体复制过去就等于历史全在。

迁移期间的冻结流程:

sequenceDiagram autonumber participant S as 四台服务器 participant T as systemd timer participant R as Spaces 仓库 participant B as R2 桶 Note over S,T: 冻结备份 S->>R: 运行最后一轮 Spaces 备份 S->>T: 暂停定时任务 activate T Note over S,B: 迁移与验证 S->>B: 运行 rclone copy 原样复制仓库 S->>B: 运行 rclone check 校验大小 S->>S: 修改本机环境变量配置 S->>B: 运行 restic 完整性校验 Note over S,T: 解冻恢复 S->>T: 恢复定时任务 deactivate T

四台都没装 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}" --json
rclone size "r2:${bucket}" --json
rclone 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 AMD1285.65 MB285.65 MB无大变化
Oracle AMD2562.08 MB562.99 MB无大变化
Oracle ARM12.79 GB1.33 GB瘦身约 11.5 GB
Aliyun1.26 GB104.48 KB剔除 MyBlog 备份
合计14.90 GB2.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=1s3.connections=2
  • ARM 用 GOMAXPROCS=2s3.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 backuprestic forgetrestic 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.regions3.bucket-lookups3.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-cacheRestic 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.dbPRAGMA integrity_check 返回 ok

这不能证明卷里每个 SQLite 都验过,但容器在整个备份窗口里是停的,数据库都没在写;至少确认了这条恢复链路能走通。

trap 救不回 SIGKILL 和掉电

Shell 的 trap 捕获不到 SIGKILLkill -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 LockRestic prune 原理)。

真正缺的是一份”服务器自己删不掉的不可变副本”,属于 3-2-1-1-0 里的那个”1 不可变”。

日常现役服务器备份写入各自隔离的 R2 桶:

flowchart LR subgraph ActiveServers["现役服务器"] S1["Oracle AMD1<br/>GOMAXPROCS=1<br/>连接数=2"] S2["Oracle AMD2<br/>GOMAXPROCS=1<br/>连接数=2"] S3["Oracle ARM<br/>GOMAXPROCS=2<br/>连接数=3"] S4["Aliyun<br/>GOMAXPROCS=1<br/>连接数=2"] end subgraph R2Active["R2 活跃桶"] B1["restic-oracle-amd1"] B2["restic-oracle-amd2"] B3["restic-oracle-arm"] B4["restic-aliyun"] end S1 --> B1 S2 --> B2 S3 --> B3 S4 --> B4

而在安全防御层面,通过离线拉取与锁定保障数据安全:

flowchart LR R2_Active["现役 R2 桶"] -->|每月只读拉取| L["本地保护机"] L -->|只增不删复制| CB["独立冷备桶"] CB -->|校验通过后| LOCK["启用 Bucket Lock"]

思路是冷备桶放在独立 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 加到活跃仓库上
这次真正记到的几条
  1. 业务迁移和备份迁移分两步:先停写做最终一致性导出、切 DNS,再冻结备份仓库做原样复制,避免备份里有一份过时的库。
  2. R2 计费看 GB-月,短留的残留不贵,但不清理就贵:看到仪表盘异常先算一遍,再配生命周期规则,不要慌。
  3. Bucket Lock 锁的是冷备不是现役:活跃 Restic 仓库必须能删能重写,锁了它就锁死了 prune;真正的不可变副本要拿到独立账号的冷备桶里去做,验证后才锁。

冷备桶、独立账号和 Bucket Lock 还在待办列表里。

等真做完第一次月度恢复,我再回来补这一段。

分享

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

Restic 仓库迁移记
https://github.com/Dawn6666666/MyBlog
作者
黎明
发布于
2026-07-16 22:00:00
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录