kube-scheduler是 kubernetes 系统的核心组件之一,主要负责整个集群资源的调度功能,根据特定的调度算法和策略,将 Pod 调度到最优的工作节点上面去,从而更加合理、更加充分的利用集群的资源。

部署使用

物理部署

直接使用二进制文件启动就可以

kube-scheduler [flags]

比如

 /usr/bin/kube-scheduler --logtostderr=true --v=4 --master=http://10.243.129.252:8080 --address=0.0.0.0 --master=http://10.243.129.252:8080 --leader-elect=true --v=5 --log-dir=/k8s_log/kubernetes --use-legacy-policy-config=true --policy-config-file=/etc/kubernetes/scheduler-policy.config

flags有很多,具体使用的时候可以查看官网

这边简单的说几个常用的

//配置文件
--config=/etc/kubernetes/config/kube-scheduler.yaml
//策略文件
--use-legacy-policy-config=true
--policy-config-file=/etc/kubernetes/scheduler-policy.config

配置文件

根据上面参数配置配置文件,我们来看看配置文件内容

cat /etc/kubernetes/config/kube-scheduler.yaml
apiVersion: kubescheduler.config.k8s.io/v1alpha1
kind: KubeSchedulerConfiguration
schedulerName: my-kube-scheduler
algorithmSource:
  policy:
    configMap:
      namespace: kube-system
      name: my-scheduler-policy
leaderElection:
  leaderElect: false
  lockObjectName: my-kube-scheduler
  lockObjectNamespace: kube-system

配置文件应该包含一个 KubeSchedulerConfiguration 对象,yaml文件指定了调度器的一些参数,包括leader选举,调度算法策略的选择(可以是configmaps,也可以是具体的json或者yaml文件),以及指定调度器的名称为my-kube-scheduler。

这边我们还需要了解一下配置文件中指定的策略问题,也可以直接使用启动参数进行配置–use-legacy-policy-config=true –policy-config-file=/etc/kubernetes/scheduler-policy.config,这个是scheduler运行的关键。

# cat /etc/kubernetes/scheduler-policy.config
{
"kind" : "Policy",
"apiVersion" : "v1",
"predicates" : [
    {"name" : "CheckNodeUnschedulable"},
    {"name" : "GeneralPredicates"},
    {"name" : "NoDiskConflict"},
    {"name" : "PodToleratesNodeTaints"},
    {"name" : "LimitSRIOVQuantity"},
    {"name" : "Reschedule"},
    {"name" : "Mutex"},
    {"name" : "AppLimit"},
    {"name" : "HAschedule"},
    {"name" : "CheckVolumeBinding"},
    {"name" : "MaxOssBucketCount"},
    {"name" : "MaxCSIVolumeCountPred"},
    {"name" : "MatchInterPodAffinity"}
    ],
"priorities" : [
    {"name" : "AppLimitPriority", "weight" : 1},
    {"name" : "AppServiceSpreadPriority", "weight" : 1},
    {"name" : "BalancedResourceAllocation", "weight" : 1},
    {"name" : "DiskIOPSPriority", "weight" : 1},
    {"name" : "GpuBinpackPriority", "weight" : 20},
    {"name" : "HAschedulerPriority", "weight" : 1},
    {"name" : "ImageLocalityPriority", "weight" : 1},
    {"name" : "InterPodAffinityPriority", "weight" : 1},
    {"name" : "NetworkBandwidthPriority", "weight" : 1},
    {"name" : "NodeAffinityPriority", "weight" : 1},
    {"name" : "ReschedulePriority", "weight" : 1},
    {"name" : "SelectorSpreadPriority", "weight" : 1},
    {"name" : "LeastRequestedPriority", "weight" : 1},
    {"name" : "TaintTolerationPriority", "weight" : 1}
    ]
}

可以看到配置预选和优选策略,也是调度的算法,默认有一个启动配置,可以根据需要进行修改和扩展。

容器部署

1、创建kube-scheduler的配置文件和策略文件的configmap

apiVersion: v1
kind: ConfigMap
metadata:
  name: my-scheduler-config
  namespace: kube-system
data:
  config.yaml: |
    apiVersion: kubescheduler.config.k8s.io/v1alpha1
    kind: KubeSchedulerConfiguration
    schedulerName: my-kube-scheduler
    algorithmSource:
      policy:
        configMap:
          namespace: kube-system
          name: my-scheduler-policy
    leaderElection:
      leaderElect: false
      lockObjectName: my-kube-scheduler
      lockObjectNamespace: kube-system
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: my-scheduler-policy
  namespace: kube-system
data:
 policy.cfg : |
  {
    "kind" : "Policy",
    "apiVersion" : "v1",
    "predicates" : [
      {"name" : "PodFitsHostPorts"},
      {"name" : "PodFitsResources"},
      {"name" : "NoDiskConflict"},
      {"name" : "MatchNodeSelector"},
      {"name" : "HostName"}
    ],
    "priorities" : [
      {"name" : "LeastRequestedPriority", "weight" : 1},
      {"name" : "BalancedResourceAllocation", "weight" : 1},
      {"name" : "ServiceSpreadingPriority", "weight" : 1},
      {"name" : "EqualPriority", "weight" : 1}
    ],
    "extenders" : [{
      "urlPrefix": "http://10.168.107.12:80/scheduler",
      "filterVerb": "predicates/always_true",
      "prioritizeVerb": "priorities/zero_score",
      "preemptVerb": "preemption",
      "bindVerb": "",
      "weight": 1,
      "enableHttps": false,
      "nodeCacheCapable": false
    }],
    "hardPodAffinitySymmetricWeight" : 10
  }

创建一个名为my-scheduler-config的configmaps,该配置文件应该包含一个 KubeSchedulerConfiguration 对象,data下的config.yaml文件指定了调度器的一些参数,包括leader选举,调度算法策略的选择(指定另一个configmaps),以及指定调度器的名称为my-kube-scheduler。

相应的创建一个my-scheduler-policy的configmaps,里面指定了选择哪些预选、优选策略,以及外部扩展调度程序的urlPrefix、扩展预选URI、扩展优选URI、扩展pod优先级抢占URI、扩展bind URI、扩展优选算法的权重等,可以根据需求进行调整。

2、yaml文件中将configmaps:my-scheduler-config以文件的形式挂载到容器内/my-scheduler目录下,并在启动参数中指定–config=/my-scheduler/config.yaml,启动kube-scheduler。

高可用

k8s中kube-scheuler的高可用是通过leaderElection实现的,一般三个master,哪一个先起来就是leader, 虽然两台机器上都安装了scheduler, 但是只有leader提供服务, 另外两个上面的scheduler是处于等待状态, 并没有真正运行自己的逻辑。

当leader异常后,其他的scheduler服务就会成为leaeder,继续提供服务。

当我们部署多个调度器的时候,每个调度器都会各自调度属于自己的pod。

调度流程

核心:待调度的pod列表、可有的合适的node列表、调度算法和策略。

1、首先,客户端通过 API Server 的 REST API 或者 kubectl 工具创建 Pod 资源

2、API Server 收到用户请求后,存储相关数据到 etcd 数据库中

3、watch apiserver,将 spec.nodeName 为空的 Pod 放入调度器内部的调度队列中

4、调度器监听 API Server 查看为调度(bind)的 Pod 列表,循环遍历地为每个 Pod 尝试分配节点

  • 从调度队列中 Pop 出一个 Pod,开始一个标准的调度周期
  • 预选阶段(Predicates),过滤节点,调度器用一组规则过滤掉不符合要求的 Node 节点,比如 Pod 设置了资源的 request,那么可用资源比 Pod 需要的资源少的主机显然就会被过滤掉
  • 优选阶段(Priorities),为节点的优先级打分,将上一阶段过滤出来的 Node 列表进行打分,调度器会考虑一些整体的优化策略,比如把 Deployment 控制的多个 Pod 副本分布到不同的主机上,使用最低负载的主机等等策略

4、经过上面的阶段过滤后选择打分最高的 Node 节点和 Pod 进行 binding 操作,然后将结果存储到 etcd 中

5、最后被选择出来的 Node 节点对应的 kubelet 去执行创建 Pod 的相关操作

预选策略

我们在部署应用的时候,如果发现有 Pod 一直处于 Pending 状态,那么就是没有满足调度条件的节点,这个时候可以去检查下节点资源是否可用。

在 Kubernetes 中,默认的调度策略有如下三种。

GeneralPredicates

第一种类型,叫作 GeneralPredicates。顾名思义,这一组过滤规则,负责的是最基础的调度策略。比如,PodFitsResources 计算的就是宿主机的 CPU 和内存资源等是否够用。

podFistResources:资源要求

判断备选节点资源是否满足备选pod的需求,检测过程如下:

  • 计算备选pod和节点中已存在的pod的所有容器的需求资源(CPU 和内存)的总和
  • 获得备选节点的状态信息,其中包括节点的资源信息
  • 如果备选pod和节点中已存在pod的所有容器的需求资源(CPU和内存)的总和超出了备选节点拥有的资源,则返回false,表明备选节点不适合备选pod,否则返回true,表明备选节点适合备选pod

PodSelectorMatches:标签匹配

判断备选节点是否包含备选pod的标签选择器指定的标签:

  • 如果pod没有指定spec.nodeSelector标签选择器,则返回true
  • 如果获得备选节点的标签信息,判断节点是否包含备选pod的标签选择器所指的标签,如果包含返回true,不包含返回false

PodFitsHost

判断备选pod的spec.nodeName域所指定的节点名称和备选节点的名称是否一致,如果一致返回true,否则返回false。

PodFitsPorts

判断备选pod所用的端口列表汇中的端口是否在备选节点中被占用,如果被占用,则返回false,否则返回true。

PodFitsHostPorts

节点上已经使用的 port 是否和 Pod 申请的 port 冲突

Volume相关

第二种类型,是与 Volume 相关的过滤规则。这一组过滤规则,负责的是跟容器持久化 Volume 相关的调度策略。

NoDiskconflict:磁盘冲突

判断备选pod的gcePersistentDisk或者AWSElasticBlockStore和备选的节点中已存在的pod是否存在冲突具体检测过程如下:

  • 首先,读取备选pod的所有的volume信息,对每一个volume执行一下步骤的冲突检测
  • 如果该volume是gcePersistentDisk,则将volume和备选节点上的所有pod的每个volume进行比较,如果发现相同的gcePersistentDisk,则返回false,表明磁盘冲突,检测结束,反馈给调度器该备选节点不合适作为备选的pod,如果volume是AWSElasticBlockStore,则将volume和备选节点上的所有pod的每个volume进行比较,如果发现相同的AWSElasticBlockStore,则返回false,表明磁盘冲突,检测结束,反馈给调度器该备选节点不合适作为备选的pod
  • 最终,检查备选pod的所有的volume均为发现冲突,则返回true,表明不存在磁盘冲突,反馈给调度器该备选节点合适备选pod

MaxPDVolumeCountPredicate

MaxPDVolumeCountPredicate 检查的条件,则是一个节点上某种类型的持久化 Volume 是不是已经超过了一定数目,如果是的话,那么声明使用该类型持久化 Volume 的 Pod 就不能再调度到这个节点了。

VolumeZonePredicate

VolumeZonePredicate,则是检查持久化 Volume 的 Zone(高可用域)标签,是否与待考察节点的 Zone 标签相匹配。此外,这里还有一个叫作 VolumeBindingPredicate 的规则。它负责检查的,是该 Pod 对应的 PV 的 nodeAffinity 字段,是否跟某个节点的标签相匹配。

宿主机相关

第三种类型,是宿主机相关的过滤规则。这一组规则,主要考察待调度 Pod 是否满足 Node 本身的某些条件。比如,PodToleratesNodeTaints,负责检查的就是我们前面经常用到的 Node 的“污点”机制。只有当 Pod 的 Toleration 字段与 Node 的 Taint 字段能够匹配的时候,这个 Pod 才能被调度到该节点上。

其他

Predicates过滤有一系列的算法可以使用,上面就是简单的列举几个,还有很多,更多更详细的我们可以查看源码文件:k8s.io/kubernetes/pkg/scheduler/algorithm/predicates/predicates.go。

虽然Predicates是串行的,但是当开始调度一个 Pod 时,Kubernetes 调度器会同时启动 16 个 Goroutine,来并发地为集群里的所有 Node 计算 Predicates,最后返回可以运行这个 Pod 的宿主机列表。

优选策略

在 Predicates 阶段完成了节点的“过滤”之后,Priorities 阶段的工作就是为这些节点打分。这里打分的范围是 0-10 分,得分最高的节点就是最后被 Pod 绑定的最佳节点。Priorities 里最常用到的一个打分规则,是 LeastRequestedPriority。

leastRequestedPriority

该策略用于从备选节点列表中选出资源消耗最小的节点:

  • 计算出所有备选节点上运行的pod和备选pod的CPU占用量
  • 计算出所有备选节点上运行的pod和备选pod的memory占用量
  • 根据特定的算法,计算每个节点的得分

公式如下

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

实际上就是在选择空闲资源(CPU 和 Memory)最多的宿主机。

CalculateNodeLabelPriority

如果用户在配置中指定了该策略,则scheduler会通过registerCustomPriorityFunction方法注册该策略。该策略用于判断策略列出的标签在备选节点中存在时,是否选择该备选节点。如果备选节点的标签在优选策略的标签列表中且优选策略的presence值为true,或者备选节点的标签不在优选策略的标签列表中且优选策略的presence值为false,则备选节点score=10,否则等于0。

BalancedResourceAllocation

该优选策略用于从备选节点列表中选出各项资源使用率最均衡的节点:

  • 计算出所有备选节点上运行的pod和备选pod的CPU占用量
  • 计算出所有备选节点上运行的pod和备选pod的memory占用量
  • 根据特定的算法,计算每个节点的得分

公式

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

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

其他

还有很多其他的策略,比如如下等等

SelectorSpreadPriority:为了更好的高可用,对同属于一个 Deployment 或者 RC 下面的多个 Pod 副本,尽量调度到多个不同的节点上,当一个 Pod 被调度的时候,会先去查找该 Pod 对应的 controller,然后查看该 controller 下面的已存在的 Pod,运行 Pod 越少的节点权重越高
ImageLocalityPriority:就是如果在某个节点上已经有要使用的镜像节点了,镜像总大小值越大,权重就越高
NodeAffinityPriority:这个就是根据节点的亲和性来计算一个权重值,后面我们会详细讲解亲和性的使用方法

同样我们可以查看源码文件:k8s.io/kubernetes/pkg/scheduler/algorithm/priorities/ 了解更多信息。每一个优先级函数会返回一个0-10的分数,分数越高表示节点越优,同时每一个函数也会对应一个表示权重的值。最终主机的得分用以下公式计算得出:

finalScoreNode = (weight1 * priorityFunc1) + (weight2 * priorityFunc2) + … + (weightn * priorityFuncn)

priority functions 集合中的每一个函数都有一个权重 (weight),最终的值为 weight 和 priority functions 的乘积,而一个节点的 weight 就是所有 priority functions 结果的加和

亲和性

在我们实际使用中,有着很多的使用的地方,比如指定node,屏蔽node。

指定 Node 节点调度

有三种方式指定 Pod 只运行在指定的 Node 节点上

  • nodeSelector:只调度到匹配指定 label 的 Node 上
  • nodeAffinity:功能更丰富的 Node 选择器,比如支持集合操作
  • podAffinity:调度到满足条件的 Pod 所在的 Node 上

1、nodeSelector

首先给 Node 打上标签

kubectl label nodes node-01 disktype=ssd

然后在 daemonset 中指定 nodeSelector 为 disktype=ssd:

spec:
  nodeSelector:
    disktype: ssd

根据默认调度器的PodSelectorMatches策略选出合适的节点,然后打分进行分配。

2、nodeAffinity

nodeAffinity 目前支持两种:requiredDuringSchedulingIgnoredDuringExecution 和 preferredDuringSchedulingIgnoredDuringExecution,分别代表必须满足条件和优选条件。比如下面的例子代表调度到包含标签 kubernetes.io/e2e-az-name 并且值为 e2e-az1 或 e2e-az2 的 Node 上,并且优选还带有标签 another-node-label-key=another-node-label-value 的 Node。

apiVersion: v1
kind: Pod
metadata:
  name: with-node-affinity
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: kubernetes.io/e2e-az-name
            operator: In
            values:
            - e2e-az1
            - e2e-az2
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 1
        preference:
          matchExpressions:
          - key: another-node-label-key
            operator: In
            values:
            - another-node-label-value
  containers:
  - name: with-node-affinity
    image: gcr.io/google_containers/pause:2.0

3、podAffinity

podAffinity 基于 Pod 的标签来选择 Node,仅调度到满足条件 Pod 所在的 Node 上,支持 podAffinity 和 podAntiAffinity。这个功能比较绕,以下面的例子为例:

如果一个 “Node 所在 Zone 中包含至少一个带有 security=S1 标签且运行中的 Pod”,那么可以调度到该 Node

不调度到 “包含至少一个带有 security=S2 标签且运行中 Pod” 的 Node 上

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
    podAntiAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          labelSelector:
            matchExpressions:
            - key: security
              operator: In
              values:
              - S2
          topologyKey: kubernetes.io/hostname
  containers:
  - name: with-pod-affinity
    image: gcr.io/google_containers/pause:2.0

可以看出基本上都是根据label进行调度选择。

亲和性(Affinity)与非亲和性(anti-affinity)

在上面会说亲和性的操作更加多样化,所以我们一般都是使用亲和性而不是使用selector在调度中。亲和性主要分为3种类型:node affinity与inter-pod affinity/anti-affinity

  • node affinity我们在deployment的调度的时候说明过亲和性调度的使用,主要是两个策略:强制和优先。调度到符合条件的node上去。
  • pod Affinity用于调度pod可以和哪些pod部署在同一拓扑结构之下。
  • podAntiAffinity相反,其用于规定pod不可以和哪些pod部署在同一拓扑结构下。AntiAffinity同样有两个策略:强制和优先。

屏蔽 Node 节点调度

Taints 和 tolerations 用于保证 Pod 不被调度到不合适的 Node 上,其中 Taint 应用于 Node 上,而 toleration 则应用于 Pod 上。

目前支持的 taint 类型

  • NoSchedule:新的 Pod 不调度到该 Node 上,不影响正在运行的 Pod
  • PreferNoSchedule:soft 版的 NoSchedule,尽量不调度到该 Node 上
  • NoExecute:新的 Pod 不调度到该 Node 上,并且删除(evict)已在运行的 Pod。Pod 可以增加一个时间(tolerationSeconds),

然而,当 Pod 的 Tolerations 匹配 Node 的所有 Taints 的时候可以调度到该 Node 上;当 Pod 是已经运行的时候,也不会被删除(evicted)。另外对于 NoExecute,如果 Pod 增加了一个 tolerationSeconds,则会在该时间之后才删除 Pod。

比如,假设 node1 上应用以下几个 taint

kubectl taint nodes node1 key1=value1:NoSchedule
kubectl taint nodes node1 key1=value1:NoExecute
kubectl taint nodes node1 key2=value2:NoSchedule

下面的这个 Pod 由于没有 toleratekey2=value2:NoSchedule 无法调度到 node1 上

tolerations:
- key: "key1"
  operator: "Equal"
  value: "value1"
  effect: "NoSchedule"
- key: "key1"
  operator: "Equal"
  value: "value1"
  effect: "NoExecute"

而正在运行且带有 tolerationSeconds 的 Pod 则会在 600s 之后删除

tolerations:
- key: "key1"
  operator: "Equal"
  value: "value1"
  effect: "NoSchedule"
- key: "key1"
  operator: "Equal"
  value: "value1"
  effect: "NoExecute"
  tolerationSeconds: 600
- key: "key2"
  operator: "Equal"
  value: "value2"
  effect: "NoSchedule"

注意,DaemonSet 创建的 Pod 会自动加上对 node.alpha.kubernetes.io/unreachable 和 node.alpha.kubernetes.io/notReady 的 NoExecute Toleration,以避免它们因此被删除。

核心原理

可以看到,Kubernetes 的调度器的核心,实际上就是两个相互独立的控制循环。

Informer Path

第一个控制循环,我们可以称之为 Informer Path。它的主要目的,是启动一系列 Informer,用来监听(Watch)Etcd 中 Pod、Node、Service 等与调度相关的 API 对象的变化。比如,当一个待调度 Pod(即:它的 nodeName 字段是空的)被创建出来之后,调度器就会通过 Pod Informer 的 Handler,将这个待调度 Pod 添加进调度队列。在默认情况下,Kubernetes 的调度队列是一个 PriorityQueue(优先级队列),并且当某些集群信息发生变化的时候,调度器还会对调度队列里的内容进行一些特殊操作。这里的设计,主要是出于调度优先级和抢占的考虑,我会在后面再详细介绍这部分内容。此外,Kubernetes 的默认调度器还要负责对调度器缓存(即:scheduler cache)进行更新。事实上,Kubernetes 调度部分进行性能优化的一个最根本原则,就是尽最大可能将集群信息 Cache 化,以便从根本上提高 Predicate 和 Priority 调度算法的执行效率。

Scheduling Path

第二个控制循环,是调度器负责 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 端的二次确认。

除了上述的“Cache 化”和“乐观绑定”,Kubernetes 默认调度器还有一个重要的设计,那就是“无锁化”。在 Scheduling Path 上,调度器会启动多个 Goroutine 以节点为粒度并发执行 Predicates 算法,从而提高这一阶段的执行效率。而与之类似的,Priorities 算法也会以 MapReduce 的方式并行计算然后再进行汇总。而在这些所有需要并发的路径上,调度器会避免设置任何全局的竞争资源,从而免去了使用锁进行同步带来的巨大的性能损耗。所以,在这种思想的指导下,如果你再去查看一下前面的调度器原理图,你就会发现,Kubernetes 调度器只有对调度队列和 Scheduler Cache 进行操作时,才需要加锁。而这两部分操作,都不在 Scheduling Path 的算法执行路径上。

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

优先级调度

Kubernetes 规定,优先级是一个 32 bit 的整数,最大值不超过 1000000000(10 亿,1 billion),并且值越大代表优先级越高。而超出 10 亿的值,其实是被 Kubernetes 保留下来分配给系统 Pod 使用的,而对于没有声明 PriorityClass 的 Pod 来说,它们的优先级就是 0。

Pod 的优先级,高优先级的 Pod 会优先被调度,或者在资源不足低情况牺牲低优先级的 Pod,以便于重要的 Pod 能够得到资源部署。

要定义 Pod 优先级,就需要先定义PriorityClass对象,该对象没有 Namespace 的限制:

apiVersion: v1
kind: PriorityClass
metadata:
  name: high-priority
value: 1000000
globalDefault: false
description: "This priority class should be used for XYZ service pods only."

其中:

value为 32 位整数的优先级,该值越大,优先级越高
globalDefault用于未配置 PriorityClassName 的 Pod,整个集群中应该只有一个PriorityClass将其设置为 true

然后通过在 Pod 的spec.priorityClassName中指定已定义的PriorityClass名称即可:

apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  containers:
  - name: nginx
    image: nginx
    imagePullPolicy: IfNotPresent
  priorityClassName: high-priority

另外一个值得注意的是当节点没有足够的资源供调度器调度 Pod,导致 Pod 处于 pending 时,抢占(preemption)逻辑就会被触发。Preemption会尝试从一个节点删除低优先级的 Pod,从而释放资源使高优先级的 Pod 得到节点资源进行部署。

抢占式调度

而当一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发。这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被删除后,待调度的高优先级 Pod 就可以被调度到这个节点上。

当上述抢占过程发生时,抢占者并不会立刻被调度到被抢占的 Node 上。事实上,调度器只会将抢占者的 spec.nominatedNodeName 字段,设置为被抢占的 Node 的名字。然后,抢占者会重新进入下一个调度周期,然后在新的调度周期里来决定是不是要运行在被抢占的节点上。这当然也就意味着,即使在下一个调度周期,调度器也不会保证抢占者一定会运行在被抢占的节点上。这样设计的一个重要原因是,调度器只会通过标准的 DELETE API 来删除被抢占的 Pod,所以,这些 Pod 必然是有一定的“优雅退出”时间(默认是 30s)的。而在这段时间里,其他的节点也是有可能变成可调度的,或者直接有新的节点被添加到这个集群中来。所以,鉴于优雅退出期间,集群的可调度性可能会发生的变化,把抢占者交给下一个调度周期再处理,是一个非常合理的选择。而在抢占者等待被调度的过程中,如果有其他更高优先级的 Pod 也要抢占同一个节点,那么调度器就会清空原抢占者的 spec.nominatedNodeName 字段,从而允许更高优先级的抢占者执行抢占,并且,这也就使得原抢占者本身,也有机会去重新抢占其他节点。这些,都是设置 nominatedNodeName 字段的主要目的。

Kubernetes 调度器里的抢占机制原理

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

  • 第一个队列,叫作 activeQ。凡是在 activeQ 里的 Pod,都是下一个调度周期需要调度的对象。所以,当你在 Kubernetes 集群里新创建一个 Pod 的时候,调度器会将这个 Pod 入队到 activeQ 里面。而我在前面提到过的、调度器不断从队列里出队(Pop)一个 Pod 进行调度,实际上都是从 activeQ 里出队的。
  • 第二个队列,叫作 unschedulableQ,专门用来存放调度失败的 Pod。

调度失败之后,抢占者就会被放进 unschedulableQ 里面。然后,这次失败事件就会触发调度器为抢占者寻找牺牲者的流程。

  • 第一步,调度器会检查这次失败事件的原因,来确认抢占是不是可以帮助抢占者找到一个新节点。这是因为有很多 Predicates 的失败是不能通过抢占来解决的。比如,PodFitsHost 算法(负责的是,检查 Pod 的 nodeSelector 与 Node 的名字是否匹配),这种情况下,除非 Node 的名字发生变化,否则你即使删除再多的 Pod,抢占者也不可能调度成功。当遍历完所有的节点之后,调度器会在上述模拟产生的所有抢占结果里做一个选择,找出最佳结果。而这一步的判断原则,就是尽量减少抢占对整个系统的影响。比如,需要抢占的 Pod 越少越好,需要抢占的 Pod 的优先级越低越好,等等。
  • 第二步,如果确定抢占可以发生,那么调度器就会把自己缓存的所有节点信息复制一份,然后使用这个副本来模拟抢占过程。在得到了最佳的抢占结果之后,这个结果里的 Node,就是即将被抢占的 Node;被删除的 Pod 列表,就是牺牲者。所以接下来,调度器就可以真正开始抢占的操作了,这个过程,可以分为三步。
    • 第一步,调度器会检查牺牲者列表,清理这些 Pod 所携带的 nominatedNodeName 字段
    • 第二步,调度器会把抢占者的 nominatedNodeName,设置为被抢占的 Node 的名字。
    • 第三步,调度器会开启一个 Goroutine,同步地删除牺牲者。

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

调度器就会对这个 Node ,将同样的 Predicates 算法运行两遍。

  • 第一遍, 调度器会假设上述“潜在的抢占者”已经运行在这个节点上,然后执行 Predicates 算法;
  • 第二遍, 调度器会正常执行 Predicates 算法,即:不考虑任何“潜在的抢占者”。

不难想到,这里需要执行第一遍 Predicates 算法的原因,是由于 InterPodAntiAffinity 规则的存在。由于 InterPodAntiAffinity 规则关心待考察节点上所有 Pod 之间的互斥关系,所以我们在执行调度算法时必须考虑,如果抢占者已经存在于待考察 Node 上时,待调度 Pod 还能不能调度成功。当然,这也就意味着,我们在这一步只需要考虑那些优先级等于或者大于待调度 Pod 的抢占者。毕竟对于其他较低优先级 Pod 来说,待调度 Pod 总是可以通过抢占运行在待考察 Node 上。而我们需要执行第二遍 Predicates 算法的原因,则是因为“潜在的抢占者”最后不一定会运行在待考察的 Node 上。关于这一点,我在前面已经讲解过了:Kubernetes 调度器并不保证抢占者一定会运行在当初选定的被抢占的 Node 上。

自定义调度器

要编写一个优秀的调度器却不容易,因为要考虑的东西很多:

  • 尽可能地将 workload 平均到不同的节点,减少单个节点宕机造成的损失
  • 可扩展性。随着集群规模的增加,怎么保证调度器不会成为性能的瓶颈
  • 高可用。调度器能做组成集群,任何一个调度器出现问题,不会影响整个集群的调度
  • 灵活性。不同的用户有不同的调度需求,一个优秀的调度器还要允许用户能配置不同的调度算法
  • 资源合理和高效利用。调度器应该尽可能地提高集群的资源利用率,防止资源的浪费

一般来说,我们有4种扩展 Kubernetes 调度器的方法。

  • 直接 clone 官方的 kube-scheduler 源代码,在合适的位置直接修改代码,然后重新编译运行修改后的程序,当然这种方法是最不建议使用的,也不实用,因为需要花费大量额外的精力来和上游的调度程序更改保持一致。
  • 和默认的调度程序一起运行独立的调度程序,默认的调度器和我们自定义的调度器可以通过 Pod 的 spec.schedulerName 来覆盖各自的 Pod,默认是使用 default 默认的调度器,但是多个调度程序共存的情况下也比较麻烦,比如当多个调度器将 Pod 调度到同一个节点的时候,可能会遇到一些问题,因为很有可能两个调度器都同时将两个 Pod 调度到同一个节点上去,但是很有可能其中一个 Pod 运行后其实资源就消耗完了,并且维护一个高质量的自定义调度程序也不是很容易的,因为我们需要全面了解默认的调度程序,整体 Kubernetes 的架构知识以及各种 Kubernetes API 对象的各种关系或限制。
  • 调度器扩展程序,这个方案目前是一个可行的方案,可以和上游调度程序兼容,所谓的调度器扩展程序其实就是一个可配置的 Webhook 而已,里面包含 过滤器 和 优先级 两个端点,分别对应调度周期中的两个主要阶段(过滤和打分)。
  • 通过调度框架(Scheduling Framework),Kubernetes v1.15 版本中引入了可插拔架构的调度框架,使得定制调度器这个任务变得更加的容易。调库框架向现有的调度器中添加了一组插件化的 API,该 API 在保持调度程序“核心”简单且易于维护的同时,使得大部分的调度功能以插件的形式存在,而且在我们现在的 v1.16 版本中上面的 调度器扩展程序 也已经被废弃了,所以以后调度框架才是自定义调度器的核心方式。

这里我们可以简单介绍下后面三种方式的实现。

调度器扩展程序

1、实现扩展程序

我们直接用 golang 来实现一个简单的调度器扩展程序,当然你可以使用其他任何编程语言,如下所示:

func main() {
    router := httprouter.New()
    router.GET("/", Index)
    router.POST("/filter", Filter)
    router.POST("/prioritize", Prioritize)

    log.Fatal(http.ListenAndServe(":8888", router))
}

然后接下来我们需要实现 /filter 和 /prioritize 两个端点的处理程序。

其中 Filter 这个扩展函数接收一个输入类型为 schedulerapi.ExtenderArgs 的参数,然后返回一个类型为 *schedulerapi.ExtenderFilterResult 的值。在函数中,我们可以进一步过滤输入的节点:

// filter 根据扩展程序定义的预选规则来过滤节点
func filter(args schedulerapi.ExtenderArgs) *schedulerapi.ExtenderFilterResult {
    var filteredNodes []v1.Node
    failedNodes := make(schedulerapi.FailedNodesMap)
    pod := args.Pod

    for _, node := range args.Nodes.Items {
        fits, failReasons, _ := podFitsOnNode(pod, node)
        if fits {
            filteredNodes = append(filteredNodes, node)
        } else {
            failedNodes[node.Name] = strings.Join(failReasons, ",")
        }
    }

    result := schedulerapi.ExtenderFilterResult{
        Nodes: &v1.NodeList{
            Items: filteredNodes,
        },
        FailedNodes: failedNodes,
        Error:       "",
    }

    return &result
}

在过滤函数中,我们循环每个节点然后用我们自己实现的业务逻辑来判断是否应该批准该节点,这里我们实现比较简单,在 podFitsOnNode() 函数中我们只是简单的检查随机数是否为偶数来判断即可,如果是的话我们就认为这是一个幸运的节点,否则拒绝批准该节点。

var predicatesSorted = []string{LuckyPred}

var predicatesFuncs = map[string]FitPredicate{
    LuckyPred: LuckyPredicate,
}

type FitPredicate func(pod *v1.Pod, node v1.Node) (bool, []string, error)

func podFitsOnNode(pod *v1.Pod, node v1.Node) (bool, []string, error) {
    fits := true
    var failReasons []string
    for _, predicateKey := range predicatesSorted {
        fit, failures, err := predicatesFuncs[predicateKey](pod, node)
        if err != nil {
            return false, nil, err
        }
        fits = fits && fit
        failReasons = append(failReasons, failures...)
    }
    return fits, failReasons, nil
}

func LuckyPredicate(pod *v1.Pod, node v1.Node) (bool, []string, error) {
    lucky := rand.Intn(2) == 0
    if lucky {
        log.Printf("pod %v/%v is lucky to fit on node %v\n", pod.Name, pod.Namespace, node.Name)
        return true, nil, nil
    }
    log.Printf("pod %v/%v is unlucky to fit on node %v\n", pod.Name, pod.Namespace, node.Name)
    return false, []string{LuckyPredFailMsg}, nil
}

同样的打分功能用同样的方式来实现,我们在每个节点上随机给出一个分数:

// it's webhooked to pkg/scheduler/core/generic_scheduler.go#PrioritizeNodes()
// 这个函数输出的分数会被添加会默认的调度器
func prioritize(args schedulerapi.ExtenderArgs) *schedulerapi.HostPriorityList {
    pod := args.Pod
    nodes := args.Nodes.Items

    hostPriorityList := make(schedulerapi.HostPriorityList, len(nodes))
    for i, node := range nodes {
        score := rand.Intn(schedulerapi.MaxPriority + 1)  // 在最大优先级内随机取一个值
        log.Printf(luckyPrioMsg, pod.Name, pod.Namespace, score)
        hostPriorityList[i] = schedulerapi.HostPriority{
            Host:  node.Name,
            Score: score,
        }
    }

    return &hostPriorityList
}

然后我们可以使用下面的命令来编译打包我们的应用:

$ GOOS=linux GOARCH=amd64 go build -o app

获得我们的扩展调度器扩展程序的二进制文件app,构建完成后,将应用 app 拷贝到 kube-scheduler 所在的节点直接运行即可,当然也可以使用pod运行,复制一个调度器的 YAML 文件然后更改下 schedulerName 来部署,这样就不会影响默认的调度器了,然后在需要使用这个测试的调度器的 Pod 上面指定 spec.schedulerName 即可。这就是第二种方法的多调度器。

2、注册扩展程序

我们只要在策略的配置文件中注册就可以格式是

apiVersion: v1
kind: Policy
extenders:
- urlPrefix: "http://127.0.0.1:8888/"
  filterVerb: "filter"
  prioritizeVerb: "prioritize"
  weight: 1
  enableHttps: false

将其写到默认的policy文件中

{
    "kind": "Policy",
    "apiVersion": "v1",
    "predicates": [{
        "name": "MatchNodeSelector"
    }, {
        "name": "PodFitsResources"
    }, {
        "name": "PodFitsHostPorts"
    },{
        "name": "HostName"
    }
    ],
    "priorities": [{
        "name": "EqualPriority",
        "weight": 2
    }, {
        "name": "ImageLocalityPriority",
        "weight": 4
    }, {
        "name": "LeastRequestedPriority",
        "weight": 2
    }, {
        "name": "BalancedResourceAllocation",
        "weight": 2
    }
    ],
    "extenders": [{
        "urlPrefix": "http://127.0.0.1:8888/prefix",
        "filterVerb": "filter",
        "prioritizeVerb": "prioritize",
        "weight": 1,
        "bindVerb": "bind",
        "enableHttps": false
    }]
}

然后启动kube-scheduler就行。这时候我们在调度的是时候,是重上到下,这样在过滤和打分阶段结束后,可以将结果分别传递给该扩展程序的端点,可以进一步过滤并确定优先级,以适应我们的特定业务需求。同一个调度器就是这样,多个调度器时候,每一个调度器都是这么个重下倒下过滤调度的流程。

3、验证

现在我们来运行一个 Deployment 查看其工作原理,我们准备一个包含20个副本的部署 Yaml:(test-scheduler.yaml)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pause
spec:
  replicas: 20
  selector:
    matchLabels:
      app: pause
  template:
    metadata:
      labels:
        app: pause
    spec:
      containers:
      - name: pause
        image: gcr.azk8s.cn/google_containers/pause:3.1

直接创建上面的资源对象:

$ kuectl apply -f test-scheduler.yaml
deployment.apps/pause created

这个时候我们去查看下我们编写的调度器扩展程序日志:

$ ./app
......
2020/01/03 12:27:29 pod pause-58584fbc95-bwn7t/default is unlucky to fit on node ydzs-node1
2020/01/03 12:27:29 pod pause-58584fbc95-bwn7t/default is lucky to get score 7
2020/01/03 12:27:29 pod pause-58584fbc95-bwn7t/default is lucky to get score 9
2020/01/03 12:27:29 pod pause-58584fbc95-86w92/default is unlucky to fit on node ydzs-node3
2020/01/03 12:27:29 pod pause-58584fbc95-86w92/default is unlucky to fit on node ydzs-node4
2020/01/03 12:27:29 pod pause-58584fbc95-86w92/default is lucky to fit on node ydzs-node1
2020/01/03 12:27:29 pod pause-58584fbc95-86w92/default is lucky to fit on node ydzs-node2
2020/01/03 12:27:29 pod pause-58584fbc95-86w92/default is lucky to get score 4
2020/01/03 12:27:29 pod pause-58584fbc95-86w92/default is lucky to get score 8
......

我们可以看到 Pod 调度的过程,另外默认调度程序会定期重试失败的 Pod,因此它们将一次又一次地重新传递到我们的调度扩展程序上,我们的逻辑是检查随机数是否为偶数,所以最终所有 Pod 都将处于运行状态。

调度器扩展程序可能是在一些情况下可以满足我们的需求,但是他仍然有一些限制和缺点:

  • 通信成本:数据在默认调度程序和调度器扩展程序之间以 http(s)传输,在执行序列化和反序列化的时候有一定成本
  • 有限的扩展点:扩展程序只能在某些阶段的末尾参与,例如“ Filter”和“ Prioritize”,它们不能在任何阶段的开始或中间被调用
  • 减法优于加法:与默认调度程序传递的节点候选列表相比,我们可能有一些需求需要添加新的候选节点列表,但这是比较冒险的操作,因为不能保证新节点可以通过其他要求,所以,调度器扩展程序最好执行“减法”(进一步过滤),而不是“加法”(添加节点)
  • 缓存共享:上面只是一个简单的测试示例,但在真实的项目中,我们是需要通过查看整个集群的状态来做出调度决策的,默认调度程序可以很好地调度决策,但是无法共享其缓存,这意味着我们必须构建和维护自己的缓存

由于这些局限性,Kubernetes 调度小组就提出了上面第四种方法来进行更好的扩展,也就是调度框架(Scheduler Framework),它基本上可以解决我们遇到的所有难题,现在也已经成官方推荐的扩展方式,所以这将是以后扩展调度器的最主流的方式。

多调度器

上面我们已经开发了一个调度器,我们只要将对应的二进制文件制作成镜像,然后运行在k8s上,并将这个调度器命一个名,比如my-scheduler,给后来的pod进行指定。当然可以参考官方文档

在整个集群中还可以同时运行多个调度器实例,通过podSpec.schedulerName 来选择使用哪一个调度器(默认使用内置的调度器)。

apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  schedulerName: my-scheduler  # 选择使用自定义调度器 my-scheduler
  containers:
  - name: nginx
    image: nginx:1.10

调度框架

Scheduler Framework 将 Pod 的调度过程分为两步:调度(过滤打分)和绑定(延时绑定和绑定)。

调度是为 Pod 选择一个合适的节点,而绑定则是将调度结果提交给集群。调度是顺序执行的,绑定并发执行。无论是在调度还是绑定过程中,如果发生错误或者判断 Pod 不可调度,那么 Pod 就会被重新放回调度队列,等待重新调度。

调度框架定义了一组扩展点,用户可以实现扩展点定义的接口来定义自己的调度逻辑,并将扩展注册到扩展点上,调度框架在执行调度工作流时,遇到对应的扩展点时,将调用用户注册的扩展。调度框架在预留扩展点时,都是有特定的目的,有些扩展点上的扩展可以改变调度程序的决策方法,有些扩展点上的扩展只是发送一个通知。

下图展示了调度框架中的调度上下文及其中的扩展点,一个扩展可以注册多个扩展点,以便可以执行更复杂的有状态的任务。

做一个简单的说明

  • QueueSort 扩展用于对 Pod 的待调度队列进行排序,以决定先调度哪个 Pod,QueueSort 扩展本质上只需要实现一个方法 Less(Pod1, Pod2) 用于比较两个 Pod 谁更优先获得调度即可,同一时间点只能有一个 QueueSort 插件生效。
  • Pre-filter 扩展用于对 Pod 的信息进行预处理,或者检查一些集群或 Pod 必须满足的前提条件,如果 pre-filter 返回了 error,则调度过程终止。
  • Filter 扩展用于排除那些不能运行该 Pod 的节点,对于每一个节点,调度器将按顺序执行 filter 扩展;如果任何一个 filter 将节点标记为不可选,则余下的 filter 扩展将不会被执行。调度器可以同时对多个节点执行 filter 扩展。
  • Post-filter 是一个通知类型的扩展点,调用该扩展的参数是 filter 阶段结束后被筛选为可选节点的节点列表,可以在扩展中使用这些信息更新内部状态,或者产生日志或 metrics 信息。
  • Scoring 扩展用于为所有可选节点进行打分,调度器将针对每一个节点调用 Soring 扩展,评分结果是一个范围内的整数。在 normalize scoring 阶段,调度器将会把每个 scoring 扩展对具体某个节点的评分结果和该扩展的权重合并起来,作为最终评分结果。
  • Normalize scoring 扩展在调度器对节点进行最终排序之前修改每个节点的评分结果,注册到该扩展点的扩展在被调用时,将获得同一个插件中的 scoring 扩展的评分结果作为参数,调度框架每执行一次调度,都将调用所有插件中的一个 normalize scoring 扩展一次。
  • Reserve 是一个通知性质的扩展点,有状态的插件可以使用该扩展点来获得节点上为 Pod 预留的资源,该事件发生在调度器将 Pod 绑定到节点之前,目的是避免调度器在等待 Pod 与节点绑定的过程中调度新的 Pod 到节点上时,发生实际使用资源超出可用资源的情况。(因为绑定 Pod 到节点上是异步发生的)。这是调度过程的最后一个步骤,Pod 进入 reserved 状态以后,要么在绑定失败时触发 Unreserve 扩展,要么在绑定成功时,由 Post-bind 扩展结束绑定过程。
  • Permit 扩展用于阻止或者延迟 Pod 与节点的绑定。Permit 扩展可以做下面三件事中的一项:
    • approve(批准):当所有的 permit 扩展都 approve 了 Pod 与节点的绑定,调度器将继续执行绑定过程
    • deny(拒绝):如果任何一个 permit 扩展 deny 了 Pod 与节点的绑定,Pod 将被放回到待调度队列,此时将触发 Unreserve 扩展
    • wait(等待):如果一个 permit 扩展返回了 wait,则 Pod 将保持在 permit 阶段,直到被其他扩展 approve,如果超时事件发生,wait 状态变成 deny,Pod 将被放回到待调度队列,此时将触发 Unreserve 扩展
  • Pre-bind 扩展用于在 Pod 绑定之前执行某些逻辑。例如,pre-bind 扩展可以将一个基于网络的数据卷挂载到节点上,以便 Pod 可以使用。如果任何一个 pre-bind 扩展返回错误,Pod 将被放回到待调度队列,此时将触发 Unreserve 扩展。
  • Bind 扩展用于将 Pod 绑定到节点上:
    • 只有所有的 pre-bind 扩展都成功执行了,bind 扩展才会执行
    • 调度框架按照 bind 扩展注册的顺序逐个调用 bind 扩展
    • 具体某个 bind 扩展可以选择处理或者不处理该 Pod
    • 如果某个 bind 扩展处理了该 Pod 与节点的绑定,余下的 bind 扩展将被忽略
  • Post-bind 是一个通知性质的扩展:
    • Post-bind 扩展在 Pod 成功绑定到节点上之后被动调用
    • Post-bind 扩展是绑定过程的最后一个步骤,可以用来执行资源清理的动作
  • Unreserve 是一个通知性质的扩展,如果为 Pod 预留了资源,Pod 又在被绑定过程中被拒绝绑定,则 unreserve 扩展将被调用。Unreserve 扩展应该释放已经为 Pod 预留的节点上的计算资源。在一个插件中,reserve 扩展和 unreserve 扩展应该成对出现。

一个 Plugin 可以实现多个扩展点。即在一个 Plugin 中既可以实现 Filter,又可以实现 Scoring,也可以再实现 Pre-Bind,看具体需求和场景,避免了一个需求实现多个 Plugin 的情况。

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

如果我们要实现自己的插件,必须向调度框架注册插件并完成配置,另外还必须实现扩展点接口,对应的扩展点接口我们可以在源码 pkg/scheduler/framework/v1alpha1/interface.go 文件中找到,如下所示:

// Plugin is the parent type for all the scheduling framework plugins.
type Plugin interface {
    Name() string
}

type QueueSortPlugin interface {
    Plugin
    Less(*PodInfo, *PodInfo) bool
}

// PreFilterPlugin is an interface that must be implemented by "prefilter" plugins.
// These plugins are called at the beginning of the scheduling cycle.
type PreFilterPlugin interface {
    Plugin
    PreFilter(pc *PluginContext, p *v1.Pod) *Status
}

// FilterPlugin is an interface for Filter plugins. These plugins are called at the
// filter extension point for filtering out hosts that cannot run a pod.
// This concept used to be called 'predicate' in the original scheduler.
// These plugins should return "Success", "Unschedulable" or "Error" in Status.code.
// However, the scheduler accepts other valid codes as well.
// Anything other than "Success" will lead to exclusion of the given host from
// running the pod.
type FilterPlugin interface {
    Plugin
    Filter(pc *PluginContext, pod *v1.Pod, nodeName string) *Status
}

// PostFilterPlugin is an interface for Post-filter plugin. Post-filter is an
// informational extension point. Plugins will be called with a list of nodes
// that passed the filtering phase. A plugin may use this data to update internal
// state or to generate logs/metrics.
type PostFilterPlugin interface {
    Plugin
    PostFilter(pc *PluginContext, pod *v1.Pod, nodes []*v1.Node, filteredNodesStatuses NodeToStatusMap) *Status
}

// ScorePlugin is an interface that must be implemented by "score" plugins to rank
// nodes that passed the filtering phase.
type ScorePlugin interface {
    Plugin
    Score(pc *PluginContext, p *v1.Pod, nodeName string) (int, *Status)
}

// ScoreWithNormalizePlugin is an interface that must be implemented by "score"
// plugins that also need to normalize the node scoring results produced by the same
// plugin's "Score" method.
type ScoreWithNormalizePlugin interface {
    ScorePlugin
    NormalizeScore(pc *PluginContext, p *v1.Pod, scores NodeScoreList) *Status
}

// ReservePlugin is an interface for Reserve plugins. These plugins are called
// at the reservation point. These are meant to update the state of the plugin.
// This concept used to be called 'assume' in the original scheduler.
// These plugins should return only Success or Error in Status.code. However,
// the scheduler accepts other valid codes as well. Anything other than Success
// will lead to rejection of the pod.
type ReservePlugin interface {
    Plugin
    Reserve(pc *PluginContext, p *v1.Pod, nodeName string) *Status
}

// PreBindPlugin is an interface that must be implemented by "prebind" plugins.
// These plugins are called before a pod being scheduled.
type PreBindPlugin interface {
    Plugin
    PreBind(pc *PluginContext, p *v1.Pod, nodeName string) *Status
}

// PostBindPlugin is an interface that must be implemented by "postbind" plugins.
// These plugins are called after a pod is successfully bound to a node.
type PostBindPlugin interface {
    Plugin
    PostBind(pc *PluginContext, p *v1.Pod, nodeName string)
}

// UnreservePlugin is an interface for Unreserve plugins. This is an informational
// extension point. If a pod was reserved and then rejected in a later phase, then
// un-reserve plugins will be notified. Un-reserve plugins should clean up state
// associated with the reserved Pod.
type UnreservePlugin interface {
    Plugin
    Unreserve(pc *PluginContext, p *v1.Pod, nodeName string)
}

// PermitPlugin is an interface that must be implemented by "permit" plugins.
// These plugins are called before a pod is bound to a node.
type PermitPlugin interface {
    Plugin
    Permit(pc *PluginContext, p *v1.Pod, nodeName string) (*Status, time.Duration)
}

// BindPlugin is an interface that must be implemented by "bind" plugins. Bind
// plugins are used to bind a pod to a Node.
type BindPlugin interface {
    Plugin
    Bind(pc *PluginContext, p *v1.Pod, nodeName string) *Status
}

我们要实现对应的插件只要实现上面对应的插件接口,也可以在一个进程中实现那多个插件。我们可以看到每个插件都组合了Plugin的结构体,这个就是默认的实现方式,也就是我们对应的原生的策略,最终使用什么还是要在策略文件中使用enable来完成指定插件名的调用。

对于调度框架插件的启用或者禁用,我们同样可以使用上面的 KubeSchedulerConfiguration 资源对象来进行配置。下面的例子中的配置启用了一个实现了 reserve 和 preBind 扩展点的插件,并且禁用了另外一个插件,同时为插件 foo 提供了一些配置信息:

apiVersion: kubescheduler.config.k8s.io/v1alpha1
kind: KubeSchedulerConfiguration

...

plugins:
  reserve:
    enabled:
    - name: foo
    - name: bar
    disabled:
    - name: baz
  preBind:
    enabled:
    - name: foo
    disabled:
    - name: baz

pluginConfig:
- name: foo
  args: >
    foo插件可以解析的任意内容

扩展的调用顺序如下:

  • 如果某个扩展点没有配置对应的扩展,调度框架将使用默认插件中的扩展
  • 如果为某个扩展点配置且激活了扩展,则调度框架将先调用默认插件的扩展,再调用配置中的扩展
  • 默认插件的扩展始终被最先调用,然后按照 KubeSchedulerConfiguration 中扩展的激活 enabled 顺序逐个调用扩展点的扩展
  • 可以先禁用默认插件的扩展,然后在 enabled 列表中的某个位置激活默认插件的扩展,这种做法可以改变默认插件的扩展被调用时的顺序

假设默认插件 foo 实现了 reserve 扩展点,此时我们要添加一个插件 bar,想要在 foo 之前被调用,则应该先禁用 foo 再按照 bar foo 的顺序激活。示例配置如下所示:

apiVersion: kubescheduler.config.k8s.io/v1alpha1
kind: KubeSchedulerConfiguration

...

plugins:
  reserve:
    enabled:
    - name: bar
    - name: foo
    disabled:
    - name: foo

在源码目录 pkg/scheduler/framework/plugins/examples 中有几个示范插件,我们可以参照其实现方式。

简单实现一个调度扩展插件

注册

其实要实现一个调度框架的插件,并不难,我们只要实现对应的扩展点,然后将插件注册到调度器中即可,我们来看一下注册:

func main() {
    rand.Seed(time.Now().UTC().UnixNano())

    command := app.NewSchedulerCommand(
        app.WithPlugin(sample.Name, sample.New),
    )

    logs.InitLogs()
    defer logs.FlushLogs()

    if err := command.Execute(); err != nil {
        _, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
        os.Exit(1)
    }

}

其中 app.WithPlugin(sample.Name, sample.New) 就是注册一个名为sample.Name的插件,接下来就是我们接下来要实现的插件,从 WithPlugin 函数的参数也可以看出我们这里的 sample.New 必须是一个 framework.PluginFactory 类型的值,而 PluginFactory 的定义就是一个函数:

type PluginFactory = func(configuration *runtime.Unknown, f FrameworkHandle) (Plugin, error)

我们简单看一下 WithPlugin 的函数

// WithPlugin creates an Option based on plugin name and factory.
func WithPlugin(name string, factory framework.PluginFactory) Option {
    return func(registry framework.Registry) error {
        return registry.Register(name, factory)
    }
}

创建的就是Option 的列表

// Option configures a framework.Registry.
type Option func(framework.Registry) error

然后给NewSchedulerCommand调用,也就是main函数中的调用的。

实现

所以 sample.New 实际上就是上面的这个函数,在这个函数中我们可以获取到插件中的一些数据然后进行逻辑处理即可,插件实现如下所示,我们这里只是简单获取下数据打印日志,如果你有实际需求的可以根据获取的数据就行处理即可,我们这里只是实现了 PreFilter、Filter、PreBind 三个扩展点,其他的可以用同样的方式来扩展即可:

// 插件名称
const Name = "sample-plugin"

type Args struct {
    FavoriteColor  string `json:"favorite_color,omitempty"`
    FavoriteNumber int    `json:"favorite_number,omitempty"`
    ThanksTo       string `json:"thanks_to,omitempty"`
}

type Sample struct {
    args   *Args
    handle framework.FrameworkHandle
}

func (s *Sample) Name() string {
    return Name
}

func (s *Sample) PreFilter(pc *framework.PluginContext, pod *v1.Pod) *framework.Status {
    klog.V(3).Infof("prefilter pod: %v", pod.Name)
    return framework.NewStatus(framework.Success, "")
}

func (s *Sample) Filter(pc *framework.PluginContext, pod *v1.Pod, nodeName string) *framework.Status {
    klog.V(3).Infof("filter pod: %v, node: %v", pod.Name, nodeName)
    return framework.NewStatus(framework.Success, "")
}

func (s *Sample) PreBind(pc *framework.PluginContext, pod *v1.Pod, nodeName string) *framework.Status {
    if nodeInfo, ok := s.handle.NodeInfoSnapshot().NodeInfoMap[nodeName]; !ok {
        return framework.NewStatus(framework.Error, fmt.Sprintf("prebind get node info error: %+v", nodeName))
    } else {
        klog.V(3).Infof("prebind node info: %+v", nodeInfo.Node())
        return framework.NewStatus(framework.Success, "")
    }
}

//type PluginFactory = func(configuration *runtime.Unknown, f FrameworkHandle) (Plugin, error)
func New(configuration *runtime.Unknown, f framework.FrameworkHandle) (framework.Plugin, error) {
    args := &Args{}
    if err := framework.DecodeInto(configuration, args); err != nil {
        return nil, err
    }
    klog.V(3).Infof("get plugin config args: %+v", args)
    return &Sample{
        args: args,
        handle: f,
    }, nil
}

完整代码可以前往仓库 https://github.com/cnych/sample-scheduler-framework 获取。

打包运行

实现完成后,编译打包成镜像即可,然后我们就可以当成普通的应用用一个 Deployment 控制器来部署即可,由于我们需要去获取集群中的一些资源对象,所以当然需要申请 RBAC 权限,然后同样通过 –config 参数来配置我们的调度器,同样还是使用一个 KubeSchedulerConfiguration 资源对象配置,可以通过 plugins 来启用或者禁用我们实现的插件,也可以通过 pluginConfig 来传递一些参数值给插件:

kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: sample-scheduler-clusterrole
rules:
  - apiGroups:
      - ""
    resources:
      - endpoints
      - events
    verbs:
      - create
      - get
      - update
  - apiGroups:
      - ""
    resources:
      - nodes
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - ""
    resources:
      - pods
    verbs:
      - delete
      - get
      - list
      - watch
      - update
  - apiGroups:
      - ""
    resources:
      - bindings
      - pods/binding
    verbs:
      - create
  - apiGroups:
      - ""
    resources:
      - pods/status
    verbs:
      - patch
      - update
  - apiGroups:
      - ""
    resources:
      - replicationcontrollers
      - services
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - apps
      - extensions
    resources:
      - replicasets
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - apps
    resources:
      - statefulsets
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - policy
    resources:
      - poddisruptionbudgets
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - ""
    resources:
      - persistentvolumeclaims
      - persistentvolumes
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - ""
    resources:
      - configmaps
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - "storage.k8s.io"
    resources:
      - storageclasses
      - csinodes
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - "coordination.k8s.io"
    resources:
      - leases
    verbs:
      - create
      - get
      - list
      - update
  - apiGroups:
      - "events.k8s.io"
    resources:
      - events
    verbs:
      - create
      - patch
      - update
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: sample-scheduler-sa
  namespace: kube-system
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: sample-scheduler-clusterrolebinding
  namespace: kube-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: sample-scheduler-clusterrole
subjects:
- kind: ServiceAccount
  name: sample-scheduler-sa
  namespace: kube-system
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: scheduler-config
  namespace: kube-system
data:
  scheduler-config.yaml: |
    apiVersion: kubescheduler.config.k8s.io/v1alpha1
    kind: KubeSchedulerConfiguration
    schedulerName: sample-scheduler
    leaderElection:
      leaderElect: true
      lockObjectName: sample-scheduler
      lockObjectNamespace: kube-system
    plugins:
      preFilter:
        enabled:
        - name: "sample-plugin"
      filter:
        enabled:
        - name: "sample-plugin"
      preBind:
        enabled:
        - name: "sample-plugin"
    pluginConfig:
    - name: "sample-plugin"
      args:
        favorite_color: "#326CE5"
        favorite_number: 7
        thanks_to: "thockin"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample-scheduler
  namespace: kube-system
  labels:
    component: sample-scheduler
spec:
  replicas: 1
  selector:
    matchLabels:
      component: sample-scheduler
  template:
    metadata:
      labels:
        component: sample-scheduler
    spec:
      serviceAccount: sample-scheduler-sa
      priorityClassName: system-cluster-critical
      volumes:
        - name: scheduler-config
          configMap:
            name: scheduler-config
      containers:
        - name: scheduler-ctrl
          image: cnych/sample-scheduler:v0.1.6
          imagePullPolicy: IfNotPresent
          args:
            - sample-scheduler-framework
            - --config=/etc/kubernetes/scheduler-config.yaml
            - --v=3
          resources:
            requests:
              cpu: "50m"
          volumeMounts:
            - name: scheduler-config
              mountPath: /etc/kubernetes

直接部署上面的资源对象即可,这样我们就部署了一个名为 sample-scheduler 的调度器了。

测试

接下来我们可以部署一个应用来使用这个调度器进行调度:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-scheduler
spec:
  replicas: 1
  selector:
    matchLabels:
      app: test-scheduler
  template:
    metadata:
      labels:
        app: test-scheduler
    spec:
      schedulerName: sample-scheduler
      containers:
      - image: nginx
        imagePullPolicy: IfNotPresent
        name: nginx
        ports:
        - containerPort: 80

这里需要注意的是我们现在手动指定了一个 schedulerName 的字段,将其设置成上面我们自定义的调度器名称 sample-scheduler。

我们直接创建这个资源对象,创建完成后查看我们自定义调度器的日志信息:

$ kubectl get pods -n kube-system -l component=sample-scheduler
NAME                               READY   STATUS    RESTARTS   AGE
sample-scheduler-7c469787f-rwhhd   1/1     Running   0          13m
$ kubectl logs -f sample-scheduler-7c469787f-rwhhd -n kube-system
I0104 08:24:22.087881       1 scheduler.go:530] Attempting to schedule pod: default/test-scheduler-6d779d9465-rq2bb
I0104 08:24:22.087992       1 plugins.go:23] prefilter pod: test-scheduler-6d779d9465-rq2bb
I0104 08:24:22.088657       1 plugins.go:28] filter pod: test-scheduler-6d779d9465-rq2bb, node: ydzs-node1
I0104 08:24:22.088797       1 plugins.go:28] filter pod: test-scheduler-6d779d9465-rq2bb, node: ydzs-node2
I0104 08:24:22.088871       1 plugins.go:28] filter pod: test-scheduler-6d779d9465-rq2bb, node: ydzs-node3
I0104 08:24:22.088946       1 plugins.go:28] filter pod: test-scheduler-6d779d9465-rq2bb, node: ydzs-node4
I0104 08:24:22.088992       1 plugins.go:28] filter pod: test-scheduler-6d779d9465-rq2bb, node: ydzs-master
I0104 08:24:22.090653       1 plugins.go:36] prebind node info: &Node{ObjectMeta:{ydzs-node3   /api/v1/nodes/ydzs-node3 1ff6e228-4d98-4737-b6d3-30a5d55ccdc2 15466372 0 2019-11-10 09:05:09 +0000 UTC <nil> <nil> ......}
I0104 08:24:22.091761       1 factory.go:610] Attempting to bind test-scheduler-6d779d9465-rq2bb to ydzs-node3
I0104 08:24:22.104994       1 scheduler.go:667] pod default/test-scheduler-6d779d9465-rq2bb is bound successfully on node "ydzs-node3", 5 nodes evaluated, 4 nodes were found feasible. Bound node resource: "Capacity: CPU<4>|Memory<8008820Ki>|Pods<110>|StorageEphemeral<17921Mi>; Allocatable: CPU<4>|Memory<7906420Ki>|Pods<110>|StorageEphemeral<16912377419>.".
可以看到当我们创建完 Pod 后,在我们自定义的调度器中就出现了对应的日志,并且在我们定义的扩展点上面都出现了对应的日志,证明我们的示例成功了,也可以通过查看 Pod 的 schedulerName 来验证:

$ kubectl get pods
NAME                                      READY   STATUS    RESTARTS   AGE
test-scheduler-6d779d9465-rq2bb           1/1     Running   0          22m
$ kubectl get pod test-scheduler-6d779d9465-rq2bb -o yaml
......
restartPolicy: Always
schedulerName: sample-scheduler
securityContext: {}
serviceAccount: default
......

在最新的 Kubernetes v1.17 版本中,Scheduler Framework 内置的预选和优选函数已经全部插件化,所以要扩展调度器我们应该掌握并理解调度框架这种方式。所以以后调度框架是必然的趋势,我们所要做的工作就是将我们开发的调度器或扩展程序移植到我们对应的调度框架中去。

代码

kubernetes 调度器的源码位于 kubernetes/pkg/scheduler 中,大体的代码目录结构如下所示:(不同的版本目录结构可能不太一样)

kubernetes/pkg/scheduler
-- scheduler.go         //调度相关的具体实现
|-- algorithm
|   |-- predicates      //节点筛选策略
|   |-- priorities      //节点打分策略
|-- algorithmprovider
|   |-- defaults         //定义默认的调度器

其中 Scheduler 创建和运行的核心程序,对应的代码在 pkg/scheduler/scheduler.go,如果要查看kube-scheduler的入口程序,对应的代码在 cmd/kube-scheduler/scheduler.go。主要调度就是上面的流程。

常见调度器

批调度

Coscheduling的定义是“在并发系统中将多个相关联的进程调度到不同处理器上同时运行的策略”。在Coscheduling的场景中,最主要的原则是保证所有相关联的进程能够同时启动。防止部分进程的异常,导致整个关联进程组的阻塞。这种导致阻塞的部分异常进程,称之为“碎片(fragement)”。 在Coscheduling的具体实现过程中,根据是否允许“碎片”存在,可以细分为Explicit Coscheduling,Local Coscheduling和Implicit Coscheduling。 其中Explicit Coscheduling就是大家常听到的Gang Scheduling。Gang Scheduling要求完全不允许有“碎片”存在, 也就是“All or Nothing”。

简单来说,一个批任务(关联进程组)包括了N个Pod(进程),Kubernetes调度器负责将这N个Pod调度到M个节点(处理器)上同时运行。如果这个批任务需要部分Pod同时启动即可运行,我们称需启动Pod的最小数量为min-available。特别地,当min-available=N时,批任务要求满足Gang Scheduling。

为什么需要批调度?

JobA需要4个Pod同时启动,才能正常运行。Kube-scheduler依次调度3个Pod并创建成功。到第4个Pod时,集群资源不足,则JobA的3个Pod处于空等的状态。但是它们已经占用了部分资源,如果第4个Pod不能及时启动的话,整个JobA无法成功运行,更糟糕的是导致集群资源浪费。如果出现更坏的情况的话,如下图所示,集群其他的资源刚好被JobB的3个Pod所占用,同时在等待JobB的第4个Pod创建,此时整个集群就出现了死锁。

解决

社区目前有Kube-batch以及基于Kube-batch衍生的Volcano 2个项目来解决上文中提到的痛点。实现的方式是通过开发新的调度器将Scheduler中的调度单元从Pod修改为PodGroup,以组的形式进行调度。使用方式是如果需要Coscheduling功能的Pod走新的调度器

这些方案虽然能够解决Coscheduling的问题,但是同样引入了新的问题。如大家所知,对于同一集群资源,调度器需要中心化。但如果同时存在两个调度器的话,有可能会出现决策冲突,例如分别将同一块资源分配给两个不同的Pod,导致某个Pod调度到节点后因为资源不足,导致无法创建的问题。解决的方式只能是通过标签的形式将节点强行的划分开来,或者部署多个集群。这种方式通过同一个Kubernetes集群来同时运行在线服务和离线作业,势必会导致整体集群资源的浪费以及运维成本的增加。再者,Volcano运行需要启动定制的MutatingAdmissionWebhook和ValidatingAdmissionWebhook。这些Webhooks本身存在单点风险,一旦出现故障,将影响集群内所有pod的创建。另外,多运行一套调度器,本身也会带来维护上的复杂性,以及与上游Kube-scheduler接口兼容上的不确定性。

基于Scheduling Framework的方案

实现

PodGroup

我们通过label的形式来定义PodGroup的概念,拥有同样label的Pod同属于一个PodGroup。min-available是用来标识该PodGroup的作业能够正式运行时所需要的最小副本数。

labels:
     pod-group.scheduling.sigs.k8s.io/name: tf-smoke-gpu
     pod-group.scheduling.sigs.k8s.io/min-available: "2"
备注: 要求属于同一个PodGroup的Pod必须保持相同的优先级

Permit

Framework的Permit插件提供了延迟绑定的功能,即Pod进入到Permit阶段时,用户可以自定义条件来允许Pod通过、拒绝Pod通过以及让Pod等待状态(可设置超时时间)。Permit的延迟绑定的功能,刚好可以让属于同一个PodGruop的Pod调度到这个节点时,进行等待,等待积累的Pod数目满足足够的数目时,再统一运行同一个PodGruop的所有Pod进行绑定并创建。

举个实际的例子,当JobA调度时,需要4个Pod同时启动,才能正常运行。但此时集群仅能满足3个Pod创建,此时与Default Scheduler不同的是,并不是直接将3个Pod调度并创建。而是通过Framework的Permit机制进行等待。

此时当集群中有空闲资源被释放后,JobA的中Pod所需要的资源均可以满足。

则JobA的4个Pod被一起调度创建出来,正常运行任务。

QueueSort

由于Default Scheduler的队列并不能感知PodGroup的信息,所以Pod在出队时处于无序性(针对PodGroup而言)。如下图所示,a和b表示两个不同的PodGroup,两个PodGroup的Pod在进入队列时,由于创建的时间交错导致在队列中以交错的顺序排列。

当一个新的Pod创建后,入队后,无法跟与其相同的PodGroup的Pod排列在一起,只能继续以混乱的形式交错排列。

这种无序性就会导致如果PodGroupA在Permit阶段处于等待状态,此时PodGroupB的Pod调度完成后也处于等待状态,相互占有资源使得PodGroupA和PodGroupB均无法正常调度。这种情况即是把死锁现象出现的位置从Node节点移动到Permit阶段,无法解决前文提到的问题。

针对如上所示的问题,我们通过实现QueueSort插件, 保证在队列中属于同一个PodGroup的Pod能够排列在一起。我们通过定义QueueSort所用的Less方法,作用于Pod在入队后排队的顺序:

func  Less(podA *PodInfo, podB *PodInfo) bool

首先,继承了默认的基于优先级的比较方式,高优先级的Pod会排在低优先级的Pod之前。 然后,如果两个Pod的优先级相同,我们定义了新的排队逻辑来支持PodGroup的排序。

如果两个Pod都是regularPod(普通的Pod),则谁先创建谁在队列里边排在前边。 如果两个Pod一个是regularPod,另一个是pgPod(属于某个PodGroup的Pod), 我们比较的是regularPod的创建时间和pgPod所属PodGroup的创建时间,则谁先创建谁在队列里边排在前边。 如果两个Pod都是pgPod,我们比较两个PodGroup的创建时间,则谁先创建谁在队列里边排在前边。同时有可能两个PodGroup的创建时间相同,我们引入了自增Id,使得PodGroup的Id谁小谁排在前边(此处的目的是为了区分不同的PodGroup)。 通过如上的排队策略,我们实现属于同一个PodGroup的Pod能够同一个PodGroup的Pod能够排列在一起。

当一个新的Pod创建后,入队后,会跟与其相同的PodGroup的Pod排列在一起。

Prefilter

为了减少无效的调度操作,提升调度的性能,我们在Prefilter阶段增加一个过滤条件,当一个Pod调度时,会计算该Pod所属PodGroup的Pod的Sum(包括Running状态的),如果Sum小于min-available时,则肯定无法满足min-available的要求,则直接在Prefilter阶段拒绝掉,不再进入调度的主流程。

UnReserve

如果某个Pod在Permit阶段等待超时了,则会进入到UnReserve阶段,我们会直接拒绝掉所有跟Pod属于同一个PodGroup的Pod,避免剩余的Pod进行长时间的无效等待。

Binpack

为什么需要Binpack功能?

Kubernetes默认开启的资源调度策略是LeastRequestedPriority,消耗的资源最少的节点会优先被调度,使得整体集群的资源使用在所有节点之间分配地相对均匀。但是这种调度策略往往也会在单个节点上产生较多资源碎片。 比如两个节点各剩余 1GPU 的资源。这是有申请 2GPU 的新作业,提交到调度器,则因为无法提供足够的资源,导致调度失败。如上这种情况情况,每个节点都有 1 个 GPU 卡空闲,可是又无法被利用,导致资源 GPU 这种昂贵的资源被浪费。如果使用的资源调度策略是 Binpack,优先将节点资源填满之后,再调度下一个节点,则上图所出现的资源碎片问题得到解决。申请 2GPU 的作业被正常调度到节点上,提升了集群的资源使用率。

Binpack实现已经抽象成Kubernetes Scheduler Framework的Score插件RequestedToCapacityRatio(根据已分配资源的某函数设置选择节点。 实现的扩展点: Score 。),用于优选阶段给节点打分。将节点根据自己定义的配置进行打分。具体的实现可以分为两个部分,构建打分函数和打分.

  • 构建打分函数的过程比较容易理解,就是用户可以自己定义不同的利用率所对应的分值大小,以便影响调度的决策过程。即如果资源利用率为 0 的时候,得分为 0 分,当资源利用率为 100 时,得分为 10 分,所以得到的资源利用率越高,得分越高,则这个行为是 Binpack 的资源分配方式。用户也可以设置成利用率为 0 时,得分为 10 分,利用率为 100 时,得分为 0 分。这样意味着资源利用率越低,则得分越高,这种行为是 spreading 的资源分配方式。
  • 打分用户可以自己定义在Binpack计算中所要参考的资源以及权重值,例如可以只是设定GPU和CPU的值和权重。

    resourcetoweightmap:
        "cpu": 1
        "nvidia.com/gpu": 1
    

然后在打分过程总,会通过计算(pod.Request + node.Allocated)/node.Total的结果得到对应资源的利用率,并且将利用率带入上文中所述的打分函数中,得到相应的分数。最后将所有的资源根据weight值,加权得到最终的分数。

Score = line(resource1_utilization) * weight1 + line(resource2_utilization) * weight2 ....) /