Rubyからバックグラウンドで動いているプロセスグループを扱う

目的

テスト実行時にプログラムから gcloud コマンドから立ち上げる Java でできたエミュレータを起動し、テストコード実行後に自動的に終了する。

何が問題か

例えば gcloud emulators firestore start したプロセスは

  • shell から立ち上げたものは Ctrl-C で終了できる
  • 何かのコマンドから立ち上げるとこれができない

という実現方法をしていて、shell が「つかんでいる状態」だと Ctrl-C でエミュレータを終了できるんだけど、そうじゃないとタスクマネージャとかアクティビティモニタとか kill コマンドからしか終了させられない。(ように見える。)これが「テスト実行時に自動的に起動して自動的に終了してほしい」という要求とすこぶる相性がよくない。

例えば他のデータベースサーバプロセスなら Procfile を利用して foreman なり類似の何かなりでテスト実行前に起動、テスト完了後に終了、という動作を比較的簡単に実現できる。

しかし gcloud コマンドから起動するエミュレータはそうはいかない。試しにやってみると foreman が終了しても Java のプロセスはそのまま残り続けてしまう。

なぜこんなことになるのか

何がどうなっているのかというと、プロセスの構造が以下のようになっている。

python ← gcloudの正体
  `-- bash
        `-- java ← 実際にネットワークを listen している

ここで shell から手で gcloud コマンドを叩いた時は gcloud コマンド自体を Ctrl-C で終了できる状態なんだけど、何らかのプログラムの中から実行するとこれがうまく機能しないし、

自分で起動したgcloudコマンドのpidだけ分かってもJavaのプロセスに影響を及ぼすことができない

Rubyでプロセスグループを扱うには

上記のような問題に対する対処方法としては

おおもとのPythonに紐づくプロセスグループを取得してその情報をもとに終了シグナルを送る

のが正解になる。では関連する処理を Ruby からどう実現するかを見ていこう。

バックグラウンド起動

まずは起動方法だが、以下のようになる。

Process.spawn(cmd, *args, pgroup: true | Integer)
  • Process.spawn は子プロセスの終了を待たない起動方法で pid を返す
  • pgroup という option を与えると pid じゃなくて pgid を返すようになる
  • true か 0 で新しいグループを作成、それ以外の整数はその指定のグループに所属させる

この状態は daemon 化はしてなくて、単に Ruby が動いてるプロセスから切り離しただけ。なんでこれでよしとしているかは今回の目的がテスト実行前後の自動化のためで、この場合は起動中のメッセージとかがカジュアルに確認できた方が便利だから。(だから stdout, stderr は切り離していない。)あくまで手動で必要なプロセスを立ち上げる必要がない、までが達成したいこと。

起動済みの何かをいったん終了(クリア)したい

特に何も前提がないのであれば「すでに起動済みなんだからそのサーバを使えばいい」という判断もできるんだけど、例えば Firestore Emulator の場合、起動時に特定のデータを読み込む機能があるので、これを利用して特定のデータの状態を持って listen していることを期待できる。

この場合はすでに起動しているサーバをいったん終了させて、「自分の期待するタイミングでサーバを明示的に起動する」という処理にしたい。これは

自分で起動したわけじゃないけどすでに起動しているサーバを終了したいという要望

なんだけど、これを自動化しようと思うとひと工夫必要になる。要件としては以下の通り。

  • プロセスまたはプロセスグループの操作のために id が必要
  • 自分で起動したわけじゃないサーバが listen しているポートからこの pid を知る必要がある

具体的にどうするかというと、自分は lsof くらいしか思いつかなかった。lsof は Unix 系のコマンドで Windows では動作しないんだけど、なんとなくさっきから書いてる前提となってる pid, pgid がそもそも Unix 系のプロセスの話なんじゃないかと思ってるのでこのまま進めようと思う。(ごめん Windows については調べてないです。)

lsofのコマンド出力をいい具合に加工する

先にコードを出すとこんな感じ。

def kill_process_if_already_exists(host:, port:)
  TCPSocket.new(host, port).close

  # `lsof -Fg -i:PORT` returns PIDs of the process listening on the port.
  process_ids, = Open3.capture3(*"lsof -Fg -g -s TCP:LISTEN -i :#{port}".split(" "))
  process_group = process_ids.lines.map(&:chomp).find { |id| id =~ /^g([0-9]+)/ }

  if process_group
    id = process_group[1..].to_i # strip first letter
    # stop(id)
  end
rescue Errno::ECONNREFUSED
  # noop
end

こういうの生々しくやるのって、イマイチだなぁとは思うんだけど他に有効な手が分からないなら仕方ない。むしろ Ruby はこういうのが得意な部類。

やっているのは

p1234
g5678
f23

みたいな出力を加工してプロセスグループのIDを取得すること。

lsof の出力には様々な情報が載るが、-Fg を付けると上のような出力になる。上からプロセスID、プロセスグループID、ファイルディスクリプタで、それぞれ prefix が付いて1行1項目になっている。

TCPSocket を利用しているのは、サーバが立ち上がっていないなら特段何かを調べる必要がないので lsof をスキップするため。

終了処理は含まれてません。終了のメソッドを呼んでいるだけ。(実際にはコメントアウトしてるけど)

終了方法

基本はこれ。

Process.kill("TERM", -pgid)
Process.wait(-pgid)

pgid の場合は引数に与える際にマイナスにする点に注意が必要。

また kill だけだとシグナル送信に成功した段階で返ってくるので wait も入れておく。テスト終了後の処理だけなら wait はしなくても Ruby プロセス全体が終了しているので問題ないっちゃないけど、終了を確認して次の何かをしようと思うなら待つべし。

ついでに言うと、何かの拍子にすでに終了している可能性がある場合は全体を begin-rescue で囲んで終了したいプロセスグループが存在しない場合に発生する例外を握りつぶしておく。

おまけ

起動待ち

spawn は子プロセスの終了を待たないが、起動完了も分からない。(分かるのはプロセスを作り終わって id が振られるところまで。)今回のテーマはプロセスグループの制御だけど、テスト実行時に自動的にサーバを開始しますといった場合には実際に LISTEN の状態までテストの実行を待つ必要がある。

雑にやるなら適当に sleep する方法もアリだが、せっかくなので今回は host, port を指定してサーバを起動しているものとして、その port の状況を確認して起動完了を待つこととする。

この場合は(すでに上のコードでも使ったが) Socket を使って応答があるかどうかを見てサーバが ready かどうかを判定する方法が使える。実際には

  1. 接続できないと例外エラーになるので、例外を握りつぶしながら sleep しながら接続できるまでくり返し待つ
  2. 永遠に待つと困るので Timeout を設定する
  3. Timeout エラーしか情報がないと何がなんだか分からなくて困るので、エラーの内容も分かるようにする

という処理を作らないといけない。意外と面倒。

とりあえず 1 だけならこんな感じ。(無限に待つのでこのまま使っちゃダメよ)

  loop do
    TCPSocket.new(host, port).close
    return
  rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL => e
    sleep backoff
  end

これを使った「待つ」メソッドがあるとして、例外が起きなかったら return する形にしてある。

ただ普通は一定の限度以上には待たないようにするし、sleep も固定にはしない。timeout と backoff の調整を入れるとこんな感じ。

  count = 1
  Timeout.timeout(timeout) do
    loop do
      TCPSocket.new(host, port).close
      return
  rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL => e
    sleep backoff
    count += 1
    backoff *= multiplier**count
  end

timeout 時間とか backoff の初期値とか multiplier は自分で考える。まぁ今なら AI がよしなにそれっぽいものを出してくれるからそれを使う。

ただこれも今度は発生するエラーが Timeout になってしまい、Timeout してしまう原因となっている例外は握りつぶされていることになる。最後に発生した例外を保存しておいて Timeout エラーの情報にその例外を与えてやると原因究明に役に立つかもしれない。

なんかgemあるのかも

実際にいちいちこういうのゼロから書くの面倒だし、process と process group の違いとか意識したくないなぁと思うんだけど、group 内のプロセスが一つしかない場合は process group id は process id と一致しているので、常に process group だと思って扱えばよい。ただ、マイナスを与えるとか、見慣れてないとパッと理解できないので、なんか gem になってる方がよいなと思うし、実際あるのかもしれない。(調べられていない。)

というのも npm だと

pkrumins/node-tree-kill: kill trees of processes

があって、これ使ってるとこういうの意識しなくてもまとめてざっくり終了、みたいなコードは簡単に書けるので。

More