首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >如何用eBPF分析Golang应用

如何用eBPF分析Golang应用

作者头像
LA0WAN9
发布2021-12-14 09:10:23
1.3K0
发布2021-12-14 09:10:23
举报
文章被收录于专栏:火丁笔记火丁笔记

当医生遇到疑难杂症时,那么可以上 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: %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 本身对栈的管理存在冲突的可能:

虽然在 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 的话推荐大家继续阅读如下资料:

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

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2021-12-12,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 通过 bpftrace 分析 golang 方法的参数和返回值
  • 通过 bpftrace 分析 golang 中 slice 是如何扩容的
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档