将Kubernetes扩展到7500个节点

162 阅读20分钟

Scaling Kubernetes to 7,500 Nodes

我们已经将Kubernetes集群扩展到7500个节点,为GPT-3CLIPDALL-E等大型模型产生了一个可扩展的基础设施,同时也为快速的小规模迭代研究,如神经语言模型的扩展规律。将单个Kubernetes集群扩展到这种规模是很少做的,需要一些特别的照顾,但好处是一个简单的基础设施,使我们的机器学习研究团队能够在不改变代码的情况下更快地前进和扩展。

自从上一篇关于扩展到2500个节点的文章后,我们继续扩大我们的基础设施,以满足研究人员的需求,在这个过程中,我们学到了许多额外的经验。这篇文章总结了这些经验,以便Kubernetes社区中的其他人能够从中受益,并以我们仍然面临的问题结束,我们将在下一步解决这些问题。

我们的工作负载

在我们走得太远之前,有必要描述一下我们的工作负载。我们用Kubernetes运行的应用程序和硬件与你在一般公司可能遇到的情况非常不同。我们的问题和相应的解决方案可能会,也可能不会,与你自己的设置相匹配。

一个大型的机器学习工作跨越了许多节点,当它能够访问每个节点上的所有硬件资源时,运行效率最高。这允许GPU直接使用NVLink进行交叉通信,或者GPU直接使用GPUDirect与网卡进行通信。因此,对于我们的许多工作负载来说,单个pod占据了整个节点。任何NUMA、CPU或PCIE资源争夺都不是调度的因素。Bin-packing或碎片化并不是一个常见的问题。我们目前的集群有完整的分节带宽,所以我们也不做任何机架或网络拓扑结构的考虑。所有这些都意味着,虽然我们有很多节点,但对调度器的压力相对较小。

也就是说,kube-scheduler的压力是尖锐的。一个新的工作可能由数百个pods组成,所有的pods都是一次性创建的,然后再回到一个相对较低的流失率。

我们最大的工作运行MPI,工作中的所有pod都参与到一个MPI通信器中。如果任何一个参与的pods死亡,整个作业就会停止,需要重新启动。工作定期检查,当重新启动时,它将从最后一个检查点恢复。因此,我们认为吊舱是半状态的,被杀死的吊舱可以被替换,工作可以继续,但这样做是破坏性的,应该保持在最低限度。

我们不怎么依赖Kubernetes的负载平衡。我们的HTTPS流量非常少,不需要A/B测试、蓝/绿或金丝雀。Pods通过SSH与MPI在他们的pod IP地址上直接通信,而不是服务端点。服务的 "发现 "是有限的;我们只是在作业启动时对哪些pod参与MPI进行一次性查找。

大多数作业都与某种形式的blob存储互动。他们通常要么直接从blob存储中提取数据集或检查点的一些碎片,要么将其缓存到一个快速的本地短暂磁盘中。我们有一些PersistentVolumes用于POSIX语义有用的情况,但是blob存储的可扩展性要好得多,而且不需要缓慢的detach/attach操作。

最后,我们的工作性质从根本上说是研究,这意味着工作负载本身是不断变化的。虽然超级计算团队努力提供我们认为是 "生产 "质量水平的计算基础设施,但在该集群上运行的应用程序是短暂的,它们的开发者迭代很快。新的使用模式可能在任何时候出现,挑战我们对趋势和适当权衡的假设。我们需要一个可持续发展的系统,同时允许我们在事情发生变化时迅速做出反应。

联网

随着我们集群中的节点和pod数量的增加,我们发现Flannel在扩展所需的吞吐量方面有困难。我们转而使用原生的pod网络技术为我们的Azure VMSSes和相关的CNI插件进行IP配置。这使我们能够在我们的pod上获得主机级别的网络吞吐量。

我们转而使用基于别名的IP地址的另一个原因是,在我们最大的集群上,我们可能在任何时候都有大约20万个IP地址在使用。当我们测试基于路由的pod网络时,我们发现在我们能有效使用的路由数量上有很大的限制。

避免封装增加了对底层SDN或路由引擎的要求,但它使我们的网络设置保持简单。添加VPN或隧道无需任何额外的适配器即可完成。我们不需要担心由于网络的某些部分具有较低的MTU而导致的数据包碎片。网络策略和流量监控是直接的;对数据包的来源和目的地没有任何歧义。

我们在主机上使用iptables标签来跟踪每个命名空间和pod的网络资源使用情况。这让研究人员可以直观地看到他们的网络使用模式。特别是,由于我们的很多实验都有明显的互联网和pod内的通信模式,能够调查任何可能发生瓶颈的地方往往很有用。

iptablesmangle 规则可用于任意标记符合特定标准的数据包。这里是我们的规则,用于检测流量是内部的还是互联网的。FORWARD 规则涵盖了来自pod的流量,与来自主机的INPUTOUTPUT 流量。

iptables -t mangle -A INPUT ! -s 10.0.0.0/8 -m comment --comment "iptables-exporter openai traffic=internet-in"
iptables -t mangle -A FORWARD ! -s 10.0.0.0/8 -m comment --comment "iptables-exporter openai traffic=internet-in"
iptables -t mangle -A OUTPUT ! -d 10.0.0.0/8 -m comment --comment "iptables-exporter openai traffic=internet-out"
iptables -t mangle -A FORWARD ! -d 10.0.0.0/8 -m comment --comment "iptables-exporter openai traffic=internet-out"

一旦做了标记,iptables将启动计数器,跟踪符合该规则的字节和数据包的数量。你可以通过使用iptables 自己来观察这些计数器。

% iptables -t mangle -L -v
Chain FORWARD (policy ACCEPT 50M packets, 334G bytes)
 pkts bytes target     prot opt in     out     source               destination
....
1253K  555M            all  --  any    any     anywhere            !10.0.0.0/8           /* iptables-exporter openai traffic=internet-out */
1161K 7937M            all  --  any    any    !10.0.0.0/8           anywhere             /* iptables-exporter openai traffic=internet-in */

我们使用一个名为iptables-exporter的开源Prometheus输出器,然后将这些跟踪数据输入我们的监控系统。这是一个简单的方法来跟踪匹配各种不同类型条件的数据包。

我们的网络模型的一个有点独特的方面是,我们将节点、pod和服务网络CIDR范围完全暴露给我们的研究人员。我们有一个集线器和辐条网络模型,并使用本地节点和吊舱CIDR范围来路由流量。研究人员连接到枢纽,并从那里可以访问任何一个单独的集群(辐条)。但集群本身不能相互交谈。这确保了集群保持隔离,没有跨集群的依赖性,不会破坏故障隔离。

我们使用一个 "NAT "主机来翻译服务网络的CIDR范围,用于来自集群外部的流量。这种设置使我们的研究人员在选择网络配置的方式和种类上有很大的灵活性,他们能够为他们的实验选择网络配置。

API服务器

Kubernetes API服务器和etcd是一个健康的工作集群的关键组成部分,所以我们特别关注这些系统的压力。我们使用kube-prometheus提供的Grafana仪表盘,以及其他内部仪表盘。我们发现对API服务器上的HTTP状态429(请求太多)和5xx(服务器错误)的比率发出警报,作为问题的高级信号是很有用的。

虽然有些人在kube中运行API服务器,但我们总是在集群本身之外运行它们。etcd和API服务器都运行在他们自己的专用节点上。我们最大的集群运行5个API服务器和5个etcd节点,以分散负载,并尽量减少影响,如果其中一个出现故障。自从我们在上一篇博文中把Kubernetes事件分割成自己的etcd集群后,我们在etcd上没有遇到明显的麻烦。API服务器是无状态的,通常很容易在自愈的实例组或规模组中运行。我们还没有尝试建立任何自愈的自动化etcd集群,因为事件极其罕见。

API服务器可能会占用相当多的内存,而这往往会随着集群中的节点数量而线性扩展。对于我们拥有7500个节点的集群,我们观察到每个API服务器使用了高达70GB的堆,所以幸运的是,这应该继续保持在硬件能力范围内,直到未来。

API服务器上的一个大的压力是终端上的WATCHes。有一些服务,如 "kubelet "和 "node-exporter",集群中的每个节点都是其成员。当一个节点从集群中被添加或移除时,这个WATCH就会启动。而由于通常每个节点本身都在通过kube-proxy观察kubelet 服务,这些响应所需的#和带宽将是N2N^2和巨大的,偶尔是1GB/s或更多。Kubernetes 1.17中推出的EndpointSlices是一个巨大的好处,它将这种负载降低了1000倍。

一般来说,我们非常注意任何API服务器的请求,这些请求随着集群的大小而扩展。我们尽量避免让任何DaemonSets与API服务器互动。如果你确实需要每个节点观察变化,引入中间缓存服务,如Datadog集群代理,似乎是一个很好的模式,以避免集群的瓶颈。

随着我们集群的发展,我们对集群的实际自动扩展做得比较少。但是,当一次自动缩放过多时,我们偶尔也会遇到麻烦。当一个新的节点加入集群时,会产生许多请求,而一次增加数百个节点会使API服务器的容量超载。平滑化这一点,即使只是几秒钟,也有助于避免中断。

使用Prometheus和Grafana的时间序列指标

我们使用Prometheus来收集时间序列指标,使用Grafana来制作图表、仪表盘和警报。我们从部署kube-prometheus开始,收集了各种各样的指标和用于可视化的良好仪表盘。随着时间的推移,我们增加了许多自己的仪表盘、指标和警报。

随着我们添加的节点越来越多,我们对普罗米修斯收集的大量指标感到很头疼。虽然kube-prometheus暴露了很多有用的数据,但有些数据我们实际上并没有看过,有些则过于细化,无法有效地收集、存储和查询。我们使用普罗米修斯的规则来 "放弃 "其中一些指标的摄入。

有一段时间,我们与一个问题作斗争,即普罗米修斯会消耗越来越多的内存,直到最终以内存不足的错误(OOM)使容器崩溃。这似乎是在向应用程序投掷了大量的内存容量后发生的。更糟糕的是,当它真的崩溃时,在启动时要花好几个小时来重放写前日志文件,才能再次使用。

最终我们找到了这些OOM的来源,是Grafana和Prometheus之间的互动,Grafana会使用Prometheus上的/api/v1/series API,查询{le!=""} (基本上是 "给我所有的直方图指标")。/api/v1/series 的实现在时间和空间上都是无限制的--对于一个有大量结果的查询,这将继续消耗越来越多的内存和时间。即使在请求者放弃并关闭连接之后,它也会继续增长。对我们来说,内存永远不够,Prometheus最终会崩溃。我们Prometheus打了补丁,将这个API包含在一个Context中,以强制执行一个超时,这就完全解决了这个问题。

虽然Prometheus崩溃的频率要低得多,但当我们确实需要重新启动它时,WAL重放仍然是一个问题。在Prometheus开始收集新的指标和提供查询服务之前,往往需要很多小时来重放所有的WAL日志。在Robust Perception的帮助下,我们发现应用GOMAXPROCS=24 有很大的改善。在WAL重放期间,Prometheus试图使用所有的核心,对于拥有大量核心的服务器来说,这种争夺会扼杀所有的性能。

我们正在探索新的方案来增加我们的监控能力,在下面的 "未解决的问题"一节中描述。

健康检查

对于这么大的一个集群,我们当然要依靠自动化来检测并从集群中移除行为不良的节点。随着时间的推移,我们已经建立了一系列的健康检查系统。

被动式健康检查

有些健康检查是被动的,总是在所有节点上运行。这些健康检查监测基本的系统资源,如网络可及性、磁盘坏了或满了,或GPU错误。GPU出现问题的方式有很多种,但常见的一种是 "无法纠正的ECC错误"。Nvidia的数据中心GPU管理器(DCGM)工具可以很容易地查询到这种错误和其他一些 "Xid "错误。我们跟踪这些错误的一种方式是通过dcgm-exporter将指标纳入我们的监控系统Prometheus。这将显示为DCGM_FI_DEV_XID_ERRORS 度量,并被设置为最近发生的错误代码。此外,NVML设备查询API暴露了有关GPU健康和运行的更多详细信息。

一旦我们检测到错误,它们通常可以通过重置GPU或系统来修复,尽管在某些情况下确实会导致底层GPU需要被物理替换。

另一种形式的健康检查是跟踪上游云提供商的维护事件。每个主要的云供应商都会公开一种方法,以了解当前的虚拟机是否即将发生维护事件,最终会导致中断。虚拟机可能需要重新启动,以便应用底层管理程序补丁,或将物理节点换成其他硬件。

这些被动健康检查在所有节点的后台不断运行。如果健康检查开始失败,该节点会被自动封锁,因此不会在该节点上安排新的pod。对于更严重的健康检查失败,我们也会尝试进行pod驱逐,要求所有当前运行的pod立即退出。这仍然取决于pod本身,可通过Pod Disruption Budget进行配置,以决定它是否要允许这种驱逐发生。最终,要么在所有pod都终止后,要么在7天后(我们的SLA的一部分),我们将强行终止虚拟机。

活跃的GPU测试

不幸的是,并非所有的GPU问题都表现为通过DCGM可见的错误代码。我们已经建立了自己的测试库,以锻炼GPU来捕捉额外的问题,并确保硬件和驱动程序的行为符合预期。这些测试不能在后台运行--它们需要单独使用GPU几秒钟或几分钟才能运行。

我们首先在节点启动时运行这些测试,我们称之为 "preflight "系统。这个污点可以防止正常的pod在节点上被安排。一个DaemonSet被配置为在所有带有这个标签的节点上运行预检测试豆荚。在成功完成测试后,测试本身会移除污点和标签,然后该节点就可用于一般使用。

我们还在节点的生命周期内定期运行这些测试。我们以CronJob的形式运行,允许它在集群的任何可用节点上运行。诚然,这对哪些节点被测试有点随机和不可控,但我们发现,随着时间的推移,它提供了足够的覆盖率,而协调或干扰最小。

配额和资源使用

随着我们的集群规模的扩大,研究人员开始发现他们很难获得所有分配的容量。传统的工作调度系统有很多不同的功能,可以在相互竞争的团队之间公平地运行工作,而Kubernetes没有这些功能。随着时间的推移,我们从那些作业调度系统中获得了灵感,并以Kubernetes原生的方式建立了几个功能。

团队污点

我们在每个集群中都有一个服务,"team-resource-manager",具有多种功能。它的数据源是一个ConfigMap,它为特定集群中所有有能力的研究团队指定了(节点选择器、要应用的团队标签、分配数量)的图元。它将此与集群中的当前节点进行核对,用openai.com/team=teamname:NoSchedule ,染指适当数量的节点。

"team-resource-manager "也有一个接纳的webhook服务,这样,当每个作业被提交时,就会根据提交者的团队成员资格应用一个相应的污点。使用污点允许我们灵活地限制Kubernetes pod调度器,例如允许对低优先级的pod进行 "任何 "容忍,这允许团队相互借用容量,而不需要重量级的协调。

CPU和GPU气球

除了使用cluster-autoscaler来动态扩展我们的虚拟机支持的集群外,我们还使用它来补救(删除和重新添加)集群中不健康的成员。我们通过将集群的 "最小尺寸 "设置为零,并将集群的 "最大尺寸 "设置为可用容量来做到这一点。然而,如果cluster-autoscaler看到闲置的节点,它将试图缩小到只需要的容量。由于多种原因(虚拟机旋转延迟、预先分配的成本、上面提到的API服务器影响),这种空闲扩展并不理想。

因此,我们为我们的纯CPU和GPU主机引入了一个气球部署。这个部署包含一个ReplicaSet,其中有 "最大尺寸 "数量的低优先级pod。这些pod占用了一个节点的资源,所以自动缩放器不认为它们是空闲的。然而,由于它们是低优先级,调度器可以立即驱逐它们,为实际工作腾出空间。(我们选择使用部署而不是DaemonSet,以避免DaemonSet被视为节点上的闲置工作负载)。

值得注意的一点是,我们使用了pod反亲和力,以确保pod会均匀地分布在各节点上。早期版本的Kubernetes调度器在使用pod anti-affinity时有一个O(N2)O(N^2)的性能问题。从Kubernetes 1.18开始,这个问题已经得到纠正。

群组调度

我们的实验经常涉及一个或多个StatefulSets,每个人都在操作训练工作的不同部分。对于优化器,研究人员需要StatefulSet的所有成员都被调度,然后才能进行任何训练(因为我们经常使用MPI来协调优化器成员之间的关系,而MPI对组成员的变化很敏感)。

然而,Kubernetes在默认情况下不一定会优先满足来自一个StatefulSet的所有请求。例如,如果两个实验各自请求100%的集群容量,Kubernetes可能不会调度一个实验的所有内容,而是只调度每个实验的一半pod,从而导致僵局,两个实验都无法取得进展。

我们尝试了一些需要自定义调度器的东西,但遇到了边缘情况,导致与正常的pod调度方式发生冲突。Kubernetes 1.18为核心的Kubernetes调度器引入了一个插件架构,使其更容易在本地添加类似的功能。我们最近找到了Coscheduling插件,作为解决这个问题的一个好方法。

未解决的问题

当我们扩大Kubernetes集群的规模时,还有很多问题需要解决。其中有几个问题包括。

衡量标准

在我们的规模下,Prometheus的内置TSDB存储引擎有很多困难,它的压缩速度很慢,而且每次重启都需要很长的时间来重放WAL(Write-Ahead-Log)。查询也容易导致 "查询处理将加载太多的样本 "的错误。我们正在迁移到另一个与Prometheus兼容的存储和查询引擎的过程中。期待未来的博文讲述它是如何进行的!

Pod网络流量整形

随着我们集群规模的扩大,每个吊舱都被计算为有一定的互联网带宽可用。每个人对互联网带宽的总需求已经很可观了,我们的研究人员现在有能力无意中给互联网上的其他位置带来巨大的资源压力,比如需要下载的数据集和需要安装的软件包。

结论

我们发现Kubernetes是一个非常灵活的平台,可以满足我们的研究需求。它有能力扩大规模,以满足我们放在它上面的最苛刻的工作负载。不过还有很多地方需要改进,OpenAI的超级计算团队将继续探索Kubernetes的扩展方式。如果这种工作看起来很有趣,你应该考虑在OpenAI申请!

import {Runtime, Inspector, Library} from "unpkg.com/@observable…"; import notebook_kube_nodes from "api.observablehq.com/d/0b8da606c…"; import notebook_kube_load from "api.observablehq.com/d/25d8299d9…"; import notebook_bandwidth from "api.observablehq.com/d/2758136b3…"; import notebook_api_requests from "api.observablehq.com/d/4fa7b09f1…"; import notebook_heap from "api.observablehq.com/d/6a4f8efd0…"; import notebook_response_size from "api.observablehq.com/d/ed770127f…"; const customWidth = function (selector) { return (new Library).Generators.observation(function(change) { var width = change(document.querySelector(selector).clientWidth); function resized() { var w = document.querySelector(selector).clientWidth; if (w !== width) change(width = w); } window.addEventListener("resize", resized); return function() { window.removeEventListener("resize", resize); }; }; const kube_nodes_renders = { "chart":"#kubenodes", "chart2":"#pendingpods", }; new Runtime(Object.assign(new Library, {width: customWidth("#kubenodes")}).module(notebook_kube_nodes, name => { const selector = kube_nodes_renders[name]; if (selector) { // key exists return new Inspector(document.querySelector(selector)); } else { return true; }) ; const kube_load_renders = { "chart":"#kubeload", }; new Runtime(Object.assign(new Library, {width: customWidth("#kubeload")}).module(notebook_kube_load, name => { const selector = kube_load_renders[name]; if (selector) { // key exists return new Inspector(document.querySelector(selector)); } else { return true; }); const bandwidth_renders = { "chart":"#bandwidth", }; new Runtime(Object.assign(new Library, {width: customWidth("#bandwidth")}).module(notebook_bandwidth, name => { const selector = bandwidth_renders[name]; if (selector) { // key exists return new Inspector(document.querySelector(selector)); } else { return true; }); const api_requests_renders = { "chart":"#apirequests", }; new Runtime(Object.assign(new Library, {width: customWidth("#apirequests")}).module(notebook_api_requests, name => { const selector = api_requests_renders[name]; if (selector) { // key exists return new Inspector(document.querySelector(selector)); } else { return true; }); const heap_renders = { "chart":"#heap", }; new Runtime(Object.assign(new Library, {width: customWidth("#heap")}).module(notebook_heap, name => { const selector = heap_renders[name]; if (selector) { // key exists return new Inspector(document.querySelector(selector)); } else { return true; }); const response_size_renders = { "chart":"#responsesize", }; new Runtime(Object.assign(new Library, {width: customWidth("#responsesize")}).module(notebook_response_size, name => { const selector = response_size_renders[name]; if (selector) { // key exists return new Inspector(document.querySelector(selector)); } else { return true; }) 。