重新思考边缘负载均衡
本文介绍了 Netflix 在基于轮询的负载均衡的基础上,集成了包括服务器使用率在内的多因素指标,并对冷启动服务器进行了特殊处理,从而优化了负载均衡逻辑,提升了整体业务性能。原文: Rethinking Netflix’s Edge Load Balancing
我们在介绍开源Zuul 2的文章中简单提到了负载均衡方面的一些改进,本文将更详细介绍这项工作的原因、方式和结果。
目标
Netflix 的云网关团队一直致力于帮助系统减少错误,获得更高的可用性,并提高故障恢复能力。因为 Netflix 每秒有超过一百万次请求,即使是很低的错误率也会影响到会员体验,所以每一点提升都有帮助。
因此,我们向 Zuul 和其他团队学习,改进负载均衡实现,以进一步减少由服务器过载引起的错误。
背景
Zuul 以前用基于轮询的Ribbon负载均衡器,并基于某些过滤机制将连接失败率高的服务器列入黑名单。
过去几年里,我们做了一些改进和定制,比如向最近上线的服务器发送较少流量,以避免过载。这些改进已经取得了显著效果,但对于某些问题比较多的原始集群,还是会看到与负载相关的错误率远高于预期。
如果集群中所有服务器都过载,那选择哪一台服务器几乎没有什么区别,不过现实中我们经常看到只有某个服务器子集过载的情况。例如:
服务器冷启动后(在红黑部署和触发自动伸缩期间)。
由于大量动态属性/脚本/数据更新或大型 GC 事件,服务器暂时变慢/阻塞。
服务器硬件问题。经常会看到某些服务器运行得总是比其他服务器慢,有可能是由于邻居节点占用太多资源,也可能因为硬件不同。
指导原则
在开始一个项目时,需要记住一些原则,从而帮助指导在设计软件时需要做出的大大小小的决定,这个项目基于的原则如下。
在现有负载均衡器框架的约束下工作
我们已经将之前定制的负载均衡器集成到了 Zuul 代码库中,从而使得无法与 Netflix 的其他团队共享这些定制。因此,我们决定这次基于约束条件并做出额外投资,从一开始就考虑复用,从而能够直接在其他系统中使用,减少重新发明轮子的代价。
向他人学习
尝试在他人的想法和实现基础上构建,例如之前在 Netflix 其他 IPC 栈中试用的"二选一(choice-of-2)"和"试用期(probation)"算法。
避免分布式状态
选择本地决策,避免跨集群协调状态的弹性问题、复杂性和滞后。
避免客户端配置和手动调优
多年来基于 Zuul 的操作经验表明,将服务配置的部分置于不属于同一团队的客户服务中会导致问题。
一个问题是,客户端配置往往与服务端不断变化的现实不同步,或者在不同团队拥有的服务之间引入耦合的变更管理。
例如,用于服务 X 的 EC2 实例类型升级,导致该集群所需节点减少。因此,现在服务 Y 中的"每台主机最大连接数"客户端配置应该增加,以反映新增加的容量。应该先对客户端进行更改,还是先对服务端进行更改,还是同时对两者进行更改?更有可能的是,完全忘了要改配置,从而导致更多问题。
尽可能不要配置静态阈值,而是采用基于当前流量、性能和环境变化的自适应机制。
当需要静态阈值时,与其让服务团队将阈值配置协调到每个客户端,不如让服务在运行时进行通信,以避免跨团队边界推动更改的问题。
负载均衡方法
主要的想法是,虽然服务器延迟的最佳数据来源是客户端视图,但服务器利用率的最佳数据来源是服务器本身。结合这两种数据源,可以得到最有效的负载均衡。
我们基于一组互补机制,其中大多数已经被其他人开发和使用过,只是以前可能没有以这种方式组合。
用于在服务器之间进行选择的二选一算法(choice-of-2 algorithm) 。
基于服务器利用率的负载均衡器视图进行主负载均衡。
基于服务器利用率的服务器视图进行二次均衡。
基于试用期和基于服务器世代的机制,避免新启动的服务器过载。
随着时间推移,收集的服务器统计数据衰减为零。
Join-the-Shortest-Queue 和服务器报告利用率相结合
我们选择支持常用的 Join-the-shortest-queue(JSQ) 算法,并将服务器报告的利用率作为第二算法,以尝试结合两者达到最佳效果。
JSQ 的问题
Join-the-shortest-queue 对于单个负载均衡器非常有效,但如果跨负载均衡器集群使用,则会出现严重问题。负载均衡器会倾向于在同一时间选择相同的低利用率服务器,从而造成超载,然后转移到下一个利用率最低的服务器并造成超载,以此类推……
通过结合使用 JSQ 和二选一算法,可以在很大程度上消除羊群问题,除了负载均衡器没有完整的服务器使用信息之外,其他方面都很好。
JSQ 通常仅从本地负载均衡器计算到服务器的正在使用的连接数量来实现,但是当有 10 到 100 个负载均衡器节点时,本地视图可能会产生误导。
单个负载平衡器的观点可能与实际情况大不相同
例如,在上图中,负载均衡器 A 有一个到服务器 X 的请求和一个到服务器 Z 的请求,但没有到服务器 Y 的请求。所以当它收到新请求时,基于本地数据,选择利用率最小的服务器,会选择服务器 Y,但这不是正确的选择。服务器 Y 实际上负载最重,其他两个负载均衡器目前都有请求发送到服务器 Y 上,但负载均衡器 A 没有办法知道。
这说明单个负载均衡器的观点与实际情况完全不同。
在只依赖客户端视图时遇到的另一个问题是,对于大型集群(特别是与低流量相结合时),负载均衡器通常只有几个活跃连接,和集群中的某个子集交互。因此,当它选择哪个服务器负载最少时,通常只是在若干个它认为负载都是 0 的服务器之间进行选择,而并没有关于所选服务器的利用率的数据,所以只能盲猜。
这个问题的解决方案是与所有其他负载均衡器共享所有活跃连接数状态……但这样就需要解决分布式状态问题。
考虑到获得的好处要大于付出的成本,因此我们通常只将分布式可变状态作为最后手段:
分布式状态增加了部署和金丝雀发布等任务的运维开销和复杂性。
弹性风险与数据损坏的爆炸半径相关(1%负载均衡器上数据损坏让人烦恼,但 100%负载均衡器上数据损坏会造成停机)。
在负载均衡器之间实现 P2P 分布式状态系统的成本,或者运维一个具有处理大量读写流量所需的性能和弹性凭证的单一数据库的成本。
另一种更简单的解决方案(也是我们选择的),是依赖于服务器向每个负载均衡器报告资源使用情况……
服务器报告使用率
服务器主动上报其使用率的好处是可以提供所有使用了该服务器的负载均衡器的完整信息,从而避免 JSQ 的不完整问题。
对此有两种实现方式:
运行状况检查端点主动轮询每个服务器的当前利用率。
被动跟踪来自服务器的响应,并标注其当前利用率数据。
我们选择第二种方式,其实现简单,可以频繁更新数据,避免了 N 个负载均衡器每隔几秒钟轮询 M 个服务器所带来的额外开销。
被动策略的影响是,负载均衡器向一台服务器发送请求的频率越高,获得的该服务器的利用率数据就越新。因此 RPS 越高,负载均衡的有效性就越高。但反过来,RPS 越低,负载均衡的效果就越差。
这对我们来说不是问题,但对于通过特定负载均衡器处理低 RPS(同时通过另一个负载均衡器处理高 RPS)的服务来说,主动轮询运行状况检查可能更有效。临界点是负载均衡器向每个服务器发送的 RPS 低于运行状况检查的轮询频率。
服务端实现
我们在服务端通过简单跟踪活跃请求计数来实现,将其转换为该服务器配置的最大百分比,并将其作为 HTTP 响应报头:
服务器可以指定可选的目标利用率,从而标识预期在正常条件下运行的利用率百分比,负载均衡器基于这一数据进行粗粒度过滤,后面会详细介绍。
我们尝试使用活跃计数以外的指标,例如操作系统报告的 cpu 利用率和平均负载,但发现它们会引起振荡,原因似乎是因为它们是基于滚动平均值计算的,因此有一定的延迟。所以我们决定现在只用相对简单的实现,即只计算活跃请求。
用二选一算法代替轮询
由于我们希望能够通过比较服务器的统计数据来选择服务器,因此不得不抛弃现有的简单轮询实现。
我们在 Ribbon 算法中尝试的一个替代方案是 JSQ 与 ServerListSubsetFilter 相结合,以减少分布式 JSQ 的羊群问题。这样可以得到合理的结果,但是结果在目标服务器之间的请求分布仍然过于分散。
因此,我们参考了 Netflix 另一个团队的早期经验,并实现了"二选一(Choice-of-2)"算法。这样做的优点是实现简单,使负载均衡器的 cpu 成本较低,并能提供良好的请求分布。
根据综合因素进行选择
为了在服务器之间进行选择,我们比较了 3 个不同的因素:
客户端运行状况: 该服务器连接相关错误的滚动百分比。
服务器利用率: 该服务器的最新利用率数据。
客户端利用率: 从当前负载均衡器发送到该服务器的活跃请求数。
这 3 个因素被用来为每个服务器计算分数,然后比较总分数选择获胜者。
像这样使用多个因素确实会使实现更加复杂,但可以避免仅依赖一个因素可能出现的边际问题。
例如,如果一台服务器开始出现故障并拒绝所有请求,那么上报的利用率将会低得多(因为拒绝请求比接受请求开销更小),如果这是唯一考虑的因素,那么所有负载均衡器将开始向那台坏服务器发送更多请求。客户端运行状况因素缓解了这种情况。
过滤
当随机选择 2 台服务器进行比较时,会过滤掉任何超过安全利用率配置和运行状况阈值的服务器。
每个请求都会进行这种过滤,以避免定期过滤会出现的过时问题。为了避免在负载均衡器上造成较高的 cpu 负载,我们尽力而为(best-effort) 尝试 N 次来随机选择一个可用服务器,然后在必要时回退到未筛选的服务器。
当服务器池中有很大一部分存在长期问题时,这样的筛选非常有用。在这种情况下,随机选择 2 个服务器通常会出现选择了 2 个坏服务器进行比较的情况。
但缺点是这依赖于静态配置阈值,而这是我们试图避免的。测试结果让我们相信这点依赖是值得的,即使只依赖一些通用(非特定于服务的)阈值。
试用期
对于任何没有发送响应给负载均衡器的服务器,一次只允许一个活跃请求,随后会过滤掉这些试用服务器,直到收到来自它们的响应。
这有助于避免新启动的服务器还没有机会显示使用率数据之前就因大量请求而超载。
基于服务器世代的预热
我们基于服务器世代在服务器启动的前 90 秒内逐步增加流量。
这是另一种有用的机制,就像试用期一样,可以在微妙的发布后增加一些关于服务器过载的警告。
统计衰变
为确保服务器不会被永久列入黑名单,我们将衰减率应用到所有用于负载均衡的统计数据上(目前是 30 秒的线性衰减)。例如,如果一个服务器的错误率上升到 80%,停止向它发送流量,使用的数据将在 30 秒内衰减为零,比方说 15 秒后是会是 40%)。
运维影响
差距更大的请求分布
不用轮询进行负载均衡的负面影响是,以前服务器之间的请求分布非常均衡,现在服务器之间的负载差距更大。
"二选一"算法在很大程度上能缓解这种情况(与跨集群中所有服务器或服务器子集的 JSQ 相比),但不可能完全避免。
因此,在运维方面确实需要考虑这一点,特别是在金丝雀分析中,我们通常比较请求计数、错误率、cpu 等的绝对值。
越慢的服务器接收的流量越少
显然这是预期效果,但对于习惯于轮询的团队来说,流量是平等分配的,这对运维方面会产生连锁反应。
由于跨原始服务器的流量分布现在依赖于它们的利用率,如果一些服务器正在运行效率更高或更低的不同构建,那么将接收到更多或更少的流量。所以:
当集群采用红黑部署时,如果新的服务器组性能下降,那么该组的流量比例将小于 50%。
同样的效果可以在金丝雀集群中看到,基线组可能会接收到与金丝雀组不同的流量。所以当我们着眼于指标时,最好着眼于 RPS 和 CPU 的组合(例如 RPS 在金丝雀中可能更低,而 CPU 相同)。
更低效的异常值检测。我们通常会自动监控集群中的异常服务器(通常是由于硬件问题导致启动速度变慢的虚拟机)并终止它们,当由于负载均衡而接收较少流量时,这种检测就更加困难。
滚动动态数据更新
从轮询迁移到新的负载均衡器取得了很好的效果,可以很好的配合动态数据和属性的分阶段更新。
最佳实践是每次在一个区域(数据中心)部署数据更新,以限制意外问题的爆发半径。
即使数据更新本身没有引起任何问题,服务器应用更新的行为也会导致短暂的负载高峰(通常与 GC 相关)。如果此峰值同时出现在集群中所有服务器上,则可能导致负载下降以及向上游传播大量错误。在这种情况下,因为所有服务器的负载都很高,负载均衡器几乎无法提供帮助。
然而,如果考虑与自适应负载均衡器结合使用,一个解决方案是在集群服务器之间进行滚动数据更新。如果只有一小部分服务器同时应用更新,那么只要还有足够服务器能够承载流量,负载均衡器就可以短暂减少到这些服务器的流量。
合成负载测试结果
在开发、测试和调优负载均衡器时,我们广泛使用了合成负载测试场景,这在使用真实集群和网络验证有效性时非常有用,可以作为单元测试之后的可重复步骤,但还没有使用真实用户流量。
测试的更多细节在后面的附录中列出,现总结要点如下:
与轮询实现相比,启用了所有功能的新负载均衡器在负载下降和连接错误方面降低了几个数量级。
平均和长尾延迟有了实质性改善(与轮询实现相比减少了 3 倍)。
服务器本身由于添加了特性,显著增加了价值,减少了一个数量级的错误以及大部分延迟。
结果比较
对实际生产流量的影响
我们发现,只要服务器能够处理,新负载均衡器就能非常有效的将尽可能多的流量分配到每个服务器。这对于在间歇和持续降级的服务器之间进行路由具有很好的效果,无需任何人工干预,从而避免工程师在半夜被叫醒处理重大生产问题。
很难说明在正常运行时的影响,但在生产事故中甚至在某些服务的正常稳态运行中,可以看到对应的影响。
事故发生时
最近的事故涉及到服务中的错误,该错误导致越来越多的服务器线程随着时间的推移而阻塞。从服务器启动的那一刻起,每小时都会阻塞几个线程,直到最终达到最大负载并造成负载下降。
在下面的服务器 RPS 图表中,可以看到在凌晨 3 点之前,服务器负载分布差距较大,这是由于负载均衡器向阻塞线程数量较多的服务器发送较少流量的缘故。然后,在凌晨 3 点 25 分之后,自动缩放启动更多服务器,由于这些服务器还没有任何线程阻塞,每个服务器收到的 RPS 大约是现有服务器的两倍,可以成功处理更多流量。
每服务器 RPS
现在,如果我们看一下同一时间范围内每台服务器的错误率图表,可以看到,在整个事故过程中,所有服务器的错误分布是相当均匀的,尽管某些服务器的容量比其他服务器小得多。这表明负载均衡器在有效工作,而由于集群整体可用容量太小,因此所有服务器都被推到稍稍超过其有效容量的位置。
然后,当自动缩放启动新服务器时,新服务器处理了尽可能多的流量,以至于出现了与集群其他部分相同的错误。
每服务器每秒错误
因此,综上所述,负载均衡器在向服务器分配流量方面非常有效,但在这种情况下,没有启动足够的新服务器,从而导致没法将整体错误水平降至零。
稳态
我们还看到,在某些服务中,由于 GC 事件而出现几秒钟的负载下降,因此稳态噪声显著降低。从这里可以看出,启用新的负载均衡器后,错误大幅减少:
启用新负载均衡器前后数周内与负载相关的错误率
告警中的差距
一个意料之外的影响是突出了我们自动告警中的一些差距。一些基于服务错误率的现有告警,以前会在渐进式问题只影响到集群的一小部分时发出告警,现在因为错误率一直很低,告警会晚得多,或者根本不发出告警。这意味着,有时没法将影响集群的大问题通知给团队。解决方案是增加对利用率指标的偏差而不仅仅是错误指标的额外告警来弥补这些差距。
结论
本文并不是为 Zuul 做宣传(尽管它是一个伟大的系统),只是为代理/服务网格/负载均衡社区分享和增加了一个有趣的方法。Zuul 是测试、实施和改进这些类型负载均衡方案的伟大系统,以 Netflix 的需求和规模来运行,使我们有能力证明和改进这些方法。
有许多不同方法可以改善负载均衡,而这个方法对我们来说效果很好,大大减少了与负载有关的错误率,并极大改善了真实流量的负载均衡。
然而,对于任何软件系统来说,都应该根据自己组织的限制和目标来做决定,并尽量避免追求完美。
附录--合成负载测试的结果
测试场景
这个负载测试场景重现了这样一种情况: 小型原始集群正在进行红黑部署,而新部署的集群存在冷启动问题或某种性能退化(通过人为在新部署的服务器上为每个请求注入额外延迟和 cpu 负载来模拟)。
该测试将 4000 RPS 发送到一个大型 Zuul 集群(200 个节点),该集群反过来代理到一个小型 Origin 集群(20 个实例),几分钟后,启用第二个缓慢的 Origin 集群(另外 20 个实例)。
启用所有功能
以下是启用了所有功能的新负载均衡器的指标图表。
启用新负载均衡器所有功能进行负载测试
作为参考,看看流量是如何在较快和较慢的服务器组之间分配的,可以看到,负载均衡器把发送到较慢组的比例减少到 15%左右(预期 50%)。
正常集群和慢速集群之间的流量分布
禁用服务器利用率
还是新负载均衡器,但禁用了服务器利用率功能,因此只有客户端数据被用于均衡。
使用新负载均衡器进行负载测试,但禁用了服务器利用率特性
原始实现
这是最初的轮询负载均衡器与服务器黑名单功能。
使用原始负载均衡器进行负载测试
你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。
微信公众号:DeepNoMind
版权声明: 本文为 InfoQ 作者【俞凡】的原创文章。
原文链接:【http://xie.infoq.cn/article/318c14cfcd7704d3589432ca4】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论