在大规模分布式系统的负载均衡中,子集是一种常用的技术。本文,我们将简要介绍 Uber 目前的服务网格架构,2016 年以来,这一架构已经为 Uber 数以千计的关键微服务提供了支持。接下来,我们将会探讨尝试在网格架构中扩大任务的数目所面临的挑战,并会探讨最初的子集方法的问题。本文最后给出了如何提出实时动态子集的解决方案,以及在生产中的结果。
Uber 服务网格
什么是服务网格?
服务网格有很多种不同的定义,但我们的定义是:一种基础设施层,它使微服务可以在远程过程调用(RPC)之间相互通信,而不用为基础设施的细节担忧。
如果一个微服务需要和另一个微服务进行通信,那么它所需要的就是目标服务名称、程序和请求。其他工作由服务网格架构来完成,例如:
发现:找到服务的最新后端任务
需要,因为集群管理可以在主机之间移动后端任务
排除不健康的后端任务
负载均衡:确保负载被正确地分配给可用的任务
认证:在服务之间安全地进行通信
流量整形:避免将请求转发到某些区域/群集
当故障发生时,请求将继续被转发到可用的区域/群集
可观察性:提供对延迟、流量模式等的洞察力。
可靠性:如节流、速率限制、自动故障转移等功能。
Uber 服务网格架构
Uber 从 2014 年开始使用微服务架构。从那时起,服务网格就进行了多次迭代,目前的版本自 2016 年以来就为成千上万的关键微服务(在数百万的容器中)之间的 RPC 支持。
总体概述
Uber 的服务网格架构主要通过自管理来运营。在网格架构中,每一项服务都由一个产品团队完全拥有,而且网络层对其设置几乎没有什么限制。
网络层对服务连接没有任何限制,只要建立了正确的认证,一项服务就可以与任何服务进行通信。服务趋向于有组织的发展,我们不会将任何容量预先分配给网络层中的特定服务。我们每周都会看到一些服务被退役或者被创造出来,但是我们没有强制要求服务生命周期,其中的一些服务是在 2014 年就已经投入使用的。
这一切都有其耐人寻味的意义:
一项服务可能每分钟从 100 到 100000 QPS(有些服务作为批处理的一部分经常这样做)。一项服务可能在任何时候开始连接到一个新的服务一项服务可能公开一个或数百个程序有些服务有几十个任务,而有些服务有几万个任务服务有数百个调用者或数百个被调用者的情况并不少见有些请求的延迟小于 10 毫秒,有些请求需要花费数分钟不同服务的任务可以处理并生成完全不同的吞吐量——有些任务接收/生成 1~2 QPS,而有些任务超过 1000 QPS。可以随时部署任意数目的服务(有些服务在持续部署中运行),从而造成位置上的不断改变。因为调用方式的改变或者部署不当,某一特定的服务突然开始拒绝所有的请求或健康检查,这种情况并不少见。因此,由于调用链中某个地方的调用者配置错误,我们看到 10~100 倍的重试风暴也是很常见的。
技术概述
Uber 基础设施在多个地区的多个区域运营,跨越内部数据中心和公共云供应商。大多数控制平面组件都是分区的,并且每个组件通常由几个子组件组成。
服务 A 通过主机代理向服务 B 发出 RPC 请求
在一个非常高的级别上,Uber 的服务网状体系结构以主机上的 L7 反向代理为中心:
下图展示了服务进行 RPC 调用时的基本流程:
调用者服务将本地端口发送 RPC,该端口由主机代理监听
主机代理会将 RPC 直接转发到被调用服务的后端任务中
在这个简单的模型背后,有很多的细节(这些细节本身就值得另写一篇文章)。
在图的另一边,代理连接到控制平面组件,以便确定在一个非常高的级别上,怎样再次发送一次通信量:
主机代理
在每个主机中,我们都有一个专用的主机代理,负责监听一些固定的端口。它的工作就是接收传入的 RPC 请求,然后将其转发给目标服务的任务。
要做到这一点,代理将连接到控制平面服务,以便:
接收来自流量控制服务的流量分配,以便理解如何在一项服务的不同区域/群组之间分割流量
使用来自流量分配的后端任务池的 URI,从发现服务获得最新的任务安排
在可用的后端任务之间执行负载均衡
代理是在同一主机上运行的所有后端任务的共享资源。换句话说,我们把每个主机视为一个孤立的域。
发现系统
该系统与集群管理接口,以保持服务后端任务的最新信息。然后,这些信息被提供给主机代理,这样代理就可以对最新的实例进行负载均衡。
流量控制系统
该服务是整个系统的指挥者;它会生成流量分配,其中包括每个后端任务池应该接受的流量分割。该分配由代理所使用。
它为服务所有者输出许多配置选项,以确定任何给定的后端任务池应该接受多少流量。它还允许服务所有者创建引流,以避免某些后端任务池,或跨数据中心进行流量故障转移。
现在我们已经对 Uber 服务网格架构有所了解,我们将在本文中重点讨论高效负载均衡技术,所以让我们深入探讨一下。
负载均衡
负载均衡目标
服务网格的职责之一就是保证服务的每个后端任务消耗相似比例的可用资源,在我们的情况下,这通常是 CPU 时间。有两个理由:
可靠性:过载的任务的表现会比预期的更糟糕
效率:糟糕的负载均衡会导致资源过度分配(也就是说,我们会增加更多的容量来分散负载,以避免“热门”任务过载)
我们通过 CPU 负载不均衡来衡量负载均衡的有效性。我们打算就负载均衡单独撰写一篇文章,但是简单地说,我们将 CPU 负载不均衡定义为特定服务的任务的 p99/CPU 平均利用率的比率。
负载均衡概念
什么是子集?
负载均衡中的子集就是指将潜在的后端任务集划分为重叠的“子集”,这样,虽然所有的后端都会收到流量,但每个代理都要在有限的任务集上进行负载均衡。
为了避免为每个请求建立和断开 TCP 连接的成本,代理通常会与后端任务保持长期连接。每个连接都会在两端消耗一些资源(CPU、内存等),即便它是空闲的,因为需要一些最低限度的簿记。
从理论上说,维护成本是非常低的,但是当后端任务越来越多时,维护成本会迅速上升,因为新的微服务或者是横向扩展已有的微服务来处理更多的请求。举例来说,一项拥有 10000 个任务的服务尝试调用另一项拥有 10000 个任务的服务,那么我们就需要 10 万个 TCP 连接。
子集可以缓解这样的情况:一个后端任务从代理处收到大量的连接,或者一个代理必须连接到大量的后端任务。
这个问题以前在网上也有过描述:
Google SRE 书 中对此有深入解释
Twitter 也有相似的问题
Netflix 的 Ribbon 支持子集
Envoy 支持“负载均衡器子集”
我们想用一种完全不同的方式来解决这个同样的问题。
一个有趣的观察是,子集并不必然意味着更差的负载均衡。根据所选择的负载均衡算法,一个完全连接的网格可能比一个精心挑选的子集表现得更糟糕(例如,一个最小等待请求的负载均衡可能会退化为一个过大的子集的轮回)。不幸的是,选择正确的子集是需要技巧的,所以,最好是超调,因为过小的子集大小可能会比过大的子集导致更大的退化。
遗留子集
由于服务网格的大小,所以主机代理需要使用子集。
当我们开始时,使用了一个静态的默认子集大小。每个微服务所有者都可以在必要时决定是否要使用覆盖。这在开始时效果不错,但随着时间的推移,出现了一些挑战。
遗留子集的问题
随机任务选择导致的不均衡
正如上面提到的,流量整形从流量分配开始,它定义了应该发送到每个池的流量的分割。然后,主机代理可以在不依赖于控制平面的情况下,从可用的池中随机挑选后端任务,并对这些任务进行负载均衡。
下图展示了一个实例,其中服务 A 调用了服务 B,合并了 120QPS。当服务 B 有 5 个后端任务时,静态子集大小为 3;因此,服务 B 的一个实例就会收到更高的流量。
这个示例说明了子集的潜在问题:如果调用者任务的数量很低,则有可能会造成某些被调用者任务的负载和利用率不足。在有些情况下,有可能有些后端任务根本就不会被选择。期望调用者/被调用者都能拥有适当的任务数比例,以均衡负载,再加上一点运气。
理论上,当服务网格越来越大时,这种情况就会越来越好,因为当一个后端服务被数以百计的服务或者数以千计的任务调用时,我们可以假定“随机性”在一定程度上会变得均衡。这并不是我们在实践中观察到的:由于不均衡的任务数比例和请求的异质性,尤其是在最大的服务方面,这种不均衡性依然很高。
请注意,上图和下图都是一种简化。代理并不只是向所有连接的后端发送平均数量的流量。在进行这项工作的时候,代理利用了“最少等待请求”的负载均衡,这将略微均衡由于子集的选择而引起的不均衡。然而,在实践中,“最少等待”并没有发挥足够的作用。这是因为每个代理都独立地跟踪它向每个后端发送的请求,而唯一隐含交换的信息就是每个后端的响应延迟。这对负载均衡略有帮助,因为过载的后端对所有代理的响应会更慢,但我们的容器利用率并不高,这对负载均衡的影响是很有意义的。另外,我们还希望负载均衡在过载现象出现之前就能起作用,因为那样的话,也会对终端用户造成影响。
主机托管导致的不均衡
在同一主机中,所有后端任务之间共享主机代理。因此,如果同一主机中的多个服务试图对同一被调用者服务进行 RPC 调用,随机挑选的后端任务子集将比其他任务收到更多请求。
示例显示,服务 A 和服务 D 都调用 B,而服务 A 和服务 D 服务都托管在同一主机上
在一些案例中,当今有一项服务在生成流量时,也会发生这种情况。我们的计算平台可能会在同一台主机上安排同一服务的多个任务,从而造成同样的不均衡情况。
运营成本
由于服务网格的不断扩大,不均衡问题也日益增多。
每当出现问题,我们的团队就需要和服务所有者合作,以确定和更新子集的大小。由于每个微服务都具有各自的容量分配和不同的调用者服务组合,所以很难找到合适的值。而且,无法确保选定的子集的大小在一星期或一个月之内是正确的。这使得我们的团队和服务所有者陷入了一种运营噩梦之中。
实时动态子集的改进
我们提出了一种动态的子集解决方案,它充分利用了目前的控制平面,尤其是流量控制服务。
其基本思想是:当主机代理得知被调用者服务接收到的 QPS 数量时,就可以计算出其对整个流量的负载比例。有了这些信息,主机代理可以根据比例动态地决定其子集的大小(即,如果它开始向一个目标服务发出更多的请求,它就应该扩大其子集的大小)。
架构
聚合控制平面
在 Uber 服务网格中,主机代理定期向流量控制服务上传流量负载报告。为了进行横向扩展,我们采用了两层聚合:第一层由聚合器组成,随着主机数量的增长,它可以横向扩展;第二层只由少数几个控制器组成,这些控制器对流量分布有一个全局观点。我们利用了流量分布的全局视图来实现一些功能,如更好的跨数据中心的负载分配。
对于动态子集,我们利用了现有的架构,开始将汇总的流量负载报告下发到主机代理上。这就为每个主机上的代理提供了总负载的全局视图,以便它知道它所贡献的流量百分比。
这一过程几乎是实时的,负载报告在全球范围内汇总,并在几秒钟内推回给每个主机代理。全局负载报告在整个集群中并不严格一致,但在实践中已经很接近了。
主机代理
在这一点上,主机代理有 3 个相关信息:
流量分配:发送至每个池的流量百分比
这是从控制平面传递下来的
负载:发送至目标服务的流量数量
这是由每个代理独立跟踪的,并进行一些后期处理,以稳定随时间变化的尖锐流量
聚合负载(新):目标服务接收的总体流量
这是从控制平面传给代理的。
新的信息使我们能够动态地调整每个代理的子集大小。我们以接近实时的方式来做这件事,所需的每个池的子集大小被重新计算,比如:
desiredSubsetSize = numberOfTaskInPool load/(aggregateLoadassignment) * constant
这就意味着,产生更多流量的代理会连接到更多的后端,并且会将其负载分散到比更闲置的代理更大的任务子集中。这使我们能够更好地均衡负载,同时仍然实现子集的目标(为网状架构中的连接总数设置上限)。通过调整常数 (constant),我们控制了出站连接的数量,从而控制了负载均衡。
虽然数学本身很简单,但一些细致的性能工作还是必要的。正如我们前面所讨论的,代理既不知道什么会在主机上被安排,也不知道目标服务会是什么。代理需要能够在任何时候处理任何请求,因此它必须不断监测数以万计的池的子集大小。幸运的是,大多数池并不会经常变化——典型的代理每次只与几百个池对话。
任务选择仍然是随机的,而且代理之间仍然没有协调。负载的不均衡性得到了明显的改善,但这还并不完美。此外,由于负载报告在代理机构、控制平面和代理机构之间传播,子集大小调整通常会延迟几秒钟。
发布与成就
发布
整个推广工作的过程用了大约 6 个月。在进行了内部测试后,我们从一些早期采用者开始,转为批量 Onboarding,然后跟进之前手动定制设置的赋能服务的长尾效应。最后一组花了大量的时间,而且所有的服务往往更关键、更不典型、更谨慎。为了防止降级,我们与服务所有者单独合作。
问题
作为发布的一部分,我们做的第一个改变是调整“最小子集”的大小:不管当前负载如何,我们都会配置代理连接到至少几个后端。这减轻了异质调用者(RPS 非常低的调用者,其 CPU 负担不成比例)和“缓慢启动”的问题,当新的工作负载被放在主机上或 RPS 是尖锐的。
有几个服务在 Onboarding 时很谨慎,所以我们允许这些服务自定义其常数。目前,只有非常少的服务在运行自定义配置,从技术角度看,我们可能会进一步减少这个数字。然而,我们发现,一些服务所有者更喜欢用他们自己的自定义、手动管理的设置来运行,特别是在最初的初始 Onboarding 期间。
我们面临的最后一个问题是,在一个代理中维持 TCP 连接的成本——这并不是最少的。在一个主机上,多个高 RPS 实例调用多个大型后端池(数以万计的任务)的组合,会导致成千上万的开放式的连接。只有少数的主机子集上(<0.01%),但是会导致代理的大量内存使用,因为 Go HTTP2 的问题。我们通过减少后端的最大连接数来解决这一问题,并在整个集群中小心地推出这种改变。
请注意,最后一个问题是在迁移完成几个月后发生的,这个问题是在独立的 tchannel-to-HTTP2 迁移时才开始显现的。由于更多的服务被独立地迁移,因此在代理中存在更大的内存压力。
成就
我们希望强调的重要成就就是降低了维护成本:自从发布以来(大约 18~12 个月前),我们已经收到了 0 起服务所有者抱怨子集的案例。但是这一点,就足以证明该项目是合理的,因为它大大减少了我们和服务所有者的辛劳。
效率提升
该项目大大改善了负载均衡,从而带来了可观的效率提升。
我们没有确切的数据来宣布这些数字。首先,我们一开始在大型 Onboarding 中没有记录充分的数据(回顾过去,我们为没有设置跨区域的 A/B 测试而感到遗憾)。因为网格本身就存在噪音,所以以后很难将这些数据进行关联。更具影响力的是,Onboarding 时恰逢新冠肺炎大流行。因为疫情对我们的统计造成了巨大的冲击,所以我们很难将这些历史数据和所推出的数据联系起来。
我们确实有早期的统计数据,这些数据是由交付组织中的合作伙伴生产工程师手动调整子集的大小。通过手动调整 8 个较大的服务,结果发现 p99 CPU 使用率下降了 15%~30%;随后,这些服务又被添加到了动态的子集,没有出现倒退,而这些服务中的统计数字也得到了改进。
我们保留了最后加入的“赋能服务”的精确数据。在 36 项服务中,有 17 项显示出 10% 以上的 p99 CPU 利用率降低,有些服务甚至显示出高达 40% 的利用率,由于这些服务的子集大小之前也是手动调整的,因此,我们非常满意,自动系统并没有显示出任何退化,甚至在 50% 的情况下超过了人类。
下面是 Onboarding 时记录的一些数据:
我们根据生产指标定义了一个不均衡指标(越高,就越不均衡),上图展示了对一个关键服务应用动态子集后的变化。
后续行动
在子集扇出场数的选取上,具有很大的随意性。在该项目完成后一年左右,我们构建了一条更为完成的评估管道,使得我们能够调节(增加)扇形常数。测试也验证了之前的预期,如果将子集扩展到更多(也就是完全连接的网格),那么对于提高负载均衡的作用就会变得越来越小。
潜在的改进措施
我们讨论了几个潜在的改进,但是由于这个系统已经很好地运行了,所以并没有实施。
由于子集的改变对代理来说是相对昂贵的,我们可以通过改善系统的滞后性来减少振荡连接的流失。特别是,我们可以急切地增加子集,但是却懒惰地减少它。这也将有助于解决尖锐的流量模式。
我们意识到一种理论上的病态情况,即一个低 RPS 的调用者与一个非常大的后端任务池交互,并且还有其他几个大的调用者,最终可能不会调用服务的所有后端。这是因为流量是全局聚集的,而不是每个调用者。我们讨论了使用池子大小的比例作为子集选择的一个额外因素。
当开始设计这个解决方案时,我们讨论了用确定性的子集来取代随机的对等选择。这将类似于先前在 Twitter 和 Google 链接中描述的解决方案。这可能会通过更好的子集选择来改善负载不均衡,但也会引入更多的复杂性,因为需要跨代理的协调。
最后,我们目前的实现是不加区分地聚集负载。由于不同的调用者和程序在后端可能有不同的资源成本,我们可以通过跟踪调用者和/或程序的负载来实现更高的精度。这将引入显著的额外复杂性。此外,如果不进行深入的请求检查,我们将无法解释请求的异质性,而这很可能是太昂贵了。
大约 12 个月后,作为一个单独项目的一部分,我们对代理进行了改进(辅助负载均衡),作为一个副作用,减少了随机对等选择的影响。它还允许我们减少子集扇出常数。这将在另一篇文章中介绍。
总结
这篇博客介绍了对 Uber 服务网的迭代改进:一个动态子集系统,其中网格的孔径参数随着流量的变化而自动动态调整。该系统已经在生产中运行了近 2 年,涉及数百万个集装箱。我们取得的结果是,不仅减少了劳务工作,而且还取得了惊人的效率。这对 Uber 来说是一个巨大的胜利,因为我们能够重用现有的服务网格栈,并改进预先存在的负载报告,为栈带来了重大改进。我们发现,新颖的无协调者方法对系统的弹性和可扩展性都有帮助。
作者介绍
Chien-Chih Liao 是 Uber 软件网络团队的一名软件工程师。他的贡献包括流量控制、流量负载均衡、数据中心故障转移,以及 Uber 服务网格的弹性功能。
Pawel Krolikowski 是软件网络团队的软件工程师。最近,他一直专注于负载均衡的研究。
Sangeeta Kundu 是一名高级工程经理,在 Uber 的软件网络团队工作。在过去的 3 年中,她为 Uber 的高速增长阶段作出了贡献。在过去的两年里,她带领软件网络控制平面团队(流量路由、发现等)和 Uber 的多区域战略,以实现全球规模的高弹性。
原文链接
https://eng.uber.com/better-load-balancing-real-time-dynamic-subsetting/
评论 1 条评论