シェルスクリプトから"foreman start"したときにCtrl-Cで終了できない現象の解説

シェルスクリプトから"foreman start"したときにCtrl-Cで終了できないという現象に遭遇しました。なぜこのようなことが起こったのかについて調べてみたので解説します。一見不可解におもえるプロセスの振舞いをUNIXプロセスの仕組みをひもとき説明してみたというおもむきの記事です。

概要

foremanはシェルスクリプトから、"foreman start"のように起動したときにCtrl-C終了できません。シェルスクリプトでなくてもssh経由で ssh -t host foreman start のようにした場合でも同様の問題が発生します。これは、foremanがsetpgrpシステムコールを呼び出してプロセスグループのリーダになるという動作をしていたのが原因でした。

現象

以下のように "foreman start"をシェルスクリプトから実行すると、Ctrl-Cによりforemanを停止できなくなります。

$ cat foreman_wrapper.sh
foreman start
$ sh foreman_wrapper.sh
# foreman のログが表示される
# Ctrl-C で停止できない

また以下のようにssh経由でremoteのforemanをstartとさせた場合も同様な現象が発生します。

$ ssh -t host foreman start
# foreman のログが表示される
# Ctrl-C で停止できない

調べてみるとForeman::Engine 内での Process.setpgrpの呼び出し がこの現象をひきおこしているようです。foreman起動時にforemanのプロセスがプロセスグループのリーダーになることを保証しています。foremanに送出されたSIGNALが子プロセスにも伝わるようにこのようにしているのだと思われます。このコード自体は妥当そうです。

現象の再現

なぜ、Process.setpgrpしてシェルスクリプトからforemanを起動したときにCtrl-Cが入力できないようなことがおこるのでしょうか? まずは、この現象の再現を試みてみます。

setpgrpシステムコールを呼び出してプロセスグループのリーダになるプログラムを用意します。SIGINTなどのシグナルを与えなければ終了しないようにしておきます。

# run.rb
Process.setpgrp # プロセスグループのリーダーになる

t = Thread.new do 
  loop do
    puts "hello"
    sleep 1
  end
end

t.join

実行すると無限に hello を出力しつづけます。Ctrl-Cを入力してSIGINTを送るととまります。

$ ruby run.rb
hello
hello
hello
hello # Ctrl-CをおしてSIGINTを送出するととまる

このプログラムが動作している時、プロセスは以下のような様子になっています。

 PID  PPID  PGID   SID TPGID TT       STAT COMMAND
 3591  3493  3493  3493    -1 ?        S    sshd: vagrant@pts/0
 3592  3591  3592  3592  8311 pts/0    Ss    \_ -bash
 8311  3592  8311  3592  8311 pts/0    Sl+       \_ ruby run.rb

通常は親プロセス(bash) と 子プロセス(ruby run.rb) のPGID(プロセスグループID)は一致しますが、この場合は子プロセスがsetpgrpシステムコールを呼び出してプロセスグループのリーダーになることを要求し(正確には自分のPGIDを自分のPIDに設定する)、新しいプロセスグループに所属するようにするため、PGIDが違っています。

STATについている+はそのプロセスがフォアグラウンドのプロセスグループに属していることを示します。Ctrl-Cなどのキーボード入力割り込みは、フォアグラウンドのプロセスグループにわたされます。この場合は、 ruby run.rb のSTATに+がついているので、キーボードからの入力が ruby run.rbに向かいます。

ここで、上のプログラムをシェルスクリプト経由で実行するようにします。するとCtrl-Cが ruby run.rb に到達しなくなります。

# wrapper.sh
ruby proc.rb # 上のプログラムをシェルスクリプト経由で起動
$ sh wrapper.sh
hello
hello
^C^C^C^Chello # とまらぬ!!!
hello

このときのプロセスの様子をみてみます。

 PID  PPID  PGID   SID TPGID TT       STAT COMMAND
 3591  3493  3493  3493    -1 ?        S    sshd: vagrant@pts/0
 3592  3591  3592  3592  6795 pts/0    Ss    \_ -bash
 6795  3592  6795  3592  6795 pts/0    S+        \_ sh wrapper.sh
 6796  6795  6796  3592  6795 pts/0    Sl            \_ ruby run.rb

ご覧のとおり ruby run.rbのSTATに+がついていません。よって、ruby run.rbにキーボードからの入力が向かわなくなり、Ctrl-Cによってプログラムが終了できなくなりました。

解説

シェルスクリプト経由で起動したProcess.setpgrpするプロセスがフォアグラウンドにならない理由は、もうすこしOSやシェルの動作を調べなければなりません。

まず、前提として、プロセスグループのリーダーになった ( = 新しくプロセスグループがつくられた) ときにそのプロセスグループが勝手にフォアグラウンドになることはありません 。プロセスをフォアグラウンドにするにはtcsetpgrpシステムコールを適切な方法で呼び出す必要があります。また、プロセスグループのID ( = PGID ) はプロセスグループのリーダーのプロセスID ( = PID)とおなじになっています。

シェルはあたらしくコマンドを実行するときそのコマンドのために新しいプロセスグループを作ります。sh wrapper.shをはさまず直接 ruby run.rb を起動すると、そこでできた新しいプロセスグループには ruby run.rb のプロセスしかいませんので、 ruby run.rb のプロセスは新しいプロセスグループのリーダーになります(下図 ①)。自分自身をプロセスグループのリーダーにするsetpgrpシステムコールを呼び出しても、プロセスの状態に変化はありません。シェルから ruby run.rb が起動されたときは、当然フォアグラウンドのプロセスグループは ruby run.rb のプロセスが所属しているプロセスグループになっており、これも変化しません。

f:id:hakobe932:20130308013338p:plain:w500

一方、sh wrapper.shから ruby run.rb を起動した場合、 ruby run.rb sh wrapper.shの起動時に作られたプロセスグループに所属します(下図 ②から③)。

f:id:hakobe932:20130308013349p:plain:w500

そこで ruby run.rb の中で、setpgrpシステムコールを呼び出します。setpgrpシステムコールは呼び出したプロセスのPGIDを呼び出したプロセスのPIDに設定するという動作をします。もとのPGIDはプロセスグループのリーダーになっているsh wrapper.shのPIDと同じ値になっていたため、sh wrapper.shと同じプロセスグループに属していましたが、sepgrpシステムコールの動作によりPGIDが ruby run.rb のPIDである新しいプロセスグループが作成され以下の図のようになります。

f:id:hakobe932:20130308013359p:plain:w500

フォアグラウンドのプロセスグループは勝手に変化をしませんから、sh wrapper.shが所属しているプロセスグループのままです。つまり、 ruby run.rb が所属しているプロセスグループはフォアグラウンドではなくなりました。この結果、Ctrl-Cなどのキーボード入力は ruby run.rb のプロセスにはわたらなくなります。

結局どうすればよいか

今回はそもそもちょっとした起動スクリプトから起動したforemanのプロセスをCtrl-Cで終了させることが目的でした。振舞いの原因がわかったことで、以下の解決策が考えられました。

  1. foremanのプロセスグループをフォアグラウンドにする(tcsetpgrpシステムコールを使う)
  2. foremanのプロセスに直接SIGINTを送出する

今回は、そもそもSIGINTが送出さえできれば良いということになり、結局、以下のような 実行したいコマンドをforkして終了をまつようなスクリプトを間にはさむことで解決することにしました。spawnが便利です (参考: https://gist.github.com/ujihisa/325036)

#!/usr/bin/env ruby
puts "Starting..."
pid = spawn *%w(bundle exec foreman start)

trap("INT") {
  puts "Shutting down..."
  Process.kill(2, pid)
}

Process.waitpid2(pid)

まとめ

foremanのようにプロセスグループを作るプロセスはフォアグラウンドプロセスでなくなることがあり、Ctrl-Cなどのキーボード入力をうけつけなくなることがわかりました。

なんか Ctrl-C がきかねーんだけど、とかいいながら調査していたら、UNIXプロセスに少しくわしくなりました。まちがっている点などありましたらぜひ教えてください。Working With Unix Processesを読んだら理解が深まりそう!

参考資料