Go言語アプリケーションへのeBPFを用いたゼロコード計装

347 Views

January 28, 26

スライド概要

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

ダウンロード

関連スライド

各ページのテキスト
1.

eBPF Japan Meetup #5 Go 言語アプリケーションへの eBPF を用いたゼロコード計装 id: appare45

2.

eBPF Japan Meetup #5 自己紹介 • 中村天晴(id: appare45) • 筑波大学 情報科学類 3 年生 • アプリケーションのゼロコード計装に興味があります 本日の資料はこちら→TBD 2

3.

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

4.

eBPF Japan Meetup #5 実現したいこと • Go 言語のアプリケーションのパフォーマンスをソースコードを変更せずに 調べたい •「ゼロコード計装」「自動計装」と呼ばれる ‣「計装(Instrumentation)」: アプリケーションに監視用のコードを組み込 むこと 4

5.

eBPF Japan Meetup #5 作ったもの • コマンドを打つだけで指定した Web アプリケーションのパフォーマンスを OpenTelemetry のトレース形式で送信するツール • eBPF を使っている ‣ https://github.com/appare45/go-auto-instrument-ebpf • Go 言語特有の課題がいくつかあったのでご紹介する 5

6.

eBPF Japan Meetup #5 sudo -E ./go-auto-instrument-ebpf ./examples/demo/demo GET: /items a95b06d292ce2616af51073ae379f944 6d34e11faf7ebe3e http.route=/items host.name=localhost:8081 http.response.status_code=200 http.request.method=GET POST: /items 69ddb82a2c4591969efcb61e9944d001 22ea087162a66006 http.route=/items host.name=localhost:8081 http.response.status_code=201 http.request.method=POST 6

7.

eBPF Japan Meetup #5 OpenTelemetry 対応サービスでビジュアライズできる 7

8.

eBPF Japan Meetup #5 様々なゼロコード計装の手法 • 実行時にコードを差し替える(モンキーパッチ) ‣ ランタイムがある言語であれば可能 • コンパイル時にコードを差し替える • リンク時にコードを差し替える ‣ 動的リンクされていれば可能 • eBPF を用いて観測する 8

9.

eBPF Japan Meetup #5 Go 言語でのゼロコード計装 • コンパイル時にコードを差し替える ‣ Alibaba Loongsuite Go Agent ‣ Datadog Orchestrion • eBPF を用いて計装する ‣ OpenTelemetry eBPF Instrument(Grafana Beyla) ‣ OpenTelemetry Go Auto Instrument OpenTelemetry Go Auto Instrument で使われている手法を実装 9

10.

eBPF Japan Meetup #5 今回のゴール: • Go 言語で作られた Web アプリケーションに計装する ‣ 特にオプションを付けずにコンパイルしたもの • アプリケーションのバイナリに手を加えない • リクエストが来たときに、以下の情報を取得する ‣ リクエストの URL パス ‣ HTTP メソッド ‣ 処理にかかった時間(レスポンスタイム) ‣ ステータスコード 10

11.

eBPF Japan Meetup #5 環境 • MacOS で起動した Docker コンテナ内で動作 • Arm64 アーキテクチャ • eBPF で計装するアプリケーションも Go 言語で実装 ‣ eBPF プログラムは C 言語で実装 ‣ bpf2go を使用して C の eBPF プログラムから Go 言語向けのバインディ ングを生成 11

12.

eBPF Japan Meetup #5 手法 • eBPF の Uprobe を用いてアプリケーション中の関数の開始と終了で情報を 収集する • 今回は HTTP リクエストを処理する関数を観測する(後述) 12

13.

eBPF Japan Meetup #5 関数の開始と終了にフックする • eBPF をアプリケーション中の関数にフックできる • トレースポイント ‣ 関数の開始は Uprobe ‣ 関数の終了は Uretprobe • Uprobe・Uretprobe ではメモリやレジスタの状態を取得できる ‣ メモリやレジスタを書き換えることもできる 13

14.
[beta]
eBPF Japan Meetup #5

int __attribute__ ((noinline)) hello(int i) {
printf("Hello, %d!\n", i);
return i * 2;
}
int main() {
for (int i = 0; i < 5; i++) {
hello(i);
}
return 0;
}

14

15.
[beta]
eBPF Japan Meetup #5

bpftrace を使ってトレース
バイナリと関数名(シンボル名)を指定してフックする
uprobe:./main:hello {
printf("uprobe: called with %d\n", reg("r0"));
}
uretprobe:./main:hello {
printf("uretprobe: return %d\n", reg("r0"));
}

15

16.

eBPF Japan Meetup #5 bpftrace を使ってトレース $ ./main Hello, 0! Hello, 1! Hello, 2! Hello, 3! Hello, 4! $ sudo bpftrace trace.bt Attaching 2 probes... uprobe: called with 0 uretprobe: return 0 uprobe: called with 1 uretprobe: return 2 uprobe: called with 2 uretprobe: return 4 ... 16

17.

eBPF Japan Meetup #5 Go 言語特有の問題 C 言語と異なる部分がいくつかありそれぞれに対応する必要がある 1. 並行処理におけるスレッドと関数のコンテキストの不一致 2. Uretprobe を使うとプログラムがクラッシュする 3. 関数の引数の取得 17

18.

eBPF Japan Meetup #5 Go の並行処理 • 非同期処理ごとに Goroutine という軽量スレッドを作る ‣ このとき確保されるスタックが小さい ‣ スタックが不足するとランタイムが拡張する • Go 言語のランタイムが Goroutine を OS スレッドに割り当てる ‣ ランタイムはアプリケーションのバイナリ中に埋め込まれる • M:N スケジューリング ‣ 非同期処理と OS スレッドが一致しない 18

19.

eBPF Japan Meetup #5 eBPF と Goroutine • eBPF プログラムからスレッドを取得しても Goroutine を特定できない • 同じ Goroutine の途中でも異なるスレッドで実行されることがある ‣ Uprobe で取得した情報と Uretprobe で取得した情報を紐づけられない 19

20.

eBPF Japan Meetup #5 Goroutine Goroutine Goroutine Goroutine Goroutine Go言語ランタイム OS Goroutine Thread 1 Thread 2 Thread 3 Thread 4 eBPF CPU 20

21.

eBPF Japan Meetup #5 Goroutine Goroutine Goroutine Goroutine Goroutine Go言語ランタイム OS Goroutine Thread 1 Thread 2 Thread 3 Thread 4 eBPF CPU 21

22.

eBPF Japan Meetup #5 Goroutine の見分け方 • Goroutine ごとに管理用の構造体 g が作られる • CPU 実行時には R28 レジスタに g 構造体のポインタが保存されている 22

23.

eBPF Japan Meetup #5 解決策: レジスタから Goroutine を特定する • 関数呼び出しごとに g 構造体のアドレスを取得する • g のアドレスを eBPF マップのキーとして利用する ‣ 関数の開始時と終了時で同じ Goroutine の情報を取得できる • eBPF ではレジスタの値を取得できるので、R28 レジスタを取得する #define goroutine_id(ctx) (__PT_REGS_CAST(ctx)->regs[28]) 23

24.

eBPF Japan Meetup #5 Go 言語特有の問題 1. 並行処理におけるスレッドと関数のコンテキストの不一致 2. Uretprobe を使うとプログラムがクラッシュする 3. 関数の引数の取得 24

25.
[beta]
eBPF Japan Meetup #5

Uretprobe が使えない
$ ./main-go
runtime: g 8: unexpected return pc for Hello, 4!
Hello, 2!
Hello, 3!
Hello, 0!
main.hello called from 0xfffffffff000
stack: frame={sp:0x4000100f50, fp:0x4000100fb0}
stack=[0x4000100000,0x4000101000)
...
25

26.

eBPF Japan Meetup #5 0x0000004000100ff0: 0x0000000000000000 fatal error: unknown caller pc 0x0000000000000000 runtime stack: runtime.throw({0xd56f8?, 0x64c30?}) runtime.(*unwinder).next(0x4000069dc8) /usr/local/go/src/runtime/traceback.go:470 +0x2a0 fp=0x4000069d80 sp=0x4000069cf0 pc=0x6c9d0 ... runtime: g 8: unexpected return pc for main.hello called from 0xfffffffff000 26

27.

eBPF Japan Meetup #5 Uprobe のしくみ • Uprobe は対象プログラムのロード時に関数の開始位置にある命令を割り込み に置き換える ‣ 割り込み発生時に eBPF プログラムをカーネル中で実行する ‣ eBPF プログラムが終了したら、元の命令を再度実行する 27

28.

eBPF Japan Meetup #5 Uprobe 設定前と設定後の比較 - stp x29, x30, [sp, #... + brk #0x5 mov x29, sp str w0, [sp, #28] ldr w1, [sp, #28] adrp x0, 0xaaaaaaaa0000 add x0, x0, #0x830 bl 0xaaaaaaaa0650 <printf@plt> ldr lsl ldp ret w0, [sp, #28] w0, w0, #1 x29, x30, [sp],... 28

29.

eBPF Japan Meetup #5 Uretprobe のしくみ • Uretprobe は Uprobe と同様関数の開始位置の命令を書き換える • 関数の開始時割り込みを発生させてリターンアドレスを eBPF のエントリポ イントへ書き換える • 関数終了時は eBPF プログラム実行後に元のリターンアドレスに戻る 29

30.

eBPF Japan Meetup #5 Uretprobe 設定後の hello 関数 Uprobe を設定したときのバイナリは全く同じ brk #0x5 mov x29, sp str w0, [sp, #28] ldr w1, [sp, #28] adrp x0, 0xaaaaaaaa0000 add x0, x0, #0x830 bl 0xaaaaaaaa0650 <printf@plt> ldr lsl ldp ret w0, [sp, #28] w0, w0, #1 x29, x30, [sp], #32 30

31.

eBPF Japan Meetup #5 Backtrace が違う hello 関数の 2 命令目で Backtrace を取得すると Return Address が不可解 なものになっている Uretprobe なし #0 0x0000aaaaaaaa07ac in hello () #1 0x0000aaaaaaaa07ec in main () Uretprobe 設定後 #0 0x0000aaaaaaaa07ac in hello () #1 0x0000fffff7ff3000 in ?? () 31

32.

eBPF Japan Meetup #5 メモリマップからアドレスを調べると… Start Addr End Addr Size Perms File ... 0x0000fffff7ff3000 0x0000fffff7ff4000 0x1000 --xp [uprobes] ... Offset 0x0 リスト 2: メモリマップ 32

33.

eBPF Japan Meetup #5 Go ではなぜ Uretprobe が使えないのか • スタック拡張が関係している ‣ Go ではランタイムが Goroutine に割り当てたスタックを拡張する • Uretprobe はスタックのリターンアドレスを書き換える • スタック拡張時にプログラムがクラッシュする 33

34.

eBPF Japan Meetup #5 スタック拡張時になぜクラッシュするのか Go のランタイムはスタック拡張時 1. 新しいスタック領域を確保する 2. 元のスタックをコピーする 3. スタック内で参照されているアドレスを更新する • このときフレームごとに関数の呼び出し元をさかのぼる(unwind) • uretprobe がリターンアドレスを書き換えると呼び出し元の関数を特定 できずプログラムがクラッシュする 34

35.

eBPF Japan Meetup #5 解決策: Uretprobe の代わりに Uprobe を使う • Uprobe は命令を書き換えるだけなのでスタックを変更しない • Uprobe はバイナリ上の任意の箇所に設定可能 • 関数の終了位置に Uprobe を設定することで、関数終了を検知できる 35

36.

eBPF Japan Meetup #5 関数の終了位置を知るには 1. バイナリを逆アセンブルする 2. ret 命令の位置を特定する 3. ret 命令のある場所すべてに Uprobe を設定する 36

37.

eBPF Japan Meetup #5 関数の終了位置を知るには • 現時点で Go 言語のコンパイラは Tail Call Optimization を行わない • すべての関数終了位置に ret 命令が存在する https://github.com/iovisor/bcc/issues/1320#issuecomment-407927542 37

38.
[beta]
eBPF Japan Meetup #5

const arm64RetInstructionSize = 4
retOffsets := make([]uint64, 0)
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))
}
}

リスト 3: ret 命令のオフセットを特定する

38

39.
[beta]
eBPF Japan Meetup #5

for _, retOffset := range retOffsets {
_, err := ex.Uprobe("", end, &link.UprobeOptions{Address:
retOffset})
if err != nil {
return nil, err
}
}

リスト 4: すべての関数終了位置に Uprobe を設定する

39

40.

eBPF Japan Meetup #5 Go 言語特有の問題 1. 並行処理におけるスレッドと関数のコンテキストの不一致 2. Uretprobe を使うとプログラムがクラッシュする 3. 関数の引数の取得 40

41.

eBPF Japan Meetup #5 関数の引数を取得したい • リクエストの URL やレスポンスのステータスコードを取得したい • これらは関数の引数に含まれる 41

42.

eBPF Japan Meetup #5 Go 言語における関数の引数 • Go 言語では関数の引数はレジスタで渡される ‣ 構造体などはアドレスがレジスタで渡される • インターフェイス型は 2 つの値(型情報とデータポインタ)で渡される 42

43.

eBPF Japan Meetup #5 net/http.serverHandler.ServeHTTP • リクエストを受け終わってから実際のハンドラを呼び出す関数 • この関数に計装する func (sh *serverHandler) ServeHTTP(rw ResponseWriter, req *Request) 43

44.

eBPF Japan Meetup #5 func (sh *serverHandler) ServeHTTP(rw ResponseWriter, req *Request) • rw ResponseWriter: レスポンスを書き込むためのインターフェイス ‣ 実体は net/http.response 構造体 ‣ ステータスコードを取得したい • req *Request: リクエスト情報を持つ構造体 ‣ URL パスやメソッドなどを取得したい 44

45.

eBPF Japan Meetup #5 net/http.serverHandler.ServeHTTP 引数の配置は以下のようになる func (h *serverHandler) ServeHTTP(w ResponseWriter, r *Request ) レジスタ R0 R1 R2 R3 R4 R5 45

46.

eBPF Japan Meetup #5 Go 言語における構造体 • eBPF では構造体の中身はポインタからフィールドまでのオフセットを使っ て取得する • 構造体は Go 言語独自のメモリレイアウトを持つ ‣ メモリレイアウトはコンパイル時に決定される 46

47.

eBPF Japan Meetup #5 eBPF でステータスコードを取得 • 関数開始時 ResponseWriter のポインタ(3 番目のレジスタ)を BPF Map に保存 struct event event = {0}; __u64 key = goroutine_id(ctx); event.resp_ptr = (__u64)PT_REGS_PARM3(ctx); bpf_map_update_elem(&traces, &key, &event, BPF_ANY) 47

48.
[beta]
eBPF Japan Meetup #5

関数終了時に
1. 関数開始時に保存した ResponseWriter のポインタを BPF マップから取得
struct event {0};
__u64 key = goroutine_id(ctx);
event = bpf_map_lookup_elem(&traces, &key);
void *resp = (void *)event->resp_ptr;

48

49.

eBPF Japan Meetup #5 2. response 構造体の StatusCode のオフセット (net_http_response_status_offset)を使ってステータスコードを読み取 る int status_code; bpf_probe_read( &status_code, sizeof(status_code), resp + net_http_response_status_offset ); 49

50.

eBPF Japan Meetup #5 フィールドのオフセット • フィールドのオフセットはコンパイル時に決まる ‣ 言語のバージョンなどで変わりうる ‣ フィールドの順序が変わったりもする • DWARF(デバッグ情報)にメモリレイアウトの情報が含まれる • eBPF にはロード時にグローバル変数を通じてオフセットを渡す 50

51.

eBPF Japan Meetup #5 eBPF ロード前に DWARF を解析して eBPF プログラムに渡す // DWARF解析 off := analyzer.StructFieldOffset("net/http.response", "status") // eBPFプログラムにセット objs.NetHttpResponseStatusOffset.Set(uint32(off)) volatile __u32 net_http_response_status_offset; 51

52.

eBPF Japan Meetup #5 最後に: BPF マップから情報を取得 • 取得した文字列はバイト列になっているので、 ユーザプログラム側で Go 言語の文字列に変換する • 時刻は unix.CLOCK_MONOTONIC で取得されているので、 ユーザプログラム側でミリ秒に変換する GET: /items a95b06d292ce2616af51073ae379f944 6d34e11faf7ebe3e http.route=/items host.name=localhost:8081 http.response.status_code=200 http.request.method=GET 52

53.

eBPF Japan Meetup #5 全体の流れ eBPF プログラムをロードするまで 1. 計装対象から DWARF をもとに構造体のフィールドオフセットを取得 2. 計装対象の関数を逆アセンブルし、関数の終了位置を特定 2. eBPF プログラムをロードし、Uprobe を設定 53

54.

eBPF Japan Meetup #5 計装対象プログラム実行後 1. 関数開始時に引数・リクエストの情報を eBPF マップに保存 2. 関数終了時にステータスコード・処理時間を eBPF マップに保存 3. ユーザプログラム側で eBPF マップから情報を取得 54

55.

eBPF Japan Meetup #5 Go 言語での eBPF ゼロコード計装まとめ • Goroutine によりスレッドと非同期処理が紐づかない ‣ 関数呼び出しごとに Goroutine を取得して紐づける • Uretprobe が利用できない ‣ なぜなら Uretprobe がリターンアドレスを書き換えるから ‣ バイナリを逆アセンブルして関数終了位置に Uprobe を設定する • 関数の引数は Go 言語独自のメモリレイアウトに対応する必要がある 以上を気をつければ簡単にユーザアプリケーションへの計装が可能 55