背景

这篇文章纯粹是一篇比较 “浮想联翩” 的随笔,主要是想发散地聊一聊用户态 BPF。这里的用户态 BPF 指的不是与 Linux 内核进行打交道的 BPF 程序,而是将内核完整的 BPF 机制复刻到用户态。文章不过多考虑真正的工程细节实现,而是纯粹从头脑风暴的角度去聊一聊用户态 BPF 是否具备相应的价值以及潜在的技术难点。

再次声明:本文仅仅只是一个不负责任的畅想,写下来只是为了理一理思路。

BPF 技术本质上解决了一个什么问题

回溯 BPF 的历史,其实我们不难得出这么一个精简的结论:BPF 技术本质上是想解决 Linux 内核的可编程性。在 Linux 内核原生的代码路径上,BPF 增加了可编程点。这其实和 CPU 的 Microcode 也有异曲同工之妙。

当遇到以下几种情形的时候,我们其实可以提前在项目中注入可编程点

  1. 代码异常复杂,常人无法安全地修改代码;
  2. 修改代码逻辑将付出非常大的代价(重启/重发布等);
  3. 可扩展性(应对未来不可知的复杂需求);

诸多可编程点形成了一个可编程平面,从而与原始项目解耦复杂度,实现关注点分离,这其实是一种 AOP 的设计思想。

既然像 Linux 内核这种如此复杂且对稳定性要求极高的项目都可以通过 BPF 技术实现可编程性,那么我们是不是可以将这套机制复刻到用户态 ,从而让用户态的程序也具备强大的可编程性 ?比如拿用户态程序的可观测性来举例。假如有了用户态 BPF,我们是不是就能够有更灵活的动态 probe 能力(尽管我们有了 uprobe,但仍然有相对较大的局限性)?比较典型是存储和网络类应用程序,如数据库,负载均衡等等。理论上,我们可以通过只埋可编程点来实现需求各异的其他埋点需求(性能上应该会比传统的静态埋点要差一些)。当然,可编程性其实并不限制具体的应用领域,这正是其美妙之处。

为什么要选 BPF

其实,为用户态应用程序增加可编程性并不一定要用 BPF,从设计思想的角度出发,实现的手段多种多样。之所以选用 BPF,我认为我们将得到以下好处:

  1. 融入 BPF 指令集生态:BPF 提供一个高性能的虚拟指令集,而且有相应的编译器支持(Clang),这就为可编程点的执行层提供了强有力的生态支持能力。我们可以用 C 语言(有可能局限于编译型语言或某种小型 DSL)写我们的逻辑,然后将其编译为 BPF 指令并注入到可编程点内执行。
  2. 充分借鉴内核 BPF 技术的实现:Linux 内核为我们提供一个高效安全的 BPF 技术的具体实现。我们其实可以通过复制其机制来获得设计上的好处,比如 verifier / BPF helper 函数 / BTF 等等。
  3. 开发体验的一致性:由于近些年 BPF 技术的蓬勃发展,不少开发者已经接受了 BPF 技术的使用体验。尽管我们将 BPF 技术搬到了用户态,但理论上整体的开发体验(比如写代码的方式、编译构建流程等等)还将保持一致(可能还会更简单,毕竟不是内核)。

综上,我觉得选用 BPF 技术来为用户态程序提供可编程性是再好不过了。

其实,关于问题 1,还有另一种技术实现手段,那就是 wasm。wasm 和 bpf,本质上都提供一种虚拟指令集的实现,主流的编译器都有了相应的支持。而且,wasm 本来就运行在用户态(比较有趣的是,wasm 社区总是会时不时讨论进入 Linux Kernel,比如 kernel-wasm 项目,但似乎也只停留在一个比较初步的阶段),其 vm 的实现也更为成熟,对各种编程语言的支持也更多,还有正在发展的 WASI 标准,理论上也可以达到我们想要的目的。事实上,有不少项目都试图通过在用户态程序(Envoy 中引入 wasm)引入 wasm vm 来实现其可编程性,而 wasm 也常被用于 FaaS 场景。

关于这个问题,本文暂时无法给出具体的结论,后面可以再仔细琢磨一下。

潜在的问题

用户态程序的可编程性看起来非常美好,但是依旧需要解决以下问题:

  1. 安全性:可通过 verifier 机制来实现,但是无论 verifier 如何智能,安全性问题都无法 “绝对” 被解决。毕竟,引入可编程点就是开了口子,而一旦开了口子,就很难保障其绝对安全(内核中不安全的 BPF 程序可参考这个有趣的项目 bad-bpf)。对于用户态程序来说,安全性问题其实要更为严峻,它不像内核有着较为统一的权限系统。
  2. 性能:依赖于 BPF vm 的实现和指令集的设计,而且,我们其实也希望,当可编程点不被启用时,其性能损耗微乎其微。
  3. 编程点的抽象:如何能够像 Linux 内核那样,将诸多 Hook 点抽象出来,从而让用户可以注入逻辑并访问相应的数据结构 ?
  4. 多语言支持:这个问题我感觉是用户态 BPF 独有的(Linux 内核就是用 C 写的,无需考虑这个问题)。这个问题其实与 3 也是强关联的。假设一个场景:我们需要 Hook 某个用户态函数,并能准确获得其参数信息。不同的编程语言对于数据类型的抽象是不太一样的,比如 C 只有几种比较裸的数据类型,而 Rust 的枚举还可以注入具体类型等等。我们如何将这一层去做抽象,感觉会是一个非常困难的问题。而且,不同的编程语言最终调用 BPF vm 的方式也不尽相同。
  5. 埋可编程点的逻辑:与 Linux 内核具有 kprobe / tracepoint 等机制不同,用户态程序一般不需要这么复杂的机制,最简单的,我们其实在对应的可编程点(也许是某个函数的入口和出口)加上调用 BPF vm 的逻辑即可。我们甚至还可以基于不同的编程语言,使用对应语言提供的机制(比如 Rust 的宏),把这种埋可编程点的逻辑尽量用户感知度最低。

如何实现用户态 BPF

如果我们复刻内核 BPF 技术的实现,那么我们将需要以下几个关键组件和机制:

  1. 用户态 BPF vm:要足够高性能,且支持 JIT;
  2. 用户态 verifier:将内核中对 BPF 程序的静态分析机制复刻到用户态(也许可以其检查规则可以做某种程度的放松),从而让每个加载到可编程点的 BPF 程序都足够安全,不会对现有的用户程序造成较大的影响;
  3. BPF Helper 和 Maps:类似的,我们也可以设计用户态程序的 BPF helper 和 Maps。与内核 BPF 技术不同的是,我们并不需要严格区分用户态和内核态,统统都是用户态。也许,我们可以让用户程序的开发者自定义对应的 helper,然后让 BPF 程序使用,从而使 helper 函数具备相应用户态程序本身的语义。而 Maps 其实就更简单了,因为我们不是内核程序,我们可以在用户态实现类似的 Map 类型;

综上,1 和 2 是关键之关键,其他的机制本质上都是为 1 和 2 服务的,从实现角度来看,其实 1 和 2 可合并在一个 BPF vm 的实现之中。

其实,用户态 BPF vm,社区不是没有过尝试,但都无疾而终或者陷入停滞状态,比如 ubpfrbpf。不过,通过这些尝试,我们基本上可以确定一个技术选型:用户态 BPF vm 要用系统编程语言来实现,比如 C 或者 Rust。个人偏好肯定是使用 Rust,这样一来,我们既能保证 Safety 又能兼顾 Performance,而且还可以编译出与 C ABI 兼容的库,供其他编程语言进行调用。

同样地,用户态 verifier,社区也有过相应的尝试,比如 ebpf-verifier(这个项目还被用于 ebpf-for-windows 中,下文将会提及)。

所以,关于问题 1 和 2,我们其实都已经有了一些轮子,只是不确定用这些轮子组装起来的汽车跑不跑得起来,亦或许我们需要自己造轮子。

参考案例

DPDK 与 BPF

DPDK 是一个典型的用户态协议栈的实现。由于 bypass kernel,理论上也就没法用上内核中基于 BPF 技术的网络 Hook 点的可观测性能力。但是 DPDK 社区也想用上 BPF,所以加上了对 BPF 的支持(还可以参考 DPDK+eBPF),不过看起来能力还不完善,需要增强的点比较多。其对 BPF 指令的执行使用的是 librte_bpf,项目看起来也是很久没有维护了,而且实现的是 cBPF。

Windows 与 BPF

也许大家会比较好奇,为什么 Windows 要选用 BPF ?Windows 居然会 “抄” Linux 的作业(不应该另起炉灶吗)?我以为,这其实体现了微软最近几年在纳德拉的带领下,技术取向越来越开放的缘故。微软其实在开源生态的建设上越来越 Open,既然 BPF 技术已经成了社区既定的事实标准,那么 Windows 直接复用 Linux 内核的成功经验也未尝不可。

通过这篇简要的技术文章,我们能够一窥 eBPF on Windows 大体的技术架构(个人觉得,这还是很不成熟的 PoC):

  1. 用户使用已有的生态将对应代码编译成 BPF 目标文件;
  2. BPF 指令加载前会先经过 PREVAIL verifier 进行静态检查,不同于 Linux 内核,这个 verifier 运行在用户态(准确来说,是 user-mode protected process);
  3. 经过静态检查的 BPF 指令可以通过 uBPF JIT 将其编译为 native code 交给内核执行,或者直接通过内核态的 uBPF 进行解释执行;

关于 Hook 点,这个方案则是将网络层的 Hook API 通过 eBPF shim 进行暴露。这样一来,BPF 程序直接面对是 eBPF shim。方案的设计者也将设计出与 BPF helper 类似的 helper 函数(Linux 无关)来让 BPF 程序的开发者使用。

总的来说,这个方案虽然是用在 Windows 内核上,但其设计思路与用户态 BPF 还是比较类似的,本质上就是将 Linux BPF 技术复刻到了 Windows 上。

总结

用户态 BPF 是一个相对比较复杂的方案,其复杂程度在于我们需要有高性能且成熟 vm 和 verifier,也需要处理因运行于用户层而带来的其他各种问题。但换句话来说,其复杂度不在于技术原理,而在于工程实现上。至于收益,我感觉很简单,就是用户态程序的可编程性,本质上是让程序变得更加灵活,以满足这个世界的复杂多变。用户态 BPF 是一个超长周期的系统性工程,一旦实现了这套方案,理论上是可被应用于很多用户态程序上,从而形成另一个 “更大” 的 BPF 技术子生态。