服务器 频道

Netflix的CPU架构瓶颈排查与修复实践

  想象一下——周五晚上,你点击 Netflix 的播放按钮,后台数百个容器在几秒钟内迅速响应你的请求。对 Netflix 而言,高效扩展容器对于为全球数百万会员提供流畅的流媒体体验至关重要。为了应对如此庞大的规模,我们对容器运行时进行了现代化改造,却意外地遇到了一个瓶颈:CPU架构。

  今天来讲讲我们是如何诊断出这个问题的,以及在硬件层面扩展容器的过程中学到了什么。

  一、问题

  当应用需求需要扩展服务器时,我们会从AWS 获取一个新的实例。为了高效利用新增容量,我们会将 Pod 分配给该节点,直到其资源被完全分配。一个节点可能在刚准备好接收这些应用时,就从没有应用运行的状态迅速变为资源饱和。

  随着旧容器平台向新平台迁移的工作逐步推进,我们开始发现一些令人担忧的异常趋势。部分节点长时间处于停滞状态,简单的健康检查也会在30秒后超时。初步调查显示,在这些情况下,挂载表长度急剧增加,仅读取挂载表就可能需要 30 秒以上。查看 systemd 的堆栈信息,发现它也忙于处理这些挂载事件,这可能导致系统完全崩溃。在此期间,Kubelet 与 containerd 通信时也频繁超时。检查挂载表后发现,这些挂载操作与容器创建有关。

  受影响的节点几乎都是 r5.metal 实例,并且正在启动容器镜像包含 50 层以上的应用程序。

  二、挑战

  安装锁竞争

  图 1 的火焰图清晰地标明了 containerd 的耗时分布:其绝大部分时间都消耗在内核级锁的获取操作上,而这正是容器根文件系统组装阶段各种挂载相关操作的一部分。  

图 1:描绘锁争的火焰图

  仔细观察,如果使用用户命名空间,containerd 会对每一层执行以下调用:

  1)使用 open_tree() 获取对层/目录的引用;

  2)使用 mount_setattr() 将 idmap 设置为与容器的用户范围匹配,从而转移所有权,使该容器可以访问这些文件;

  3)使用 move_mount() 在主机上创建一个绑定挂载点,并应用此新的 ID 映射。

  这些绑定挂载点属于容器的用户范围,并用作创建基于 overlayfs 的容器根文件系统的底层目录。一旦 overlayfs 根文件系统挂载完成,这些绑定挂载点便会被卸载。因为overlayfs构建完成后,它们便不再需要保留。

  如果一个节点同时启动多个容器,那么每个 CPU 最终都会忙于执行这些挂载和卸载操作。内核虚拟文件系统 (VFS) 有各种与挂载表相关的全局锁,正如在火焰图顶部看到的那样,每次挂载都需要获取这些锁。任何试图快速启动大量容器的系统都容易出现这种情况,而这与容器镜像中的层数有关。

  例如,假设一个节点启动了 100 个容器,每个容器的镜像包含 50 个层。每个容器需要50个绑定挂载点来为每一层创建 ID 映射。容器的 overlayfs 挂载点将使用这些绑定挂载点作为底层目录创建,然后可以通过 umount 命令清除所有 50 个绑定挂载点。实际上,Containerd 会执行两次此操作,一次用于确定镜像中的一些用户信息,另一次用于创建实际的根文件系统。这意味着这 100 个容器在启动路径上总共需要执行 100 * 2 * (1 + 50 + 50) = 20200 次挂载操作,而所有这些操作都需要获取各种全局挂载相关的锁。

  三、诊断

  1、新版运行时环境有何不同?

  正如引言中提到的,Netflix一直在对其容器运行时进行现代化改造。过去使用的是虚拟kubelet + docker解决方案,而现在使用的是kubelet + containerd解决方案。新旧运行时都使用了用户命名空间,那么它们之间的区别是什么呢?

  1)旧版运行时:

  所有容器共享同一个主机用户范围。镜像层中的 UID 在解压时进行更改,以确保容器访问文件时文件权限匹配。这种方式之所以有效,是因为所有容器都使用同一个主机用户。

  2)新运行时:

  每个容器都拥有唯一的宿主机用户范围,从而提升了安全性。即使容器逃逸,也只能影响自身的文件。为了避免为每个容器解压和迁移 UID 的繁琐过程,新运行时采用了内核的 idmap 功能。这使得每个容器都能高效地进行 UID 映射,而无需复制或更改文件所有权,这也是 containerd 执行大量挂载操作的原因。

  下图 2 是此 idmap 特征的简化示例:  

图 2:idmap 特征

  2、为什么实例类型很重要?

  如前所述,该问题主要发生在 r5.metal 实例上。一旦确定根本原因,便可通过创建一个包含多层容器镜像,并将数百个使用该镜像的工作负载发送到测试节点来轻松复现该问题。

  为了更好地了解为什么某些实例上的瓶颈比其他实例上的更为严重,我们对不同类型的 AWS 实例上的容器启动进行了基准测试:

  r5.metal(第五代英特尔处理器,双路,多NUMA域)

  m7i.metal-24xl(第七代英特尔处理器,单路,单NUMA域)

  m7a.24xlarge(第七代 AMD,单路,单 NUMA 域)

  3、基线结果

  图 3 显示了在每种实例类型上扩展容器的基线结果:  

  在低并发(≤ ~20 个容器)情况下,所有平台的性能都相近;

  随着并发量的增加,r5.metal 在大约 100 个容器时开始出现故障;

  随着并发量的增长,第七代 AWS 实例保持了更低的启动时间和更高的成功率;

  m7a实例展现出最稳定的扩展性能,即使在高并发情况下,故障率也最低。

  四、深度探索

  利用性能记录和自定义微基准测试,我们可以看到最耗费资源的代码路径位于 Linux 内核的虚拟文件系统 (VFS) 路径查找代码中。具体来说,是 `path_init()` 函数中一个等待序列锁的紧密自旋循环。CPU 大部分时间都花在了执行暂停指令上,这表明许多线程都在自旋等待全局锁,如下面的反汇编代码片段所示。

  path_init(): … mov mount_lock,%eax test $ 0x1 ,%al je 7 c pause …

  利用英特尔的自顶向下微架构分析(TMA),我们观察到:

  95.5% 的管道槽位因存在争议的访问而停滞(tma_contested_accesses);

  57% 的插槽问题是由于伪共享(多个核心访问同一缓存行)造成的;

  缓存行跳转和锁争用是主要原因。

  鉴于资源访问争用的耗时占比较高,从硬件差异的角度出发,自然会考虑到研究架构中的NUMA和超线程技术对该子集的影响。

  1、NUMA 效应

  非统一内存访问 (NUMA) 是一种系统设计,其中每个处理器都拥有自己的本地内存以实现更快的访问速度,但需要通过互连网络才能访问连接到远程处理器的内存。NUMA 于 20 世纪 90 年代引入,旨在提高多处理器系统的可扩展性。NUMA 提升了性能,但也导致 CPU 访问连接到另一个处理器的内存时延迟增加。图 4 是一张简图,描述了 NUMA 架构的本地访问与远程访问模式。  

图 4:来源:https ://pmem.io/images/posts/numa_overview.png

  AWS 实例种类繁多,规格各异。为了获得最大的核心数,我们测试了双路第五代金属实例 (r5.metal),容器由 titus 代理进行编排。现代双路架构采用 NUMA 设计,虽然本地访问速度更快,但远程访问延迟更高。尽管容器编排可以保持本地性,但由于远程同步,全局锁很容易导致高延迟。为了测试 NUMA 的影响,我们对比测试了具有两个 NUMA 节点或插槽的 AWS 48xl 实例和具有单个 NUMA 节点或插槽的 AWS 24xl 实例。如图 5 所示,额外的跃点引入了高延迟,因此很快就会出现故障。  

图 5:沼冲击

  2、超线程效应

  超线程 (HT):如图 6 所示,在 m7i.metal-24xl (Intel) 上禁用超线程后,容器启动延迟降低了 20% 至 30%。因为超线程会争用共享的执行资源,加剧锁争用。启用超线程后,每个物理 CPU 核心会被分成两个逻辑 CPU(超线程),它们共享核心的大部分执行资源,例如缓存、执行单元和内存带宽。虽然这能提高未充分利用核心的工作负载的吞吐量,但对高度依赖全局锁的工作负载带来了巨大的挑战。禁用超线程后,每个线程都在其自身的物理核心上运行,从而消除超线程之间对共享资源的争用。因此,线程可以更快地获取和释放全局锁,从而减少整体争用,并改善通常共享底层资源的操作延迟。 

图 6:超线程的影响

  五、为什么硬件架构如此重要?

  1、集中式缓存架构

  一些现代服务器 CPU 使用网状互连来连接核心和缓存片,每个交汇点负责管理一部分内存地址的缓存一致性。在这些设计中,所有通信都通过一个中央队列结构,该结构一次只能处理一个针对给定地址的请求。当全局锁(例如挂载锁)争用严重时,所有针对该锁的原子操作都会被集中到这个队列中,导致请求堆积,最终造成内存停顿和延迟峰值。

  在一些知名的基于网状结构的架构中(如下图 7 所示),这个中央队列被称为“请求表”(Table of Requests,简称 TOR)。当多个线程争用同一个锁时,它会成为一个意想不到的瓶颈。如果你曾经好奇为什么某些 CPU 在高负载下会“喘口气”,这通常就是罪魁祸首。  

图 7:来自一家主要 CPU 厂商的公开文档 来源:https://www.intel.com/content/dam/developer/articles/technical/ddi o-analysis-performance-monitoring/Figure1.png

  2、分布式缓存架构

  一些现代服务器 CPU 采用分布式、基于芯片组的架构(图 8),其中多个核心复合体(每个复合体都有自己的本地末级缓存)通过高速互连结构连接。在这些设计中,缓存一致性在每个核心复合体内部进行管理,复合体之间的流量则由可扩展的控制结构处理。与采用集中式队列结构的网状架构不同,这种分布式方法将争用分散到多个域中,从而降低了因全局锁争用而导致的严重停顿的可能性。对于关注技术细节的读者,主流 CPU 厂商的公开技术文档会提供关于分布式缓存与芯片组设计的深度解析。 

图 8:来自一家主要 CPU 供应商的公开文档,来源:(AMD EPYC 9004 Genoa Chiplet Architecture 8x CCD — ServeTheHome)

  以下是对 m7i(集中式缓存架构)和 m7a(分布式缓存架构)上运行相同工作负载的比较。请注意,为了使实验更具可比性,鉴于图 6 中所示的性能下降,我们在 m7i 上禁用了超线程 (HT),并且实验使用了相同的核心数。结果清晰地显示,性能差异相当稳定,约为 20%,如图 9 所示。  

图 9:m7i 和 m7a 之间的架构差异

  3、微基准测试结果

  为了验证上述关于NUMA、HT和微架构的理论,我们开发了一个小型微基准测试程序。该程序会调用一定数量的线程,这些线程会持续等待一个全局竞争锁。通过增加线程数运行该基准测试程序,可以揭示系统在不同场景下的延迟特性。例如,下图10展示了使用NUMA、HT和不同微架构的微基准测试结果。  

图 10:全局锁争用基准测试结果

  此自定义综合基准测试(pause_bench)的结果证实:

  在 r5.metal 上,仅使用单个插槽来消除 NUMA 可以显著降低高线程数下的延迟。

  在 m7i.metal-24xl 上,禁用超线程可以进一步提高扩展性。

  在 m7a.24xlarge 上,性能扩展性更优,这表明分布式缓存架构在全局锁的情况下能够更优雅地处理缓存行争用。

  六、改进软件架构

  了解硬件架构的影响对于评估可能的缓解措施固然重要,但问题的根本原因在于对全局锁的争用。通过与上游 containerd 团队合作,我们找到了两种可能的解决方案:

  1)使用较新的内核挂载 API 的 fsconfig() lowerdir+ 支持,将经过 ID 映射的 lowerdir 作为文件描述符 (fd) 而不是文件系统路径提供。这样可以避免之前提到的 move_mount() 系统调用,该调用需要全局锁才能将每一层挂载到挂载表中。

  2)映射所有层的公共父目录。这使得每个容器的挂载操作次数从 O(n) 降低到 O(1),其中 n 是镜像中的层数。

  由于使用新版 API 需要使用新内核,我们选择进行后一项更改,以惠及更多社区成员。如此一来,containerd 的火焰图不再被挂载相关操作所主导。事实上,如下图 11 所示,不得不将它们用紫色高亮显示才能看到。  

图 11:优化方案

  七、结论

  在 Netflix 迁移到现代 kubelet + containerd 运行时环境的过程中,深刻体会到大规模运行时软件和硬件架构之间错综复杂的联系。虽然 kubelet/containerd 使用独立容器用户显著提升了安全性,但也暴露出一些新的瓶颈,这些瓶颈根植于内核和 CPU 架构,在并行启动数百个多层容器镜像时尤为突出。调查显示,并非所有硬件都适合这种工作负载:集中式缓存管理加剧了缓存争用,而分布式缓存设计则能在负载下平滑扩展。

  最终,更优解决方案是将硬件感知与软件改进相结合。为了立即缓解问题,我们选择将这些工作负载路由到在这些条件下扩展性更好的 CPU 架构。通过优化软件设计以最大限度减少各层挂载操作,消除了全局锁在容器启动阶段的瓶颈限制,最终实现了更高效稳定的弹性扩展,且该方案不依赖底层 CPU 架构。这一经验凸显了整体性能工程的重要性:在Netflix的规模下,要实现稳定流畅的用户体验,关键在于理解并优化软件堆栈以及运行的硬件环境。

  我们相信,这些技术见解可以帮助更多从业者从容应对不断发展的容器生态系统,将潜在的技术挑战转变为构建强大、高性能平台的机遇。

0
相关文章