eBPF 小结
由来
eBPF(扩展的伯克利包过滤器,Extended Berkeley Packet Filter)发展自 BPF(Berkeley Packet Filter),这是一个在类 Unix 系统上数据链路层提供原始接口的技术,用于捕获和分析网络数据包。BPF 最初由 Steven McCanne 和 Van Jacobson 在 1992 年开发,它引入了一种新的虚拟机设计,可以在基于寄存器结构的 CPU 上高效地工作,并且只复制与过滤相关的数据包数据,从而减少了处理的数据量。
不过和 BPF 相比,eBPF 多了一个 extend,这意味着 eBPF 已经不仅仅局限于网络数据包的过滤,而是可以在内核中执行更多功能的代码。eBPF 的设计目标是在不需要重新编译内核的情况下,允许用户在内核中注入自定义的代码,这些代码可以在内核中执行,从而实现了一种类似于内核模块的功能,但是又不需要重新编译内核。
目前内核依旧还可以加载普通 BPF 字节码,但是在运行之前会先将其转化为 eBPF 字节码再执行,普通的 BPF 字节码可以被透明转换为 eBPF 的字节码。
eBPF 的诞生促进了内核技术的快速发展,它在现代数据中心和云原生环境中提供了高性能的网络和负载均衡,以低开销提取细粒度的安全可观察性数据,并帮助应用程序开发人员跟踪应用程序的运行状态,高效进行性能故障定位,保证应用程序和容器运行时的安全。
简介
在目前的 Linux 内核中,eBPF 的地位类似于一个内嵌的小系统。我们作为用户可以动态的向这个系统内部加载模块-也就是我们需要执行的代码。不过比较有意思的是,这些代码虽然需要使用 c 编写,并使用 clang 编译,但是,在加载到内核并执行的时候,这部分代码并不会被直接编译为对应机器码,而是编译成中间代码,再交由给机器去执行。可以把编译好的 eBPF 和系统内核的关系理解成 js 和浏览器之间的关系。如此设计的原因,主要还是为了内核的安全。毕竟 eBPF 运行在虚拟机里面,不管出了什么问题,内核起码都会有对这个部分的处理机制。并且,在运行之前,内核也可以对这部分代码进行审查,确保不会对系统造成危害。这部分在稍后也会提及。
架构
不过,eBPF 的架构也很有意思,一部分运行在用户态,一部分运行在内核态,所以在进行诸如网络包过滤等等这些需要进行大量系统调用的情况下,eBPF 的性能会高于单纯用户态的程序。在 eBPF 的架构中,内核态的程序主要用于负责在内核中执行特定事件。如果需要回传结果,通常会使用 map(统计摘要信息) 或 perf-event (实时获取事件) 的方式将获得的数据共享给用户态的程序。
限制
前面说了 eBPF 会运行在虚拟机里面,这就意味着 eBPF 的运行环境是受限的。这部分的限制主要有三个:一个是空间上的限制,一个是时间上的限制,一个是权限上的限制。
空间
就空间来说,eBPF 堆栈大小被限制在 MAX_BPF_STACK 这个值的大小以内。这个值最新的大小没有去查,但是在 Linux5.8 版本上,这个值被设置为 512。所以如果要在栈上储存点东西可就得精打细算。毕竟来个 char[256]就可以占走这个栈的一半内容。不过也有办法绕过这个限制,在我查到的资料上可以改用 BPF Maps 来储存数据,其实这就是之前提及到的和用户态空间的 eBPF 程序之间交换信息的通道。当然,如果我们有机器的权限的话,可以重新编译整个内核,修改这个栈的上限来解决这个问题。这也不失为一种力大砖飞的解决方法。
同时,eBPF 程序也不允许编写无法到达的代码。在加载到内核的过程中,eBPF 验证器会对整个代码进行可达性检查,这个限制估计是为了防止无效代码占据宝贵的栈空间。
时间
eBPF 禁止循环。虽然循环在代码编写的时候是天经地义就应该存在的东西,但是在编写代码的过程中,这部分往往也是最容易出现问题的地方,尤其是 eBPF 如果出现了死循环,整个系统就寄了。不过,如果可以明确的告诉内核,我这个循环一定会在某个可以预见到的不太长的时间执行完毕的话,还是可以去执行的。要达成这个目标,我们需要使用 eBPF 所提供的一些辅助函数,或者保证整个循环可以被展开,亦或者循环的次数被明确的告知了内核。
这部分在 eBPF 中验证器检查的方式是去检查程序的路径是否为一个有向无环图。如果程序的路径是一个有向无环图,那么这个程序就应当是合法的。当然一些上面提到的有环的投入特殊情况也可以通过验证器的验证。
同时,单个 eBPF 程序的字节码大小也有限制。在早期的 Linux 内核上,只允许存在 4096 个指令,所以有时就需要精打细算的设计代码,不过在目前较新的内核版本中,对于有权限的 eBPF 程序,这个限制被放宽到了 100 万条,并且也支持了 eBPF 之间的级联调用。这样我们就可以通过组合不同的 eBPF 程序来实现更复杂的功能。当然没有权限的 eBPF 程序还是受限在 4096 个指令内的。考虑到循环展开之后的代码量,4096 个指令就不太够用了。
权限
既然用户写的程序想直接加载到内核态,那么这个 eBPF 程序的权限就需要受到严格的限制。在验证器的第二轮检查的时候,验证器会遍历程序的每条指令,检查寄存器的使用情况。如果发现了可疑的情况,比如数组越界、helper 函数参数类型不匹配等等。那么这个程序就会被拒绝加载。
以下是会被拒绝加载的情况:
栈访问非法,如栈溢出、栈偏移为变量等;
helper 函数入参的参数类型不匹配;
未做范围检查,可能导致内存访问越界;
指针未对齐。
eBPF 程序虽然在 Linux 内核中以内核态的方式运行,但是它不可以直接调用内核态的函数。它可以调用的函数被规定在内核模块所规定的函数列表中。不过还好这个函数列表不是固定的,也会随着内核的更新而更新。
简单上手
ref: BCC (BPF Compiler Collection)
首先我们得有个类 unix 系统。通常的 Linux 必然是可以的。mac os 不是很清楚行不行。windows 的话,可以用 wsl2 来管中窥豹一下。
接着我们需要安装整套工具链。比较简单的方式是基于 bcc 开发,前端是 python,后端是 cpp,前端把代码提交给后端,后端通过 llvm 来把对应的 c 代码编译成 ebpf 代码。
这部分简单贴一下 Ubuntu 的安装方式:
1 |
|
接下来,我们就可以用 python 写一个简单的 eBPF 程序来体验一下 eBPF 的快乐了。
1 |
|
这段代码会在每个新的进程被创建的时候(sysclone),打印当时的时间戳,调用hello
函数,并把Hello, World!
打印到系统内核日志里面。
运行 dmesg | grep Hello
就可以看到这个结果。