分散システムの可観測性(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は柔軟性が高い分、適切な設定が求められます。本記事の工夫が、皆さんのプロジェクトの参考になれば幸いです。