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 加密且處於鎖定狀態,你還是可以做完整備份和增量備份。這看起來正是備份那些不常用、又不想一直保持解密狀態的資料的好方法。

我平常是用 resticresticpy 把資料備份到雲端,所以我需要讓 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 進行翻譯

留言