Node.js × OpenTelemetry 実践ガイド:本番運用で役立つ8つの工夫

Node.js × OpenTelemetry 実践ガイド:本番運用で役立つ8つの工夫

分散システムの可観測性(Observability)を高めるためにOpenTelemetryを導入する機会が増えています。本記事では、Node.jsアプリケーションにOpenTelemetryを導入する際の実践的なノウハウを、具体的なコード例とともに解説します。

技術スタック

コアパッケージ

OpenTelemetryの基盤となるパッケージ群です。

npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-trace-node
npm install @opentelemetry/auto-instrumentations-node
npm install @opentelemetry/exporter-trace-otlp-http
  • @opentelemetry/api - OpenTelemetryのAPI仕様
  • @opentelemetry/sdk-node - Node.js向けSDK
  • @opentelemetry/sdk-trace-node - トレーシング機能
  • @opentelemetry/auto-instrumentations-node - 自動計装(HTTP、DB、外部APIなどを自動でトレース)
  • @opentelemetry/exporter-trace-otlp-http - OTLP HTTPプロトコルでのエクスポート

フレームワーク統合

利用しているフレームワークに応じた統合パッケージを導入します。

npm install @hono/otel    # Honoフレームワーク向け
npm install bullmq-otel   # BullMQジョブキュー向け

ロギング統合

構造化ログとトレースを連携させることで、ログからトレースへのジャンプが可能になります。

npm install pino hono-pino

ローカル開発環境の構築

本番環境に近いトレーシング体験をローカルで得るには、Jaeger All-in-Oneが便利です。数分で環境を構築できます。

Docker Composeでの起動

# docker-compose.yml
services:
  otel:
    image: jaegertracing/all-in-one:latest
    ports:
      - "4318:4318"    # OTLP HTTP Collector
      - "16686:16686"  # Jaeger UI
docker-compose up -d

起動後、http://localhost:16686/ でJaeger UIにアクセスできます。

環境変数の設定

OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel:4318/v1/traces
OTEL_SDK_DISABLED=false
OTEL_METRICS_EXPORTER=none  # Jaegerはメトリクス非対応のため無効化

本番運用で役立つ8つの工夫

ここからが本記事のメインです。実際の運用で遭遇した課題と、その解決策を紹介します。

工夫①:二重初期化の防止(Symbolパターン)

Node.jsのモジュールキャッシングにより、ESMとCJSが混在する環境ではSDKが二重に初期化される可能性があります。Symbolを使ったグローバル状態管理で防止できます。

const globalKey = Symbol.for("@myapp/otel/sdkInitialized")
const globalState = globalThis as typeof globalThis & { [globalKey]?: boolean }

if (!globalState[globalKey]) {
  sdk.start()
  globalState[globalKey] = true
}

Symbol.for()はグローバルSymbolレジストリを使用するため、モジュールの読み込み方法に関わらず同じSymbolを参照できます。

工夫②:カスタムSpanProcessorによるノイズ除外

ヘルスチェックやネットワークレベルのスパン(DNS lookup、TLS handshakeなど)は、トラブルシューティング時にノイズになりがちです。カスタムSpanProcessorで除外しましょう。

class FilteringSpanProcessor implements SpanProcessor {
  private excludedTraceIds = new Set<string>()
  private static MAX_EXCLUDED_TRACE_IDS = 10000

  onStart(span: Span) {
    // ヘルスチェックスパンを除外
    if (span.attributes["url.full"]?.toString().includes("/health")) {
      this.excludedTraceIds.add(span.spanContext().traceId)
    }

    // ルートレベルのネットワークスパンを除外
    if (!span.parentSpanId &&
        ["dns.lookup", "tls.connect", "tcp.connect"].includes(span.name)) {
      this.excludedTraceIds.add(span.spanContext().traceId)
    }
  }

  onEnd(span: ReadableSpan) {
    if (this.excludedTraceIds.has(span.spanContext().traceId)) {
      return // エクスポートしない
    }
    // 通常のエクスポート処理
  }
}

効果: ストレージコストの削減、クエリ性能の向上、可読性の改善

工夫③:メモリリーク対策

工夫②で使用したexcludedTraceIdsのSetは、長時間運用すると無限に膨らむ可能性があります。上限を設けてクリアしましょう。

if (this.excludedTraceIds.size >= FilteringSpanProcessor.MAX_EXCLUDED_TRACE_IDS) {
  this.excludedTraceIds.clear()
  diag.warn("excludedTraceIds size exceeded, cleared")
}

工夫④:自動計装の選別設定

@opentelemetry/auto-instrumentations-nodeは便利ですが、すべての計装を有効にするとノイズが増えます。プロジェクトに応じて取捨選択しましょう。

const instrumentations = getNodeAutoInstrumentations({
  // ファイルI/Oのトレースはノイズになりやすい
  "@opentelemetry/instrumentation-fs": { enabled: false },

  // HTTPの受信リクエストは@hono/otelに委譲
  "@opentelemetry/instrumentation-http": {
    disableIncomingRequestInstrumentation: true,
    requestHook: (span, request) => {
      // 送信リクエストにはメソッド情報を付与
      const path = new URL(request.url).pathname
      span.setAttribute("rpc.method", `${request.method} ${path}`)
    }
  },

  // pinoのESM対応に問題があるため無効化
  "@opentelemetry/instrumentation-pino": { enabled: false },
})

工夫⑤:AWS X-Ray互換のトレースID変換

OpenTelemetryとAWS X-RayではトレースIDの形式が異なります。変換関数を用意しておくと、CloudWatchでの検索が容易になります。

function convertToXRayTraceId(traceId: string): string {
  // OpenTelemetry: 32文字の16進数
  // X-Ray形式: 1-{8文字}-{24文字}
  const timestampPart = traceId.substring(0, 8)
  const uniqueIdPart = traceId.substring(8)
  return `1-${timestampPart}-${uniqueIdPart}`
}

// 使用例
const otelTraceId = "5f84b7a1234567890abcdef123456789"
const xrayTraceId = convertToXRayTraceId(otelTraceId)
// → "1-5f84b7a1-234567890abcdef123456789"

工夫⑥:属性補完でAWS X-Ray Application Signals対応

X-Rayコンソールで「UnknownRemoteService」と表示される問題は、peer.service属性の欠落が原因です。ネットワークスパンに属性を自動設定しましょう。

// SpanProcessor内でのフック例
onStart(span: Span) {
  const spanName = span.name
  const urlAttr = span.attributes["url.full"]?.toString()

  if (urlAttr) {
    const hostname = new URL(urlAttr).hostname
    span.setAttribute("peer.service", hostname)
    span.setAttribute("rpc.method", spanName)
  }
}

これにより、X-RayのService Mapやサービス間依存関係の可視化が正しく機能します。

工夫⑦:ログとトレースの自動連携

構造化ログにトレースIDを埋め込むことで、ログからX-Rayへ直接ジャンプできるようになります。

function getSpanContextLogBindings(span: Span | undefined) {
  if (!span) return {}

  const spanCtx = span.spanContext()
  return {
    xray_trace_id: convertToXRayTraceId(spanCtx.traceId),
    trace_id: spanCtx.traceId,
    span_id: spanCtx.spanId,
  }
}

// Pinoロガーでの使用例
const currentSpan = trace.getActiveSpan()
const logger = baseLogger.child(getSpanContextLogBindings(currentSpan))
logger.info("Processing request")

CloudWatch Logs Insightsではxray_trace_idフィールドでフィルタリングできます。

工夫⑧:preLoadによる早期初期化

OpenTelemetry SDKはアプリケーションの起動前に初期化する必要があります。--importフラグを使えば、すべてのモジュールロードがトレース対象になります。

# 開発時
tsx watch --import ./src/lib/telemetry/preLoad.ts ./src/app/main.ts

# 本番時
node --import ./dist/lib/telemetry/preLoad.js ./dist/app/main.js

preLoad.tsの中でSDKを初期化しておきます。

// src/lib/telemetry/preLoad.ts
import { initializeTelemetry } from "./init"
initializeTelemetry()

メトリクス設計の実例

ジョブキュー(BullMQなど)の監視を例に、メトリクス設計のベストプラクティスを紹介します。

import { metrics } from "@opentelemetry/api"

class QueueMetrics {
  private meter = metrics.getMeter("queue-metrics")

  // ジョブ処理総数(ステータス別)
  jobsTotal = this.meter.createCounter("jobs_total", {
    description: "Total number of jobs processed"
  })

  // 処理中ジョブ数(リアルタイム)
  jobsActive = this.meter.createUpDownCounter("jobs_active", {
    description: "Currently active jobs"
  })

  // ジョブ処理時間の分布
  jobDuration = this.meter.createHistogram("job_duration_seconds", {
    description: "Job processing duration in seconds"
  })

  // リトライ総数
  jobRetries = this.meter.createCounter("job_retries_total", {
    description: "Total number of job retries"
  })
}

ラベル設計

// ラベルの例
const labels = {
  queue: "notification",        // キュー名
  job: "sendEmail",            // ジョブ種別
  status: "completed"          // completed | failed_retry | failed_permanent
}

queueMetrics.jobsTotal.add(1, labels)

ラベルのカーディナリティ(組み合わせ数)が爆発しないよう、値のバリエーションが限定的な属性のみをラベルに使用しましょう。

ライフサイクル管理

Graceful shutdownを実装し、プロセス終了時にスパンが確実にエクスポートされるようにします。

interface LifecycleHandler {
  name: string
  priority: number  // 小さいほど先に初期化、後にクリーンアップ
  cleanup: () => Promise<void>
}

const handlers: LifecycleHandler[] = []

function registerLifecycle(handler: LifecycleHandler) {
  handlers.push(handler)
  handlers.sort((a, b) => a.priority - b.priority)
}

// OpenTelemetryは最優先で登録
registerLifecycle({
  name: "opentelemetry",
  priority: 10,
  cleanup: async () => {
    await sdk.shutdown()
  },
})

// シャットダウン時の処理
process.on("SIGTERM", async () => {
  for (const handler of handlers.reverse()) {
    await handler.cleanup()
  }
  process.exit(0)
})

priorityを使うことで、OpenTelemetryのシャットダウンを最後に行い、他のコンポーネントのクリーンアップ処理もトレースできます。

環境別設定

Zodを使った環境変数のバリデーションで、設定ミスを起動時に検出します。

import { z } from "zod"

const otelEnvSchema = z.object({
  OTEL_SDK_DISABLED: z.string().optional(),
  OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: z.string().url().optional(),
  DEPLOY_ENV: z.enum(["dev", "stg", "prod"]).optional(),
  APP_NAME: z.string(),
  APP_MODE: z.enum(["publicApi", "internalApi", "job"]),
})

export const otelEnv = otelEnvSchema.parse(process.env)

サービス名の動的生成

環境やアプリモードごとにサービス名を分けると、トレースの分類・検索が容易になります。

function getServiceName(): string {
  const prefix = otelEnv.DEPLOY_ENV
    ? `${camelCase(otelEnv.DEPLOY_ENV)}.`
    : ""
  return `${prefix}${camelCase(otelEnv.APP_NAME)}.${camelCase(otelEnv.APP_MODE)}`
}

// 生成例
// "dev.backend.publicApi"
// "prod.backend.job"
// "stg.backend.internalApi"

AWS環境での確認方法

X-Rayコンソール

工夫⑤のトレースID変換により、CloudWatchログからX-Rayコンソールへ直接遷移できます。

CloudWatch Logs Insights

fields @timestamp, @message, xray_trace_id
| filter xray_trace_id like /1-[a-f0-9]{8}-[a-f0-9]{24}/
| sort @timestamp desc
| limit 100

Application Signals

工夫⑥のpeer.service属性を設定することで、サービス間依存関係が正しく可視化されます。

まとめ

本記事で紹介した工夫をまとめます。

カテゴリポイント
導入の容易さJaeger All-in-Oneでローカル環境が数分で構築可能
ノイズ対策ヘルスチェック・ネットワークスパンの除外で見やすいトレース
AWS統合トレースID変換とpeer.service属性でX-Rayと自然に連携
ログ連携構造化ログにトレースIDを埋め込み、相関分析が容易
型安全Zodによる環境変数バリデーションで設定ミス防止
本番運用メモリリーク対策、Graceful shutdown対応

OpenTelemetryは柔軟性が高い分、適切な設定が求められます。本記事の工夫が、皆さんのプロジェクトの参考になれば幸いです。

参考リンク