設定ファイルへの書き込み中に、電源が落ちたら何が起きるか。あるいはWindowsのウイルス対策ソフトが、書き込み中のファイルを一瞬ロックしたら。素朴に open(path, 'w') で直接上書きしていると、書き込みが中断された瞬間のファイルがそのまま残る。JSONであれば構文が壊れ、次の起動時に json.load() が例外を投げる——設定がまるごと消えるという事故につながる。この記事では、この問題を「一時ファイル + アトミックなリネーム」という手法で防ぐ仕組みを整理する。
補足: 「アトミック(atomic)」とは、ここでは「操作が全部成功するか、まったく実行されなかったかのどちらかであり、中途半端な状態が外部から観測されない」という性質を指す。データベースのトランザクションで使われるのと同じ意味である。
なぜ直接上書きが危険か
open(path, 'w') は、内部的にはファイルを一旦空にしてから新しい内容を書き込んでいく。この「空にしてから書く」という時間差の間に処理が中断されると、ファイルは空、あるいは書きかけの不完全な内容のまま残る。
# 危険な例: 書き込み中にクラッシュすると壊れたファイルが残る
with open('config.json', 'w') as f:
json.dump(data, f) # ここで中断されたら?
中断の原因は様々である。kill -9 による強制終了、OSごとの電源断、Windowsでウイルス対策ソフトが一時的にファイルへのアクセスを止める、外部のバックアップツールが書き込み中のファイルを掴んでいる、など。開発中の手元環境では滅多に再現しないが、長期間稼働する実運用環境では確率的に必ず起こりうる。
解決策: 一時ファイルに書いてから置き換える
対処の骨格は単純である。本体ファイルには直接触れず、まず一時ファイルに完全な内容を書き込み、書き込みが完全に成功したことを確認してから、本体ファイルをその一時ファイルで置き換える。
import json
import os
import tempfile
def atomic_write_json(filepath, data):
dirpath = os.path.dirname(os.path.abspath(filepath)) or '.'
fd, tmp_path = tempfile.mkstemp(dir=dirpath, suffix='.json.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4, ensure_ascii=False)
os.replace(tmp_path, filepath)
except Exception:
os.unlink(tmp_path)
raise
tempfile.mkstemp() は、衝突しない一時ファイル名を発行し、そのファイルディスクリプタを返す。重要なのは dir=dirpath の指定である。本体ファイルと同じディレクトリに一時ファイルを作ることで、後続の os.replace() が同一ファイルシステム内での操作になる。
os.replace() がアトミックである理由
このパターンの核心は最後の os.replace(tmp_path, filepath) にある。Pythonの公式ドキュメントは、os.replace() が「同一ファイルシステム内であれば、この関数はアトミックな操作になる」と明記している。
POSIX系OS(Linux/macOS)では、これはシステムコール rename(2) に相当する。カーネルレベルで、ディレクトリエントリの張り替えが単一の不可分な操作として実行される。処理の途中経過というものが存在しないため、外部から見えるファイルの状態は「置き換え前」か「置き換え後」のどちらかしかない。
Windowsでも、Python 3.3以降では既存ファイルへの上書きがアトミックに処理されるよう実装されている(MoveFileEx に MOVEFILE_REPLACE_EXISTING フラグを付けた呼び出しに相当する)。
この性質により、json.dump() の途中でプロセスが落ちても、影響を受けるのは名前も付いていない一時ファイルだけであり、本体の設定ファイルは直前の正常な状態のまま無傷で残る。
os.fsync() でディスクへの永続化を強制する
os.replace() がアトミックであっても、その前段の json.dump() の内容がまだOSのページキャッシュ上にしかなく、物理ディスクに書き込まれていないタイミングで電源断が起きると、リネーム自体は完了していても中身が古いままという事態がありうる。これに対処するのが os.fsync() である。
with open(tmp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.flush()
try:
os.fsync(f.fileno())
except OSError:
pass
os.replace(tmp_path, filepath)
f.flush() はPythonの内部バッファからOSへ内容を渡すだけであり、OSのキャッシュに留まっている可能性がある。os.fsync() はさらに一歩進んで、OSに対して物理ディスクへの書き込み完了を待つよう要求する。この2段階を踏むことで、os.replace() が実行される時点で一時ファイルの内容が確実にディスク上に存在する状態を作れる。
例外時の後始末
一時ファイルへの書き込み自体が途中で失敗した場合(ディスク容量不足、権限エラーなど)は、os.replace() に到達しないため本体ファイルには一切影響しない。ただし、書きかけの一時ファイルがディスク上に残ってしまう。これを放置するとゴミファイルが蓄積するため、例外発生時には明示的に削除する。
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
os.unlink() 自体が失敗する可能性(既に削除済み、権限がないなど)も考慮し、二重の try で握りつぶしている。ここで元の例外を再送出(raise)することで、呼び出し元には書き込み失敗の事実がきちんと伝わる。
それでも残る .tmp ファイルへの備え
os.replace() は成功すれば一時ファイル名を消費して本体名に置き換えるため、通常運用では .tmp ファイルが残ることはない。しかし、一時ファイルへの書き込みが完了した直後・os.replace() が呼ばれる直前の一瞬にプロセスが kill -9 されるような極端なタイミングでは、.tmp ファイルが残存する可能性がゼロではない。
この残存ファイルは本体データに影響しない(本体は無傷のまま)ため緊急性は低いが、放置すると地味にディスクを消費する。起動時に、設定ディレクトリ直下の一定時間(たとえば1時間)以上前の *.tmp ファイルを走査して削除するクリーンアップ処理を用意しておくと、長期運用での掃除漏れを防げる。
import time
def cleanup_stale_tmp_files(directory, stale_seconds=3600):
now = time.time()
for name in os.listdir(directory):
if not name.endswith('.tmp'):
continue
path = os.path.join(directory, name)
if now - os.path.getmtime(path) > stale_seconds:
os.remove(path)
まとめ
| 手順 | 目的 |
|---|---|
tempfile.mkstemp(dir=本体と同じディレクトリ) |
後続の置き換えを同一ファイルシステム内に収める |
一時ファイルへ書き込み + flush() + fsync() |
内容を確実にディスクへ永続化する |
os.replace(一時ファイル, 本体) |
置き換え自体を不可分な操作にする |
| 例外時は一時ファイルを削除 | 書きかけのゴミを残さない |
起動時に古い .tmp を掃除 |
極端なタイミングでの残存に備える |
「本体ファイルへの直接書き込み」を「一時ファイルへの完全な書き込み + アトミックなリネーム」に置き換えるだけで、途中経過というものが観測されなくなり、設定ファイルの破損という事故のクラス自体を構造的に取り除ける。実装の手間はさほど大きくないため、頻繁に読み書きする設定ファイルやキャッシュファイルを扱う際には、最初から採用しておく価値のあるパターンである。
同じく「Mac開発中は再現せず本番運用で初めて表面化する」種類の罠としては、Pythonのクロスプラットフォームファイルロックで扱った複数プロセス間の排他制御も近い関係にある。実際、アトミックな書き込みとファイルロックは補完関係にあり、複数プロセスが同時に書き込みうる環境では両方を組み合わせて使うのが望ましい。