1. 前言
无论是在以Hadoop为生态的大数据领域,还是以Kubernetes为代表的云原生领域,调度通常是一个难题,没有所谓“最好”的调度策略。本文不分析Yarn Scheduler和Kubernetes Scheduler谁更好,而是参考和借鉴Yarn Scheduler的优秀设计,以及业界对Kubernetes调度器的实践,打磨一款高性能的Kubernetes调度器,以支持大规模Kubernetes集群的离、在线混部。
Hadoop Yarn默认提供了三种调度器和可配置策略,满足不同用户场景的需求,而Kubernetes默认只提供了一种名叫default-scheduler的默认调度器,但是得益于Kubernetes的扩展和插件机制,如Scheduler Extender和Scheduler Framework,开发者可以根据自身业务需求,实现一定的的调度能力,但是原生Kubernetes调度器是为在线业务而设计的,且扩展点和灵活性受限,因此独立设计Kubernetes调度器也是一种较好的选择。
本文首先分析了大数据应用最常用的Hadoop Yarn的三种调度器及其特点,接着分析了Kubernetes调度器的特性,然后分析了业界在Kubernetes领域在调度器优化上的一些最佳实践。在这些基础之上,我们分析优缺点,选则并围绕TKE-Scheduler,打磨适合大数据应用云原生的Kubernetes调度器,最终目标是参考Yarn调度器的设计、Kubernetes调度器和业界在Kubernetes调度器的优秀实践,以业务需求为导向,不断完善TKE-Scheduler,并应用到大规模的在、离线混部集群。
2. Hadoop应用领域的调度器策略
上面提到,Hadoop Yarn提供了三种调度器,具体来说,分别是FIFO(先进先出)调度器、CapacityScheduler (容量调度器)和FairScheduler( 公平调度器),本文首先介绍这三种调度器的特性。
1)简单“公平”,先来后到 -- FIFO调度器
顾名思义,FIFO先进先出调度策略,所有的任务将按照提交顺序放在同一个队列里,只有前一个任务所需的资源得到了调度,才会执行下一个。这种简单的调度策略,其缺点非常明显,如果集群是多个业务方共享的,在资源紧张的情况下,耗时长的任务势必会导致后提交的任务处于等待状态。所以,这种调度策略通常是配合后面两种调度器使用。
2)多租户场景下的调度需求 -- CapacityScheduler容量调度器
通常情况下,多租户是共享同一个集群资源的,这就要求调度器有能力保证租户的应用可以在分配策略的限制下,及时的分配到资源,从而为多租户最大化吞吐量和集群资源使用率。容量调度器正是解决多租户场景下的调度需求。相比较之FIFO调度器,容量调度器的实现就复杂了很多,其支持的特性也非常多,这里列出一些主要的特性。
- Hierarchical Queues(层级队列):支持配置队列层级,一个队列下面可以细分更多层级。
- Capacity Guarantees(容量保证):支持为每个队列和层级设置容量及上限。
- Security(安全):每个队列有严格的ACL认证,控制用户可以提交到该队列,而safe-guards进程保证用户不能查看和修改其他用户的应用。
- Elasticity(支持弹性队列功能):通常情况下,作业不能使用超过队列容量,但是如果一个队列中有多于一个的作业,并且有空闲的资源,则调度器会为作业分配资源,即使这会导致队列超出容量限制;为了避免队列占用过多其他队列的资源,可以配置一个最大容量,队列只能使用该容量以内的资源,但是会牺牲一定的弹性,所以,需要在不断的尝试和失败中找到一个合理的折衷。
- Multi-tenancy(多租户):提供综合的限制策略,保证集群资源不会被单一应用、用户、队列占用。
- Operability(可操作性):1)运行时配置,队列的定义和属性(如容量,acls)可以在运行时被管理员修改,从而减少对用户对影响。yarn提供了控制台,管理员和用户可以从控制台查询当前各个队列对资源分配情况,管理员可以增加额外的队列,但是不能删除队列,除非队列的状态是STOOPED并且没有pending和running的应用。 2)优雅驱逐:管理员可以停止队列,新的应用不会调度到该队列,但是已经存在的应用会继续运行直到完成,所以这个队列上的应用可以被优雅的驱逐。管理员可以再次启动这个队列。
- Resource-based Scheduling:应用可以指定所需的资源,当前支持内存资源的自定义。
- 队列放置:将应用程序放在哪个队列里面,如果未指定,则会被放入default队列。
- Priority Scheduling :允许应用指定不同的优先级数值。
- Absolute Resource Configuration(最大数量):配置单个用户或者应用能被分配到的最大资源数量,而非百分比,这样可以更好的控制队列的资源数量。
- Dynamic Auto-Creation and Management of Leaf Queues:基于queue-mapping配置,动态创建子队列
- 可以指定最大运行的应用的数量
这种类型的调度器也有一定的缺点,通常有一个独立的专门队列保证小作业一提交就可以启动,由于队列容量是为那个队列中的作业保留的,所以该策略是以牺牲整个集群的利用率为代价的,也因此,相对于FIFO调度器,大作业执行的时间要长。
3)真正的公平--FairScheduler公平调度器
公平调度器不需要预留一定量的资源,因为调度器会在所有运行的作业之间动态的平衡资源。第一个(大)作业启动时,它也是唯一运行的作业,因而获得集群所有的资源。
当第二个(小)作业启动时,它被分配到一半的资源,这样每个作业都会公平的共享资源。
需要注意的是,第二个作业提交时,获得资源会又一个时间差,它必须等待第一个作业使用的容器用完并释放资源,当小作业结束且不再申请资源,大作业将回去再次使用全部的资源。
最终的效果就是,得到了较高的资源利用率,又能保证小作业及时完成,主要特性如下,
- 公平调度:这里的公平,指的是用户平均使用资源,而非应用平均使用资源,如上图,用户A和用户B持有两个队列,A提交Job1的时候,集群内只有job1,所以使用所有的资源,当用户B提交了job2时,用户A和用户B各使用一半资源,当用户B提交job3时,用户B的job2和job3平均分配队列B的资源,不会影响到队列A, 所以公平指的是用户纬度的公平,而非应用。
- 队列层级和权重:队列的层次使用嵌套queue元素来定义,可以指定权重的比例,下面这个代码块展示了一种常见的嵌套queue及权重的配置。
<?xml version="1.0"?>
<allocations>
<defaultQueueSchedulingPolicy>fair
</defaultQueueSchedulingPolicy>
<queue name="prod">
<weight>40</weight>
<schedulingPolicy>fifo
</schedulingPolicy>
</queue>
<queue name="dev">
<weight>60</weight>
<queue name="eng"/>
<queue name="science"/>
</queue>
<queue name="sample_queue">
<minResources>10000 mb,0vcores
</minResources>
<maxResources>90000 mb,0vcores
</maxResources>
<maxRunningApps>50</maxRunningApps>
<maxAMShare>0.1
</maxAMShare>
<weight>2.0</weight>
<schedulingPolicy>fair
</schedulingPolicy>
<queue name="sample_sub_queue">
<aclSubmitApps>charlie
</aclSubmitApps>
<minResources>5000 mb,0vcores
</minResources>
</queue>
<queue name="sample_reservable_queue">
<reservation>
</reservation>
</queue>
</queue>
<queuePlacementPolicy>
<rule name="specified" create="false"/>
<rule name="primaryGroup" create="false"/>
<rule name="default" queue="dev.eng"/>
</queuePlacementPolicy>
</allocations>
- 每个队列可以设置不同的调度策略:未指定则通过顶层元素defaultQueueSchedulingPolicy的值进行设定未。
- 每个队列可以设置最大最小资源数量(其中,最小资源数量还可以用于以下场景:当两个队列的资源都低于它们的公平共享额度时,调度器会优先为远低于最小资源数量的队列分配资源)。
- 每个队列可以设置最大运行应用数量。
- 队列放置:基于一系列配置规则来告诉调度器如何将新建的应用放在哪个队列,每条规则会被以此尝试直到成功,正如上述代码片段里queuePlacementPolicy标签中所列的规则。
- 抢占:允许调度器终止那些占用资源超过来公平共享份额的的队列的容器。抢占会降低整个集群的效率,因为被终止的容器会重新执行。
- 延迟调度:所有的yarn调度器都试图以本地请求为重,也就是将应用调度到本地节点,如果应用请求某个指定的节点,通常说明该节点上有其他容器在运行,通常处理是,放宽本地性要求,在同机架重分配一个容器,然而实践发现,如果此时等待一会(几秒),能够戏剧性的增加在所请求的节点上分配一个容器的机会,从而提高集群的效率,整个特性称之为延迟调度。
- 支持主导资源公平性(Dominant Resource Fairness,DFR【1】):默认调度是基于内存的,DFR算法是基于内存+vcore的综合判断,其基本原理是,调度器以迭代的方式,选择当前主导资源占比最小的用户,也就是说已经分配给用户的主导资源占这种资源总量比例哪个小,优先给该用户分配资源。
通过以上特性可以看出,FairScheduler调度算法从资源利用率和资源公平分配两方面,来解决多租户场景下的资源调度问题,业界不少大数据团队也正是使用的该调度器。
3. Kubernetes默认调度器及其扩展
Kubernetes的默认调度器,其调度队列是一个PriorityQueue优先级队列,并通过一系列预选和优选策略,最终选择出最合适的节点,并且支持抢占机制。此外,随着AI、大数据领域的应用向Kubernetes集群前移,默认的调度器的功能已不能支持,但是开发者可以开发出适合自己场景的自定义调度器,并与默认调度器共同工作,同时Kubernetes提供了调度器扩展机制,使开发者扩展调度器的门槛进一步降低。下面分别从预选、优选、抢占、配置多个调度器和调度器扩展来介绍Kubernetes调度器的特性。
细粒度⁵:cpu以milli-cores为单位,如500m,也就是0.5个cpu;内存以bytes为单位,可以选择Ei、Pi、Ti、Gi、Mi、Ki等方式作为bytes的值;
动态资源: 参考borg系统的动态资源设计,实现了requests+limits方式,调度器根据requests来调度资源,Kubelet真正用来设置cgroup的,是limits的值;
Qos服务质量:三级服务质量,用于资源紧张时的驱逐和调度时的抢占机制,其中驱逐又可分为Soft优雅驱逐和Hard立即驱逐;
节点状态上报机制:当宿主机达到驱逐阈值后,会被kubelet设定为MemoryPressure或者DiskPressure状态,避免新的pod被调度过来;
绑核设定(cpuset):Qos为Guaranteed且cpu的requests和limits被设定为相等的整数,则kubelete为该pod开启cpuset绑核;
基于informer机制的scheduler cache,提高predicate和priority调度算法的执行效率;
多种预选策略
预选策略分三种类型来过滤掉不合适的节点,注意这些策略列出的顺序(Static ording²),也正是Kubernetes Scheduler调度程序在具体执行时所采用的顺序,如果排在前面的策略检查失败了,后面的策略也就没有执行的意义;需要指出的是,Kubernetes Scheduler对单个宿主机的过滤策略是顺序执行的,但是对于所有宿主机的过滤,是启动了16个Goroutines并非执行的。
- 宿主机状态筛查策略
检查宿主机的可用状态是否为NotReady;
检查宿主机的磁盘状态是否为OutOfDisk;
检查宿主机的网络状态是否为Unavailable;
检查宿主机的调度状态是否为UnScheduleable;
- 基础过滤策略
检查宿主机是否有足够的资源;
检查应用申请的宿主机端口是否已经被使用;
检查应用指定的主机名是否和宿主机的主机名相同;
检查应用指定的宿主机标签或者亲和性标签是否和宿主机匹配;
- Volume相关的过滤策略
检查多个应用POD声明挂载的持久卷是否有冲突,比如有的厂商的Volume不允许一盘多挂;
检查宿主机上某种持久卷的数量是否超过上限;
检查持久化Volume的高可用标签是否与宿主机的相同;
- 宿主机相关的过滤策略
检查应用POD是否容忍宿主机的污点;
- 应用POD相关的过滤策略
检查应用POD之间的亲和性和反亲和性;
另外,Kubernetes也允许集群管理员以Scheduler policy的方式自定义顺序,如下代码片段所示,
{"kind" : "Policy",
"apiVersion" : "v1",
"predicates" : [
{"name" : "PodFitsHostPorts", "order": 2},
{"name" : "PodFitsResources", "order": 3},
{"name" : "NoDiskConflict", "order": 5},
{"name" : "PodToleratesNodeTaints", "order": 4},
{"name" : "MatchNodeSelector", "order": 6},
{"name" : "PodFitsHost", "order": 1} ],
"priorities" : [
{"name" : "LeastRequestedPriority", "weight" : 1},
{"name" : "BalancedResourceAllocation", "weight" : 1},
{"name" : "ServiceSpreadingPriority", "weight" : 1},
{"name" : "EqualPriority", "weight" : 1} ],
"hardPodAffinitySymmetricWeight" : 10}
多种优选策略
优选策略的目的是为预选策略得到的宿主机进行打分,范围是0-10分,得分最高的的节点就是最后被应用POD绑定的最佳节点,主要支持以下策略,
- LeastRequestedPriority打散策略,空闲资源最多的宿主机分数越高;
- MostRequestedPriority凝聚策略,空闲资源最小的宿主机分数越高;
- ImageLocalityPriority镜像策略,宿主机上已经拥有Pod需要的容器镜像,则分数越高;为防止这种策略引发的调度堆叠,需要判断这类节点数量是否占集群总节点的比例,如果过低,需要降低这类节点的权重;
- BalancedResourceAllocation均衡策略,避免一个节点上CPU被大量分配,而Memory
大量剩余的情况;
- SelectorSpread优先级,尽量将归属于同一个Service、StatefulSet或RelicaSet的POD资源分散到不同的宿主机上;
- Node亲和性优先级;
- 污点容忍优先级;
- POD亲和性优先级;
优先级和抢占机制
借助Kubernetes PriorityClass对象,为POD分配优先级,当该POD调度失败后,启动抢占机制,驱逐优先级较低的POD。
支持配置多个调度器
Kubernetes允许我们开发自定义的调度器,并与默认调度器同时运行,POD通过指定调度器的名称选择合适的调度器。
支持Scheduler extender扩展机制(webhook)
开发者可以根据独立需求构建调度服务,实现对应的远程调用接口(HTTP),Scheduler在调度的对应阶段根据用户自定义的接口来进行远程调用。
支持Scheduler Framework扩展机制(Kubernetes1.15进入alpha阶段,1.19中该特性仍处于alpha版本)
Kubernetes在调度器生命周期的各个关键点上,为用户暴露出可以扩展和实现的接口,从而极大的提高了用户自定义调取器的能力。
4. 业界对默认调度器的扩展
1) Kube-batch调度器
- 支持Gang Scheduler
- 支持多租户资源共享(Queue+Namespace)
2) Volcano
- 支持Gang Scheduler
- DRF(Yarn和Mesos都有,k8s的default-scheduler没有)这个算法是基于容器组,每组容器里,计算cpu、内存、gpu各纬度占当前集群的比重,挑选最大的,和其他容器组里最大的比较,小的胜出。
- Binpack:优先将容器调度到水位较高的节点上(基于单个容器),该算法有利于集群节点的自动扩缩容。 Kubernetes默认的优先级算法LeastRequestedPriority正好相反,优先往负载低的节点调度, Kubernetes也提供了MostRequestedPriority,默认不开启,需要指定policy来开启。
- Proportion(queue队列),借鉴了yarn调度器,控制不同角色可以使用的资源比例。
- 最终权重:给调度算法设置权重,控制每种算法的影响因子。
- 支持动态调度⁶:原生Kubernetes的调度,是基于资源的request值,而很多场景下,一些节点的资源requests较低但是负载很高,另一些节点的资源requests较高但是负载很低。动态调度,根据监控指标调度,保证应用尽量不调度到负载较高的节点上。
- 支持Descheduler⁷(反调度器):Kubernetes集群资源是实时动态变化的,反调度器认为,驱逐一个running的Pod到其他节点是有必要的,比如一些节点利用率过低或或高、新的节点被加入集群中、一些节点状态变为不可用、污点或者标签被增加或者删除、pod/node的亲和性不在满足等。
- 基于Kubernetes Scheduler Framework实现了Gang scheduler, 轻量级Gang调度,性能较差。
- 基于Kubernetes Scheduler Famework实现了CPU拓扑感知调度
Nodes节点上的多个CPU密集型Pods之间会争抢节点的CPU资源,导致Pod在不同的CPU Core之间频繁的切换,尤其是NUMA Node之间的切换,对应用的性能影响很大,Kubernetes提供的CPU Manager功能仅限于在节点级别进行CPU调度(绑核),所以需要CPU拓扑感知,在集群层面进行调度。
5. Hadoop Yarn Scheduler和Kubernetes Scheduler对比
通过以上章节的特性分析,这里简对Yarn Scheduler和Kubernetes Scheduler进行了一系列对比,Yarn调度器和Kubernetes各有优劣,这里并不分析哪个更好,而是更注重借鉴Yarn Scheduler的设计,完善Kubernetes 调度器。
多租户资源共享问题
从以上特性的分析中,不难发现,Yarn调度器更注重多租户场景下的资源公平共享,而这又恰好是Kubernetes默认调度器的短板。好在Kubernetes强大的插件和扩展机制,使得开发者得以灵活的扩展默认调度器的不足,实现业务期望的特性。比如Kube-Batch项目,就通过QueueName+Namespace的方式,实现了Yarn调度器的多租户资源共享的特性。
调度粒度和资源利用率
在资源层面,Hadoop Yarn默认支持以内存为单位进行调度,用户也可以进行配置从而支持CPU层面的调度;Kubernetes默认支持CPU和内存的隔离以及CPU绑核,可以将CPU和内存资源以较细的粒度进行划分,并以容器为调度单位,充分提高资源的利用率;在应用层面,Kubernetes以容器为部署单位,在应用之间减少了底层组件的依赖和影响。
调度性能
Yarn scheduler如FairScheduler,社区的版本都是单线程的,当集群规模较大的时候,容易出现性能瓶颈;Kubernetes基于Scheduler cache和多goroutines的调度,在性能上优势明显。
实时流计算、AI场景下的特殊调度需求--Gang调度
在流计算和AI场景下,往往要求单次作业所需的资源全部都启动,才能保证作业正常运行,这种All-or-Nothing的要求,也就是Gang调度需求。以流计算领域的代表Apache Flink为例,假如某次作业的并发度为10,则需要创建并启动10个TaskManager(slot) Pod作为计算资源,如果集群中的剩余资源只有9个,那么将会有1个TaskManager Pod一直处于Pending状态,其他9个TaskManager Pod虽然处于Running状态,但是Flink的作业是不会开始运行的,且作业状态处于Created状态,这种状态下,9个TaskManager Pod一直处于空载状态,占用了集群的资源,降低了资源的利用率。在AI和机器学习领域,如KubeFlow,也存在这种问题。而Gang scheduler的出现,正是解决这类问题的。Hadoop Yarn Scheduler和Kubernetes Scheduler默认都没有支持Gang Scheduler,不过都可以通过扩展机制来实现。
批量调度功能
区别于Gang调度,批量调度指的是当调度队列里的待调度对象达到一定的数量时,再统一批量调度,从而提高调度性能。