IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    如何用eBPF分析Golang应用

    老王发表于 2021-12-12 05:06:01
    love 0

    当医生遇到疑难杂症时,那么可以上 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

    我会不定期的汇总上面的资料,大家如果有好的资料也请告诉我,谢谢。

     



沪ICP备19023445号-2号
友情链接