代表的な面接トピック

Linux面接:アトミックなファイル更新をクラッシュセーフにするにはどうすればよいか?

一般難しい
Offer.cc 編集チーム公開日 更新日

質問

Linuxデーモンが、他のプロセスによって読み取られている64 KiBの状態ファイルを置換します。デーモンが成功を報告した後は、新しいバージョンが電源喪失を生き残る必要があります。それ以前のいかなるクラッシュの後でも、対象パスには完全に以前コミットされたバージョンか、完全に新しいバージョンのいずれかが含まれていなければならず、途中で切り詰められた混合状態になってはなりません。書き込みプロトコル、エラー規約、並行性の境界、リカバリ動作、およびクラッシュテストを設計してください。

問題とユースケース

この問題には3つの異なる保証が含まれています。アトミックな可視性(Atomic visibility)とは、対象パスをオープンしたリーダーが、インプレースでの部分的な書き換えではなく、1つの完全なバージョンを参照することを意味します。クラッシュに対する永続性(Crash durability)とは、確認応答(ack)されたバージョンが再起動後もアクセス可能な状態を維持することを意味します。ライターの分離(Writer isolation)は、2つの更新が競合した際に何が起こるかを決定します。単一の rename() は、そのファイルシステムの規約において最初の保証のみに対応します。

fsync() と同一ファイルシステム内でのアトミックな置換を正しく実装しているローカルLinuxファイルシステム、直列化された1つのライター、および同じプロトコルによって書き込まれた以前コミット済みのバージョンを前提とします。64 KiBというサイズは面接上の前提条件です。ネットワークファイルシステム、キャッシュフラッシュに関して虚偽を返す障害のあるハードウェア、複数ファイルのトランザクション、および悪意のあるディレクトリ変更には別の規約が必要です。

典型的な用途には、構成スナップショット、ローカルエージェントの状態、チェックポイントマニフェスト、パッケージメタデータ、エディタの保存などがあります。状態が複数のファイルにまたがる場合や、クエリや並行トランザクションが必要な場合は、このプロトコルを手動で拡張するよりも、組み込みデータベースやジャーナルを使用する方が通常安全です。

面接官が評価している点

第1の評価ポイントは、候補者が write() の成功、rename() のアトミック性、および永続的なコミットを明確に区別しているかどうかです。Linuxのドキュメントには、write() が部分書き込みになる可能性があり、呼び出しの成功復帰がディスクへの永続化を証明するものではないことが明記されています。fsync(file) はファイルの変更されたデータと関連するメタデータをフラッシュしますが、そのディレクトリエントリを必ずしも永続化するわけではありません。最後の部分には fsync(parent_directory) が必要です。

第2の評価ポイントは順序付け(Ordering)です。名前空間が対象名を新しいバイトに向ける前に、それらのバイトが永続化されていなければなりません。その後、成功を報告する前にディレクトリエントリが永続化されていなければなりません。いずれかのバリアを逆にしたり省略したりすると、クラッシュウィンドウ(データの不整合が生じる隙)が生じます。

第3の評価ポイントは、正確で誠実な障害セマンティクスです。fsync() は、遅延した EIOENOSPC、またはクォータ超過エラーを顕在化させることがあります。最後のディレクトリ同期が失敗した場合、永続性が不明であるにもかかわらず rename はすでに可視化されている可能性があります。APIは、成功を主張したり常にロールバックできるかのように装ったりせず、不確定な失敗(indeterminate failure)を返す必要があります。

回答前に確認すべき明確化のための質問

  • 「アトミック」の対象はプロセスクラッシュですか、それともホストの電源喪失ですか? プロセスの終了だけであればカーネルのページキャッシュは残ります。設問ではホストクラッシュに対する永続性が求められているため、同期バリアが必要です。
  • 対象は文書化されたセマンティクスを持つローカルファイルシステム上にありますか? NFS、FUSE、オーバーレイファイルシステム、特殊なストレージスタックでは、異なるエラーおよび永続化動作を示す場合があります。実際のデプロイメント規約をテストする必要があります。
  • 複数のライターが存在しますか? この基本設計はライターを直列化します。一意の一時ファイル名は衝突を防ぎますが、後勝ち(last-writer-wins)の更新を防ぐものではありません。
  • 複数のファイルを同時に変更する必要がありますか? rename は単一のパス名の置換をコミットします。複数ファイルの一貫性を保つには、世代ディレクトリ、ジャーナル、またはデータベースを使用してください。
  • すでに開かれている古いファイル記述子は古いバージョンを読み続けられますか? はい。rename はディレクトリのマッピングを変更します。すでに開かれている記述子は依然として古い inode を参照します。リーダーは最新世代が必要になった時点で再度オープンする必要があります。
  • 何をもって有効なコンテンツとしますか? 公開前にシリアライゼーション、スキーマ、チェックサム、バージョンを検証してください。ファイルシステムのアトミック性によって、形式が不正だが完全なペイロードを正しくすることはできません。

30秒の回答フレームワーク

「私はアトミックな可視性と永続性を分離して考えます。まず親ディレクトリを開き、同じディレクトリ内に排他的作成フラグを用いて一意の一時ファイルを作成し、すべてのバイトが受け入れられるまでループで書き込み、必要なメタデータを設定して、一時ファイルに対して fsync を実行します。次にそれをクローズし、対象ファイルに対してアトミックに rename を行い、成功を返す前に親ディレクトリを fsync します。ファイルの同期により新しい inode の内容が永続化され、rename によって1つの完全なバージョンが公開され、ディレクトリの同期によってその名前と inode の対応関係の変更が永続化されます。ライターを直列化し、最後のディレクトリ同期の失敗を含むすべてのシステムコールエラーを明示的な不確定状態を伴う非成功として扱い、リカバリ時に孤立した一時ファイルをクリーンアップし、プロセスの強制終了を永続性の証拠とするのではなく、各ステップの後に電源喪失ポイントを設けてテストします。」

ステップバイステップの詳細解説

呼び出し元の視点からコミットポイントを定義します。親ディレクトリの fsync が成功して初めて、承認された成功(acknowledged success)となります。実装の概要は以下の通りです。

text
durableReplace(parentDir, targetName, bytes):
  dirfd = open(parentDir, read-only | directory | close-on-exec)
  tmpName = uniqueSiblingName(targetName)
  tmpfd = openat(dirfd, tmpName, create | exclusive | write-only | close-on-exec, mode)

  writeAll(tmpfd, bytes)          // retry EINTR; advance after partial writes
  setRequiredMetadata(tmpfd)      // mode/ownership if part of the contract
  fsync(tmpfd)                    // check delayed I/O and allocation errors
  close(tmpfd)                    // check the result

  renameat(dirfd, tmpName, dirfd, targetName)
  fsync(dirfd)                    // persist the namespace change
  close(dirfd)
  return committed

一時ファイルは対象ファイルの同階層(兄弟)でなければなりません。同一ディレクトリに配置することで、rename が単一のファイルシステム内で完結し、EXDEV によるコピーへのフォールバックを回避できます。排他的作成と予測不可能なプロセスローカルのサフィックスを使用し、攻撃者が制御可能な既存のシンボリックリンクをたどらないようにします。ファイル同期の前にパーミッションを設定し、必要なメタデータがファイルの永続状態に含まれるようにします。

writeAll は通常のファイルであっても重要です。要求したバイト数よりも小さい正の戻り値は部分書き込みを意味するため、バッファを進めて継続します。適切に中断された操作のみを再試行してください。write() の成功は、データがカーネルに受け入れられた状態に到達したことを意味し、安定した記憶媒体に届いたことを意味しません。close() 単独ではコミットバリアにはならず、エラーは後で fsync() によって報告される場合があります。

次に、一時ファイルを同期します。fsync は変更されたファイルデータと inode メタデータを対象とし、ストレージデバイスが完了を報告するのを待ちます。fdatasync は無関係なメタデータ処理を削減できますが、ファイルサイズなど、データの取得に必要なメタデータは引き続き永続化する必要があります。シンプルで監査しやすい回答にするため、測定および厳密なファイルシステム規約によって fdatasync が正当化されない限り、fsync を使用してください。

その同期が成功した後にのみ、renameat で対象を置換する必要があります。Linuxでは、移動先がすでに存在する場合、移動先を開こうとする他のプロセスがそれが存在しない瞬間を観測することはないと保証されています。古いファイルをすでに開いているリーダーは古い inode の読み取りを完了でき、後から開いたものは新しい inode を解決します。どちらも完全なバージョンであり、これが意図された読み取り規約です。

rename はディレクトリメタデータを更新します。ファイルの fsync は必ずしもそのディレクトリエントリを永続化するわけではないため、親ディレクトリを開いて rename 後に同期します。この順序が正しさの証明となります。

text
new contents durable
  -> target name atomically points to new inode
  -> target-name mapping durable
  -> caller may receive success

rename の前のクラッシュでは、古い対象ファイルと孤立した一時ファイルが残る可能性があります。rename 後、ディレクトリ同期前の段階では、可視化された対象は新しいものになっている可能性がありますが、再起動後の到達性はまだ保証されていません。ディレクトリ同期が成功した後は、承認された対象のマッピングと同期済みのコンテンツがコミットされた世代を形成します。リカバリ処理では、所有権と命名規則を検証した上で、認識可能な古い一時ファイルのみを削除します。前回の操作がエラーを返したという理由だけで対象ファイルを削除してはなりません。

エラー報告には、ブール値の成功よりも詳細な状態が必要です。rename 前の失敗は not_committed であり、以前コミットされた古い対象が信頼できる状態を維持します。rename 後、ディレクトリ同期の失敗は indeterminate となります。新しいファイルはすでに見えている可能性があり、クラッシュを生き残るかどうかは保証されません。エラーを返し、診断情報を保持し、リカバリ処理に世代番号やチェックサムを検査させてください。古い値を盲目的に書き戻すと、コミットされていない別の遷移が発生し、より新しいライターのデータを上書きする可能性があります。

複数のライターが存在する場合、すべてのライターが遵守する場合に限り、read-modify-write の周囲にアドバイザリロックを配置するか、期待される世代番号を保持して古い更新を拒否します。一意の一時ファイル名はステージングファイル同士の衝突を防ぐだけです。直列化がなければ、2つの有効な rename は個々にはアトミックですが、後から実行されたものが暗黙的に勝ちます。関連する複数のファイルの場合は、単一の永続ポインタを通じて1つの不変な世代ディレクトリを公開するか、先行書き込みジャーナル(WAL)を追加するか、SQLiteを使用します。rename を繰り返しても複数ファイルのトランザクションにはなりません。

厳格な永続性を確保するには、コミットされた更新ごとに少なくとも1回のファイルフラッシュと1回のディレクトリフラッシュのコストがかかります。すべての更新が電源喪失を生き残る必要がない場合は、一時ファイルと rename を用いてアトミックな可視性を確保し、最新バージョンの喪失を明示的に許容する、別の名称を持った緩和モード(relaxed mode)を提供します。更新頻度が高い場合は、複数の論理的変更を1つのジャーナルコミットにバッチ処理するか、グループコミットを備えたデータベースを使用します。ベンチマークスコアを向上させるために規約を勝手に緩めてはなりません。

すべての境界でプロトコルをテストします:部分書き込み、EINTRENOSPC、ファイル同期の EIO、ファイル同期前後のクラッシュ、rename の前・実行中・後のクラッシュ、およびディレクトリ同期の失敗。再起動後、対象ファイルがパース可能であり、最後に確認応答された世代または許可された未確認の新しい世代のいずれかに一致し、決してバイトの混合状態になっていないことをアサートします。また、確認応答されたすべての世代が生き残っていることもアサートします。プロセスの SIGKILL テストはクリーンアップとファイル記述子の動作を確認しますが、揮発性キャッシュの喪失をテストできるのは制御されたVMまたはストレージのクラッシュテストのみです。

質の高い模範回答

「私はまず3つの規約を定義することから始めます。リーダーは完全なバージョンを参照すること、確認応答された更新はホストのクラッシュを生き残ること、そしてライターが直列化されていることです。現在の対象ファイルは同じプロトコルによってコミットされたものと仮定します。

親ディレクトリを開き、対象ファイルの隣に排他的で一意な一時ファイルを作成します。write は短いバイト数を返す可能性があるためループ処理を行い、必要なパーミッションを適用し、一時ファイルに対して fsync を呼び出し、すべての結果をチェックします。その後、その兄弟ファイルを対象ファイルの上に rename します。この rename がアトミックな可視性の境界となります。既存のリーダーは古い inode を保持し続けることができ、新しいオープンは完全な新しい inode を解決します。

rename はまだ永続的なコミットではありません。ディレクトリエントリを変更しただけであり、ファイルを同期してもそのエントリが永続化されるとは限りません。したがって、開いている親ディレクトリを fsync し、それが成功した後にのみ成功を返します。rename 前のクラッシュでは古い対象ファイルと、場合によっては削除可能な一時ファイルが残ります。rename 後、ディレクトリ同期前の失敗は結果が不確定であるため、ロールバックを約束するのではなく、エラーを返してリカバリ時に世代メタデータを検査します。

部分書き込みやすべてのシステムコール障害をフォールトインジェクションでシミュレートし、すべての境界でVMをクラッシュさせてテストします。不変条件は、対象ファイルが常に有効な古い世代または新しい世代のいずれかであり、破損した混合状態にならないこと、そして確認応答されたすべての世代が再起動後も残っていることです。並行ライターや複数ファイルのトランザクションが必要な場合は、rename がそれらの問題を解決すると主張するのではなく、世代チェックとロックを追加するか、ジャーナル/データベースに移行します。」

よくある間違い

  • 対象ファイルをその場で上書きする → クラッシュ時に切り詰めや混合コンテンツが露出する → 完全な兄弟ファイルをステージングして rename する。
  • 1回の write() で全てが書き込まれると仮定する → ショートライトによりステージングされたファイルが暗黙的に切り詰められる → すべてのバイトが書き込まれるかエラーで終了するまでループする。
  • 一時ファイルを同期する前に rename を呼び出す → 永続化された名前が不完全または失われたデータを指す可能性がある → 公開前に新しい inode を同期する。
  • rename を永続的なコミットとして扱う → アトミックな可視性はディレクトリエントリを永続化しない → rename 後に親ディレクトリを同期する。
  • 別のマウント上の一時ディレクトリを使用する → rename が EXDEV で失敗するか、非アトミックなコピーに劣化する → 対象ディレクトリ内に一時ファイルを作成する。
  • fsync エラーを無視する → 遅延したストレージ障害が誤った成功報告になる → 明示的な非成功または不確定な結果を伝播させる。
  • SIGKILL のみでテストする → カーネルキャッシュはプロセス終了後も残る → 制御されたホスト/ストレージのクラッシュインジェクションを追加する。
  • 並行ライターを競合させる → 個々にはアトミックな更新であっても世代が失われる → 直列化するか、期待される世代を比較する。

フォローアップの質問と回答

フォローアップ1:なぜ fsync(temp) では不十分なのですか?

これはファイルシステム規約に基づき、一時 inode の内容と関連メタデータを永続化します。対象のパス名はディレクトリエントリです。rename 後にそのマッピングが変更されますが、Linuxではファイルの同期が親ディレクトリエントリを必ずしも永続化しないことが明記されています。確認応答を返す前に親ディレクトリを同期してください。

フォローアップ2:別のプロセスがすでに対象を開いている場合でも rename() はアトミックですか?

名前空間の置換は、パス検索に対してアトミックです。既存の記述子は古い inode を参照し続け、新しく開かれた記述子は新しい inode を解決します。リーダーが複数の読み取りにわたって単一の世代を必要とする場合は、途中で再度開くのではなく、1つの記述子を開いたままにしておく必要があります。

フォローアップ3:rename 後にディレクトリの fsync が失敗した場合、APIは何を返すべきですか?

不確定なコミット状態を示すエラーを返します。rename はすでに可視化されている可能性があり、アプリケーションはそれが再起動を生き残るかどうかを証明できません。意図された世代とエラーを記録し、成功の主張をやめ、リカバリ処理に対象を検証させてください。盲目的な自動ロールバックを行うと、後続の有効な更新を破壊する可能性があります。

フォローアップ4:fdatasyncfsync の代わりになりますか?

プラットフォームの規約と測定結果によって正当化される場合は代替可能です。以降のデータ取得に関係のないメタデータは除外されますが、ファイルサイズなど必要なメタデータは依然として永続化されなければなりません。rename 後に親ディレクトリを同期する必要性がなくなるわけではありません。一般的な回答としては、規約の監査が容易な fsync を優先してください。

フォローアップ5:3つのファイルをアトミックに更新するにはどうしますか?

3回の独立した rename では、混在した世代が露出する可能性があります。不変な世代ディレクトリ配下にすべてのファイルを書き込み、そのディレクトリを永続化してから、1つのマニフェストまたはポインタをアトミックに切り替えて同期します。あるいは、ジャーナルや組み込みデータベースを使用します。リーダーは単一の世代を解決し、操作の間それを保持します。

フォローアップ6:2人のライターが更新の喪失(Lost Update)を防ぐにはどうすればよいですか?

read-modify-commit の完全なシーケンス全体で、すべてのライターが遵守するロックを使用するか、期待される世代を含めて古いコミットを拒否します。一意の一時ファイル名はステージングの衝突を防ぐだけです。アトミックな rename はビジネス上のバージョンを比較しないため、自然とラストライターウィン(後勝ち)を許容してしまいます。

公開情報ソース

関連する質問