一份能用来恢复的备份,应当无需人工干预地按计划运行,在被入侵的服务器无法删除的异地保留一份副本,以一致的状态捕获数据库,并且最近真正恢复过一次并记录了耗时。用 restic 来实现,就是一个位于 S3 兼容存储上的加密仓库,由 object lock 或 append-only 服务器保护;每次运行前先做数据库转储;一个带失败告警的 systemd 定时器;以及定期的恢复演练。本文以 Linux 服务器为例搭建这套方案,同样的组件也适用于开发者的个人电脑。
选工具之前先确定 RPO 和 RTO
恢复点目标(recovery point objective,RPO)表示你能承受丢失多少数据,以时间衡量。恢复时间目标(recovery time objective,RTO)表示在恢复期间服务最多可以停多久。服务负责人可以用朴素的话回答这两个问题:“我们最多只能丢一天的订单”,以及“商店必须在四小时内恢复营业”。
定时备份在最坏情况下的数据损失,大约等于两次运行之间的间隔,加上一次运行把数据送到异地存储所需的时间。一个 02:00 开始、02:40 完成上传的夜间任务,最坏情况下的 RPO 约为 24 小时 40 分钟。如果这对数据库来说太多,就提高数据库的备份频率,或者加上 WAL 归档。
RTO 主要取决于传输时间和重建时间。传输秒数等于以兆比特计的数据量除以以 Mbit/s 计的链路速度。恢复 200 GB 相当于 1,600,000 兆比特,在 100 Mbit/s 下需要 16,000 秒,约 4.4 小时,这还没算数据库恢复。如果这已经超过 RTO,单靠远程存储桶无法达标,你需要本地副本或备用系统。
3-2-1 规则及其扩展 3-2-1-1-0
3-2-1 规则指的是:数据保留三份副本,存放在两种不同类型的存储上,其中一份位于异地。在 VPS 上,生产磁盘是第一份,独立存储上的本地转储或仓库是第二份,另一家服务商或另一个账号下的存储桶是第三份。同一块盘上的第二个分区会和第一个分区一起损坏,所以请把本地副本放在另一台设备上(NVMe SSD 选购指南可以帮你挑选),或者放到另一台主机上。
备份厂商推广了 3-2-1-1-0 这一扩展。多出来的 1 是一份离线或不可变的副本,即使攻击者拿到了服务器的 root 权限和它的云凭据,也无法删除它。0 表示完整性检查和恢复测试中零错误。大多数方案都跳过了最后这一位,而恰恰是它能说明前面四位是不是真的。
备份什么,排除什么
备份那些无法在合理时间内从公开来源重建的内容:/etc,/srv 或 /var/www 下的应用代码和上传文件,包含 SSH 与 GPG 密钥的家目录,crontab,TLS 相关文件,数据库转储,以及由 apt-mark showmanual 生成的已安装软件包列表。在开发者电脑上,真正有价值的是尚未推送的工作、stash、dotfiles、密钥和文档。
排除体积大或可以重新生成的数据:各类缓存、node_modules、虚拟环境、构建产物、容器镜像、模型权重和 swap。还要排除正在运行的数据库目录,例如 /var/lib/postgresql,因为从运行中的服务器上复制出来的文件不是可用的备份。在 restic 的排除文件中,以 / 开头的模式匹配绝对路径,而像 node_modules 这样的裸名称可以匹配任意深度:
/var/lib/postgresql
/var/lib/docker
/var/cache
/var/tmp
/swapfile
/home/*/.cache
node_modules
.venv
__pycache__
*.tmp
--exclude-caches 还会跳过包含标准 CACHEDIR.TAG 文件的目录,--one-file-system 则阻止 restic 进入其他已挂载的文件系统。
restic 基础:仓库、加密与密码
restic 把去重后的快照存放在仓库中,仓库可以位于本地磁盘、SFTP、restic 自己的 REST 服务器或对象存储上,每次只上传新的数据块。加密无法关闭:根据 restic 的设计文档,所有数据都使用计数器模式的 AES-256 加密,并用 Poly1305-AES 认证,密钥由经过 scrypt 强化的密码保护。
把配置放在只有 root 可读的环境文件里,让交互式命令和定时任务使用同一组值。仓库文档列出了 URL 格式,例如 S3 兼容服务使用 s3:https://server:port/bucket。
# /etc/restic/restic.env, mode 600
RESTIC_REPOSITORY=s3:https://s3.example.com/web1-backups
RESTIC_PASSWORD_FILE=/etc/restic/password
RESTIC_CACHE_DIR=/var/cache/restic
AWS_ACCESS_KEY_ID=replace-me
AWS_SECRET_ACCESS_KEY=replace-me
HEARTBEAT_URL=https://monitor.example.net/ping/web1-backup
ALERT_WEBHOOK_URL=https://chat.example.net/hooks/backups
install -d -m 700 /etc/restic
(umask 077; openssl rand -base64 32 > /etc/restic/password)
set -a; . /etc/restic/restic.env; set +a
restic init
restic key add # 第二个密码,用于离线保存的恢复副本
restic backup --one-file-system --exclude-caches \
--exclude-file=/etc/restic/excludes.txt /etc /root /home /srv /var/www
restic snapshots
文档说得很直白:丢失密码意味着数据将无法挽回地丢失。把密码、仓库 URL 和存储桶凭据保存在密码管理器和一份密封的离线副本中,绝不能只存放在被保护的服务器上。如果使用密钥管理器,可以让 RESTIC_PASSWORD_COMMAND 输出密码,而不是读取文件。
用 forget 和 prune 管理保留,用 check 检查完整性
restic forget 对每一组快照应用保留策略(默认按主机和路径分组),prune 则删除不再被任何快照引用的数据。--prune 参数会一次完成这两步:
restic forget --prune --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --keep-yearly 3
restic check --read-data-subset=10%
这个策略会在最近 14 个有快照的日子里各保留最新的一份快照,然后在 8 周内每周保留一份,12 个月内每月保留一份,3 年内每年保留一份。修改策略时,先加上 --dry-run 预览结果。forget 文档指出,prune 期间仓库会被锁定,所以要把它安排在备份之外的时段,并给备份命令加上 --retry-lock。
restic check 验证结构和元数据。--read-data 会下载每一个 pack,既耗时又产生出站流量,而 --read-data-subset 只读取一部分:10% 每次运行随机选取一个子集,3/12 则固定读取十二分之一,仓库维护指南对此有说明。检查通过只能证明仓库一致,不能证明你能把服务恢复起来。
勒索软件抹不掉的异地仓库
拿到 root 权限的攻击者可以读取 /etc/restic/restic.env。如果这些凭据能删除对象,异地副本同样会被删除。把凭据的权限限制在单个存储桶上,把管理凭据放在服务器之外,并运用 Linux VPS 加固指南中的最小权限原则。然后从下面两种保护方式中选一种。
append-only 模式的 REST 服务器
restic 的 rest-server 提供 --append-only 模式,允许写入新的备份,但禁止删除或修改已有备份。restic 文档建议从一台独立且防护良好的机器上对这类仓库运行 forget 和 prune,并使用 --keep-within 代替 --keep-weekly 之类的规则,因为被入侵的客户端可能添加时间戳经过精心挑选的伪造快照,挤占真实快照的位置。
S3 兼容存储上的 object lock
S3 Object Lock 以 write-once-read-many 模型保存对象版本。它要求启用版本控制;在 compliance 模式下,包括账号 root 用户在内的任何人都无法在保留期结束前删除被锁定的版本。从 0.13 版起,restic 会发送这类存储桶要求的上传校验和,因此可以配合存储桶默认保留策略使用(其他服务商请加上 --endpoint-url):
aws s3api create-bucket --bucket web1-backups --object-lock-enabled-for-bucket
aws s3api put-object-lock-configuration --bucket web1-backups \
--object-lock-configuration '{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":30}}}'
当 restic 在这种存储桶里删除文件时,S3 只会添加一个删除标记,被锁定的版本依然保留。遭到攻击后,把存储桶按事发前的状态复制出来,例如使用 rclone copy --s3-version-at "2026-09-20" offsite:web1-backups /srv/recovered-repo,再从这份副本恢复。被 prune 删除的数据在保留期结束前仍会占用存储空间,因此请用生命周期规则让非当前版本过期,且时间不早于保留期。不同的 S3 兼容实现存在差异,务必在你的服务商那里测试完整流程。
一致的 PostgreSQL 备份
PostgreSQL 关于文件系统级备份的文档写得很明确:要得到可用的数据目录副本,必须先停止服务器;仅仅禁止连接是不够的,因为 tar 之类的工具不会做原子快照,而且服务器会在内部缓冲数据。包含全部数据文件和 WAL 的卷原子快照是可行的,因为 PostgreSQL 会像崩溃后那样重放 WAL。
对大多数服务器来说,逻辑转储仍是最简单且正确的选择。pg_dump 能在数据库使用期间生成一致的导出,并且不会阻塞其他用户。directory 格式支持并行转储和并行恢复。角色不包含在转储中,所以需要单独导出全局对象。下面的脚本在每次备份前运行,并把最新的转储保留在本地磁盘上:
#!/usr/bin/env bash
# /usr/local/sbin/pg-dump-for-backup
set -euo pipefail
out=/var/backups/postgresql
install -d -m 700 -o postgres -g postgres "$out"
runuser -u postgres -- pg_dumpall --globals-only --file="$out/globals.sql"
runuser -u postgres -- psql -Atc \
"SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate" |
while read -r db; do
rm -rf "$out/$db.new"
runuser -u postgres -- pg_dump --format=directory --jobs=2 --file="$out/$db.new" "$db"
rm -rf "$out/$db"
mv "$out/$db.new" "$out/$db"
done
当大型集群的逻辑恢复无法满足 RTO,或者需要时间点恢复时,请使用 pg_basebackup。它通过复制协议把整个集群复制到一个空目录,需要具备 REPLICATION 属性的角色和相应的 pg_hba.conf 条目,并会传输启动这份副本所需的 WAL。pg_verifybackup 会依据备份清单对结果进行校验:
sudo -u postgres pg_basebackup --pgdata=/var/backups/pg-base --wal-method=stream --checkpoint=fast
sudo -u postgres pg_verifybackup /var/backups/pg-base
pg_restore 会根据定义重建索引。如果数据库里有大型向量索引,例如 pgvector 混合搜索指南中的 HNSW 索引,这一步重建可能占去恢复时间的大头。
用 systemd 服务和定时器调度
oneshot 类型的服务先运行转储,再运行备份。如果转储失败,备份不会启动,单元会进入失败状态。把这些单元保存在 /etc/systemd/system/ 中:
# restic-backup.service
[Unit]
Description=restic backup to the off-site repository
Wants=network-online.target
After=network-online.target postgresql.service
OnFailure=backup-alert@%n.service
[Service]
Type=oneshot
EnvironmentFile=/etc/restic/restic.env
CacheDirectory=restic
Nice=10
IOSchedulingClass=idle
ExecStartPre=/usr/local/sbin/pg-dump-for-backup
ExecStart=/usr/local/bin/restic backup --retry-lock 30m \
--one-file-system --exclude-caches \
--exclude-file=/etc/restic/excludes.txt --tag scheduled \
/etc /root /home /srv /var/www /var/backups/postgresql
ExecStartPost=-/usr/bin/curl -fsS --max-time 10 --retry 3 ${HEARTBEAT_URL}
# restic-backup.timer
[Unit]
Description=Nightly restic backup
[Timer]
OnCalendar=*-*-* 02:15:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
用 systemctl enable --now restic-backup.timer 启用定时器,再用 systemctl list-timers 确认。根据 systemd.timer,Persistent=true 会补跑机器关机期间错过的任务,RandomizedDelaySec= 则把共用同一存储桶的多台主机在时间上错开。oneshot 服务默认没有启动超时。在 Linux 笔记本上,同样这一对单元可以作为用户单元来备份家目录。
每周运行一次、单元头部相同的 restic-maintenance.service 负责保留策略和检查:
ExecStart=/usr/local/bin/restic forget --prune --retry-lock 2h \
--keep-daily 14 --keep-weekly 8 --keep-monthly 12 --keep-yearly 3
ExecStart=/usr/local/bin/restic check --read-data-subset=10%%
在单元文件中要写 %% 才能得到字面意义上的百分号,因为 % 会开启一个说明符。
监控与失败告警
请使用两个相互独立的信号,否则静默的失败往往要到出事时才被发现。第一个信号是 OnFailure=。restic 的退出码含义明确:1 表示命令失败,3 表示部分源文件无法读取,10 表示仓库不存在,11 表示加锁失败,12 表示密码错误。systemd 把任何非零退出码都视为失败,这对 3 同样合适:快照虽然存在,但有缺失。
# [email protected]
[Unit]
Description=Alert for failed %i
[Service]
Type=oneshot
EnvironmentFile=/etc/restic/restic.env
ExecStart=/usr/bin/curl -fsS --max-time 10 --retry 3 \
--data-urlencode "text=%H: %i failed, see journalctl -u %i" \
${ALERT_WEBHOOK_URL}
一个模板即可服务所有任务,systemd 还会向它传递 MONITOR_EXIT_STATUS 等变量。用 systemctl start [email protected] 测试整条链路。
第二个信号是 dead man's switch:ExecStartPost= 只在成功后才访问心跳 URL,如果在比如 26 小时内没有收到请求,监控系统就发出告警。这样可以发现被禁用的定时器或已关机的主机。若想从外部观察,每天在另一台机器上运行 restic snapshots --latest 1 --json --no-lock,当最新快照比 RPO 更旧时发出告警。
进行计时的恢复演练
演练把假设变成数字。在初次部署后、重大变更后,以及至少每季度一次进行演练;使用一台干净的虚拟机而不是生产主机,并且只使用真实事故中工程师能拿到的东西:密码管理器中的条目和 runbook。
- 在模拟事故开始时启动秒表。
- 取得凭据,安装 restic,列出快照。最新快照距今的时间就是你实际达到的 RPO。
- 先恢复文件和转储,再恢复数据库,并为每一步计时。
- 启动应用的 staging 副本,走通一条真实业务流程。
- 记录各阶段耗时和问题,并更新 runbook。
set -a; . ./restic.env; set +a
restic snapshots --latest 3
time restic restore latest --target /restore --verify
sudo -u postgres psql -f /restore/var/backups/postgresql/globals.sql
sudo -u postgres createdb app
time sudo -u postgres pg_restore --jobs=4 --dbname=app /restore/var/backups/postgresql/app
sudo -u postgres psql -d app -c "SELECT max(created_at) FROM orders;"
--verify 会重新读取恢复出来的文件,并与快照进行比对。最后一条查询可以显示这次恢复实际丢失了多少数据。出现某些角色已存在的报错属于正常现象。还要练习恢复单个文件,例如使用 --include /etc/nginx/nginx.conf,这是现实中最常见的请求。
把总耗时与 RTO 对比。常见的意外包括:缺少凭据、密码只存放在已损坏的服务器上、索引重建很慢,以及遗漏的文件,比如 TLS 密钥或位于备份路径之外的 .env。
restic、BorgBackup 还是文件系统快照
许多方案会组合使用这些方式:用 ZFS、Btrfs 或 LVM 快照做快速的本地回滚,再把文件级备份送到异地。BorgBackup 1.4 是当前的稳定分支。
| 属性 | restic | BorgBackup 1.4 | 文件系统快照 |
|---|---|---|---|
| 存储目标 | 本地、SFTP、REST 服务器、S3 兼容存储及其他云存储 | 本地,或通过 SSH,服务器上装有 Borg 时效果最好 | 同一存储池;异地需要 zfs send 之类的复制 |
| 加密 | 始终开启 | 创建仓库时选择,可不启用 | 取决于底层存储 |
| 客户端被入侵时 | rest-server --append-only,存储桶 object lock |
borg serve --append-only |
只有复制到独立管理的系统时才受保护 |
| 数据库一致性 | 需要转储 | 需要转储 | 若数据与 WAL 在同一个原子快照中,则为崩溃一致 |
| 恢复 | 从单个文件到整棵目录树,支持 FUSE 挂载 | 从单个文件到整棵目录树,支持 FUSE 挂载 | 回滚整个卷,或从挂载的快照中复制 |
| 客户端平台 | Linux、macOS、Windows、BSD | Linux、macOS、BSD;Windows 仅能通过实验性的 WSL 或 Cygwin 使用 | 取决于文件系统 |
同一存储池上的快照可以防范一次失败的部署,却防不了存储池损坏或主机被入侵,因此它本身不算 3-2-1 意义上的一份副本。
检查清单
- RPO 和 RTO 已经写下来,并与服务负责人达成一致。
- 共有三份副本,其中一份在异地,一份不可变或为 append-only。
- 密码、仓库 URL 和凭据保存在密码管理器中,并有离线副本。
- 运行中的数据库目录已被排除,转储文件已被包含。
- PostgreSQL 由带全局对象的
pg_dump或经过校验的pg_basebackup覆盖。 - 定时器使用了
Persistent=true,OnFailure=处理程序在测试中至少触发过一次。 - 心跳在缺少成功记录时告警,外部检查在快照过旧时告警。
forget --prune和check --read-data-subset每周运行。- 服务器上的凭据无法删除被锁定的版本。
- 本季度已完成一次计时恢复演练,runbook 反映了演练结论。