虽然BPF程序没有明确的分类,但本节根据其主要目的,可以将BPF程序分为两类。
第一类是跟踪。你能编写程序来更好地了解系统正在发生什么。这类程序提供了系统行为及系统硬件的直接信息。同时,它们可以访问特定程序的内存区域,从运行进程中提取执行跟踪信息。另外,它们也可以直接访问为每个特定进程分配的资源,包括文件描述符、CPU和内存。
第二类是网络。这类程序可以检测和控制系统的网络流量。它们可以对网络接口的数据包进行过滤,甚至可以完全拒绝数据包。我们可以使用不同的程序类型,将程序附加到内核网络处理的不同阶段上,这样做各有利弊。例如,你可以将BPF程序附加到网络驱动程序接收数据包的网络事件上,此时,由于内核没有提供足够的信息,程序也只能访问较少的数据包信息。另一方面,你可以将BPF程序附加到数据包传递给用户空间的网络事件。在这种情况下,你可以获得更多的数据包信息,这将有助于你做出更明智的决策,但是完整地处理这些数据包信息需要付出更高的代价。
接下来,我们将不按照前面提到的分类来介绍程序类型,而是按照各种类型添加到内核的时间顺序来进行介绍。同时,我们将那些很少使用的程序类型放在本节末尾进行介绍,现在将重点聚焦在更加有用的内容上。如果你对任何未详细介绍的程序类型感兴趣,可以通过man 2 bpf( https://oreil.ly/qXl0F )了解所有相关信息。
BPF_PROG_TYPE_SOCKET_FILTER类型是添加到Linux内核的第一个程序类型。套接字过滤器程序会附加到原始套接字上,用于访问所有套接字处理的数据包。套接字过滤器程序只能用于对套接字的观测,不允许修改数据包内容或更改其目的地。BPF程序会接收与网络协议栈信息相关的元数据,例如,发送数据包的协议类型。
我们将在第6章中详细介绍套接字过滤程序和其他网络程序。
kprobe是动态附加到内核调用点的函数,我们将在第4章介绍跟踪时对kprobe进行详细介绍。BPF kprobe程序类型允许使用BPF程序作为kprobe的处理程序。程序类型被定义为BPF_PROG_TYPE_KPROBE。BPF虚拟机确保kprobe程序总是可以安全运行,这是传统kprobe模块的优势。但需要注意,在内核中kprobe被认为是不稳定的入口点,因此你需要确定kprobe BPF程序是否与正在使用的特定内核版本兼容。
编写附加到kprobe模块上的BPF程序,我们需要确定BPF程序是在函数调用的第一条指令执行还是函数调用完成时执行。我们需要在BPF程序的SEC头部声明行为。例如,如果你想在内核exec系统调用前检查参数,则需要在系统调用开始时附加BPF程序。这里,你需要在BPF程序设置SEC头部:SEC("kprobe/sys_exec")。如果你想检查exec系统调用的返回值,需要在BPF程序设置SEC头部:SEC("kretprobe/sys_exec")。
在本书的后续章节中,我们将更多地介绍kprobe,它们是了解使用BPF进行跟踪的基础。
跟踪点程序会附加到内核提供的跟踪点处理程序上。跟踪点程序类型被定义为BPF_PROG_TYPE_TRACEPOINT。我们将在第4章介绍跟踪点,跟踪点是内核代码的静态标记,允许注入跟踪和调试相关的任意代码。因为跟踪点需要在内核中预先定义,所以它们的灵活性不如kprobe,但是它们引入内核之后,可以保证稳定性。所以当你调试系统时,跟踪点程序提供了更高的可预测性。
系统中的所有跟踪点都定义在/sys/kernel/debug/tracing/events目录中,你可以找到包括跟踪点的每个子系统,并将BPF程序附加在其上。同时,有意思的是BPF也宣布了自己的跟踪点,可以编写BPF程序检查其他BPF程序的行为。BPF跟踪点定义在/sys/kernel/debug/tracing/events/bpf中。这里,例如,你能发现bpf_prog_load的跟踪点定义,这意味着你可以编写一个BPF程序来检查何时加载的其他BPF程序。
像kprobe一样,跟踪点程序是理解BPF跟踪的另一个基础概念。在下面的章节中,我们将讨论跟踪点程序,并演示如何使用跟踪点编写程序。
当网络包到达内核时,XDP程序会在早期被执行。程序类型被定义为BPF_PROG_TYPE_XDP。在这种情况下,因为内核还没有对数据包本身进行太多的处理,所以显示数据包的信息非常有限。但因为代码在数据包初期被执行,所以程序对数据包的处理具有更高级别的控制。
XDP程序定义了一些对数据包控制的操作,用于决定如何处理数据包。如果XDP程序返回XDP_PASS,这意味着数据包将传递到内核下一个子系统。如果返回XDP_DROP,则意味着内核会彻底忽略该数据包,不做任何处理。另外,还可以返回XDP_TX,这意味着数据包已经转发回首先收到数据包的网卡上。
这种级别的控制操作为开发网络层程序开启了大门。XDP已成为BPF的主要组件之一,这也是本书为什么会单独采用一章来介绍XDP。我们将在第7章中讨论许多XDP的用例,例如实现防御分布式拒绝服务(DDoS)攻击。
Perf事件程序将BPF代码附加到Perf事件上。Perf事件程序类型定义为BPF_PROG_TYPE_PERF_EVENT。Perf是内核的内部分析器,可以产生硬件和软件的性能数据事件。我们可以用Perf事件程序监控很多系统信息,从计算机的CPU到系统中运行的任何软件。当BPF程序附加到Perf事件上时,每次Perf产生分析数据时,程序代码都将被执行。
cgroup套接字程序可以将BPF逻辑附加到控制组(cgroup)上。程序类型定义为BPF_PROG_TYPE_CGROUP_SKB。该程序允许cgroup在其包含的进程中控制网络流量。在网络数据包传入cgroup控制的进程之前,通过cgroup套接字程序,你可以决定如何处理该数据包。内核发送到同一cgroup控制的任何进程上的任何数据包都将经过这些过滤器之一。同时,当cgroup控制的进程通过网络接口发送网络数据包时,也可以通过该程序决定对这些数据包执行什么操作。
如你所见,BPF_PROG_TYPE_CGROUP_SKB程序的行为类似于BPF_PROG_TYPE_SOCKET_FILTER程序。主要区别在于BPF_PROG_TYPE_CGROUP_SKB程序附加到cgroup控制的所有进程上,而不是特定的进程。该方式适用于对给定cgroup控制的进程上已创建的和未来创建的套接字进行控制。BPF程序附加到cgroup上对于容器环境很有用。在容器环境中,容器进程组受cgroup限制,你可以对所有容器进程使用相同的策略,无须单独识别每个进程。Cillium( https://github.com/cilium/cilium )是一个受欢迎的开源项目,该项目为Kubernetes提供负载平衡和安全功能。Cillium使用cgroup套接字程序将策略应用于进程组上,而不是在隔离的容器上。
这种程序类型允许cgroup内的任何进程打开网络套接字时执行代码。这个行为类似于cgroup套接字缓冲区,cgroup开放套接字程序不是访问网络数据包,而是在进程打开新套接字时进行控制。程序类型定义为BPF_PROG_TYPE_CGROUP_SOCK。这种程序可以对打开套接字的程序组提供安全性和访问控制,而不必单独限制每个进程的功能。
当数据包通过内核网络栈的多个阶段中转时,这种类型程序允许运行时修改套接字连接选项。像BPF_PROG_TYPE_CGROUP_SOCK和BPF_PROG_TYPE_CGROUP_SKB那样,套接字选项程序附加到cgroup上,但不同的是该程序类型可以在套接字连接的生命周期中被多次调用。套接字选项程序类型定义为BPF_PROG_TYPE_SOCK_OPS。
当用该类型创建BPF程序时,函数调用时会收到op参数,该参数表示内核在套接字连接上将执行的操作。因此通过这个参数,你可以知道程序被调用是发生在连接生命周期内哪个阶段。有了这些信息,你可以访问网络IP地址和连接端口之类的数据,并且还可以修改连接选项,设置超时以及更改给定数据包的往返延迟时间。
例如,Facebook使用此功能为同一数据中心内的连接设置短期恢复时间目标(Recovery Time Objective,RTO)。RTO是指系统或网络连接出现故障后的恢复时间。该目标还表示系统可接受的无法使用的时间。Facebook设定同一数据中心的计算机应该具有较短的RTO,所以,Facebook使用BPF程序修改了这个阈值。
BPF_PROG_TYPE_SK_SKB程序可以访问套接字映射和套接字重定向。在第3章中,我们将了解套接字映射,套接字映射可以保留对一些套接字的引用。我们可以使用这些引用和特定的帮助函数将套接字的数据包重定向到其他套接字。这种程序类型可以实现负载平衡功能。通过跟踪多个套接字,我们可以在内核空间的多个套接字之间转发网络数据包。Cillium项目以及Facebook的Katran项目( https://oreil.ly/wDtfR )广泛利用这种程序类型对网络流量进行控制。
这种类型程序可以决定是否能在给定设备上执行cgroup中的操作。这种程序类型定义为BPF_PROG_TYPE_CGROUP_DEVICE。cgroups(v1)的第一个实现允许为特定设备设置权限。但是,随后第二个迭代中缺少了这个功能。这种程序类型提供了该功能。当你需要时,可以编写这种类型程序,从而更加灵活地设置权限。
这种类型程序可以控制是否将消息发送到套接字。程序类型定义为BPF_PROG_TYPE_SK_MSG。当内核创建一个套接字时,会将套接字存储在上面提到的套接字映射中。内核通过套接字映射可以快速访问特定的套接字组。当套接字消息BPF程序附加到套接字映射上时,发送到这些套接字的所有消息在发送前都将被过滤。在过滤消息之前,内核会复制消息中的数据,以便可以读取并决定如何处理。这种类型的程序有两种可能的返回值:SK_PASS和SK_DROP。SK_PASS意味着希望内核将消息发送到套接字,SK_DROP意味着希望内核忽略该消息,不将消息传递给套接字。
前面我们讨论了一种访问内核跟踪点的程序类型。内核开发人员添加了一种新的跟踪点程序,用于以内核原始格式访问跟踪点参数。这种格式提供了内核正在执行任务的更多详细信息,但它会有少许性能开销。大多数时候,我们使用常规跟踪点程序以避免性能开销。但记住当需要时,你也可以使用原始跟踪点程序访问原始跟踪点参数。这种程序类型定义为BPF_PROG_TYPE_RAW_TRACE。
这种类型程序允许操作cgroup控制的用户空间程序的IP地址和端口号。当系统使用多个IP地址时,可以确保一组特定的用户空间程序使用相同的IP地址和端口。当你将这些用户空间程序放在同一cgroup中时,这些BPF程序可以灵活地操作这些程序的绑定。这样可以确保这些应用程序的所有传入和传出连接都使用BPF程序提供的IP和端口。这种程序类型定义为BPF_PROG_TYPE_CGROUP_SOCK_ADDR。
SO_REUSEPORT是内核中的一个选项,允许相同主机上多个进程绑定相同的端口。在高并发情况下,我们可以使用多个线程处理并发负载,该选项可以使可接受的网络连接提高,从而获得较高的性能。
这种BPF_PROG_TYPE_SK_REUSEPORT程序类型允许编写BPF程序逻辑,这些程序逻辑挂钩到内核用来确定是否要重用端口。如果BPF程序返回SK_DROP,则防止程序重用同一端口。如果返回SK_PASS,则通知内核启动端口重用。
流量解析器是一个内核组件,用于跟踪网络数据包经过的不同层,从网络数据包到达系统再到网络数据包发送给用户空间程序。流量解析器允许使用不同分类方法对数据包进行控制。内核中内置流量解析器称Flower分类器,被防火墙和其他过滤设备使用,用来决定如何处理特定数据包。
BPF_PROG_TYPE_FLOW_DISSECTOR程序将程序逻辑挂钩到流量解析器路径上,提供了内置解析器没有的安全保证,例如,内置解析器不能确保程序终止,但该程序类型可以确保程序总能终止。这种BPF程序会修改内核网络数据包流。
我们讨论了在不同场景中使用的程序类型。但是值得注意的是,这里还有一些其他BPF程序类型尚未涵盖。这里我们将简要提及。
网络分类程序
BPF_PROG_TYPE_SCHED_CLS和BPF_PROG_TYPE_SCHED_ACT这两类BPF程序允许对网络流量进行分类,并修改套接字缓冲区中数据包某些属性。
轻量级隧道程序
BPF_PROG_TYPE_LWT_IN、BPF_PROG_TYPE_LWT_OUT、BPF_PROG_TYPE_LWT_XMIT和BPF_PROG_TYPE_LWT_SEG6LOCAL这四类BPF程序可以将代码附加到内核的轻量级隧道基础架构上。
红外设备程序
BPF_PROG_TYPE_LIRC_MODE2程序允许通过连接将BPF程序附加到红外设备(如远程遥控器)上。
这些程序是专用的,它们的用法尚未被社区广泛采用。
接下来,我们将介绍BPF如何确保程序在内核加载后,不会在系统中引起灾难性的故障。这是一个重要的主题,因为程序的加载方式也会影响编写这些程序的方式。