Back Up Encrypted ZFS Data without Unlocking It

Michael Lynch

在不解锁的情况下备份加密的 ZFS 数据

我最近搭建了我的第一台家用 TrueNAS 服务器。我用它来存储我的大部分个人和工作数据,因此我一直在学习如何充分利用 TrueNAS 及其文件系统 ZFS。

今天,我想和你聊聊如何备份加密数据。

零售包装中的 NAS 服务器零件照片组装完成的服务器照片

我的家庭实验室 TrueNAS 服务器

ZFS 的一个很棒的特性是,你可以在加密数据仍处于加密状态时对其进行备份。棘手之处在于,TrueNAS 假定你只会备份到其他 TrueNAS 系统。如果你像我一样想把加密数据备份到通用的云存储服务商,就需要多做一些工作。在今天的博客文章中,我会向你展示具体做法。

为什么要备份加密数据?

我有一些很少访问但仍想保存在加密数据集(dataset)中的文件。

在我之前的 Synology NAS 上,没有办法备份加密卷。如果数据被加密了,那么在你解锁之前,任何东西都无法访问它。对于我的大部分数据来说这没什么问题,但对于那些我不常访问的卷呢?我的每夜备份将无法把它们复制到云存储。

TrueNAS 就好多了!即使数据集处于加密并锁定状态,你也可以进行完整和增量备份。这似乎是一种很好的方式,可以备份不常访问的数据,而无需保持其解密状态。

我使用 resticresticpy 将数据备份到云存储,所以我需要一种让 restic 访问我的加密 ZFS 备份的方法。经过一番折腾和手写 bash 脚本,我终于搞定了。

尝试通过 TrueNAS 备份加密数据集(错误的方式)

为了演示我想做的事情,我创建了一个名为 diary-entries 的数据集。

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。我在这个新数据集上禁用了加密,因为已经加密的备份之上不需要再加一层加密:

TrueNAS 数据集创建界面截图,加密已禁用

现在,我准备设置一个复制任务,把 diary-entries 数据集的加密快照备份到未加密的 diary-entries-backup 数据集。之后 restic 就可以访问 diary-entries-backup 数据集并将其复制到云存储。

当我创建复制任务时,TrueNAS 警告我正在复制一个加密的数据集。没关系——这正是我想要的。我想获取加密快照并在它们仍处于加密状态时备份到云端:

TrueNAS 中的警告:你正在复制以下加密数据集:'pool1/diary-entries'。目标数据集将被锁定,可以使用源数据集的加密密钥解锁

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

无法将加密数据集 'pool1/diary-entries' 发送到现有的未加密或不相关的数据集 'pool1/diary-entries-backup'。

错误信息是:

Unable to send encrypted dataset ‘pool1/diary-entries’ to existing unencrypted or unrelated dataset ‘pool1/diary-entries-backup’.

真扫兴!

它不允许我把加密数据集复制到未加密的数据集上。这看起来有点傻:如果快照是加密且锁定的,那它放在一个同样加密的数据集上又有什么关系呢?

注意:之所以没成功,是因为我对 ZFS 复制的理解模型是错误的。稍后我会讲到正确的模型。

通过命令行界面使用 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, --raw For 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 看看我创建了什么:

TrueNAS 中 diary-entries-backup2 的截图,标记为加密数据集

糟糕,这不是我想要的。

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-backup3 作为加密数据集的截图

见证时刻到了!如果我能用与 diary-entries 相同的密码解密 diary-entries-backup3,并且它包含相同的数据,那我就能确认文件 diary-entries-backup/snapshot@2022-07-05diary-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

我把它恢复了!

diary-entries-backup3 作为加密数据集的截图

而且我的两个文件都在:

$ 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 任务是每月创建完整备份的任务:

TrueNAS 中的 Cron Job,命令为 '/mnt/pool1/secure-backups/scripts/replicate-full-snapshots.sh',计划为 '0 0 3 * *'

我把它安排在每月一号凌晨 3 点开始,因为那时我最可靠地处于睡眠状态。

接下来,我想要一个每日任务,基于我的月度快照创建增量备份。我把它安排在凌晨 4 点开始,这样凌晨 3 点的完整备份就有时间在增量备份开始之前完成:

TrueNAS 中的 Cron Job,命令为 '/mnt/pool1/secure-backups/scripts/replicate-incremental-snapshots.sh',计划为 '0 0 4 * *'

为了验证我的 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 Monitor,名称为 truenas-full-backups,计划为 0 0 3 * *

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

我对增量备份任务重复同样的流程,就这样搞定了!

现在我拥有了一套健壮的系统来备份我的加密 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 上发布了我的便捷脚本:

原文由 Michael Lynch 发布

本文章由 stealth/ox-alpha 进行翻译