阅读本文之前能对ebpf程序有一定的了解。
背景
为了观测线上程序的运行情况,通常我们会在对应点记录响应的日志。对于日志不完善的情况,可以添加日志代码并重新打包部署上线。有时候这在生产环境成本是巨大的,比如
添加日志代码上线后通过新的日志记录定位问题,处理后又需要重新打包部署上线。
我们可以利用ebpf解决这个问题。ebpf的uprobe探针允许我们将探针动态插入到任意用户程序位置,且不影响线上程序的正常运行。
eBPF的uprobe和uretprobe
uprobe是用户空间的插桩技术,它允许用户在用户空间中动态地插入和观察程序的探针,以便监视应用程序的行为、性能和调试信息。
uretprobe不只是能够获取函数的返回值。它能够将我们自定义的返回值附加到原来的返回值上。即我们能在uretprobe中修改函数的返回值,也就是能动态修改程序的逻辑。
uretprobe修改线上运行程序的返回值是个非常危险的行为,尤其是对于有自己运行时的语言(例如Golang)。
这些语言的参数和返回值存在自己的堆栈中,uretprobe修改的是寄存器上的值,具体可了解ABI规范问题。
模拟一个线上程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
//go:build ignore
#include <stdio.h>
int AddNum(int i, int i1);
int main() {
printf("%d\n", AddNum(3,2));
return 0;
}
int AddNum(int n1, int n2) {
return n1 + n2;
}
|
文件名为test.c
,这是一个最简单的c语言程序,自定义了一个AddNum
函数,用于计算两数之和。每执行一次代表我们线上程序被调用一次接口。
eBPF程序开发
eBPF程序分为内核态程序和用户态程序,内核态我们采用c语言,用户态我们采用golang的cilium/ebpf库
内核态程序开发
程序文件命名为uprobe_kernel.c
,下面进行一些初始化定义,头文件会在最后的源码中给出。
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
|
//go:build ignore
#include "common.h"
#include <bpf/bpf_tracing.h>
#include <stdbool.h>
char __license[] SEC("license") = "Dual MIT/GPL";
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
} events SEC(".maps");
// Force emitting struct event into the ELF.
const struct data_t *unused __attribute__((unused));
struct data_t {
__u32 pid;
__u8 comm[80];//TASK_COMM_LEN
__u64 args[5];
__u64 ret;
bool entry;
__u64 ktime_ns;
};
|
定义了一个ebpf Map,类型为BPF_MAP_TYPE_PERF_EVENT_ARRAY
,它可以帮助我们将自定义的事件数据共享到用户态。data_t
是我们自定义的结构
- pid记录进程id
- comm记录程序名
- args记录函数的入参
- entry记录入参时为true, 记录返回值是为false
- ktime_ns时间字段,计算观测函数执行的耗时
- ret记录返回值
增加代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
SEC("uprobe")
int uprobe_entry(struct pt_regs *ctx) {
struct data_t data = {};
__u32 pid = bpf_get_current_pid_tgid() >> 32;
data.pid = pid;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
// Fill in the arguments
data.args[0] = PT_REGS_PARM1(ctx);
data.args[1] = PT_REGS_PARM2(ctx);
data.args[2] = PT_REGS_PARM3(ctx);
data.args[3] = PT_REGS_PARM4(ctx);
data.args[4] = PT_REGS_PARM5(ctx);
data.entry = true;
data.ktime_ns = bpf_ktime_get_ns();
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &data, sizeof(data));
return 0;
}
|
SEC("uprobe")
eBPF程序的section声明,表明以下的函数用于uprobe,在SEC中并未指定可执行程序位置和函数名,我们在eBPF用户态程序中指定。另外一种写法是SEC("uprobe//proc/self/exe:TestFunc")
uprobe_entry
这个函数从ctx
中获取输入参数等,uprobe获取的入参最多支持6个,似乎是从寄存器上取值,构造完成后将data
存储到ebpf Map中。
继续完善代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
SEC("uretprobe")
int uprobe_return(struct pt_regs *ctx) {
struct data_t data = {};
__u32 pid = bpf_get_current_pid_tgid() >> 32;
data.pid = pid;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
u64 ret = PT_REGS_RC(ctx);
data.ret = ret;
data.entry = false;
data.ktime_ns = bpf_ktime_get_ns();
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &data, sizeof(data));
return 0;
}
|
逻辑与上一个函数类似,SEC("uretprobe")
表示以下函数用于uretprobe,可用于获取或修改返回值。
uprobe正常工作需要满足两个条件:
1.被attach的函数名称
2.函数所在的可执行文件路径
这里的函数名称可以是一个抽象概念。两个条件的目的是为了获取可执行文件符号表中函数符号的地址。这个地址我们可以加上一定的偏移量,因此uprobe探针可以动态插入到用户程序的任意位置
用户态程序开发
基本库使用github.com/cilium/ebpf
,github地址为 https://github.com/cilium/ebpf
golang代码初始结构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
package main
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/perf"
"github.com/cilium/ebpf/rlimit"
"golang.org/x/sys/unix"
"log"
"os"
"os/signal"
"syscall"
"time"
)
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -target amd64 -type data_t bpf uprobe_kernel.c -- -I./headers
func main() {
}
|
首先执行go generate
触发//go:generate
生成对应的文件。
main
的逻辑如下:
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
|
func main() {
stopper := make(chan os.Signal, 1)
signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)
// Allow the current process to lock memory foMakefiler eBPF resources.
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}
// Load pre-compiled programs and maps into the kernel.
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
log.Fatalf("loading objects: %s", err)
}
defer objs.Close()
// Open an ELF binary and read its symbols.
ex, err := link.OpenExecutable("./test")
if err != nil {
log.Fatalf("opening executable: %s", err)
}
uprobe, err := ex.Uprobe("AddNum",objs.UprobeEntry, nil)
if err != nil {
log.Fatalf("creating uprobe: %s", err)
}
defer uprobe.Close()
// Open a Uretprobe at the exit point of the symbol and attach
// the pre-compiled eBPF program to it.
up, err := ex.Uretprobe("AddNum", objs.UprobeReturn, nil)
if err != nil {
log.Fatalf("creating uretprobe: %s", err)
}
defer up.Close()
// Open a perf event reader from userspace on the PERF_EVENT_ARRAY map
// described in the eBPF C program.
rd, err := perf.NewReader(objs.Events, os.Getpagesize())
if err != nil {
log.Fatalf("creating perf event reader: %s", err)
}
defer rd.Close()
go func() {
// Wait for a signal and close the perf reader,
// which will interrupt rd.Read() and make the program exit.
<-stopper
log.Println("Received signal, exiting program..")
if err := rd.Close(); err != nil {
log.Fatalf("closing perf event reader: %s", err)
}
}()
log.Printf("Listening for events..")
// bpfEvent is generated by bpf2go.
var event bpfDataT
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, perf.ErrClosed) {
return
}
log.Printf("reading from perf event reader: %s", err)
continue
}
if record.LostSamples != 0 {
log.Printf("perf event ring buffer full, dropped %d samples", record.LostSamples)
continue
}
// Parse the perf event entry into a bpfEvent structure.
if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
log.Printf("parsing perf event: %s", err)
continue
}
comment := fmt.Sprintf(" pid:%v ", event.Pid)
comment += fmt.Sprintf(" comm:%s ", unix.ByteSliceToString(event.Comm[:]))
comment += fmt.Sprintf(" current_time:%s ", ktimeToTime(event.KtimeNs).String())
if event.Entry {
comment += fmt.Sprintf(" arg1:%v ", event.Args[0])
comment += fmt.Sprintf(" arg2:%v ", event.Args[1])
}else {
comment += fmt.Sprintf(" return:%v ", event.Ret)
}
fmt.Println(comment)
}
}
// ktimeToTime 将内核时间(自开机以来的纳秒数)转换为 time.Time 类型的时间对象
func ktimeToTime(ktime uint64) time.Time {
// 获取当前时间的纳秒数
now := time.Now().UnixNano()
// 计算自开机以来经过的纳秒数
uptimeNanos := now - int64(ktime)
// 转换为 time.Duration 对象
uptimeDuration := time.Duration(uptimeNanos)
// 创建 time.Time 对象
bootTime := time.Unix(0, 0).Add(uptimeDuration)
return bootTime
}
|
上面的逻辑比较简单,将我们的内核态程序加载到内核中,并将uprobe和uretprobe探针附加到指定位置。这里指定位置是指: 可执行文件路径:./test (还记得最开始模拟的线上程序吗,我们会执行编译gcc -o test test.c
) 函数名: AddNum
测试
完整源码地址为: https://github.com/ldlb9527/ebpf-dynamic-log
附一个Makefile文件的内容:
1
2
3
4
5
6
7
8
9
10
11
|
build-test:
gcc -o test test.c
build:
go generate && go build -o uprobe
run:
./uprobe
run-test:
./test
|
依次执行make build-test
make build
make run
,此时我们的ebpf程序已经被我们加载到内核并从PERF_EVENT_ARRAY
获取对应的数据
打开另一个终端相同目录下执行make run-test
,每执行一次控制台会打印
1
2
3
4
5
6
7
8
9
|
root# make run-test
./test
5
root# make run-test
./test
5
root# make run-test
./test
5
|
打印了5
,因为我们的test程序就是打印3+2的和。回到另一个终端,控制台打印了
1
2
3
4
5
6
7
8
9
10
11
|
root# make run
./uprobe
2024/06/10 21:17:07 Listening for events..
pid:2481911 comm:test current_time:2024-06-07 01:19:51.254030467 +0800 CST arg1:3 arg2:2
pid:2481911 comm:test current_time:2024-06-07 01:19:51.254029907 +0800 CST return:5
pid:2483341 comm:test current_time:2024-06-07 01:19:51.254019975 +0800 CST arg1:3 arg2:2
pid:2483341 comm:test current_time:2024-06-07 01:19:51.254013336 +0800 CST return:5
pid:2483349 comm:test current_time:2024-06-07 01:19:51.253990432 +0800 CST arg1:3 arg2:2
pid:2483349 comm:test current_time:2024-06-07 01:19:51.254012139 +0800 CST return:5
pid:2483415 comm:test current_time:2024-06-07 01:19:51.25399224 +0800 CST arg1:3 arg2:2
pid:2483415 comm:test current_time:2024-06-07 01:19:51.254004328 +0800 CST return:5
|
第一个参数arg1为3 第二个参数arg2为2 返回值return为5 两个current_time的差就是函数执行耗时
分析
上面我们说明uprobe成功的两个条件是:可执行文件和函数名 我们指定的可执行文件是./test
函数名是AddNum
实际上函数名只是一个符号,程序会去可执行文件中寻找对应地址。将uprobe探针附加到对应位置。执行命令nm ./test | grep AddNum
可查看到对应函数的地址:
1
2
|
root@VM-20-15-ubuntu:~/ebpf-dynamic-log# nm ./test | grep AddNum
000000000000117d T AddNum
|
因此我们在程序中并不完全是指定函数名,我们可以通过一些开源库分析可执行文件获取地址,设置一定偏移量即可将探针附加到程序任意位置了。
cilium/ebpf库支持直接设置地址,而不用指定函数名。
扩展