作ってわかるOpenTelemetryのゼロコード計装 Go言語eBPF編

OpenTelemetryではeBPFという技術を用いることで、Go言語でできたアプリケーションのコードを変更せずに観測可能にすることができます。本記事では、その手法と仕組みについて実際にコードを書きながら解説します。

こんにちは。Mackerelチームでアルバイトをしているエンジニアの id:appare45 です。本記事ではOpenTelemetryのゼロコード計装を、Go言語でできたアプリケーションに対してeBPFを用いて実現する方法について、実際に実装しながら解説していきます。

ゼロコード計装とは

オブザーバビリティにおけるゼロコード計装とは、アプリケーションのコードを変更せずに、トレースやメトリックのようなシグナルを生成する技術です。コードを変更したりコンパイルし直したりする必要がないため、手間が少なく手軽にオブザーバビリティを始められることが特徴です。

例えば、ウェブアプリケーションに対してトレースを取得する場合、通常は次のようなコードをアプリケーションに追加する必要があります。

// 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

関数の実行開始と終了を記録する

まず関数の開始と終了を、次のような流れでプログラムの外から観測できるようにします。

  1. 関数の開始と終了時にeBPFプログラムが呼ばれるようにする
  2. eBPFプログラムが呼ばれたら現在時刻をBPF Mapに保存する
  3. 監視アプリケーションでは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を使ってバイナリを解析します。

手順としては次のとおりです。

  1. ELFバイナリのシンボルテーブルから関数の仮想アドレスとサイズを取得する
  2. プログラムヘッダから関数が属するセグメントを探し出し、ファイルオフセットを特定する(詳しくは次の図を参照)

まず、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で取得してトレースに含めていきます。

  1. ServeHTTP関数の引数をレジスタから取得する
  2. Request構造体の中身からルートパスとHTTPメソッドを取得する
  3. Go言語のstring型の値をeBPFから取得する
  4. 関数の引数からステータスコードを取得するためにResponseWriterインターフェースのポインタを保存する
  5. 関数の終了時に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.methodhttp.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インターフェースのポインタを関数の開始時に保存し、関数の終了時に保存していたResponseWriterStatusCodeからレスポンスのステータスコードを取得します。

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」拡張機能を使って開発できます。