这是我和 David Mackey 在 2019 年 4 月共同撰写的一份内部文档的摘录。由于原文大部分内容涉及比较提高 Twitter 效率的潜在方法,而这些信息在缺乏大量额外解释/背景的情况下,对 Twitter 以外的人毫无意义,因此仅摘录了部分内容。

在 Twitter,大多数 CPU 密集型服务在容器预留 CPU 利用率达到约 50% 时就开始出现问题,而几乎所有服务在 CPU 利用率略高于此水平时也会开始出现故障——尽管理论上 CPU 密集型服务应该能够达到更高的 CPU 利用率。由于负载通常无法在各分片间均匀分布,且一旦超过 50% 的 CPU 利用率,分片级别的性能下降会非常严重,这使得实际限制远低于 50%,即使在峰值负载事件期间也是如此。

本文档将描述这一问题的潜在解决方案。首先,我们将解释为何在现有服务配置方式以及所使用的 Linux 调度器机制下,这个问题是预期会出现的。接着,我们将通过案例研究探讨如何通过特定服务的配置调优来解决此问题,这有望将容量提升 1.5 到 2 倍,从而为大型服务节省每年 [已编辑] 百万美元至 [已编辑] 百万美元的成本。虽然这项工作值得开展,并且通过为大型服务进行此类调优,我们可能能在总拥有成本(TCO)上每年节省 [已编辑] 百万美元至 [已编辑] 百万美元,但逐一手动修复服务并不具备可扩展性。因此,我们还将探讨如何通过系统性变革,为大多数服务挽回部分价值。

理论上的问题

Twitter 的几乎所有服务都运行在 Linux 上,使用 CFS 调度器,并通过 CFS 带宽控制配额 实现隔离,且均采用默认参数。这样设计的目的是允许不同服务共存于同一台机器上,防止某个服务的失控 CPU 使用影响其他服务,同时避免空机器上的服务占用全部 CPU 导致性能不可预测——在启用配额之前,服务所有者发现这种不可预测性难以分析。配额机制限制了每个容器的平均 CPU 使用率,但并未限制作业在任意时刻可使用的核心数。相反,如果某个作业“想要”在配额时间片内使用超过其配额的核心数,它会在短时间内使用更多核心,然后被节流(即基本被置于休眠状态),以使其平均核心使用率保持在配额以下——这对尾部延迟 来说是灾难性的1

由于 Twitter 上绝大多数服务使用的线程池规模远超其 Mesos 核心预留量,当任务负载较高时,它们最终会请求并使用超出预留量的核心数,进而触发限流。这导致那些基于负载测试数据或负载下观测延迟进行资源预配的服务,不得不过度配置 CPU 以避免违反其 SLO。它们要么为每个分片申请比实际需求更多的 CPU,要么增加使用的分片数量。

这个问题的一个典型例子是 JVM 垃圾回收器。在 JVM 实现容器感知功能之前,每个 JVM 默认将 GC 并行线程池大小设置为机器核心数。GC 期间,所有这些 GC 线程会同时运行,迅速耗尽 CPU 配额并导致限流。其结果是原本亚秒级的 STW(Stop-The-World)GC 暂停可能需要数秒的挂钟时间才能完成。虽然 GC 问题已得到修复,但对于几乎所有运行在 Mesos 上的服务而言,应用层面仍然存在类似问题。

实际问题案例研究

以 Twitter 规模最大、成本最高的 service-1 服务为例进行案例分析。

下图是该服务在负载测试即将失败时的 CPU 利用率直方图——即服务刚好超过其能处理的峰值负载,即将违反 SLO 的时刻。X 轴表示某一时刻使用的 CPU 数量,Y 轴表示该利用率水平的(相对)时间占比。该服务预配了 20 个核心,但即使运行在接近峰值负载时,利用率也大多显著低于该值:

20 CPU 配额服务的直方图显示:平均利用率远低于配额,但当服务过载并违反 SLO 时,峰值利用率显著升高

问题在于 20 以上的小柱状条。这些峰值导致任务耗尽 CPU 配额后被限流,进而引发延迟急剧上升——这就是尽管平均利用率仅约 8 个核心(配额 40%),SLO 仍被违反的原因。需注意:该图的采样周期为 10ms,而配额周期为 100ms,因此理论上图中可能出现超过 20 的峰值而不触发限流,但通常如果频繁出现大幅超过 20 的峰值,限流几乎必然发生。

在缩小线程池规模以避免过度使用核心并引发限流后,负载测试下的 CPU 利用率直方图变为:

20 CPU 配额服务的直方图显示:平均利用率远低于配额,但当服务过载并违反 SLO 时,峰值利用率显著升高

此负载为之前直方图的1.6倍(请求速率)。在这种情况下,负载测试工具无法增加足够负载来确定service-1的峰值负载,因为该服务在故障前能处理极大负载,导致负载测试期间为其提供输入的服务无法跟上并发送更多负载(虽然此问题可修复,但我没有足够权限快速处理)。[后续测试表明,调整线程池大小后,该服务能处理约2倍容量]

此案例并非孤例——Andy Wilcox对service-2进行了类似分析,发现因相同原因,在负载下性能也有类似提升。

对于关注延迟的服务,若优先降低延迟而非成本,可显著改善延迟。对于service-1,若保持预置容量不变(而非削减至一半),延迟可降低20%。

对单个大型服务进行此类优化收益显著(以service-1为例,该服务每年可节省[中七位数],包括其克隆服务在内每年可节省[低八位数]),但手动调优每个服务不可扩展。这引出一个问题:有多少服务受到影响?

整个集群的线程使用情况

若观察中等规模服务(>= 100分片)的活动线程数与预留核心数之比,几乎所有服务的待执行线程数都远超预留核心数。每个预留核心对应数十个可运行线程的情况并不罕见。这使得上述service-1示例(负载下每个预留核心对应1.5至2个可运行线程)显得相对温和。

若探究这些线程的来源,常见情况是程序拥有多个线程池,每个线程池的大小设置为预留核心数的两倍或宿主机逻辑核心数的两倍。在Twitter内外,常见建议是线程池大小应为机器逻辑核心数的两倍。此建议可能源于类似gcc编译的工作负载——当有任务可执行时,我们不希望资源闲置。由于线程有时会被阻塞而无所事事,将线程数设为2倍可通过降低任何核心空闲的概率来提升吞吐量,而2倍恰好是个整数。

然而,将此建议应用于Twitter应用程序存在几个问题:

  1. 大多数应用程序拥有多个相互竞争的线程池
  2. 超出预留核心限制极为不利
  3. 额外线程处理计算任务可能增加延迟

“我们应该按逻辑核心数两倍来配置”的模型假设我们只有一个主线程池处理所有工作,且让可能执行任务的线程闲置几乎不会带来负面影响,同时我们面对的是吞吐量导向型工作负载,不关心任何特定工作单元的截止时间。

在使用CFS调度器的情况下,拥有活跃工作且超出核心预留量的线程并不会闲置,它们会被调度并运行,但这会导致限流,从而对尾部延迟产生负面影响。

潜在解决方案

鉴于我们在许多服务上观察到与案例研究类似的情况,且向大量服务推送性能修复存在困难(因为服务所有者缺乏采纳性能改进的动力),我们该如何在整个集群范围内(而非仅针对少数精选大型服务)解决此问题?我们将列出潜在解决方案,随后逐一详细讨论。

  • 优化跨集群线程池的默认配置(eventbus、netty等)
  • 通过共享库协商线程池大小
  • CFS周期调优
  • CFS带宽切片调优
  • 其他调度器调优
  • CPU绑定与隔离
  • 在mesos调度器层面进行过度配置

优化跨集群线程池的默认配置

潜在影响:效率小幅提升 优势:工作量远小于全面解决方案,可与其他方案并行推进,即使其他方案落地后仍能带来收益(因锁竞争和上下文切换减少)。 缺点:无法解决大部分问题。

许多默认配置过大。Netty默认线程池大小为预留核心数的两倍。在[某组织]的部分团队中,他们使用一个库启动eventbus并分配主机逻辑核心数两倍的线程池(导致[超过100个]eventbus线程),而大多数eventbus使用场景下1-2个线程已足够。

调整这些默认大小无法根治问题,但能减轻影响,且工作量远小于后续方案,因此可在推进更全面解决方案的同时并行实施。

通过共享库(API)协商线程池大小

[本节由Vladimir Kostyukov撰写]

潜在影响:可基本缓解大多数服务的问题。 优势:设计与实现相当直接;可将其作为Finagle/Finatra的一等公民。 缺点:需要服务所有者明确选择采用(采用新的线程池构建API)。

CSL的util库中有一个包(util-jvm)提供了应用程序与JVM之间的集成点,这可能是托管新API以协商应用程序所需线程池大小的理想位置。

此类API的外观与风格实际上取决于协商所需的粒度。若仅针对每个进程分配的线程总数进行简单协商(虽易于实现),却无法区分应用线程与IO线程。而在线程池中引入线程服务质量(QoS)概念(例如“IO线程:不可阻塞”、“应用线程:可阻塞”),则能使协商更加精细化。

CFS周期调优

潜在影响:通过缩短进程组CFS运行时配额刷新前的时间周期长度,可小幅降低尾部延迟。 优势:改动相对直接,仅需极少量代码变更。 缺点:会增加调度器开销成本,可能抵消收益,且无法解决并行性耗尽配额的核心问题,可能导致总节流次数增加。

为限制CPU使用率,CFS基于称为CFS周期的时间窗口运行。调度组中的进程从分配给cgroup的CFS配额中获取时间,该配额在cfs_period_us内以CFS带宽切片形式消耗。通过缩短CFS周期,配额耗尽导致节流与进程组可重新运行之间的最坏情况时间将按比例缩短。以默认值(CFS带宽切片5ms、CFS周期100ms)为例,在最坏情况下,高度并行的应用程序可能在首个带宽切片内耗尽所有配额,导致后续95ms的节流时间内无任何线程可被重新调度。

总节流次数可能增加,因为100ms内的调度时间可能未超过阈值,但存在(例如)5ms突发超过阈值的情况。

CFS带宽切片调优

潜在影响:通过允许应用程序更充分利用已分配配额,可小幅降低尾部延迟。 优势:改动相对直接,仅需极少量代码变更。 缺点:会增加调度器开销成本,可能抵消收益,且无法解决并行性耗尽配额的核心问题。

当CFS调度进程时,会在全局池与CPU本地池之间传输运行时,以减少大型系统上的全局记账压力。每次传输量称为“切片”。从调度器角度看,较大的带宽切片效率更高,但较小的带宽切片能实现更精细的执行粒度。在排查[内部JIRA工单链接]问题时发现:若被调度进程因执行完毕或阻塞于其他进程而未能消耗完整带宽切片(默认切片大小为5ms),则这部分时间将丢失,导致进程组无法充分利用其请求的全部可用资源。

调整此值的开销预计很小,但应进行测量。此外,它很可能不是一个通用的可调参数,但在Mesos中,过去曾拒绝将其作为可调参数暴露给用户。确定调整此值的启发式方法并为每个应用程序提供设置方式可能不可行。

其他调度器调优

潜在影响:尾部延迟小幅降低,节流减少。 优势:相对直接的更改,所需代码改动最少。 缺点:可能增加调度器开销成本,从而抵消收益,且未解决并行性耗尽配额的核问题。

内核具有众多自动缩放和自动分组功能,其对调度性能和节流的影响目前尚不明确。kernel.sched_tunable_scaling 可以在我们对其值理解不足的情况下调整 kernel.sched_latency_nskernel.sched_min_granularity_nskernel.sched_wakeup_granularity_ns 可进行调整,以允许更早抢占,从而改善资源共享并减少延迟。kernel.sched_autogroup_enabled 目前可能不遵循 kernel.sched_latency_ns,导致更多节流挑战和调度效率低下。这些可调参数尚未得到充分研究,调整它们的影响未知。

CFS调度器改进

潜在影响:更好的整体CPU资源利用率,并因CFS效率低下而减少节流。 优势:改进对用户空间透明。 缺点:CFS调度器复杂,因此更改成功风险较大,且上游对某些类型修改的接受可能面临挑战。

CFS调度器处理CFS带宽切片中未使用的空闲时间的方式已被证明效率低下。内核团队有一个补丁,确保这些未使用时间返回全局池供其他进程使用,https://lore.kernel.org/patchwork/patch/907450/,以确保更好的整体系统资源利用率。还有一些其他途径值得探索,可能提供进一步增强。该领域近期许多讨论之一源于k8s节流问题(https://github.com/kubernetes/kubernetes/issues/67577),详见https://lkml.org/lkml/2019/3/18/706

此外,CFS可能因错误(如[链接到内部JIRA工单]和http://www.ece.ubc.ca/~sasha/papers/eurosys16-final29.pdf)而损失效率。然而,我们尚未花太多时间研究Twitter特定用例下的CFS性能。更仔细地审视CFS可能会找到提高效率的方法。

另一个潜在影响更大的更改是使用CFS以外的调度器。

CPU绑定与隔离

潜在影响:通过使应用程序开发者的CPU心智模型映射到物理CPU,从系统中移除节流概念。 优势:从应用程序开发者角度简化理解,调度器强加的节流不再是应用程序需要应对的概念,缓存效率提升,资源干扰大幅减少,从而带来更确定的性能。 缺点:运营复杂性增加,超卖更加复杂,当前运行环境需重大变更。

导致节流发生的根本问题在于,一个高度线程化的应用可能拥有比其请求的“CPU数量”更多的并行执行线程,从而导致可用运行时间的过早耗尽。通过将同时执行的线程数限制为应用请求的CPU数量,现在实现了1:1的映射关系,应用的进程组可以自由消耗逻辑CPU线程,不受调度器的阻碍。此外,通过为应用分配专用CPU线程而非带宽切片,应用能够充分利用CPU缓存优势,无需在节流或上下文切换期间与其他被调度到同一CPU线程的应用竞争。

在Mesos中,实现CPU绑定已被证明相当困难。然而,在k8s中,英特尔的一个名为k8s CPU管理器的项目带来了希望。CPU管理器作为k8s 1.8版本的alpha特性被加入,并从1.10版本起作为beta特性启用。由于似乎很少有人使用它,该特性在beta阶段进展有些停滞,但核心功能已经具备。CPU管理器项目所宣称的性能提升非常显著,例如在https://kubernetes.io/blog/2018/07/24/feature-highlight-cpu-manager/https://builders.intel.com/docs/networkbuilders/cpu-pin-and-isolation-in-kubernetes-app-note.pdf等示例中所示。虽然对这些基准测试应持一定怀疑态度,但它确实为探索这一方向提供了有希望的前景。对该项目的初步检查指出了几个可能仍需工作的领域,但它已处于可用于验证方法的可用状态。底层上,k8s CPU管理器利用了内核中存在的cpuset cgroup功能。

这种方法可能会降低机器超卖的能力。然而,从最小化跨Pod干扰、CPU节流、更确定的执行配置文件等方面获得的效率提升,可能会抵消超卖的需求。目前,k8s CPU管理器确实允许以系统级容器和DaemonSet的形式进行轻微超卖,但在Pod调度基础上,CPU是为该Pod的使用而保留的。

Brian Martin等人的实验表明,CPU绑定带来的性能提升几乎与我们超卖因子的大小相当。

从长远来看,通过一种多层方法可以实现超卖:其中一类主要Pod使用CPU绑定进行调度,而另一类对延迟不敏感的次要Pod则允许在所有核心上浮动,消耗主要Pod的闲置资源。在CPU管理器方面的工作将非常广泛。然而,最近Facebook在调度器方面进行了一些工作,以进一步推动这一概念,且对主要Pod类别的影响最小,我们可以在此基础上进行扩展或演进。

集群调度器层面的超卖

潜在影响:可将机器利用率提升至任意高水平,并实现“足够”的过度配置。
优势:集群调度器层面的超额订阅与本文档所述问题无关;通过数据驱动方式实现超额订阅,可在无需尝试解决此处具体问题的情况下提升机器利用率。这既能解决本文档中的问题(因过度配置以避免限流导致的低CPU利用率),同时也能修复[引用描述另一问题的文档]。
劣势:我们在[内部文档链接]中发现,运行在高负载主机上的服务分片性能会下降。除非我们修改Mesos调度器,使其基于实际利用率(而非预留量)进行调度,否则部分主机将负载过高,导致其上的服务分片性能不佳。

禁用CFS配额

潜在影响:可防止限流,并允许服务通过“份额”机制(而非配额)使用主机上所有可用核心。
优势:从某种意义上看,这能实现最高可能的利用率。
劣势:行为异常的服务可能严重干扰同一主机上的其他服务。此外,服务所有者将更难预测自身服务的性能,因为无负载与有负载状态间的性能波动会显著增大。

该方案正是我们在启用配额前所采用的方式。从朴素的硬件利用率角度看,依赖份额机制似乎最优——这意味着若主机利用率不足,服务可占用闲置核心;但当主机利用率升高时,服务将退回到按其核心预留比例获取份额。然而,在使用该系统的时期,大多数服务所有者认为在负载下估算性能过于困难,导致该方案缺乏实用性。至少有一家公司曾尝试用此方案解决限流问题,但因负载下出现严重事故而失败。若今天重新采用此方案,我们的处境不会比启用配额前更好。

根据我们分配容量的方式,有两个因素能使这一方案比以往更有效:一是更精细地控制对单个分片的请求速率,二是建立负载测试环境,让服务所有者了解负载激增时的真实情况。而我们的系统目前只能向单个分片注入不切实际的负载,这存在两个问题:请求混合模式与实际负载激增时不同,且被注入负载的分片并未受到同一机器上其他服务运行带来的负载压力。根据[另一份内部文档],我们知道影响分片级性能的最大因素之一是机器的整体负载,且延迟影响是非线性的且难以预测,因此目前无法通过现有负载测试框架下的测试性能来预测实际负载下的表现。

尽管这些缺失的因素至关重要且影响重大,但解决这些问题超出了本文档的范围;[X团队]负责负载测试并正在推进相关工作,或许待问题解决后值得重新审视。

一个折中方案是将调度器配额设置为大于Mesos中保留核心数的值。这既能限制“过多”CPU可用性导致不可预测性能的影响,又能在高负载时减少限流——因为当机器负载过高时,调度器会自动回退到份额机制。例如,若cgroup配额是Mesos配额的两倍,原本在50%保留Mesos CPU使用率时崩溃的服务,将改为在100%保留Mesos CPU使用率时崩溃。对于高负载机器,更高的整体利用率会减少限流,因为其他核心的负载增加意味着拥有过多可运行线程的服务无法同时执行这么多线程。这会产生弱化版的禁用配额弊端:根据[内部文档],其他服务的机器负载是分片级性能差异的最大因素之一,若我们不改变机器上Mesos核心保留数,这将增加机器负载。而如果按比例减少机器上Mesos保留核心数,则会使变更失去意义——相当于只是将每个服务的CPU保留量翻倍,只不过“暗中”翻倍可能会减少人们提出这样的疑问:“为什么负载测试中CPU使用率不能超过X%,否则服务就会崩溃?”

结果

本节内容不在2019年4月的原始文档中;写于2021年12月,描述了原始文档促成的工作。

采纳了更改默认线程池大小的建议,并带来了小幅改进。更重要的是,该文档催生了两项重大成果。Vladimir Kostyukov(来自CSL团队)和Flavio Brasil(来自JVM团队)创建了Finagle Offload Filter,而Xi Yang(当时是我的实习生2,现已成为我团队的全职员工)则创建了一个内核补丁,消除了容器限流问题(该补丁目前仍为内部使用,但有望最终提交到上游)。

Twitter上几乎所有运行在mesos上的应用程序都基于Finagle。Finagle Offload Filter使服务所有者能够轻松地将应用程序工作分配到与IO不同的线程池(这在以前往往无法实现)。结合合理调整线程池大小,在其他条件不变的情况下,应用程序的延迟显著降低,从而能够在满足SLO的同时减少预置容量,进而降低成本。根据服务类型不同,这可为服务降低15%至60%的成本。

内核补丁实现了一个显而易见的思路:不再允许容器使用机器上所有可用的核心,然后因使用过多核心而将其休眠以降低平均核心使用率,而是确保容器在任何时刻使用的核心数不超过其配额。

在Twitter运行主要服务的实验表明,这一改进预期能消除与节流相关的问题,对于线程池大小未经调整的典型服务,成本降低约50%。而实际影响比我们撰写本文时意识到的更大,因为阻止服务使用“过多”核心并触发节流3后,干扰减少的效果进一步放大了收益。此外,尽管当时已认识到这一点,但本文未提及:节流问题会导致分片从“基本完全正常”状态滑入类似“GC死亡螺旋”的“节流死亡螺旋”,仅需少量额外负载就会显著增加系统可靠运行的难度。具体机制是:当服务处于高负载时会触发节流。节流不会阻止请求进入已节流的分片,因此当分片从节流状态恢复时,积压的工作比节流前更多,导致CPU使用率更高、节流更频繁,进而堆积更多工作。Finagle具备为严重异常分片卸载负载的机制(与故障服务器通信的客户端会将其标记为不可用并暂停发送请求),但分片通常在服务整体负载较高时陷入这种糟糕状态,因此标记节点不可用只会将更多负载转移至其他分片,这些分片随后也会“倾向于”进入节流死亡螺旋。在节流可能引发死亡螺旋的机制下运行,本质上是一种亚稳态。消除这两个问题带来的影响,其重要性不亚于我们通过消除节流所实现的成本降低。

杨曦已对上述朴素内核调度器变更方案进行了多种变体实验,但即便最基础的变更相较于无变更也效果显著——尽管朴素变更意味着服务在请求全量CPU时往往无法获得完整分配,例如若服务在前半周期不请求CPU、后半周期请求无限CPU,旧系统会为其分配整周期的额定CPU量,而新系统仅能获得一半。杨曦的部分变体补丁以不同方式解决了此问题,但与优先防止节流相比,其影响相对较小。

Pratik Tandel推动的一项独立变更,通过减少分片间差异的影响来降低节流对服务的影响,其做法是迁移到更少但更大的分片。该变更的主要目标是减少因所有分片中重复工作/内存而产生的开销,但同时也因每个分片配额更大、随机噪声影响更小而产生效果。总体而言,这使Twitter大型服务的CPU使用率降低了0%到20%,内存使用率降低了10%到40%,具体取决于服务。

附录:其他容器节流相关研究

感谢Xi Yang、Ilya Pronin、Ian Downes、Rebecca Isaacs、Brian Martin、Vladimir Kotsyukov、Moses Nakamura、Flavio Brasil、Laurence Tratt、Akshay Shah、Julian Squires、Michael Greenberg @synrotek和Miguel Angel Corral提供的评论/更正/讨论


  1. 如果这个盒子负载过高,因为可分配的核数不足,那么容器可能无法获得它请求的全部核数,但这并没有改变根本问题。[return]
  2. 我常开玩笑说,实习生总能接手最有趣的工作,而我们这些全职员工只能处理实习生不愿做的杂活。[return]
  3. 在一项独立研究中,Matt Tejo 发现,在平均核利用率固定的情况下,会进行节流的服务对同一主机上其他服务的负面影响,远大于使用固定核数的服务。这是因为高负载并处于节流状态的服务,会在试图使用盒子上所有核与完全不使用核之间来回切换,从而在它试图使用所有核的时段内造成极其严重的干扰。[return]