2022年10月2日日曜日

Node.jsサーバーのgraceful shutdownまとめ

 Node.jsでプロダクションレベルのサービスを開発している人にとっては今更感のある、ウェブサーバーをgraceful shutdownする方法について。調べれば色々出てくるんですが、日本語で体系的にまとまっているものが少ない気がしたので。

以下ではコード例をいくつか載せていますが、そのままnodeコマンド(Node.js v15以降)の引数として渡せば実行できます。package.jsonは不要です。Expressなどのhttp.Serverを継承したフレームワークでも同じように動かせるはずです。

graceful shutdownとは

簡単に説明すると、「サーバーが不意に終了しても、終わる前にちゃんと後片付けをすること」です。

具体的には以下のようなことを行います。

  1. 終了シグナルを受け取る
  2. 新規のリクエスト受付を停止する
  3. 処理中のリクエストは最後まで処理する
  4. 使っていたリソース(DB接続やファイルなど)を閉じる

会社の退職時に急にいなくなるんじゃなくて、残務を最後までやったりデスクを片付けたりするようなものだと思ってください。

ウェブサービスを無停止でバージョンアップするときには必須です。

なお、graceful shutdownできるのは、具体的にはkillコマンド(SIGTERM)とかCtrl+CSIGINT)とかのプログラム内で通知を受け取れるシグナルに限ります。強制終了(SIGKILL)のように通知を受け取れないものは、そもそも1の終了シグナルの受け取りができないので無力です。そして当たり前ですが、急に電源が切れた場合にもどうしようもありません。

graceful shutdownの流れ

流れ自体は難しくありません。上で書いた4つのことをそのまま行います。

ただし、これをNode.jsのコードに落とす場合は少し気をつけることがありますので、以下で説明します。

何も考えないウェブサーバー

まずは、graceful shutdownを行わない簡単なサービスを作ってみます。以下のコードをgraceful.jsという名前で保存してください。

const {setTimeout} = require("timers/promises");
const http = require("http");
const server = http.createServer((request, response) => {
	// 10秒後に地獄の言葉
	setTimeout(10000)
		.then(() => {
			response.end("Hell Word\n");
		});
})
server.listen(3000);

リクエスト後にサーバーに終了シグナルを出したときの動作確認がやりやすいように、リクエストから10秒後に地獄の言葉を返しています。Node.js v15でPromise版のsetTimeout()が登場したので、待機にはこれを使います。

動かしてみる

端末を開き、以下のコマンドでサーバーを起動します。

$ node graceful.js

この状態で http://localhost:3000 にアクセスします。ウェブブラウザーでも別端末からのcurlでも構いません。

$ curl http://localhost:3000
Hell Word
$ 

想定通り、10秒後に地獄の言葉が返ってきました。

では続いて、 http://localhost:3000 にアクセスして10秒以内(レスポンスが来る前)にサーバーを終了させます。graceful.jsを実行中の端末でCtrl+Cを押してください。すると、 http://localhost:3000 のリクエスト結果はどうなるでしょうか。

$ curl http://localhost:3000
curl: (52) Empty reply from server
$ 

サーバーを終了させた瞬間にこうなりました。急にサーバーが落ちたので何もレスポンスを得られなかった状態です。残務を放って急に退職するとこうなります。

基本的なgraceful shutdown

ではgraceful shutdownを実装してみましょう。まずは以下の3つを実装します。

  1. 終了シグナルを受け取る
  2. 新規のリクエスト受付を停止する
  3. 処理中のリクエストは最後まで処理する

これらはそのままの機能が用意されているので難しくありません。

const {setTimeout} = require("timers/promises");
const http = require("http");
const server = http.createServer((request, response) => {
	// 10秒後に地獄の言葉
	setTimeout(10000)
		.then(() => {
			response.end("Hell Word\n");
		});
})
server.listen(3000);

// graceful shutdownの設定
server
	.on("close", () => {
		// リクエストの処理がすべて終わったらここに来る
		console.info("リクエスト処理完了");
	});

process
	.on("SIGINT", () => { // 1. 終了シグナルを受け取る
		console.info("終了シグナル受信");

		// 2. 新規のリクエスト受付を停止する
		// 3. 処理中のリクエストは最後まで処理する
		// リクエストの処理がすべて終わったら、サーバーにcloseイベントが発火される
		server.close();
	});

SIGINTなどのシグナルはprocessで受け取ります。ちゃんと実装するならSIGTERMSIGHUPとかも処理すべきとか、ログ出力にconsoleは使うべきではないとか色々ありますが、それは今回の本質じゃない。気にするな。お前ならちゃんとやれる。

そして新規リクエストの受け付け停止と既存リクエストの処理は、server.close()というそのままのメソッドがあります。まさにgraceful shutdownのために用意された機能ですね。

リクエストの処理がすべて終わったら、サーバーインスタンスにcloseイベントが発火されます。

動かしてみる

早速動かしてみましょう。先ほどと同じように、nodeコマンドでサーバーを起動し、 http://localhost:3000 にアクセスします。

# サーバー起動
$ node graceful.js
# 別端末からアクセス
$ curl http://localhost:3000

そして同じく10秒以内にサーバー側をCtrl+Cで終了させます。先程は終了させた瞬間に別端末からのアクセスも切れてしまいましたが、今度はどうでしょうか。

# サーバー起動
$ node graceful.js
^C終了シグナル受信
リクエスト処理完了
$ 
# 別端末からアクセス
$ curl http://localhost:3000
Hell Word
$ 

今度はサーバー側もすぐには終わらず、別端末のリクエストを処理してから終了しました。「リクエスト処理完了」というメッセージも、別端末に地獄の言葉が表示されてから出てきます。これがgraceful shutdownです。

では、サーバーにCtrl+Cで終了シグナルを送ってから別端末のリクエスト処理が完了する前、つまり地獄の言葉が表示される前に、さらに別の端末からアクセスしてみるとどうでしょうか。

# サーバー起動
$ node graceful.js
^C終了シグナル受信
# 別端末からアクセス
$ curl http://localhost:3000
# ↑の端末にレスポンス表示される前に、さらに別の端末からアクセス
$ curl http://localhost:3000
curl: (7) Failed to connect to localhost port 3000 after 0 ms: 接続を拒否されました
$ 

接続が拒否されました。つまりサーバーに終了シグナルを送った後のリクエストは受付を停止していることがわかります。これも希望通りですね。

後処理をする

はい、めでたしめでたし・・・ではありません。ここまでならちょっと調べればいくらでも出てきます。

大事なのはその後の処理、つまりリソースの片付けです。開いているコネクションやファイルなどはシステムが勝手に片付けてくれることも多いですが、そういった行儀の悪いプログラムを嫌う方もいます。退職時にデスクを片付けなくても残った方が片付けてくれますが、立つ鳥は跡を濁さないほうがいいですよね。

また、何かしらのロックをかけている場合に終了時に解除するとか、終了したこと自体をログに残したいとか、色々やりたいことはあると思います。

この後処理は一般にI/Oが絡むので、非同期関数として実装する必要があります。例えば、以下のような後処理に1秒ほどかかる関数を作ってみます。

// 後処理(DB接続を閉じたりファイルをフラッシュしたり)
async function postprocess() {
	// 後処理に1秒かかるとする
	await setTimeout(1000);
}

これをコールするのはもちろんリクエストの処理がすべて終わった後なので、具体的にはcloseイベントの中です。後処理関数はPromiseを返すので、イベントハンドラーも以下のようにasync関数にします。

server
	.on("close", async() => { // ←async関数にする
		// リクエストの処理がすべて終わったらここに来る
		console.info("リクエスト処理完了");

		// 後処理
		console.info("後処理開始");
		await postprocess(); // 1秒かかる
		console.info("後処理完了");
	});

動かしてみる

このように書き換えたサーバーを先ほどと同じように走らせてみます。

# サーバー起動
$ node graceful.js
^C終了シグナル受信
リクエスト処理完了
後処理開始
後処理完了
$ 
# 別端末からアクセス
$ curl http://localhost:3000
Hell Word
$ 

「後処理開始」が表示されて1秒後に「後処理終了」が表示されました。

asyncコールバック関数について

これで万事OK・・・のように見えます。一応希望通りの動作をするので細かいことを気にしなければこれでもいいんですが、ここで使ったasyncコールバック関数についてちょっと補足しておきます。

先ほどのコードが想定通り動くことで勘違いされがちなのですが、そもそもの話としてNode.jsの標準ライブラリーでは、イベントハンドラーはPromiseに対応していません

どういうことかというと、closeイベントの処理を以下のように2回繰り返してみます。

server
	.on("close", async() => {
		// リクエストの処理がすべて終わったらここに来る
		console.info("リクエスト処理完了(1)");

		// 後処理
		console.info("後処理開始(1)");
		await postprocess(); // 1秒かかる
		console.info("後処理完了(1)");
	})
	.on("close", async() => { // ←もう1つつなげる
		// リクエストの処理がすべて終わったらここに来る
		console.info("リクエスト処理完了(2)");

		// 後処理
		console.info("後処理開始(2)");
		await postprocess(); // 1秒かかる
		console.info("後処理完了(2)");
	});

2つのイベントハンドラーのメッセージが区別つくように、(1)(2)の番号をつけました。このサーバーを起動後、Ctrl+Cで終了するとどのような順番でメッセージが表示されるでしょうか。

もしイベントハンドラーがPromiseに対応していれば、(1)の後処理まですべて終わってから(2)が開始され、停止するまで全体で2秒かかるはずですが果たして。

# サーバー起動
$ node graceful.js
^C終了シグナル受信
リクエスト処理完了(1)
後処理開始(1)
リクエスト処理完了(2)
後処理開始(2)
後処理完了(1)
後処理完了(2)
$ 

(1)と(2)が交互に表示され、停止するまでは1秒でした。このことから、イベントハンドラーがPromiseを返しても内部でawaitをしているわけではないことがわかります。ここの理屈がわからない方は、Node.jsの非同期処理についてあらためて学習してください

このことを理解した上でasync関数を使っているのならいいんですが、そうではなく普通にイベントハンドラーがasyncに対応していると勘違いしている人もチラホラいるようなので気をつけましょう。ましてや「よくわからんけど動いてるからヨシ!」は絶対やめましょう

あなたが理解していても、チーム開発では他の人が勘違いするかもしれません。勘違いを生まないように、以下のように書くといいかもしれません。

server
	.on("close", () => { // 勘違いを生まないためにasync関数は使わない
		// リクエストの処理がすべて終わったらここに来る
		console.info("リクエスト処理完了");

		// 後処理
		console.info("後処理開始");
		postprocess() // 1秒かかる
			.then(() => {
				console.info("後処理完了");
			});
	});

ちゃんとgraceful shutdownするために

これでgraceful shutdownの方法を一通り解説しました。ただしちゃんとしたサービスに組み込む場合はこれでは不十分で、

  • SIGINTだけではなくSIGTERMとかSIGHUPにも対応したほうがええんちゃうか
  • Ctrl+Cを連打した場合のように、連続で終了シグナルを受け取ったときに2回目以降は無視したほうがええんちゃうか
  • 後処理に失敗した場合は、エラーを出力したり0以外の終了コードにしたほうがええんちゃうか

など色々あります。お前ならちゃんとやれる。

0 件のコメント:

コメントを投稿