学习笔记-架构的演进之k8s的资源模型与调度器设计-3月day12

前言

调度是容器编排系统最核心的功能之一,“编排”这个词本来也包含了“调度”的含义。调度是指为新创建出来的 Pod,寻找到一个最恰当的宿主机节点来运行它,而这个过程成功与否、结果恰当与否,关键就取决于容器编排系统是怎么管理和分配集群节点的资源的。

调度必须要以容器编排系统的资源管控为前提。

资源模型

资源是什么?

从广义上来讲,Kubernetes 系统中所有你能接触的方方面面,都被抽象成了资源,比如表示工作负荷的资源(Pod、ReplicaSet、Service、……),表示存储的资源(Volume、PersistentVolume、Secret、……),表示策略的资源(SecurityContext、ResourceQuota、LimitRange、……),表示身份的资源(ServiceAccount、Role、ClusterRole、……),等等。

一切皆为资源”的设计是 Kubernetes 能够顺利施行声明式 API 的必要前提。Kubernetes 以资源为载体,建立了一套同时囊括了抽象元素(如策略、依赖、权限)和物理元素(如软件、硬件、网络)的领域特定语言。它通过不同层级间资源的使用关系,来描述上至整个集群甚至是集群联邦,下至某一块内存区域或者一小部分的处理器核心的状态,这些对资源状态的描述的集合,就共同构成了一幅信息系统工作运行的全景图。

从编排系统的角度来看,Node 是资源的提供者,Pod 是资源的使用者,而调度是将两者进行恰当的撮合。

Node 通常能够提供三方面的资源:计算资源(如处理器、图形处理器、内存)、存储资源(如磁盘容量、不同类型的介质)和网络资源(如带宽、网络地址)。其中与调度关系最密切的是处理器和内存,虽然它们都属于计算资源,但两者在调度时又有一些微妙的差别:

  • 处理器这样的资源,被叫做是可压缩资源(Compressible Resources),特点是当可压缩资源不足时,Pod 只会处于“饥饿状态”,运行变慢,但不会被系统杀死(容器会被直接终止,或者是被要求限时退出)。
  • 而像内存这样的资源,则被叫做是不可压缩资源(Incompressible Resources),特点是当不可压缩资源不足,或者超过了容器自己声明的最大限度时,Pod 就会因为内存溢出(Out-Of-Memory,OOM)而被系统直接杀掉。

Kubernetes 给处理器资源设定的默认计量单位是“逻辑处理器的个数”。至于具体“一个逻辑处理器”应该如何理解,就要取决于节点的宿主机是如何解释的,它通常会是我们在/proc/cpuinfo中看到的处理器数量(cpu cores),可能会是多路处理器系统上的一个处理器、多核处理器中的一个核心、云计算主机上的一个虚拟化处理器(Virtual CPU,vCPU),或者是处理器核心里的一条超线程(Hyper-Threading)。

(Mi:Mebibytes与M:Megabytes的差异,前者表示 1024×1024 Bytes,后者表示 1000×1000 Bytes。所以一般都是MB)

服务质量与优先级

设定资源计量单位的目的,是为了管理员能够限制某个 Pod 对资源的过度占用,避免影响到其他 Pod 的正常运行。
Pod 是由一个到多个容器组成的,资源最终是交由 Pod 的各个容器去使用,所以资源的需求是设定在容器上的。但是,对资源需求的配额则不是针对容器,而是针对 Pod 整体,Pod 的资源配额不需要手动设置,因为 Pod 的资源配额就是 Pod 包含的每个容器资源需求的累加值。

如果允许节点给 Pod 分配的资源总和,超过了 Kubernetes 自己最大的可提供资源的话,Kubernetes 就迫不得已要杀掉一部分 Pod,以腾出资源来保证其余 Pod 能正常运行(驱逐机制)。该先牺牲哪些 Pod、该保留哪些 Pod 的明确准则,形成了 Kubernetes 的服务质量等级(Quality of Service Level,QoS Level)和优先级(Priority)的概念。

服务质量等级

质量等级是 Pod 的一个隐含属性,也是 Kubernetes 优先保障重要的服务,放弃一些没那么重要的服务的衡量准绳。
Kubernetes 目前提供的服务质量等级一共分为三级,由高到低分别为 Guaranteed、Burstable 和 BestEffort:

  1. 如果 Pod 中所有的容器都设置了limits和requests,且两者的值相等,那此 Pod 的服务质量等级就是最高的 Guaranteed;
  2. 如果 Pod 中有部分容器的 requests 值小于limits值,或者只设置了requests而未设置limits,那此 Pod 的服务质量等级就是第二级 Burstable;
  3. 如果是前面说的那种情况,limits和requests两个都没设置,那就是最低的 BestEffort 了。

一般来说,我们会建议把数据库应用等有状态的应用,或者是一些重要的、要保证不能中断的业务的服务质量等级定为 Guaranteed。这样,除非是 Pod 使用超过了它们的limits所描述的不可压缩资源,或者节点的内存压力大到 Kubernetes 已经杀光所有等级更低的 Pod 了,否则它们都不会被系统自动杀死。

优先级

除了服务质量等级以外,Kubernetes 还允许系统管理员自行决定 Pod 的优先级,这是通过类型为 PriorityClass 的资源来实现的。优先级决定了 Pod 之间并不是平等的关系,而且这种不平等还不是谁会多占用一点儿的资源的问题,而是会直接影响 Pod 调度与生存的关键。高优先级的 Pod 会优先被调度。而 Pod 越晚被调度,就越大概率地会因节点资源已被占用而不能成功。

Kubernetes 的抢占机制(Preemption),如果有一个被设置了明确优先级的 Pod 调度失败,无法创建的话,Kubernetes 就会在系统中寻找出一批牺牲者(Victims),把它们杀掉以便给更高优先级的 Pod 让出资源。而这个寻找的原则,就是在优先级低于待调度 Pod 的所有已调度的 Pod 里,按照优先级从低到高排序,从最低的杀起,直至腾出的资源可以满足待调度 Pod 的成功调度为止,或者已经找不到更低优先级的 Pod 为止。

驱逐机制(eviction,资源回收)

Pod 的驱逐机制是通过 kubelet 来执行的,kubelet 是部署在每个节点的集群管理程序,因为它本身就运行在节点中,所以最容易感知到节点的资源实时耗用情况。kubelet 一旦发现某种不可压缩资源将要耗尽,就会主动终止节点上服务质量等级比较低的 Pod,以保证其他更重要的 Pod 的安全。而被驱逐的 Pod 中,所有的容器都会被终止,Pod 的状态会被更改为 Failed。

驱逐 Pod 是一种毁坏性的清理行为,它有可能会导致服务产生中断,因而必须更加谨慎。驱逐机制中就有了软驱逐(Soft Eviction)、硬驱逐(Hard Eviction)以及优雅退出期(Grace Period)的概念:

  • 软驱逐:通常会配置一个比较低的警戒线(比如可用内存仅剩 20%),当触及此线时,系统就会进入一段观察期。如果只是暂时的资源抖动,在观察期内能够恢复到正常水平的话,那就不会真正启动驱逐操作。否则,资源持续超过警戒线一段时间,就会触发 Pod 的优雅退出(Grace Shutdown),系统会通知 Pod 进行必要的清理工作(比如将缓存的数据落盘),然后自行结束。在优雅退出期结束后,系统会强制杀掉还没有自行了断的 Pod。
  • 硬驱逐:通常会配置一个比较高的终止线(比如可用内存仅剩 10%),一旦触及此线,系统就会立即强制杀掉 Pod,不理会优雅退出。

软驱逐是为了减少资源抖动对服务的影响,硬驱逐是为了保障核心系统的稳定,它们并不矛盾,一般会同时使用。

关于服务质量、优先级、驱逐机制这些概念,都是在 Pod 层面上限制资源,是仅针对单个 Pod 的低层次约束。而在现实中,我们还经常会遇到面向更高层次去控制资源的需求,比如,想限制由多个 Pod 构成的微服务系统耗用的总资源,或者是由多名成员组成的团队耗用的总资源。
要满足这种资源限制的需求,Kubernetes 的解决方案是应该先为它们建立一个专用的名称空间,然后再在名称空间里建立 ResourceQuota 对象,来描述如何进行整体的资源约束。但是这样,ResourceQuota 与调度就没有直接关系了,它针对的对象也不是 Pod,所以这里我所说的资源,可以是广义上的资源,系统不仅能够设置处理器、内存等物理资源的限额,还可以设置诸如 Pod 最大数量、ReplicaSet 最大数量、Service 最大数量、全部 PersistentVolumeClaim 的总存储容量等各种抽象资源的限额。

默认调度器

Kubernetes 是如何撮合 Pod 与 Node 的?

调度是为新创建出来的 Pod,寻找到一个最恰当的宿主机节点去运行它。而在这句话里,就包含有“运行”和“恰当”两个调度中的关键过程,它们具体是指:

  • 运行:从集群的所有节点中,找出一批剩余资源可以满足该 Pod 运行的节点。为此,Kubernetes 调度器设计了一组名为 Predicate 的筛选算法。
  • 恰当:从符合运行要求的节点中,找出一个最适合的节点完成调度。为此,Kubernetes 调度器设计了一组名为 Priority 的评价算法。

Google 在论文《Omega: Flexible, Scalable Schedulers for Large Compute Clusters》里总结了自身的经验,并参考了当时Apache Mesos和Hadoop on Demand(HOD)的实现,提出了一种共享状态(Shared State)的双循环调度机制。用于解决随着集群和它们的工作负载不断增长,调度器很有可能会成为扩展性瓶颈所在的问题:
在这里插入图片描述
“状态共享的双循环”中,第一个控制循环可被称为“Informer Loop”,它是一系列Informer的集合,这些 Informer 会持续监视 etcd 中与调度相关资源(主要是 Pod 和 Node)的变化情况,一旦 Pod、Node 等资源出现变动,就会触发对应 Informer 的 Handler。Informer Loop 的职责是根据 etcd 中的资源变化,去更新调度队列(Priority Queue)和调度缓存(Scheduler Cache)中的信息。比如当有新 Pod 生成,就将其入队(Enqueue)到调度队列中,如有必要,还会根据优先级触发上节课我提到的插队和抢占操作。再比如,当有新的节点加入集群,或者已有的节点资源信息发生变动,Informer 也会把这些信息更新同步到调度缓存之中。
另一个控制循环可被称为“Scheduler Loop”,它的核心逻辑是不停地把调度队列中的 Pod 出队(Pop),然后使用 Predicate 算法进行节点选择。Predicate 本质上是一组节点过滤器(Filter),它会根据预设的过滤策略来筛选节点。Kubernetes 中默认有三种过滤策略,分别是:

  1. 通用过滤策略:最基础的调度过滤策略,用来检查节点是否能满足 Pod 声明中需要的资源。比如处理器、内存资源是否满足,主机端口与声明的 NodePort 是否存在冲突,Pod 的选择器或者nodeAffinity指定的节点是否与目标相匹配,等等。
  2. 卷过滤策略:与存储相关的过滤策略,用来检查节点挂载的 Volume 是否存在冲突(比如将一个块设备挂载到两个节点上),或者 Volume 的可用区域是否与目标节点冲突,等等。在“Kubernetes 存储设计”中提到的 Local PersistentVolume 的调度检查,就是在这里处理的。
  3. 节点过滤策略:与宿主机相关的过滤策略,最典型的是 Kubernetes 的污点与容忍度机制(Taints and Tolerations),比如默认情况下,Kubernetes 会设置 Master 节点不允许被调度,这就是通过在 Master 中施加污点来避免的。前面我提到的控制节点处于驱逐状态,或者在驱逐后一段时间不允许调度,也是在这个策略里实现的。

Predicate 算法所使用的一切数据,都来自于调度缓存,它绝对不会去远程访问节点本身。这里你要知道,只有 Informer Loop 与 etcd 的监视操作才会涉及到远程调用,而 Scheduler Loop 中,除了最后的异步绑定要发起一次远程的 etcd 写入外,其余全部都是进程内访问,这一点正是调度器执行效率的重要保证。
所谓的调度缓存,就是两个控制循环的共享状态(Shared State),这样的设计避免了调度器每次调度时主动去轮询所有集群节点,保证了调度器的执行效率。但是它也存在一定的局限,也就是调度缓存并不能完全避免因节点信息同步不及时,而导致调度过程中实际资源发生变化的情况,比如节点的某个端口在获取调度信息后、发生实际调度前被意外占用了。
为此,当调度结果出来以后,在 kubelet 真正创建 Pod 以前,还必须执行一次 Admit 操作,在该节点上重新做一遍 Predicate,来进行二次确认。经过 Predicate 算法筛选出来符合要求的节点集,会交给 Priorities 算法来打分(0~10 分)排序,以便挑选出“最恰当”的一个。(Kubernetes提供了不同的打分规则来满足不同的主观需求,比如最常用的 LeastRequestedPriority 规则)

经过 Predicate 的筛选、Priorities 的评分之后,调度器已经选出了调度的最终目标节点,最后一步就是通知目标节点的 kubelet 可以去创建 Pod 了。我们要知道,调度器并不会直接与 kubelet 通讯来创建 Pod,它只需要把待调度的 Pod 的nodeName字段更新为目标节点的名字即可,kubelet 本身会监视该值的变化来接手后续工作。

不过,从调度器在 etcd 中更新nodeName,到 kubelet 从 etcd 中检测到变化,再执行 Admit 操作二次确认调度可行性,最后到 Pod 开始实际创建,这个过程可能会持续一段不短的时间,如果一直等待这些工作都完成了,才宣告调度最终完成,那势必也会显著影响调度器的效率。
Kubernetes 调度器采用了乐观绑定(Optimistic Binding)的策略来解决这个问题,它会同步地更新调度缓存中 Pod 的nodeName字段,并异步地更新 etcd 中 Pod 的nodeName字段,这个操作被称为绑定(Binding)。如果最终调度成功了,那 etcd 与调度缓存中的信息最终必定会保持一致,否则如果调度失败了,那就会由 Informer 来根据 Pod 的变动,将调度成功却没有创建成功的 Pod 清空nodeName字段,重新同步回调度缓存中,以便促使另外一次调度的开始。

对调度过程的大部分行为,你都可以通过 Scheduler Framework 暴露的接口来进行扩展和自定义,如下图所示:
在这里插入图片描述
图中绿色的部分,就是 Scheduler Framework 暴露的扩展点。由于 Scheduler Framework 属于 Kubernetes 内部的扩展机制(通过 Golang 的 Plugin 机制来实现的,需静态编译),它的通用性跟我在前面课程中提到的其他扩展机制(比如 CRI、CNI、CSI 那些)无法相提并论,属于比较高级的 Kubernetes 管理技能了。

总结

调度可以分解为几个相对独立的子问题来研究,比如说,如何衡量工作任务的算力需求;如何区分工作任务的优先级,保障较重要的任务有较高的服务质量;如何在资源紧张时自动驱逐相对不重要的任务,等等。解决这一系列子问题的组件,就称为容器编排系统的调度器。

此文章为3月Day12学习笔记,内容来源于极客时间《周志明的软件架构课

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值