言いたいこと
重要なファイルを書くときは、予期しないOSシャットダウンなどを考慮した書き方にする必要があるというお話。
お作法を知らないと、中途半端なファイルや空ファイルが生成され、システム起動時や連携システムで致命的なことになる。
例としてC言語/Java/Python/JavaScript(node.js)を挙げるが、ほぼすべての言語で対策する必要あり。
背景
本番運用されているソフトウェアが起動しなくなるという致命的な不具合が発生した。
ログやコンフィグファイルを収集・解析したところ、コンフィグファイルがぶっ壊れていた。
コンフィグファイルは起動時に読まれるが、必要に応じて書き込まれることもある。
コードを追っていくと、書き込み処理中に強制終了したりすると、中途半端に書かれる可能性があることに気づく。
使い終わると電源がぶち切りされる運用をされており、奇跡的にタイミングが重なったのかもしれない。
まずやってみたこと
コンフィグファイルに直で書いちゃ、わずかな時間とはいえ中途半端な状態(例えば10文字書きたいとき、まだ1文字しかかけてない状態)ができるからだめじゃん。
config.yml.tmpにいったん書きこみ完了させて、その後config.ymlにリネームすれば全部が更新されるか、されないかアトミックにできる!
結果1
満を持してリリースするが、また起動しなくなる不具合が発生。
今度はコンフィグファイルが0KBになっている。
tmpファイル書き込みに失敗したらリネームしないから0KBになることは絶対フロー上はありえないはずなのに。
flushも明示的にしてるし、closeもきちんとしている。
お手上げ状態で、手元にあるオライリー本 Linuxシステムプログラミングをなんとなくパラパラとめくっていると、とある項目を発見。
- fsyncとfdatasync
なになに・・?ダーティバッファをディスクに書き出します・・?
ここでようやく誤りと解決法に気づく。そういや知識としては学んだのに出てこなかった。
ダーティバッファ
ファイルは書き込み処理をプログラムしてもすぐには書き込まれずダーティバッファという形式で一旦保存される。
ディスク書き出し処理は極めて遅い処理のため、いちいち書き出していたらプログラムがめちゃくちゃ(100倍以上とかそのレベルで)遅くなる。
それを回避するため、最近のファイルシステムで必ず取り入れられている手法である。
この手法では、プロセスはOSに遅い書き込み処理を委託し、自身は先に進むことができる。OSがディスクにいつ書き込むかはOSがやり取りするファイルシステムに依存する。
場合によっては10秒以上かかることもあり、ファイルを壊すには十分すぎるほどのリードタイムがある。
この状態で予期しない電源断などでOSが落ちたりすると、プログラムフロー上はflush、closeしていたとしても、ぶっ壊れたファイルや空ファイルが出来上がる。
つまり、config.yml.tmp自体が中途半端だったから、リネームでconfig.ymlにしても半端だったというわけ。
fsync()を呼ぶと、ダーティバッファがディスクに即時書き込まれ、それが完了してから次の処理に進める。
fsync()とfdatasync()
Linuxではファイルは以下の二種類のデータで構成される。
- inodeと呼ばれるメタデータ
- ファイルそのもののデータ
inodeはファイルの更新日時とか、ディレクトリで管理する際に表示されるデータのこと。
fsync()では両方書き出して、fdatasync()ではファイルそのもののデータのみ書き出す。
あまり更新されないファイルであったり、性能を気にしなくて良い場面ならfsync()でOK。
inode(つまり最終更新時刻とかのメタデータ)は最悪更新されなくてもいい場合や、頻繁に更新されて性能が気になる場合はfdatasync()を使う。
対策
重要ファイル生成時は、ディスクまで強制的に書き出せば予期しないシャットダウンなどがあっても安心。
なお、Linuxでは正規の手順を踏んだシャットダウンでは問題にならない。
いくつかコードの具体例を挙げる。エラー処理は一部省略。
int main() {
const char* tmp_file_path = "./fsync_test.txt";
FILE* fp= fopen(tmp_file_path , "w");
int fd = fileno(fp);
fputs("fsync() test\n", fp);
fflush(fp);
// ここがポイント!!
int fsync_ret = fsync(fd);
fclose(fp);
return fsync_ret;
}
import java.io.File;
import java.io.FileOutputStream;
public class FsyncTest {
public static void main(String[] args) throws Exception {
File file = new File("./fsync.txt");
try (FileOutputStream output = new FileOutputStream(file);) {
output.write("Fsync() test\n".getBytes("UTF-8"));
// ここがポイント!!
output.getFD().sync();
}
}
}
import os
with open('./fsync_test.txt', 'w') as f:
f.write('fsync() test')
f.flush() # これだけだとダーティバッファ
# ここがポイント!!
os.fsync(f.fileno())
const http = require('http');
const server = http.createServer((request, response) => {
const fs = require('fs');
fs.open('./fsync_test.txt', 'w', (err, fd) => {
fs.write(fd, 'fsync() test\n', () => {
// ここがポイント!!
fs.fsyncSync(fd);
response.writeHead(200, {'Content-Type': 'text/plain'})
response.end('Write success\n');
fs.close(fd, ()=>{});
})
})
})
server.listen(9000);
結果2
ファイルは無事壊れなくなった。
ちなみに、ダーティバッファとなっている時間は結構長い。ファイルシステムによっては30秒近くとか。
対策前の話だが、手元のWindows 7では、ファイルを書き込んで、テキストエディタで開いて内容を書き込まれているのを確認し、15秒後に電源を抜いてみたら、そのファイルが起動後にぶっ壊れていた。
CentOS6環境で試してもほぼ同様の結果だった。
対策後は書き込み直後でも壊れなくなった。
結論
言語に限らず、重要なファイル生成時にはfsync()もしくは同等の処理は必須。
ただし、極めて遅い同期的なディスク書き出しを強制することになるので、条件によっては性能に深刻な影響が出る。
安全だからと闇雲に行うのはNG。
補足
関連する課題
コメントに、本記事と関連度の高いリンクを張っていただけました。
firefoxの課題
これは、fsync()が遅いことに起因する問題ですね。
fdatasync()を使うことでinodeが更新されない分だけ早くなるという記事です。
PostgreSQLの課題
https://masahikosawada.github.io/2019/02/17/PostgreSQL-fsync-issue/
fsync()が失敗した場合、再度fsync()を呼んでもダメだという問題。
PostgreSQLではfsync()に失敗したらデータベースをクラッシュさせて、トランザクションログ(WAL)から
復旧させる修正をしたらしい。
そもそも失敗なんてするのかと思ったが、SAN、NFSでは簡単に発生するらしい。
一般のシステムで書くなら、write()をするデータを保持しておいて、もう一度write()からやり直せばいいかな。
Windowsでの対応
fsync()はLinuxのライブラリ関数のため、Windowsでは利用できない。
Windowsでは下記のAPIを使うことで実現できる。
BOOL FlushFileBuffers(HANDLE hFile);
また、Windows7ではOS全体の設定でも実現可能。
[1]コントロールパネルを開く
[2]デバイスマネージャを開く
[3]ディスクドライブからディスクを選択、プロパティを開く
[4]ポリシータブの、「デバイスの書き込みキャッシュを有効にする」のチェックを外す
この手法を取るとアプリだけでなくすべての動作が遅くなるので注意。