9 月 26-28 日,由 Linux 基金会、CNCF 主办的 KubeCon + CloudNativeCon + Open Source Summit China 2023 在上海举办。作为社区积极贡献者和最终用户,字节跳动和火山引擎团队在此次大会上进行了 7 个分享,本系列内容根据此次会议分享整理而成,欢迎关注!
来源 | 火山引擎云原生团队
本文将分享火山引擎容器服务 VKE 作为云上 Kubernetes 平台,在帮助客户实现集群资源弹性过程中的一些经历和挑战,共分为以下几个部分:
- 第一部分介绍什么是 CA,以及它内部的流程和实现方式,帮助大家更好地 理解其工作机制;
- 第二部分简要说明客户批处理作业的使用场景;
- 第三部分把重心放在客户在使用 Cluster Autoscaler 的过程中,碰到的问题和挑战,以及我们是如何解决的;
- 最后将给出一些建议,帮助大家更好地实现集群弹性,避免踩到类似的坑。
0 1
什么是 Cluster Autoscaler(CA)
从 Cluster Autoscaler 项目的 README 文档中,可以看到它包括几个方面:
- 自动调整集群大小,即扩缩容
- 因为集群中资源不足,才会扩容
- 缩容时由于集群中的节点使用率低于阈值,这个低使用率的节点上的 Pod 可以调度到其他节点上去
下图展示了用户视角下 CA 扩容的情况。当集群中出现 Pending Pod,没有节点能让这些节点调度上去时,CA 就会触发扩容,往集群中加入新的节点,让 Pod 调度上去。
而节点的使用率较低,比如图中的低于 50%,CA 就会把这个节点删除,Pod 被重新调度到其他的空闲节点上。这样一来,集群中工作负载的数量不变,但是节点数减少了,剩余节点和集群整体的使用率就提高了,对用户来说,这相当于 降本增效 。
CA 是一个定期重复执行的过程,如果简化一下,它大致可以分为以下几个部分:
- 准备工作,CA 会先从集群中获取相关的数据,比如节点、集群的状态、需要调度的 Pending Pod、清理创建失败的节点、过滤还没 ready 的 GPU 节点等;
- 扩容逻辑;
- 缩容逻辑;
- 结束;
- 等待一段时间后,再从头开始。
在 扩容 阶段,CA 会先找到集群中无法调度的 Pending Pod,然后试着把这些 Pending Pod 和节点池做匹配,看看每个节点池都满足哪些 Pending Pod 的调度要求:有的节点池可能扩容了也不满足调度要求,这些节点池就被排除了;有的节点池能调度一部分 Pending Pod,那这些节点池就会保留下来。
对于这些保留下来的节点池,CA 会计算需要扩容多少个节点才能满足这些 Pending Pod 的资源用量,接着从这些节点池中按照设置的扩容策略选一个最合适的节点池。 扩容策略可能是随机选择、也可能是优先级,或者最小浪费,这些都是由用户配置的。 选择出最合适的节点池之后,CA 就会调用接口,告知云厂商需要扩容的数量,云厂商完成具体的 ECS 创建、加入集群等动作。
而在 缩容 阶段,CA 会找到使用率低于阈值的节点,查看这些节点上是否还有 Pod,如果没有 Pod 了,就认为这个是空节点,会被优先批量删除。删除完空节点以后,CA 再判断这些非空的节点上,Pod 是否可以调度到其他节点上去:如果可以调度,CA 也会把这个非空节点删除,节点上的 Pod 被驱逐、然后在别的节点上被重建。
这大概就是 CA 的整个过程,虽然省去了很多细节,但大家应该可以理解几个关键点:一个是 CA 中的逻辑,是 定期运行 的;第二个是在整个流程中,有扩容和缩容 两个阶段 ,这两个阶段相互独立,扩容需要计算新增的节点数量、按照扩容策略选节点池,缩容就只看节点的使用率和上面的 Pod 是否可被重调度。
02
客户场景
我们遇到过这样一个案例,客户有自己的任务分发平台,不同计算任务通过任务平台下发到 Kubernetes 集群中,每批计算任务对应一堆的 Pod。而他们的业务存在这几个特点:
- 任务种类多,不同的任务所需的资源不同,CPU 用量各异,有的也会使用 GPU;
- 不同任务对应的 Pod 数量也不同,峰值时整个集群超过 2w Pod;
- 一般业务高峰期是在晚上,从凌晨开始跑,一直跑到早上;
- 整体耗时长,不同批次任务耗时有长有短;
- Pod 的镜像也非常的大,拉取耗时长。
在这样的业务场景下,为了节省成本,客户很自然地使用了 Cluster Autoscaler,期望在计算任务下发后,节点池能自动扩容,添加新的节点到集群中,让 Pod 调度上去。在计算任务跑完以后,节点空闲下来,Cluster Autoscaler 再把节点删除,避免资源浪费。为了提高装箱率减少资源碎片,客户会对某些类型的任务,设置 Pod 的 resource request 和节点规格一致,尽量让这种任务的 Pod 独占一个节点。
03
问题与解决方案
问题一:扩容成功率低
在客户上量过程中,我们碰到的第一个问题,是在大规模扩容过程中出现的大量 扩容失败 。CA 触发节点池扩容后,一部分节点创建成功,调度了部分 Pod,另一部分节点创建失败,在随后的过程中又被 CA 删除。由于还有部分 Pod 处于 Pending 状态,又触发 CA 扩容,然后又失败,周而复始。
这就给客户带来了非常糟糕的体验,一是看到很多失败的扩容记录,使其对云厂商的信任度降低;二是增加了不必要的成本,因为这些创建失败的节点并没有加入集群,不能被客户使用,但是节点对应的云服务器是实实在在被创建出来了,客户花了钱,但资源又没用上,就增加了无谓的成本。
经过仔细排查,我们发现节点扩容失败是因为云服务器在初始化 Kubernetes 组件的过程中,写入磁盘的速度特别慢,很久都不能加入集群,超过了预设的超时限制,我们判定这是一个异常的节点。异常节点随后又被 CA 清理删除,那我们就很好奇,为什么 ECS 的云盘写入这么慢?经过进一步的调研,我们发现主要原因是云盘服务的压力太大:
一方面,云服务器自身在初始化 Kubernetes 组件的时候,比如安装系统软件包、从对象存储上拉取 Kubernetes 的安装包再解压等动作,是有磁盘写入的,一个节点可能还好,当几百个节点同时处于这个阶段的时候,云盘服务的整体写入压力会大幅上升。
另一方面,在于容器镜像的拉取。在已经正常创建的节点上,用户的 Pending Pod 会调度上去,然后开始拉取镜像,由于这个客户的镜像很大,拉的耗时也很久,如果很多节点都处于这个阶段,那会有大量的写入操作,导致整个云盘服务的写入吞吐量被打到一个较高的位置,新的节点在初始化的时候,因为要争抢写带宽,所以写入速度就降低了。
为了解决这个问题,我们的想法是对同时扩容的节点数量做一个 限制 。虽然社区的 CA 中并没有对同时扩容的节点数有什么限制,但任何系统都存在上限,通过对系统做合理的限制,不仅能提供稳定的服务,从全局上也有助于提升性能。
我们根据云盘的吞吐能力,估算了一个可被接受的同时扩容节点数,比如限制是 100,这样一来,用户看到的就是 100 一批 100 一批的扩容,节点都能扩容成功。虽然扩容的批次增加了,但扩容成功率也提高了,整体的云盘写入流量更加平滑,整体的扩容速度也比之前提升了很多。
问题二:容器镜像大,扩容速度慢
我们碰到的第二个问题,是极致的性能问题,我们先讲扩容的 性能 问题。在批处理场景下,客户使用的镜像会比较大,并且客户对扩容端到端速度要求会比较高,比如要求在 5min 内扩容出 500 个节点,并且 Pod 都能运行起来,这是一件非常有挑战的事情。
在客户视角下,他们计算任务的启动延迟,大概分为 5 个阶段:
- 第一阶段:下发任务,集群中出现因资源不足而导致 pending 的 Pod;
- 第二阶段:CA 感知到这些 Pending Pod,触发节点池扩容。这个阶段一般是秒级的,如果是使用了 GPU 的 Pod,由于 CA 自身的策略,会导致最多延迟 30s 再扩容。这里 CA 不立马扩容要等几秒,是因为如果最新的 Pending Pod,创建时间离现在比较近,很有可能还会有新的 Pending Pod 被创建出来。比如 deployment 的副本数从 0 改到 1000,可能就需要 10 多秒才能全部创建完,所以 CA 宁愿多等一会儿等所有 Pod 都被创建了才执行扩容;
- 第三阶段:云厂商接收到扩容请求,去创建云服务器、注册到集群中。这个阶段是分钟级别的,不同云厂商的耗时可能会略有差别;
- 第四阶段:把这些 Pending Pod 调度到节点上,如果 Pod 数量和集群规模不大,Pod 的调度条件不复杂,相对整个过程来说,这阶段的耗时可以忽略不计;
- 第五阶段: 节点上的 Pod 开始拉取镜像、启动。这个阶段的耗时是不太稳定的,比如同时扩容的节点数量比较多,容器镜像又比较大,就很有可能会打满云厂商的限速,对整个端到端的影响比较大。
比如在这张图里,在多个节点同时扩容时,除了用户的计算任务的 Pod,节点上还有很多系统 daemonset 的 Pod,比如网络组件、device plugin、日志采集组件等等,这些 Pod 的镜像也会大量的、同时的从镜像仓库拉取,很容易就达到网络瓶颈,或者给云盘服务带来写入压力。如果 500 个节点同时扩容,每个节点上都在争抢带宽或者磁盘的写入,是无法达到刚刚说的性能要求的。
在这种极致的性能要求下,我们采用了 自定义系统镜像 方案。这个自定义系统镜像是指云服务器的系统镜像,我们先在云服务器中把容器镜像预先拉取下来,然后把云服务器导出为自定义系统镜像,把业务的容器镜像固化到系统中去,这样在后续扩容的时候,我们用这个自定义系统镜像去创建云服务器,云服务器作为节点加入集群后,容器镜像就已经在节点上了,不需要再去镜像仓库拉取,Pod 可以做到秒级启动。
但这个方案也有一些弊端,比如我们可以把整个容器镜像固化到系统中后,后续容器镜像发生了变化,这个自定义系统镜像也需要重新制作,比较麻烦,如果容器镜像变化比较频繁,就要频繁的制作自定义系统镜像。所以我们也可以把镜像做一下拆分,把数据量比较大的、又不怎么更新的静态数据,打包到基础镜像中,然后把这个基础镜像再固化到系统中,这样节点在启动以后,拉取的数据量也会大大减小。
在使用这个方案前,如果客户扩容 500 节点,在单批次运行最多 70 个节点扩容的情况下,每个节点上 1 个 10GiB 的容器镜像,那从下发到 Pod 全部运行,大概需要 22min。
而如果使用自定义镜像,因为不需要拉取容器镜像,所以刚刚说的云盘服务的压力就减轻了,所以我们直接放开扩容数量的限制,直接从 0 到 500 做扩容,从 Pod 下发到最终 Running,可以在 5min 以内完成,并且云盘服务整体的写入流量,可以从峰值的 14GB/s 下降到 6GB/s,大幅减少数据写入。
这个方案对于需要快速扩容、对扩容时的端到端耗时非常敏感的业务,是一个可行的解决思路。
问题三:多节点干扰,缩容速度慢
客户因为计算任务的不同,会触发不同节点池的扩容。 比如客户先进行 GPU 计算任务,触发了节点池 A 的扩容(节点池 A 是 GPU 节点), 在计算任务 A 快结束的时候,可能会下发新的计算任务,触发节点池 B 的扩容。
那按照客户的预期,这时节点池 A 的这些 GPU 节点,因为上面没有 GPU 计算任务、节点使用率已经降低,需要在任务结束的一段时间内很快就被缩容掉。但实际情况是节点池 A 的缩容会被推后较长的时间,这就造成了一些 资源浪费 。
所以为什么节点池 A 的缩容会被推迟呢?
CA 内部的缩容流程中,有一个冷却时间,表示扩容后多久时间内,是不能对节点做缩容的,这个值由用户来设定。这个计时是集群级别的,就是任何一个节点池扩容了,这个计时器都会被重置,重新计算。在大规模、多节点池扩容的情况下,如果用户分批扩容,那每次扩容都会做一次重置,导致扩容过程中,空闲的节点池无法被缩容,造成资源的空跑。
当前社区对此已经有解决方案,但代码还处于草稿阶段,具体的解决思路就是把计时器改成 节点池级别 ,每个节点池只针对自己的扩容过程做倒计时,不受其他节点池干扰。
我们在生产环境上对社区的方案做了验证,确实很好的解决了我们的问题,在计算任务结束后,节点池 A 就会很快被缩容。那这个缩容时间的缩短,非常显著地降低了客户的使用成本。
问题四: Pending Pod 过多导致未扩容
最后我们再来看一下由规模带来的问题。
如前文所述,客户用自己的任务分发平台将计算任务转换成 Pod 下发到 Kubernetes 集群,有时候并不能非常好地控制任务的下发速度。 峰值时期,整个集群中有 2w 多个 Pod,其中 Pending Pod 的数量高达 1.8w 个。面对如此 大的规模,CA 难免“力不从心”。
下图展示了集群中 Pod 的数量情况和 CA 的日志分布情况,可以发现在 Pod 数激增的那段时间里,CA 基本上没有输出日志,集群中的节点池也没有扩容,客户的计算任务被大量堆积、阻塞。
经过调查我们发现,CA 主要卡在 调度预测阶段 ,在这一阶段,CA 会计算每个节点池需要扩容多少个节点才能满足这些 Pending Pod 的资源用量。为了复现这个问题,我们做了一些压测,期望能找到影响这个耗时的主要因素,方便针对客户的场景做一些优化。
一开始我们想到的就是 Pending Pod 的数量。为此我们使用两个不同规格的节点池,然后往集群中下发大量的 Pending Pod,这些 Pending Pod 通过资源用量期望调度到其中一个节点池上。
我们发现随着集群中 Pending Pod 数量的增长,单个节点池的整个计算耗时,是不断上升的,在 2.2w Pod 时,单个节点池的计算耗时会到 400s,而从 Pod 视角来看,单个 Pod 的平均耗时,也是线性增长的。虽然 Pending Pod 的数量规模达到了,但实际 CA 僵死的时间是远比我们测出来的 400s 多,所以我们继续接近客户的使用方式,将 Pod 的调度逻辑做了修改,从之前的默认调度约束,改为了使用节点 亲和性 。
我们发现在不使用节点亲和性的情况下,整体的耗时和第一次压测的是一样的,而如果使用了节点亲和性,在 Pending Pod 数量在 1.8w 的时候就达到了 700s。下方右侧这张图中蓝色的那条曲线也说明,单个 Pod 的平均计算时间,比之前不使用节点亲和性的场景增长得快,整条线上升的速度更快、斜率更高。
除此以外,我们继续控制变量,调整 Pod 的 request,将之前的单个节点上只跑 1 个 Pod,改为单个节点上能跑 8 个 Pod,这样修改后,预期添加到集群中的节点数量是之前的 1/8,同时整个计算耗时,相比之前的曲线,也是接近水平了。
从上面的 3 次压测中,我们可以得出一些 结论 :
- Pending Pod 越多,需要计算的耗时越久,且平均每个 Pending Pod 的耗时随总数的增加而增加;
- 使用了 Node Affinity 的 Pending Pod,在做调度预测时,会耗时更久;
- 预估节点数量越多,调度预测越久;
- 可被调度的节点池数量越多,调度预测越久。
这是我们从压测中得到的实验结论,那真实的技术理解应该是怎样的呢?
CA 在估算节点池需要扩容多少个节点的时候,内部有一个快照,一开始这个快照包含了集群中的节点和节点上的 Pod。如果集群中有多个节点池,CA 会先对每个节点池做一下计算,看看哪些 Pending Pod 能调度到节点池上。因为如果节点池不能满足 Pod 的调度要求,即使扩容了也没有用。
比如这张图里,集群中一共有 8 个 Pending Pod,节点池 A 能满足所有 Pending Pod 的调度要求,节点池 B 只能调度 6 个。这个过程的复杂度是 O(n^2),跟 Pod 的数量、节点池的数量有关,当然也跟 Pod 的调度条件有关系,调度条件越复杂,这个耗时也会更久一点。在做完这一步之后,CA 会再根据节点池和节点池上的这些 Pending Pod,去计算需要扩容多少个节点
比如节点池 A 能满足 8 个 Pending Pod 的调度条件,CA 会先对这些 Pending Pod 和快照里的节点做一轮调度模拟,跑一下 scheduler framework 中的 prefilter 和 filter 阶段,看能不能正常通过,如果能通过就表示这些 Pending Pod 可以调度到快照的节点上,如果不能通过,就会根据节点池 A 的规格信息,构建出一个虚拟的 Node,放到快照里,然后再做刚刚的调度模拟,此时这些 Pending Pod 是可以调度到这个新加的虚拟的 Node 里的。
CA 重复这个过程,直到这里所有的 Pending Pod 都能加入到快照中,此时快照里新增了多少个虚拟的 Node,其实就是节点池 A 需要多扩容的节点数。只要集群里新增了这些节点,这些因资源不足而无法调度的 Pending Pod 就能真正的跑起来。
节点池 B 也是类似的,只不过在我们的例子中,节点池 B 的规模会比节点池 A 小。根据我们刚刚的分析,整个过程的复杂度是接近 O(n^3) 的,跟 Pending Pod 的数量、快照中的节点数量、节点池得到数量相关。
这也跟我们的压测结论是一样的: Pending Pod 的数量越多、节点池越多、预估的节点数量越多、调度条件越复杂,整个扩容的耗时就越久 。
对此,CA 社区主要提出了两个改进点:
- 限制节点数量的上限 ,就是减少快照中的节点数量,这个跟我们刚刚提到的观点是类似的,如果对扩容的节点数量不加限制,其实是不太稳妥的;
- 对单个节点池整体的计算耗时做限制 ,比如不能超过 10s,如果这个过程超过了 10s,我们就截断这个过程。
如果你的 CA 版本还比较老,低于 v1.25 的,可能就没法使用社区的解法了。
04
资源弹性建议
如果业务对扩容的延迟比较敏感,期望能更快的让 Pod 启动,可以考虑将静态的、较大的容器镜像,打包进云服务器的系统镜像里,加速扩容。
视频回放:关注【字节跳动开源】公众号,在后台回复“KubeCon”或点击底部【阅读原文】
- END -
推荐阅读