平常運転

アニソンが好き

過去記事とかは記事一覧で見れます

Server::Starterの0.17以下のバージョンでは特定の条件下でHUPを送ってもプロセスが入れ替わらないことがあった

要約

Server::Starterの0.17以下のバージョン(とStarlet)によって動かしているときに無限ループ等でいつまでも処理の終わらないリクエストが発生すると、アプリケーションプロセスの再起動のためのHUPシグナルをServer::Starterが正しく処理してくれないことがある。
この挙動によってアプリケーションプロセスが古いリビジョンで動かしてしまうなどの問題があって困っていたんだけど、気付いたら0.19でこの挙動が改善していた。

前提

perldaemontoolsを用いてアプリケーションプロセスを動かし、かつホットデプロイを実現しようと思ったときの有力な選択肢がServer::Starterによるstart_serverとStarletの組み合わせだと思う。start_serverとStarletは以下のような挙動を示す。

  • start_serverはHUPを受け取ったときに新しいworker(配下のプロセス)を立ち上げ、その後古いworkerにTERMを送る
  • StarletはTERMを受け取ると現在処理中のリクエストが終わるまで待ってから死ぬ

ここで、両者を組み合わせた次のような構成のプロセスを動かすことを考える。
(議論の簡略化のためStarletの子workerの存在は無視している)

carton exec -- start_server --port 80 -- plackup -s Starlet -a app.psgi

このプロセスに対してHUPを送ったとき、次のような流れでStarletのプロセスが入れ替わる。

  • start_serverにHUPが渡される
  • start_serverがStarletの新しいプロセスを立ち上げる
    • この時点では新旧両方のStarletプロセスが存在している
  • 新しいプロセスが立ち上がったら、start_serverが古いStarletのプロセスにTERMを送る
  • 古いStarletのプロセスは現在処理しているリクエストが終わり次第終了する
  • 結果、新しいプロセスだけが残って入れ替え完了

当然、もう1回HUPを送ると同じようにプロセスが入れ替わる。

  • start_serverにHUPが渡される
  • start_serverがStarletの新しいプロセスを立ち上げる
    • この時点では新旧両方のStarletプロセスが存在している
  • 新しいプロセスが立ち上がったら、start_serverがさっきまで新しかったStarletのプロセスにTERMを送る
  • さっきまで新しかったStarletのプロセスは現在処理しているリクエストが終わり次第終了する
  • 結果、新しいプロセスだけが残って入れ替え完了

0.17の挙動

start_server@0.17は、HUPを受け取ったときに上記の2回目のHUPの際に、1回目のHUPでTERMを送られた"古いプロセス"がまだ存在していたならば、その"古いプロセス"が終了するまで次の"新しいプロセス"を起動せずに待つ、という挙動だった。

具体的なコードを以下に引用する。
シグナルを受け取ると以下のように$restart_flag = 1が立ち、

            if ($signals_received[0] eq 'HUP') {
                print STDERR "received HUP (num_old_workers=$num_old_workers)\n";
                $restart_flag = 1;

次のように$restart_flagが1の時、古いプロセスが終了してから新しいプロセスの立ち上げを始める。

        if ($restart_flag > 1 || ($restart_flag > 0 && $num_old_workers == 0)) {
            print STDERR "spawning a new worker (num_old_workers=$num_old_workers)\n";

挙動

ここで、次のような状況を考える。

  • 動作しているアプリケーションプロセスが不幸にして暴走しており、(無限ループなどで)プロセスのリクエスト処理が終わらない状況になっている

1回目のHUP

この時、1回目にstart_serverにHUPを送信すると以下のように処理される。

  • start_serverにHUPが渡される
  • start_serverがStarletの新しいプロセスを立ち上げる
    • この時点では新旧両方のStarletプロセスが存在している
  • 新しいプロセスが立ち上がったら、start_serverが古いStarletのプロセスにTERMを送る
  • 古いStarletのプロセスは現在処理しているリクエストが終わらないのでずっと生き残り続ける

この結果、アプリケーションプロセスは以下のような状況になる。

  • Starletは新旧2プロセスが起動している
  • 新しいリクエストは全て新しいStarletのプロセスが処理する
    • 古いStarletのプロセスはリクエスト処理中のままなので新しいリクエストの処理を行わない

つまり、この時点ではアプリケーションの動作を観測している限りは特に異常に気付かない。ここで送信したHUPがデプロイ目的だった場合、新しいリクエストは新しいStarletプロセスが処理するので問題なく新しいリビジョンに切り替えができる。

2回目のHUP

上記の状況が続いている状況で問題に気付かずさらにHUPを送った場合、今度は次のような挙動を示す。

  • start_serverにHUPが渡される
    • この時点ではいつまでも死なない古いプロセス1回目のHUPで起動したプロセスが存在している
  • 古いStarletプロセスがまだ存在しているので、start_serverは新しいプロセスを立ち上げずにじっと待つ
  • 古いプロセスが終わらない限り永遠に新しいプロセスは立ち上がらない
  • 結果的にHUPを受け付けなかったように見える

一度こうなると、何度start_serverにHUPを送っても同じ状況のまま変わらなくなる。
ただし、この状況で古いStarletプロセスをちゃんと殺せば状況は正常化される。古いStarletプロセス配下で該当リクエストを処理してるプロセスを手でkill -TERMすると、以下の流れで正常化される。

  • 古いプロセスkillされて死ぬ
  • 古いプロセスがいなくなったので、start_serverがStarletの新しいプロセスを立ち上げる
    • この時点でさっきまで新しかったプロセス新しいプロセスの2つが立ち上がっている
  • 新しいプロセスが立ち上がったら、start_serverがさっきまで新しかったStarletのプロセスにTERMを送る
  • さっきまで新しかったプロセスが死んだので、無事新しいプロセスに入れ替え完了

問題

上記の流れを見ると想像が付くと思うけれど、2回目(以降)にHUPを送っても新しいアプリケーションプロセスが立ち上がらない。
そのため、2回目のHUP送信がデプロイ目的だった場合は古いリビジョンが動き続けることになってしまう。
これは勿論「意図したデプロイができていない」という意味で問題だし、「デプロイごとにディレクトリを分け、古いリビジョンのディレクトリはしばらく経ったら削除する」という運用の場合は最悪動作中のリビジョンのディレクトリが削除されていきなりエラーが来るということもありえる。

対策

もちろん、根本的には終わらないリクエストを発生させるアプリケーションが悪くてアプリケーションプロセスがヘルシーなら問題は起こらない。
また、対症療法としては"1回目"のHUP送信からしばらく経っても古いプロセスが生き続けていたときにそれをズバッと殺してしまえば自体は解消する。(また、古いプロセスが生き続けてるとlogrotateに失敗してログがディスクを食い続けるというパターンもあり得るので、そういう観点がクリティカルな場合はいずれにせよ古いリクエストをちゃんと殺さないといけない。)

最初は古いプロセスを見つけたときに手で殺して対策していて、後々スクリプトで自動化することでとりあえずこの問題に対処できた、ということになっていた。

0.19での変更

ところが、先日Server::Starterの0.19がリリースされた。このバージョンでは、for stabilityとして該当部分の再実装が行われた。
Changes - metacpan.org

0.19
- reimplement changes in 0.15, 0.16 for stability (#13)
- update inc/Module/Install

その結果HUPを受け取った際の挙動が変わって、古いプロセスの終了を待たずに新しいプロセスを立ち上げ、その後で古いプロセスさっきまで新しかったプロセスの両方にTERMを送るようになった。

        if ($restart) {
            $old_workers{$current_worker} = $ENV{SERVER_STARTER_GENERATION};
            $start_worker->();
            print STDERR "new worker is now running, sending $opts->{signal_on_hup} to old workers:";

この結果として、上で取り上げた"2回目"にHUPを送ったときのフローが以下のようになった。

  • start_serverにHUPが渡される
  • 古いStarletのプロセスが存在するが、気にせずstart_serverがStarletの新しいプロセスを立ち上げる
    • この時点で、古いプロセスさっきまで新しかったプロセス新しいプロセスの3つが立ち上がっている
  • 新しいプロセスが立ち上がったら、start_serverが古いプロセスさっきまで新しかったStarletのプロセスにTERMを送る
  • さっきまで新しかったプロセスが死んで、新しいプロセスに入れ替わる

手元で確認した限りでは"古いプロセス"は2度目のTERMを受け取っても不幸なことに死なないままだったんだけど、少なくともHUPによるプロセスの入れ替えは行えるようになったので、だいぶ挙動は改善されたと言える。
該当するissueを見る限りこれを意図して変更されたと言うよりは副次的にこうなったという感じに見受けられるんだけども、結果的にこれによって救われた。

本当ならissueにするとかテストケース書いてp-rするとかしたらいいんだろうけど、とりあえず記録してここに記す。