Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
OBI Deep Dive 〜どのようにして自動計装は実現されているのか?〜
Search
Sponsored
·
Your Podcast. Everywhere. Effortlessly.
Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
→
Mitsuhiro Tanda
August 26, 2026
140
0
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
OBI Deep Dive 〜どのようにして自動計装は実現されているのか?〜
Mitsuhiro Tanda
August 26, 2026
More Decks by Mitsuhiro Tanda
See All by Mitsuhiro Tanda
OBI Deep Dive — How Is Automatic Instrumentation Actually Implemented?
mtanda
0
6
低コストなログ基盤を支えるアーキテクチャ 〜Grafana Lokiの設計思想〜
mtanda
2
4.4k
Life with Grafana
mtanda
0
950
Featured
See All Featured
Design and Strategy: How to Deal with People Who Don’t "Get" Design
morganepeng
133
19k
Code Reviewing Like a Champion
maltzj
528
40k
Marketing Yourself as an Engineer | Alaka | Gurzu
gurzu
0
290
How to Align SEO within the Product Triangle To Get Buy-In & Support - #RIMC
aleyda
2
1.8k
16th Malabo Montpellier Forum Presentation
akademiya2063
PRO
0
370
Reality Check: Gamification 10 Years Later
codingconduct
0
2.3k
Lightning Talk: Beautiful Slides for Beginners
inesmontani
PRO
2
660
Efficient Content Optimization with Google Search Console & Apps Script
katarinadahlin
PRO
1
840
Testing 201, or: Great Expectations
jmmastey
46
8.3k
The #1 spot is gone: here's how to win anyway
tamaranovitovic
3
1.2k
How to Ace a Technical Interview
jacobian
281
24k
Stewardship and Sustainability of Urban and Community Forests
pwiseman
0
510
Transcript
OBI Deep Dive どのようにして自動計装は実現されているのか? eBPF Japan Meetup #7 Staff Developer
Advocate, Grafana Labs 反田光洋
自己紹介 反田 光洋 (たんだ みつひろ) @mtanda Staff Developer Advocate Grafana
Labs ▶ 約 10 年来の Grafana / Prometheus ユーザー ▶ Grafana 初期バージョンからのコントリビューター
1. OBI の概要
1. OBI の概要 自動計装とは • アプリケーションのコードを書き換えずに、metrics や trace などのテレメトリを 収集する仕組み
• 手動計装では、OpenTelemetry SDK を使ってコードに span を埋め込む必要があ る • 言語ごとに自動計装の手法は異なる • OBI は eBPF を使って複数の言語を自動計装している
1. OBI の概要 OBI とは • OpenTelemetry eBPF Instrumentation の略1
• 対応プロトコル ‣ HTTP, MySQL, PostgreSQL, Redis, Kafka, HTTP/2, gRPC, etc • 対応言語 ‣ Go, Python, Ruby, Node.js, Java, .NET, Rust, etc • 内部の tracer は 2 種類ある ‣ gotracer — Go 専用。Go のプログラムに uprobe を直接埋め込む(今回はこち らを取り上げる) ‣ generictracer — 汎用の tracer。ネットワークプロトコルを直接解析する 1 github.com/open-telemetry/opentelemetry-ebpf-instrumentation(Apache License 2.0)
1. OBI の概要 トレーシングとは 1 つのリクエストが複数のマイクロサービスをまたいで処理される際に、内部の個別 処理を span として記録し、その span
全体を trace としてまとめて管理する仕組み。 処理全体のボトルネック特定などに使える。 同じ parent_id (s2) を持つ 2 つの span Payment Service trace_id: abc123 span_id: s3 parent_id: s2 Gateway trace_id: abc123 span_id: s1 parent_id: (無し) 呼び出し Order Service trace_id: abc123 span_id: s2 parent_id: s1 呼び出し 呼び出し Inventory Service trace_id: abc123 span_id: s4 parent_id: s2
None
2. どうやって自動計装するのか?
2. どうやって自動計装するのか? 再現すべき手動計装の処理 • 手動計装では、計装対象の処理の前後に span の開始と終了を埋め込む • span を入れ子にすることで、処理の依存関係を表現する
• Go の場合は、trace の状態を記録した context を引数として渡すことで、状態を伝 搬させている
2. どうやって自動計装するのか? 手動計装のイメージ ctx, span := tracer.Start(ctx, "GET /users") //
親span開始 ctx, child := tracer.Start(ctx, "SELECT users.name") // 同じctxから子span開始 child.SetAttributes( attribute.String("db.query.text", "SELECT name FROM users")) // ... 何か処理 ... child.End() // 子span終了 span.End() // 親span終了
2. どうやって自動計装するのか? 計装結果の出力例 span は、span 単位で出力され、span 単位で保存される。クエリ時に親子関係を参 照して、trace に再構成される。 {
"Name": "SELECT users.name", "SpanContext": { "TraceID": "3fa0f027c5eac4614599f227f8fc877e", "SpanID": "a84667204884ec1f" }, "Parent": { "TraceID": "3fa0f027c5eac4614599f227f8fc877e", "SpanID": "2246972e944f7b38" }, "Attributes": [ {"Key": "db.query.text", "Value": {"Type": "STRING", "Value": "SELECT name FROM users"}} ] }
2. どうやって自動計装するのか? 自動計装による span の記録 span の記録は、uprobe で外部から処理の開始と終了を観測することで実現してい る。 アプリケーション
HTTP SQL エクスポーター ユーザーランド カーネル uprobe ring buffer OBI (uprobe) SQL span HTTP span Tempo など
2. どうやって自動計装するのか? span を goroutine に関連づける 手動計装では context を明示的に渡すことで親子関係を伝えていたが、コードを書き 換えない
OBI はこの受け渡しができない。そこで、goroutine に現在有効な span を 記録しておくことで、親子関係を辿れるようにしている。 span A (親) span B (子) 同じ goroutine に関連づける goroutine
2. どうやって自動計装するのか? context を外部の状態に関連づける Go のランタイム側にも uprobe を設定し、外部の状態に関連づけることで、trace の 状態を辿れるようにしている。
① goroutine が増える G (親) G’ (子) runtime.newproc1 ② channel で引き継ぐ G’ (送信側) W (受信側) chansend1 / chanrecv1 ③ スレッドが変わる G (Thread M1) G (Thread M2) runtime.casgstatus
3. どうやって uprobe を埋め込 むのか?
3. どうやって uprobe を埋め込むのか? uprobe 設定対象 OBI は uprobe を設定する対象となる関数のリストを持っている。
キーが Go のシン ボル名で、呼び出し時と戻り時にそれぞれ probe を設定できるようになっている。 // gotracer.goより抜粋・一部簡略化(Start/Endは実際はeBPFプログラムへの参照) func (p *Tracer) GoProbes() map[string][]*ebpfcommon.ProbeDesc { m := map[string][]*ebpfcommon.ProbeDesc{ "runtime.newproc1": {{ Start: ..., End: ... }}, "net/http.serverHandler.ServeHTTP": {{ Start: ..., End: ... }}, "net/http.(*conn).readRequest": {{ Start: ..., End: ... }}, // ... } }
3. どうやって uprobe を埋め込むのか? uprobe を設定するアドレスをどう見つける? • Go の Program
Counter Line Table (pclntab)を利用する • pclntab は Go のバイナリに埋め込まれているメタデータ • シンボル名とアドレスの対応関係などが記録されている • stack trace の生成などに利用されている • ランタイム自身が依存しているため、比較的確実に利用できる • (DWARF には依存していない)
3. どうやって uprobe を埋め込むのか? 実際に pclntab を読んでみる debug/elf と debug/gosym
を使って、以下のようなコードで確認できる。 package main import ( "debug/elf" "debug/gosym" "fmt" "os" ) func main() { f, _ := elf.Open(os.Args[1]) pclndat, _ := f.Section(".gopclntab").Data() var symdat []byte if sec := f.Section(".gosymtab"); sec != nil { symdat, _ = sec.Data() } tab, _ := gosym.NewTable(symdat, gosym.NewLineTable(pclndat, f.Section(".text").Addr)) for _, fn := range tab.Funcs { fmt.Printf("%x %s\n", fn.Entry, fn.Name) // <アドレス> <関数名> } }
3. どうやって uprobe を埋め込むのか? 実際の pclntab の中身 uprobe 設定対象のシンボルのアドレスが取得できる。 $
go run main.go target_binary | grep -E \ 'chansend1|chanrecv1|casgstatus|newproc1|execDC|queryDC|readRequest|ServeHTTP' 224f0 runtime.chansend1 233d0 runtime.chanrecv1 5afc0 runtime.casgstatus 63160 runtime.newproc1 f0930 database/sql.(*DB).execDC f1230 database/sql.(*DB).queryDC 201d30 net/http.(*conn).readRequest 20f370 net/http.serverHandler.ServeHTTP
4. HTTP と SQL 呼び出しの span 生成
4. HTTP と SQL 呼び出しの span 生成 HTTP リクエストの span
生成 1. net/http の関数に uprobe を設定 2. リクエストに traceparent ヘッダが設定されていれば、trace ID を引き継ぐ 3. 他にも複数の経路がある(詳細は割愛) 4. どれも無ければ trace ID を新規生成する // go_nethttp.c より抜粋 SEC("uprobe/ServeHTTP") int obi_uprobe_ServeHTTP(struct pt_regs *ctx) { void *req = GO_PARAM4(ctx); // readContinuedLineSliceが先に読み取ったヘッダ情報を確認 server_http_func_invocation_t *header_inv = bpf_map_lookup_elem(&ongoing_http_server_requests, &g_key); tp_info_t *decoded_tp = 0; if (header_inv && valid_trace(header_inv->tp.trace_id)) { decoded_tp = &header_inv->tp; } // decoded_tpがあればそれを使い、無ければ他の経路にフォールバック if (req) { server_trace_parent(goroutine_addr, &invocation.tp, decoded_tp); }
4. HTTP と SQL 呼び出しの span 生成 SQL リクエストの span
生成 1. database/sql の関数に uprobe を設定 2. SQL の文字列をそのまま取得できる(generictracer ではプロトコルに合わせて パースが必要) // go_sql.c より抜粋 SEC("uprobe/queryDC") int obi_uprobe_queryDC(struct pt_regs *ctx) { void *goroutine_addr = GOROUTINE_PTR(ctx); void *driver_conn = GO_PARAM6(ctx); void *sql_param = GO_PARAM8(ctx); // SQL文字列へのポインタ void *query_len = GO_PARAM9(ctx); // その長さ set_sql_info(goroutine_addr, driver_conn, sql_param, query_len); return 0; }
5. context をどのようにして伝 搬させるか?
5. context をどのようにして伝搬させるか? go_trace_map で実際の context を記録する 直前に開始した span の情報を
goroutine のアドレスをキーとして go_trace_map に記 録しておく。 これにより、親 span を見つけることができる。 手動計装で context と して伝搬させている情報を、この go_trace_map で代替する。 // go_common.h より抜粋 struct { __type(key, go_addr_key_t); // キー: goroutineのアドレス __type(value, tp_info_t); // 値: trace_id/span_id/parent_id/... __uint(pinning, OBI_PIN_INTERNAL); // 全uprobe間で共有される1つのmap } go_trace_map SEC(".maps"); span A (親) ① 書き込み go_trace_map ② 参照して関連づけ // ServeHTTPなどから go_trace_map が更新される bpf_map_update_elem(&go_trace_map, &g_key, tp, BPF_ANY); span B (子)
5. context をどのようにして伝搬させるか? ①子の goroutine から context を辿れるようにする go_trace_map は
goroutine の id をキーとしているため、go_trace_map だけでは親の span を見つけられない。 そこで、goroutine の親子関係も記録して、context を辿れ るようにしている。 // go_runtime.c より抜粋——callとreturn、2つのuprobeで情報を橋渡しする SEC("uprobe/runtime_newproc1") int obi_uprobe_runtime_newproc1(struct pt_regs *ctx) { void *creator_goroutine_addr = GOROUTINE_PTR(ctx); // call: 呼び出し元=親 new_func_invocation_t invocation = {.parent = (u64)GO_PARAM2(ctx)}; go_addr_key_t g_key = {}; go_addr_key_from_id(&g_key, creator_goroutine_addr); bpf_map_update_elem(&newproc1, &g_key, &invocation, BPF_ANY); // returnまで退避 return 0; } SEC("uprobe/runtime_newproc1_return") int obi_uprobe_runtime_newproc1_return(struct pt_regs *ctx) { // return: 直前のcallで退避した親情報を、呼び出し元アドレスをキーに取り出す new_func_invocation_t *invocation = bpf_map_lookup_elem(&newproc1, &c_key); void *parent_goroutine = (void *)invocation->parent; void *goroutine_addr = (void *)GO_PARAM1(ctx); // newproc1の戻り値=新goroutine goroutine_metadata metadata = {.parent = p_key}; bpf_map_update_elem(&ongoing_goroutines, &g_key, &metadata, BPF_ANY); // 親子確定 bpf_map_delete_elem(&newproc1, &c_key); // 一時mapは掃除 return 0; }
5. context をどのようにして伝搬させるか? 親子関係を辿って、親の span を見つける SQL/Redis 等の span 生成時に、client_trace_parent()が親子関係を
find_parent_goroutine()で辿り、親の goroutine の go_trace_map から親の span を見 つける。 // go_common.h より抜粋 static u64 find_parent_goroutine(go_addr_key_t *current) { u64 r_addr = current->addr; go_addr_key_t *parent = current; int attempts = 0; do { // 今のgoroutineがtrace処理中か確認 tp_info_t *p_inv = bpf_map_lookup_elem(&go_trace_map, parent); if (p_inv) return r_addr; // 見つかった -> このtrace_idを引き継ぐ // 無ければ親子関係をたどってもう1段上へ goroutine_metadata *g = bpf_map_lookup_elem(&ongoing_goroutines, parent); if (!g) break; // これ以上たどれない r_addr = g->parent.addr; parent = &g->parent; } while (++attempts < 6); // 最大6階層まで遡る return 0; // 見つからなければ新規traceのrootになる }
5. context をどのようにして伝搬させるか? ②channel の場合は span link で関連づける • channel
の場合、送信側と受信側に明確な親子関係があるとは限らない • eBPF で観測しているが故の技術的な制約で、この親子関係は確定できない • runtime.chansend1 / chanrecv1 / chanrecv2 に uprobe を設定して、span link をはる ことで span 間の関係だけを記録している • 該当コードの引用は割愛
5. context をどのようにして伝搬させるか? ③実行スレッドの切り替わりを反映する goroutine が実行中に別の OS スレッドへ移った際に、内部で保持している情報から 正確な情報を再現する。 //
go_runtime.c より抜粋 SEC("uprobe/runtime.casgstatus") int obi_uprobe_runtime_casgstatus(struct pt_regs *ctx) { void *g = (void *)GO_PARAM1(ctx); // 状態が変わるgoroutine自身 // g->m->procid から今のOSスレッドを特定し、g_pid_tgidを組み立てる u64 g_pid_tgid = ((u64)pid << 32) | (procid & 0xffffffff); go_addr_key_t g_key = {.addr = (u64)g, .pid = pid}; const u32 newval = (u32)(uintptr_t)GO_PARAM3(ctx); // 遷移先の状態 switch (newval) { case g_running: case g_syscall: // http/sql/redis/...——対応するmapを順に確認 http_server_inv = bpf_map_lookup_elem(&ongoing_http_server_requests, &g_key); if (http_server_inv) { obi_ctx__set_(g_pid_tgid, &http_server_inv->tp, &obi_info); // traces_ctx_v1更新 return 0; } // ...sql/redis/...も同じ形で続く break; default: obi_ctx__del(g_pid_tgid); // それ以外はtraces_ctx_v1から削除 } return 0; }
5. context をどのようにして伝搬させるか? 外部サービスへのリクエストに trace_id/span_id を書き込む 計装対象のサービス自身が外部サービスを HTTP で呼び出す際、リクエストヘッダ の
traceparent に trace_id/span_id を書き込んで伝えないと、呼び出し先で trace が 途切れてしまう。 trace_id/span_id を把握しているのは eBPF 側だけなので、送信リ クエストに割り込み、 そこへ直接書き込むことで伝えている。 1. gotracer 専用の方法: header_writeSubset の uprobe で goroutine がトラップされ ている間に bpf_probe_write_user で bufio.Writer へ直接書き込む 2. tpinjector のフォールバック: カーネルの sk_msg フックでパケットバイト自体を 書き換える
6. OBI プロセスから OTLP で外部 へ送信するまで
6. OBI プロセスから OTLP で外部へ送信するまで ring buffer 経由での span 送信
span が確定した時点で、順次 ring buffer へ書き出す // go_sql.c より抜粋(process_sql_return内) sql_request_trace_t *trace = bpf_ringbuf_reserve(&events, sizeof(sql_request_trace_t), 0); if (trace) { trace->tp = invocation->tp; // trace_id/span_id bpf_probe_read(trace->sql, query_len, (void *)invocation->sql_param); bpf_ringbuf_submit(trace, get_flags()); // ここで書き込み確定 }
6. OBI プロセスから OTLP で外部へ送信するまで ring buffer の先で何が起きているか ring buffer
へは eBPF(カーネル側)が書き込み、そこから先は OBIプロセス (ユー ザー空間)が読み出し、外部バックエンドまで届ける。 OBI プロセス (user space) ring buffer 読み出し バッチ化 OTLP export OTLP Tempo など
7. 手動計装と組み合わせる
7. 手動計装と組み合わせる 自動計装と手動計装を併用できる • OpenTelemetry Go SDK には、eBPF の自動計装との連携を前提に設計された Auto
SDK がある • OpenTelemetry Go SDK v1.36.0 以降であれば、特に意識せずに使える • OBI 側で SDK の登録有無を見て、自動計装優先か手動計装優先か切り替わるよう になっている
7. 手動計装と組み合わせる span 開始時に、親を退避してから go_trace_map を上書き Tracer.Start の戻り時の uprobe で、find_parent_goroutine
を使って親の context を 取得し、span->prev_tp に退避してから go_trace_map を子の context で上書きする。 // go_sdk.c obi_uprobe_tracer_Start_Returns() より抜粋 tp_info_t *tp = tp_info_from_parent_go(&g_key, &span->parent_go); // find_parent_goroutine経由 if (tp) { __builtin_memcpy(&span->prev_tp, tp, sizeof(tp_info_t)); // 親のcontextを退避 tp_from_parent(&span->tp, tp); // 子のtrace_idは親から継承 urand_bytes(span->tp.span_id, SPAN_ID_SIZE_BYTES); // span_idは新規発行 if (span->parent_go) { go_addr_key_t gp_key = {}; go_addr_key_from_id(&gp_key, (void *)span->parent_go); update_tp_parent_go(&gp_key, &span->tp); // go_trace_map[親goroutine] = 子context(上書き) go_addr_key_from_id(&gp_key, span_ptr); bpf_map_update_elem(&active_spans, &gp_key, span, BPF_ANY); // spanの状態自体はここに保存 } }
7. 手動計装と組み合わせる span 終了時に退避しておいた値に戻す Span.End に相当する関数に uprobe を設定し、開始時に退避しておいた span>prev_tp で
go_trace_map を上書きし直す。 // go_sdk.c obi_uprobe_nonRecordingSpan_End() より抜粋 otel_span_t *span = bpf_map_lookup_elem(&active_spans, &s_key); if (span == NULL) { return 0; } span->end_time = bpf_ktime_get_ns(); if (span->parent_go) { go_addr_key_t gp_key = {}; go_addr_key_from_id(&gp_key, (void *)span->parent_go); update_tp_parent_go(&gp_key, &span->prev_tp); // go_trace_mapを親のcontextへ戻す(pop) } bpf_ringbuf_output(&events, span, sizeof(otel_span_t), get_flags()); // spanを出力 bpf_map_delete_elem(&active_spans, &s_key);
7. 手動計装と組み合わせる まとめ • OBI は、さまざまな手法を組み合わせて自動計装を実現している • 手動計装が実現していることの多くは、すでに再現できている • OBI
により、コードを変更せずに OpenTelemetry 導入をすぐに始められる