投稿

稼働中バイナリを差し替えるときの atomic rename パターン

稼働中バイナリを差し替えるときの 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/foo

rename(2) は inode の中身を触らない。ディレクトリエントリ(パス → inode のマッピング)を書き換えるだけのメタデータ操作なので、古いバイナリが実行中でも通る。

atomic rename パターン

cp src dest.tmp.$$
chmod 0755 dest.tmp.$$
mv -f dest.tmp.$$ dest

ポイント:

  • 同一ファイルシステム内なら rename(2) は原子的: 成功か失敗のどちらかで、壊れたバイナリが残らない
  • 古いプロセスは旧 inode を握り続ける: 既に open しているプロセスからは旧 inode が透過的に見える
  • 新規 exec から新バイナリ: 次に destexec するプロセスは新 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 はその恩恵を最大限活かすイディオムだった。

トレンドのタグ