📄

[自分用メモ]TypeScriptでNode.js向けのDockerfileを書くときにTips

2022/01/15に公開

技術記事ですが、自分用メモなので検索などを邪魔しないようにアイデアに設定しています。

Node.jsがPID 1で動いている時にSIGTERMを受け取らない問題

Node.jsはPID 1で動作している場合は、SIGTERMやSIGINTを受け取ったときに終了する処理を明示的に記述しない限り無視します。
下記のようにprocess.onceまたはprocess.onを使い処理を明示すればシグナルを受け取ったときに安全に終了する事が出来ます。

process.once("SIGTERM", async () => {
  console.log("SIGTERM received.");
  shutdown();
});

process.once("SIGINT", async () => {
  console.log("SIGINT received.");
  shutdown();
});

またnpmコマンドを使ってソフトウェアを実行すると(PID 1で動いている場合は)シグナルをソフトウェアに伝播する事が出来ずにエラーで終了します。
下記のようにnodeコマンドを使って直接実行する必要があります。

CMD [ "node", "build/src/index.js" ]

ググると軽量initシステムであるtiniを噛ませることを勧める情報が多いですが、ソフトウェアを安全に終了させる為にはシグナルを受け取ったときの処理を明示的に書くべきで、明示的に書いていれば軽量initシステムを噛ませる必要はありません。

既存のソフトウェアが存在しそれを変更することが出来ない場合は、軽量initシステムの利用すると良いと思います。

この問題はインターネット上に情報が錯綜しており、それに気づかずに今まで深く考えずにtiniを挟んでいました。
といっても、これで本当に良いのか特にnodeをPID 1で実行することにシグナルを受け取ったときに終了する処理を明示的に書かないといけない以外に問題が無いかなどが気になっています。情報があればコメント頂けるととてもうれしいです。

httpサーバーを素早く終了するには明示的にSocketを終了する必要がある

http packageで立ち上げたサーバーはServer.close([callback])メソッドを実行すると、以降は新規コネクションを受け付けなくなり、既存コネクションが終了するとコールバック関数を実行します。
しかし、HTTP Keep-Aliveが有効である場合は既存コネクションが維持され使い回されるので素早くサーバーを終了することができません。例えばAmazon ECSではコンテナがSIGTERMを受け取った場合には30秒で終了できないと、SIGKILLが送信され強制的に終了させられてしまいます。(デフォルトの設定の場合のタイムアウト)

コンテナを素早く終了するためにSIGTERMを受け取るとServer.close()を実行後に一定時間待った後にコネクションを全てagent.destroy()で終了させます。このために、ECMAScript2015のSetを使ってコネクションを手動で管理しています。

const sockets = new Set<Socket>();
server.on("connection", (socket) => {
  sockets.add(socket);

  socket.once("close", () => {
    sockets.delete(socket);
  });
});

const shutdown = async () => {
  server.close((err) => {
    if (err !== undefined) {
      console.error(err);
      process.exit(1);
    }

    console.log("🔴 terminate server.");
    process.exit();
  });

  setTimeout(() => {
    sockets.forEach((socket) => {
      socket.destroy();
      sockets.delete(socket);
    });
  }, 3000);
};

プロセスの実行を非rootユーザーで行う

コンテナ内でプログラムを実行する際は非rootユーザーを使うとより安全です。[1]
rootユーザーで行う必要がある作業を最初に行い、その後USERコマンドを使ってユーザーを非rootユーザーに切り替えます。その後の処理によっては切り替える前にWORKDIRのオーナーを変更しておく必要があります。
また、COPYコマンドを実行する際には--chown=node:nodeのようにオプションを付けないとrootユーザーとしてファイルやディレクトリが作成され、書き込みが出来なくなってしまいます。

Dockerfile
WORKDIR /opt/app
RUN chown node:node ./
USER node

COPY --chown=node:node package.json package-lock.json ./
RUN npm ci && npm cache clean --force

COPY --chown=node:node . .

サンプルリポジトリ

この記事のリポジトリはintercept6/ts-dockerfile-sampleです。

参考

脚注
  1. コンテナでプログラムをrootとして実行することがなぜ問題なのか KubernetsのCVE-2019-11245を例に考える ↩︎

Discussion