ファイルシステムの仕組み 徹底解説 - inode・ジャーナリング・ページキャッシュ

ファイルシステムの仕組み 徹底解説 - inode・ジャーナリング・ページキャッシュ

作成日:
読了:63
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

write(2) が成功を返したとき、そのデータはディスクに書かれているでしょうか。答えは「たぶん書かれていない」です。手元のUbuntu機で300MBのファイルをddで作った直後、/proc/meminfoDirty12312 kBまで膨らんでいました。書き込みはメモリ上に溜まっているだけで、電源が落ちれば消えます。

ファイルシステムは、ただのバイト列を並べたデバイスの上に「名前」「ディレクトリ」「サイズ」「権限」という抽象を作り出し、さらにいつ電源が落ちても壊れないように整合性を守っています。この記事では、ファイルを開いて書き込んで閉じるまでにカーネルとディスクの中で何が起きているのかを、VFSからinode、ページキャッシュ、ジャーナリングまで順に追いかけます。

数値は原則としてman7.orgのmanページとkernel.orgのドキュメントから引き、実行例はLinux(さくらのVPS、Ubuntu、uname -r5.15.0-181-generic、ルートは/dev/vda2のext4)とmacOS(Apple M3 Max / macOS 26.5 / APFS)の実機で取得したものです。

ブロックデバイスの上になぜ抽象が要るのか

SSDやHDDがカーネルに見せている姿は、驚くほど素っ気ないものです。固定長のセクタが一列に並んだ配列で、できることは「n番目のブロックを読む」「n番目のブロックに書く」だけ。名前もディレクトリもタイムスタンプも存在しません。

ブロックデバイスが提供するものファイルシステムが足すもの
番号で指定したブロックの読み書き人間が読める名前と階層的なディレクトリ
固定長の一様な領域可変長で伸縮するファイルという単位
データそのもの所有者・権限・タイムスタンプなどのメタデータ
単発の書き込み複数ブロックにまたがる更新の整合性
(なし)空き領域の管理と割り当て

このうち最後の2つが厄介です。「ファイルを1バイト追記する」という操作ひとつでも、実際には空きブロックの確保、ビットマップの更新、inodeのサイズとタイムスタンプの更新、データブロックの書き込みという複数の書き込みに分解されます。デバイスはそれらを一括で行う手段を持たないので、途中で電源が落ちれば中途半端な状態が残ります。ファイルシステム設計の難しさの大半は、この複数ブロック更新のアトミック性をどう作るかに費やされています。

VFS - open/read/write はどこへ分岐するのか

Linuxでは、open(2)read(2)が最初にたどり着くのは個別のファイルシステムではなく、VFS(Virtual Filesystem Switch、仮想ファイルシステム)という共通層です。kernel.orgのVFSドキュメントは次のように定義しています。

The Virtual File System (also known as the Virtual Filesystem Switch) is the software layer in the kernel that provides the filesystem interface to userspace programs.

VFSがあるおかげで、ext4だろうとXFSだろうとNFSだろうと/procだろうと、アプリケーションからは同じopen/read/writeで扱えます。ファイルシステム側は「このオブジェクトに対して読み込みが来たら呼ばれる関数」を関数ポインタの表として登録するだけで済みます。

Loading diagram...

VFSが扱う中心的なオブジェクトは4つです。

オブジェクト表しているもの寿命
struct super_blockマウントされたファイルシステムそのものマウント中
struct inodeファイルの実体(メタデータとデータの所在)参照されている間キャッシュ
struct dentryディレクトリエントリ、つまり名前とinodeの結びつきdcacheに載っている間
struct file「開いているファイル」、ファイル記述子のカーネル側実装openからcloseまで

パス名の解決を担うのがdcache(dentry cache)です。VFSドキュメントは「a very fast look-up mechanism to translate a pathname (filename) into a specific dentry」と説明しています。/var/log/nginx/access.logを開くたびに親ディレクトリのデータブロックを読み直していては話にならないので、名前からdentryへの解決結果をメモリにキャッシュしておくわけです。

重要なのは、dentryとinodeが多対1である点です。VFSドキュメントも「multiple dentries can reference a single inode through hard links」と述べています。ここが次節の主題につながります。

openが成功すると、VFSはstruct fileを割り当てます。ドキュメントの表現では「Opening a file requires another operation: allocation of a file structure (this is the kernel-side implementation of file descriptors)」です。ファイルオフセット(次にどこから読むか)はstruct fileが持っているので、同じファイルを2回openすれば独立したオフセットが2つでき、dupforkで複製したファイル記述子は同じstruct fileを共有してオフセットも共有します。この違いは、複数プロセスから同一ログファイルに追記するときの挙動を左右します。

inodeとディレクトリエントリ - 名前はファイルの属性ではない

inode(7)が列挙するinodeの中身は、デバイス番号、inode番号、ファイル種別とモード、リンク数、UID/GID、サイズ、推奨I/Oブロックサイズ、割り当て済みブロック数、そして4つのタイムスタンプ(atime/mtime/ctime/btime)です。ここにファイル名は含まれていません

では名前はどこにあるのか。ディレクトリです。ディレクトリは特別扱いされたファイルであり、その中身は「名前 -> inode番号」の対応表です。つまりファイル名はファイルの属性ではなく、ディレクトリが持っている参照です。この一点を飲み込むと、Unix系のファイル操作の挙動がまとめて腑に落ちます。

ls -iでinode番号を表示すると、ハードリンクが同じ実体を指していることが見えます。

# Linux (ext4) 上での実行
cd /tmp/fsdemo
echo hello > a.txt
ln a.txt b.txt        # ハードリンク: 同じinodeへの2つ目の名前
ln -s a.txt c.txt     # シンボリックリンク: パス文字列を持つ別ファイル
ls -li
実行結果 (Ubuntu / ext4)
total 8
609 -rw-rw-r-- 2 ubuntu ubuntu 6 Sep  8 05:06 a.txt
609 -rw-rw-r-- 2 ubuntu ubuntu 6 Sep  8 05:06 b.txt
681 lrwxrwxrwx 1 ubuntu ubuntu 5 Sep  8 05:06 c.txt -> a.txt

a.txtb.txtのinode番号はどちらも609で、3列目のリンク数が2になっています。一方c.txtは別のinode番号681を持ち、リンク数は1です。シンボリックリンクは「a.txtという文字列」を中身に持つ独立したファイルなので、実体とは何の関係もありません。

statでもう少し細かく見ます。

stat a.txt
実行結果 (Ubuntu / ext4)
  File: a.txt
  Size: 6         	Blocks: 8          IO Block: 4096   regular file
Device: fc02h/64514d	Inode: 609         Links: 2
Access: (0664/-rw-rw-r--)  Uid: ( 1000/  ubuntu)   Gid: ( 1000/  ubuntu)
Access: 2026-09-08 05:06:28.474638300 +0900
Modify: 2026-09-08 05:06:28.474638300 +0900
Change: 2026-09-08 05:06:28.478638301 +0900
 Birth: 2026-09-08 05:06:28.474638300 +0900

Sizeは6バイトですがBlocks8です。inode(7)st_blocksについて「This field indicates the number of blocks allocated to the file, 512-byte units」と述べているので、512バイト単位で8ブロック、つまり4096バイトが実際に割り当てられています。6バイトのファイルでも4KiBのブロックを1つ丸ごと消費するわけです。

NOTE

macOSのstatはBSD系で、引数なしだと1行の生の値を吐きます。Linuxのstatに近い読みやすい出力が欲しいときはstat -xを使ってください。GNU coreutilsを入れてgstatを使う手もあります。

# macOS (APFS) 上での実行
stat -x a.txt
実行結果 (macOS 26.5 / APFS)
  File: "a.txt"
  Size: 6            FileType: Regular File
  Mode: (0644/-rw-r--r--)         Uid: (  501/y-sakata)  Gid: (    0/   wheel)
Device: 1,16   Inode: 358306963    Links: 2
Access: Tue Sep  8 05:05:24 2026
Modify: Tue Sep  8 05:05:24 2026
Change: Tue Sep  8 05:05:24 2026
 Birth: Tue Sep  8 05:05:24 2026

rmが実際に呼んでいるのはunlink(2)、すなわち「ディレクトリから名前を1つ消す」操作です。inodeとデータが解放されるのは、リンク数が0になり、かつそのinodeを開いているプロセスが1つもなくなったときです。

rm a.txt
stat -x b.txt | grep -E 'Inode|Links'
実行結果 (macOS)
Device: 1,16   Inode: 358306963    Links: 1

名前を1つ消してもデータは無事で、リンク数が2から1に減っただけです。この性質はlink(2)の制約とセットで理解しておくと安全です。manページは「Hard links, as created by link(), cannot span filesystems」と明記しており、ディレクトリへのハードリンクはEPERMで拒否されます(ループを作れてしまうため)。

「開いている間は消えない」性質は、運用でよく事故になります。ログファイルをrmしたのにディスク使用量が減らない、という現象です。実測してみます。

# Linux: 300MBのファイルを作り、openしたままunlinkする
python3 - <<'PY'
import os, subprocess, time
def used():
    out = subprocess.run(["df","-k","/tmp"],capture_output=True,text=True).stdout.splitlines()[1].split()
    return int(out[2])
f = open("ghost.bin", "wb")
f.write(b"x" * (300 * 1024 * 1024)); f.flush(); os.fsync(f.fileno())
print("作成後      使用量:", used(), "KB")
os.unlink("ghost.bin")
print("unlink後 ls:", os.listdir("."))
print("unlink後    使用量:", used(), "KB  <- 減っていない")
f.close(); time.sleep(2)
print("close後     使用量:", used(), "KB  <- ここで解放")
PY
実行結果 (Ubuntu / ext4)
作成後      使用量: 142189488 KB
unlink後 ls: ['sparse.bin', 'b.txt', 'a.txt', 'c.txt']
unlink後    使用量: 142189488 KB  <- 減っていない
close後     使用量: 141882284 KB  <- ここで解放

lsからは消えているのに、closeするまで約300MBが返ってきません。ログローテーション後にプロセスへHUPを送って開き直させるのは、まさにこの参照を切るためです。原因を特定したいときはlsof | grep deletedが定番です。

inode枯渇とdf -i

inodeはファイルシステム作成時に個数が固定されます(ext4の場合)。ディスク容量が余っていてもinodeを使い切れば、ENOSPCでファイルが作れなくなります。

df -i
実行結果 (Ubuntu / ext4)
Filesystem       Inodes   IUsed    IFree IUse% Mounted on
tmpfs            251320    4500   246820    2% /run
/dev/vda2      13107200 2233893 10873307   18% /
tmpfs            251320       1   251319    1% /dev/shm

inode数はどう決まるのか。mke2fsの設定ファイルに答えがあります。

cat /etc/mke2fs.conf
実行結果 (Ubuntu / e2fsprogs 1.46.5)
[defaults]
	base_features = sparse_super,large_file,filetype,resize_inode,dir_index,ext_attr
	default_mntopts = acl,user_xattr
	enable_periodic_fsck = 0
	blocksize = 4096
	inode_size = 256
	inode_ratio = 16384

inode_ratio = 16384は「16KiBあたり1個のinodeを作る」という意味です。実際、この環境はtune2fs -lでBlock count 52427984、Block size 4096、Inode count 13107200でした。約215GBを16384で割るとほぼ1310万になり、計算が合います。この比率は[fs_types]セクションで容量帯ごとに上書きされ、bigなら32768、hugeなら65536です。小さいファイルを大量に置くメールスプールやセッションストアでは、mkfs.ext4 -i 4096のように作成時に密度を上げておかないと後から変更できません。

WARNING

ディスクフルの調査ではdf -hdf -iを必ず両方見てください。容量に余裕があるのに書き込めない場合、犯人はinode枯渇か、前述の「削除済みだがopenされたままのファイル」のどちらかであることがほとんどです。なおXFSやBtrfs、APFSはinodeを動的に割り当てるため、この形の枯渇は起きにくくなっています。

データブロックの配置 - 間接ブロックからエクステントへ

inodeは「このファイルのデータがディスクのどこにあるか」を持っています。その表現方法が、ext2からext4への最大の進化点です。

まず土台となるのがブロックサイズです。kernel.orgのext4ドキュメントによれば既定は4KiBで、ブロックグループは8 * block_size_in_bytesブロック、つまり「With the default block size of 4KiB, each group will contain 32,768 blocks, for a length of 128MiB」となります。実機のtune2fs -lでもBlocks per group: 32768が確認できました。ブロックアロケータは「keep each file's blocks within the same group, thereby reducing seek times」ように動きます。

ext2系の間接ブロック

ext2/ext3では、inode内のi_blockという60バイトの領域を、4バイトのブロック番号15個として使います。kernel.orgのext4ドキュメント(ifork)の記述はこうです。

  • i_block[0]からi_block[11]: 「Direct map to file blocks 0 to 11.」
  • i_block[12]: 「Indirect block: (file blocks 12 to ... 1035 if 4KiB blocks)」
  • i_block[13]: 二重間接ブロック(4KiBブロックなら1036から1049611)
  • i_block[14]: 三重間接ブロック(4KiBブロックなら1049612から1074791436)
inode.i_block (60バイト = 4バイト x 15)
+----+----+ ... +----+------+------+------+
| D0 | D1 |     |D11 | IND  | DIND | TIND |
+----+----+ ... +----+------+------+------+
                        |      |      |
                        v      v      v
                     [1024個][1024x1024個][1024^3個のブロック番号]

小さいファイルは12個の直接ブロック(4KiBブロックなら48KiB)で完結するので高速ですが、大きなファイルは間接ブロックを何段もたどる必要があります。しかも1GBの連続領域を表すのに、25万個以上のブロック番号を律儀に列挙することになります。メタデータの量もI/O回数も、ファイルサイズにほぼ比例して増えてしまいます。

ext4のエクステント

ext4はこれをエクステント(extent)に置き換えました。エクステントは「論理ブロックxから始まる連続したnブロックが、物理ブロックyから始まる」という区間表現です。kernel.orgは「extent format reduces metadata overhead (RAM, IO for access, transactions)」「extent format more robust in face of on-disk corruption due to magics, internal redundancy in tree」と説明しています。

同じくiforkのドキュメントから、実装上の数値を押さえます。

項目出典の記述
i_blockのサイズ60バイト「60 bytes of storage」
ヘッダ/インデックス/リーフの各構造体12バイト3構造体とも12バイト
inode内に直接収まるエクステント数4個「allows for the first four extents to be recorded without the use of extra metadata blocks」
1エクステントの最大ブロック数32768ee_lenの初期化済み最大値
エクステント木の最大の深さ5段「can be at most 5 levels deep」

4KiBブロックなら、1つのエクステントは32768 x 4096 = 128MiBまでの連続領域を12バイトで表現できます。inode内に4つ入るので、断片化していないファイルなら512MiBまで追加のメタデータブロックなしで表現できる計算です。5つ目からは木構造に切り替わり、ee_lenが32768を超える値は未初期化エクステント(fallocateで予約だけした領域)を表し「the maximum length of an uninitialized extent is 32767」となります。

この違いは最大サイズにも直結します。

ブロックサイズ最大ファイルサイズ(エクステント)最大ファイルサイズ(ブロックマップ)最大FSサイズ(32bit)最大FSサイズ(64bit)
1KiB4TiB16GiB4TiB16ZiB
2KiB8TiB256GiB8TiB32ZiB
4KiB16TiB4TiB16TiB64ZiB
64KiB256TiB256TiB256TiB1YiB

エクステントの実際の並びはfilefrag -vで覗けます。

# Linux: 200MBのファイルを作って同期し、エクステントを表示
dd if=/dev/zero of=big.bin bs=1M count=200 status=none
sync
filefrag -v big.bin | head -14
実行結果 (Ubuntu / ext4)
Filesystem type is: ef53
File size of big.bin is 209715200 (51200 blocks of 4096 bytes)
 ext:     logical_offset:        physical_offset: length:   expected: flags:
   0:        0..    2047:   51718144..  51720191:   2048:            
   1:     2048..    4095:   51240960..  51243007:   2048:   51720192:
   2:     4096..    6143:   27133952..  27135999:   2048:   51243008:
   3:     6144..    8191:   27146240..  27148287:   2048:   27136000:
   4:     8192..   10239:   16889856..  16891903:   2048:   27148288:

expected列は「直前のエクステントの続きならこの物理ブロック番号のはずだった」という値です。実際のphysical_offsetがそれと違うので、この200MBのファイルは物理的に離れた場所に散っている(使用率73%の実運用ディスクなので当然です)と分かります。全体では17 extents foundでした。8KiB単位ではなく2048ブロック(8MiB)ずつまとまっているのは、後述する遅延アロケーションの効果です。

遅延アロケーションと断片化

ext4の既定では遅延アロケーション(delayed allocation、delalloc)が有効です。kernel.orgの説明は「Defer block allocation until just before ext4 writes out the block(s) in question. This allows ext4 to better allocation decisions more efficiently.」。write(2)の時点では物理ブロックを決めず、ライトバックの直前まで引き延ばすことで、そのファイルが最終的に何バイトになるかを知った上でまとめて連続領域を割り当てられます。

効果を確かめるため、ランダムな順序で2000ブロックを穴埋めして書いてみました。

python3 - <<'PY'
import os, random
fd = os.open("frag.bin", os.O_CREAT|os.O_WRONLY|os.O_TRUNC, 0o644)
blocks = list(range(2000)); random.shuffle(blocks)
buf = b"x" * 4096
for b in blocks:
    os.pwrite(fd, buf, b * 4096)
os.fsync(fd); os.close(fd)
PY
filefrag frag.bin
実行結果 (Ubuntu / ext4)
frag.bin: 1 extent found

書いた順序はバラバラなのに、エクステントは1つです。書き込みがページキャッシュ上で溜まり、ライトバック時には全体が確定していたため、連続した8MiBを一発で割り当てられたわけです。断片化の話でありながら、実質的にはページキャッシュの話になっているところが面白い点です。

スパースファイル

「サイズは大きいが実体がほとんどない」ファイルも作れます。書き込んでいない範囲にはブロックを割り当てず、読むとゼロが返るスパースファイルです。

# 100MB目の位置に1バイトだけ書く
dd if=/dev/zero of=sparse.bin bs=1 count=1 seek=104857599 status=none
ls -l sparse.bin
du -h --apparent-size sparse.bin
du -h sparse.bin
filefrag -v sparse.bin | head -6
実行結果 (Ubuntu / ext4)
-rw-rw-r-- 1 ubuntu ubuntu 104857600 Sep  8 05:06 sparse.bin
100M	sparse.bin
4.0K	sparse.bin
Filesystem type is: ef53
File size of sparse.bin is 104857600 (25600 blocks of 4096 bytes)
 ext:     logical_offset:        physical_offset: length:   expected: flags:
   0:    25599..   25599:          0..         0:      0:             last,unknown_loc,delalloc,eof

見かけ100MB、実消費4KiBです。inode(7)st_blocksについて「This may be smaller than st_size/512 when the file has holes.」と注記しているのは、まさにこの状態を指しています。同じことをmacOS(APFS)でやると16KiB消費でした(APFSのブロックサイズは4096ですが割り当て単位が異なります)。

実行結果 (macOS 26.5 / APFS)
-rw-r--r-- 1 y-sakata wheel 104857600 Sep  8 05:05 sparse.bin
 16K	sparse.bin
104857600 bytes, 32 blocks(512B), blksize=4096

スパースファイルの落とし穴は、cptarで扱うと実体化して本当に100MB消費してしまう点、そして書き込み時に初めてブロックを割り当てるため後からENOSPCになり得る点です。スワップファイルをddではなくfallocateで作る/作らないの判断もここに関わってきます。詳しくはLinuxのスワップファイル作成: fallocateとddの違いで扱っています。

ページキャッシュとライトバック - write(2)が返ってもディスクには届いていない

ここからが実務で最も効く領域です。通常のwrite(2)は、ユーザ空間のバッファからページキャッシュへデータをコピーした時点で成功を返します。ページキャッシュはファイルの内容をページ単位で保持するカーネル内のキャッシュで、変更されてまだディスクに書かれていないページをdirty pageと呼びます。

dirty pageは、次のいずれかのタイミングで実際のデバイスへ書き出されます(ライトバック)。

  1. flusherスレッドが定期的に起きたとき
  2. dirtyの総量が閾値を超えたとき
  3. アプリケーションがfsync/fdatasync/syncを明示的に呼んだとき
  4. メモリが逼迫して回収が必要になったとき

閾値を決めるのが/proc/sys/vm/のパラメータです。既定値はLinuxのmm/page-writeback.cにハードコードされています。

linux/mm/page-writeback.c (mainline)
/*
 * Start background writeback (via writeback threads) at this percentage
 */
static int dirty_background_ratio = 10;
 
/*
 * The generator of dirty data starts writeback at this percentage
 */
static int vm_dirty_ratio = 20;
 
/*
 * The interval between `kupdate'-style writebacks
 */
unsigned int dirty_writeback_interval = 5 * 100; /* centiseconds */
 
/*
 * The longest time for which data is allowed to remain dirty
 */
unsigned int dirty_expire_interval = 30 * 100; /* centiseconds */

実機でも同じ値でした。

sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs vm.dirty_writeback_centisecs
実行結果 (Ubuntu / kernel 5.15.0-181)
vm.dirty_ratio = 20
vm.dirty_background_ratio = 10
vm.dirty_expire_centisecs = 3000
vm.dirty_writeback_centisecs = 500

意味を整理します。

パラメータ既定意味
vm.dirty_background_ratio10この割合を超えると、バックグラウンドのflusherスレッドが書き出しを始める
vm.dirty_ratio20この割合を超えると、書き込んでいるプロセス自身が書き出しに参加させられる(体感的なストール)
vm.dirty_expire_centisecs3000 (30秒)この時間dirtyのままのデータは、次にflusherが起きたときに書き出される
vm.dirty_writeback_centisecs500 (5秒)flusherスレッドが起きる間隔
vm.dirty_bytes / vm.dirty_background_bytes0 (無効)割合ではなく絶対バイト数で指定したい場合に使う。設定すると対応するratio側が0になる

kernel.orgのvm.rstはdirty_ratioを「the number of pages at which a process which is generating disk writes will itself start writing out dirty data」と説明しています。ここが重要で、閾値を超えるとアプリケーション自身が書き出しに巻き込まれるため、それまで一定だったwriteのレイテンシが突然跳ね上がります。大容量メモリのサーバでdirty_ratio = 20のままにしておくと、数十GBのdirtyが一気に吐き出されて数秒間I/Oが詰まる、といった事故が起きます。ワークロードによってはvm.dirty_bytesで絶対値を小さめに固定するほうが安定します。

実際にdirtyが膨らむ様子を観察できます。

grep -E "^(Dirty|Writeback):" /proc/meminfo
dd if=/dev/zero of=dirty.bin bs=1M count=300 status=none
grep -E "^(Dirty|Writeback):" /proc/meminfo
sync
grep -E "^(Dirty|Writeback):" /proc/meminfo
実行結果 (Ubuntu / ext4)
Dirty:                52 kB
Writeback:           216 kB
Dirty:             12312 kB
Writeback:          8196 kB
Dirty:                 0 kB
Writeback:            48 kB

ddの直後にDirtyが12MBほどに膨らみ、syncでゼロに戻っています(300MB全部がdirtyになっていないのは、途中でライトバックが並行して進んでいるためです)。この間に電源が落ちれば、ddが成功していたにもかかわらずデータは消えます。

ページキャッシュそのものの量はfreeで見えます。

free -h
実行結果 (Ubuntu)
               total        used        free      shared  buff/cache   available
Mem:           1.9Gi       1.7Gi        87Mi       0.0Ki       183Mi       101Mi
Swap:           23Gi       9.7Gi        14Gi

buff/cache列がページキャッシュ(と一部のカーネル構造)です。ここが大きくても「メモリが足りない」わけではなく、availableが実際に使える量を示します。キャッシュは必要になれば捨てられるからです。「速い層で遅い層を隠す」という発想自体はCPUキャッシュとメモリ階層と同じで、ページキャッシュはその階層の1段下に位置しています。

fsyncとその親戚

明示的にディスクへ届けたいときに使うのがfsync(2)です。manページの記述を正確に押さえます。

fsync() transfers ("flushes") all modified in-core data of (i.e., modified buffer cache pages for) the file referred to by the file descriptor fd to the disk device

そしてfdatasyncとの違いは、

fdatasync() is similar to fsync(), but does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be correctly handled.

つまりファイルサイズが変わる追記ではfdatasyncでもサイズ更新は同期されますが、mtimeの更新のためだけの余分なI/Oは省けます。既存領域の上書きだけならfdatasyncのほうが確実に速くなります。

手段データメタデータ備考
write(2)のみページキャッシュまでページキャッシュまで電源断で消える
fdatasync(2)ディスクまで読み出しに必要な分のみ上書き中心なら最速
fsync(2)ディスクまですべてディレクトリエントリは別途必要
O_DSYNC付きでopenwriteでデータ同期必要な分のみ「synchronized I/O data integrity completion」
O_SYNC付きでopenwriteでデータ同期すべて「synchronized I/O file integrity completion」
O_DIRECT付きでopenページキャッシュを迂回-同期は保証しない。別途fsyncが要る

O_DIRECTopen(2)のmanページで「Try to minimize cache effects of the I/O to and from this file. In general this will degrade performance, but it is useful in special situations, such as when applications do their own caching.」と説明されています。自前でバッファプールを持つDBのための機能であって、一般アプリの高速化手段ではありません。バッファのアドレスとサイズと転送オフセットに厳しいアラインメント制約があり、manページも「Applications should avoid mixing O_DIRECT and normal I/O to the same file, and especially to overlapping byte regions in the same file.」と警告しています。なお、O_DIRECTはディスクの揮発キャッシュを飛ばしてくれるわけではないので、耐久性が必要ならやはりfsync(またはO_DSYNC)が必要です。非同期I/Oと組み合わせる話はノンブロッキングI/OとI/O多重化にまとめています。

fsyncのコストは実測しておく価値があります。手元のMacで、4KiBを200回書く処理を「最後に1回だけfsync」と「毎回fsync」で比べました。

wtest2.c (抜粋)
int fd = open("a.dat", O_CREAT | O_WRONLY | O_TRUNC, 0644);
double t0 = now();
for (int i = 0; i < N; i++) write(fd, buf, sizeof(buf));
fsync(fd);
double t1 = now();
close(fd);
 
fd = open("b.dat", O_CREAT | O_WRONLY | O_TRUNC, 0644);
double t2 = now();
for (int i = 0; i < N; i++) { write(fd, buf, sizeof(buf)); fsync(fd); }
double t3 = now();
実行結果 (macOS 26.5 / Apple M3 Max / APFS)
write x200 + fsync x1   :     5.32 ms (0.027 ms/op)
write x200 + fsync x200 :    38.84 ms (0.194 ms/op)

7倍以上の差です。NVMe SSDでこの差なので、回転ディスクやネットワークストレージではさらに開きます。「安全のために毎回fsyncする」設計は、この単価を理解した上で選ぶべきトレードオフです。

クラッシュ整合性とジャーナリング

ここまでで「書き込みは遅延する」ことが分かりました。では、遅延している最中に電源が落ちたら何が起きるのか。単にデータが失われるだけなら諦めもつきますが、問題はもっと深刻です。

ファイルに1ブロック追記する処理を分解します。

  1. ブロックビットマップの空きブロックを「使用中」に立てる
  2. そのブロックにデータを書く
  3. inodeのエクステント情報を更新して、そのブロックを自分のものにする
  4. inodeのサイズとmtimeを更新する

もし3と4だけが書かれて2が書かれないまま落ちたら、ファイルは「自分のものだと主張しているが中身は前の持ち主のゴミ」というブロックを含むことになります。他人の削除済みパスワードファイルの断片が、自分のログファイルの末尾から読める。これがジャーナリングが解こうとしている本当の問題で、単なる「fsckを速くする仕組み」ではありません。

ジャーナル(journal)は、本来の書き込み先に手を付ける前に「これから何をするか」をディスク上の専用領域へ順番に書き留めておく仕組みです。ext4ではJBD2というレイヤが担当します。kernel.orgのjournal.rstから引きます。

the ext4 filesystem employs a journal to protect the filesystem against metadata inconsistencies in the case of a system crash.

Should the system crash during the second slow write, the journal can be replayed all the way to the latest commit record, guaranteeing the atomicity of whatever gets written through the journal to the disk.

用語を2つ押さえます。ジャーナルへトランザクションを書き終えてコミットレコードを置くことをコミット(commit)、コミット済みの内容を本来の場所へ反映することをチェックポイント(checkpoint)と呼びます。クラッシュ後のマウント時には、コミット済みだがチェックポイントされていないトランザクションをリプレイ(replay)します。コミットレコードが無い中途半端なトランザクションは丸ごと破棄されます。全か無か、というわけです。WAL(Write-Ahead Log)とまったく同じ発想で、DBの世界の話はトランザクションとACIDで扱っています。

              ジャーナル領域                      本体領域
時刻 --->  [Tx開始][メタデータ][コミット]  ...  [実際のinode/ビットマップへ反映]
                                    ^                        ^
                                 コミット完了           チェックポイント
           ここより前で落ちる -> トランザクション破棄(何も起きなかったことに)
           ここより後で落ちる -> リプレイして反映(必ず起きたことに)

実機のext4のジャーナル情報も見ておきます。

sudo dumpe2fs -h /dev/vda2 | grep -iE 'journal|inode size|block size'
実行結果 (Ubuntu / ext4)
Filesystem features:      has_journal ext_attr resize_inode dir_index filetype needs_recovery extent 64bit flex_bg sparse_super large_file huge_file dir_nlink extra_isize metadata_csum
Block size:               4096
Inode size:	          256
Journal inode:            8
Journal backup:           inode blocks
Journal features:         journal_incompat_revoke journal_64bit journal_checksum_v3
Total journal size:       64M
Total journal blocks:     16384
Journal sequence:         0x00d435c7

ジャーナル自体がinode番号8のファイルとして存在し、64MB確保されています。

data=journal / ordered / writeback

ここで冒頭の「順序」問題に戻ります。journal.rstは既定の挙動をこう述べています。

For performance reasons, ext4 by default only writes filesystem metadata through the journal. This means that file data blocks are /not/ guaranteed to be in any consistent state after a crash.

メタデータだけをジャーナルに通すなら、データとメタデータの前後関係をどう扱うか、という選択肢が生まれます。ext4はこれをマウントオプションで切り替えられるようにしています。kernel.orgのadmin-guide/ext4.rstの記述をそのまま引きます。

モードドキュメントの記述特徴
data=journal「All data are committed into the journal prior to being written into the main file system. Enabling this mode will disable delayed allocation and O_DIRECT support.」最も安全だがデータを2回書くので遅い
data=ordered (既定)「All data are forced directly out to the main file system prior to its metadata being committed to the journal.」データを先、メタデータのコミットを後
data=writeback「Data ordering is not preserved, data may be written into the main file system after its metadata has been committed to the journal.」最速だがクラッシュ後に古いデータが見え得る

data=orderedが既定である理由は、この表を並べると明快です。データを先に本体へ書き切ってからメタデータをコミットするので、「inodeが自分のものだと主張しているブロックに中身が無い」という状態が原理的に発生しません。しかもデータはジャーナルを経由しないので、data=journalのような二重書き込みのコストがかかりません。安全性の要点だけを押さえて、コストは払わないという設計です。

実際のマウント状態は/proc/fs/ext4/デバイス名/optionsで確認できます。

cat /proc/fs/ext4/vda2/options
実行結果 (Ubuntu / ext4、抜粋)
rw
dioread_nolock
nodiscard
delalloc
journal_checksum
barrier
auto_da_alloc
errors=continue
commit=5
data=ordered

commit=5はジャーナルのコミット間隔で、ドキュメントによれば既定は5秒、「limits the maximum age of the running transaction」つまり電源断で失われ得る最新メタデータの上限時間を決めます。barrierはライトバリアで、「enforces proper on-disk ordering of journal commits, making volatile disk write caches safe to use, at some performance penalty」とあります。これをnobarrierで無効化すると、ディスクの揮発キャッシュ上で順序が入れ替わり、ジャーナルの保証が壊れます。速度目的で切るべきではありません。

auto_da_alloc - 遅延アロケーションが招いた事故への対処

data=orderedと遅延アロケーションを組み合わせると、有名な落とし穴が生まれます。「一時ファイルに書いてrenameで差し替える」という定番パターンで、fsyncを省いた場合です。renameのメタデータ変更だけが先にコミットされ、データはまだ割り当てすらされていないので、クラッシュ後に長さ0のファイルが残ります。kernel.orgのadmin-guide/ext4.rstは、この対処をこう説明しています。

Many broken applications don't use fsync() when replacing existing files via patterns such as fd = open("foo.new")/write(fd,..)/close(fd)/ rename("foo.new", "foo"), or worse yet, fd = open("foo", O_TRUNC)/write(fd,..)/close(fd). If auto_da_alloc is enabled, ext4 will detect the replace-via-rename and replace-via-truncate patterns and force that any delayed allocation blocks are allocated such that at the next journal commit, in the default data=ordered mode, the data blocks of the new file are forced to disk before the rename() operation is committed.

ext4はパターンを検出して救済してくれますが、これはext4の親切であって規格上の保証ではありません。他のファイルシステムでは救われないので、アプリケーション側で正しくfsyncするのが本来の姿です。その正しい書き方は次章で扱います。

Copy-on-Write系との対比 - ジャーナルを持たないアプローチ

ジャーナリングは「本来の場所を上書きする前に、やることをログに残す」方式です。これに対して、そもそも上書きをしないという戦略があります。BtrfsやZFSが採るCopy-on-Write(CoW)です。

kernel.orgのBtrfsドキュメントは「Btrfs is a copy on write filesystem for Linux aimed at implementing advanced features while focusing on fault tolerance, repair and easy administration.」と述べ、機能として「Checksums on data and metadata」「Writable snapshots」「Subvolumes (separate internal filesystem roots)」「Reflink and deduplication」などを列挙しています。伝統的なジャーナルへの言及はありません。

CoWの更新手順はこうです。

  1. 変更するブロックを、空き領域の新しい場所に書く
  2. そのブロックを指していた親ノードも、新しい場所にコピーして書き換える
  3. 木の根まで同じことを繰り返す
  4. 最後にスーパーブロック(root pointer)を新しい根に切り替える

3までは既存のデータに一切触っていないので、どの時点で電源が落ちても古い木は完全な形で残ります。4のポインタ切り替えは1ブロックの書き込みで済み、これが唯一のアトミックな瞬間です。ジャーナルという別領域を用意せずとも、構造そのものでクラッシュ整合性が得られます。

この構造からスナップショットがほぼタダで手に入ります。「古い根を捨てずに取っておく」だけで、その時点の完全なファイルシステムイメージになるからです。

方式代表クラッシュ整合性の担保長所短所
メタデータジャーナリングext4 (data=ordered)、XFSジャーナルのコミットとリプレイ実績が厚く、上書きが局所的で速いデータ自体の整合性は別途の工夫が必要
フルデータジャーナリングext4 (data=journal)データもジャーナル経由データも守られるすべてを2回書くので遅い
Copy-on-WriteBtrfs、ZFS木の根の差し替えスナップショット、チェックサム、圧縮が自然に載る上書きが断片化を招きやすく、空き容量管理が複雑

XFSはCoWではなくメタデータジャーナリングのファイルシステムですが、後年reflink(共有エクステントとCoW書き込み)を取り込み、cp --reflinkによる瞬時のコピーやスナップショット的な運用ができるようになっています。kernel.orgのXFS設計ドキュメントも「reverse mapping and reflink were introduced」と触れています。ジャーナリング系とCoW系は排他的な二択ではなく、良いところを取り込み合っているのが現状です。

なお、CoWは「同じ場所を何度も小さく上書きする」ワークロードと相性が悪く、DBのデータファイルやVMイメージを置くと断片化が進みます。BtrfsではそうしたファイルにCoWを無効化する属性(chattr +C)を付ける運用が定番です。CoW自体の考え方は仮想メモリとページングの仕組みで扱ったforkのコピーオンライトとまったく同じで、対象がページかディスクブロックかの違いしかありません。

アプリケーション開発者が知っておくべき実務

理論を踏まえて、実際に書くべきコードの話をします。

アトミックな書き込みの定石

設定ファイルやキャッシュファイルを更新するとき、既存ファイルを直接開いて上書きするのは最悪の選択です。クラッシュすれば新旧が混ざった壊れたファイルが残ります。正しい手順は次の4段階です。

Loading diagram...

鍵になるのがrename(2)の保証です。manページはこう明記しています。

If newpath already exists, it will be atomically replaced, so that there is no point at which another process attempting to access newpath will find it missing.

他のプロセスから見て、対象パスは常に「古い完全なファイル」か「新しい完全なファイル」のどちらかであり、途中の状態や不在の瞬間が観測されることはありません。ただし制約があります。

  • 同じファイルシステム内でしか動きません。manページは「oldpath and newpath are not on the same mounted filesystem」でEXDEVになるとし、「Linux permits a filesystem to be mounted at multiple points, but rename() does not work across different mount points, even if the same filesystem is mounted on both」と注記しています。一時ファイルを/tmpに作ると失敗し得るので、必ず差し替え先と同じディレクトリに作ります
  • アトミックなのは名前空間の操作であって、ディスクへの到達ではありません。renameが返ってもディレクトリエントリの変更はページキャッシュ上にしかない可能性があります

そして4段目が忘れられがちです。fsync(2)のmanページの警告を引きます。

Calling fsync() does not necessarily ensure that the entry in the directory containing the file has also reached disk. For that an explicit fsync() on a file descriptor for the directory is also needed.

ファイルの中身をfsyncしても、「そのファイルがこの名前で存在する」という事実はディレクトリ側のメタデータなので、ディレクトリを開いてfsyncする必要があります。実装するとこうなります。

atomic_write.c
#define _GNU_SOURCE
#include <fcntl.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
 
/* data を path へアトミックに書き込む */
int atomic_write(const char *dir, const char *path, const char *data, size_t len)
{
    char tmp[4096];
    snprintf(tmp, sizeof(tmp), "%s/.tmp-XXXXXX", dir);
 
    int fd = mkstemp(tmp);          /* 1. 同じディレクトリに一時ファイル */
    if (fd < 0) return -1;
 
    if (write(fd, data, len) != (ssize_t)len) goto fail;
    if (fsync(fd) < 0) goto fail;   /* 2. 中身をディスクまで届ける */
    if (close(fd) < 0) { unlink(tmp); return -1; }
 
    if (chmod(tmp, 0644) < 0) { unlink(tmp); return -1; }
    if (rename(tmp, path) < 0) { unlink(tmp); return -1; }  /* 3. アトミックに差し替え */
 
    int dfd = open(dir, O_RDONLY);  /* 4. ディレクトリ自体も同期 */
    if (dfd < 0) return -1;
    if (fsync(dfd) < 0) { close(dfd); return -1; }
    close(dfd);
    return 0;
 
fail:
    close(fd);
    unlink(tmp);
    return -1;
}
cc -o atomic_write atomic_write.c && ./atomic_write && cat config.json
実行結果 (macOS 26.5)
ok
committed

Pythonなら短く書けます。

atomic_write.py
import os
import tempfile
 
def atomic_write(path: str, data: bytes) -> None:
    directory = os.path.dirname(os.path.abspath(path))
    fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-")
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
            f.flush()
            os.fsync(f.fileno())     # 中身をディスクへ
        os.chmod(tmp, 0o644)
        os.replace(tmp, path)        # os.replace は rename(2) 相当でアトミック
    except BaseException:
        os.unlink(tmp)
        raise
    dfd = os.open(directory, os.O_RDONLY)
    try:
        os.fsync(dfd)                # ディレクトリエントリをディスクへ
    finally:
        os.close(dfd)

Pythonのos.renameはWindowsで既存ファイルがあると失敗するので、移植性が要るならos.replaceを使ってください。

WARNING

close(2)fsyncではありません。closeが成功しても、データがディスクへ届いた保証は一切ありません。またfsyncの戻り値を無視するコードもよく見かけますが、これはwriteの戻り値を無視するより危険です。Linux 4.13以降は「errors from write-back will be reported to all file descriptors that might have written the data which triggered the error」となり、エラーの取りこぼしは改善されましたが、fsyncEIOを返した場合、そのデータはもう回復できないと考えるべきです。

関連する周辺API

API用途備考
open(..., O_CREAT | O_EXCL)ロックファイルの作成既存ならEEXIST。アトミックな「作成できたら勝ち」
renameat2(..., RENAME_NOREPLACE)既存ファイルを上書きしないrenameLinux 3.15以降
renameat2(..., RENAME_EXCHANGE)2つのパスをアトミックに入れ替えLinux 3.15以降。A/Bデプロイのディレクトリ切り替えに便利
open(dir, O_TMPFILE)名前のない一時ファイルを作るLinux 3.11以降。linkat(2)で初めて名前が付く
O_APPEND追記manページは「O_APPEND may lead to corrupted files on NFS filesystems if more than one process appends data to a file at once」と警告

O_TMPFILEは面白い機能で、名前空間に一切現れない状態でファイルを作り、内容を書いてfsyncしてからlinkatで名前を与えられます。中途半端な一時ファイルが残る心配がありません。

DBがfsyncに依存する理由

SQLiteの公式ドキュメント「Atomic Commit In SQLite」は、ロールバックジャーナルの書き出しについてこう述べています。

The next step is to flush the content of the rollback journal file to nonvolatile storage. As we will see later, this is a critical step in insuring that the database can survive an unexpected power loss. This step also takes a lot of time, since writing to nonvolatile storage is normally a slow operation.

そしてディレクトリの同期についても、我々が上で書いたのとまったく同じことをしています。

SQLite tries to prevent this by opening and syncing the directory containing the rollback journal at the same time it syncs the journal file itself.

さらに、この前提が崩れる可能性にも率直です。

SQLite assumes that the flush or fsync will not return until all pending write operations for the file that is being flushed have completed. ... often the IDE disk control lies and says that data has reached oxide while it is still held only in the volatile control cache.

PostgreSQLの信頼性に関するドキュメントも同じ立場で、「Fortunately, all operating systems give applications a way to force writes from the buffer cache to disk, and PostgreSQL uses those features. (See the wal_sync_method parameter to adjust how this is done.)」として、wal_sync_methodで同期手段を選べるようにしています。macOSについては「On macOS, write caching can be prevented by setting wal_sync_method to fsync_writethrough」と特記されており、プラットフォームごとに「本当にディスクまで届く呼び出し」が違うことが分かります。

つまり、DBのDurability(耐久性)は最終的にファイルシステムとfsyncの正しさに乗っかっています。裏を返せば、自作のアプリケーションで「クラッシュしてもデータを失わない」を実現したいなら、DBがやっているのと同じ手順を踏む必要がある、ということです。バッファリングによる高速化とその落とし穴という構図はメモリアロケータ入門のtcacheの話とも重なります。

計測と観察 - どのコマンドで何が見えるか

最後に、調査で使う道具を整理します。

目的コマンド見るところ
inodeの枯渇確認df -iIUse%が100%に近いか
容量の確認df -h容量に余裕があるのに書けないならinodeか削除済みopenファイル
ファイルのメタデータstat FILE (macOSはstat -x)BlocksLinksInode、各タイムスタンプ
ファイルシステム全体の情報stat -f MOUNTPOINTブロックサイズ、総ブロック数、総inode数
ハードリンクの同定ls -lifind . -inum Ninode番号が一致するか
断片化の状況filefrag -v FILEエクステント数とexpected列のズレ
ext4の構成sudo tune2fs -l DEVsudo dumpe2fs -h DEVブロックサイズ、inodeサイズ、ジャーナルサイズ
実際のマウントオプションcat /proc/fs/ext4/DEV/optionsdata=orderedcommit=5barrierなど
dirtyページの量grep -E '^(Dirty|Writeback):' /proc/meminfo書き込み後にどれだけ溜まっているか
ページキャッシュの量free -hbuff/cacheavailable
ライトバック閾値sysctl -a | grep dirtyvm.dirty_ratioなど
デバイスのI/O状況iostat -x 1%utilawaitw_await
削除済みだが開かれたファイルlsof | grep deletedプロセスと保持サイズ

stat -fはファイルシステム自体の情報(statfs)を返します。

stat -f /tmp
実行結果 (Ubuntu / ext4)
  File: "/tmp"
    ID: 2699219d909fac23 Namelen: 255     Type: ext2/ext3
Block size: 4096       Fundamental block size: 4096
Blocks: Total: 51581505   Free: 16110935   Available: 13485460
Inodes: Total: 13107200   Free: 10873301

FreeAvailableが違うのは、rootのために予約されたブロック(既定で5%)があるためです。tune2fs -mで調整できます。

書き込みが遅いときの切り分けは、おおむね次の順序で進めると効率的です。

  1. iostat -x 1でデバイスが飽和しているか(%utilが100%付近か)を見る
  2. 飽和しているなら、/proc/meminfoDirtyvm.dirty_ratioに張り付いていないかを確認する(張り付いていればライトバックのストール)
  3. アプリ側でfsyncを過剰に呼んでいないか、strace -c -e trace=fsync,fdatasync,writeで回数を数える
  4. filefragで対象ファイルの断片化を確認する
  5. ファイル数が多いディレクトリならinode枯渇とdir_indexの有無を確認する

まとめ

ファイルシステムを一本の線で振り返ると、次のようになります。

  • ブロックデバイスは番号付きブロックの読み書きしか提供しない。名前もディレクトリもサイズも、すべてファイルシステムが作り出した抽象です
  • VFSが共通の入口を用意し、struct file(開いている状態)、dentry(名前とinodeの結びつき)、inode(実体)、super_block(FS全体)という4つのオブジェクトで世界を表現します
  • inodeにファイル名は入っていません。名前はディレクトリが持つ参照であり、だからハードリンク、rmしても消えないファイル、リンクカウントといった挙動が説明できます
  • データの所在は、ext2系の間接ブロックからext4のエクステントへ進化しました。4KiBブロックなら1エクステントで最大128MiB、inode内に4つまで直接収まります
  • write(2)が返しているのはページキャッシュに置いたという事実だけです。ディスクへ届くのはflusherスレッド、閾値超過、fsyncのいずれかのタイミング。既定ではvm.dirty_ratio = 20vm.dirty_background_ratio = 10、期限は30秒です
  • ジャーナリングは「fsckを速くする仕組み」ではなく、複数ブロック更新のアトミック性を作る仕組みです。ext4の既定data=orderedは、データを先に本体へ書いてからメタデータをコミットすることで、二重書き込みなしに「中身のないブロックを掴む」事故を防ぎます
  • CoW系はそもそも上書きせず、木の根の差し替えという1回のアトミック操作で整合性を得ます。スナップショットが安いのはその副産物です
  • アプリケーション側の定石は一時ファイル + fsync + rename + ディレクトリのfsyncrename(2)はアトミックですが同一ファイルシステム内に限られ、ディレクトリのfsyncは別途必要です

「名前はディレクトリのもの、実体はinode、速度はページキャッシュ、耐久性はfsync、整合性はジャーナル」。この5点を軸に置くと、ディスクフルの謎も、消したはずのファイルが容量を食い続ける現象も、DBが妙にfsyncにこだわる理由も、ばらばらの知識ではなく一つの仕組みの現れとして読めるようになります。

参考リンク