write() が成功してもディスクに書かれたとは限らない:短い書き込み、遅れて届くエラー、fsync

write() の成功はディスクへの記録を意味しません。短い書き込みの扱い、Linux 4.13 以降の writeback エラー報告、fsync と fdatasync の範囲、新規ファイルや rename の後にディレクトリの fsync が要る理由をまとめました。

開発OSLinux

設定ファイルや処理結果を保存するコードは、たいてい open()、write()、close() で終わります。write() がエラーなく戻れば保存は済んだと考えがちです。しかし Linux の write(2) マニュアルの NOTES には、write() が成功してもデータがディスクに記録されたことは保証されないと書かれています。

write() の成功と「電源が落ちても残っている」ことは別の段階です。この記事では、その間にあるものを順に見ていきます。何バイトが受け取られたのか、書き込みエラーはいつ報告されるのか、fsync() と fdatasync() は何を書き出すのか、そしてファイル名を持つディレクトリは別に扱う必要があるという点です。

Linux の挙動は Linux man-pages 6.19 の write(2)、fsync(2)、open(2)、rename(2) に、移植性のある要件は POSIX.1-2024 に基づいています。

戻り値は受け取られたバイト数

write(fd, buf, count) の戻り値は実際に書かれたバイト数で、count より小さいことがあります。POSIX.1-2024 が定めているのは、戻り値が実際に書かれたバイト数であり、count を超えないということだけです。

通常のファイルでも短い書き込みは起こります。Linux の write(2) に挙がっているのは次のような場合です。

  • 下にあるデバイスの空き容量が足りないとき
  • RLIMIT_FSIZE のファイルサイズ制限に達したとき
  • 一部を書いた後でシグナルハンドラによって呼び出しが中断されたとき
  • Linux で 1 回の write() が転送する上限である 0x7ffff000(2,147,479,552)バイトを超えて要求したとき。32 ビットでも 64 ビットでも同じです。

シグナルの扱いは POSIX と Linux のマニュアルで一致しています。1 バイトも書く前に中断されれば -1 と EINTR が返り、1 バイトでも書いた後に中断されれば、そこまでに書いたバイト数が成功として返ります。短い書き込みの後に残りを再び write() すると、さらに書けるか、ディスクが一杯になった場合のようにエラーになります。

そのため戻り値を 1 回見て終わりにはできません。残りが 0 になるまで繰り返します。

#include <errno.h>
#include <stddef.h>
#include <sys/types.h>
#include <unistd.h>

/* 短い書き込みと EINTR を処理しながら len バイトをすべて書く */
static int write_all(int fd, const char *buf, size_t len)
{
    while (len > 0) {
        ssize_t n = write(fd, buf, len);
        if (n < 0) {
            if (errno == EINTR)
                continue;      /* 何も書く前に中断された: 再試行 */
            return -1;
        }
        if (n == 0) {          /* 進まなければ失敗とみなす(防御的な処理) */
            errno = EIO;
            return -1;
        }
        buf += n;
        len -= (size_t)n;
    }
    return 0;
}

このループで保証されるのは「すべてのバイトをカーネルが受け取った」ところまでです。ディスクに届いたという意味ではありません。

書き込みエラーは後から届くことがある

write() が戻った後も、データはカーネルのメモリに残り、後でストレージに書き出されることがあります。fsync(2) はこれをファイルの変更済み in-core データ、つまり変更されたバッファキャッシュのページと呼んでいます。このメモリが free の出力でどこに表れるかは、free コマンドの buff/cache と available の記事で説明しています。

この後からの書き出し(writeback)でエラーが起きても、元の write() 呼び出しはすでに戻っているので、そこでは受け取れません。Linux write(2) の EIO の説明は、このケースを直接扱っています。

  • EIO は、以前の write() が書いたデータの writeback に関係するエラーであることがあり、その write() は同じファイルの別のファイルディスクリプタを通したものかもしれません。
  • Linux 4.13 以降、writeback のエラーは後続の write() で報告されることがあり、後続の fsync() では必ず報告されます。write() ですでに報告されたかどうかには関係ありません。

fsync(2) の EIO の説明も同じ方向です。Linux 4.13 以降、writeback のエラーは、そのデータを書いた可能性のあるすべてのファイルディスクリプタに報告されます。NFS のように、どのデータがどのディスクリプタから来たかを追跡するファイルシステムはより正確に報告し、多くのローカルファイルシステムは、エラーが記録された時点でそのファイルを開いていたすべてのディスクリプタに報告します。

容量不足も後から表れることがあります。write(2) によると、NFS などの一部のファイルシステムでは write() の成功が領域の確保すら保証せず、エラーが後続の write()、fsync()、さらには close() まで遅れることがあります。fsync(2) も、write() の時点で領域を割り当てないファイルシステムで以前の書き込みが容量不足で失敗していれば、fsync() が ENOSPC や EDQUOT を返すと説明しています。

そのため close() の戻り値も確認します。ただしこれは遅れて届いたエラーを見逃さないためであり、close() がデータをディスクに書かせるわけではありません。write(2) は、確かめる唯一の方法はすべてのデータを書いた後に fsync() を呼ぶことだとしています。

fsync と fdatasync が書き出すもの

Linux fsync(2) によると、fsync() はファイルの変更済みデータをディスク(または他の永続ストレージ)に転送し、ディスクキャッシュがあればそれを書き抜けるかフラッシュします。そしてデバイスが転送完了を報告するまでブロックします。データと一緒に、サイズやタイムスタンプなどのファイルのメタデータも書き出します。

fdatasync() は、後でデータを正しく読むのに必要のないメタデータは書き出しません。マニュアルの例では、アクセス時刻(st_atime)や更新時刻(st_mtime)だけの変更はフラッシュ不要で、ファイルサイズ(st_size)の変更にはメタデータのフラッシュが必要です。目的は、すべてのメタデータの同期を必要としないプログラムのディスク操作を減らすことです。ファイル末尾への追記はサイズを変えるので、その場合は fdatasync() でもサイズは書き出されます。

write() のたびに同じ効果を得る方法もあります。open(2) の O_DSYNC を付けると各 write() の後に fdatasync() を呼んだように、O_SYNC なら fsync() を呼んだように動きます。O_DIRECT はキャッシュの影響を減らそうとするだけで、単独では O_SYNC が与えるデータとメタデータの転送保証はありません。

fsync() が失敗したら、保存は失敗したものとして扱います。POSIX.1-2024 は、fsync() が失敗した場合、処理中だった I/O の完了は保証されないと定めています。その後に最初から書き直すか、ユーザーに知らせるかはプログラム側で決めることです。

ファイルを fsync しても名前は別に残る

ここは見落とされやすいところです。ファイル名はファイルの中ではなく、そのファイルを含むディレクトリのエントリです。名前とファイル本体が別々に動く仕組みは、unlink と開いたままのファイルの記事で扱いました。

Linux fsync(2) には、fsync() を呼んでも、そのファイルを含むディレクトリのエントリまでディスクに届いたとは限らず、そのためにはディレクトリを指すファイルディスクリプタに対して明示的に fsync() を呼ぶ必要があると書かれています。

新しく作ったファイルなら、内容は fsync() で書き出されても、その名前がディレクトリに記録されたかどうかは別の話です。rename() で名前を変えた場合もディレクトリが変わるので、同じ問題が残ります。

POSIX.1-2024 の fsync() の APPLICATION USAGE も、ディレクトリにファイルを作るなどしてディレクトリを変更したアプリケーションは、そのディレクトリに fsync() を呼んでエントリを同期できると説明しています。同じ段落には、ほとんどのアプリケーションではディレクトリエントリの同期は必要ないはずだとも書かれています。すべてのファイル操作に付ける決まりごとというより、変わった名前まで障害後に残す必要がある場合に使う手段と考えるのが妥当です。

rename の原子性と永続性は別の性質

ファイルを安全に置き換えるよくある方法は、一時ファイルに新しい内容を書き、rename() で元の名前に上書きすることです。Linux rename(2) は、newpath がすでに存在すれば原子的に置き換えられ、別のプロセスが newpath にアクセスしたときにそれが存在しない瞬間はないと説明しています。操作が失敗しても newpath のインスタンスが 1 つは残ることも保証しています。

この約束は、動いている別のプロセスから見える状態についてのものです。データをストレージに書き出す要求ではなく、システムが突然止まったときにどちらが残るかについても rename(2) は何も述べていません。原子性は「途中の状態が見えない」という性質で、永続性は「障害の後も残る」という性質です。両方が欲しければ、それぞれを別に確保する必要があります。

一時ファイルを open
  → write_all          すべてのバイトをカーネルが受け取る
  → fsync(一時ファイル)  内容とメタデータを書き出し、エラーを確認
  → close              遅れて届いたエラーを確認
  → rename             他のプロセスからは一度に切り替わって見える
  → fsync(ディレクトリ)  変わった名前を書き出す

順序が大切です。rename() の前に一時ファイルを fsync() しておかないと、新しい名前が、内容がまだストレージに届いていないファイルを指すことになりえます。前に見たとおり write() の成功はディスクへの書き込みを保証せず、rename() はデータを書き出す呼び出しではないからです。

この流れを実際のコードにするとき、いちばん注意が要るのは最初の一時ファイルを開く段階です。save.tmp のような決まった名前を O_CREAT | O_TRUNC で開くと、そのパスにすでにあったファイルを空にしたり、そこに置かれたシンボリックリンクをたどってまったく別のファイルを上書きしたりしかねません。同じ名前を使う別のプロセスとぶつかることもあります。

そのため一時ファイルは、対象ファイルと同じディレクトリに、推測できない名前で、必ず新しいファイルとして作ります。mkstemp() を使う方法や、ディレクトリを開いてから openat() に O_CREAT | O_EXCL を指定する方法を慎重に設計するのがその例です。Linux open(2) によると、O_CREAT と O_EXCL を一緒に指定すると、その呼び出しはファイルを新しく作った場合にだけ成功し、パスがシンボリックリンクならリンク先がどこであっても失敗します。同じディレクトリに置くのにも理由があります。rename(2) は、2 つのパスが同じマウントされたファイルシステム上にないと EXDEV で失敗し、同じファイルシステムでもマウントポイントが違えば動かないと書いています。

エラー処理は上の順序に沿います。rename() を呼ぶ前の段階で失敗したなら、一時パスはまだプログラムのものなので、一時ファイルを消して保存の失敗を返します。rename() 自体がエラーを返した場合は、そう単純ではありません。Linux rename(2) の BUGS 節は、NFS では呼び出しが失敗しても名前が変わっていないとは仮定できないと警告しています。サーバーが rename を済ませた後にクラッシュすると、復旧後に処理される再送要求が失敗を返すからです。この状況はアプリケーションが対処すべきものとされています。ですから、何も起きなかったとみなして一時パスをむやみに消すのではなく、結果を不明な状態として扱い、プログラム自身の復旧設計に沿ってどの名前に何があるかを確かめてから片付けます。fsync() が失敗したときも、その場で呼び直すより失敗として扱うほうが前の節の説明に合います。rename() の後でディレクトリの fsync() が失敗した場合、他のプロセスにはすでに新しい内容が見えていますが、名前の永続性は確認できていません。このときも呼び出し側に失敗を伝え、「保存完了」と表示しないようにします。

この記事が約束しないこと

ここまでがシステムコールの文書が約束する範囲です。実際の障害の後に何が残るかは、その下の層にも左右されます。

  • POSIX.1-2024 は fsync() の転送の性質を実装に委ねています。RATIONALE には、_POSIX_SYNCHRONIZED_IO が定義されていないシステムでは何もしない実装も認められると書かれています。
  • Linux fsync(2) は、古いカーネルやあまり使われないファイルシステムの fsync() はディスクキャッシュをフラッシュできないことがあり、その場合は安全のためにディスクキャッシュを無効にする必要があると説明しています。
  • ファイルシステムの種類、マウントオプション、ストレージのファームウェア、ネットワークファイルシステムによって、障害後の状態は変わりえます。この記事では組み合わせごとの結果は扱いません。
  • データベースのように複数のファイルの更新を 1 つにまとめる必要がある場合は、この手順だけでは足りず、別の設計が必要です。

write() の戻り値はカーネルが受け取った量であり、Linux 4.13 以降 writeback のエラーは後続の fsync() で必ず報告され、新しい名前はディレクトリの fsync() で別に書き出す必要があります。

ブログへ