Back Up Encrypted ZFS Data without Unlocking It

Michael Lynch

无需解锁即可备份加密的 ZFS 数据

原文由 Michael Lynch 发布,订阅该博客

不久前,我搭建了自己的第一台家用 TrueNAS 服务器。我用它来存放个人和工作上的大部分数据,因此一直在学习如何充分发挥 TrueNAS 及其文件系统 ZFS 的作用。

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

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

我的家庭实验室 TrueNAS 服务器

ZFS 一个很酷的特性是,即使数据处于加密状态,你也能直接备份它。麻烦的地方在于,TrueNAS 默认假定你只会备份到另一台 TrueNAS 系统上。如果你和我一样,想把加密数据备份到通用的云存储服务商那里,就得多费点功夫。在今天这篇博文中,我会告诉你具体该怎么做。

为什么要备份加密数据?

我有一些很少访问、但仍想保存在加密数据集上的文件。

在我之前的 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’。

错误信息是:

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

哎呀!

它不让我把加密数据集复制到未加密的数据集上。这有点奇怪,因为如果快照本身就是加密且锁定的,那把它放在同样加密的数据集上又有什么关系呢?

注意:这次失败是因为我对 ZFS 复制的理解有误。后面我会讲到正确的理解方式。

使用命令行界面操作 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 的网页界面看看创建了什么:

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

这个操作成功了,并在我的存储池中创建了一个新的数据集:

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

见证奇迹的时刻到了!如果我能用当初给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

我恢复成功了!

TrueNAS 中 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 的网页界面中这很容易做到,我只需在 Tasks > Cron Jobs 中创建任务即可。

第一个定时任务是每月创建一次完整备份:

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

我把它安排在每月 1 号凌晨 3 点执行,因为那是我睡得最熟、最不容易被打扰的时间。

接下来,我想要一个每天创建增量备份的任务,相对于每月的快照。我把它安排在凌晨 4 点启动,这样凌晨 3 点的完整备份就有足够时间先完成:

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

要验证定时任务是否成功运行,我可以查看/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 监控项,名称为 truenas-full-backups,计划为 0 0 3 * *

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

我对增量备份任务也重复了同样的流程,这样就大功告成了!

现在我有了一套可靠的系统来为加密的 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 上:

本文章由 muse-spark-1.2-contributor 进行翻译

评论