Back Up Encrypted ZFS Data without Unlocking It

Michael Lynch

ロックを解除せずに暗号化されたZFSデータをバックアップする

最近、初めて自宅用のTrueNASサーバーを自作しました。個人用と仕事用のデータの大部分をそこに保存しているので、TrueNASとそのファイルシステムであるZFSを最大限に活用する方法を学んでいます。

今回は、暗号化されたデータのバックアップについてお話しします。

小売パッケージに入ったNASサーバーパーツの写真組み立てが完了したサーバーの写真

私のhomelab用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

@aaという名前のスナップショットを表すので、2022-07-05というスナップショットを取得します。

zfs snapshot pool1/diary-entries@2022-07-05

そして、diary-entriesdiary-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データセット上に、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-backup3のスクリーンショット

いよいよ正念場です。diary-entries-backup3diary-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

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

ロックを解除することなく、暗号化されたデータセットの暗号化されたバックアップファイルを作成することに成功しました。

増分バックアップの作成

この方法でバックアップしようとしているデータセットのひとつは、スクリーンキャストのビデオキャプチャ用です。現在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

このデモではファイルが非常に小さいので少し大げさですが、それでも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のスクリーンショット

そして、両方のファイルがちゃんとあります。

$ 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から簡単にできるので、タスク > Cronジョブからタスクを作成します。

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

コマンドが '/mnt/pool1/secure-backups/scripts/replicate-full-snapshots.sh'、スケジュールが '0 0 3 * *' のTrueNASのCronジョブ

最も確実に眠っている毎月1日の午前3時に開始するようスケジュールしました。

次に、月次のスナップショットを基準に増分バックアップを作成する日次のタスクが必要です。完全バックアップが午前3時に完了する時間を確保するため、午前4時に開始するようにします。

コマンドが '/mnt/pool1/secure-backups/scripts/replicate-incremental-snapshots.sh'、スケジュールが '0 0 4 * *' のTrueNASのCronジョブ

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からアラートを受け取れるようになりました。

注意:暗号化ルートをバックアップしてください

コメント欄で@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  -

データセット自身が暗号化ルートである場合、追加のデータセットをバックアップする必要はありません。

TrueNASの仮想マシンなど、別のZFSシステムでバックアップをテストし、スナップショットファイルから復元できることを確認することをお勧めします。

ソースコード

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

原文は Michael Lynch により に公開されました。

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