トレースのスパンモデルと因果再構成
トレースが追えなくなる原因の多くは因果の取りこぼし。child-ofとfollows-fromの違い、非同期・ファンアウト・キューでスパンがちぎれる理屈、クリティカルパス抽出の原理まで整理できます。
- 1.スパンは開始・終了時刻と親参照を持つ区間。トレースはspan_idとparent_idの参照辺で組む有向木で、コンテキスト伝播でtrace_idとspan_idを下流へ運ぶことだけが因果を保証する。
- 2.child-ofは親が子の完了を待つ同期従属、follows-fromは親が待たない非同期後続。fire-and-forgetやキュー消費はfollows-fromで表すべきで、これを取り違えると待ち時間を二重計上する。
- 3.クリティカルパスは根から葉への経路のうち、待ちで隠れない実時間の総和が最大の経路。ファンアウトでは最遅の子だけが効くため、その特定が遅延削減の投資先を決める。
スパンとは何か:区間としての作業単位
分散トレーシングの最小単位は**スパン(span)です。スパンは1つの作業区間を表し、最低限の構成要素は決まっています。trace_id(同じリクエストに属する全スパンで共通)、span_id(このスパン固有のID)、parent_id(親スパンのID。根なら空)、開始時刻、終了時刻、そして名前と属性です。終了時刻から開始時刻を引いた値がそのスパンの所要時間(duration)**になります。
ここで誤解されがちなのは、スパンは「ログの一行」ではなく時間軸を持つ区間だという点です。点ではなく線分なので、スパン同士は「包含する/重なる/前後する」という関係を取りうる。トレースとは、この線分群を parent_id の参照で結んだ有向グラフにほかなりません。木構造で描かれることが多いのは、多くのスパンが親をちょうど1つだけ持つからですが、後述するようにファンインでは木に収まりません。
スパンが正しく繋がるのは、子が生成時に「自分の親は誰か」を parent_id として自己申告するからです。中央の調整役がグラフを組み立てるのではなく、各スパンが局所的に持つ参照辺を後からバックエンドが集約して再構成します。つまり親子関係の正しさは、参照IDを正しく受け渡せたかどうかにすべて依存します。
因果を運ぶのはコンテキスト伝播だけ
スパンが繋がる条件は、子が trace_id と「親の span_id」を知っていることです。これを実現するのが**コンテキスト伝播(context propagation)**で、プロセス内ではスレッドローカル等で、プロセス間ではHTTPヘッダ(W3C Trace Context の traceparent)やメッセージ属性で運ばれます。詳細はトレースコンテキスト伝播の仕組みに譲りますが、核心は次の一点です。
伝播が途切れた瞬間に因果も途切れる。 ヘッダを引き継がないプロキシ、コンテキストを捨てるスレッドプール、伝播を実装していないライブラリ境界——どれか1つでも欠けると、下流のスパンは親を見失い、parent_id が空のまま新しい根として記録されます。結果、本来1本だったトレースが複数の断片に割れる。これは「サンプリングで欠けた」のではなく「因果の鎖が物理的に切れた」状態で、後から繋ぎ直す手段はありません。
正しい伝播:
service-A [span_id=a1, parent=∅, trace=T] --traceparent--> HTTP
service-B [span_id=b1, parent=a1, trace=T] # T と a1 を受信して繋がる
伝播の欠落:
service-A [span_id=a1, parent=∅, trace=T] --(ヘッダ落ち)--> HTTP
service-B [span_id=b9, parent=∅, trace=U] # 新しい根。T とは別トレース扱い
child-of と follows-from:従属の2種類
スパン関係には2つの意味があり、ここを混同すると分析が壊れます。
| 関係 | 意味 | 親は子を待つか | 典型例 |
|---|---|---|---|
| child-of | 親の処理が子の結果に依存する同期従属 | 待つ(子の完了が親の進行に必要) | RPC呼び出し、DBクエリ、同期API |
| follows-from | 子は親に起因するが親は完了を待たない後続 | 待たない(親は子を起動して先へ進む) | fire-and-forget、キュー投入、非同期ジョブ |
child-of では、親スパンの時間区間は子スパンの区間を包含します。親は子の返事を待つので、子の所要時間は親の所要時間の一部です。一方 follows-from では、親は子の起動だけして自分は終了してよい。子は親より後に・親とは無関係に走るため、親の区間が子を包含するとは限らず、しばしば親の終了後に子が始まります。
この区別を取り違えると何が起きるか。fire-and-forget を child-of として記録すると、ツールは「親は子を待っている」と解釈し、本来は非同期で隠れていた子の時間を親の待ち時間に二重計上してしまう。逆に同期呼び出しを follows-from にすると、クリティカルパス上にあるはずの遅延が経路から外れて見落とされます。OpenTelemetry ではこれを SpanKind(CLIENT/SERVER/PRODUCER/CONSUMER/INTERNAL)と Link で表現します。実装上の詳細はOpenTelemetryの内部構造を参照してください。
非同期・ファンアウト・キューで因果が難しくなる理由
同期RPCだけの世界なら因果は素直です。難しくなるのは、親子が時間的にも空間的にもずれる場合です。
ファンアウト(fan-out)。 1つの親が N 個の子を並行起動する。各子は独立した child-of で、すべて親の区間に含まれます。問題は集約側で、N 個の結果を待ち合わせる処理です。親の所要時間に効くのは合計でも平均でもなく、最も遅く返ってきた子だけ。N が大きいほど、どれか1つが裾の遅延を引く確率が上がり、全体が引きずられます(テール遅延の合流)。
ファンイン(fan-in)。 逆に、複数の親に起因する処理が1点に合流する場合、そのスパンは親を複数持ちます。parent_id は1つしか書けないため、純粋な木では表現できません。OpenTelemetry はここでLinkを使い、「主たる親」を parent_id に、それ以外の起因を Link 配列に記録します。バッチ処理がその典型です。
キュー消費側が複数メッセージを1バッチでまとめて処理すると、1つの consumer スパンが多数の producer に起因します。このとき parent_id を1つに固定すると、残りのメッセージの因果が消えます。Link で全 producer を参照すれば因果は保てますが、トレース木は「1リクエスト=1木」という素朴な前提を失い、**DAG(有向非巡回グラフ)**として扱う必要が出ます。可視化ツールが木しか描けないと、ここで情報が落ちます。
メッセージキュー。 producer が投入してから consumer が取り出すまでに、秒〜分の時間差が空きます。両者は別プロセス・別時刻なので child-of ではなく follows-from(Link)で結ぶのが正しい。さらに consumer 側のスパンを producer の trace_id に属させるべきか、新しいトレースを起こして Link で繋ぐべきかという設計判断が生じます。前者は end-to-end の因果が1トレースで追える利点、後者はキュー滞留がトレース全体の所要時間を不当に膨らませない利点があります。キューの配信意味論そのものはメッセージキューの配信保証で扱います。
クリティカルパス抽出の原理
トレースが手に入っても、どこを速くすれば全体が速くなるかは自明ではありません。並行に走るスパンは何本あっても、最も遅い1本しか全体時間に効かないからです。ここで効くのが**クリティカルパス(critical path)**の抽出です。
クリティカルパスとは、根スパンの所要時間を構成する区間のうち、待ちで隠れず実時間として効いている部分を、根から葉へ繋いだ経路です。直感的には「ここを1ミリ秒削れば、全体も1ミリ秒縮む」区間の連なりです。原理はこうです。
ある親スパンの区間 [t_start, t_end] を、子スパンで埋めていく:
- child-of の子で、親の待ち時間を実際に占有している区間を選ぶ
- 並行する子が複数あれば、その時間帯で最後に終わる子だけがクリティカル
- 子の中も再帰的に同じ規則で分解する
- どの子にも覆われない区間(親自身のCPU処理)もクリティカルに含める
要点は2つあります。第一に、ファンアウトでは最遅の子のみがクリティカルで、他の子をいくら最適化しても全体は1秒も縮みません。第二に、follows-from(非同期後続)は親の完了を待たせないので、原則クリティカルパスに乗りません。だからこそ child-of と follows-from の正しい区別が、そのまま分析の正しさに直結します。これはhappens-before と因果順序で論じる「真の依存」と「見かけの並行」を、実時間軸の上で具体化したものと言えます。
P99 を縮めたいとき、平均的に重いスパンではなく「クリティカルパス上で、かつ遅延の分散が大きいスパン」を探すのが定石です。並行実行で隠れているスパンの最適化は体感に効きません。サンプリングでクリティカルパスを取りこぼさないための統計的な配慮はテールサンプリングの統計を参照してください。
まとめ
スパンモデルの正しさは、結局「因果を正しく表現できているか」に尽きます。表現を誤れば、どれだけ高機能なバックエンドでも嘘の遅延分析を返します。
- スパンは開始・終了時刻と
parent_idを持つ区間で、トレースは参照辺で組む有向グラフ。木に見えるのは多くのスパンが親を1つだけ持つから。 - 因果を保証するのはコンテキスト伝播だけ。
traceparentの引き継ぎが1箇所でも切れると、下流は新しい根になりトレースが断片化する。後から繋ぎ直せない。 - child-of は同期従属(親が待つ)、follows-from は非同期後続(親は待たない)。取り違えると待ち時間を二重計上したり、逆に実遅延を見落とす。
- ファンイン・バッチ・キューでは親が複数になり木に収まらないためLink で DAG として表す。木しか描けない可視化は因果を落とす。
- クリティカルパスは実時間として効く区間の連なり。ファンアウトでは最遅の子だけ、follows-from は原則乗らない。ここを正しく抽出してはじめて最適化の投資先が決まる。
DevOps/インフラ Article
トレースのスパンモデルと因果再構成を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
分散トレーシング
比較で見る軸
難易度: advanced / カテゴリ: DevOps/インフラ / タグ数: 6
導入後に効く点
child-ofは親が子の完了を待つ同期従属、follows-fromは親が待たない非同期後続。fire-and-forgetやキュー消費はfollows-fromで表すべきで、これを取り違えると待ち時間を二重計上する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- DevOps/インフラ
- タグ数
- 6
判断チェックリスト
- 自社の用途が「分散トレーシング / オブザーバビリティ」に近いか確認する。
- 強みである「スパンは開始・終了時刻と親参照を持つ区間。トレースはspan_idとparent_idの参照辺で組む有向木で、コンテキスト伝播でtrace_idとspan_idを下流へ運ぶことだけが因果を保証する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。