LoginSignup
42
42

More than 5 years have passed since last update.

Railsから複数行のLinuxコマンドを非同期で実行させる

Last updated at Posted at 2014-12-26

Railsから複数行のLinuxコマンドを非同期(バックグラウンド)で実行させる

時間のかかる処理を非同期で実行させる場合に、いろいろはまったのでまとめておきます。

最終ゴール:

複数行のLinuxコマンド(ShellScript)を非同期で実行させ、
途中でキャンセルもできるようにする

非同期で実行する仕組みはresqueとかsidekiqとかいくつもありますが、

  • 非同期で実行させたいのはRubyのコードではなくLinuxのコマンドだけ
  • 定期ジョブで実行させたい(コマンド処理が終わればRubyは終了させたい)

ということもあり、Ruby標準の仕組みで簡単に済まそうと考えました。

ポイントは次の6つです。

  • 複数のLinuxコマンドは実行可能ファイルにまとめる
  • その実行可能ファイルを非同期(バックグラウンド)で実行させる
  • バックグラウンド実行のために標準出力と標準エラーをファイルに落としておく(もしくは/dev/nullにリダイレクト)
  • forkしてrailsとは別プロセスで実行させる
  • キャンセル用(kill用)にpidを取得しておく
  • pidからpgidを取得し、紐づいている子プロセス含めて全てキャンセルする

Linuxコマンド実行は次のページを参考にしました。

Rubyで外部コマンドを実行して結果を受け取る方法あれこれ

結論を早く見たい人は、 試行3 あたりから見てもらえると良いと思います。

非同期処理させたいLinuxコマンドを準備

まず時間のかかる処理をtest_commandとして作っておきます。

$ echo "sleep 100000" > ~/bin/test_command
$ chmod +x ~/bin/test_command
~/bin/test_command
sleep 100000

次にRailsにtest_commandを呼び出す部分とキャンセルする部分を作ります。
(本当はpidをmodelやファイルに保存するなどの処理が必要ですが、この記事用にはpidを標準出力に表示させる形にしています)

試行1(IO.popenを使う)

最初に試したのはIO.popenです。
pidが簡単に取れそうだったからです。

lib/job_task.rb
class JobTask
  class << self
    def execute
      execute_job "test_command > test.log >2&1 &"
    end

    def cancel(pid)
      IO.popen("kill -TERM #{pid}")
      p "canceled!"
    end

    private

    def execute_job(command)
      begin
        IO.popen("#{command}"){|pipe| p pipe.pid}
      rescue => e
        Rails.logger.error e.message
      end
    end
  end
end

実行させるとpidが表示されます。

$ rails runner JobTask.execute
3377

ここで、pidを確認してみると

$ ps aux | grep test_command
ogi              3388   0.0  0.0  2432784    580 s001  R+    3:37PM   0:00.00 grep test_command
ogi              3378   0.0  0.0  2442580    580   ??  S     3:35PM   0:00.00 sh -c test_command > test.log 2>&1 &

となり、取得した3377とは異なるpid(+1された値)となっていました。

これではpidでキャンセルできません。う〜ん困りました。

試行2(実行後にpsでpidを取得する)

欲しいpidはtest_commandのものなので、実行直後にpsでpidを取得しようと考えました。
grepするとgrepのプロセスまで取得してしまうため、それは対象としないようにしました。

lib/job_task.rb
class JobTask
  class << self
    def execute
      execute_job "test_command > test.log >2&1 &"
    end

    def cancel(pid)
      IO.popen("kill -TERM #{pid}")
      p "canceled!"
    end

    private

    def execute_job(command)
      IO.popen("#{command}"){}
      IO.popen("ps aux | grep -E \"test_command \> test.log\"") do |pipe|
        pipe.readlines.each do |line|
          p line.split[1] if line =~ /^(?!.*grep -E).*$/
        end
      end
    end
  end
end

実行します。

$ rails runner JobTask.execute
"3836"

取得したpidが正しいか確認します。

$ ps aux | grep test_command
ogi              3847   0.0  0.0  2432784    524 s001  R+    4:08PM   0:00.00 grep test_command
ogi              3836   0.0  0.0  2442580    580   ??  S     4:08PM   0:00.00 sh -c test_command > test.log 2>&1 &

今度は欲しいpidが取得できていました。

ではそのpidを使ってキャンセルしてみます。

$ rails runner "JobTask.cancel(3836)"

キャンセルされたか確認します。

$ ps aux | grep test_command
ogi              3944   0.0  0.0  2423368    184 s001  R+    4:14PM   0:00.00 grep test_command

プロセスはkillされていました。

と思っていましたが、落とし穴がありました。

test_command内で呼び出しているコマンド(sleep)は残っていました。

$ ps aux | grep sleep
ogi              3954   0.0  0.0  2432784    504 s001  R+    4:17PM   0:00.00 grep sleep
ogi              3838   0.0  0.0  2432764    468   ??  S     4:08PM   0:00.00 sleep 100000

つまり、対象のプロセスはkillできましたが、そこから呼び出している子プロセスまではkillできていないということです。

試行3(echo $!でpid取得と、pgidで関連プロセス全てキャンセル)

まずpid取得ですが、grepとか使わずとも簡単に取得できることがわかりました。

test_command > test.log 2>&1 & echo $!

のように、バックグラウンド実行直後にecho $!をつなげるだけで、最後に実行したpidが取得できるというのです!
(西脇.rb & 神戸.rbのメンバーに感謝!)

pidが一行で取得できるのでIO.popenではなく`test_command`の形に変更できそうです。

さらに、子プロセスまでまとめてkillする方法も教えてもらいました。
gpidを利用する方法です。
(こちらも西脇.rb & 神戸.rbのメンバーが教えてくれました)

プロセスグループIDの調べ方

$ ps -p 4043 -o "pgid"
 PGID
 2947

プロセスグループをkillする方法

$ kill -- -2947

rubyから実行する場合は、--がうまく解釈されないようなので次のようにします。

`kill -TERM -2947`

これらを反映したコードは次の通りです。

lib/job_task.rb
class JobTask
  class << self
    def execute
      execute_job "test_command > test.log 2>&1 & echo $!"
    end

    def cancel(pid)
      command = "ps -p #{pid} -o \"pgid\""
      pgid = system_command(command).lines.to_a.last.lstrip.chomp
      if pgid =~ /[0-9]/
        system_command "kill -TERM -#{pgid}"
        p "canceled!"
      else
        Rails.logger.error 'Process was not found'
      end
    end

    private

    def execute_job(command)
      begin
        pid = system_command(command).lstrip.chomp
        if pid =~ /[0-9]/
          p pid
        else
          Rails.logger.error 'command has not pid'
        end
      rescue => e
        Rails.logger.error e.message
      end
    end

    def system_command(command)
      `#{command}`
    end
  end
end

再度試します。

$ rails runner JobTask.execute
"4308"

欲しいpidが返ってきました。

$ ps aux | grep test_command
ogi              4317   0.0  0.0  2423368    216 s001  R+    4:45PM   0:00.00 grep test_command
ogi              4308   0.0  0.0  2442580    568   ??  S     4:44PM   0:00.00 sh -c test_command > test.log 2>&1 & echo $!
$ ps aux | grep sleep
ogi              4324   0.0  0.0  2432784    472 s001  R+    4:45PM   0:00.00 grep sleep
ogi              4309   0.0  0.0  2432764    468   ??  S     4:44PM   0:00.00 sleep 100000

キャンセルしてみます。

$ rails runner "JobTask.cancel(4308)"
$

プロセスを確認しています。

$ ps aux | grep test_command
ogi              4456   0.0  0.0  2424588    388 s001  R+    4:48PM   0:00.00 grep test_command
$ ps aux | grep sleep
ogi              4467   0.0  0.0  2432784    508 s001  R+    4:50PM   0:00.00 grep sleep

子プロセスまでちゃんとkillされています。

うまくいった!と思いきや、ここにも落とし穴がありました。

コードでは、

        system_command "kill -TERM -#{pgid}"
        p "canceled!"

とkillの後にログを表示させているのですが、コマンド実行しても何も表示されていませんでした。
デバッガで追いかけても続きを実行できません。
まるで途中で強制終了されたような動きです。

JobTask.execute後のプロセスグループをもう一度確認してみます。

$ rails runner JobTask.execute
"4520"
$ ps -p 4520 -o "pgid"
 PGID
 4449
$ ps 4449
  PID   TT  STAT      TIME COMMAND
 4449   ??  Ss     0:05.35 spring app    | background_linux_command | started 8 mins ago | development mode

グループのルートプロセスがspring(railsを動かしているそのもの)だとわかりました。
なるほど、test_commandとその子プロセスをkillしたつもりが、親のrailsまでkillしていたため続きが実行されていなかったということですね。納得です。

ということで別グループで動かす必要があることがわかりました。

試行4(forkして別プロセスで実行する)

railsとは別グループで動かすにはどうすればいいのでしょう?
そういえばデーモンっぽく動かしているdaemon-spawnはどうしてるんだろう?
と思ってソースを見てみました。

daemon_spawn.rb
:
def self.start(daemon, args) #:nodoc:
  :
  fork do
    Process.setsid
    exit if fork
    open(daemon.pid_file, 'w') { |f| f << Process.pid }
    :
  end
  :
end
:

なるほど、forkして別プロセスにするわけですね!

これを参考にしてみました。

lib/job_task.rb
class JobTask
  class << self
    def execute
      execute_job "test_command > test.log 2>&1 & echo $!"
    end

    def cancel(pid)
      command = "ps -p #{pid} -o \"pgid\""
      pgid = system_command(command).lines.to_a.last.lstrip.chomp
      if pgid =~ /[0-9]/
        system_command "kill -TERM -#{pgid}"
        p "canceled!"
      else
        Rails.logger.error 'Process was not found'
      end
    end

    private

    def execute_job(command)
      begin
        fork do
          Process.setsid
          pid = system_command(command).lstrip.chomp
          if pid =~ /[0-9]/
            p pid
          else
            Rails.logger.error 'command has not pid'
          end
        end
      rescue => e
        Rails.logger.error e.message
      end
    end

    def system_command(command)
      `#{command}`
    end
  end
end

では実行してみます。

$ rails runner JobTask.execute
"4877"
$ ps -p 4877 -o "pgid"
 PGID
 4875
$ ps 4875
  PID   TT  STAT      TIME COMMAND

ルートプロセスはspringではなくなりました。というより今度はルートプロセスがすでにいなくなっています。

Rails consoleで確認するとわかるのですが、forkさせるとルートプロセスはrubyになっていました。

$ rails c
Loading development environment (Rails 4.1.8)
irb(main):001:0> JobTask.execute
=> 5003
irb(main):002:0> "5005"

irb(main):003:0* exit
$ ps -p 5005 -o "pgid"
 PGID
 5003
$ ps 5003
  PID   TT  STAT      TIME COMMAND
 5003   ??  Z      0:00.00 (ruby)

Rails runnerの処理が終わってrubyプロセスが終了したという認識です。

では、今度はキャンセルしてみます。

$ rails runner "JobTask.cancel(4877)"
"canceled!"
$ ps aux | grep test_command
ogi              4951   0.0  0.0  2432784    488 s001  R+    5:18PM   0:00.00 grep test_command
$ ps aux | grep sleep
ogi              4958   0.0  0.0  2432784    500 s001  R+    5:18PM   0:00.00 grep sleep

今度は意図した動きになりました。

試行5(実行させるコマンドをRailsから生成させる)

これまでは実行させるコマンドを~/bin/test_command固定にしていましたが、
実行時に生成したい場合を考えます。

chefのbashブロックがやってるように一時ファイルにコマンドを出力し、
それを実行させるようにします。

lib/job_task.rb
class JobTask
  class << self
    def execute
      execute_job
    end

    def cancel(pid)
      command = "ps -p #{pid} -o \"pgid\""
      pgid = system_command(command).lines.to_a.last.lstrip.chomp
      if pgid =~ /[0-9]/
        system_command "kill -TERM -#{pgid}"
        p "canceled!"
      else
        Rails.logger.error 'Process was not found'
      end
    end
    private

    def create_command
      command = <<-"EOS"
        echo "start sleep"
        sleep 100000
        echo "end sleep"
      EOS
      script_path = "tmp/test_command"
      File.open(script_path, "w", 0755) do |f|
        f.write command
      end

      # execute background and return pid
      "#{script_path} > tmp/test.log 2>&1 & echo $!"
    end

    def execute_job
      begin
        fork do
          Process.setsid
          pid = system_command(create_command).lstrip.chomp
          if pid =~ /[0-9]/
            p pid
          else
            Rails.logger.error 'command has not pid'
          end
        end
      rescue => e
        Rails.logger.error e.message
      end
    end

    def system_command(command)
      `#{command}`
    end
  end
end

実行してみます。

$ rails runner JobTask.execute
"5507"
$ ps aux | grep test_command
ogi              5522   0.0  0.0  2432784    496 s001  R+    5:49PM   0:00.00 grep test_command
ogi              5507   0.0  0.0  2442580    568   ??  S     5:49PM   0:00.00 sh -c tmp/test_command > tmp/test.log 2>&1 & echo $!
$ ps aux | grep sleep
ogi              5529   0.0  0.0  2424588    404 s001  R+    5:49PM   0:00.00 grep sleep
ogi              5508   0.0  0.0  2432764    468   ??  S     5:49PM   0:00.00 sleep 100000

キャンセルしてみます。

$ rails runner "JobTask.cancel(5507)"
"canceled!"
$ ps aux | grep test_command
ogi              5572   0.0  0.0  2442000    616 s001  S+    5:50PM   0:00.00 grep test_command
$ ps aux | grep sleep
ogi              5579   0.0  0.0  2432784    512 s001  R+    5:50PM   0:00.00 grep sleep

実行用のファイルとログも確認します。

$ ls tmp/
cache       test.log    test_command
$ cat tmp/test_command
        echo "start sleep"
        sleep 100000
        echo "end sleep"
$ cat tmp/test.log
start sleep

意図した動きになりました。

最後に

試行錯誤でやりたいことを実現している状態なので、
もっと良い方法を知っている方がいましたら教えてもらえるとうれしいです。

42
42
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
42