Kodokon kodokon.com

可観測性:ログ、ヘルスチェック、グレースフルシャットダウン

サービスを可観測にし、きれいに停止できるようにしましょう。JSONログ、liveness/readinessプローブ、そしてSIGTERMの処理です。

9 分 · 3 問

このレッスンを Kodokon で開く

本番環境では、ログは人間ではなく機械(Loki、Datadog、CloudWatch)のためのものです。1行につき1つのJSONオブジェクト(1行 = 1イベント)を、レベル、ISOタイムスタンプ、構造化されたフィールドとともに出力しましょう。問い合わせが不可能な自由形式の文字列補間は決して使わないでください。あまり知られていない細かい点があります。stdoutへの書き込みは、ファイルやターミナルに対しては同期的ですが、パイプに対しては非同期です。したがっておしゃべりなロガーは、出力先しだいでイベントループをブロックしかねません。これこそがpinoのようなライブラリの存在意義であり、高速にシリアライズし、書き込みを別に肩代わりさせられるのです。

JAVASCRIPT
function log(level, message, fields = {}) {
  const entry = {
    level,
    message,
    time: new Date().toISOString(),
    pid: process.pid,
    ...fields,
  };
  process.stdout.write(JSON.stringify(entry) + "\n");
}

log("info", "server started", { port: 3000 });
log("error", "db unreachable", { retryInMs: 5000 });
最小限の構造化ロガー。そのまま問い合わせできる

二つのプローブを区別しましょう。Livenessは「プロセスを再起動すべきか?」に答えます。これは外部への依存を持たず、ごく単純なままでなければなりません。Readinessは「トラフィックを受け取れるか?」に答えます。データベースを確認してもよく、そして何よりもシャットダウン中に503へ切り替わることで、ロードバランサーがインスタンスの停止前にそれを外せるようにします。この二つを混同すると、依存先が一瞬つまずいた途端に、健全なプロセスが再起動されてしまいます。

JAVASCRIPT
import { createServer } from "node:http";

let ready = true;

const server = createServer((req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(ready ? 200 : 503);
    res.end(ready ? "ok" : "draining");
    return;
  }
  res.end("hello");
});

server.listen(3000);

function shutdown() {
  ready = false;
  server.closeIdleConnections();
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 10_000).unref();
}

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
動的なreadinessと、SIGTERMでのグレースフルシャットダウン

グレースフルシャットダウンの流れはこうです。オーケストレーターがSIGTERMを送る。あなたはreadinessを503へ切り替える。server.close()は新しい接続を拒否し、処理中のリクエストが終わるのを待つ。closeIdleConnections()は、そうしなければclose()を永遠に引き止めてしまうアイドルのkeep-alive接続を閉じる。unref()を付けたタイマーは安全網の役割を果たします。10秒後にプロセスを終了させますが、それ自体がプロセスがもっと早く終了するのを妨げることはありません。

JAVASCRIPT
process.on("uncaughtException", (err) => {
  const entry = {
    level: "fatal",
    message: err.message,
    stack: err.stack,
    time: new Date().toISOString(),
  };
  process.stderr.write(JSON.stringify(entry) + "\n");
  process.exit(1);
});

process.on("unhandledRejection", (reason) => {
  throw reason;
});
最後の手段:ログを残してから終了する

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. livenessプローブとreadinessプローブの違いは何ですか?
    • 違いはない。同じテストの二つの呼び名にすぎない
    • livenessはプロセスが動いていることを確認し、readinessは依存先も含めてトラフィックを受け入れられることを確認する
    • readinessはコンテナを再起動し、livenessはそれをロードバランサーから外す
  2. server.close()は正確には何をしますか?
    • 開いているすべての接続を即座に切る
    • 固定の30秒後にプロセスを終了させる
    • 新しい接続を拒否し、既存の接続が終わるのを待つ
  3. uncaughtExceptionのハンドラーは何をすべきですか?
    • エラーをログに残してから、プロセスを終了する
    • エラーを無視する。Nodeが自力で復旧する
    • 失敗した操作を自動的に再試行する