Slide 1

Slide 1 text

OpenTelemetry eBPF Instrumentationの舞台裏 GoバイナリへのeBPF計装は、なぜ難しいのか Go Conference 2026 · Room A · Yoshi Yamaguchi (@ymotongpoo) · 2026-09-11

Slide 2

Slide 2 text

山口 能迪(やまぐちよしふみ) Staff Developer Advocate Grafana Labs 専門領域 オブザーバビリティ SRE @ymotongpoo

Slide 3

Slide 3 text

AGENDA OBIがGoバイナリを計装するまでの4つの難所 OBI (OpenTelemetry eBPF Instrumentation) はアプリのコードを変更せずに分散トレースやメトリ クスの取得を実現するツール 1. 関数の出口が取れない 2. 引数がスタックに無い 3. 構造体オフセットがバージョンによってずれる 4. goroutineをまたいでコンテキストを運べない

Slide 4

Slide 4 text

背景 Goだけが深く計装される理由 他言語のゼロコード計装は、通信のバイト列を解釈する汎用経路が主 ライブラリ関数のレベルまでuprobeで踏み込む計装を持つのはGoだけ 静的リンクで全関数のアドレスがビルド時に決まり、 net/http という共通の計装点がある 踏み込んだ先で、Goだけの難所にぶつかる

Slide 5

Slide 5 text

CHECKPOINT はじめに会場アンケート 挙手をお願いします。 Q1: uprobe の仕組みを説明できる Q2: Goの可動スタックを知っている Q3: Go 1.17のレジスタ渡し規約を知っている 「知らない」が多め → Part 1: 基礎ダイジェスト から 「ほぼ知っている」 → OBI本編へジャンプ(DEEP DIVEまで話します)

Slide 6

Slide 6 text

Part 1: 基礎ダイジェスト eBPF計装を理解するために必要な最小限のOSとGoの知識を7枚で

Slide 7

Slide 7 text

基礎 プログラムは番地付きのバイト列 機械語もスタックも構造体も、すべてメモリ上のバイト列。 計装とは、この番地と実行の流れに外から割り込むこと 以降の機械語の例は linux/amd64

Slide 8

Slide 8 text

基礎 ユーザー空間とカーネル空間 アプリはハードウェアに直接触れず、システムコール で依頼する プロセスごとに独立した仮想アドレス空間。隣のプ ロセスのメモリは読めない 機械語のページは読み取り専用として複数プロセス で共有される eBPFはカーネル側に安全に自前コードを置く仕組み

Slide 9

Slide 9 text

基礎 ELFバイナリとシンボルテーブル func double(n int) int は 48 01 c0 c3 というバイト列になる 1命令ごとにアドレスが付く。RETは c3 の1バイト シンボルテーブルが main.double → 0x49e180 を教える デバッグ情報 DWARF も .debug_* セク ションに存在(難所3で登場)

Slide 10

Slide 10 text

基礎 関数呼び出し: CALLとRET CALLは戻りアドレスをスタックに積む RETはそれを取り出してジャンプする 1回の呼び出しが使うスタック上の領域をフレームと呼ぶ 戻り先はスタック上の1スロットに書かれたただの数値

Slide 11

Slide 11 text

基礎 goroutineと可動スタック goroutineはOSスレッドではない。ランタ イムが管理する実行単位 カーネルが知っている実行単位はスレッド まで スタックもランタイムが自前で確保し、足 りなくなると別領域へ丸ごとコピー 引っ越し時、スタックを指すポインタはす べて新アドレスへ書き換えられる

Slide 12

Slide 12 text

基礎 eBPFの実行モデル 検証器が停止性とメモリ安全をロード時に静的検 査。通れば常駐 イベントごとに実行されて終わる。状態はマップ で持つ ユーザー空間のローダー(Go製が多い)がマッ プを読み出す

Slide 13

Slide 13 text

基礎 uprobeによる1バイト差し替え 対象アドレスの先頭1バイトを int3( cc )に差し替える。CoWでそのプロセスのページだけ 到達→トラップ→レジスタのコピーを添えてeBPF実行→元の命令を別領域で実行→復帰

Slide 14

Slide 14 text

Part 2: OBIと4つの難所 ここからが本編

Slide 15

Slide 15 text

OBI アプリのコードに1行も触れない計装 SDK計装ではアプリが自分でヘッダを書く。ゼロコード計装ではOBIがカーネル側から代行する。

Slide 16

Slide 16 text

OBI OBIの計装パイプライン 難所は2〜4の工程に現れる 1. 発見: /proc から対象プロセスを選ぶ 2. ELF解析: どこに置くか・読むかを決定 3. ロードとアタッチ: 検証器を通してuprobe を置く 4. イベント収集: フック発火 → スパン組み立 て 5. エクスポート: OTLP / Prometheusで送信

Slide 17

Slide 17 text

難所1・関数の出口が取れない 関数の「出口」の捕捉 入口だけの計装で分かるのは、呼ばれた回数 だけ レイテンシは入口と出口の時刻の差からしか 出せない 入口は先頭アドレスにuprobeを置くだけ。出 口の定番は uretprobe スタック上の戻りアドレスをトランポリンに 書き換え、RETで「戻らせて」発火させる

Slide 18

Slide 18 text

難所1・関数の出口が取れない 可動スタックとの衝突 ランタイムは戻りアドレスを .gopclntab で引きながら全フレームを走査する .gopclntab は命令アドレスから関数・行 番号・インライン履歴を引くGo独自の表 トランポリンはカーネルがプロセスに張っ た別の領域なので、この表に無い fatal error: unknown caller pc でア プリごと落ちる

Slide 19

Slide 19 text

難所1・関数の出口が取れない 全RETへのuprobe配置 1命令ずつデコードして RET命令を全部列挙し、 通常のuprobeを置く スタックは一切書き換えない。走査を壊さない 代償: probeが増える。関数ごとに逆アセンブルが 要る

Slide 20

Slide 20 text

難所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 の数から出口の位置は 決められない

Slide 21

Slide 21 text

難所1・関数の出口が取れない DEEP DIVE 戻りアドレスに厳格な理由 スタック引っ越しの手順そのもの。 1. 新領域を確保して丸ごとコピー 2. 戻りアドレスを読む 3. .gopclntab でどの関数のフレームか引 く 4. フレームの大きさとポインタの位置が判明 5. ポインタを書き換え、次のフレームへ 表に無い=手順3で止まる=引っ越し不能

Slide 22

Slide 22 text

難所1・関数の出口が取れない DEEP DIVE インライン関数への非対応 小さい関数は呼び出しごとコンパイル時に消える CALL/RETが無ければ、入口と出口として置けるアド レスが無い 計装できるのは「呼び出しとして残った」関数だけ

Slide 23

Slide 23 text

難所2・引数がスタックに無い スタックに無い引数 Go 1.17でスタック渡し → レジスタ渡し(ABIInternal)に 第1引数からAX, BX, CX, DI, SI, R8…の順。Cは RDI, RSI から 汎用ツールはCの規約で読むので、無言で違う場所を読む

Slide 24

Slide 24 text

難所2・引数がスタックに無い 処理中goroutineの識別 ランタイムは専用レジスタに現在の g 構 造体のポインタを常駐させる OBIは g のアドレスを識別キーに使う リクエストの開始と終了、親子関係はすべ てこのキーで紐づく アーキテク 現在の g を保持す チャ るレジスタ x86_64 R14 arm64 R28

Slide 25

Slide 25 text

難所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 が縮小)

Slide 26

Slide 26 text

難所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] の同じ表が ある

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

難 所 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 を超えて も値は返る

Slide 30

Slide 30 text

難 所 3 ・ 構 造 体 オフ セ ッ ト が バ ー ジ ョ ン に よ って ず れる DEEP DIVE -s -w の現実 実測: 5,444,719 → 3,748,002バイト。DWARFセクション8個 → 0個。ただし .gopclntab は残 る。オフセット解決は全面的に表頼みになる

Slide 31

Slide 31 text

難所4・GOROUTINEをまたいでコンテキストを運べない プロセスをまたぐトレース接続 分散トレースは同じトレースIDで処理をつなぐ。運ぶ のが traceparent ヘッダ SDKならアプリが書く1行を、OBIが外から代行する 失敗すると1本のトレースが2本に千切れて見える

Slide 32

Slide 32 text

難所4・GOROUTINEをまたいでコンテキストを運べない newproc1フックによる親子記録 ctx は追えないので、代わりに goroutineの生成関係を手がかりにする runtime.newproc1 の入口で親を控え、出口で子と組にして記録する

Slide 33

Slide 33 text

難所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が振 られる

Slide 34

Slide 34 text

難所4・GOROUTINEをまたいでコンテキストを運べない traceparentヘッダの書き込み方法 http.Header は map[string][]string で、外からmapには書き こめない 狙うのは直列化された後の bufio.Writer のバッファ writeSubset の戻り値の末尾に1行を書き足し、使用済み長 n を 進める 書き込みに使う bpf_probe_write_user は kernel lockdown 環 境では使えない

Slide 35

Slide 35 text

難所4・GOROUTINEをまたいでコンテキストを運べない DEEP DIVE 2つの書き込み経路 経路1: bpf_probe_write_user でアプリのメモリに直接書 く 暗号化前の平文に書くのでHTTPSでも動く。lockdown 環境では無効化 経路2: sk_msg でソケットへ出ていくバイト列自体を伸ば す アプリのメモリには触らないが、TLSの暗号文には注入 できない 二重注入を避けるため、経路1が動いたときは経路2を飛ば す排他制御が入っている

Slide 36

Slide 36 text

難所4・GOROUTINEをまたいでコンテキストを運べない DEEP DIVE 出ていくバイト列への差し込み位置 HTTP/1.1リクエストは「ヘッダ行の並び+ 空行」のただのバイト列 空行の前に Traceparent: の1行分を差し 込むだけ 受け取ったサービスには、SDKが書いたヘ ッダと区別がつかない

Slide 37

Slide 37 text

Part 3: 観測される側の進化 eBPFの外からの計装に、Goのエコシステムはどう向き合ってきたか

Slide 38

Slide 38 text

GO本体の対応 eBPF支援は入っていない ランタイムの内部構造は公開APIではないという姿勢で一貫している uretprobe対応の issue #22008 は2017年から「Unplanned」のまま goroutine起動フックの提案 #73798 は2025年に「not planned」でクローズ

Slide 39

Slide 39 text

GO本体の対応 内からの観測手段は増えている flight recording: Go 1.25の runtime/trace.FlightRecorder 。直近をリングバッファに保持し、 必要になった時点で「直前」を取り出す goroutineリークプロファイル: Go 1.26で実験導入、1.27で標準機能に。GCの到達可能性情報で、復 帰できないgoroutineを判定する

Slide 40

Slide 40 text

別のアプローチ 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 とは対照的に、ビルド時に中 へ埋め込む

Slide 41

Slide 41 text

まとめ 4つの難所の総括 どの難所も「Goを速く書きやすくする設計」の裏返し 難所 問題になるGoの性質 OBIの対処 1. uretprobe 可動スタック 全RETへの通常uprobe 2. レジスタABI ABIInternal レジスタ対応表 + g のアドレス 3. オフセット 非公開の内部構造 DWARF + offsets.json 4. 文脈の分断 goroutine ≠ スレッド newproc1の親子記録 + ヘッダ注入

Slide 42

Slide 42 text

まとめ Go開発者への示唆 OBIとSDK計装は排他ではない。広い範囲をOBIで、重要な処理にSDKを追加できる 計装の確実性を上げるなら、ビルド時の -s と -w の両方を見直す 依存ライブラリが新しすぎるときは offsets.json の追従漏れを疑う 外からの観測と内からの観測は競合ではなく補完

Slide 43

Slide 43 text

まとめ 今日話したことの完全版 基礎から実測まで含めた解説bookをZennで公開中 今日の図とコードはすべてそこからの抜粋 zenn.dev/ymotongpoo/books/go-ebpf-primer

Slide 44

Slide 44 text

Thank you ご質問は Ask the Speaker へ · @ymotongpoo