2025 年夏天,我在服务器上执行了一条 rm -rf。路径里少了一个空格,删掉的不是临时目录,而是 source 下的一部分文章。命令跑完只花了一秒,终端没有任何提示。

我第一反应是“Git 里有”。打开仓库才想起来,最近两天的修改还没有提交。那两天写了两篇草稿,一篇刚写完,一篇改到一半。它们不在任何备份里。

那次我丢了两天的内容,后来靠记忆重写了一部分,另一部分再也找不回来。损失不算大,但足够让我重新理解“备份”这件事。

把 Git 当备份,是一个常见的误会

Git 很好,但它不是备份系统。

  • 它只保存你提交过的内容,未提交的修改不在里面;
  • 它默认在同一个磁盘上,磁盘坏了仓库一起坏;
  • 它不覆盖数据库、配置、证书和上传的文件;
  • git push 到同一个平台,不等于异地冗余。

我当时的“备份”就是 git push。它保护了历史版本,却没有保护“最近的工作”和“Git 之外的东西”。

现在的结构

我按 3-2-1 的原则重新搭了一遍:至少三份数据,两种介质,一份异地。

  • 本机工作副本:日常写作和修改的地方;
  • 服务器上的站点目录:构建产物和运行配置;
  • 异地备份:另一台机器 + 对象存储。

每天一次增量同步,每周一次全量快照。增量用 rsync,全量用 restic。前者简单直接,后者负责去重、加密和保留策略。

每日同步大概是这样:

1
2
3
4
rsync -a --delete \
--exclude 'node_modules' \
--exclude '.cache' \
/srv/axis/ backup@backup.example.com:/backup/axis/daily/

--delete 是有意的:镜像要真实反映源目录。但它也意味着源目录被误删时,目标会被同步删除。所以每日镜像之外,必须有带保留策略的全量快照。

每周的全量:

1
2
3
4
restic backup /srv/axis \
--exclude node_modules \
--tag weekly
restic forget --keep-daily 7 --keep-weekly 8 --keep-monthly 12 --prune

restic 的仓库是加密的,密码不放在脚本里,而是通过环境变量或权限受限的密码文件传入。备份里可能有数据库导出和配置文件,不加密等于把钥匙和锁放在一起。

备份成功的标志不是“脚本没报错”

脚本跑完返回 0,只能说明命令执行了,不能说明备份可用。我见过磁盘满的时候 rsync 只同步了一半,也见过对象存储权限变化导致上传静默失败。

所以现在每次备份结束都会做两件事:

  1. 检查退出码,并把结果写进日志;
  2. 每周跑一次 restic check,确认仓库结构完整。

更关键的是每季度一次的恢复演练。流程很土:

1
2
restic restore latest --target /tmp/restore-test
diff -r /srv/axis/source /tmp/restore-test/srv/axis/source

随机挑一个文件打开,随机挑一个目录对比,确认内容真的是最近的版本。演练之后把恢复耗时记下来——这个数字比“备份多大”更有意义,因为它决定了故障时我要等多久。

失败也要能被告知

备份最危险的状态不是“没有备份”,而是“以为有,其实早就失败了”。

我给备份脚本加了失败告警:只要退出码非 0、目标端剩余空间低于阈值、或者连续两天没有新的快照,就发通知。通知走的是和站点监控同一条通道,避免再多一个需要维护的系统。

定时任务用 systemd timer,而不是直接写 crontab。好处是能用 systemctl list-timers 看下一次执行时间,日志也能用 journalctl 统一查:

1
2
systemctl list-timers --all | grep backup
journalctl -u axis-backup.service --since "7 days ago"

备份是一种习惯,不是一次配置

刚配好备份的那几周,我每天都会去看一眼日志。后来它变成习惯,就不再天天看了,但每季度的恢复演练会把我拉回来一次。

现在我对自己只有一条要求:在没有备份的情况下,不做不可逆的操作。 删文件、改数据库、换服务器、升级系统,动手之前先确认最近的备份时间和恢复方式。

这条要求听起来保守,但它让我在几次本来可能很麻烦的操作里,都有一个明确的退路。备份的意义不是“我有很多副本”,而是“我敢做那些必须做的事”。