【编者的话】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相互通信。我们的服务“发现”功能很有限,一般只需要在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 -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
做好标记后,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 的开源 Prometheus 导出程序,将这些跟踪信息导出到监控系统中。这样就可以直接跟踪符合各种条件的数据包了。
我们的网络模型的独特之处在于,Node、Pod和服务网络的CIDR范围是完全暴露给研究者的。网络采用了轮辐模型,使用原生节点和Pod的CIDR范围进行路由。研究者连接到中央枢纽,从那里可以访问到任何集群。但是两个集群之间不能互相通信。这样可以保证每个集群都是隔离的,不会出现跨集群依赖(否则会破坏故障隔离原则)。
我们使用一个“NAT”宿主对来自集群外部的流量进行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 规则“放弃”了一些度量。
长期以来,有一个问题一直困扰我们:Prometheus消耗的内存越来越多,最终由于内存耗尽而崩溃。即使给Prometheus提供大量的内存也无济于事。更糟糕的是,每当出现崩溃,它就需要花费好几个小时重新执行预写式日志(write-ahead log)文件,之后才能正常使用。
最后我们研究了Prometheus的源代码,发现内存耗尽是由于Grafana和Prometheus之间的交互导致的,Grafana会使用Prometheus上的/api/v1/series这个API,进行{le!=""}的查询(含义是“获取所有直方图的度量”)。而/api/v1/series的实现在运行时间和空间上都没有任何限制,如果查询结果过多,就会消耗越来越多的内存和时间。即使请求者放弃请求并关闭连接,查询也会继续执行。对于我们的情况而言,无论多少内存都不够,Prometheus最终总会崩溃。于是,我们给Prometheus打了补丁,将这个API包裹在一个Context中以实现超时,终于修复了该问题。
虽然Prometheus的崩溃次数大大减少了,但我们依然需要经常重启,因此预写式日志(简称WAL)的重新执行依然是一个问题。重新执行所有 WAL 通常需要花费好几个小时,之后Prometheus才能启动,并开始收集度量和查询请求。在Robust Perception的帮助下,我们发现设置GOMAXPROCS=24可以极大地改善这个问题。因为Prometheus会在执行WAL期间尝试使用所有CPU核心,对于核心数量极多的服务器而言,核心之间的竞争会导致性能大幅度下降。
我们正在探索新的选项,以增加我们的监测能力,如下面的“未解决的问题”一节所述。
健康检查
面对如此庞大的集群,我们必须依赖自动化来检测并移除任何有问题的节点。慢慢地,我们建立起了一系列健康检查系统。
被动健康检查
一些健康检查是被动的,永远在节点上运行。这些健康检查会监视基本的系统资源,如网络不通畅、磁盘失败、磁盘写满或GPU错误等。GPU会呈现多种错误,但最常见的就是“Uncorrectable ECC error”(无法修复的ECC错误)。Nvidia的Data Center GPU Manager (DCGM)工具可以帮助查询该错误,以及许多其他的“Xid”错误。跟踪错误的方法之一就是使用dcgm-exporter工具将度量导出到Prometheus监视系统中。这样就可以创建DCGM_FI_DEV_XID_ERRORS度量,其内容为最近发生过的错误代码。此外,NVMLDevice Query API还可以提供有关GPU的健康情况和操作的更详细信息。
检测到错误之后,通常重启就能修复GPU或系统,尽管有些情况下需要更换显卡。
另一种健康检查会跟踪来自上游云服务提供商的维护事件。每个主流云服务提供商都会提供一种方法,获知当前使用的VM是否即将维护,从而导致服务中断。VM可能需要重启,因为需要给监视程序打补丁,或者给物理服务器更换硬件。
这些被动健康检查在所有节点的后台不断运行。如果运行状况检查开始失败,将自动隔离该节点,这样就不会在该节点上调度新的Pod。对于更严重的健康检查失败,我们还将尝试终止Pod,请求所有当前运行的Pod立即退出。它仍然取决于Pod本身,通过Pod中断预算进行配置,以决定它是否希望允许这种终止发生。最终,在所有Pod终止或7天过去(我们SLA的一部分)之后,我们将强制终止VM。
主动GPU测试
不幸的是,并非所有的GPU问题都能从DCGM中看到错误码。我们自己构建了GPU测试库,能够捕获额外的错误,确保硬件和驱动程序按照预期运行。这些测试无法在后台运行,因为运行测试需要独占GPU几秒钟或几分钟。
首先,我们会在节点启动时运行测试,称为“预运行”。所有加入集群的节点都会加上“preflight” 污染并打标签。该污染可以防止普通Pod被调度到节点上。然后配置一个DaemonSet,在所有带有该标签的Pod上运行预运行测试。测试成功后,测试程序会移除污染,节点就可以正常使用了。
我们还会在节点的生命周期内定期执行测试。测试通过CronJob运行,因此可以在集群中的任何可用节点上执行。虽然这样无法控制测试在哪个节点上运行,但我们发现,只要时间足够长,它就能提供足够的测试覆盖,同时不会对服务造成太多干扰。
配额和资源利用
当我们扩大集群时,研究人员开始发现他们很难获得分配给它们的所有能力。传统的Job调度系统有很多不同的特性,可以在竞争团队之间公平地运行工作,而Kubernetes没有这些特性。随着时间的推移,我们从这些Job调度系统中获得了灵感,给Kubernetes添加了几个原生功能。
Team taints
我们在每个集群都有一个服务“team-resource-manager”,它具有多种功能。它的数据源是一个ConfigMap,它为在给定集群中有能力的所有研究团队指定元组(节点选择器、要应用的团队标签、分配数量)。它与集群中的当前节点保持一致,从而设置适当数量的节点。
openai.com/team=teamname:NoSchedule.
“team-resource-manager”还具有一个admission webhook服务,例如,当提交每个作业时,会根据提交者的团队成员申请相应的容忍度。使用taints允许我们灵活地约束Kubernetes Pod调度程序,例如允许对较低优先级的Pod有“any”容忍度,这允许团队在不需要重量级协调的情况下借用彼此的能力。
CPU & GPU balloons
除了使用cluster-autoscaler来动态伸缩集群之外,我们还会删除并重新添加集群内的不健康节点。实现方法是将集群的最小尺寸设置为零,最大尺寸设置为可用的容量。但是,如果cluster-autoscaler看到空闲节点,就会尝试将集群收缩至必要限度大小。从许多角度来看(VM的启动延迟、预分配的成本、对API服务器的影响)来看,这种空闲状态的伸缩并不理想。
所以,我们同时为仅支持CPU的宿主和支持GPU的宿主引入了气球部署。该部署包含一个ReplicaSet,其中设置了低优先级Pod的最大数量。这些Pod会占用一个节点内的资源,所以自动缩放器就不会认为该节点闲置。但是由于这些Pod优先级很低,因此调度器可以随时将其驱逐,给真正的作业腾出空间。(我们选择了使用部署而不是DaemonSet,避免DaemonSet在节点上被认为是闲置负载。)
需要注意的一点是,我们使用了Pod反亲和性来保证Pod最终会均匀地分布到节点上。Kubernetes早期版本的调度器在处理Pod反亲和性时的性能为O(N^2),不过这一点在1.8版本后就修正了。
有问题的调度
我们的实验经常涉及到一个或多个StatefulSet,每个负责训练作业的一部分。至于优化器,研究人员要求所有的StatefulSet都被调度,训练作业才能完成(因为我们经常使用MPI来协调优化器的各个成员,而MPI对于组内成员数量的变化非常敏感)。
但是,Kubernetes默认并不一定会优先满足某个StatefulSet的所有请求。例如,如果两个实验都要求100%的集群容量,那么Kubernetes不一定会调度某个实验的所有Pod,而是可能会为每个实验调度一半的Pod,导致死锁状态,每个实验都无法完成。
我们尝试了几种方案,但都遇到了一些极端情况,会与正常Pod的调度产生冲突。Kubernetes 1.18为核心调度器引入了一个插件架构,因此添加功能变得非常容易了。我们最近刚刚发布了Coscheduling plugin,以解决这个问题。
未解决的问题
随着我们的Kubernetes集群不断扩大,还有许多问题有待解决。一些问题包括:
度量
在目前的规模下,Prometheus自带的TSDB存储引擎有许多问题,例如速度很慢、重启时需要很长时间重新执行WAL(预写入日志)等。查询也很容易导致“查询可能会加载过多数据”的错误。我们正在迁移到与Prometheus兼容的另一个存储和查询引擎上。
Pod网络流量
随着集群的扩大,每个Pod都会占用一定的互联网带宽。因此,每个人的互联网带宽加起来就无法忽略不计了,我们的研究人员有可能无意间给互联网的其他部分带来不可忽略的资源压力,例如下载数。
总结
我们发现Kubernetes对于我们的研究需求来说是一个非常灵活的平台。它有能力扩大规模,以满足最苛刻的工作负载。不过,Kubernetes还有很多需要改进的地方,OpenAI的超级计算团队将继续探索Kubernetes如何扩展。