コンテンツへスキップ
8分で読めます

Node.jsオブザーバビリティ:複雑さなしでログ、メトリクス、トレースを実現

Pinoによる構造化ログ、Prometheusメトリクス、OpenTelemetry分散トレーシング。Node.jsプロダクションアプリのオブザーバビリティスタックをゼロから完全な可視性まで解説。

シェア:X / TwitterLinkedIn

以前はオブザーバビリティとは「console.logをいくつか追加して、何か壊れたときに確認する」ことだと思っていました。それはうまくいかなくなるまでうまくいっていました。転機となったのは、APIが200を返しているのにデータが古くなっていたプロダクションのインシデントでした。ログにエラーなし。例外もなし。ダウンストリームのキャッシュが古くなっていて、4時間も誰も気づかなかったため、静かに間違った応答を返し続けていただけでした。

そのときモニタリングオブザーバビリティの違いを学びました。モニタリングは何かが間違っていることを教えてくれます。オブザーバビリティはなぜ間違っているのかを教えてくれます。そしてその2つのギャップこそが、プロダクションインシデントが住む場所なのです。

これは、ほとんどの代替手段を試した後にNode.jsサービスのために落ち着いたオブザーバビリティスタックです。世界で最も洗練されたセットアップではありませんが、ユーザーが気づく前に問題をキャッチし、何かが漏れた場合でも、数時間ではなく数分で診断できます。

3つの柱、そしてなぜすべてが必要なのか#

誰もが「オブザーバビリティの3本柱」、つまりログ、メトリクス、トレースについて語ります。誰も教えてくれないのは、各柱が根本的に異なる質問に答えるということ、そしてどの柱も単独ではすべての質問に答えられないため、3つすべてが必要だということです。

ログが答えるのは:何が起きたか?

ログ行は「14:23:07に、ユーザー4821が/api/ordersをリクエストし、データベース接続がタイムアウトしたため500を受け取った」と教えてくれます。これは物語です。1つの特定のイベントのストーリーを教えてくれます。

メトリクスが答えるのは:どのくらい起きているか?

メトリクスは「直近5分間で、リクエストの2.3%が500を返し、p99レイテンシが340msで、データベースコネクションプールの使用率が89%」と教えてくれます。ストーリーではなくダッシュボードです。異常を見つけたい場合は、ログよりもメトリクスの方がはるかに効率的です。1ログ行あたりの解析 vs. 1カウンターの加算の話です。

トレースが答えるのは:ボトルネックはどこか?

トレースは「このリクエストは合計340msかかった。うち15msがauth、12msがデータベースクエリ、290msがサードパーティAPI呼び出し、23msがレスポンスのシリアライゼーション」と教えてくれます。マイクロサービスにおけるX線撮影のようなものです。

ログだけでは、全体的なエラー率が上昇していることに気づけません。メトリクスだけでは、何が原因かわかりません。トレースだけでは、問題がどのくらいの頻度で起きているかわかりません。プロダクションでの私のデバッグフローはこの順番です:

  1. メトリクスがアラートを発する(エラー率が2%を超えた)
  2. ログがどんなエラーかを示す(データベースタイムアウト)
  3. トレースがどこで時間がかかっているかを示す(コネクション取得に200ms待っている)

柱1:Pinoによる構造化ログ#

もうすべてのログをconsole.logで記録するのはやめましょう。console.logには構造がなく、レベルがなく、コンテキストがなく、プレーンテキスト文字列のパフォーマンス特性はバッファリングや非同期書き込みとは比較になりません。

PinoはNode.jsで最速の構造化ロガーです。速い理由は、最初からパフォーマンスを重視して設計されているからです。非同期のデフォルト書き込み、可能な限り割り当てゼロのシリアライゼーション、そしてロギングパイプラインをメインスレッドの外に移すワーカースレッドトランスポート。

基本セットアップ#

typescript
import pino from "pino";
 
const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  // Redactで機密データを自動的にマスク
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "body.password",
      "body.token",
      "*.ssn",
      "*.creditCard",
    ],
    clobberWith: "[REDACTED]",
  },
  // シリアライゼーション — フィールドの制御
  serializers: {
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
    err: pino.stdSerializers.err,
  },
  // タイムスタンプフォーマット — ISO 8601
  timestamp: pino.stdTimeFunctions.isoTime,
});
 
export default logger;

redact設定は重要です。パスワード、トークン、クレジットカード番号がログストリームに入り込まないようにします。データベースオブジェクトを丸ごとログに記録して、すべてのプレーンテキストパスワードがCloudWatchに入っていたプロダクションインシデントを見たことがあります。Redactionはオプションではありません。

子ロガーによるコンテキスト#

ここでPinoが真価を発揮します。子ロガーはすべてのログ行に自動的にコンテキストフィールドを追加します:

typescript
// リクエストスコープのロガー — リクエストIDがすべての行に付与される
app.use((req, res, next) => {
  req.log = logger.child({
    requestId: req.headers["x-request-id"] || crypto.randomUUID(),
    method: req.method,
    path: req.url,
    userAgent: req.headers["user-agent"],
  });
  next();
});
 
// ハンドラー内ではreq.logを使用
app.get("/api/orders", async (req, res) => {
  req.log.info("Fetching orders");
 
  const orders = await db.orders.findMany({
    where: { userId: req.user.id },
  });
 
  req.log.info({ orderCount: orders.length }, "Orders retrieved");
  res.json(orders);
});

すべてのログ行にrequestIdが含まれるようになります。リクエストに何か問題があった場合、そのIDでgrepすれば完全なストーリーが得られます。ログの一行一行が孤立した謎ではなくなります。

サービスレベルの子ロガー#

リクエストスコープ以外にも、サービスやモジュールレベルの子ロガーを使います:

typescript
// データベースモジュール
const dbLogger = logger.child({ module: "database" });
 
export async function query(sql: string, params: unknown[]) {
  const start = performance.now();
  try {
    const result = await pool.query(sql, params);
    const duration = performance.now() - start;
 
    dbLogger.info(
      {
        duration: Math.round(duration * 100) / 100,
        rowCount: result.rowCount,
        // SQLをログに記録するが、パラメータは記録しない(機密データの可能性)
        query: sql.substring(0, 200),
      },
      "Query executed"
    );
 
    return result;
  } catch (error) {
    const duration = performance.now() - start;
    dbLogger.error(
      {
        duration: Math.round(duration * 100) / 100,
        query: sql.substring(0, 200),
        err: error,
      },
      "Query failed"
    );
    throw error;
  }
}
typescript
// Redisモジュール
const cacheLogger = logger.child({ module: "cache" });
 
export async function getCached<T>(key: string): Promise<T | null> {
  try {
    const cached = await redis.get(key);
    if (cached) {
      cacheLogger.debug({ key, hit: true }, "Cache hit");
      return JSON.parse(cached);
    }
    cacheLogger.debug({ key, hit: false }, "Cache miss");
    return null;
  } catch (error) {
    cacheLogger.warn({ key, err: error }, "Cache read failed, falling back");
    return null;
  }
}

各モジュールにmoduleフィールドが設定された子ロガーがあるため、ログのモジュール別フィルタリングが簡単です:jq 'select(.module == "database")'

トランスポート — ログの送信先#

Pinoのトランスポートシステムは、重い処理をワーカースレッドに移します。メインのイベントループは書き込みをブロックしません:

typescript
import pino from "pino";
 
const transport = pino.transport({
  targets: [
    // プロダクション:JSON→stdout(コンテナランタイムが収集)
    {
      target: "pino/file",
      options: { destination: 1 }, // stdout
      level: "info",
    },
    // 開発:整形済みの人間が読める出力
    ...(process.env.NODE_ENV === "development"
      ? [
          {
            target: "pino-pretty",
            options: {
              colorize: true,
              translateTime: "HH:MM:ss",
              ignore: "pid,hostname",
            },
            level: "debug",
          },
        ]
      : []),
  ],
});
 
const logger = pino(transport);

プロダクションでは、ログはstdoutにJSON行として送信されます。Dockerやk8sがそこから取得します。開発ではpino-prettyが読みやすくフォーマットします。

ログのパフォーマンスが重要#

ここからが現実的な話です。ロギングがアプリケーションのボトルネックにならないことを確認する必要があります。ベンチマーク(Pinoの公式ベンチマーク、コールドスタート、10,000反復):

  • Pino:3,204 ops/ms
  • Winston:362 ops/ms
  • Bunyan:357 ops/ms
  • console.log:76 ops/ms

Pinoは最も近い代替手段の10倍近く高速です。リクエストパスでログを頻繁に記録するアプリケーション(ログを記録すべきです)の場合、この差は実際のレイテンシの違いとして現れます。

しかしロガーの速度だけが重要ではありません。ログのも重要です。各リクエストでdebugレベルのログを20行出す場合、ログは1秒あたり数万行生成される可能性があります。これにはディスクI/O、ネットワーク帯域幅、ログストレージコストがかかります。

私のルール:

  • debug:開発およびトラブルシューティング中のみ。プロダクションではデフォルトで無効。
  • info:意味のあるビジネスイベント。「注文作成」は○、「ミドルウェア1を通過」は×。
  • warn:リカバリー可能な問題。キャッシュフォールバック、リトライ、非推奨の使用。
  • error:リカバリー不能な問題。未処理例外、クリティカルサービスの障害。

柱2:Prometheusによるメトリクス#

ログはイベントを教えてくれます。メトリクスはトレンドを教えてくれます。「過去1時間でエラー率は上昇しているか?」 — ログでは答えられますが、10万行のログを解析する必要があります。メトリクスのカウンターでは瞬時に答えられます。

Node.js用のprometheusクライアント#

prom-clientはNode.jsのPrometheusライブラリの事実上の標準です。デフォルトのNode.jsメトリクス(イベントループラグ、ヒープサイズ、GCの持続時間)を自動的にバンドルし、カスタムメトリクスの定義も可能です。

typescript
import promClient from "prom-client";
 
// デフォルトメトリクスの収集 — イベントループ、ヒープ、GCなど
promClient.collectDefaultMetrics({
  prefix: "myapp_",
  gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
 
// --- カスタムメトリクス ---
 
// HTTPリクエストの持続時間ヒストグラム
export const httpRequestDuration = new promClient.Histogram({
  name: "myapp_http_request_duration_seconds",
  help: "HTTP request duration in seconds",
  labelNames: ["method", "route", "status_code"] as const,
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});
 
// HTTPリクエストの合計カウンター
export const httpRequestsTotal = new promClient.Counter({
  name: "myapp_http_requests_total",
  help: "Total HTTP requests",
  labelNames: ["method", "route", "status_code"] as const,
});
 
// アクティブな接続のゲージ
export const activeConnections = new promClient.Gauge({
  name: "myapp_active_connections",
  help: "Number of active connections",
});
 
// ビジネスメトリクス
export const ordersCreated = new promClient.Counter({
  name: "myapp_orders_created_total",
  help: "Total orders created",
  labelNames: ["payment_method"] as const,
});
 
export const orderValue = new promClient.Histogram({
  name: "myapp_order_value_dollars",
  help: "Order value in dollars",
  buckets: [10, 25, 50, 100, 250, 500, 1000, 5000],
});

メトリクスミドルウェア#

すべてのHTTPリクエストが自動的に計測されるように:

typescript
import { Request, Response, NextFunction } from "express";
import { httpRequestDuration, httpRequestsTotal } from "./metrics";
 
export function metricsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // ルートが設定される前にタイマーを開始
  const end = httpRequestDuration.startTimer();
 
  // レスポンス完了時にメトリクスを記録
  res.on("finish", () => {
    const route = req.route?.path || req.path || "unknown";
    const labels = {
      method: req.method,
      route: route,
      status_code: res.statusCode.toString(),
    };
 
    end(labels);
    httpRequestsTotal.inc(labels);
  });
 
  next();
}

メトリクスエンドポイント#

Prometheusはアプリからメトリクスをスクレイプします。メトリクスを公開するエンドポイントが必要です:

typescript
import { Router } from "express";
import promClient from "prom-client";
 
const metricsRouter = Router();
 
metricsRouter.get("/metrics", async (_req, res) => {
  res.set("Content-Type", promClient.register.contentType);
  res.end(await promClient.register.metrics());
});
 
export default metricsRouter;

これはPrometheusがスクレイプするエンドポイントです。公開インターネットに公開しないでください。Kubernetesを使用している場合は、メインサービスポートとは別のポートで公開するのが一般的なパターンです。

どのメトリクスを追跡すべきか#

あらゆるものにメトリクスを追加したくなるかもしれません。やめてください。すべてのメトリクスにはコスト(メモリ、スクレイプ時間、ストレージ)があります。重要なものに集中してください:

REDメソッド(サービス用):

  • Rate:1秒あたりのリクエスト数
  • Errors:1秒あたりの失敗リクエスト数
  • Duration:リクエストにかかる時間

USEメソッド(リソース用):

  • Utilization:リソースの使用率(CPU、メモリ、コネクションプール)
  • Saturation:キューに入っている作業(イベントループラグ、リクエストキュー長)
  • Errors:リソースエラーの数

最低限:

typescript
// REDメトリクス — すべてのサービスに必要
const httpRequestDuration = new Histogram({ ... });    // Duration
const httpRequestsTotal = new Counter({ ... });        // Rate
const httpErrorsTotal = new Counter({ ... });          // Errors
 
// USEメトリクス — インフラに必要
// collectDefaultMetrics()がNode.jsリソースの大部分をカバー
 
// ビジネスメトリクス — あなた固有のサービスに必要
const ordersCreated = new Counter({ ... });
const paymentProcessed = new Histogram({ ... });

メトリクスのカーディナリティの罠#

これは誰もがぶつかる最大の落とし穴です。カーディナリティとはメトリクスラベルのユニーク値の組み合わせ数です。

typescript
// 良い — 低カーディナリティ
httpRequestDuration.observe(
  {
    method: "GET", // ~5の値
    route: "/api/orders", // ~50の値
    status_code: "200", // ~20の値
  },
  duration
);
// 合計の組み合わせ:~5,000 — 問題なし
 
// 悪い — 高カーディナリティ
httpRequestDuration.observe(
  {
    method: "GET",
    url: "/api/orders/12345", // 無限の値!
    user_id: "user_4821", // 数千の値!
    status_code: "200",
  },
  duration
);
// 合計の組み合わせ:無限 — Prometheusが爆発する

ルール:ラベル値は限られた既知のセットであるべきです。ユーザーIDをラベルとして使用しないでください。個別のURLパスを使用しないでください。ルートテンプレート(/api/orders/:id)を使用し、決してインスタンス化されたパス(/api/orders/12345)を使わないでください。

Prometheusの設定#

最小限のPrometheus設定:

yaml
# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s
 
scrape_configs:
  - job_name: "myapp"
    static_configs:
      - targets: ["localhost:3000"]
    metrics_path: "/metrics"
    scrape_interval: 10s

Kubernetesでは、ServiceMonitorやPodアノテーション(prometheus.io/scrape: "true")を使ってサービスディスカバリを自動化するのが一般的です。

Grafanaダッシュボード#

Prometheusだけでは半分です。Grafanaがデータを視覚化します。すべてのサービスに設定する主要パネル:

  1. リクエストレートrate(myapp_http_requests_total[5m])
  2. エラー率rate(myapp_http_requests_total{status_code=~"5.."}[5m]) / rate(myapp_http_requests_total[5m])
  3. レイテンシパーセンタイルhistogram_quantile(0.99, rate(myapp_http_request_duration_seconds_bucket[5m]))
  4. イベントループラグmyapp_nodejs_eventloop_lag_seconds
  5. メモリ使用量myapp_nodejs_heap_size_used_bytes
  6. アクティブ接続数myapp_active_connections

エラー率のGrafanaアラートルール例:

yaml
# 5分間でエラー率が5%を超えた場合のアラート
- alert: HighErrorRate
  expr: |
    rate(myapp_http_requests_total{status_code=~"5.."}[5m])
    / rate(myapp_http_requests_total[5m]) > 0.05
  for: 5m
  labels:
    severity: critical
  annotations:
    summary: "5%超のリクエストがエラーを返している"

柱3:OpenTelemetryによるトレーシング#

トレーシングは3本柱の中で最も導入が難しいものですが、マイクロサービスのデバッグで最も価値の高いものです。トレースがないと、複数のサービスにまたがるリクエストのデバッグは「ログのタイムスタンプ相関」という地獄になります。

OpenTelemetryとは何か#

OpenTelemetry(OTel)は、テレメトリーデータ(トレース、メトリクス、ログ)を生成、収集、エクスポートするためのベンダー中立のフレームワークです。Jaeger、Zipkin、Datadog、Honeycomb、Grafana Tempoなど、任意のバックエンドにデータを送信できます。ベンダーロックインなし。

セットアップ — インストゥルメンテーション#

重要なのは、OpenTelemetryのセットアップはアプリケーションコードが実行される前に行う必要があることです。Node.jsでは、これは別のインストゥルメンテーションファイルで行い、--requireまたは--importで読み込みます。

typescript
// instrumentation.ts — これが最初にロードされる
import { NodeSDK } from "@opentelemetry/sdk-node";
import {
  getNodeAutoInstrumentations,
} from "@opentelemetry/auto-instrumentations-node";
import {
  OTLPTraceExporter,
} from "@opentelemetry/exporter-trace-otlp-http";
import {
  OTLPMetricExporter,
} from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: "order-service",
    [ATTR_SERVICE_VERSION]: "1.2.0",
    environment: process.env.NODE_ENV || "development",
  }),
 
  // トレース — OTLPコレクターへ送信
  traceExporter: new OTLPTraceExporter({
    url:
      process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
      "http://localhost:4318/v1/traces",
  }),
 
  // メトリクス — OTLPコレクターへ送信
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url:
        process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
        "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 30000,
  }),
 
  // 自動インストゥルメンテーション — ライブラリを自動的に計測
  instrumentations: [
    getNodeAutoInstrumentations({
      // 不要なインストゥルメンテーションを無効化
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // HTTP、Express、pg、ioredisなどは自動的に有効
    }),
  ],
});
 
sdk.start();
 
// グレースフルシャットダウン
process.on("SIGTERM", () => {
  sdk
    .shutdown()
    .then(() => console.log("OTel SDK shut down"))
    .catch((err) => console.error("OTel SDK shutdown error", err))
    .finally(() => process.exit(0));
});

起動方法:

bash
node --import ./instrumentation.ts src/index.ts
# または
node --require ./instrumentation.js src/index.js

自動インストゥルメンテーションがライブラリをフックし、コード変更なしでスパンを作成します。Express、HTTP、PostgreSQL(pg)、Redis(ioredis)、gRPC、その他多くのライブラリが自動的に計測されます。

カスタムスパン#

自動インストゥルメンテーションはHTTPやDBの呼び出しをキャッチしますが、アプリケーション固有の操作にはカスタムスパンが必要です:

typescript
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
const tracer = trace.getTracer("order-service");
 
export async function processOrder(orderId: string) {
  // カスタムスパンを作成
  return tracer.startActiveSpan("processOrder", async (span) => {
    try {
      span.setAttribute("order.id", orderId);
 
      // バリデーションスパン
      const validationResult = await tracer.startActiveSpan(
        "validateOrder",
        async (validationSpan) => {
          const result = await validateOrder(orderId);
          validationSpan.setAttribute(
            "validation.itemCount",
            result.items.length
          );
          validationSpan.end();
          return result;
        }
      );
 
      // 支払い処理スパン
      const payment = await tracer.startActiveSpan(
        "processPayment",
        async (paymentSpan) => {
          paymentSpan.setAttribute(
            "payment.method",
            validationResult.paymentMethod
          );
          paymentSpan.setAttribute(
            "payment.amount",
            validationResult.total
          );
          const result = await chargeCustomer(validationResult);
          paymentSpan.setAttribute(
            "payment.transactionId",
            result.transactionId
          );
          paymentSpan.end();
          return result;
        }
      );
 
      // インベントリ更新スパン
      await tracer.startActiveSpan(
        "updateInventory",
        async (inventorySpan) => {
          await updateInventory(validationResult.items);
          inventorySpan.setAttribute(
            "inventory.itemsUpdated",
            validationResult.items.length
          );
          inventorySpan.end();
        }
      );
 
      span.setAttribute("order.status", "completed");
      span.setStatus({ code: SpanStatusCode.OK });
      return { success: true, transactionId: payment.transactionId };
    } catch (error) {
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: error instanceof Error ? error.message : "Unknown error",
      });
      span.recordException(error as Error);
      throw error;
    } finally {
      span.end();
    }
  });
}

このトレースはJaegerやTempoで視覚化すると、こうなります:

processOrder (340ms)
├── validateOrder (15ms)
├── processPayment (290ms)
│   └── HTTP POST payment-gateway.com (285ms)
└── updateInventory (35ms)
    ├── pg.query UPDATE products (12ms)
    └── pg.query INSERT order_items (23ms)

一目で、支払いゲートウェイが時間の85%を占めていることがわかります。ログではこれは一連のタイムスタンプとして見え、手動で計算が必要です。

サービス間でのコンテキスト伝播#

トレーシングの魔法は伝播です。リクエストがサービスAからサービスBに渡されると、トレースコンテキストも一緒に渡されます。OpenTelemetryはHTTPヘッダー(traceparent)を介して自動的にこれを処理します。

typescript
// サービスA — ダウンストリームサービスの呼び出し
app.get("/api/orders/:id", async (req, res) => {
  // このHTTPリクエストはtraceparentヘッダーを自動的に含む
  const inventory = await fetch(
    `http://inventory-service/api/stock/${req.params.id}`
  );
  // ...
});
typescript
// サービスB — 受信リクエストからトレースコンテキストを自動的にピックアップ
app.get("/api/stock/:productId", async (req, res) => {
  // このスパンはサービスAのスパンの子として自動的にリンクされる
  const stock = await db.query(
    "SELECT * FROM inventory WHERE product_id = $1",
    [req.params.productId]
  );
  res.json(stock);
});

両方のサービスにOpenTelemetryがセットアップされていれば、JaegerやTempoはサービス境界を越えた完全なトレースを表示します。これは、リクエストがどこで遅くなっているかを理解するために不可欠です。

OpenTelemetry Collector#

プロダクションでは、アプリケーションからバックエンドに直接エクスポートしないでください。代わりに、OTelコレクターを介してプロキシします。コレクターはバッチ処理、リトライ、リサンプリングなどの制御を提供します。

yaml
# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
 
processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128
 
exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  prometheus:
    endpoint: 0.0.0.0:8889
 
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]

なぜ間にコレクターを入れるのか?

  1. バッファリング — アプリケーションはクラッシュしてもトレースデータを失わない
  2. リサンプリング — コストを管理するためにトレースの一部だけを保持
  3. 変換 — 属性の追加やフィルタリングをアプリ変更なしで
  4. マルチバックエンド — 同じデータを複数の宛先に送信

すべてを統合する:Docker Composeスタック#

ここに、完全なオブザーバビリティスタックの開発用Docker Compose設定を示します:

yaml
# docker-compose.observability.yml
version: "3.8"
 
services:
  # アプリケーション
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
      - LOG_LEVEL=info
    depends_on:
      - otel-collector
 
  # OpenTelemetry Collector
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    command: ["--config=/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "4317:4317" # gRPC
      - "4318:4318" # HTTP
      - "8889:8889" # Prometheusエクスポーター
 
  # Grafana Tempo — トレースストレージ
  tempo:
    image: grafana/tempo:latest
    command: ["-config.file=/etc/tempo.yaml"]
    volumes:
      - ./tempo.yaml:/etc/tempo.yaml
 
  # Prometheus — メトリクスストレージ
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"
 
  # Grafana — ダッシュボードと可視化
  grafana:
    image: grafana/grafana:latest
    ports:
      - "3001:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning

Grafana データソース設定#

yaml
# grafana/provisioning/datasources/datasources.yaml
apiVersion: 1
 
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
 
  - name: Tempo
    type: tempo
    access: proxy
    url: http://tempo:3200
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        filterByTraceID: true
      nodeGraph:
        enabled: true

プロダクション環境での注意事項#

サンプリング#

プロダクションでは、すべてのリクエストをトレースする余裕はないかもしれません。サンプリングはオーバーヘッドを管理します:

typescript
import { TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
 
const sdk = new NodeSDK({
  // トレースの10%のみ記録
  sampler: new TraceIdRatioBasedSampler(0.1),
  // ...残りの設定
});

テール(Tail)サンプリングはさらに賢い方法です。コレクターですべてのトレースを見てから、興味深いもの(エラーがある、遅い、特定の属性を持つ)だけを保持します:

yaml
# otel-collector-config.yaml
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      # すべてのエラーを保持
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      # 遅いトレースを保持
      - name: slow-traces
        type: latency
        latency: { threshold_ms: 1000 }
      # 残りの5%をサンプリング
      - name: randomized
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

ログとトレースの相関#

ログにトレースIDを含めると、JaegerやGrafanaから直接関連するログにジャンプできます:

typescript
import { trace } from "@opentelemetry/api";
 
// ログ時に現在のトレースコンテキストを自動的に含める
const loggerWithTrace = logger.child({
  get traceId() {
    return trace.getActiveSpan()?.spanContext().traceId;
  },
  get spanId() {
    return trace.getActiveSpan()?.spanContext().spanId;
  },
});
 
// ログ出力例:
// {"level":"info","traceId":"abc123","spanId":"def456","msg":"Order created"}

GrafanaではTempoのトレースからLokiのログに直接ジャンプするように設定できます。テレメトリデータ間のこの連携がオブザーバビリティの真の力です。

ヘルスチェックとレディネスプローブ#

オブザーバビリティの基礎として、適切なヘルスチェックも欠かせません:

typescript
app.get("/healthz", async (_req, res) => {
  const health = {
    status: "ok",
    timestamp: new Date().toISOString(),
    checks: {} as Record<string, { status: string; latency?: number }>,
  };
 
  // データベースチェック
  try {
    const start = performance.now();
    await db.query("SELECT 1");
    health.checks.database = {
      status: "ok",
      latency: Math.round(performance.now() - start),
    };
  } catch {
    health.checks.database = { status: "error" };
    health.status = "degraded";
  }
 
  // Redisチェック
  try {
    const start = performance.now();
    await redis.ping();
    health.checks.redis = {
      status: "ok",
      latency: Math.round(performance.now() - start),
    };
  } catch {
    health.checks.redis = { status: "error" };
    health.status = "degraded";
  }
 
  const statusCode = health.status === "ok" ? 200 : 503;
  res.status(statusCode).json(health);
});

アラートのフィロソフィー#

アラートについて最後に重要なことを。すべてにアラートを設定しないでください。アラート疲れはオブザーバビリティの最大の敵です。すべてのwarningにアラートがあると、誰もアラートに注意を払わなくなります。

私のアラートルール:

  1. ユーザーに影響があるものだけアラート — エラー率、レイテンシ、可用性
  2. 予兆にアラート、症状にアラートしない — ディスク使用率80%(予兆)> ディスクフル(もう手遅れ)
  3. アクション可能なアラートだけ — アラートを受け取った人が具体的に何かできるべき
  4. 低レイテンシのアラート閾値に注意 — 一時的なスパイクで深夜に起こされたくない。for: 5mを使う
yaml
# 良いアラートの例
- alert: HighErrorRate
  expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
  for: 5m  # 5分間持続してからアラート
  annotations:
    summary: "5%超のリクエストが失敗"
    runbook: "https://wiki.internal/runbooks/high-error-rate"  # ランブック必須
 
# 悪いアラートの例
- alert: SingleError
  expr: increase(http_requests_total{status="500"}[1m]) > 0
  # for句なし、1回のエラーでアラート、深夜3時に起こされる

まとめ#

オブザーバビリティは目的地ではなく旅です。完璧なセットアップは存在しません。重要なのは、問題が発生したときに「何が起きているかわからない」から「正確にどこが、なぜ壊れているかわかる」に移行できるかどうかです。

始めるための最小限のスタック:

  1. Pino — 構造化ログ、子ロガーによるコンテキスト、redaction
  2. prom-client + Prometheus + Grafana — REDメトリクス、ダッシュボード、アラート
  3. OpenTelemetry + Tempo(またはJaeger) — 分散トレーシング、サービス間のリクエスト追跡

全部を一度に導入する必要はありません。Pinoから始めて構造化ログを手に入れましょう。次にメトリクスを追加してトレンドを見ましょう。最後にトレーシングを追加してボトルネックを見つけましょう。

各ステップで、プロダクションインシデントの平均復旧時間(MTTR)が劇的に短くなります。そして深夜3時に起こされたとき、「何が起きているかわからない」ではなく「正確にこれが問題だ」と言える差は計り知れません。

関連記事