eBPF 介绍
什么事eBPF?
eBPF
是一项革命性技术,起源于 Linux
内核,可以在特权上下文(例如操作系统内核)中运行沙盒程序。它用于安全有效地扩展内核的功能,而无需更改内核源代码或加载内核模块。从历史上看,由于内核具有监督和控制整个系统的特权能力,操作系统一直是实现可观察性、安全性和网络功能的理想场所。同时,操作系统内核由于其核心地位,对稳定性和安全性要求较高,演化难度较大。因此,与操作系统外部实现的功能相比,操作系统级别的创新率传统上较低。
eBPF
从根本上改变了这个公式。它允许沙盒程序在操作系统内运行,这意味着应用程序开发人员可以运行 eBPF
程序以在运行时向操作系统添加附加功能。然后,操作系统可以保证安全性和执行效率,就像借助即时 (JIT
) 编译器和验证引擎进行本机编译一样。这引发了一波基于 eBPF
的项目,涵盖广泛的用例,包括下一代网络、可观察性和安全功能。
如今,eBPF
被广泛用于驱动各种场景:在现代数据中心和云原生环境中提供高性能网络和负载平衡,以低开销提取细粒度的安全和可观察性数据,帮助应用程序开发人员跟踪应用程序,提供性能故障排查、预防应用程序和容器运行时安全实施等方面的问题。
钩子概述
eBPF
程序是事件驱动的,当内核或应用程序通过某个挂钩点时运行。预定义的挂钩包括系统调用、函数入口/出口、内核跟踪点、网络事件等。
如果不存在满足特定需求的预定义挂钩,则可以创建内核探针 (kprobe
) 或用户探针 (uprobe
) 来将 eBPF
程序附加到内核或用户应用程序中的几乎任何位置。
eBPF程序是如何编写的?
在很多场景中,eBPF
并不直接使用,而是通过Cilium
、bcc
或bpftrace
等项目间接使用,这些项目在 eBPF
之上提供抽象,不需要直接编写程序,而是提供指定基于意图的定义的能力,然后这些定义用eBPF
实现。
如果不存在更高级别的抽象,则需要直接编写程序。Linux
内核期望 eBPF
程序以字节码的形式加载。虽然直接编写字节码当然是可能的,但更常见的开发实践是利用LLVM
等编译器套件将伪 C
代码编译为 eBPF
字节码。
加载器和验证架构
当识别出所需的钩子后,可以使用 bpf
系统调用将 eBPF
程序加载到 Linux
内核中。这通常是使用可用的 eBPF
库来完成的。
当程序加载到 Linux
内核中时,它在附加到请求的钩子之前要经过两个步骤:验证步骤确保 eBPF
程序可以安全运行。它验证程序是否满足多个条件,例如:
- 加载
eBPF
程序的进程拥有所需的能力(特权)。除非启用非特权eBPF
,否则只有特权进程才能加载eBPF
程序。 - 该程序不会崩溃或以其他方式损害系统。
- 程序总是运行到完成(即程序不会永远处于循环中,从而阻止进一步的处理)。
即时 (JIT
) 编译步骤将程序的通用字节码转换为机器特定的指令集,以优化程序的执行速度。这使得eBPF
程序的运行效率与本机编译的内核代码或作为内核模块加载的代码一样高效。eBPF
程序的一个重要方面是共享收集的信息和存储状态的能力。为此,eBPF
程序可以利用eBPF
映射的概念来存储和检索各种数据结构中的数据。eBPF
映射可以通过系统调用从eBPF
程序以及用户空间中的应用程序访问。 以下是支持的映射类型的不完整列表,以帮助您了解数据结构的多样性。对于各种地图类型,可以使用共享版本和每个CPU
版本。 - 哈希表、数组
- LRU(最近最少使用)
- 环形缓冲器
- 堆栈跟踪
- LPM(最长前缀匹配)
- …
eBPF
程序无法调用任意内核函数。允许这样做会将eBPF
程序绑定到特定的内核版本,并使程序的兼容性变得复杂。相反,eBPF
程序可以对辅助函数进行函数调用,辅助函数是内核提供的众所周知且稳定的API
。eBPF
程序可以通过尾部和函数调用的概念进行组合。函数调用允许在eBPF
程序中定义和调用函数。尾部调用可以调用并执行另一个eBPF
程序并替换执行上下文,类似于常规进程的execve()
系统调用的操作方式。
eBPF 安全
eBPF
是一项极其强大的技术,现在运行在许多关键软件基础设施组件的核心。在eBPF
的开发过程中,当考虑将eBPF
纳入Linux
内核时,eBPF
的安全性是最重要的方面。eBPF
的安全性通过多个层面来确保:除非启用非特权 eBPF
,否则所有打算将 eBPF
程序加载到 Linux
内核中的进程都必须在特权模式(root
)下运行,或者需要 CAP_BPF
功能。这意味着不受信任的程序无法加载 eBPF
程序。如果启用非特权 eBPF
,非特权进程可以加载某些 eBPF
程序,但功能集会减少,并且对内核的访问受到限制。
- 程序经过验证以确保它们始终运行完成,例如,eBPF 程序可能永远不会阻塞或永远处于循环中。eBPF 程序可能包含所谓的有界循环,但只有当验证者可以确保循环包含保证为真的退出条件时,该程序才会被接受。
- 程序不得使用任何未初始化的变量或越界访问内存。
- 程序必须符合系统的大小要求。不可能加载任意大的 eBPF 程序。
- 程序必须具有有限的复杂性。验证者将评估所有可能的执行路径,并且必须能够在配置的复杂性上限范围内完成分析。
验证器是一种安全工具,用于检查程序是否可以安全运行。它不是检查程序正在做什么的安全工具。成功完成验证后,eBPF
程序将根据程序是从特权进程还是非特权进程加载来运行强化过程。该步骤包括:
- 程序执行保护:保存
eBPF
程序的内核内存受到保护并设为只读。如果由于任何原因,无论是内核错误还是恶意操纵,试图修改eBPF
程序,内核将崩溃,而不是允许它继续执行损坏/操纵的程序。 - 针对
Spectre
的缓解措施:据推测,CPU
可能会错误预测分支并留下可通过侧通道提取的可观察到的副作用。举几个例子:eBPF
程序屏蔽内存访问,以便将瞬态指令下的访问重定向到受控区域,验证器还遵循仅在推测执行下可访问的程序路径,并且JIT
编译器在尾调用无法转换为直接调用的情况下发出Retpolines
。 - 常量致盲:代码中的所有常量都被致盲,以防止
JIT
喷射攻击。这可以防止攻击者将可执行代码作为常量注入,在存在另一个内核错误的情况下,可能允许攻击者跳转到eBPF
程序的内存部分来执行代码。
eBPF
程序无法直接访问任意内核内存。位于程序上下文之外的数据和数据结构必须通过 eBPF
帮助程序进行访问。这保证了数据访问的一致性,并使任何此类访问都受到 eBPF
程序特权的约束,例如,只有与程序类型相关的数据结构可以被读取或(有时)修改,前提是验证者可以确保在加载时绑定访问永远不会发生;或者,运行中的eBPF
程序只有在能够保证修改安全的情况下才允许修改某些数据结构的数据。eBPF
程序不能随机修改内核中的数据结构。
eBPF 对 Linux 内核的影响
现在让我们回到 eBPF
。为了了解 eBPF
对 Linux
内核的可编程性影响,有助于对 Linux
内核的架构以及它如何与应用程序和硬件交互有一个高层次的了解。
Linux
内核的主要目的是抽象硬件或虚拟硬件并提供一致的API
(系统调用),允许应用程序运行和共享资源。为了实现这一目标,需要维护大量的子系统和层来分配这些职责。每个子系统通常允许某种级别的配置来满足用户的不同需求。如果无法配置所需的行为,则需要更改内核,从历史上看,留下两个选项:
借助 eBPF
,可以使用一个新选项,允许对 Linux
内核的行为进行重新编程,而无需更改内核源代码或加载内核模块。在很多方面,这与 JavaScript
和其他脚本语言如何解锁系统的演变非常相似,而系统的改变变得困难或昂贵。
开发工具链
存在多个开发工具链来协助 eBPF 程序的开发和管理。它们都满足用户的不同需求:
bcc
BCC
是一个框架,使用户能够编写嵌入了 eBPF
程序的 python
程序。该框架主要针对涉及应用程序和系统分析/跟踪的用例,其中 eBPF
程序用于收集统计数据或生成事件,而用户空间中的对应程序则收集数据并以人类可读的形式显示数据。运行python
程序将生成eBPF
字节码并将其加载到内核中。
bpftrace
bpftrace
是一种适用于 Linux eBPF
的高级跟踪语言,可在半新的 Linux
内核 (4.x) 中使用。bpftrace
使用 LLVM
作为后端将脚本编译为 eBPF
字节码,并利用 BCC
与 Linux eBPF
子系统以及现有的 Linux
跟踪功能进行交互:内核动态跟踪 (kprobes
)、用户级动态跟踪 (uprobes
) 和跟踪点。bpftrace
语言受到 awk、C
和前身跟踪器(例如 DTrace
和 SystemTap
)的启发。
eBPF Go 库
eBPF Go
库提供了一个通用的 eBPF
库,它将获取 eBPF
字节码的过程与 eBPF
程序的加载和管理解耦。eBPF
程序通常是通过编写更高级的语言来创建的,然后使用 clang/LLVM
编译器编译为 eBPF
字节码。
libbpf C/C++ 库
libbpf
库是一个基于 C/C++
的通用 eBPF
库,它有助于将从 clang/LLVM
编译器生成的 eBPF
目标文件加载到内核中,并通过为应用程序提供易于使用的库 API
来抽象与 BPF
系统调用的交互。