ファイル書き込みをするプログラムで気をつけた方がよいこと
2022年07月06日 水曜日
CONTENTS
この記事について
この記事では、ファイルに書き込みを行うプログラムを実装する時の注意点について説明します。
ファイル書き込みは、プログラミングにおいて比較的よく利用される機能でありながら、実装時に注意していないと、システムクラッシュ(意図しない電源の喪失や OS のクラッシュ等)後にファイル上のデータが整合性を失う可能性、平たく言えば、データが破損する場合があります。
今回の主な内容はトランザクションに関連する事柄で、ご存知の方からすると当たり前と思われることだと思われますが、執筆者がプログラミングの勉強を始めて以降知らない期間が長かったことと、他にもご存知ない方がある程度いらっしゃるのではないかと思ったため、このように記事にさせていただきました。
また、ここで説明する注意点は、クラッシュ後にデータの整合性が重要でない場合は、気を付ける必要がないものであることを先に書いておきます。
先にこの記事の結論
当記事の結論は以下の2点です。
- データの整合性を損なうことなくファイルへ書き込みを行うためには、一例ですが、以下の「安全なファイル書き込み方法」の項にあるようにする必要があります。
- 自分でファイル書き込みを行う箇所を実装するのが難しいと思われる場合には、既存のデータベースプログラムを利用する方が良いかもしれません。
参考資料
この記事は、以下の論文を参考にしています。
- Yige Hu, Zhiting Zhu, Ian Neal, Youngjin Kwon, Tianyu Cheng, Vijay Chidambaram, Emmett Witchel, “TxFS: Leveraging File-System Crash Consistency to Provide ACID Transactions”, 2018 USENIX Annual Technical Conference (USENIX ATC 18).
- Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, Samer Al-Kiswany, Andrea C. Arpaci-Dusseau, Remzi H. Arpaci-Dusseau, “All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications”, 11th USENIX Symposium on Operating Systems Design and Implementation (OSDI 14).
具体的には、上記の参考資料番号1の論文の 2.1 章(Figure 1)で、安全なファイル更新方法について説明しており、今回の記事の「安全なファイル書き込み方法」の項では、そちらを参照します。(参照するのは、論文が新規に提案している手法ではなく、論文が一般的な方法として紹介している書き込み方法です。)
参考資料番号2の論文には、システムクラッシュ後にファイルがどのような状態になる可能性があるかということについて、特に 2.2 章(Table 1)に記述されており、当記事の「なぜクラッシュにより、ファイル上のデータの整合性が失われるか?」という項で参照します。また、こちらの論文には、当記事では触れていないクラッシュ後の状態についても詳述されておりますので、よろしければそちらもご参照ください。
導入:特に注意しない場合
まず、特に注意しないでファイル書き込みを行うプログラムを実装する場合の例から見ていきます。
open(/dir/orig) // ファイルを開いて write(/dir/orig) // データを書き込み fsync(/dir/orig) // データがディスクへ書き込まれたことを保証する
open, write, fsync は UNIX 系 OS のシステムコールで、C 言語を模しています。
基本的に write の完了は、ファイルへ書き込みたいデータが OS へ引き渡されたことを確認し、fsync の完了は、その OS へ引き渡されたデータが、ディスクまで書き込まれたことを保証するものです。
今回は C 言語を模したコードになっていますが、プログラミング言語に関わらず、概ね上のような実装になるのではないかと思われます。
問題:ファイル上のデータの整合性
今回の問題は、上の実装では不十分で、実行中にクラッシュが発生した場合、/dir/orig 上の「データの整合性」が失われる可能性がある、ということです。
ファイル上のデータの整合性が失われるとは?
先へ進む前に、ここで言う「データの整合性」について整理しておきます。
整合性の説明の際に、よく取り上げられる銀行口座の例で考えてみます。
以下のような、銀行口座を管理するプログラムを検討します。
- 1000 人分の口座の残高を管理する。
- 残高のデータはファイルへ保存する。
- 簡単のため、一人最大 255 円まで預けられることとする。(unsigned char の最大)
- unsigned char balance[1000] をファイルへ保存することで、1000 人分の口座残高をディスクへ保存する。
- 後述しますが、1 セクター 512 バイトの時に、2 セクターにまたがるデータである点がポイントです。
これらの前提の上で、特にファイル書き込みの安全性に注意せずに、銀行口座番号 300 の人から銀行口座番号 800 の人へ 10 円送金する場合の実装と操作は、以下のようになると思われます。
unsigned char balance[1000]; open(/dir/orig) read(/dir/orig, balance, 1000byte) // 最新の状態をファイルからメモリ上(balance)へ読み込み balance[300] -= 10; // アカウント番号 300 の人の残高を 10 円減らす balance[800] += 10; // アカウント番号 800 の人の残高を 10 円増やす write(/dir/orig, balance, 1000byte) // ファイルへ更新内容(更新後の balance)を保存 fsync(/dir/orig) // fsync で永続性を保証
今回、問題としているのは、上のような実装の場合、システムクラッシュ後のファイル上のデータの状態が、
- 状態1: balance[300] から 10 減っているが、balance[800] はプログラム実行前と変更なし
- 状態2: balance[300] はプログラム実行前と変更なし、balance[800] は 10 加算されている
になる可能性があることです。
言い換えると、
- 状態1:口座番号 300 番の人の 10 円が消失した
- 状態2:口座番号 800 番の人がこの世に存在しなかった 10 円を受け取とった
ことになる可能性がある、ということです。
今回は、このような事態を「データの整合性が失われる」と表現しています。
不可分性(Atomicity)
上の銀行の例の場合、理想的には、システムクラッシュ後のファイル上のデータの状態が、
- 状態3: balance[300] はプログラム実行前と変更なし、かつ、balance[800] はプログラム実行前と変更なし
- 状態4: balance[300] から 10 減っており、balance[800] は 10 加算されている
のどちらかであるべきだと思われます。
簡単には、
- 状態3:口座番号 300 番、800 番の人の残高両方に変化がない
- 状態4:口座番号 300 番、800 番の人の残高両方適切に増減した
の2通りの状態のみになることを保証したいと言い換えられると思います。
このような、保証を用語としては、不可分性(Atomicity)と呼ぶそうです。
* Wikipedia「ACID (コンピュータ科学)」参照
なぜクラッシュにより、ファイル上のデータの整合性が失われるか?
“All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications” (参考資料番号2)という論文によれば、同時にディスク上の複数セクターにまたがる書き込みをリクエストし、完了が確認される前にシステムがクラッシュした場合、一部のディスク上のセクターのみ書き込みが行われる場合があるようです。
結果として、上の銀行のプログラムの例では、unsigned char balance[1000] は 1000 バイトで、1 セクターが 512 バイトの場合、2 セクターにまたがることから、
- 状態1:1 セクター目に書き込み完了、2 セクター目は書き込まれず(balance[300] のみ更新)
- 状態2:1 セクター目に書き込まれず、2 セクター目は書き込み完了(balance[800] のみ更新)
となった場合に、不整合が発生すると予想されます。
これを避けるためには、ファイル書き込み中のクラッシュ後には、必ず、
- 状態3:1、2 セクター目両方に書き込まれない
- 状態4:1、2 セクター目両方に書き込まれた
のどちらかになるように注意してプログラムを書く必要があります。
この問題の厄介な点
まず、ファイルのチェックサム等をどこかに保存していないと、クラッシュ後に整合性が失われたのか確認することもできません。
さらに、特に注意していないと、どこの書き込みが成功して、どこが書き込まれなかったかがわからないため、クラッシュ一回でファイル全体のデータが信頼できなくなると思われます。
なので、ファイルの内容が間違っていない、ということが重要な場合には、この問題への対策を講じておく必要があると思われます。
安全なファイル書き込み方法
ここからは、”TxFS: Leveraging File-System Crash Consistency to Provide ACID Transactions”(参考資料番号1)の論文に示されているデータの整合性を失わない書き込み方法について見ていきます。
論文の 2.1 章 Figure 1 には以下の2通りの方法(Atomic rename と Logging) が示されています。単一のファイルへの書き込みは失敗する可能性があるものと捉え、クラッシュ後の復旧が可能なように、安全に書き込みを行いたいファイル(以下のプログラム例では /dir/orig)の更新は、別の箇所(以下の例ではそれぞれ /dir/tmp と /dir/log)へデータを書いた後に行う、というのが基本的な戦略と思われます。
注意:以下の説明では、fsync が呼び出される前でも、open、write、rename、unlink によって加えられた変更が非同期でディスクへ反映される可能性についても考慮しています。
方法1: Atomic rename
// open(/dir/tmp) // まず /dir/tmp というファイルを作成し write(/dir/tmp) // 更新したい内容全部を書き出す fsync(/dir/tmp) // その後、fsync を /dir/tmp に対して実行 fsync(/dir) // その後、fsync を /dir へ実行(/dir/tmp というエントリの永続性の保証) rename(/dir/tmp, /dir/orig) // /dir/tmp を /dir/orig へリネーム fsync(/dir/) // エントリの変更の永続性を保証
この方法では、rename が既に存在するファイルを置き換える場合の操作は、不可分 (Atomic) であるという特性を利用しているそうです。
この方法で書き込みを実行中に、仮にシステムクラッシュが発生した場合には、
- 再起動後に /dir/tmp が見つかれば、(処理の停止位置:2 行目 ~ 6 行目完了前のどこか)
- /dir/tmp から /dir/orig への rename が完了する前に処理が止まったことになり、/dir/orig 自体には変更が加えらえれていないため /dir/orig 上のデータの整合性には問題ない
- この場合 /dir/tmp への書き込みは失敗している可能性があるが、復旧処理としては /dir/tmp を破棄するだけでよい
- /dir/tmp から /dir/orig への rename が完了する前に処理が止まったことになり、/dir/orig 自体には変更が加えらえれていないため /dir/orig 上のデータの整合性には問題ない
- 再起動後に /dir/tmp がなければ、(処理の停止位置:1 行目以前 ~ 5 行目完了前もしくは 6 行目完了後)
- 1 行目以前 ~ 5 行目完了前:/dir/tmp のエントリがディスクへ保存される前(2 ~ 5 行目)、もしくは上の処理が開始する前(1 行目以前)に停止しており、この場合は /dir/orig を更新する rename(6 行目)まで辿り着いていない確証があることから /dir/orig の整合性には問題がない
- 6 行目完了後:/dir/tmp の書き込みの成功(つまり、/dir/tmp 上のデータに整合性の問題がないこと)が保証された(4 行目の完了)後に実行された rename によって、/dir/tmp から /dir/orig への置き換えが完了しているため、/dir/orig に含まれるデータの整合性に問題はない
ということになりそうです。
方法2: Logging
// open(/dir/log) // log ファイルを作成 write(/dir/log) // 更新内容を書き出し fsync(/dir/log) // 更新内容を fsync で永続性を保証 fsync(/dir/) // /dir/log のエントリの永続性を保証 write(/dir/orig) // /dir/orig へ更新内容を書き出し fsync(/dir/orig) // /dir/orig の更新内容の永続性を保証 unlink(/dir/log) // log ファイルを削除 fsync(/dir/) // log ファイルが削除されたことを保証
* この Logging の例では、open(/dir/orig) は既に 1 行目以前で完了しているものとして考えます。
もう一方の方法では、仮にシステムクラッシュが発生した場合には、
- 再起動後に /dir/log が見つかれば、(処理の停止位置:2 行目 ~ 9 行目完了前のどこか)
- /dir/log のデータが破損していれば、/dir/log を破棄する(/dir/log の破損を検知できるチェックサム等を保存しておく必要があると思われます)
- /dir/log への書き込み途中(2 ~ 4 行目)で停止しているため、/dir/orig への上書き(6 行目)が開始していない確証があり、/dir/orig に整合性の問題はない
- /dir/log のデータに問題がなければ、/dir/log のデータを /dir/orig へコピーする
- この場合、/dir/orig への書き込み(6 行目)は開始した状態で停止し、再起動直後には /dir/orig 上のデータの整合性が失われている可能性があるが、この時点で(4 行目の完了によって)破損していない確証がある /dir/log のデータで /dir/orig のデータを上書きすることで整合性を回復できる
- /dir/log のデータが破損していれば、/dir/log を破棄する(/dir/log の破損を検知できるチェックサム等を保存しておく必要があると思われます)
- 再起動後に /dir/log が見つからなければ、(処理の停止位置:1 行目以前 ~ 5 行目完了前まで、もしくは 8 行目以降)
- 1 行目以前 ~ 5 行目完了前:/dir/orig への書き込み(6 行目)が開始していないことが確かなため、/dir/orig の整合性に問題はない
- 8 行目以降:/dir/orig への適切な書き込み(7 行目)の完了が保証された後に実行される unlink(/dir/log) によって /dir/log が消されているため、/dir/orig の整合性に問題はない
ということになりそうです。
既存のデータベースプログラムの利用
上のような、復旧時の操作を含めた、ファイル書き込みを行うプログラムを自分で実装するのは大変、かつ、実装が間違っていないことを確認するのも多大な労力が必要と思われます。
解決策として、クラッシュ後のファイル上のデータの整合性を担保可能なように設計・実装されており、試験もされている、既存のデータベースプログラムを利用するのは一つの方法かと思います。
データベースプログラム利用時の注意点
一方で、設定によっては安全な更新が保証されていない場合があるので、こちらも注意が必要と思われます。
また、
- 書いたと思ったデータが書き込まれていない可能性を排除できる
- データベースファイルの整合性を保証できる
の2点は別の設定で、書いたと思ったデータが書き込まれていないことはあっても、データベースの整合性は担保される、というような設定もあり得ます。
特に、書いたと思ったデータが書き込まれていない可能性の排除には fsync を書き込みリクエストごとに発行する必要があり、性能の大幅な劣化が想定され、デフォルトの設定でこちらを保証しないものもあります。
- 例えば、RocksDB では、デフォルトで、安全性よりも速度重視の設定となっており、電源喪失時に、新しく書き込まれたはずのデータが失われる可能性があるようです。
- また、若干用途が異なりそうですが、Redis では、append-only file という比較的よく使われていそうな保存形式を適用した場合、デフォルトでは、1 秒おきに非同期で fsync を呼び出すようなので、こちらも直近1秒間に保存されたデータがなくなる可能性があると思われます。
また、ファイルシステムや、その設定によっても安全性が保証されないことがありそうです。
- 例えば、SQLite ではファイルシステムのロックにバグがあるとデータベースファイルが壊れるそうです。また、NFS だとロックが実装されていないため、データベースが破損するそうです。
- また、SQLite は ext3 のマウントオプションによってはデータベースファイルが破損するそうです。
このようなことから、単純にデータベースプログラムを使ったから安全である、というわけではない点にも注意した方がよさそうです。
逆に、クラッシュ後の整合性が重要でない場合や即時の書き込みの保証が不要な場合は、それらを保証しない設定をデータベースプログラムへ適用すると、性能の向上が期待できる場合があると思われます。
まとめ
- 頻繁に起こることではないと思われますが、ファイル書き込みはクラッシュ時に不完全なまま完了してしまうことがあるため、クラッシュ後のデータの整合性が重要な場合には、書き込み失敗を想定して、一例ですが、上の「安全なファイル書き込み方法」のようにリカバリ可能にしておく必要があると思われます。
- 自分で実装するのは大変そうなので、クラッシュ後のデータの整合性が重要な場合は、既存のデータベースプログラムを利用すると良いかもしれません。
- 既存のデータベースプログラムも設定によっては安全とは言い難いので、ファイルシステムを含め、設定を確認しておくほうが良いと思われます。