全面介绍eBPF
2017年12月2日
本文由Matt Fleming贡献
内核中的BPF
Brendan Gregg 在其关于eBPF内核虚拟机的linux.conf.au 2017演讲[YouTube]中宣称,“超级能力终于来临了Linux”。使eBPF达到这一点是一条漫长的发展和设计之路。尽管eBPF最初用于网络数据包过滤,但事实证明,在运行状况检查虚拟机中运行用户空间代码对于内核开发人员和生产工程师而言是一个强大的工具。随着时间的流逝,新的eBPF用户似乎开始利用其性能和便利性。本文介绍了eBPF如何演变,以及它如何在内核中使用。
eBPF的演变
原始的Berkeley数据包过滤器(BPF)[PDF]设计用于捕获和过滤与特定规则匹配的网络数据包。筛选器被实现为要在基于寄存器的虚拟机上运行的程序。
在内核中运行用户提供的程序的能力被证明是一个有用的设计决定,但是原始BPF设计的其他方面却表现得不那么理想。一方面,随着现代处理器移至64位寄存器并发明了多处理器系统所需的新指令,例如原子交换和添加指令(XADD),虚拟机的设计及其指令集体系结构(ISA)被遗忘了。 。BPF对提供少量RISC指令的关注不再符合现代处理器的现实。
因此,Alexei Starovoitov引入了扩展的BPF(eBPF)设计,以利用现代硬件的优势。eBPF虚拟机更类似于现代处理器,允许将eBPF指令更紧密地映射到硬件ISA,以提高性能。最显着的变化之一是移至64位寄存器,寄存器数量从2个增加到10个。由于现代体系结构具有两个以上的寄存器,因此可以像在本机硬件上一样将参数传递给eBPF虚拟机寄存器中的函数。另外,新的BPF_CALL指令使廉价调用内核函数成为可能。
将eBPF映射到本机指令的简便性使其适合即时编译,从而提高了性能。在 原来的补丁,对于eBPF增加的支持在3.15内核表明eBPF高达四倍的速度比x86-64的老经典BPF(CBPF)实现一些网络过滤微基准,其中多数是快1.5倍。许多架构都支持即时(JIT)编译器(x86-64,SPARC,PowerPC,ARM,arm64,MIPS和s390)。
最初,eBPF仅在内核内部使用,而cBPF程序在后台无缝翻译。但是随着2014年commit daedfb22451d的发布,eBPF虚拟机直接暴露给用户空间。
您可以使用eBPF做什么?
eBPF程序“附加”到内核中的指定代码路径。遍历代码路径时,将执行所有附加的eBPF程序。鉴于其起源,eBPF特别适合于编写网络程序,并且可以编写附加到网络套接字上的程序以过滤流量,对流量进行分类并运行网络分类器操作。甚至可以使用eBPF程序修改已建立的网络套接字的设置。该XDP项目,特别是使用eBPF通过在网络堆栈的最低级别运行eBPF程序,在接收到数据包后,立即做高性能的数据包处理。
内核执行的另一种过滤类型是限制进程可以使用的系统调用。这是通过seccomp BPF完成的。
eBPF对调试内核和执行性能分析也很有用。程序可以附加到跟踪点,kprobes和perf事件。由于eBPF程序可以访问内核数据结构,因此开发人员可以编写和测试新的调试代码,而不必重新编译内核。对于忙碌的工程师在运行中的系统上调试问题而言,其含义是显而易见的。甚至可以通过使用Userland静态定义的Tracepoint来使用eBPF调试用户空间程序。
eBPF的强大功能来自两个优点:快速且安全。要完全理解它,您需要了解它是如何工作的。
eBPF内核验证程序
允许用户空间代码在内核中运行存在固有的安全性和稳定性风险。因此,在加载每个eBPF程序之前,会对它们进行大量检查。第一个测试确保eBPF程序终止,并且不包含任何可能导致内核锁定的循环。这是通过对程序的控制流程图(CFG)进行深度优先搜索来检查的。严格禁止无法到达的指令;包含无法访问的指令的任何程序都将无法加载。
第二阶段涉及更多内容,并且要求验证程序一次模拟一次eBPF程序的执行。在执行每条指令之前和之后都要检查虚拟机状态,以确保寄存器和堆栈状态有效。禁止越界跳越,禁止访问越界数据。
验证程序不需要遍历程序中的每条路径,它足够聪明,可以知道程序的当前状态何时是已经检查过的状态的子集。由于所有先前路径都必须有效(否则程序将无法加载),因此当前路径也必须有效。这允许验证者“修剪”当前分支并跳过其仿真。
验证器还具有禁止指针算术的“安全模式”。每当具有CAP_SYS_ADMIN特权的用户加载eBPF程序时,就会启用安全模式 。这样做的目的是确保内核地址不会泄漏给非特权用户,并且指针不能写入内存。如果未启用安全模式,则仅在执行附加检查之后才允许指针算术。例如,检查所有指针访问是否违反类型,对齐方式和范围。
具有未初始化内容(从未写入的内容)的寄存器无法读取;这样做会导致程序加载失败。寄存器R0-R5的内容通过存储特殊值来捕获未初始化寄存器的所有读取,从而在函数调用中标记为不可读。进行了类似的检查以读取堆栈上的变量,并确保没有指令写入只读帧指针寄存器。
最后,验证者使用eBPF程序类型(稍后介绍)来限制可以从eBPF程序调用哪些内核功能以及可以访问哪些数据结构。例如,某些程序类型被允许直接访问网络数据包数据。
bpf()系统调用
使用bpf()系统调用和 BPF_PROG_LOAD命令来加载程序。系统调用的原型是:
int bpf(int cmd,联合bpf_attr * attr,无符号int大小);
该bpf_attr工会允许数据在内核和用户空间之间传递; 确切的格式取决于cmd参数。该 尺寸 参数给出的大小bpf_attr以字节为单位的工会对象。
提供了用于创建和修改eBPF映射的命令。映射是用于在eBPF程序与内核或用户空间之间进行通信的通用键/值数据结构。其他命令允许将eBPF程序附加到控制组目录或套接字文件描述符,遍历所有映射和程序,并将eBPF对象固定到文件,以便在加载它们的进程终止时不会破坏它们(后者由tc分类器/操作代码,这样eBPF程序就可以持久存在而无需继续加载过程)。完整的命令列表可以在bpf()手册页中找到。
尽管出现了许多不同的命令,但它们可以分为三类:用于eBPF程序的命令,用于eBPF映射的命令,或用于程序和映射的命令(统称为对象)。
eBPF程序类型
加载BPF_PROG_LOAD的程序的类型决定了四件事:可以在何处附加程序,验证程序将允许调用哪些内核内帮助函数,是否可以直接访问网络数据包数据,以及作为第一个传递的对象的类型该程序的参数。实际上,程序类型本质上定义了一个API。甚至纯粹是创建新程序类型来区分允许的可调用函数的不同列表(例如,BPF_PROG_TYPE_CGROUP_SKB与 BPF_PROG_TYPE_SOCKET_FILTER)。
内核支持的当前eBPF程序类型集为:
- BPF_PROG_TYPE_SOCKET_FILTER:网络数据包过滤器
- BPF_PROG_TYPE_KPROBE:确定是否应触发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_ *:用于轻型隧道的网络数据包过滤器
- BPF_PROG_TYPE_SOCK_OPS:用于设置套接字参数的程序
- BPF_PROG_TYPE_SK_SKB:网络数据包过滤器,用于在套接字之间转发数据包
- BPF_PROG_CGROUP_DEVICE:确定是否应该允许设备操作
随着添加了新的程序类型,内核开发人员也发现也需要添加新的数据结构。
eBPF数据结构
eBPF程序使用的主要数据结构是eBPF映射,eBPF映射是一种通用数据结构,它允许在内核内或内核与用户空间之间来回传递数据。顾名思义,“地图”使用键存储和检索数据。
使用bpf()系统调用来创建和处理地图。成功创建映射后,将返回与该映射关联的文件描述符。通常通过关闭关联的文件描述符来销毁地图。每个映射由四个值定义:类型,最大元素数,值大小(以字节为单位)和键大小(以字节为单位)。有不同的地图类型,每种地图类型提供不同的行为和权衡方案:
- BPF_MAP_TYPE_HASH:哈希表
- BPF_MAP_TYPE_ARRAY:数组映射,已针对快速查找速度进行了优化,通常用于计数器
- BPF_MAP_TYPE_PROG_ARRAY:对应于eBPF程序的文件描述符数组;用于实现跳转表和子程序以处理特定的数据包协议
- BPF_MAP_TYPE_PERCPU_ARRAY:每个CPU的阵列,用于实现延迟的直方图
- BPF_MAP_TYPE_PERF_EVENT_ARRAY:存储指向struct perf_event的指针,用于读取和存储perf事件计数器
- BPF_MAP_TYPE_CGROUP_ARRAY:存储指向控制组的指针
- BPF_MAP_TYPE_PERCPU_HASH:每个CPU的哈希表
- BPF_MAP_TYPE_LRU_HASH:仅保留最近使用项目的哈希表
- BPF_MAP_TYPE_LRU_PERCPU_HASH:每个CPU的哈希表,仅保留最近使用的项目
- BPF_MAP_TYPE_LPM_TRIE:最长前缀匹配树,适用于将IP地址匹配到某个范围
- BPF_MAP_TYPE_STACK_TRACE:存储堆栈跟踪
- BPF_MAP_TYPE_ARRAY_OF_MAPS:地图中地图数据结构
- BPF_MAP_TYPE_HASH_OF_MAPS:地图中地图数据结构
- BPF_MAP_TYPE_DEVICE_MAP:用于存储和查找网络设备引用
- BPF_MAP_TYPE_SOCKET_MAP:存储和查找套接字,并允许使用BPF帮助器函数进行套接字重定向
可以使用bpf_map_lookup_elem()和 bpf_map_update_elem()函数从eBPF或用户空间程序访问所有地图 。某些映射类型(例如套接字映射)可与执行特殊任务的其他eBPF帮助器功能一起使用。
如何编写eBPF程序
从历史上看,有必要手动编写eBPF汇编并使用内核的bpf_asm汇编器生成BPF字节码。幸运的是,LLVM Clang编译器已经增加了对eBPF后端的支持,该后端将C编译为字节码。然后可以使用bpf()系统调用和 BPF_PROG_LOAD命令直接加载包含该字节码的目标文件。
您可以使用-march = bpf参数与Clang一起编译,从而用C编写自己的eBPF程序。内核的samples / bpf / 目录中有许多eBPF程序示例;大多数文件名都有后缀“ _kern.c ”。Clang发出的目标文件(eBPF字节码)需要由计算机上本地运行的程序加载(这些示例的文件名通常带有“ _user.c ”)。为了使编写eBPF程序更容易,内核提供了libbpf库,该库包括用于加载程序以及创建和操作eBPF对象的帮助程序函数。例如,使用libbpf的eBPF程序和用户程序的高级流程 可能会像这样:
- 将eBPF字节码读取到用户应用程序的缓冲区中,并将其传递给bpf_load_program()。
- eBPF程序在由内核运行时,将调用 bpf_map_lookup_elem()在地图中查找元素并将其存储新值。
- 用户应用程序调用bpf_map_lookup_elem()来读取eBPF程序存储在内核中的值。
但是,所有示例代码都有一个主要缺点:您需要从内核源代码树中编译eBPF程序。幸运的是,创建了BCC项目来解决此问题。它包括一个完整的工具链,用于编写eBPF程序并加载它们,而无需链接内核源代码树。
BCC将在本系列的下一篇文章中介绍。全套是: