14.凤凰架构:构建可靠的大型分布式系统 --- 资源与调度
第14章 资源与调度调度是指为新创建的pod找到一个最恰当的宿主机节点来运行它,这个过程成功与否,结果恰当与否,关键取决于容器编排系统是如何管理与分配集群节点的资源的。可以认为调度是必须以容器编排系统的资源管控为前提。14.1 资源模型资源是什么,在k8s里面所有能接触的方方面面都被抽象成了资源,譬如表示工作负荷的资源(Pod,ReplicaSet,Service等),表示存储的资源(Volume
·
第14章 资源与调度
调度是指为新创建的pod找到一个最恰当的宿主机节点来运行它,这个过程成功与否,结果恰当与否,关键取决于容器编排系统是如何管理
与分配集群节点的资源的。可以认为调度是必须以容器编排系统的资源管控为前提。
14.1 资源模型
资源是什么,在k8s里面所有能接触的方方面面都被抽象成了资源,譬如表示工作负荷的资源(Pod,ReplicaSet,Service等),表示存储的资源(Volume,
PersistentVolume,Secret等),表示策略的资源(SecurityContext,ResourceQuota,LimitRange等),表示身份的资源(ServiceAccount,Role,
ClusterRole等)。"一切皆为资源"的设计是k8s能够顺利实施声明式API的必要前提。k8s以资源为载体,建立了一套同时囊括抽象元素(如策略,依赖,权限)
和物理元素(如软件,硬件,网络)的领域特定语言。通过不同层级间资源的使用关系来描述上至整个集群甚至集群联邦,下至某一块内存区域或者一小部分处理器
核心的状态,这些对资源状态的描述的集合,共同构成了一幅信息系统工作的全景图。
这里讨论的是狭义上的资源,譬如处理器资源,内存资源,磁盘存储资源等。
从编排系统的角度看,Node是资源的提供者,Pod是资源的使用者,调度是对两者进行恰当的撮合。Node能提供三方面的资源:计算资源(如处理器、图形处理器、
内存)、存储资源(如磁盘容量、不同类型的介质)和网络资源(如带宽、网络地址)。其中与调度关系最密切的是处理器和内存,虽然它们都属于计算资源,但两者在调度
时又有一些微妙的差别。处理器这样的资源被称为 可压缩资源(Compressible Resource),特点是当可压缩资源不足的时候,Pod只会处于"饥饿状态",运行变慢,
但不会被系统杀死,即容器不会被直接终止,或被要求限时退出。而像内存这样的资源,则被称作不可压缩的资源(Incompressible Resource),特点是当不可压缩
的资源不足时,或者超过了容器自己声明的最大限度时,pod就会因为内存溢出(Out-of-Memory,OOM)而被系统杀掉。
14.2 服务质量与优先级
设定资源计量单位的目的是使得管理员能够限制某个pod对资源的过度占用,避免影响到其他pod的正常运行。pod由一到多个容器组成,资源最终交由pod的各个
容器去使用,所以资源的需求是设定在容器上的,具体的配置是pod的 spec.containers[].resource.limits/requests.cpu/memory 字段。但是对资源需求
的配额不是针对容器的,而是针对pod整体,pod的资源配额无需手动设置,它就是其包含的每个容器资源需求的累加值。
为容器设置最大的资源配额的做法从 cgroups 诞生后就已经屡见不鲜了,但你是否注意到k8s给出的配置中有 requests和limits 两个设置项呢?这两者的区别
很简单:requests 是供调度器使用的,k8s选择哪个节点运行pod,只会根据requests的值来进行决策;limits 才是供 cgroups 使用的,k8s在向cgroups传递
资源配额时,会按照limits的值进行设置。
实际用户提交工作负载时设置的配额,并不是容器调度必须严格遵循的值,因为根据实际经验,大多数的工作负载在运行的过程中真正用到的资源,其实都远小于它所
请求的资源配置。
当然,仅依靠一个资源配额的设置拆分成 requests 和 limits 两个设置项是不太可能解决这个矛盾的,k8s 为此还进行了许多额外的处理。一旦不按照最保守、
最安全的方式去分配资源,就意味着容器编排系统必须为有可能出现的极端情况买单。如果允许节点给pod分配的资源总是超过自己自己最大的可提供资源的话,假如某个
时刻这些pod的总消耗量真的超标了,便会不可避免的导致节点无法继续遵循调度时对pod许下的承若,为此,k8s只能杀掉一部分Pod腾出资源来,这个操作就是驱逐
机制(Eviction)。要进行驱逐,首先k8s就必须制定资源不足时该先牺牲哪些pod,保留哪些pod的明确准则,由此就形成了k8s的服务质量等级(Quality of Service
Level,QoS Level)和优先级(Priority)的概念。
质量等级是pod的一个隐含属性,也是k8s优先保障重要的服务,放弃一些没有那么重要的服务的衡量标准。不知道你想到这样一个细节没有:如果不去设置limits和
requests会怎样?如果不设置处理器和内存的资源,就意味着没有上限,该pod可以使用节点上所有可用的资源。虽然这类pod能以最灵活的方式使用资源,但也正是这类
pod扮演着最不稳定的风险来源的角色。Google明确提出了针对这类pod的一种近乎带惩罚性质的建议:当节点硬件资源不足的时候,优先干掉这类pod,说的优雅一点,
就是给与这类pod最低的服务质量等级。
k8s目前提供的服务质量等级一共分为三级,由高到低分别为 Guaranteed,Burstable 和 BestEffort。如果pod中所有的容器都设置了limits和requests,
且两者的值相等,那此pod的服务质量等级便是最高的Guaranteed;如果pod中有部分容器的requests值小于limits的值,或者只设置了requests而未设置limits,
那此pod的服务质量等级为第二级Burstable;如果是上文说的那种情况,limits和requests 两个都没有设置则属于最低的BestEffort。
通常建议将数据库应用等有状态的应用,或者一些比较重要的不能中断的业务的服务质量等级设定为 Guaranteed,这样除非pod使用超过了它们的limits所描述
的不可压缩资源,或者节点的内存压力大到k8s已经杀光了所有等级更低的pod了,否则它们都不会被系统自动杀死。相对的,一些临时的,不那么重要的任务设置为
BestEffort,这样有利于调度时在更大的节点范围中寻找主机,也有利于在宿主机中利用更多的资源快速的完成任务,然后退出,尽量缩减影响范围;当然,遇到系统
资源紧张的时候,它们也更容易被系统杀掉。
除了服务质量,k8s还允许系统管理员自行决定pod的优先级,这是通过类型为 PriorityClass的资源实现的,优先级决定了pod之间不是平等的关系,且这种
不平等关系不是谁会占用更多资源的问题,而是会直接影响pod的调度与生存的关键。
优先级会影响调度这很容易理解,它是指当多个pod同时被调度的话,高优先级的pod会被优先调度。pod越晚被调度,就越大的概率因节点资源被占用而不能成功。
但受优先级影响更大的另一方面是指k8s的抢占机制(Preemption),在正常未设置优先级的情况下,如果pod调度失败,就会暂时处于Pending状态被搁置了,直到集群
中有新节点加入或者旧pod退出。但是,如果有一个被设置了优先级的pod调度失败无法创建的话,k8s会在系统中寻找一批牺牲者(Victim),将它们杀掉以便个更高优先级
的pod让出资源。寻找的原则是根据在优先级低于待调度的pod的所有已调度的pod里,按照优先级从低到高排序,从最低的杀起,直至腾出的资源足以支持待调度的pod成功
调度所需的资源为止,或者已经找不到优先级更低的pod。
14.3 驱逐机制
前面说的杀掉某个Pod,在k8s中专业的称呼为"驱逐"(Eviction,即资源回收)。pod的驱逐机制是通过kubelet来执行的,kubelet是部署在每个节点的集群
管理程序,由于本身就运行在节点中,所以最容易感知到节点的资源实时消耗情况。kubelet一旦发现某种不可压缩的资源将要耗尽的时候,就会主动终止节点上较低
服务质量等级的pod,以保证其他更重要的pod的安全,被驱逐的pod的所欲容器都会被终止,pod的状态也会被设定为Failed。
默认配置下,前面说的"资源即将耗尽"的"即将",具体阈值是可用内存小于100Mi。除了可用内存(memory.available)外,其他不可压缩的资源
还包括宿主机的可用磁盘空间(nodefs.available),文件系统可用inodes数量(nodes.inodesFree),以及可用的容器运行时镜像存储空间
(imagefs.available)。后面三个的阈值都是按照实际容量的百分比来计算的,具体的默认值如下:
memory.available < 100 Mi
nodefs.available < 10%
nodefs.inodesFree < 5%
imagefs.available < 15%
管理员在kubelet启动时,通过命令行参数来修改这些默认值
kubelet --eviction-hard=memory.available < 10%
k8s 的驱逐不能完全等同于编程语言的垃圾收集。垃圾收集是安全的内存回收行为,而驱逐pod是一种破坏性的清理行为,有可能导致服务中断,必须更加
谨慎。譬如,要同时兼顾硬件资源可能只是短时间内间歇性的超过了阈值的场景,以及资源正在被快速消耗,很快会危机高服务质量的pod甚至整个节点稳定性的
场景。因此,驱逐机制中就有了 软驱逐(Soft Eviction)、硬驱逐(Hard Eviction)以及优雅退出期(Grace Period)的概念。
1.软驱逐
通常配置一个较低的警戒线(如可用内存仅剩20%),触及此线时,系统将进入一段观察期。如果只是暂时的资源抖动,在观察期内能够恢复正常水平的,
那么就不会真正启动驱逐机制。否则,若资源持续超过警戒线一段时间,就会触发pod的优雅退出(Grace shutdown),系统会通知pod进行必要的清理
工作,然后自行结束。在优雅退出期结束后,系统会强制杀掉还未曾自行了断的pod。
2.硬驱逐
通常配置一个较高的终止线(如可用内存仅剩20%),一旦触及此红线,立即强制杀掉pod,而不会优雅退出。
软驱逐是为了减少资源抖动对服务的影响,硬驱逐是保证核心功能的稳定,它们并不矛盾,一般会同时使用,如下:
kubelet --eviction-hard=memory.available < 10%
--eviction-soft=memory.available < 20%
--eviction-soft-grace-period=memory.available=1m30s
--eviction-max-pod-grace-period=600
k8s 的驱逐与编程语言中的垃圾收集的另外一个不同之处是,垃圾收集可以"应收尽收",而驱逐显然不行,不能无缘无故把整个节点中所有可驱逐的pod都
清空掉。但是,通常也不能只清理到刚刚好低于警戒线就停止,必须考虑驱逐之后的新pod调度与旧pod运行的新增消耗。譬如kubelet驱逐了若干个pod,让资源
使用率勉强低于阈值,那么狠可能在极短的时间内,资源使用率又会因为某个pod稍微占用了些资源而重新超过阈值,再次产生驱逐,如此反复。为此,k8s提供了
--eviction-minimum-reclaim 参数设置一旦驱逐发生之后,至少清理出多少资源才会中止。
不过,问题到这里还没解决。k8s很少会单独创建pod,通常都是由ReplicaSet,Deployment等更高层资源来管理,这意味着当pod被驱逐之后,它不会
从此彻底消失。k8s将自动生成一个新的pod来取代,并经过调度选择一个节点继续运行。如果没有额外的处理,那么大概率这个pod会被系统调度到当前节点上
重新创建,因为上一次调度就选择了这个节点,而且这个节点刚刚驱逐完一批pod得到空闲资源,所以它显然符合此pod的调度需求。为了避免上述情况,k8s还
提供了另外一个参数 --eviction-pressure-transition-period 来约束调度器,设置在驱逐发生之后多长时间内不得往该节点调度pod。
关于驱逐机制,你应该意识到这些措施被设计为以参数的形式开启,就说它们一定不是放之四海而皆准的通用准则。
最后,服务质量,优先级,驱逐机制这些概念,都是在pod层面上限制资源,是仅针对单个pod的低层次约束,但现实中我们还会遇到面向更高层次去控制
资源的需求,譬如,想限制由多个Pod构成的微服务系统的消耗的总资源,或者由多名成员组成的团队消耗的总资源。要满足这种资源限制的要求,k8s的解决
方案是为它们建立一个专门的名称空间,然后在名称空间里建立 ResourceQuota对象来描述如何对整体的资源进行限制。
但是ResourceQuota与调度就没有什么直接关系了,它针对的对象也不是pod,所以这里说的资源是广义上的资源,不仅能够设置处理器、内存等物理
资源的限制。还可以设置诸如Pod最大数量,ReplicaSet最大数量,Service最大数量,全部PersistentVolumeClaim的总存储容量等各种抽象资源的
限额。甚至当k8s预置的资源模型不能满足约束需要时,还能够根据实际情况去扩展,譬如要控制GPU的使用实例,完全可以通过k8s的设备插件(Device Plugin)
机制扩展出诸如 nvidia.com/gpu:4 这样的配置来。
14.4 默认调度器
调度是为新创建出来的pod寻找一个最恰当的宿主机节点去运行它,这句话就包含"运行"和"恰当"两个调度中关键过程:
1.运行
从集群中所有节点找出一批剩余资源可以满足该pod运行的节点。为此,k8s调度器设计了一组名为 Predicate 的筛选算法。
2.恰当
从符合运行要求的节点中找出一个最合适的节点完成调度。为此,k8s调度器设计了一组名为Priority的评价算法。
针对上述问题,Google 参考了当时的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),它根据预设的过滤策略来筛选节点.k8s中有三种默认的过滤策略,分别是:
1.通用过滤策略
最基础的调度过滤策略,用来检查节点能否满足pod声明中需要的资源需求。譬如处理器、内存资源是否满足,主机端口与声明的NodePort是否存在
冲突,pod的选择器或者nodeAffinity指定的节点是否与目标相匹配等。
2.卷过滤策略
与存储相关的过滤策略,用来检查节点挂载的Volume是否存在冲突,或者Volume的可用区域是否与目标节点冲突,等等。
3.节点过滤策略
与宿主机相关的过滤策略,最典型的k8s的污点与容忍度机制(Taint and Toleration),譬如默认情况下k8s会设置Master节点不允许被调度,
这就是通过Master中施加污点来避免的。之前提到的控制节点处于驱逐状态,或者在驱逐后一段时间不允许调度,也是在这个策略里实现的。
Predicate 算法所使用的一切数据均来自于调度缓存,而绝对不会去远程访问节点本身。只有Informer Loop 与 etcd 的监视操作才会涉及远程调用,
Scheduler Loop中除了最后的异步绑定要发起一次远程的etcd写入外,其余都是进程内访问,这一点是调度器执行效率的重要保证。
调度缓存就是两个控制循环的共享状态(Shared State),这样的设计避免了每次调度时主动去轮询所有集群的节点,保证了调度器的执行效率,但是并
不能完全避免因节点信息同步不及时而导致调度过程中实际资源发生变化的情况。譬如节点的某个端口在获取调度信息后,发生实际调度前辈意外占用了。为此,
当调度结果出来之后,kubelet真正创建pod之前,还必须执行一次 Admit 操作,在该节点上重新用 Predicate算法来二次确认。
经过Predicate算法筛选出符合要求的节点集,交给Priorities算法来打分(0~10分)并排序,以便挑选出"最恰当"的一个。"恰当"是带有主观色彩的
词语,k8s 也提供了不同的打分规则来满足不同的主观需求,譬如最常用的 LeastRequestdPriority规则,它的计算公式是:
score = (cpu((capacity - sum(requested)) * 10/capacity) + memory((capacity - sum(requested)) * 10/capacity)) / 2
从公式上很容易看出这就是在选择 处理器和内存空闲资源最多的节点,因为这些资源剩余越多,得分就越高。经常与它配合使用的是
BalancedResourceAllocation规则 ,它的公式是:
score = 10 - variance(cpuFraction,memoryFraction,volumeFraction) * 10
此公式中cpuFraction,memoryFraction,volumeFraction的含义分别是 pod请求的处理器、内存和存储资源占节点上对应可用资源的比例,variance
函数的作用是计算资源之间的差距,差距越大,函数值越大。由此可知BalancedResourceAllocation规则的意图是希望调度完成后,所有节点里各种资源的分配
尽量均衡,避免节点上出现诸如处理器资源被大量分配而内存大量剩余的尴尬情况。k8s内置的评分规则还有 ImageLocalityPriority,NodeAffinityPriority,TaintTolerationPriority等。
经过 Predicate的筛选、Priorities 的评分之后,调度器已经选出了调度的最终目标节点,最后一步是通知目标节点的kubelet去创建pod。调度器并
不会直接与kubelet通信来创建pod,它只需要把待调度的pod的nodeName字段更新为目标节点的名字即可,kubelet本身会监视该值的变化来接手后续的工作,
不过,从调度器在etcd中更新nodeName,kubelet从etcd中检测到变化,执行Admit操作二次确认调度的可行性,最后到pod开始实际创建的这个过程可能会
持续一段不短的时间,如果一直等待这些工作完成才宣告调度完成,那势必也会显著影响调度器的效率。实际上,k8s调度器采用了乐观绑定(Optimistic Binding)
的策略来解决此问题,它会同步更细调度缓存中的pod的nodeName字段,并异步更新etcd中的pod的nodeName字段,这个操作被称为绑定。如果最终调度成功了,
那etcd与调度缓存中的信息最终必定会保持一致,否则,如果调度失败了,那将会由Informer来根据pod的变动,清空调度成功却没有创建成功的pod的nodeName
字段,重新同步回调度缓存中,以便促使另外一次调度的开始。
最后,请注意这一节用的是"默认调度器",这强调的是以上行为是k8s的默认的行为。对调度过程的大部分行为,你都可以通过Scheduler Framework
暴露的接口来扩展和自定义。由于 Scheduler Framework 属于 k8s 内部的扩展机制(通过golang 的plugin机制实现,需要静态编译),通用性和本章提到的
其他扩展机制(CRI,CNI,CSI等)无法相提并论,属于较为高级的k8s管理技能。
更多推荐
已为社区贡献1条内容
所有评论(0)