Back Up Encrypted ZFS Data without Unlocking It

Michael Lynch

暗号化したZFSデータをアンロックせずにバックアップする

原文は Michael Lynch により に公開されました。 このブログを購読する

最近、初めて自宅用の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を扱いやすくしたUIです。話を簡単にするために、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のWeb UIに戻って、何が作成されたか確認してみます。

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」データセット上に、24KBのバックアップファイルを作成できました。

$ 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-backup3」を「diary-entries」と同じパスワードで復号でき、同じデータが含まれていれば、ファイル「diary-entries-backup/snapshot@2022-07-05」がスナップショット「2022-07-05」時点の「diary-entries」データセットの完全なバックアップであることが証明されます。

そこで、「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

やった!うまくいきました。

暗号化されたデータセットを一度もアンロックすることなく、暗号化されたバックアップファイルを正常に作成できるようになりました。

増分バックアップの作成

この方法でバックアップしようと考えているデータセットの一つに、スクリーンキャストの動画キャプチャ用があります。このデータセットは現在12GBあり、今後も増えていく見込みです。毎日バックアップを行うとなると、毎日12GBもの新しいファイルができるのは避けたいところです。

幸い、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

今回のデモではファイル自体がとても小さいのであまり意味がありませんが、それでも2つ目のスナップショットが1つ目よりかなり小さいことがわかります。これは「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のスクリーンショット

そして、2つのファイルがどちらもしっかり存在しています。

$ 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でデータセットをレプリケートする仕組みがわかったので、定期的なバックアップタスクを自動化するためのシェルスクリプトを作成します。

まず、私の環境固有の設定を定義するための「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」はシンプルさを優先するため、ディスク容量を多少無駄にします。常に最新のフルバックアップを基準に増分バックアップを作成し、より新しい増分バックアップは無視します。つまり、月曜日にフルバックアップを作成し、その後5日間毎日増分バックアップを作成した場合、水曜日のバックアップには火曜日のバックアップと重複するデータが含まれる可能性が高く、容量を無駄にすることになります。増分バックアップの上にさらに増分バックアップを重ねることも検討しましたが、バックアップシステムに求める以上に複雑さやミスの可能性が増すため、やめました。

最後に、バックアップは復元できなければあまり意味がないので、バックアップファイルからZFSデータセットに戻すための便利スクリプト「snapshot-to-dataset.sh」を作成しました。

#!/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のWeb UIで簡単にできるので、「Tasks > Cron Jobs」からタスクを作成するだけです。

最初のcronジョブは、フルバックアップを作成するための月次タスクです。

コマンド '/mnt/pool1/secure-backups/scripts/replicate-full-snapshots.sh'、スケジュール '0 0 3 * *' で設定されたTrueNASのCron Job

毎月1日の午前3時に開始するようスケジュールしました。その時間が私が最も確実に寝ている時間だからです。

次に、月次のスナップショットを基準とした増分バックアップを作成する日次タスクを作ります。午前3時のフルバックアップが完了してから増分バックアップが開始されるように、午前4時に開始するよう設定します。

コマンド '/mnt/pool1/secure-backups/scripts/replicate-incremental-snapshots.sh'、スケジュール '0 0 4 * *' で設定されたTrueNASのCron Job

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

バックアップ失敗時のアラート

2か月後にバックアップが失敗し始めたらどうなるでしょうか。バックアップが正常に動作しているか確認するために、毎日ログをチェックするわけにはいきません。

幸い、スケジュールされたタスクが実行されなかったときに通知してくれるサービスは数多くあります。私はCronitorが無料枠が充実していてセットアップも簡単だったので、使うことにしました。

Cronitorで、TrueNASサーバー上のフルバックアップと同じ「0 0 3 * *」というスケジュールで新しいモニターを作成しました。

名前 'truenas-full-backups'、スケジュール '0 0 3 * *' で作成された新しいCronitorモニター

Cronitorはこのモニター用に次のようなユニークなURLを生成します。

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

フルバックアップのcronジョブが成功を報告するように、バックアップが正常に完了したときにCronitorへ通知するcurlコマンドをcronジョブに追加します。

/mnt/pool1/secure-backups/scripts/replicate-full-snapshots.sh && curl --silent https://cronitor.link/p/[my telemetry id]?state=complete

増分バックアップのジョブでも同じ手順を繰り返せば完了です!

これで暗号化されたZFSデータセットのバックアップを作成する堅牢なシステムができました。万が一ジョブが失敗すれば、Cronitorからアラートが届きます。

注意:暗号化ルートをバックアップする

ZFSを別のZFSシステムではなくファイルにバックアップする際の重要な注意点をコメントで指摘してくれた@Invisibleさんと@adamkfさんに感謝します。

データセットの暗号化ルートは必ずバックアップしてください。

暗号化されたデータセットの子であるデータセットをバックアップする場合、子データセットの「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のVMであっても、そこでバックアップをテストすることをお勧めします。

ソースコード

便利スクリプトはGitHubで公開しています。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント