不用解鎖就能備份加密的 ZFS 資料
原文由 Michael Lynch 于 發布,訂閱此部落格
前陣子我組裝了人生第一台家用 TrueNAS 伺服器。平時我把大部分個人與工作的資料都放在上面,所以一直在摸索怎麼把 TrueNAS 和它的檔案系統 ZFS 用到極致。
今天想來聊聊怎麼備份加密資料。


ZFS 有個很棒的功能,就是可以在資料保持加密的狀態下進行備份。麻煩的是,TrueNAS 預設你只會備份到另一台 TrueNAS 上。如果你跟我一樣,想把加密資料備份到一般的雲端儲存服務,就得多花點工夫。今天這篇文章就來告訴你怎麼做。
為什麼要備份加密資料?
有些檔案我很少會去開,但還是想放在加密的 dataset 裡。
之前用的 Synology NAS 完全沒辦法備份加密的儲存空間。資料一旦加密,在解鎖之前就完全無法存取。對大部分資料來說這樣還好,但那些很少存取的儲存空間該怎麼辦?我的每日夜間備份就沒辦法把它們複製到雲端儲存上了。
TrueNAS 就好多了!就算 dataset 加密且處於鎖定狀態,你還是可以做完整備份和增量備份。這看起來正是備份那些不常用、又不想一直保持解密狀態的資料的好方法。
我平常是用 restic 和 resticpy 把資料備份到雲端,所以我需要讓 restic 能存取這些加密的 ZFS 備份。雖然得動手調整,還寫了一些 bash 腳本才搞定,但最後總算成功了。
試著用 TrueNAS 備份加密的 dataset(錯誤的方法)
為了示範我想做的事,我建立了一個叫做 diary-entries 的 dataset。

好,現在來放一個檔案進去:
echo "I enjoy Taylor Swift, but I don't want anyone to know"
> /mnt/pool1/diary-entries/2022-07-05.txt
接著我需要再建立一個用來接收備份的 dataset,叫做 diary-entries-backup。我已經把這個新 dataset 的加密關掉了,因為備份本身就已經是加密的,不需要再多加一層加密:

現在,我準備設定一個複寫任務,把 diary-entries dataset 加密的快照備份到未加密的 diary-entries-backup dataset。之後,restic 就能存取 diary-entries-backup,再把它複寫到雲端儲存。
建立複寫任務時,TrueNAS 警告我正在複寫加密的 dataset。沒關係——這正是我的目的。我就是想把加密的快照在保持加密的狀態下備份到雲端:

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

錯誤訊息是:
Unable to send encrypted dataset ‘pool1/diary-entries’ to existing unencrypted or unrelated dataset ‘pool1/diary-entries-backup’.
可惡!
它不讓我把加密的 dataset 複寫到未加密的 dataset。這有點莫名其妙,既然快照本身就是加密且鎖定的,那放在有沒有加密的 dataset 上有什麼差別?
透過命令列介面使用 ZFS
TrueNAS 本質上就是 ZFS 的友善操作介面。為了讓事情更簡單,我跳過 TrueNAS,直接使用功能更強大的 ZFS 命令列介面(CLI)。
ZFS 文件中有個複寫 dataset 的範例指令:
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
好吧,所以不能複寫到已經存在的 dataset?那就換個新名稱 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 才能複寫?我以為加密的 dataset 也能複寫的說……
回頭再看 ZFS 文件,我發現有個 --raw 參數:
-w, --raw對於加密的 dataset,以資料在磁碟上的原始樣貌傳送。
好,來試試看:
$ zfs send --raw pool1/diary-entries@2022-07-05 \
| zfs receive pool1/diary-entries-backup2
成功了!
回到 TrueNAS 的網頁介面看看我建立了什麼:

可惡,這不是我想要的結果。
ZFS 又建立了一個加密的 dataset。我想要的是在未加密的 dataset 上放一個加密的備份檔。ZFS 似乎沒有提供這種做法。
靈光一現:我可以把輸出重新導向到檔案
重新檢視 ZFS 的複寫指令,我注意到一件事:
$ zfs send --raw pool1/diary-entries@2022-07-05 \
| zfs receive pool1/diary-entries-backup2
好,這裡的 zfs send 指令是把輸出用 pipe 傳給 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 dataset 上建立了一個 24 KB 的備份檔:
$ du -h /mnt/pool1/diary-entries-backup/*
24K /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
既然備份已經是以檔案的形式放在未加密的 dataset 上,restic 就能像處理其他檔案一樣把它備份到雲端了。
不過,先讓我測試一下能不能用 zfs receive 指令從這個備份把 diary-entries 的資料還原回來:
$ zfs receive pool1/diary-entries-backup3 \
< /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
指令成功執行,並在我的 pool 裡建立了一個新的 dataset:

見真章的時刻到了!如果我能用跟 diary-entries 相同的密碼解密 diary-entries-backup3,而且裡面的資料完全一樣,那就證明 diary-entries-backup/snapshot@2022-07-05 這個檔案就是 diary-entries dataset 在快照 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
太棒了!成功了。
我真的可以在完全不解鎖的情況下,替加密的 dataset 建立加密的備份檔。
建立增量備份
我打算用這種方式備份的其中一個 dataset 是用來放螢幕錄影的影片。目前這個 dataset 大約 12 GB,而且還會繼續變大。如果每天都備份,我可不想要每天都產生一個 12 GB 的新檔案。
幸好 ZFS 支援增量備份。如果你週一對 dataset 做了一次快照,週二又做了一次,就不需要分別為週一和週二各做一個完整備份。週二的備份只要包含相對於週一的差異部分就好。
為了示範,我再往 diary-entries dataset 加一點資料:
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 快照以來的變更。
還沒把原始資料還原出來,測試就不算完成,所以我來試著用這個增量備份建立一個新的 dataset:
# 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 複寫 dataset 的原理,接下來就是寫個 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 的腳本,用來建立 dataset 的完整備份:
#!/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 中定義的每個 dataset,分別建立新的快照,然後為每個快照建立完整備份。
接下來,我再建立一個叫做 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 會找出每個 dataset 最近一次的完整備份,然後以它為基準建立增量備份。
要注意的是,replicate-incremental-snapshots.sh 為了求簡單,會浪費一點磁碟空間。它永遠只以最近一次完整備份為基準來做增量備份,而忽略更新的增量備份。也就是說,如果我週一做了一次完整備份,接下來五天每天都做增量備份,就會浪費空間,因為週三的備份很可能包含了週二備份中已經有的重複資料。我有考慮過讓增量備份疊在前一次增量備份之上,但那會讓複雜度和出錯的風險變高,這不是我希望在備份系統中看到的。
最後,備份如果不能還原就沒什麼用,所以我還寫了一個叫做 snapshot-to-dataset.sh 的便利腳本,可以把備份檔還原回 ZFS dataset:
#!/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 範例 dataset 來示範:
這是我的 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
成功了!它建立了一個包含我所有檔案的新 dataset:
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 上,我建立了一個新的 monitor,排程設為 0 0 3 * *,跟我 TrueNAS 上完整備份的排程一致:

Cronitor 會為這個 monitor 產生一個專屬的 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 dataset 建立備份,而且如果任務失敗,Cronitor 就會發出提醒。
提醒:記得備份你的 encryption root
感謝留言區的 @Invisible 和 @adamkf 指出一個重要陷阱:當你把 ZFS 備份到檔案,而不是另一套 ZFS 系統時要特別注意。
永遠要備份 dataset 的 encryption root。
如果你備份的 dataset 是某個加密 dataset 的子 dataset,單獨對子 dataset 執行 zfs send,並不會包含在另一台 TrueNAS 系統上還原快照所需的所有資料,這樣你的備份就形同無效。
要檢查 dataset 的 encryption root,可以執行 zfs get -r encryptionroot pool/dataset。如果該 dataset 沒有繼承上層的加密,你會看到輸出顯示它本身就是自己的 encryption root:
$ zfs get -r encryptionroot pool1/diary-entries | head -n 2
NAME PROPERTY VALUE SOURCE
pool1/diary-entries encryptionroot pool1/diary-entries -
當 dataset 本身就是自己的 encryption root 時,你就不需要再額外備份其他 dataset。
我建議在另一套 ZFS 系統上測試你的備份,就算只是用 TrueNAS 虛擬機也好,以確認你真的能從快照檔還原。
原始碼
我已經把我的便利腳本發佈到 GitHub 上:
隨機一篇部落格
留言
登入後參與討論