mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 14:43:42 +08:00
del
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
<audio id="audio" title="40 | Kubernetes的资源模型与资源管理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/b4/14e542e8b91de60f2e4a7e9b3572eeb4.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:Kubernetes的资源模型与资源管理。
|
||||
|
||||
作为一个容器集群编排与管理项目,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"
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
备注:关于哪些属性属于Pod对象,而哪些属性属于Container,你可以在回顾一下第14篇文章[《深入解析Pod对象(一):基本概念》](https://time.geekbang.org/column/article/40366)中的相关内容。
|
||||
|
||||
|
||||
在Kubernetes中,像CPU这样的资源被称作“可压缩资源”(compressible resources)。它的典型特点是,当可压缩资源不足时,Pod只会“饥饿”,但不会退出。
|
||||
|
||||
而像内存这样的资源,则被称作“不可压缩资源(incompressible resources)。当不可压缩资源不足时,Pod就会因为OOM(Out-Of-Memory)被内核杀掉。
|
||||
|
||||
而由于Pod可以由多个Container组成,所以CPU和内存资源的限额,是要配置在每个Container的定义上的。这样,Pod整体的资源配置,就由这些Container的配置值累加得到。
|
||||
|
||||
其中,Kubernetes里为CPU设置的单位是“CPU的个数”。比如,cpu=1指的就是,这个Pod的CPU限额是1个CPU。当然,具体“1个CPU”在宿主机上如何解释,是1个CPU核心,还是1个vCPU,还是1个CPU的超线程(Hyperthread),完全取决于宿主机的CPU实现方式。Kubernetes只负责保证Pod能够使用到“1个CPU”的计算能力。
|
||||
|
||||
此外,Kubernetes允许你将CPU限额设置为分数,比如在我们的例子里,CPU limits的值就是500m。所谓500m,指的就是500 millicpu,也就是0.5个CPU的意思。这样,这个Pod就会被分配到1个CPU一半的计算能力。
|
||||
|
||||
当然,**你也可以直接把这个配置写成cpu=0.5。但在实际使用时,我还是推荐你使用500m的写法,毕竟这才是Kubernetes内部通用的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=1024*1024;1M=1000*1000
|
||||
|
||||
|
||||
此外,不难看到,**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时间的按比例分配。
|
||||
|
||||
而如果你指定了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论文中对“动态资源边界”的定义**,既:容器化作业在提交时所设置的资源边界,并不一定是调度系统所必须严格遵守的,这是因为在实际场景中,大多数作业使用到的资源其实远小于它所请求的资源限额。
|
||||
|
||||
基于这种假设,Borg在作业被提交后,会主动减小它的资源限额配置,以便容纳更多的作业、提升资源利用率。而当作业资源使用量增加到一定阈值时,Borg会通过“快速恢复”过程,还原作业原始的资源限额,防止出现异常情况。
|
||||
|
||||
而Kubernetes的requests+limits的做法,其实就是上述思路的一个简化版:用户在提交Pod时,可以声明一个相对较小的requests值供调度器使用,而Kubernetes真正设置给容器Cgroups的,则是相对较大的limits值。不难看到,这跟Borg的思路相通的。
|
||||
|
||||
在理解了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
|
||||
|
||||
```
|
||||
|
||||
那么,Kubernetes为Pod设置这样三种QoS类别,具体有什么作用呢?
|
||||
|
||||
实际上,**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允许你为Eviction过程设置一段“优雅时间”,比如上面例子里的imagefs.available=2m,就意味着当imagefs不足的阈值达到2分钟之后,kubelet才会开始Eviction的过程。
|
||||
|
||||
而Hard Eviction模式下,Eviction过程就会在阈值达到之后立刻开始。
|
||||
|
||||
>
|
||||
Kubernetes计算Eviction阈值的数据来源,主要依赖于从Cgroups读取到的值,以及使用cAdvisor监控到的数据。
|
||||
|
||||
|
||||
当宿主机的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的优先级来进行进一步地排序和选择。
|
||||
|
||||
在理解了Kubernetes里的QoS类别的设计之后,我再来为你讲解一下Kubernetes里一个非常有用的特性:cpuset的设置。
|
||||
|
||||
我们知道,在使用容器的时候,你可以通过设置cpuset把容器绑定到某个CPU的核上,而不是像cpushare那样共享CPU的计算能力。
|
||||
|
||||
这种情况下,由于操作系统在CPU之间进行上下文切换的次数大大减少,容器里应用的性能会得到大幅提升。事实上,**cpuset方式,是生产环境里部署在线应用类型的Pod时,非常常用的一种方式。**
|
||||
|
||||
可是,这样的需求在Kubernetes里又该如何实现呢?
|
||||
|
||||
其实非常简单。
|
||||
|
||||
- 首先,你的Pod必须是Guaranteed的QoS类型;
|
||||
- 然后,你只需要将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的资源模型和QoS类别相关的主要内容。
|
||||
|
||||
## 总结
|
||||
|
||||
在本篇文章中,我先为你详细讲解了Kubernetes里对资源的定义方式和资源模型的设计。然后,我为你讲述了Kubernetes里对Pod进行Eviction的具体策略和实践方式。
|
||||
|
||||
正是基于上述讲述,在实际的使用中,我强烈建议你将DaemonSet的Pod都设置为Guaranteed的QoS类型。否则,一旦DaemonSet的Pod被回收,它又会立即在原宿主机上被重建出来,这就使得前面资源回收的动作,完全没有意义了。
|
||||
|
||||
## 思考题
|
||||
|
||||
为什么宿主机进入MemoryPressure或者DiskPressure状态后,新的Pod就不会被调度到这台宿主机上呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<audio id="audio" title="41 | 十字路口上的Kubernetes默认调度器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/f5/481afebfbbe867534f6b7d1726fcf6f5.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:十字路口上的Kubernetes默认调度器。
|
||||
|
||||
在上一篇文章中,我主要为你介绍了Kubernetes里关于资源模型和资源管理的设计方法。而在今天这篇文章中,我就来为你介绍一下Kubernetes的默认调度器(default scheduler)。
|
||||
|
||||
**在Kubernetes项目中,默认调度器的主要职责,就是为一个新创建出来的Pod,寻找一个最合适的节点(Node)。**
|
||||
|
||||
而这里“最合适”的含义,包括两层:
|
||||
|
||||
<li>
|
||||
从集群所有的节点中,根据调度算法挑选出所有可以运行该Pod的节点;
|
||||
</li>
|
||||
<li>
|
||||
从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。
|
||||
</li>
|
||||
|
||||
所以在具体的调度流程中,默认调度器会首先调用一组叫作Predicate的调度算法,来检查每个Node。然后,再调用一组叫作Priority的调度算法,来给上一步得到的结果里的每个Node打分。最终的调度结果,就是得分最高的那个Node。
|
||||
|
||||
而我在前面的文章中曾经介绍过,调度器对一个Pod调度成功,实际上就是将它的spec.nodeName字段填上调度结果的节点名字。
|
||||
|
||||
>
|
||||
备注:这里你可以再回顾下第14篇文章[《深入解析Pod对象(一):基本概念》](https://time.geekbang.org/column/article/40366)中的相关内容。
|
||||
|
||||
|
||||
在Kubernetes中,上述调度机制的工作原理,可以用如下所示的一幅示意图来表示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/53/bb95a7d4962c95d703f7c69caf53ca53.jpg" alt="">
|
||||
|
||||
可以看到,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 端的二次确认。
|
||||
|
||||
>
|
||||
备注:关于Kubernetes默认调度器的调度算法,我会在下一篇文章里为你讲解。
|
||||
|
||||
|
||||
**除了上述的“Cache化”和“乐观绑定”,Kubernetes默认调度器还有一个重要的设计,那就是“无锁化”。**
|
||||
|
||||
在Scheduling Path上,调度器会启动多个Goroutine以节点为粒度并发执行Predicates算法,从而提高这一阶段的执行效率。而与之类似的,Priorities算法也会以MapReduce的方式并行计算然后再进行汇总。而在这些所有需要并发的路径上,调度器会避免设置任何全局的竞争资源,从而免去了使用锁进行同步带来的巨大的性能损耗。
|
||||
|
||||
所以,在这种思想的指导下,如果你再去查看一下前面的调度器原理图,你就会发现,Kubernetes调度器只有对调度队列和Scheduler Cache进行操作时,才需要加锁。而这两部分操作,都不在Scheduling Path的算法执行路径上。
|
||||
|
||||
当然,Kubernetes调度器的上述设计思想,也是在集群规模不断增长的演进过程中逐步实现的。尤其是 **“Cache化”,这个变化其实是最近几年Kubernetes调度器性能得以提升的一个关键演化。**
|
||||
|
||||
不过,随着Kubernetes项目发展到今天,它的默认调度器也已经来到了一个关键的十字路口。事实上,Kubernetes现今发展的主旋律,是整个开源项目的“民主化”。也就是说,Kubernetes下一步发展的方向,是组件的轻量化、接口化和插件化。所以,我们才有了CRI、CNI、CSI、CRD、Aggregated APIServer、Initializer、Device Plugin等各个层级的可扩展能力。可是,默认调度器,却成了Kubernetes项目里最后一个没有对外暴露出良好定义过的、可扩展接口的组件。
|
||||
|
||||
当然,这是有一定的历史原因的。在过去几年,Kubernetes发展的重点,都是以功能性需求的实现和完善为核心。在这个过程中,它的很多决策,还是以优先服务公有云的需求为主,而性能和规模则居于相对次要的位置。
|
||||
|
||||
而现在,随着Kubernetes项目逐步趋于稳定,越来越多的用户开始把Kubernetes用在规模更大、业务更加复杂的私有集群当中。很多以前的Mesos用户,也开始尝试使用Kubernetes来替代其原有架构。在这些场景下,对默认调度器进行扩展和重新实现,就成了社区对Kubernetes项目最主要的一个诉求。
|
||||
|
||||
所以,Kubernetes的默认调度器,是目前这个项目里为数不多的、正在经历大量重构的核心组件之一。这些正在进行的重构的目的,一方面是将默认调度器里大量的“技术债”清理干净;另一方面,就是为默认调度器的可扩展性设计进行铺垫。
|
||||
|
||||
而Kubernetes默认调度器的可扩展性设计,可以用如下所示的一幅示意图来描述:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/cd/fd17097799fe17fcbc625bf178496acd.jpg" alt="">
|
||||
|
||||
可以看到,默认调度器的可扩展机制,在Kubernetes里面叫作Scheduler Framework。顾名思义,这个设计的主要目的,就是在调度器生命周期的各个关键点上,为用户暴露出可以进行扩展和实现的接口,从而实现由用户自定义调度器的能力。
|
||||
|
||||
上图中,每一个绿色的箭头都是一个可以插入自定义逻辑的接口。比如,上面的Queue部分,就意味着你可以在这一部分提供一个自己的调度队列的实现,从而控制每个Pod开始被调度(出队)的时机。
|
||||
|
||||
而Predicates部分,则意味着你可以提供自己的过滤算法实现,根据自己的需求,来决定选择哪些机器。
|
||||
|
||||
**需要注意的是,上述这些可插拔式逻辑,都是标准的Go语言插件机制(Go plugin 机制)**,也就是说,你需要在编译的时候选择把哪些插件编译进去。
|
||||
|
||||
有了上述设计之后,扩展和自定义Kubernetes的默认调度器就变成了一件非常容易实现的事情。这也意味着默认调度器在后面的发展过程中,必然不会在现在的实现上再添加太多的功能,反而还会对现在的实现进行精简,最终成为Scheduler Framework的一个最小实现。而调度领域更多的创新和工程工作,就可以交给整个社区来完成了。这个思路,是完全符合我在前面提到的Kubernetes的“民主化”设计的。
|
||||
|
||||
不过,这样的Scheduler Framework也有一个不小的问题,那就是一旦这些插入点的接口设计不合理,就会导致整个生态没办法很好地把这个插件机制使用起来。而与此同时,这些接口本身的变更又是一个费时费力的过程,一旦把控不好,就很可能会把社区推向另一个极端,即:Scheduler Framework没法实际落地,大家只好都再次fork kube-scheduler。
|
||||
|
||||
## 总结
|
||||
|
||||
在本篇文章中,我为你详细讲解了Kubernetes里默认调度器的设计与实现,分析了它现在正在经历的重构,以及未来的走向。
|
||||
|
||||
不难看到,在 Kubernetes 的整体架构中,kube-scheduler 的责任虽然重大,但其实它却是在社区里最少受到关注的组件之一。这里的原因也很简单,调度这个事情,在不同的公司和团队里的实际需求一定是大相径庭的,上游社区不可能提供一个大而全的方案出来。所以,将默认调度器进一步做轻做薄,并且插件化,才是 kube-scheduler 正确的演进方向。
|
||||
|
||||
## 思考题
|
||||
|
||||
请问,Kubernetes默认调度器与Mesos的“两级”调度器,有什么异同呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -0,0 +1,215 @@
|
||||
<audio id="audio" title="42 | Kubernetes默认调度器调度策略解析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/2e/ad1b622967c61a0e2bc552adbca5112e.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:Kubernetes默认调度器调度策略解析。
|
||||
|
||||
在上一篇文章中,我主要为你讲解了Kubernetes默认调度器的设计原理和架构。在今天这篇文章中,我们就专注在调度过程中Predicates和Priorities这两个调度策略主要发生作用的阶段。
|
||||
|
||||
首先,我们一起看看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
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们这个 Pod 通过`alpha.kubernetes.io/nvidia-gpu=2`这样的定义方式,声明使用了两个 NVIDIA 类型的 GPU。
|
||||
|
||||
而在PodFitsResources里面,调度器其实并不知道这个字段 Key 的含义是 GPU,而是直接使用后面的 Value 进行计算。当然,在 Node 的Capacity字段里,你也得相应地加上这台宿主机上 GPU的总数,比如:`alpha.kubernetes.io/nvidia-gpu=4`。这些流程,我在后面讲解 Device Plugin 的时候会详细介绍。
|
||||
|
||||
而PodFitsHost检查的是,宿主机的名字是否跟Pod的spec.nodeName一致。
|
||||
|
||||
PodFitsHostPorts检查的是,Pod申请的宿主机端口(spec.nodePort)是不是跟已经被使用的端口有冲突。
|
||||
|
||||
PodMatchNodeSelector检查的是,Pod的nodeSelector或者nodeAffinity指定的节点,是否与待考察节点匹配,等等。
|
||||
|
||||
可以看到,像上面这样一组GeneralPredicates,正是Kubernetes考察一个Pod能不能运行在一个Node上最基本的过滤条件。所以,GeneralPredicates也会被其他组件(比如kubelet)直接调用。
|
||||
|
||||
我在上一篇文章中已经提到过,kubelet在启动Pod前,会执行一个Admit操作来进行二次确认。这里二次确认的规则,就是执行一遍GeneralPredicates。
|
||||
|
||||
**第二种类型,是与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字段,是否跟某个节点的标签相匹配。
|
||||
|
||||
在前面的第29篇文章[《PV、PVC体系是不是多此一举?从本地持久化卷谈起》](https://time.geekbang.org/column/article/42819)中,我曾经为你讲解过,Local Persistent Volume(本地持久化卷),必须使用nodeAffinity来跟某个具体的节点绑定。这其实也就意味着,在Predicates阶段,Kubernetes就必须能够根据Pod的Volume属性来进行调度。
|
||||
|
||||
此外,如果该Pod的PVC还没有跟具体的PV绑定的话,调度器还要负责检查所有待绑定PV,当有可用的PV存在并且该PV的nodeAffinity与待考察节点一致时,这条规则才会返回“成功”。比如下面这个例子:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: example-local-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 500Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: local-storage
|
||||
local:
|
||||
path: /mnt/disks/vol1
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- my-node
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个 PV 对应的持久化目录,只会出现在名叫 my-node 的宿主机上。所以,任何一个通过 PVC 使用这个 PV 的 Pod,都必须被调度到 my-node 上才可以正常工作。VolumeBindingPredicate,正是调度器里完成这个决策的位置。
|
||||
|
||||
**第三种类型,是宿主机相关的过滤规则。**
|
||||
|
||||
这一组规则,主要考察待调度 Pod 是否满足 Node 本身的某些条件。
|
||||
|
||||
比如,PodToleratesNodeTaints,负责检查的就是我们前面经常用到的Node 的“污点”机制。只有当 Pod 的 Toleration 字段与 Node 的 Taint 字段能够匹配的时候,这个 Pod 才能被调度到该节点上。
|
||||
|
||||
>
|
||||
备注:这里,你也可以再回顾下第21篇文章[《容器化守护进程的意义:DaemonSet》](https://time.geekbang.org/column/article/41366)中的相关内容。
|
||||
|
||||
|
||||
而NodeMemoryPressurePredicate,检查的是当前节点的内存是不是已经不够充足,如果是的话,那么待调度 Pod 就不能被调度到该节点上。
|
||||
|
||||
**第四种类型,是 Pod 相关的过滤规则。**
|
||||
|
||||
这一组规则,跟 GeneralPredicates大多数是重合的。而比较特殊的,是PodAffinityPredicate。这个规则的作用,是检查待调度 Pod 与 Node 上的已有Pod 之间的亲密(affinity)和反亲密(anti-affinity)关系。比如下面这个例子:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: with-pod-antiaffinity
|
||||
spec:
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: security
|
||||
operator: In
|
||||
values:
|
||||
- S2
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- name: with-pod-affinity
|
||||
image: docker.io/ocpqe/hello-pod
|
||||
|
||||
```
|
||||
|
||||
这个例子里的podAntiAffinity规则,就指定了这个 Pod 不希望跟任何携带了 security=S2 标签的 Pod 存在于同一个 Node 上。需要注意的是,PodAffinityPredicate是有作用域的,比如上面这条规则,就仅对携带了Key 是`kubernetes.io/hostname`标签的 Node 有效。这正是topologyKey这个关键词的作用。
|
||||
|
||||
而与podAntiAffinity相反的,就是podAffinity,比如下面这个例子:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: with-pod-affinity
|
||||
spec:
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: security
|
||||
operator: In
|
||||
values:
|
||||
- S1
|
||||
topologyKey: failure-domain.beta.kubernetes.io/zone
|
||||
containers:
|
||||
- name: with-pod-affinity
|
||||
image: docker.io/ocpqe/hello-pod
|
||||
|
||||
```
|
||||
|
||||
这个例子里的 Pod,就只会被调度到已经有携带了 security=S1标签的 Pod 运行的 Node 上。而这条规则的作用域,则是所有携带 Key 是`failure-domain.beta.kubernetes.io/zone`标签的 Node。
|
||||
|
||||
此外,上面这两个例子里的requiredDuringSchedulingIgnoredDuringExecution字段的含义是:这条规则必须在Pod 调度时进行检查(requiredDuringScheduling);但是如果是已经在运行的Pod 发生变化,比如 Label 被修改,造成了该 Pod 不再适合运行在这个 Node 上的时候,Kubernetes 不会进行主动修正(IgnoredDuringExecution)。
|
||||
|
||||
上面这四种类型的Predicates,就构成了调度器确定一个 Node 可以运行待调度 Pod 的基本策略。
|
||||
|
||||
**在具体执行的时候, 当开始调度一个 Pod 时,Kubernetes 调度器会同时启动16个Goroutine,来并发地为集群里的所有Node 计算 Predicates,最后返回可以运行这个 Pod 的宿主机列表。**
|
||||
|
||||
需要注意的是,在为每个 Node 执行 Predicates 时,调度器会按照固定的顺序来进行检查。这个顺序,是按照 Predicates 本身的含义来确定的。比如,宿主机相关的Predicates 会被放在相对靠前的位置进行检查。要不然的话,在一台资源已经严重不足的宿主机上,上来就开始计算 PodAffinityPredicate,是没有实际意义的。
|
||||
|
||||
接下来,我们再来看一下 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
|
||||
|
||||
```
|
||||
|
||||
其中,每种资源的 Fraction 的定义是 :Pod 请求的资源/节点上的可用资源。而 variance 算法的作用,则是计算每两种资源 Fraction 之间的“距离”。而最后选择的,则是资源 Fraction 差距最小的节点。
|
||||
|
||||
所以说,BalancedResourceAllocation选择的,其实是调度完成后,所有节点里各种资源分配最均衡的那个节点,从而避免一个节点上 CPU 被大量分配、而 Memory 大量剩余的情况。
|
||||
|
||||
此外,还有NodeAffinityPriority、TaintTolerationPriority和InterPodAffinityPriority这三种 Priority。顾名思义,它们与前面的PodMatchNodeSelector、PodToleratesNodeTaints和 PodAffinityPredicate这三个 Predicate 的含义和计算方法是类似的。但是作为 Priority,一个 Node 满足上述规则的字段数目越多,它的得分就会越高。
|
||||
|
||||
在默认 Priorities 里,还有一个叫作ImageLocalityPriority的策略。它是在 Kubernetes v1.12里新开启的调度规则,即:如果待调度 Pod 需要使用的镜像很大,并且已经存在于某些 Node 上,那么这些Node 的得分就会比较高。
|
||||
|
||||
当然,为了避免这个算法引发调度堆叠,调度器在计算得分的时候还会根据镜像的分布进行优化,即:如果大镜像分布的节点数目很少,那么这些节点的权重就会被调低,从而“对冲”掉引起调度堆叠的风险。
|
||||
|
||||
以上,就是 Kubernetes 调度器的 Predicates 和 Priorities 里默认调度规则的主要工作原理了。
|
||||
|
||||
**在实际的执行过程中,调度器里关于集群和 Pod 的信息都已经缓存化,所以这些算法的执行过程还是比较快的。**
|
||||
|
||||
此外,对于比较复杂的调度算法来说,比如PodAffinityPredicate,它们在计算的时候不只关注待调度 Pod 和待考察 Node,还需要关注整个集群的信息,比如,遍历所有节点,读取它们的 Labels。这时候,Kubernetes 调度器会在为每个待调度 Pod 执行该调度算法之前,先将算法需要的集群信息初步计算一遍,然后缓存起来。这样,在真正执行该算法的时候,调度器只需要读取缓存信息进行计算即可,从而避免了为每个 Node 计算 Predicates 的时候反复获取和计算整个集群的信息。
|
||||
|
||||
## 总结
|
||||
|
||||
在本篇文章中,我为你讲述了 Kubernetes 默认调度器里的主要调度算法。
|
||||
|
||||
需要注意的是,除了本篇讲述的这些规则,Kubernetes 调度器里其实还有一些默认不会开启的策略。你可以通过为kube-scheduler 指定一个配置文件或者创建一个 ConfigMap ,来配置哪些规则需要开启、哪些规则需要关闭。并且,你可以通过为 Priorities 设置权重,来控制调度器的调度行为。
|
||||
|
||||
## 思考题
|
||||
|
||||
请问,如何能够让 Kubernetes 的调度器尽可能地将 Pod 分布在不同机器上,避免“堆叠”呢?请简单描述下你的算法。
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -0,0 +1,140 @@
|
||||
<audio id="audio" title="43 | Kubernetes默认调度器的优先级与抢占机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/1a/dc0441b3e25bbee1e3c9a56cdbaf161a.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:Kubernetes默认调度器的优先级与抢占机制。
|
||||
|
||||
在上一篇文章中,我为你详细讲解了 Kubernetes 默认调度器的主要调度算法的工作原理。在本篇文章中,我再来为你讲解一下 Kubernetes 调度器里的另一个重要机制,即:优先级(Priority )和抢占(Preemption)机制。
|
||||
|
||||
首先需要明确的是,优先级和抢占机制,解决的是 Pod 调度失败时该怎么办的问题。
|
||||
|
||||
正常情况下,当一个 Pod 调度失败后,它就会被暂时“搁置”起来,直到 Pod 被更新,或者集群状态发生变化,调度器才会对这个 Pod进行重新调度。
|
||||
|
||||
但在有时候,我们希望的是这样一个场景。当一个高优先级的 Pod 调度失败后,该 Pod 并不会被“搁置”,而是会“挤走”某个 Node 上的一些低优先级的 Pod 。这样就可以保证这个高优先级 Pod 的调度成功。这个特性,其实也是一直以来就存在于 Borg 以及 Mesos 等项目里的一个基本功能。
|
||||
|
||||
而在 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."
|
||||
|
||||
```
|
||||
|
||||
上面这个 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 里的主要体现。**
|
||||
|
||||
>
|
||||
备注:这里,你可以再回顾一下第41篇文章[《十字路口上的Kubernetes默认调度器》](https://time.geekbang.org/column/article/69890)中的相关内容。
|
||||
|
||||
|
||||
而当一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发。这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被删除后,待调度的高优先级 Pod 就可以被调度到这个节点上。**这个过程,就是“抢占”这个概念在 Kubernetes 里的主要体现。**
|
||||
|
||||
为了方便叙述,我接下来会把待调度的高优先级 Pod 称为“抢占者”(Preemptor)。
|
||||
|
||||
当上述抢占过程发生时,抢占者并不会立刻被调度到被抢占的 Node 上。事实上,调度器只会将抢占者的spec.nominatedNodeName字段,设置为被抢占的 Node 的名字。然后,抢占者会重新进入下一个调度周期,然后在新的调度周期里来决定是不是要运行在被抢占的节点上。这当然也就意味着,即使在下一个调度周期,调度器也不会保证抢占者一定会运行在被抢占的节点上。
|
||||
|
||||
这样设计的一个重要原因是,调度器只会通过标准的 DELETE API 来删除被抢占的 Pod,所以,这些 Pod 必然是有一定的“优雅退出”时间(默认是30s)的。而在这段时间里,其他的节点也是有可能变成可调度的,或者直接有新的节点被添加到这个集群中来。所以,鉴于优雅退出期间,集群的可调度性可能会发生的变化,**把抢占者交给下一个调度周期再处理,是一个非常合理的选择。**
|
||||
|
||||
而在抢占者等待被调度的过程中,如果有其他更高优先级的 Pod 也要抢占同一个节点,那么调度器就会清空原抢占者的spec.nominatedNodeName字段,从而允许更高优先级的抢占者执行抢占,并且,这也就使得原抢占者本身,也有机会去重新抢占其他节点。这些,都是设置nominatedNodeName字段的主要目的。
|
||||
|
||||
那么,Kubernetes 调度器里的抢占机制,又是如何设计的呢?
|
||||
|
||||
接下来,我就为你详细讲述一下这其中的原理。
|
||||
|
||||
我在前面已经提到过,抢占发生的原因,一定是一个高优先级的 Pod 调度失败。这一次,我们还是称这个 Pod 为“抢占者”,称被抢占的 Pod 为“牺牲者”(victims)。
|
||||
|
||||
而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来说,因为有上述抢占者的存在,它的调度过程,其实是有一些特殊情况需要特殊处理的。
|
||||
|
||||
具体来说,在为某一对 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 上。关于这一点,我在前面已经讲解过了:Kubernetes调度器并不保证抢占者一定会运行在当初选定的被抢占的 Node 上。
|
||||
|
||||
以上,就是 Kubernetes 默认调度器里优先级和抢占机制的实现原理了。
|
||||
|
||||
## 总结
|
||||
|
||||
在本篇文章中,我为你详细讲述了 Kubernetes 里关于 Pod 的优先级和抢占机制的设计与实现。
|
||||
|
||||
这个特性在v1.11之后已经是Beta了,意味着比较稳定了。所以,我建议你在Kubernetes集群中开启这两个特性,以便实现更高的资源使用率。
|
||||
|
||||
## 思考题
|
||||
|
||||
当整个集群发生可能会影响调度结果的变化(比如,添加或者更新 Node,添加和更新 PV、Service等)时,调度器会执行一个被称为MoveAllToActiveQueue的操作,把所调度失败的 Pod 从 unscheduelableQ 移动到activeQ 里面。请问这是为什么?
|
||||
|
||||
一个相似的问题是,当一个已经调度成功的 Pod 被更新时,调度器则会将unschedulableQ 里所有跟这个 Pod 有 Affinity/Anti-affinity 关系的 Pod,移动到 activeQ 里面。请问这又是为什么呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -0,0 +1,159 @@
|
||||
<audio id="audio" title="44 | Kubernetes GPU管理与Device Plugin机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/d6/a8db9e648b0fe173c7e5e6f8a05dc3d6.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:Kubernetes GPU管理与Device Plugin机制。
|
||||
|
||||
2016年,随着 AlphaGo 的走红和TensorFlow 项目的异军突起,一场名为 AI 的技术革命迅速从学术界蔓延到了工业界,所谓的 AI 元年,就此拉开帷幕。
|
||||
|
||||
当然,机器学习或者说人工智能,并不是什么新鲜的概念。而这次热潮的背后,云计算服务的普及与成熟,以及算力的巨大提升,其实正是将人工智能从象牙塔带到工业界的一个重要推手。
|
||||
|
||||
而与之相对应的,从2016年开始,Kubernetes 社区就不断收到来自不同渠道的大量诉求,希望能够在 Kubernetes 集群上运行 TensorFlow 等机器学习框架所创建的训练(Training)和服务(Serving)任务。而这些诉求中,除了前面我为你讲解过的 Job、Operator 等离线作业管理需要用到的编排概念之外,还有一个亟待实现的功能,就是对 GPU 等硬件加速设备管理的支持。
|
||||
|
||||
不过, 正如同 TensorFlow 之于 Google 的战略意义一样,**GPU 支持对于 Kubernetes 项目来说,其实也有着超过技术本身的考虑**。所以,尽管在硬件加速器这个领域里,Kubernetes 上游有着不少来自 NVIDIA 和 Intel 等芯片厂商的工程师,但这个特性本身,却从一开始就是以 Google Cloud 的需求为主导来推进的。
|
||||
|
||||
而对于云的用户来说,在 GPU 的支持上,他们最基本的诉求其实非常简单:我只要在 Pod 的 YAML 里面,声明某容器需要的 GPU 个数,那么Kubernetes 为我创建的容器里就应该出现对应的 GPU 设备,以及它对应的驱动目录。
|
||||
|
||||
以 NVIDIA 的 GPU 设备为例,上面的需求就意味着当用户的容器被创建之后,这个容器里必须出现如下两部分设备和目录:
|
||||
|
||||
<li>
|
||||
GPU 设备,比如 /dev/nvidia0;
|
||||
</li>
|
||||
<li>
|
||||
GPU 驱动目录,比如/usr/local/nvidia/*。
|
||||
</li>
|
||||
|
||||
其中,GPU 设备路径,正是该容器启动时的 Devices 参数;而驱动目录,则是该容器启动时的 Volume 参数。所以,在 Kubernetes 的GPU 支持的实现里,kubelet 实际上就是将上述两部分内容,设置在了创建该容器的 CRI (Container Runtime Interface)参数里面。这样,等到该容器启动之后,对应的容器里就会出现 GPU 设备和驱动的路径了。
|
||||
|
||||
不过,Kubernetes 在 Pod 的 API 对象里,并没有为 GPU 专门设置一个资源类型字段,而是使用了一种叫作 Extended Resource(ER)的特殊字段来负责传递 GPU 的信息。比如下面这个例子:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: cuda-vector-add
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: cuda-vector-add
|
||||
image: "k8s.gcr.io/cuda-vector-add:v0.1"
|
||||
resources:
|
||||
limits:
|
||||
nvidia.com/gpu: 1
|
||||
|
||||
```
|
||||
|
||||
可以看到,在上述 Pod 的 limits 字段里,这个资源的名称是`nvidia.com/gpu`,它的值是1。也就是说,这个 Pod 声明了自己要使用一个 NVIDIA 类型的GPU。
|
||||
|
||||
而在 kube-scheduler 里面,它其实并不关心这个字段的具体含义,只会在计算的时候,一律将调度器里保存的该类型资源的可用量,直接减去 Pod 声明的数值即可。所以说,Extended Resource,其实是 Kubernetes 为用户设置的一种对自定义资源的支持。
|
||||
|
||||
当然,为了能够让调度器知道这个自定义类型的资源在每台宿主机上的可用量,宿主机节点本身,就必须能够向 API Server 汇报该类型资源的可用数量。在 Kubernetes 里,各种类型的资源可用量,其实是 Node 对象Status 字段的内容,比如下面这个例子:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: Node
|
||||
metadata:
|
||||
name: node-1
|
||||
...
|
||||
Status:
|
||||
Capacity:
|
||||
cpu: 2
|
||||
memory: 2049008Ki
|
||||
|
||||
```
|
||||
|
||||
而为了能够在上述 Status 字段里添加自定义资源的数据,你就必须使用 PATCH API 来对该 Node 对象进行更新,加上你的自定义资源的数量。这个 PATCH 操作,可以简单地使用 curl 命令来发起,如下所示:
|
||||
|
||||
```
|
||||
# 启动 Kubernetes 的客户端 proxy,这样你就可以直接使用 curl 来跟 Kubernetes 的API Server 进行交互了
|
||||
$ kubectl proxy
|
||||
|
||||
# 执行 PACTH 操作
|
||||
$ curl --header "Content-Type: application/json-patch+json" \
|
||||
--request PATCH \
|
||||
--data '[{"op": "add", "path": "/status/capacity/nvidia.com/gpu", "value": "1"}]' \
|
||||
http://localhost:8001/api/v1/nodes/<your-node-name>/status
|
||||
|
||||
```
|
||||
|
||||
PATCH 操作完成后,你就可以看到 Node 的 Status 变成了如下所示的内容:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: Node
|
||||
...
|
||||
Status:
|
||||
Capacity:
|
||||
cpu: 2
|
||||
memory: 2049008Ki
|
||||
nvidia.com/gpu: 1
|
||||
|
||||
```
|
||||
|
||||
这样在调度器里,它就能够在缓存里记录下node-1上的`nvidia.com/gpu`类型的资源的数量是1。
|
||||
|
||||
当然,在 Kubernetes 的 GPU 支持方案里,你并不需要真正去做上述关于 Extended Resource 的这些操作。在 Kubernetes 中,对所有硬件加速设备进行管理的功能,都是由一种叫作 Device Plugin的插件来负责的。这其中,当然也就包括了对该硬件的 Extended Resource 进行汇报的逻辑。
|
||||
|
||||
Kubernetes 的 Device Plugin 机制,我可以用如下所示的一幅示意图来和你解释清楚。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/10/10a472b64f9daf24f63df4e3ae24cd10.jpg" alt="">
|
||||
|
||||
我们先从这幅示意图的右侧开始看起。
|
||||
|
||||
首先,对于每一种硬件设备,都需要有它所对应的 Device Plugin 进行管理,这些 Device Plugin,都通过gRPC 的方式,同 kubelet 连接起来。以 NVIDIA GPU 为例,它对应的插件叫作[`NVIDIA GPU device plugin`](https://github.com/NVIDIA/k8s-device-plugin)。
|
||||
|
||||
这个 Device Plugin 会通过一个叫作 ListAndWatch的 API,定期向 kubelet 汇报该 Node 上 GPU 的列表。比如,在我们的例子里,一共有三个GPU(GPU0、GPU1和 GPU2)。这样,kubelet 在拿到这个列表之后,就可以直接在它向 APIServer 发送的心跳里,以 Extended Resource 的方式,加上这些 GPU 的数量,比如`nvidia.com/gpu=3`。所以说,用户在这里是不需要关心 GPU 信息向上的汇报流程的。
|
||||
|
||||
需要注意的是,ListAndWatch向上汇报的信息,只有本机上 GPU 的 ID 列表,而不会有任何关于 GPU 设备本身的信息。而且 kubelet 在向 API Server 汇报的时候,只会汇报该 GPU 对应的Extended Resource 的数量。当然,kubelet 本身,会将这个 GPU 的 ID 列表保存在自己的内存里,并通过 ListAndWatch API 定时更新。
|
||||
|
||||
而当一个 Pod 想要使用一个 GPU 的时候,它只需要像我在本文一开始给出的例子一样,在 Pod 的 limits 字段声明`nvidia.com/gpu: 1`。那么接下来,Kubernetes 的调度器就会从它的缓存里,寻找 GPU 数量满足条件的 Node,然后将缓存里的 GPU 数量减1,完成Pod 与 Node 的绑定。
|
||||
|
||||
这个调度成功后的 Pod信息,自然就会被对应的 kubelet 拿来进行容器操作。而当 kubelet 发现这个 Pod 的容器请求一个 GPU 的时候,kubelet 就会从自己持有的 GPU列表里,为这个容器分配一个GPU。此时,kubelet 就会向本机的 Device Plugin 发起一个 Allocate() 请求。这个请求携带的参数,正是即将分配给该容器的设备 ID 列表。
|
||||
|
||||
当 Device Plugin 收到 Allocate 请求之后,它就会根据kubelet 传递过来的设备 ID,从Device Plugin 里找到这些设备对应的设备路径和驱动目录。当然,这些信息,正是 Device Plugin 周期性的从本机查询到的。比如,在 NVIDIA Device Plugin 的实现里,它会定期访问 nvidia-docker 插件,从而获取到本机的 GPU 信息。
|
||||
|
||||
而被分配GPU对应的设备路径和驱动目录信息被返回给 kubelet 之后,kubelet 就完成了为一个容器分配 GPU 的操作。接下来,kubelet 会把这些信息追加在创建该容器所对应的 CRI 请求当中。这样,当这个 CRI 请求发给 Docker 之后,Docker 为你创建出来的容器里,就会出现这个 GPU 设备,并把它所需要的驱动目录挂载进去。
|
||||
|
||||
至此,Kubernetes 为一个Pod 分配一个 GPU 的流程就完成了。
|
||||
|
||||
对于其他类型硬件来说,要想在 Kubernetes 所管理的容器里使用这些硬件的话,也需要遵循上述 Device Plugin 的流程来实现如下所示的Allocate和 ListAndWatch API:
|
||||
|
||||
```
|
||||
service DevicePlugin {
|
||||
// ListAndWatch returns a stream of List of Devices
|
||||
// Whenever a Device state change or a Device disappears, ListAndWatch
|
||||
// returns the new list
|
||||
rpc ListAndWatch(Empty) returns (stream ListAndWatchResponse) {}
|
||||
// Allocate is called during container creation so that the Device
|
||||
// Plugin can run device specific operations and instruct Kubelet
|
||||
// of the steps to make the Device available in the container
|
||||
rpc Allocate(AllocateRequest) returns (AllocateResponse) {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
目前,Kubernetes社区里已经实现了很多硬件插件,比如[FPGA](https://github.com/intel/intel-device-plugins-for-kubernetes)、[SRIOV](https://github.com/intel/sriov-network-device-plugin)、[RDMA](https://github.com/hustcat/k8s-rdma-device-plugin)等等。感兴趣的话,你可以点击这些链接来查看这些 Device Plugin 的实现。
|
||||
|
||||
## 总结
|
||||
|
||||
在本篇文章中,我为你详细讲述了 Kubernetes 对 GPU 的管理方式,以及它所需要使用的 Device Plugin 机制。
|
||||
|
||||
需要指出的是,Device Plugin 的设计,长期以来都是以 Google Cloud 的用户需求为主导的,所以,它的整套工作机制和流程上,实际上跟学术界和工业界的真实场景还有着不小的差异。
|
||||
|
||||
这里最大的问题在于,GPU 等硬件设备的调度工作,实际上是由 kubelet 完成的。即,kubelet 会负责从它所持有的硬件设备列表中,为容器挑选一个硬件设备,然后调用 Device Plugin 的 Allocate API 来完成这个分配操作。可以看到,在整条链路中,调度器扮演的角色,仅仅是为 Pod 寻找到可用的、支持这种硬件设备的节点而已。
|
||||
|
||||
这就使得,Kubernetes 里对硬件设备的管理,只能处理“设备个数”这唯一一种情况。一旦你的设备是异构的、不能简单地用“数目”去描述具体使用需求的时候,比如,“我的 Pod 想要运行在计算能力最强的那个 GPU 上”,Device Plugin 就完全不能处理了。
|
||||
|
||||
更不用说,在很多场景下,我们其实希望在调度器进行调度的时候,就可以根据整个集群里的某种硬件设备的全局分布,做出一个最佳的调度选择。
|
||||
|
||||
此外,上述 Device Plugin 的设计,也使得 Kubernetes 里,缺乏一种能够对 Device 进行描述的 API 对象。这就使得如果你的硬件设备本身的属性比较复杂,并且 Pod 也关心这些硬件的属性的话,那么 Device Plugin 也是完全没有办法支持的。
|
||||
|
||||
更为棘手的是,在Device Plugin 的设计和实现中,Google 的工程师们一直不太愿意为 Allocate 和 ListAndWatch API 添加可扩展性的参数。这就使得,当你确实需要处理一些比较复杂的硬件设备使用需求时,是没有办法通过扩展 Device Plugin 的 API来实现的。
|
||||
|
||||
针对这些问题,RedHat 在社区里曾经大力推进过 [ResourceClass](https://github.com/kubernetes/community/pull/2265)的设计,试图将硬件设备的管理功能上浮到 API 层和调度层。但是,由于各方势力的反对,这个提议最后不了了之了。
|
||||
|
||||
所以说,目前 Kubernetes 本身的 Device Plugin 的设计,实际上能覆盖的场景是非常单一的,属于“可用”但是“不好用”的状态。并且, Device Plugin 的 API 本身的可扩展性也不是很好。这也就解释了为什么像 NVIDIA 这样的硬件厂商,实际上并没有完全基于上游的 Kubernetes 代码来实现自己的 GPU 解决方案,而是做了一定的改动,也就是 fork。这,实属不得已而为之。
|
||||
|
||||
## 思考题
|
||||
|
||||
请你结合自己的需求谈一谈,你希望如何对当前的 Device Plugin进行改进呢?或者说,你觉得当前的设计已经完全够用了吗?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
Reference in New Issue
Block a user