Back Up Encrypted ZFS Data without Unlocking It

Michael Lynch

不用解鎖就能備份加密的 ZFS 資料

原文由 Michael Lynch 于 發布,訂閱此部落格

前陣子我組裝了人生第一台家用 TrueNAS 伺服器。平時我把大部分個人與工作的資料都放在上面,所以一直在摸索怎麼把 TrueNAS 和它的檔案系統 ZFS 用到極致。

今天想來聊聊怎麼備份加密資料。

零售包裝中的 NAS 伺服器零件照片組裝完成的伺服器照片

我的 homelab TrueNAS 伺服器

ZFS 有個很棒的功能,就是可以在資料保持加密的狀態下進行備份。麻煩的是,TrueNAS 預設你只會備份到另一台 TrueNAS 上。如果你跟我一樣,想把加密資料備份到一般的雲端儲存服務,就得多花點工夫。今天這篇文章就來告訴你怎麼做。

為什麼要備份加密資料?

有些檔案我很少會去開,但還是想放在加密的 dataset 裡。

之前用的 Synology NAS 完全沒辦法備份加密的儲存空間。資料一旦加密,在解鎖之前就完全無法存取。對大部分資料來說這樣還好,但那些很少存取的儲存空間該怎麼辦?我的每日夜間備份就沒辦法把它們複製到雲端儲存上了。

TrueNAS 就好多了!就算 dataset 加密且處於鎖定狀態,你還是可以做完整備份和增量備份。這看起來正是備份那些不常用、又不想一直保持解密狀態的資料的好方法。

我平常是用 restic 和 resticpy 把資料備份到雲端,所以我需要讓 restic 能存取這些加密的 ZFS 備份。雖然得動手調整,還寫了一些 bash 腳本才搞定,但最後總算成功了。

試著用 TrueNAS 備份加密的 dataset(錯誤的方法)

為了示範我想做的事,我建立了一個叫做 diary-entries 的 dataset。

TrueNAS 中 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 的加密關掉了,因為備份本身就已經是加密的,不需要再多加一層加密:

已停用加密的 TrueNAS dataset 建立畫面截圖

現在,我準備設定一個複寫任務,把 diary-entries dataset 加密的快照備份到未加密的 diary-entries-backup dataset。之後,restic 就能存取 diary-entries-backup,再把它複寫到雲端儲存。

建立複寫任務時,TrueNAS 警告我正在複寫加密的 dataset。沒關係——這正是我的目的。我就是想把加密的快照在保持加密的狀態下備份到雲端:

TrueNAS 中的警告:You are replicating the following encrypted datasets: 'pool1/diary-entries'. Destination datasets will be locked and can be unlocked with source datasets' encryption key

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

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

錯誤訊息是:

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

可惡!

它不讓我把加密的 dataset 複寫到未加密的 dataset。這有點莫名其妙,既然快照本身就是加密且鎖定的,那放在有沒有加密的 dataset 上有什麼差別?

注意:這次失敗是因為我對 ZFS 複寫的理解有誤。後面會說明正確的觀念。

透過命令列介面使用 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 的網頁介面看看我建立了什麼:

TrueNAS 中 diary-entries-backup2 的截圖,顯示為加密的 dataset

可惡,這不是我想要的結果。

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-backup3 的截圖,顯示為加密的 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

我還原成功了!

diary-entries-backup3 的截圖,顯示為加密的 dataset

而且兩個檔案都在:

$ 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 工作是每月執行一次的完整備份任務:

TrueNAS 中的 Cron Job,指令為 '/mnt/pool1/secure-backups/scripts/replicate-full-snapshots.sh',排程為 '0 0 3 * *'

我把它排在每月 1 號凌晨 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 上,我建立了一個新的 monitor,排程設為 0 0 3 * *,跟我 TrueNAS 上完整備份的排程一致:

名稱為 truenas-full-backups、排程為 0 0 3 * * 的新 Cronitor Monitor

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

我對增量備份的工作重複同樣的步驟,這樣就完成了!

現在我已經有了一套穩健的系統,可以為加密的 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 上:

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

留言