本番環境でのロギングは、Node.jsアプリケーション開発において最も軽視されがちな要素のひとつです。console.logはローカルデバッグには便利ですが、ログを検索・構造化・アクション可能にする必要がある分散環境では不十分です。本記事では、スケーラブルなロギング戦略を構築するための必須パターンを解説します。
なぜ構造化ログか
従来の非構造化ログはプレーンテキストで出力されるため、プログラムによる解析が困難です。構造化ログは各ログイベントをJSONオブジェクトとして出力し、ログ集約システムで機械可読かつ検索可能にします。
// 非構造化 - 本番環境では避ける
console.log("支払い処理完了:", paymentId, "ユーザー:", userId);
// 構造化 - 推奨
logger.info({ paymentId, userId, amount, currency }, "支払い処理完了");
JSON形式により、Elasticsearch、Loki、Datadogなどのツールでカスタムパースロジックなしにフィールドベースのフィルタリングやアラートが可能になります。
ロガーの選択:Pino vs Winston
Node.jsのロギングライブラリとして、PinoとWinstonが広く使われています。選択はパフォーマンス要件とエコシステムのニーズに依存します。
| 機能 | Pino | Winston |
|---|---|---|
| 速度 | 1行約0.5µs | 1行約3–5µs |
| トランスポート | 少なめ、拡張可能 | 豊富(ファイル、HTTP、Syslog、カスタム) |
| 子ロガー | pino.child({ context }) | winston.createChild() |
| 機密データ削除 | ビルトイン redact オプション | fast-redact が必要 |
| 開発体験 | pino-pretty で可読性向上 | ビルトインのフォーマットオプション |
Pinoは高スループットのサービスに最適です。Winstonは複雑なトランスポートルーティングが必要な場合や既存のプラグインエコシステムに依存している場合に適しています。
// Pinoのセットアップ例
const pino = require("pino");
const logger = pino({
level: process.env.LOG_LEVEL || "info",
redact: ["password", "authorization"],
transport: process.env.NODE_ENV !== "production"
? { target: "pino-pretty" }
: undefined,
});
ログレベルと使用タイミング
RFC 5424に準拠したログレベルを採用することで、サービス間で一貫性が確保されます。各レベルはイベントの重大度とアクション可能性を示します。
| レベル | 値 | 使用タイミング |
|---|---|---|
fatal | 60 | アプリケーションクラッシュ切迫 |
error | 50 | リクエスト失敗、プロセスは継続 |
warn | 40 | 予期せぬが致命的でない状況 |
info | 30 | 通常の運用マイルストーン |
debug | 20 | 詳細な診断情報 |
trace | 10 | 非常に詳細な実行フロー |
環境変数でログレベルを動的に制御することで、再デプロイなしに本番環境の詳細度を調整できます。
リクエスト相関ID
マイクロサービスアーキテクチャでは、1つのユーザーリクエストが複数のサービスを通過することがあります。各サービスを個別にログ出力するとデバッグが困難になります。解決策は、すべての受信リクエストに一意の相関IDを割り当て、サービス間で伝搬することです。
const { AsyncLocalStorage } = require("async_hooks");
const asyncLocalStorage = new AsyncLocalStorage();
app.use((req, res, next) => {
const correlationId = req.headers["x-correlation-id"] || crypto.randomUUID();
res.setHeader("x-correlation-id", correlationId);
req.log = logger.child({ correlationId });
asyncLocalStorage.run(new Map([["correlationId", correlationId]]), () => next());
});
AsyncLocalStorageを使用すると、すべての関数シグネチャに渡すことなく、任意の非同期コンテキストから相関IDにアクセスできます。
集中ログ管理
ログを集中管理システムに送信することで、生テキストが検索可能な可観測性プラットフォームに変わります。
ELK Stack: FilebeatまたはLogstashを介してJSONログをElasticsearchに送信し、Kibanaで可視化します。Docker Composeのローカルセットアップ例:
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.x
logstash:
image: docker.elastic.co/logstash/logstash:8.x
kibana:
image: docker.elastic.co/kibana/kibana:8.x
Loki + Grafana: Lokiはラベルベースでログストリームをインデックス化するため、Kubernetes環境での大量ログにコスト効率が高く、Grafanaでダッシュボードとアラートを設定できます。
エラーロギングと機密データの保護
エラーは常に完全なスタックトレースとともに記録します。Pinoはerrキーで渡されたErrorオブジェクトを自動的にシリアライズします:
try {
await processPayment(order);
} catch (err) {
logger.error({ err, orderId: order.id }, "支払い処理失敗");
}
機密データをログに記録してはいけません。Pinoのビルトインredact機能で特定パスを出力から除外します:
const logger = pino({
redact: ["password", "authorization", "req.headers.cookie"],
});
パフォーマンス考慮事項
ロギングはI/O操作であり、過剰なロギングはアプリケーションのスループットを低下させます。Pinoは1行約0.5µs、Winstonは約3–5µsです。高トラフィックのエンドポイントではサンプリングを検討し、常に非同期トランスポートを使用してください。warnレベルでは、logger.debug()の呼び出しはほぼコストゼロであるべきで、Pinoはレベル短絡によってこれを実現しています。
まとめ
構造化JSONログ、適切なライブラリの選択、相関ID、集中集約が、可観測性のあるNode.jsアプリケーションの基盤です。ワークロードに合ったロガーを選び、初日から相関IDを実装し、フィールドベースのクエリをサポートするシステムにログをルーティングしてください。これらのパターンがあれば、本番障害のデバッグはフラットファイルをくまなく調べるのではなく、検索クエリを実行するだけで済みます。
