はじめに
株式会社 AJA の DSP チームにて、2026 年 7 月より約 2 ヶ月間、内定者アルバイトをしていた 27 卒パブリッククラウド内定者の杉原充稀(@mitsu3s_)です。
本記事では、内定者アルバイト期間中に取り組んだ、AJA DSP を構成するマイクロサービスにおけるオブザーバビリティ強化の施策について紹介します。
分散トレーシングの必要性
AJA DSP の広告配信基盤では、複数のマイクロサービスが連携して、SSP から届いた入札リクエストに対して、広告候補の取得、配信条件の判定、予算の確認などを行い、短い期限の中で入札結果を返します。
上記の構成は単純化したものですが、一つのリクエストが複数の内部サービスやデータストアを横断します。
これまで、この広告配信基盤には、サービスを横断して一連の処理を確認できるトレーシング基盤がありませんでした。メトリクスから異常を検知し、各サービスのログを使って原因を調査することはできましたが、一つのリクエストがどのサービスを通り、どこで時間を使い、どこで失敗したのかを追うには、複数の画面やログを行き来する必要がありました。
そこで、OpenTelemetry による計装と、OpenTelemetry Collector を活用した Tail-based Sampling 基盤を導入しました。
Tail-based Sampling の選定理由
広告配信基盤には大量のリクエストが流れます。すべての Trace を Datadog へ保存すると、保存量とコストが大きくなります。
処理開始時に保存可否を決める Head-based Sampling では、そのリクエストがエラーになるか、遅くなるかを判断できません。低い確率でサンプリングすると、調査したい異常な Trace まで失う可能性があります。
Tail-based Sampling では、 Trace が完了するまで一時的に保持し、結果を確認してから保存可否を決めます。これにより、エラーなどの調査価値が高い Trace は確実に残しながら、正常な Trace は必要な割合だけ保存できます。ただし、保存しない Trace も、判断が終わるまでは Collector へ届きます。そのため、Datadog へ送る量だけでなく、サンプリング前の全 Span を処理できる構成が必要になります。
DDOT と OpenTelemetry Collector による二層構成
採用した構成は次の通りです。
Datadog Distribution of OpenTelemetry Collector(DDOT) は、各ノードでアプリケーションから Span を受け取り、Trace ID を基準にサンプリング処理をする Collector へ振り分けます。
Tail-based Sampling では、一つの Trace に含まれるすべての Span を同じ Collector へ集める必要があります。通常の Kubernetes Service による分散では、同じ Trace の Span が複数の Collector に分かれる可能性があります。そのため、DDOT の Load Balancing Exporter を利用しました。
以下は構成を簡略化した例です。
exporters:
load_balancing:
routing_key: traceID
retry_on_failure:
enabled: true
sending_queue:
enabled: true
protocol:
otlp:
retry_on_failure:
enabled: false
sending_queue:
enabled: false
resolver:
k8s:
service: <collector-service>.<namespace>
ports:
- 4317
DDOT を Collector 群として構築する案もありましたが、その役割は受信と振り分けに限定できます。そこで、各ノードで DaemonSet として動く DDOT を利用し、 Trace の保持とサンプリングを行う Collector だけを専用のノードプールに配置しました。
Collector は Trace の保持によるメモリ使用と、サンプリングルールの評価による CPU 使用に偏りがあります。アプリケーションと同じノードプールに配置すると、広告配信処理とリソースを奪い合う可能性があるため、ノードプールを分離しています。
調査価値に基づく Sampling Policy
当初は、エラーになった Trace を全件保存し、正常な Trace を低い割合で保存する方針から始めました。しかし、本番の Trace を確認すると、Span Status が ERROR かどうかだけでは調査価値を判断できないことが分かりました。
そこで、HTTP Status や Span Status だけでは表現できない状態については、処理結果や劣化状態を示す属性(Attribute)をアプリケーション側で付与しました。現在は、障害調査だけでなく、応札状況の変化や配信処理の最適化にも利用できるよう、処理の意味に応じて保持率を変えています。
| 対象 | 保持方針 |
|---|---|
| システム側の処理失敗 | 全量保持 |
| 原因と判定できる下流エラー | 全量保持 |
| 非同期処理の送信失敗 | 全量保持 |
| フォールバックによる品質低下 | 全量保持 |
| 入力異常 | 全量保持 |
| キャンセルやその他の 4xx | 一部保持 |
| 応答期限に近い処理 | 通常系より高い割合で保持 |
| 業務上重要な正常処理 | 通常系より高い割合で保持 |
| 一般的な正常処理 | 少量保持 |
| 成功した機械的な非同期処理 | ごく少量保持 |
実際のルールは複数の条件を組み合わせていますが、基本的な構造だけを抜き出すと次のようになります。
processors:
tail_sampling:
decision_wait: 10s
sample_on_first_match: true
policies:
# エラーを含むトレースは保持する
- name: errors
type: status_code
status_code:
status_codes:
- ERROR
# 上の条件に一致しないトレースから少量を保持する
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 0.1
本番適用後に直面した問題への対処
サンプリングルールの評価による CPU の過度な消費
本番データを確認しながら保存条件を追加した結果、サンプリングポリシーは一時 20 個まで増えました。ポリシーが多いこと自体が問題ではなく、今回の構成では、保持率が同じ条件を別々のポリシーとして定義し、それぞれが同じ Trace 内の Span を走査していたことが CPU 負荷につながりました。
以下は評価方法を説明するための例です。条件名と保持率は実際の設定とは異なります。変更前は、条件 A と条件 B を同じ割合で保存する場合でも、別々のポリシーとして評価していました。
tail_sampling:
policies:
- name: condition-a-sample
type: and
and:
and_sub_policy:
# すべてのトレースから条件 A を探す
- name: condition-a
type: ottl_condition
ottl_condition:
span:
- span.attributes["example.category"] == "A"
- name: sampling
type: probabilistic
probabilistic:
sampling_percentage: 10
- name: condition-b-sample
type: and
and:
and_sub_policy:
# 同じトレースをもう一度走査して条件 B を探す
- name: condition-b
type: ottl_condition
ottl_condition:
span:
- span.attributes["example.category"] == "B"
- name: sampling
type: probabilistic
probabilistic:
sampling_percentage: 10
この構成では、最終的に 10% しか保存しない場合でも、すべての Trace に対して条件 A と条件 B の評価が行われます。変更後は、保持率が同じ条件を一つのポリシーへまとめました。また、安価な確率判定を先に実行し、対象になった Trace だけを OTTL で詳しく評価するようにしています。
tail_sampling:
# ポリシーを優先度順に評価し、保存が決まった時点で後続の評価を終了する
sample_on_first_match: true
policies:
- name: grouped-sample
type: and
and:
and_sub_policy:
# 先に保存候補を絞る
- name: sampling-first
type: probabilistic
probabilistic:
sampling_percentage: 10
# 候補になったトレースだけを走査する
- name: grouped-condition
type: ottl_condition
ottl_condition:
span:
- span.attributes["example.category"] == "A"
- span.attributes["example.category"] == "B"
例えば 100 万件の Trace が届いた場合、変更前は条件 A と条件 B のために、それぞれ最大 100 万件を評価します。変更後は、先に 10% へ絞ったうえで、最大 10 万件を一度走査します。実際の設定では、全件保存する条件、同じ割合で保存する調査条件、通常系の条件をそれぞれ集約しました。さらに、保存条件に一致した時点で後続ポリシーの評価を終了することで、保持条件を変えずに 20 個のポリシーを 7 個まで削減しています。
Collector のスケールダウン時に Trace を失わないための設計
トラフィックが落ち着き、Collector がスケールダウンする際には、DDOT と Collector の二か所に未処理のデータが残ります。DDOT では、未送信 Span が終了する Collector へ再送し続ける問題があり、Collector では、判定待ちの Trace を持ちながら即座に Pod が終了する課題がありました。

DDOT の送信キューを共通化する
DDOT は、Trace ID を使って送信先 Collector を決めています。例えば下図のように、送信先ごとにキューを持つ構成では、一度 Collector C 向けのキューに入った Span は、C が終了しても C への再送を続けます。そこで、送信先を決める前の共通キューだけを利用する構成に変更しました。
設定の要点は、Load Balancing Exporter 全体でキューと再送を持ち、送信先ごとの OTLP Exporter には持たせないことです。再送時には最新の Collector 一覧から送信先を選び直すため、終了した Collector に固定されず、稼働中の Collector へ送信できます。
exporters:
load_balancing:
# 送信先を決める前の共通キュー
sending_queue:
enabled: true
retry_on_failure:
enabled: true
protocol:
otlp:
# 送信先ごとのキューは作らない
sending_queue:
enabled: false
retry_on_failure:
enabled: false
Collector の判定待ちトレースを終了前に処理する
DDOT から送信済みの Span は、Collector の Tail-based Sampling Processor で Trace 単位に保持されています。Collector Pod がすぐに終了すると、判定待ちバッファに残っている Trace を処理できません。そのため、Pod の終了前に待機時間を設けています。
terminationGracePeriodSeconds: 60
lifecycleHooks:
preStop:
sleep:
seconds: 30
Collector が終了状態になると、DDOT の送信先一覧から対象 Pod が外れます。一方、Collector のプロセスは待機時間中も動作するため、すでに受信していた Trace の判定と送信を継続できます。
DDOT では、まだ Collector へ届いていない Span を再振り分けし、Collector ではすでに届いている Trace を終了前に処理します。この二つを組み合わせることで、スケールダウン時の欠損を抑えています。
導入後の状態
分散トレーシングの導入により、これまでサービスごとのログを追っていた処理を、Datadog 上で一つの Trace として追跡できるようになりました。
本番トラフィックでもサンプリングルールは意図した通り機能しています。5xx や処理失敗、処理品質の低下、非同期処理の失敗を含む Trace は全体が保存され、遅延、キャンセル、4xx、正常な処理もそれぞれ定めた割合で保存されています。
下記は、Datadog 上でエラー Trace を表示した例です。入口から下流サービスやデータストアまでの処理経路に加え、エラー箇所や各処理にかかった時間を一つの画面で追跡できます。

処理結果やエラー原因を示す属性も Datadog まで引き継がれており、同じ HTTP Status でも内部の処理結果を区別できます。正常な Trace も処理結果やレイテンシに応じて残しているため、障害調査だけでなく、性能調査や配信状況の分析にも利用できます。
本番適用後も、アプリケーションのレイテンシやエラー率、応札処理に顕著な悪化は発生していません。Collector のリソースとサンプリングポリシーを調整することで、ピーク帯のトラフィックを処理しながら、想定していた予算帯での運用を実現しています。
まとめ
本記事では、高トラフィックな広告配信基盤へ Tail-based Sampling を導入するために、DDOT と OpenTelemetry Collector による二層構成を採用し、調査価値に応じて Trace を保存する仕組みを紹介しました。
本番運用では、サンプリングポリシーの評価による CPU 負荷と、Collector のスケールダウン時における Span の欠損が発生しました。保存条件と保持率を維持したままポリシーを集約し、DDOT のキュー構成と Collector の終了処理を見直すことで対応しています。
Tail-based Sampling は、Collector を配置してエラーを保存するだけでは完結しません。どの Trace に調査価値があるのかを本番データから判断し、サンプリング前の生成量や Collector の縮退まで含めて設計できたため、より効果的な分散トレーシング基盤になったと考えています。
最後までお読みいただき、ありがとうございました。
