LESSON 40分

ストーリー

佐藤CTO
3本柱の最後 — ログだ。ログは最も古くからあるテレメトリだが、最も誤用されやすい
あなた
今のシステムでも大量のログを出力していますが、障害時に探すのが大変です
佐藤CTO
それは非構造化ログだからだ。console.log('error happened') のような文字列ログでは、検索も集約も困難だ。構造化ログ — JSON形式のログに統一することで、ログの価値が劇的に変わる

非構造化ログ vs 構造化ログ

比較

// 非構造化ログ(アンチパターン)
console.log('Error: Payment failed for order 12345, amount 5000, user abc');
console.log(`[${new Date().toISOString()}] WARN: Retry attempt 3/5 for payment`);
console.error('Database connection timeout after 30s');

// 構造化ログ(推奨)
logger.error('Payment processing failed', {
  orderId: '12345',
  amount: 5000,
  currency: 'JPY',
  userId: 'abc',
  errorCode: 'PAYMENT_TIMEOUT',
  provider: 'stripe',
  traceId: 'abc-123-def-456',
  spanId: 'span-789',
});
観点非構造化ログ構造化ログ
フォーマット自由テキストJSON
検索性全文検索のみフィールド指定検索
集約正規表現が必要フィールドでグループ化
パース手動パース自動パース
トレース相関困難traceId/spanIdで直接紐付け

構造化ログの実装

ロガーライブラリの設定

import pino from 'pino';

// ロガーの初期化
const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  formatters: {
    level: (label) => ({ level: label }),
    bindings: (bindings) => ({
      service: 'payment-service',
      version: process.env.APP_VERSION ?? 'unknown',
      environment: process.env.NODE_ENV ?? 'development',
      hostname: bindings.hostname,
      pid: bindings.pid,
    }),
  },
  timestamp: pino.stdTimeFunctions.isoTime,
  // 本番ではJSON、開発では整形表示
  transport: process.env.NODE_ENV === 'development'
    ? { target: 'pino-pretty' }
    : undefined,
});

// 出力例(本番JSON):
// {
//   "level": "error",
//   "time": "2026-02-14T10:30:00.000Z",
//   "service": "payment-service",
//   "version": "1.2.0",
//   "environment": "production",
//   "hostname": "payment-pod-abc123",
//   "pid": 1,
//   "msg": "Payment processing failed",
//   "orderId": "12345",
//   "traceId": "abc-123-def-456"
// }

ログレベルの使い分け

レベル用途出力先
FATALシステム停止レベルアラート即時通知DB接続完全喪失
ERROR処理失敗、要対応アラート通知決済処理失敗
WARN潜在的問題ダッシュボード監視リトライ3回目
INFO重要なイベント通常ログリクエスト完了、デプロイ
DEBUG詳細情報開発/トラブルシュートSQLクエリ、変数値
TRACE最詳細開発環境のみ関数の入出力

Trace IDとの相関

import { context, trace } from '@opentelemetry/api';

// OpenTelemetryのTrace IDをログに自動付与するミドルウェア
function traceContextMiddleware(req: Request, res: Response, next: NextFunction): void {
  const activeSpan = trace.getSpan(context.active());
  const spanContext = activeSpan?.spanContext();

  // リクエストスコープのロガーを作成
  req.log = logger.child({
    traceId: spanContext?.traceId ?? 'no-trace',
    spanId: spanContext?.spanId ?? 'no-span',
    requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
    method: req.method,
    path: req.path,
    userAgent: req.headers['user-agent'],
  });

  req.log.info('Request started');

  res.on('finish', () => {
    req.log.info('Request completed', {
      statusCode: res.statusCode,
      responseTime: Date.now() - req.startTime,
    });
  });

  next();
}

ログ集約基盤

ELK Stack vs Grafana Loki

観点ELK (Elasticsearch)Grafana Loki
インデックス全フィールドインデックスラベルのみインデックス
ストレージコスト高い低い(元ログはObject Storage)
検索速度高速(全文検索)ラベル検索は高速、本文検索は遅い
運用コスト高い(クラスタ管理)低い(シンプルな構成)
Grafana統合Kibana or Grafanaネイティブ統合
推奨ケースログの全文検索が多い場合Grafanaスタックを使う場合

Loki設定例

# loki-config.yaml
auth_enabled: false

server:
  http_listen_port: 3100

common:
  path_prefix: /loki
  storage:
    s3:
      endpoint: s3.amazonaws.com
      bucketnames: loki-logs
      region: ap-northeast-1

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb
      object_store: s3
      schema: v13
      index:
        prefix: loki_index_
        period: 24h

limits_config:
  retention_period: 30d
  max_query_length: 24h
  max_entries_limit_per_query: 10000

# ログパイプラインでの加工
ruler:
  alertmanager_url: http://alertmanager:9093

LogQL(Lokiのクエリ言語)

# 基本的なログ検索
{service="payment-service"} |= "error"

# JSONパース + フィルタ
{service="payment-service"}
  | json
  | level = "error"
  | orderId != ""
  | line_format "{{.orderId}}: {{.msg}}"

# エラー率の計算(メトリクスクエリ)
sum(rate(
  {service="payment-service"} | json | level = "error" [5m]
))
/
sum(rate(
  {service="payment-service"} [5m]
))

# 特定のTrace IDに関連するログ
{service=~".*"} | json | traceId = "abc-123-def-456"

# 遅いリクエストのログ
{service="payment-service"}
  | json
  | responseTime > 3000
  | line_format "Slow request: {{.path}} took {{.responseTime}}ms"

ログ設計のベストプラクティス

ログに含めるべき情報

# 共通フィールド(全ログに含む)
common_fields:
  timestamp: "ISO 8601形式"
  level: "error/warn/info/debug"
  service: "サービス名"
  version: "アプリバージョン"
  environment: "production/staging/development"
  traceId: "OpenTelemetry Trace ID"
  spanId: "OpenTelemetry Span ID"
  requestId: "リクエスト固有ID"

# イベント固有フィールド
event_fields:
  http_request:
    - method
    - path
    - statusCode
    - responseTime
    - userAgent
    - clientIp
  payment:
    - orderId
    - amount
    - currency
    - provider
    - transactionId
  error:
    - errorCode
    - errorMessage
    - stackTrace
ログのアンチパターンと改善
アンチパターン問題改善
機密情報のログ出力クレジットカード番号、パスワードマスキング処理を実装
大量のDEBUGログストレージコスト増大本番ではINFO以上に制限
非構造化文字列検索・集約が困難JSON構造化ログに統一
ログ出力のない例外処理障害の原因追跡が不能catch節でエラーログを必ず出力
Trace IDの欠落ログとトレースの紐付け不可ミドルウェアで自動付与
ログローテーションなしディスク溢れログ管理ツール or stdout + 外部収集
// 機密情報のマスキング例
function maskSensitiveData(data: Record<string, unknown>): Record<string, unknown> {
  const sensitiveFields = ['password', 'creditCard', 'ssn', 'token'];
  const masked = { ...data };

  for (const field of sensitiveFields) {
    if (field in masked) {
      masked[field] = '***MASKED***';
    }
  }

  return masked;
}

まとめ

ポイント内容
構造化ログJSON形式で統一し、フィールド検索・集約を可能にする
ログレベルFATAL/ERROR/WARN/INFO/DEBUG/TRACEの適切な使い分け
Trace相関traceId/spanIdをログに含め、トレースと紐付ける
ログ集約ELK(全文検索重視)vs Loki(コスト重視・Grafana統合)
ベストプラクティス共通フィールドの標準化、機密情報マスキング

チェックリスト

  • 構造化ログと非構造化ログの違いを説明できる
  • pinoを使った構造化ログの実装ができる
  • Trace IDとログの相関付けを実装できる
  • ELK/Lokiの特徴と選択基準を理解した
  • LogQLで基本的なログ検索・集約クエリを記述できる

次のステップへ

次は「演習:オブザーバビリティ基盤を構築しよう」です。メトリクス、トレース、ログを統合したオブザーバビリティ基盤の設計と構築に挑戦しましょう。


推定読了時間: 40分