[PERFORMANCE]ADVANCED

スナップショット一括削除の直後にバックアップを繋げて原因調査に半日溶かした話

仕事の基盤チームで、古いスナップショットを一括削除する定期ジョブの直後にバックアップジョブを繋げていたら、原因不明の性能劣化に悩まされたことがあります。犯人は`zfs destroy`後もバックグラウンドで解放処理が続く非同期解放(async destroy)でした。仕組みと`freeing`の監視方法、クローン削除を高速化するlivelist、削除運用でハマりがちな落とし穴を整理します。

仕事で見ている基盤の定期メンテナンスジョブで、古いスナップショットを一括でzfs destroyした直後にバックアップジョブを繋げる、というスケジュールを組んでいたことがあります。しばらくしてバックアップの所要時間が伸び始め、原因調査に半日近く溶かしました。犯人はzfs destroyそのものではなく、コマンドが返った後もバックグラウンドで継続していたブロックの解放処理でした。これが**非同期解放(async destroy)**です。この記事では、async destroyの仕組みと、なぜ大量削除の直後に性能劣化が起きるのか、そして監視・チューニングの実務ポイントを整理します。

destroyは「削除予約」に近い

zfs destroy tank/big-datasetを実行すると、ZFSはそのデータセット/スナップショットが参照していたブロックポインタのツリーをすべて解放対象としてマークしますが、実際のブロック解放処理は同期的に完了させません。代わりに、解放すべきブロックの一覧をbpobj(block pointer object、解放待ちブロックの一覧を保持する内部オブジェクト)に積み、以降の複数のトランザクショングループ(txg)にまたがって少しずつ処理していきます。

これがasync destroy(feature flagとしてはasync_destroy)で、現在のOpenZFSでは新規プールに対して事実上デフォルトで有効になっています。この仕組みのおかげで、数百GB〜数TB規模のスナップショットを消しても、zfs destroyコマンド自体は数秒で返ってきます。しかし裏側では、その巨大なブロックポインタツリーをたどりながら実際にfree処理を行うワークがtxg syncのたびに継続しており、**「コマンドは終わったのに負荷は終わっていない」**という状態がしばらく続きます。

freeingプロパティで進行状況を確認する

いま解放待ちのブロック量がどれだけ残っているかは、プールのfreeingプロパティで確認できます。

zpool get freeing,leaked tank

freeingは「解放処理が完了していない、キューに残っているバイト数」を表します。巨大なデータセットを削除した直後にこの値を確認すると、ゼロになるまで時間がかかっているのが分かります。leakedは本来解放されるべきなのに参照が失われてしまったブロック(通常は0が正常)で、大きな値が出ている場合は別途調査が必要な異常サインです。

削除処理の完了を待ってから次の作業(たとえばプールのエクスポートや別の大規模操作)に進みたい場合は、zpool waitサブコマンドが便利です。

zfs destroy tank/big-dataset
zpool wait -t free tank    # freeing が0になるまでブロックして待つ

スクリプトで大量削除を伴う運用(定期的な古いスナップショットの一括削除など)を自動化している場合、destroyの直後に次の処理へ進んでしまうと、解放処理と後続処理のI/Oが競合します。zpool wait -t freeを挟むことで、少なくとも解放の完了を待ってから次のステップに進む、という安全な運用にできます。

解放処理自体がI/Oを生む

async destroyによる後始末処理は、ただメタデータをメモリ上で消しているだけではありません。空き領域を管理するspace map(各vdev内のどの領域が空いているかを記録する構造)を更新する必要があり、これ自体がディスクへの書き込みを伴います。巨大な削除であればあるほど、更新すべきspace mapのエントリ量も増え、通常のI/Oと帯域を奪い合う形になります。

HDD中心のプールでは、この「解放のための書き込み」がランダムI/O寄りになりやすく、体感できるレベルでの性能劣化として現れます。scrub/resilverと同様に、**「見た目のコマンドは終わっているのに、裏側のI/O負荷はまだ続いている」**タイプの処理だと理解しておくと、原因調査で迷わずに済みます。zpool iostat -v 1でtxg syncのタイミングに合わせて書き込みが継続しているかを観察すると、進行中のasync destroyが影響しているかどうかの当たりをつけられます。

クローン削除の特殊事情とlivelist

ZFSのクローン(zfs cloneで作成したデータセット)を削除する場合、通常のスナップショット削除よりもさらに厄介な問題がありました。クローンは元のスナップショットとブロックを共有しているため、「どのブロックがクローン固有で、どのブロックが元と共有されているか」を判定しながら解放する必要があり、共有関係が複雑な環境では削除処理自体に非常に長い時間がかかることがありました。

この問題に対応するため、OpenZFSにはlivelistという仕組みが導入されています。クローンが持つ「元との差分ブロック」の一覧を事前に維持しておくことで、実際の削除時にブロックポインタツリー全体を走査し直す必要がなくなり、クローン削除が大幅に高速化されます。

zfs get -r all tank/clones/vm1  # livelistの有効状態はプールのfeature flagとして確認可能

関連するチューニングパラメータとしてzfs_livelist_max_entries(livelistが保持するエントリ数の上限)やzfs_livelist_min_percent_shared(共有率がこの値を下回るとlivelistの利用を打ち切る閾値)がありますが、これらは多くの環境でデフォルトのまま運用して問題ありません。重要なのは、大量のクローンを作成・破棄するワークロード(VMテンプレートからの大量プロビジョニングなど)では、クローンの削除もまた非同期解放の対象であり、一括で大量のクローンを消すとfreeingのバックログが積み上がりやすいという点を運用上意識しておくことです。

ファイル単位でも非同期解放は起きる: zfs_delete_blocks

データセット丸ごとの削除だけでなく、**単一の巨大ファイルの削除(unlink/rm)**もasync destroyの対象になり得ます。ファイルが持つブロック数が閾値(zfs_delete_blocks、多くのバージョンでデフォルト20480)を超えている場合、そのファイルの解放はunlink呼び出し元をブロックせず、バックグラウンドの非同期処理に回されます。

これは体感として、「巨大なファイルをrmしたのにコマンドはすぐ終わり、その後ディスク使用量がじわじわ減っていく」という形で現れます。バックアップの巨大なイメージファイルや、DBのスパースファイルを削除する運用では、この挙動を前提にして「削除直後にdfの空き容量が満杯にならない」ことに驚かないようにしてください。

半日溶かした後に直したこと

犯人が分かってからは、メンテナンスジョブのスナップショット削除とバックアップ開始の間にzpool wait -t freeを1行挟むだけで解決しました。あの半日は、次を知ってさえいれば要らなかったはずです。

  • 巨大なデータセット/スナップショットを削除する前後で、zpool get freeing tankの値を確認する
  • 削除直後に別の重いI/O処理(バックアップ、resilverなど)を続けてスケジュールしていないか
  • スクリプトによる一括削除運用で、zpool wait -t freeを使って解放の完了を待つステップを挟んでいるか
  • 計画停止やホスト移行の前に、freeingのバックログがない状態であることを確認する(export/importは解放処理の続きを引き継ぐため、バックログがあると想定以上に時間がかかる)
  • VMテンプレートなどクローンを大量に作成・破棄するワークロードで、削除の非同期性を前提とした容量監視をしているか
  • leakedが0以外の値を示していないか(0以外なら別途調査が必要)

scrub/resilverと同じく、async destroyも「コマンドの見た目の完了」と「実際のI/O負荷の完了」がずれる処理です。大量削除を伴う運用を自動化するなら、freeingの監視とzpool waitをセットで組み込んでおくと、自分のような半日を失わずに済みます。