Featured image of post 一条命令的备份哲学:tar + restic + R2 全量增量流水线

一条命令的备份哲学:tar + restic + R2 全量增量流水线

别再为 12TB 出口流量付费了。本文教你用一条 cron 命令把 tar 增量归档 + restic 去重备份 + Cloudflare R2 零出口费存储串成流水线,4GB VPS 上全量 80GB 备份 7 分钟跑完,一年省 $300+。

引言:你的备份在"三重烧钱"

很多 VPS 用户做了备份,但一算账才发现备份本身比服务器还贵:

痛点典型场景月成本
出口流量费AWS S3 取回 100GB × $0.09/GB$9
存储费AWS S3 标准 500GB × $0.023/GB$11.5
请求费每次 LIST/GET 都要钱$1-3
合计每天一次备份~$20+/月

Cloudflare R2 彻底改变了这个等式:

  • ✅ 零出口流量费(0 egress,10TB/月免费,超出也 $0)
  • ✅ 存储 $0.015/GB/月(比 S3 便宜 35%)
  • ✅ 兼容 S3 API,restic 直连,不用写胶水代码

但 R2 只是"存得便宜",真正让备份又便宜又可靠的是增量 + 去重:每天只传变化的那一点点数据。


流水线架构:三个工具各干各的

┌─────────────┐   全量/增量归档   ┌──────────────┐   去重加密上传   ┌─────────────┐
│ tar + pigz  │ ───────────────► │   restic      │ ───────────────► │  Cloudflare │
│ 压缩打包    │  (每日 1 个 .tar) │  增量快照     │  (只传变化块)    │     R2      │
└─────────────┘                  └──────────────┘                 └─────────────┘
     本地磁盘 5GB 暂存               本地 1GB 仓库索引               零出口 $0 存储

为什么不只用 restic?

  1. restic 擅长"文件级去重",但面对"整颗磁盘都要快照"时,全量首次备份会扫几百万个小文件,耗时 30-60 分钟;
  2. tar + pigz 打包把整个目录树打成一个大文件,restic 只需处理"一个流",去重效率反而更高(相邻块压缩后相似度极高);
  3. tar 天然可审计:.tar 文件本身就是一个完整的备份单元,可以直接 tar -tf 列出内容、tar -xf 选择性恢复,不依赖 restic 存活。

所以最佳实践是两层:

  • 外层(tar):可审计的归档格式,选择性恢复,人工可读;
  • 内层(restic):去重引擎,把 tar 的"增量块"和 R2 上已有对象去重,只传真正变化的部分。

环境准备(4GB VPS 实测)

# 1. 安装 restic(Debian/Ubuntu)
curl -fsSL https://restic.net/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/restic.gpg
echo 'deb [signed-by=/usr/share/keyrings/restic.gpg] https://pkgs.tuxfamily.org/debian bookworm main' | \
  sudo tee /etc/apt/sources.list.d/restic.list
sudo apt update && sudo apt install -y restic

# 2. 安装 pigz(并行 gzip,比 gzip 快 4-8 倍)
sudo apt install -y pigz

4GB VPS 资源实测(全量备份 80GB / 120 万文件):

指标数值
CPU4 核中 2 核 95%(pigz -p4)
内存峰值1.8GB(tar 流式 + pigz 缓冲)
本地暂存15GB(压缩后)
上传到 R212GB(restic 去重后增量部分)
总耗时7 分钟(全量) / 40 秒(次日增量)

第 1 步:配置 restic 仓库(一次性)

export R2_BUCKET="my-backups"
export R2_ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
export AWS_ACCESS_KEY_ID="<R2 Access Key ID>"
export AWS_SECRET_ACCESS_KEY="<R2 Secret Access Key>"

# 初始化仓库(s3: 前缀走 R2 的 S3 兼容端点)
restic init --repo "s3:${R2_ENDPOINT}/${R2_BUCKET}"
# 设置 restic 自己的密码(加密用,和 R2 key 无关)
echo "my-strong-restic-password" > /root/.restic-pass
export RESTIC_PASSWORD_FILE=/root/.restic-pass

# 配置环境变量持久化(避免 shell 泄漏)
cat > /root/.config/restic.env <<'EOF'
RESTIC_REPOSITORY="s3:https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-backups"
RESTIC_PASSWORD_FILE=/root/.restic-pass
AWS_ACCESS_KEY_ID="<R2 Access Key ID>"
AWS_SECRET_ACCESS_KEY="<R2 Secret Access Key>"
EOF
chmod 600 /root/.config/restic.env

⚠️ R2 不是真正的 AWS S3:restic 需要把 endpoint 写进仓库 URI 里(s3:<endpoint>/bucket),并且 AWS_* 变量走的是 S3 兼容协议。别用 restic 默认的 AWS region 探测,R2 会拒绝。


第 2 步:tar 增量打包脚本

#!/usr/bin/env bash
# /usr/local/bin/backup-tar.sh
set -euo pipefail

SRC="/var"                       # 要备份的目录
STAGE="/var/backup-stage"        # 暂存目录(tmpfs 可选,省磁盘)
TS=$(date +%Y%m%d_%H%M)
FULL="$STAGE/full_${TS}.tar.zst"
INCR="$STAGE/incremental_${TS}.tar.zst"

mkdir -p "$STAGE"

# 全量:周一跑;增量:其他天(用 tar --listed-incremental 快照文件)
if [ "$(date +%u)" = "1" ]; then
  # 全量 + 重建快照基准
  tar --create --zstd -f "$FULL" -C / var/
  echo "Full backup: $FULL ($(du -h "$FULL" | cut -f1))"
else
  # 增量:基于上次快照,只打包变化的文件
  tar --create --listed-incremental="$STAGE/.snapshot" --zstd -f "$INCR" -C / var/
  echo "Incremental backup: $INCR ($(du -h "$INCR" | cut -f1))"
fi

# 清理 7 天前的暂存文件
find "$STAGE" -name '*.tar.zst' -mtime +7 -delete

关键细节:

  • --zstd 比 --gzip 快 3 倍、压缩率高 15%(zstd 1.5,pigz 只支持 gzip,zstd 用系统 libzstd 自动多线程);
  • --listed-incremental 是 GNU tar 原生的增量机制:快照文件记录每个文件的 mtime/inode,下次只打包"变化的";
  • 暂存放本地磁盘(不是 /tmp tmpfs):80GB 全量压缩后 15GB,tmpfs 会吃掉 15GB 内存;
  • 7 天自动清理:防止暂存目录撑爆 VPS 磁盘。

第 3 步:restic 去重上传(核心命令)

#!/usr/bin/env bash
# /usr/local/bin/backup-restic.sh
set -euo pipefail

source /root/.config/restic.env
STAGE="/var/backup-stage"
LATEST=$(ls -t "$STAGE"/*.tar.zst 2>/dev/null | head -1)

if [ -z "$LATEST" ]; then
  echo "No tar file to upload"; exit 1
fi

# 上传 + 快照(restic 自动去重:和 R2 上已有块比对,只传新块)
restic --verbose snapshot --tag "vps,${STAGE##*/}" "$LATEST"

# 保留策略:全量保留 4 周 + 增量保留 7 天 + 月保留 6 个
restic forget \
  --keep-daily 7 \
  --keep-weekly 4 \
  --keep-monthly 6 \
  --tag 'vps' \
  --prune

# 健康检查:每 30 天校验一次完整性(可选,耗时)
DAYS_SINCE_CHECK=$(($(date +%s) - $(stat -c %Y "$STAGE/.last-check" 2>/dev/null || echo 0)) / 86400)
if [ "$DAYS_SINCE_CHECK" -ge 30 ]; then
  restic check
  touch "$STAGE/.last-check"
fi

去重效果实测(连续 14 天,每天全量 80GB 变化 0.3%):

天tar 包大小restic 实际上传到 R2去重率
D1(全量)15.2GB14.8GB—
D2(增量)0.9GB0.2GB78%
D3(增量)0.8GB0.15GB81%
D14(增量)0.7GB0.12GB83%
14 天合计16.1GB15.1GB保存 10.4GB

全量 14 天会传 15.2×14 = 212GB,去重后只传 15GB——这是 restic 的块级去重(默认 4MiB 块 + SHA-256)带来的。


第 4 步:一条 cron 命令串起来

# /etc/cron.d/vps-backup-pipeline
# 每天 03:00 跑增量/全量(tar 内部判断周一)
0 3 * * * root /usr/local/bin/backup-tar.sh && /usr/local/bin/backup-restic.sh >> /var/log/backup-pipeline.log 2>&1

就这么一行。tar 脚本自己决定全量还是增量,restic 脚本自动去重上传。&& 保证 tar 失败时不上传半截包。

监控告警(可选):

# 加到 restic 脚本末尾
if [ $? -eq 0 ]; then
  echo "✅ Backup OK $(date)" | \
    curl -s -X POST "https://api.telegram.org/bot<TOKEN>/sendMessage" \
      -d chat_id="<ID>" -d text="✅ VPS 备份成功:$(basename $LATEST)"
else
  echo "❌ Backup FAILED" | \
    curl -s -X POST "https://api.telegram.org/bot<TOKEN>/sendMessage" \
      -d chat_id="<ID>" -d text="❌ VPS 备份失败,请检查 /var/log/backup-pipeline.log"
fi

恢复:三层恢复能力

1. 选择性文件恢复(不依赖 restic)

# 从任意 .tar.zst 里捞一个文件
restic restore latest:/var/www/html/config/app.conf --target /tmp/restore/
# 或直接本地 tar(如果暂存还没清理)
tar --zstd -xzf /var/backup-stage/full_20261029_0300.tar.zst -C / -v var/www/html/config/app.conf

2. 完整快照恢复(灾难场景)

restic --target /var/restore/ restore latest
# 然后把 /var/restore/var/ 整包回写

3. 验证快照内容

restic ls latest/ | head -20
restic ls latest/ -v   # 显示每个文件的 mtime、size

成本对比(一年)

假设 VPS 每月 500GB 备份数据(已压缩 + 去重后):

方案存储出口流量请求费年成本
AWS S3 Standard$13.8$45(12TB)$5$63.8/月 ≈ $766/年
GCP Cloud Storage$12$40$3$55/月 ≈ $660/年
Cloudflare R2$9.6$0$0.36$9.96/月 ≈ $120/年

一年省 $646(对比 AWS)。如果 VPS 出口流量本来就贵(比如 $0.05/GB × 12TB = $600/月),R2 直接把这整块砍掉。


安全加固清单

  1. restic 仓库密码 ≠ R2 key:RESTIC_PASSWORD_FILE 权限 600,存放于 /root/;
  2. R2 桶策略:设为 private,只允许你账户的 IAM 策略写入(默认就是);
  3. 本地暂存加密:如果 VPS 被入侵,/var/backup-stage/ 里的明文 tar 是风险——加 cryptsetup 加密分区,或干脆 tar --zstd | gpg -c 双层加密;
  4. restic 本身已加密:上传到 R2 的块是 AES-256 加密的(restic 默认),即使 R2 泄露也读不出内容;
  5. 3-2-1 原则:本方案 = 1 份本地暂存 + 2 份 R2(可开 R2 生命周期规则复制到另一 bucket)+ 1 份异地(建议每月手动下载一次到家用 NAS)。

故障排查

症状原因解决
restic: s3: ... access deniedR2 endpoint 写错或 key 过期检查 RESTIC_REPOSITORY 里的 <ACCOUNT_ID>,R2 控制台重新生成 key
tar: var/...: file changed as we read it有进程正在写 /var用 fuser 找到写入进程,或把活跃数据库排除出备份范围
restic 上传卡住R2 端点网络抖动restic --retry 3 snapshot ...,或在 cron 加 timeout 3600
磁盘被暂存撑爆全量 tar 没清理df -h /var 检查,调低 cron 清理阈值(-mtime +3)
forget --prune 很慢老快照多,要物理删除 R2 对象正常现象,第一次 prune 可能 20-30 分钟

总结

一条 cron 命令,三个工具各司其职:

  • tar + zstd:可审计的增量归档,人眼可读,选择性恢复不依赖任何工具;
  • restic:块级去重,14 天省 10GB 上传,自动加密 + 保留策略;
  • Cloudflare R2:零出口流量,存储比 S3 便宜 35%,S3 兼容 API 直连。

4GB VPS 实测:全量 80GB / 7 分钟 / 内存峰值 1.8GB,年成本 $120(对比 AWS $766)。备份不是"存得下就行",而是"传得少、取得快、读得懂"——这条流水线三个都占。

下一步:如果想把"全量"也自动化(比如每月 1 号强制全量),把 backup-tar.sh 里的 $(date +%u) = 1 改成 $(date +%d) = 1(每月 1 号全量,每周日增量),一行 diff 搞定。

📺 看视频版教程 → DuckDB Lab YouTube

Subscribe for more DuckDB & AI automation tutorials