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

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

目次

Node.js で 軽量 で 構造化ログ出力 できるロガーライブラリ pino

ログレベルの使い分け

多くのロガーライブラリは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・キュー・ファイルシステムへの呼び出しとその結果
認証・認可の成否
状態が変わるときプロセスの起動・終了
設定の読み込み・適用
ジョブやバッチの開始・完了
ユーザーの重要な操作(注文確定、設定変更など)
異常が起きたときcatch文の中で使用が多いかな
判断の根拠を残したいとき条件分岐で「なぜこちらに進んだか」を後から追えるようにするケース。
これはdebugレベルで出すのが適切
  • 境界を越えるとき

// 外部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接続リトライ');
  • 判断の根拠を残したいとき

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

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

ログに何を含めるか

コンテキスト情報リクエストID、ユーザーID、トランザクションIDなど、
ログ同士を紐付けるための識別子は必須と考えてよい。
これがないと、複数リクエストが混在するログから特定の処理を追えない。
具体的なメッセージ「失敗しました」ではなく「DBへのINSERTがタイムアウトした
(テーブル名: orders、経過時間: 5023ms)」のように、
何が・どこで・どうなったかを具体的に書く。
出してはいけないものパスワード、アクセストークン、クレジットカード番号、個人情報をログに含めてはいけない。
ログに入ると漏洩リスクになる。pinoではredactオプションでマスキングを仕組みとして組み込める。
  • コンテキスト情報

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":"注文処理開始"}
  • 出してはいけないもの

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の使いやすさ、ログとメトリクスやトレースを横断して相関分析できることにある。ただし従量課金のため、ログ量が多いとコストが高くなりやすい。

Datadog Agentは、監視対象となるEC2やコンテナの内部に直接インストールするデータ収集用のソフトウェアです。

AWSの標準機能だけでは把握できない、OSの詳細なメモリやディスクの使用状況、ミドルウェアの稼働状態、アプリケーションのログやプログラムの処理時間などを、システムの内部から直接読み取ってDatadogへ送信する役割を持ちます。

EC2であればOSに直接インストールし、ECSやEKSなどのコンテナ環境であればデータ収集用のコンテナを配置することで、システム内部の詳細な監視を実現します。

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

OpenSearchの役割

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

ECSログの取り込み方について

  • パターン1
    ECS → CloudWatch Logs → OpenSearch に直接ストリーミング
    • ECSタスクのログはデフォルトでCloudWatch Logsに送られます。CloudWatch Logsのサブスクリプションフィルターを使って、Lambda経由またはOpenSearch Ingestionパイプライン経由でOpenSearchにリアルタイムに流し込みます。
  • パターン2
    ECS → Fluent Bit → OpenSearch に直接送信
    • ECSタスクにFluent Bitをサイドカーコンテナとして配置し、OpenSearchのエンドポイントに直接書き込みます。これが最もリアルタイム性が高い。
  • パターン3
    S3経由
    • S3に貯めてからOpenSearchに取り込む方法もありますが、これはバッチ的な分析やアーカイブ用途が多いです。リアルタイムのログ監視には向きません。

重要な点として、OpenSearchはデータを自分自身のクラスター内に保持します。

データをOpenSearchにコピー(インジェスト)して、OpenSearch内のインデックスに格納します

ただし例外として、OpenSearch Serverlessの「S3データソース」機能を使えば、S3上のデータを直接クエリすることも可能です。ただこれは比較的新しい機能で、従来のパターンとは異なります。

JSON形式で出力するのが鉄則

OpenSearchはドキュメントをJSONとして格納するので、アプリケーションのログもJSON形式(構造化ログ)で出力するのが最も扱いやすいです。プレーンテキストだと取り込み時にパース処理が必要になり、失敗の原因になります。

jsonの場合、OpenSearchでJsonのキーでパースして高速に検索できます。

フィールド名の制約
  • ドット . はネスト構造として解釈される。user.name{"user": {"name": "..."}} と同義になる。意図せず使うとマッピングが壊れる
  • 同じインデックス内で同じフィールド名に異なる型を混在させるとエラーになる。例えば status フィールドにあるログでは 200(数値)、別のログでは "OK"(文字列)を入れると、後から来たほうがインデックス拒否される。これが実務上一番多いトラブル
  • フィールド名に空白やハイフンは使えないわけではないが、クエリ時にエスケープが必要になるので避けたほうがいい

OpenSearchにも予約語は存在します。

OpenSearchのSQL/PPLプラグインでは、正規の識別子(フィールド名など)に予約キーワードを使うことができません。つまり、OpenSearch DashboardsでSQLやPPLクエリを使ってログを検索する場合、フィールド名が予約語と衝突するとそのままでは使えません。 OpenSearch

source は予約識別子の一つです。他にも SELECT, WHERE, FROM, AND, OR, NOT, ORDER, GROUP, INDEX, LIKE, IN などSQL由来のキーワードが予約されています。 OpenSearch

インデックスについて

インデックスはRDBでいう「テーブル」の概念に相当

インデックスはRDBでいう「テーブル」の概念に相当

OpenSearchは「データを自分自身で保持する」

CloudWatch LogsやS3からOpenSearchにログを送ると、OpenSearchはそのデータを自分のEBSボリューム上にインデックスとして書き込みます。元のCloudWatch LogsやS3のデータとは完全に別のコピーです。なのでデータが二重に存在することになり、ストレージコストも二重にかかります。

この点がOpenSearchの設計上のトレードオフで、独自のデータ構造(転置インデックス)に変換して保持するからこそ高速な全文検索ができる反面、元データとの重複が避けられません。

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に自動的に入る。

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)— 本番運用の障害パターンと対策。「ログがなくて詰んだ」系の教訓が多い

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

目次