Grafana TempoとOpenTelemetryで分散トレーシング:トレースサンプリングでストレージコストを削減する

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

このシステムを構築するきっかけになった障害

約1年前、本番環境のチェックアウトサービスが断続的に障害を起こし始めた。レスポンスタイムが200msから8秒に急騰したのだが、それはリクエストのおよそ3%にしか発生しなかった。ログにはエラーが記録されていたが、APIゲートウェイ、認証サービス、商品カタログ、カート、在庫管理、決済プロセッサという6つのサービスに散らばっていた。

タイムスタンプをもとにログ行を突き合わせながら、何が起きたのかを把握しようと4時間費やした。最終的に原因を突き止めた——在庫サービスのデータベース接続プールの枯渇が上流に連鎖していたのだ。修正に5分、原因特定に半日かかった。

それを機に、分散トレーシングを真剣に検討し始めた。あれば便利な機能としてではなく、2つ以上のサービスが連携するシステムには必須のものとして。

ログだけではマイクロサービスに対応できない理由

マイクロサービスアーキテクチャにおけるログの根本的な問題は、ログが孤立していることだ。各サービスはそれぞれのコンテキストで自分のログを書く。ユーザーのリクエストが7つのサービスを通過すると、7つの別々のログストリームが生成され、それらをつなぎ合わせるネイティブな方法がない。

結果として、頭の中で複雑な作業を強いられる。タイムスタンプを照合し、ターミナルウィンドウ間でリクエストIDをコピーし、誰かがトレースIDヘッダーを実際に伝播してくれていることを祈る。だいたいの場合、誰かがしていない。

分散トレーシングはすべてのリクエストに単一のトレースIDを付与し、それがすべてのサービスを追跡することでこの問題を解決する。各サービスはスパン——時間計測された作業単位——を追加し、それらが1つのツリーとしてつながり、何が、どの順序で、各ステップにどれだけ時間がかかったかを正確に示す。これはログ単独では提供できない可視性のレイヤーだ。

選択肢を比較する:Jaeger、Zipkin、Grafana Tempo

Grafana Tempoに決める前に、一般的なバックエンドを評価してトレードオフを理解した。

JaegerとZipkin

どちらも成熟した、ドキュメントが充実したプロジェクトだ。JaegerはCNCFの卒業プロジェクトであり、エコシステムのサポートが強い。問題はストレージだ。デフォルトではElasticsearchやCassandraをバックエンドとして使用するため、運用オーバーヘッドとコストが大きくなる。高トラフィックのサービスでは、何かが壊れたときにしか見ないトレースデータを保存するためだけに、重いデータベースクラスターを管理することになる。

Zipkinはよりシンプルだが、開発が活発でなくエコシステムも小さい。すでにElasticsearchを運用していないチームにとっては、Jaegerのオペレーションコストがメリットを上回ることもある。

Grafana Tempo

Tempoは根本的に異なるアプローチを取る。検索インデックスを維持するのではなく、S3、GCS、Azure Blob、またはローカルディスクといったオブジェクトストレージにフラットファイルとしてトレースを保存する。これにより、コストが劇的に安くなる。オブジェクトストレージは同じデータ量のブロックストレージと比べて、一般的に約20倍安い。

理解しておくべきトレードオフがある。Tempo単独では、サービス名や任意の属性でトレースを検索することはできない。ログ内のトレースID(Loki経由)またはメトリクス(Prometheus経由)との相関に依存している。すでにGrafanaエコシステムを使っているなら自然に合う。そうでなければ、統合作業も考慮に入れる必要がある。

Grafanaスタックで構築しているほとんどのチームにとって、スケール時のコスト管理においてTempoは明確な選択肢だ。

本当のコスト問題:すべてを保存することはできない

チームがトレーシングを初めてセットアップするとき、あまり議論されないことがある。忙しいサービスは1分間に数百万のスパンを生成できるということだ。そのボリュームでは、安価なオブジェクトストレージを使っても、すべてのトレースを保存するのはすぐに高コストになる。

解決策はサンプリングだ——トレースの一部だけを記録する。しかし、根本的に異なる2つの戦略があり、間違った方を選ぶと目的が台無しになる。

ヘッドベースサンプリング

ヘッドベースサンプリングでは、データが収集される前の、リクエストの開始時点でトレースを記録するかどうかを決定する。「リクエストの10%をランダムにサンプリングする」という設定が一般的だ。シンプルでオーバーヘッドもない。

問題は、盲目的にサンプリングしているということだ。実際にデバッグが必要だったあの遅いリクエスト?90%の確率でキャプチャされていない。ヘッドベースサンプリングは実装は簡単だが、実際に重要なトレースを保持するのが非常に苦手だ。

テールベースサンプリング

テールベースサンプリングは、リクエストが完了した後に判断を行う。OpenTelemetry Collectorはすべてのスパンをバッファリングし、トレース全体が揃うのを待ち、結果に基づいて何を保持するかを決定する。すべてのエラーを保持し、2秒を超えるすべての遅いリクエストを保持し、それ以外の5%を保持するという具合だ。

このアプローチを本番環境で適用したところ、結果は一貫して安定していた。デバッグに実際に重要なトレースの100%を保持しながら、ストレージコストが大幅に削減された。ランダムな5%のサンプリングにより、コストを膨らませることなくパフォーマンストレンドの分析に十分なデータを確保できた。

Grafana TempoとOpenTelemetryのセットアップ

Docker Composeを使った動作するセットアップを紹介する。これはVPSへのデプロイやKubernetes向けへの適用方法を反映している。

ステップ1:スタックのDocker Compose

docker-compose.ymlを作成する:

version: '3.8'
services:
  tempo:
    image: grafana/tempo:latest
    command: [ "-config.file=/etc/tempo.yaml" ]
    volumes:
      - ./tempo.yaml:/etc/tempo.yaml
      - tempo-data:/var/tempo
    ports:
      - "3200:3200"

  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    command: [ "--config=/etc/otel-collector.yaml" ]
    volumes:
      - ./otel-collector.yaml:/etc/otel-collector.yaml
    ports:
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP
    depends_on:
      - tempo

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml

volumes:
  tempo-data:

ステップ2:Tempoの設定

tempo.yamlを作成する:

server:
  http_listen_port: 3200

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

storage:
  trace:
    backend: local
    local:
      path: /var/tempo/blocks
    wal:
      path: /var/tempo/wal

本番環境では、backend: localbackend: s3に変更し、S3バケットの設定を追加する。ローカルディスクは開発環境や低トラフィック環境では問題なく動作する。

ステップ3:テールサンプリングを設定したOpenTelemetry Collector

コスト管理が行われるのはここだ。otel-collector.yamlを作成する:

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

processors:
  tail_sampling:
    decision_wait: 10s       # 判断前にトレース全体を10秒待機
    num_traces: 100000       # 保留中のトレースを最大10万件バッファリング
    policies:
      - name: errors-policy
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow-traces-policy
        type: latency
        latency: {threshold_ms: 2000}
      - name: probabilistic-policy
        type: probabilistic
        probabilistic: {sampling_percentage: 5}

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

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [tail_sampling]
      exporters: [otlp]

この設定では、エラートレースの100%、2秒を超える遅いリクエストの100%、それ以外の5%を保持する。トラフィック量とストレージ予算に応じてパーセンテージを調整する——非常に高いトラフィック(1万req/分以上)であれば、通常のトレースの1%でも十分なデータが得られる。

ステップ4:Pythonアプリケーションのインストゥルメンテーション

OpenTelemetryパッケージをインストールする:

pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install

アプリケーション起動時にトレーシングを初期化する:

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

provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)

tracer = trace.get_tracer(__name__)

# 可視性が必要な箇所にカスタムスパンを追加する
def process_order(order_id: str):
    with tracer.start_as_current_span("process-order") as span:
        span.set_attribute("order.id", order_id)
        result = run_inventory_check(order_id)
        span.set_attribute("inventory.available", result)
        return result

OpenTelemetry HTTPインストゥルメンテーションライブラリを使用すると、トレースIDはHTTPヘッダー(W3C TraceContextフォーマット)を介してサービス間で自動的に伝播される。FastAPIの場合は特に、アプリ初期化後にFastAPIInstrumentor().instrument_app(app)を追加するだけで、手動でコードを書かずにすべてのエンドポイントのスパンが自動的に作成される。

ステップ5:Grafanaデータソースの設定

grafana-datasources.yamlを作成する:

apiVersion: 1
datasources:
  - name: Tempo
    type: tempo
    url: http://tempo:3200
    isDefault: true

セットアップの確認

docker-compose up -dでスタックを起動し、インストゥルメンテーションされたサービスにリクエストを送り、http://localhost:3000でGrafanaを開く。Explore → Tempoデータソースを選択 → 最近のトレースを閲覧するかトレースIDで検索する。

サービス全体の各スパンをタイミング情報とともに表示するウォーターフォールビューが表示される。リクエストが遅かった場合、どのサービスがボトルネックだったか、どのデータを処理していたかがすぐにわかる。

調整が必要な点が一つある。テールサンプラーのdecision_waitの値は、最も遅い可能性のあるトレースよりも長く設定する必要がある。トレースがエンドツーエンドで15秒かかるのに判断ウィンドウが10秒だと、サンプラーは不完全な判断を下し、重要なスパンを削除してしまう可能性がある。ほとんどのHTTP APIでは10〜30秒が安全な範囲だ。バッチ処理ジョブでは、それ以上が必要になる場合もある。

本番スケールで期待できること

1分間に約500リクエストを処理する4サービス構成のPythonアプリケーションでこのセットアップをデプロイしたところ、ストレージは1日あたり2GB未満に収まった。サンプリングなしでは、同じワークロードで1日あたり約40GBのトレースデータが生成される。通常リクエストの5%確率的ポリシーにより、パフォーマンストレンドの分析とキャパシティプランニングに十分以上のデータが得られた。一方、エラーと遅延のポリシーにより、調査する価値のあるインシデントを見逃すことがなかった。

ワークフローの変化は大きい。インシデント発生時のログ調査の代わりに、エンジニアはGrafanaを開き、顧客レポートやアラートからトレースIDを取得し、30秒以内にどこで遅延が発生したか、各ステップでどのデータが関与していたかを正確に把握できる。

まず1つのサービスから始め、ウォーターフォールビューの読み方に慣れてから、サービスごとにインストゥルメンテーションを拡張していく。データベース呼び出しと外部HTTPクライアントを最初にインストゥルメンテーションする——レイテンシの意外な原因はほぼ常にそこに潜んでいる。

Share: