《深入剖析Kubernetes》总结十:资源管理和调度

资源模型与资源管理

资源模型设计

CPU 和内存资源的限额要配置在每个 Container 的定义上,Pod 整体的资源配置,就由这些 Container 的配置值累加得到

Kubernetes 里为 CPU 设置的单位是“CPU 的个数”,,具体“1 个 CPU”在宿主机上如何解释,是 1 个 CPU 核心,还是 1 个 vCPU,还是 1 个 CPU 的超线程(Hyperthread),完全取决于宿主机的 CPU 实现方式

对于内存资源来说,它的单位就是 bytes

Kubernetes 里 Pod 的 CPU 和内存资源,实际上还要分为 limits 和 requests 两种情况::在调度的时候,kube-scheduler 只会按照 requests 的值进行计算,而在真正设置 Cgroups 限制的时候,kubelet 则会按照 limits 的值来进行设置;
其设计思想参考了Brog:容器化作业在提交时所设置的资源边界,并不一定是调度系统所必须严格遵守的,这是因为在实际场景中,大多数作业使用到的资源其实远小于它所请求的资源限额,基于这种假设,在作业被提交后,可以主动减小它的资源限额配置,以便容纳更多的作业、 提升资源利用率。而当作业资源使用量增加到一定阈值时,可以通过“快速恢复”过程,还原作业原始的资源限额,防止出现异常情况

Kubernetes 的 requests+limits 的做法是Brog思路的一个简化版:用户在提交 Pod 时,可以声明一个相对较小的 requests 值供调度器使用,而 Kubernetes 真正设置给容器 Cgroups 的,则是相对较大的 limits 值

QoS模型

不同的 requests 和 limits 的设置方式,会将 Pod 划分到不同的 QoS 级别当中

当 Pod 里的每一个 Container 都同时设置了 requests 和 limits,并且 requests 和 limits 值相等的时候,这个 Pod 就属于 Guaranteed 类别

当 Pod 不满足 Guaranteed 的条件,但至少有一个 Container 设置了 requests,那么这 个 Pod 就会被划分到 Burstable 类别。

如果一个 Pod 既没有设置 requests,也没有设置 limits,那么它的 QoS 类别就是 BestEffort

QoS 划分的主要应用场景,是当宿主机资源紧张的时候,kubelet 对 Pod 进行 Eviction(即资源回收)时需要用到的:当 Kubernetes 所管理的宿主机上不可压缩资源短缺时,就有可能触发 Eviction,比 如,可用内存(memory.available)、可用的宿主机磁盘空间(nodefs.available),以及容器运行时镜像存储空间(imagefs.available)等等(CPU为可压缩,当可压缩资源不足时,Pod 只会“饥饿”,但不会退出)

Eviction 在 Kubernetes 里分为 Soft 和 Hard 两种模式:
Soft Eviction 模式允许开发者为 Eviction 过程设置一段“优雅时间”;
Hard Eviction 模式下,Eviction 过程就会在阈值达到之后立刻开始

当宿主机的 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 的优先级来进行进一步地排序和选择

在实际的使用中,强烈建议将 DaemonSet 的 Pod 都设置为 Guaranteed 的 QoS 类型;
否则,一旦 DaemonSet 的 Pod 被回收,它又会立即在原宿主机上被重建出来,资源回收的动作是没有意义的

  • cpuset 的设置

在使用容器的时候可以通过设置 cpuset 把容器绑定到某个 CPU 的核上,而不 是像 cpushare 那样共享 CPU 的计算能力;
这种情况下,由于操作系统在 CPU 之间进行上下文切换的次数大大减少,容器里应用的性能会得到大幅提升

Kubernetes中的实现:
1 Pod 必须是 Guaranteed 的 QoS 类型;
2 将 Pod 的 CPU 资源的 requests 和 limits 设置为同一个相等的整数值即可

默认调度器

默认调度器的主要职责是为一个新创建出来的 Pod,寻找一个最合适的节点(Node)。

最合适的含义:

  1. 从集群所有的节点中,根据调度算法挑选出所有可以运行该 Pod 的节点(Predicate算法);
  2. 从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果(Priority算法)

调度器对一个 Pod 调度成功,实际上就是将它的 spec.nodeName 字段填上调度结果的节点名字。

工作原理图如下:
在这里插入图片描述
Kubernetes 的调度器的核心是两个相互独立的控制循环:

1 第一个控制循环为 Informer Path

主要目的是启动一系列 Informer,用来监听(Watch)Etcd 中 Pod、Node、Service 等与调度相关的 API 对象的变化

比如,当一个待调度 Pod(即:它的 nodeName 字段是空的)被创建出来之后,调度器就会通过 Pod Informer 的 Handler,将这个待调度 Pod 添加进调度队列,还要负责对调度器缓存(即:scheduler cache)进行更新,尽最大可能将集群信息 Cache 化,以便从根本上提高 Predicate 和 Priority 调度算法的执行效率

默认情况下调度队列是一个 PriorityQueue(优先级队列)

2 第二个控制循环是调度器负责 Pod 调度的主循环,为 Scheduling Path

不断地从调度队列里出队一个 Pod,然后调用 Predicates 算法进行“过滤”,得到一组列表,然后调用 Priorities 算法为得到的列表里的 Node 打分,分数从 0 到 10。得分 最高的 Node,就会作为这次调度的结果

代码优化:
会启动多个协程以节点为粒度并发执行 Predicates 算法;
Priorities 算法会以 MapReduce 的方式并行计算然后再进行汇总;
在这些所有需要并发的路径上,调度器会避免设置任何全局的竞争资源,从而免去了使用锁进行同步带来的巨大的性能损耗

调度算法执行完成后,调度器就需要将 Pod 对象的 nodeName 字段的值,修改为上述 Node 的名字,这个步骤在 Kubernetes 里面被称作 Bind

代码优化:为了不在关键调度路径里远程访问 APIServer,Kubernetes 的默认调度器在 Bind 阶 段,只会更新 Scheduler Cache 里的 Pod 和 Node 的信息;
这种基于“乐观”假设的 API 对 象更新方式,在 Kubernetes 里被称作 Assume;
Assume 之后,调度器才会创建一个 Goroutine 来异步地向 APIServer 发起更新 Pod 的请 求,来真正完成 Bind 操作;
如果这次异步的 Bind 过程失败了也没有关系,等 Scheduler Cache 同步之后一切就会恢复正常

由于“乐观”绑定的设计,就有了一个check操作:当一个新的 Pod 完成调度需要在某个节点上运行起来之前,该节点上的 kubelet 会通过一个叫作 Admit 的操作来再次验证该 Pod 是否确实能够运行在该节点上;
这一步 Admit 操作,实际上就是把一组叫作 GeneralPredicates 的、最基本的调度算法,比如:“资源是否可用”“端口是否冲突”等再执行一遍,作为 kubelet 端的二次确认

可扩展性设计:
在这里插入图片描述
默认调度器的可扩展机制在 Kubernetes 里面叫作 Scheduler Framework;
这个设计的主要目的就是在调度器生命周期的各个关键点上,为用户暴露出可以进行扩 展和实现的接口,从而实现由用户自定义调度器的能力

上图中每一个绿色的箭头都是一个可以插入自定义逻辑的接口;
比如Queue 部分意味着你可以在这一部分提供一个自己的调度队列的实现,从而控制每个 Pod 开始被调度 (出队)的时机;
Predicates 部分意味着可以提供自己的过滤算法实现,根据自己的需求,来决定选择哪些机器

这些可插拔式逻辑,都是标准的 Go 语言插件机制(Go plugin 机制),需要在编译的时候选择把哪些插件编译进去。

默认调度器策略

Predicates

可以理解为Filter,从当前集群的所 有节点中,“过滤”出一系列符合条件的节点,这些节点,都是可以运行待调度 Pod 的宿主机

默认有如下类型的规则:

1 GeneralPredicates

负责最基础的调度策略,比如PodFitsResources 计算的是宿主机的 CPU 和内存资源等是否够用、 PodFitsHost 检查宿主机的名字是否跟 Pod 的 spec.nodeName 一致

2 与 Volume 相关的过滤规则

负责跟容器持久化 Volume 相关的调度策略,比如NoDiskConflict 检查的条件是多个 Pod 声明挂载的持久化 Volume 是否有冲突

3 与宿主机相关的过滤规则

4 与Pod 相关的过滤规则

Priorities

Priorities 阶段就是为Predicates选出来的节点打分;
打分的范围是 0-10 分,得分最高的节点就是最后被 Pod 绑定的最佳节点

Priorities 里最常用到的一个打分规则,是 LeastRequestedPriority,其计算公式为:选择空闲资源(CPU 和 Memory)最多的宿主机

score = (cpu((capacity-sum(requested))10/capacity) + memory((capacity-sum(requested))10/capacity

与 LeastRequestedPriority 一起发挥作用的还有 BalancedResourceAllocation,其计算公式为:

score = 10 - variance(cpuFraction,memoryFraction,volumeFraction)*10

每种资源的 Fraction 的定义是 :Pod 请求的资源 / 节点上的可用资源;
variance 算 法的作用,则是计算每两种资源 Fraction 之间的“距离”;
最后选择的,则是资源 Fraction 差距最小的节点;
所以说,BalancedResourceAllocation 选择的,其实是调度完成后,所有节点里各种资源分配最均衡的那个节点,从而避免一个节点上 CPU 被大量分配、而 Memory 大量剩余的情况

默认调度器的优先级与抢占机制

优先级

调度器里维护着一个调度队列,当 Pod 拥有了优先级之后,高优先级的 Pod 就可能会比低优先级的 Pod 提前出队,从而尽早完成调度过程;
这个过程,就是“优先级”这个概念在 Kubernetes 里的主要体现

当抢占过程发生时,抢占者并不会立刻被调度到被抢占的 Node 上;
调度器只会 将抢占者的 spec.nominatedNodeName 字段设置为被抢占的 Node 的名字,然后,抢占者会重新进入下一个调度周期,然后在新的调度周期里来决定是不是要运行在被抢占的节点上;
这也就意味着,即使在下一个调度周期,调度器也不会保证抢占者一定会运行在被抢占的节点上

这样设计的一个重要原因是,调度器只会通过标准的 DELETE API 来删除被抢占的 Pod,所以,这些 Pod 必然是有一定的“优雅退出”时间(默认是 30s)的;
而在这段时间里,其他的节点也是有可能变成可调度的,或者直接有新的节点被添加到这个集群中来;
所以,鉴于优雅退出期间,集群的可调度性可能会发生的变化,把抢占者交给下一个调度周期再处理,是一个非常 合理的选择

抢占机制

当一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发;
这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被删除后, 待调度的高优先级 Pod 就可以被调度到这个节点上;
这个过程,就是“抢占”这个概念在 Kubernetes 里的主要体现

抢占发生的原因,一定是一个高优先级的 Pod 调度失败

在调度队列的实现里,使用了两个不同的队列:

1 activeQ

凡是在 activeQ 里的 Pod,都是下一个调度周期需要调度的对象;
创建一个 Pod 的时候,调度器会将这个 Pod 入队到 activeQ 里面;
调度器不断从队列里出队(Pop)一个 Pod 进行调度, 实际上都是从 activeQ 里出队的。

2 unschedulableQ

专门用来存放调度失败的 Pod,调度失败之后,抢占者就会被放进 unschedulableQ 里面;
当一个 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 的名字(这一步就是unschedulableQ移到activeQ的流程,因为Pod被更新了)

3 调度器会开启一个 Goroutine,异步地删除牺牲者

对于任意一个待调度 Pod 来说,因为抢占者的存在,它的调度过程有一些特殊情况需要特殊处理:

在为某一对 Pod 和 Node 执行 Predicates 算法的时候,如果待检查的 Node 是一 个即将被抢占的节点,即:调度队列里有 nominatedNodeName 字段值是该 Node 名字的 Pod 存在(可以称之为:“潜在的抢占者”),那么,调度器就会对这个 Node ,将同样的 Predicates 算法运行两遍

第一遍, 调度器会假设上述“潜在的抢占者”已经运行在这个节点上,然后执行 Predicates 算 法;

第二遍, 调度器会正常执行 Predicates 算法,即:不考虑任何“潜在的抢占者”

只有这两遍 Predicates 算法都能通过时,这个 Pod 和 Node 才会被认为是可以绑定 (bind)的

原因:
1 第一遍 Predicates 算法:因为InterPodAntiAffinity 规则

InterPodAntiAffinity 规则关心待考察节点上所有 Pod 之间的互斥关系,所以在执行调度算法时必须考虑如果抢占者已经存在于待考察 Node 上时,待调度 Pod 还能不能调度成功;
这也意味着在这一步只需要考虑那些优先级等于或者大于待调度 Pod 的抢占者,因为对于其他较低优先级 Pod 来说,待调度 Pod 总是可以通过抢占运行在待考察 Node 上

2 第二遍 Predicates 算法的原因:因为“潜在的抢占者”最后不一定会运行 在待考察的 Node 上

Kubernetes 调度器并不保证抢占者一定会运行在当初选定的被抢占的 Node 上

猜你喜欢

转载自blog.csdn.net/qq_41594698/article/details/107235039