默认调度器
- 在 Kubernetes 项目中,默认调度器的主要职责,就是为一个新创建出来的 Pod,寻找一个
最合适
的节点(Node)。 - “最合适” 的含义,包括两层:
- 从集群所有的节点中,根据调度算法挑选出所有可以运行该 Pod 的节点;
- 从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。
- 所以在具体的调度流程中,默认调度器会首先调用一组叫作 Predicate 的调度算法,来检查每个 Node。然后,再调用一组叫作 Priority 的调度算法,来给上一步得到的结果里的每个 Node 打分。最终的调度结果,就是得分最高的那个 Node。
- 在 Kubernetes 中,调度机制的工作原理,可以用如下所示的一幅示意图来表示。
- Kubernetes 的调度器的核心,实际上就是两个相互独立的控制循环。
- 第一个控制循环,可以称之为 Informer Path。它的主要目的,是启动一系列 Informer,用来监听(Watch)Etcd 中 Pod、Node、Service 等与调度相关的 API 对象的变化。
- 比如,当一个待调度 Pod(即:它的 nodeName 字段是空的)被创建出来之后,调度器就会通过 Pod Informer 的 Handler,将这个待调度 Pod 添加进调度队列。在默认情况下,Kubernetes 的调度队列是一个 PriorityQueue(优先级队列),并且当某些集群信息发生变化的时候,调度器还会对调度队列里的内容进行一些特殊操作。此外,Kubernetes 的默认调度器还要负责对调度器缓存(即:scheduler cache)进行更新。事实上,Kubernetes 调度部分进行性能优化的一个最根本原则,就是尽最大可能将集群信息 Cache 化,以便从根本上提高 Predicate 和 Priority 调度算法的执行效率。
- 第二个控制循环,是调度器负责 Pod 调度的主循环,可以称之为 Scheduling Path。Scheduling Path 的主要逻辑,就是不断地从调度队列里出队一个 Pod。然后,调用 Predicates 算法进行“过滤”。这一步“过滤”得到的一组 Node,就是所有可以运行这个 Pod 的宿主机列表。当然,Predicates 算法需要的 Node 信息,都是从 Scheduler Cache 里直接拿到的,这是调度器保证算法执行效率的主要手段之一。
- 第一个控制循环,可以称之为 Informer Path。它的主要目的,是启动一系列 Informer,用来监听(Watch)Etcd 中 Pod、Node、Service 等与调度相关的 API 对象的变化。
- 接下来,调度器就会再调用 Priorities 算法为上述列表里的 Node 打分,分数从 0 到 10。得分最高的 Node,就会作为这次调度的结果。
- 调度算法执行完成后,调度器就需要将 Pod 对象的 nodeName 字段的值,修改为上述 Node 的名字。这个步骤在 Kubernetes 里面被称作 Bind。
- 为了不在关键调度路径里远程访问 APIServer,Kubernetes 的默认调度器在 Bind 阶段,只会更新 Scheduler Cache 里的 Pod 和 Node 的信息。这种基于“乐观”假设的 API 对象更新方式,在 Kubernetes 里被称作 Assume。
- Assume 之后,调度器才会创建一个 Goroutine 来异步地向 APIServer 发起更新 Pod 的请求,来真正完成 Bind 操作。如果这次异步的 Bind 过程失败了,其实也没有太大关系,等 Scheduler Cache 同步之后一切就会恢复正常。
- 当然,正是由于Kubernetes 调度器的“乐观”绑定的设计,当一个新的 Pod 完成调度需要在某个节点上运行起来之前,该节点上的 kubelet 还会通过一个叫作 Admit 的操作来再次验证该 Pod 是否确实能够运行在该节点上。这一步 Admit 操作,实际上就是把一组叫作 GeneralPredicates 的、最基本的调度算法,比如:“资源是否可用”“端口是否冲突”等再执行一遍,作为 kubelet 端的二次确认。
- 在 Scheduling Path 上,调度器会启动多个 Goroutine 以节点为粒度并发执行 Predicates 算法,从而提高这一阶段的执行效率。而与之类似的,Priorities 算法也会以 MapReduce 的方式并行计算然后再进行汇总。而在这些所有需要并发的路径上,调度器会避免设置任何全局的竞争资源,从而免去了使用锁进行同步带来的巨大的性能损耗。
默认调度器调度策略解析
Predicates
- Predicates 在调度过程中的作用,可以理解为 Filter,即:它按照调度策略,从当前集群的所有节点中,“过滤”出一系列符合条件的节点。这些节点,都是可以运行待调度 Pod 的宿主机。
Kubernetes 中,默认的调度策略:
- 第一种类型,叫作 GeneralPredicates。顾名思义,这一组过滤规则,负责的是最基础的调度策略。比如,PodFitsResources 计算的就是宿主机的 CPU 和内存资源等是否够用。
- PodFitsResources 检查的只是 Pod 的 requests 字段。需要注意的是,Kubernetes 的调度器并没有为 GPU 等硬件资源定义具体的资源类型,而是统一用一种名叫 Extended Resource 的、Key-Value 格式的扩展字段来描述的。比如这个例子:
apiVersion: v1 kind: Pod metadata: name: extended-resource-demo spec: containers: - name: extended-resource-demo-ctr image: nginx resources: requests: alpha.kubernetes.io/nvidia-gpu: 2 limits: alpha.kubernetes.io/nvidia-gpu: 2
- PodFitsHost 检查的是,宿主机的名字是否跟 Pod 的 spec.nodeName 一致。
- PodFitsHostPorts 检查的是,Pod 申请的宿主机端口(spec.nodePort)是不是跟已经被使用的端口有冲突。
- PodMatchNodeSelector 检查的是,Pod 的 nodeSelector 或者 nodeAffinity 指定的节点,是否与待考察节点匹配,等等。
- PodFitsResources 检查的只是 Pod 的 requests 字段。需要注意的是,Kubernetes 的调度器并没有为 GPU 等硬件资源定义具体的资源类型,而是统一用一种名叫 Extended Resource 的、Key-Value 格式的扩展字段来描述的。比如这个例子:
- 可以看到,像上面这样一组 GeneralPredicates,正是 Kubernetes 考察一个 Pod 能不能运行在一个 Node 上最基本的过滤条件。所以,GeneralPredicates 也会被其他组件(比如 kubelet)直接调用。
- 第二种类型,是与 Volume 相关的过滤规则。这一组过滤规则,负责的是跟容器持久化 Volume 相关的调度策略。
- NoDiskConflict 检查的条件,是多个 Pod 声明挂载的持久化 Volume 是否有冲突。比如,AWS EBS 类型的 Volume,是不允许被两个 Pod 同时使用的。所以,当一个名叫 A 的 EBS Volume 已经被挂载在了某个节点上时,另一个同样声明使用这个 A Volume 的 Pod,就不能被调度到这个节点上了。
- MaxPDVolumeCountPredicate 检查的条件,则是一个节点上某种类型的持久化 Volume 是不是已经超过了一定数目,如果是的话,那么声明使用该类型持久化 Volume 的 Pod 就不能再调度到这个节点了。
- VolumeZonePredicate,则是检查持久化 Volume 的 Zone(高可用域)标签,是否与待考察节点的 Zone 标签相匹配。
- VolumeBindingPredicate 的规则。它负责检查的,是该 Pod 对应的 PV 的 nodeAffinity 字段,是否跟某个节点的标签相匹配。
- 第三种类型,是宿主机相关的过滤规则。这一组规则,主要考察待调度 Pod 是否满足 Node 本身的某些条件。
- PodToleratesNodeTaints,负责检查的就是 Node 的“污点”机制。只有当 Pod 的 Toleration 字段与 Node 的 Taint 字段能够匹配的时候,这个 Pod 才能被调度到该节点上。
- NodeMemoryPressurePredicate,检查的是当前节点的内存是不是已经不够充足,如果是的话,那么待调度 Pod 就不能被调度到该节点上。
- 第四种类型,是 Pod 相关的过滤规则。这一组规则,跟 GeneralPredicates 大多数是重合的。而比较特殊的,是 PodAffinityPredicate。这个规则的作用,是检查待调度 Pod 与 Node 上的已有 Pod 之间的亲密(affinity)和反亲密(anti-affinity)关系。
- 上面这四种类型的 Predicates,就构成了调度器确定一个 Node 可以运行待调度 Pod 的基本策略。
当开始调度一个 Pod 时,Kubernetes 调度器会同时启动 16 个 Goroutine,来并发地为集群里的所有 Node 计算 Predicates,最后返回可以运行这个 Pod 的宿主机列表。
Priorities
- Predicates 阶段完成了节点的“过滤”之后,Priorities 阶段的工作就是为这些节点打分。这里打分的范围是 0-10 分,得分最高的节点就是最后被 Pod 绑定的最佳节点。
- Priorities 里最常用到的一个打分规则,是 LeastRequestedPriority,这个算法实际上就是在选择空闲资源(CPU 和 Memory)最多的宿主机。与 LeastRequestedPriority 一起发挥作用的,还有 BalancedResourceAllocation。BalancedResourceAllocation 选择的,其实是调度完成后,所有节点里各种资源分配最均衡的那个节点,从而避免一个节点上 CPU 被大量分配、而 Memory 大量剩余的情况。
- NodeAffinityPriority、TaintTolerationPriority 和 InterPodAffinityPriority 这三种 Priority。顾名思义,它们与前面的 PodMatchNodeSelector、PodToleratesNodeTaints 和 PodAffinityPredicate 这三个 Predicate 的含义和计算方法是类似的。但是作为 Priority,一个 Node 满足上述规则的字段数目越多,它的得分就会越高。
- ImageLocalityPriority 的策略。它是在 Kubernetes v1.12 里新开启的调度规则,即:如果待调度 Pod 需要使用的镜像很大,并且已经存在于某些 Node 上,那么这些 Node 的得分就会比较高。
Kubernetes 调度器里其实还有一些默认不会开启的策略。你可以通过为 kube-scheduler 指定一个配置文件或者创建一个 ConfigMap ,来配置哪些规则需要开启、哪些规则需要关闭。并且,你可以通过为 Priorities 设置权重,来控制调度器的调度行为。
默认调度器的优先级与抢占机制
- Kubernetes 调度器里的另一个重要机制,即:优先级(Priority )和抢占(Preemption)机制。优先级和抢占机制,解决的是 Pod 调度失败时该怎么办的问题。
- 正常情况下,当一个 Pod 调度失败后,它就会被暂时“搁置”起来,直到 Pod 被更新,或者集群状态发生变化,调度器才会对这个 Pod 进行重新调度。但在有时候,我们希望的是 当一个高优先级的 Pod 调度失败后,该 Pod 并不会被“搁置”,而是会“挤走”某个 Node 上的一些低优先级的 Pod 。这样就可以保证这个高优先级 Pod 的调度成功。
- 在 Kubernetes 里,优先级和抢占机制是在 1.10 版本后才逐步可用的。要使用这个机制,首先需要在 Kubernetes 里提交一个 PriorityClass 的定义,如下所示:
apiVersion: scheduling.k8s.io/v1beta1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000
globalDefault: false
description: "This priority class should be used for high priority service pods only."
- globalDefault 被设置为 true 的话,那就意味着这个 PriorityClass 的值会成为系统的默认值。而如果这个值是 false,就表示我们只希望声明使用该 PriorityClass 的 Pod 拥有值为 1000000 的优先级,而对于没有声明 PriorityClass 的 Pod 来说,它们的优先级就是 0。
- Kubernetes 规定,优先级是一个 32 bit 的整数,最大值不超过 1000000000(10 亿,1 billion),并且值越大代表优先级越高。而超出 10 亿的值,其实是被 Kubernetes 保留下来分配给系统 Pod 使用的。显然,这样做的目的,就是保证系统 Pod 不会被用户抢占掉。
- 创建了 PriorityClass 对象之后,Pod 就可以声明使用它了,如下所示:
apiVersion: v1
kind: Pod
metadata:
name: nginx
labels:
env: test
spec:
containers:
- name: nginx
image: nginx
imagePullPolicy: IfNotPresent
priorityClassName: high-priority
- Pod 通过 priorityClassName 字段,声明了要使用名叫 high-priority 的 PriorityClass。当这个 Pod 被提交给 Kubernetes 之后,Kubernetes 的 PriorityAdmissionController 就会自动将这个 Pod 的 spec.priority 字段设置为 1000000。
- 调度器里维护着一个调度队列。所以,当 Pod 拥有了优先级之后,高优先级的 Pod 就可能会比低优先级的 Pod 提前出队,从而尽早完成调度过程。这个过程,就是 “优先级”这个概念在 Kubernetes 里的主要体现。而当一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发。这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被删除后,待调度的高优先级 Pod 就可以被调度到这个节点上。这个过程,就是 “抢占”这个概念在 Kubernetes 里的主要体现。
Kubernetes 调度器里的抢占机制,又是如何设计的呢?
- Kubernetes 调度器实现抢占算法的一个最重要的设计,就是在调度队列的实现里,使用了两个不同的队列。
- 第一个队列,叫作 activeQ。凡是在 activeQ 里的 Pod,都是下一个调度周期需要调度的对象。所以,当你在 Kubernetes 集群里新创建一个 Pod 的时候,调度器会将这个 Pod 入队到 activeQ 里面。前面提到过的、调度器不断从队列里出队(Pop)一个 Pod 进行调度,实际上都是从 activeQ 里出队的。
- 第二个队列,叫作 unschedulableQ,专门用来存放调度失败的 Pod。
- 当一个 unschedulableQ 里的 Pod 被更新之后,调度器会自动把这个 Pod 移动到 activeQ 里,从而给这些调度失败的 Pod “重新做人”的机会。
- 调度失败之后,抢占者就会被放进 unschedulableQ 里面。然后,这次失败事件就会触发调度器为抢占者寻找牺牲者的流程。
- 第一步,调度器会检查这次失败事件的原因,来确认抢占是不是可以帮助抢占者找到一个新节点。这是因为有很多 Predicates 的失败是不能通过抢占来解决的。比如,PodFitsHost 算法(负责的是,检查 Pod 的 nodeSelector 与 Node 的名字是否匹配),这种情况下,除非 Node 的名字发生变化,否则你即使删除再多的 Pod,抢占者也不可能调度成功。
- 第二步,如果确定抢占可以发生,那么调度器就会把自己缓存的所有节点信息复制一份,然后使用这个副本来模拟抢占过程。抢占过程很容易理解。调度器会检查缓存副本里的每一个节点,然后从该节点上最低优先级的 Pod 开始,逐一“删除”这些 Pod。而每删除一个低优先级 Pod,调度器都会检查一下抢占者是否能够运行在该 Node 上。一旦可以运行,调度器就记录下这个 Node 的名字和被删除 Pod 的列表,这就是一次抢占过程的结果了。当遍历完所有的节点之后,调度器会在上述模拟产生的所有抢占结果里做一个选择,找出最佳结果。而这一步的判断原则,就是尽量减少抢占对整个系统的影响。比如,需要抢占的 Pod 越少越好,需要抢占的 Pod 的优先级越低越好,等等。
- 在得到了最佳的抢占结果之后,这个结果里的 Node,就是即将被抢占的 Node;被删除的 Pod 列表,就是牺牲者。所以接下来,调度器就可以真正开始抢占的操作了,这个过程,可以分为三步。
- 第一步,调度器会检查牺牲者列表,清理这些 Pod 所携带的 nominatedNodeName 字段。
- 第二步,调度器会把抢占者的 nominatedNodeName,设置为被抢占的 Node 的名字。
- 第三步,调度器会开启一个 Goroutine,同步地删除牺牲者。
- 接下来,调度器就会通过正常的调度流程把抢占者调度成功。调度器并不保证抢占的结果:在这个正常的调度流程里,是一切皆有可能的。
- 不过,对于任意一个待调度 Pod 来说,因为有上述抢占者的存在,它的调度过程,其实是有一些特殊情况需要特殊处理的。具体来说,在为某一对 Pod 和 Node 执行 Predicates 算法的时候,如果待检查的 Node 是一个即将被抢占的节点,即:调度队列里有 nominatedNodeName 字段值是该 Node 名字的 Pod 存在(可以称之为:“潜在的抢占者”)。那么,调度器就会对这个 Node ,将同样的 Predicates 算法运行两遍。
- 第一遍, 调度器会假设上述“潜在的抢占者”已经运行在这个节点上,然后执行 Predicates 算法;
- 第二遍, 调度器会正常执行 Predicates 算法,即:不考虑任何“潜在的抢占者”。
- 只有这两遍 Predicates 算法都能通过时,这个 Pod 和 Node 才会被认为是可以绑定(bind)的。
你知道的越多,你不知道的越多。