Skip to content
Published on

分散トレーシング実践ガイド: OpenTelemetry, Jaeger, Grafana Tempo

Authors
  • Name
    Twitter
分散トレーシング OpenTelemetry

はじめに

マイクロサービスアーキテクチャにおいて、1つのユーザーリクエストは複数のサービスを経由して処理されます。API Gatewayから始まったリクエストが、認証サービス、注文サービス、決済サービス、通知サービスを順次的・並列的に呼び出す構造では、特定のリクエストが遅い原因を把握するために、呼び出しチェーン全体を追跡する必要があります。単一サービスのログやメトリクスだけでは、サービス間の因果関係を把握するのは困難です。

分散トレーシング(Distributed Tracing)はこの問題を解決します。リクエストがサービス境界を越えるたびに固有のTrace IDを伝播し、各サービスで実行した作業をSpanとして記録することで、リクエスト全体の流れを1つのトレースとして再構成します。Googleの Dapper論文(2010年)から始まったこの概念は、現在OpenTelemetryというCNCFプロジェクトとして標準化されており、JaegerやGrafana Tempoなどのバックエンドを通じて保存・分析できます。

この記事では、分散トレーシングのコア概念から始まり、OpenTelemetry SDKを活用したPython/Goの計装、OpenTelemetry Collectorの構成、JaegerとGrafana Tempoバックエンドの構築、サンプリング戦略、コスト最適化、運用時の注意事項まで、プロダクション環境で必要なパイプライン全体を実践的なコードとともに解説します。


分散トレーシングのコア概念

Trace、Span、Context Propagation

分散トレーシングの3つのコア概念を整理します。

Traceは、1つのユーザーリクエストがシステムを通過する全体の経路を表します。固有のTrace IDで識別され、複数のSpanの集合で構成されます。

Spanは、トレース内の1つの作業単位を表します。各Spanは固有のSpan ID、親Span ID、開始/終了時刻、属性(Attributes)、イベント(Events)、ステータス(Status)を持ちます。Spanの種類(SpanKind)には、SERVER、CLIENT、PRODUCER、CONSUMER、INTERNALの5つがあります。

Context Propagationは、Trace IDとSpan IDをサービス境界を越えて伝達するメカニズムです。HTTPヘッダー(W3C Trace Contextのtraceparent、tracestate)、gRPCメタデータ、メッセージキューヘッダーなどを通じてコンテキストが伝播されます。

W3C Trace Context標準

W3C Trace Contextは、分散トレーシングのコンテキスト伝播標準です。traceparentヘッダーの形式は以下の通りです。

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             |  |                                |                |
           バージョン  Trace ID (32文字)           Parent Span ID   Flags

OpenTelemetryはデフォルトでW3C Trace Contextを使用し、B3(Zipkin)やJaeger形式の伝播もサポートしています。

OpenTelemetryアーキテクチャ概要

OpenTelemetryは、テレメトリデータ(トレース、メトリクス、ログ)を生成、収集、送信するためのベンダー中立的なフレームワークです。主な構成要素は以下の通りです。

  • API: 計装のためのインターフェース定義(ベンダー非依存)
  • SDK: APIの実装体(サンプリング、バッチ、エクスポートなど)
  • Collector: テレメトリデータを受信、処理、エクスポートする独立プロセス
  • Instrumentation Libraries: ライブラリ/フレームワークの自動計装

OpenTelemetry SDK計装(Python/Go)

Python計装

PythonアプリケーションにOpenTelemetryを適用する方法を見ていきます。まず必要なパッケージをインストールします。

pip install opentelemetry-api \
  opentelemetry-sdk \
  opentelemetry-exporter-otlp \
  opentelemetry-instrumentation-flask \
  opentelemetry-instrumentation-requests \
  opentelemetry-instrumentation-sqlalchemy

次に、FlaskアプリケーションにOpenTelemetryを適用する全体のコードです。

# tracing.py - OpenTelemetry 초기화 모듈
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import W3CBaggagePropagator

def init_tracer(service_name: str, otlp_endpoint: str = "localhost:4317"):
    """OpenTelemetry TracerProviderを初期化し、グローバル設定を適用する。"""
    resource = Resource.create({
        SERVICE_NAME: service_name,
        SERVICE_VERSION: "1.0.0",
        "deployment.environment": "production",
    })

    provider = TracerProvider(resource=resource)

    # OTLP gRPC Exporter 設定
    otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
    span_processor = BatchSpanProcessor(
        otlp_exporter,
        max_queue_size=2048,
        max_export_batch_size=512,
        schedule_delay_millis=5000,
    )
    provider.add_span_processor(span_processor)

    # グローバルTracerProvider登録
    trace.set_tracer_provider(provider)

    # W3C Trace Context + Baggage 伝播設定
    set_global_textmap(CompositePropagator([
        TraceContextTextMapPropagator(),
        W3CBaggagePropagator(),
    ]))

    return provider
# app.py - Flask アプリケーションにトレーシングを適用
from flask import Flask, request, jsonify
from opentelemetry import trace
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from tracing import init_tracer
import requests

# トレーサー初期化
provider = init_tracer("order-service", "otel-collector:4317")

app = Flask(__name__)

# 自動計装: Flaskとrequestsライブラリ
FlaskInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()

# 手動計装のためのトレーサー取得
tracer = trace.get_tracer("order-service", "1.0.0")

@app.route("/orders", methods=["POST"])
def create_order():
    """注文作成API - 手動Spanを追加して詳細な追跡を実行"""
    with tracer.start_as_current_span(
        "validate_order",
        attributes={"order.type": "standard"}
    ) as span:
        order_data = request.get_json()
        span.set_attribute("order.item_count", len(order_data.get("items", [])))

        # 在庫確認 - 外部サービス呼び出し(自動計装済み)
        inventory_resp = requests.post(
            "http://inventory-service:8080/check",
            json=order_data["items"]
        )

        if inventory_resp.status_code != 200:
            span.set_status(trace.StatusCode.ERROR, "Inventory check failed")
            span.record_exception(Exception("在庫不足"))
            return jsonify({"error": "insufficient_inventory"}), 400

    with tracer.start_as_current_span("process_payment") as span:
        payment_resp = requests.post(
            "http://payment-service:8080/charge",
            json={"amount": order_data["total"]}
        )
        span.set_attribute("payment.method", order_data.get("payment_method", "card"))

    return jsonify({"order_id": "ORD-12345", "status": "confirmed"}), 201

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

Go計装

Goアプリケーションでは go.opentelemetry.io/otel パッケージを使用します。HTTPサーバーとgRPCクライアントにトレーシングを適用する例を見ていきます。

// tracing.go - OpenTelemetry 초기화
package main

import (
    "context"
    "log"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
    "go.opentelemetry.io/otel/trace"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)

func initTracer(ctx context.Context, serviceName, endpoint string) (*sdktrace.TracerProvider, error) {
    // gRPC接続でOTLP Exporterを作成
    conn, err := grpc.NewClient(endpoint,
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        return nil, err
    }

    exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
    if err != nil {
        return nil, err
    }

    // Resource定義 - サービスメタデータ
    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceName(serviceName),
            semconv.ServiceVersion("1.0.0"),
            semconv.DeploymentEnvironment("production"),
        ),
    )
    if err != nil {
        return nil, err
    }

    // TracerProvider作成
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter,
            sdktrace.WithMaxQueueSize(2048),
            sdktrace.WithMaxExportBatchSize(512),
        ),
        sdktrace.WithResource(res),
        sdktrace.WithSampler(sdktrace.ParentBased(
            sdktrace.TraceIDRatioBased(0.1), // 10%サンプリング
        )),
    )

    // グローバル設定
    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    ))

    return tp, nil
}

// handleOrder - HTTPハンドラーで手動Spanを作成
func handleOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    tracer := otel.Tracer("order-handler")

    ctx, span := tracer.Start(ctx, "process-order",
        trace.WithSpanKind(trace.SpanKindServer),
    )
    defer span.End()

    // ビジネスロジックにコンテキストを渡す
    orderID, err := processOrder(ctx)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        http.Error(w, "order failed", http.StatusInternalServerError)
        return
    }

    span.SetAttributes(attribute.String("order.id", orderID))
    w.WriteHeader(http.StatusCreated)
}

自動計装 vs 手動計装

区分自動計装手動計装
適用方式ライブラリ/エージェントを使用コードに直接Spanを作成
コード変更最小(初期化のみ)ビジネスロジック各所に挿入
カバレッジHTTP、DB、gRPCなどフレームワークレベルカスタムビジネスロジックまで対応可能
細かい制御制限的属性、イベント、リンクを自由に追加
推奨戦略基本骨格として使用コアビジネスロジックに補完的に追加

実践では、自動計装でHTTP/gRPC/DB呼び出しの基本Spanを作成し、手動計装でビジネスロジックの重要な区間を追加で計装するハイブリッド戦略を推奨します。


OpenTelemetry Collectorの構成

OpenTelemetry Collectorは、テレメトリデータを受信(Receivers)、処理(Processors)、エクスポート(Exporters)するパイプラインの役割を担います。アプリケーションから直接バックエンドにデータを送信する代わりに、Collectorを中間に配置することで、リトライ、バッチ、サンプリング、ルーティングなどを一元管理できます。

Collector構成ファイル

# 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
    send_batch_max_size: 2048

  memory_limiter:
    check_interval: 1s
    limit_mib: 1024
    spike_limit_mib: 256

  resource:
    attributes:
      - key: environment
        value: production
        action: upsert
      - key: cluster
        value: k8s-prod-01
        action: upsert

  # Tail-based サンプリング: エラーと遅いリクエストは100%収集
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    expected_new_traces_per_sec: 1000
    policies:
      - name: error-policy
        type: status_code
        status_code:
          status_codes:
            - ERROR
      - name: latency-policy
        type: latency
        latency:
          threshold_ms: 1000
      - name: probabilistic-policy
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

exporters:
  otlp/jaeger:
    endpoint: jaeger-collector:4317
    tls:
      insecure: true

  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true

  debug:
    verbosity: basic

extensions:
  health_check:
    endpoint: 0.0.0.0:13133
  zpages:
    endpoint: 0.0.0.0:55679

service:
  extensions: [health_check, zpages]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resource, tail_sampling, batch]
      exporters: [otlp/jaeger, otlp/tempo, debug]
  telemetry:
    logs:
      level: info
    metrics:
      address: 0.0.0.0:8888

Collector Docker Composeデプロイ

# docker-compose.yaml (Collector 部分)
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.116.0
    command: ['--config=/etc/otel-collector-config.yaml']
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
    ports:
      - '4317:4317' # OTLP gRPC
      - '4318:4318' # OTLP HTTP
      - '8888:8888' # Collector メトリクス
      - '13133:13133' # Health check
      - '55679:55679' # zPages
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 2g
          cpus: '1.0'
    healthcheck:
      test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:13133']
      interval: 10s
      timeout: 5s
      retries: 3

Collectorの構成で注意すべき点はprocessorsの順序です。memory_limiterは必ず最初に配置してOOMを防止し、batchは最後に配置してネットワーク効率を高めます。


Jaegerバックエンドの構築と運用

Jaegerは、Uberが開発したオープンソースの分散トレーシングシステムで、CNCF Graduatedプロジェクトです。Jaeger v2はOpenTelemetry Collectorを基盤に再構築されており、ネイティブOTLPサポートを提供します。

Jaegerプロダクションデプロイ

プロダクション環境では、Jaeger all-in-oneではなく、分離されたコンポーネントでのデプロイを推奨します。Elasticsearchをストレージバックエンドとして使用する構成を見ていきます。

# docker-compose-jaeger.yaml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - 'ES_JAVA_OPTS=-Xms1g -Xmx1g'
    ports:
      - '9200:9200'
    volumes:
      - es_data:/usr/share/elasticsearch/data
    healthcheck:
      test: ['CMD-SHELL', 'curl -f http://localhost:9200/_cluster/health || exit 1']
      interval: 10s
      timeout: 5s
      retries: 10

  jaeger:
    image: jaegertracing/jaeger:2.4.0
    environment:
      - SPAN_STORAGE_TYPE=elasticsearch
      - ES_SERVER_URLS=http://elasticsearch:9200
      - ES_NUM_SHARDS=3
      - ES_NUM_REPLICAS=1
      - ES_INDEX_PREFIX=jaeger
      - ES_TAGS_AS_FIELDS_ALL=true
      - COLLECTOR_OTLP_ENABLED=true
    ports:
      - '16686:16686' # Jaeger UI
      - '4317:4317' # OTLP gRPC
      - '4318:4318' # OTLP HTTP
      - '14250:14250' # gRPC Collector
    depends_on:
      elasticsearch:
        condition: service_healthy
    restart: unless-stopped

volumes:
  es_data:
    driver: local

Jaegerインデックス管理

Elasticsearch基盤のJaeger運用において、インデックス管理は必須です。古いトレースを自動削除するcronジョブを設定します。

#!/bin/bash
# jaeger-index-cleaner.sh - 14日以上経過したJaegerインデックスを削除
ES_URL="http://elasticsearch:9200"
RETENTION_DAYS=14

# 削除対象の日付を計算
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)

echo "Deleting Jaeger indices older than ${CUTOFF_DATE}..."

# jaeger-span-* および jaeger-service-* インデックスの照会と削除
for INDEX_TYPE in "jaeger-span" "jaeger-service"; do
  INDICES=$(curl -s "${ES_URL}/_cat/indices/${INDEX_TYPE}-*" \
    | awk '{print $3}' \
    | sort)

  for INDEX in ${INDICES}; do
    # インデックス名から日付を抽出(jaeger-span-2026-03-01 形式)
    INDEX_DATE=$(echo "${INDEX}" | grep -oP '\d{4}-\d{2}-\d{2}')
    if [[ "${INDEX_DATE}" < "${CUTOFF_DATE}" ]]; then
      echo "Deleting index: ${INDEX}"
      curl -s -X DELETE "${ES_URL}/${INDEX}"
    fi
  done
done

echo "Cleanup completed."

Jaegerの主要機能

  • トレース検索: サービス名、操作名、タグ、時間範囲によるトレース検索
  • トレース比較: 2つのトレースを並べて比較し、パフォーマンスの違いを分析
  • 依存関係グラフ: サービス間の呼び出し関係をDAGで可視化
  • SPM(Service Performance Monitoring): REDメトリクス(Rate、Error、Duration)の自動生成

Grafana Tempo: 大規模トレースストレージ

Grafana Tempoは、大規模な分散トレーシングのために設計されたバックエンドです。Jaegerとは異なりインデックスを作成せず、オブジェクトストレージ(S3、GCS、Azure Blob)にトレースを直接保存することで、コストを大幅に削減します。

Tempoの構成

# tempo-config.yaml
server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

ingester:
  max_block_duration: 5m
  max_block_bytes: 1073741824 # 1GB
  flush_check_period: 10s

compactor:
  compaction:
    block_retention: 336h # 14日
  ring:
    kvstore:
      store: memberlist

storage:
  trace:
    backend: s3
    s3:
      bucket: tempo-traces-prod
      endpoint: s3.ap-northeast-2.amazonaws.com
      region: ap-northeast-2
      # IAM Roleベースの認証を推奨(access_keyの直接入力は避ける)
    wal:
      path: /var/tempo/wal
    local:
      path: /var/tempo/blocks
    pool:
      max_workers: 100
      queue_depth: 10000

querier:
  search:
    query_timeout: 30s
  frontend_worker:
    frontend_address: tempo-query-frontend:9095

metrics_generator:
  registry:
    external_labels:
      source: tempo
      cluster: k8s-prod
  storage:
    path: /var/tempo/generator/wal
    remote_write:
      - url: http://prometheus:9090/api/v1/write
        send_exemplars: true
  traces_storage:
    path: /var/tempo/generator/traces
  processor:
    service_graphs:
      dimensions:
        - service.namespace
        - deployment.environment
    span_metrics:
      dimensions:
        - http.method
        - http.status_code
        - http.route

overrides:
  defaults:
    metrics_generator:
      processors: [service-graphs, span-metrics]

Tempo + Grafana統合

Tempoの真の力は、Grafanaとの統合にあります。TraceQLを使用してトレースをクエリし、ログ(Loki)とメトリクス(Prometheus/Mimir)をトレースと相互連携できます。

GrafanaのデータソースにTempoを追加し、Trace to Logs、Trace to Metrics機能を有効にすると、トレースの特定のSpanから関連するログやメトリクスに直接ジャンプできます。これがGrafanaスタックの核心的な差別化ポイントである「相関分析(Correlation)」です。

TraceQLクエリ例

Tempo 2.0から導入されたTraceQLは、トレース専用のクエリ言語です。

// エラーステータスのSpanを持つトレースを検索
{ status = error }

// 特定のサービスで1秒以上かかったSpanを検索
{ resource.service.name = "order-service" && duration > 1s }

// HTTP 500エラーを返したSpanを検索
{ span.http.status_code = 500 }

// 2つのサービス間の呼び出し関係を検索
{ resource.service.name = "api-gateway" } >> { resource.service.name = "payment-service" }

トレーシングバックエンド比較(Jaeger vs Tempo vs Zipkin)

トレーシングバックエンドを選択する際は、環境と要件に合ったツールを選ぶことが重要です。主要なトレーシングバックエンドを比較します。

項目JaegerGrafana TempoZipkinAWS X-Ray
開発元Uber (CNCF Graduated)Grafana LabsTwitter (現 X)AWS
ストレージElasticsearch, Cassandra, KafkaS3, GCS, Azure BlobElasticsearch, MySQL, CassandraAWS独自ストレージ
インデックス完全インデックスインデックスなし(Trace IDベース)完全インデックス独自インデックス
クエリサービス、タグ、時間ベース検索TraceQL、Trace ID検索サービス、タグ、時間ベース検索Filter Expressions
GBあたりストレージコスト高い(ESインデックス)非常に低い(オブジェクトストレージ)高い(ESインデックス)中程度(マネージド)
OTLPサポートネイティブ(v2)ネイティブ別途Collector必要別途SDK/Collector
スケーリング水平スケーリング可能水平スケーリング優秀制限的自動(マネージド)
UI自前UI(優秀)Grafana(卓越)自前UI(基本的)AWSコンソール
メトリクス生成SPM(v2)Metrics Generatorなし独自メトリクス
ログ連携制限的Lokiネイティブ連携なしCloudWatch連携
適した環境独立トレーシングシステムGrafanaスタック使用環境小規模/学習AWSネイティブ

選択ガイド

  • Jaeger: 独立したトレーシングシステムが必要で、豊富な検索機能が重要であり、Elasticsearch/Cassandraの運用能力があるチーム
  • Grafana Tempo: すでにGrafana、Loki、Prometheus/Mimirを使用しており、コスト効率が重要で、大規模なトレースを処理する必要がある環境
  • Zipkin: 迅速なプロトタイピングや学習目的、またはレガシーZipkin形式との互換性が必要な場合
  • AWS X-Ray: AWSサービス(Lambda、ECS、EKS)を主に使用し、マネージドサービスを好む環境

サンプリング戦略とコスト最適化

プロダクション環境ですべてのトレースを収集すると、ストレージコストとネットワーク負荷が急増します。効果的なサンプリング戦略は、コストと可視性のバランスを取るための鍵です。

Head-basedサンプリング

リクエストの開始時にサンプリングの可否を決定します。実装はシンプルですが、エラートレースを見逃す可能性があります。

# PythonでのHead-based確率的サンプリング設定
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBased

# ParentBased: 親Spanがサンプリングされていれば子もサンプリング
# 親がないルートSpanは10%の確率でサンプリング
sampler = ParentBased(root=TraceIdRatioBased(0.1))

provider = TracerProvider(
    resource=resource,
    sampler=sampler,
)

Tail-basedサンプリング

トレースが完成した後、全体のSpanを見てサンプリングの可否を決定します。OpenTelemetry Collectorの tail_sampling プロセッサを使用し、エラートレースやレイテンシの大きいトレースを100%収集できます。ただし、Collectorにメモリ負荷がかかるため、decision_waitの時間とnum_tracesの設定を環境に合わせて調整する必要があります。

コスト最適化戦略

  1. 階層別サンプリング: 正常トラフィックは5~10%、エラー/遅いリクエストは100%収集
  2. サービス別差等サンプリング: コアサービスは高い割合、内部ヘルスチェックは0%
  3. ストレージティアリング: 最新データは高速ストレージ、古いデータは低コストストレージ
  4. TTL設定: トレースの保持期間を7~14日に制限(ほとんどのデバッグは48時間以内に発生)
  5. Attributeフィルタリング: 不要なSpan AttributeをCollectorで除去してデータサイズを削減

運用時の注意事項とトラブルシューティング

Context Propagationの欠落

分散トレーシングで最も一般的な問題は、コンテキスト伝播の欠落です。サービス間の呼び出しでtraceparentヘッダーが伝達されないと、トレースが途切れます。

症状: Jaeger/Tempoでトレースを見ると、Spanが1つしかないか、異なるTrace IDに分離されています。

原因と解決策:

  • HTTPクライアントライブラリの自動計装が欠落している場合: RequestsInstrumentor().instrument() または otelhttp.NewTransport() を適用
  • メッセージキュー(Kafka、RabbitMQ)を通じた呼び出しでコンテキストが伝播されない場合: メッセージヘッダーにtraceparentを手動で注入/抽出
  • gRPCインターセプターが適用されていない場合: otelgrpc.UnaryClientInterceptor() を追加

Collectorのボトルネックとバックプレッシャー

トラフィックが急増すると、Collectorが処理量に追いつけず、Spanが欠落する可能性があります。

モニタリング指標:

  • otelcol_exporter_send_failed_spans: エクスポートに失敗したSpan数
  • otelcol_processor_batch_timeout_trigger_send: タイムアウトによる強制送信
  • otelcol_receiver_refused_spans: 受信拒否されたSpan数

解決策:

  • memory_limiterプロセッサを必ず設定してOOMを防止
  • Collectorを水平スケールし、ロードバランサーの背後に配置
  • batchプロセッサのsend_batch_sizetimeoutを調整

高カーディナリティAttribute

Span Attributeにユーザー ID、セッションID、リクエストボディなどカーディナリティの高い値を入れると、インデックスコストが急増します(特にJaeger + Elasticsearch環境)。

ガイドライン:

  • Attribute値のカーディナリティは数千以下に維持
  • 一意な識別子はSpan Attributeの代わりにSpan Eventやログに記録
  • JaegerのES_TAGS_AS_FIELDS_ALL=falseに設定し、必要なタグのみインデックス化

障害事例と復旧手順

事例1: Collector OOMによるトレース消失

状況: Tail-basedサンプリングを使用するCollectorで、トラフィック急増によりメモリ使用量が限界を超え、プロセスが終了しました。約5分間、すべてのトレースが消失しました。

根本原因: tail_samplingプロセッサのnum_tracesが50,000に設定されていましたが、ピーク時には毎秒10,000件の新しいトレースが流入していました。decision_waitが30秒に設定されていたため、最大300,000個のトレースをメモリに保持する必要がありました。

復旧手順:

  1. Collector Podの自動再起動を確認(Kubernetes liveness probe)
  2. num_tracesを200,000に増加、decision_waitを10秒に短縮
  3. memory_limiterlimit_mibを実際のPodメモリ上限の80%に設定
  4. Collectorインスタンスを3つに水平スケール

予防措置: Collectorのメモリ使用量に対するアラートルールを設定し、オートスケーリング(HPA)を適用します。

事例2: Elasticsearchインデックス爆増によるJaeger障害

状況: 新しいサービスをデプロイした後、Span Attributeにリクエストボディ全体を含む計装が追加されました。24時間でElasticsearchのディスク使用量が95%を超え、インデックスの書き込みがブロックされました。

根本原因: 開発者がデバッグ目的で span.set_attribute("request.body", json.dumps(request_body)) を追加しましたが、このAttributeの平均サイズが10KBで、毎秒1,000件のリクエストが発生し、1日あたり約800GBの追加データが生成されました。

復旧手順:

  1. 問題のあるサービスのSpan Attributeを除去するホットフィックスをデプロイ
  2. Elasticsearchのディスクウォーターマークを一時的に調整して書き込みを再開
  3. 問題期間のインデックスを手動削除してディスクを確保
  4. CollectorにAttribute サイズ制限プロセッサを追加

予防措置: CollectorレベルでAttributesプロセッサを使用してAttribute値のサイズを制限し、CI/CDパイプラインで計装コードレビューを義務化します。

事例3: クロックスキューによる異常なSpan順序

状況: Jaeger UIでトレースを見ると、子Spanが親Spanより先に開始されたり、Spanの時間がマイナスで表示されていました。

根本原因: コンテナホストノード間の時刻同期(NTP)が正常に動作しておらず、最大500msのクロックスキューが発生していました。

復旧手順:

  1. すべてのノードでNTP同期状態を確認: chronyc tracking
  2. NTPサーバー設定の修正と強制同期
  3. クロックスキューが大きい期間のトレースデータは無視

予防措置: Kubernetesノードにchronyまたはsystemd-timesyncdを設定し、NTPオフセットに対するモニタリングアラートを構成します。


おわりに

分散トレーシングは、マイクロサービスアーキテクチャにおいて不可欠なオブザーバビリティツールです。OpenTelemetryを通じてベンダー中立的に計装し、JaegerまたはGrafana Tempoをバックエンドとして使用すれば、プロダクション環境でリクエストフローを透過的に追跡できます。

まとめ:

  1. OpenTelemetryで計装を標準化します。自動計装で基本骨格を作り、手動計装でビジネスロジックを補完します。
  2. OpenTelemetry Collectorを中間に配置します。リトライ、サンプリング、ルーティングを一元管理します。
  3. バックエンドは環境に合わせて選択します。GrafanaスタックならTempo、独立システムならJaegerが適しています。
  4. Tail-basedサンプリングでコストを最適化します。エラーとレイテンシの大きいリクエストは必ず収集し、正常リクエストは低い割合でサンプリングします。
  5. 運用パイプラインをモニタリングします。Collectorとバックエンド自体のメトリクスを収集し、アラートを設定します。

分散トレーシングを導入する際に最も重要なのは「すべてを計装しようとしないこと」です。コアユーザージャーニー(Critical User Journey)をまず定義し、その経路のサービスを優先的に計装してから段階的に拡大するのが現実的なアプローチです。


参考資料