[译]将 Kubernetes 扩展至7500个节点

k8s技术圈

共 8822字,需浏览 18分钟

 ·

2021-02-21 15:00

我们已经将 Kubernetes 集群扩展到了7500个节点,该集群主要是为 GPT-3、CLIP 和 DALL·E 等大型模型提供可扩展的基础设施,同时也为神经语言模型的缩放定律等快速的小规模迭代研究提供基础支持。将单个 Kubernetes 集群扩展到这种规模是很少见的,因而需要特别小心,但好处是一个简单的基础设施,使我们的机器学习研究团队能够更快地迁移和扩展,而不需要更改他们的代码。

自从我们上一篇关于《扩容到2,500个节点》(https://openai.com/blog/scaling-kubernetes-to-2500-nodes/)的文章以来,我们一直在扩容我们的基础设施来满足研究人员的需求,在这个过程中,我们学到了很多额外的经验教训。这篇文章总结了这些经验,以便 Kubernetes 社区中的其他人可以从中受益,最后还介绍了我们目前仍然面临的问题,接下来我们将重点解决这些问题。

工作负载

在我们深入本文之前,先简单介绍下我们的工作负载是非常有必要的。我们使用 Kubernetes 运行的应用程序和硬件与你在大部分公司可能遇到的情况有很大不同。

一个大型机器学习作业跨越多个节点,当它能够访问每个节点上的所有硬件资源时,它的运行效率最高。这使得 GPU 可以直接使用 NVLink 进行交叉通信,或者 GPU 可以直接使用 GPUDirect 与网卡进行通信。所以对于我们的许多公众任务,一个 Pod 就会占据整个节点。NUMA、CPU 或 PCIE 资源竞争都不是我们调度的因素。

Bin-packing 碎片化对我们而言并也不是一个常见的问题。我们当前的集群有充分的带宽,因此我们也不用去考虑任何机架或网络拓扑结构问题。这些都意味着,虽然我们有很多节点,但对调度器的压力相对较小。

一个新的作业可能包含数百个同时创建的 Pod,此时对 kube-scheduler 来说可能压力会比较大,但是然后就会恢复到一个相对较低的利用率了。

我们最大的任务是运行 MPI,任务中的所有 Pod 都参与一个 MPI 通信。如果任何一个参与的 Pod 死亡,整个任务就会停止,需要重新启动。任务会定期检查,当重新启动时,会从最后一个检查点开始恢复。因此,我们认为 Pods 是半状态的,被杀死的 Pods 可以被替换,任务可以继续,但是这样做具有破坏性,应该尽量减少。

我们并不那么依赖 Kubernetes 进行负载均衡。我们的 HTTPS 流量很少,不需要进行 A/B 测试、蓝/绿或金丝雀发布。Pods 通过 SSH,而不是服务端点,直接在其 Pod IP 地址上与 MPI 相互通信。服务“发现”是有限的;我们只是在任务启动时一次性查找哪些 Pods 在参与 MPI。

大部分任务都会与某种形式的 blob 存储进行交互。它们通常会直接从 blob 存储中读写数据集,或将其缓存到更快的本地临时磁盘。我们同时也有一些 PersistentVolumes,但是 blob 存储的可扩展性更强,并且不需要缓慢的 detach/attach 操作。

我们的工作性质实际上属于研究工作,这意味着工作负载本身是不断变化的。尽管超级计算团队努力提供我们所认为的满足“生产”质量水平的计算基础设施,但在该集群上运行的应用程序寿命很短,开发人员也会快速迭代。任何时候都有可能出现新的使用方式,这对我们关于未来趋势的预测提出了挑战,我们需要一个可持续发展的系统,该系统还可以让我们在事情发生变化时迅速做出响应。

网络

随着集群中节点和 Pod 数量的增加,我们发现 Flannel 难以满足所需的网络吞吐量。我们转而使用元素的 Pod 网络技术为我们的 IP 配置 Azure VMSSes 和相关的 CNI 插件。这使我们能够在 Pod 上获得宿主机级别的网络吞吐量。

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

避免封装会增加对底层 SDN 或路由引擎的需求,但它使我们的网络配置变得简单。无需任何其他适配器即可添加 VPN 或隧道,我们不需要担心由于网络的某些部分具有较低的 MTU 而导致数据包碎片。网络策略和流量监控非常简单,数据包的源和目的地都很明确。

我们在主机上使用 iptables 标记来跟踪每个命名空间和 Pod 的网络资源使用情况。这可以让研究人员可视化他们的网络使用模式,特别是,由于我们的很多实验都有不同的网络和 Pod 内部通信模式,因此这对排查可能出现瓶颈的地方会很有帮助。

iptables mangle 规则可以用来标记符合特定条件的数据包,以下是我们用来检测流量是内部流量还是外部流量的规则,同时也可以看到 FORWARD 规则包含了来自 Pods 的流量,与来自主机的 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 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(https://github.com/madron/iptables-exporter/) 的开源 Prometheus exporter,然后将其部署到我们的监控系统中。

我们的网络模型有一个特别的地方是,我们将节点、Pod 和 Service CIDR 范围完全暴露给我们的研究人员。我们有一个中心辐射网络模型,并使用本机节点和 Pod CIDR 来路由该流量。研究人员连接到中心,然后从那里可以访问任何单个集群。但是集群本身无法相互通信。这样可确保集群保持隔离,没有跨群集的依赖关系会破坏故障隔离。

我们使用 NAT 主机来转换 Service CIDR,以处理来自群集外部的流量。这种设置使我们的研究人员在选择实验方式和选择哪种网络配置时具有极大的灵活性。

APIServer

Kubernetes APIServer 和 etcd 是集群健康运行的核心组件,因此我们需要特别关注这些系统的压力。我们使用 kube-prometheus(https://github.com/coreos/kube-prometheus)提供的 Grafana 仪表盘,以及其他内部仪表盘。我们发现,在 APIServer 上 HTTP 状态码429(过多请求)和5xx(服务端错误)的告警速率是很有用的,通过他们能得知当前的 Kubernetns 集群的压力。

虽然有些人在 kube 中运行 APIServer,但我们是在集群之外运行它们的。etcd 和 APIServer 都在各自的专用节点上运行。我们最大的集群运行5个 APIServer 和5个 etcd 节点,以分散负载,在其中一个节点宕机时将影响降到最低。自从我们在上一篇博文中将 Kubernetes Events 拆分到自己的 etcd 集群后,etcd 就没有出现过明显的问题了,APIServer 是无状态的,通常很容易在自愈实例组或 scaleset 中运行。

此外 APIServer 会占用相当大的内存,并且会随着群集中节点的数量增加而线性扩展。对于有7500个节点的集群,我们观察到每个 APIServer 使用了高达70GB的堆内存,不过这完全在我们的物理硬件能力范围之内。

APIServer 的一大压力是 Endpoints 上的 WATCH 操作,有一些服务,例如 kubeletnode-exporter,集群中的每个节点都有这些组件。当从集群中添加或删除节点时,将触发 WATCH 事件。而且由于每个节点本身通常都通过 kube-proxy 来 WATCH kubelet 服务,因此这些响应所需的带宽将是 N^2 ,这是非常庞大的,偶尔甚至会达到1GB/s或更高。在 Kubernetes 1.17 中推出的 EndpointSlices 特性带了很大的改善,它让这个负载降低了1000倍。

一般来说,我们非常关注所有随集群大小而扩展的 APIServer 请求,我们尽量避免 DaemonSet 与 APIServer 交互。如果确实需要这样做,那么引入中间缓存服务,例如 Datadog Cluster Agent,这是避免集群瓶颈的一个好的方式。

随着我们集群的增长,我们对集群的实际自动伸缩操作比较少,但是当一次自动缩放过多时,我们偶尔还是会遇到一些问题,当新节点加入集群时,会生成很多请求,如果一次添加数百个节点可能会使 APIServer 容量过载,即使只需几秒钟就可以消除这种情况。

Prometheus 和 Grafana 的监控指标

我们使用 Prometheus 收集监控指标,并使用 Grafana 进行图形展示以及告警。我们首先部署 kube-prometheus,它收集各种各样的指标来用于可视化仪表板配置。随着时间的推移,我们添加了很多自己的仪表板、指标和告警。

随着我们添加越来越多的节点,我们对 Prometheus 收集的大量指标而苦恼,尽管 kube-prometheus 提供了很多有用的数据,但其中一些我们实际上从未使用过,而有些则过于精细而无法有效地收集、存储和查询。我们使用 Prometheus 的 relabel 规则(https://prometheus.io/docs/prometheus/latest/configuration/configuration/#relabel_config)来删除其中的某些指标。

有一段时间,我们一直在努力解决一个问题,即 Prometheus 会消耗越来越多的内存,直到最终由于内存不足错误(OOM)使容器崩溃。即使在应用程序上投入了大量的内存容量之后,这种情况似乎仍会发生。更糟糕的是,当它真的崩溃时,在启动时要花几个小时才能重放 write-ahead-log 日志文件才能正常。

最终,我们追踪到这些 OOM 的来源是 Grafana 与 Prometheus 之间的交互,其中 Grafana 会在 Prometheus 上使用 /api/v1/series 这个接口并查询{le!=""},对于有大量结果的查询,/api/v1/series 在时间和空间上都是不受限制的,但这将消耗越来越多的内存和时间。即使在请求者放弃并关闭连接后,它也会继续增长。对于我们来说,内存永远是不够用的,Prometheus 最终都会崩溃。为此我们为 Prometheus 提交了一个patch(https://github.com/openai/prometheus/pull/1),将这个 API 包含在一个 Context 中来强制超时,从而完全修复了这个 bug。

虽然 Prometheus 崩溃的频率比较小,但在我们确实需要重新启动它的时候,WAL replay 仍然是一个问题。在 Prometheus 收集新指标和服务查询之前,经常需要花费几个小时来重放所有 WAL 日志。在 Robust Perception 的帮助下,我们发现配置 GOMAXPROCS=24 后会对 Prometheus 有很大的改善。

目前我们正在尝试新的方案来增加我们的监控能力,在下面未解决问题部分有介绍。

健康检查

对于一个如此庞大的集群,我们当然要依靠自动化来检测并从集群中移除异常的节点。随着时间的推移,我们已经建立了一些健康检查系统。

被动健康检查

有些健康检查是被动的,并且始终在所有节点上运行。它们监控基本的系统资源,例如网络可达性、磁盘损坏或磁盘容量、GPU 错误等。GPU 表现出的问题有很多不同的方式,但一个常见的问题是 Uncorrectable ECC error.,Nvidia 的数据中心 GPU 管理器工具可以很容易地查询这个错误以及其他的一些 Xid 错误。我们跟踪这些错误的一种方法是通过 dcgm-exporter(https://github.com/NVIDIA/gpu-monitoring-tools#dcgm-exporter)将指标抓取到我们的监控系统 Prometheus 中,当指标 DCGM_FI_DEV_XID_ERRORS 出现时,表示最近发生的错误代码,此外,NVML 设备查询 API 暴露了有关 GPU 的运行状况的详细信息。

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

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

这些被动健康检查在所有节点的后台持续运行,如果健康检查一开始就失败,节点将自动被停用,因此不会在该节点上调度新的 Pod,对于更严重的健康检查失败,我们还将尝试驱逐容器,以让所有当前节点运行的容器立即退出。这些还是由 Pod本身决定,可以通过 Pod Disruption Budget 进行配置,决定是否要让这种配置生效。最终,在所有 Pod 终止后或7天后,我们将强制停掉虚拟机。

主动 GPU 测试

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

我们首先在启动时在节点上运行这些测试,我们称之为预检系统,一开始,所有节点均以预检污点和标签加入集群,此污点会阻止在节点上调度普通的 Pod,将 DaemonSet 配置为在带有此标签的所有节点上运行预检测试 Pod,成功完成测试后,测试本身将去除污点和标签,然后该节点即可用于常规用途。

然后,我们还将在节点的生命期内定期运行这些测试。我们将其作为 CronJob 运行,使其可以在集群中的所有可用节点上运行,当然这是随机的,无法控制要测试的节点,但是我们发现,随着时间的流逝,它可以提供足够的覆盖范围,并且干扰影响最小。

配额和资源使用

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

团队污点

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

team-resource-manager 还有一个准入 webhook 服务,以便在提交每个作业时,根据提交者的团队成员身份应用相应的容忍度,使用污点可以使我们灵活地约束 Kubernetes Pod 调度器,例如允许对优先级较低的 Pod 允许 any 容忍,这样就可以让团队互相借用对方的能力,而不需要重量级的协调。

CPU 和 GPU balloons

除了使用 cluster-autoscaler 动态扩展我们的虚拟机支持的集群之外,我们还使用它来修复(删除和重新添加)集群中不健康的成员,为此,我们将集群的最小大小设置为零,将集群的最大大小设置为可用容量来实现。但是,如果 cluster-autoscaler 看到空闲节点,它将尝试缩小到需要的容量。由于多种原因(虚拟机启动延迟、预先分配的成本、上面提到的 APIServer 影响),这种空闲扩展并不理想。

因此,我们为我们的 CPU 和 GPU 主机引入了一个 balloon 式部署。此部署包含一个有“最大大小”低优先级 Pod 数的 ReplicaSet,这些 Pod 占用节点内的资源,因此 autoscaler 不认为它们是空闲的。但是,由于它们的优先级较低,调度程序可以立即将它们逐出,以便为实际工作腾出空间。(我们选择使用 Deployment 而不是 DaemonSet,以避免 DaemonSet 被视为节点上的空闲工作负载。)

调度争用

我们的实验通常涉及一个或多个 StatefulSet,每个 StatefulSet 都负责不同部分的训练工作。对于优化器来说,研究人员需要 StatefulSet 的所有成员都被调度好,然后才能进行训练。

但是,默认情况下,Kubernetes 不一定会优先满足来自一个 StatefulSet 的所有请求。例如,如果两个实验都请求集群100%的容量,那么 Kubernetes 可能只调度每个实验的一半 Pod,而不是调度一个或另一个实验的全部容量,从而导致死锁,最终导致两个实验都无法进行。

我们尝试了一些自定义调度程序的方式,但是遇到了一些极端情况,这些情况导致与普通 Pod 的调度方式发生冲突。Kubernetes 1.18引入了用于核心 Kubernetes 调度程序的插件架构,这使得在本地添加此类功能变得更加容易。最近我们落地了 Coscheduling 插件(https://github.com/kubernetes/enhancements/pull/1463),是解决这个问题的好办法。

未解决的问题

在扩展 Kubernetes 集群时,我们仍有很多问题需要解决。其中几个问题包括:

监控指标

在我们的规模中,我们有很多问题都是与 Prometheus 的内置 TSDB 存储引擎相关,因为它的压缩很缓慢,一旦需要重启,就需要很长的时间来重放 WAL,查询还会导致 query processing would load too many samples 错误,当前,我们正在迁移到另一个与 Prometheus 兼容的存储和查询引擎,期待未来的有机会介绍!

Pod 网络 traffic shaping

随着我们集群规模的扩大,每个 Pod 都会被计算为有一定的外网带宽,每个人对带宽的总需求已经变得相当大了,并且我们的研究人员现在在无意间对外网的访问(例如,下载数据和安装软件包)带来了巨大的资源压力。

结论

我们发现 Kubernetes 是一个非常灵活的平台,可以满足我们的研究需求,它有能力扩展以满足我们所需要的最苛刻的工作负载。不过还有很多地方需要改进,OpenAI 的超级计算团队将继续探索 Kubernetes 如何扩展。

原文链接:https://openai.com/blog/scaling-kubernetes-to-7500-nodes/


CKA 线上培训班


 点击屏末  | 即刻学习

浏览 40
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报