无需解锁即可备份加密的 ZFS 数据
原文由 Michael Lynch 于 发布,订阅该博客
不久前,我搭建了自己的第一台家用 TrueNAS 服务器。我用它来存放个人和工作上的大部分数据,因此一直在学习如何充分发挥 TrueNAS 及其文件系统 ZFS 的作用。
今天,我想聊聊如何备份加密数据。


ZFS 一个很酷的特性是,即使数据处于加密状态,你也能直接备份它。麻烦的地方在于,TrueNAS 默认假定你只会备份到另一台 TrueNAS 系统上。如果你和我一样,想把加密数据备份到通用的云存储服务商那里,就得多费点功夫。在今天这篇博文中,我会告诉你具体该怎么做。
为什么要备份加密数据?
我有一些很少访问、但仍想保存在加密数据集上的文件。
在我之前的 Synology NAS 上,根本没法备份加密卷。只要数据被加密,在解密之前,任何程序都无法访问它。对我的大多数数据来说这没什么问题,但那些不常访问的卷怎么办?我的每夜备份就无法把它们同步到云存储上了。
TrueNAS 就好得多!即使数据集处于加密锁定状态,你也可以创建完整备份和增量备份。这看起来像是备份不常访问数据的好办法,而且无需让它们保持解密状态。
我使用restic和resticpy把数据备份到云存储,所以我需要让 restic 能够访问我加密的 ZFS 备份。经过一番折腾和手写 bash 脚本,我终于把它搞定了。
尝试通过 TrueNAS 备份加密数据集(错误的做法)
为了演示我要做的事,我创建了一个名为diary-entries的数据集。

好了,我们往这个数据集中放一个文件:
echo "I enjoy Taylor Swift, but I don't want anyone to know"
> /mnt/pool1/diary-entries/2022-07-05.txt
接下来,我需要创建一个用来接收备份的新数据集,叫作diary-entries-backup。我在这个新数据集上禁用了加密,因为已经加密过的备份没必要再加一层加密:

现在,我准备设置一个复制任务,将diary-entries数据集的加密快照备份到未加密的diary-entries-backup数据集中。之后,restic 就可以访问diary-entries-backup数据集,并将其同步到云存储。
创建复制任务时,TrueNAS 警告我正在复制加密数据集。没关系——这正是我想要的。我就是想抓取加密快照,并在保持加密的状态下把它们备份到云端:

我启动了复制任务,结果……失败了:

错误信息是:
无法将加密数据集‘pool1/diary-entries’发送到已有的未加密或不相关的数据集‘pool1/diary-entries-backup’。
哎呀!
它不让我把加密数据集复制到未加密的数据集上。这有点奇怪,因为如果快照本身就是加密且锁定的,那把它放在同样加密的数据集上又有什么关系呢?
使用命令行界面操作 ZFS
TrueNAS 本质上是 ZFS 的一个友好图形界面。为了更方便,我绕过 TrueNAS,直接使用了 ZFS 更强大的命令行界面(CLI)。
ZFS 文档中包含一个复制数据集的示例命令:
zfs send pool/fs@a | zfs receive poolB/received/fs@a
其中的@a代表名为a的快照,所以我先创建一个名为2022-07-05的快照:
zfs snapshot pool1/diary-entries@2022-07-05
然后我尝试把diary-entries复制到diary-entries-backup:
$ zfs send pool1/diary-entries@2022-07-05 \
| zfs receive pool1/diary-entries-backup
cannot receive new filesystem stream: destination 'pool1/diary-entries-backup' exists
must specify -F to overwrite it
好吧,看来不能复制到已存在的数据集中?那就指定一个新数据集名称diary-entries-backup2试试:
$ zfs send pool1/diary-entries@2022-07-05 \
| zfs receive pool1/diary-entries-backup
warning: cannot send 'pool1/diary-entries@2022-07-05': dataset key must be loaded
cannot receive: failed to read from stream
所以,除非diary-entries已解密,否则就拒绝复制?我还以为可以复制加密数据集呢……
回头再看 ZFS 文档,我发现了一个--raw参数:
-w, --raw对于加密数据集,按其在磁盘上的原样发送数据。
好吧,来试一下:
$ zfs send --raw pool1/diary-entries@2022-07-05 \
| zfs receive pool1/diary-entries-backup2
成功了!
让我回到 TrueNAS 的网页界面看看创建了什么:

糟糕,这不是我想要的结果。
ZFS 又创建了一个加密数据集。我想要的是在未加密数据集上的一个加密备份文件。ZFS 似乎并没有提供这样做的办法。
灵光一现:可以把输出重定向到文件
再回头看 ZFS 的复制命令,我注意到了一些东西:
$ zfs send --raw pool1/diary-entries@2022-07-05 \
| zfs receive pool1/diary-entries-backup2
这里有一个zfs send命令,把输出通过管道传给zfs receive命令。如果我不把输出传给zfs receive,而是直接写入文件,会怎么样呢?
$ zfs send --raw pool1/diary-entries@2022-07-05 \
> /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
嘿,成功了!我在未加密的diary-entries-backup数据集上创建了一个 24 KB 的备份文件:
$ du -h /mnt/pool1/diary-entries-backup/*
24K /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
既然备份已经作为文件保存在未加密的数据集上,restic 就可以像对待普通文件一样把它备份到云端了。
不过首先,让我测试一下能否用zfs receive命令从这个备份中还原diary-entries里的原始数据:
$ zfs receive pool1/diary-entries-backup3 \
< /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
这个操作成功了,并在我的存储池中创建了一个新的数据集:

见证奇迹的时刻到了!如果我能用当初给diary-entries设的同一个密码来解密diary-entries-backup3,并且里面包含相同的数据,那就说明文件diary-entries-backup/snapshot@2022-07-05是数据集diary-entries在快照2022-07-05时刻的完整备份。
于是,我用同样的密码解密了diary-entries-backup3并检查了它的内容:
$ cat /mnt/pool1/diary-entries-backup3/2022-07-05.txt
I enjoy Taylor Swift, but I don’t want anyone to know
太好了!成功了。
我成功在无需解锁的情况下,为加密数据集创建了加密的备份文件。
创建增量备份
我打算用这种方式备份的数据集中,有一个是用来存放截屏录像的。这个数据集目前有 12 GB,而且还会继续增长。如果每天备份,我可不想每天都生成一个 12 GB 的新文件。
幸运的是,ZFS 支持增量备份。如果你周一给数据集打了一个快照,周二又打了一个,你不需要为周一和周二都创建完整备份文件。相反,周二的备份可以只是相对于周一的增量。
为了演示,我再往diary-entries数据集中添加一点数据:
echo "Upon reflection, I'm not ashamed of how much I enjoy You Belong with Me" \
> /mnt/pool1/diary-entries/2022-07-06.txt
然后我会创建一个包含最新条目的新快照:
zfs snapshot pool1/diary-entries@2022-07-06
最后,我将使用-i参数指定基准快照,来创建一个相对于2022-07-05快照的增量备份:
zfs send \
--raw \
--verbose \
-i pool1/diary-entries@2022-07-05 \
pool1/diary-entries@2022-07-06 \
> /mnt/pool1/diary-entries-backup/snapshot@2022-07-05-to-2022-07-06
成功了!命令创建了一个新的增量备份:
$ du -h /mnt/pool1/diary-entries-backup/*
24K /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
6.5K /mnt/pool1/diary-entries-backup/snapshot@2022-07-05-to-2022-07-06
在这个演示里有点小题大做,因为我的文件本来就很小,但你仍然可以看到第二个快照比第一个小得多。这是因为它只包含了自2022-07-05快照以来的变更。
只有从备份中成功恢复出原始数据,测试才算完成,所以我来试试用增量备份创建一个新的数据集:
# Recover from full backup.
zfs receive pool1/diary-entries-backup4@2022-07-05 \
< /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
# Add changes since incremental backup.
zfs receive pool1/diary-entries-backup4 \
< /mnt/pool1/diary-entries-backup/snapshot@2022-07-05-to-2022-07-06
我恢复成功了!

而且两个文件都在:
$ tail -n +1 /mnt/pool1/diary-entries-backup4/*
==> /mnt/pool1/diary-entries-backup4/2022-07-05.txt <==
I enjoy Taylor Swift, but I don't want anyone to know
==> /mnt/pool1/diary-entries-backup4/2022-07-06.txt <==
Upon reflection, I'm not ashamed of how much I enjoy Blank Space
注意,我必须先从完整备份恢复,然后再用增量备份来更新它。如果我试图直接用增量备份来恢复,ZFS 会报错:
# This isn't going to work because it's an incremental backup.
$ zfs receive pool1/diary-entries-backup5 \
< /mnt/pool1/diary-entries-backup/snapshot@2022-07-05-to-2022-07-06
cannot receive incremental stream: destination 'pool1/diary-entries-backup5' does not exist
编写备份脚本
既然已经搞懂了用 ZFS 复制数据集的原理,接下来就该写个 shell 脚本来自动化定期备份任务了。
首先,我会创建一个名为settings.sh的文件,来定义所有与我系统相关的配置:
readonly POOL="mypool"
readonly BASE_DIR="/mnt/${POOL}/encrypted-backups"
readonly FULL_SNAPSHOTS_DIR="${BASE_DIR}/full-snapshots"
readonly INCREMENTAL_SNAPSHOTS_DIR="${BASE_DIR}/incremental-snapshots"
DATASETS=()
DATASETS+=("documents")
DATASETS+=("music")
DATASETS+=("emails")
readonly DATASETS
接下来,我会创建一个名为replicate-full-snapshots.sh的脚本来创建数据集的完整备份:
#!/bin/bash
# Create full snapshots of datasets in DATASETS array.
set -eux
. settings.sh
mkdir -p "${FULL_SNAPSHOTS_DIR}"
TIMESTAMP="$(date -Iseconds | sed 's/://g' | sed 's/+0000/Z/g')"
readonly TIMESTAMP
for DATASET in "${DATASETS[@]}"; do
# Take a snapshot.
SNAPSHOT_NAME="${POOL}/${DATASET}@${TIMESTAMP}"
zfs snapshot "${SNAPSHOT_NAME}"
# Write the snapshot to a file.
OUTPUT_FILENAME="${SNAPSHOT_NAME//${POOL}\\//}"
zfs send --raw --verbose "${SNAPSHOT_NAME}" > "${FULL_SNAPSHOTS_DIR}/${OUTPUT_FILENAME}"
done
这个脚本会遍历我在settings.sh中定义的每个数据集,为每个数据集创建新快照,然后为每个快照创建完整备份。
然后,我再创建一个名为replicate-incremental-snapshots.sh的脚本来创建增量备份:
#!/bin/bash
# Create incremental snapshots of datasets in DATASETS array relative to their
# last full snapshot.
set -eux
. settings.sh
mkdir -p "${INCREMENTAL_SNAPSHOTS_DIR}"
TIMESTAMP="$(date -Iseconds | sed 's/://g' | sed 's/+0000/Z/g')"
readonly TIMESTAMP
for DATASET in "${DATASETS[@]}"; do
# Take a snapshot.
INCREMENTAL_SNAPSHOT="${POOL}/${DATASET}@${TIMESTAMP}"
zfs snapshot "${INCREMENTAL_SNAPSHOT}"
# Find the most recent full snapshot.
BASE_SNAPSHOT_FILENAME="$(basename "$(ls -tr "${FULL_SNAPSHOTS_DIR}/${DATASET}"* | tail -1)")"
BASE_SNAPSHOT="${POOL}/${BASE_SNAPSHOT_FILENAME}"
# Write the incremental snapshot to a file.
OUTPUT_FILENAME="${INCREMENTAL_SNAPSHOT//${POOL}\\//}"
OUTPUT_PATH="${INCREMENTAL_SNAPSHOTS_DIR}/${OUTPUT_FILENAME}"
zfs send --raw --verbose -i "${BASE_SNAPSHOT}" "${INCREMENTAL_SNAPSHOT}" \
> "${OUTPUT_PATH}"
done
replicate-incremental-snapshots.sh会为每个数据集查找最近的完整备份,然后基于它创建增量备份。
需要注意的是,replicate-incremental-snapshots.sh为了简单起见牺牲了一些磁盘空间。它总是相对于上一次完整备份来创建增量备份,而忽略了更新的增量备份。这意味着如果我在周一创建了一个完整备份,接下来五天每天都创建增量备份,就会浪费空间,因为周三的备份很可能会包含周二备份中已有的重复数据。我曾考虑过在增量备份之上再做增量备份,但那样会让备份系统的复杂度和出错风险都大幅增加,这不是我想要的。
最后,光会备份还不够,还得能恢复,所以我创建了一个名为snapshot-to-dataset.sh的便捷脚本,用于把备份文件还原成 ZFS 数据集:
#!/bin/bash
#
# Recover a dataset from an encrypted snapshot.
#
# Usage:
# ./snapshot-to-dataset.sh new-dataset-name full-snapshot-path [incremental-snapshot-path]
set -ex
. settings.sh
NEW_DATASET_NAME="$1"
readonly NEW_DATASET_NAME
FULL_SNAPSHOT_PATH="$2"
readonly FULL_SNAPSHOT_PATH
INCREMENTAL_SNAPSHOT_PATH="$3"
readonly INCREMENTAL_SNAPSHOT_PATH
set -u
# Restore from base snapshot
zfs receive "${POOL}/${NEW_DATASET_NAME}" < "${FULL_SNAPSHOT_PATH}"
if [[ -n "${INCREMENTAL_SNAPSHOT_PATH}" ]]; then
# Update dataset to latest incremental snapshot
zfs receive "${POOL}/${NEW_DATASET_NAME}" < "${INCREMENTAL_SNAPSHOT_PATH}"
fi
这些脚本已发布在 GitHub 上:
我的便捷脚本实战演示
为了展示这些脚本的实际效果,我将以diary-entries示例数据集来演示:
这是我的settings.sh文件:
readonly POOL="pool1"
readonly BASE_DIR="/mnt/${POOL}/secure-backups"
readonly FULL_SNAPSHOTS_DIR="${BASE_DIR}/full-snapshots"
readonly INCREMENTAL_SNAPSHOTS_DIR="${BASE_DIR}/incremental-snapshots"
DATASETS=()
DATASETS+=("diary-entries")
readonly DATASETS
现在我来运行一次完整备份:
./replicate-full-snapshots.sh
成功了吗?
$ du -h /mnt/pool1/secure-backups/full-snapshots/diary-entries*
24K /mnt/pool1/secure-backups/full-snapshots/diary-entries@2022-07-27T073416-0400
不错,正如预期的那样创建了一个备份文件。
现在,我再添加一些新数据:
echo "I've got a blank space, so I'll write a new diary entry" \
> /mnt/pool1/diary-entries/2022-07-27.txt
接下来,我将创建一个包含2022-07-27.txt的增量备份:
./replicate-incremental-snapshots.sh
我应该能在incremental-snapshots文件夹中看到一个新文件:
$ du -h /mnt/pool1/secure-backups/incremental-snapshots/diary-entries*
12K /mnt/pool1/secure-backups/incremental-snapshots/diary-entries@2022-07-27T074246-0400
来看看能否从中恢复。回想一下,我的snapshot-to-dataset.sh脚本的用法是:
./snapshot-to-dataset.sh new-dataset-name full-backup-file [incremental-backup-file]
有了这个,我来试试从备份中恢复:
./snapshot-to-dataset.sh \
diary-entries-backup5 \
/mnt/pool1/secure-backups/full-snapshots/diary-entries@2022-07-27T073416-0400 \
/mnt/pool1/secure-backups/incremental-snapshots/diary-entries@2022-07-27T074246-0400
成功了!它创建了一个包含我所有文件的新数据集:
tail -n +1 /mnt/pool1/diary-entries-backup5/*
==> /mnt/pool1/diary-entries-backup5/2022-07-05.txt <==
I enjoy Taylor Swift, but I don't want anyone to know
==> /mnt/pool1/diary-entries-backup5/2022-07-06.txt <==
Upon reflection, I'm not ashamed of how much I enjoy Blank Space
==> /mnt/pool1/diary-entries-backup5/2022-07-27.txt <==
I've got a blank space, so I'll write a new diary entry
定时备份
既然备份已经脚本化,我就可以创建定时任务来定期运行备份了。好在在 TrueNAS 的网页界面中这很容易做到,我只需在 Tasks > Cron Jobs 中创建任务即可。
第一个定时任务是每月创建一次完整备份:

我把它安排在每月 1 号凌晨 3 点执行,因为那是我睡得最熟、最不容易被打扰的时间。
接下来,我想要一个每天创建增量备份的任务,相对于每月的快照。我把它安排在凌晨 4 点启动,这样凌晨 3 点的完整备份就有足够时间先完成:

要验证定时任务是否成功运行,我可以查看/var/log/cron中的日志:
$ tail /var/log/cron
Jul 28 05:45:03 truenas 1 2022-07-28T08:45:03.757011-04:00 truenas.local cron 302 - - + OUTPUT_PATH=/mnt/pool1/secure-backups/incremental-snapshots/videos@2022-07-28T084502-0400
Jul 28 05:45:03 truenas 1 2022-07-28T08:45:03.757087-04:00 truenas.local cron 302 - - + [[ -f /mnt/pool1/secure-backups/incremental-snapshots/videos@2022-07-28T084502-0400 ]]
Jul 28 05:45:03 truenas 1 2022-07-28T08:45:03.757168-04:00 truenas.local cron 302 - - + zfs send --raw --verbose -i pool1/videos@2022-07-28T080351-0400 pool1/videos@2022-07-28T084502-0400
Jul 28 05:45:03 truenas 1 2022-07-28T08:45:03.761156-04:00 truenas.local cron 302 - - send from pool1/videos@2022-07-28T080351-0400 to pool1/videos@2022-07-28T084502-0400 estimated size is 624B
Jul 28 05:45:03 truenas 1 2022-07-28T08:45:03.761291-04:00 truenas.local cron 302 - - total estimated size is 624
Jul 28 05:45:03 truenas 1 2022-07-28T08:45:03.761877-04:00 truenas.local cron 302 - - + echo 'Finished replicating incremental snapshots'
Jul 28 05:45:03 truenas 1 2022-07-28T08:45:03.761958-04:00 truenas.local cron 302 - - Finished replicating incremental snapshots
备份失败告警
如果两个月后我的备份开始失败了怎么办?我可不会每天去检查日志来确认备份是否正常。
好在有各种服务可以在定时任务未能运行时发出告警。我选择了Cronitor,因为它有慷慨的免费额度,而且设置起来很简单。
在 Cronitor 中,我创建了一个新监控项,计划设置为0 0 3 * *,与 TrueNAS 服务器上完整备份的计划保持一致:

Cronitor 会为这个监控项生成一个唯一的 URL,看起来像这样:
https://cronitor.link/p/88e0dba70a87424b83c5fd3e9227ac92/1bBG6q
为了确保完整备份的定时任务能上报成功状态,我在定时任务中添加了一条curl命令,在备份成功完成后向 Cronitor 发送成功信号:
![/mnt/pool1/secure-backups/scripts/replicate-full-snapshots.sh && curl --silent https://cronitor.link/p/[my telemetry id]?state=complete](https://mtlynch.io/zfs-encrypted-backups/add-cronitor.png)
我对增量备份任务也重复了同样的流程,这样就大功告成了!
现在我有了一套可靠的系统来为加密的 ZFS 数据集创建备份,如果任务失败,Cronitor 会及时向我发出告警。
注意事项:备份你的加密根
感谢评论区的@Invisible和@adamkf指出了将 ZFS 备份到文件而非另一个 ZFS 系统时一个重要的坑。
一定要备份数据集的加密根。
如果你要备份的数据集是某个加密数据集的子数据集,那么对子数据集执行zfs send并不会捕获在另一台 TrueNAS 系统上恢复快照所需的全部数据,这样你的备份就毫无价值了。
要检查数据集的加密根,请运行zfs get -r encryptionroot pool/dataset。如果该数据集没有父级加密,你会看到输出显示该数据集本身就是加密根:
$ zfs get -r encryptionroot pool1/diary-entries | head -n 2
NAME PROPERTY VALUE SOURCE
pool1/diary-entries encryptionroot pool1/diary-entries -
当数据集本身就是加密根时,你就不需要再备份其他额外的数据集了。
我建议在另一个 ZFS 系统上,甚至是在 TrueNAS 虚拟机中测试你的备份,以验证你能否从快照文件中恢复。
源代码
我已将便捷脚本发布在 GitHub 上:
随机一篇博客
评论
登录后参与讨论