概述 Link to heading

eBPF(extended Berkeley Packet Filter)是一种强大的内核虚拟机技术,它允许在不修改内核源码的情况下,在运行时动态加载和执行安全的程序。这种机制为性能分析、网络编程以及系统监控等领域提供了高效且灵活的解决方案。在本文中,我们将深入探讨 eBPF 的工作原理,包括其架构设计、核心组件及其在实际应用中的价值。

钩子 Link to heading

eBPF 程序是事件驱动的,当内核或应用程序通过某个钩子点时运行。预定义的钩子包括系统调用、函数入口/退出、内核跟踪点、网络事件等。

如果预定义的钩子不能满足特定需求,则可以创建内核探针(kprobe)或用户探针(uprobe),以便在内核或用户应用程序的几乎任何位置附加 eBPF 程序。

如何编写 eBPF 程序 ? Link to heading

在很多情况下,eBPF 不是直接使用,而是通过像 Ciliumbccbpftrace 这样的项目间接使用,这些项目提供了 eBPF 之上的抽象,不需要直接编写程序,而是提供了指定基于意图的来定义实现的能力,然后用 eBPF 实现。

如果不存在更高层次的抽象,则需要直接编写程序。Linux 内核期望 eBPF 程序以字节码的形式加载。虽然直接编写字节码当然是可能的,但更常见的开发实践是利用像 LLVM 这样的编译器套件将伪 c 代码编译成 eBPF 字节码。

加载器和校验架构 Link to heading

确定所需的钩子后,可以使用 bpf 系统调用将 eBPF 程序加载到 Linux 内核中。这通常是使用一个可用的 eBPF 库来完成的。下一节将介绍一些开发工具链。

当程序被加载到 Linux 内核中时,它在被附加到所请求的钩子上之前需要经过两个步骤:

验证 Link to heading

验证步骤用来确保 eBPF 程序可以安全运行。它可以验证程序是否满足几个条件,例如:

  • 加载 eBPF 程序的进程必须有所需的能力(特权)。除非启用非特权 eBPF,否则只有特权进程可以加载 eBPF 程序。
  • eBPF 程序不会崩溃或者对系统造成损害。
  • eBPF 程序一定会运行至结束(即程序不会处于循环状态中,否则会阻塞进一步的处理)。

JIT 编译 Link to heading

JIT (Just-in-Time) 编译步骤将程序的通用字节码转换为机器特定的指令集,用以优化程序的执行速度。这使得 eBPF 程序可以像本地编译的内核代码或作为内核模块加载的代码一样高效地运行。

Maps Link to heading

eBPF 程序的其中一个重要方面是共享和存储所收集的信息和状态的能力。为此,eBPF 程序可以利用 eBPF maps 的概念来存储和检索各种数据结构中的数据。eBPF maps 既可以从 eBPF 程序访问,也可以通过系统调用从用户空间中的应用程序访问。

下面是支持的 map 类型的不完整列表,它可以帮助理解数据结构的多样性。对于各种 map 类型,共享的或 per-CPU 的变体都支持。

  • 哈希表,数组
  • LRU (Least Recently Used) 算法
  • 环形缓冲区
  • 堆栈跟踪 LPM (Longest Prefix match)算法

Helper 调用 Link to heading

eBPF 程序不直接调用内核函数。这样做会将 eBPF 程序绑定到特定的内核版本,会使程序的兼容性复杂化。而对应地,eBPF 程序改为调用 helper 函数达到效果,这是内核提供的通用且稳定的 API。

可用的 helper 调用集也在不断发展迭代中。一些 helper 调用的示例:

  • 生成随机数
  • 获取当前时间日期
  • eBPF map 访问
  • 获取进程 / cgroup 上下文
  • 操作网络数据包及其转发逻辑

尾调用和函数调用 Link to heading

eBPF 程序可以通过尾调用和函数调用的概念来组合。函数调用允许在 eBPF 程序内部完成定义和调用函数。尾调用可以调用和执行另一个 eBPF 程序并替换执行上下文,类似于 execve() 系统调用对常规进程的操作方式。

eBPF 安全 Link to heading

能力越大责任越大。

eBPF 是一项非常强大的技术,并且现在运行在许多关键软件基础设施组件的核心位置。在 eBPF 的开发过程中,当考虑将 eBPF 包含到 Linux 内核中时,eBPF 的安全性是最关键的方面。eBPF 的安全性是通过几层来保证的:

需要的特权 Link to heading

除非启用了非特权 eBPF,否则所有打算将 eBPF 程序加载到 Linux 内核中的进程必须以特权模式 (root) 运行,或者需要授予 CAP_BPF 权限 (capability)。这意味着不受信任的程序不能加载 eBPF 程序。

如果启用了非特权 eBPF,则非特权进程可以加载某些 eBPF 程序,这些程序的功能集减少,并且对内核的访问将会受限。

验证器 Link to heading

如果一个进程被允许加载一个 eBPF 程序,那么所有的程序仍然要通过 eBPF 验证器。eBPF 验证器确保程序本身的安全性。这意味着,例如:

  • 程序必须经过验证以确保它们始终运行到完成,例如一个 eBPF 程序通常不会阻塞或永远处于循环中。eBPF 程序可能包含所谓的有界循环,但只有当验证器能够确保循环包含一个保证会变为真的退出条件时,程序才能通过验证。
  • 程序不能使用任何未初始化的变量或越界访问内存。
  • 程序必须符合系统的大小要求。不可能加载任意大的 eBPF 程序。
  • 程序必须具有有限的复杂性。验证器将评估所有可能的执行路径,并且必须能够在配置的最高复杂性限制范围内完成分析。

验证器是一种安全工具,用于检查程序是否可以安全运行。它不是一个检查程序正在做什么的安全工具。

加固 Link to heading

在成功完成验证后,eBPF 程序将根据程序是从特权进程还是非特权进程加载而运行一个加固过程。这一步包括:

  • 程序执行保护: 内核中保存 eBPF 程序的内存受到保护并变为只读。如果出于任何原因,无论是内核错误还是恶意操作,试图修改 eBPF 程序,内核将会崩溃,而不是允许它继续执行损坏/被操纵的程序。
  • 缓解 Spectre 漏洞: 根据推断,CPU 可能会错误地预测分支并留下可观察到的副作用,这些副作用可以通过旁路(side channel)提取。举几个例子: eBPF 程序可以屏蔽内存访问,以便在临时指令下将访问重定向到受控区域,验证器也遵循仅在推测执行(speculative execution)下可访问的程序路径,JIT 编译器在尾调用不能转换为直接调用的情况下发出 Retpoline。
  • 常量盲化(Constant blinding):代码中的所有常量都是盲化的,以防止 JIT 喷射攻击。这可以防止攻击者将可执行代码作为常量注入,在存在另一个内核错误的情况下,这可能允许攻击者跳转到 eBPF 程序的内存部分来执行代码。

抽象出来的运行时上下文 Link to heading

eBPF 程序不能直接访问任意内核内存。必须通过 eBPF helper 函数来访问程序上下文之外的数据和数据结构。这保证了一致的数据访问,并使任何此类访问受到 eBPF 程序的特权的约束,例如,如果可以保证修改是安全的,则允许运行的 eBPF 程序修改某些数据结构的数据。eBPF 程序不能随意修改内核中的数据结构。

结语 Link to heading

通过对 eBPF 原理的深入剖析,我们可以看到它如何以安全、高效的方式扩展了内核的能力,为开发者提供了一种全新的编程接口。从网络过滤到性能调优,再到系统监控,eBPF 的应用场景几乎无处不在。随着社区生态的不断完善和硬件支持的持续增强,eBPF 正逐步成为现代操作系统中不可或缺的一部分。未来,它有望在云原生、服务网格及边缘计算等新兴领域发挥更大的作用。希望本文能够帮助读者建立起对 eBPF 技术的全面认识,并激发更多创新的应用场景。