ZFS on Linux 快照与 zfs send/recv 增量异地复制实战:原子快照、全量首传与日常增量循环
普通文件系统要备份一个「某一时刻的完整状态」,得先停服务、再 rsync、还得担心同步过程中文件被改。ZFS 完全不同:它用写时复制(Copy-on-Write)实现快照,快照创建是原子且瞬时的,几乎不占额外空间,而且可以配合 zfs send / zfs receive 把整个数据集(含快照)流式复制到另一台机器,增量传输只传变化的块。这让 ZFS 成为自建 NAS、备份服务器、以及「需要时间点恢复」的站长手里最锋利的工具之一。这篇文章从池的规划讲到日常增量复制的自动化脚本,把这条链路走通。
一、为什么用 ZFS 做备份源
- 原子快照:
zfs snapshot不需要等待、不需要停服务,得到的一定是某个精确时刻的一致视图。 - 增量发送:
zfs send -i只传两个快照之间的差异,几 GB 的数据日增量可能只有几百 MB。 - 校验和:ZFS 默认对所有数据做校验(fletcher4/sha256),静默损坏能被发现(
zpool scrub)。 - 压缩:lz4 压缩几乎零开销,能显著减少网络传输量(发送的是已压缩数据流)。
注意:ZFS 是内存大户,规则是「每 1TB 存储配 1GB 内存起步,开去重则要 5GB/TB 以上」。个人站长用 8GB 内存跑 2-4TB 的单盘池完全可行,但不要随便开 dedup(去重),它吃内存且回不了头。
二、建池与基本参数
# 安装
apt-get install -y zfsutils-linux # Debian/Ubuntu
# 或 dnf install -y zfs-release && dnf install -y zfs
# 用整块盘建单盘池(推荐整盘,不要用分区,ZFS 能拿到准确的扇区信息)
zpool create -o ashift=12 tank /dev/disk/by-id/ata-WDC_WD40EFRX_XXXX
# ashift=12 表示 4K 扇区对齐,几乎所有现代盘都应如此
# 查看池状态
zpool status tank
zpool list
# 建数据集(相当于挂载点)
zfs create tank/data
zfs create tank/backup
# 调优:lz4 压缩、atime 关闭(减少无用写)、记录大小按场景
zfs set compression=lz4 tank/data
zfs set atime=off tank/data
zfs set recordsize=1M tank/backup # 大文件备份用 1M 更高效
zfs set xattr=sa tank/data
# 查看
zfs get compression,atime,recordsize tank/datarecordsize 值得说一句:默认 128K 适合通用场景;数据库这类小随机写应该调到 16K 甚至 8K;纯大文件归档用 1M 更好。改动只影响新写入的数据。
三、快照:瞬时、只读、空间共享
# 手动快照,命名规范强烈建议带时间戳
zfs snapshot tank/data@2026-10-10_0300
# 列出快照
zfs list -t snapshot -o name,used,refer tank/data
# 递归快照(含所有子数据集)
zfs snapshot -r tank/data@daily-2026-10-10
# 快照只读,但要访问其中的文件:
ls /tank/data/.zfs/snapshot/2026-10-10_0300/
# 从快照恢复单个文件
cp /tank/data/.zfs/snapshot/2026-10-10_0300/www/index.php /tank/data/www/index.php
# 整体回滚(谨慎,会丢弃快照之后的所有改动)
zfs rollback tank/data@2026-10-10_0300
# 删除旧快照释放空间
zfs destroy tank/data@2026-10-10_0300关键认知:快照本身不复制数据,它只是「记住那一刻的块指针」。只有当你修改了某个块,旧块才会因为快照引用而保留下来占空间。zfs list -o used 显示的 USED 就是该快照独占的空间,refer 是该快照「看到」的总数据量。
四、send / recv 全量传输
首次把数据集复制到远端,走全量:
# 1. 先在源端建一个基准快照
zfs snapshot tank/data@base
# 2. 流式发送并接收(管道 + ssh,不落中间文件)
zfs send tank/data@base | ssh backup@remote "zfs receive -F backuptank/data"
# 若远端池名不同,接收时重命名:
zfs send tank/data@base | ssh backup@remote "zfs receive -F backuptank/data_copy"
# 大流可以压缩再传(源已 lz4,可在管线上再 gzip)
zfs send tank/data@base | gzip -3 | ssh backup@remote "gunzip | zfs receive -F backuptank/data"
# 也可先落盘成文件,便于归档寄送
zfs send tank/data@base > /backup/tank-data-base.zfs
zfs receive -F backuptank/data < /backup/tank-data-base.zfs-F(force)会先把目标端的文件系统回滚到接收流的起点快照,保证一致性。生产环境第一次全量传输前,建议先用 -n(dry-run)或小数据集练手。
五、增量传输:日常复制的核心
增量是 ZFS 备份的精髓,语法是 zfs send -i <起点快照> <终点快照>:
# 昨天传过 @base,今天又建了 @today,只传差异
zfs snapshot tank/data@today
zfs send -i tank/data@base tank/data@today | ssh backup@remote "zfs receive -F backuptank/data"
# 传完即可删除源端旧快照,节省源空间(但目标端会保留)
zfs destroy tank/data@base要点:发送端必须同时拥有起点和终点两个快照,否则无法计算差异。因此增量链的正确姿势是「传完上一段再删上一段的起点」,形成 base → s1 → s2 → s3 的递进链。目标端会自动把收到的每个快照都保留下来,需要单独清理。
如果链路中间缺了一环(比如源端误删了 base),增量就断了,只能重新做一次全量。这是 ZFS 复制最常见的运维事故,务必用脚本严格管理快照生命周期。
六、用 syncoid 简化整条链路
手写 send/recv 脚本容易出错,开源工具 sanoid / syncoid 把快照策略和复制都自动化了,强烈推荐:
# 安装(Debian 系)
apt-get install -y sanoid
# sanoid 负责按策略自动打快照/清理
# /etc/sanoid/sanoid.conf
cat > /etc/sanoid/sanoid.conf <<'EOF'
[tank/data]
use_template = production
recursive = yes
[template_production]
frequently = 0
hourly = 24
daily = 30
monthly = 6
yearly = 0
autosnap = yes
autoprune = yes
EOF
# 启用定时器
systemctl enable --now sanoid.timer
# syncoid 负责复制(可手动跑,也可自己写 cron)
# 全量/增量自动判断,断链时自动重同步
syncoid --recursive --compress=lz4 tank/data backup@remote:backuptank/data
# 想让它自动处理中间快照,加 --no-sync-snap 关闭本地建快照(交给 sanoid)
syncoid --no-sync-snap --recursive tank/data backup@remote:backuptank/datasyncoid 会自动找两端公共快照、计算增量、必要时回退到全量,比裸写 send/recv 稳得多。日常做法是 sanoid 打快照、syncoid 做复制,各司其职。
七、手写一个可用的增量复制脚本
如果不想引入额外依赖,这个脚本范式足够可靠:
#!/usr/bin/env bash
# /usr/local/bin/zfs-replicate.sh
# 递归复制 tank/data 到远端,自动增量、自动清理源端旧快照
set -euo pipefail
SRC=tank/data
DST_HOST=backup@remote
DST_POOL=backuptank
TS=$(date +%Y%m%d_%H%M)
SNAP="$SRC@auto-$TS"
KEEP=7 # 源端保留最近 7 个 auto- 快照
# 1. 打新快照
zfs snapshot -r "$SNAP"
echo "[+] snapshot $SNAP created"
# 2. 找目标端最新的公共快照作为增量起点
# 列出源端由旧到新的 auto 快照
mapfile -t SNAPS < <(zfs list -H -t snapshot -o name -s creation -r "$SRC" \
| grep '@auto-' | grep -v "@auto-$TS$")
LAST_SNAP="${SNAPS[-1]:-}"
# 3. 复制
if [ -n "$LAST_SNAP" ]; then
echo "[+] incremental from $LAST_SNAP"
zfs send -i "$LAST_SNAP" -R "$SNAP" \
| ssh -o StrictHostKeyChecking=no "$DST_HOST" \
"zfs receive -F -u $DST_POOL/data"
else
echo "[+] no common snap, full send"
zfs send -R "$SNAP" \
| ssh -o StrictHostKeyChecking=no "$DST_HOST" \
"zfs receive -F -u $DST_POOL/data"
fi
# 4. 清理源端过老快照(保留最近 KEEP 个)
zfs list -H -t snapshot -o name -s creation -r "$SRC" \
| grep '@auto-' | head -n -"$KEEP" | while read -r s; do
zfs destroy "$s" && echo "[-] destroyed old $s"
done
echo "[+] replication done"放进 cron,每天凌晨跑:
# crontab -e
0 3 * * * /usr/local/bin/zfs-replicate.sh >> /var/log/zfs-replicate.log 2>&1zfs send -R 表示递归发送(含子数据集),配合 zfs receive -u(不自动挂载)在远端更安全。
八、验证、监控与常见坑
复制做完不代表没事,必须验证。
# 目标端确认最新快照已到
ssh backup@remote "zfs list -t snapshot -o name,creation -r backuptank/data | tail -5"
# 目标端做一次完整性扫描(可定期)
ssh backup@remote "zpool scrub backuptank"
# 源端查看发送历史与错误
zpool status tank
zfs get -r all tank/data | grep -i error常见坑清单:
- 增量断链:源端删了旧快照导致
-i找不到起点。解决:严格靠脚本管理,或让 syncoid 自动处理。 - 目标端快照越积越多:目标端不会自动清理,需要单独写 prune 逻辑,否则空间耗尽接收失败。
- 内存不足:ZFS ARC 吃内存,小内存机器建议限制 ARC,如
echo 2147483648 > /sys/module/zfs/parameters/zfs_arc_max(写进 modprobe.conf 持久化)。 - ashift 设错:建池时若没设 ashift=12,扇区不对齐会导致写放大、寿命下降。已经建好的池改不了 ashift,只能重建。
- 整盘 vs 分区:给 ZFS 用整块盘,别先分区,否则扇区对齐信息可能失真。
- recv 目标被挂载占用:
zfs receive -F在目标被挂载且正在用时可能失败,用-u或先 umount。 - 时间同步:脚本里依赖时间戳命名快照,服务器时间要开 NTP,否则快照名乱序、prune 出错。
九、一次完整的演练流程
# 源端
zpool create -o ashift=12 tank /dev/disk/by-id/XXXX
zfs create tank/data
zfs set compression=lz4 tank/data
zfs snapshot tank/data@base
# 目标端建好池与父数据集
ssh backup@remote "zpool create -o ashift=12 backuptank /dev/disk/by-id/YYYY && zfs create backuptank/data"
# 首次全量
zfs send tank/data@base | ssh backup@remote "zfs receive -F backuptank/data"
# 写点新数据,建增量快照
zfs snapshot tank/data@inc1
zfs send -i tank/data@base tank/data@inc1 | ssh backup@remote "zfs receive -F backuptank/data"
# 目标端验证两个快照都在
ssh backup@remote "zfs list -t snapshot -o name backuptank/data"把上面这套流程走一遍,你就拥有了「任意时间点可选、每天增量、可异地」的备份能力。恢复时,远端 zfs rollback backuptank/data@inc1 就能回到那个时刻,或从远端再 send 回源端。
九、加密传输与保留策略的取舍
异地复制常常要跨越公网,此时纯 ssh 已提供加密,但如果目标端是第三方的存储(比如把快照流写进对象存储再寄到别处),就需要 zfs send 自带的加密。zfs send -w(raw)会原样发送已在数据集层开启加密的原始数据,目标端无需密钥即可存储,只有持有密钥的人才能恢复——这对合规寄送磁盘特别有用。发送时加上 -c 可指定压缩算法,接收端自动解压,能进一步减少跨公网带宽。
保留策略是另一条必须提前想清楚的规则。源端我们靠 sync 脚本保留最近 N 个快照;目标端则应该采用「祖父-父-子」式的分层保留,比如保留最近 7 个每日、4 个每周、12 个每月。这样近期的恢复粒度细,远期的历史跨度长,而总体空间又可控。sanoid 的模板天然支持这种周期定义,手写脚本则要自己实现 prune 逻辑。无论哪种方式,都要在目标端设置一个「空间水位告警」:当 zpool 使用率超过 80% 就发通知,避免快照把池写满导致接收失败,进而引发一整条复制链断裂。
最后强调恢复演练。备份最大的风险不是「没备份」,而是「以为自己备份了,真要用时才发现收不回来」。建议每季度选一个目标端快照,实际 zfs rollback 或 zfs send 回一个沙箱数据集,确认数据可读、可挂载、内容完整。没有演练过的备份,不算备份。
十、小结
ZFS 的快照 + send/recv 组合,把「一致性备份」这件难事变成了日常操作:快照原子瞬时,全量首传打底,增量只传变化块,异地复制用一个管道加 ssh 就能完成。个人站长的正确姿势是——用 sanoid 按策略打快照、用 syncoid 或自写脚本做增量复制、定期在目标端 scrub 验证、严格控制快照生命周期避免断链。做到这些,你的数据就不再是一份「可能已经损坏还不自知」的副本,而是有据可查、可回退到任意时间点的完整时间线。