稼働中バイナリを差し替えるときの atomic rename パターン
まとめ
- 稼働中の ELF バイナリに
cpで上書きするとText file busy(ETXTBSY) で失敗する - 一時ファイルに書き出して
mv -fで差し替える atomic rename で回避できる rename(2)はディレクトリエントリだけを書き換え、古い inode は実行中プロセスが使い続ける- Rails で同じ問題に遭遇しなかったのは Capistrano の symlink swap が裏で同等のことをしていたから
起きたこと
自作デーモンを cp で更新しようとしたら cp: Text file busy で失敗した。systemd サービスは systemctl stop していたが、別場所で動いていたサブコマンドプロセスがバイナリを掴んでいた(fuser で別 PID が判明)。
なぜ EBUSY になるのか
Linux カーネルは「実行中の ELF バイナリ」への 書き込み を禁止する。open(path, O_WRONLY) のような既存 inode の中身を上書きする操作がエラー (ETXTBSY) になる、という意味であって、削除や差し替え は禁止されていない。
# ❌ 既存 inode に O_WRONLY → ETXTBSY
cp new_binary /usr/local/bin/foo
# ✅ ディレクトリエントリの操作なので OK
mv new_binary /usr/local/bin/foorename(2) は inode の中身を触らない。ディレクトリエントリ(パス → inode のマッピング)を書き換えるだけのメタデータ操作なので、古いバイナリが実行中でも通る。
atomic rename パターン
cp src dest.tmp.$$
chmod 0755 dest.tmp.$$
mv -f dest.tmp.$$ destポイント:
- 同一ファイルシステム内なら
rename(2)は原子的: 成功か失敗のどちらかで、壊れたバイナリが残らない - 古いプロセスは旧 inode を握り続ける: 既に open しているプロセスからは旧 inode が透過的に見える
- 新規 exec から新バイナリ: 次に
destをexecするプロセスは新 inode を読む
サービスを新バイナリで動かしたいときは明示的に再起動する。systemd なら try-restart が便利。
systemctl --user try-restart myservice.serviceファイルシステム境界の罠
mv は同一ファイルシステム内では rename(2) を呼ぶが、異なるマウントポイントを跨ぐと内部的に copy + unlink になる。原子性が失われる上、コピー中の dest を読んだプロセスが壊れたバイナリを実行する可能性がある。一時ファイルは dest と同じディレクトリ に置くのが鉄則。
Rails / Capistrano で遭遇しなかった理由
長年 Rails + Capistrano でデプロイしてきたが、この種の問題に遭遇した記憶がない。理由は 2 つ。
理由 1: Rails アプリは「実行中バイナリ」ではない
Ruby から見れば .rb ファイルは実行ファイルではなく 読み込まれるテキスト。ETXTBSY は ELF バイナリの実行プロセスにしか発生しないので、.rb の上書きはそもそも EBUSY を返さない。実行中バイナリは Ruby インタプリタ本体だけ。
理由 2: Capistrano は別ディレクトリ展開 + symlink swap
/var/www/myapp/
├── current → releases/20260509120000 ← symlink
└── releases/
├── 20260509100000/ (1つ前)
└── 20260509120000/ (今動いてる)デプロイは新リリースを releases/<新タイムスタンプ>/ に展開してから、current symlink を張り替える。ln -sfn の中身は 一時 symlink を作って rename する実装で、symlink そのものを atomic に差し替えるイディオム。
つまり Capistrano は「単一バイナリの atomic rename」をディレクトリツリー全体に拡張したもの。rollback が current symlink を前のリリースに戻すだけで済むのも、この構造の副産物。
比較
表1: atomic rename と Capistrano 流 symlink swap
| 観点 | atomic rename | symlink swap |
|---|---|---|
| 対象 | 単一ファイル | ディレクトリツリー全体 |
| 仕組み | dest を rename(2) で差し替え | symlink target を rename で差し替え |
| ロールバック | 困難(旧 inode は次回 unlink で消える) | 簡単(symlink を前のリリースに張り替え) |
| ディスク使用量 | 1 個分 | N リリース分(Capistrano デフォルト 5 個) |
| 適する場面 | systemd サービスの単一バイナリ更新 | アプリ全体のデプロイ |
実用ヘルパー
シェルスクリプトに 1 関数だけ生やしておくと再利用しやすい。
install_binary() {
local src="$1"
local dest="$2"
local tmp="${dest}.new.$$"
cp "$src" "$tmp"
chmod 0755 "$tmp"
mv -f "$tmp" "$dest"
}
install_binary build/myservice ~/.local/bin/myservice
systemctl --user try-restart myservice.serviceこれで「サービスを止めてから cp する」前段処理が要らなくなる。差し替えは止めずに行い、明示的に再起動したいときだけ try-restart を呼べばよい。
OS 差
- Linux: 実行中ファイルの 書き込み は ETXTBSY、unlink/rename は OK
- macOS: 実行中ファイルの上書きは制限が緩く
cpも通ることが多い - Windows: 実行中ファイルは rename も unlink も不可。デプロイ時はプロセス停止が必須
Linux は「実行中バイナリの差し替えに優しい」OS で、atomic rename はその恩恵を最大限活かすイディオムだった。