Tech Learning Daily

2026-09-14 (Mon) — 第 59 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Obs 🌿 基礎 ⏱ 約 7 分

フロントとバックエンドのログはバラバラに出るのに、なぜ1つのリクエストとして追跡できるのか?

API Gateway、認証サービス、注文サービス、DB……1 つのリクエストが裏で 5 つのサービスをまたいでいるのに、障害調査では Datadog や New Relic の画面で「1 本のトレース」としてそれが時系列に並んで出てくる。各サービスは互いの存在を意識せず、別々のプロセスで別々にログを吐いているだけのはずなのに、なぜバラバラの記録が 1 つのリクエストとして再構成できるのだろうか。

🎯 3 行まとめ
  • 分散トレーシングは、1 つのリクエストが複数サービスをまたぐ間に生まれる処理の単位「span」を集め、共通の「trace」としてつなげる仕組みだ。
  • つながりを保証しているのは HTTP ヘッダー traceparent(W3C Trace Context)で、trace-id と直前の span-id をサービス間で伝播させる。
  • 全リクエストを記録すると負荷とコストが大きいため、どのトレースを保存するかをサンプリングで間引く。決め方には head-based と tail-based の 2 通りがある。

各サービスのログは、最初から独立している

モノリスなら 1 つのリクエストは 1 つのプロセス内で完結し、ログも 1 つのファイルに時系列で並ぶ。だがサービスを分割すると、リクエストごとに複数のプロセスをまたぐようになり、各サービスは自分が受け取ったリクエストしか知らない。認証サービスのログには注文サービスで何が起きたかは一切書かれないし、逆も同じだ。

この状態で障害調査をするには、まず「これは同じリクエストのログだ」と突き合わせる目印が要る。分散トレーシングが解決しているのはこの一点で、各サービスがバラバラに記録する処理の断片に共通の識別子を持たせ、後から 1 本の線として再構成できるようにしている。

span と trace — 処理の単位と、その集合

span はサービス 1 つ分の処理単位で、「いつ始まっていつ終わったか」「何をしていたか」を記録する。1 つのリクエストがサービス A → B → C と流れれば、A・B・C それぞれが自分の span を作る。trace はある 1 つのリクエストに属する span 全体の集合で、複数サービスをまたいでも同じ trace-id を共有する。

span 同士は親子関係を持つ。B の span は「自分は A の span から呼ばれた」ことを、呼び出し元の span-id を parent-id として保持することで表す。trace-id が「同じリクエストに属している」ことしか示さないのに対し、parent-id のチェーンをたどることで、どの処理がどの処理を呼び出したかという呼び出し順序とネストの構造まで復元できる。

🍱 たとえるなら

駅伝を思い浮かべてほしい。レース全体を識別する背番号(trace-id)は区間が変わっても同じだが、実際にたすきを渡すときは「自分が誰から受け取ったか」(parent-id)も一緒に伝わる。第 3 走者のタイムだけを見ても、それが同じレースの何区なのか、直前の走者は誰だったのかは背番号だけでは分からない。たすきの受け渡し情報があって初めて、5 区間の記録を 1 つのレースの物語として並べ直せる。

trace context はどうやってサービス間を渡っていくのか

この trace-id と parent-id を実際に運んでいるのが、W3C が標準化した traceparent という HTTP ヘッダーだ。フォーマットは version-trace-id-parent-id-trace-flags で、trace-id は 16 バイト(32 桁の16進数)、parent-id は 8 バイト(16 桁の16進数)の固定長になっている。

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  └─ trace-id(32桁)              └─ parent-id(16桁) └─ flags
             └─ version

サービス A はリクエストを受けると自分の span-id を発行し、B を呼ぶ際にその span-id を parent-id として traceparent に書き込んで送る。B はこれを受け取り、そこに書かれた trace-id をそのまま引き継いで自分の span を作り、C を呼ぶときは自分の span-id を新しい parent-id として書き換えて渡す。各サービスは trace-id を生成し直さず「引き継いで転送する」だけなので、途中のどのサービスも他のサービスの実装を知らなくてよい。末尾の trace-flags の最下位ビットは sampled フラグで、このリクエストが記録対象かどうかを次のサービスに伝える。

Client
  │ traceparent なし
  ▼
┌────────┐ span:A
│Service A│ trace-id 生成
└───┬────┘
    │ traceparent: 00-{tid}-{A}-01
    ▼
┌────────┐ span:B (parent=A)
│Service B│
└───┬────┘
    │ traceparent: 00-{tid}-{B}-01
    ▼
┌────────┐ span:C (parent=B)
│Service C│
└────────┘
trace-id は共通、parent-idは1つ前の span

全部記録すればいいはずでは? — サンプリングが要る理由

trace-id さえ伝播すれば技術的には全リクエストを記録できる。だが実際にそうすると、トレーシング基盤への送信・保存コストがリクエスト数に比例して膨らみ続ける。そこでどのトレースを実際に保存するかを間引く「サンプリング」が必要になる。

head-based sampling はリクエストの開始時点、つまり結果(エラーかどうか、遅かったかどうか)がまだ分からない段階でサンプリングの可否を決める方式だ。実装が単純でどの地点でも判定でき、コストの見積もりもしやすい一方、エラーはただでさえ発生頻度が低いため、一律のサンプリング率で間引くとエラー時のトレースが結果的に取りこぼされやすいという弱点がある。tail-based sampling はリクエストが完了し全 span が集まった後で、エラーが含まれるか・レイテンシが閾値を超えたかといった条件を見てから保存を決める。取りこぼしには強いが、判定が終わるまで全リクエストの span を一時的に保持し続ける必要があるため、収集基盤側のメモリコストが上がる。

OpenTelemetry SDK が何もサンプラーを指定しなければ既定は ParentBased(root=AlwaysOn)、つまり親 span がなければ常にサンプリングする設定になっており、実質 100% 記録される。ローカル動作確認では都合がよいが、本番相当の流量でこのまま運用するとコストが跳ねるため、実運用では明示的にサンプラーとその比率を設定するのが前提になる。

💼 実務でどう出会うか

マイクロサービス構成でエラーが起きたとき、Datadog APM や New Relic、Jaeger の画面で 1 つの trace-id を検索すると、関わった全サービスの span がウォーターフォール状に並んで表示され、どのサービスで時間がかかったか・どこで例外が飛んだかが一目で分かる。ログ基盤(CloudWatch Logs、Datadog Logs 等)側でも trace-id をログの構造化フィールドに含めておけば、トレースのウォーターフォールから該当区間のログへワンクリックで飛べる。trace-id はログとトレースをつなぐ共通鍵として機能する。

⌨️ 手を動かす(5 分)

traceparent ヘッダーを自分で組み立てて送り、フォーマット通りの文字列がそのまま相手に届くことを確認する。

TRACE_ID=$(openssl rand -hex 16)
PARENT_ID=$(openssl rand -hex 8)
echo "送信する traceparent: 00-${TRACE_ID}-${PARENT_ID}-01"
curl -s -H "traceparent: 00-${TRACE_ID}-${PARENT_ID}-01" https://httpbin.org/headers

レスポンスの JSON 内に、送信した traceparent の値がそのまま含まれているはずだ。実際のサービス間通信でも、B は受け取ったこの文字列から trace-id を取り出して自分の span に引き継ぎ、C に転送する際は parent-id 部分だけを自分の span-id に書き換える。ヘッダー 1 本が伝播の全てを担っていることが、この単純な echo からも見て取れる。

🙅 よくある誤解
  • 分散トレーシングを入れればログ収集は要らなくなる — トレースは「どのサービスをどんな順序・時間で通ったか」という経路と時間分布を可視化するもので、各処理の詳細な中身(パラメータの値やエラーメッセージ全文)までは持たないことが多い。実務では trace-id をログの構造化フィールドに含め、両方を相互参照する。
  • trace-id さえ共有すれば、span 同士の呼び出し関係も分かる — trace-id は「同じリクエストに属する」ことしか示さない。どの span がどの span から呼ばれたかというネスト構造は、parent-id のチェーンがなければ復元できない。
  • サンプリングは単にランダムに間引くだけなので、重大なエラーのトレースが失われる心配は特にない — head-based sampling は結果が分かる前に判定するため、発生頻度の低いエラーは一律の確率でさらに取りこぼされやすい。エラーを確実に残したい場合は tail-based sampling や、エラー時のみ強制的にサンプリングする仕組みが必要になる。
📖 用語ミニ辞典
span
1 つのサービスにおける 1 処理単位の記録。開始・終了時刻や処理内容を持つ。
trace
1 つのリクエストに属する span 全体の集合。複数サービスをまたいでも共通の trace-id で束ねられる。
trace-id
1 つのリクエスト(trace)全体を識別する固定長の ID。経由する全サービスで共有される。
traceparent
trace-id と直前の span-id をサービス間で伝播させる W3C 標準の HTTP ヘッダー。
head-based sampling
リクエスト開始時点、結果が分かる前にサンプリングの可否を決める方式。
tail-based sampling
全 span が集まった後、エラーや遅延などの条件を見てから保存を決める方式。
🔗 もっと深く
  • Trace Context | W3C — traceparent/tracestate ヘッダーの正式な仕様。フィールドのバイト長や sampled フラグの定義がここにある。
  • Context propagation | OpenTelemetry — trace context がサービス間でどう受け渡されるかの概念図と考え方。
  • Sampling | OpenTelemetry — head-based / tail-based サンプリングの違いと、それぞれのトレードオフの公式解説。