上一篇文章介绍了 通过 Webhook 扩展 k8s 的方法,在这篇文章中我们来探索 k8s 核心组件「调度器」的扩展方法。首先来了解一下调度器的工作流程并实现一个「MVP」版本的调度器;接着来了解 k8s 默认调度器的功能以及默认调度器的扩展机制;最后实现一个根据节点网速调度 Pod 的调度器。

引用官方文档中对调度器的描述:

在 Kubernetes 中,调度是指将 Pod 放置到合适的节点上,以便对应节点上的 Kubelet 能够运行这些 Pod。调度器通过 Kubernetes 的监测(Watch)机制来发现集群中新创建且尚未被调度到节点上的 Pod。 调度器会将所发现的每一个未调度的 Pod 调度到一个合适的节点上来运行。调度器会依据下文的调度原则来做出调度选择。

调度器的工作流程可以总结为以下步骤:

  1. 监测未被调度到节点上的 Pod
  2. 选择合适的节点
  3. 将 Pod 调度到节点上

虽然大概知道了调度器的工作流程,但有两个小问题需要解答:如何定义未被调度到节点上的 Pod?如何将 Pod 调度到节点上?带着这两个问题来看一个「MVP」调度器:random-scheduler。顾名思义这个调度器会将 Pod 随机调度到节点上,虽然不具有实际意义但它能让我们更直观地了解调度器的工作流程:

完整代码放在 Github仓库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package main

func main() {
clientset, err := makeKubeClient()
if err != nil {
panic(err)
}

_, ctr := cache.NewInformer(&cache.ListWatch{
ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
// 仅获取 `schedulerName` 为 `random-scheduler` 的 Pod
options.FieldSelector = "spec.schedulerName=" + SchedulerName
return clientset.CoreV1().Pods(corev1.NamespaceAll).List(context.Background(), options)
},
WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
// 仅监听 `schedulerName` 为 `random-scheduler` 的 Pod
options.FieldSelector = "spec.schedulerName=" + SchedulerName
return clientset.CoreV1().Pods(corev1.NamespaceAll).Watch(context.Background(), options)
},
}, &corev1.Pod{}, 0, cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
pod := obj.(*corev1.Pod)
if pod.Spec.NodeName != "" {
return
}

node := getNode(clientset)
if node == nil { // 无可用节点
slog.Error("no available node", "pod", cache.MetaObjectToName(pod))
return
}

binding := &corev1.Binding{ // 创建 `binding` SubResource
ObjectMeta: pod.ObjectMeta,
Target: corev1.ObjectReference{Kind: "Node", Name: node.NodeName},
}

// 绑定
err := clientset.
CoreV1().
Pods(pod.Namespace).
Bind(context.Background(), binding, metav1.CreateOptions{})

if err != nil {
slog.Error(
"bind pod error",
"error", err,
"pod", cache.MetaObjectToName(pod),
)
}
},
})

ctr.Run(wait.NeverStop)
}

func getNode(clientset *kubernetes.Clientset) *corev1.Node {}

random-scheduler 的实现非常简单,通过 Informer 对象监听 spec.schedulerName=random-scheduler 的 Pods,这个字段平时比较少接触到,用于指定该 Pod 由哪个调度器负责调度,默认值 default 表示默认调度器;监听到有新的 Pod 创建首先判断 spec.nodeName 字段是否为空,这也是判断 Pod 是否已经调度的依据,如果 Pod 没有调度则进入调度流程,调用 getNode 方法获取一个随机节点,最后调用 k8s-client 的 Bind 方法将 Pod 调度到该节点上。需要注意的是虽然 spec.nodeName 是判断 Pod 是否调度的依据,但调度器不能直接设置这个字段因为这个字段是不可变的。

完成以上步骤后调度器的工作就结束了,可以看出除了 api-server 外调度器没有和其它组件有交互,这样的设计使我们能非常容易地替换调度器,下面的流程图展示了一个 Pod 从创建到在节点上运行的过程,以及调度器是如何参与这个过程的:

20240131094849
20240131094849

random-scheduler 和「正儿八经」的调度器之间还有很大的差距,比较显而易见的有:

  1. 不具备有选择合适节点的能力
  2. 不具备调度队列能力,Pod 调度失败后无法再次调度…

kube-scheduler

kube-scheduler 是 k8s 的默认调度器,在 k8s 体系中只有调度器能决定将 Pod 调度到哪个节点上,因此选择一个合适的节点是调度工作的重中之重。kube-scheduler 将这项工作分为了两个阶段:

  1. 过滤(Filter)阶段:

    1. 资源需求检查:kube-scheduler 会检查每个节点是否有足够的资源(如CPU、内存等)来满足Pod的需求。如果一个节点的可用资源少于Pod的需求,那么这个节点就会被过滤掉。

    2. 节点亲和性和反亲和性规则检查:亲和性和反亲和性是 k8d 中的一种特性,允许你指定Pod更倾向于被调度到哪些节点,或者不希望被调度到哪些节点。kube-scheduler会检查Pod的亲和性和反亲和性规则,过滤掉不符合规则的节点。

    3. Taints 和 Tolerations 检查:如果一个节点设置了Taint,而Pod没有设置相应的Toleration,那么这个节点就会被过滤掉。

  2. 打分(Score)阶段:

    1. 资源优先:kube-scheduler会考虑节点的剩余资源,例如CPU和内存。一般来说,资源剩余多的节点会得到更高的分数。

    2. Pod亲和性规则:如果Pod设置了亲和性规则,kube-scheduler会根据这些规则进行打分。例如,如果Pod设置了与某些Pod具有亲和性,那么那些已经运行了这些Pod的节点会得到更高的分数。

    3. 节点负载:kube-scheduler会考虑节点的负载情况,例如节点上已经运行的Pod数量。一般来说,负载较低的节点会得到更高的分数。

在打分阶段结束后 kube-scheduler 会将 Pod 调度到得分最高的节点上,如果有多个节点得分相同 kube-scheduler 会随机选择一个。

接下来看 kube-scheduler 是如何实施这两个阶段的调度工作的,为了使调度器能够更容易地进行扩展和优化从 1.19 版本起调度器采用了 调度框架 架构,这是一种插件化的架构,调度框架提供监视 Pod、绑定 Pod 等公共能力,调度功能则由 调度插件 实现。

调度框架将调度流程分解为多个 扩展点,每个扩展点都对应调度流程中的一个环节,这也是调度插件实现调度功能的入口,调度框架会在合适的时机调用扩展点上注册的调度插件。利用这些扩展点可以实现自定义节点过滤和打分逻辑,也可以终止 Pod 调度以及将暂时不可调度的 Pod 推入调度队列等待下次度等功能。

20240112161324
20240112161324

调度框架也是扩展调度器的基础,我们要做的是在 kube-scheduler 之上扩展自定义调度功能而不是开发一个全新的调度器,毕竟将 Pod 调度到一个网速很好但 CPU 占用率 > 90% 的节点上不是一个合适的选择。下面我们正式进入到扩展调度器开发环节。

network-scheduler

network-scheduler 整体分为以下三部分:

  1. 入口文件:注册调度插件,启动调度器
  2. 调度插件:实现调度逻辑
  3. 节点网速拨测:测试节点网速

第一步先来编写入口文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// main.go
package main

import (
"k8s-network-scheduler/plugins/networkspeed"
"k8s.io/component-base/cli"
"k8s.io/kubernetes/cmd/kube-scheduler/app"
"os"
)

func main() {
cmd := app.NewSchedulerCommand(
app.WithPlugin(networkspeed.PluginName, networkspeed.New),
)

code := cli.Run(cmd)
os.Exit(code)
}

入口文件的代码非常简洁,主要调用 NewSchedulerCommand 将调度插件注册到 kube-scheduler 中。下面来编写调度插件 networkspeed 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package networkspeed

const PluginName = "NetworkSpeed"

type NetworkSpeedPlugin struct {
config *Config
handle framework.Handle
}

func (n *NetworkSpeedPlugin) Name() string {
return PluginName
}

func (n *NetworkSpeedPlugin) Score(ctx context.Context, state *framework.CycleState, p *v1.Pod, nodeName string) (int64, *framework.Status) {}

func (n *NetworkSpeedPlugin) NormalizeScore(ctx context.Context, state *framework.CycleState, p *v1.Pod, scores framework.NodeScoreList) *framework.Status {}

func New(ctx context.Context, configuration runtime.Object, handle framework.Handle) (framework.Plugin, error) {
var config Config
if err := frameworkruntime.DecodeInto(configuration, &config); err != nil {
return nil, err
}

return &NetworkSpeedPlugin{
config: &config,
handle: handle,
}, nil
}

上面是调度器的骨架代码,New 是构造函数用于初始化调度器后返回调度器实例,configuration 参数可以用来获取调度器配置,稍后会讲到如何向调度器传递配置;handle 是一个非常有用的参数,它提供了一系列的方法用于获取调度器的状态、获取 Pod 信息、获取 k8s client 等,绝大多数情况下我们都会用到它。

NetworkSpeedPlugin 就是调度插件,在 Go 语言中扩展点还有一个『亲切』的名称:接口(interface)。每个扩展点都有对应的接口,例如 Score 扩展点对应是 ScorePlugin,结构体实现任意一个扩展点接口就可以作为调度插件注册到 kube-scheduler 中。

在实现 ScorePlugin 接口方法前先来了解一下如何判断节点网速,这里用 blackbox-exporter 作为拨测器,它支持使用 HTTP、HTTPS、DNS、TCP 和 ICMP 协议对目标地址进行拨测,拨测结果以 Prometheus 指标格式返回,例如使用 HTTP 协议对 lin2ur.cn 发起拨测:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl http://blackbox-exporter:9115/probe?module=http_2xx&target=lin2ur.cn
...
# HELP probe_duration_seconds Returns how long the probe took to complete in seconds
# TYPE probe_duration_seconds gauge
probe_duration_seconds 0.124378116
# HELP probe_http_duration_seconds Duration of http request by phase, summed over all redirects
# TYPE probe_http_duration_seconds gauge
probe_http_duration_seconds{phase="connect"} 0.029487528
probe_http_duration_seconds{phase="processing"} 0.029606199
probe_http_duration_seconds{phase="resolve"} 0.016701177999999997
probe_http_duration_seconds{phase="tls"} 0.032871766
probe_http_duration_seconds{phase="transfer"} 0.014863664
...

probe_duration_seconds 指标是拨测的总耗时,调度器以这个值来判断节点网速,耗时越短表示节点网速越好。doProbe 方法用于在特定节点上执行拨测任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func (n *NetworkSpeedPlugin) doProbe(ctx context.Context, nodename, target string) (duration float64, err error) {
prober := n.getProber(nodename)
if prober == nil {
return 0, fmt.Errorf("no available prober")
}

probeUrl := fmt.Sprintf(
"http://%s:%d/probe?module=%s&target=%s",
prober.Status.PodIP,
proberConf.Port,
proberConf.Module,
target,
)

res, err := http.Get(probeUrl)
// ... 解析拨测数据 ...
return duration, nil
}

func (n *NetworkSpeedPlugin) getProber(nodeName string) *v1.Pod {
pods, err := n.handle.
SharedInformerFactory().
Core().
V1().
Pods().
Lister().
List(labels.SelectorFromSet(n.config.Prober.Selector))

for _, pod := range pods {
if pod.Spec.NodeName == nodeName && pod.Status.Phase == v1.PodRunning {
return pod
}
}

return nil
}

辅助方法 getProber 从构造函数传入的 handle 参数中获取 SharedInformer 对象,再使用 SharedInformer 根据配置的 label selector 获取所有 blackbox-exporter Pods,最后再进行过滤返回指定节点上的 Pod。

完事具备接下来就可以实现打分逻辑了,从 文档 可以看到打分环节涉及三个扩展点,这里我们忽略 PreScore,重点来看一下其它两个扩展点的作用:

  • Score: 这些插件用于对通过过滤阶段的节点进行排序。调度器将为每个节点调用每个评分插件。将有一个定义明确的整数范围(0 ~ 100),代表最小和最大分数。在标准化评分阶段(NormalizeScore)之后,调度器将根据配置的插件权重合并所有插件的节点分数。
  • NormalizeScore: 这些插件用于在调度器计算 Node 排名之前修改分数。 在此扩展点注册的插件被调用时会使用同一插件的 Score 结果。每个插件在每个调度周期调用一次。

下面在 NetworkSpeedPlugin 中实现这两个扩展点接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func (n *NetworkSpeedPlugin) Score(ctx context.Context, state *framework.CycleState, p *v1.Pod, nodeName string) (int64, *framework.Status) {
// 从 Pod Annotations 获取拨测目标地址
duration, err := n.doProbe(ctx, nodeName, p.Annotations["network-scheduler/probe-target"])
if err != nil {
slog.Error("doProbe error", "err", err.Error())
return 0, nil
}

// 将分数转换为整数
return int64(duration * 1000), nil
}

func (n *NetworkSpeedPlugin) NormalizeScore(ctx context.Context, state *framework.CycleState, p *v1.Pod, scores framework.NodeScoreList) *framework.Status {
sortScores := scores[:]

// 按照拨测耗时对节点进行排序
slices.SortFunc(sortScores, func(a, b framework.NodeScore) int {
return int(a.Score - b.Score)
})

// 将排序结果转换为分数
for i := range scores {
scores[i].Score = int64((len(scores) - slices.Index(sortScores, scores[i])) * (100 / len(scores)))
}

slog.Info("normalize scores", "scores", scores)

return nil
}

因为拨测结果值范围可能会很大可能是 50ms 也可能是 5s,想要将其转换为 0 ~ 100 的分数算法实现起来可能会比较复杂,因此这里采用排序的方式为节点打分。此外,如果 Score 方法能得到一个明确的分数那就无需实现 NormalizeScore 扩展点。

所有扩展点接口方法都有一个用于描述状态的 *framework.Status 类型的返回值,可以使用辅助函数 NewStatus(code Code, reasons ...string) 创建,参数 code 用于表示状态,reasons 是一个可变参数用于解释状态的原因,下面来看看在代码中的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package framework

// Code is the Status code/type which is returned from plugins.
type Code int

const (
// Success means that plugin ran correctly and found pod schedulable.
// NOTE: A nil status is also considered as "Success".
Success Code = iota
// Error is one of the failures, used for internal plugin errors, unexpected input, etc.
// Plugin shouldn't return this code for expected failures, like Unschedulable.
// Since it's the unexpected failure, the scheduling queue registers the pod without unschedulable plugins.
// Meaning, the Pod will be requeued to activeQ/backoffQ soon.
Error
// Unschedulable is one of the failures, used when a plugin finds a pod unschedulable.
// If it's returned from PreFilter or Filter, the scheduler might attempt to
// run other postFilter plugins like preemption to get this pod scheduled.
// Use UnschedulableAndUnresolvable to make the scheduler skipping other postFilter plugins.
// The accompanying status message should explain why the pod is unschedulable.
//
// We regard the backoff as a penalty of wasting the scheduling cycle.
// When the scheduling queue requeues Pods, which was rejected with Unschedulable in the last scheduling,
// the Pod goes through backoff.
Unschedulable
// ...
)

// NewStatus makes a Status out of the given arguments and returns its pointer.
func NewStatus(code Code, reasons ...string) *Status {}

以上几个都是比较常用的状态,其中用的最多的是 Success,从文档中可以看出它表示「一切正常」,接口方法返回空值时等效于 Success 状态。在使用其它 Code 时要格外注意:在不同阶段的扩展点中 Code 的含义是不一样的。

以上面 Score 方法为例,调用 doProbe 方法是有可能发生错误的,但即使发生错误 Score 也是返回正常状态。按照正常思维,如果某个节点拨测失败返回 UnschedulableError 状态表示该节点不可调度会更合适,但实际情况是返回 Unschedulable 会导致调度器拒绝调度 Pod,这个信息可以在 ScorePlugin 接口的文档中看到:

1
2
3
4
5
6
7
8
type ScorePlugin interface {
Plugin
// Score is called on each filtered node. It must return success and an integer
// indicating the rank of the node. All scoring plugins must return success or
// the pod will be rejected.
Score(ctx context.Context, state *CycleState, p *v1.Pod, nodeName string) (int64, *Status)
// ...
}

综上所述 Unschedulable 在这里表示的是 Pod 不可调度,但在其它扩展点却有不同的含义。为了让打分阶段能顺利进行,一般会在前置的 Filter 阶段过滤掉不满足打分条件的节点,在 NetworkSpeedPlugin 中导致 doProbe 发生错误的原因之一是该节点上没有可用的拨测器,因此可以把这些节点提前过滤掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package networkspeed

func (n *NetworkSpeedPlugin) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
if _, err := n.getProber(nodeInfo.Node().Name); err != nil {
klog.ErrorS(
err, "Filter error",
"node", nodeInfo.Node().Name,
"pod", pod.Namespace+"/"+pod.Name,
)
return framework.NewStatus(framework.Unschedulable)
}

return nil
}

可以看出在 Filter 方法中 Unschedulable 表示的则是节点不可调度。在开发调度插件时一定要先阅读每个扩展点接口的文档以及『在对的时间做对的事情』。

到这里调度器扩展工作就完成了,总结一下:在默认调度器 kube-scheduler 中新增了一个 NetworkSpeedPlugin 调度插件,这个插件实现了 Score NormalizeScore Filter 扩展点。

部署

调度器以 Deployment 部署在集群中,拨测器 blackbox-exporter 则以 DaemonSet 部署确保每个节点都拥有一个副本。运行调度器需要一份 配置文件 用于配置调度器参数、启用或禁用扩展点上的调度插件、配置调度插件等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apiVersion: v1
kind: ConfigMap
metadata:
labels:
app.kubernetes.io/instance: network-scheduler
name: network-scheduler-config
namespace: network-scheduler
data:
config.yaml: |-
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
leaderElection:
leaderElect: false
profiles:
- schedulerName: network-scheduler
plugins:
multiPoint:
enabled:
- name: NetworkSpeed
pluginConfig:
- name: NetworkSpeed
args:
selector:
app.kubernetes.io/instance: network-scheduler
app.kubernetes.io/name: blackbox-exporter

解释一下关键字段:

  • leaderElection:配置 leader 选举,不需要高可用部署可禁用选举。
  • profiles:调度器配置,里面每一项都可以理解为声明了一个名为 schedulerName 的调度器。
  • plugins:配置各个 扩展点 启用或禁用的调度插件;multiPoint 是一个特殊 key,用于同时在多个扩展点启用插件。
  • plugins.enabled.name:调度插件名称,要和插件注册时的名称一致。
  • pluginConfig:调度插件配置,在初始化 name 对应的调度插件时会将 args 字段的配置传入,也就是插件构造函数中 configuration 参数的来源。

需要注意的是内置的调度插件是默认启用的因此不用再 plugins 中声明,而自定义度插件默认不启用。总结:这份配置声明了一个 network-scheduler 调度器并在所有扩展点启用了 NetworkSpeed 调度插件。

其余的 RBAC、Deployment、blackbox-exporter 等配置这里就不一一介绍了,完整部署文件放在 GitHub仓库。调度器启动后就可以来测试了,这里用 Job 来创建 Pod 以便在运行完毕后自动删除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# test.yaml
apiVersion: batch/v1
kind: Job
metadata:
generateName: network-scheduler-
namespace: network-scheduler
spec:
backoffLimit: 0
parallelism: 1
ttlSecondsAfterFinished: 300
template:
metadata:
annotations:
network-scheduler/probe-target: 'https://httpbin.org/status/200' # <- 拨测地址
spec:
containers:
- name: busybox
image: busybox
imagePullPolicy: IfNotPresent
command:
- sh
args:
- -c
- 'echo $(hostname)'
restartPolicy: Never
schedulerName: network-scheduler # <- 指定调度器

创建 Job:

1
2
$ kubectl create -f test.yaml
job.batch/network-scheduler-7bjdk created

稍等一会在调度器的日志中就可以看到节点的拨测结果以及最终的分数:

1
2
3
4
5
6
...
I0227 17:56:13.137890 76386 networkspeed.go:46] "doProbe" node="macmini" target="https://httpbin.org/status/200" pod="default/network-scheduler-288w7-hsnmf" duration="1.008478537s"
I0227 17:56:13.192691 76386 networkspeed.go:46] "doProbe" node="raspberrypi" target="https://httpbin.org/status/200" pod="default/network-scheduler-288w7-hsnmf" duration="1.00373723s"
I0227 17:56:13.233066 76386 networkspeed.go:46] "doProbe" node="ecs" target="https://httpbin.org/status/200" pod="default/network-scheduler-288w7-hsnmf" duration="1.053753546s"
I0227 17:56:13.236653 76386 networkspeed.go:82] "NormalizeScore" scores=[{"Name":"raspberrypi","Score":99},{"Name":"macmini","Score":66},{"Name":"ecs","Score":33}]
I0227 17:56:13.243499 76386 trace.go:236] Trace[1626348004]: "Scheduling" namespace:default,name:network-scheduler-288w7-hsnmf (27-Feb-2024 17:56:11.566) (total time: 1670ms):

从日志中可以看出 raspberrypi 节点得到了最高的分数,我们来看看 Pod 最终调度到哪个节点上:

1
2
3
$ kubectl get po -n network-scheduler -l job-name=network-scheduler-7bjdk-jz7l5 -o wide
NAME READY STATUS RESTARTS AGE IP NODE
network-scheduler-7bjdk-jz7l5 0/1 Completed 0 37s 10.42.2.86 macmini

Pod 并没有如预期的那样调度到得分最高的 raspberrypi 节点上,这是因为 macmini 节点的可用 CPU 和内存资源比 raspberrypi 节点多,即使 NetworkSpeed 插件给 raspberrypi 节点打了最高分,但最终得分需要综合所有打分插件的分数,因此调度器最终选择了 macmini 节点。

幸运的是调度器允许在配置文件中配置 Score 扩展点上的各个调度插件的打分权重,以上面的配置为例:

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
leaderElection:
leaderElect: false
profiles:
- schedulerName: network-scheduler
plugins:
multiPoint:
enabled:
- name: NetworkSpeed
weight: 50 # <- 调整权重
...

调整插件的权重后创建新的 Pod 调度器就如预期那样将 Pod 调度到 raspberrypi 节点上了:

1
2
3
$ kubectl get po -n network-scheduler -l job-name=network-scheduler-nvbz2-pg46z -o wide
NAME READY STATUS RESTARTS AGE IP NODE
network-scheduler-nvbz2-pg46z 0/1 Completed 0 7s 10.42.2.102 raspberrypi

调整默认调度器行为

除了扩展调度插件外,我们也可以对内置插件的配置进行调整来改变调度器的行为,例如以下场景:假设集群有 4 个 4C8G 的节点,现有 20 个 Pod 需要调度,每个 Pod 需要 0.5 核 CPU 以及 1G 内存,我们知道调度器会尽量平衡各个节点的资源占用,因此这 20 个 Pod 大概率会平分到 4 个节点上,每个节点上运行 5 个 Pod,剩余 2C3G 的可用资源。

这看起来很好所有 Pod 都能运行并且每个节点都剩余了一些资源,但如果此时有个需要 4核 CPU 的 Pod 需要运行那情况就会变得糟糕起来,即使整个集群还剩余 8核 CPU 这个 Pod 仍然无法被调度运行,因为没有能满足 CPU 需求的节点,这种现象我们称为资源碎片化。

幸运的是我们无需开发一个新的调度插件来解决这个问题,前面说到调度器的功能都是由内置调度插件实现的,其中 NodeResourcesFit 插件 负责基于节点上的可用资源进行调度决策,它支持两种调度策略:

  1. LeastAllocated:这种策略优先考虑那些未分配资源较多的节点。换句话说,它会尝试将新的 Pod 放在资源利用率相对较低的节点上,以此来实现资源的均衡使用。
  2. MostAllocated:与 LeastAllocated 策略相反,这种策略优先考虑那些已分配资源较多的节点。也就是说,它会尝试将新的 Pod 放在资源利用率相对较高的节点上,从而尽可能减少资源的碎片化。

不难猜到调度器默认使用的是 LeastAllocated 策略,既然提供了第二种策略那肯定就能用起;在介绍 KubeSchedulerConfiguration 的时候提到 pluginConfig 字段用于给调度插件传递配置,我们可以用 NodeResourcesFitArgs 来配置 NodeResourcesFit 插件,最后声明一个新的 moreallocated-scheduler 调度器来调度这 20 个 Pod:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
leaderElection:
leaderElect: false
profiles:
- schedulerName: moreallocated-scheduler
pluginConfig:
- args:
apiVersion: kubescheduler.config.k8s.io/v1
kind: NodeResourcesFitArgs
scoringStrategy:
resources:
- name: cpu
weight: 1
- name: memory
weight: 1
type: MostAllocated # <- 修改策略
name: NodeResourcesFit # <- 指定调度插件

文档中详细列出了所有内置调度插件的功能作用以及用于配置的 pluginConfig 这里就不一一介绍了,通过这种方式就能配置出适合业务的调度器而无需扩展调度器。