OTel Collector におけるアダプティブ・テールサンプリングの仕組み
Honeycomb は Refinery のアダプティブ・テールサンプリングを OpenTelemetry Collector に寄贈。新しい processor は OTTL と TraceState のしきい値で固定レートを置き換える。
日本語
コピー

生成される trace データは、ストレージに払いたい金額より多い。だからサンプリングする。固定の 1/100 サンプリングレートなら請求額は削れるが、それは盲目的だ。エラーの 1%、あのマイナーなルートのリクエストの 1%、ヘルスチェックの 1% が、すべて同じ比率で残る。最も関心の薄いノイズトラフィックが保持データの大半を占め、障害時に本当に必要な trace こそ、すでに捨てられている可能性が最も高い。私たちは adaptive_tail_sampling processor を先日リリースした。Honeycomb が Refinery と dynsampler-go を支える適応サンプリングアルゴリズムを OpenTelemetry Collector に寄贈したものだ。この processor は私が実装し、同僚の Yingrong Zhao とともにアップストリームでメンテナンスしている。リリース告知では、なぜ作ったのかと試し方を説明している。この記事では決定の仕組み、各種 sampler、そしてサンプリングパイプラインの残りの部分とどう組み合わさるかを扱う。先に一点だけ明確にしておく。これは Refinery を Collector に移植したものではなく、同じアルゴリズム群を OpenTelemetry ネイティブな形で表現し直したものだ。ルールは起動時に検証される OTTL 式。サンプリングの決定は OpenTelemetry の一貫確率サンプリング仕様に従って W3C TraceState のしきい値(ot=th)としてエンコードされ、ベンダー独自のフィールドは使わない。SDK の head sampler や他の Collector sampler とも組み合わせられる。仕様を理解しているシステムなら、どのバックエンドに送ろうとサンプリング済みデータを再重み付けできる。
trace をサンプリングする 3 つの方法
リリースブログでは、Collector が現在サポートする 3 つの方法を紹介している。それぞれの意味するところは明確にしておく価値がある。まさにこの違いが新しい processor を生んだからだ。
確率(ヘッド)サンプリングは trace の開始時点で決定する。最も安価で、バッファリングは一切不要だが、自分が何を捨てたかは見えない。エラーやレアなトラフィックも、他のすべてのリクエストと同じ比率で残る。レアなイベントでは数字が悲惨なことになる。1,000 リクエストに 1 回支払い失敗が起きるとしよう。1% のヘッドサンプリングを適用する。失敗した trace も他の trace と同じく 1% の確率で生き残るので、平均して 10 万リクエストにつき 1 本しか失敗 trace が残らない。1 日 5 万リクエストを処理するサービスなら、この失敗に 1 日およそ 50 回遭遇しながら、証拠が残るのは 2 日に 1 回だ。決定の時点でまだ何も失敗していないのだから、サンプラーは失敗に偏ることはできない。
テールサンプリングは trace 全体をバッファし、すべての情報が揃ってから決定する。使うのは tail_sampling processor だ。ポリシーは OR で組み合わされる。決定は保持か破棄かの二択で、サンプリング確率は付かない。そのため保持されたデータは通常ダウンストリームで再重み付けできず、サンプリング済みデータから算出したカウントも実際のトラフィックを反映しなくなる。OR の組み合わせは適応サンプリングも排除する。trace を保持しないポリシーは「破棄」ではなく「決定なし」を返すので、trace はリストの次のポリシーに渡り、後続のどのポリシーでも保持しうる。この意味論では、適応サンプラーは本来適用すべき確率を制御できない。
適応テールサンプリング、すなわちこの processor は、テールベースのバッファリングと、自分が適用する確率を常に把握している適応サンプラーを組み合わせる。ルールは OR ではなく最初にマッチしたものが適用される。すべての決定には ot=th でエンコードされた明示的なサンプリングしきい値が付く。手動でチューニングしなくても興味深いテールを保持でき、サンプリング後のカウントも統計的に信頼できる。
仕組み
span はメモリ上にバッファされ、trace ID ごとにグループ化される。trace は次のいずれかが最初に起きた時点で決定可能な状態になる。root span を受信した、trace_timeout が満了した(デフォルト 30s)、または span_limit 個の span に達した(デフォルト 10,000)。span の上限は、1 本の巨大な trace がどれだけメモリを占めうるかを制約し、即座に決定を下す。待ち続けても trace が伸びるだけだからだ。残り 2 つのトリガーでは、processor は遅れて届く span を集めるために decision_delay(デフォルト 2s)待ってから評価する。何を root span とみなすか自体も OTTL 式で設定できる(root_span_condition)。プロセスをまたぐ trace でサーバー側だけが Collector に届く場合や、producer がメッセージコンシューマー span を実際の root としてマークしている場合には、これが重要になる。
ルールは順に評価され、最初に一致したものが適用される。各ルールはサンプラーを1つ指定し、条件のないルールがフォールバックになる。典型的な設定ではすべてのエラーを保持し、残りはアダプティブサンプラーに任せる:
processors:
adaptive_tail_sampling:
rules:
- name: keep-errors
conditions:
- span.status.code == STATUS_CODE_ERROR
sampler:
type: always_sample
- name: default
sampler:
type: adaptive_percentage
goal_percentage: 10
fingerprint_attributes:
- resource.attributes["service.name"]
- span.attributes["http.route"]
まず用語を整理しておく。OpenTelemetry と Honeycomb では同じ判断を別の言い方で表すからだ。OpenTelemetry の流儀では、各 trace は 56 ビットの乱数を持ち、それは TraceState に ot=rv として明示的に書かれるか、trace ID の下位 7 バイトから取られる。値の範囲は 0 から 2^56 - 1 だ。サンプラーは同じ範囲からサンプリング閾値を1つ選び、乱数が閾値以上の trace を保持する。中間点より上をすべて保持すれば 50% が残る。閾値は保持された span とともに ot=th に書かれて伝わるので、後続のどの段階でもその trace が生き残った確率を復元できる。Honeycomb は同じ判断をサンプリングレート、つまり N 件のうち1件を保持する、という形で表す。dynsampler-go の各アルゴリズムが出力するのはこの形式のサンプリングレートだ。processor はこの2つを変換し、各サンプリングレートを対応する閾値に換算する。
サンプリングされた trace は転送時に、TraceState に閾値を持ち、span 属性には一致したルール(otelcol.processor.adaptive_tail_sampling.rule)とその判断を引き起こしたイベント(otelcol.processor.adaptive_tail_sampling.trigger)が示される。だからダッシュボード上で、どの trace がどのルールによって残されたのかがはっきり分かる。判断キャッシュは直近の結果を記憶しているので、遅れて届いた span もそれが属する trace の残りと足並みが揃う——同じようにマークされるか、同じように破棄されるかのどちらかだ。
processor は負荷がかかったときも正直だ。バッファが埋まると(num_traces、デフォルト 50k)、最も古い trace が追い出され、その際には単に捨てるのではなく、実際のサンプリング判断が下される。デフォルトの追い出しポリシーは、それまでに見た span に対してあなたのルールを実行するので、keep-errors は高負荷時でも機能する。追い出しが本当の過負荷を意味するなら、定数時間の確率的ポリシーに切り替えればよい。シャットダウン時はバッファを捨てずに排出するので、クリーンな再起動でデータを失わない。
サンプラーの選び方
アダプティブサンプラーは fingerprint_attributes でトラフィックをグループ化する。つまり resource.attributes["service.name"] や span.attributes["http.route"] のようなスコープ付き selector だ。選ぶべきは route、method、status code のようにトラフィックを分類できる属性であり、個々のリクエストを識別してしまう属性ではない——後者では trace ごとに固有の指紋ができてしまい、適応が働かなくなる。指紋ごとにサンプラーはサンプリングレートを1つ計算する。processor はそのレートを閾値に換算し、trace の乱数と比較する。よくある目標は2種類のアダプティブ方式でカバーできる:
adaptive_percentageは span 量の目標パーセント(goal_percentage)を目指す。指数移動平均で fingerprint ごとのトラフィックを追跡するので、希少な fingerprint は高いサンプリングレートを保ち、頻度の高いものは積極的にサンプリングされ、総量はやがて目標値に収束する。検証実行では、負荷水準にかかわらず目標 10% に対して 9.4〜10.3% で安定した。adaptive_throughputは代わりに量の予算(goal_throughput、単位は spans/秒、collector インスタンスごとに適用)に近づける。下流の予算が固定ならこちらを使う。デフォルトのemaアルゴリズムは同じ移動平均でトラフィックを平滑化し、algorithm: windowedはスライディングウィンドウ上で再計算するためトラフィックの変化に素早く反応するが、その代わり短時間のスパイクに敏感になる。
ほかに always_sample と probabilistic(固定比率で、probabilistic_sampler processor のインライン版に相当)があり、適応を必要としないルールに使う。重要な点が1つある。sampler が生成するのはレートだけだ。このレートを trace の乱数と比較して閾値を決める仕組みはすべてのルールで共通であり、まさにこれが判断を再現可能にし、保持された各 span に下流で正しい重みを与える。
後続の pipeline が信頼できる判断
判断は単純な保持/破棄ではなく spec にエンコードされた閾値なので、この processor は上流・下流のサンプリング段階と組み合わせられる。SDK の head sampler や probabilistic_sampler processor がすでに ot=th を書き込んでいる場合は、より厳しい側が優先され、最終的に生き残る閾値は常にエンドツーエンドの有効確率を反映する。
probabilistic_sampler を equalizing モードで使い、上流 50% のサンプリングに加えてこの processor で 10% の adaptive_percentage 目標を設定して検証した。元のトラフィックのおよそ 10% が生き残る(上流の 50% のうちの 10% ではない)。保持された各 span は 10% の閾値を持つ。下流を always-keep ルールに変えれば、上流の 50% の閾値がそのまま残る。どちらの方向でも、推定器が再構築した元の送信量の誤差は 1.5% 以内だった。
同じ性質は trace の外でも役に立つ。spanmetrics connector は ot=th を読み、サンプリングされたデータから重みの正しい R.E.D メトリクスを生成する。だから大部分の span が破棄されても、リクエストレートとエラー率は正確なままだ。
もっともな疑問は、なぜこれを tail_sampling の新しいポリシー群として実装しないのか、というものだ。短く答えるなら、tail_sampling のポリシー契約はサンプリング確率や閾値を運べない。その OR 合成モデルも、アダプティブサンプラーが依存する会計処理を壊してしまう。README で仕組みを詳しく説明している。2つの processor にはそれぞれの用途があり、独立させることで tail_sampling のユーザーに不利益はない。
性能
開発期間を通じて、このプロセッサにはベンチマークとソークテストを実施した。Yingrong と私は独立したプロセッサに対して完全な負荷テストを行い、本番に近い2層構成、サンプリング層はレプリカ2台で各7 vCPU・13GiBという環境にデプロイした。典型的な span サイズでは、レプリカ1台が約0.3コアで毎秒10万 span 前後を安定して処理する。CPU は span の負荷サイズに比例して増加し、基数テストではスループットが毎秒17万 span を超えた。
バッファを意図的に小さく設定し、70倍の過負荷を継続した状況でも、エビクション機構がメモリを有界に保ち、プロセスがメモリ制限超過で kill されることは一度もなかった。span_limit の上限はその価値を証明した。巨大な trace ではスループットは変わらず、メモリが約36%減り、その代わり CPU が約34%増える。性能は良好で、同等の負荷では Refinery とほぼ互角だ。改善は続けていく。
デプロイと現時点の制約
このプロセッサをデプロイする前に、知っておくべきことがいくつかある。
- 判断はインスタンス単位で行われる。 そのため、1つの trace の span はすべて同じ Collector に到達しなければならない。標準的な2層の
loadbalancingexporter パターンで水平スケールし、trace ID でルーティングする。loadbalancingexporter は現時点でot=rvによるルーティングに対応していないが、ルーティングキーとして追加するPR が提出済みだ。 - メモリはバイト数ではなく trace 数(
(num_traces) and per-trace span count (span_limit)で決まる。span レートにバッファウィンドウを掛けた分だけ増える。 - ルールは起動時に固定される。 ルールを変えるには再起動が必要だ。シャットダウン時にはバッファをドレインするので、再起動でデータは失われない。
- 現時点では trace のみに対応する。 ルール条件が評価するのは span context だ。
はじめかた
このプロセッサは本日、上流の安定性マイルストーンに先駆けて、Honeycomb OpenTelemetry Collector ディストリビューションで beta として提供されている。リリース告知には、Kubernetes、Docker、スタンドアロン構成それぞれのセットアップ手順が段階的に書かれている。上流では collector-contrib リポジトリにあり、alpha 安定性に向けて進行中で、到達すれば collector-contrib ディストリビューションに入る。
README には、完全な設定リファレンス、よくあるデプロイパターンの例、プロセッサ自身のメトリクスでダッシュボードを構築するためのテレメトリ規約が揃っている。問題や提案は #49311 で受け付けている。
固定サンプリングレートを使い続け、盲点に耐えてきた人、テールサンプリングは欲しいが正確なカウントを後から得る必要がある人、このプロセッサはそういう人のために作った。あなたのトラフィックでどう動くのか、ぜひ知りたい。
なぜ Refinery が今も最も使われるサンプリングツールなのか
このプロセッサは Honeycomb のサンプリングアルゴリズムを OpenTelemetry Collector に持ち込むが、Refinery は依然として最も使われるサンプリングツールだ。理由はアルゴリズムではなく運用にある。大規模なサンプリングは、設定して終わりのコンポーネントではなく、継続的に運用し続けるシステムだ。Refinery の背後には長年の本番環境での鍛え込みがある。
- スケールにルーティング層が要らない。 Refinery クラスタはノード間で trace の帰属を共有するので、ノードを足せば容量が増える。Collector プロセッサは2層トポロジが必要で、しかも各インスタンスは自分が見えるトラフィックの分からしかレートを学習できない。
- ルールのホットリロード。 Refinery はサンプリングルールの再読み込みに再起動が要らない。これが最も効くのは障害対応の最中だ。今まさにサンプリングを絞るか緩めるかが必要になる。
- 過負荷保護。 負荷軽減機構が飽和状態を検知し、クラスタが回復するまで決定的サンプリングに切り替える。だから予測可能に劣化し、いきなり落ちることはない。
- 運用面の厚み。 環境を意識したマルチテナンシー、充実したランタイムテレメトリ、そして Refinery as a Service というマネージドの選択肢。
OpenTelemetry Collector パイプラインにネイティブ統合された適応的サンプリングを求めるなら、このプロセッサから始めればいい。すでに Refinery を動かしているなら、README に Refinery のサンプラー種別とプロセッサのサンプラー種別の直接の対応表がある。サンプリングが重要インフラになり、スケール、テナント分離、障害対応を自前で用意する必要が出てきたとき、Refinery はまさにそのために作られている。