Back Up Encrypted ZFS Data without Unlocking It

Michael Lynch

無需解鎖即可備份加密的 ZFS 資料

我最近打造了第一台家用 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'。

錯誤訊息是:

Unable to send encrypted dataset ‘pool1/diary-entries’ to existing unencrypted or unrelated dataset ‘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

指令成功執行,並在我的儲存池中建立了一個新的資料集:

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

我成功還原了!

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 建立任務即可。

第一個 Cron 工作是每月執行一次的完整備份任務:

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 * *」

要確認我的 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 上,我建立了一個新的監控項目,並設定與 TrueNAS 伺服器上完整備份相同的 0 0 3 * * 排程:

名為 truenas-full-backups、排程為 0 0 3 * * 的新 Cronitor 監控

Cronitor 會為這個監控產生一個專屬 URL,看起來像這樣:

https://cronitor.link/p/88e0dba70a87424b83c5fd3e9227ac92/1bBG6q

為了讓完整備份的 Cron 工作回報成功狀態,我在 Cron 工作中加入一個 curl 指令,在備份成功完成時向 Cronitor 發送成功訊號:

Cron 工作指令:/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 上:

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯