一、rclone sync 不是备份,它是镜像
这句话我在不同场合说过很多次,但还是不断看到有人把 rclone sync /backup remote:backup 当成异地备份的全部方案。它的语义是「让目标端和源端完全一样」——源端删除的文件,目标端也会被删除;源端被 ransomware 加密的文件,目标端会在几秒内同步成同样的密文。所以它解决的是「硬盘坏了」,完全解决不了「误删、误改、被加密」。
真正能救命的是三种东西的组合:空间快照(源端某个时刻的不可变视图)、远程版本化(目标端保留历史版本而不是覆盖)、离线副本(一份连不上网的拷贝)。单机个人站做到前两个就够了,第三个可以用一块冷备硬盘,或者干脆定期下载一份到本地电脑。
下面是我在一台 2 核 4G、系统盘 60G + 数据盘 200G 的 VPS 上的实际配置,把 Nginx 站点目录、Typecho 上传目录、MySQL 逻辑备份、SSL 私钥、Nginx 配置这五类东西全部纳入,恢复目标是「换一台新机器,两小时内站点恢复上线」。
二、先画清楚:什么该备份,什么绝对不能备份
第一版脚本我犯的错是「把所有东西都备份」,结果 nightly 任务每天要跑 40 分钟,200G 数据盘几乎写满。清理时才发现里面塞了这些完全没必要的东西:
/var/cache/nginx/、/var/lib/php/sessions/——缓存和会话,恢复后本来就是空的。node_modules/、vendor/——上万个文件,全是从 lock 文件能重建的,占备份体积七成以上。*.log、*.log.gz——日志有 logrotate 管,备份它等于每天复制一遍历史,纯浪费。wp-content/cache/、Typecho 的usr/plugins/*/cache/——插件生成的临时数据,恢复后必须重建。
反过来,有几样东西特别容易被漏掉,而它们恰恰恢复时最麻烦:
/etc/letsencrypt/:证书和账号私钥。丢了就得重新验证域名,遇到 DNS 还没切过来的阶段会卡住。/etc/nginx/、/etc/php/8.2/fpm/:配置文件。光有代码,恢复时把伪静态规则重写一遍要花一整天。/root/.my.cnf、/root/.config/rclone/rclone.conf:凭据。丢了就只能去各家后台重新生成 key。crontab和/etc/systemd/system/*.timer:定时任务。恢复了站点却忘了定时任务,会有很多「说不上哪里不对」的问题。- PHP 版本和扩展列表:
php -m输出,恢复后照着装,否则站点会因为缺一个gd或intl报 500。
所以我把备份对象分成两组,用不同的策略:
A 组(每日全量,体积小、变化多):站点代码、数据库 dump、/etc 关键配置。总大小 1.2GB 左右,压缩后 300MB。
B 组(每周全量,体积大、变化少):上传目录、附件、图片。8GB 左右,走 restic 增量。
三、用 restic 做增量加密备份:一次配置长期省心
为什么不用 tar + rclone?因为 tar 每次都是全量,8GB 的附件目录每周全传一遍,一个月就是 32GB 出网流量,VPS 的月流量额度很快就爆了。restic 的去重和增量是内容寻址的——一个 200MB 的附件被改了一个字节,只上传变化的那一个块,实测第二次全量备份只传了 40MB。
初始化仓库(密码放在 root 只有可读权限的文件里,别写在脚本里):
# 密码文件
umask 077
openssl rand -base64 32 > /root/.restic-pass
chmod 600 /root/.restic-pass
# 仓库放在本地数据盘 + 远端对象存储两个位置
export RESTIC_PASSWORD_FILE=/root/.restic-pass
restic -r /data/restic init
restic -r s3:https://xxxx.r2.cloudflarestorage.com/zz1984-restic init注意 restic 仓库的体积大约是数据的 1.1~1.3 倍,如果直接把仓库放在要备份的目录旁边,一次误删就可能被连带删掉。我的做法是本地仓库放在独立的数据盘 /data/restic,同时用一个 rclone sync 把仓库本身同步到对象存储——这次用 sync 是正确的,因为 restic 仓库内部有自己的版本管理,sync 只是把仓库镜像出去。
备份命令:
restic backup \
/var/www/zz1984 \
/etc/nginx /etc/letsencrypt /etc/php \
/root/.my.cnf /var/spool/cron/crontabs \
--exclude='*.log' \
--exclude='*.log.gz' \
--exclude='node_modules' \
--exclude='vendor' \
--exclude='wp-content/cache' \
--exclude-caches \
--tag daily-$(date +%u) \
--host zz1984几个参数的含义值得记住。--exclude-caches 会跳过所有带 CACHEDIR.TAG 的目录,这是缓存目录的约定,命中率比手写 path 高。--tag daily-3 用星期几打标签,方便后面按周做保留策略——这是 restic 里实现「每日 7 份、每周 4 份、每月 6 份」的关键。
留策略用 forget --prune,一定要分开理解:forget 只是删快照引用,--prune 才真正回收不可达的数据块,后者很慢(8GB 仓库大约 3~6 分钟),所以不要每天跑,一周一次足够:
restic forget --prune \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6 \
--keep-tag manual--keep-tag manual 保证手工打上 manual 标签的快照永远不会被策略清掉,我习惯在每次大改动(升级 PHP、换主题、批量改数据)之前跑一次 restic backup --tag manual。
四、文件系统快照:解决「备份时不一致」的老问题
restic 备份 /var/www 的时候,如果网站正在写文件,可能出现「文件 A 是新的、文件 B 是旧的」这种撕裂。对纯静态文件无所谓,但如果备份的是一个正在被 composer install 修改的目录,恢复出来就是坏的。
解决办法是用 LVM 快照或者 btrfs/ZFS 快照,先冻结出一瞬间的视图,再从容地备份这个视图。以 LVM 为例:
# 1. 建一个预留 10% 空间的快照(够容纳备份期间的变化量)
lvcreate -L 10G -s -n www-snap /dev/vg0/www
# 2. 挂载到只读挂载点
mkdir -p /mnt/snap
mount -o ro,noload /dev/vg0/www-snap /mnt/snap
# 3. 备份快照内容
restic backup /mnt/snap --tag snapshot
# 4. 清理
umount /mnt/snap
lvremove -f /dev/vg0/www-snap这里最容易出事的不是快照本身,而是快照空间耗尽。LVM 快照在写入量超过预留空间后会变成 Invalid 状态,此时快照上的 I/O 全部返回错误,读取会得到一堆 I/O error。/var/log/syslog 里会出现 Snapshot vg0/www-snap is full, aborting writes to the origin,而有些内核版本在快照满掉之后甚至会拒绝写入源卷,直接把站点写挂。这就是为什么我预留了 10G 而不是 1G,并且在脚本里先查一次余量:
lvs --noheadings -o data_percent vg0/www-snap | awk '{exit ($1 > 60)}'没有 LVM 的机器,退一步用一个折中方案:备份前把站点切到维护模式或者 systemctl stop php8.2-fpm,几十秒的空窗窗口换取一致性。对个人站来说,凌晨三点停 30 秒完全可以接受,比配置 LVM 简单得多。
五、把三类备份串成一条链:3-2-1 的落地版本
教科书上说的 3-2-1 是「3 份数据、2 种介质、1 份异地」。个人站可以不严格照搬,但结构要对应上:
第一份:源数据(/var/www + MySQL)
第二份:本机数据盘上的 restic 仓库(/data/restic) ← 解决误删
第三份:对象存储上的 restic 仓库镜像(R2/OSS) ← 解决机器丢失
第四份(可选):本地电脑每月下载一次 restic snapshot tar ← 解决账号被盗第三份的同步我用一个独立的 systemd timer 来做,跑在 restic backup 之后 30 分钟,避免和备份争抢带宽:
[Unit]
Description=Mirror restic repo to object storage
After=network-online.target
[Service]
Type=oneshot
Environment=RESTIC_PASSWORD_FILE=/root/.restic-pass
ExecStart=/usr/bin/rclone sync /data/restic s3:zz1984-restic \
--transfers 4 --checkers 8 --bwlimit 6M \
--log-file /var/log/rclone-restic.log --log-level INFO为什么要延迟 30 分钟而不是立刻同步?两个原因:一是 restic 刚写完仓库,立刻同步会和 prune 的收尾抢磁盘 IO;二是如果备份本身出了问题(比如误备份了一个被加密的目录),30 分钟的间隔给了你一个肉眼发现异常的机会——至少能通过 du -sh 看到仓库体积暴涨。
还有一个我踩过的坑:rclone sync 同步 restic 仓库时,如果目标端有 index/ 下的文件而源端正在写,sync 可能删掉目标端的部分索引文件,导致远端仓库 restic check 报错。正确姿势是先 rclone copy 再单独清理,或者更简单——加 --immutable 参数让 rclone 只增不删:
rclone sync /data/restic s3:zz1984-restic --immutable --bwlimit 6M--immutable 的含义是「已存在的文件不再修改或删除」,这正好契合 restic 仓库的写入模式(数据块一旦写入就不变),代价是远端仓库会保留所有历史块,配合定期 restic prune 和手动清远端,可控。
六、恢复演练:写清楚步骤,别在慌乱中现编
备份做得好不好,只有恢复过才知道。我把恢复步骤写成一个 markdown 文件放在仓库里,每次演练都对着它走一遍,任何一步走不通就回去改脚本。核心流程:
# 1. 新机器上装 restic 与凭据
apt install -y restic
echo 'RESTIC_PASSWORD' > /root/.restic-pass && chmod 600 /root/.restic-pass
# 2. 直接指向远端仓库(不依赖本地仓库)
export RESTIC_PASSWORD_FILE=/root/.restic-pass
restic -r s3:... snapshots --host zz1984 # 先看有哪些快照
# 3. 先恢复到临时目录,别直接覆盖线上
restic -r s3:... restore latest --target /tmp/restore --host zz1984
# 4. 从 /tmp/restore 里把文件搬回原位
rsync -av --dry-run /tmp/restore/var/www/ /var/www/ # 先 dry-run!
# 5. 数据库单独还原(见另一套 mysqldump 流程)
zstd -dc /tmp/restore/backup/mysql/daily/latest.sql.zst | mysql -uroot -p zz1984
# 6. 权限修复:这一步漏了会出现 403
chown -R www-data:www-data /var/www/zz1984
find /var/www/zz1984 -type d -exec chmod 755 {} \;
find /var/www/zz1984 -type f -exec chmod 644 {} \;
# 7. 校验仓库完整性
restic -r s3:... check --read-data-subset=5%第 6 步的权限修复是恢复里最高频的故障来源。restic 恢复时默认保留原 uid/gid,但如果新机器上没有对应的用户(比如老机器是 www-data uid 33、新机器某个包占用了 33 变成了别的用户),文件属主就全乱了,Nginx 直接 403。稳妥做法是恢复时加 --no-owner 让所有文件归当前用户,再统一 chown 给 www-data。
第 7 步的 check --read-data-subset=5% 是抽查 5% 的数据块做哈希比对,全量 --read-data 会把整个仓库下载一遍,8GB 仓库在 100Mbps 带宽下要十几分钟,日常演练抽查就够了,每季度做一次全量。
七、监控:备份失败必须吵醒你
我最不能容忍的配置是「备份脚本失败了但只在本地日志里留一行」。三个月后你打开日志,会发现它从第二周就一直在失败。所以每台机器上都有一条最简单的哨兵:备份成功后用 curl 往 ntfy 推一条消息,成功和失败都推,标题区分:
if [ $? -eq 0 ]; then
curl -s -H "Title: zz1984 备份成功" -d "restic: $(du -sh /data/restic | cut -f1)" \
https://ntfy.sh/your-secret-topic > /dev/null
else
curl -s -H "Title: zz1984 备份失败!!" -H "Priority: urgent" \
-d "$(tail -20 /var/log/restic.log)" https://ntfy.sh/your-secret-topic > /dev/null
fi这个话题名相当于密码,别用 backup 这种能被猜到的名字,任何人订阅了都能看到你的备份日志内容。除此之外再加一条「沉默即告警」:在另一台机器上跑一个脚本,如果 25 小时内没收到备份成功的 webhook,就发告警。这是唯一能抓住「cron 被删了、脚本根本就没跑」这个场景的手段——备份脚本自己不会告诉你它没被执行。
最后提醒一句:备份脚本的每一次修改都要在改完后立刻手工跑一次恢复演练。我在一次把 --exclude 从 cache 改成 */cache/* 之后,不小心把 /var/www/zz1984/usr/uploads 也排除了(正则写宽了),三周后要找一个老图片才发现所有快照里都没有上传目录。这个错误在备份日志里毫无痕迹——它只是安静地变少了,安静得让人以为一切正常。