OTEP-4947: スレッドコンテキスト: 外部リーダーとスレッドレベル情報を共有する
OpenTelemetry eBPF プロファイラーのようなプロセス外のリーダーに対して、OpenTelemetry SDK がスレッドレベルの属性を公開するための標準的な仕組みを導入します。 これは OTEP 4719: Process Context と関連しており、初期設定情報をリーダーと共有するためにそれを利用します。
仕様の完全な例、および、サンプルのリーダーとライターは https://github.com/scottgerring/ctx-sharing-demo にあります (open-telemetry/sig-profiling に移動される可能性があります)。
OTel eBPF Profiler 側でのこの実装の作業中バージョンは https://github.com/open-telemetry/opentelemetry-ebpf-profiler/pull/1229 にあります。
動機
OpenTelemetry eBPF プロファイラーのような外部リーダーは、計装対象のプロセスの外側で動作するため、観測対象のプロセス内で実行されているアクティブな OpenTelemetry トレースに関する情報を収集できません。 これは主に2つの問題を引き起こします。
- 観測結果とコンテキストメタデータを関連付けられない - アクティブなスパンなどのコンテキスト情報が見えないため、外部リーダーは自身の観測結果を特定の HTTP エンドポイントや他のリクエストの特性に紐づけることができません。
- サンプリングされていないトレースを持つスレッド上で収集されたサンプルにリクエストメタデータがない - 多くの場合、外部プロセスが観測するアクティブなスパンは、OpenTelemetry SDK によってサンプリングされていない可能性があります。 このような場合、追加のメタデータを外部プロセスに直接利用可能にしておくことで、トレーサー側でのサンプリングに直面してもサンプルが有用なコンテキストを保持できるようになります。
- 重複の回避 - OTel OBI のような他のプロセス外リーダーは、OTel SDK がすでに必要なスレッドコンテキスト情報を共有している場合に、冗長な計装の取り組みを回避できるため、ユーザー体験を簡素化し、リソースを節約できます。
説明
私たちは、Linux 固有の ELF スレッドローカルストレージ(TLS)変数を使った標準的なフォーマットを通じて、アクティブなリクエストのコンテキストを反映するスレッドレベルの情報を OpenTelemetry SDK が公開するための仕組みを提案します。
このメカニズムはネイティブコンポーネントを持つこと、そしてランタイムがいつコンテキストを切り替えるかを知ることに依存しているため、一部のランタイム(あるいはランタイムのバージョン)はこれを現実的にまたは効率的に実装できない可能性があり、私たちはこれを SDK にとってサポートするかどうかがオプションであると考えています。
TLS ベースの公開メカニズムと、メモリ内のスレッドローカルコンテキストレコードフォーマットは意図的に分離可能になっています。 TLS を通じてコンテキストを効率的に公開できないランタイムや、OS のスレッドローカルなコンテキストが適切な実行コンテキストモデルではないランタイムは、このメカニズムを実装する必要はありません。 しかし、ランタイム固有のペイロードフォーマットを定義するのではなく、ランタイム固有の発見メカニズムを通じて同等のコンテキストを公開する場合には、同じメモリ内レコードフォーマットを再利用することを強く推奨します。 そのようなメカニズムはこの OTEP の対象範囲外ですが、実用的な範囲でレコードのレイアウトを再利用することで、リーダーがランタイムをまたいでパース処理を共有できるようになります。
リクエストコンテキストがスレッドにアタッチまたはデタッチされると、SDK はこの文書に記載されたフォーマットで、トレース ID、スパン ID、トレースフラグを含む選択された情報を、適切なスレッドローカルに公開します。 外部リーダーがこのスレッドを観測するとき、そのような TLS データがアタッチされているかどうかを確認し、アタッチされていればそれを自身のテレメトリーに含めます。
他のコンテキストソースとの相互作用
有効なスレッドローカルコンテキストレコードは、観測対象のスレッドについて、公開元の SDK が把握しているアクティブな OpenTelemetry コンテキストのビューを表します。 リーダーは、このコンテキストが提供する OpenTelemetry コンテキストフィールドについて、これを権威あるものとして扱うべきです(SHOULD)。
OBI から得られる情報のような他のコンテキストソースは、観測結果を補強するため、あるいは、SDK が公開するスレッドコンテキストが利用できない場合のフォールバックとして、引き続き使用されることがあります。 リーダーが SDK の公開する競合するコンテキストを他のソースで上書きしたりマージしたりすることを選択する場合、そのポリシーを明示的に文書化すべきです(SHOULD)。
目標
このメカニズムは以下の目標を達成するように設計されています。
- リーダーの柔軟性: リーダーは eBPF ベースの実装に限定されません。
ライブラリの発見のために
/proc/<pid>/mapsを検査し、対象プロセスのメモリを読み取るための十分な権限を持つ任意の外部リーダーがこのメカニズムを使用できるはずです (注: OTEP-4719 もこのリソースへのアクセスを必要とします)。 - ランタイムの互換性: このメカニズムは、ELF TLS を使用でき、適切なスレッディングモデルを持つ言語向けの OpenTelemetry SDK に対するオプションの拡張です。 私たちはこれを C/C++、Rust、Java でテストしており、他のランタイムでも動作することを意図しています。 詳細は後述の「OTel SDK がサポートするランタイムにとって、これは実際には何を意味するのか」の節を参照してください。
- 低いオーバーヘッド、オプトイン方式: コンテキストのアタッチ/デタッチは OpenTelemetry SDK においてパフォーマンス上のクリティカルパスであり、このメカニズムはスレッドコンテキストをシリアライズする際に固定的で低いオーバーヘッドを提供するように設計されています。
- 単純さ: ライターとリーダーの両方の側で実装の複雑さを抑えます。
内部の詳細
プロセスコンテキスト: スレッドローカル参照データ
これは、TLS データが参照することになるプロセス全体のデータです。
これは OTEP 4719 で導入された Process Context 内に、ProcessContext.attributes フィールドのエントリーとして格納されます。
以下の値が格納されます。
threadlocal.schema_version- スキーマの型とバージョンです。 当初は実験用としてtlsdesc_v1_devを使用します(OTEP がマージされたらtls_v1に変更されます)。- 注: フォーマットの進化とは別に、スキーマの型を持つことで、アプリケーションは、たとえば自身が Go アプリケーションであり、そのためコンテキストはスレッドローカルではなく Go の pprof ラベルから読むべきである、あるいは Node.js の場合は異なるオフセットから読むべきであるといったことをシグナリングできます (このような代替スキーマは別の文書の対象となります)。
threadlocal.attribute_key_map- キーインデックス(uint8 の最大値まで)から属性名(文字列)へのマッピングを提供します。 スレッドローカルストレージ自体は、その後、属性名の代わりにこれらのキーインデックスを使用します。
注:
threadlocal.*キーは、プロセス間の調整用メタデータであり、テレメトリー属性ではないため、セマンティック規約としてではなくここで定義されています。 また、OTLP のエクスポートに現れることも想定していません。
使用される正確なフォーマットは、OTEP-4719 で標準化された ProcessContext.attributes フィールドの repeated KeyValue protobuf 構造です。
そのスキーマの要素の使い方を示す、いくつかの例の値を含んだ文字列表現は以下の通りです。
key: "threadlocal.schema_version"
value:
string_value: "tlsdesc_v1_dev"
key: "threadlocal.attribute_key_map"
value:
array_value:
values:
- string_value: "http_route" # index 0
- string_value: "http_method" # index 1
- string_value: "user_id" # index 2
理由: このメカニズムは、静的でプロセススコープのデータを TLS ストレージから分離するため、リーダーはそれを毎回スレッドをサンプリングするたびにではなく、一度だけ読み取ることができます。 これにより、必要に応じて任意の追加属性セットをサンプルに保存する柔軟性を保ちながら、書き込みと読み取り両方のスレッドサンプルのコストを削減します。 OTEP 4719 を活用することで、私たちは同じリーダーの多くが使用する可能性が高い別の機能とも同じ場所に配置することになります。
attribute_key_map 辞書のセマンティクス
キーマップは、トレースデータが利用できない場合でもサンプルが有用であり続けるように、最小限のコンテキスト属性のセットをプロファイルに付与することを意図しています。
attribute_key_map は追記専用です。
エントリーは追加されますが、削除されたり順序が変更されたりすることはありません。
既存のキーインデックスは一度割り当てられると安定しているため、リーダーはマップ全体を再読み込みしなくても、以前見たインデックスを有効なものとして扱うことができます。
キーはプロセス起動時に登録される必要はありません。 新しいキーは、SDK が新しい属性名に遭遇するたびに、時間をかけて追加されることがあります。 更新は頻繁には発生しないと想定されています。 実際には、キーのセットは通常プロセスのライフタイムの早い段階で確立されます。 これまでの実装では、一般的なケースでは少数のキーで十分であることが示されています。
キーが追加されると、SDK は Process Context 内の attribute_key_map エントリーを更新します。
リーダーは、破損や中途半端な状態での読み取りのない Process Context ブロック全体を読み取るために、OTEP 4719 の更新プロトコルに依拠します。
複数のアプリケーションスレッドからの同時のキー追加が互いに競合したり OTEP 4719 の更新プロトコルに違反したりしないように、SDK は自身のスレッド間で、たとえばミューテックスや同等の内部的な調整手段を使って、自身が単一のライターとして動作することを保証する責任を負います。
ライターが OTEP 4719 の更新プロトコルを正しく実行している限り、OTEP 4719 のリーダープロトコルは、リーダーがライターと同時に/競合して動作している場合でも、attribute_key_map を含む正しく完全なプロセスコンテキストのペイロードをリーダーが観測できることを保証します。
uint8 のキーインデックスにより、マップは最大256エントリーに制限されます。 これは v1 における意図的なトレードオフであり、もし制限が問題になる場合、将来のバージョンで辞書を拡張したり、高カーディナリティなユースケースのためにインラインキーを許可したりする可能性があります。
レコードを読み取ったあと、リーダーは attrs-data を処理して、キャッシュしている attribute_key_map に照らして各キーインデックスを解決します。
その時点で認識できないインデックスに遭遇した場合、続行する前にキャッシュしたマップを更新しなければなりません(MUST)。
スレッドローカル変数の解決
otel_thread_ctx_v1 は、静的にリンクされたバイナリであっても(たとえば --export-dynamic-symbol または同等のリンカーオプションを介して)、動的シンボルテーブル(.dynsym)内で ELF TLS シンボルとしてエクスポートされる必要があります。
以下の TLS アクセスモデルがサポートされます。
- Global Dynamic / TLSDESC(推奨): シンボルは TLSDESC 方言(たとえば GCC や Clang での
-mtls-dialect=gnu2)を使用し、共有ライブラリ内で TLSDESC リロケーションを生成します。 ライターにとって最良のランタイムパフォーマンスを提供するため、これが望ましいモデルです。 - Global Dynamic / legacy GNU: シンボルは従来の GNU TLS General Dynamic リロケーションを使用します。 これはサポートされますが、望ましいものではありません。
- 静的アクセス(initial-exec または local-exec): 変数のモジュールがリンク時にわかっている場合(たとえば
otel_thread_ctx_v1がメインの実行ファイルで定義されている場合)、リンカーは Global Dynamic な参照を initial-exec や local-exec に緩和することがあります。 この緩和はライターの制御外にあり、リーダーはこれを処理しなければなりません(MUST)。
Local Dynamic モデルはサポートされません。
ライターは、書き込みパスのパフォーマンスがより良くなるため、実用的な場合には TLSDESC 方言を使用すべきです(SHOULD)。 リーダーは、上記の3つのモデルすべてをサポートしなければなりません(MUST)。 詳細は「読み取りプロトコル」の節を参照してください。
スレッドローカル変数
私たちは、単一のスレッドローカル otel_thread_ctx_v1 を導入します。
これは、当該スレッドに関連付けられたアクティブなスレッドローカルコンテキストレコードへのポインタです。
スレッドローカルコンテキストレコード
これはアタッチされたスレッドレコードそのものです。 SDK 側の実装は、アクティブなスパンに対してこれの複数のインスタンスを保持し、TLS を適切なエントリーを指すように設定することでそれらをアタッチ/デタッチすることを選択できます。 私たちは単純さを優先し、文字列(UTF-8 バイト列)属性のみをサポートします。
レコードのレイアウトは、示されている通りに正確にバイトパックされており、フィールド間に暗黙のコンパイラパディングはありません。 複数バイトのフィールドはネイティブなマシン(ホスト)のエンディアンです。
| 名前 | データ型 | 注記 | |
|---|---|---|---|
| trace-id | uint8[16] | W3C Trace Context フォーマットです。ゼロはアクティブなものがないことを示すために使用できます。trace-id と span-id のいずれか一方が設定されている場合、両方が設定されていなければなりません。 | |
| span-id | uint8[8] | W3C Trace Context フォーマットです。 | |
| valid | uint8 | レコードが有効な場合、この値は 1 に設定されます。読み取り時に他の値が設定されている場合、コンシューマーはこのレコードを無視すべきです。それ以外のすべての値は予約済みであり、無効なものとして扱われます。 | |
| trace-flags | uint8 | 上記の trace-id/span-id に関連付けられた W3C Trace Context の trace-flags バイトで、sampled ビットと random-trace-id ビットを含みます。trace-id/span-id が未設定の場合はゼロです。また、attrs-data-size を 2 バイト境界に揃える役割も果たします。 | |
| attrs-data-size | uint16 | attrs-data のサイズです。これにより、リーダーは TLS バッファ内のすべての attrs-data レコードを消費し終えたことを知ることができます。レコード全体は 640 バイト以下に収めることが推奨されます。 | |
| attrs-data | uint8[] | 属性そのものを格納するバイトバッファです。全体の長さは attrs-data-size によって与えられます。 | |
| [x].key | uint8(*代替案は下記を参照) | キーテーブルへのインデックスです。リーダーは、threadlocal.attribute_key_map の範囲外のキーインデックスを持つエントリーを無視しなければなりません(MUST)。 | |
| [x].length | uint8(*代替案は下記を参照) | val 文字列の長さです。 | |
| [x].val | uint8[length](UTF-8 バイト列) | 文字列値そのもののインライン配列です。次の属性エントリーが始まる前に、ちょうど length バイトがここに現れます。 |
attrs-data 内のエントリーはエントリー間にパディングを挟まず連続してパックされます。
リーダーは、残りのバッファが完全なエントリーを保持できない場合、attrs-data のパースを停止しなければなりません(MUST)。
同じキーインデックスが attrs-data 内に複数回現れる場合、リーダーは最後の出現を使用しなければなりません(MUST)。
これにより、ライターは既存のキーの値を更新するために、以前のエントリーを書き直すことなく追記できます。
レコードは、少なくとも2バイト境界にアラインされたアドレスから始まらなければなりません(MUST)。
このフォーマットは通常2回の読み取りに依存しています。1回目は attrs-data-size までを含む必要なフィールド(最初の28バイト)を読み取り、2回目は任意のカスタム属性を読み取ります。
このレイアウトの理由: これはスケーラブルです。
ライターが必要な属性のみを公開しカスタムフィールドを持たないように設定されている場合、attrs-data-size を0に設定できます。
構造体の最初の部分を読み取った後、リーダーは残りを読み取ることなく停止します。
キャッシュへの影響: 同様に、倹約的なライターは、レコード全体を64バイト(キャッシュラインの典型的なサイズ)未満に収めることを目指すことがあり、その場合、最初の読み取りの後にはレコード全体がキャッシュに載っていることを期待できます。
このフォーマットは、リード・インに28バイトを要し、キーバリューペアごとに2バイトのオーバーヘッドがかかります。
1組のペアの場合、値には34バイトが使えます(合計64バイト から リード・イン28バイト、キー1バイト、長さ1バイトを引いたもの)。2組の場合は合計32バイトです(前の値から、キー1バイト、長さ1バイトをさらに引いたもの)。
path や method のような属性を追跡することを想定していた場合、これは現実的に64バイトの単一キャッシュラインに収まると期待できることを意味します。
OTel eBPF Profiler の上限に合わせるため、レコード全体は640バイト以下に保つことを推奨します。
考えられる代替案/コメント募集: [x].key と [x].length の一方または両方を、protobuf 形式の可変長整数に切り替えます。
これにより、必要であれば255を超えるキー数や255バイトを超える値の長さが可能になります。これを望みますか。
公開プロトコル
公開側の SDK は、以下の手順を通じて外部リーダーがスレッドローカルコンテキストを利用できるようにします。
1. プロセスの初期化
プロセス起動時に、SDK は以下を行います。
- (OTEP-4719 に従って)スレッドローカル参照データをプロセスコンテキストに公開します。
threadlocal.schema_version: 互換性チェックのためのスキーマのバージョンthreadlocal.attribute_key_map: キーインデックス(uint8)から属性名へのマッピング
- SDK は
attribute_key_mapを記録しておきます。これは TLS データそのものを準備するために使用されます。
2. コンテキストのアタッチ
リクエストコンテキストがスレッドにアタッチされるとき、SDK は以下を行います。
- 想定されるレコードサイズを格納するのに十分な大きさの連続したバッファを取得します。
- バッファ内に、以下を含む新しいスレッドローカルコンテキストレコードを構築します。
- トレースコンテキスト(トレース ID、スパン ID、トレースフラグ)
- レコードフォーマットに従ってエンコードされた、設定済みの任意の属性
- レコードが完成したことを示すために
validフィールドを設定します。 - TLS ポインタを更新して新しいレコードを参照させます。
すでにリーダーから見えている可能性のあるストレージを再利用する場合、SDK は、構築中はリーダーに何もレコードが見えないようにするために、まず TLS ポインタを NULL に設定してもかまいません(MAY)。
注: SDK は、このパスでの割り当てを節約するために、既存のバッファを自由に再利用できます。
代わりに、SDK は TLS ポインタを固定レコードを指したままにしておき、更新時には valid フラグのみを変更するという選択をしてもかまいません。
TLS ポインタがスレッドごとに一度だけ設定され固定レコードを指していると仮定すると、更新プロセスは次のようになります。
validフラグをfalseに設定します。- スレッドのスレッドローカルコンテキストレコードを必要に応じて更新します。
- レコードが完成したことを示すために
validフィールドをtrueに設定します。
SDK は、TLS ポインタ自体を設定/解除するか、valid フラグを設定するかのいずれか一方を選ぶべきであり、両方を行うべきではありません。
この設計の意図は、ライターに柔軟性を持たせることです。
一部のライターは特定のスレッドに対して固定レコードを保持し、それをその場で変更することを選ぶかもしれず、また別のライターは、代わりにレコードを他の高レベルな概念(コルーチンやリクエストなど)に関連付けて保持し、それらが任意のスレッド上でアクティブになるたびに必要に応じてポインタを差し替えるかもしれない、と私たちは想定しています。
いずれの場合も、すべてのポインタと有効性の更新は、コンパイラによる命令の並べ替えを防ぐために、コンパイラフェンス(atomic_signal_fence または同等のもの)とヴォラタイル書き込みを使用します。
この設計はシグナルハンドラのようなセマンティクスを前提としているため、CPU による並べ替えに対する保護は不要です。
attrs-data のみを拡大または縮小する場合
リーダーは、attrs-data から始まる最初の attrs-data-size バイトのみを気にします。
属性を追記したいライターは、コンテキストをデタッチしたり valid を変更したりすることさえなく、attrs-data + attrs-data-size から始まるバイトを自由に変更してもかまいません(MAY)。
この状況では、attrs-data-size に対する最後の更新(これにより新しい属性が可視化される)が最後に行われるようにするために、適切なフェンシングだけが必要です。
同様の状況は「縮小」でも起こり得ます。attrs-data-size の値をより小さい値に置き換えることは、他の変更と並べ替えられないことを適切なフェンシングが保証する限り、valid を変更することなく行ってもかまいません(MAY)。
3. コンテキストのデタッチ
リクエストコンテキストがスレッド上で非アクティブになったとき、SDK は TLS ポインタを NULL に設定するか、valid フラグを false に設定します。
設計上の考慮事項
レコードの再利用: SDK は、頻繁に再アタッチされるコンテキスト(たとえば、繰り返し出入りする親スパン)のレコードを、再構築するのではなく保持しておくというキャッシュ戦略を実装してもかまいません。
valid フィールドは、代わりに、完全にデタッチすることなくレコードが変更中であることを示すために使用することもできます。
割り当て: 実装は、ある固定数のスレッドローカル参照データインスタンス用のストレージを事前に割り当てることを選んでもかまいません。 これにより、ホットパスでの割り当てが不要になります。
並行性モデル: プロセスコンテキスト(OTEP 4719)ではライターがリーダーと非同期に競合し CPU のメモリバリアが必須とされているのに対し、スレッドコンテキストはシグナルハンドラのようなセマンティクスを前提とします。
実際には、コンテキストの読み取りは、コンテキストが読み取られているスレッドが停止しているか、あるいは何らかの形で割り込まれているかのように振る舞うことが期待されます。
このプロトコルは、あるスレッドのコンテキストレコードがそのスレッド自身によってのみ更新されることを要求しているため、読み取りと書き込みの間に並行性の危険が生じることはありません。
これは、CPU のメモリ順序が問題にならないことを意味します。
ライターは、コンパイラがコンテキストへの書き込みと valid や TLS ポインタへの書き込みを並べ替えることを防ぐために、コンパイラフェンス(atomic_signal_fence または同等のもの)や、ヴォラタイル書き込みを使用するだけで十分です。
これらのフェンスは、ランタイムのコストをまったくかけないことが期待されます。
この仕様に準拠するリーダーは、対象のスレッドが停止しているか割り込まれている間(たとえば eBPF perf イベント、ptrace-stop、または同等のメカニズムを介して)にのみスレッドコンテキストを読み取らなければなりません(MUST)。
読み取りプロトコル
(OpenTelemetry eBPF プロファイラーのような)外部リーダーは、以下のようにスレッドローカルコンテキストを発見して読み取ります。 この読み取りプロトコルは、リーダーが各スレッドを、それが停止しているか割り込まれている間に観測することを前提としています。
1. プロセスの初期化
外部リーダーは、観測すべき新しいプロセスを発見します。リーダーは以下を行います。
1.1 プロセスコンテキストの特定
- OTEP-4719 の読み取りプロトコルに従い、プロセスコンテキストからスレッドローカル参照データのリソースを取得します。
- リーダーは、この時点でプロセスマッピングから読み取った情報を、TLS の解決のためにすぐに必要となるため、保持しておくことを選んでもかまいません。
- OTEP-4719 はプロセスコンテキストの更新を許容しており、そのためスレッドローカル参照データはこれらの更新のいずれかの際に現れる可能性があり、最初に公開されたコンテキストから存在することは保証されていない点に注意してください。
1.2. バイナリとロードされたライブラリの TLS dynsym を確認する
この時点でプロセスコンテキスト内に threadlocal.* キーが存在しない場合、リーダーは TLS シンボルの発見を保留し、プロセスコンテキストが次に更新された際にステップ1.2を再実行すべきです(SHOULD)。
リーダーは、OTEP-4719 で説明されているポーリングまたは prctl フックのメカニズムを使って、プロセスコンテキストの更新を検出できます。
/proc/<pid>/maps内のプロセスマッピングから、プロセスによってロードされた動的ライブラリの一覧を生成します。- プロセス自身とその動的にロードされたライブラリのそれぞれについて、
dynsymテーブルに上記の TLS シンボルが含まれているかを確認します。- 含まれていれば、それらを収集します。
- 発見されたスレッドローカル参照データの TLS オフセットを記録しておきます。
threadlocal.* キーが存在し、TLS シンボルが発見されたら、リーダーはスレッドのサンプリングを開始するために必要なものをすべて手に入れたことになります。
TLS のオフセットを見つける方法の詳細については、この Google ドキュメントを参照してください。
2. スレッドのサンプリング
外部リーダーは、対象のスレッドについてスレッドローカル参照データの TLS レコードを読み取り、スレッドローカルコンテキストレコードのインスタンスへのポインタを取得します。
ポインタが null であれば、コンテキストレコードはアタッチされておらず、リーダーの作業は完了です。
ポインタが null でなければ、リーダーはまずポインタから固定長28バイトのレコードヘッダーを読み取り、トレースコンテキスト、有効性フラグ、attrs-data-size を取得します。
レコードが有効で attrs-data-size が非ゼロであれば、リーダーはオフセット28からの attrs-data-size バイトを2回目の読み取りとして行い、カスタム属性データを取得します。
このバッファは、その後、リーダーの都合の良いタイミングで上記の仕様に従ってパースできます。
既存機能との相互作用
OpenTelemetry SDK
このメカニズムは追加的なものであり、既存の OpenTelemetry SDK の挙動を変更しません。
OpenTelemetry - プロセスコンテキスト
スレッドローカル参照データは、Process Context Proposal に追加される予定です。
- スレッドローカル参照データのライフサイクルは、スレッドではなくプロセスに、より正確に紐づいています。
- プロセスコンテキストのコンシューマーは、スレッドレベルコンテキストのコンシューマーと大きく重なる可能性が高いです。
トレードオフと緩和策
ホストと権限の要件
このメカニズムは、(eBPF プロファイラーのような)外部リーダーが計装対象のプロセスと同じホスト上で動作しており、プロセスが公開するメモリマッピングにアクセスし対象プロセスのメモリを読み取るために十分な権限を持っていることを要求します。
OpenTelemetry eBPF Profiler のような外部リーダーは、通常、設計上すでにこれらの権限を持っています。 このアプローチは、プロセスコンテキストのリモートまたはホストをまたいだ相関をサポートしておらず、適切な権限なし(たとえば非特権ユーザーから)にプロセスコンテキストのマッピングへアクセスしようとする試みは失敗します。
SDK 実装者にとっての複雑さ
TLS シンボルをエクスポートし、それがプロセスの dynsym テーブルに確実に含まれるようにすることは、SDK の実装に複雑さを加えます。
緩和策: 私たちは、既存の PolarSignals の custom-labels の作業を拡張し、C でのリファレンス実装と Rust でのバインディングを提供しています。
ソースからビルドするエンドユーザーにとっての複雑さ
(たとえば Rust の場合など)ネイティブバイナリをソースからビルドする際に、SDK が公開する TLS シンボルがアプリケーションの dynsym テーブルに確実に含まれるようにするには、エンドユーザー自身によるビルドツールの言語ごとの設定が必要です。
プロトコルの進化
要件が進化するにつれて、ペイロードフォーマットを拡張する必要が生じる可能性があります。
緩和策: この設計には、バージョニングと拡張ポイントの両方が含まれています。
- オープンエンドなカスタム属性セット: 関連する属性をスレッドローカルストレージに公開するために、SDK、ユーザー、あるいはその両方によって設定できます。オーバーヘッドを削減するために空集合に設定することもできます。
- バージョン番号: 互換性のない変更のためのもの(頻繁に変更されることは想定していません)
メモリオーバーヘッド
頻繁に変化するスレッドローカルコンテキストレコードのデータを、静的でプロセス全体にわたるスレッドローカル参照データから分離することで、以下を保証します。
- 属性キー名を繰り返すことによるオーバーヘッドが、インデックス方式によって最小化されます。
- TLS コンテキストの読み取りによるメモリオーバーヘッドが削減され、スレッドを一時停止させておく必要がある時間が最小化され、CPU キャッシュへの影響が軽減されます。
トレースサンプリング
プロセス外のリーダーは、プロセス内トレーサーのサンプリング判断に影響を与える能力を持ちません。そのため、収集されたサンプルは SDK によってエクスポートされなかったトレースデータを参照している可能性があり、route のようなリクエストメタデータでサンプルを補強するために使用することはできません。
これにより、そのような状況下で捕捉されたサンプルの利用は制限されます。
緩和策: スレッドローカルコンテキストレコードにカスタムのキーバリューペアをオプションで追加できるようにすることで、SDK は、中核となる属性情報が外部リーダーと直接共有されることを保証するように設定できます。
これは、たとえば各サンプルに route を付与するために使用できます。
OTel SDK がサポートするランタイムにとって、これは実際には何を意味するのか
この文書の上記で述べたように、このメカニズムはオプションであることを意図しています。これは、すべてのランタイムおよびランタイムバージョンがこれを現実的に、あるいは効率的に実装できるわけではないためです。
これまでのところ、私たちは多数のランタイム/言語を検討しており、それらの実現可能性について私たちが学んだことを以下に列挙します。 この節は、仕様の実装者を制約すること(あるいは、興味がない場合に SDK にこの機能の採用を強制すること)を意図したものではなく、むしろ、これがそれらの言語/ランタイムにどの程度適合すると私たちが現時点で考えているかを示すことを意図しています。
- C/C++: 完全にサポートされます。
- Rust: 完全にサポートされます。TLS シンボルが正しく公開されることを保証するために、ネイティブライブラリとのリンクが必要です(こちらを参照)。
- Java: 完全にサポートされます。ネイティブライブラリの呼び出し(たとえば JNI または同等の API を介して)が必要です。
- .NET: ネイティブライブラリへの FFI バインディングを介して完全にサポートされます。
- Python: ネイティブライブラリを使用して完全にサポートされます。Python 3.14 以降で動作するトレーサーは、コンテキストのアクティベーションを追跡するために PyContext_WatchCallback を使用できます。それより古いバージョンではランタイムをモンキーパッチする必要があります。
- Ruby: ネイティブ拡張を介して完全にサポートされます。ruby-profiler gem(Shopify 提供)は、Ruby でこれを行う非常によく似たアプローチの例を示しています。
以下の2つのランタイムについては、当面この提案でサポートされる見込みがないと私たちは考えています(詳細は以下の通りです)。
- Go:
- Node.js:
Go サポートのための代替案
きめ細かいゴルーチンベースの並行処理モデルと、FFI をまたぐ呼び出しの相対的なコストのため、Go のリーダーは pprof ラベルを直接読み取ることになると私たちは予想しています。
Go の SDK は、以下のようなコンテキスト参照データを公開すべきです(SHOULD)。
key: "threadlocal.schema_version"
value:
string_value: "go_pprof_labels_v1"
また、threadlocal.attribute_key_map は持たない(あるいは空にする)べきです。
Node.js サポートのための代替案
私たちは、スレッディングモデルと、コンテキストのアタッチ/デタッチ時に Node-API/ネイティブ FFI の境界を越えることによるパフォーマンスへの影響から、Node.js が TLS 公開メカニズムを直接使用するとは想定していません。
Node.js のリーダーは、Polar Signal のプロファイラーのように、Node.js 固有のランタイム内部を通じてコンテキストを発見する可能性が高いです。 ただし、発見メカニズムのみが Node.js 固有であるという形で、Node.js 固有の別の提案がスレッドローカルコンテキストレコードのメモリ内フォーマットを再利用することが見込まれます。
今後の提案が、発見の詳細と、Node.js が使用すべき(SHOULD)想定される threadlocal.schema_version を定義する予定です。
先行技術と代替技術
私たちが着想を得た、類似の情報を共有するための既存の TLS メカニズムを2つ把握しています。 どちらの場合も、ここで説明したものと同じメカニズムが TLS の発見とアクセスに使用されており、違いはストレージフォーマットのみです。
Elastic Universal Profiling Integration: 単一の TLS の背後に、コアとなるトレース情報(parent、flags、ID、span ID、transaction ID)のみを捕捉する、単純でフラットなメモリの固定長レイアウトを使用します。 これは実装が単純で、読み書きが高速です。
Polar Signals Custom Labels: Elastic モデルの代替であり、追加でカスタムのキーバリューペアをサポートします。
この提案は、Elastic フォーマットの静的な割り当てと読み取り時のパフォーマンスを保ちつつ、Polar Signals フォーマットの柔軟性を提供することで、これら2つのアプローチの利点を統合しようとするものです。
TLS 値のストレージ: プロファイルに付与される属性の値が、起動時には不明だが固定的な集合から来ると仮定するなら、これらをスレッドローカルコンテキストレコード自体の外部にある共有ハッシュマップに格納するという選択肢もあり、これによりレコードのサイズと読み書きに伴うコストをさらに削減できます。
これは、uuid() のようなものではなく、http_method や http_route のようなものの属性を格納する場合に当てはまるでしょう。
これにはまた、ロックフリーな読み取りを持つプロセス全体のハッシュテーブルの実装も必要になります。
Datadog の Java プロファイラーのコンテキスト共有メカニズムに先行事例があります。
macOS と Windows のサポート
私たちは、以下の理由から、この OTEP を Linux 固有のものにとどめることにしました。
- 私たちは、これの最初の利用者は eBPF Profiler と OBI になると予想しており、どちらも現時点では eBPF フックを多用しているため Linux 固有です。
- 現代のすべてのオペレーティングシステムはスレッドローカルを提供していますが、それらの間には(特に macOS において)非常に異なるセキュリティモデルが存在しており、そのためこの選択肢がそこで最良の選択肢であるかどうかは明確ではありません。
- 提案されているメカニズムはかなり低レベルであり、非常に厳しい効率性の目標を持っています。私たちは、「誰もがスレッドローカルを持っているのだからこれで問題ないはずだ」というやり方をするのではなく、これらが他のオペレーティングシステムに実際にどのように対応するかを、より注意深く後で評価する方が良いと考えています。
未解決の課題
キーと値の数および長さに関する現在の制限は、受け入れ可能な、あるいは適切なトレードオフでしょうか。
attribute_key_mapを介して追加する必要のない動的なキーを提供する方法があるべきでしょうか。
プロトタイプ
リーダー:
- ctx-sharing-demo reader: PolarSignals の custom-labels の作業に基づいたサンプルリーダー
- OpenTelemetry eBPF Profiler PR: スレッドコンテキスト情報を読み取り、プロファイルと共に伝播する
ライター:
- ctx-sharing-demo repo: Rust や C を含む複数のライターの例
- opentelemetry-rust: Rust SDK 向けのプロセスコンテキストとスレッドコンテキストの両方の実験的実装
- Node.js thread context: Node.js 向けのスレッドコンテキストの実験的実装
- libdd-otel-thread-ctx: Rust における Datadog のスレッドコンテキストのオープンソース実装
- Datadog’s dd-trace-java SDK: スレッドコンテキストの高性能な実装を示し、プロセス内プロファイラーによるこのメカニズムの採用を含み、eBPF ベースのリーダーを超えた適用可能性を実証している
将来の可能性
OTEP 4719 やそれに先立つ OTel eBPF Profiler と同様に、この提案は Linux 専用のメカニズムを規定しています。 将来的には、他のオペレーティングシステム向けの同様のメカニズムを検討する可能性があります。