💾

Goで安全に既存ファイルを更新したり再生成する方法

に公開

Goで既存のファイルを安全に更新したい場合は、直接そのファイルを書き換えるのではなく、一時ファイルに書き出してから最後にrenameで置き換えるのが基本になります。ここではLinuxを前提に、なぜ直接上書きしてはいけないのか、os.CreateTempで/tmpにファイルを作ると何が起きるのか、systemdのPrivateTmpが有効なときに気をつけること、という3点に絞って整理します。

なぜ直接上書きしないか

キャッシュ用のファイルを書き出すとか、nkf --overwriteみたいに既存ファイルを書き換えるCLIを作るときに、元のファイルをそのまま開いて上書きするのはやめたほうがいいです。理由は3つです。

  1. 途中で落ちると終わり
    書き込み途中でエラーになったりプロセスが死んだりすると、元の内容が消えた状態で残ります。変更が小さくても全損します。なのでrenameは完全に最後にやる必要があります。

  2. 他プロセスと競合する
    別のプロセスが同じファイルを参照している可能性があります。直接書き換えると、途中状態を読まれることがあります。別ファイルに全部書いておいて、完成したらrenameすれば「存在しないタイミング」は作らずに済みます。Linuxのrenameは同一ファイルシステム内ならatomicに置き換わるので、途中状態は基本見えません。

  3. 同時実行時の結果が読めなくなる
    2つ以上のプロセスが同時に「上書き」しようとすると、どちらの内容かわからない中途半端なファイルになることがあります。完成してからrenameする形にしておけば、単純に「最後に書いたほうが勝つ」になります。

なので「別名で全部書き切る→rename」が基本パターンになります。

os.CreateTempの使い方でハマるところ

Goで一時ファイルを作るときは以下のコードがよく使われます。

f, err := os.CreateTemp("", "tmp-*")

第一引数を空文字にするとos.TempDir()の下に作られます。Linuxだと多くの場合/tmpです。ここで問題になるのが「最終的に置きたいファイルが存在するディレクトリと/tmpはパーティションが違うかもしれない」ことです。

/tmpがtmpfsだったり、アプリの実際のデータが/var/lib/...や/home/...にあったりすると、/tmp→本番ファイルへのrenameが失敗します。エラーはこれです。

invalid cross-device link

Linuxのrenameは同じファイルシステム内じゃないとできません。つまり「どのパーティションに置きたいか」が最初から決まっているなら、同じディレクトリで一時ファイルを作る必要があります。

一時ファイルは最終配置先のディレクトリで作る

なのでこう書きます。

dst := "/var/lib/myapp/cache.json"

dir := filepath.Dir(dst)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
  return err
}
defer os.Remove(tmp.Name()) // 失敗したときに掃除する用

こうしておけば、tmpとdstは同じディレクトリ=同じファイルシステム上にいるので、最後にos.Renameで確実に置き換えられます。

if err := tmp.Close(); err != nil {
  return err
}

if err := os.Rename(tmp.Name(), dst); err != nil {
  return err
}

ポイントは「一時ファイルに書き切ってからCloseして、それからrenameする」です。

ちなみにファイルのパーミッションは0600になるので、変えたい場合は明示的に変更する必要があります。

systemdのPrivateTmpの落とし穴

これはGoというよりLinux/systemdの話です。systemdでPrivateTmpが有効になっていると、プロセスからは/tmpに見えているけど実は他プロセスとは別のディレクトリを見ている、という状態になります。

何が困るかというと、

  • プロセスAが/tmpに一時ファイルを作る
  • プロセスBが「/tmpにあるはず」と思って探しに行く
  • でも見えない

ということが起きます。つまり「他のプロセスからも見えるはずの一時ファイル」を/tmpに置く、という設計が通らなくなります。そういう用途なら、

  • PrivateTmpを無効にする
    • /tmpの自動削除設定を活かしたいならこの方法
  • 共通で見えるディレクトリを作ってそこに書く
    • ただしディレクトリが存在しないと失敗するので注意

のどれかを選ぶ必要があります。

今回のように「最終的に配置するディレクトリに最初から一時ファイルを作る」やり方なら、この問題は最初から踏みません。

同時実行を考える

同時に複数のプロセスがファイルを書き換えようとしたときの挙動も考える必要があります。「最後に動いたやつが勝てばいい」前提なら、ここまでのやり方で問題ありません。CreateTempはファイル名を被らないようにしてくれるので、同じディレクトリでも衝突しません。

もっと厳密に「古い内容で上書きされたくない」「自分が見た世代だけを書きたい」という要件がある場合は、ロックファイルやflockを別で考える必要があります。

まとめ

  • 元ファイルを直接書き換えず、必ず別ファイルに全部書いてからrenameする
  • renameを安全かつatomicにしたいなら、最終的に置くディレクトリ内で一時ファイルを作る
    • パーティションが違うとinvalid cross-device linkでrenameに失敗する
  • systemdのPrivateTmpが有効な場合は/tmpを共有できないので、無効にするか共通ディレクトリを使う

Discussion