文章目录
Kubernetes 为用户提供的基础设施能力,不仅包括了前面介绍的应用定义和描述的部分,还包括了对应用的资源管理
和调度的处理
。
资源模型
作为 Kubernetes 的资源管理与调度部分的基础,我们要从它的资源模型开始说起。
在前面的文章中已经提到过,在 Kubernetes 里,Pod 是最小的原子调度单位
。这也就意味着,所有跟调度
和资源管理
相关的属性都应该是属于 Pod 对象的字段。而这其中最重要的部分,就是 Pod 的 CPU
和内存
配置,如下所示:
apiVersion: v1
kind: Pod
metadata:
name: frontend
spec:
containers:
- name: db
image: mysql
env:
- name: MYSQL_ROOT_PASSWORD
value: "password"
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
- name: wp
image: wordpress
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
在 Kubernetes 中,像 CPU 这样的资源被称作“
可压缩资源
”(compressible resources)。它的典型特点是,当可压缩资源不足时,Pod 只会“饥饿”,但不会退出
。
而像内存这样的资源,则被称作“
不可压缩资源
(incompressible resources)。当不可压缩资源不足时,Pod 就会因为 OOM(Out-Of-Memory)被内核杀掉
。
CPU
而由于 Pod 可以由多个 Container
组成,所以 CPU 和内存资源的限额,是要配置在每个 Container 的定义上的。这样,Pod 整体的资源配置,就由这些 Container 的配置值累加
得到。
其中,Kubernetes 里为 CPU 设置的单位是“CPU 的个数”
。比如,cpu=1
指的就是,这个 Pod 的 CPU 限额是 1 个 CPU。Kubernetes 只负责保证 Pod 能够使用到“1 个 CPU”的计算能力。
此外,Kubernetes 允许你将 CPU 限额设置为分数,比如在我们的例子里,CPU limits
的值就是 500m
。所谓 500m,指的就是 500 millicpu
,也就是 0.5 个 CPU
的意思。这样,这个 Pod 就会被分配到 1 个 CPU 一半的计算能力。
内存
而对于内存资源来说,它的单位自然就是 bytes
。Kubernetes 支持你使用 Ei、Pi、Ti、Gi、Mi、Ki(或者 E、P、T、G、M、K)的方式来作为 bytes 的值。比如,在我们的例子里,Memory requests 的值就是 64MiB
(2 的 26 次方 bytes) 。这里要注意区分 MiB
(mebibyte)和 MB
(megabyte)的区别。
备注:1Mi=10241024;1M=10001000
此外,不难看到,Kubernetes 里 Pod 的 CPU 和内存资源,实际上还要分为 limits 和 requests 两种情况,如下所示:
spec.containers[].resources.limits.cpu
spec.containers[].resources.limits.memory
spec.containers[].resources.requests.cpu
spec.containers[].resources.requests.memory
这两者的区别其实非常简单:在调度的时候,kube-scheduler
只会按照 requests
的值进行计算。而在真正设置 Cgroups
限制的时候,kubelet 则会按照 limits
的值来进行设置。
更确切地说,当你指定了 requests.cpu=250m
之后,相当于将 Cgroups
的 cpu.shares
的值设置为 (250/1000)*1024。而当你没有设置 requests.cpu 的时候,cpu.shares 默认则是 1024。这样,Kubernetes 就通过 cpu.shares
完成了对 CPU 时间的按比例分配。
cpu.shares
不是限制进程能使用的绝对的 cpu 时间,而是控制各个组之间的配额。
而如果你指定了 limits.cpu=500m
之后,则相当于将 Cgroups
的 cpu.cfs_quota_us
的值设置为 (500/1000)*100ms,而 cpu.cfs_period_us
的值始终是 100ms。这样,Kubernetes 就为你设置了这个容器只能用到 CPU 的 50%。
而对于内存来说,当你指定了 limits.memory=128Mi
之后,相当于将 Cgroups
的 memory.limit_in_bytes
设置为 128 * 1024 * 1024
。而需要注意的是,在调度的时候,调度器只会使用 requests.memory=64Mi
来进行判断。
Kubernetes 这种对 CPU 和内存资源限额的设计,实际上参考了
Borg
论文中对“动态资源边界”
的定义,既:容器化作业在提交时所设置的资源边界,并不一定是调度系统所必须严格遵守的,这是因为在实际场景中,大多数作业使用到的资源其实远小于它所请求的资源限额
。
而 Kubernetes 的 requests+limits
的做法,其实就是上述思路的一个简化版:用户在提交 Pod 时,可以声明一个相对较小的 requests
值供调度器使用,而 Kubernetes 真正设置给容器 Cgroups 的,则是相对较大的 limits
值。
Qos
在理解了 Kubernetes 资源模型的设计之后,我再来和你谈谈 Kubernetes 里的 QoS
模型。在 Kubernetes 中,不同的 requests
和 limits
的设置方式,其实会将这个 Pod 划分到不同的 QoS
级别当中。
当 Pod 里的每一个 Container
都同时设置了 requests
和 limits
,并且 requests
和 limits
值相等的时候,这个 Pod 就属于 Guaranteed
类别,如下所示:
apiVersion: v1
kind: Pod
metadata:
name: qos-demo
namespace: qos-example
spec:
containers:
- name: qos-demo-ctr
image: nginx
resources:
limits:
memory: "200Mi"
cpu: "700m"
requests:
memory: "200Mi"
cpu: "700m"
当这个 Pod 创建之后,它的 qosClass
字段就会被 Kubernetes 自动设置为 Guaranteed
。需要注意的是,当 Pod 仅设置了 limits 没有设置 requests 的时候,Kubernetes 会自动为它设置与 limits 相同的 requests 值,所以,这也属于 Guaranteed
情况。
而当 Pod 不满足 Guaranteed
的条件,但至少有一个 Container
设置了 requests
。那么这个 Pod 就会被划分到 Burstable
类别。比如下面这个例子:
apiVersion: v1
kind: Pod
metadata:
name: qos-demo-2
namespace: qos-example
spec:
containers:
- name: qos-demo-2-ctr
image: nginx
resources:
limits
memory: "200Mi"
requests:
memory: "100Mi"
而如果一个 Pod 既没有设置 requests
,也没有设置 limits
,那么它的 QoS 类别就是 BestEffort
。比如下面这个例子:
apiVersion: v1
kind: Pod
metadata:
name: qos-demo-3
namespace: qos-example
spec:
containers:
- name: qos-demo-3-ctr
image: nginx
作用
实际上,QoS
划分的主要应用场景,是当宿主机资源紧张的时候,kubelet 对 Pod 进行 Eviction
(即资源回收)时需要用到的。
具体地说,当 Kubernetes 所管理的宿主机
上不可压缩资源短缺时,就有可能触发 Eviction
。比如,可用内存
(memory.available)、可用的宿主机磁盘空间
(nodefs.available),以及容器运行时镜像存储空间(imagefs.available)等等。
目前,Kubernetes 为你设置的 Eviction
的默认阈值如下所示:
memory.available<100Mi
nodefs.available<10%
nodefs.inodesFree<5%
imagefs.available<15%
当然,上述各个触发条件在 kubelet 里都是可配置的。比如下面这个例子:
kubelet --eviction-hard=imagefs.available<10%,memory.available<500Mi,nodefs.available<5%,nodefs.inodesFree<5% --eviction-soft=imagefs.available<30%,nodefs.available<10% --eviction-soft-grace-period=imagefs.available=2m,nodefs.available=2m --eviction-max-pod-grace-period=600
在这个配置中,你可以看到 Eviction 在 Kubernetes 里其实分为
Soft
和Hard
两种模式。
Soft Eviction
其中,Soft Eviction
允许你为 Eviction
过程设置一段“优雅时间”,比如上面例子里的 imagefs.available=2m,就意味着当 imagefs 不足的阈值达到 2 分钟之后,kubelet 才会开始 Eviction 的过程。
Hard Eviction
而 Hard Eviction
模式下,Eviction
过程就会在阈值达到之后立刻开始。
Kubernetes 计算 Eviction 阈值的数据来源,主要依赖于从
Cgroups
读取到的值,以及使用cAdvisor
监控到的数据。
当宿主机的 Eviction
阈值达到后,就会进入 MemoryPressure
或者 DiskPressure
状态,从而避免新的 Pod 被调度到这台宿主机上。
而当 Eviction
发生的时候,kubelet 具体会挑选哪些 Pod
进行删除操作,就需要参考这些 Pod 的 QoS
类别了。
1,首当其冲的,自然是 BestEffort 类别的 Pod。
2,其次,是属于 Burstable 类别、并且发生“饥饿”的资源使用量已经超出了 requests 的 Pod。
3,最后,才是 Guaranteed 类别。并且,Kubernetes 会保证只有当 Guaranteed 类别的 Pod 的资源使用量超过了其 limits 的限制,或者宿主机本身正处于 Memory Pressure 状态时,Guaranteed 的 Pod 才可能被选中进行 Eviction 操作。
当然,对于同 QoS
类别的 Pod 来说,Kubernetes 还会根据 Pod 的优先级
来进行进一步地排序和选择。
cpuset
我们再来了解下Kubernetes 里一个非常有用的特性:cpuset
的设置。
在使用容器的时候,你可以通过设置 cpuset
把容器绑定到某个 CPU
的核上,而不是像 cpushare
那样共享 CPU 的计算能力。
这种情况下,由于操作系统在 CPU
之间进行上下文切换的次数大大减少,容器里应用的性能会得到大幅提升。事实上,cpuset
方式,是生产环境里部署在线应用类型的 Pod 时,非常常用的一种方式。
实现过程。
1,首先,你的 Pod 必须是 Guaranteed
的 QoS
类型;
2,然后,你只需要将 Pod 的 CPU 资源的 requests
和 limits
设置为同一个相等的整数值即可。
spec:
containers:
- name: nginx
image: nginx
resources:
limits:
memory: "200Mi"
cpu: "2"
requests:
memory: "200Mi"
cpu: "2"
这时候,该 Pod 就会被绑定在 2 个独占的 CPU 核上。当然,具体是哪两个 CPU 核,是由 kubelet 为你分配的。
默认调度器
流程
在 Kubernetes 项目中,默认调度器
的主要职责,就是为一个新创建出来的 Pod,寻找一个最合适的节点
(Node)。
而这里“最合适”
的含义,包括两层:
1,从集群所有的节点中,根据调度算法挑选出
所有可以运行该 Pod 的节点
;
2,从第一步的结果中,再根据调度算法
挑选一个最符合条件的节点
作为最终结果。
所以在具体的调度流程中,默认调度器会首先调用一组叫作 Predicate
的调度算法,来检查每个 Node。然后,再调用一组叫作 Priority
的调度算法,来给上一步得到的结果里的每个 Node 打分。最终的调度结果,就是得分最高
的那个 Node
。
调度器对一个
Pod
调度成功
,实际上就是将它的spec.nodeName
字段填上调度结果的节点名字。
在 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
里直接拿到的。
接下来,调度器就会再调用 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 端的二次确认
。
无锁化
除了上述的“Cache 化”
和“乐观绑定”
,Kubernetes 默认调度器还有一个重要的设计,那就是“无锁化”
。
在 Scheduling Path
上,调度器会启动多个 Goroutine
以节点为粒度并发执行 Predicates
算法,从而提高这一阶段的执行效率。而与之类似的,Priorities
算法也会以 MapReduce
的方式并行计算然后再进行汇总。而在这些所有需要并发的路径上,调度器会避免设置任何全局的竞争资源,从而免去了使用锁进行同步带来的巨大的性能损耗。
所以,在这种思想的指导下,如果你再去查看一下前面的调度器原理图,你就会发现,Kubernetes 调度器只有对调度队列和 Scheduler Cache
进行操作时,才需要加锁
。而这两部分操作,都不在 Scheduling Path
的算法执行路径上。
Kubernetes 调度器的上述设计思想,也是在集群规模不断增长的演进过程中逐步实现的。尤其是
“Cache 化”
,这个变化其实是 Kubernetes 调度器性能得以提升的一个关键演化。
调度器的扩展机制
随着 Kubernetes 项目逐步趋于稳定,越来越多的用户开始把 Kubernetes 用在规模更大、业务更加复杂的私有集群当中。很多以前的 Mesos
用户,也开始尝试使用 Kubernetes 来替代其原有架构。在这些场景下,对默认调度器进行扩展
和重新实现
,就成了社区对 Kubernetes 项目最主要的一个诉求。
Kubernetes 默认调度器的可扩展性设计,可以用如下所示的一幅示意图来描述:
可以看到,默认调度器的可扩展机制,在 Kubernetes 里面叫作 Scheduler Framework
。这个设计的主要目的,就是在调度器生命周期的各个关键点上,为用户暴露出可以进行扩展和实现的接口,从而实现由用户自定义调度器的能力。
上图中,每一个绿色的箭头都是一个可以插入自定义逻辑
的接口。比如,上面的 Queue 部分,就意味着你可以在这一部分提供一个自己的调度队列的实现,从而控制每个 Pod 开始被调度(出队)的时机
。
而 Predicates
部分,则意味着你可以提供自己的过滤算法实现,根据自己的需求,来决定选择哪些机器。
需要注意的是,上述这些可插拔式逻辑,都是标准的 Go 语言插件机制(Go plugin 机制),也就是说,你需要在编译的时候选择把哪些插件编译进去。
有了上述设计之后,扩展和自定义 Kubernetes 的默认调度器
就变成了一件非常容易实现的事情。这也意味着默认调度器在后面的发展过程中,必然不会在现在的实现上再添加太多的功能,反而还会对现在的实现进行精简
,最终成为 Scheduler Framework
的一个最小实现。而调度领域更多的创新和工程工作,就可以交给整个社区来完成了。
默认调度器调度策略解析
Predicates
Predicates
在调度过程中的作用,可以理解为 Filter
,即:它按照调度策略,从当前集群的所有节点中,“过滤”出一系列符合条件的节点。这些节点,都是可以运行待调度 Pod 的宿主机。
而在 Kubernetes 中,默认的调度策略有如下四种。
第一种类型,叫作 GeneralPredicates。
第二种类型,是与 Volume 相关的过滤规则。
第三种类型,是宿主机相关的过滤规则。
第四种类型,是 Pod 相关的过滤规则。
在具体执行的时候, 当开始调度一个 Pod 时,Kubernetes 调度器会同时启动 16 个 Goroutine,来并发地为集群里的所有 Node 计算 Predicates,最后返回可以运行这个 Pod 的宿主机列表。
Priorities
在 Predicates
阶段完成了节点的“过滤”之后,Priorities
阶段的工作就是为这些节点打分。这里打分的范围是 0-10 分,得分最高的节点就是最后被 Pod 绑定的最佳节点。
Priorities 里最常用到的一个打分规则,是 LeastRequestedPriority
。它的计算方法,可以简单地总结为如下所示的公式:
score = (cpu((capacity-sum(requested))10/capacity) + memory((capacity-sum(requested))10/capacity))/2
可以看到,这个算法实际上就是在选择空闲资源(CPU 和 Memory)最多的宿主机。而与 LeastRequestedPriority
一起发挥作用的,还有BalancedResourceAllocation
。它的计算公式如下所示:
score = 10 - variance(cpuFraction,memoryFraction,volumeFraction)*10
此外,还有 NodeAffinityPriority
、TaintTolerationPriority
和 InterPodAffinityPriority
这三种 Priority。顾名思义,它们与前面的 PodMatchNodeSelector
、PodToleratesNodeTaints
和 PodAffinityPredicate
这三个 Predicate 的含义和计算方法是类似的。但是作为 Priority,一个 Node 满足上述规则的字段数目越多,它的得分就会越高。
在默认 Priorities
里,还有一个叫作 ImageLocalityPriority
的策略。即:如果待调度 Pod 需要使用的镜像很大,并且已经存在于某些 Node 上,那么这些 Node 的得分就会比较高。
在实际的执行过程中,调度器里关于集群和 Pod 的信息都已经缓存化,所以这些算法的执行过程还是比较快的。
优先级与抢占机制
首先需要明确的是,优先级和抢占机制,解决的是 Pod 调度失败时该怎么办的问题。
正常情况下,当一个 Pod
调度失败后,它就会被暂时“搁置”
起来,直到 Pod 被更新,或者集群状态发生变化,调度器才会对这个 Pod 进行重新调度。
但在有时候,我们希望的是这样一个场景。当一个高优先级的 Pod
调度失败后,该 Pod
并不会被“搁置”
,而是会“挤走”
某个 Node 上的一些低优先级的 Pod 。这样就可以保证这个高优先级 Pod 的调度成功。这个特性,其实也是一直以来就存在于 Borg
以及 Mesos
等项目里的一个基本功能。
要使用这个机制,你首先需要在 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."
上面这个 YAML
文件,定义的是一个名叫 high-priority
的 PriorityClass
,其中 value
的值是 1000000
(一百万)。
Kubernetes 规定,优先级是一个 32 bit 的整数,最大值不超过 1000000000(10 亿,1 billion),并且值越大代表优先级越高。而超出 10 亿的值,其实是被 Kubernetes 保留下来分配给系统 Pod 使用的。显然,这样做的目的,就是保证系统 Pod 不会被用户抢占掉。
而一旦上述 YAML
文件里的 globalDefault
被设置为 true
的话,那就意味着这个 PriorityClass
的值会成为系统的默认值。而如果这个值是 false,就表示我们只希望声明使用该 PriorityClass
的 Pod
拥有值为 1000000
的优先级,而对于没有声明 PriorityClass
的 Pod 来说,它们的优先级就是 0。
在创建了 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 里的主要体现。
主要流程
我们接下来会把待调度
的高优先级 Pod 称为“抢占者”
(Preemptor)。
当抢占过程发生时,抢占者
并不会立刻被调度到被抢占的 Node 上。事实上,调度器只会将抢占者的 spec.nominatedNodeName
字段,设置为被抢占的 Node
的名字。然后,抢占者会重新进入下一个调度周期,然后在新的调度周期里来决定是不是要运行在被抢占的节点上。这当然也就意味着,即使在下一个调度周期,调度器也不会保证
抢占者一定会运行在被抢占的节点上
。
这样设计的一个重要原因是,调度器
只会通过标准的 DELETE API
来删除被抢占的 Pod,所以,这些 Pod 必然是有一定的“优雅退出”
时间(默认是 30s
)的。而在这段时间里,其他的节点也是有可能变成可调度
的,或者直接有新的节点被添加到这个集群中来。所以,鉴于优雅退出期间,集群的可调度性可能会发生的变化,把抢占者交给下一个调度周期再处理,是一个非常合理的选择。
而在抢占者等待被调度的过程中,如果有其他更高优先级的 Pod 也要抢占同一个节点,那么调度器就会清空
原抢占者的 spec.nominatedNodeName
字段,从而允许更高优先级的抢占者执行抢占,并且,这也就使得原抢占者本身,也有机会去重新抢占其他节点。这些,都是设置 nominatedNodeName
字段的主要目的。
设计实现
前面已经提到过,抢占
发生的原因,一定是一个高优先级的 Pod
调度失败。这一次,我们还是称这个 Pod 为“抢占者”
,称被抢占的 Pod 为“牺牲者”
(victims)。
而 Kubernetes
调度器实现抢占算法的一个最重要的设计,就是在调度队列的实现里,使用了两个不同的队列。
1,第一个队列,叫作 activeQ
。凡是在 activeQ
里的 Pod,都是下一个调度周期需要调度的对象。所以,当你在 Kubernetes 集群里新创建一个 Pod 的时候,调度器会将这个 Pod
入队到 activeQ
里面。而我在前面提到过的、调度器不断从队列里出队(Pop)一个 Pod 进行调度,实际上都是从 activeQ
里出队的。
2,第二个队列,叫作 unschedulableQ
,专门用来存放调度失败
的 Pod
。
而这里的一个关键点就在于,当一个 unschedulableQ
里的 Pod 被更新之后,调度器会自动把这个 Pod 移动到 activeQ
里,从而给这些调度失败的 Pod “重新做人”
的机会。
我们从 抢占者
调度失败这个时间点上来看。
调度失败
之后,抢占者就会被放进unschedulableQ
里面。然后,这次失败事件就会触发调度器为抢占者寻找牺牲者
的流程。
1,第一步,调度器会检查这次失败事件的原因,来确认抢占是不是可以帮助抢占者找到一个新节点。这是因为有很多 Predicates
的失败是不能通过抢占来解决的。比如,PodFitsHost
算法(负责的是,检查 Pod 的 nodeSelector 与 Node 的名字是否匹配),这种情况下,除非 Node 的名字发生变化,否则你即使删除再多的 Pod,抢占者也不可能调度成功。
2,第二步,如果确定抢占可以发生,那么调度器就会把自己缓存的所有节点信息复制一份
,然后使用这个副本
来模拟抢占过程。
这里的抢占过程很容易理解。调度器会检查缓存副本
里的每一个节点,然后从该节点上最低优先级的 Pod 开始,逐一“删除”这些 Pod。而每删除一个低优先级 Pod,调度器都会检查一下抢占者是否能够运行在该 Node 上。一旦可以运行,调度器就记录下这个 Node
的名字和被删除 Pod
的列表,这就是一次抢占过程的结果了。
当遍历完
所有的节点之后,调度器会在上述模拟产生的所有抢占结果里做一个选择,找出最佳结果
。而这一步的判断原则,就是尽量减少抢占对整个系统的影响。比如,需要抢占的 Pod 越少越好,需要抢占的 Pod 的优先级越低越好,等等。
在得到了最佳的
抢占结果
之后,这个结果里的 Node,就是即将被抢占的 Node;被删除的 Pod 列表,就是牺牲者
。所以接下来,调度器就可以真正开始抢占的操作了,这个过程,可以分为三步
。
1,调度器会检查牺牲者
列表,清理这些 Pod 所携带的nominatedNodeName
字段。
2,调度器会把抢占者的 nominatedNodeName
,设置为被抢占的 Node 的名字。
3,调度器会开启一个 Goroutine
,同步地删除牺牲者。
对待调度Pod的影响
对于任意一个待调度 Pod
来说,因为有上述抢占者
的存在,它的调度过程,其实是有一些特殊情况需要特殊处理的。
具体来说,在为某一对 Pod
和 Node
执行 Predicates
算法的时候,如果待检查的 Node 是一个即将被抢占
的节点,即:调度队列里有 nominatedNodeName
字段值是该 Node
名字的 Pod
存在(可以称之为:“潜在的抢占者”)。那么,调度器就会对这个 Node ,将同样的 Predicates
算法运行两遍。
第一遍, 调度器会假设上述“潜在的抢占者”
已经运行在这个节点上,然后执行 Predicates
算法;
第二遍, 调度器会正常执行 Predicates
算法,即:不考虑任何“潜在的抢占者”
。
而只有这两遍 Predicates
算法都能通过时,这个 Pod 和 Node 才会被认为是可以绑定(bind
)的。
这里需要执行第一遍 Predicates
算法的原因,是由于 InterPodAntiAffinity
规则的存在。
由于 InterPodAntiAffinity
规则关心待考察节点上所有 Pod 之间的互斥关系,所以我们在执行调度算法时必须考虑,如果抢占者已经存在于待考察 Node 上时,待调度 Pod 还能不能调度成功。
当然,这也就意味着,我们在这一步只需要考虑那些优先级等于或者大于待调度 Pod 的抢占者。毕竟对于其他较低优先级 Pod 来说,待调度 Pod 总是可以通过抢占运行在待考察 Node 上。
而我们需要执行第二遍
Predicates
算法的原因,则是因为“潜在的抢占者”
最后不一定会运行在待考察的 Node 上。