在分享这篇文章之前,先简单和大家说下背景。在之前的文章中作者分享了一些关于Service Mesh微服务架构的文章,在Service Mesh架构中需要通过SideCar代理的方式对应用容器流量进行劫持,并以此实现微服务治理相关的各种能力。但这种SideCar方式在微服务数量过多时会造成系统性能的降低,因为SideCar本质上来说,也是通过用户代码实现的网络代理来进行流量管控的。而eBPF则是一种替代SideCar的新式解决方案,它存在于操作系统的内核层级,在性能上表现更优。 因此目前关于Service Mesh微服务架构的技术方案开始逐步趋向于使用eBPF来替代原先的像Envoy这样的SideCar代理。本文的内容将详细介绍eBPF的前世今生,具体如下:
— 1 —
技术背景
eBPF 源于 BPF,本质上是处于内核中的一个高效与灵活的虚类虚拟机组件,以一种安全的方式在许多内核 hook 点执行字节码。BPF 最初的目的是用于高效网络报文过滤,经过重新设计,eBPF 不再局限于网络协议栈,已经成为内核顶级的子系统,演进为一个通用执行引擎。
开发者可基于 eBPF 开发性能分析工具、软件定义网络、安全等诸多场景。本文将介绍 eBPF 的前世今生,并构建一个 eBPF 环境进行开发实践,文中所有的代码可以在我的 GitHub[1] 中找到。
发展历史
BPF,是类 Unix 系统上数据链路层的一种原始接口,提供原始链路层封包的收发。1992 年,Steven McCanne 和 Van Jacobson 写了一篇名为《The BSD Packet Filter: A New Architecture for User-level Packet Capture[2]》的论文。在文中,作者描述了他们如何在 Unix 内核实现网络数据包过滤,这种新的技术比当时最先进的数据包过滤技术快 20 倍。
BPF 在数据包过滤上引入了两大革新:
- 一个新的虚拟机 (VM) 设计,可以有效地工作在基于寄存器结构的 CPU 之上
- 应用程序使用缓存只复制与过滤数据包相关的数据,不会复制数据包的所有信息,这样可以最大程度地减少BPF 处理的数据
由于这些巨大的改进,所有的 Unix 系统都选择采用 BPF 作为网络数据包过滤技术,直到今天,许多 Unix 内核的派生系统中(包括 Linux 内核)仍使用该实现。tcpdump 的底层采用 BPF 作为底层包过滤技术,我们可以在命令后面增加 -d 来查看 tcpdump 过滤条件的底层汇编指令。
代码语言:javascript复制$ tcpdump -d 'ip and tcp port 8080'
(000) ldh [12]
(001) jeq #0x800 jt 2 jf 12
(002) ldb [23]
(003) jeq #0x6 jt 4 jf 12
(004) ldh [20]
(005) jset #0x1fff jt 12 jf 6
(006) ldxb 4*([14]&0xf)
(007) ldh [x 14]
(008) jeq #0x1f90 jt 11 jf 9
(009) ldh [x 16]
(010) jeq #0x1f90 jt 11 jf 12
(011) ret #262144
(012) ret #0
2014 年初,Alexei Starovoitov 实现了 eBPF(extended Berkeley Packet Filter)。经过重新设计,eBPF 演进为一个通用执行引擎,可基于此开发性能分析工具、软件定义网络等诸多场景。
eBPF 最早出现在 3.18 内核中,此后原来的 BPF 就被称为经典 BPF,缩写 cBPF(classic BPF),cBPF 现在已经基本废弃。现在,Linux 内核只运行 eBPF,内核会将加载的 cBPF 字节码透明地转换成 eBPF 再执行。
eBPF 与 cBPF
eBPF 新的设计针对现代硬件进行了优化,所以 eBPF 生成的指令集比旧的 BPF 解释器生成的机器码执行得更快。扩展版本也增加了虚拟机中的寄存器数量,将原有的 2 个 32 位寄存器增加到 10 个 64 位寄存器。
由于寄存器数量和宽度的增加,开发人员可以使用函数参数自由交换更多的信息,编写更复杂的程序。总之,这些改进使 eBPF 版本的速度比原来的 BPF 提高了 4 倍。
维度 | cBPF | eBPF |
---|---|---|
内核版本 | Linux 2.1.75(1997 年) | Linux 3.18(2014 年)[4.x for kprobe/uprobe/tracepoint/perf-event] |
寄存器数目 | 2 个:A,X | 10个:R0–R9,另外 R10 是一个只读的帧指针R0:eBPF 中内核函数的返回值和退出值R1 - R5:eBF 程序在内核中的参数值R6 - R9:内核函数将保存的被调用者callee保存的寄存器R10:一个只读的堆栈帧指针 |
寄存器宽度 | 32 位 | 64 位 |
存储 | 16 个内存位: M[0–15] | 512 字节堆栈,无限制大小的 map 存储 |
限制的内核调用 | 非常有限,仅限于 JIT 特定 | 有限,通过 bpf_call 指令调用 |
目标事件 | 数据包、 seccomp-BPF | 数据包、内核函数、用户函数、跟踪点 PMCs 等 |
- R0:eBPF 中内核函数的返回值和退出值
- R1 - R5:eBF 程序在内核中的参数值
- R6 - R9:内核函数将保存的被调用者callee保存的寄存器
- R10:一个只读的堆栈帧指针
寄存器宽度 32 位 64 位 存储 16 个内存位: M[0–15] 512 字节堆栈,无限制大小的 map 存储 限制的内核调用 非常有限,仅限于 JIT 特定 有限,通过 bpf_call 指令调用 目标事件 数据包、 seccomp-BPF 数据包、内核函数、用户函数、跟踪点 PMCs 等
2014 年 6 月,eBPF 扩展到用户空间,这也成为了 BPF 技术的转折点。正如 Alexei 在提交补丁的注释中写到:「这个补丁展示了 eBPF 的潜力」。当前,eBPF 不再局限于网络栈,已经成为内核顶级的子系统。
eBPF 与内核模块
对比 Web 的发展,eBPF 与内核的关系有点类似于 JavaScript 与浏览器内核的关系,eBPF 相比于直接修改内核和编写内核模块提供了一种新的内核可编程的选项。eBPF 程序架构强调安全性和稳定性,看上去更像内核模块,但与内核模块不同,eBPF 程序不需要重新编译内核,并且可以确保 eBPF 程序运行完成,而不会造成系统的崩溃。
维度 | Linux 内核模块 | eBPF |
---|---|---|
kprobes/tracepoints | 支持 | 支持 |
安全性 | 可能引入安全漏洞或导致内核 Panic | 通过验证器进行检查,可以保障内核安全 |
内核函数 | 可以调用内核函数 | 只能通过 BPF Helper 函数调用 |
编译性 | 需要编译内核 | 不需要编译内核,引入头文件即可 |
运行 | 基于相同内核运行 | 基于稳定 ABI 的 BPF 程序可以编译一次,各处运行 |
与应用程序交互 | 打印日志或文件 | 通过 perf_event 或 map 结构 |
数据结构 | 丰富性 | 一般丰富 |
入门门槛 | 高 | 低 |
升级 | 需要卸载和加载,可能导致处理流程中断 | 原子替换升级,不会造成处理流程中断 |
内核内置 | 视情况而定 | 内核内置支持 |
eBPF 架构
eBPF 分为用户空间程序和内核程序两部分:
- 用户空间程序负责加载 BPF 字节码至内核,如需要也会负责读取内核回传的统计信息或者事件详情
- 内核中的 BPF 字节码负责在内核中执行特定事件,如需要也会将执行的结果通过 maps 或者 perf-event 事件发送至用户空间
- 其中用户空间程序与内核 BPF 字节码程序可以使用 map 结构实现双向通信,这为内核中运行的 BPF 字节码程序提供了更加灵活的控制
eBPF 整体结构图如下:
用户空间程序与内核中的 BPF 字节码交互的流程主要如下:
1、使用 LLVM 或者 GCC 工具将编写的 BPF 代码程序编译成 BPF 字节码
2、使用加载程序 Loader 将字节码加载至内核
3、内核使用验证器(Verfier) 组件保证执行字节码的安全性,以避免对内核造成灾难,在确认字节码安全后将其加载对应的内核模块执行
4、内核中运行的 BPF 字节码程序可以使用两种方式将数据回传至用户空间:
- maps 方式可用于将内核中实现的统计摘要信息(比如测量延迟、堆栈信息)等回传至用户空间;
- perf-event 用于将内核采集的事件实时发送至用户空间,用户空间程序实时读取分析。
eBPF 限制
eBPF 技术虽然强大,但是为了保证内核的处理安全和及时响应,内核中的 eBPF 技术也给予了诸多限制,当然随着技术的发展和演进,限制也在逐步放宽或者提供了对应的解决方案。
eBPF 程序不能调用任意的内核参数,只限于内核模块中列出的 BPF Helper 函数,函数支持列表也随着内核的演进在不断增加。
eBPF 程序不允许包含无法到达的指令,防止加载无效代码,延迟程序的终止。
eBPF 程序中循环次数限制且必须在有限时间内结束,这主要是用来防止在 kprobes 中插入任意的循环,导致锁住整个系统;解决办法包括展开循环,并为需要循环的常见用途添加辅助函数。Linux 5.3 在 BPF 中包含了对有界循环的支持,它有一个可验证的运行时间上限。
eBPF 堆栈大小被限制在 MAX_BPF_STACK,截止到内核 Linux 5.8 版本,被设置为 512;参见 include/linux/filter.h[3],这个限制特别是在栈上存储多个字符串缓冲区时:一个char[256]缓冲区会消耗这个栈的一半。目前没有计划增加这个限制,解决方法是改用 bpf 映射存储,它实际上是无限的。
代码语言:javascript复制/* BPF program can access up to 512 bytes of stack space. */
#define MAX_BPF_STACK 512
eBPF 字节码大小最初被限制为 4096 条指令,截止到内核 Linux 5.8 版本, 当前已将放宽至 100 万指令( BPF_COMPLEXITY_LIMIT_INSNS),参见:include/linux/bpf.h[4],对于无权限的BPF程序,仍然保留4096条限制 ( BPF_MAXINSNS );新版本的 eBPF 也支持了多个 eBPF 程序级联调用,虽然传递信息存在某些限制,但是可以通过组合实现更加强大的功能。
代码语言:javascript复制#define BPF_COMPLEXITY_LIMIT_INSNS 1000000 /* yes. 1M insns */
— 2 —
eBPF 实战
在深入介绍 eBPF 特性之前,让我们 Get Hands Dirty,切切实实的感受 eBPF 程序到底是什么,我们该如何开发 eBPF 程序。随着 eBPF 生态的演进,现在已经有越来越多的工具链用于开发 eBPF 程序,在后文也会详细介绍:
- 基于 bcc 开发:bcc 提供了对 eBPF 开发,前段提供 Python API,后端 eBPF 程序通过 C 实现。特点是简单易用,但是性能较差。
- 基于 libebpf-bootstrap 开发:libebpf-bootstrap 提供了一个方便的脚手架。
- 基于内核源码开发:内核源码开发门槛较高,但是也更加切合 eBPF 底层原理,所以这里以这个方法作为示例。
内核源码编译
系统环境如下,采用腾讯云 CVM,Ubuntu 20.04,内核版本 5.4.0。
代码语言:javascript复制$ uname -a
Linux VM-1-3-ubuntu 5.4.0-42-generic #46-Ubuntu SMP Fri Jul 10 00:24:02 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
首先安装必要依赖:
代码语言:javascript复制sudo apt install -y bison build-essential cmake flex git libedit-dev pkg-config libmnl-dev
python zlib1g-dev libssl-dev libelf-dev libcap-dev libfl-dev llvm clang pkg-config
gcc-multilib luajit libluajit-5.1-dev libncurses5-dev libclang-dev clang-tools
一般情况下推荐采用 apt 方式的安装源码,安装简单而且只安装当前内核的源码,源码的大小在 200M 左右。
代码语言:javascript复制# apt-cache search linux-source
# apt install linux-source-5.4.0
源码安装至 /usr/src/ 目录下。
代码语言:javascript复制$ ls -hl
total 4.0K
drwxr-xr-x 4 root root 4.0K Nov 9 13:22 linux-source-5.4.0
lrwxrwxrwx 1 root root 45 Oct 15 10:28 linux-source-5.4.0.tar.bz2 -> linux-source-5.4.0/linux-source-5.4.0.tar.bz2
$ tar -jxvf linux-source-5.4.0.tar.bz2
$ cd linux-source-5.4.0
$ cp -v /boot/config-$(uname -r) .config # make defconfig 或者 make menuconfig
$ make headers_install
$ make modules_prepare
$ make scripts # 可选
$ make M=samples/bpf # 如果配置出错,可以使用 make oldconfig && make prepare 修复
编译成功后,可以在 samples/bpf 目录下看到一系列的目标文件和二进制文件。
Hello World
前面说到 eBPF 通常由内核空间程序和用户空间程序两部分组成,现在 samples/bpf 目录下有很多这种程序,内核空间程序以 _kern.c 结尾,用户空间程序以 _user.c 结尾。先不看这些复杂的程序,我们手动写一个 eBPF 程序的 Hello World。
内核中的程序 hello_kern.c:
代码语言:javascript复制#include <linux/bpf.h>
#include "bpf_helpers.h"
#define SEC(NAME) __attribute__((section(NAME), used))
SEC("tracepoint/syscalls/sys_enter_execve")
int bpf_prog(void *ctx)
{
char msg[] = "Hello BPF from houmin!n";
bpf_trace_printk(msg, sizeof(msg));
return 0;
}
char _license[] SEC("license") = "GPL";
函数入口:
上述代码和普通的C语言编程有一些区别。
- 程序的入口通过编译器的 pragama __section("tracepoint/syscalls/sys_enter_execve") 指定的。
- 入口的参数不再是 argc, argv, 它根据不同的 prog type 而有所差别。我们的例子中,prog type 是 BPF_PROG_TYPE_TRACEPOINT, 它的入口参数就是 void *ctx。
头文件:
代码语言:javascript复制#include <linux/bpf.h>
这个头文件的来源是kernel source header file 。它安装在 /usr/include/linux/bpf.h中。
它提供了bpf 编程需要的很多symbol。例如:
- enum bpf_func_id 定义了所有的kerne helper function 的id
- enum bpf_prog_type 定义了内核支持的所有的prog 的类型。
- struct __sk_buff 是bpf 代码中访问内核struct sk_buff的接口。
等等
代码语言:javascript复制#include “bpf_helpers.h”
来自libbpf ,需要自行安装。我们引用这个头文件是因为调用了bpf_printk()。这是一个kernel helper function。
程序解释:
这里我们简单解读下内核态的 ebpf 程序,非常简单:
- bpf_trace_printk 是一个 eBPF helper 函数,用于打印信息到 trace_pipe (/sys/kernel/debug/tracing/trace_pipe),详见这里[5]
- 代码声明了 SEC 宏,并且定义了 GPL 的 License,这是因为加载进内核的 eBPF 程序需要有 License 检查,类似于内核模块
加载 BPF 代码:
用户态程序 hello_user.c:
代码语言:javascript复制#include <stdio.h>
#include "bpf_load.h"
int main(int argc, char **argv)
{
if(load_bpf_file("hello_kern.o") != 0)
{
printf("The kernel didn't load BPF programn");
return -1;
}
read_trace_pipe();
return 0;
}
在用户态 ebpf 程序中,解读如下:
- 通过 load_bpf_file 将编译出的内核态 ebpf 目标文件加载到内核
- 通过 read_trace_pipe 从 trace_pipe 读取 trace 信息,打印到控制台中
修改 samples/bpf 目录下的 Makefile 文件,在对应的位置添加以下三行:
代码语言:javascript复制hostprogs-y = hello
hello-objs := bpf_load.o hello_user.o
always = hello_kern.o
重新编译,可以看到编译成功的文件:
代码语言:javascript复制$ make M=samples/bpf
$ ls -hl samples/bpf/hello*
-rwxrwxr-x 1 ubuntu ubuntu 404K Mar 30 17:48 samples/bpf/hello
-rw-rw-r-- 1 ubuntu ubuntu 317 Mar 30 17:47 samples/bpf/hello_kern.c
-rw-rw-r-- 1 ubuntu ubuntu 3.8K Mar 30 17:48 samples/bpf/hello_kern.o
-rw-rw-r-- 1 ubuntu ubuntu 246 Mar 30 17:47 samples/bpf/hello_user.c
-rw-rw-r-- 1 ubuntu ubuntu 2.2K Mar 30 17:48 samples/bpf/hello_user.o
进入到对应的目录运行 hello 程序,可以看到输出结果如下:
代码语言:javascript复制$ sudo ./hello
<...>-102735 [001] .... 6733.481740: 0: Hello BPF from houmin!
<...>-102736 [000] .... 6733.482884: 0: Hello BPF from houmin!
<...>-102737 [002] .... 6733.483074: 0: Hello BPF from houmin!
代码解读
前面提到 load_bpf_file 函数将 LLVM 编译出来的 eBPF 字节码加载进内核,这到底是如何实现的呢?
经过搜查,可以看到 load_bpf_file 也是在 samples/bpf 目录下实现的,具体的参见 bpf_load.c[6]。
阅读 load_bpf_file 代码可以看到,它主要是解析 ELF 格式的 eBPF 字节码,然后调用 load_and_attach[7] 函数。
在 load_and_attach 函数中,我们可以看到其调用了 bpf_load_program 函数,这是 libbpf 提供的函数。
调用的 bpf_load_program 中的 license、kern_version 等参数来自于解析 eBPF ELF 文件,prog_type 来自于 bpf 代码里面 SEC 字段指定的类型。
代码语言:javascript复制static int load_and_attach(const char *event, struct bpf_insn *prog, int size)
{
bool is_socket = strncmp(event, "socket", 6) == 0;
bool is_kprobe = strncmp(event, "kprobe/", 7) == 0;
bool is_kretprobe = strncmp(event, "kretprobe/", 10) == 0;
bool is_tracepoint = strncmp(event, "tracepoint/", 11) == 0;
bool is_raw_tracepoint = strncmp(event, "raw_tracepoint/", 15) == 0;
bool is_xdp = strncmp(event, "xdp", 3) == 0;
bool is_perf_event = strncmp(event, "perf_event", 10) == 0;
bool is_cgroup_skb = strncmp(event, "cgroup/skb", 10) == 0;
bool is_cgroup_sk = strncmp(event, "cgroup/sock", 11) == 0;
bool is_sockops = strncmp(event, "sockops", 7) == 0;
bool is_sk_skb = strncmp(event, "sk_skb", 6) == 0;
bool is_sk_msg = strncmp(event, "sk_msg", 6) == 0;
//...
fd = bpf_load_program(prog_type, prog, insns_cnt, license, kern_version,
bpf_log_buf, BPF_LOG_BUF_SIZE);
if (fd < 0) {
printf("bpf_load_program() err=%dn%s", errno, bpf_log_buf);
return -1;
}
//...
}
— 3 —
eBPF 特性
Hook Overview
eBPF 程序都是事件驱动的,它们会在内核或者应用程序经过某个确定的 Hook 点的时候运行,这些 Hook 点都是提前定义的,包括系统调用、函数进入/退出、内核 tracepoints、网络事件等。
如果针对某个特定需求的 Hook 点不存在,可以通过 kprobe 或者 uprobe 来在内核或者用户程序的几乎所有地方挂载 eBPF 程序。
Verification
With great power there must also come great responsibility.
每一个 eBPF 程序加载到内核都要经过 Verification,用来保证 eBPF 程序的安全性,主要包括:
要保证加载 eBPF 程序的进程有必要的特权级,除非节点开启了 unpriviledged 特性,只有特权级的程序才能够加载 eBPF 程序。
1、内核提供了一个配置项 /proc/sys/kernel/unprivileged_bpf_disabled 来禁止非特权用户使用 bpf(2) 系统调用,可以通过 sysctl 命令修改
2、比较特殊的一点是,这个配置项特意设计为一次性开关(one-time kill switch), 这意味着一旦将它设为 1,就没有办法再改为 0 了,除非重启内核
3、一旦设置为 1 之后,只有初始命名空间中有 CAP_SYS_ADMIN 特权的进程才可以调用 bpf(2) 系统调用 。Cilium 启动后也会将这个配置项设为 1:
代码语言:javascript复制$ echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled
要保证 eBPF 程序不会崩溃或者使得系统出故障。
要保证 eBPF 程序不能陷入死循环,能够 runs to completion。
要保证 eBPF 程序必须满足系统要求的大小,过大的 eBPF 程序不允许被加载进内核。
要保证 eBPF 程序的复杂度有限,Verifier 将会评估 eBPF 程序所有可能的执行路径,必须能够在有限时间内完成 eBPF 程序复杂度分析。
JIT Compilation
Just-In-Time(JIT)编译用来将通用的 eBPF 字节码翻译成与机器相关的指令集,从而极大加速 BPF 程序的执行:
- 与解释器相比,它们可以降低每个指令的开销。通常,指令可以 1:1 映射到底层架构的原生指令
- 这也会减少生成的可执行镜像的大小,因此对 CPU 的指令缓存更友好
- 特别地,对于 CISC 指令集(例如 x86),JIT 做了很多特殊优化,目的是为给定的指令产生可能的最短操作码,以降低程序翻译过程所需的空间
64 位的 x86_64、arm64、ppc64、s390x、mips64、sparc64 和 32 位的 arm 、x86_32 架构都内置了 in-kernel eBPF JIT 编译器,它们的功能都是一样的,可以用如下方式打开:
代码语言:javascript复制$ echo 1 > /proc/sys/net/core/bpf_jit_enable
32 位的 mips、ppc 和 sparc 架构目前内置的是一个 cBPF JIT 编译器。这些只有 cBPF JIT 编译器的架构,以及那些甚至完全没有 BPF JIT 编译器的架构,需要通过内核中的解释器(in-kernel interpreter)执行 eBPF 程序。
要判断哪些平台支持 eBPF JIT,可以在内核源文件中 grep HAVE_EBPF_JIT:
代码语言:javascript复制$ git grep HAVE_EBPF_JIT arch/
arch/arm/Kconfig: select HAVE_EBPF_JIT if !CPU_ENDIAN_BE32
arch/arm64/Kconfig: select HAVE_EBPF_JIT
arch/powerpc/Kconfig: select HAVE_EBPF_JIT if PPC64
arch/mips/Kconfig: select HAVE_EBPF_JIT if (64BIT && !CPU_MICROMIPS)
arch/s390/Kconfig: select HAVE_EBPF_JIT if PACK_STACK && HAVE_MARCH_Z196_FEATURES
arch/sparc/Kconfig: select HAVE_EBPF_JIT if SPARC64
arch/x86/Kconfig: select HAVE_EBPF_JIT if X86_64
Maps
BPF Map 是驻留在内核空间中的高效 Key/Value store,包含多种类型的 Map,由内核实现其功能,具体实现可以参考我的这篇博文[8]。
BPF Map 的交互场景有以下几种:
- BPF 程序和用户态程序的交互:BPF 程序运行完,得到的结果存储到 map 中,供用户态程序通过文件描述符访问
- BPF 程序和内核态程序的交互:和 BPF 程序以外的内核程序交互,也可以使用 map 作为中介
- BPF 程序间交互:如果 BPF 程序内部需要用全局变量来交互,但是由于安全原因 BPF 程序不允许访问全局变量,可以使用 map 来充当全局变量
- BPF Tail call:Tail call 是一个BPF程序跳转到另一BPF程序,BPF程序首先通过 BPF_MAP_TYPE_PROG_ARRAY 类型的 map 来知道另一个BPF程序的指针,然后调用 tail_call() 的 helper function 来执行Tail call
共享 map 的 BPF 程序不要求是相同的程序类型,例如 tracing 程序可以和网络程序共享 map,单个 BPF 程序目前最多可直接访问 64 个不同 map。
当前可用的通用 map 有:
- BPF_MAP_TYPE_HASH
- BPF_MAP_TYPE_ARRAY
- BPF_MAP_TYPE_PERCPU_HASH
- BPF_MAP_TYPE_PERCPU_ARRAY
- BPF_MAP_TYPE_LRU_HASH
- BPF_MAP_TYPE_LRU_PERCPU_HASH
- BPF_MAP_TYPE_LPM_TRIE
以上 map 都使用相同的一组 BPF 辅助函数来执行查找、更新或删除操作,但各自实现了不同的后端,这些后端各有不同的语义和性能特点。随着多CPU架构的成熟发展,BPF Map也引入了 per-cpu 类型,如BPF_MAP_TYPE_PERCPU_HASH、BPF_MAP_TYPE_PERCPU_ARRAY等。
当你使用这种类型的BPF Map时,每个 CPU 都会存储并看到它自己的 Map 数据,从属于不同 CPU 之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的 BPF 程序主要是在做收集时间序列型数据,如流量数据或指标等。
当前内核中的非通用 map 有:
- BPF_MAP_TYPE_PROG_ARRAY:一个数组 map,用于 hold 其他的 BPF 程序
- BPF_MAP_TYPE_PERF_EVENT_ARRAY
- BPF_MAP_TYPE_CGROUP_ARRAY:用于检查 skb 中的 cgroup2 成员信息
- BPF_MAP_TYPE_STACK_TRACE:用于存储栈跟踪的 MAP
- BPF_MAP_TYPE_ARRAY_OF_MAPS:持有(hold) 其他 map 的指针,这样整个 map 就可以在运行时实现原子替换
- BPF_MAP_TYPE_HASH_OF_MAPS:持有(hold) 其他 map 的指针,这样整个 map 就可以在运行时实现原子替换
Helper Calls
eBPF 程序不能够随意调用内核函数,如果这么做的话会导致 eBPF 程序与特定的内核版本绑定,相反它内核定义的一系列 Helper functions。Helper functions 使得 BPF 能够通过一组内核定义的稳定的函数调用来从内核中查询数据,或者将数据推送到内核。
所有的 BPF 辅助函数都是核心内核的一部分,无法通过内核模块来扩展或添加。当前可用的 BPF 辅助函数已经有几十个,并且数量还在不断增加,你可以在《Linux Manual Page: bpf-helpers[9]》看到当前 Linux 支持的 Helper functions。
不同类型的 BPF 程序能够使用的辅助函数可能是不同的,例如:
- 与 attach 到 tc 层的 BPF 程序相比,attach 到 socket 的 BPF程序只能够调用前者可以调用的辅助函数的一个子集
- lightweight tunneling 使用的封装和解封装辅助函数,只能被更低的 tc 层使用;而推送通知到用户态所使用的事件输出辅助函数,既可以被 tc 程序使用也可以被 XDP 程序使用
所有的辅助函数都共享同一个通用的、和系统调用类似的函数方法,其定义如下:
代码语言:javascript复制u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)
内核将辅助函数抽象成 BPF_CALL_0() 到 BPF_CALL_5() 几个宏,形式和相应类型的系统调用类似,这里宏的定义可以参见 include/linux/filter.h 。以 bpf_map_update_elem 为例,可以看到它通过调用相应 map 的回调函数完成更新 map 元素的操作:
代码语言:javascript复制/kernel/bpf/helpers.c
BPF_CALL_4(bpf_map_update_elem, struct bpf_map *, map, void *, key,
void *, value, u64, flags)
{
WARN_ON_ONCE(!rcu_read_lock_held());
return map->ops->map_update_elem(map, key, value, flags);
}
const struct bpf_func_proto bpf_map_update_elem_proto = {
.func = bpf_map_update_elem,
.gpl_only = false,
.ret_type = RET_INTEGER,
.arg1_type = ARG_CONST_MAP_PTR,
.arg2_type = ARG_PTR_TO_MAP_KEY,
.arg3_type = ARG_PTR_TO_MAP_VALUE,
.arg4_type = ARG_ANYTHING,
};
这种方式有很多优点:
- 虽然 cBPF 允许其加载指令(load instructions)进行超出范围的访问(overload),以便从一个看似不可能的包偏移量(packet offset)获取数据以唤醒多功能辅助函数,但每个 cBPF JIT 仍然需要为这个 cBPF 扩展实现对应的支持。 而在 eBPF 中,JIT 编译器会以一种透明和高效的方式编译新加入的辅助函数,这意味着 JIT 编 译器只需要发射(emit)一条调用指令(call instruction),因为寄存器映射的方式使得 BPF 排列参数的方式(assignments)已经和底层架构的调用约定相匹配了。 这使得基于辅助函数扩展核心内核(core kernel)非常方便。所有的 BPF 辅助函数都是核心内核的一部分,无法通过内核模块(kernel module)来扩展或添加。
- 前面提到的函数签名还允许校验器执行类型检测(type check)。上面的 struct bpf_func_proto 用于存放校验器必需知道的所有关于该辅助函数的信息,这样校验器可以确保辅助函数期望的类型和 BPF 程序寄存器中的当前内容是匹配的。
- 参数类型范围很广,从任意类型的值,到限制只能为特定类型,例如 BPF 栈缓冲区(stack buffer)的 pointer/size 参数对,辅助函数可以从这个位置读取数据或向其写入数据。对于这种情况,校验器还可以执行额外的检查,例如,缓冲区是否已经初始化过了。
Tail Calls
尾调用的机制是指:一个 BPF 程序可以调用另一个 BPF 程序,并且调用完成后不用返回到原来的程序。
- 和普通函数调用相比,这种调用方式开销最小,因为它是用长跳转(long jump)实现的,复用了原来的栈帧 (stack frame)
- BPF 程序都是独立验证的,因此要传递状态,要么使用 per-CPU map 作为 scratch 缓冲区 ,要么如果是 tc 程序的话,还可以使用 skb 的某些字段(例如 cb[])
- 相同类型的程序才可以尾调用,而且它们还要与 JIT 编译器相匹配,因此要么是 JIT 编译执行,要么是解释器执行(invoke interpreted programs),但不能同时使用两种方式
BPF to BPF Calls
除了 BPF 辅助函数和 BPF 尾调用之外,BPF 核心基础设施最近刚加入了一个新特性:BPF to BPF calls。在这个特性引入内核之前,典型的 BPF C 程序必须将所有需要复用的代码进行特殊处理,例如,在头文件中声明为 always_inline。当 LLVM 编译和生成 BPF 对象文件时,所有这些函数将被内联,因此会在生成的对象文件中重 复多次,导致代码尺寸膨胀:
代码语言:javascript复制#include <linux/bpf.h>
#ifndef __section
# define __section(NAME)
__attribute__((section(NAME), used))
#endif
#ifndef __inline
# define __inline
inline __attribute__((always_inline))
#endif
static __inline int foo(void)
{
return XDP_DROP;
}
__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
return foo();
}
char __license[] __section("license") = "GPL";
之所以要这样做是因为 BPF 程序的加载器、校验器、解释器和 JIT 中都缺少对函数调用的支持。从 Linux 4.16 和 LLVM 6.0 开始,这个限制得到了解决,BPF 程序不再需要到处使用 always_inline 声明了。因此,上面的代码可以更自然地重写为:
代码语言:javascript复制#include <linux/bpf.h>
#ifndef __section
# define __section(NAME)
__attribute__((section(NAME), used))
#endif
static int foo(void)
{
return XDP_DROP;
}
__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
return foo();
}
char __license[] __section("license") = "GPL";
BPF 到 BPF 调用是一个重要的性能优化,极大减小了生成的 BPF 代码大小,因此 对 CPU 指令缓存(instruction cache,i-cache)更友好。
BPF 辅助函数的调用约定也适用于 BPF 函数间调用:
- r1 - r5 用于传递参数,返回结果放到 r0
- r1 - r5 是 scratch registers,r6 - r9 像往常一样是保留寄存器
- 最大嵌套调用深度是 8
- 调用方可以传递指针(例如,指向调用方的栈帧的指针) 给被调用方,但反过来不行
当前,BPF 函数间调用和 BPF 尾调用是不兼容的,因为后者需要复用当前的栈设置( stack setup),而前者会增加一个额外的栈帧,因此不符合尾调用期望的布局。
BPF JIT 编译器为每个函数体发射独立的镜像(emit separate images for each function body),稍后在最后一通 JIT 处理(final JIT pass)中再修改镜像中函数调用的地址 。已经证明,这种方式需要对各种 JIT 做最少的修改,因为在实现中它们可以将 BPF 函数间调用当做常规的 BPF 辅助函数调用。
Object Pinning
BPF map 和程序作为内核资源只能通过文件描述符访问,其背后是内核中的匿名 inode。这带来了很多优点:
- 用户空间应用程序能够使用大部分文件描述符相关的 API
- 传递给 Unix socket 的文件描述符是透明工作等等
但同时,文件描述符受限于进程的生命周期,使得 map 共享之类的操作非常笨重,这给某些特定的场景带来了很多复杂性。
例如 iproute2,其中的 tc 或 XDP 在准备环境、加载程序到内核之后最终会退出。在这种情况下,从用户空间也无法访问这些 map 了,而本来这些 map 其实是很有用的。例如,在 data path 的 ingress 和 egress 位置共享的 map(可以统计包数、字节数、PPS 等信息)。另外,第三方应用可能希望在 BPF 程序运行时监控或更新 map。
为了解决这个问题,内核实现了一个最小内核空间 BPF 文件系统,BPF map 和 BPF 程序都可以 pin 到这个文件系统内,这个过程称为 object pinning。BPF 相关的文件系统不是单例模式(singleton),它支持多挂载实例、硬链接、软连接等等。
相应的,BPF 系统调用扩展了两个新命令,如下图所示:
- BPF_OBJ_PIN:钉住一个对象
- BPF_OBJ_GET:获取一个被钉住的对象
Hardening
1、Protection Execution Protection
为了避免代码被损坏,BPF 会在程序的生命周期内,在内核中将 BPF 解释器解释后的整个镜像(struct bpf_prog)和 JIT 编译之后的镜像(struct bpf_binary_header)锁定为只读的。在这些位置发生的任何数据损坏(例如由于某些内核 bug 导致的)会触发通用的保护机制,因此会造成内核崩溃而不是允许损坏静默地发生。
查看哪些平台支持将镜像内存(image memory)设置为只读的,可以通过下面的搜索:
代码语言:javascript复制$ git grep ARCH_HAS_SET_MEMORY | grep select
arch/arm/Kconfig: select ARCH_HAS_SET_MEMORY
arch/arm64/Kconfig: select ARCH_HAS_SET_MEMORY
arch/s390/Kconfig: select ARCH_HAS_SET_MEMORY
arch/x86/Kconfig: select ARCH_HAS_SET_MEMORY
CONFIG_ARCH_HAS_SET_MEMORY 选项是不可配置的,因此平台要么内置支持,要么不支持,那些目前还不支持的架构未来可能也会支持。
2、Mitigation Against Spectre
为了防御 Spectre v2 攻击,Linux 内核提供了 CONFIG_BPF_JIT_ALWAYS_ON 选项,打开这个开关后 BPF 解释器将会从内核中完全移除,永远启用 JIT 编译器:
- 如果应用在一个基于虚拟机的环境,客户机内核将不会复用内核的 BPF 解释器,因此可以避免某些相关的攻击
- 如果是基于容器的环境,这个配置是可选的,如果 JIT 功能打开了,解释器仍然可能会在编译时被去掉,以降低内核的复杂度
- 对于主流架构(例如 x86_64 和 arm64)上的 JIT 通常都建议打开这个开关
将 /proc/sys/net/core/bpf_jit_harden 设置为 1 会为非特权用户的 JIT 编译做一些额外的加固工作。这些额外加固会稍微降低程序的性能,但在有非受信用户在系统上进行操作的情况下,能够有效地减小潜在的受攻击面。
但与完全切换到解释器相比,这些性能损失还是比较小的。对于 x86_64 JIT 编译器,如果设置了 CONFIG_RETPOLINE,尾调用的间接跳转( indirect jump)就会用 retpoline 实现。写作本文时,在大部分现代 Linux 发行版上这个配置都是打开的。
3、Constant Blinding
当前,启用加固会在 JIT 编译时盲化(blind)BPF 程序中用户提供的所有 32 位和 64 位常量,以防御 JIT spraying攻击,这些攻击会将原生操作码作为立即数注入到内核。
这种攻击有效是因为:立即数驻留在可执行内核内存(executable kernel memory)中,因此某些内核 bug 可能会触发一个跳转动作,如果跳转到立即数的开始位置,就会把它们当做原生指令开始执行。
盲化 JIT 常量通过对真实指令进行随机化(randomizing the actual instruction)实现 。在这种方式中,通过对指令进行重写,将原来基于立即数的操作转换成基于寄存器的操作。指令重写将加载值的过程分解为两部分:
- 加载一个盲化后的(blinded)立即数 rnd ^ imm 到寄存器
- 将寄存器和 rnd 进行异或操作(xor)
这样原始的 imm 立即数就驻留在寄存器中,可以用于真实的操作了。这里介绍的只是加载操作的盲化过程,实际上所有的通用操作都被盲化了。下面是加固关闭的情况下,某个程序的 JIT 编译结果:
代码语言:javascript复制$ echo 0 > /proc/sys/net/core/bpf_jit_harden
ffffffffa034f5e9 <x>:
[...]
39: mov $0xa8909090,�x
3e: mov $0xa8909090,�x
43: mov $0xa8ff3148,�x
48: mov $0xa89081b4,�x
4d: mov $0xa8900bb0,�x
52: mov $0xa810e0c1,�x
57: mov $0xa8908eb4,�x
5c: mov $0xa89020b0,�x
[...]
加固打开之后,以上程序被某个非特权用户通过 BPF 加载的结果(这里已经进行了常量盲化):
代码语言:javascript复制$ echo 1 > /proc/sys/net/core/bpf_jit_harden
ffffffffa034f1e5 <x>:
[...]
39: mov $0xe1192563,%r10d
3f: xor $0x4989b5f3,%r10d
46: mov %r10d,�x
49: mov $0xb8296d93,%r10d
4f: xor $0x10b9fd03,%r10d
56: mov %r10d,�x
59: mov $0x8c381146,%r10d
5f: xor $0x24c7200e,%r10d
66: mov %r10d,�x
69: mov $0xeb2a830e,%r10d
6f: xor $0x43ba02ba,%r10d
76: mov %r10d,�x
79: mov $0xd9730af,%r10d
7f: xor $0xa5073b1f,%r10d
86: mov %r10d,�x
89: mov $0x9a45662b,%r10d
8f: xor $0x325586ea,%r10d
96: mov %r10d,�x
[...]
两个程序在语义上是一样的,但在第二种方式中,原来的立即数在反汇编之后的程序中不再可见。同时,加固还会禁止任何 JIT 内核符合(kallsyms)暴露给特权用户,JIT 镜像地址不再出现在 /proc/kallsyms 中。
Offloads
BPF 网络程序,尤其是 tc 和 XDP BPF 程序在内核中都有一个 offload 到硬件的接口,这样就可以直接在网卡上执行 BPF 程序。
当前,Netronome 公司的 nfp 驱动支持通过 JIT 编译器 offload BPF,它会将 BPF 指令翻译成网卡实现的指令集。另外,它还支持将 BPF maps offload 到网卡,因此 offloaded BPF 程序可以执行 map 查找、更新和删除操作。
— 4 —
eBPF 接口
BPF 系统调用
eBPF 提供了 bpf() 系统调用来对 BPF Map 或 程序进行操作,其函数原型如下:
代码语言:javascript复制#include <linux/bpf.h>
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
函数有三个参数,其中:
- cmd 指定了 bpf 系统调用执行的命令类型,每个 cmd 都会附带一个参数 attr
- bpf_attr union 允许在内核和用户空间之间传递数据,确切的格式取决于 cmd 这个参数
- size 这个参数表示bpf_attr union 这个对象以字节为单位的大小
cmd 可以为一下几种类型,基本上可以分为操作 eBPF Map 和操作 eBPF 程序两种类型:
- BPF_MAP_CREATE:创建一个 eBPF Map 并且返回指向该 Map 的文件描述符
- BPF_MAP_LOOKUP_ELEM:在某个 Map 中根据 key 查找元素并返回其 value
- BPF_MAP_UPDATE_ELEM:在某个 Map 中创建或者更新一个元素 key/value 对
- BPF_MAP_DELETE_ELEM:在某个 Map 中根据 key 删除一个元素
- BPF_MAP_GET_NEXT_KEY:在某个 Map 中根据 key 查找元素然后返回下一个元素的 key
- BPF_PROG_LOAD:校验并加载 eBPF 程序,返回与该程序关联的文件描述符
- ……
bpf_attr union 的结构如下所示,根据不同的 cmd 可以填充不同的信息。
代码语言:javascript复制union bpf_attr {
struct { /* Used by BPF_MAP_CREATE */
__u32 map_type;
__u32 key_size; /* size of key in bytes */
__u32 value_size; /* size of value in bytes */
__u32 max_entries; /* maximum number of entries in a map */
};
struct { /* Used by BPF_MAP_*_ELEM and BPF_MAP_GET_NEXT_KEY commands */
__u32 map_fd;
__aligned_u64 key;
union {
__aligned_u64 value;
__aligned_u64 next_key;
};
__u64 flags;
};
struct { /* Used by BPF_PROG_LOAD */
__u32 prog_type;
__u32 insn_cnt;
__aligned_u64 insns; /* 'const struct bpf_insn *' */
__aligned_u64 license; /* 'const char *' */
__u32 log_level; /* verbosity level of verifier */
__u32 log_size; /* size of user buffer */
__aligned_u64 log_buf; /* user supplied 'char *' buffer */
__u32 kern_version; /* checked when prog_type=kprobe (since Linux 4.1) */
};
} __attribute__((aligned(8)));
使用 eBPF 程序的命令:
BPF_PROG_LOAD 命令用于校验和加载 eBPF 程序,其需要填充的参数 bpf_xattr,下面展示了在 libbpf 中 bpf_load_program 的实现,可以看到最终是调用了 bpf 系统调用。
代码语言:javascript复制/tools/lib/bpf/bpf.c
int bpf_load_program(enum bpf_prog_type type, const struct bpf_insn *insns,
size_t insns_cnt, const char *license,
__u32 kern_version, char *log_buf,
size_t log_buf_sz)
{
struct bpf_load_program_attr load_attr;
memset(&load_attr, 0, sizeof(struct bpf_load_program_attr));
load_attr.prog_type = type;
load_attr.expected_attach_type = 0;
load_attr.name = NULL;
load_attr.insns = insns;
load_attr.insns_cnt = insns_cnt;
load_attr.license = license;
load_attr.kern_version = kern_version;
return bpf_load_program_xattr(&load_attr, log_buf, log_buf_sz);
}
int bpf_load_program_xattr(const struct bpf_load_program_attr *load_attr,
char *log_buf, size_t log_buf_sz)
{
// ...
fd = sys_bpf_prog_load(&attr, sizeof(attr));
if (fd >= 0)
return fd;
// ...
}
static inline int sys_bpf_prog_load(union bpf_attr *attr, unsigned int size)
{
int fd;
do {
fd = sys_bpf(BPF_PROG_LOAD, attr, size);
} while (fd < 0 && errno == EAGAIN);
return fd;
}
使用 eBPF Map 的命令:
和前面一样,查看 libbpf 中 bpf_create_map 的实现,可以看到最终也调用了 bpf 系统调用:
代码语言:javascript复制/tools/lib/bpf/bpf.c
int bpf_create_map(enum bpf_map_type map_type, int key_size,
int value_size, int max_entries, __u32 map_flags)
{
struct bpf_create_map_attr map_attr = {};
map_attr.map_type = map_type;
map_attr.map_flags = map_flags;
map_attr.key_size = key_size;
map_attr.value_size = value_size;
map_attr.max_entries = max_entries;
return bpf_create_map_xattr(&map_attr);
}
int bpf_create_map_xattr(const struct bpf_create_map_attr *create_attr)
{
union bpf_attr attr;
memset(&attr, '