3-2-1 备份原则在一台 VPS 上怎么落地:从增量快照到可验证恢复

举报
yd_290186593 发表于 2026/11/01 11:33:00 2026/11/01
【摘要】 硬链接增量让 14 天快照只占 1.1 倍空间;age 加密上传异地;恢复演练做 checksum 比对。附 systemd timer 健康检查脚本。

大多数人做备份的方式是:装个脚本,`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. **检查"够不够新"而不是"有没有跑"** —— 静默停止是主要失败模式

真正难的不是写脚本,是**在没出事的时候花时间验证它能在出事时生效**。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。