本文地址:https://www.ebpf.top/post/ebpf_and_go

本文为 “Go 夜读” 分享的 “eBPF 与 Go 超能力组合” 的简单文字版本,完整视频可以在 Youtube 和 B 站观看,PPT 下载, 8.7M , Github 地址:DavadDi/bpf_demo

markdown 文档中嵌入 bilibili 视频参考这里

1. 前言

本文主要对 eBPF 程序跟踪 Go 程序的函数调用、Go 函数调用参数等方面进行介绍, 而且还基于 bpf_probe_write_user 函数对跟踪 Go 语言函数进行修改的样例。本文涉及的完整环境搭建,请参见 “环境准备” 篇。

被跟踪的 Go 语言代码非常简单,仅实现了一个 ebpfDemo 函数供 main 函数调用,主要代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main

import "fmt"

//go:noinline
func ebpfDemo(a1 int, a2 bool, a3 float32) (r1 int64, r2 int32, r3 string) {
	fmt.Printf("ebpfDemo:: a1=%d, a2=%t a3=%.2f\n", a1, a2, a3)
	return 100, 200, "test for ebpf"
}

func main() {
	r1, r2, r3 := ebpfDemo(100, true, 66.88)
	fmt.Printf("main:: r1=%d, r2=%d r3=%s\n", r1, r2, r3)

	return;
}

其中第 5 行的 //go:noinline 注解明确告诉编译器不适用内联优化,避免造成造成我们无法对函数进行跟踪,相关内容可参考:Golang bcc/BPF Function Tracing 或者中文

2. 使用 eBPF 跟踪 Go 程序

一般情况下的跟踪我们无需直接使用 C 代码编写 BPF 程序,社区相关的 BCC 或者 bpftrace 可协助我们快速上手,这里我们使用 bpftrace 工具,相比较 BCC,其提供的语法更加高阶和方便 ,更加详细的使用方式参见 bpftrace 参考

2.1 查询可跟踪 uprobe 列表

首先我们可以使用 bpftrace 快速查看二进制文件提供的 uprobe 函数列表,语法支持通配符过滤。

1
2
3
$ sudo bpftrace -l 'uprobe:./tracee:main*'
uprobe:./tracee:main.ebpfDemo
uprobe:./tracee:main.main

上述命令运行后,我们可以获取到以 main 开头的两个函数 main.ebpfDemomain.main

uprobe:./tracee:main.ebpfDemo 格式分别对应为:

  • uprobe 代表我们跟踪点的类型;
  • ./tracee 表示跟踪的二进制文件;
  • main.ebpfDemo 代表我们跟踪的完整函数名词,其中 main 为包名。

2.2 打印函数参数

1
2
3
4
$ sudo bpftrace -e ‘uprobe:tracee/tracee:main.ebpfDemo { printf(“arg: %d\n”, sarg0); }’
Attaching 1 probe...
arg: 100

其中 sarg0, sarg1, …, sargN,用于表示基于栈保存的函数参数列表,默认参数是 64 位。

在函数 func ebpfDemo(a1 int, a2 bool, a3 float32) 中,sarg0 则代表了参数 a1。 每次我们允许 tracee 上述这段 bpftrace 代码就会打印出我们传入的 a1 参数。

2.3 打印函数调用堆栈

我们也可以使用 bpftrace 来打印调用 main.ebpfDemo 的调用堆栈:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ sudo bpftrace -e 'uprobe:tracee/tracee:main.ebpfDemo
 {
	printf("arg: %s\n", ustack(perf));
}’


Attaching 1 probe...
arg:
	49a620 main.ebpfDemo+0 (bpf_demo/bpf_go_tracer/tracee/tracee)
	434909 runtime.main+521 (bpf_demo/bpf_go_tracer/tracee/tracee)
	462fc1 runtime.goexit+1 (bpf_demo/bpf_go_tracer/tracee/tracee)

上述跟踪代码中的 ustack 表示用户空间的堆栈,其中 perf 表示栈的格式,还可以指定用户空间栈的层级,比如 ustack(perf, 3) 表示仅对 perf 格式的用户空间栈选取最近的 3 层。

2.4 跟踪函数调用延迟

如果要实现跟踪函数的调用延时,我们就需要在函数的入口和出口处进行相关时间的统计,这里不再方便使用 bpftrace -e 这种单行模式,这里我们定一个 call.bt 文件,内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
uprobe:tracee/tracee:main.ebpfDemo
{
	@start[pid] = nsecs;
}

uretprobe:tracee/tracee:main.ebpfDemo
/@start[pid]/
{
	@ns[comm] = hist(nsecs - @start[pid]);
	delete(@start[pid]);
}

uprobe 行表明我们跟踪函数 main.ebpfDemo 的入口,并将相关入口的时间信息以 pid 作为键值保存到名为 start 的 map 中。

uretprobe 表示在匹配在函数出口处,如果匹配了 start[pid],那么这将函数入口与出口的差值作为直方图数据保存至 ns map 中,同时删除 start 中的相关数据。

在程序最终退出时,会统一打印出来保存的整体直方图的数据。

1
2
3
4
5
6
7
8
9
$ sudo bpftrace call.bt
Attaching 2 probes...
^C

@ns[tracee]:
[64K, 128K)            4  |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[128K, 256K)           1 |@@@@@@@@@@@@@ |
[256K, 512K)           2 |@@@@@@@@@@@@@@@@@@@@@@@@@@      |

运行几次 tracee 后,我们可以在程序的结果看到函数延迟分布区间的直方图,这对于我们跟踪某些复杂场景下函数延迟情况提供了良好的数据分布展示。

3. 使用 eBPF 跟踪 Go 函数参数及修改对应参数

效果演示:

3.1 跟踪程序代码

3.2 跟踪和修改 ebpfDemo 入参的 BPF 程序代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <uapi/linux/ptrace.h>

BPF_PERF_OUTPUT(events);

typedef struct {
	u64   	arg1;
	char   	arg2;
	char    pad[3];
	float   arg3;
} args_event_t;

inline int get_arguments(struct pt_regs *ctx) {
		void* stackAddr = (void*)ctx->sp;
		args_event_t event = {};

		bpf_probe_read(&event.arg1, sizeof(event.arg1), stackAddr+8);
		bpf_probe_read(&event.arg2, sizeof(event.arg2), stackAddr+16);
		bpf_probe_read(&event.arg3, sizeof(event.arg3), stackAddr+20);

		long tmp = 2021;
		bpf_probe_write_user(stackAddr+8, &tmp, sizeof(tmp));

		events.perf_submit(ctx, &event, sizeof(event));

		return 0;
}

其中 bpf_probe_write_user 用于修改 ebpfDemo 的第一个入参。

单次运行 tracee 结果如下:

1
2
3
4
5
bpf_demo/bpf_go_tracer/tracee$ make build && make run
go build -o tracee main.go
./tracee
ebpfDemo:: a1=100, a2=true a3=66.88
main:: r1=100, r2=200 r3=test for ebpf

启动 tracer 程序(make build && make run),然后再次运行 tracee (make run),tracee 和 tracer 的输出如下:

1
2
3
4
5
6
7
bpf_demo/bpf_go_tracer/tracer$ make build && make run
go build -o tracer main.go
sudo ./tracer --binary ../tracee/tracee --func main.ebpfDemo
Trace ../tracee/tracee on func [main.ebpfDemo]
[100 0 0 0 0 0 0 0 1 0 0 0 143 194 133 66 0 0 0 0]
ebpfDemo:: a1=100, a2=1 a3=66.8./tracee/tracee on func [main.ebpfDemo] # 输出

tracee 的输出结果如下:

1
2
3
4
bpf_demo/bpf_go_tracer/tracee$ make run
./tracee
ebpfDemo:: a1=2021, a2=true a3=66.88   # 入参已经从原来的 a1=100 =>  a1=2021
main:: r1=100, r2=200 r3=test for ebpf

通过运行结果我们发现通过 BPF 程序完成了 ebpfDemo 的参数跟踪和参数修改。

3.3 uprobe 原理

对于 Go 程序的跟踪,我们主要使用 uprobe/uretprobe 来进行跟踪,在整个跟踪架构中的位置如下图所示:

image-20211213202750078

uprobe 的跟踪原理是在用户空间的地址中通过中断机制注入内核层面运行的 eBPF 代码,与 kprobe 在内核中直接调用 eBPF 代码相比,性能和吞吐量略有下降,但是就跟踪的场景看性能还是符合预期。

图片来自 Pixie 博客。

uprobe 注入函数的整体原理架构图如下:

arch

以上跟踪使用 uprobe 方式进行跟踪,被跟踪的函数入口处会被 “int 3 ” 中断指令替换,在函数执行到此处时,会进入到中断,中断处理程序会查找到前期注册的 eBPF 程序并运行。

Uprobe 原理演示图如下:

arch

完整演示版本参考如下:

此种方式的局限性如下:

  • eBPF 在分析 Go 语言 ABI 的时候局限于简单类型,更加复杂的类型(用户自定义结构体/指针/引用/通道接口等),比较适用的场景是函数的调用次数,函数延迟,函数返回值等;

  • 基于 uprobe 需要被跟踪的程序带有符号表(not stripped)

  • eBPF 需要特权用户,一般情况下会限制适用范围;

4. 环境准备

本文我们测试的环境如下:

  • Ubuntu 20.04 LTS , amd64
  • bcc v0.23.0(源码安装)
  • iovisor/gobpf v0.2.0
  • Go1.15 linux/amd64 (最新 1.17 版本调用方式可能有变化)

需要注意 iovisor/gobpf 需要与 bcc 版本相对应,否则可能会报接口函数签名不一致,本质上 gobpf 库只是 bcc 底层开发库的一层 cgo 包装。

在 Ubuntu 20.04 LTS 系统中,仓库中对应的包存在一些问题,所以必须采用源码方式进行安装,报错信息如下:

1
vendor/github.com/iovisor/gobpf/bcc/module.go:32:10: fatal error: bcc/bcc_common.h: No such file or directory

详情可参见 Github Issue 214

4.1 Ubuntu 20.04 环境安装

这里我们使用 multipass 进行环境安装:

1
2
$ multipass find # 该命令可以显示当前全部的版本
$ multipass launch -n ubuntu  -c 4 -m 4G -d 40G  20.04

直接使用 multipass launch -n ubuntu -c 4 -m 4G -d 40G 20.04 命令进行安装,详细参数介绍如下:

  • -n ubuntu 创建虚拟机的名字,后续登录需要基于该名字;
  • -c 虚拟机使用的 CPU 函数可以根据自己的情况进行调整;
  • -m 虚拟机占用的内存大小;
  • -d 虚拟机占用的磁盘,默认只有 5G,涉及编译可以根据自己情况调整;
  • 20.04 为我们要安装的系统镜像名字,也可以使用别名,比如 focal(20.04);

系统安装成功后,使用 multipass shell ubuntu 即可进行登录。

BPF 程序运行需要依赖 linux-header文件,我们提前进行安装:

1
$ sudo apt-get install bpfcc-tools linux-headers-$(uname -r)

4.2 BCC 源码安装

安装流程参考 BCC 安装文档

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 安装编译相关工具
$ sudo apt install -y bison build-essential cmake flex git libedit-dev \
  libllvm7 llvm-7-dev libclang-7-dev python zlib1g-dev libelf-dev libfl-dev python3-distutils
  
$ git clone https://github.com/iovisor/bcc.git
$ git checkout v0.23.0 -b branch_v0.23.0
$ git submodule update
$ mkdir bcc/build; cd bcc/build
$ cmake ..
$ make

# 需要特权模式
$ sudo make install  

4.3 Go 安装

1
2
3
4
$ wget https://dl.google.com/go/go1.15.linux-amd64.tar.gz
$ sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.15.linux-amd64.tar.gz
$ export PATH=$PATH:/usr/local/go/bin
$ go version

5. 参考文档