资源与调度

作为一个容器集群编排与管理项目,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"
  • 可压缩资源(compressible resources):CPU,当可压缩资源不足时,Pod只饥饿,但是不会退出

  • 不可压缩资源(incompressible resources):内存,当不可压缩资源不足时,Pod会因为OOM(out of memory)被内核杀掉

Pod可以由多个Container组成,所以CPU和内存资源的限额,是要配置在每个Container的定义上。这样Pod整体的资源配置,就由这些Container的配置值累加得到。

CPU

kubernetes里为CPU设置的单位是“CPU的个数”,比如,cpu=1指的就是这个Pod的CPU限额是1个CPU,具体的1个CPU在宿主机上如何解释:

  1. 1个CPU核心

  2. 1个vCPU

  3. 1个CPU的超线程

这完全取决于宿主机CPU实现方式。kubernetes只负责保证Pod能够使用到“1个CPU”的计算能力。

kubernets运行将CPU配额设置为分数,比如500m(指的是500millicpu,即0.5个CPU),这样Pod被分配到的就是1个CPU一半的计算能力。

内存

内存资源的单位是bytes,kubernetes支持使用Ei、Pi、Ti、Gi、Mi、Ki(或者E、P、T、G、M、K)的方式来作为bytes的值。如64Mib。

主要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

两者的区别:

  1. 在调度时,kube-scheduler只会按照requests的值进行计算

  2. 在真正设置Cgroups限制的时候,kubelet则会按照limits的值进行设置

pod字段

Cgroups属性值

描述

默认值

requests.cpu=250m

cpu.shares=(250/1000)*1024

这样kubernetes就通过cpu.shares完成了对CPU时间的按比例分配

cpu.shares默认值是1024

limits.cpu=500m

cpu.cfs_quota_us=(500/1000)*100ms

kubernetes为容器只分配50%CPU

cpu.cfs_period_us始终为100ms

limits.memory=128Mi

memory.limit_in_bytes=128×1024×1024

在调度的时候,调度器只会使用requets.memory=64Mi来进行判断

kubernetes这种对CPU和内存资源限额的设计,实际上参考了Borg论文中对"动态资源边界"的定义。即,容器化作业在提交时所设置的资源边界,并不一定是调度系统所必须严格遵守的,因为在大多数作业使用到的资源其实远小于它所请求的资源限额。基于这种假设,borg在作业被提交后,会主动减小它的资源配额,以便容纳更多的作业、提升资源利用率。当作业资源使用量增加到一定阈值后,通过快速恢复过程,还原作业原始的资源配额,防止出现异常情况。

kubernetes的requests和limits是上述思想的简化版:用户在提交Pod时,可以声明一个相对较小的requests值供调度器使用,而kubernetes真正给容器Cgroups的则是相对较大的limits值。这与borg的思路是相通的。

QoS模型

在kubernetes中,不同的requests和limits的设置方式,会将这个Pod划分到不同的QoS级别中。

Guaranteed

当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类别。

Burstable

当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"

BestEffort

如果一个Pod既没有设置requests也没有设置limits,那就属于BestEffort类别,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: qos-demo-3
  namespace: qos-example
spec:
  containers:
  - name: qos-demo-3-ctr
    image: nginx

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过程就会在阈值到达之后立刻开始

kubernetes计算Eviction阈值的数据来源,主要依赖于从Cgroups读取的值,以及使用cAdvisor监控到的数据。

当宿主机上的Eviction阈值达到后,就会进入MemoryPressure或者DiskPressure状态(此时给node打上污点),从而避免新的Pod被调度到这台宿主机上。

而当Eviction发生的时候,kubelet具体会挑选哪些Pod进行删除操作,就需要参考这些Pod的QoS类别:

  • 首当其冲的是BestEffect类别的Pod

  • 其次是Burstable类别,并且发生“饥饿”的资源使用量已经超出requests的那些Pod

  • 最后是Guaranteed类别:

    • 并且kubernetes会保证只有当Guaranteed类别的Pod的资源用量超过limits的限制

    • 或者宿主机本身处于MemoryPressure状态时,Guaranteed的Pod才可能被选中进行Eviction操作

对于同QoS类别的Pod,kubernetes会根据Pod的优先级进行排序和选择。

cpuset

在使用容器的时候,通过设置cpuset把容器绑定到某个CPU的内核上,而不是像cpuset那样共享CPU计算能力。这种情况下,由于操作系统在CPU之间进行上下文切换的次数大大减少,容器里应用的性能会大幅提升。事实上,cpuset方式是生产环境中部署在线类型(long running task)的Pod时,非常有用的一种方式

如何在kubernetes中实现这样的操作?

  1. pod必须是Guaranteed的QoS类型

  2. 将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分配。

DaemonSet的Pod都设置为Guaranteed的QoS类型,否则一旦DaemonSet的Pod被回收,它又会立即在原宿主机上被重建出来,这就使得前面资源回收的动作,完全没有意义了。

作业调度

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

此处最合适的含义:

  1. 从集群所有的节点中,根据调度算法挑选出所有可以运行该Pod的节点

  2. 在上一步的结果中,在根据调度算法挑选一个最符合条件的节点作为最终结果

kubernetes发展的主旋律是整个开源项目的“民主化“,是组件的轻量化、接口化、插件化。所以有了CRI、CNI、CSI、CRD、Aggregated APIServer、Initializer、Device Plugin等各个层级的可扩展能力,默认调度器,却是kubernetes项目里最后一个没有对外暴露出良好的、定义过的、可扩展接口的组件。

调度时需要满足:

  • 资源调度(满足Pod资源要求)

    • Resources:CPU/Memory/Storage/GPU/FGPA

    • QoS:Guaranteed/Burstable/BestEffort

    • Resource Quota

  • 关系调度(满足Pod/Node的特殊关系/条件要求)

    • PodAffinity/PodAffinity:Pod和Pod之间的关系

    • NodeSelector/NodeAffinity:由Pod决定适合自己的Node

    • Taint/Tolerations:限制调度到某个Node

默认调度器的具体的调度流程:

  1. 检查Node(调用Predicate算法)

  2. 给Node打分(调用Priority算法)

  3. 最高分Node(调度结果)

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

上述调度机制的工作原理如下图所示:

kubernetes的调度器的核心,实际上就是两个相互独立的控制循环

Informer Path

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

比如,当一个待调度Pod(即它的nodeName字段为空)被创建出来后,调度器就会通过Pod Informer的Handler将这个待调度Pod添加进调度队列。

在默认情况下,kubernetes的调度队列是一个PriorityQueue(优先级队列),并且当某些集群信息发生变化时,调度器还会对调度队列里的内容进行特殊的操作(调度优先级和抢占)。默认调度器还负责对调度器缓存进行更新,在kubernetes的调度部分进行性能优化的一个根本原则就是尽最大可能将集群信息Cache化,以便从根本上提高Predicate和Priority调度算法的执行效率

Scheduling Path

调度器负责Pod调度的主循环,主要逻辑就是:

  1. 从调度队列里出队一个Pod

  2. 调用Predicate算法进行过滤,得到一组Node(所有可以运行这个Pod的宿主机列表)

    Predicate算法需要的Node信息,都是从Scheduler Cache里直接拿到的,这是调度器保证算法执行效率的主要手段之一。

  3. 调度器再调用Priority算法为上述列表里的Node打分,分数从0到10,得分最高的Node作为这次调度的结果

  4. 调度算法执行完成后,调度器就需要将Pod对象的nodeName字段的值,修改为上述Node的名字(称为Bind)

    为了不在关键调度路径里远程访问APIServer,kubernetes的默认调度器在Bind阶段,只会根据Scheduler Cache里的Pod和Node信息(这种基于乐观假设的API对象更新方式,称为Assume)。

  5. Assume之后,调度器会创建一个Goroutine来异步地向APIServer发起更新Pod的请求,来真正完成Bind操作

    如果这次异步的Bind过程失败了,其实也没有太大关系,等Scheduler Cache同步之后一切就会恢复正常。

  6. 由于调度器的乐观绑定设计,当一个新的Pod完成调度需要在某个节点上运行起来之前,该节点上的kubelet会进行Admit操作,来再次验证该Pod是否能够运行在该节点上

    Admit操作实际上就是把一组称为GeneralPredicates的最基本的调度算法,比如:资源是否可用、端口是否冲突等在执行一遍,作为kubelet端的二次确认。

无锁化

除了上述过程中的Cache化和乐观绑定,默认调度器还有一个重要的设计:无锁化。在Scheduling Path 上:

  1. 调度器会启动多个Goroutine以节点为粒度并发执行Predicates算法,从而提高这一阶段的执行效率

  2. Priorities算法也会以MapReduce的方式并行计算然后再进行汇总

在这个需要并发的路径上,调度器会避免设置任何全局的竞争资源。从而避免去使用锁进行同步带来的巨大的性能损耗

所以,kubernetes调度器只有对调度队列和Scheduler Cache进行操作时,才需要加锁,而这两部分操作,都不在Scheduling Path的算法执行路径上。

kubernetes调度器的上述设计思想,也是在集群规模不断增长的演进过程中逐步实现的,尤其是”Cache化“,这个变化是kubernetes调度器性能得以提升的一个关键演化

默认调度器的可扩展性设计

如下图所示:

默认扩展器的可扩展机制,在kubernetes里叫作Scheduler Framework,这个设计的主要目的就是在调度器声明周期的各个关键点上,为用户暴露出可以进行扩展和实现的接口,从而实现用户自定义调度器的能力。

每个绿色的箭头都是一个可以插入自定义逻辑的接口,如Queue部分可以提供一个自己的调度队列的实现,从而控制每个Pod开始被调度(出队)的时机。Predicates部分,意味着可以提供自定义的过滤算法实现,根据自己的需求,来决定选择哪些机器。这些可插拔式逻辑,都是标准的Go语言插件机制(Go plugin机制),需要在编译的时候选择把哪些插件编译进去。

调度策略

在调度的过程中主要发生作用的是Predicates和Priorities两个调度策略。

Predicates

在调度的过程中,可以理解为Filter。按照调度策略,从当前集群的所有节点中,过滤出一些列符合条件的节点,这些节点都是可以运行待调度Pod的宿主机。目前,默认的调度策略有四种:

  1. GeneralPredicate

  2. Volume相关过滤规则

  3. 宿主机相关过滤规则

  4. Pod相关过滤规则

GeneralPredicate

这一组过滤规则负责的是最基础的调度策略:

调度策略

描述

PodFitsResources

宿主机的CPU和内存资源等是否够用

PodFitsHost

宿主机的名字是否跟Pod的spec.nodeName一致

PodFitsHostPorts

Pod申请的宿主机端口(spec.nodePort)是不是跟已经被使用的端口有冲突

PodMatchNodeSelector

Pod的nodeSelector或者nodeAffinity指定的节点,是否与待考察节点匹配

这一组GeneralPredicate正式Kubernetes考察一个Pod能不能运行在一个Node上最基本的过滤条件。所以,GeneralPredicate也会被其他组件(如kubelet在启动pod前,会执行Admit操作,就是再执行一次GeneralPredicate)直接调用。

PodFitsResources检查的只是Pod的requests字段,kubernetes的调度器没有为GPU等硬件资源定义具体的资源类型,而是统一用External 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       # 声明使用两个NVIDIA类型的GPU
      limits:
        alpha.kubernetes.io/nvidia-gpu: 2

在PodFitsResources里,调度器并不知道这个字段的key的含义是GPU,而是直接使用后面的value进行计算,在Node的Capacity字段了,需要相应的加上这台宿主机上GPU的总数(如alpha.kubernetes.io/nvidia-gpu=4)。

Volume相关过滤规则

这一组过滤规则,负责的是跟容器持久化Volume相关的调度策略:

调度策略

描述

NoDiskConfict

多个Pod声明挂载的持久化Volume是否冲突

MaxPDVolumeCountPredicate

一个节点上某种类型的持久化Volume是不是已经超过了一定数目,如果超过则声明该类型的持久化Voluem的Pod就不能再调度到这个节点上

VolumeZonePredicate

检查持久化Volume的Zone(高可用域)标签,是否与待考察节点的Zone标签相匹配

VolumeBindingPredicate

Pod对应的PV的nodeAffinity字段是否与某个节点的标签相匹配

Local Persistent Volume(本地持久化卷),必须使用nodeAffinity来跟某个具体节点绑定,这就意味着Predicates节点,Kubernetes就必须能够根据Pod的Volume属性来进行调度。如果该Pod的PVC还没有跟具体的PV绑定,调度器还要负责检查所有待绑定PV,当有可用的PV存在并且该PV的nodeAffinity与待考察节点一致时,VolumeBindingPredicate这条规则才会返回成功,如下所示。

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才会被调度到该节点

NodeMemoryPressurePredicate

检查当前节点的内存是否已经不够充足,如果是,待调度Pod就不能被调度到该节点上

Pod相关过滤规则

这一组规则,与GeneralPredicates大多数是重合的,比较特殊的是:

调度策略

描述

PodAffinityPredicate

检查待调度Pod与Node上的已有Pod之间的亲密(Affinity)和反亲密(Anti-Affinity)关系

如下面的例子:

# podAntiAffinity
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的作用域,如上kubernetes.io/hostname标签的Node有效
#  这是topologykey关键词的作用


# 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

# podAffinity规则就是只会调度已经有携带了security=S1标签的Pod运行的Node上
# podAffinity的作用域,如上failure-domain.beta.kubernetes.io/zone标签的Node有效

例子中requiredDuringSchedulingIgnoredDuringExecution字段的含义是:

  • 这条规则必须在Pod调度是进行检查(requiredDuringScheduling

  • 如果已经在运行的Pod发生变化(如Label被修改),造成不再适合运行在这个Node上的时候,kubernetes不会进行主动修改(IgnoredDuringExecution

在具体执行的时候,当开始调度一个Pod时,kubernetes调度器会同时启动16个Goroutine,来并发地为集群里的所有Node计算Predicates,最后返回可以运行这个Pod的宿主机列表

每个Node执行Predicates时,调度器会按照规定的顺序来进行检查。这个顺序是按照Predicates本身的含义确定的。如宿主机相关的Predicates会被放在相对靠前的位置进行检查。否则一台资源严重不足的宿主机上来就开始计算PodAffinityPredicate是没有实际意义的。

Priorities

在Predicates阶段完成了节点的“过滤”后,Priorities阶段的工作就是为这些节点打分(0-10分),得分最高的节点就是最后被Pod绑定的最佳节点。

调度规则

描述

LeastRequestPriority

选择空闲资源最多的宿主机

BalancedResourceAllocation

选择各种资源分配最均衡的宿主机

NodeAffinityPriority

与PodMatchNodeSelector的含义和计算方法类似,一个Node满足上述规则的字段数越多,得分越高

TaintTolerationPriority

PodToleratesNodeTaints的含义和计算方法类似,一个Node满足上述规则的字段数越多,得分越高

InterPodAffinityPriority

PodAffinityPredicate的含义和计算方法类似,一个Node满足上述规则的字段数越多,得分越高

ImageLocalityPriority

v1.12中的新调度规则,如果待调度Pod需要使用的镜像很大,并且已经存在与某些Node上,那么这些Node的得分就会比较高

最常用的打分规则是LeastRequestPriority,计算公式如下:

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

# 这个算法实际上是在选择空闲资源(CPU和内存)最多的宿主机

LeastRequestPriority一起发挥作用的还有BalancedResourceAllocation,它的计算公式如下:

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

# 每种资源的Fraction的定义是:Pod请求的资源/节点上的可用资源。
# variance算法的作用是计算没两种资源Fraction之间的距离
# 最后选择的是资源Fraction差距最小的节点

BalancedResourceAllocation选择的是调度完成后,所有节点里各种资源分配最均衡的那个节点,从而避免一个节点上CPU被大量分配而内存大量剩余的情况。

为了避免ImageLocalityPriority算法引擎调度堆叠,调度器在计算得分的时候,还会根据镜像的分布进行优化,如果大镜像分布的节点数目很少,那么这些节点的权重就会被降低,从而“对冲”掉引起调度堆叠的风险。

在实际执行中,调度器中关于集群和Pod的信息已经缓存化,所有这些算法的执行过程比较快

对于比较复杂的调度算法,如PodAffinityPredicate,在计算的时候不止关注待调度Pod和待考察Node,还需要关注整个集群的信息,如遍历所有节点,读取它们的Labels。kubernetes调度器会在为每个待调度Pod执行该调度算法之前,先将算法需要的集群信息初步计算一遍,然后缓存起来。这样,在真正执行该算法的时候,调度器只需要读取缓存信息进行计算即可,从而避免了为每个Node计算Predicates的时候反复获取和计算整个集群的信息。

在kubernetes调度器里其实还有一些默认不开启的策略,可以通过为kube-Scheduler指定一个配置文件或者创建一个ConfigMap,来配置哪些规则需要开启,哪些规则需要关闭,并且可以通过为Priorties设置权重,来控制调度器的调度行为。

优先级(Priority)和抢占机制(Preemption)

优先级与抢占机制解决的是Pod调度失败时该怎么办的问题。

  1. 正常情况下,当一个Pod调度失败后,他就会被暂时“搁置”,直到Pod被更新,或者集群状态发生变化,调度器才会对这个Pod进行重新调度。

  2. 特殊情况下,当一个高优先级的Pod调度失败后,该Pod并不会被“搁置”,而是会“挤走”某个Node上的一些低优先级的Pod,这样就能保证这个高优先级Pod的调度成功。

v1.10版本之后,要使用这个机制,需要在kubernetes里提交一个PriorityClass的定义,如下所示:

# 创建PriorityClass
apiVersion: scheduling.k8s.io/v1beta1
kind: PriorityClass
metadata:
  name: high-priority
value: 1000000              # 一百万
globalDefault: false        # 设置为true意味着PriorityClass的值会成为系统的默认值
                            # false表示该PriorityClass的Pod拥有值为1000000的优先级
                            # 没有声明PriorityClass的Pod来说,优先级为0
description: "This priority class should be used for high priority service pods only."

# 创建Pod,声明使用上面的PriorityClass
apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    env: test
spec:
  containers:
  - name: nginx
    image: nginx
    imagePullPolicy: IfNotPresent
  priorityClassName: high-priority  # Pod提交后,kubernetes的PriorityAdmissionController
                                    # 就会自动将pod的spec.priority字段设置为1000000

kubernetes规定,优先级是一个32bit的整数,最大值不超过十亿,并且值越大代表优先级越高。而大于十亿的值,其实是被kubernetes保留下来分配给系统Pod使用的。这样的目的就是保证系统Pod不会被用户抢占掉。

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

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

将高优先级Pod称为抢占者,当上述抢占过程发生时:

  1. 抢占者并不会立刻被调度到被抢占的Node上,调度器只会将抢占者的spec.nominatedNodeName字段,设置为被抢占的Node的名字

  2. 抢占者会重新进入下一个调度周期

  3. 在新的调度周期来决定是不是要运作在被抢占的节点上

  4. 在下一个周期,调度器也不能保证抢占者一定会运行在被抢占的节点上

这样设计的重要原因是,调度器只会通过标准的DELETE API来删除被抢占的Pod,所有,这些Pod必然是有一定的“优雅退出”时间(默认30秒),在这段时间里,其他节点也可能会变成能够调度的,或者有新节点加入集群中,所以鉴于优雅退出期间,集群的可调度性可能会发生变化,把抢占者交给下一个调度周期再处理,是一个非常合理的选择。 在抢占者等待被调度的过程中,如果有其他更高优先级的Pod也要抢占同一个节点,那么调度器就会清空原抢占者的spec.nominatedNodeName字段,从而运行更高级别的抢占者执行抢占,并且原抢占者也有机会重新抢占其他节点,这是设置nominatedNodeName字段的主要目的。

抢占机制的设计

抢占发生的原因一定是一个高优先级的Pod调度失败,高优先级Pod成为“抢占者”,被抢占的Pod为“牺牲者”。

kubernetes调度器实现抢占算法的一个重要设计就是在调度队列的实现里,使用了两个不同的队列:

  • activeQ:凡是在activeQ里的Pod都是下一个调度周期需要调度的对象

  • unschedulableQ:专门用来存储调度失败的Pod,在这个队列中的Pod被更新后,调度器会自动把Pod移动到activeQ,从而给一次重新调度的机会

调度失败后,抢占者进入unschedulableQ,这次失败时间会触发调度器为抢占者寻找牺牲者的流程:

  1. 调度器检查这次失败事件的原因,来确认抢占是不是可以帮助抢占者找到一个新节点,因为有很多Predicate的失败是不能通过抢占来解决的。

    如PodFitsHost算法,负责检查Pod的nodeSelector与Node的名字是否匹配,除非Node的名字发生变化,否则即使删除再多Pod也不能调度成功的

  2. 如果确定抢占可以发生,那么调度器就会把自己缓存的所有节点信息复制一份,然后使用这个副本来模拟抢占过程

抢占的过程就是调度器检查缓存副本里的每一个节点,然后从该节点上最低优先级的Pod开始,逐一删除这些Pod,每删除一个Pod调度器都会检查一下抢占者是否能够运行在该Node上,一旦可以运行,调度器就会记录下这个Node的名字和被删除Pod的列表,这就是一次抢占过程的结果。

抢占操作

得到了最佳的抢占结果之后,这个结果里的Node,就是即将被抢占的Node,被删除的Pod列表就是牺牲者,然后调度器开始真正的抢占操作,分为三个步骤:

  1. 调度器检查牺牲者列表,清理这些Pod所携带的nominatedNodeName字段

  2. 调度器会把抢占者的nominatedNodeName设置为被抢占者的Node名字(此处出发从unschedulableQ到activeQ的过程)

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

  4. 调度器通过正常的调度流程把抢占者调度成功(在这个正常的调度流程里,一切皆有可能,所有调度器并不会保证抢占的结果)

对于任何一个待调度Pod来说,因为存在上述抢占者,它的调度过程,是有一些特殊情况需要处理的,具体来说,在为某一对Pod和Node执行Predicates算法的时候,如果待检测的Node是一个即将被抢占的节点,即调度队列里有nominatedNodeName字段值是该Node名字的Pod存在(潜在抢占者),调度器就会对这个Node将同样的Predicates算法运行两遍。

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

  • 第二遍,调度器正常执行Predicates算法,不考虑潜在的抢占者

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

执行第一遍的原因是InterPodAffinity规则的存在,该规则关系待考察节点上所有Pod之间的互斥性,所以在执行调度算法时必须考虑,如果抢占者已经存在于待考察Node上,待调度Pod还能不能调度成功。这里只需要考虑优先级大于等于待调度Pod的抢占者。

执行第二遍Predicates算法的原因是,潜在抢占者最后不一定会运行在待考察的Node上。因为kubernetes调度器并不会保证抢占者一定会运行在当初选定的被抢占的Node上。

Pod的优先级和抢占机制是在v1.11版之后是Beta了,性能稳定可以使用,从而提高集群的资源利用率。

当整个集群发生可能会影响调度结果的变化,如添加或者更新Node、添加或者更新PV、Service等,调度器会执行MoveAllToActiveQueue的操作,把所有调度失败的Pod从unschedulableQ移动到activeQ里面。

当一个已经调度成功的Pod被更新时,调度器会将unschedulableQ里所更整个Pod有Affinity/Anti-Affinity关系的Pod,移动activeQ里面。

Device Plugin

对于云的用户来说,在GPU的支持上,只要在Pod的YAML文件中声明,某个容器需要的GPU个数,那么kubernetes创建的容器里就应该出现对应的GPU设备,以及它所对应的驱动目录。

以NVIDIA的GPU设备为例,上面的需求就意味着当用户的容器创建之后,这个容器里必须出现如下两部分设备和目录:

  1. GPU设备:/dev/nvidia0,这个是容器启动时的Devices参数

  2. GPU驱动目录:/usr/local/nvidia/*,这个是容器启动是Volume参数

在kubernetes的GPU支持的实现中,kubelet实际上就是将上述两部分内容,设置在了创建该容器的CRI参数里。这样等容器启动之后,对应的容器里就会出现GPU设备和驱动路径。

kubernetes在Pod的API对象里,并没有为GPU专门设置一个资源类型字段,使用Extended Resource的特殊字段来负责传递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为用户设置的一种对自定义资源的支持

为了让调度器知道这个自定义类型的资源在每台宿主机上的可用量,宿主机节点本身,就必须能够想 APIServer汇报该类型资源的可用量。在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机制,可用如下的一幅图来描述:

  1. 每一种硬件都需要有它所对应的Device Plugin进行管理,这些Device Plugin通过gRPC的方式同kubelet连接起来。

  2. Device Plugin通过ListAndWatch的API,定期向kubelet汇报该Node上GPU的列表。kubelet在拿到这个列表之后,就可以直接在它向APIServer发送的心跳里,以Extended Resource的方式,加上这些GPU的数量,如nvidia.com/gpu=3,用户在这里不需要关心GPU信息向上的汇报流程。

ListAndWatch向上汇报的信息,只要本机上GPU的ID列表,而不会有任何关于GPU设备本身的信息。kubelet在向APIServer汇报的时候,只会汇报该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所管理的容器里使用这些硬件的话,需要遵守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) {}
  }

目前支持的硬件有:

  • FPGA(Field Programmable Gate Array):作为专用集成电路(ASIC)领域中的一种半定制电路,既解决了定制电路的不足,又克服了原有可编程器件门电路数有限的缺点

  • SR-IOV(Single Root I/O Virtualization):启用SR-IOV将大大减轻宿主机的CPU负荷,提高网络性能,降低网络时延等

  • RDMA(remote direct memory access):绕过远程主机操作系统内核访问其内存中数据的技术,不经过操作系统,节省大量CPU资源,提高系统吞吐量、降低系统的网络通信延迟,适合大规模并行计算机集群

GPU硬件设备的调度工作,实际上是由kubelet完成的,kubelet会负责从它所持有的硬件设备列表中,为容器挑选一个硬件设备,然后调用Device Plugin的Allocate API来完成这个分配操作。在整条链路中,调度器扮演的角色,仅仅是为Pod寻找到可用的、支持这种硬件设备的节点而已。

这使得kubernetes里对硬件设备的管理、只能处于”设备个数“这唯一一种情况,一旦设备是异构的,不能简单地用数目去描述具体使用需求的时候(如Pod想要运行计算能力最强的那个GPU上),Device Plugin就完全不能处理了。

在很多场景下,希望在调度器进行调度的时候,可以根据整个集群里的某种硬件设备的全局分布,做出一个最佳的调度选择。

上述Device Plugin的设计,使得kubernetes里,缺乏一种能够对Device进行描述的API对象,这使得如果硬件设备本身的属性比较复杂,并且Pod也关系这些硬件的属性的时,Device Plugin完全没办法支持。

目前,kubernetes的Device Plugin的设计,覆盖的场景非常单一,能用却不好用,Device Plugin的API本身的扩展性也不好。

最后更新于

这有帮助吗?