「Text file busy」の対処 - 実行中バイナリの上書き

「Text file busy」の対処 - 実行中バイナリの上書き

「Text file busy」とは何のエラーか?

結論: いま実行中のプログラム(または使用中の共有ライブラリ)の本体ファイルを、上書き・切り詰めしようとして弾かれている状態。カーネルが ETXTBSY(errno 26)を返している。走っているプロセスを止めるか、上書きではなく「置き換え(rename)」に切り替えれば解決する。

典型的にはバイナリのコピー・ビルド・デプロイで現れる。

cp: cannot create regular file '/usr/local/bin/myapp': Text file busy

Text file busy の "text" は、歴史的な Unix 用語でプログラムの コードセグメント(text segment) を指す。ファイルの中身が日本語テキストかどうかとは無関係で、「実行コードのファイルが使用中」という意味。

カーネルがこのエラーを返すのは、おおむね次の操作のとき。

  • 実行中のバイナリを書き込みモードで開こうとしたcp での上書き、> file でのリダイレクトなど)
  • 使用中の共有ライブラリ(.so)を上書きしようとした(メモリに mmap 済み)
  • 書き込み用に開いているファイルを execve() で実行しようとした(ビルド直後の競合など)

Text file busy は「権限がない(Permission denied)」とも「読み取り専用FS(Read-only file system)」とも別物。権限・FS は正しくても、実行中であるという一点で書き込みが拒否される。エラー文を取り違えると対処を誤る。

なぜ実行中のバイナリは上書きできないのか?

結論: Linux はプロセスが実行中の実行ファイル(とロード中の共有ライブラリ)の inode を「書き込み禁止」として保護する。走行中のコードを途中で書き換えるとクラッシュや未定義動作を招くため、カーネルが ETXTBSY で先回りして防いでいる。

プログラムを起動すると、カーネルはその実行ファイルの内容を mmap でメモリにマップ して実行する。ページは必要に応じて遅延読み込みされるため、ファイルの実体は実行中ずっと参照され続ける。

ここで誰かがファイルを書き換えると、まだ読み込んでいないページが別物に変わり、整合性が崩れる。これを防ぐためカーネルは inode 単位で「実行中フラグ」を立て、書き込みオープン(O_WRONLY / O_RDWR)や切り詰め(truncate)を ETXTBSY で拒否する。共有ライブラリも実行コードを含むため同じ保護対象になる。

逆方向の保護もある。あるファイルを 書き込み用に開いたまま そのファイルを execve() で実行しようとしても ETXTBSY になる。ビルドスクリプトが出力ファイルを閉じ忘れたまま実行に進むと、このパターンに当たる。

なお、シェルスクリプトは通常このエラーにならない。スクリプトは bash などのインタプリタが「データとして読む」だけで、保護対象の実行イメージにはならないため。ETXTBSY は主に ELF バイナリと共有ライブラリで起きる。

原因プロセスを特定するには?

結論: lsof <ファイル> または fuser <ファイル> で、そのバイナリを掴んでいるプロセスを一覧する。出てきた PID が「止めるべき相手」。サービス管理下のプロセスなら systemctl で止めるのが安全。

lsof でファイル単位に見る

lsof に上書きしたいファイルパスを渡すと、それを開いている・実行しているプロセスが分かる。

lsof /usr/local/bin/myapp
COMMAND   PID  USER   FD   TYPE DEVICE SIZE/OFF   NODE NAME
myapp    4821 deploy  txt    REG  259,1  6291456 131080 /usr/local/bin/myapp

FD 列の txt が「実行中のテキスト(コード)として使用」を示す。この myapp(PID 4821)が原因。共有ライブラリの場合は mem(mmap 済み)として現れる。

fuser で素早く PID を出す

PID だけ手早く知りたいなら fuser

fuser /usr/local/bin/myapp
/usr/local/bin/myapp: 4821e

末尾の e実行中(executable being run) を意味する。fuser -v で USER / COMMAND まで確認できる。

原因が常駐サービスやデーモンの場合、kill で直接落とすと自動再起動や中途半端な状態を招く。サービスは systemctl stop で止めるのが筋。手動起動のプロセスのみ kill を検討する。

上書きせずに安全に置き換えるには?

結論: 走行中プロセスを止められないなら、cp での上書きをやめて mv(rename)や install新しいファイルに差し替える。rename は inode を作り直してディレクトリエントリを張り替えるだけなので、実行中プロセスは古い inode を保持したまま、新バイナリと衝突しない。

なぜ mv は通って cp は弾かれるのか

  • cp new /usr/local/bin/myapp … 既存の inode を O_TRUNC で開いて中身を書き換える(in-place)→ 実行中なので ETXTBSY
  • mv new /usr/local/bin/myapp … 新しい inode を myapp という名前に rename し、古い名前を置き換える → 実行中 inode には触れないので成功

ポイントは「同じ場所のファイルシステム内」で完結させること。/tmp(別FS)から mv すると内部的にコピー+削除になり、上書きパスを踏む場合がある。同一ディレクトリに新ファイルを置いてから rename するのが定石。

# 同じディレクトリにダウンロード/ビルドしてから rename
cp myapp.new /usr/local/bin/myapp.new   # 別名でまず配置
mv /usr/local/bin/myapp.new /usr/local/bin/myapp   # 原子的に差し替え

走行中のプロセスは古いバイナリ(ディレクトリから消えたが inode は生存)で動き続け、次回起動時から新バイナリになる。

install を使うとより簡潔

install は「一時ファイルに書いて rename」を内部で行い、権限・所有者もまとめて設定できる。デプロイ用途に向く。

sudo install -m 0755 -o root -g root myapp.new /usr/local/bin/myapp

どうしても同じ inode に書きたいなら先に削除

rm でディレクトリエントリを消してから書けば、新規作成扱いになり ETXTBSY を避けられる(unlink 自体は実行中でも可能)。

sudo rm /usr/local/bin/myapp        # 実行中でも unlink は通る
sudo cp myapp.new /usr/local/bin/myapp

最も確実なのは「プロセスを止めてから上書き」。サービスなら systemctl stop myapp && cp ... && systemctl start myapp。無停止で差し替えたい場合に rename / install 方式を使う、と覚えると判断が速い。

デプロイやビルドで再発させないには?

結論: デプロイは「上書き」ではなく「rename による原子的差し替え」を原則にする。ビルドでは出力ファイルのディスクリプタを確実に閉じ、実行中の同名バイナリへ直接書かない。

再発を防ぐチェックポイント。

  • デプロイスクリプトで cp -f 直書きをやめる … 一時名へ配置 → mv / install で差し替え。多くのデプロイツール(go build の出力差し替え等)がこの方式を採る
  • 稼働中サービスは停止→更新→再起動を基本に … 無停止更新が必要なら rename 方式、または systemd の ExecReload / ソケットアクティベーションを検討
  • ビルド直後の実行は出力 fd を閉じてから … スクリプトで make && ./out のように繋ぐとき、ビルドが書き込みハンドルを残していると ETXTBSY。明示的に閉じる・別ステップに分ける
  • 共有ライブラリ更新も同様 … 使用中の .so は上書きせず rename で差し替え。パッケージマネージャ(apt/dnf)はこの手順を内部で行うため、手動 cp での .so 上書きは避ける

NFS など一部のネットワークファイルシステムでは、別ホストで実行中のバイナリに対して ETXTBSY の挙動が一貫しないことがある。共有ストレージ上の実行ファイルを更新する際は、各ホストでプロセスを止めてから差し替えるのが安全。

まとめ / 次に読む