eBPF冒险记(一)

eBPF冒险记(一)

1.eBPF简要介绍

1.1应用介绍

eBPF是什么呢? 从它的全称“扩展的伯克利数据包过滤器 (Extended Berkeley Packet Filter)” 来看,它是一种数据包过滤技术,是从BPF(Berkeley Packet Filter)技术扩展而来的。BPF提供了一种在内核事件和用户程序事件发生时安全注入代码的机制,这就让非内核开发人员也可以对内核进行控制。随着内核的发展,BPF逐步从最初的数据包过滤扩展到了网络、内核、安全、跟踪等,而且它的功能特性还在快速发展中。实际上,现代内核所运行的都是eBPF,如果没有特殊说明,内核和开源社区中提到的BPF等同于eBPF。在eBPF之前,内核模块是注入内核的最主要机制。由于缺乏对内核模块的安全控制,内核的基本功能很容易被一个有缺陷的内核模块破坏。而eBPF则借助即时编译器(JIT),在内核中运行了一个虚拟机,保证只有被验证安全的eBPF指令才会被内核执行。同时,因为eBPF指令依然运行在内核中,无需向用户态复制数据,这就大大提高了事件处理的效率。

img

1.2发展历程

为了更好地理解eBPF的发展历程,eBPF诞生以来的发展过程如图所示:

img

1.3简要原理

eBPF程序并不像常规的线程那样,启动后就一直运行在那里,它需要事件触发后才会执行。这些事件包括系统调用、内核跟踪点、内核函数和用户态函数的调用退出、网络事件,等等。借助于强大的内核态插桩(kprobe)和用户态插桩(uprobe),eBPF程序几乎可以在内核和应用的任意位置进行插桩。看到这个令人惊叹的能力,你一定有疑问:这会不会像内核模块一样,一个异常的eBPF程序就会损坏整个内核的稳定性呢?其实,确保安全和稳定一直都是eBPF的首要任务,不安全的eBPF程序根本就不会提交到内核虚拟机中执行。

通常我们借助LLVM把编写的eBPF程序转换为BPF字节码,然后再通过bpf系统调用提交给内核执行。内核在接受BPF字节码之前,会首先通过验证器对字节码进行校验,只有校验通过的BPF字节码才会提交到即时编译器执行(如BPF程序不能包含无限循环、BPF程序不能导致内核崩溃、BPF程序必须在有限时间内完成)。

img

BPF程序可以利用BPF映射(map)进行存储,而用户程序通常也需要通过BPF映射同运行在内核中的BPF程序进行交互。在性能观测中,BPF 程序收集内核运行状态存储在映射中,用户程序再从映射中读出这些状态。可以看到,eBPF程序的运行需要历经编译加载验证内核态执行等过程,而用户态程序则需要借助BPF映射来获取内核态eBPF程序的运行状态。

img

2.eBPF环境搭建

2.1前置环境

作为eBPF最重大的改进之一,一次编译到处执行(简称CO-RE)解决了内核数据结构在不同版本差异导致的兼容性问题。不过,在使用CO-RE之前,内核需要开启CONFIG_DEBUG_INFO_BTF=yCONFIG_DEBUG_INFO=y这两个编译选项。为了避免首次学习eBPF时就去重新编译内核,推荐使用已经默认开启这些编译选项的发行版,作为你的开发环境,比如Ubuntu 20.10+。

image-20240916065247579

虚拟机创建好之后,接下来就需要安装eBPF开发和运行所需要的开发工具:

1
2
# For Ubuntu20.10+
sudo apt-get install -y make clang llvm libelf-dev libbpf-dev bpfcc-tools libbpfcc-dev linux-tools-$(uname -r) linux-headers-$(uname -r)

2.2快速上手

BCC是一个BPF编译器集合,包含了用于构建BPF程序的编程框架和库,并提供了大量可以直接使用的工具。使用BCC的好处是,它把eBPF执行过程通过内置框架抽象了起来,并提供了Python、C++等编程语言接口。这样,你就可以直接通过Python语言去跟eBPF的各种事件和数据进行交互。接下来,我就以跟踪openat(即打开文件)这个系统调用为例,来看看如何开发并运行第一个eBPF程序。

(1)使用C开发BPF程序

1
2
3
4
5
6
7
// BPF程序,后续会被LLVM编译成字节码并通过bpf系统调用提交给内核检查并执行
int hello_world(void *ctx)
{
// BPF辅助函数,输出是内核调试文件/sys/kernel/debug/tracing/trace_pipe
bpf_trace_printk("Hello, World!");
return 0;
}

(2)使用Python和BCC库开发用户态程序

1
2
3
4
5
6
7
8
9
#!/usr/bin/env python3
# 1) import bcc library
from bcc import BPF
# 2) load BPF program
b = BPF(src_file="hello.c")
# 3) attach kprobe
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
# 4) read and print /sys/kernel/debug/tracing/trace_pipe
b.trace_print()

(3)执行eBPF程序

image-20240916070832997

到了这里,我们已经成功开发并运行了第一个eBPF程序!不过,短暂的兴奋之后,发现这个程序还有不少的缺点,比如:

  • 既然跟踪的是打开文件的系统调用,除了调用这个接口进程的名字之外,被打开的文件名也应该在输出中;
  • bpf_trace_printk() 的输出格式不够灵活,像是CPU编号、bpf_trace_printk函数名等内容没必要输出;
  • ……

2.3程序优化

实际上,并不推荐通过内核调试文件系统输出日志的方式。一方面,它会带来很大的性能问题;另一方面,所有的eBPF程序都会把内容输出到同一个位置,很难根据eBPF程序去区分日志的来源。接下来,我们就试着一起改进这个程序。

(1)使用C开发BPF程序

为了解决上面提到的第一个问题,即获取被打开文件名的问题,我们就要引入BPF映射。为了简化BPF映射的交互,BCC定义了一系列的库函数和辅助宏定义,比如,可以使用BPF_PERF_OUTPUT来定义一个Perf事件类型的BPF映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 包含头文件
#include <uapi/linux/openat2.h>
#include <linux/sched.h>

// 定义数据结构
struct data_t {
u32 pid;
u64 ts;
char comm[TASK_COMM_LEN];
char fname[NAME_MAX];
};

// 定义性能事件映射events
BPF_PERF_OUTPUT(events);

然后,在eBPF程序中,填充这个数据结构,并调用perf_submit()把数据提交到刚才定义的BPF映射中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 定义kprobe处理函数
int hello_world(struct pt_regs *ctx, int dfd, const char __user * filename, struct open_how *how)
{
struct data_t data = { };

// 获取PID和时间
data.pid = bpf_get_current_pid_tgid();
data.ts = bpf_ktime_get_ns();

// 获取进程名
if (bpf_get_current_comm(&data.comm, sizeof(data.comm)) == 0)
{
bpf_probe_read(&data.fname, sizeof(data.fname), (void *)filename);
}

// 提交性能事件
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
  • bpf_get_current_pid_tgid用于获取进程的TGID和PID。因为这儿定义的data.pid数据类型为u32,所以高32位舍弃掉后就是进程的PID;
  • bpf_ktime_get_ns用于获取系统自启动以来的时间,单位是纳秒;
  • bpf_get_current_comm用于获取进程名,并把进程名复制到预定义的缓冲区中;
  • bpf_probe_read用于从指定指针处读取固定大小的数据,这里则用于读取进程打开的文件名。

(2)使用Python和BCC库开发用户态程序

有了BPF映射之后,前面我们调用的bpf_trace_printk()其实就不再需要了,因为用户态进程可以直接从BPF映射中读取内核eBPF程序的运行状态。这其实也就是上面提到的第二个待解决问题。那么,怎样从用户态读取BPF映射内容并输出到标准输出STDOUT呢?

在BCC中,与eBPF程序中BPF_PERF_OUTPUT相对应的用户态辅助函数是open_perf_buffer()。它需要传入一个回调函数,用于处理从Perf事件类型的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
from bcc import BPF

# 1) load BPF program
b = BPF(src_file="trace-open.c")
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")

# 2) print header
print("%-18s %-16s %-6s %-16s" % ("TIME(s)", "COMM", "PID", "FILE"))

# 3) define the callback for perf event
start = 0
def print_event(cpu, data, size):
global start
event = b["events"].event(data)
if start == 0:
start = event.ts
time_s = (float(event.ts - start)) / 1000000000
print("%-18.9f %-16s %-6d %-16s" % (time_s, event.comm, event.pid, event.fname))

# 4) loop with callback to print_event
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()

(3)执行eBPF程序

image-20240916072750870

恭喜,我们已经开发了第一个完整的eBPF程序。相对于前面的Hello World,它的输出不仅格式更为清晰,还把进程打开的文件名输出出来了,这在排查频繁打开文件相关的性能问题时尤其有用。

3.eBPF运行原理

3.1eBPF虚拟机

eBPF是一个运行在内核中的虚拟机,很多人在初次接触它时,会把它跟系统虚拟化(比如KVM)中的虚拟机弄混。其实,虽然都被称为虚拟机,系统虚拟化和eBPF虚拟机还是有着本质不同的。

系统虚拟化基于x86arm64通用指令集,这些指令集足以完成完整计算机的所有功能。而为了确保在内核中安全地执行,eBPF只提供了非常有限的指令集。这些指令集可用于完成一部分内核的功能,但却远不足以模拟完整的计算机。为了更高效地与内核进行交互,eBPF指令还有意采用了C调用约定,其提供的辅助函数可以在C语言中直接调用,极大地方便了eBPF程序的开发。

img

  • 第一个模块是eBPF辅助函数。它提供了一系列用于eBPF程序与内核其他模块进行交互的函数。这些函数并不是任意一个eBPF程序都可以调用的,具体可用的函数集由BPF程序类型决定。
  • 第二个模块是eBPF验证器。它用于确保eBPF程序的安全。验证器会将待执行的指令创建为一个有向无环图(DAG),确保程序中不包含不可达指令;接着再模拟指令的执行过程,确保不会执行无效指令。
  • 第三个模块是由11个64位寄存器一个程序计数器一个512字节的栈组成的存储模块。这个模块用于控制eBPF程序的执行。其中R0寄存器用于存储函数调用和eBPF程序的返回值,这意味着函数调用最多只能有一个返回值;R1-R5寄存器用于函数调用的参数,因此函数调用的参数最多不能超过5个;而R10则是一个只读寄存器,用于从栈中读取数据。
  • 第四个模块是即时编译器,它将eBPF字节码编译成本地机器指令,以便更高效地在内核中执行。
  • 第五个模块是BPF映射(map),它用于提供大块的存储。这些存储可被用户空间程序用来进行访问,进而控制eBPF程序的运行状态。

3.2BPF指令格式

只看图中的这些模块,你可能觉得它们并不是太直观。所以接下来,我们还是用上一讲的Hello World作为例子,一起看下BPF指令到底是什么样子的。

1
2
3
4
5
int hello_world(void *ctx)
{
bpf_trace_printk("Hello, World!");
return 0;
}

首先,打开一个新的终端,执行下面的命令,查询系统中正在运行的eBPF程序:

image-20240916121809468

输出中,19是这个eBPF程序的编号,kprobe是程序的类型,而hello_world是程序的名字。有了eBPF程序编号之后,执行下面的命令就可以导出这个eBPF程序的指令:

image-20240916121911986

其中,分号开头的部分,正是我们前面写的C代码,而其他行则是具体的BPF指令。具体每一行的BPF指令又分为三部分:

  • 第一部分,冒号前面的数字0-12 ,代表BPF指令行数;
  • 第二部分,括号中的16进制数值,表示BPF指令码。
  • 第三部分,括号后面的部分,就是BPF指令的伪代码。

结合前面讲述的各个寄存器的作用,不难理解这些BPF指令的含义:

  • 第0-8行,借助R10寄存器从栈中把字符串“Hello, World!”读出来,并放入R1寄存器中;
  • 第9行,向R2寄存器写入字符串的长度14(即代码注释里面的sizeof(_fmt));
  • 第10行,调用BPF辅助函数bpf_trace_printk输出字符串;
  • 第11行,向R0寄存器写入0,表示程序的返回值是0;
  • 最后一行,程序执行成功退出。

总结起来,这些指令先通过R1和R2寄存器设置了bpf_trace_printk的参数,然后调用bpf_trace_printk函数输出字符串,最后再通过R0寄存器返回成功。

当这些BPF指令加载到内核后,BPF即时编译器会将其编译成本地机器指令,最后才会执行编译后的机器指令:

image-20240916121944750

3.3具体执行过程

BCC负责了eBPF程序的编译和加载过程。因而,要了解BPF指令的加载过程,就可以从BCC执行eBPF程序的过程入手。那么,怎么才能查看到BCC的执行过程呢?那就是跟踪它的系统调用过程。

image-20240916123536077

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bpf(BPF_PROG_LOAD,
{
prog_type=BPF_PROG_TYPE_KPROBE,
insn_cnt=13,
insns=[
{code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0x21},
{code=BPF_STX|BPF_H|BPF_MEM, dst_reg=BPF_REG_10, src_reg=BPF_REG_1, off=-4, imm=0},
{code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0x646c726f},
{code=BPF_STX|BPF_W|BPF_MEM, dst_reg=BPF_REG_10, src_reg=BPF_REG_1, off=-8, imm=0},
{code=BPF_LD|BPF_DW|BPF_IMM, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0x6c6c6548},
{code=BPF_LD|BPF_W|BPF_IMM, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0x57202c6f},
{code=BPF_STX|BPF_DW|BPF_MEM, dst_reg=BPF_REG_10, src_reg=BPF_REG_1, off=-16, imm=0},
{code=BPF_ALU64|BPF_X|BPF_MOV, dst_reg=BPF_REG_1, src_reg=BPF_REG_10, off=0, imm=0},
{code=BPF_ALU64|BPF_K|BPF_ADD, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0xfffffff0},
{code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_2, src_reg=BPF_REG_0, off=0, imm=0xe},
{code=BPF_JMP|BPF_K|BPF_CALL, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0x6},
{code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0},
{code=BPF_JMP|BPF_K|BPF_EXIT, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0}
],
prog_name="hello_world",
...
},
128) = 4

这些参数看起来很复杂,但实际上,如果你查询bpf系统调用的格式(执行man bpf命令),就可以发现,它实际上只需要三个参数:

image-20240916122616741

对应前面的strace输出结果,这三个参数的具体含义如下。

  • 第一个参数是BPF_PROG_LOAD,表示加载BPF程序。
  • 第二个参数是bpf_attr类型的结构体,表示BPF程序的属性。其中,有几个需要你留意的参数,比如:
    • prog_type表示BPF程序的类型,这儿是BPF_PROG_TYPE_KPROBE,跟我们Python代码中的attach_kprobe一致;
    • insn_cnt(instructions count)表示指令条数;
    • insns(instructions)包含了具体的每一条指令,这儿的13条指令跟我们前面bpftool prog dump的结果是一致的;
    • prog_name则表示BPF程序的名字,即hello_world。
  • 第三个参数128表示属性的大小。

eBPF程序并不像常规的线程那样,启动后就一直运行在那里,它需要事件触发后才会执行。这些事件包括系统调用、内核跟踪点、内核函数和用户态函数的调用退出、网络事件,等等。对于我们的Hello World来说,由于调用了attach_kprobe函数,很明显,这是一个内核跟踪事件。

所以,除了把eBPF程序加载到内核之外,还需要把加载后的程序跟具体的内核函数调用事件进行绑定。在eBPF的实现中,诸如内核跟踪(kprobe)、用户跟踪(uprobe)等的事件绑定,都是通过perf_event_open()来完成的。

image-20240916123709787

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
...
/* 1) 加载BPF程序 */
bpf(BPF_PROG_LOAD,...) = 4
...

/* 2)查询事件类型 */
openat(AT_FDCWD, "/sys/bus/event_source/devices/kprobe/type", O_RDONLY) = 5
read(5, "6\n", 4096) = 2
close(5) = 0
...

/* 3)创建性能监控事件 */
perf_event_open(
{
type=0x6 /* PERF_TYPE_??? */,
size=PERF_ATTR_SIZE_VER7,
...
wakeup_events=1,
config1=0x7f275d195c50,
...
},
-1,
0,
-1,
PERF_FLAG_FD_CLOEXEC) = 5

/* 4)绑定BPF到kprobe事件 */
ioctl(5, PERF_EVENT_IOC_SET_BPF, 4) = 0
...

从输出中,你可以看出BPF与性能事件的绑定过程分为以下几步:

  • 首先,借助bpf系统调用,加载BPF程序,并记住返回的文件描述符;
  • 然后,查询kprobe类型的事件编号。BCC实际上是通过/sys/bus/event_source/devices/kprobe/type来查询的;
  • 接着,调用perf_event_open创建性能监控事件。比如,事件类型(type是上一步查询到的6)、事件的参数(config1包含了内核函数do_sys_openat2)等;
  • 最后,再通过ioctl的PERF_EVENT_IOC_SET_BPF命令,将BPF程序绑定到性能监控事件。

4.eBPF编程接口

用高级语言开发的eBPF程序,需要首先编译为BPF字节码,然后借助bpf系统调用加载到内核中,最后再通过性能监控等接口与具体的内核事件进行绑定。这样,内核的性能监控模块才会在内核事件发生时,自动执行我们开发的eBPF程序。

4.1BPF系统调用

一个完整的eBPF程序通常包含用户态和内核态两部分。其中,用户态负责eBPF程序的加载、事件绑定以及eBPF程序运行结果的汇总输出;内核态运行在eBPF虚拟机中,负责定制和控制系统的运行状态

img

对于用户态程序来说,我想你已经了解,它们与内核进行交互时必须要通过系统调用来完成。而对应到eBPF程序中,我们最常用到的就是bpf系统调用:

image-20240916130428224

img

4.2BPF辅助函数

eBPF程序并不能随意调用内核函数,因此,内核定义了一系列的辅助函数,用于eBPF程序与内核其他模块进行交互。需要注意的是,并不是所有的辅助函数都可以在eBPF程序中随意使用,不同类型的eBPF程序所支持的辅助函数是不同的。比如,对于Hello World示例这类内核探针(kprobe)类型的eBPF程序,你可以在命令行中执行bpftool feature probe,来查询当前系统支持的辅助函数列表:

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
$ bpftool feature probe
...
eBPF helpers supported for program type kprobe:
- bpf_map_lookup_elem
- bpf_map_update_elem
- bpf_map_delete_elem
- bpf_probe_read
- bpf_ktime_get_ns
- bpf_get_prandom_u32
- bpf_get_smp_processor_id
- bpf_tail_call
- bpf_get_current_pid_tgid
- bpf_get_current_uid_gid
- bpf_get_current_comm
- bpf_perf_event_read
- bpf_perf_event_output
- bpf_get_stackid
- bpf_get_current_task
- bpf_current_task_under_cgroup
- bpf_get_numa_node_id
- bpf_probe_read_str
- bpf_perf_event_read_value
- bpf_override_return
- bpf_get_stack
- bpf_get_current_cgroup_id
- bpf_map_push_elem
- bpf_map_pop_elem
- bpf_map_peek_elem
- bpf_send_signal
- bpf_probe_read_user
- bpf_probe_read_kernel
- bpf_probe_read_user_str
- bpf_probe_read_kernel_str
...

img

这其中,需要你特别注意的是以bpf_probe_read开头的一系列函数。eBPF内部的内存空间只有寄存器和栈。所以,要访问其他的内核空间或用户空间地址,就需要借助bpf_probe_read这一系列的辅助函数。这些函数会进行安全性检查,并禁止缺页中断的发生。

而在eBPF程序需要大块存储时,就不能像常规的内核代码那样去直接分配内存了,而是必须通过BPF映射(BPF Map)来完成。

4.3BPF映射

BPF映射用于提供大块的键值存储,这些存储可被用户空间程序访问,进而获取eBPF程序的运行状态。eBPF程序最多可以访问64个不同的BPF映射,并且不同的eBPF程序也可以通过相同的BPF映射来共享它们的状态。

img

在前面的BPF系统调用和辅助函数小节中,你也看到,有很多系统调用命令和辅助函数都是用来访问BPF映射的。我相信细心的你已经发现了BPF辅助函数中并没有BPF映射的创建函数,BPF映射只能通过用户态程序的系统调用来创建。比如,你可以通过下面的示例代码来创建一个BPF映射,并返回映射的文件描述符:

1
2
3
4
5
6
7
8
9
10
11
12
int bpf_create_map(enum bpf_map_type map_type,
unsigned int key_size,
unsigned int value_size, unsigned int max_entries)
{
union bpf_attr attr = {
.map_type = map_type,
.key_size = key_size,
.value_size = value_size,
.max_entries = max_entries
};
return bpf(BPF_MAP_CREATE, &attr, sizeof(attr));
}

这其中,最关键的是设置映射的类型。你可以使用如下的bpftool命令,来查询当前系统支持哪些映射类型:

1
2
3
4
5
6
7
8
9
$ bpftool feature probe | grep map_type
eBPF map_type hash is available
eBPF map_type array is available
eBPF map_type prog_array is available
eBPF map_type perf_event_array is available
eBPF map_type percpu_hash is available
eBPF map_type percpu_array is available
eBPF map_type stack_trace is available
...

img

除了创建之外,映射的删除也需要你特别注意。BPF系统调用中并没有删除映射的命令,这是因为BPF映射会在用户态程序关闭文件描述符的时候自动删除即close(fd)。如果你想在程序退出后还保留映射,就需要调用BPF_OBJ_PIN命令,将映射挂载到/sys/fs/bpf中。

4.4BPF类型格式

了解过BPF辅助函数和映射之后,我们再来看一个开发eBPF程序时最常碰到的问题:内核数据结构的定义。在安装BCC工具的时候,你可能就注意到了,内核头文件linux-headers-$(uname -r)也是必须要安装的一个依赖项。这是因为BCC在编译eBPF程序时,需要从内核头文件中找到相应的内核数据结构定义。这样,你在调用bpf_probe_read时,才能从内存地址中提取到正确的数据类型。但是,编译时依赖内核头文件也会带来很多问题。主要有这三个方面:

  • 首先,在开发eBPF程序时,为了获得内核数据结构的定义,就需要引入一大堆的内核头文件;
  • 其次,内核头文件的路径和数据结构定义在不同内核版本中很可能不同。因此,你在升级内核版本时,就会遇到找不到头文件和数据结构定义错误的问题;
  • 最后,在很多生产环境的机器中,出于安全考虑,并不允许安装内核头文件,这时就无法得到内核数据结构的定义。在程序中重定义数据结构虽然可以暂时解决这个问题,但也很容易把使用着错误数据结构的eBPF程序带入新版本内核中运行。

那么,这么多的问题该怎么解决呢?不用担心,BPF类型格式(BPF Type Format, BTF)的诞生正是为了解决这些问题。从内核5.2开始,只要开启了CONFIG_DEBUG_INFO_BTF,在编译内核时,内核数据结构的定义就会自动内嵌在内核二进制文件vmlinux中。并且,你还可以借助下面的命令,把这些数据结构的定义导出到一个头文件中(通常命名为vmlinux.h):

1
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

有了内核数据结构的定义,你在开发eBPF程序时只需要引入一个vmlinux.h即可,不用再引入一大堆的内核头文件了。

img

同时,借助BTF、bpftool等工具,我们也可以更好地了解BPF程序的内部信息,这也会让调试变得更加方便。比如,在查看BPF映射的内容时,你可以直接看到结构化的数据,而不只是十六进制数值:

1
2
3
4
5
6
7
8
9
10
11
12
13
# bpftool map dump id 386
[
{
"key": 0,
"value": {
"eth0": {
"value": 0,
"ifindex": 0,
"mac": []
}
}
}
]

解决了内核数据结构的定义问题,接下来的问题就是,如何让eBPF程序在内核升级之后,不需要重新编译就可以直接运行。eBPF的一次编译到处执行(Compile Once Run Everywhere,简称CO-RE)项目借助了BTF提供的调试信息,再通过下面的两个步骤,使得eBPF程序可以适配不同版本的内核:

  • 第一,通过对BPF代码中的访问偏移量进行重写,解决了不同内核版本中数据结构偏移量不同的问题;
  • 第二,在libbpf中预定义不同内核版本中的数据结构的修改,解决了不同内核中数据结构不兼容的问题。

BTF和一次编译到处执行带来了很多的好处,但你也需要注意这一点:它们都要求比较新的内核版本(>=5.2),并且需要非常新的发行版(如 Ubuntu20.10+、RHEL8.2+ 等)才会默认打开内核配置CONFIG_DEBUG_INFO_BTF。

5.eBPF事件触发

eBPF程序类型决定了一个eBPF程序可以挂载的事件类型和事件参数,这也就意味着,内核中不同事件会触发不同类型的eBPF程序。

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
enum bpf_prog_type {
BPF_PROG_TYPE_UNSPEC, /* Reserve 0 as invalid program type */
BPF_PROG_TYPE_SOCKET_FILTER,
BPF_PROG_TYPE_KPROBE,
BPF_PROG_TYPE_SCHED_CLS,
BPF_PROG_TYPE_SCHED_ACT,
BPF_PROG_TYPE_TRACEPOINT,
BPF_PROG_TYPE_XDP,
BPF_PROG_TYPE_PERF_EVENT,
BPF_PROG_TYPE_CGROUP_SKB,
BPF_PROG_TYPE_CGROUP_SOCK,
BPF_PROG_TYPE_LWT_IN,
BPF_PROG_TYPE_LWT_OUT,
BPF_PROG_TYPE_LWT_XMIT,
BPF_PROG_TYPE_SOCK_OPS,
BPF_PROG_TYPE_SK_SKB,
BPF_PROG_TYPE_CGROUP_DEVICE,
BPF_PROG_TYPE_SK_MSG,
BPF_PROG_TYPE_RAW_TRACEPOINT,
BPF_PROG_TYPE_CGROUP_SOCK_ADDR,
BPF_PROG_TYPE_LWT_SEG6LOCAL,
BPF_PROG_TYPE_LIRC_MODE2,
BPF_PROG_TYPE_SK_REUSEPORT,
BPF_PROG_TYPE_FLOW_DISSECTOR,
BPF_PROG_TYPE_CGROUP_SYSCTL,
BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE,
BPF_PROG_TYPE_CGROUP_SOCKOPT,
BPF_PROG_TYPE_TRACING,
BPF_PROG_TYPE_STRUCT_OPS,
BPF_PROG_TYPE_EXT,
BPF_PROG_TYPE_LSM,
BPF_PROG_TYPE_SK_LOOKUP,
};

对于具体的内核来说,因为不同内核的版本和编译配置选项不同,一个内核并不会支持所有的程序类型。你可以在命令行中执行下面的命令,来查询当前系统支持的程序类型:

image-20240916141837897

根据具体功能和应用场景的不同,这些程序类型大致可以划分为三类:

  • 第一类是跟踪,即从内核和程序的运行状态中提取跟踪信息,来了解当前系统正在发生什么。
  • 第二类是网络,即对网络数据包进行过滤和处理,以便了解和控制网络数据包的收发过程。
  • 第三类是除跟踪和网络之外的其他类型,包括安全控制、BPF扩展等等。

5.1跟踪类eBPF程序

跟踪类eBPF程序主要用于从系统中提取跟踪信息,进而为监控、排错、性能优化等提供数据支撑。比如,我们前几讲中的Hello World示例就是一个BPF_PROG_TYPE_KPROBE类型的跟踪程序,它的目的是跟踪内核函数是否被某个进程调用了。

img

这其中,KPROBE、TRACEPOINT以及PERF_EVENT都是最常用的eBPF程序类型,大量应用于监控跟踪、性能优化以及调试排错等场景中。我们前几讲中提到的BCC工具集,其中包含的绝大部分工具也都属于这个类型。

5.2网络类eBPF程序

网络类eBPF程序主要用于对网络数据包进行过滤和处理,进而实现网络的观测、过滤、流量控制以及性能优化等各种丰富的功能。根据事件触发位置的不同,网络类eBPF程序又可以分为XDP(eXpress Data Path,高速数据路径)程序、TC(Traffic Control,流量控制)程序、套接字程序以及cgroup程序,下面我们来分别看看。

5.2.1XDP程序

XDP程序的类型定义为BPF_PROG_TYPE_XDP,它在网络驱动程序刚刚收到数据包时触发执行。由于无需通过繁杂的内核网络协议栈,XDP程序可用来实现高性能的网络处理方案,常用于DDoS 防御、防火墙、4层负载均衡等场景。

需要注意,XDP程序并不是绕过了内核协议栈,它只是在内核协议栈之前处理数据包,而处理过的数据包还可以正常通过内核协议栈继续处理。

XDP Packet Processing

根据网卡和网卡驱动是否原生支持XDP程序,XDP运行模式可以分为下面这三种:

  • 通用模式。它不需要网卡和网卡驱动的支持,XDP程序像常规的网络协议栈一样运行在内核中,性能相对较差,一般用于测试;
  • 原生模式。它需要网卡驱动程序的支持,XDP程序在网卡驱动程序的早期路径运行;
  • 卸载模式。它需要网卡固件支持XDP卸载,XDP程序直接运行在网卡上,而不再需要消耗主机的CPU资源,具有最好的性能。

无论哪种模式,XDP程序在处理过网络包之后,都需要根据eBPF程序执行结果,决定数据包的去处。这些执行结果对应以下5种XDP程序结果码:

img

通常来说,XDP程序通过ip link命令加载到具体的网卡上,加载格式为:

1
2
3
4
# eth1 为网卡名
# xdpgeneric 设置运行模式为通用模式
# xdp-example.o 为编译后的 XDP 字节码
sudo ip link set dev eth1 xdpgeneric object xdp-example.o

而卸载XDP程序也是通过ip link命令,具体参数如下:

1
sudo ip link set veth1 xdpgeneric off

除了ip link之外,BCC也提供了方便的库函数,让我们可以在同一个程序中管理XDP程序的生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from bcc import BPF

# 编译XDP程序
b = BPF(src_file="xdp-example.c")
fn = b.load_func("xdp-example", BPF.XDP)

# 加载XDP程序到eth0网卡
device = "eth0"
b.attach_xdp(device, fn, 0)

# 其他处理逻辑
...

# 卸载XDP程序
b.remove_xdp(device)

5.2.2TC程序

TC程序的类型定义为BPF_PROG_TYPE_SCHED_CLSBPF_PROG_TYPE_SCHED_ACT,分别作为Linux流量控制的分类器和执行器。Linux流量控制通过网卡队列、排队规则、分类器、过滤器以及执行器等,实现了对网络流量的整形调度和带宽控制。

得益于内核v4.4引入的direct-action模式,TC程序可以直接在一个程序内完成分类和执行的动作,而无需再调用其他的TC排队规则和分类器,具体如下图所示:

img

同XDP程序相比,TC程序可以直接获取内核解析后的网络报文数据结构sk_buff(XDP则是xdp_buff),并且可在网卡的接收和发送两个方向上执行(XDP则只能用于接收)。下面我们来具体看看TC程序的执行位置:

  • 对于接收的网络包,TC程序在网卡接收(GRO)之后、协议栈处理(包括IP层处理和iptables等)之前执行;
  • 对于发送的网络包,TC程序在协议栈处理(包括IP层处理和iptables等)之后、数据包发送到网卡队列(GSO)之前执行。

除此之外,由于TC运行在内核协议栈中,不需要网卡驱动程序做任何改动,因而可以挂载到任意类型的网卡设备(包括容器等使用的虚拟网卡)上。同XDP程序一样,TC eBPF程序也可以通过Linux命令行工具来加载到网卡上,不过相应的工具要换成tc。你可以通过下面的命令,分别加载接收和发送方向的eBPF程序:

1
2
3
4
5
6
7
8
# 创建 clsact 类型的排队规则
sudo tc qdisc add dev eth0 clsact

# 加载接收方向的 eBPF 程序
sudo tc filter add dev eth0 ingress bpf da obj tc-example.o sec ingress

# 加载发送方向的 eBPF 程序
sudo tc filter add dev eth0 egress bpf da obj tc-example.o sec egress

5.2.3套接字程序

套接字程序用于过滤、观测或重定向套接字网络包,具体的种类也比较丰富。根据类型的不同,套接字eBPF程序可以挂载到套接字(socket)、控制组(cgroup )以及网络命名空间(netns)等各个位置。你可以根据具体的应用场景,选择一个或组合多个类型的eBPF程序,去控制套接字的网络包收发过程。

img

注意,这几类网络eBPF程序是在不同的事件触发时执行的,因此,在实际应用中我们通常可以把多个类型的eBPF程序结合起来,一起使用,来实现复杂的网络控制功能。比如,最流行的Kubernetes网络方案Cilium就大量使用了XDP、TC和套接字eBPF程序。

5.3其他程序

除了上面的跟踪和网络eBPF程序之外,Linux内核还支持很多其他的类型。这些类型的eBPF程序虽然不太常用,但在需要的时候也可以帮你解决很多特定的问题。

img

虽然每个eBPF程序都有特定的类型和触发事件,但这并不意味着它们都是完全独立的。通过BPF映射提供的状态共享机制,各种不同类型的eBPF程序完全可以相互配合,不仅可以绕过单个eBPF程序指令数量的限制,还可以实现更为复杂的控制逻辑。