restic增量备份替换rsync:对象存储仓库与恢复演练

为什么要把仓库从 rsync 换成 restic

很多站长做异地备份的第一反应是 rsync -a:把整个网站目录同步到另一台机器或者对象存储的挂载目录。这个方案在只有一个快照的时候完全够用,一旦你想保留「每天一份、留三十天」,问题就来了——rsync 同步出来的是「当前状态」,第二天的同步会覆盖第一天的文件,你只有一份。想保留历史只能每天复制一整份,一个 30GB 的站点、三十天就是 900GB 的流量和存储。

于是就有了增量方案。有人用 rsync 的 --link-dest 做硬链接快照,这套流程确实能跑,本站之前也专门写过。但它有两个绕不开的限制:一是仓库必须放在支持硬链接的文件系统上,S3、B2 这类对象存储用不了;二是硬链接依赖「文件路径不变」,一个文件改名,整份文件就要重新存一遍。restic 是另一条路线:它把文件切成块,按内容哈希去重,加密后再写进仓库,仓库可以是本地目录,也可以是 S3、Backblaze B2、SFTP 甚至 rclone 挂载的任意后端。

这篇文章不讲概念,只讲一个真实场景:一台跑 Typecho 的 Debian 服务器,网站目录 28GB(主要是历年的图片附件),MySQL 数据库 900MB,原来每天 rsync 全量到另一台机器,现在换成 restic 增量加对象存储,把每日备份耗时从四十多分钟压到六分钟,月存储成本降到原来的四分之一。下面是完整的实施步骤、参数取舍和踩过的坑。

第一步:搞清楚哪些数据必须进备份

在敲任何命令之前,先列清楚备份边界。这一步做错,后面全是白干。

一个 Typecho 站点的数据大致分三类。第一类是程序文件,包括主题、插件、核心代码,这部分体积小(通常几十 MB),但它是「可重建的」——只要记下版本号和自己在主题里改过哪些文件,理论上能重新装回来。第二类是用户上传的附件,在 /usr/share/nginx/html/usr/uploads/ 下面,这部分不可重建,是备份的重点。第三类是数据库,文章内容、评论、分类关系、插件配置全在里面,体积不大但价值最高。

很多人只备份前两类,觉得数据库可以用 mysqldump 每天单独跑一次。这样做的问题是一致性:网站文件和数据库分别在不同时间点备份,恢复的时候文章在库里但配图在文件系统里缺失,或者反过来,页面上出现一堆裂图。restic 虽然不能替你做应用层的一致性快照,但至少可以把「导出数据库」和「备份文件」放进同一个脚本、同一个仓库、同一个快照,恢复时是一套完整的现场。

# 先量一下各部分体积,决定哪些值得存
du -sh /usr/share/nginx/html/usr/uploads
du -sh /usr/share/nginx/html --exclude=usr/uploads
mysql -uroot -p -e "SELECT table_schema,
  ROUND(SUM(data_length+index_length)/1024/1024,1) AS mb
  FROM information_schema.tables
  WHERE table_schema='zz1984' GROUP BY table_schema;"

量完之后你会发现,附件占了 95% 以上的体积,而它恰恰是「大量文件只写一次、之后再也不变」的典型场景——这正是 restic 内容去重最擅长的部分。每天新增的图片可能只有几十 MB,增量快照的体积几乎就等于新增量。

第二步:初始化加密仓库

restic 的仓库永远是加密的,没有「关掉加密」这个选项。密码丢了,数据就永久打不开,没有任何后门。所以第一件事不是初始化,而是想清楚密码存哪:写进密码文件(权限 600),文件本身再用另一套机制保护,比如放在你本机的密码管理器里、或者用 root 之外的另一个备份密钥加密。

apt install -y restic
export RESTIC_REPOSITORY="s3:https://s3.us-west-004.backblazeb2.com/zz1984-backup"
export RESTIC_PASSWORD_FILE=/root/.restic-pass
chmod 600 /root/.restic-pass
restic init

初始化完 restic init 会打印一行 created restic repository xxxx at ...,请把这一行连同仓库地址一起记下来。恢复的时候需要同一套 RESTIC_REPOSITORY 加密码,仓库 ID 不匹配会直接报错。如果你用的是 S3 兼容存储,还需要设置 AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY,Backblaze B2 的话则是 B2_ACCOUNT_ID 和 B2_ACCOUNT_KEY,具体看官方文档的环境变量表。

一个容易忽略的点:环境变量写在 shell 里,cron 跑的时候是拿不到的。要么把它们写进脚本头部,要么用 /etc/restic/env 这样的文件配合 EnvironmentFile= 由 systemd 注入。别指望 crontab 里 interactive shell 里的 export 会生效。

第三步:写备份脚本,把数据库导出和文件备份串起来

脚本的结构应该是:导出数据库到一个临时目录,restic backup 一次带两个路径(网站目录 + 临时导出目录),然后 forget --prune 清理旧快照,最后做一次检查。下面是我现在实际在用的版本,去掉了很多花哨选项,只保留必要的。

#!/bin/bash
set -euo pipefail
export RESTIC_REPOSITORY="s3:https://s3.us-west-004.b2.com/zz1984-backup"
export RESTIC_PASSWORD_FILE=/root/.restic-pass
export B2_ACCOUNT_ID=xxxx
export B2_ACCOUNT_KEY=xxxx

DUMPDIR=/var/backups/mysql-dump
mkdir -p "$DUMPDIR"
STAMP=$(date +%F)

# 1) 导库:--single-transaction 保证 InnoDB 一致性且不锁表
mysqldump -uroot -p"$(cat /root/.my.cnf.pass)" \
  --single-transaction --quick --default-character-set=utf8mb4 \
  --routines --triggers --events zz1984 \
  | gzip -6 > "$DUMPDIR/zz1984-$STAMP.sql.gz"

# 2) 备份:一次快照包含文件和 SQL 导出
restic backup \
  /usr/share/nginx/html \
  "$DUMPDIR" \
  --exclude='*.log' \
  --exclude='*/cache/*' \
  --exclude='*.tmp' \
  --tag daily \
  --host zz1984-web01

# 3) 保留策略:7 天日备 + 4 周周备 + 6 个月月备
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 \
  --tag daily --prune

# 4) 每周日做一次数据校验(抽查 5%)
if [ "$(date +%u)" = "7" ]; then
  restic check --read-data-subset=5%
fi

rm -f "$DUMPDIR/zz1984-$STAMP.sql.gz"

几个细节值得说明。--single-transaction 只对 InnoDB 表有效,如果你的库里还有 MyISAM 表(老插件留下的),导出期间那几张表会被锁住,du 检查 SHOW TABLE STATUS WHERE Engine='MyISAM',能转 InnoDB 的先转。--quick 让 mysqldump 逐行读取而不是把整表塞进内存,大表上能省下几个 GB 的内存峰值。

--exclude='*/cache/*' 是我加得最划算的一条。Typecho 的一些插件会在附件目录旁边生成缩略图缓存,这类文件每天重建、体积不小、恢复时完全不需要。第一版没加这条的时候,备份体积比现在大将近一倍,restic stats 一看全是可以排除的缓存。

第四步:搞清楚 forget 和 prune 的区别,别把仓库玩坏

这是 restic 新手最容易搞混的地方。restic forget 只是「删掉快照索引」,也就是你说「这个时间点我不需要了」,但对应的数据块还在仓库里。restic prune 才是真正去扫描所有剩余的块,把没人引用的块从存储里删掉、重写 pack 文件、回收空间。

为什么这个区别重要?因为 prune 是 restic 里唯一一个「有写放大、会消耗大量时间和流量」的操作。它要把仓库里几万个 pack 文件读出来重新组织,对 S3 这类按请求计费的后端,一次全量 prune 可能产生几十万次 GET/PUT 请求——我实测第一次 prune 在一个 30GB 的仓库上花了两小时,账单上的请求费用比存储费用还显眼。

所以实践中的做法是:每日脚本里跑 forget --prune 没问题,但要控制频率。更稳妥的做法是把 forget 和 prune 拆开,forget 每天跑,prune 每周或每月单独跑一次,并且加 --max-repack-size 限制单次重写的量:

# 每日只 forget,不重排仓库
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --tag daily

# 每月一次 prune,限制单次最多重写 5GB
restic prune --max-repack-size 5G

还有一个坑:prune 期间不能有其他进程往仓库里写数据,否则会遇到 repository is already locked。restic 用锁文件机制做互斥,如果上一次备份因为断电或 OOM 被 kill,锁会残留,需要 restic unlock 手动清掉。但别养成一看到锁就 unlock 的习惯——如果锁是被一个正在跑的备份进程持有的,强行 unlock 后两个进程同时写仓库,可能造成仓库损坏。正确做法是先 ps aux | grep restic 确认没有活跃进程,再 unlock。

第五步:恢复演练,这才是备份真正有没有价值的分水岭

备份没做过恢复测试,等于没有备份。我见过太多人每天看着日志里「备份成功」四个字就心安,真出事的时候才发现密码忘了、仓库路径写错了、或者恢复出来的目录权限是 root 导致 nginx 读不到文件。每月做一次完整的恢复演练,成本半小时,收益是「出事时不慌」。

演练流程分三步,先看清快照,再定点恢复,最后验证内容:

# 1) 列出所有快照,确认日期和大小
restic snapshots

# 2) 只恢复某一天的数据库导出(不用整个仓库拉下来)
restic restore latest --target /tmp/restore-test \
  --include /var/backups/mysql-dump

# 3) 校验 SQL 文件完整性和内容
ls -lh /tmp/restore-test/var/backups/mysql-dump/
zcat /tmp/restore-test/var/backups/mysql-dump/*.sql.gz | tail -5
# 结尾应该有 "-- Dump completed on ...",没有就是导出被中断了

--include 参数非常关键,它让 restic 只恢复匹配路径的文件,而不是把 28GB 的图片全拉下来。演练时用 --include 只取数据库导出和几张有代表性的图片,几分钟就能验证完。

验证的内容至少包括:SQL 文件能不能 zcat | tail 正常读完(能说明 gzip 完整)、结尾有没有 mysqldump 的完成标记(能说明不是被中途 kill 的半截文件)、图片文件的 md5 和原件一致、恢复出来的目录属主是不是 www-data。最后这一条最容易被忽略——restic 默认以 root 身份恢复,文件属主会变成 root,直接覆盖到线上目录后 nginx 会报 403。

第六步:监控与告警,别让备份静默失败

restic 是命令行工具,不会主动告诉你「我连续三周失败了」。如果备份脚本挂在 cron 里,唯一的信号就是邮件——而很多服务器的本地 MTA 根本没配好,邮件发不出去,失败就这么静默了几十天。

我的做法是让脚本在所有路径上都留下可检查的状态:成功时往一个文件里写时间戳,失败时用 trap 捕获并主动上报。然后监控系统(或者另一台机器上的一条简单 cron)去检查这个时间戳是不是超过 26 小时没更新。

#!/bin/bash
set -euo pipefail
STATUS=/var/lib/backup-status/last-ok
fail() {
  curl -s -m 10 "https://api.telegram.org/bot$TOKEN/sendMessage" \
    -d chat_id="$CHAT" -d text="zz1984 backup FAILED at $(date)" >/dev/null || true
}
trap fail ERR
# ... 备份逻辑 ...
mkdir -p "$(dirname "$STATUS")"
date +%s > "$STATUS"

另外 restic backup 的退出码有讲究:0 是成功,1 是「备份完成了但有个别文件读不到」(比如某个文件正在被写、或者权限不足),2 是致命错误,3 是「备份完成但清理阶段出问题」。因为脚本开头有 set -e,退出码 1 会让脚本直接中止,导致 forget 和状态写入都不执行——这个行为在某些场景下反而是对的(半个备份不值得记成「成功」),但你得知道它会发生,否则会以为是随机失败。

还有 restic check 的 --read-data-subset:默认的 check 只校验元数据,不读数据块,所以存储端的静默损坏它发现不了。每周跑一次 --read-data-subset=5% 是个合理折中,一年下来每个块大约被读过十几次。如果预算够,每月跑一次 100% 的 --read-data 最稳妥,但那意味着要把整个仓库下载一遍,流量成本要算进去。

成本与效果:改造前后的实际数字

改造前的方案是每天 rsync -az --delete 到另一台 VPS,那台机器 500GB 硬盘,28GB 的站点能撑十几天,之后就得手动清理。每日备份耗时四十到五十分钟(瓶颈是两边磁盘的随机 IO 和跨机房带宽),那台机器的月成本是 12 欧元。

换成 restic + Backblaze B2 之后,第一次全量上传用了三个多小时(28GB 上传,速度受限于出口带宽),之后每天增量备份耗时 5 到 7 分钟,其中 mysqldump 大约 40 秒,restic 扫描 28GB 目录的元数据大约 2 分钟,实际上传的新数据通常只有几十 MB。仓库的存储量因为去重和压缩,30 天快照加起来不到 32GB,B2 的价格是每 GB 每月 0.006 美元(写这篇文章时的价格,请以官网为准),月存储成本约 0.19 美元,加上请求费用每月不到 1 美元。

代价是恢复不再「直接 rsync 回来」。从对象存储恢复 28GB,如果只走单线程下载需要好几个小时。这里有个技巧:restic 支持 rclone 作为后端,而 rclone 支持多线程传输,配置 --transfers 后恢复速度能提升三四倍。另一个办法是临时开一台同区域的机器做中转,区域内的下载通常不计流量费,速度也快得多。

几条愿意的话就照做的经验

第一,不要把仓库和源站放在同一朵云、同一个区域。这不是理论上的风险,而是真实发生过:账号被风控冻结的时候,实例和对象存储一起进不去。跨服务商是成本最低的容灾。

第二,--host 和 --tag 一定要提前规划好。将来你可能会有第二台服务器往同一个仓库备份,或者同一台机器上跑「文件」和「数据库」两类不同的备份策略。restic 的 forget 是按 host 和 tag 过滤的,一开始不规划,后面改起来要去 restic snapshots --json 里逐个核对。

第三,仓库密码不要只存一份。存在服务器上的文件、存在你笔记本电脑上的密码管理器、写在纸质记录里——至少两份在不同的物理位置。服务器挂了、笔记本丢了、密码管理器账号忘了,这三种情况同时发生的概率不高,但任何一种单独发生都会让备份变成一堆无法解读的密文。

第四,定期看一眼 restic stats 的增长曲线。正常情况下每日增量应该稳定在几十 MB 到几百 MB 之间,如果某天突然变成好几 GB,通常是某个插件开始生成大量临时文件,或者日志被误包含进备份范围。这个信号比任何告警都早。

第五,也是最重要的一条:备份的价值不体现在「跑成功了几年」,而体现在「出事那天恢复花了多久」。把恢复演练写进日历,每次演练习得写下来:从发出指令到网站重新上线用了多少分钟,中间卡在哪一步。那个数字才是你的真实 RPO 和 RTO,其他都是自我安慰。

Last modification:October 21st, 2026 at 08:25 pm

Leave a Comment