关于 eBPF 的故事已经在云计算世界泛滥了一段时间。有时候,人们会把它描述成自切片面包以来最伟大的东西,有时候又嘲笑它是对现实世界无用的干扰。当然,现实情况要微妙得多,所以我们似乎有必要仔细了解一下 eBPF 能做什么和不能做什么——技术毕竟只是工具,我们应该为手头要处理的任务选择合适的工具。
最近经常出现的一个问题与服务网格(Service Mesh)所需的 Layer7 处理有关。将这个任务交给 eBPF 处理可能是服务网格的一个巨大胜利,因此让我们来仔细地研究一下 eBPF 可能扮演的角色。
那么 eBPF 到底是什么
我们先来看看这个名字——“eBPF”最初是指“扩展的伯克利包过滤器(extended Berkeley Packet Filter)”,尽管现在它不指代任何东西。伯克利包过滤器(BPF)可以追溯到近 30 年前——它是一种允许用户应用程序直接在操作系统内核中运行特定代码的技术(当然是经过严格审查和高度约束的代码)。BPF 仅应用于网络栈,但仍然可能促成一些令人惊奇的事情。
一个经典的例子是,它让试验新类型的防火墙等东西变得非常容易。我们不需要不断地重新编译内核模块,只需要编辑 eBPF 代码并重新加载它们。
类似的,它可以轻松地开发出一些非常强大的网络分析功能,包括你不想在内核中运行的东西。例如,如果你想使用机器学习对传入的数据包进行分类,可以使用 BPF 抓取感兴趣的数据包,并将它们分发给运行 ML 模型的用户应用程序。
以上是两个非常明显的可能应用 BPF 的领域【1】,除此之外还有其他一些例子——eBPF 将同样的概念扩展到网络以外的领域。但所有这些都涉及一个问题——为什么它们都需要引起我们特别的注意。
简单地说,这与“隔离性”有关。
隔离性
计算——尤其是原生云计算——严重依赖于硬件同时为多个实体做多件事情的能力,即使有些实体对其他实体是不友好的。这就是多租户争用,我们通常使用可以调解对内存访问的硬件来管理多租户争用。例如,在 Linux 中,操作系统为自己创建一个内存空间(内核空间),并为每个用户程序创建一个单独的空间(总的来说就是用户空间,尽管每个程序都有自己的空间)。然后,操作系统使用硬件来防止跨空间的访问【2】。
维护系统各部分之间的这种隔离对于安全性和可靠性来说都是绝对关键的——与计算安全相关的东西基本上都依赖于它,而原生云对它的依赖甚至更严重,因为云原生要求内核能够保持容器之间的隔离。因此,内核开发人员花了数千人年的时间仔细检查与隔离性相关的每一个交互,并确保内核可以正确地处理所有东西。这是一项棘手的、微妙的、艰苦的任务。而且很不幸的是,在发现 bug 之前,它常常不会被注意到,而这些又占了操作系统工作的很大一部分【3】。
这项工作之所以如此棘手和微妙,部分原因在于内核和用户程序不能完全隔离:用户程序显然需要访问某些操作系统函数。传统上说,这属于系统调用。
系统调用
系统调用是操作系统内核向用户代码公开 API 的一种原始的方式。用户代码隐藏了大量细节,将请求打包并将其交给内核。内核会进行仔细检查,确保所有规则都得到了遵守,并且——如果一切正常——内核将代表用户执行系统调用,并根据需要在内核空间和用户空间之间复制数据。系统调用的关键部分是:
内核控制着一切。用户代码可以发出请求,而不是要求。
检查、复制数据等操作都需要时间,导致系统调用比运行普通代码更慢,无论是用户代码还是内核代码:这是一种跨越边界的行为,会降低速度。随着时间的推移,执行速度变得越来越快,但是,为繁忙系统中的每一个网络包都执行系统调用是不可行的。
而这就是 eBPF 可以发挥作用的地方:不为每一个网络包(或跟踪点或其他什么)执行系统调用,而是直接将一些用户代码放到内核中!然后,内核就可以全速运行,只有在真正需要的时候才将数据分发到用户空间。(最近,人们对 Linux 的用户/内核交互进行了大量的反思,带来了很好的效果。io_uring就是这方面的一项杰出的工作。)
当然,在内核中运行用户代码是非常危险的,因此内核要花费大量的精力来验证用户代码想要做什么事情。
eBPF 验证
当一个用户进程启动时,内核基本上会在可能正常的范围内启动它。内核在它周围设置了护栏,并立即杀死任何试图破坏规则的用户进程,但用户代码被假定具有执行的权利。
对 eBPF 代码则不提供这样的礼遇。在内核中,保护屏障基本上是不存在的,它会盲目地运行用户代码,并希望它们是安全的,这为安全漏洞打开了大门(一些错误可能会带崩整台机器)。相反,eBPF 代码只有在内核能够证明它是安全的情况下才会被执行。
要证明一个程序是安全的是非常困难的【4】。为了让它变得更容易些,内核极大地限制了 eBPF 程序可以做的事情。以下是一些例子。
不允许 eBPF 程序发生阻塞。
不允许 eBPF 程序有无界的循环(事实上,直到最近它们才被允许有循环)。
不允许 eBPF 程序超过一定的体积。
验证器必须能够评估所有可能的执行路径。
验证器必须非常严格,它的决策是决定性的:它必须这样做才能维持整个云原生世界所需的隔离性保证。它在判断程序是不安全的时候还必须宁可杀错不可放过:如果它不能完全确定程序是安全的,就会将其拒绝。不幸的是,有些 eBPF 程序是安全的,但是验证程序不够聪明,无法通过验证——如果你遇到了这种情况,需要重写程序,直到验证器通过,或者需要给验证器打补丁并构建自己的内核【5】。
最终的结果是,eBPF成了一种高度受限的语言。也就是说,虽然对每个传入的网络数据包执行简单的检查是很容易的,但像跨多个数据包缓冲数据这样看似简单的事情却很难。在 eBPF 中实现 HTTP/2 或终止 TLS 是完全不可能的:它们太复杂了。
最后,所有这些都把我们引向了一个问题:将 eBPF 的网络功能应用到服务网格中将会是什么样子?
eBPF 和服务网格
服务网格必须处理云原生网络的所有复杂性。例如,它们必须产生和终止 mTLS、重试失败的请求、将连接从 HTTP/1 透明地升级到 HTTP/2、基于工作负载标识执行访问策略、跨集群边界发送流量,等等。在原生云世界中有很多事情正在发生。
大多数服务网格使用边车模型来管理一切。网格将一个运行在自己容器中的代理附加到每个应用程序 Pod 中,代理拦截与应用程序 Pod 之间的网络流量,完成网格所需的任何工作。这意味着网格可以处理任意的工作负载,并且不需要更改应用程序,这对开发人员来说是一个巨大的胜利。这对平台方也是一种胜利——他们不再需要依赖应用程序开发人员来实现 mTLS、重试、黄金指标【6】等,因为网格在整个集群中提供了所有这些东西,甚至更多。
另一方面,就在不久前,人们还认为部署这些代理的想法是非常疯狂的,因为他们仍然担心运行额外容器所带来的负担。但是Kubernetes使部署变得很容易,只要你能够保持代理的轻量级和速度,它确实可以很好地工作。(“轻量级和速度”当然是带有主观性。许多网格使用通用的 Envoy 代理作为边车。Linkerd 似乎是唯一一个使用专门构建的轻量级代理的。)
因此,一个显而易见的问题是,我们是否可以让功能从边车下沉到 eBPF 中。在 OSI 的 Layer3 和 Layer4——IP、TCP 和 UDP——我们已经看到了 eBPF 的几个明显的优势。例如,eBPF 可以让复杂的动态 IP 路由变得相当简单。它可以进行非常智能的包过滤,或者进行复杂的监控,而且它可以快速和低成本地完成所有这些任务。在网格需要与这些层交互的地方,eBPF 似乎非常有用。
然而,在 OSI Layer7 情况就不同了。eBPF 的执行环境如此有限,以至于 HTTP 和 mTLS 级别的协议远远超出了它的能力,至少在今天是这样。鉴于 eBPF 在不断地发展,也许未来的某个版本可以管理这些协议,但需要注意的是,编写 eBPF 非常困难,而调试可能会更加困难。许多 Layer7 协议都非常复杂,在相对宽松的用户空间中,它们糟糕到不能正常使用。目前还不清楚为 eBPF 重写它们是否可行,即使这么做是可能的。
当然,我们可以做的是将 eBPF 与代理配对:将核心底层功能放在 eBPF 中,然后将其与用户空间代码配对,以此来管理复杂性。通过这种方式,我们可以在较低的级别上获得 eBPF 的性能优势,同时将真正讨厌的东西留在用户空间中。这实际上是今天每个现有的“eBPF 服务网格”所做的,尽管通常没有被广泛公开。
这就提出了一些关于这样的代理到底应该被放在哪里的问题。
单主机代理与边车
我们不为每一个应用程序 Pod 部署一个代理(正如我们在边车模型中所做的那样),而是在每台主机(在 kubernetes 中就是节点)上部署一个代理。它给管理 IP 路由带来了一点额外的复杂性,但似乎也提供了一些良好的规模经济,因为你所需要的代理变少了。
不过,边车比单主机代理有一些显著的好处。这是因为边车被作为应用程序的一部分,而不是独立于应用程序之外。
边车使用的资源与应用程序负载成正比,所以如果应用程序的负载不重,边车的资源使用就不会很高【7】。当应用程序负载很重时,所有 Kubernetes 的现有机制(资源请求和限制、OOMKiller 等)都将完全按照你所习惯的方式工作。
如果一个边车发生故障,它只会影响一个 Pod,而现有的 Kubernetes 机制也能正常对 Pod 故障做出响应。
边车操作基本上与应用程序 Pod 操作相同。例如,通过一个正常的 Kubernetes 滚动重启将边车升级到新版本。
边车和它的 Pod 有着完全相同的安全边界:相同的安全环境、相同的 IP 地址,等等。例如,它只需要为它的 Pod 处理 mTLS,这意味着它只需要单个 Pod 的密钥信息。如果代理中有 bug,它只会泄漏这一个密钥。
对于单主机代理,所有这些问题都消失了。请记住,在Kubernetes中,集群调度器决定将哪些 Pod 调度到给定节点上,这意味着每个节点将有效地获得一组随机的 Pod。这也意味着一个给定的代理将与应用程序完全解耦。
对单代理的资源使用情况进行推断实际上是不可能的,因为它是由流向随机应用程序 Pod 的随机流量驱动的。反过来,这意味着代理最终会因为一些难以理解的原因而失败,而网格团队将为此承担责任。
应用程序更容易受到噪音邻居(noisy neighbor)问题的影响,因为安排给指定主机上的 Pod 的流量都必须通过单个代理。一个高流量的 Pod 完全可能消耗掉节点的所有代理资源,并让其他 Pod 挨饿。代理可以尝试确保公平性,但如果高流量的 Pod 也在消耗所有节点的 CPU,代理的尝试也会失败。
如果一个代理失败,它会影响应用程序 Pod 的一个随机子集——而这个子集将不断发生变化。同样,尝试升级一个代理将影响一个类似的随机的、不断变化的应用程序 Pod 子集。任何故障或维护任务都会突然产生不可预测的副作用。
代理现在必须跨越应用程序 Pod 的安全边界,这比只与单个 Pod 耦合的情况要复杂得多。例如,mTLS 要求持有每个 Pod 的密钥,不能混淆了密钥与 Pod。代理中的任何 bug 都可能导致可怕的后果。
从根本上说,边车利用了容器模型的优势:内核和 Kubernetes 努力在容器级别强制执行隔离和公平性,一切都能正常工作。单主机代理超出了该模型,这意味着它们必须自己解决多租户争用的所有问题。
单主机代理确实是有优势的。首先,在 Pod 世界中,从一个 Pod 到另一个 Pod 总是需要经过两次代理。而在单主机代理世界中,有时只需要经过一次【8】,这可以减少一点延迟。此外,你可以运行更少的代理,如果你的代理在空闲时有很高的资源使用,这样可以节省资源消耗。不过,与运维和安全方面的成本相比,这些改进带来的好处是很小的,这些问题主要可以通过使用更小、更快、更简单的代理来缓解。
我们是否可以通过改进代理来更好地处理多租户争用来缓解这些问题?也许吧。这种方法存在两个主要问题。
多租户争用是一个安全问题,安全问题最好使用更小、更简单、更容易调试的代码来处理。通过添加大量代码来处理多租户争用问题基本上与安全最佳实践是南辕北辙的。
即使安全问题能够得到解决,仍然会存在业务问题。在任何时候,当我们选择进行更复杂的操作时,我们都应该问问为什么要这么做,以及谁会受益。
总之,这种代理上的变化很可能涉及大量的工作【9】,为此我们非常关注这些工作所能带来的价值。
让我们回到最初的问题:将服务网格功能下沉到 eBPF 会是什么样子?我们知道我们需要一个代理来维护我们所需的 Layer7 功能,我们还知道边车代理可以在操作系统的隔离保证范围内运行,单主机代理必须自己管理一切。这是一个不小的差异:单主机代理的潜在性能优势远远超过额外的安全问题和操作复杂性,因此无论是否使用 eBPF, 边车都是最可行的选择。
展望未来
显然,任何服务网格的第一优先级必须是用户的操作体验。我们可以通过 eBPF 来获得更好的性能和更低的资源使用,这太棒了!但需要注意的是,我们不能在这个过程中牺牲用户体验。
eBPF 最终能够接得住整个服务网格吗?似乎不太可能。正如上面所讨论的,在 eBPF 中实现所需的 Layer7 处理是否可行还不清楚,即使在某个时候它确实是有可能的。类似的,我们也可以通过一些其他的机制来将 Layer7 的功能迁移到内核中——尽管从历史上看,这方面并没有很大的推动力,也不清楚什么会真正使其引人注目。(请记住,将功能迁移到内核中意味着将移除我们在用户空间中所依赖的安全屏障。)
因此,在可预见的未来,服务网格发展的最佳路线似乎是积极地寻找在性能方面可以依赖 eBPF 的地方,但要接受在用户空间使用边车代理,并加倍努力让代理尽可能小、快速和简单。
脚注
或者至少大大简化了。
至少,在程序之间没有预先安排的情况下。这超出了本文讨论的范围。
剩下的大部分都是调度。
事实上,这在一般情况下是不可能的。如果你想重温计算机科学课程,首先要从悬而未决的问题开始。
其中一件事可能比另一件容易。特别是如果你想让你的验证器补丁被上游接受!
流量、延迟、错误和饱和。
假设还是一个足够轻量级的边车。
但有时仍然是两次,所以这有点喜忧参半。
例如,有一个有趣的推特帖子介绍了为 Envoy 这么做有多困难。
作者简介:
Flynn 是 Buoyant 公司的技术布道者,主要关注 Linkerd 服务网格、Kubernetes 和云原生开发。他还是 Emissary-Ingress API 网关的原始作者和维护者,并在软件工程领域工作了几十年的时间,始终遵循通信和安全的共同主线。
原文链接:
相关阅读:
中国工商银行基于 eBPF 技术的云原生可观测图谱探索与实践
评论