K8s 自定义调度器 Part1:通过 Scheduler Extender 实现自定义调度逻辑
本文主要分享如何通过 Scheduler Extender 扩展调度器从而实现自定义调度策略。
1. 为什么需要自定义调度逻辑
什么是所谓的调度?
所谓调度就是指给 Pod 对象的 spec.nodeName 赋值
待调度对象则是所有 spec.nodeName 为空的 Pod
调度过程则是从集群现有的 Node 中为当前 Pod 选择一个最合适的
实际上 Pod 上还有一个平时比较少关注的属性: spec.schedulerName
,用于指定该 Pod 要交给哪个调度器进行调度。
那么问题来了,平时用的时候也没给 spec.schedulerName 赋值过,怎么也能调度呢?
因为默认的 kube-scheduler 可以兼容 spec.schedulerName 为空或者为 default
的 Pod。
为什么需要自定义调度逻辑
自定义调度逻辑可以解决特定应用场景和需求,使集群资源使用更高效,适应特殊的调度策略。
比如:
- 不同的工作负载可能有特定的资源需求,比如 GPU 或 NPU,需要确保 Pod 只能调度到满足这些资源条件的节点上。
- 某些集群可能需要均衡资源消耗,避免将多个负载集中到某些节点上。
- 为了降低延迟,可能需要将Pod调度到特定地理位置的节点上。自定义调度器可以根据节点的地理位置标签进行调度决策。
- 某些应用需要与其他应用隔离运行,以避免资源争抢。通过自定义调度器,可以将特定类型的任务或工作负载隔离到专用的节点上。
- …
总之就是业务上有各种特殊的调度需求,因此我们需要通过实现自定义调度器来满足这些需求。
通过实现自定义调度器,可以根据具体的业务需求和集群环境,实现更灵活、更高效的资源管理和调度策略。
2.如何增加自定义调度逻辑
自定义调度器的几种方法
要增加自定义调度逻辑也并不复杂,K8s 整个调度流程都已经插件化了,我们并不需要重头开始实现一个调度器,而只需要实现一个调度插件,通过在调度过程中各个阶段加入我们的自定义逻辑,来控制最终的调度结果。
总体来说可以分为以下几个方向:
1)新增一个调度器
直接修改 kube-scheduler 源码,编译替换- 使用 调度框架(Scheduling Framework),我们可以使用 scheduler-plugins 作为模板,简化自定义调度器的开发流程。
- Kubernetes v1.15 版本中引入了可插拔架构的调度框架,使得定制调度器这个任务变得更加的容易。调库框架向现有的调度器中添加了一组插件化的 API,该 API 在保持调度程序“核心”简单且易于维护的同时,使得大部分的调度功能以插件的形式存在。
2)扩展原有调度器
- 通过 Scheduler Extender 可以实现对已有调度器进行扩展。单独创建一个 HTTP 服务并实现对应接口,后续就可以将该服务作为外置调度器使用。通过配置
KubeSchedulerConfiguration
原 Scheduler 会以 HTTP 调用方式和外置调度器交互,实现在不改动原有调度器基础上增加自定义逻辑。
3)其他非主流方案
- 自定义 Webhook 直接修改未调度 Pod 的 spec.nodeName 字段,有点离谱但理论可行哈哈
二者都有自己的优缺点
优点 | 缺点 | |
---|---|---|
新增调度器 | 性能好:由于不依赖外部插件或 HTTP 调用,调度流程的延迟相对较低,适合对性能要求较高的场景复用性高:可复用现有的调度插件,如 scheduler-plugins ,大大降低了开发难度,提升了开发效率。 | 多个调度器可能会冲突:比如多个调度器同时调度了一个 Pod 到节点上,先启动的 Pod 把资源占用了,后续的 Pod 无法启动。 |
扩展调度器 | 实现简单:无需重新编译调度器,通过配置 KubeSchedulerConfiguration 创建一个外部 HTTP 服务来实现自定义逻辑。零侵入性:不需要修改或重构调度器的核心代码,可快速上线新的调度逻辑。灵活性较高:原有调度器和自定义逻辑相对独立,方便维护与测试。 | 性能差:调度请求需要经过 HTTP 调用,增加了调用延迟,对性能可能有影响。 |
一般在我们要改动的逻辑不多时,直接使用 Scheduler Extender 是比较简单的。
Scheduler 配置
调度器的配置有一个单独的对象:KubeSchedulerConfiguration,不过并没有以 CRD 形式存在,而是存放到 Configmap 中的。
以下是一个完整 KubeSchedulerConfiguration 的 yaml:
apiVersion: v1
data:
config.yaml: |
apiVersion: kubescheduler.config.k8s.io/v1beta2
kind: KubeSchedulerConfiguration
leaderElection:
leaderElect: false
profiles:
- schedulerName: hami-scheduler
extenders:
- urlPrefix: "https://127.0.0.1:443"
filterVerb: filter
bindVerb: bind
nodeCacheCapable: true
weight: 1
httpTimeout: 30s
enableHTTPS: true
tlsConfig:
insecure: true
managedResources:
- name: nvidia.com/gpu
ignoredByScheduler: true
- name: nvidia.com/gpumem
ignoredByScheduler: true
- name: nvidia.com/gpucores
ignoredByScheduler: true
一般分为基础配置和 extenders 配置两部分。
基础配置
基础配置一般就是配置调度器的名称,Demo 如下:
apiVersion: v1
kind: ConfigMap
metadata:
name: my-scheduler-config
namespace: kube-system
data:
my-scheduler-config.yaml: |
apiVersion: kubescheduler.config.k8s.io/v1beta2
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: my-scheduler
leaderElection:
leaderElect: false
通过 schedulerName 来指定该调度器的名称,比如这里就是 my-scheduler
。
创建 Pod 时除非手动指定 spec.schedulerName 为 my-scheduler,否则不会由该调度器进行调度。
扩展调度器:extenders 配置
extenders 部分通过额外指定一个 http 服务器来实现外置的自定义的调度逻辑。
一个简单的 Scheduler Extender 配置如下:
apiVersion: v1
kind: ConfigMap
metadata:
name: i-scheduler-extender
namespace: kube-system
data:
i-scheduler-extender.yaml: |
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: i-scheduler-extender
leaderElection:
leaderElect: false
extenders:
- urlPrefix: "http://localhost:8080"
enableHTTPS: false
filterVerb: "filter"
prioritizeVerb: "prioritize"
bindVerb: "bind"
weight: 1
nodeCacheCapable: true
核心部分为
extenders:
- urlPrefix: "http://localhost:8080"
enableHTTPS: false
filterVerb: "filter"
prioritizeVerb: "prioritize"
bindVerb: "bind"
weight: 1
nodeCacheCapable: true
几个核心参数含义如下:
urlPrefix: http://127.0.0.1:8080
用于指定外置的调度服务访问地址filterVerb: "filter"
:表示 Filter 接口在外置服务中的访问地址为 filter,即完整地址为http://127.0.0.1:8080/filter
prioritizeVerb: "prioritize"
:同上,Prioritize(Score) 接口的地址为 prioritizebindVerb: "bind"
:同上,Bind 接口的地址为 bind
这样该调度器在执行 Filter 接口逻辑时,除了内置的调度器插件之外,还会通过 HTTP 方式调用外置的调度器。
这样我们只需要创建一个 HTTP 服务,实现对应接口即可实现自定义的调度逻辑,而不需要重头实现一个调度器。
ManagedResources 配置
在之前的配置中是所有 Pod 都会走 Extender 的调度逻辑,实际上 Extender 还有一个 ManagedResources 配置,用于限制只有申请使用指定资源的 Pod 才会走 Extender 调度逻辑,这样可以减少无意义的调度。
一个带 managedResources 的 KubeSchedulerConfiguration 内容如下
apiVersion: v1
data:
config.yaml: |
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
leaderElection:
leaderElect: false
profiles:
- schedulerName: hami-scheduler
extenders:
- urlPrefix: "https://127.0.0.1:443"
filterVerb: filter
bindVerb: bind
nodeCacheCapable: false
enableHTTPS: false
managedResources:
- name: nvidia.com/gpu
ignoredByScheduler: true
- name: nvidia.com/gpumem
ignoredByScheduler: true
- name: nvidia.com/gpucores
ignoredByScheduler: true
- name: nvidia.com/gpumem-percentage
ignoredByScheduler: true
- name: nvidia.com/priority
ignoredByScheduler: true
在原来的基础上增加了 managedResources 部分的配置
managedResources:
- name: nvidia.com/gpu
ignoredByScheduler: true
- name: nvidia.com/gpumem
ignoredByScheduler: true
- name: nvidia.com/gpucores
ignoredByScheduler: true
- name: nvidia.com/gpumem-percentage
ignoredByScheduler: true
只有 Pod 申请这些特殊资源时才走 Extender 调度逻辑,否则使用原生的 Scheduler 调度即可。
ignoredByScheduler: true
的作用是告诉调度器忽略指定资源,避免将它们作为调度决策的依据。也就是说,虽然这些资源(如 GPU 或其他加速硬件)会被 Pod 请求,但调度器不会在选择节点时基于这些资源的可用性做出决定。
ps:因为这些资源可能是虚拟的,并不会真正的出现在 Node 上,因此调度时需要忽略掉,否则就没有任何节点满足条件了,但是这些虚拟资源则是我们的自定义调度逻辑需要考虑的事情。
Scheduler 中的判断逻辑如下,和之前说的一样,只有当 Pod 申请了这些指定的资源时,Scheduler 才会调用 Extender。
// IsInterested returns true if at least one extended resource requested by
// this pod is managed by this extender.
func (h *HTTPExtender) IsInterested(pod *v1.Pod) bool {
if h.managedResources.Len() == 0 {
return true
}
if h.hasManagedResources(pod.Spec.Containers) {
return true
}
if h.hasManagedResources(pod.Spec.InitContainers) {
return true
}
return false
}
func (h *HTTPExtender) hasManagedResources(containers []v1.Container) bool {
for i := range containers {
container := &containers[i]
for resourceName := range container.Resources.Requests {
if h.managedResources.Has(string(resourceName)) {
return true
}
}
for resourceName := range container.Resources.Limits {
if h.managedResources.Has(string(resourceName)) {
return true
}
}
}
return false
}
3. Scheduler Extender 规范
Scheduler Extender 通过 HTTP 请求的方式,将调度框架阶段中的调度决策委托给外部的调度器,然后将调度结果返回给调度框架。
我们只需要实现一个 HTTP 服务,然后通过配置文件将其注册到调度器中,就可以实现自定义调度器。
通过 Scheduler Extender 扩展原有调度器一般分为以下两步:
- 1)创建一个 HTTP 服务,实现对应接口
- 2)修改调度器配置 KubeSchedulerConfiguration,增加 extenders 相关配置
外置调度器可以影响到三个阶段:
Filter:调度框架将调用 Filter 函数,过滤掉不适合被调度的节点。
Priority:调度框架将调用 Priority 函数,为每个节点计算一个优先级,优先级越高,节点越适合被调度。
Bind:调度框架将调用 Bind 函数,将 Pod 绑定到一个节点上。
Filter、Priority、Bind 三个阶段分别对应三个 HTTP 接口,三个接口都接收 POST 请求,各自的请求、响应结构定义在这里:#kubernetes/kube-scheduler/extender/v1/types.go
在这个 HTTP 服务中,我们可以实现上述阶段中的任意一个或多个阶段的接口,来定制我们的调度需求。
每个接口的请求和响应值请求如下。
Filter
请求参数
// ExtenderArgs represents the arguments needed by the extender to filter/prioritize
// nodes for a pod.
type ExtenderArgs struct {
// Pod being scheduled
Pod *v1.Pod
// List of candidate nodes where the pod can be scheduled; to be populated
// only if Extender.NodeCacheCapable == false
Nodes *v1.NodeList
// List of candidate node names where the pod can be scheduled; to be
// populated only if Extender.NodeCacheCapable == true
NodeNames *[]string
}
响应结果
// ExtenderFilterResult represents the results of a filter call to an extender
type ExtenderFilterResult struct {
// Filtered set of nodes where the pod can be scheduled; to be populated
// only if Extender.NodeCacheCapable == false
Nodes *v1.NodeList
// Filtered set of nodes where the pod can be scheduled; to be populated
// only if Extender.NodeCacheCapable == true
NodeNames *[]string
// Filtered out nodes where the pod can't be scheduled and the failure messages
FailedNodes FailedNodesMap
// Filtered out nodes where the pod can't be scheduled and preemption would
// not change anything. The value is the failure message same as FailedNodes.
// Nodes specified here takes precedence over FailedNodes.
FailedAndUnresolvableNodes FailedNodesMap
// Error message indicating failure
Error string
}
Prioritize
请求参数
// ExtenderArgs represents the arguments needed by the extender to filter/prioritize
// nodes for a pod.
type ExtenderArgs struct {
// Pod being scheduled
Pod *v1.Pod
// List of candidate nodes where the pod can be scheduled; to be populated
// only if Extender.NodeCacheCapable == false
Nodes *v1.NodeList
// List of candidate node names where the pod can be scheduled; to be
// populated only if Extender.NodeCacheCapable == true
NodeNames *[]string
}
响应结果
// HostPriority represents the priority of scheduling to a particular host, higher priority is better.
type HostPriority struct {
// Name of the host
Host string
// Score associated with the host
Score int64
}
// HostPriorityList declares a []HostPriority type.
type HostPriorityList []HostPriority
Bind
请求参数
// ExtenderBindingArgs represents the arguments to an extender for binding a pod to a node.
type ExtenderBindingArgs struct {
// PodName is the name of the pod being bound
PodName string
// PodNamespace is the namespace of the pod being bound
PodNamespace string
// PodUID is the UID of the pod being bound
PodUID types.UID
// Node selected by the scheduler
Node string
}
响应结果
// ExtenderBindingResult represents the result of binding of a pod to a node from an extender.
type ExtenderBindingResult struct {
// Error message indicating failure
Error string
}
4. Demo
这部分则是手把手实现一个简单的扩展调度器,完整代码见:lixd/i-scheduler-extender
功能如下:
- 1)过滤阶段:仅调度到带有 Label
priority.lixueduan.com
的节点上 - 2)打分阶段:直接将节点上 Label
priority.lixueduan.com
的值作为得分- 比如某节点携带 Label
priority.lixueduan.com=50
则打分阶段该节点则是 50 分
- 比如某节点携带 Label
代码实现
main.go
比较简单,直接通过内置的 net.http 包启动一个 http 服务即可。
var h *server.Handler
func init() {
h = server.NewHandler(extender.NewExtender())
}
func main() {
http.HandleFunc("/filter", h.Filter)
http.HandleFunc("/filter_onlyone", h.FilterOnlyOne) // Filter 接口的一个额外实现
http.HandleFunc("/priority", h.Prioritize)
http.HandleFunc("/bind", h.Bind)
http.ListenAndServe(":8080", nil)
}
由于 Priority 阶段返回的得分 kube-Scheduler 会自行汇总后重新计算,因此扩展调度器的 priority 接口并不能安全控制最终调度结果,因此额外实现了一个 filter_onlyone 接口。
Filter 实现
filter 接口用于过滤掉不满足条件的接口,这里直接过滤掉没有指定 label 的节点即可。
// Filter 过滤掉不满足条件的节点
func (ex *Extender) Filter(args extenderv1.ExtenderArgs) *extenderv1.ExtenderFilterResult {
nodes := make([]v1.Node, 0)
nodeNames := make([]string, 0)
for _, node := range args.Nodes.Items {
_, ok := node.Labels[Label]
if !ok { // 排除掉不带指定标签的节点
continue
}
nodes = append(nodes, node)
nodeNames = append(nodeNames, node.Name)
}
// 没有满足条件的节点就报错
if len(nodes) == 0 {
return &extenderv1.ExtenderFilterResult{Error: fmt.Errorf("all node do not have label %s", Label).Error()}
}
args.Nodes.Items = nodes
return &extenderv1.ExtenderFilterResult{
Nodes: args.Nodes, // 当 NodeCacheCapable 设置为 false 时会使用这个值
NodeNames: &nodeNames, // 当 NodeCacheCapable 设置为 true 时会使用这个值
}
}
具体返回 Nodes 还是 NodeNames 决定了后续 Scheduler 部署的 NodeCacheCapable 参数的配置,二者对于即可。
Prioritize 实现
Prioritize 对应的就是 Score 阶段,给 Filter 之后留下来的节点打分,选择一个最适合的节点进行调度。
这里的逻辑就是之前说的:解析 Node 上的 label ,直接将其 value 作为节点分数。
// Prioritize 给 Pod 打分
func (ex *Extender) Prioritize(args extenderv1.ExtenderArgs) *extenderv1.HostPriorityList {
var result extenderv1.HostPriorityList
for _, node := range args.Nodes.Items {
// 获取 Node 上的 Label 作为分数
priorityStr, ok := node.Labels[Label]
if !ok {
klog.Errorf("node %q does not have label %s", node.Name, Label)
continue
}
priority, err := strconv.Atoi(priorityStr)
if err != nil {
klog.Errorf("node %q has priority %s are invalid", node.Name, priorityStr)
continue
}
result = append(result, extenderv1.HostPriority{
Host: node.Name,
Score: int64(priority),
})
}
return &result
}
Bind 实现
就是通过 clientset 创建一个 Binding 对象,指定 Pod 和 Node 信息。
// Bind 将 Pod 绑定到指定节点
func (ex *Extender) Bind(args extenderv1.ExtenderBindingArgs) *extenderv1.ExtenderBindingResult {
log.Printf("bind pod: %s/%s to node:%s", args.PodNamespace, args.PodName, args.Node)
// 创建绑定关系
binding := &corev1.Binding{
ObjectMeta: metav1.ObjectMeta{Name: args.PodName, Namespace: args.PodNamespace, UID: args.PodUID},
Target: corev1.ObjectReference{Kind: "Node", APIVersion: "v1", Name: args.Node},
}
result := new(extenderv1.ExtenderBindingResult)
err := ex.ClientSet.CoreV1().Pods(args.PodNamespace).Bind(context.Background(), binding, metav1.CreateOptions{})
if err != nil {
klog.ErrorS(err, "Failed to bind pod", "pod", args.PodName, "namespace", args.PodNamespace, "podUID", args.PodUID, "node", args.Node)
result.Error = err.Error()
}
return result
}
Filter OnlyOne 实现
Extender 仅作为一个额外的调度插件接入, 接口返回得分最终 Scheduler 会将其和其他插件打分合并之后再选出最终节点,因此 extender 中无法通过 prioritie 接口的分数完全控制调度结果。
不过也不是没有办法!
想要完全控制调度结果,我们可以在 Filter 接口中特殊处理。
Filter 接口先过滤掉不满足条件的节点,然后对剩余节点进行打分,最终只返回得分最高的那个节点,这样就一定会调度到该接口,从而实现完全控制调度结果。
具体实现如下:
// FilterOnlyOne 过滤掉不满足条件的节点,并将其余节点打分排序,最终只返回得分最高的节点以实现完全控制调度结果
func (ex *Extender) FilterOnlyOne(args extenderv1.ExtenderArgs) *extenderv1.ExtenderFilterResult {
// 过滤掉不满足条件的节点
nodeScores := &NodeScoreList{NodeList: make([]*NodeScore, 0)}
for _, node := range args.Nodes.Items {
_, ok := node.Labels[Label]
if !ok { // 排除掉不带指定标签的节点
continue
}
// 对剩余节点打分
score := ComputeScore(node)
nodeScores.NodeList = append(nodeScores.NodeList, &NodeScore{Node: node, Score: score})
}
// 没有满足条件的节点就报错
if len(nodeScores.NodeList) == 0 {
return &extenderv1.ExtenderFilterResult{Error: fmt.Errorf("all node do not have label %s", Label).Error()}
}
// 排序
sort.Sort(nodeScores)
// 然后取最后一个,即得分最高的节点,这样由于 Filter 只返回了一个节点,因此最终肯定会调度到该节点上
m := (*nodeScores).NodeList[len((*nodeScores).NodeList)-1]
// 组装一下返回结果
args.Nodes.Items = []v1.Node{m.Node}
return &extenderv1.ExtenderFilterResult{
Nodes: args.Nodes,
NodeNames: &[]string{m.Node.Name},
}
}
部署
构建镜像
Dockerfile 如下:
# syntax=docker/dockerfile:1
# Build the manager binary
FROM golang:1.22.5 as builder
ARG TARGETOS
ARG TARGETARCH
ENV GOPROXY=https://goproxy.cn
WORKDIR /workspace
# Copy the go source
COPY . /workspace
# cache deps before building and copying source so that we don't need to re-download as much
# and so that source changes don't invalidate our downloaded layer
RUN go mod download
# Build
# the GOARCH has not a default value to allow the binary be built according to the host where the command
# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o extender main.go
# Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details
#FROM gcr.io/distroless/static:nonroot
FROM busybox:1.36
WORKDIR /
COPY --from=builder /workspace/extender .
USER 65532:65532
ENTRYPOINT ["/extender"]
部署到集群
部分也是分为两步:
1)部署 Extender:由于 Extender 只是一个 HTTP 服务器,只需要使用 Deployment 将其部署到集群即可。
2)配置 Extender:修改调度器的 KubeSchedulerConfiguration 配置,在其中 extender 部分指定 url 以及对应的 path,进行接入。
这里为了不影响到默认的 kube-scheduler,我们使用 kube-scheduler 镜像单独启动一个 Scheduler,然后为该调度器配置上 Extender,同时为了降低网络请求的影响,直接将 kube-scheduler 和 Extender 直接运行在同一个 Pod 里,通过 localhost 进行访问。
完整 yaml 如下:
apiVersion: v1
kind: ServiceAccount
metadata:
name: i-scheduler-extender
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: i-scheduler-extender
subjects:
- kind: ServiceAccount
name: i-scheduler-extender
namespace: kube-system
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: ConfigMap
metadata:
name: i-scheduler-extender
namespace: kube-system
data:
i-scheduler-extender.yaml: |
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: i-scheduler-extender
leaderElection:
leaderElect: false
extenders:
- urlPrefix: "http://localhost:8080"
enableHTTPS: false
filterVerb: "filter"
prioritizeVerb: "prioritize"
bindVerb: "bind"
weight: 1
nodeCacheCapable: true
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
component: i-scheduler-extender
tier: control-plane
name: i-scheduler-extender
namespace: kube-system
spec:
replicas: 1
selector:
matchLabels:
component: i-scheduler-extender
tier: control-plane
template:
metadata:
labels:
component: i-scheduler-extender
tier: control-plane
spec:
serviceAccountName: i-scheduler-extender
containers:
- name: kube-scheduler
image: registry.k8s.io/kube-scheduler:v1.29.0
command:
- kube-scheduler
- --config=/etc/kubernetes/i-scheduler-extender.yaml
livenessProbe:
httpGet:
path: /healthz
port: 10259
scheme: HTTPS
initialDelaySeconds: 15
readinessProbe:
httpGet:
path: /healthz
port: 10259
scheme: HTTPS
resources:
requests:
cpu: '0.1'
volumeMounts:
- name: config-volume
mountPath: /etc/kubernetes
- name: i-scheduler-extender
image: lixd96/i-scheduler-extender:v1
ports:
- containerPort: 8080
volumes:
- name: config-volume
configMap:
name: i-scheduler-extender
kubectl apply -f deploy
确认服务正常运行
[root@scheduler-1 ~]# kubectl -n kube-system get po|grep i-scheduler-extender
i-scheduler-extender-f9cff954c-dkwz2 2/2 Running 0 1m
接下来就可以开始测试了。
测试
创建 Pod
创建一个 Deployment 并指定使用上一步中部署的 Scheduler,然后测试会调度到哪个节点上。
apiVersion: apps/v1
kind: Deployment
metadata:
name: test
spec:
replicas: 1
selector:
matchLabels:
app: test
template:
metadata:
labels:
app: test
spec:
schedulerName: i-scheduler-extender
containers:
- image: busybox:1.36
name: nginx
command: ["sleep"]
args: ["99999"]
创建之后 Pod 会一直处于 Pending 状态
[root@scheduler-1 lixd]# k get po
NAME READY STATUS RESTARTS AGE
test-58794bff9f-ljxbs 0/1 Pending 0 17s
查看具体情况
[root@scheduler-1]# k describe po test-58794bff9f-ljxbs
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 99s i-scheduler-extender all node do not have label priority.lixueduan.com
Warning FailedScheduling 95s (x2 over 97s) i-scheduler-extender all node do not have label priority.lixueduan.com
可以看到,是因为 Node 上没有我们定义的 Label,因此都不满足条件,最终 Pod 就一直 Pending 了。
添加 Label
由于我们实现的 Filter 逻辑是需要 Node 上有priority.lixueduan.com
才会用来调度,否则直接会忽略。
理论上,只要给任意一个 Node 打上 Label 就可以了。
[root@scheduler-1 install]# k get node
NAME STATUS ROLES AGE VERSION
scheduler-1 Ready control-plane 4h34m v1.27.4
scheduler-2 Ready <none> 4h33m v1.27.4
[root@scheduler-1 install]# k label node scheduler-1 priority.lixueduan.com=10
node/scheduler-1 labeled
再次查看 Pod 状态
[root@scheduler-1 lixd]# k get po -owide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
test-58794bff9f-ljxbs 1/1 Running 0 104s 172.25.123.201 scheduler-1 <none> <none>
已经被调度到 node1 上了,查看详细日志
[root@scheduler-1 install]# k describe po test-7f7bb8f449-w6wvv
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 116s i-scheduler-extender 0/2 nodes are available: preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.
Warning FailedScheduling 112s (x2 over 115s) i-scheduler-extender 0/2 nodes are available: preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.
Normal Scheduled 26s i-scheduler-extender Successfully assigned default/test-58794bff9f-ljxbs to scheduler-1
可以看到,确实是 i-scheduler-extender 这个调度器在处理,调度到了 node1.
多节点排序
我们实现的 Score 是根据 Node 上的 priority.lixueduan.com
对应的 Value 作为得分的,因此调度器会优先考虑调度到 Value 比较大的一个节点。
因为 Score 阶段也有很多调度插件,Scheduler 会汇总所有得分,最终才会选出结果,因此这里的分数也是仅供参考,不能完全控制调度结果。
给 node2 也打上 label,value 设置为 20
[root@scheduler-1 install]# k get node
NAME STATUS ROLES AGE VERSION
scheduler-1 Ready control-plane 4h34m v1.27.4
scheduler-2 Ready <none> 4h33m v1.27.4
[root@scheduler-1 install]# k label node scheduler-2 priority.lixueduan.com=20
node/scheduler-2 labeled
然后更新 Deployment ,触发创建新 Pod ,测试调度逻辑。
[root@scheduler-1 lixd]# kubectl rollout restart deploy test
deployment.apps/test restarted
因为 Node2 上的 priority 为 20,node1 上为 10,那么理论上会调度到 node2 上。
[root@scheduler-1 lixd]# k get po -owide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
test-84fdbbd8c7-47mtr 1/1 Running 0 38s 172.25.0.162 scheduler-1 <none> <none>
结果还是调度到了 node1,为什么呢?
这就是前面提到的:因为 Extender 仅作为一个额外的调度插件接入,Prioritize 接口返回得分最终 Scheduler 会将其和其他插件打分合并之后再选出最终节点,因此 Extender 想要完全控制调度结果,只能在 Filter 接口中实现,过滤掉不满足条件的节点,并对剩余节点进行打分,最终 Filter 接口只返回得分最高的那个节点,从而实现完全控制调度结果。
ps:即之前的 Filter OnlyOne 实现,可以在 KubeSchedulerConfiguration 中配置不同的 path 来调用不同接口进行测试。
修改 KubeSchedulerConfiguration 配置,
apiVersion: v1
kind: ConfigMap
metadata:
name: i-scheduler-extender
namespace: kube-system
data:
i-scheduler-extender.yaml: |
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: i-scheduler-extender
leaderElection:
leaderElect: false
extenders:
- urlPrefix: "http://localhost:8080"
enableHTTPS: false
filterVerb: "filter_onlyone"
prioritizeVerb: "prioritize"
bindVerb: "bind"
weight: 1
nodeCacheCapable: true
修改点:
filterVerb: "filter_onlyone"
Path 从 filter 修改成了 filter_onlyone,这里的 path 和前面注册服务时的路径对应:
http.HandleFunc("/filter", h.Filter)
http.HandleFunc("/filter_onlyone", h.FilterOnlyOne) // Filter 接口的一个额外实现
修改后重启一下 Scheduler
kubectl -n kube-system rollout restart deploy i-scheduler-extender
再次更新 Deployment 触发调度
[root@scheduler-1 install]# k rollout restart deploy test
deployment.apps/test restarted
这样应该是调度到 node2 了,确认一下
[root@scheduler-1 lixd]# k get po -owide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
test-849f549d5b-pbrml 1/1 Running 0 12s 172.25.0.166 scheduler-2 <none> <none>
现在我们更新 Node1 的 label,改成 30
k label node scheduler-1 priority.lixueduan.com=30 --overwrite
再次更新 Deployment 触发调度
[root@scheduler-1 install]# k rollout restart deploy test
deployment.apps/test restarted
这样应该是调度到 node1 了,确认一下
[root@scheduler-1 lixd]# k get po -owide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
test-69d9ccb877-9fb6t 1/1 Running 0 5s 172.25.123.203 scheduler-1 <none> <none>
说明修改 Filter 方法实现之后,确实可以直接控制调度结果。
5. 小结
本文主要分享了如何通过 Scheduler Extender 方式实现自定义调度逻辑。
Extender 具体为一个 http 服务器,可以实现 Filter、Prioritize(调度中的 Score 阶段)、Bind 三个接口。
通过修改 KubeSchedulerConfiguration 配置,为 Scheduler 指定了一个外部的调度器插件。
因此通过 Scheduler Extender 扩展原有调度器一般分为以下两步:
- 1)创建一个 HTTP 服务,实现对应接口
- 2)修改调度器配置 KubeSchedulerConfiguration,增加 extenders 相关配置
此外还可以通过 ManagedResources 配置实现只让部分 Pod 走 Extender 调度逻辑。
需要注意的是:Extender 仅作为一个额外的调度插件接入,Prioritize 接口返回得分最终 Scheduler 会将其和其他插件打分合并之后再选出最终节点,因此 Extender 想要完全控制调度结果,只能在 Filter 接口中实现,过滤掉不满足条件的节点,并对剩余节点进行打分,最终 Filter 接口只返回得分最高的那个节点,从而实现完全控制调度结果。
最佳实践:
如果只是要实现普通调度插件:可以正常实现 Filter、Prioritize、Bind 三个接口。
如果要由 Extender 完全控制调度结果:只需要实现Filter、Bind 接口,且 Filter 结果只能返回一个得分最高的节点作为最终选定的节点,即:将 FIlter 和 Score 逻辑都合并到 Filter 接口中。