本文使用 Zhihu On VSCode 创作并发布
引言
群脉通过基于虚拟机自建 Kubernetes 集群进行容器(Pod)编排,从而在保证系统稳定性的前提下大大提高了运维效率。我们内部有一条运维原则,叫做“坚持混部”,即尽量把各种不同类型的业务 Pod,无论长期运行的服务,还是短期运行的批处理,都部署到一个集群内,共享一个资源池,不将虚拟机按业务划分,以最大化的提高资源利用率,降低运维成本,包括大数据服务也是如此。这带来了一些挑战,下面会讲。
我们的大数据服务核心是分布式计算框架 Spark,其从 2.3 开始支持运行在 Kubernetes 上,工作原理如下:
如上图,每个 Spark 任务实例包含一个 driver Pod 和多个 executor Pod,driver Pod 启动完成后再启动 executor Pod,调度由 Kubernetes 完成,相比于 YARN 方案,可以更好的利用我们已有的集群,防止浪费,我们的 SRE 团队也不需要多学习和维护一套资源管理系统。
群脉内置的动态群组、事件分析、漏斗分析、RFM 分析、客户价值分析、交互式分析等大数据分析功能,除动态群组通过流计算保证实时性以外,其它都是批处理,每个批处理对应一套 Spark 任务实例,执行耗时为几分钟到几个小时不等,执行完后相应 Pod 会被释放。当有 Spark Pod 需要启动,但集群剩余资源(CPU、内存)不足时,会触发 Kubernetes Cluster Autoscaler 自动扩容集群,即增加按量付费的新节点(动态节点)。当 Spark Pod 被释放、集群资源过剩时,Cluster Autoscaler 会自动释放按量付费的节点。
问题产生
随着业务规模的不断增加,Spark 任务的数量越来越多,其属于对资源要求比较高的任务,既耗 CPU 又占内存,每次运行几乎都会导致集群自动扩容,而且这些扩容节点经常会因各种原因运行很久才能被释放,由于扩容节点使用的都是按量付费方式,所以增加了很多服务器成本。如何更好更优地调度批处理任务就成了迫切需要解决的问题。
下图为当时的自动扩容节点使用情况的截图:
可以发现凌晨 2 点开始突然升高至 15 个节点,接下来慢慢降低至 5 个,22 点后变为 2 个。这是不理想的,理想情况下应该是多数时候是 0,有突发批处理时升上去,在批处理执行结束后降回去。而这里长时间维持在 5 个以上,需调查、优化。
问题定位
- 通过查看 Cluster Autoscaler 的日志发现凌晨 2 点时扩容了 15 个节点的原因是很多定时任务都设置到了那个时段执行,导致那段时间的资源申请量很大。其中多数定时任务都很快结束了,个别执行时间很长。
- 通过查看 Cluster Autoscaler 的日志发现导致后续一直保持在 5 个动态节点无法释放的原因是上面运行了 Spark Pod,由于其属于无复本(不属于 Service、RC、RS、StatefulSet)的 Pod,Cluster Autoscaler 必须等到这样的 Pod 退出才能释放节点。而这几个 Pod 均属于一两个运行时间特别长的 Spark 任务,因为其较均匀地分布在了多个节点上,导致这些节点都无法释放。即便这些 Pod 结束了,Cluster Autoscaler 释放这些节点也需要时间:等待 10 分钟无新 Pod 调度过来再释放。在这期间,节点很有可能又被其它新启动的 Spark Pod 占住了,如此则可能一直释放不了。尤其 8:30 后我们的大数据工程师和数据分析师开始上班,会大量使用交互式分析,进而会创建大量 Spark Pod,如此一堆无副本的 Pod 被均匀分布到了这些按量付费的节点上,即便有资源过剩也无法释放。
根据以上发现,猜想是由于 Kubernetes scheduler 默认的调度策略导致了上述问题。
验证猜想
首先需要了解的就是 Kubernetes scheduler 的工作原理,其在调度 Pod 时主要是预选(Predicates)和优选(Priorities)两个步骤:
- 预选:选出符合条件的节点,如可分配资源是否满足待调度 Pod 所申请资源等。
- 优选:在预选中符合条件的节点会对它们按照一系列算法进行评分(分值都在 0-10 之间)并求和,评分最高的即为最终将被调度的节点。
在优选过程中使用到的算法中,以下是最常用的也是非常重要的算法:
- LeastRequestedPriority:通过计算 CPU 和内存的使用率来决定得分,使用率越低得分越高。
- BalancedResourceAllocation:通过计算 CPU 和内存的使用率来决定得分,两者使用率越接近得分越高,用于减少资源碎片化。
- SelectorSpreadPriority:将同属于一个 Service、RC、RS、StatefulSet 下面的多个 Pod 副本,尽量调度到多个不同的节点上,用于增加服务的高可用性。
- ImageLocalityPriority:检查节点上是否已经有要使用的容器镜像缓存,镜像缓存的总大小值越大,得分就越高。
基于以上 4 个算法,对 Spark 任务调度进行分析:
- 如果某个节点的资源利用率较低,根据 LeastRequestedPriority 算法它的得分会更高一些。
- 我们的节点使用的是 16c64g 的规格,每个 Spark Pod 申请的资源也是按照 CPU 和内存 1:4 的比例申请的,所以 BalancedResourceAllocation 的评分结果会是差不多的,区分度不大。
- 由于 Spark Pod 是无副本的,所以 SelectorSpreadPriority 规则不适用,得分都为 0。
- 如果某个节点有 Spark Pod 的容器镜像缓存,根据 ImageLocalityPriority 算法它的得分更高一些。
综上分析,Spark Pod 最优调度节点的选择主要受 LeastRequestedPriority、ImageLocalityPriority 算法影响,当扩容节点上都运行了或运行过 Spark Pod 后,这时各个节点都有了 Spark 任务的容器镜像,由于我们所有业务的 Spark Pod 镜像是一致的,所以 ImageLocalityPriority 算法的得分会是差不多的,那么优选过程中起主要作用的就是 LeastRequestedPriority 算法了,这也就解释了前面“Spark 任务 Pod 较均匀的分布在了多个节点上”的原因。
优化方案
找到了原因所在,下面开始制定优化方案。
将定时任务的执行时间重新分配
对于需要在凌晨时段执行的任务在时间上进行更细粒度的划分,尽量分散一下,减少同一时刻扩容的节点数量,进而降低同一个 Spark 任务的 Pod 被分散在多个节点的可能性。
将批处理任务优先调度到静态节点上
通过为 Pod 添加 Node affinity(Kubernetes scheduler 优选算法之一)属性,使其优先调度到(携带了 static: true
标签的)静态节点上。
具体配置如下:
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- preference:
matchExpressions:
- key: static
operator: In
values:
- "true"
weight: 100
注意这里有一个 weight: 100
的参数,这是用来计算算法得分用的,weight
(取值为 [1-100])越大,得分越高。最终 Kubernetes scheduler 会将该算法计算出的各个节点的得分经过 reduce 函数转换成一个 [0-10] 的分数表示。
添加上述配置主要是为了当集群中存在动态节点且静态节点与动态节点同时满足批处理任务所申请的资源时,优先调度到静态节点上以减少动态节点资源占用,以加快动态节点释放。
优先调度批处理任务
通过为 Pod 添加 Pod priority属性,并设置一个比长期运行的服务更高的优先级,使其可以被优先调度到静态节点。如此部分长期运行的服务 Pod 会被调度到动态节点上,因其是有副本的,不会卡节点,动态节点会更容易被释放。
具体配置如下如示:
root@192-168-1-1:~# kubectl get priorityclasses
NAME VALUE GLOBAL-DEFAULT AGE
system-node-critical 2000001000 false 16d
system-cluster-critical 2000000000 false 16d
node-critical 1000100 false 16d
cluster-critical 1000000 false 16d
batch-job 2000 false 16d
default 1000 true 16d
preemptable 100 false 16d
batch-job.yml
:
spec:
preemptionPolicy: Never
priority: 2000
priorityClassName: batch-job
上面的配置有一点需要注意,就是 preemptionPolicy: Never
表示此 Pod 不会抢占(通过移除某一节点上的一个或多个比待调度 Pod 优先级低的 Pod 从而使该节点拥有足够的资源可以运行待调度 Pod)比其优先级低的 Pod。
如果设置为 preemptionPolicy: PreemptLowerPriority
或不设置(默认为 PreemptLowerPriority)在调度时就会抢占比其优先级低的 Pod,进而可能导致服务不稳定,因为抢占是不完全遵循PDB(Pod Disruption Budgets) 限制的,也就是说即使设置了 PDB 为 minAvailable: 1
,在可抢占的高优先级的待调度 Pod 过多且当前可用资源不足时依然会抢占被 PDB 保护的低优先级 Pod,进而可能导致某些服务出现完全不可用。
同时还针对一些副本数量较多或业务不关键的服务设置较小的优先级以允许其被关键业务抢占,以确保核心业务的稳定。
综上所述,现有集群中服务的优先级为:批处理任务(不可抢占)> 关键业务(可抢占)> 非关键业务(可抢占)。
优先将同类型的批处理任务调度到同一节点上
通过为 Pod 添加 Pod affinity(Kubernetes scheduler 优选算法之一)属性,使属于同一 Spark 任务实例的 Pod 尽量调度到同一节点上。
具体配置如下:
spec:
affinity:
podAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchExpressions:
- key: type
operator: In
values:
- batch-job
topologyKey: kubernetes.io/hostname
weight: 1
- podAffinityTerm:
labelSelector:
matchExpressions:
- key: service
operator: In
values:
- bigdata-spark-${jobId}
topologyKey: kubernetes.io/hostname
weight: 10
由于同一 Spark 任务的 Pod 执行耗时是一样的,将其优先调度到同一节点就可以避免耗时久的 Spark 任务分散到多个动态节点上、进而卡多个节点,如此一个 Spark 任务最多卡一个。
注意这里有两个 weight
变量(同 NodeAffinity 的 weight
作用一样,用于计算得分使用,最终得分会转换成 [1-10] 的分值),分别是 10 和 1,设置为 10 的是 Spark 任务,设置为 1 的是批处理任务(包括 Spark 任务)。这样设置是为了在保证同一 Spark 任务的 Pod 优先调度到同一节点的前提下,同时 Spark 任务以外的批处理任务优先调度到已经在运行批处理任务的节点上,这样做可以避免新的批处理任务调度到了正在准备释放的节点上,进而卡节点。
至于 weight
值设置 10 和 1 是因为在优选算法的逻辑中,各个优选算法的结果是合并计算的,如上面提到的 NodeAffinity 算法,最终会对这些算法的结果求和算出总分作为该节点的最终得分。
for i := range nodes {
result = append(result, schedulerapi.HostPriority{Host: nodes[i].Name, Score: 0})
for j := range priorityConfigs {
result[i].Score += results[j][i].Score * priorityConfigs[j].Weight
}
...
}
注意上面代码片段中的 priorityConfigs[j].Weight
的 Weight
和 NodeAffinity、PodAffinity 的 weight
不是同一个含义,前者是用来计算加权得分的,是在注册优选算法时指定的,固定为 1(NodePreferAvoidPods 算法除外,本文未涉及,这里不再赘述),后者是用来计算算法得分的。
为了可以使 NodeAffinity 的优先级高于 PodAffinity ,故对它们的 weight
做了特别设置:
- NodeAffinity: 100,这是 Kubernetes 规定的
weight
的最大值。 - PodAffinity: Spark 任务 10,批处理任务 1,由于 Spark 任务是非常耗资源的,一个节点上并不会运行很多 Spark Pod,这里假设一个节点最多可容纳 9 个 Spark Pod,这样 PodAffinity 的最大结果为
(10+1) * 9 = 99
小于 100。也就是说,当有静态节点和动态节点(且上面已经在运行了同一 Spark 任务的 Pod)同时满足调度条件时,依然可以保证优先调度到静态节点上。
验证
基于以上方案优化后,通过监控发现每天的扩容节点数量得到了显著地减少,同时服务的稳定性也得到了保障。
总结
综上所述,对于批处理任务的调试优化,可以采用以下方案解决:
- 优化定时任务的运行时间,避免同时启动大量批处理任务。
- 将批处理任务优先调度到静态节点上。
- 优先调度批处理任务。
- 优先将同类型的批处理任务调度到同一节点上。
补充说明:此优化完成于 2019 年二季度,只是现在才整理出来,某些信息可能已过时,请注意识别。
关于
- 作者:张中华(Byron Zhang),群脉 SRE 团队负责人。
- 编辑:王永浩(Aaron Wang),群脉首席架构师。