如何用eBPF分析Golang应用
当医生遇到疑难杂症时,那么可以上 X 光机,有没有病?病在哪里?一照便知!当程序员遇到疑难杂症时,那么多半会查日志,不过日志的位置都是预埋的,可故障的位置却总是随机的,很多时候当我们查到关键的地方时却总是发现没有日志,此时就无能为力了,如果改代码加日志重新发布的话,那么故障往往就不能稳定复现了。回想医生的例子,他们可没有给病人加日志,可为什么他们能找到问题的,因为他们有 X 光机,所以对程序员来说,我们也需要有我们的 X 光机,它就是 eBPF。
为了降低使用 eBPF 的门槛,社区开发了 bcc,bpftrace 等工具,因为 bpftrace 在语法上贴近 awk,所以我一眼就爱上了,本文将通过它来讲解如何用 eBPF 分析 Golang 应用。
通过 bpftrace 分析 golang 方法的参数和返回值
下面是演示代码 main.go,我们的目标是通过 bpftrace 分析 sum 方法的输入输出:
package main func main() { println(sum(11, 22)) } func sum(a, b int) int { return a + b }
在编译的时候,记得关闭内联,否则一旦 sum 被内联了,eBPF 就没法加探针了:
shell> go build -gcflags="-l" ./main.go shell> objdump -t ./main | grep -w sum 000000000045dd60 g F .text 0000000000000033 main.sum
准备工作做好之后,我们就可以通过如下 bpftrace 命令来监控 sum 的输入输出了:
shell> bpftrace -e ' uprobe:./main:main.sum {printf("a: %d b: %d\n", sarg0, sarg1)} uretprobe:./main:main.sum {printf("retval: %d\n", retval)} ' a: 11 b: 22 retval: 33
不过测试发现,如上 bpftrace 命令仅在 go1.17 之前的版本工作正常,在 go1.17 之后的版本,sargx 变量取不到数据,这是因为从 go.1.17 开始,参数不再保存在栈里,而是保存在寄存器中,关于这一点在 Go internal ABI specification 中有详细的描述:
amd64 architecture
The amd64 architecture uses the following sequence of 9 registers for integer arguments and results:
RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11
让我们通过 gdb 来验证这一点:
shell> gdb ./main (gdb) # 设置断点 (gdb) b main.sum (gdb) # 运行 (gdb) r (gdb) # 查看寄存器 (gdb) i r rax 0xb 11 rbx 0x16 22
如上可见:main.sum 的第一个参数保存在 rax 寄存器,第二个参数保存在 rbx 寄存器,和 Go internal ABI specification 中的描述一致。
搞清楚这些之后,我们就知道在 go1.17 以后的版本,如何用 bpftrace 监控输入输出了:
shell> bpftrace -e ' uprobe:./main:main.sum {printf("a: %d b: %d\n", reg("ax"), reg("bx"))} uretprobe:./main:main.sum {printf("retval: %d\n", retval)} ' a: 11 b: 22 retval: 33
说到这,细心的读者可能已经发现:我们一直在讨论整形,如果是字符串该怎么办?我们不妨构造一个字符串的例子再来测试一下,本次测试是在 go1.17 下进行的:
下面是演示代码 main.go,我们的目标是通过 bpftrace 分析 concat 方法的输入输出:
package main func main() { println(concat("ab", "cd")) } func concat(a, b string) string { return a + b }
让我们通过 gdb 来看看 go1.17 中字符串参数是怎么传递的:
shell> go build -gcflags="-l" ./main.go shell> gdb ./main (gdb) # 设置断点 (gdb) b main.concat (gdb) # 运行 (gdb) r (gdb) # 查看参数 (gdb) i args x = 0x461513 "ab" y = 0x461515 "cd" (gdb) # 查看寄存器 (gdb) i r rax 0x461513 4592915 rbx 0x2 2 rcx 0x461515 4592917 rdi 0x2 2 (gdb) # 检查地址 0x461513 (gdb) x/2cb 0x461513 0x461513: 97 'a' 98 'b' (gdb) # 检查地址 0x461515 (gdb) x/2cb 0x461515 0x461515: 99 'c' 100 'd' (gdb) # 查看寄存器 (gdb) i r rax 0xc00001a0e0 824633827552 rbx 0x4 4 (gdb) # 检查地址 0xc00001a0e0 (gdb) x/4cb 0xc00001a0e0 0xc00001a0e0: 97 'a' 98 'b' 99 'c' 100 'd'
如上可见:当我们给 main.sum 方法传递两个字符串参数的时候,实际上是占用 4 个寄存器,每个字符串参数占用两个寄存器,分别是地址和长度,正好贴合字符串的数据结构:
type StringHeader struct { Data uintptr Len int }
了解了相关知识之后,我们就可以通过如下 bpftrace 命令来监控 sum 的输入输出了:
shell> bpftrace -e ' uprobe:./main:main.concat { printf("a: %s b: %s\n", str(reg("ax"), reg("bx")), str(reg("cx"), reg("di")) ) } uretprobe:./main:main.concat { printf("retval: %s\n", str(reg("ax"), reg("bx"))) // printf("retval: %s\n", str(retval)) } ' a: ab b: cd retval: abcd
以上,我们介绍了当参数和返回值是整形或字符串时,如何用 bpftrace 分析 golang 程序,如果类型更复杂的话,比如说是一个 struct,那么原理也是类似的,篇幅所限,本文就不再赘述了,有兴趣的读者可以参考文章后面的相关链接。
补充说明:通过 uretprobe 检查 golang 方法的返回值可能存在风险。这是因为 uretprobe 是通过修改栈来加入探针的, 这和 golang 本身对栈的管理存在冲突的可能:
runtime: ebpf uretprobe support Go crash with uretprobe虽然在 golang 程序中使用 uretprobe 是不安全的,但是好在 uprobe 还可以放心用。其实换个角度看,即便我们不使用 uretprobe,依然有办法获取返回时,比如我们可以通过在 本方法 return 的时候或者在一个方法开始的时候设置一个 uprobe 来获取返回值。
通过 bpftrace 分析 golang 中 slice 是如何扩容的
本例代码依然以 go1.17 版本为例,它的逻辑就是不断追加数据,迫使 slice 扩容:
package main import "time" func main() { var s []int for range time.Tick(time.Microsecond) { s = append(s, 1) } _ = s }
控制 slice 扩容行为的方法是 runtime.growslice,对应的签名如下:
// It is passed the slice element type, the old slice, // and the desired new minimum capacity, func growslice(et *_type, old slice, cap int) slice type slice struct { array unsafe.Pointer len int cap int }
这里面,看上去 cap 是我们最关心的参数,不过从源代码注释中看,此 cap 的含义是「the desired new minimum capacity」,并不是真正的 cap,实际上 old 参数里也有一个 cap,它才是我们需要的 cap:
et *_type 是一个指针,占用一个寄存器 old slice 是一个 struct,占用三个寄存器,分别是 slice 类型定义中的 array, len, cap cap int 是一个整形,占用一个寄存器所以我们需要的 cap 实际保存在第 4 个寄存器,也就是 RDI,我们用 reg(“di”) 就可以拿到对应的数据:
shell> bpftrace -e ' uprobe:./main:runtime.growslice {printf("cap: %d\n", reg("di"))} ' cap: 0 cap: 0 cap: 2 cap: 0 cap: 1 cap: 2 cap: 0 cap: 0 cap: 0 cap: 1 cap: 2 cap: 4 cap: 8 cap: 16 cap: 32 cap: 64 cap: 128 cap: 256 cap: 512 cap: 1024 cap: 1280 cap: 1696 cap: 2304 cap: 3072 cap: 4096 cap: 5120 cap: 7168 cap: 9216
前面有一些噪音数据,可以忽略,从 1 开始,每次扩容都会翻倍,一直到 1024,接着从 1024 扩容到 1280,是 1.25 倍,然后从 1280 扩容到 1696,是 1.325 倍。整个分析过程中,我们没有手动加任何日志,仅依赖 bpftrace 观测到的数据。
本文介绍了 eBPF 最基本的用法,想深入了解 eBPF 的话推荐大家继续阅读如下资料:
聊聊风口上的 eBPF eBPF 与 Go,超能力组合 BPF 在 Golang 中 Crash 导致用户进程奔溃 bpf study Golang bcc/BPF Function Tracing BPF and Go: Modern forms of introspection in Linux Challenges of BPF Tracing Go我会不定期的汇总上面的资料,大家如果有好的资料也请告诉我,谢谢。
文章来源:
Author:老王
link:https://blog.huoding.com/2021/12/12/970