はじめに
これは Bluemix の sdk-for-nodejs ビルドパックを使用した Node.js + Express アプリを対象にした記事です。
Bluemix コンソールや cf コマンドで Node.js + Express アプリを停止させる時に、とある REST API を呼びたいと考えて調査をしました。この記事はその副産物です。
まとめ
Clound Foundry ではアプリを停止させるとき、SIGTERM シグナルがアプリのプロセスに対して送信されます。そこで、終了処理は SIGTERM シグナルのイベントハンドラとして実装します。
あとは Node.js の Process オブジェクトを使って SIGTERM シグナルのイベントハンドラに終了処理を実装しましょう。ただし、あまり時間がかかる処理は許されません。10秒後には強制終了されますのでお気をつけて。
以下、V3.13以前でのお話になります。こちらに記載していた事象は 2017/10/6 にリリースされた V3.14 でバグとして修正されたようです。
A buildpack bug, which prevented Node.js apps from shutting down gracefully, was fixed.
Announcement: The SDK for Node.js buildpack V3.14 is now available
https://console.bluemix.net/status/notification/86d823561d42ce50bae502acbb8ee364
ここからは古い記事です。
注意点として、Cloud Foundry の Node.js ビルドパックと Bluemix の sdk-for-nodejs ビルドパックでは動作が異なります。その上、どちらのビルドパックでもデフォルトの構成では SIGTERM シグナルを受信できません。
Bluemix の sdk-for-nodejs ビルドパックのランタイムでは、アプリはデフォルトで「アプリ管理ユーティリティー」の元で動作するためです。
この問題に対応するためには、アプリ管理ユーティリティーをインストールしないという選択が必要です。そうすれば、SIGTERM シグナルを受け取ることが出来るようになり・・・ません。もう一つ関門があります。
ここからは Cloud Foundry の Node.js ビルドパックの場合にも該当するのですが、アプリの開始コマンドを npm start コマンド (package.json ファイルの scripts.start) を使って指定していると、アプリ管理ユーティリティーがインストールされている場合と同じように npm が親プロセスとなってシグナルを受け取ってしまいます。
こちらのケースでは Procfile ファイルで開始コマンドを指定するか、scripts.start で指定するコマンドを exec を使って親プロセスで実行するようにすることで問題を回避できます。
Procfile を用意して対応する場合は
web: node app.js
といった感じのファイルをルートに置いてアプリをデプロイします。
Procfile ファイルの役割についてはCloud Foundry のドキュメントを参照してください。
以上で、SIGTERM シグナルを受け取ることが出来るようになります。
調べたこと
必要な情報は以上です。以下はおまけです。
Cloud Foundry はアプリをどうやって停止するのか
そもそも、sdk-for-nodejs ランタイムは cf stop したときにどのような挙動をするのでしょうか。
Clound Foundry のドキュメントを参照してみます。
CF sends the app process in the container a SIGTERM. The process has ten seconds to shut down gracefully. If the process has not exited after ten seconds, CF sends a SIGKILL.
sdk-for-nodejs に限らず、Cloud Foundry がアプリを停止させる時の動作としては、アプリケーションのプロセスに対して SIGTERM シグナルが送信された後、10秒後には SIGKILL シグナルが送信されるようです。
Express/Node.js は SIGTERM を受け取るとどうなるのか
SIGTERM はプロセスを終了させるシグナルなので、アプリがハンドルしない限りは実行中の処理があっても割り込まれた段階でプロセスが終了するというのがデフォルトの動作になります。
Express がハンドルしている場合はドキュメントに何かしら記述があると期待できると思うのですが、見つけられませんでした。(server.close() くらいしてくれているかもと期待したのですが、そうではないようです。)
では Node.js レベルではどうでしょうか、Express の app.listen() が起点となってアプリケーション(サーバー)が動いているわけなので、そこもチェックしておきます。
まず、Express のドキュメントによれば app.listen() は Node.js でいうところの http.Server.listen() と同じであるということでしたが、こちらも特にはシグナルについての記述はありませんでした。シグナルによる割り込みが発生しても何もしてくれないようです。
では、サーバーとは関係なく Node.js を実行環境として考えたときはどうでしょうか。Node.js アプリが SIGTERM シグナルを受け取った場合のデフォルトのふるまいは Process オブジェクトのシグナルイベントの項に説明があります。
SIGTERM and SIGINT have default handlers on non-Windows platforms that resets the terminal mode before exiting with code 128 + signal number. If one of these signals has a listener installed, its default behavior will be removed (Node.js will no longer exit).
端末のリセット処理はしてくれるようですが、結局はプロセスをその場で終了させるようですね。
結論としては、Express および Node.js は SIGTERM シグナルを受け取ったら、いろいろお節介をやいたりせずに、さくっと終了することがわかりました。
終了処理を実装してみる
さて、Cloud Foundry 上の Express アプリがどのように停止するかがわかったところで、本題の終了処理を実装することにしたいと思います。
Node.js の Process オブジェクトにはシグナルを処理するためのイベントが用意されているのでこれを利用すれば良さそうです。
では早速、SIGTERM を受け取った時にイベントが発生するか確かめてみましょう。
まずは Bluemix コンソールから Cloud Foundry の Node.js アプリを作成した場合にインストールされる NodejsStarterApp に対して各イベントハンドラを追加して実験してみます。
| イベント | 発生 | 
|---|---|
| SIGTERM | しない | 
| SIGINT | しない | 
| exit | しない | 
| beforeExit | しない | 
あれ、いずれも反応しません。
・・・ととぼけてみましたが、最初の「まとめ」でお話した通り「アプリ管理ユーティリティー」や「npm」がシグナルを受けとるのですが、彼らは子プロセスであるところのわれらがアプリにはシグナルを送ってくれないようです。残念。
どこのどいつがシグナルを受け取っているのか調べる
「アプリ管理ユーティリティー」にたどり着くまでの説明です。
Cloud Fooundry のドキュメントにはさらっとしか記述がありませんが、アプリの開始時に実行されるコマンドは staging_info.yml に設定されています。
アプリ管理ユーティリティーが有効な状態で cf ssh で接続して覗いてみます。
$ cat staging_info.yml 
{"detected_buildpack":"","start_command":"./vendor/initial_startup.rb"}
$
./vendor/initial_startup.rb を覗いてみると、IBM SDK for Node.js Buildpack を構成するものの一つのようです。この中でアプリ管理ユーティリティーと思しき .app-management/scripts/start を exec しています。
これで、シグナルはアプリ管理ユーティリティーが受け取っていることがわかりました。
実際のプロセスも確認してみます。
$ ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 09:13 ?        00:00:00 /proc/self/exe init
vcap           7       0  0 09:13 ?        00:00:00 /tmp/lifecycle/diego-sshd --allowedKeyExchanges= --address=0.0.0.0:222
vcap          12       0  0 09:13 ?        00:00:00 bash .app-management/scripts/start 8080
vcap          41      12  0 09:13 ?        00:00:00 npm                                                                   
vcap          62      41  0 09:14 ?        00:00:00 sh -c node app.js
vcap          63      62  0 09:14 ?        00:00:00 node app.js
vcap          83       7  0 09:14 pts/0    00:00:00 /bin/bash
vcap         159      83  0 09:16 pts/0    00:00:00 ps -ef
$
.app-management/scripts/start -> npm -> shell -> node という親子関係になっているのが分かりますね。
余談ですが、.app-management/scripts/stop というスクリプトもあったので、.app-management/scripts/start で SIGTERM を trap してそいつを呼んでくれればどうにかなったんじゃないかと思わなくもないです。
さて、アプリ管理ユーティリティーをインストールせず、起動するコマンドも直接 node とすることで次のようになります。
$ cat staging_info.yml
{"detected_buildpack":"","start_command":"node app.js"}
$ 
$ ps -ef 
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:50 ?        00:00:00 /proc/self/exe init
vcap           7       0  0 11:50 ?        00:00:00 node app.js
vcap          12       0  0 11:50 ?        00:00:00 /tmp/lifecycle/diego-sshd --allowedKeyExchanges= --ad
vcap          56      12  0 11:51 pts/0    00:00:00 /bin/bash
vcap         137      56  0 11:55 pts/0    00:00:00 ps -ef
$ 
すっきりしました。
イベントがどうなるか確認します。
| イベント | 発生 | 
|---|---|
| SIGTERM | する | 
| SIGINT | しない | 
| exit | しない | 
| beforeExit | しない | 
めでたし、めでたし。
SIGTERM のイベントハンドラを実装した場合の正常終了
ユーザーが SIGTERM のイベントハンドラを実装すると前述のデフォルトのふるまいにはならず、プロセスが終了しません。
用が済んだら process.exit() でプロセスを終了しましょう。そうすれば exit イベントも発生するようになります。
終了させないと Cloud Foundry がしびれを切らして SIGKILL を送信して強制終了となります。
Monitoring and Analytics サービスのパフォーマンス・モニター
アプリ管理ユーティリティーを無効にしても Monitoring and Analytics サービスのパフォーマンス・モニターは見れました。
パフォーマンス・モニターは liberty-for-java と sdk-for-nodejs にしか対応していないので、アプリ管理ユーティリティーが機能の一翼を担っていたりしそうだなあ、なんて邪推をしていたのですが、ビルドパックのレベルではなくて、IBM 版 Node.js としての IBM SDK for Node.js のレベルで含まれている機能のようですね。
どこでどのように有効化しているのかさっぱりわかりせんが、詮索はこのくらいにしておきます。
おまけ
applications:
- path: .
  buildpack: sdk-for-nodejs
  env:
    BLUEMIX_APP_MGMT_INSTALL: false
process.on('SIGTERM', () => {
  console.log(`SIGTERM`);
  server.close(() => {
	console.log(`ここで終了処理をする。`);
    process.exit(0);
  });
});