2014年3月14日金曜日

外部コマンドを実行してタイムアウトしたらkillする

Rubyから外部コマンドを起動して、一定時間が経過しても終了しなかったらkillする、という処理をしようと思って次のようなコードを書いてみた(実際にはSTDOUT, STDERRを取るなどもっと複雑)。

#!/bin/sh
# Rubyから呼び出される外部コマンド

echo "do something"
sleep 30
echo "done something"
# 外部コマンドを実行してタイムアウトしたらkillする
require "timeout"

cmd = "/tmp/heavy.sh"

pid = spawn(cmd)
thr = Process.detach(pid)
begin
  Timeout.timeout(3) do
    thr.join
  end
rescue Timeout::Error
  puts "execution expired"
  Process.kill(:TERM, pid)
end

status = thr.value
p status

で、早速実行してみると、一見うまく動いているように見えるんだけど、実際にはheavy.shの中で実行しているコマンド(ここではsleep 30)は生き残っている。

実行中

$ ps xjf
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1  1599  1599  1599 ?           -1 Ss     500   6:43 tmux
 1599  7765  7765  7765 pts/6     9309 Ss     500   0:00  \_ /usr/local/bin/zsh
 7765  9309  9309  7765 pts/6     9309 Sl+    500   0:00      \_ ruby run_command.rb
 9309  9311  9309  7765 pts/6     9309 S+     500   0:00          \_ /bin/sh /tmp/heavy.sh
 9311  9315  9309  7765 pts/6     9309 S+     500   0:00              \_ sleep 30

killされた後(heavy.shは終了したけどsleep 30は生きてる)

$ ps xjf
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1  9315  9309  7765 pts/6     7765 S      500   0:00 sleep 30

これはどういうことかというと、UNIX系のOSでは親プロセスが死んで孤児になったプロセスはinitの養子になることが決まっているから(sleep 30のPPIDが1=initになっている)。
道連れになって親と一緒に死んだりはしない。

で、子だけ生き残って処理が続行されるのは都合が悪いという場合は、次のようなアプローチが考えられる。

  • killしようとしているプロセスの子プロセスを列挙して全部killする
  • 新しいプロセスグループにしてまとめてkillする

幸いにもRubyのspawnには:pgroupというプロセスグループを指定するオプションがあるので、これを指定して新しいプロセスグループで実行させることができる。
そしてProcess.killのpidに負数を指定すると、そのIDのプロセスグループ全体に対してシグナルを投げることができる(ちなみにkillコマンドも同じ仕様)。

通常、プロセスグループIDはグループリーダー、つまりそのグループの最初のプロセスIDになるので、ここではsapwnで返ってきたpidをマイナスにした値(pid=1234なら-1234)を使えばよいことになる。

というわけで、先のRubyスクリプトをこんな感じに修正することで、タイムアウトしたら外部コマンドとそこからさらに実行されたコマンド全てをkillすることができましたとさ。めでたしめでたし。

require "timeout"

cmd = "/tmp/heavy.sh"

pid = spawn(cmd, :pgroup => true)  # :pgroup => trueを追加
thr = Process.detach(pid)
begin
  Timeout.timeout(3) do
    thr.join
  end
rescue Timeout::Error
  puts "execution expired"
  Process.kill(:TERM, -pid)        # -pidに変更
end

status = thr.value
p status

参考

0 件のコメント:

コメントを投稿