【译】eBPF 和服务网格:还不能丢掉 Sidecar
服务网格以典型的 sidecar 模型为人熟知,将 sidecar 容器与应用容器部署在同一个 Pod 中。虽说 sidecar 并非很新的模型(操作系统的 systemd、initd、cron 进程;Java 的多线程),但是以这种与业务逻辑分离的方式来提供服务治理等基础能力的设计还是让人一亮。
随着 eBPF 等技术的引入,最近关于服务网格是否需要 sidecar (也就是 sidecarless)的讨论渐增。
笔者认为任何问题都有其起因,长久困扰服务网格的不外乎性能和资源占用。这篇文章翻译自 Buoyant 的 Flynn 文章 eBPF and the Service Mesh: Don’t Dismiss the Sidecar Yet。希望这篇文章能帮助大家穿透迷雾看透事物的本质。
本文要点
- eBPF 是一个旨在通过(谨慎地)允许在内核中运行一些用户代码来提高性能的工具。
- 在可预见的未来,服务网格所需的第 7 层处理在 eBPF 中不太可能实现,这意味着网格仍然需要代理。
- 与 sidecar 代理相比,每个主机代理增加了操作复杂性并降低了安全性。
- 可以通过更小、更快的 Sidecar 代理来解决有关 Sidecar 代理的典型性能问题。
- 目前,sidecar 模型对服务网格仍是最有意义的。
关于 eBPF 的故事已经在云原生世界中泛滥了一段时间,有时将其描述为自切片面包以来最伟大的事物,有时则嘲笑它是对现实世界的无用干扰。当然,现实要微妙得多,因此仔细研究一下 eBPF 能做什么和不能做什么似乎是有必要的——技术毕竟只是工具,使用的工具应该适合手头的任务。
最近经常出现的一项特殊任务是服务网格所需的复杂的第 7 层处理。将其交给 eBPF 可能对服务网格来说是一个巨大的胜利,所以让我们仔细看看 eBPF 可能扮演的角色。
究竟什么是 eBPF?
让我们先把这个名字弄清楚:“eBPF”最初是“extended Berkeley Packet Filter”(扩展的伯克利包过滤器),尽管现在它 根本不代表任何东西。Berkeley 数据包过滤器可以追溯到近 30 年前:它是一种允许用户应用程序直接在操作系统内核中运行某些代码(可以肯定是经过严格审查和高度约束的代码)的技术。BPF 仅限于网络堆栈,但它仍然使一些惊人的事情成为可能:
- 典型的例子是,它可以使试验新型防火墙之类的东西变得更加容易。无需不断地重新编译内核模块,只需对 eBPF 代码进行编辑并重新加载。
- 同样,它可以为轻松开发一些非常强大的网络分析打开大门,包括那些不想在内核中运行的。例如,如果想使用机器学习对传入数据包进行分类,可以使用 BPF 抓取感兴趣的数据包并将它们交给运行 ML 模型的应用程序。
还有其他例子:这只是 BPF 实现的两件非常明显的事情 1 — eBPF 采用了相同的概念并将其扩展到网络以外的领域。但是所有这些讨论都提出了一个问题,即为什么这种事情首先需要特别注意。
简短的回答是“隔离”。
隔离
计算——尤其是云原生计算——在很大程度上依赖于硬件同时为多个实体做多项事情的能力,即使其中一些实体对其他实体怀有敌意。这是 竞争性多租户 ,我们通常使用可以调解对内存本身的访问的硬件进行管理。例如,在 Linux 中,操作系统为自己创建一个内存空间(内核空间),并为每个用户程序创建一个单独的空间(用户空间),尽管每个程序都有自己的空间但统称为用户空间。操作系统然后使用硬件来防止任何跨空间访问 2。
保持系统各部分之间的这种隔离对于安全性和可靠性都是非常关键的——基本上 所有 计算安全都依赖于它,事实上,云原生世界更加依赖它,也要保持内核容器之间的隔离。因此,内核开发人员共同花费了数千人年的时间来审查围绕这种隔离的每一次交互,并确保内核都能正确处理。这是一项棘手的、精细的、艰苦的工作,遗憾的是,在发现错误之前常常被忽视,而且它是操作系统实际所做工作的 重要 组成部分 3。
这项工作如此棘手和精细的部分原因是内核和用户程序不能完全隔离:用户程序显然需要访问某些操作系统功能。从历史上看,这是 系统调用 的领域。
系统调用
系统调用或 syscall 是操作系统内核向用户代码公开 API 的原始方式。对大量细节进行了修饰,用户代码将请求打包并将其交给内核。内核仔细检查以确保其遵循了所有规则,并且——如果一切看起来正常——内核将代表用户执行系统调用,并根据需要在内核空间和用户空间之间复制数据。关于系统调用的关键是:
- 内核控制着一切。用户代码可以提出请求,而不是要求。
- 检查、复制数据等需要时间。这使得系统调用比运行普通代码慢,无论是用户代码还是内核代码:是跨越边界的行为让执行变慢。随着时间的推移,事情变得越来越快,但是对于繁忙的系统来说对每个网络数据包进行系统调用是不可能的。
这就是 eBPF 的亮点:无需对每个网络数据包(或跟踪点和其他)进行系统调用,只需将一些用户代码直接放入内核!然后内核可以全速运行它,只有在真正需要时才将数据分发给用户空间。(最近对 Linux 中的用户/内核交互进行了相当多类似的思考,通常效果很好。[io_uring](https://www.scylladb.com/2020/05/05/how-io_uring-and-ebpf-will-revolutionize-programming-in-linux/)
正是该领域的另一个例子。)
当然,在内核中运行用户代码确实很危险,因此内核花费了大量精力来验证用户代码的实际用途。
eBPF 验证
当用户进程启动时,内核基本上会认为其没问题的并启动运行它。内核在它周围设置了围栏,并且会立即杀死任何试图破坏规则的用户进程,但是用户代码基本上被认为是有权执行的。
对于 eBPF 代码,没有这样的礼遇。在内核本身中,保护性围栏基本上不存在,并且盲目地认为用户代码是可以安全运行的,这会为每个安全漏洞敞开大门(以及允许错误使整个机器崩溃)。相反,eBPF 代码只有在内核能够果断地证明它是安全的时才能运行。
证明一个程序是安全的 非常 困难 4。为了使它更易于处理,内核极大地限制了 eBPF 程序可以做什么。例如:
- eBPF 程序不允许阻塞。
- 他们不允许有无限循环(事实上,直到 最近才允许他们有循环)。
- 它们不允许超过某个最大尺寸。
- 验证者必须能够评估所有可能的执行路径。
验证者完全是严苛的,并最终做出决定:它必须如此,以维持我们整个云原生世界所依赖的隔离保证。它还必须在声明程序不安全时报错:如果不能 完全 确定程序是安全的,它就会拒绝。不幸的是,有一些 eBPF 程序是安全的,但是验证者不够聪明,无法通过——如果你处于那个位置,你需要重写程序直到验证者可以接受,或者你将需要修补验证程序并构建自己的内核 5。
最终结果是 eBPF 是一种 高度 受限的语言。这意味着虽然对每个传入的网络数据包进行简单检查等事情很容易,但在多个数据包之间缓冲数据等看似简单的事情却很难。在 eBPF 中实现 HTTP/2 或终止 TLS 根本不可能:它们太复杂了。
最后,所有这些带来了问题:将 eBPF 的网络功能应用于服务网格会是什么样子。
eBPF 和服务网格
服务网格必须处理云原生网络的所有复杂性。例如,它们通常必须发起和终止 mTLS、重试失败的请求、透明地将连接从 HTTP/1 升级到 HTTP/2、基于工作负载身份执行访问策略、跨越集群边界发送流量等等。云原生世界还会有更多的复杂性。
大多数服务网格使用 边车 模型来实现。网格将在自己的容器中运行的代理附加到每个应用程序 pod,并且代理拦截进出应用程序 pod 的网络流量,执行网格功能所需的任何操作。这意味着网格可以处理任何工作负载并且不需要更改应用程序,这对开发人员来说是一个相当大的胜利。这也是平台方面的胜利:他们不再需要依赖应用程序开发人员来实现 mTLS、重试、黄金指标 6 等,因为网格在整个集群中提供了所有这些以及更多。
另一方面,就在不久前,部署所有这些代理的想法完全是疯狂的,人们仍然担心运行额外容器带来的开销。但是 Kubernetes 使部署变得容易,只要保持代理的轻量级和足够快,它就可以很好地工作。(当然,“轻量级和快速”是主观的。许多网格使用通用 Envoy 代理作为 sidecar;Linkerd 似乎是唯一使用专门构建的轻量级代理的。笔者注:还有 Flomesh 使用 可编程的轻量级代理 Pipy。)
那么,一个明显的问题是,我们是否可以将 sidecar 中的功能下沉到 eBPF 中,以及这样做是否会有所帮助。在 OSI 第 3 层和第 4 层——IP、TCP 和 UDP——我们已经看到 eBPF 取得了一些明显的成功。例如,eBPF 可以使复杂的动态 IP 路由变得相当简单。它可以进行非常智能的数据包过滤,或进行复杂的监控,并且可以快速且高效地完成所有这些工作。在网格需要与这些层的功能交互的地方,eBPF 似乎肯定可以帮助网格实现。
然而,OSI 第 7 层情况有所不同。eBPF 的执行环境受到如此严格的限制,以至于 HTTP 和 mTLS 级别的协议 远远 超出了它的能力,至少在今天是这样。鉴于 eBPF 不断发展,也许未来的某个版本可以管理这些协议,但值得记住的是,编写 eBPF 本身就非常困难,调试可能更加困难。许多第 7 层协议是复杂的野兽,在相对宽容的用户空间环境中表现得非常糟糕。即使在 eBPF 的受限环境中可以重写它们,但目前尚不清楚否实用。
当然,我们可以做的是将 eBPF 与代理配对:将核心低级功能放在 eBPF 中,然后将其与用户空间代码配对以管理复杂的功能。这样我们就有可能在底层获得 eBPF 性能的优势,同时将真正令人讨厌的东西留在用户空间中。这实际上是当今每个现存的“eBPF 服务网格”所做的,尽管它通常没有被广泛宣传。
这引发了新的问题:这样的代理应该在哪?
主机级代理与边车
与其在 sidecar 模型中那样在每个应用程序 pod 上部署一个代理,不如考虑为每个主机(或者,用 Kubernetes 来说,每个节点)部署一个代理。它为管理 IP 路由的方式增加了一点复杂性,但乍一看似乎提供了一些经济优势,因为需要代理少了。
然而,sidecar 比主机级代理有一些显著的好处。这是因为 sidecar 就像应用程序的一部分一样,而不是独立于应用程序:
- Sidecar 资源占用与应用程序负载成正比,因此如果应用程序没有做太多事情,sidecar 的资源占用将保持在较低水平 7。当应用程序承受大量负载时,Kubernetes 的所有现有机制(资源请求和限制、OOMKiller 等)都会完全按照习惯的方式工作。
- 如果 sidecar 发生故障,它只会影响一个 pod,并且现有的 Kubernetes 的机制会响应 pod 故障使其再次正常工作。
- sidecar 操作与应用程序 pod 操作基本相同。例如,通过正常的 Kubernetes 滚动重启升级到新版本的 sidecar。
- sidecar 与它的 pod 具有完全相同的安全边界:相同的安全上下文、相同的 IP 地址等。例如,它只需要为它的 pod 做 mTLS,这意味着它只需要那个单一 pod 的密钥数据。如果代理中存在错误,它只能泄漏该单个密钥。
对于主机级代理,所有这些事情都会消失。请记住,在 Kubernetes 中,集群调度程序决定将哪些 pod 调度到给定节点上,这意味着每个节点都可以获得一组随机的 pod。这意味着给定的代理将与应用程序 完全 解耦,这很重要:
- 实际上不可能推断单个代理的资源使用情况,因为它将由到应用程序 pod 随机子集的随机的流量子集决定。反过来,这意味着代理最终会因为一些难以理解的原因而失败,而网格团队将承担责任。
- 应用程序突然更容易受到 嘈杂邻居 的影响,因为给定主机上调度的每个 pod 的流量都必须流经单个代理。一个高流量的 pod 可能会完全消耗该节点的所有代理资源,让所有其他 pod 都饿死。代理可以尝试确保公平处理,但如果高流量 Pod 也消耗了所有节点的 CPU,这也会失败。
- 如果代理失败,它会影响应用程序 pod 的随机子集——并且该子集将不断变化。同样,尝试升级代理将影响类似随机的、不断变化的应用程序 pod 子集。任何故障或维护任务都会突然产生不可预知的副作用。
- 代理现在会跨越节点上调度的每个应用程序 pod 的安全边界,这比仅仅耦合到单个 pod 复杂得多。例如,mTLS 需要为每个调度的 pod 保存密钥,而不是混淆哪个密钥与哪个 pod 一起使用。代理中的任何错误都是更可怕的事情。
基本上,sidecar 使用容器模型来发挥其优势:内核和 Kubernetes 努力在容器级别强制执行隔离和公平,且正常。主机级代理超出了该模型,这意味着它们必须自己解决所有竞争性多租户问题。
主机级代理确实有优势。首先,在 sidecar 世界中,从一个 Pod 到另一个 Pod 总是两次通过代理;在主机的世界中,有时它只有一个跳跃 8,这可以减少一点延迟。此外,最终可以运行更少的代理,如果代理在空闲时资源使用率很高,则可以节省资源消耗。然而,与运营和安全问题的成本相比,这些改进相当小,而且它们在很大程度上可以通过使用更小、更快、更简单的代理来缓解。
我们是否还可以通过改进代理以更好地处理竞争性多租户来缓解这些问题?也许。这种方法有两个主要问题:
- 竞争多租户是一个安全问题,最好使用更小、更简单、更易于调试的代码来处理安全问题。添加大量代码以更好地处理竞争性多租户基本上与安全最佳实践截然相反。
- 即使安全问题可以完全解决,操作问题仍然存在。每当我们选择进行更复杂的操作时,我们都应该问为什么,以及谁受益。
总体而言,这些类型的代理更新可能需要大量工作 9,这引发了有关进行这项工作的价值的真正问题。
把一切都绕回来,让我们回顾一下我们最初的问题:将服务网格功能下沉到 eBPF 会是什么样子?我们知道我们需要一个代理来维护我们需要的第 7 层功能,并且我们进一步知道 sidecar 代理可以在操作系统的隔离保护范围内工作,其中主机级的代理必须自己管理所有内容。这不是一个小区别:主机级代理的潜在性能优势根本不会超过额外的安全问题和操作复杂性,因此无论是否涉及 eBPF,我们都将 sidecar 作为最可行的选择。
展望未来
显而易见,任何服务网格的首要任务都必须是用户的操作体验。我们可以在哪里使用 eBPF 来获得更高的性能和更低的资源使用,太棒了!但是我们需要注意不要在这个过程中牺牲用户体验。
eBPF 最终会能够覆盖服务网格的全部范围吗?不太可能。如上所述,非常不清楚在 eBPF 中实现所有需要的第 7 层处理是否可行,即使在某些时候它确实有可能。同样,可能还有一些其他机制可以将这些 L7 功能转移到内核中——不过,从历史上看,这方面并没有很大的推动力,也不清楚是什么真正使这种能力引人注目。(请记住,将功能移入内核意味着移除我们为确保用户空间安全而依赖的围栏。)
那么,在可预见的未来,服务网格的最佳前进方向似乎是积极寻找依赖 eBPF 来提高性能的地方,但接受用户空间 sidecar 的需求代理,并加倍努力使代理尽可能小、快速和简单。
脚注
关于作者
Flynn 是 Buoyant 的技术布道者,主要关注 Linkerd 服务网格、Kubernetes 和云原生开发。他还是 Emissary-ingress API 网关的原作者和维护者,并且在通信和安全方面的软件工程领域投入了数十年的时间。