K8S是分布式系统里面的操作系统,Pod更像是操作系统里面的进程组,如此以来当一个Pod想要运行的时候,就必须要依赖于K8S的调度策略来完成这些Pod的调度。
本篇文章就是来整理和介绍k8s的调度这些事,文章从下面几个方面来进行整理:
一、k8s的资源调度策略
操作系统中对于一个进程来说,如果希望运行必须需要cpu和存储才行,同样的道理一个pod想要运行,也必须有这两部分才行,于是k8s把pod运行所需要的资源划分成了两大类:可压缩资源和不可压缩资源。
1. k8s的资源模型:
可压缩资源:指的是cpu这一类资源,这类资源的特点是,在资源不够的时候,只会导致pod等运行的时间越来越久也就是会导致“饥饿”,并不会退出。
不可压缩资源:指的是mem这一类,一旦资源不足,就会被内核杀死,并强制pod退出。
为了描述这些资源信息,k8s将这部分资源与pod绑定,又因为k8s里面一个pod是由多个容器组成的,所以pod里面的资源就是容器资源的总和,其中两个比较重要的指标CPU和Memory。
CPU 属于可压缩资源:K8S里面描述CPU的单位是millicpu,例如:500m,指的就是 500 millicpu,也就是 0.5 个 CPU 的意思。
Memory属于不可压缩资源:K8S里面使用这些Ei、Pi、Ti、Gi、Mi、Ki(或者 E、P、T、G、M、K)的方式来作为 bytes 的值,其中带i结尾的是2的幂次方,例如:1Mi=1024*1024;1M=1000*1000。
k8s将这些资源划分成预期和限制两种方式来描述,如下所示:
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"
在调度的时候,kube-scheduler 只会按照 requests 的值进行计算,表示的是分配的资源大小。而在真正设置 Cgroups 限制的时候,kubelet 则会按照 limits 的值来进行设置,表示的使用资源的大小。
2.k8s的Qos模型
至于为什么k8s如此设计,原因如下所示: Kubernetes 这种对 CPU 和内存资源限额的设计,实际上参考了 Borg 论文中对“动态资源边界”的定义:容器化作业在提交时所设置的资源边界,并不一定是调度系统所必须严格遵守的,这是因为在实际场景中,大多数作业使用到的资源其实远小于它所请求的资源限额。
正是基于k8s的这个资源模型设计理念,k8s又设计了下面几种Qos 模型,用来解决资源不足的情况,k8s到底回收哪一些pod的一个标准。
1)Guaranteed 类别:当 Pod 里的每一个 Container 都同时设置了 requests 和 limits,并且 requests 和 limits 值相等的时候,这个 Pod 就属于 Guaranteed 类别。
2)Burstable 类别:当 Pod 不满足 Guaranteed 的条件,但至少有一个 Container 设置了 requests,那么这个 Pod 就会被划分到 Burstable 类别。
3)BestEffort类别:如果一个 Pod 既没有设置 requests,也没有设置 limits,那么它的 QoS 类别就是 BestEffort。
当宿主机的 Eviction 阈值达到后,就会进入 MemoryPressure 或者 DiskPressure 状态,从而避免新的 Pod 被调度到这台宿主机上。当 Eviction 发生的时候,kubelet 具体会挑选哪些 Pod 进行删除操作,就需要参考这些 Pod 的 QoS 类别了。
首当其冲的,自然是 BestEffort 类别的 Pod。
其次,是属于 Burstable 类别、并且发生“饥饿”的资源使用量已经超出了 requests 的 Pod。
最后,才是 Guaranteed 类别。并且,Kubernetes 会保证只有当 Guaranteed 类别的 Pod 的资源使用量超过了其 limits 的限制,或者宿主机本身正处于 Memory Pressure 状态时,Guaranteed 的 Pod 才可能被选中进行 Eviction 操作。
补充说明:对于同 QoS 类别的 Pod 来说,Kubernetes 还会根据 Pod 的优先级来进行进一步排序和选择。
二、K8S的调度过程
在 Kubernetes 项目中,默认调度器的主要职责,就是为一个新创建出来的 Pod,寻找一个最合适的节点(Node)。主要有两步操作:
1、从集群所有的节点中,根据调度算法挑选出所有可以运行该 Pod 的节点;
2、从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。
调度流程如下所示:
1.默认调度器会首先调用一组叫作 Predicate 的调度算法,来检查每个 Node。
2.再调用一组叫作 Priority 的调度算法,来给上一步得到的结果里的每个 Node 打分,最终的调度结果,就是得分最高的那个 Node。
3.调度器对一个 Pod 调度成功,实际上就是将它的 spec.nodeName 字段填上调度结果的节点名字。
1.调度机制的工作原理
K8S 调度器的核心,实际上就是两个相互独立的控制循环。调度机制的工作原理,如下所示:
第一个控制循环,我们可以称之为 Informer Path。
1.启动一系列 Informer,用来监听(Watch)Etcd 中 Pod、Node、Service 等与调度相关的 API 对象的变化。
2.当一个待调度 Pod(即:它的 nodeName 字段是空的)被创建出来之后,调度器就会通过 Pod Informer 的 Handler,将这个待调度 Pod 添加进调度队列。
同时Kubernetes 的默认调度器还要负责对调度器缓存(即:scheduler cache)进行更新。
第二个控制循环,是调度器负责 Pod 调度的主循环,我们可以称之为 Scheduling Path。
3.不断地从调度队列里出队一个 Pod。
4.调用 Predicates 算法进行“过滤”。这一步“过滤”得到的一组 Node,就是所有可以运行这个 Pod 的宿主机列表。
(备注:Predicates 算法需要的 Node 信息,都是从 Scheduler Cache 里直接拿到的。)。
5.调度器再调用 Priorities 算法为上述列表里的 Node 打分,分数从 0 到 10。得分最高的 Node,就会作为这次调度的结果。
6.调度算法执行完成后,进行Bind操作:调度器将 Pod 对象的 nodeName 字段的值,修改为上述 Node 的名字。但是,为了不在关键调度路径里远程访问 APIServer,Kubernetes 的默认调度器在 Bind 阶段,只会更新 Scheduler Cache 里的 Pod 和 Node 的信息,这叫做Assume。
7.Assume 之后,调度器才会创建一个 Goroutine 来异步地向 APIServer 发起更新 Pod 的请求,来真正完成 Bind 操作。
2.调度算法
1)Predicates 调度算法:
Predicates 在调度过程中的作用,可以理解为 Filter,即:它按照调度策略,从当前集群的所有节点中,“过滤”出一系列符合条件的节点。k8s里面主要有下面几种调度策略: 第一种, GeneralPredicates:
这一组过滤规则,负责的是最基础的调度策略,例如:PodFitsResources 计算的就是宿主机的 CPU 和内存资源等是否够用。
PodFitsHost 检查的是,宿主机的名字是否跟 Pod 的 spec.nodeName 一致。PodFitsHostPorts 检查的是,Pod 申请的宿主机端口(spec.nodePort)是不是跟已经被使用的端口有冲突。
PodMatchNodeSelector 检查的是,Pod 的 nodeSelector 或者 nodeAffinity 指定的节点,是否与待考察节点匹配,等等。
第二种, Volume 相关的过滤规则: 这一组过滤规则,负责的是跟容器持久化 Volume 相关的调度策略。
NoDiskConflict 检查的条件,是多个 Pod 声明挂载的持久化 Volume 是否有冲突,冲突的话就不能调度到这个节点了。
MaxPDVolumeCountPredicate 检查的条件,则是一个节点上某种类型的持久化 Volume 是不是已经超过了一定数目,超过了就不能调度到这个节点了。
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)关系。
执行阶段: 开始调度一个 Pod 时,Kubernetes 调度器会同时启动 16 个 Goroutine,来并发地为集群里的所有 Node 计算 Predicates,最后返回可以运行这个 Pod 的宿主机列表。
2)Priorities调度算法:
Priorities 阶段的工作就是为Predicates阶段选出来的这些节点按照 0-10 分进行打分,得分最高的节点就是最后被 Pod 绑定的最佳节点。常用的几个打分规则如下所示: LeastRequestedPriority:选择空闲资源(CPU 和 Memory)最多的宿主机。
BalancedResourceAllocation:选择的是调度完成后,所有节点里各种资源分配最均衡的那个节点,从而避免一个节点上 CPU 被大量分配、而 Memory 大量剩余的情况。
ImageLocalityPriority :Kubernetes v1.12 里新开启的调度规则,即:如果待调度 Pod 需要使用的镜像很大,并且已经存在于某些 Node 上,那么这些 Node 的得分就会比较高。
NodeAffinityPriority、TaintTolerationPriority 和 InterPodAffinityPriority 这三种 Priority,一个 Node 满足上述规则的字段数目越多,它的得分就会越高。
三、K8S的优先级调度策略
调度并不都是成功的,一旦Pod 调度失败时应该怎么办呢?
k8s模仿操作系统,通过优先级和抢占机制来解决这个难题。
1.优先级:
Kubernetes 规定,优先级是一个 32 bit 的整数,最大值不超过 1000000000(10 亿,1 billion),并且值越大代表优先级越高。超出 10 亿的值是被 Kubernetes 保留下来分配给系统 Pod 使用的,这样做的目的,就是保证系统 Pod 不会被用户抢占掉。
优先级的实现是通过一个优先级队列,当调度的时候,优先级高的pod会先被pop出来。
2. 抢占:
一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发。这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被删除后,待调度的高优先级 Pod 就可以被调度到这个节点上。
抢占过程: 1)调度器只会将抢占者的 spec.nominatedNodeName 字段,设置为被抢占的 Node 的名字,抢占者并不会立刻被调度到被抢占的 Node 上。
2)抢占者会重新进入下一个调度周期,然后在新的调度周期里来决定是不是要运行在被抢占的节点上。
备注:这意味着,即使在下一个调度周期,调度器也不会保证抢占者一定会运行在被抢占的节点上。
3.抢占机制的设计简介:
1)抢占发生后,将抢占者存到unschedulableQ队列
2)失败事件触发调度器为抢占者寻找牺牲者的流程
第一步,调度器会检查这次失败事件的原因,来确认抢占是不是可以帮助抢占者找到一个新节点,这是因为有很多 Predicates 的失败是不能通过抢占来解决的。
在得到了最佳的抢占结果之后,这个结果里的 Node,就是即将被抢占的 Node,被删除的 Pod 列表,就是牺牲者。
3)抢占操作
第一步,调度器会检查牺牲者列表,清理这些 Pod 所携带的 nominatedNodeName 字段。
第二步,调度器会把抢占者的 nominatedNodeName,设置为被抢占的 Node 的名字。// 让抢占者在下一个调度周期重新进入调度流程。
第三步,调度器会开启一个 Goroutine,同步地删除牺牲者。
4)抢占成功:调度器就会通过正常的调度流程把抢占者调度成功。
参看资料:
极客时间:深入剖析 Kubernetes
K8S权威指南第5版