Skip to content

16cilium之Ebpf和XDP参考

eBPF

我们直接切入核心。BPF,也就是Berkeley Packet Filter,它本质上是一个运行在Linux内核中的虚拟机。这个虚拟机非常灵活,能够高效地执行字节码,而且是在内核的安全钩点上运行。

虽然它历史悠久,但我们现在讨论的主要是扩展版本eBPF,它在现代网络、追踪和安全领域扮演着至关重要的角色。说到BPF,很多人可能首先想到的是tcpdump,没错,它最初是作为经典BPF,也就是cBPF,用于网络数据包过滤。但这个cBPF功能有限,指令集也比较简单,就像一个功能单一的工具。后来,随着内核发展到3.18版本,扩展版eBPF登场了。eBPF带来了全新的指令集和更强大的能力,可以说是cBPF的全面升级。

现在,Linux内核只运行eBPF,如果你加载的是旧的cBPF代码,内核会自动帮你转换成eBPF的形式来执行,这个过程对用户来说是透明的。所以,今天我们讨论的BPF,基本上就是指这个强大的eBPF。

别被名字里的Packet Filter给迷惑了,虽然BPF最初是为网络过滤设计的,但它的通用性远超于此。它的指令集足够灵活,可以用来做很多事。比如,网络方面,除了过滤,还能做负载均衡、网络监控;追踪方面,可以帮助我们分析性能瓶颈、诊断故障;安全领域,像沙箱、入侵检测也离不开它。甚至在文件系统、内核模块开发等方面,你也能看到它的身影。

可以说,BPF已经成为了一个非常通用的内核编程工具,潜力巨大。在众多使用BPF的项目中,Cilium绝对是深度实践者。它把BPF用到了极致,尤其是在它的数据路径里。Cilium利用BPF的特性,实现了高性能的网络策略,比如安全策略、服务发现等等。BPF的灵活性和可扩展性,也让Cilium能够不断进化。所以,如果你想深入了解Cilium,或者想开发Cilium相关的功能,那么透彻理解BPF,特别是eBPF,就显得尤为重要。这不仅是理解Cilium的基础,更是提升开发效率的关键。我们再深入一点,看看BPF是如何工作的。你可以把它想象成一个运行在内核里的虚拟机,有点像Java虚拟机,但它是内核级的。

你写好的BPF程序,会被编译成一种特殊的BPF字节码。这个字节码会被加载到内核中,并且在特定的内核钩点上执行。关键在于,这个执行过程是安全的,BPF程序运行在一个沙箱里,它的权限被严格限制,不会轻易破坏内核。同时,BPF的设计目标是高效,它有自己优化的指令集和执行引擎,保证了性能。这种内核虚拟机的模式,让BPF能在内核的关键位置,比如网络栈入口,插入程序来执行特定功能。内核安全是重中之重。BPF之所以能在内核里运行,很大程度上得益于它完善的安全机制。

首先,它运行在一个隔离的沙箱里,权限被严格限制,比如不能访问内核的内存空间,不能调用系统调用,等等。

更重要的是,BPF程序在加载到内核执行之前,会经过一个严格的验证器检查。这个验证器会静态地分析你的字节码,确保它不会做任何危险的操作,比如死循环、无限递归、访问越界等等。只有通过了验证器的检查,程序才能被加载和执行。

此外,BPF程序只能在特定的、安全的内核钩点上执行,进一步限制了它的活动范围。这种层层把关的机制,确保了BPF程序的安全性,让内核能够信任并执行它。

除了安全,BPF的另一个核心优势就是高效。为什么要在内核里跑程序?因为性能!BPF程序是内核态执行的,这意味着它不需要像传统用户态程序那样,频繁地在用户空间和内核空间之间来回切换,这种切换是非常耗时的。BPF程序直接在内核里运行,减少了大量的上下文切换开销。

而且,BPF的字节码指令集是专门为高效执行设计的,内核也有专门的优化引擎来执行这些指令。这种内核态的直接执行,加上优化的指令集,使得BPF在处理大量数据包或者进行实时监控时,能够达到极高的性能,这是很多传统解决方案难以企及的。BPF之所以能被广泛应用,很大程度上归功于它的灵活性。它的指令集虽然高效,但功能却非常强大和通用,可以用来实现各种复杂的逻辑。

而且,BPF不是一成不变的,它是一个开放的生态系统,社区一直在推动它的发展,不断添加新的指令集、新的功能,比如访问更多内核资源、支持更复杂的控制流等等。围绕BPF,已经形成了一个活跃的社区,提供了大量的库、工具和示例,极大地降低了使用门槛。这种灵活性和持续发展的特性,让BPF的应用场景不断扩展,从最初的网络过滤,到现在的安全、追踪、性能分析,几乎无处不在。

XDP

现在,我们来聊聊XDP。XDP,全称是eXpress Data Path,也就是快速数据路径。它是BPF在网络安全领域的一个非常重要的应用,可以说是BPF的网络加速器。

它的核心思想是,在网络数据包进入内核的完整网络栈之前,就让BPF程序介入处理。这个介入时机非常早,通常在数据包刚从网卡接收进来,但还没进入网络协议栈的那一刻。通过这种方式,XDP可以极大地减少数据包在网络栈中的处理时间,从而显著降低延迟,提高吞吐量。对于需要处理大量数据包的网络设备,比如高性能交换机、路由器,或者需要快速响应的网络应用,XDP都是一个非常关键的技术。

XDP是怎么做到快速处理的呢?关键在于它抢占了网络处理的先机。当数据包从网卡进来时,它会先到达一个特殊的接收钩点。如果我们在这个钩点上加载了XDP程序,那么这个程序就会直接在内核中运行,对数据包进行处理。这个处理过程是发生在网络栈之前的,所以,很多后续的网络栈操作,比如协议解析、路由查找、防火墙检查等等,都可以被跳过或者优化。这就像在高速公路上设置了一个快速通道,只有少数车辆需要进入主收费站,大部分车辆可以直接通过,大大提高了通行效率。这种抢占式处理,使得XDP能够以极低的延迟完成数据包的过滤、转发等任务。XDP带来的好处是实实在在的。

  • 最直接的就是低延迟,因为数据包处理时间大大缩短了。
  • 同时,由于减少了不必要的网络栈处理,CPU的负担也减轻了,从而可以实现更高的吞吐量。
  • 在资源受限的环境中,比如嵌入式设备或者边缘计算节点,XDP能够显著降低CPU占用,让系统资源得到更有效的利用。

这些优势使得XDP在高性能网络、数据中心、云服务等场景中变得越来越重要。无论是需要处理微秒级延迟的实时应用,还是需要处理海量数据包的网络设备,XDP都能提供强有力的支持。

展望未来,XDP的应用前景非常广阔。在高性能网络设备领域,比如新一代的交换机、路由器,XDP几乎是标配。在数据中心,无论是传统的负载均衡,还是新兴的安全防护,比如DPI、WAF等,XDP都能提供高性能的解决方案。在云计算领域,无论是虚拟机网络、容器网络,还是云服务的安全隔离,XDP都能发挥关键作用。随着边缘计算的兴起,XDP在边缘节点的加速能力也将变得越来越重要。

可以说,XDP不仅仅是一种技术,更代表着网络技术发展的一个重要方向,它将深刻地影响未来的网络架构和应用。让我们再次聚焦Cilium。Cilium之所以能提供高性能的网络服务,比如安全策略、服务发现、网络遥测,很大程度上得益于它对BPF和XDP的深度集成。

BPF提供了强大的内核编程能力,让Cilium能够高效地实现各种网络功能。而XDP则为Cilium的数据路径提供了强大的加速能力,尤其是在处理大量网络流量时,XDP能够显著降低延迟,提高吞吐量。可以说,BPF和XDP是Cilium的两个核心引擎,它们协同工作,共同赋予了Cilium强大的网络能力,使其能够在复杂多变的网络环境中游刃有余。

总结一下,BPF和XDP是现代Linux内核中非常强大的技术,它们分别代表了灵活高效的内核编程和高性能网络加速。Cilium正是充分利用了这两者的特性,构建了强大的网络解决方案。理解和掌握BPF和XDP,不仅能帮助我们更好地理解Cilium,也能为我们在其他领域开发高性能内核应用打下坚实基础。希望今天的分享能给大家带来一些启发。

我们直接来看BPF。BPF不仅仅是一个指令集,它是一个完整的生态系统,包括了指令集、高效的映射、辅助函数、尾调用等。得益于LLVM,我们可以通过C语言编译成BPF代码,然后加载到内核中运行。BPF的核心优势在于它与Linux内核的深度集成,使得在内核层面进行编程成为可能,同时还能保持接近原生性能。为什么BPF这么重要?因为它赋予了我们内核可编程的能力。

想象一下,像Cilium这样的网络策略,可以直接在内核里处理数据包,而无需来回折腾用户空间和内核空间,这效率提升是巨大的。而且,BPF非常灵活,你可以根据你的具体需求,比如某个容器只用IPv6,就编译出只处理IPv6的BPF程序,把不需要的功能都编译掉,极致优化性能。更厉害的是,像网络程序,可以在运行时原子更新,更新过程对流量没有中断,状态也通过Maps保持,简直是无缝升级。

再加上BPF提供稳定的API,保证了程序在不同内核版本间的兼容性,还有内核自带的验证机制,确保它不会搞垮系统,安全又可靠。BPF的核心是它的指令集,这是一个通用的RISC架构。它的目标是让你用C语言写程序,然后通过编译器比如LLVM把它变成BPF指令,再由内核里的JIT编译器翻译成机器码执行。

BPF程序是事件驱动的,比如网络包来了,或者某个内核地址被访问了,它才会启动。它有11个寄存器,其中r10是只读的,用来访问栈,r0是返回值寄存器,r6到r9是保存寄存器,这个设计非常聪明,它直接映射到主流架构如x86_64, arm64的ABI,JIT编译器只需要发个CALL指令,不用额外搬数据,性能极佳。

程序启动时,r1会包含当前的上下文,比如网络包的skb。虽然BPF支持跳转,但内核验证器会禁止死循环,保证程序总是能结束。指令格式是固定的64位,结构清晰。

BPF的指令集主要分为几大类。

  • 加载和存储类,比如BPF_LDX, BPF_STX,用来从内存或寄存器读写数据,BPF_STX还能做原子操作,比如计数器。
  • 算术逻辑类ALU,包括加减乘除、位运算、移位等等,支持32位和64位模式。

  • 跳转类是控制流的关键,有无条件跳转、各种条件判断跳转、退出指令、调用辅助函数和我们后面会讲到的尾调用。

这些指令构成了BPF程序执行的基础,虽然指令不多,但功能强大。光有指令集还不够,BPF程序需要和内核其他部分交互,这时候就需要辅助函数。

这些函数是内核提供的,BPF程序通过调用它们,可以访问内核数据,比如Maps,或者调用内核功能,比如网络接口、文件系统操作。每个辅助函数的签名都是一样的,接收5个参数,返回值类型也明确。调用约定和我们之前讲的寄存器约定是一致的,方便JIT编译。

更重要的是,内核的验证器会检查这些参数的类型,确保传入的数据是正确的,这大大提高了安全性。目前内核提供的辅助函数已经非常丰富,而且还在不断增长,比如现在就有38个以上,覆盖了各种各样的需求。

BPF程序之间需要共享状态,比如计数器、过滤规则,这时候就需要用到映射。你可以把它们看作是内核空间里的高效键值存储。常见的类型有哈希表、数组、LRU哈希表等,它们提供了不同的性能和语义特性。BPF程序可以直接通过辅助函数操作这些映射,而用户空间也可以通过文件描述符来访问和修改它们。

一个非常酷的点是,不同类型的BPF程序,比如网络程序和追踪程序,可以共享同一个映射。当然,一个BPF程序最多能访问64个映射。除了这些通用的,还有一些特殊的映射,比如程序数组,可以用来存放其他BPF程序的文件描述符,实现动态调用,非常灵活。

之前我们提到,BPF对象比如Maps、Programs是通过文件描述符来访问的。但文件描述符是进程生命周期的,一旦进程退出,这些文件描述符就失效了,导致Maps无法共享。这在很多场景下是个问题,比如iproute2加载完BPF程序就退出了,用户空间就没法访问它创建的Maps了。

为了解决这个问题,内核引入了BPF文件系统。你可以把它想象成一个特殊的文件系统,专门用来存放BPF对象。通过bpf系统调用的BPF_OBJ_PIN命令,我们可以把一个BPF对象比如一个Map绑定到这个文件系统里,然后通过BPF_OBJ_GET命令,任何进程都可以获取到这个绑定的对象,拿到它的文件描述符。这样就实现了BPF对象的持久化和共享,解决了之前提到的共享难题,也为进程间监控和更新Map内容提供了便利。

我们再来看一个非常巧妙的设计——尾调用。你可以把它想象成一种特殊的函数调用,调用之后,原来的程序就结束了,控制权直接跳转到被调用的程序那里,而且不会返回。这种实现方式是通过一个长跳转完成的,它重用了栈帧,所以开销非常小。不过,尾调用也有一些限制,比如只能调用同类型、同JIT编译状态的程序。

实现尾调用需要一个特殊的映射,叫做程序数组,用户空间可以往这个数组里存入被调用程序的文件描述符。尾调用非常适合用来实现程序的分层,比如解析一个复杂的网络头,可以拆分成多个尾调用的程序,每个程序负责解析一部分。它还能让你在运行时动态修改程序的行为,比如替换一个解析模块,非常灵活。

在BPF发展早期,有个痛点,就是如果你想把一个函数比如在头文件里定义的函数复用,就必须在编译时加上 always inline 属性,这样编译器才会把函数体直接复制到每个调用的地方。这会导致代码膨胀,尤其是在大型程序里,影响性能和缓存。

后来,从Linux 4.16和LLVM 6.0开始,这个限制被打破了。现在BPF程序可以直接调用另一个BPF程序,就像调用普通函数一样,不需要再用 always inline 了。这大大减少了代码的体积,提高了代码的缓存友好性,对性能是个明显的优化。

调用约定和辅助函数调用是一样的,r1到r5传参数,r0返回结果。不过,嵌套调用层数是有限制的,最多8层。从5.10内核开始,尾调用和BPF到BPF的调用可以结合使用了,但要注意,如果混合使用,可能会导致栈溢出,需要特别小心。BPF程序的执行速度,很大程度上取决于JIT编译器。

JIT,即Just-In-Time,即时编译器,它的作用是把BPF的字节码,在程序运行时,动态地翻译成目标机器的原生代码。这和解释器相比,速度提升是巨大的。因为JIT编译后的代码可以直接执行,避免了逐条指令解释的开销。而且,JIT编译器通常会优化代码,比如选择更短的指令序列,或者更好地利用CPU的指令缓存,进一步提升性能。

目前主流的架构如x86_64, arm64, arm, ppc64等都内核自带了eBPF JIT编译器,只需要通过设置 /proc/sys/net/core/bpf_jit_enable 为1就可以启用。对于那些没有JIT编译器的架构,或者在某些特殊情况下,BPF程序会退回到使用内核解释器,但JIT的效率优势是显而易见的。安全是BPF设计中至关重要的一环。内核采取了很多措施来加固BPF程序。

  • 首先,BPF的解释器和JIT编译后的代码,在内存中都被设置为只读,任何试图修改的行为都会触发保护故障,防止被恶意篡改。
  • 其次,可以通过设置 /proc/sys/net/core/bpf_jit_harden 为1来启用JIT硬化,这会混淆掉BPF代码中的常量,比如把一个立即数变成一个随机数异或的结果,然后再通过一个异或操作恢复,这样可以有效防止攻击者利用这些常量作为跳转目标,执行恶意代码。在x86_64上,JIT还使用了RETPOLINE技术来缓解Spectre v2漏洞。
  • 此外,还可以通过设置 /proc/sys/kernel/unprivileged_bpf_disabled 为1,彻底禁止非特权用户使用bpf系统调用,提高安全性。甚至可以配置内核,完全移除解释器,强制使用JIT,进一步提升性能和安全性。

除了在CPU上运行,BPF还可以被卸载到网络硬件上执行。这被称为BPF Offload。它的核心思想是,把一部分BPF程序和相关的映射,直接放到网卡上运行。这样做的好处非常明显,网卡处理数据的速度更快,延迟更低,而且可以大大减轻CPU的负担,让CPU专注于更复杂的任务。

目前,像Netronome的nfp系列网卡驱动就支持这种Offload功能。通过BPF Offload,我们可以将网络处理推向极致,实现更高的性能和更低的延迟。

最后,我们简单看一下几个常用的BPF相关Sysctl参数。

  • /proc/sys/net/core/bpf_jit_enable 是用来控制JIT编译器是否启用的,设为1就启用,0是默认的禁用状态。
  • /proc/sys/net/core/bpf_jit_harden 控制JIT硬化,设为1开启,可以增强安全性。
  • /proc/sys/net/core/bpf_kallsyms 控制是否将JIT编译后的程序符号导出到 /proc/kallsyms,方便调试和性能分析,但开启JIT硬化后会禁用。
  • /proc/sys/kernel/unprivileged_bpf_disabled,这个是关键的,设为1就永久禁用非特权用户的bpf系统调用,提高安全性,但一旦设为1,重启前无法恢复。

这些参数提供了灵活的配置选项,可以根据系统需求进行调整。

总结一下,BPF架构的核心价值在于它赋予了内核可编程的能力,让我们能够高效地定制和扩展内核功能,同时保持接近原生的性能。JIT编译器是性能的关键,而严格的验证和安全加固机制则保证了系统的稳定和安全。BPF提供的指令集、辅助函数、映射等组件,使得它能够灵活地适应各种应用场景,从网络数据处理到内核追踪,BPF已经成为一个不可或缺的强大工具。