Upgrade to Pro — share decks privately, control downloads, hide ads and more …

OpenTelemetry eBPF Instrumentationの舞台裏 / Behind...

Avatar for ymotongpoo ymotongpoo
September 11, 2026

OpenTelemetry eBPF Instrumentationの舞台裏 / Behind the Scenes of OpenTelemetry eBPF Instrumentation

Go Conference 2026での登壇資料です
https://gocon.jp/2026/timetable/1263399/

Avatar for ymotongpoo

ymotongpoo

September 11, 2026

More Decks by ymotongpoo

Other Decks in Technology

Transcript

  1. CHECKPOINT はじめに会場アンケート 挙手をお願いします。 Q1: uprobe の仕組みを説明できる Q2: Goの可動スタックを知っている Q3: Go

    1.17のレジスタ渡し規約を知っている 「知らない」が多め → Part 1: 基礎ダイジェスト から 「ほぼ知っている」 → OBI本編へジャンプ(DEEP DIVEまで話します)
  2. 基礎 ELFバイナリとシンボルテーブル func double(n int) int は 48 01 c0

    c3 というバイト列になる 1命令ごとにアドレスが付く。RETは c3 の1バイト シンボルテーブルが main.double → 0x49e180 を教える デバッグ情報 DWARF も .debug_* セク ションに存在(難所3で登場)
  3. OBI OBIの計装パイプライン 難所は2〜4の工程に現れる 1. 発見: /proc から対象プロセスを選ぶ 2. ELF解析: どこに置くか・読むかを決定

    3. ロードとアタッチ: 検証器を通してuprobe を置く 4. イベント収集: フック発火 → スパン組み立 て 5. エクスポート: OTLP / Prometheusで送信
  4. 難所1・関数の出口が取れない defer があるとRETは1つでは済まない $ go tool objdump -s 'main\.Lookup$' s2_ret

    s2_ret.go:17 0x49e28f POPQ BP s2_ret.go:17 0x49e290 RET ← 通常の経路 s2_ret.go:17 0x49e291 CALL runtime.deferreturn(SB) ... s2_ret.go:17 0x49e29f POPQ BP s2_ret.go:17 0x49e2a0 RET ← defer 経由の経路 ポイント ソース上の return は1つ。それでもこの ビルドではRETが2つ 2つ目は runtime.deferreturn を通っ てから抜ける経路 ソースの return の数から出口の位置は 決められない
  5. 難所1・関数の出口が取れない DEEP DIVE 戻りアドレスに厳格な理由 スタック引っ越しの手順そのもの。 1. 新領域を確保して丸ごとコピー 2. 戻りアドレスを読む 3.

    .gopclntab でどの関数のフレームか引 く 4. フレームの大きさとポインタの位置が判明 5. ポインタを書き換え、次のフレームへ 表に無い=手順3で止まる=引っ越し不能
  6. 難所2・引数がスタックに無い スタックに無い引数 Go 1.17でスタック渡し → レジスタ渡し(ABIInternal)に 第1引数からAX, BX, CX, DI,

    SI, R8…の順。Cは RDI, RSI から 汎用ツールはCの規約で読むので、無言で違う場所を読む
  7. 難所2・引数がスタックに無い goidを「読まない」という設計 // bpf/common/go_addr_key.h typedef struct go_addr_key { u64 pid;

    // PID of the process u64 addr; // Address of the goroutine } go_addr_key_t; ポイント g 構造体には通し番号 goid があるが、OBIは読まない キーは g のアドレス + PID の組だけ 中身に触れない= g のレイアウト変更に追従不要 Go goid のオフセット(実測) 1.21–1.22 +152 1.23–1.24 +160(手前に syscallbp が追加) 1.25–1.26 +152( sched が縮小)
  8. 難所2・引数がスタックに無い DEEP DIVE レジスタ対応表の実物 // bpf/bpfcore/utils.h(x86_64側) #define GO_PARAM1(x) ((void *)(x)->ax)

    #define GO_PARAM2(x) ((void *)(x)->bx) #define GO_PARAM3(x) ((void *)(x)->cx) #define GO_PARAM4(x) ((void *)(x)->di) /* ... GO_PARAM9 まで ... */ // In x86, current goroutine is pointed by r14 #define GOROUTINE_PTR(x) ((void *)(x)->r14) ポイント x はトラップ時のCPUレジスタのコピー ( pt_regs ) Goの公開APIは介在しない。停止した瞬間のレジスタ を直に読む arm64側には regs[0] 〜と regs[28] の同じ表が ある
  9. 難 所 3 ・ 構 造 体 オフ セ ッ

    ト が バ ー ジ ョ ン に よ って ず れる 構造体オフセットのバージョン間のずれ gRPCの Stream.method は 80 → 88 → 24 → 16 と3回動いた internal パッケージに互換性の約束は無 い ハードコードした +88 は、バージョンが変 われば別のフィールドを指す クラッシュしないのでアラートには何も掛か らない
  10. 難 所 3 ・ 構 造 体 オフ セ ッ

    ト が バ ー ジ ョ ン に よ って ず れる DWARFとoffsets.jsonの二段構え 1段目: バイナリ自身のDWARFを読む。目 の前の事実なので追従不要 2段目: 欠けた分だけ offsets.json の表で 補う offsets.json は go-offsets-tracker が各バ ージョンを実際にビルドして自動生成 配布時にデバッグ情報を落とすと、1段目が 使えず表頼みになる
  11. 難 所 3 ・ 構 造 体 オフ セ ッ

    ト が バ ー ジ ョ ン に よ って ず れる offsets.jsonの実際の構造 "net/http.Request": { "Method": { "versions": {"oldest": "1.17.0", "newest": "1.27.1"}, "offsets": [{"offset": 0, "since": "1.17.0"}] }, "URL": { "versions": {"oldest": "1.17.0", "newest": "1.27.1"}, "offsets": [{"offset": 16, "since": "1.17.0"}] }, "Header": { "versions": {"oldest": "1.17.0", "newest": "1.27.1"}, "offsets": [{"offset": 56, "since": "1.17.0"}] } } ポイント 「いつからその位置か」の履歴を バージョンの範囲つきで持つ 手元で unsafe.Offsetof(r.Header) を印字すると 56 が出て、表と一 致する 検索は対象バージョン以下で最新 の記録を返す。 newest を超えて も値は返る
  12. 難 所 3 ・ 構 造 体 オフ セ ッ

    ト が バ ー ジ ョ ン に よ って ず れる DEEP DIVE -s -w の現実 実測: 5,444,719 → 3,748,002バイト。DWARFセクション8個 → 0個。ただし .gopclntab は残 る。オフセット解決は全面的に表頼みになる
  13. 難所4・GOROUTINEをまたいでコンテキストを運べない 探索は自身を含めて6回まで u64 r_addr = current->addr; go_addr_key_t *parent = current;

    ポイント // 起点は自分自身 int attempts = 0; do { tp_info_t *p_inv = bpf_map_lookup_elem(&go_trace_map, parent); if (!p_inv) { // 親を1段遡る(ongoing_goroutines を引く) // ... } else { return r_addr; // 開始済みトレースを発見 } attempts++; // We loop far back because some clients, e.g. Kafka Franz-Go // really nest the client calls. } while (attempts < 6); // Up to 6 levels of goroutine nesting allowed 検証器は探索回数に上限のない ループを通さない。「見つかる まで」とは書けない 起点が自分自身なので、親方向 に辿れるのは5世代分 見つからず他に引き継ぐ情報も 無ければ新しいトレースIDが振 られる
  14. 難所4・GOROUTINEをまたいでコンテキストを運べない traceparentヘッダの書き込み方法 http.Header は map[string][]string で、外からmapには書き こめない 狙うのは直列化された後の bufio.Writer のバッファ

    writeSubset の戻り値の末尾に1行を書き足し、使用済み長 n を 進める 書き込みに使う bpf_probe_write_user は kernel lockdown 環 境では使えない
  15. 難所4・GOROUTINEをまたいでコンテキストを運べない DEEP DIVE 2つの書き込み経路 経路1: bpf_probe_write_user でアプリのメモリに直接書 く 暗号化前の平文に書くのでHTTPSでも動く。lockdown 環境では無効化

    経路2: sk_msg でソケットへ出ていくバイト列自体を伸ば す アプリのメモリには触らないが、TLSの暗号文には注入 できない 二重注入を避けるため、経路1が動いたときは経路2を飛ば す排他制御が入っている
  16. 別のアプローチ Compile-Time Instrumentation $ otelc go build -o myapp .

    // net/http 自身の RoundTrip。otelc がビルド時に実際に書き換えた中身 // (関数名末尾の衝突回避用ハッシュは省略) func (t *Transport) RoundTrip(req *Request) (_r0 *Response, _r1 error) { if hc, _ := OtelBeforeTrampoline_RoundTrip(&t, &req); false { } else { defer OtelAfterTrampoline_RoundTrip(hc, &_r0, &_r1) } if t == nil { panic("transport is nil") } return t.roundTrip(req) } // Before/AfterRoundTrip は go:linkname で otelc の計装パッケージに直結 func BeforeRoundTrip(hc HookContext, recv0 *Transport, param0 *Request) func AfterRoundTrip(hc HookContext, arg0 *Response, arg1 error) ポイント Go本体とは別の OpenTelemetry側の取り組 み。2025年1月にSIG発足 実行後に外から割り込むeBPF とは対照的に、ビルド時に中 へ埋め込む
  17. まとめ 4つの難所の総括 どの難所も「Goを速く書きやすくする設計」の裏返し 難所 問題になるGoの性質 OBIの対処 1. uretprobe 可動スタック 全RETへの通常uprobe

    2. レジスタABI ABIInternal レジスタ対応表 + g のアドレス 3. オフセット 非公開の内部構造 DWARF + offsets.json 4. 文脈の分断 goroutine ≠ スレッド newproc1の親子記録 + ヘッダ注入