2018年1月OpenAI官方博客称,他们已将KubeRnetes集群扩展到2500个节点。时隔三年,在2021年1月,OpenAI官方博客再度宣布KubeRnetes集群扩展到7500个节点,目前不仅可以满足GPT-3、CLIP和DALL&E等大型训练模型提供可扩展的基础架构,而且还可用于小规模快速迭代研究,例如神经语言模型的标度律等。下面文章来自于OpenAI官方博客,描述了走向这个7500节点规模过程中遇到的问题和解决办法,以及对于未来走向的畅想。
我们的KubeRnetes集群规模已经上升到7,500个节点,主要为诸如GPT-3、CLIP和DALL&E等大型训练模型提供可扩展的基础架构,而且还可用于小规模快速迭代研究,例如神经语言模型的标度律等。将单个KubeRnetes集群扩展到如此规模很难完成,同时在这个过程中需要格外小心。但好处是借助这种简单的基础架构使得我们的机器学习研究团队无需更改其代码就可以快速扩容。
自上一篇有关扩展到2,500个节点的文章发表以来,我们一直在不断扩展基础架构以满足研究人员的需求,在此过程中我们还学到了很多经验。这篇文章对此作了总结,以便KubeRnetes社区共同受益,最后介绍我们仍然要面对的问题以及解决办法探讨。
工作负载
在我们深入讨论之前,介绍一下我们的工作负载是很重要的。我们运行KubeRnetes软硬件和您在公司的情况可能不太一样。我们的问题和相应的解决方案可能是,也可能不是,也请您视情况而应用!
大型机器学习作业跨越许多节点,并且只有当可以访问每个节点上的所有硬件资源时,才能最大化运行效率。如此一来,GPU就可以通过 NVlink直接进行交叉通信,或者GPU也可以通过GPUDiRect直接与NIC通信。因此,对于我们的许多工作负载,一个节点上只放置一个Pod。任何NUMA、CPU或PCIE资源争用都不是调度的因素,因此装箱调度或碎片化不是一个常见的问题。我们现有的集群拥有完整的对分带宽,因此也无需考虑任何机架或网络拓扑。所有这些都表明,我们的KubeRnetes拥有许多节点,但是调度的压力相对较低。
不过,kube-scheduleR上经常会出现峰值压力。一个新的Job可能包含数百个一次性创建的Pod,但具有较低的使用率。
我们最大的Job上运行着 MPI 协议(消息传递接口协议),该Job内的所有Pod都加入了同一个MPI通信器。如果某个Pod宕机,则整个Job都将暂停,需要重新启动。我们会定期保存检查点,Job重启时会从上一个检查点恢复。因此,可以认为Pod是半状态化的,终止的Pod可以被替换掉,而且Job还可以继续,但是这种做法会干扰正常的Job,应尽量减少。
由于https通道流量很少,也不需要进行A/B测试、蓝/绿或金丝雀部署,我们没有完全依赖KubeRnetes进行负载均衡。Pod之间通过SSH(而不是服务端点),利用IP地址直接通过MPI相互通信。我们的服务“发现&Rdquo;功能很有限,一般只需要在Job启动的时候执行一次查找去找到MPI中的Pod。
我们的大多数Job都使用了某种形式的Blob存储。通常,它们会直接从Blob存储,以流的形式读取数据及或检查点的某些分片,或将其缓存到临时的本地磁盘。在需要POSIX语义的时候,我们也使用了一些持久卷,但是Blob存储更容易扩展,而且不需要缓慢的分离/附加操作。
最后要提醒,我们的工作大多是基于研究性质的,这意味着负载本身在不断变化。尽管超算团队努力提供了生产级别的计算基础架构,但集群上运行的应用程序的生命周期很短,而且开发人员的迭代非常快。新的使用模式随时可能出现,因此我们很难预料发展趋势,并做出适当的折中。我们需要一个可持续发展的系统,以便在事情发生变化时迅速做出响应。
网络
由于集群内的Node数和Pod数不断增长,我们发现Flannel难以扩展到所需的吞吐量。于是,我们转而使用原生Pod网络技术来管理AzuRe VMSSes的IP配置和相关的CNI插件。这样我们的Pod就能够获得宿主级别的网络吞吐。
我们最大的集群上大约有20万个IP地址正在使用中,在测试基于路由的Pod网络时,我们发现可以有效利用的路由数量受到了严重限制。因此我们改用基于别名的IP寻址。
避免封装增加了对底层SDN或路由引擎的要求,但它使我们的网络设置保持简单。无需任何额外的适配器就可以添加隧道。我们不需要担心数据包分片,因为网络的某些部分MTU较低。网络策略和流量监控也很简单;数据包的源和目的地不存在歧义。
我们在宿主上使用IPtables来跟踪每个命名空间和Pod上网络资源的使用情况。这样研究人员就可以可视化网络的使用情况。具体来说,因为许多实验的互联网和Pod间通信都有独特的模式,所以能够调查何处可能出现瓶颈是非常必要的。
IPtables的Mangle规则可以给任何符合特定规则的数据包做标记。我们采用了以下规则来检测流量属于内部还是发向外网。FORWARD规则负责Pod间的流量,而INPUT和outPUT负责来自宿主的流量。
做好标记后,IPtables就会统计符合该规则的数据包的字节数。使用IPtables命令就可以看到这些统计结果。
我们使用了一个名为 IPtables-expoRteR 的开源 ProMetheUS 导出程序,将这些跟踪信息导出到监控系统中。这样就可以直接跟踪符合各种条件的数据包了。
我们的网络模型的独特之处在于,Node、Pod和服务网络的CIDR范围是完全暴露给研究者的。网络采用了轮辐模型,使用原生节点和Pod的CIDR范围进行路由。研究者连接到中央枢纽,从那里可以访问到任何集群。但是两个集群之间不能互相通信。这样可以保证每个集群都是隔离的,不会出现跨集群依赖(否则会破坏故障隔离原则)。
我们使用一个“NAT&Rdquo;宿主对来自集群外部的流量进行CIDR范围转译。这种结构可以让研究人员自由地选择使用何种网络配置以及怎样使用,以满足实验的需要。
API SeRveRs
对于健康工作的集群来讲, API SeRveRs和etcd是KubeRnetes的关键组件,所以我们特别关注这些组件。我们采用了kube-ProMetheUS提供的GRaFAna仪表板,以及自己设计的仪表板。我们发现,针对API SeRveRs上发生的HTTP 429(Too Many requests)和5xx(SeRveR ERRoR)发送高级别报警非常有效。
虽然许多人在KubeRnetes内部运行API SeRveRs,但我们选择了在集群外部运行。etcd和API SeRveRs都运行在独立的节点上。最大的集群运行了5个API SeRveRs和5个etcd节点,并以分散负载减小宕机造成的影响。自从将KubeRnetes Events分离到单独的etcd集群上以后,就再也没有出现过因etcd问题导致的故障。API SeRveRs是无状态的,因此只需要运行一个自我修复的实例组或scaleset就可以。我们没有尝试过针对etcd集群构建自我修复自动化,因为它极少出故障。
API SeRveRs占用的内存相当多,而且内存占用会随着集群中的节点数量增加而呈线性增长。对于我们拥有7500节点的集群,每个API SeRveRs上的堆空间占用最多为70GB,还好这依然在硬件能够承受的范围内。
API SeRveRs上比较大的压力之一就是端点上的Watch。有几个服务的服务对象是集群中的所有成员,如kubelet、node-expoRteR等。每当集群中添加或删除节点时,就会触发Watch。而且由于每个节点自身都会通过kube-Proxy监视kubelet服务,这些服务的响应数量和所需带宽就会呈N^2增长,大约每秒增加1 GB。KubeRnetes 1.17中发布的EndpointSlices极大地缓解了这个压力,它将负载降低了1000倍。
一般而言,我们会注意任何API SeRveRs请求数量随着集群大小而变化的情况。我们会尽量避免让任何DaeMonSet与API SeRveRs交流。如果需要让每个节点监控变化,那么引入中间缓存服务(如DatadogClUSteR Agent)或许是避免集群范围瓶颈的好办法。
随着集群的增长,我们的自动伸缩越来越少了。但偶尔也会出现大幅自动伸缩的情况。新的节点加入集群会产生许多请求,而一次性增加几百个节点会超过API SeRveRs能够承受的容量。平滑请求速度,甚至仅仅增加几秒钟,就可以有效地避免这个问题。
使用ProMetheUS和GRaFAna测量时序列度量
我们使用ProMetheUS收集时序列度量,利用GRaFAna绘制成图表、显示仪表板并生成警告。首先我们部署了kube-ProMetheUS来收集各种度量和可视化的仪表板。随着时间的推移,我们已经添加了许多我们自己的仪表板、指标和警报。
随着节点越来越多,我们逐渐难以理解ProMetheUS收集到的度量。尽管kube-ProMetheUS公开了许多非常有用的数据,但有些数据我们并不需要,而有些数据过于细致,很难收集、存储和有效地查询。因此我们使用ProMetheUS 规则“放弃&Rdquo;了一些度量。
长期以来,有一个问题一直困扰我们:ProMetheUS消耗的内存越来越多,最终由于内存耗尽而崩溃。即使给ProMetheUS提供大量的内存也无济于事。更糟糕的是,每当出现崩溃,它就需要花费好几个小时重新执行预写式日志(wRITe-ahead log)文件,之后才能正常使用。
最后我们研究了ProMetheUS的源代码,发现内存耗尽是由于GRaFAna和ProMetheUS之间的交互导致的,GRaFAna会使用ProMetheUS上的/API/v1/seRies这个API,进行{le!=””}的查询(含义是“获取所有直方图的度量&Rdquo;)。而/API/v1/seRies的实现在运行时间和空间上都没有任何限制,如果查询结果过多,就会消耗越来越多的内存和时间。即使请求者放弃请求并关闭连接,查询也会继续执行。对于我们的情况而言,无论多少内存都不够,ProMetheUS最终总会崩溃。于是,我们给ProMetheUS打了补丁,将这个API包裹在一个context中以实现超时,终于修复了该问题。
虽然ProMetheUS的崩溃次数大大减少了,但我们依然需要经常重启,因此预写式日志(简称WAL)的重新执行依然是一个问题。重新执行所有 WAL 通常需要花费好几个小时,之后ProMetheUS才能启动,并开始收集度量和查询请求。在RobUSt PeRception的帮助下,我们发现设置GOMaxProCS=24可以极大地改善这个问题。因为ProMetheUS会在执行WAL期间尝试使用所有CPU核心,对于核心数量极多的服务器而言,核心之间的竞争会导致性能大幅度下降。
我们正在探索新的选项,以增加我们的监测能力,如下面的“未解决的问题&Rdquo;一节所述。
健康检查
面对如此庞大的集群,我们必须依赖自动化来检测并移除任何有问题的节点。