1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

【解説付き】Node.js + ExpressのAPIサーバをgraceful shutdownする【実装例付き】

Last updated at Posted at 2024-07-17

はじめに

こんにちは!Gopherくん大好きマンの@o_ga09です!

TL;DR

  • この記事を読むと
    • Graceful Shutdownを理解できる
    • Graceful Shutdownを実装しないと起き得るエラーを理解できる
    • Graceful Shutdownを実装できるようになる

対象読者

  • バックエンドエンジニア
  • コンピュータにおけるプロセス・スレッドが理解している
  • 何かしらの言語でAPIサーバの実装経験がある
  • Dockerを使用したことがある

免責事項

本記事における実装はサンプルです。本記事を参考にして実装されたコードで起こる障害に関して筆者はいかなる責任も負いかねます。

目次

事象

リリース時のECSタスクが置き換わったタイミングで以下のエラーが毎回発生する。

Auroraエラーログ
[Warning] Aborted connection xxx to db: '<database name>' user: '<user name>' host: '<host IP>' (Got an error reading communication packets)

実害はないが、リリース後に毎回発生するのはドキドキする。

原因

ECS on Fargate のローリングアップデート時、コンテナがスケールインする際にDB切断処理が行われていないため。

以下公式の回答

Dockerなどのコンテナ仮想化エンジンは、通常、コンテナを停止させる際にSIGTERMというプロセスシグナルを送信して停止しますが、それでも停止できない場合にSIGKILLというプロセスシグナルを送信することで強制的にコンテナを停止させます。

大体の場合は、SIGTERMというプロセスシグナルで停止可能なので問題ないのですが、
今回のようにNode.jsの場合は、PID:1問題(後述)があり、SIGTERMというプロセスシグナルで停止できないのです。

なので、今回は明示的にサーバ終了処理をいれて適切にコンテナを停止できるようにしましたというお話です。

アーキテクチャ

ユーザーの窓口に、APIGateway
コンピューティングに、ECS on Fargate
DBに、AmazonAurora for MySQL

を使用したごくシンプルなサーバレス構成

achitecture.png

1. Graceful Shutdownとは?

そもそも、APIサーバをシャットダウンさせる方法は、大き分けて2つあります。

  • グレースフルシャットダウン (Graceful Shutdown)
  • 即時/強制シャットダウン (Immediate/forceful Shutdown)

これら2つにはどのような違いあるのでしょうか?また、それぞれにはどのようなメリット/デメリットがあるのでしょうか?

グレースフルシャットダウン (Graceful Shutdown)

  • 進行中のリクエストを完了させてから、サーバーを停止します
  • 新しいリクエストは受け付けません
  • リソースを適切に解放し、データの整合性を維持します

即時/強制シャットダウン (Immediate/forceful Shutdown)

  • サーバーを直ちに停止します
  • 進行中のリクエストは中断されます
  • データの整合性が損なわれる可能性

メリット/デメリット

比較項目 グレースフルシャットダウン 即時/強制シャットダウン
DB接続 セッション正規の手順で切る セッションが残る可能性が高い
処理中のリクエスト 処理を必ず完了させる 処理が完了する保証はない
新規のリクエスト 受けつない 受け付ける
プロセスの扱い 正常終了 強制終了

ただし、上記は一例であり、すべてこの通りというわけではなく、実装によるところもあります。ただ、あるべきとしてのメリット/デメリットを記載しております。

図で言うとこんな感じ

  • グレースフルシャットダウン

image.png

  • 即時/強制シャットダウン

image.png

2. OSのシグナルの種類

kill -lをLinuxもしくはMacのターミナル上で実行すると、使用可能なシグナルの一覧が表示される。

Mac上のターミナルで実行

 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL
 5) SIGTRAP	 6) SIGABRT	 7) SIGEMT	 8) SIGFPE
 9) SIGKILL	10) SIGBUS	11) SIGSEGV	12) SIGSYS
13) SIGPIPE	14) SIGALRM	15) SIGTERM	16) SIGURG
17) SIGSTOP	18) SIGTSTP	19) SIGCONT	20) SIGCHLD
21) SIGTTIN	22) SIGTTOU	23) SIGIO	24) SIGXCPU
25) SIGXFSZ	26) SIGVTALRM	27) SIGPROF	28) SIGWINCH
29) SIGINFO	30) SIGUSR1	31) SIGUSR2

いろいろ、種類はありますがよく使用されるのはせいぜい3〜4種類くらい(体感です)

No シグナル 用途
15 SIGTERM プロセスに停止命令を送る
9 SIGKILL プロセスを強制終了する
2 SIGINT プロセスへの割り込み命令を送る
1 SIGHUP プロセスをハングアップさせる

ちなみに、Dockerコンテナを停止させるdocker stopコマンドを実行するとDockerから内部で動作しているアプリケーションのプロセスにまず、SIGTERMが送信されて停止されます。また、SIGTERM命令で一定時間後に停止できなかった場合、SIGKILL命令で強制終了されます。

そのため、アプリケーションをDockerコンテナで動作させる際は、なるべくグレースフルシャットダウンさせないと、もし、なんらかの原因でSIGTERMによる停止ができなかった場合、強制終了させられるので不都合が起き得る可能性が高まります。

3. ローカルでGraceful Shutdownする

実際にグレースフルシャットダウンを実装したサーバを起動してみましょう。

スクリーンショット 2024-07-03 20.36.48.png

この通り、ローカルでサーバを立ててkillコマンドで15(SIGTERM)のシグナルを送ってみるとサーバが{"time":"2024-07-03T11:36:18.176Z","severity":"Info","message":"SIGTERM signal received."}というログを出してシャットダウンしてくていることが分かります。

また、連続サーバを起動してもポートの解放などの処理が行われているので正常に起動できます。

スクリーンショット 2024-07-03 20.40.09.png

ちなみに、kill -9で強制終了した場合は、ポートの解放が行われずにサーバが異常終了するため連続での起動はできません。(ローカルの場合)

スクリーンショット 2024-07-03 20.43.02.png

4. Dockerコンテナ環境でGraceful Shutdownする

今度は、Docker環境でグレースフルシャットダウンを試してみましょう。

スクリーンショット 2024-07-03 21.16.24.png

このように、正常にグレースフルシャットダウンができました。

5. Node.js ✖️ Dockerコンテナにおける問題点

ただし、Node.jsのアプリケーションをDocker化する際には気をつけなければならないことがあります。
それは、Node.jsのアプリケーションのPIDが1になってしまう問題です。

なぜ、PIDが1だといけないのか?

多くの場合、dockerコンテナのベースイメージとしてLinuxを選択するかと思います。LinuxにおいてPID 1というのはinitプロセスであり、カーネルから起動される親プロセスのようなものです。そのため、rootでシグナルを送ってもkillすることができないのです。

また、node.js公式のリポジトリで言及されている通り、node.jsはPID 1で動作するように設計されていないためシグナルを送っても応答がないのです。

スクリーンショット 2024-07-03 21.30.36.png

killでシグナルを送ってもサーバが起動している。

参考

6. Graceful Shutdownを考慮しないと起き得るエラー

一例ですが、簡単に挙げてみたいと思います。

  • DBのセッションを接続したままにしてしまうことによる、DBからのAborted connectionエラー
  • ポートが正常に解放されないことによるエラー
  • データ更新リクエストの途中で、終了したことによるデータの不整合

こんな感じで、上げ出したらきりがないほど影響はあるかなと思います。
まぁ、ただし、起きる起きないは運次第みたいなところが、あるのも事実な気がするので、実装しておいた方が無難かなくらいに捉えていただければと思います。

7. 実装例

ここからは、簡単に実装例を紹介します。

app.ts
import express from 'express';
import { HealthCheckController } from './controller/system';
import { usecase } from '../DI/container';
import { TaskController } from './controller/task';
import { logger } from '../middleware/logger';

export class Server {
  readonly app = express();
  readonly HealthCheckHandler = express.Router();
  readonly TaskHandler = express.Router();
  readonly apiRouter = express.Router();
  readonly r = new HealthCheckController();
  readonly t = new TaskController(usecase);
  
  constructor() {
    this.apiRouter.use('/', this.HealthCheckHandler);
    this.apiRouter.use('/tasks', this.TaskHandler);
  }
  
  Run() {
    
    // GET リクエスト
    this.HealthCheckHandler.get('/', this.r.healthCheck);
    this.TaskHandler.get('/', this.t.getAllTasks.bind(this.t));

    // パスパラメータを取得する
    this.TaskHandler.get('/:id', this.t.getById.bind(this.t));

    // POST リクエスト
    this.TaskHandler.post('/', this.t.CreateTask.bind(this.t));

    // PUT リクエスト
    this.TaskHandler.put('/:id', this.t.UpdateTask.bind(this.t));

    // DELETE リクエスト
    this.TaskHandler.delete('/:id', this.t.DeleteTask.bind(this.t));

    this.app.use(express.json());
    this.app.use('/api/v1', this.apiRouter);
    // サーバ起動時にapp.listenの戻り値を格納しておく
    const server = this.app.listen(8080, () => {
      logger.info('starting server :8080');
    });

    // ここ重要!!!
    process.on('SIGTERM', () => {
     // サーバをシャットダウンする
      server.close(() => {
        // シャットダウン時の処理を実装する
        db.close();
        logger.info('SIGTERM signal received.');
      });
    });
  }
}

実装例は、以上!!!

簡単ですよね!!!

ほんの少しだけ解説すると、

process.on('SIGTERM', () => {

で、シグナルの受信をして、そのコールバック関数内で、

server.close(() => {
    // シャットダウン時の処理を実装する
    db.close();
    logger.info('SIGTERM signal received.');
});

また、あらかじめサーバ起動時に、サーバの起動時のデータをserver変数に持っておきます

const server = this.app.listen(8080, () => {
    logger.info('starting server :8080');
});

こうすることで、後からサーバのシャットダウン処理をすることができます。

サーバのシャットダウンやDBの接続の終了処理やロギングを行います。

8. 動作確認

この実装をすることで、以下のようにSIGTERM signal received.というログを出力して、「SIGTERMのシグナルを受け取りましたサーバをシャットダウンします。」となるのです。

{"time":"2024-07-03T11:36:18.176Z","severity":"Info","message":"SIGTERM signal received."}

まとめ

このNode.jsのアプリケーションをDockerコンテナ上で起動するとPID 1となってしまう事象はかなり有名ですが今回、改めて自分への戒めも含めで記事にしてみました。よろしければ、いいねをよろしくお願いいたします。

さいごに

弊社では、エンジニア積極採用中です!
SES、請負、受託、Saleforce やりたい方はご興味を持っていただけたら幸いです。

ワクトでは、以下の Mission・Vision・Value を掲げております。

Mission : 「IT× ワクワク」で、社会の発展に貢献する
Vision : 大切な人に心から薦めたい会社であり続ける
Value :
One team for customers
熱意 × 人格 × 能力
まずやってみる
ワクトでは、もう一つ重要なものとして「 マインドマップ 」というものがあります。
個人的にですが、こちらに共感していただけた方はワクトに合うかなと思っております。

MindMap

また、本記事の内容は個人の考えであり、会社を代表するものではございません。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?