eBPF冒险记(一)
1.eBPF简要介绍
1.1应用介绍
eBPF是什么呢? 从它的全称“扩展的伯克利数据包过滤器 (Extended Berkeley Packet Filter)” 来看,它是一种数据包过滤技术
,是从BPF(Berkeley Packet Filter)技术扩展而来的。BPF提供了一种在内核事件和用户程序事件发生时安全注入代码的机制
,这就让非内核开发人员也可以对内核进行控制。随着内核的发展,BPF逐步从最初的数据包过滤扩展到了网络、内核、安全、跟踪等,而且它的功能特性还在快速发展中。实际上,现代内核所运行的都是eBPF,如果没有特殊说明,内核和开源社区中提到的BPF等同于eBPF。在eBPF之前,内核模块是注入内核的最主要机制。由于缺乏对内核模块的安全控制,内核的基本功能很容易被一个有缺陷的内核模块破坏。而eBPF则借助即时编译器(JIT),在内核中运行了一个虚拟机,保证只有被验证安全的eBPF指令才会被内核执行
。同时,因为eBPF指令依然运行在内核中,无需向用户态复制数据,这就大大提高了事件处理的效率。
1.2发展历程
为了更好地理解eBPF的发展历程,eBPF诞生以来的发展过程如图所示:
1.3简要原理
eBPF程序并不像常规的线程那样,启动后就一直运行在那里,它需要事件触发
后才会执行。这些事件包括系统调用、内核跟踪点、内核函数和用户态函数的调用退出、网络事件,等等。借助于强大的内核态插桩(kprobe)和用户态插桩(uprobe),eBPF程序几乎可以在内核和应用的任意位置进行插桩。看到这个令人惊叹的能力,你一定有疑问:这会不会像内核模块一样,一个异常的eBPF程序就会损坏整个内核的稳定性呢?其实,确保安全和稳定一直都是eBPF的首要任务,不安全的eBPF程序根本就不会提交到内核虚拟机中执行。
通常我们借助LLVM
把编写的eBPF程序转换为BPF字节码
,然后再通过bpf
系统调用提交给内核执行。内核在接受BPF字节码之前,会首先通过验证器对字节码进行校验,只有校验通过的BPF字节码才会提交到即时编译器执行(如BPF程序不能包含无限循环、BPF程序不能导致内核崩溃、BPF程序必须在有限时间内完成)。
BPF程序可以利用BPF映射(map)
进行存储,而用户程序通常也需要通过BPF映射同运行在内核中的BPF程序进行交互。在性能观测中,BPF 程序收集内核运行状态存储在映射中,用户程序再从映射中读出这些状态。可以看到,eBPF程序的运行需要历经编译
、加载
、验证
和内核态执行
等过程,而用户态程序则需要借助BPF映射来获取内核态eBPF程序的运行状态。
2.eBPF环境搭建
2.1前置环境
作为eBPF最重大的改进之一,一次编译到处执行(简称CO-RE)解决了内核数据结构在不同版本差异导致的兼容性问题。不过,在使用CO-RE之前,内核需要开启CONFIG_DEBUG_INFO_BTF=y
和CONFIG_DEBUG_INFO=y
这两个编译选项。为了避免首次学习eBPF时就去重新编译内核,推荐使用已经默认开启这些编译选项的发行版,作为你的开发环境,比如Ubuntu 20.10+。
虚拟机创建好之后,接下来就需要安装eBPF开发和运行所需要的开发工具:
1 | For Ubuntu20.10+ |
2.2快速上手
BCC是一个BPF编译器集合,包含了用于构建BPF程序的编程框架和库,并提供了大量可以直接使用的工具。使用BCC的好处是,它把eBPF执行过程通过内置框架抽象了起来,并提供了Python、C++等编程语言接口。这样,你就可以直接通过Python语言去跟eBPF的各种事件和数据进行交互。接下来,我就以跟踪openat(即打开文件)这个系统调用为例,来看看如何开发并运行第一个eBPF程序。
(1)使用C开发BPF程序
1 | // BPF程序,后续会被LLVM编译成字节码并通过bpf系统调用提交给内核检查并执行 |
(2)使用Python和BCC库开发用户态程序
1 | #!/usr/bin/env python3 |
(3)执行eBPF程序
到了这里,我们已经成功开发并运行了第一个eBPF程序!不过,短暂的兴奋之后,发现这个程序还有不少的缺点,比如:
- 既然跟踪的是打开文件的系统调用,除了调用这个接口进程的名字之外,被打开的文件名也应该在输出中;
- bpf_trace_printk() 的输出格式不够灵活,像是CPU编号、bpf_trace_printk函数名等内容没必要输出;
- ……
2.3程序优化
实际上,并不推荐通过内核调试文件系统输出日志的方式。一方面,它会带来很大的性能问题;另一方面,所有的eBPF程序都会把内容输出到同一个位置,很难根据eBPF程序去区分日志的来源。接下来,我们就试着一起改进这个程序。
(1)使用C开发BPF程序
为了解决上面提到的第一个问题,即获取被打开文件名的问题,我们就要引入BPF映射。为了简化BPF映射的交互,BCC定义了一系列的库函数和辅助宏定义,比如,可以使用BPF_PERF_OUTPUT
来定义一个Perf事件类型的BPF映射:
1 | // 包含头文件 |
然后,在eBPF程序中,填充这个数据结构,并调用perf_submit()
把数据提交到刚才定义的BPF映射中:
1 | // 定义kprobe处理函数 |
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 | from bcc import BPF |
(3)执行eBPF程序
恭喜,我们已经开发了第一个完整的eBPF程序。相对于前面的Hello World,它的输出不仅格式更为清晰,还把进程打开的文件名输出出来了,这在排查频繁打开文件相关的性能问题
时尤其有用。
3.eBPF运行原理
3.1eBPF虚拟机
eBPF是一个运行在内核中的虚拟机,很多人在初次接触它时,会把它跟系统虚拟化(比如KVM)中的虚拟机弄混。其实,虽然都被称为虚拟机,系统虚拟化和eBPF虚拟机还是有着本质不同的。
系统虚拟化基于
x86
或arm64
等通用指令集
,这些指令集足以完成完整计算机的所有功能。而为了确保在内核中安全地执行,eBPF只提供了非常有限的指令集
。这些指令集可用于完成一部分内核的功能,但却远不足以模拟完整的计算机。为了更高效地与内核进行交互,eBPF指令还有意采用了C调用约定
,其提供的辅助函数可以在C语言中直接调用,极大地方便了eBPF程序的开发。
- 第一个模块是
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 | int hello_world(void *ctx) |
首先,打开一个新的终端,执行下面的命令,查询系统中正在运行的eBPF程序:
输出中,19是这个eBPF程序的编号,kprobe是程序的类型,而hello_world是程序的名字。有了eBPF程序编号之后,执行下面的命令就可以导出这个eBPF程序的指令:
其中,分号开头的部分,正是我们前面写的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即时编译器会将其编译成本地机器指令,最后才会执行编译后的机器指令:
3.3具体执行过程
BCC负责了eBPF程序的编译和加载过程。因而,要了解BPF指令的加载过程,就可以从BCC执行eBPF程序的过程入手。那么,怎么才能查看到BCC的执行过程呢?那就是跟踪它的系统调用过程。
1 | bpf(BPF_PROG_LOAD, |
这些参数看起来很复杂,但实际上,如果你查询bpf系统调用的格式(执行man bpf命令),就可以发现,它实际上只需要三个参数:
对应前面的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()
来完成的。
1 | ... |
从输出中,你可以看出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虚拟机中,负责定制和控制系统的运行状态
。
对于用户态程序来说,我想你已经了解,它们与内核进行交互时必须要通过系统调用来完成。而对应到eBPF程序中,我们最常用到的就是bpf系统调用:
4.2BPF辅助函数
eBPF程序并不能随意调用内核函数,因此,内核定义了一系列的辅助函数,用于eBPF程序与内核其他模块进行交互。需要注意的是,并不是所有的辅助函数都可以在eBPF程序中随意使用,不同类型的eBPF程序所支持的辅助函数是不同的。比如,对于Hello World示例这类内核探针(kprobe)类型的eBPF程序,你可以在命令行中执行bpftool feature probe
,来查询当前系统支持的辅助函数列表:
1 | bpftool feature probe |
这其中,需要你特别注意的是以bpf_probe_read开头的一系列函数。eBPF内部的内存空间只有寄存器和栈。所以,要访问其他的内核空间或用户空间地址,就需要借助
bpf_probe_read
这一系列的辅助函数。这些函数会进行安全性检查,并禁止缺页中断的发生。
而在eBPF程序需要大块存储时,就不能像常规的内核代码那样去直接分配内存了,而是必须通过BPF映射(BPF Map)来完成。
4.3BPF映射
BPF映射用于提供大块的键值存储,这些存储可被用户空间程序访问,进而获取eBPF程序的运行状态。eBPF程序最多可以访问64个不同的BPF映射,并且不同的eBPF程序也可以通过相同的BPF映射来共享它们的状态。
在前面的BPF系统调用和辅助函数小节中,你也看到,有很多系统调用命令和辅助函数都是用来访问BPF映射的。我相信细心的你已经发现了BPF辅助函数中并没有BPF映射的创建函数,BPF映射只能通过用户态程序的系统调用来创建
。比如,你可以通过下面的示例代码来创建一个BPF映射,并返回映射的文件描述符:
1 | int bpf_create_map(enum bpf_map_type map_type, |
这其中,最关键的是设置映射的类型。你可以使用如下的bpftool命令,来查询当前系统支持哪些映射类型:
1 | bpftool feature probe | grep map_type |
除了创建之外,映射的删除也需要你特别注意。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
即可,不用再引入一大堆的内核头文件了。
同时,借助BTF、bpftool等工具,我们也可以更好地了解BPF程序的内部信息,这也会让调试变得更加方便。比如,在查看BPF映射的内容时,你可以直接看到结构化的数据,而不只是十六进制数值:
1 | bpftool map dump id 386 |
解决了内核数据结构的定义问题,接下来的问题就是,如何让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 | enum bpf_prog_type { |
对于具体的内核来说,因为不同内核的版本和编译配置选项不同,一个内核并不会支持所有的程序类型。你可以在命令行中执行下面的命令,来查询当前系统支持的程序类型:
根据具体功能和应用场景的不同,这些程序类型大致可以划分为三类:
- 第一类是
跟踪
,即从内核和程序的运行状态中提取跟踪信息,来了解当前系统正在发生什么。 - 第二类是
网络
,即对网络数据包进行过滤和处理,以便了解和控制网络数据包的收发过程。 - 第三类是除跟踪和网络之外的
其他
类型,包括安全控制、BPF扩展等等。
5.1跟踪类eBPF程序
跟踪类eBPF程序主要用于从系统中提取跟踪信息,进而为监控、排错、性能优化等提供数据支撑
。比如,我们前几讲中的Hello World示例就是一个BPF_PROG_TYPE_KPROBE类型的跟踪程序,它的目的是跟踪内核函数是否被某个进程调用了。
这其中,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程序,XDP运行模式可以分为下面这三种:
通用模式
。它不需要网卡和网卡驱动的支持,XDP程序像常规的网络协议栈一样运行在内核中,性能相对较差,一般用于测试;原生模式
。它需要网卡驱动程序的支持,XDP程序在网卡驱动程序的早期路径运行;卸载模式
。它需要网卡固件支持XDP卸载,XDP程序直接运行在网卡上,而不再需要消耗主机的CPU资源,具有最好的性能。
无论哪种模式,XDP程序在处理过网络包之后,都需要根据eBPF程序执行结果,决定数据包的去处。这些执行结果对应以下5种XDP程序结果码:
通常来说,XDP程序通过ip link
命令加载到具体的网卡上,加载格式为:
1 | eth1 为网卡名 |
而卸载XDP程序也是通过ip link
命令,具体参数如下:
1 | sudo ip link set veth1 xdpgeneric off |
除了ip link之外,BCC也提供了方便的库函数,让我们可以在同一个程序中管理XDP程序的生命周期:
1 | from bcc import BPF |
5.2.2TC程序
TC程序的类型定义为BPF_PROG_TYPE_SCHED_CLS
和BPF_PROG_TYPE_SCHED_ACT
,分别作为Linux流量控制的分类器和执行器。Linux流量控制通过网卡队列、排队规则、分类器、过滤器以及执行器等,实现了对网络流量的整形调度和带宽控制。
得益于内核v4.4引入的direct-action模式,TC程序可以直接在一个程序内完成分类和执行的动作,而无需再调用其他的TC排队规则和分类器,具体如下图所示:
同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 | 创建 clsact 类型的排队规则 |
5.2.3套接字程序
套接字程序用于过滤、观测或重定向套接字网络包,具体的种类也比较丰富。根据类型的不同,套接字eBPF程序可以挂载到套接字(socket)、控制组(cgroup )以及网络命名空间(netns)等各个位置。你可以根据具体的应用场景,选择一个或组合多个类型的eBPF程序,去控制套接字的网络包收发过程。
注意,这几类网络eBPF程序是在不同的事件触发时执行的,因此,在实际应用中我们通常可以把多个类型的eBPF程序结合起来,一起使用,来实现复杂的网络控制功能。比如,最流行的Kubernetes网络方案Cilium就大量使用了XDP、TC和套接字eBPF程序。
5.3其他程序
除了上面的跟踪和网络eBPF程序之外,Linux内核还支持很多其他的类型。这些类型的eBPF程序虽然不太常用,但在需要的时候也可以帮你解决很多特定的问题。
虽然每个eBPF程序都有特定的类型和触发事件,但这并不意味着它们都是完全独立的。通过BPF映射提供的状态共享机制,各种不同类型的eBPF程序完全可以相互配合,不仅可以绕过单个eBPF程序指令数量的限制,还可以实现更为复杂的控制逻辑。