序章
最近筆者があるシステム上の非同期ワーカーに対して作業していたところ、新しいコードをデプロイしてこのプロセス達を再起動すると全てのワーカーが同じタイミングで停止→再起動してしまうのでアラートがちらほら流れてきました。クリティカルなものではないのですが、アラートはうざいです。さらに開発機では何回か失敗もしたのですが、その失敗のせいでワーカーが起動に失敗することもありました。その間は当然ワーカー機能は止まったままです。
アラートはできればみたくないのです。さらに万が一新しいコードが起動に失敗した場合でも前の世代が動いていればこのあたりの心配をする必要がなくなります自分がそのあたりに手を入れるタイミングでServer::Starterをかまして対処してしまうことにしました。
元のワーカー
まず前提として、このワーカー達は以下のような形で「実行するワーカーのコマンド名(実際はクラス名)」と「いくつのプロセスを立ち上げるか」というようなコマンドライン引数を与えられて起動されていました。
#!/bin/sh
exec ./bin/worker-runner.pl --max_workers=5 --command=Hoge 2>&1
そしてさらにその中では以下のようにParallel::Preforkを使って必要な個数のワーカーが立ち上がっていました。
use strict;
use Parallel::Prefork;
#(中略)
my $pp = Parallel::Prefork->new(
max_workers => $opts->{max_workers},
trap_signals => {
TERM => 'TERM',
HUP => 'TERM',
}
);
while ($pp->signal_received !~ /TERM/) {
$pp->start(sub {
my $obj = $command->new(...);
$obj->run();
});
}
# (中略)
Server::Starterでプロセスを守る
まずデプロイしたコードになぜか間違いで構文エラーが混入してしたとか、必要なディレクトリやファイルが作成されていなくて起動に失敗した・・・そんな不慮の事態にも備えつつある程度安心して再起動ができるように対応します。これは簡単です。Server::Starterをインスールして、起動スクリプトを変更するたけです
#!/bin/sh
exec start_server -- \
./bin/worker-runner.pl \
--max_workers=5 \
--command=Hoge \
2>&1
これだけです!ポイントは、Plackなどをラップしているときは start_serverコマンドに--port引数をつけていたのを何も明記しない、というだけです。
Server::Starterは元々任意のポートにlistenしているサーバーを再起動するために作られた物ですが、このようにポートにlistenしていない状態ならただ単純に「与えられた引数のコマンドがちゃんと起動できたら以前のプロセスを停止する」という動作をするのでこのようなことが可能になります。
これでワーカーを起動するまでの部分は守られます。当然ながらワーカーが起動した後に起こる問題に関してはこれだけでは検知不可能なのでご注意を。Server::Starterをかましたからといってワーカーを再起動する時に確認を怠っていいというわけではありません。
slow-restartに対応する
今回のワーカーのように複数プロセスが動作しているデーモンにおいて、何も考えずにデーモンを書くとシグナルを送って再起動する際に全てのプロセスがほぼ同時に停止し、さらに新しい世代のプロセスがほぼ同時に起動する状態になってしまいます。
この瞬間一時的にホストマシン上のリソースを同時に奪い合うため、マシン全体のレスポンスが悪くなってしまったりすることがあります。我々の場合は具体的にはロードアベレージが一瞬20近くまで上がってしまう状態でした。
これを回避するためにslow-restartという手法をとることができます。これに関してはそもそも今回紹介しているServer::StarterとParallel::Preforkのいずれも書いている奥一穂氏の発表が詳しいです。ここではあまり詳しい仕組みについてはふれないで、あくまで自分で書いたワーカーでこの仕組みを使うために何をするかだけをまとめています。
さて、というわけでslow-restartです。まずParallel::Preforkに「子プロセスを起動する場合は間隔をあける」ように指定する必要がります。コマンドライン引数からこの値を変更できるようにしておくと便利なので以下のように変えました:
#!/bin/sh
exec start_server -- \
./bin/worker-runner.pl \
--max_workers=5 \
--command=Hoge \
--spawn_interval=1 \
2>&1
スクリプト側ではGetopt::Long等を使ってこの引数を$opts->{spawn_interval}に渡して、それをParallel::Preforkに渡します:
# (中略)
my $pp = Parallel::Prefork->new(
max_workers => $opts->{max_workers},
spawn_interval => $opts->{spawn_interval},
trap_signals => {
TERM => 'TERM',
HUP => 'TERM',
}
);
これでサーバーを再起動した際に子プロセスは同時にシグナルが送られ停止しますが、再起動時には1秒ごとに子プロセスが起動するようになり、同じタイミングで全てのプロセスが起動することはなくなります。
これだけでも元の問題に対してはかなり効果があるのですが、さらにもう一歩進めて、子プロセスにシグナルが送られるタイミングも少しずつずらせるようにします。
# (中略)
my $pp = Parallel::Prefork->new(
max_workers => $opts->{max_workers},
spawn_interval => $opts->{spawn_interval},
trap_signals => {
TERM => 'TERM',
HUP => 'TERM',
USR1 => [ 'TERM', $opts->{spawn_interval} ],
}
);
こんどはtrap_signalsに新たなオプションが追加されました。この追加された行は「USR1シグナルを受け取った場合は 子プロセス達にTERMシグナルを送るが、その際間に$opts->{spawn_interval}秒間をあける」という意味になります。これを先ほどのspawn_interval引数の機能と合わせると「旧世代のワーカーがUSR1シグナルを受け取った場合、1秒ごとに1個子プロセスがTERMシグナルを受け取り、順次停止していく。同時に新世代のワーカーは1秒ごとに新しい子プロセスを起動していく」という動作が完成します。
これにより、稼働しているワーカープロセスがゼロという状態は極力作らずにきれいに世代交代が可能になります。
最後にもう一つだけつなぎこみを行います。デフォルト状態ではstart_serverスクリプトはHUPでもTERMでもシグナルを受け付けると、管理対象プロセスにはTERMを送ります。しかし最初に説明したようにこのslow-restartの仕組み全体を使うにはUSR1シグナルを送る必要があります。そこで、start_serverスクリプトに「HUPをうけとったら管理プロセスにはUSR1を送っておいて」と指定する必要があります:
#!/bin/sh
exec start_server --signal-on-hup=USR1 -- \
./bin/worker-runner.pl \
--max_workers=5 \
--command=Hoge \
--spawn_interval=1 \
2>&1
これで、start_serverにTERMを送れば通常再起動になり、HUPを送れば世代入れ替えを行いつつ間隔をあけて子プロセスにシグナル送られるようになります。
まとめ
奥一穂氏のツールを使ってデーモンの管理を少しだけ楽にしてみました。
明日はsyohexさんです!