eBPF 是最近几年异常火爆的一门内核技术,从2011年开发至今,eBPF 社区依然非常活跃 。eBPF 可以通过热加载的方式动态的获取、修改内核中的关键数据和执行逻辑,避免内核模块的方式可能会引入宕机风险,并具备堪比原生代码的执行效率。

大家已经在各种文章中了解到 eBPF 的应用场景、最佳实践等,也在 cilium 和 bcc 等工具中领略到了 eBPF 的强大能力。eBPF 是如何具备堪比原生的执行效率和动态扩展当前 Linux 内核的能力,接下来将为大家揭开这一层薄纱。

Intro

首先我们介绍一下 eBPF 的前世今生,以便我们更好的了解接下来的内容。如果已有了解和实践,可快速跳到下一章节。

大家或多或少都接触使用过 tcpdump 工具,tcpdump 可以根据用户指定的自定义过滤规则,在报文出入协议栈时获取报文的元信息。tcpdump 之所以可以灵活的过滤用户报文,本质是将过滤规则转化为一种特殊的指令,例如下图:

image.png

这种特殊的指令被称为 BPF,在 eBPF 诞生后被称为 cBPF。这种特殊指令通过 libpcap 接口传递进入内核,当网卡收到了数据包后会执行注册的 AF_PACK 协议中的 packet_rcv 函数,执行用户态传入的 BPF 指令,如果满足过滤规则就 clone 到用户态。大体的流程如下图:

image.png

通过这种机制可以极大提高了规则的灵活度,可以根据用户的需求过滤复杂的报文。同时可以不断优化内核中的 BPF 指令执行器提高执行效率,例如 JIT、SIMD 等等。

cBPF (classic Berkeley Packet Filter) 的诞生可以追溯到1992年。 tcpdump 作为 cBPF 的典型应用,seccomp 也基于 cBPF 进行安全过滤。cBPF 主要特点如下:

  1. 内核内置 BPF 指令解释器,允许从用户态传入内核中;
  2. 图灵不完备,BPF 指令不具备循环等语义,确保内核执行指令的安全;
  3. 解释运行,支持 JIT。如上面提到的 tcpdump 场景,每一个报文皆需要经过过滤器,指令的执行速度严重影响性能,故引入了常见的 JIT 指令优化方式,可以将指令转换为本地指令,加速指令执行,通常会有数倍的性能提升;

时间逐渐来到了21世纪,eBPF 从2011年开始开发。eBPF 与 cBPF 的主要区别如下:

  1. 定义了新的 ISA,扩展了 cBPF 指令,eBPF 的指令主要受 amd64 和 arm64 的影响,并扩展了 64bit 的寄存器;
  2. 使用 LLVM 作为 BPF 的编译器,由于 eBPF 指令极大的扩展,并支持将 C 编译为 BPF 指令集,再将编译器内置在内核中会引入庞大的代码,同时社区已有 LLVM 和 GCC 等成熟的工具,故首先基于 LLVM 扩展了 BPF 后端,GCC 距离使用还要等等;
  3. 引入了用户可使用的 bpf.h 头文件,便于用户态程序使用内核封装的 eBPF 程序;
  4. 依然是图灵不完备,安全和效率依然是第一位考虑,不过在最近的内核中引入了 bonded loop,可以在安全的情况下执行循环;
  5. 解释运行,支持 JIT。同 cBPF,但是扩展了更多的架构,支持在 amd64 和 aarch64 等更多的架构;

经过了 cBPF 和 eBPF 的不断迭代和发展,基于 BPF 已经诞生了很多生产级别的项目:

  1. Katran,Facebook 开源的4层负载均衡,基于 XDP;
  2. BCC 工具集,bpftrace 和 systemtap-bpf,丰富并增强了内核调试和跟踪的能力;
  3. Cilium,微服务和 k8s 场景下的网络治理工具;
  4. IO Visor Project,提到了 BCC 就不能不提到 iovisor 项目,其开源了 BCC, bpftrace, gobpf, ubpf 等一众工具;

当前的 BPF 常见模型:无循环、无锁的简短的 BPF 程序,将很多内核的 helper 和 hook 点粘合在一起。在下面这几种场景下都有运用:

  1. Tracing
    1. kprobe
    2. tracepoint
  2. Networking
    1. sched
    2. XDP
  3. Security
    1. secomp

最后,大家为什么会去了解并使用 BPF。很重要的原因是为了更多的控制权,包括实现一些在用户态还不能够满足需求,或者内核的某些行为需要修改的场景。BPF 的最佳场景也是在用户态和内核态互相配合,共享数据。当然,BPF 也是 CO-RE,一次编译各处运行,具有比较好的可移植性。

Why BPF is FAST

BPF 在内核中的运行,可以概括为下面的流程:

image.png

我们假设一种场景,我们将 BPF attach 到了某个热点的 tracepoint 之上,例如收发包,每次收发包时,tracepoint attached 的 BPF 程序都会被执行一遍。在比较繁忙的机器上,收发包可能每秒钟百万次,执行效率至关重要,如果 BPF 程序被 attach 在热点中,性能问题很可能会成千上万倍的放大。在我们探讨 BPF 程序为什么会执行的如此之快之前,我们有必要先了解下 BPF 指令和解释器。

指令

BPF 当前拥有102个指令,主要包括三大类:ALU (64bit and 32bit)、内存操作和分支操作。其中指令的格式主要由下面这几部分组成:

  1. 8bit opcode
  2. 4bit destination register (dst)
  3. 4bit source register (src)
  4. 16bit 偏移
  5. 32bit 立即数

image.png

与我们常见的 x86 或 ARM 的指令非常接近。在定义了指令后,每一条的指令执行,是通过内核中的解释器运行,流程可以抽象为一个 loop 循环,也被称为指令分发,循环内会不断的载入指令、执行指令,直至退出。

image.png

虚拟机

我们可以认为是 BPF 字节码是运行在内核中的 BPF 虚拟机中,BPF 字节码也是我们通常提到的 p-code (portable code),主要目的是为了软件解释器的高效运行。提到了虚拟机,不得不提到我们常见的几种解释运行的语言,例如 Python 和 Lua。根据虚拟机的实现,可以分为两类,基于栈的虚拟机和基于寄存器的虚拟机,其中基于栈的虚拟机的思想,最早是来自于 Pascal,CPython 和 Lua 4 同样是基于栈的虚拟机。Lua 5 和 Dalvik JVM 则是基于寄存器的虚拟机,BPF 同样是基于寄存器的虚拟机,那么栈和寄存器的实现有何不同,性能是否有所差异,接下来我们继续分析。

基于栈的虚拟机,顾名思义指令是以栈的数据结构组织的。下面的图可以比较清晰的展示这一流程:

image.png

当我们需要获得 20+7 结果时,需要生成4条指令,LIFO 执行。这样会生成更多的指令,同时需要移动多次内存,但是由于没有众多的寄存器,虚拟机的实现会相对简单。

我们再来看下基于寄存器的虚拟机,不同于频繁操作栈,它可以直接操作寄存器,如下图流程演示:

image.png

同样的需要获得 20+7 的结果,在寄存器足够的情况下,我们只需要生成并执行一条指令即可。指令行数相对于栈的实现有显著减少,效率也会提高。但是基于寄存器的虚拟机实现会更加复杂,同时每次指令需要访问更多的内存,并且指令也会更复杂,因为需要提供 2,3,4 地址指令的支持。

通过 Data from A Performance on Stack-based and Register-based Virtual Machine 论文中数据,基于栈和基于寄存器的进行一个简单的对比:

  • 基于寄存器的虚拟机性能在总的时间上比基于栈的虚拟机快 20.39%;
    • 指令分发执行,基于寄存器的虚拟机快 66.42%
    • 数据获取,基于栈的虚拟机快 23.5%

image.png image.png

在论文中的提到的场景下,基于寄存器比基于栈的虚拟机实现,性能更好。当然仅仅这种精心设计的测试可能实际意义不是很大,我们还需要一个实际生产级别的示例和数据。巧合的是,Lua 4 的虚拟机实现是基于栈,而 Lua 5 换成了性能更好的基于寄存器的实现。我们对比了二者的性能:

image.png

通过这一个官方的数据对比,可以看出来 Lua 5 比 Lua 4 快了 34% 左右。

JIT

在语言层面的性能对比中,有一个代表性的性能测试场景 Techempower。一门语言,和这门语言下的不同 web 框架,分别测试 HTTP 处理性能。通过下面这种图,我们可以看到,编译为本地代码的语言性能遥遥领先,而 Python 这种解释运行的语言却名落孙山,但是其中有一个例外,Java 的性能可以和 Rust、Go 这些语言互有胜负,我们已经知道 Java 某种意义上也是解释运行,抛开 Java VM 多年持续优化,与 CPython 最大的不同则是 JIT 的支持。

image.png

何为 JIT?JIT (Just-in-time) 在2011年引入到 cBPF。与 JIT 相对应的为 AOT (ahead-of-time)。JIT 不需要解释器,或者说扩展了解释器,JIT 在运行时会将指令编译为原生指令在本机执行。BPF 虚拟机会将所有的字节码翻译到本地原生代码再执行,具体的是翻译 BPF 字节码到本地原生代码,保存到内存中的特定区域并执行。BPF 程序通常比较简洁和轻量,引入 JIT 不会显著影响冷启动性能。

启用 JIT 究竟会带来多大的性能提升?之前提到的 Lua 在之后的版本提供了 LuaJIT 的实现,最大的变化是使用 JIT 重写。下面是一组 LuaJIT vs Lua 的性能数据,我们可以看到 LuaJIT 比 Lua 快2-10倍。

image.png

对于 BPF 而言,JIT 究竟会带来多大的性能?uBPF 是一个很好的测试程序,uBPF 是 BPF 虚拟机在用户态的实现,它提供了可选的 JIT,我们可以使用 clang 将测试程序编译为 elf 文件,分别测试开启和关闭 JIT 情况下,执行同一个 BPF 程序的性能。从下面的测试数据可以看到,开启 JIT 后性能同样也有数倍的提升。

image.png

How BPF extends Kernel

我们在前面的内容中,提到了编译、指令集和虚拟机。那么 BPF 是如何编译成一个可执行文件,在内核中运行的?

LLVM

当前 BPF 的编译离不开 LLVM,LLVM 分为前端和后端,我们可以将任何语言编译为 LLVM IR,这是一种中间文件。LLVM 可以将 LLVM IR 编译为目标文件,也就是我们提到的二进制文件。

image.png

对于 BPF 而言,我们可以使用 clang 将 BPF 编译为 LLVM IR 文件,LLVM 当前已经支持 BPF 作为目标文件,因此我们可以将任何的 LLVM IR 编译为 BPF 目标文件。大体的流程可以参考下图:

image.png一张图

我们当前在使用 C 编写,并编译成 BPF 程序。从上面的流程中,我们可以了解到,我们可以将任何语言翻译为 LLVM IR,只需要这门语言提供 LLVM 的前端,我们就可以将这门语言编译为 BPF 目标文件。幸运的是,当前很多主流语言都提供了 LLVM 的前端,例如 C, C++, Go Haskell 等等。

我们将各种语言编译为 BPF 目标文件后,我们不仅可以使用这些语言来开发 BPF 程序,我们还可以将 BPF 作为一种通用的指令集,使用用户态的虚拟机来运行 BPF 执行,作为一种平台无关、CO-RE 的指令架构。

WASM

如同现在如日中天的 WASM,作为一种开源的可移植的字节码格式,在边缘计算和浏览器中被广泛使用。其中 WASM 已有具备了在内核中执行的能力,BPF 作为内核的亲儿子,相比于 WASM 更适合在内核中运行,并且可以与内核更紧密的结合。

image.png

BPF in the future

在谈未来之前,我们不能忘记 BPF 的初衷:

BPF goal

  • Let non-kernel developers safely and easily modify kernel behavior.

BPF non goals

  • Implement dynamic tracing and kernel introspection
  • Implement software defined networking, firewalls, load balancers, service mesh

在秉持着 BPF 的 goals 前提下,我们在未来做的更多,场景也更大:

BPF in kernel

  • 安全的锁和内存操作
  • 允许用户在内核中执行更多的指令
  • 更快的速度

BPF in user-space

  • 作为一种通用的字节码
  • CO-RE
  • 原生支持 Rust、Go 和其他语言

尾巴

我们团队在使用 eBPF 做一些很 cool 的事情,包括将社区的 bcc 工具包引入集团和 Aliyun Linux 2 中,基于 eBPF + tracepoint 自研了网络时延跟踪工具 NX tracepoint 等等。如果有对 BPF 技术生态感兴趣的小伙伴可以随时联系我们。