
OpenTelemetryではeBPFという技術を用いることで、Go言語でできたアプリケーションのコードを変更せずに観測可能にすることができます。本記事では、その手法と仕組みについて実際にコードを書きながら解説します。
こんにちは。Mackerelチームでアルバイトをしているエンジニアの
id:appare45 です。本記事ではOpenTelemetryのゼロコード計装を、Go言語でできたアプリケーションに対してeBPFを用いて実現する方法について、実際に実装しながら解説していきます。
- ゼロコード計装とは
- eBPFとは
- Go言語でeBPFプログラムを書く
- 関数の実行開始と終了を記録する
- uretprobeを使わずに関数の終了を知る
- Goroutineを追う
- Mackerelへトレースを投稿する
- 引数の値を取得する
- eBPFを用いたGo言語のゼロコード計装のまとめ
- Appendix: 環境構築について
ゼロコード計装とは
オブザーバビリティにおけるゼロコード計装とは、アプリケーションのコードを変更せずに、トレースやメトリックのようなシグナルを生成する技術です。コードを変更したりコンパイルし直したりする必要がないため、手間が少なく手軽にオブザーバビリティを始められることが特徴です。
例えば、ウェブアプリケーションに対してトレースを取得する場合、通常は次のようなコードをアプリケーションに追加する必要があります。
// https://mackerel.io/ja/docs/entry/tracing/installations/go より func initTracerProvider(ctx context.Context) (func(context.Context) error, error) { client := otlptracehttp.NewClient( otlptracehttp.WithEndpoint("otlp-vaxila.mackerelio.com"), otlptracehttp.WithHeaders(map[string]string{ "Accept": "*/*", "Mackerel-Api-Key": os.Getenv("MACKEREL_API_KEY"), }), otlptracehttp.WithCompression(otlptracehttp.GzipCompression), ) exporter, err := otlptrace.New(ctx, client) if err != nil { return nil, err } tp := trace.NewTracerProvider( trace.WithBatcher(exporter), ) otel.SetTracerProvider(tp) otel.SetTextMapPropagator(propagation.TraceContext{}) return tp.Shutdown, nil } func main() { ctx := context.Background() shutdown, err := initTracerProvider(ctx) if err != nil { panic(err) } defer shutdown(ctx) otelHandler := otelhttp.NewHandler( // ここでHTTPハンドラをラップして計装する http.HandlerFunc(awesomeActionHandler), "awesome_span_name", ) http.Handle("/awesome_path/", otelHandler) http.ListenAndServe(":80", nil) } ...
一方ゼロコード計装を使うと、次のようなコマンドを実行するだけで、アプリケーションのコードを変更せずにトレースを取得できるようになります。
# https://github.com/open-telemetry/opentelemetry-go-instrumentation/blob/main/docs/getting-started.md#steps $ sudo OTEL_GO_AUTO_TARGET_EXE=/home/bin/service_executable OTEL_SERVICE_NAME=my_service OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 ./otel-go-instrumentation
本記事ではどのようにしてゼロコード計装が実現されているのか、eBPFを用いた仕組みについて解説します。
eBPFとは
eBPFとはLinuxカーネルに組み込まれている、カーネルの挙動を動的に変更できる仕組みです。eBPFを用いることで、カーネルのコードを直接書き換えることなく、簡単かつ安全にOSの挙動を変更できます。
ユーザーがeBPFプログラムを、関数の呼び出しやネットワークパケットの到達などのイベントに登録(アタッチ)すると、イベントが発生したときに自動でeBPFプログラムが呼ばれるようになります。

OpenTelemetryのゼロコード計装ではGo言語の標準ライブラリに含まれるnet/httpや、database/sqlの関数呼び出しにeBPFプログラムをアタッチし、実行時間や引数をトレースとして送信しています。
Go言語でeBPFプログラムを書く
ここからは、実際にGo言語でできたアプリケーションに対して、eBPFを用いてゼロコード計装を実現する方法について解説します。ここからのコードは下記のリポジトリにまとめています。
https://github.com/appare45/go-auto-instrument-ebpf/tree/64b75cbea37932d9ea72d8ab1cf23d042be04dd0
本記事の執筆時点(2025年11月)でeBPFプログラムにコンパイルできる言語は、私の知る限りC言語とRust言語のみです。本記事ではOpenTelemetryの公式実装と同じく計装アプリケーションの部分をGo言語で、eBPFのコードはC言語で書くことにします。
環境構築の方法などについては「Appendix: 環境構築について」を参照してください。本記事中のコードはArm環境でのUbuntuで動作確認を行っています。
C言語で書いたeBPFのコードは、bpf2goというツールを使うと、Go言語向けのバインディング生成とeBPFコードのコンパイルができます。

今回はeBPFプログラムをtracer.cに書くことにします。go generateを使って、tracer.cからeBPFプログラムへのコンパイルとバインディングを生成できるようにします。
package main //go:generate go tool bpf2go -target=$GOARCH -tags linux tracer tracer.c -- -I/usr/include/aarch64-linux-gnu
関数の実行開始と終了を記録する
まず関数の開始と終了を、次のような流れでプログラムの外から観測できるようにします。
- 関数の開始と終了時にeBPFプログラムが呼ばれるようにする
- eBPFプログラムが呼ばれたら現在時刻をBPF Mapに保存する
- 監視アプリケーションではBPF Mapを監視し、イベントが追加されたらターミナルに表示する

BPF Mapは、eBPFからもアプリケーションプログラムからもアクセスすることができる領域です。BPF Mapには循環リストやハッシュマップなどさまざまなデータ構造が用意されています。今回はシンプルなリストBPF_MAP_TYPE_PERF_EVENT_ARRAYを利用します。
tracer.cに次のコードを書いて、実際に試してみましょう。
#include <asm/ptrace.h> #include <linux/bpf.h> #include <bpf/bpf_helpers.h> #include <bpf/bpf_tracing.h> struct event { __u64 pid; __u64 tid; __u64 timestamp; }; struct { __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); // BPF Mapの定義 __type(value, struct event); } events SEC(".maps"); // 関数の開始時に呼ばれるeBPFプログラム SEC("uprobe/start_event") int uprobe_start_event(struct pt_regs *ctx) { struct event event; __u64 id = bpf_get_current_pid_tgid(); event.pid = id >> 32; // 現在のPID(プロセスID)をevent.pidに保存 event.tid = (__u32)id; // 現在のTID(スレッドID)をevent.tidに保存 event.start_time = bpf_ktime_get_ns(); // 現在時刻をナノ秒で取得しevent.start_timeに保存 bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event)); // BPF Mapにeventを書き込む return 0; } // 関数の終了時に呼ばれるeBPFプログラム SEC("uretprobe/end_event") int uretprobe_end_event(struct pt_regs *ctx) { struct event event; __u64 id = bpf_get_current_pid_tgid(); event.pid = id >> 32; // 現在のPID(プロセスID)をevent.pidに保存 event.tid = (__u32)id; // 現在のTID(スレッドID)をevent.tidに保存 event.end_time = bpf_ktime_get_ns(); // 現在時刻をナノ秒で取得しevent.end_timeに保存 bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event)); // BPF Mapにeventを書き込む return 0; } char LICENSE[] SEC("license") = "GPL"; // eBPFではGPLを含むライセンスを指定する必要がある
Go言語側(計装アプリケーション)では、作成したBPF Mapを監視して、イベントが追加されたらターミナルに出力します。
// https://github.com/cilium/ebpf/blob/main/examples/uretprobe/main.go をもとに作成しています func main() { if len(os.Args) < 3 { log.Fatalf("usage: %s <binary_path> <symbol_name>", os.Args[0]) return } binPath := os.Args[1] if _, err := os.Stat(binPath); os.IsNotExist(err) { log.Fatalf("binary path does not exist: %s", binPath) return } symbol := os.Args[2] if symbol == "" { log.Fatalf("symbol name is required") return } if err := rlimit.RemoveMemlock(); err != nil { log.Fatal("Removing memlock:", err) } objs := tracerObjects{} // eBPFから生成されたバインディングの構造体 if err := loadTracerObjects(&objs, nil); err != nil { // eBPFプログラムをカーネルにロード log.Fatalf("loading objects: %s", err) } defer objs.Close() ex, err := link.OpenExecutable(binPath) // 監視対象のバイナリを読み込む if err != nil { log.Fatalf("opening executable: %s", err) } up, err := ex.Uprobe(symbol, objs.UprobeStartEvent, nil) // uprobeイベントにeBPFプログラムをアタッチ if err != nil { log.Fatalf("creating uprobe: %s", err) } defer up.Close() uretp, err := ex.Uretprobe(symbol, objs.UretprobeEndEvent, nil) // uretprobeイベントにeBPFプログラムをアタッチ if err != nil { log.Fatalf("creating uretprobe: %s", err) } defer uretp.Close() rd, err := perf.NewReader(objs.Events, os.Getpagesize()) // BPF Mapのリーダーを作成 if err != nil { log.Fatalf("creating perf event reader: %s", err) } defer rd.Close() stopper := make(chan os.Signal, 1) signal.Notify(stopper, os.Interrupt, syscall.SIGTERM) go func() { <-stopper log.Println("Received signal, exiting program..") if err := rd.Close(); err != nil { log.Fatalf("closing perf event reader: %s", err) } }() log.Printf("Listening for events..") for { record, err := rd.Read() if err != nil { if errors.Is(err, perf.ErrClosed) { return } log.Printf("reading from perf event reader: %s", err) continue } if record.LostSamples != 0 { log.Printf("perf event ring buffer full, dropped %d samples", record.LostSamples) continue } var event tracerEvent if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil { // BPF Mapからeventを読み込む log.Printf("parsing perf event: %s", err) continue } log.Printf("pid %d, tid %d, timestamp %d", event.Pid, event.Tid, event.Timestamp) } }
このプログラムを使って実際に関数の実行時間を計測してみましょう。計測対象のプログラムをこちらに用意しました。
go generate && go build && sudo ./auto-instrument-go-ebpf ./example/hello-world main.hello のように実行するとプログラムがビルドされて、実際に関数の開始と終了のイベントがターミナルに出力されることを確認できます。Go言語ではバイナリに含まれる関数のシンボル名は、<パッケージ名>.<関数名> という形式になることに注意してください。
$ sudo ./otel-go-auto ./examples/hello-world/hello-world main.hello 2025/11/11 16:42:01 PID: 3644, Duration: 122207 ns
uretprobeを使わずに関数の終了を知る
先ほどのコードではuretprobeを使って関数の終了時にeBPFプログラムを実行しました。しかしGo言語でuretprobeを使うと、アプリケーションがたびたびクラッシュします。これは、uretprobeが関数のリターンアドレスを上書きしてしまい、Go言語のランタイムによってスタックが変更されると、正しくリターンアドレスに戻れなくなるためです *1 。
そこで本節ではuretprobeを使わずに関数の終了を知る方法について解説します。uretprobeを使わずに関数の終了を知るためには、バイナリを解析して関数の終了位置を静的に特定し、uprobeを使って関数の終了位置にeBPFプログラムをアタッチします。
前節のコードでは、uprobeの設定にバイナリのパスとシンボル名をfunc(*Executable) Uprobeに渡していました。Uprobeの内部では渡されたシンボルの、位置(ファイルオフセット)を探してトレースポイントを設定しています *2 。つまり、uprobeは関数の開始位置に限らず、プログラムの任意の位置にeBPFプログラムをアタッチできます。この特性を使い、関数の終了位置を事前にバイナリを解析して特定し、uprobeを使ってeBPFプログラムが実行されるようにします。
関数の終了位置を特定する前に、関数の開始位置を調べておきましょう。Go言語の標準ライブラリdebug/elfを使ってバイナリを解析します。
手順としては次のとおりです。
- ELFバイナリのシンボルテーブルから関数の仮想アドレスとサイズを取得する
- プログラムヘッダから関数が属するセグメントを探し出し、ファイルオフセットを特定する(詳しくは次の図を参照)

まず、ELFバイナリのシンボルテーブルから計装したい関数の仮想アドレスとサイズを取得します。
symbol := "main.hello" var va int var size int for _, sym := range symbols { if elf.ST_TYPE(sym.Info) != elf.STT_FUNC { // 関数シンボル以外はスキップ continue } if (sym.Name == symbol) { // シンボルの仮想アドレスとサイズを取得 va = sym.Value size = sym.Size break } }
次に先ほど調べた仮想アドレスをもとに、プログラムヘッダから関数が属するセグメントを探し出します。見つかったセグメントのオフセットにシンボルのオフセット(仮想アドレスの差から計算)を加えることで、ファイルオフセットを特定します。
var offset int for _, ph := range elfFile.Progs { if ph.Type != elf.PT_LOAD || (ph.Flags&elf.PF_X) == 0 { // 実行可能セグメント以外はスキップ continue } if (ph.Vaddr <= uint64(va) && uint64(va) < ph.Vaddr + ph.Memsz) { // 関数が属するセグメントを特定 offset = int(ph.Off + (uint64(va) - ph.Vaddr)) // ファイルオフセットを計算 break } }
ここまでで関数の開始位置を特定することができました。次に関数の終了位置を特定します。関数の終了位置は、関数のバイナリをディスアセンブルし、RET命令の位置を探すことで特定します。
まず、関数に対応するバイナリを探し出します。プログラムのバイナリはELFファイルの.textセクションに含まれているので、.textセクションの仮想アドレスと関数の仮想アドレスとサイズから、関数に対応するバイナリを抽出します。
buf := make([]byte, size) readbytes, err := e.TextSection.ReadAt(buf, int64(va-e.TextSection.Addr)) readBuf := buf[:readbytes]
抽出したバイナリをディスアセンブルし、RET命令の位置を列挙します。
retOffsets := make([]uint64, 0) const arm64RetInstructionSize = 4 for i := 0; i < len(readBuf); i += arm64RetInstructionSize { instruction, err := arm64asm.Decode(readBuf[i:]) if err != nil { continue } if instruction.Op == arm64asm.RET { retOffsets = append(retOffsets, offset+uint64(i)) } }
これで関数の終了位置をすべて特定できました。これらの値をもとにuprobeを使って関数の開始・終了にアタッチします。
up, err := ex.Uprobe("", start, &link.UprobeOptions{Address: startAddr}) // 関数開始位置にアタッチ if err != nil { return nil, err } for _, retOffset := range endAddrs { uretp, err := ex.Uprobe("", end, &link.UprobeOptions{Address: retOffset}) // すべての関数終了位置にアタッチ if err != nil { return nil, err } }
これで、uretprobeを使わずに関数の終了にeBPFプログラムをアタッチできるようになりました。
Goroutineを追う
Go言語の非同期処理では、Goroutineという言語独自の仕組みが使われます。Goroutineでは非同期で実行したい処理が言語のランタイムによりOSのスレッドにスケジューリングされます*3。そのため、スレッドIDを使ってeBPFから関数を呼び出したGoroutineを特定できません。関数を呼び出したGoroutineがわからないと、関数の実行開始と実行終了を紐づけることができないため、正しいトレースを作ることができません。

実際に関数の開始時と終了時にスレッドIDを出力するコードを用意しました。実行してみるとTID1とTID2に表示される開始時・終了時のスレッドのIDが、同じ関数でも異なることがわかります。
2009/11/10 23:00:00 Goroutine 0 - PID: 10, TID1: 12, TID2: 12 2009/11/10 23:00:00 Goroutine 1 - PID: 10, TID1: 14, TID2: 10 2009/11/10 23:00:00 Goroutine 2 - PID: 10, TID1: 10, TID2: 14
そこで、OpenTelemetryのゼロコード計装ではレジスタを使って、呼び出したGoroutineを特定します。Goroutine構造体(g)のアドレスはGo言語のABIに基づき、Arm64アーキテクチャの場合はR28レジスタに置かれます *4。
R28レジスタはArm64特有のレジスタなので、ctxを__PT_REGS_CASTを用いて変換し取得します。次のようなマクロ *5で、GoroutineIDをeBPFから取得できます。
#define goroutine_id(ctx) (__PT_REGS_CAST(ctx)->regs[28])
BPF Mapのハッシュマップを用いて、GoroutineIDをキーとしてイベントを保存することで、関数の呼び出しと終了を紐づけられるようになります。監視アプリケーションでは終了済みの関数だけを参照できるよう、関数が終了したら配列にeventを追加するようにします。
#define goroutine_id(ctx) (__PT_REGS_CAST(ctx)->regs[28]) // GoroutineIDを取得するマクロ struct event { __u64 start_time; __u64 end_time; }; struct { __uint(type, BPF_MAP_TYPE_HASH); // ハッシュマップを定義 __type(key, __u64); __type(value, struct event); __uint(max_entries, 1024); } traces SEC(".maps"); struct { __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); // 関数が終了したときにイベントを送信するための配列を定義 } events SEC(".maps"); SEC("uprobe/start_trace") int uprobe_start_trace(struct pt_regs *ctx) { struct event event = {0}; __u64 key = goroutine_id(ctx); // GoroutineIDをキーにする event.start_time = bpf_ktime_get_ns(); if (bpf_map_update_elem(&traces, &key, &event, BPF_ANY) < 0) { // BPF MapにGoroutineIDをキーにしてeventを保存 bpf_printk("bpf_map_update_elem failed\n"); } return 0; } SEC("uprobe/end_trace") int uprobe_end_trace(struct pt_regs *ctx) { struct event *event; __u64 key = goroutine_id(ctx); event = bpf_map_lookup_elem(&traces, &key); // BPF_MAPからGoroutineIDをキーにしてeventを取得 if (event == NULL) { return 0; } event->end_time = bpf_ktime_get_ns(); // 終了時刻を保存 bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, event, sizeof(*event)); return 0; }
これで、Goroutine内の実行でも関数の開始と終了を正しく観測できるようになりました。
Mackerelへトレースを投稿する
OpenTelemetryではトレースの開始時刻と終了時刻はUNIX時間で定義されます*6 。これに対し、eBPFプログラムで時刻を保存するために呼んでいるbpf_ktime_get_ns()はシステムの起動からの経過時間CLOCK_MONOTONICを返すため*7、そのまま使うことはできず、UNIX時間に変換する必要があります。本節では計装アプリケーションでUNIX時間に変換する方法について解説します。
Go言語ではeBPFで取得している値をunix.ClockGettime(unix.CLOCK_MONOTONIC, &now)で取得できるので、これを使って変換します。
func GetRealTimestamp(timeNanosec int64) (time.Time, error) { var now unix.Timespec if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil { return time.Time{}, err } offset := time.Nanosecond * time.Duration(now.Nano()-timeNanosec) return time.Now().Add(-1 * offset), nil }
ここまででMackerelにトレースを投稿する準備が整いました。実際にトレースを投稿して確認してみましょう。Go言語でOpenTelemetryを使って、Mackerelにトレースを投稿する方法はヘルプドキュメントで解説しています。
この解説に従ってTraceProviderを作成したら、次のようなコードでトレースを作成します。トレースの開始時刻と終了時刻は、先ほど変換したUNIX時間で引数に渡して設定します。
_, span := tracer.Start(context.TODO(), "function call",
trace.WithTimestamp(starttime),
)
span.End(trace.WithTimestamp(endTime))

実際にトレースがMackerelに投稿されていることを確認できました。
引数の値を取得する
前節までで関数の実行時間をMackerelに投稿できるようになりました。本節では、MackerelのAPMを活用するために、eBPFで呼び出された関数の引数の値をレジスタやメモリから取得してトレースに含める方法について解説します。
今回はHTTPサーバーの分析機能を活用できるようにしましょう。ヘルプドキュメントに記載がありますが、MackerelのAPMでHTTPサーバーを分析するためには、次の属性を持ったトレースを送信する必要があります。
http.route: ルートパスhttp.method: HTTPメソッドhttp.status_code: HTTPステータスコード
HTTPサーバーへの計装には、OpenTelemetryの公式実装にならって、net/httpパッケージのnet/http.serverHandler.ServeHTTP関数の引数や実行時間をトレースとして送信することにします *8 。
ここからは、次の手順で必要な属性をeBPFで取得してトレースに含めていきます。
ServeHTTP関数の引数をレジスタから取得するRequest構造体の中身からルートパスとHTTPメソッドを取得する- Go言語の
string型の値をeBPFから取得する - 関数の引数からステータスコードを取得するために
ResponseWriterインターフェースのポインタを保存する - 関数の終了時に
ResponseWriterからステータスコードを取得する
ServeHTTP関数は次のようなシグネチャを持っています。
func (h *serverHandler) ServeHTTP(w ResponseWriter, r *Request)
リクエストのルートなどはServeHttpの引数r *Requestから取得できます。Go言語での引数は言語のABIに従ってレジスタに配置されます。Arm64アーキテクチャの場合はR0から順に引数が配置されます。また、インターフェース型は2つのレジスタに分けて配置されるため、w ResponseWriter引数もR2とR2レジスタに分けて配置されます。

構造体の中身を取得するには、レジスタに置かれたポインタと、知りたいフィールドまでのオフセットを足し合わせてアクセスします。構造体の各フィールドへのオフセットは標準ライブラリのreflectパッケージで調べられます。フィールドのオフセットは言語のバージョンにより変化する可能性があるので、OpenTelemetryの公式実装では構造体の各フィールドのオフセットをJSONファイルに保存し、ビルド時に読み込んでいます。今回は、必要なオフセットを次のように手動で調べてコードに埋め込むことにします。
The layout of net/http.Request (exported fields only):
Method offset: 0x0, type: string size: 16
URL offset: 0x10, type: *url.URL size: 8
Proto offset: 0x18, type: string size: 16
ProtoMajor offset: 0x28, type: int size: 8
ProtoMinor offset: 0x30, type: int size: 8
Header offset: 0x38, type: http.Header size: 8
Body offset: 0x40, type: io.ReadCloser size: 16
GetBody offset: 0x50, type: func() (io.ReadCloser, error) size: 8
ContentLength offset: 0x58, type: int64 size: 8
TransferEncoding offset: 0x60, type: []string size: 24
Close offset: 0x78, type: bool size: 1
Host offset: 0x80, type: string size: 16
Form offset: 0x90, type: url.Values size: 8
PostForm offset: 0x98, type: url.Values size: 8
MultipartForm offset: 0xa0, type: *multipart.Form size: 8
Trailer offset: 0xa8, type: http.Header size: 8
RemoteAddr offset: 0xb0, type: string size: 16
RequestURI offset: 0xc0, type: string size: 16
TLS offset: 0xd0, type: *tls.ConnectionState size: 8
Cancel offset: 0xd8, type: <-chan struct {} size: 8
Response offset: 0xe0, type: *http.Response size: 8
Pattern offset: 0xe8, type: string size: 16
ctx offset: 0xf8, type: context.Context size: 16
pat offset: 0x108, type: *http.pattern size: 8
matches offset: 0x110, type: []string size: 24
otherValues offset: 0x128, type: map[string]string size: 8
また、リクエストのルートのようにGo言語のstring型で表される値は、取り扱いに注意が必要です。Go言語のstring型は文字列長と先頭へのポインタの構造体として表されます *9 。そのため、stringの中身をeBPFから取得するには、2段階のポインタ参照が必要になります。これを実現するマクロをCOPY_GO_STRとして定義します。
// Go言語のstring型の構造体 typedef struct go_str { char *str; unsigned int len; } go_str_t; // Go言語のstring型をeBPFからコピーするマクロ #define COPY_GO_STR(dst, go_str_var, src_ptr) \ do { \ struct go_str go_str_var = {0}; \ bpf_probe_read(&go_str_var, sizeof(go_str_var), src_ptr); \ (dst)[0] = '\0'; \ int _len = (go_str_var.len > sizeof(dst) - 1) \ // バッファサイズを超える場合は切り詰める ? (sizeof(dst) - 1) \ : go_str_var.len; \ bpf_probe_read_user(&(dst), _len, go_str_var.str); \ // 長さ分だけコピーする } while (0)
ここまでの説明を踏まえると、http.methodとhttp.routeをeBPFで取得できるようになります。
// net/http.Requestのオフセット const int net_http_Request_URL_offset = 0x10; // net/url.URLのオフセット const int net_url_URL_Path_offset = 0x38; // net/http.Request.Methodのオフセット const int net_http_Request_Method_offset = 0x0; SEC("uprobe/start_trace") int uprobe_start_trace(struct pt_regs *ctx) { struct event event = {0}; // GoroutineIDの取得など省略 void *req = (void *)PT_REGS_PARM4(ctx); // Requestを取得 // url構造体を取得 void *url = NULL; bpf_probe_read(&url, sizeof(url), req + net_http_Request_URL_offset); COPY_GO_STR(event.path, path, url + net_url_URL_Path_offset); // URL.Pathを取得 COPY_GO_STR(event.method, method, req + net_http_Request_Method_offset); // Request.Methodを取得 return 0; }
最後に、レスポンスのステータスコードを取得します。ServeHttpの第一引数、ResponseWriterインターフェースのポインタを関数の開始時に保存し、関数の終了時に保存していたResponseWriterのStatusCodeからレスポンスのステータスコードを取得します。
ResponseWriterはインターフェース型ですが、実体はhttp.response型です*10。ステータスコードはこのhttp.response型から取得できます。関数終了時には開始時からレジスタの中身が変化しているので、関数開始時にResponseWriterへのポインタをBPF Mapに保存し、関数終了時にBPF Mapから取得する必要があります。
// net/http.ResponseのStatusCodeフィールドのオフセット const int net_http_Response_StatusCode_offset = 120; SEC("uprobe/start_trace") int uprobe_start_trace(struct pt_regs *ctx) { // URLやMethodの取得など省略 event.resp_ptr = (__u64)PT_REGS_PARM3(ctx); // ResponseWriterへのポインタを保存 // 省略 } SEC("uprobe/end_trace") int uprobe_end_trace(struct pt_regs *ctx) { // 省略 void *resp = (void *)event->resp_ptr; // 保存しておいたResponseWriterを取得 bpf_probe_read(&event->status_code, sizeof(event->status_code), resp + net_http_Response_StatusCode_offset); // Response.StatusCodeを取得 // 省略 return 0; }
これで、HTTPサーバーの分析に必要な属性をeBPFから取得できるようになりました。これらの情報をトレースの属性としてMackerelに送信することで、APMを使ってHTTPサーバーを分析できるようになります。
_, span := tracer.Start(context.TODO(), fmt.Sprintf("%s: %s", methodStr, pathStr), trace.WithTimestamp(starttime), trace.WithAttributes( semconv.HTTPRoute(pathStr), semconv.HTTPResponseStatusCode(int(event.StatusCode)), attribute.String(string(semconv.HTTPRequestMethodKey), methodStr), ), ) span.End(trace.WithTimestamp(endTime))

eBPFを用いたGo言語のゼロコード計装のまとめ
本記事では、eBPFを用いたGo言語のゼロコード計装について解説しました。アプリケーションのコードを変更せずに、OpenTelemetryのトレースを送信できるようになりました。
eBPFによるゼロコード計装には、次のような仕組みが使われていることがおわかりいただけたかと思います。
- eBPFプログラムをGo言語から操作する
- バイナリ解析で関数の終了位置を特定する
- Goroutineの追跡のためにレジスタを使う
- Go言語の構造体や型の中身をeBPFから取得する
- 開始時刻と終了時刻を指定してOpenTelemetryのトレースを投稿する
最近ではOpenTelemetryによりeBPFを用いて任意の言語に計装するOBIというプロジェクトがリリースされるなど、ますますeBPFを用いたオブザーバビリティへの注目度が高まっています。eBPFを用いたゼロコード計装は奥が深い分野ですが、本記事が理解の一助となれば幸いです。
Appendix: 環境構築について
本記事ではlimaを用いて仮想環境をmacOS上で用意しています。次のファイルを用意して、limactl createコマンドを実行すると、仮想環境を作成できます。bpf2goを用いてeBPFへコンパイルするには、clangやlibbpfなどのライブラリがインストールされている必要があるのでご注意ください。
base: - template://_images/ubuntu provision: - mode: system script: | #!/bin/bash apt update apt install -y make clang libbpf-dev llvm - mode: system script: | #!/bin/bash GOARCH=$(dpkg --print-architecture) GOVERSION=1.25.3 curl -L https://go.dev/dl/go$GOVERSION.linux-$GOARCH.tar.gz | tar -C /usr/local -xzf - - mode: user script: | #!/bin/bash echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> $HOME/.bashrc echo 'export GOPATH=$HOME/go' >> $HOME/.bashrc source $HOME/.bashrc
limaを用いて作成したVMには簡単にSSHで接続できるので、Visual Studio Codeの「Remote - SSH」拡張機能を使って開発できます。
*2:https://github.com/cilium/ebpf/blob/main/link/uprobe.go#L167
*4:GDBを使うと簡単に任意のタイミングでレジスタの中身を確認できます。
*5:eBPFプログラムでは内部で関数を定義できないので、再利用する処理はマクロで定義します。
*6:https://opentelemetry.io/docs/specs/otel/trace/api/#timestamp
*7:https://docs.ebpf.io/linux/helper-function/bpf_ktime_get_ns/
*8:この関数の詳細は次の記事が大変詳しいです:httpサーバー起動の裏側|Deep Dive into The Go's Web Server
*9:https://github.com/golang/go/blob/8111104a2120e14ef068b9cfbda91965473ab345/src/runtime/string.go#L290-L293
*10:https://zenn.dev/hsaki/books/golang-httpserver-internal/viewer/httphandler#http.responsewriter%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%BC%E3%83%95%E3%82%A7%E3%83%BC%E3%82%B9%E3%81%AE%E5%AE%9F%E4%BD%93%E5%9E%8B