作者:Eliza Weisman
部分由于Linkerd的性能数字和一流的安全审计报告,最近对Linkerd2-proxy(Linkerd使用的底层代理)的兴趣激增。作为一名Linkerd2维护者,我大部分时间都在Linkerd2-proxy上工作,所以这个主题非常贴近我的内心。在本文中,我将更详细地介绍Linkerd2-proxy是什么以及它是如何工作的。
代理可以说是服务网格中最关键的组件。它可以随应用程序的部署而扩展,因此低附加延迟和低资源消耗至关重要。它也是处理应用程序所有敏感数据的地方,因此安全性至关重要。如果代理速度慢、臃肿或不安全,那么服务网格也是如此。
现在的Linkerd2-proxy就是为了满足这些严格的要求而设计的。事实上,我认为它可能是服务网格用例和世界上最令人兴奋的一些技术的最佳代理。正如William Morgan最近所说的,Linkerd2-proxy是现代网络编程的最先进的状态:
不像Envoy、NGINX和haproxy这样的通用代理,开源的Linkerd2-proxy被设计为只做一件事,并且比任何人做得都好:成为一个服务网格边车(sidecar)代理。事实上,我们认为Linkerd2-proxy代表了安全、现代网络编程的最新技术。它是完全异步的,用现代类型安全和内存安全的语言编写。它充分利用了现代Rust网络生态系统,与亚马逊的Firecracker等项目共享基础。它对现代网络协议(如gRPC)有原生支持,可以基于实时延迟实现负载平衡请求,并对零配置使用进行协议检测。它是完全开源的、经过审计的和大规模广泛测试的。
所以,如果你想了解最先进的网络编程是什么样子的,请继续阅读!
为什么使用Rust呢?
谈到Linkerd2-proxy,如果不讨论Rust,那就不完整。当我们在2017年开始着手Linkerd2-proxy的开发时,我们有意识地决定使用Rust,尽管当时Rust的网络生态系统非常非常早。为什么我们选择这条冒险的道路,而不是坚持使用Scala,或者一些更“传统”的代理语言,如C++或C?
决定使用Rust的原因有几个。首先,服务网格代理有一些非常严格的要求:因为它是作为每个pod基础上的边车部署的,所以它必须拥有尽可能小的内存和CPU占用。因为应用程序的大部分或所有网络流量都要通过代理,所以它需要有最小的延迟开销,尤其是最坏情况下的尾部延迟。也许最重要的是,由于代理处理应用程序数据(可能包括难以置信的敏感数据,如金融交易或个人健康),因此必须保证安全。
让我们依次从资源消耗开始。在我们编写Linkerd2-proxy之前,我们构建了Linkerd 1.x。Linkerd的第一个版本有一个用Scala编写的代理组件,并利用健壮的Scala和Java网络生态系统来实现大规模的卓越性能。但是,因为它运行在Java虚拟机上,所以它占用了相当大的资源。(JVM擅长于“伸”,但不擅长“缩”,正如William在他的InfoQ文章中关于决定重新实现Linkerd的文章中所写的那样。)尽管Linkerd社区在调优JVM的内存使用以最小化内存占用方面做得很好,但在按pod服务网格部署模型中,要求这么做还是太多了。所以我们知道我们需要一种可以编译成原生二进制文件的语言,比如Rust、Go和C++。
现在,谈一谈延迟。我们从Linkerd 1中学到的另一个教训。告诉我们选择Rust的是垃圾收集的影响。在垃圾收集运行时中,GC必须偶尔遍历内存中的对象图,以找到不再使用且可以回收的对象。这个过程需要时间,而且可能在不可预测的点发生。如果请求是在垃圾收集器通过期间传入的,那么它可能具有显著的延迟。这个尖尖的、不可预测的延迟配置文件与我们对服务网格代理的期望相反。因此,尽管我们喜欢Go(Linkerd 2.x控制平面是用它写的),Go,也是一种垃圾收集语言。这就给我们留下了没有垃圾收集的语言,比如Rust和C++。
最后,是谈安全。确保服务之间的安全和私有通信是服务网格的主要价值支柱。但是,在数据路径中插入另一个跳也会向攻击者暴露一个新的攻击面。在我们考虑提高应用程序的安全性之前,我们必须确保我们没有使它变得更糟。我们已经确定,垃圾收集语言不适合Linkerd2-proxy的用例,但是Scala、Java、Ruby和Go所有依赖垃圾收集一个关键原因是:确保内存安全与手动内存管理的语言,像C和C++,比看起来要困难得多。
为什么内存安全如此重要?很简单:绝大多数可利用的安全漏洞——Chromium和Windows中70%的严重安全漏洞,以及最近内存中一些最严重的安全漏洞,如heartbleed——都是由缓冲区溢出和释放后使用等内存安全漏洞造成的。与C和C++不同,Rust解决了这些问题,但它是在编译时解决的,不会受到垃圾收集的性能影响。换句话说,Rust让我们避开了大量潜在的数据平面漏洞,否则这些漏洞会困扰Linkerd。
考虑到所有这些因素,Rust是Linkerd2-proxy的唯一选择。它提供了闪电般的性能、可预见的低延迟和我们知道服务网格代理需要的安全属性。它还为我们提供了现代语言特性,如模式匹配和富有表现力的静态类型系统,以及工具,如内置的测试框架和包管理器,使在其中编程变得非常愉快。
Rust的生态系统
令人高兴的是,自2017年以来,Rust网络生态系统已经蓬勃发展——这在很大程度上要归功于Buoyant公司在几个关键项目上的投资。今天,Linkerd2-proxy是建立在一些基础的Rust网络库上的:
- Tokio:Rust的异步运行时
- Hyper:快速、安全、正确的HTTP实现
- Rustls:安全的现代TLS实现
- Tower:模块化和可组合的网络软件组件库
让我们逐个来看一下。
Tokio是一个构建快速、可靠、轻量级网络应用的平台。它提供了一个与操作系统的非阻塞I/O功能、高性能计时器和任务调度集成的事件循环。对于熟悉Node的读者。可以认为Tokio扮演的角色类似于C库libuv——事实上,使用Tokio是Node创建者Ryan Dahl选择在他的下一代JavaScript运行时Deno使用Rust的主要原因之一。自从Linkerd在2016年首次使用Tokio以来,它已经在TiKV、微软Azure的iot-edge和Facebook的Mononoke等开源项目,以及从AWS到Discord等公司中得到了迅速而广泛的采用。
Hyper是Rust领先的异步HTTP实现,以其最佳的类内性能和正确性而著称。和Tokio一样,Hyper因大规模使用而久经沙场。
为了使用相互TLS来保护网络通信,Linkerd代理使用rustls,这是TLS协议的一种实现,构建在ring和webpki之上,这些库提供了底层的加密功能。一项由CNCF赞助的独立安全审计发现,这个加密堆栈的质量非常高,来自Cure53的审计人员“对所呈现的软件印象非常深刻”。
今天,这些组件构成了Rust网络生态系统的核心构建块,毫不夸张地说,大部分开发都是由Linkerd2-proxy驱动的。2017年,当我们开始Linkerd2-proxy的工作时,还没有一个可生产的HTTP/2或gRPC实现,所以我们率先开发了h2库和tower-gRPC。现在,h2增强了Hyper的HTTP/2支持,而tower-gRPC(现在被称为Tonic)已经成为Rust最流行的gRPC库。受Linkerd 1.x提供动力的Scala库Finagle的启发,我们还推动了Tower的开发,这是一个用于以模块化、可组合的方式实现网络服务和中间件的抽象层。
请求的生命周期
有了这些构建块,让我们来讨论一下代理实际上做了什么。作为一个服务网络,Linkerd的最大好处之一可以总结为“零配置就能工作”:如果你把Linkerd添加到一个正常运行的应用程序,它应该继续运行,用户不应该做任何配置。(如果你是从其他服务网项目来的Linkerd,这看起来很神奇。)
Linkerd是如何完成这一惊人壮举的?当然是使用了Linkerd2-proxy。因此,让我们分解通过代理的请求的生命周期。代理在不进行配置的情况下如何智能地处理通信量,同时对网格化的应用程序保持透明?
第一步是协议检测。要使零配置成为现实,当代理收到请求时,我们需要确定正在使用的协议。所以我们做的第一件事是从连接的客户端读取几个字节,然后问几个问题:
- “这是HTTP请求吗?”
- “这是TLS客户端Hello message吗?”
如果请求是客户端hello,则查看服务器名称指示(server name indication,SNI)值,该值包含客户端希望终止的服务器的主机名。如果SNI值表明TLS连接是为注入的应用程序准备的,代理将直接转发消息。透明性的一个重要部分是,如果代理接收到一个消息,它不能做任何聪明的事情,它应该直接发送它——在这种情况下,消息是加密的,而代理没有解密它的密钥,所以我们没有别的办法。类似地,未知协议中的TCP流量将透明地转发到其原始目的地。
另一方面,如果加密连接是为我们提供的,作为Linkerd的自动互TLS特性的一部分呢?网格中的每个代理都有自己独特的加密身份,代理在启动时为其生成关键材料,并且从不离开pod边界或写入磁盘。控制平面的身份服务对这些身份进行签名,以指示对代理进行身份验证,以便为注入代理的pod的Kubernetes ServiceAccount服务流量。如果SNI名称与代理的服务帐户匹配,那么我们对其进行解密,并将其作为服务网格的一部分进行处理。
接下来,如果请求被网格化,代理会做什么?让我们考虑这样一种情况,网格化的客户机向其代理发送出站请求。代理执行我们上面讨论的协议检测,并确定这是一个HTTP/1、HTTP/2或gRPC请求协议,Linkerd理解并可以智能路由。因此,现在我们需要确定请求的去向。Linkerd根据目标权限(target authority)来路由HTTP流量,目标权限是HTTP/1.1和1.0请求的Host: header或请求URL的权限部分的值,或者HTTP/2中的:authority头字段的值。代理检查请求,并根据使用的协议版本查找目标权限,并执行DNS查询以确定该名称的规范形式。
当代理知道了请求的目标权限,它就通过从Linkerd控制平面的目标服务中查找权限来执行服务发现。是否咨询控制平面取决于一组搜索后缀:默认情况下,代理被配置为查询位于默认Kubernetes集群本地域.cluster.local的服务。但是可以为使用自定义域的集群覆盖此功能。目标服务向代理提供组成该权限的Kubernetes服务的所有端点的地址,以及特定于链接的元数据和配置重试、超时和其他策略的服务配置文件。所有这些数据都流到代理,所以如果有任何变化——例如。如果一个服务被放大或缩小,或者服务概要配置被编辑——控制平面将在发生时将新状态推送到代理。
然后,代理将在控制平面提供的一组端点上对请求进行负载平衡。当请求被转发到目的地时,代理会使用一种名为指数加权移动平均(exponentially weighted moving averages,EWMA)的负载平衡算法来计算负载估算。本质上,这意味着代理在一个有限的时间窗口内保持一个移动平均延迟,以便在延迟发生时对其做出反应,并且该负载估计是基于向该端点运行的请求的数量进行加权的。在传统上,负载平衡决策通常是通过选择估计负载最低的端点来做出的,比如使用有序堆。然而,保持端点的有序集合从最小到最大的负载在计算上是昂贵的,所以Linkerd实现了“两种选择的能力”(power of two choices,P2C)负载平衡。在这种方法中,我们通过从两个随机选择的可用端点中选择负载较少的端点来做出每个负载平衡决策。尽管这似乎违反直觉,但从数学上来说,这至少在规模上与总是选择负载最小的副本一样有效,而且它避免了多个负载均衡器都将流量发送到负载最小的副本、导致重载的问题。此外,这种快捷方式效率更高,因此在速度上有很大差别。
当目标端点有自己的Linkerd代理时,控制平面将向代理指示它可以发起相互TLS,以确保连接是安全和私有的。同样的,当HTTP/1.x请求在网格中发送,代理将透明地将它们升级为HTTP/2,这样多个请求可以在一个连接上多路复用,并由目标代理降级为HTTP/1,这样升级对应用程序是不可见的。与Linkerd的智能、支持协议的负载平衡相结合,这是网状流量通常比非网状流量具有更低延迟的原因之一,尽管采用了额外的网络跳数。
把它们放在一起,代理中的基本逻辑流程看起来如下:
尽管Linkerd2-proxy提供了很多功能,但我们尽可能保持它的简单和最低限度。最重要的是,代理的模块化体系结构意味着大多数特性可以实现为小的、自包含的模块,并插入到堆栈的适当位置,保持整体代码复杂度低。
代理是关键
今天,Linkerd是唯一一个以数据平面代理为特性的服务网格,它是为服务网格用例显式地从头设计的。通过关注服务网格的独特需求,充分利用Rust令人印象深刻的性能、安全保证和尖端的异步网络栈,我们相信Linkerd2-proxy是Linkerd成功的秘诀。
那么,你是否想参与一个在世界各地的关键系统中使用的最前沿的开源Rust项目?好消息,Linkerd是开源的,所以你可以!在GitHub上加入我们,并查看Slack上的#contributors频道。我们希望你能加入。
Linkerd适用于所有人
Linkerd是一个社区项目,由CNCF托管。Linkerd致力于开放治理。如果你有功能要求、问题,或评论,我们希望你加入我们快速增长的社区!Linkerd代码托管在GitHub上,我们在Slack、Twitter和邮件列表上有一个蓬勃发展的社区。快来加入我们乐趣满满的项目吧!
CNCF (Cloud Native Computing Foundation)成立于2015年12月,隶属于Linux Foundation,是非营利性组织。
CNCF(云原生计算基金会)致力于培育和维护一个厂商中立的开源生态系统,来推广云原生技术。我们通过将最前沿的模式民主化,让这些创新为大众所用。扫描二维码关注CNCF微信公众号。