ログ設計の基本 — いつ・何を・どこに出すか

ログは「とりあえず出しておけばいい」ものではない。何を・いつ・どのレベルで出すかを設計しないと、いざ障害が起きたときにログの山に埋もれて何もわからない、あるいはそもそもログがなくて手がかりゼロという状況に陥る。この記事では、ログレベルの使い分け、ログを出すべき場面の判断基準、そしてCloudWatchやDatadog・OpenSearchといったログ基盤の役割分担まで、実務で使えるログ設計の考え方を整理する。実装例にはNode.js向け高速ロガーのpinoを使う。

目次

ログの3つの目的

ログを出す前に「このログは何のために存在するのか」を考える。目的は大きく3つに分類できる。

  • 障害対応 — 問題が起きたとき、何が・いつ・なぜ起きたかを特定する
  • 運用監視 — システムが正常に動いているかを確認する
  • 監査・追跡 — 誰が何をしたかの記録を残す(コンプライアンスやビジネス要件)

「このログは3つのうちどれに役立つか?」を問い、どれにも該当しないなら出す必要はない。

ログレベルの使い分け

多くのロガーライブラリは6段階のレベルを持っている。pinoも同様で、各レベルには数値が割り当てられている。

レベル数値用途
trace10関数の出入り、変数の中身など極めて細かいデバッグ情報。通常は有効にしない
debug20開発中に有用な情報。リクエスト内容の詳細、分岐判定の理由など
info30正常動作の記録。サーバー起動、リクエスト処理完了、ジョブ実行など
warn40異常だが処理は続行できる状態。非推奨APIの使用、リトライ発生、閾値への接近
error50処理が失敗した。例外捕捉、API呼び出し失敗、想定外の状態
fatal60プロセス続行不能。起動時の必須設定欠如、回復不能なエラー

レベルはフィルタとして機能する

ログレベルは単なるラベルではなく、フィルタ(閾値)として機能する。設定したレベルの数値未満のログは一切出力されない。

つまり「レベルを上げる=出力が減る」「レベルを下げる=出力が増える」という関係になる。典型的な運用では、開発環境はdebugtrace、本番環境はinfoに設定し、障害調査時に一時的にdebugへ下げるという使い方をする。

pinoでの実装例

import pino from 'pino';

// 本番環境ではinfo、開発環境ではdebug
const logger = pino({
  level: process.env.NODE_ENV === 'production' ? 'info' : 'debug'
});

logger.info({ port: 3000 }, 'サーバー起動');
logger.debug({ headers: req.headers }, 'リクエストヘッダー詳細');
logger.error({ err, orderId: 'ord-456' }, '決済API呼び出し失敗');

出力はNDJSON(改行区切りJSON)形式で、構造化されたまま吐き出される。

{"level":30,"time":1718193600000,"pid":12345,"hostname":"app-1","msg":"サーバー起動","port":3000}

ログを出すべき場面

境界を越えるとき

外部との入出力はほぼ必ずログに残す。問題の大半はシステムの「境界」で起きる。

  • HTTPリクエストの受信・レスポンス返却
  • 外部API・DB・キュー・ファイルシステムへの呼び出しとその結果
  • 認証・認可の成否
// 外部API呼び出しのログ
logger.info({ url, method }, '外部API呼び出し開始');
try {
  const res = await fetch(url);
  logger.info({ url, status: res.status, duration: Date.now() - start }, '外部API応答');
} catch (err) {
  logger.error({ url, err }, '外部API呼び出し失敗');
}

状態が変わるとき

  • プロセスの起動・終了
  • 設定の読み込み・適用
  • ジョブやバッチの開始・完了
  • ユーザーの重要な操作(注文確定、設定変更など)

異常が起きたとき

  • 例外の捕捉
  • リトライの発生
  • タイムアウト
  • 想定外の分岐に入った
// リトライ発生時のログ
logger.warn({ attempt: retryCount, maxRetries: 3, err }, 'DB接続リトライ');

判断の根拠を残したいとき

条件分岐で「なぜこちらに進んだか」を後から追えるようにするケース。これはdebugレベルで出すのが適切。

logger.debug({ userId, plan, feature }, 'フィーチャーフラグ判定: 有効');

「出しすぎ」と「出さなすぎ」の判断基準

出しすぎの害は明確で、ログ量の増加はストレージコスト、I/O負荷、そして本当に必要な情報がノイズに埋もれるという問題を引き起こす。ループ内で毎回ログを吐くのは典型的なアンチパターンだ。

出さなすぎの害はもっと深刻で、障害時に「何もわからない」状態になる。再現が難しい問題ほど、ログがないと詰む。

実用的な指針として、infoレベルだけでリクエストの流れを追跡できるかをひとつの基準にするとよい。infoで全体の流れが見えて、debugに下げれば詳細が見える、という構成が理想形になる。

ログに何を含めるか

ログを出す・出さないの判断と同じくらい重要なのが「何を書くか」だ。

コンテキスト情報

リクエストID、ユーザーID、トランザクションIDなど、ログ同士を紐付けるための識別子は必須と考えてよい。これがないと、複数リクエストが混在するログから特定の処理を追えない。

pinoのchildメソッドを使えば、コンテキストを自動的に付与できる。

// リクエストごとにchild loggerを生成
app.use((req, res, next) => {
  req.log = logger.child({
    requestId: req.headers['x-request-id'] || crypto.randomUUID(),
    userId: req.user?.id
  });
  next();
});

// ハンドラ内ではreq.logを使う
req.log.info({ orderId }, '注文処理開始');
// → {"requestId":"abc-123","userId":"u-456","orderId":"ord-789","msg":"注文処理開始"}

具体的なメッセージ

「失敗しました」ではなく「DBへのINSERTがタイムアウトした(テーブル名: orders、経過時間: 5023ms)」のように、何が・どこで・どうなったかを具体的に書く。

出してはいけないもの

パスワード、アクセストークン、クレジットカード番号、個人情報をログに含めてはいけない。ログに入ると漏洩リスクになる。pinoではredactオプションでマスキングを仕組みとして組み込める。

const logger = pino({
  redact: ['req.headers.authorization', 'user.password', 'card.number']
});

ログ基盤の構成 — CloudWatchとその先

ここまではアプリケーション側のログ設計の話だった。ここからは、出力されたログをどう収集・分析するかというログ基盤の話に移る。

CloudWatch Logsの役割

CloudWatch LogsはAWSネイティブのログ収集・保存サービスだ。Lambda、ECS、EC2などのAWSリソースからのログは基本的にここに集まる。設定が簡単で、AWSを使っていれば自然と溜まっていく。

ただし検索・分析の機能は限定的で、CloudWatch Logs Insightsというクエリ機能はあるものの、大量のログから横断的に調査したり、可視化したりするには力不足だ。

Datadogの役割

DatadogはAWSとは別の独立したSaaS企業が提供する監視・オブザーバビリティプラットフォームだ。ログだけでなくメトリクス、トレース、APMを統合的に扱える。CloudWatch Logsからログを転送し、Datadog側で検索・分析・アラート・ダッシュボード化するのが一般的な構成になる。

強みは検索の速さ、UIの使いやすさ、ログとメトリクスやトレースを横断して相関分析できることにある。ただし従量課金のため、ログ量が多いとコストが高くなりやすい。

OpenSearchの役割

OpenSearchはもともとElasticsearchというOSSをAWSがフォークして生まれたプロジェクトで、大量のログを全文検索・集計するのに向いている。Datadogと違って自前運用(またはAWSマネージドのAmazon OpenSearch Service)なので、ログ量が多い場合にコストを抑えやすい反面、運用の手間がかかる。

CloudWatchだけの場合との比較

やりたいことCloudWatch だけDatadog / OpenSearch
特定のログを探すできるが遅い・面倒高速に全文検索
複数サービス横断で追跡ロググループを切り替えて手動で照合リクエストIDで一発検索
エラーの傾向を可視化Logs Insightsで頑張ればできるダッシュボードですぐ見える
「いつから壊れたか」の特定手作業で探すグラフの変化点で一目瞭然

障害調査の具体例

ここまでの内容を繋げて、実際の障害調査がどう進むかを見てみる。

「ユーザーから『注文できない』と問い合わせが来た」というケースを想定する。

アプリがログを吐く

const orderLog = logger.child({ orderId: 'ord-456', userId: 'u-123' });

orderLog.info('注文処理開始');
orderLog.info({ item: 'widget-A', qty: 3 }, '在庫確認中');
orderLog.error({ err }, '決済API呼び出し失敗');

この出力がECSの標準出力からCloudWatch Logsに自動的に入る。

CloudWatchだけで調査する場合

ロググループを選んで、ログストリームを開いて、フィルタにord-456を入力して…と手作業でログを探す。別のサービス(決済API側)のログを見たければ、ロググループを切り替えて同じ作業を繰り返す。

Datadogで調査する場合

検索窓に orderId:ord-456 と入力するだけで、関連するログが時系列で一覧表示される。さらに同じuserId: u-123の過去のリクエストも絞り込め、「決済API呼び出し失敗」が他のユーザーでも起きているかを確認でき、決済APIのエラー率をグラフで見て「15:00から急増している」と特定できる。アラートを設定しておけば、問い合わせが来る前に気づくことも可能になる。

pinoのトランスポート設定

pinoはv7以降、ワーカースレッド経由でログの出力先を設定するtransport機能を持っている。開発環境と本番環境で出力先を切り替える典型的な設定は以下のようになる。

import pino from 'pino';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // 開発環境では人間が読める形式に整形
  transport: process.env.NODE_ENV !== 'production'
    ? { target: 'pino-pretty' }
    : undefined,
  // 本番環境ではJSON出力のままCloudWatchへ流す(stdoutをそのまま収集)
  redact: ['req.headers.authorization', 'user.password'],
});

本番環境ではJSON出力をそのまま標準出力に吐き、CloudWatch Logsが自動収集する。開発環境ではpino-prettyで人間が読める形に整形する。ログの中身が変わるわけではなく、見え方だけが変わるという点がポイントだ。

参考書籍

ログ設計を体系的に学ぶなら、以下の書籍が参考になる。

  • 『Observability Engineering』(Charity Majors 他)— ログ・メトリクス・トレースをどう設計・運用するかを扱うオブザーバビリティの本
  • 『入門 監視』(James Turnbull、オライリー)— 監視設計全般を薄く読みやすくまとめた入門書
  • 『Release It!』(Michael Nygard)— 本番運用の障害パターンと対策。「ログがなくて詰んだ」系の教訓が多い

ただし、ログ設計は書籍よりも実際の障害対応で「このログがあって助かった」「なくて困った」という経験から学ぶ部分が大きい。書籍で考え方を押さえた上で、自分のプロジェクトで試行錯誤するのが一番身につくはずだ。

目次