1)技术背景
在eBPF诞生之前,对内核的调试和开发有着相当高的门槛,不仅要十分熟悉庞大的内核代码及开发流程,同时重新编译内核后若希望生效还需要重启OS,开发效率也相当低下。而eBPF提供了相当友好的内核开发/观测机制,即:由用户编写符合一定规范的代码,编译后加载至内核,内核会在指定的时机执行这段代码,内核同时还会将Hook点相关的上下文传递给这段代码供使用,代码可以修改上下文,或是通过返回值来改变内核接下来的行为。
毫无疑问这个机制是突破性的,丰富的挂载点使得我们几乎可以在内核的任意位置执行eBPF程序,这些程序可以输出内核状态或当前事件的上下文,甚至可以影响内核对该事件的处理,例如我们可以编写一个eBPF程序挂载至网络包传输的事件上,通过这段程序的逻辑来决定是否将该包丢弃,从而实现一个类似防火墙的功能。事实上,内核提供了大量的挂载点,算上kprobe挂载点,你几乎可以在内核代码的任意函数挂载一段eBPF程序。linux kernel从3.15开始支持BPF,到现在经历了大量迭代,这里有所有BPF相关特性与其开始支持的内核版本的完整列表。
2)eBPF程序的形态
一个完整eBPF程序通常由两部分组成,我称它们为eBPF程序和用户态程序,eBPF程序是被内核在Hook点调用并执行的程序,这个比较容易理解,而用户态程序则通常扮演以下角色:
-
加载eBPF程序到内核
你可以用多种语言(C/C++/Golang/Rust)开发用户态程序,但是,由于eBPF程序需要在内核中执行,所以类似Golang这种带有GC机制的语言则完全不可能被允许用于开发eBPF程序,目前eBPF程序只能够使用C和Rust开发。
3)eBPF程序类型
我们之前提到,eBPF程序可以在内核各处触发执行,然而不同的内核事件必然存在着完全不同的上下文,为此,eBPF提供了多种eBPF程序,你可以通过内核代码文件(这个是目前最全的)来看到内核提供的所有eBPF程序类型。例如BPF_PROG_TYPE_SOCKOPS是在Socket相关代码中触发的eBPF程序类型,BPF_PROG_TYPE_SK_MSG则是在sendmsg系统调用时触发的eBPF程序类型。
4)eBPF Helper functions
eBPF程序运行在内核中,无法也完全不需要像其他用户态应用程序一样调用系统调用来与OS交互,为了使得eBPF程序能够与操作系统或上下文进行交互,kernel提供了eBPF程序的“专属API集合”,它们就是BPF Helper functions。以下是文档介绍的节选。
These helpers are used by eBPF programs to interact with the system, or with the context in which they work. For instance, they can be used to print debugging messages, to get the time since the system was booted, to interact with eBPF maps, or to manipulate network packets.
我们在上文提到过,eBPF程序有多种类型,每一种类型的上下文一定是不同的,那么为了与不同的上下文交互,每一种eBPF程序都只能调用其类型对应的一组eBPF Helper functions,以下是文档中对这部分的描述。
Since there are several eBPF program types, and that they do not run in the same context, each program type can only call a subset of those helpers.
5)eBPF Map
很多时候,我们希望在eBPF程序之间,或是eBPF程序与用户态程序之间传递一些信息,例如eBPF程序收集内核事件相关数据,传递到用户态程序打印日志或进一步处理;或是eBPF程序之间共同协作时共享数据。在用户态编程中我们用到的通信手段在eBPF的场景下全部无法使用,于是,内核提供了eBPF Map作为原生的数据共享机制,文档中是这样描述的:
eBPF maps are a generic data structure for storage of different data types. Data types are generally treated as binary blobs, so a user just specifies the size of the key and the size of the value at map-creation time. In other words, a key/value for a given map can have an arbitrary structure.
eBPF Map为了满足不同的场景衍生出了多种多样的类型,如下图所示:
你可以在eBPF程序中定义一个Map,代码看起来大致是这样:
struct bpf_map SEC("maps") map = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(int),
.value_size = sizeof(int),
.max_entries = 1,
}
除了指定key和value的大小,你还需要指定这个map最大可以存储多少条数据 -- 运行在内核的程序必须每个角落都明明白白,示例代码使用的TYPE是BPF_MAP_TYPE_HASH,这种类型的Map与传统意义上的Map一致,为Key/Value映射数据结构,虽然打着MAP的名头,实际上还有BPF_MAP_TYPE_ARRYA类型,若使用这种类型的话,数据结构则是一个数组。
如果希望对eBPF Map进行操作,则需要使用eBPF Map相关的一组eBPF Helper function,主要有以下三个,分别用于对Map查找、新增/修改、删除操作。
void *bpf_map_lookup_elem(struct bpf_map *map, const void *key);
long bpf_map_update_elem(struct bpf_map *map, const void *key, const void *value, u64 flags);
long bpf_map_delete_elem(struct bpf_map *map, const void *key);
并发问题
你可能注意到,用户态程序和eBPF程序或许是并发的,多个eBPF程序之间也是并发的,eBPF Map是线程安全的吗?在这一篇LWN文章中对该问题进行了讨论,限于篇幅,本文决定不对该问题展开讨论。
6)eBPF 验证器
David Miller - The only thing sitting between our eBPF programs and a deep dark chasm of destruction is the eBPF verifier.
eBPF程序的目的之一是降低定制内核的门槛,然而这使得大量并不具备内核开发技能的开发者拥有了编写运行与内核的代码的机会,为了保障内核不被挂起或是崩溃或是其他异常,eBPF的程序有着严格的要求,eBPF验证器负责在加载eBPF程序时对eBPF程序进行一系列检查,通过检查的eBPF程序才会被放行至JIT Compiler进行编译。
-
代码中不允许存在unreachable code
事实上还有很多检查的细节,本文无法一一列举,但这个视频是一个了解eBPF verifier不错的资料。
7)总结
本文介绍了eBPF的基础知识,下一篇文章将开始介绍一个来自intel的开源项目istio-tcpip-bypass,这个项目使用eBPF对服务网格中本机通信的场景(Pod与Pod在同一Host上,或Pod与Sidecar通信)进行了优化,使得这部分通信可以绕过协议栈,以此提升了10-20%的性能。