Carpe Diem

備忘録

OpenTelemetryでのSampling

概要

トレーシングで重要なのがSamplingです。
単純に全データを計測するとコストが増えたりパフォーマンスに影響が出たりします。

OpenTelemetryではSamplingに対する仕様が決まっているので、それに基づいて具体的な実装を紹介します。

OpenTelemetry Specification - Sampling

環境

  • Go v1.20.2
  • go.opentelemetry.io/otel/trace v1.14.0

前提知識

サンプリング手法

サンプリングには大きく2つのタイプがあります。
Head basedとTail basedです。

ref: https://newrelic.com/jp/blog/best-practices/distributed-tracing-and-sampling

Head based sampling

シンプルでよく使われるのがHead based samplingです。
分散トレースする場合にアプリケーションレイヤでサンプリングする際はこちらになります。

Pros

  • シンプル
  • パフォーマンス影響が少ない
  • 統計的なサンプリングにより十分な透明性を与える

Cons

  • ランダムなので欲しいトレース(エラーだったり遅かったり)がサンプリングされない事がある

Tail based sampling

エラーとレイテンシの高いトレースを漏れなく観測したい場合はこちらになります。
OpenTelemetry Collectorのように、一度トレースを集約させてからサンプリングする場合はこのような方法が可能です。

ref: Elastic Observability 8.2: Tail-based sampling, plus more serverless visibility for AWS | Elastic Blog

Pros

  • 100%のトレースを計測した後でサンプリングするので欲しいデータを選択できる

Cons

  • データ量が増えるのでパフォーマンスに影響を与える(CPU、メモリ、転送量)
  • ↑によりコストも増える可能性がある
  • OpenTelemetry CollectorのようなProxyを用意する必要がある

OpenTelemetryのSampler

Samplerはどの程度標本抽出するかをカスタマイズできるインタフェースです。

Build-in sampler

あらかじめ仕様で用意されているSamplerについて紹介します。

Sampler 説明
AlwaysOn 全て計測
AlwaysOff 全て破棄
TraceIdRatioBased 指定されたサンプリングレート分だけ計測
ParentBased 親スパンの設定に基づいて計測

ParentBasedはリモートかローカルか、親がサンプリングしているかどうかで条件が変わるので、以下の様になっています。

親がリモートか 親はサンプリングされているか 呼び出されるSampler デフォルト値
なし
(ルートSpan)
- - root() -
あり true true remoteParentSampled() AlwaysOn
あり true false remoteParentNotSampled() AlwaysOff
あり false true localParentSampled() AlwaysOn
あり false false localParentNotSampled() AlwaysOff

Head based samplingの場合、本番ではParentBasedTraceIdRatioBasedをルートSpanとして扱うパターンが良いです。

実装

具体的な実装方法について説明します。

リモートスパンを扱えるよう OpenTelemetryで分散トレーシング - Carpe Diem で行った分散トレーシング環境を想定します。

Goでの実装

TraceProviderでsdktrace.WithSampler()を用いて設定します。

func NewTracerProvider(serviceName string, ratio float64) (*sdktrace.TracerProvider, func(), error) {
        exporter, err := NewJaegerExporter()
        if err != nil {
                return nil, nil, err
        }

        r := NewResource(serviceName, "1.0.0", "local")
        tp := sdktrace.NewTracerProvider(
                sdktrace.WithBatcher(exporter),
                sdktrace.WithResource(r),
                sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))),  // ここ
        )

        otel.SetTracerProvider(tp)

sdktrace.ParentBased()はルートSpanの設定にサンプリングレート指定のsdktrace.TraceIDRatioBased()を入れ、それ以外はデフォルト設定とします。

sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))

sdktrace.ParentBased(
        sdktrace.TraceIDRatioBased(ratio),
        sdktrace.WithLocalParentSampled(sdktrace.AlwaysSample()),
        sdktrace.WithLocalParentNotSampled(sdktrace.NeverSample()),
        sdktrace.WithRemoteParentSampled(sdktrace.AlwaysSample()),
        sdktrace.WithRemoteParentNotSampled(sdktrace.NeverSample()),
))

と同値です。

動作確認

Gateway: ratio 0.1, Backend: ratio 0.1

ratioを0.1にして検証してみます。

$ ab -n10 -c 10 http://localhost:8000/hello

設定通り1/10だけサンプリングされました。

Gateway: ratio 0.1, Backend: ratio 0.5

ParentBasedがきちんと機能しているかの確認のため、Backend側のratioを0.5に変更してみます。

  backend-grpc:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - TARGET=backend-grpc
    environment:
      - EXPORTER_ENDPOINT=http://jaeger:14268/api/traces
      - SAMPLING_RATIO=0.5

期待通り1/10だけサンプリングされました。

Gateway: ratio 0.5, Backend: ratio 0.1

Gateway側のratio(親のルートSpan)を0.5に変更してみます。

4/10だけサンプリングされました。

もう一度実行すると

9/20になったので、若干ズレは発生するようです。

その他

サンプルコード

今回のサンプルコードはこちらです。

github.com

まとめ

OpenTelemetryでのSamplingの仕様とGoでの実装方法について説明しました。

参考