在不解锁的情况下备份加密的 ZFS 数据
我最近搭建了我的第一台家用 TrueNAS 服务器。我用它来存储我的大部分个人和工作数据,因此我一直在学习如何充分利用 TrueNAS 及其文件系统 ZFS。
今天,我想和你聊聊如何备份加密数据。


ZFS 的一个很棒的特性是,你可以在加密数据仍处于加密状态时对其进行备份。棘手之处在于,TrueNAS 假定你只会备份到其他 TrueNAS 系统。如果你像我一样想把加密数据备份到通用的云存储服务商,就需要多做一些工作。在今天的博客文章中,我会向你展示具体做法。
为什么要备份加密数据?
我有一些很少访问但仍想保存在加密数据集(dataset)中的文件。
在我之前的 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 警告我正在复制一个加密的数据集。没关系——这正是我想要的。我想获取加密快照并在它们仍处于加密状态时备份到云端:

我启动了复制任务,然后……失败了:

错误信息是:
Unable to send encrypted dataset ‘pool1/diary-entries’ to existing unencrypted or unrelated dataset ‘pool1/diary-entries-backup’.
真扫兴!
它不允许我把加密数据集复制到未加密的数据集上。这看起来有点傻:如果快照是加密且锁定的,那它放在一个同样加密的数据集上又有什么关系呢?
通过命令行界面使用 ZFS
TrueNAS 本质上是围绕 ZFS 的一个友好 UI。为了让事情更简单,我绕过 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, --rawFor encrypted datasets, send data exactly as it exists on disk.
好,试试看:
$ zfs send --raw pool1/diary-entries@2022-07-05 \
| zfs receive pool1/diary-entries-backup2
成功了!
让我回到 TrueNAS 的 Web UI 看看我创建了什么:

糟糕,这不是我想要的。
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 的 Web UI 中很容易做到,只需在 Tasks > Cron Jobs 下创建任务即可。
第一个 cron 任务是每月创建完整备份的任务:

我把它安排在每月一号凌晨 3 点开始,因为那时我最可靠地处于睡眠状态。
接下来,我想要一个每日任务,基于我的月度快照创建增量备份。我把它安排在凌晨 4 点开始,这样凌晨 3 点的完整备份就有时间在增量备份开始之前完成:

为了验证我的 cron 任务是否正常运行,我可以查看 /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
为了确保我的完整备份 cron 任务会报告成功,我在 cron 任务中添加了一条 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 系统时的一个重要陷阱。
请务必备份数据集的加密根(encryption root)。
如果你备份的数据集是某个加密数据集的子数据集,对子数据集执行 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 上发布了我的便捷脚本:
随机一篇博客