ファイルシステム 基礎

サーバはあらゆるリクエストを access.log にログする。 その write() 呼び出しはスタックに乗る:page cache、journal、 ブロックデバイス、物理メディア —— 各レイヤがレイテンシを加え、独自の一貫性ルールを持つ。 5 セクションで全体像を組み立てる:解剖(inode、ディレクトリ、ファイル抽象);page cache + 対話的な書き込みパスウォークスルー;耐久性 —— journaling、fsync、そして「書いた」と「クラッシュに耐える」の違い; 具体的なファイルシステム(ext4、XFS、ZFS、btrfs)とそのトレードオフ; 最後にクイックリファレンス

01

ファイルシステム解剖 —— inode、ディレクトリ、ブロック

「ファイル」は基本概念ではない。inode を指す名前で、inode はディスクブロックのコレクションを指す。 あらゆるファイルシステム操作はこの 3 つを再配置する。

あらゆる Unix 風ファイルシステムの底にあるのが ブロック —— 基底ディスクの固定サイズチャンク(典型的に 4 KB、ページサイズに合わせる)。 その上に inode がある:ファイルごとのメタデータ構造 —— サイズ、パーミッション、タイムスタンプ、所有者、ファイルのデータを保持するブロックへのポインタ。 inode の上に、ディレクトリはそれ自体が特殊な inode で、 そのデータは(名前 → inode 番号)エントリの表。

inode の詳細

伝統的な Unix inode は ~128 バイトで含む:

mode(ファイルタイプ + パーミッション、例 rwx)
uid、gid(所有者)
size(バイト)
atime、mtime、ctime(タイムスタンプ)
link count(ここを指すディレクトリエントリの数)
直接ブロックポインタ(ファイルの最初の 12 ブロック)
間接ブロックポインタ(さらに多くのブロックへのポインタを保持するブロック)
2 重間接、3 重間接(巨大ファイル用)

4 KB ブロックと 8 バイトポインタで、直接ポインタは 48 KB をカバー; 単一間接は 2 MB を追加;2 重間接は 1 GB を追加;3 重間接は 512 GB を追加。 現代のファイルシステム(ext4、XFS)は代わりに extent を使う —— 連続実行を記述する(開始、長さ)ペア、大型ファイルは数千のポインタではなく 数個の extent で表現可能になり得る。

パス解決

open("/var/log/access.log") するとき、カーネルは:

  1. ルート inode を検索する(ext4 では慣習で inode 2)。
  2. ルートのディレクトリデータを読み、inode 番号 N の var エントリを見つける。
  3. inode N を読む(ディレクトリであることを確認)。
  4. inode N のディレクトリデータを読み、inode M の log エントリを見つける。
  5. ... 最終的な名前まで続く。

各ステップは潜在的に inode 読み + ディレクトリデータ読み。キャッシュなしには深くネストされた パスを開くのは遅い。カーネルの dentry キャッシュ(directory entry cache) が最近の(パスコンポーネント → inode)ルックアップをキャッシュする;inode キャッシュが最近の inode 内容をキャッシュする。 両方がページキャッシュに住み、典型的なワークロードではデータキャッシュよりはるかに効果的 —— ほとんどのパスルックアップはディスクゼロでヒット。

ハードリンクとソフトリンク

ハードリンクは同じ inode を指す追加のディレクトリエントリに過ぎない。 inode のリンクカウントが上がる;ゼロに達するとファイルは解放される。ln file1 file2 がハードリンクを作る;「オリジナル」と「リンク」を区別できない。ソフトリンク(symlink)は特殊な inode で、そのデータはパス文字列; カーネルが透過的に追跡する。Symlink はファイルシステムを跨げ、存在しないパスを指せる; ハードリンクはできない。

ファイルディスクリプタの再訪

プロセス内のオープンな fd はプロセスごとのファイルディスクリプタ表のインデックス。 各エントリは inode、現在のオフセット、オープンフラグ(O_RDONLY、O_APPEND)、位置を含む カーネル struct file を指す。同じファイルを開く 2 プロセスは異なる 2 つのstruct file を得るが、同じ基底 inode(と同じデータ)を得る。 1 プロセス内の 2 つの dup された fd は 1 つの struct file(したがって 1 つのオフセット)を共有する。

要点。「ファイルシステムは 3 層:ブロック(生の 4 KB チャンク)、 inode(ファイルごとのメタデータ + ブロックポインタ)、ディレクトリ(名前を inode 番号に マップする特殊な inode)。パス解決はディレクトリツリーを歩く;dentry / inode キャッシュがそれを安くする。オープンな fd はプロセスごとの表のインデックスで、 エントリは struct file を指し、それが inode を指す。」

02

ページキャッシュ —— あらゆる読み書きが実際に行く場所

ユーザコードとディスクの間にページキャッシュが座る:ファイルページを RAM にキャッシュする カーネル構造。あらゆる Linux システムの空きメモリの単一最大消費者であり、 ほぼあらゆる読み書きがディスクではなくそれに触れる。

ページキャッシュは最近アクセスされたファイルデータのカーネルストアで、 (inode、オフセット)でインデックスされる。read 時、カーネルはまず ページキャッシュを確認する;ページがあればユーザバッファにコピーして即座に戻る。 なければディスクから取得してページキャッシュに入れ、その後コピー。write 時、 カーネルはページキャッシュにコピーし、ページをダーティとマークし、戻る —— 実際のディスク書き込みはバックグラウンドで非同期に発生する。

メモリのどこに住むか

典型的な Linux システムでは、ページキャッシュはプロセスが使っていない任意の物理メモリを取る。free -h は「buff/cache」の下にそれを表示する;この数は設計で大きく、 アプリケーションがメモリを必要とするとき自動的に再利用される。サーバをコールドブートし、 ワークロードを走らせると、ページキャッシュが空き RAM を埋めるまで成長する。 これが Linux が「常にメモリ不足」に見える理由 —— そうではない;キャッシュが仕事をしているだけ。

write() からディスクへ —— 実際に何が起こり、fsync がどこに収まるかステップ 0 —— ユーザが write(fd, buf, n) を呼ぶユーザプロセス⚠ volatile (lost on power-off)PAGE CACHE(カーネル RAM)⚠ volatile (lost on power-off)JOURNAL(ディスク上の先行書き込みログ)⚠ volatile (lost on power-off)BLOCK レイヤ(リクエストキュー、スケジューラ)⚠ volatile (lost on power-off)物理メディア(NAND / 回転プラッタ)⚠ volatile (lost on power-off)
アプリケーションがユーザ空間バッファにバイトを持ち、カーネルにそれらをファイルに書くよう頼む。標準 150 ns syscall 遷移。
1 / 8
write() が成功を返すことは「カーネルがバイトを RAM に持っている」を意味し、 「ディスクにある」ではない。ページキャッシュとジャーナルは両方とも fsync が外へ押し出すまで volatile。 これが「書いた」と「クラッシュに耐える」が異なる 2 つの言明で、データベースが正しく行うことに執着する理由。 非クリティカルデータ(ログ、キャッシュ)はカーネルが数秒後にバッチ書き; クリティカルデータ(データベース)は各コミット後に fsync する。

書き込みはあなたが思うものではない

成功した write() は「データがページキャッシュにある」を意味する。それだけ。 カーネルは 5 秒後にディスクに書くかもしれない(デフォルト dirty_writeback_centisecs)、 または低メモリ圧力下でずっと後。write() とディスクコミットの間の電源喪失で、 データは消える —— write() が成功を返したにもかかわらず。

3 つの syscall が耐久性を強制する:

  • fsync(fd) —— このファイルのすべてのダーティページとメタデータを ディスクにフラッシュし、完了時に戻る。典型的:NVMe で 5–15 ms、回転ディスクで 5–50 ms。
  • fdatasync(fd) —— ファイルサイズと変更時間が変わっていなければ、 メタデータをスキップする以外 fsync と同じ。わずかに速い。
  • sync() —— システム全体のライトバックをキックする;待たない。 単一ファイルの待たないバージョンには sync_file_range を使う。

O_DIRECT —— ページキャッシュをバイパス

オープンファイルに O_DIRECT をセットすると、カーネルに伝える: ページキャッシュを使わず、ユーザバッファから直接デバイスへ DMA する。 バッファアラインメントが必要(典型的に 512 バイト)、ユーザバッファサイズ制約、 そしてキャッシュの恩恵を失う —— あらゆる読みが本当にディスクに当たる。 データベース(Oracle、Postgres の direct_io 付き)とファイルシステム ベンチマークで使われる、アプリケーションがカーネルよりメモリに何を保持するかを よく知っているシナリオで。

Readahead とプリフェッチ

カーネルがファイルからの逐次読みに気づくと、投機的に先読みする —— アプリケーションがまだ要求していないページを取得する。10 個の別個の read呼び出しに見えるものを 1 つの大きな I/O 操作に変換し、スループットを劇的に改善する。posix_fadvise はアクセスパターンについてカーネルにヒントを与えられる (POSIX_FADV_SEQUENTIAL、POSIX_FADV_RANDOM、POSIX_FADV_WILLNEED)。

要点。「ページキャッシュはファイルデータを空き RAM にキャッシュする。read は通常それにヒットする;なければ取得 + キャッシュ + コピー。write は常にそれにヒットする —— 実際のディスク書きは非同期に発生し、 つまり write() 戻りは『RAM 内』を意味し『ディスク上』ではない。 耐久性を強制するには fsync;バイパスするには O_DIRECT; パターンをカーネルにヒントするには posix_fadvise。」

03

耐久性 —— journaling、fsync、クラッシュに許されること

電源喪失はあらゆるファイルシステムが通過しなければならないテスト:マシン再起動後、 ファイルシステムは依然として意味をなさなければならない。 Journaling は現代ファイルシステムが完全な fsck なしでこれを達成する方法。

Journaling なしでは、複数ブロック書き込みの最中の電源喪失はファイルシステムを 不整合状態に残す —— メタデータの半分が更新され、半分が更新されない。ブート時のfsck はあらゆる inode とブロックをスキャンして不整合を見つけねばならず、 大型ディスクで数分から数時間かかる。現代ファイルシステムはjournaling(先行書き込みログ)を使ってこれを回避する。

Journaling の動作

ジャーナルは保留トランザクションの固定サイズ循環ログ。 ファイルシステム本体に変更を適用する前に、カーネルは:

  1. ジャーナルにbegin-transactionマーカーを書く。
  2. 影響を受ける各ブロックの新しい内容をジャーナルに書く。
  3. チェックサム付きの commit マーカーを書く、その後トランザクションは耐久的に回復可能。
  4. 変更を実際のファイルシステムブロックに適用する。
  5. ジャーナルエントリを完了とマーク。

クラッシュ回復時、ファイルシステムはジャーナルをスキャンする:コミットマーカー付きの トランザクションはファイルシステムブロックにリプレイされる;なしのものは破棄される。 回復はジャーナルサイズ(典型的に数 MB)で制限されるので、時間ではなく秒。

3 つの journaling モード(ext4)

  • data=writeback —— メタデータのみジャーナル;データは順序外で着く可能性がある。 最速、しかしクラッシュが新しく拡張されたファイルに古いデータを露出させ得る。プロダクションではまれ。
  • data=ordered(デフォルト)—— メタデータのみジャーナル、 しかしデータブロックがメタデータコミット前に書かれることを保証する。 メタデータは未フラッシュデータを参照しない。速度と安全性の良いバランス。
  • data=journal —— データもジャーナルを通る。書き込みが倍増するが、 最強のクラッシュセマンティクスを提供する。データベースや高完全性ファイルシステムで使われることがある。

fsync —— 唯一の耐久性の約束

fsync(fd) は呼び出し前にコミットされたデータがクラッシュに耐えることを POSIX が保証する唯一のもの。内部的に:

  1. ファイルのすべてのダーティページをブロックレイヤにフラッシュ。
  2. ストレージデバイスにハードウェア書き込みキャッシュフラッシュを発行。
  3. デバイスが完了を ack するのを待つ。

ステップ 2 が重要。SSD と HDD は書き込みキャッシュを持ち、デバイスがコミットを決めるまで データをデバイスの volatile DRAM に保持する。デバイスへの生の書き込みは、データがまだ volatile の状態で成功を返す可能性がある。fsync は FLUSH_CACHE / FUA コマンドを発行し、 ack の前にデバイスにコミットを強制する。

fsyncgate と Postgres が学んだこと

Postgres は 2018 年に有名なほぼ災害(「fsyncgate」)を経験した:fsync が失敗すると、 カーネルはダーティページをページキャッシュから再試行なしに破棄しうる。 同じ fd 上の後続の fsync は成功を返す —— しかしデータは消えている。 Postgres の WAL 設計は fsync 失敗が再試行可能だと仮定していた;違った。 業界全体の修正:あらゆる fsync 失敗をパニックとして扱う;プロセスを再起動、WAL から再構築。

裂け書きと POSIX の正直な範囲

POSIX は ≤ PIPE_BUF(典型的に 512 バイト)の N バイト書き込みがアトミックであることを保証する。 それを超えると、書き込みは分割可能 —— 途中でクラッシュすることを含む。 データベースは WAL 内の全ページ書き込み(Postgres)またはコピーオンライトセマンティクス (ZFS、btrfs)でこれを処理する。ファイルシステムは一般的に POSIX が要求する以上を約束しない。

要点。「Journaling は fsck をディスクサイズではなくジャーナルサイズで制限する。 ext4 のデフォルト(data=ordered)はメタデータのみジャーナルするがデータを先に書く。fsync が耐久性を約束する唯一のもの —— そして成功しなければならない; 失敗時、カーネルがデータを破棄したと扱う(fsyncgate)。PIPE_BUF を超える裂け書きは合法; データベースは全ページ書き込みや COW で補償する。」

04

具体的なファイルシステム —— ext4、XFS、ZFS、btrfs

Linux ディストロは ext4 か XFS をデフォルトにする。ZFS と btrfs はより単純なファイル システムができない機能を提供する —— スナップショット、チェックサム、組み込みボリューム 管理 —— 複雑性のコストで。

ext4

大半の Linux ディストロのデフォルト。extent、遅延割り当て、ジャーナルチェックサム付きの ext3 後継。 成熟、よくチューニングされ、ファイル 16 TB、ファイルシステム 1 EB までサポート。 保守的な機能セット —— スナップショットなし、透過圧縮なし。 質問なしで「ファイルシステム」を欲しいときに手を伸ばすもの; 大半の Docker イメージがその上で走るものでもある。

XFS

元々 SGI のもの、現在は Red Hat / Fedora のデフォルト。非常に大きなファイルと非常に高い並列性のために設計。 Extent ベース、遅延割り当て、allocation group(CPU ごとの並列割り当て)。 大型ファイルワークロードで ext4 よりわずかに速い;縮小しない(成長のみ); 歴史的にオンライン defrag なし(最近のバージョンで追加)。RHEL での真剣なストレージのデフォルト。

ZFS

元々 Sun のもの;Linux では OpenZFS 経由で利用可能。区別される点:コピーオンライト(あらゆる書き込みが新しいブロックへ行く、 メタデータポインタのフリップがアトミックなコミット);あらゆるブロックの強チェックサム(ディスクファームウェアが静かに許容するビット腐敗を捕捉);O(1) のスナップショットとクローン(メタデータ操作のみ);組み込みボリューム管理(zpool が LVM + ファイルシステムを置き換える);透過圧縮と重複排除

コスト:高い RAM 使用(ZFS の ARC キャッシュはページキャッシュとは別で、多くのメモリを欲しがる —— 1 TB あたり 1 GB が一般的なルール);Linux 上のライセンスの複雑さ (CDDL vs GPL は out-of-tree で出荷されることを意味する);ext4 よりも管理が複雑。

btrfs

Linux の ZFS への答え。コピーオンライト、スナップショット、チェックサム、圧縮、サブボリューム。 in-tree(GPL クリーン)。一部の方法で ZFS よりも柔軟(オンライン resize、送受信レプリケーション)、 歴史的に一部の RAID 構成で安定性が低かった。Facebook が巨大なディスクストレージに使用; Fedora 33+ と openSUSE のデフォルト。

tmpfs と専用

tmpfs はファイルシステムを装った RAM:/tmp に書き込むと、 データはページキャッシュにのみ住み、ディスクは関与しない。速いが volatile。procfs(/proc)と sysfs(/sys)は 内容がカーネルによってオンデマンドで生成される仮想ファイルシステムで、 プロセスとデバイスの状態をファイルとして公開する。

1 つ選ぶ

  • デフォルトの Linux マシン → ext4(またはディストロが選んだもの)。
  • 大型ファイル / 高並列サーバ(データベース、バックアップターゲット)→ XFS。
  • 強い完全性と運用機能(スナップショット、チェックサム、送受信レプリケーション) → ZFS または btrfs。ライセンス柔軟性のある環境では ZFS、GPL のみの環境では btrfs。
  • コンテナイメージとエフェメラルデータ → overlayfs 内の ext4。 Linux カーネルのコンテナインフラ(overlayfs)は読み取り専用ベース + 書き込み可能上層を 階層化し、両方とも通常 ext4 上。

要点。「ext4 が安全なデフォルト;XFS が高並列 / 大型ファイル選択。 ZFS と btrfs はコピーオンライト、スナップショット、チェックサムを複雑性と RAM のコストで追加。 tmpfs はファイルシステム形状の RAM ディスク;procfs と sysfs はファイルを装ったカーネル状態 API。 大半のアプリケーションコードはどれで走っているか気にしない、気にするまでは。」

05

クイックリファレンス

冷たく説明できる価値のある 6 つの核心問題と、コードレビューで一目で見抜くべき 5 つの赤旗。

ブロック、inode、ディレクトリの関係は?

ブロックはディスクの生の固定サイズチャンク(典型的に 4 KB)。 Inode はファイルごとのメタデータ構造(mode、所有者、タイムスタンプ、サイズ、ブロックポインタ)。 ディレクトリは特殊な inode で、そのデータは(名前 → inode 番号)エントリの表。 パス解決はディレクトリツリーを歩く;dentry / inode キャッシュが繰り返しのルックアップを安くする。

write() は実際に何をする、データはいつ耐久になるか?

write() はユーザバッファからページキャッシュ(カーネル RAM)へバイトをコピーして戻る。 データはまだディスク上にない;write() と最終的なライトバックの間の電源喪失でデータを失う。fsync(fd) のみが耐久性を保証する —— ダーティページをジャーナル経由でディスクにフラッシュし、 デバイスが ack するのを待つ。典型的な fsync コスト:NVMe で 5–15 ms。

Journaling は何で、何を保護するか?

ジャーナルはディスク上の循環先行書き込みログ。ファイルシステムブロックを変更する前に、 カーネルが変更(または data=ordered モードではメタデータのみ)をコミットマーカー付きで ジャーナルに書く。クラッシュ回復時、コミットマーカー付きのジャーナルエントリはリプレイされる; 未コミットのものは破棄される。fsck 時間をディスクサイズ(時間)ではなくジャーナルサイズ(秒)に 制限し、fsync がファイルシステムを不整合状態に残すのを防ぐ。

ページキャッシュは何で、どのくらい大きくなるか?

ページキャッシュは最近アクセスされたファイルデータの、(inode、オフセット)でインデックスされる カーネルのメモリ内キャッシュ。プロセスが使っていない RAM を埋めるまで成長する ——free -h で「buff/cache」として可視。典型的なサーバで、総 RAM の 50–80% になり得る。 アプリケーションがメモリを必要とするとき自動的に再利用される。

fsyncgate は何で、そこから何を取るべきか?

2018 年の開示(Postgres 中心)、Linux カーネルが fsync 失敗時にダーティページを破棄し、 同じ fd の後続 fsync で成功を返す —— 静かにデータを失う。現代のデータベースは今や fsync 失敗をパニック値しと扱う:失敗をログし、プロセスを中止し、再起動時に WAL をリプレイする。 一般原則:fsync エラーは「カーネルがこのファイルについて何も保証できなくなった」を意味し、 「後でもう一度試す」ではない。

いつ ext4 ではなく XFS を選ぶか?

大型ファイル(単一ファイルが数百 GB)、高並列性(多くの同時書き手)、または非常に大きな ディレクトリのワークロード。XFS の allocation group は複数コアが競合なしに並列でブロック割り当てできる。 ext4 は一般的なケースには問題ない;XFS のエッジは規模で意味を持つ。 スナップショット、チェックサム、コピーオンライトセマンティクスには、どちらも機能しない —— ZFS または btrfs を使う。

コードレビューの赤旗

  • fsync なしのループ内 write()、耐久性を主張。データはページキャッシュにあり、ディスクにない。耐久性が重要なら、 論理トランザクションの最後の書き込みの後に fsync する。
  • fsync 失敗を一時的なものとして扱う。fsyncgate を参照。 「データが消えた、プロセスを再起動」として扱う —— カーネルがページを破棄したかもしれない。
  • flush なしで printf 経由のロギング。stdio は内部でバッファする; ファイルが行バッファモードで開かれていてもクラッシュは最後の数 KB のログを失う。 バッファされていない stderr または重要なポイントでの明示的フラッシュを使う。
  • 1 ディレクトリ内に多くの小型ファイルを保存。ext4 と XFS はそれを扱うが、ディレクトリ操作は ~10 万ファイルを超えると著しく遅くなる。 git や任意の大型オブジェクトストアのようにサブディレクトリへハッシュする (aa/aabbcc-deadbeef)。
  • O_DIRECT が速いと仮定。ページキャッシュなしでは、読みは常にディスクに当たる。 O_DIRECT は自分のキャッシュを持ちカーネルよりよく知っているときのみ速い。 そうでなければデフォルトのページキャッシュが大幅に勝つ。