聚合国内IT技术精华文章,分享IT技术精华,帮助IT从业人士成长

如何用eBPF分析Golang应用

2021-12-12 13:55 浏览: 3501 次 我要评论(0 条) 字号:

当医生遇到疑难杂症时,那么可以上 X 光机,有没有病?病在哪里?一照便知!当程序员遇到疑难杂症时,那么多半会查日志,不过日志的位置都是预埋的,可故障的位置却总是随机的,很多时候当我们查到关键的地方时却总是发现没有日志,此时就无能为力了,如果改代码加日志重新发布的话,那么故障往往就不能稳定复现了。回想医生的例子,他们可没有给病人加日志,可为什么他们能找到问题的,因为他们有 X 光机,所以对程序员来说,我们也需要有我们的 X 光机,它就是 eBPF

为了降低使用 eBPF 的门槛,社区开发了 bccbpftrace 等工具,因为 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: %dn", sarg0, sarg1)}
    uretprobe:./main:main.sum {printf("retval: %dn", 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: %dn", reg("ax"), reg("bx"))}
    uretprobe:./main:main.sum {printf("retval: %dn", 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 个寄存器,每个字符串参数占用两个寄存器,分别是地址和长度,返回值也是一样的原理。了解了相关知识之后,我们就可以通过如下 bpftrace 命令来监控 sum 的输入输出了:

shell> bpftrace -e '
    uprobe:./main:main.concat {
        printf("a: %s b: %sn",
            str(reg("ax"), reg("bx")),
            str(reg("cx"), reg("di"))
        )
    }
    uretprobe:./main:main.concat {
        printf("retval: %sn", str(reg("ax"), reg("bx")))
        // printf("retval: %sn", str(retval))
    }
'
a: ab b: cd
retval: abcd

以上,我们介绍了当参数和返回值是整形或字符串时,如何用 bpftrace 分析 golang 程序,如果类型更复杂的话,比如说是一个 struct,那么原理也是类似的,篇幅所限,本文就不再赘述了,有兴趣的读者可以参考文章后面的相关链接。

补充说明:通过 uretprobe 检查 golang 方法的返回值可能存在风险。这是因为 uretprobe 是通过修改栈来加入探针的, 这和 golang 本身对栈的管理存在冲突的可能:

可见对 golang 程序使用 uretprobe 是不安全的,好在 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,对应的签名如下:

func growslice(et *_type, old slice, cap int) slice

这里面,我们最关心的是 cap 参数,从表面上看,它是第三个参数,按照 Go internal ABI specification 的描述,它应该使用 RCX 寄存器,但是如果我们仔细看 growsclide 的参数:

  • et *_type 是一个指针,占用一个寄存器
  • old slice 是一个 struct,占用两个寄存器,分别是地址和长度
  • cap int 是一个整形,占用一个寄存器

所以 cap 实际保存在第 4 个寄存器,也就是 RDI,我们用 reg(“di”) 就可以拿到对应的数据,再次强调一下,这是新版本 go1.17 的行为,如果是老版本,请使用 sarg2:

shell> bpftrace -e '
    uprobe:./main:runtime.growslice {printf("cap: %dn", 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 的话推荐大家继续阅读如下资料:

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



网友评论已有0条评论, 我也要评论

发表评论

*

* (保密)

Ctrl+Enter 快捷回复