3-2-1 备份原则在一台 VPS 上怎么落地:从增量快照到可验证恢复
大多数人做备份的方式是:装个脚本,`tar` 打包,扔到另一个目录,加个 crontab,然后就不再看了。直到某天真的需要恢复,才发现压缩包是坏的、密钥丢了、或者备份里根本没有要的那张表。
备份的唯一定义是**能恢复**。不能恢复的东西叫副本,不叫备份。这篇记录一套在一台 2 核 2G、40G 盘的 Debian 12 上跑通的方案,重点不在"怎么备份",而在"怎么证明备份能用"。
3-2-1 原则拆开看
| 数字 | 含义 | 在这台机器上的落地 |
|---|---|---|
| 3 | 至少 3 份数据 | 生产数据 + 本地快照 + 异地副本 |
| 2 | 至少 2 种介质 | 云盘 + 对象存储 |
| 1 | 至少 1 份异地 | 对象存储在不同可用区 |
关键理解:**3 份不是 3 个备份脚本,是 3 个独立故障域**。同一块盘上的 `/data` 和 `/backup` 只能算 1 份——盘坏了两个一起没。
第一层:本地增量快照
用 `rsync --link-dest` 做硬链接增量。没变的文件共享 inode,只有变化的文件占新空间,但每个快照目录看起来都是完整的一份。
#!/usr/bin/env bash
set -euo pipefail
SRC=/var/www
ROOT=/backup/snapshots
LATEST="$ROOT/latest"
TODAY="$ROOT/$(date +%F)"
mkdir -p "$ROOT"
rsync -a --delete \
--link-dest="$LATEST" \
"$SRC/" "$TODAY/"
# 原子切换 latest 指针
ln -sfn "$TODAY" "$LATEST.tmp"
mv -Tf "$LATEST.tmp" "$LATEST"
# 只保留 14 天
find "$ROOT" -maxdepth 1 -type d -name '20*' -mtime +14 -exec rm -rf {} +
三个细节值得说:
**1. `--link-dest` 必须指向完整快照,不是增量目录。** 它对比的是"目标目录里已有什么",用硬链接复用相同的文件。所以 `latest` 要指向最近一次完整快照。
**2. `ln -sfn` 加 `mv -Tf` 是为了原子替换。** 直接 `ln -sfn` 在符号链接已存在时行为不可靠,`mv -Tf` 保证指针切换要么成功要么不变,不会出现半个状态。
**3. `-a` 已包含 `-l`(保留硬链接),但 `--link-dest` 的复用逻辑独立于它。** 两者配合才能既保留源数据内的硬链接关系,又复用快照间的相同文件。
实测空间效率:`/var/www` 约 800MB,14 天快照总共占 **1.1GB**,而不是 800MB × 14 = 11GB。
第二层:异地对象存储
本地快照解决不了"整台机器没了"和"云盘被误删"。第二份要出机器。
#!/usr/bin/env bash
set -euo pipefail
SNAP=/backup/snapshots/latest
STAGE=/backup/stage
REMOTE=r2:my-bucket/vps-backup
mkdir -p "$STAGE"
OUT="$STAGE/$(date +%F).tar.zst.age"
tar -C "$SNAP" -cf - . \
| zstd -19 -T0 \
| age -r "$(cat /etc/backup/age.pub)" \
> "$OUT"
rclone copy "$OUT" "$REMOTE/" --transfers 4 --checkers 8
rclone delete "$REMOTE/" --min-age 30d
rm -f "$OUT"
选型说明:
- **zstd 而不是 gzip**:压缩率接近,速度快 3-5 倍。`-19` 是高压缩档,`-T0` 用满所有核。
- **age 而不是 gpg**:age 的密钥格式短、无交互、无 web-of-trust 包袱。公钥加密,私钥离线保管。
- **rclone 而不是 s3cmd**:一套配置支持 S3/R2/B2/WebDAV,换供应商不改脚本。
加密不是可选项
对象存储不是你的私有磁盘。即使是私有 bucket,也存在误配置公开、供应商内部访问、账号被盗三种风险。备份里通常有数据库导出、`.env`、SSH 私钥——**明文上传等于把凭据副本交给了第三方**。
age 的用法:
# 生成密钥对(只做一次)
age-keygen -o /etc/backup/age.key
# 输出里会打印 age1... 开头的公钥,记下来
# 公钥放机器上,私钥绝不放在被备份的机器上
cat > /etc/backup/age.pub <<'EOF'
age1qz...
EOF
**私钥必须离线。** 放在同一台机器上,机器挂了私钥也一起没了,备份就成了永远打不开的黑盒。建议打印一份纸质存放,或用密码管理器单独保存。
恢复演练:90% 的人跳过的一步
到这里,大部分人会觉得"备份做好了"。但没有验证过的备份,可靠性和没做一样——你只是**假设**它能恢复。
恢复演练要做三件事:
**1. 定期真实解密 + 解包**
#!/usr/bin/env bash
set -euo pipefail
REMOTE=r2:my-bucket/vps-backup
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# 取最新一份
LATEST=$(rclone lsf "$REMOTE/" --files-only | sort | tail -1)
rclone copy "$REMOTE/$LATEST" "$TMP/"
# 解密 + 解包
age -d -i /root/age.key "$TMP/$LATEST" \
| zstd -d \
| tar -C "$TMP/restore" -xf -
# 校验文件数
SRC_COUNT=$(find /var/www -type f | wc -l)
DST_COUNT=$(find "$TMP/restore" -type f | wc -l)
[ "$SRC_COUNT" -eq "$DST_COUNT" ] || { echo "COUNT MISMATCH $SRC_COUNT vs $DST_COUNT"; exit 1; }
# 抽样比对内容(用 checksum 而非 diff,避免时间戳噪声)
(cd /var/www && find . -type f -print0 | xargs -0 sha256sum | sort) > "$TMP/src.sum"
(cd "$TMP/restore" && find . -type f -print0 | xargs -0 sha256sum | sort) > "$TMP/dst.sum"
diff -q "$TMP/src.sum" "$TMP/dst.sum" || { echo "CHECKSUM MISMATCH"; exit 1; }
echo "restore drill OK: $DST_COUNT files"
**2. 把演练结果记录下来**
失败要能看见。写进日志,配合告警:
/usr/local/bin/restore-drill.sh >> /var/log/restore-drill.log 2>&1
**3. 演练频率**
| 数据重要度 | 建议频率 |
|---|---|
| 有合规要求 | 每月 |
| 生产业务数据 | 每季度 |
| 个人项目 | 每半年,但换机器时必须做一次 |
监控:确认备份真的在跑
备份最典型的失败模式不是报错,是**静默停止**——磁盘满了、cron 被注释了、rclone token 过期了,而你以为还在跑。
三个必查项:
#!/usr/bin/env bash
set -euo pipefail
ROOT=/backup/snapshots
MAX_AGE_HOURS=25
FAIL=0
# 1. 最新快照是否够新
NEWEST=$(find "$ROOT" -maxdepth 1 -type d -name '20*' -printf '%T@ %p\n' \
| sort -rn | head -1 | cut -d' ' -f2-)
if [ -z "$NEWEST" ]; then
echo "FAIL: no snapshot found"; FAIL=1
else
AGE_H=$(( ( $(date +%s) - $(stat -c %Y "$NEWEST") ) / 3600 ))
[ "$AGE_H" -le "$MAX_AGE_HOURS" ] || { echo "FAIL: snapshot ${AGE_H}h old"; FAIL=1; }
fi
# 2. 磁盘剩余空间
USED_PCT=$(df --output=pcent /backup | tail -1 | tr -dc '0-9')
[ "$USED_PCT" -lt 85 ] || { echo "FAIL: /backup ${USED_PCT}% used"; FAIL=1; }
# 3. 异地副本是否同步(rclone 会缓存,用 --refresh 强制刷新)
REMOTE_COUNT=$(rclone lsf r2:my-bucket/vps-backup/ --files-only | wc -l)
[ "$REMOTE_COUNT" -ge 1 ] || { echo "FAIL: remote empty"; FAIL=1; }
[ "$FAIL" -eq 0 ] && echo "backup health OK"
exit "$FAIL"
接进 systemd timer,失败时发通知:
# /etc/systemd/system/backup-health.service
[Unit]
Description=Backup health check
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-health.sh
# /etc/systemd/system/backup-health.timer
[Unit]
Description=Daily backup health check
[Timer]
OnCalendar=*-*-* 06:30:00
Persistent=true
[Install]
WantedBy=timers.target
`Persistent=true` 很重要:机器关机错过了时间点,开机后会补跑一次。cron 没有这个行为。
完整时间线
| 时间 | 任务 | 保留 |
|---|---|---|
| 02:00 | 本地增量快照 | 14 天 |
| 02:30 | 加密上传异地 | 30 天 |
| 06:30 | 健康检查 + 告警 | — |
| 每季度 | 恢复演练 | — |
小结
这套方案的关键决策点:
1. **本地用硬链接增量** —— 14 天快照只占 1.1 倍空间,不是 14 倍
2. **异地必须加密** —— 对象存储不是私有磁盘,私钥必须离线
3. **恢复演练是备份的一部分** —— 没演练过的备份只是假设
4. **监控用 systemd timer 而非 cron** —— `Persistent=true` 能补跑漏掉的窗口
5. **检查"够不够新"而不是"有没有跑"** —— 静默停止是主要失败模式
真正难的不是写脚本,是**在没出事的时候花时间验证它能在出事时生效**。
- 点赞
- 收藏
- 关注作者
评论(0)