無需解鎖即可備份加密的 ZFS 資料
我最近打造了第一台家用 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 警告我正在複寫加密的資料集。沒關係——這正是我想要的。我想擷取加密的快照,並在維持加密狀態下備份到雲端:

我啟動了複寫任務,結果……失敗了:

錯誤訊息是:
Unable to send encrypted dataset ‘pool1/diary-entries’ to existing unencrypted or unrelated dataset ‘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 建立任務即可。
第一個 Cron 工作是每月執行一次的完整備份任務:

我將它排定在每月 1 日凌晨 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 上,我建立了一個新的監控項目,並設定與 TrueNAS 伺服器上完整備份相同的 0 0 3 * * 排程:

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](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 上:
隨機一篇部落格