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:

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 如下:

yaml 复制代码
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 配置如下:

yaml 复制代码
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

核心部分为

yaml 复制代码
    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) 接口的地址为 prioritize

  • bindVerb: "bind":同上,Bind 接口的地址为 bind

这样该调度器在执行 Filter 接口逻辑时,除了内置的调度器插件之外,还会通过 HTTP 方式调用外置的调度器。

这样我们只需要创建一个 HTTP 服务,实现对应接口即可实现自定义的调度逻辑,而不需要重头实现一个调度器。

ManagedResources 配置

在之前的配置中是所有 Pod 都会走 Extender 的调度逻辑,实际上 Extender 还有一个 ManagedResources 配置,用于限制只有申请使用指定资源的 Pod 才会走 Extender 调度逻辑,这样可以减少无意义的调度。

一个带 managedResources 的 KubeSchedulerConfiguration 内容如下

yaml 复制代码
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 部分的配置

yaml 复制代码
      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。

go 复制代码
// 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

请求参数

go 复制代码
// 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
}

响应结果

go 复制代码
// 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

请求参数

go 复制代码
// 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
}

响应结果

go 复制代码
// 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

请求参数

go 复制代码
// 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
}

响应结果

go 复制代码
// 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 分

代码实现

main.go

比较简单,直接通过内置的 net.http 包启动一个 http 服务即可。

go 复制代码
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 的节点即可。

go 复制代码
// 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 作为节点分数。

go 复制代码
// 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 信息。

go 复制代码
// 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 接口先过滤掉不满足条件的节点,然后对剩余节点进行打分,最终只返回得分最高的那个节点,这样就一定会调度到该接口,从而实现完全控制调度结果。

具体实现如下:

go 复制代码
// 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 如下:

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 如下:

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
bash 复制代码
kubectl apply -f deploy

确认服务正常运行

bash 复制代码
[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,然后测试会调度到哪个节点上。

yaml 复制代码
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 状态

bash 复制代码
[root@scheduler-1 lixd]# k get po
NAME                    READY   STATUS    RESTARTS   AGE
test-58794bff9f-ljxbs   0/1     Pending   0          17s

查看具体情况

bash 复制代码
[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 就可以了。

bash 复制代码
[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 状态

bash 复制代码
[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 上了,查看详细日志

bash 复制代码
[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

bash 复制代码
[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 ,测试调度逻辑。

bash 复制代码
[root@scheduler-1 lixd]# kubectl rollout restart deploy test
deployment.apps/test restarted

因为 Node2 上的 priority 为 20,node1 上为 10,那么理论上会调度到 node2 上。

bash 复制代码
[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 配置,

yaml 复制代码
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

修改点:

yaml 复制代码
filterVerb: "filter_onlyone"

Path 从 filter 修改成了 filter_onlyone,这里的 path 和前面注册服务时的路径对应:

go 复制代码
    http.HandleFunc("/filter", h.Filter)
    http.HandleFunc("/filter_onlyone", h.FilterOnlyOne) // Filter 接口的一个额外实现

修改后重启一下 Scheduler

bash 复制代码
kubectl -n kube-system rollout restart deploy i-scheduler-extender

再次更新 Deployment 触发调度

bash 复制代码
[root@scheduler-1 install]# k rollout restart deploy test
deployment.apps/test restarted

这样应该是调度到 node2 了,确认一下

bash 复制代码
[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

bash 复制代码
k label node scheduler-1 priority.lixueduan.com=30 --overwrite

再次更新 Deployment 触发调度

bash 复制代码
[root@scheduler-1 install]# k rollout restart deploy test
deployment.apps/test restarted

这样应该是调度到 node1 了,确认一下

bash 复制代码
[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 方法实现之后,确实可以直接控制调度结果。


【Kubernetes 系列】 持续更新中,搜索公众号【探索云原生】订阅,阅读更多文章。


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 接口中。

完整代码见:lixd/i-scheduler-extender

相关推荐
开挖掘机上班39 分钟前
基于Alpine构建MySQL镜像
mysql·docker·容器
todoitbo1 小时前
docker搭建freeswitch实现点对点视频,多人视频
docker·容器·音视频·freeswitch·视频聊天
William一直在路上3 小时前
回顾一下Docker的基本操作
docker·容器·eureka
阿里云云原生3 小时前
Nacos 开源 MCP Router,加速 MCP 私有化部署
云原生
赵成ccc3 小时前
如何进行 Docker 数据目录迁移
docker·容器·eureka
康闯4 小时前
Docker 部署emberstack/sftp 镜像
java·docker·容器
小阿技术4 小时前
本地电脑安装Dify|内网穿透到公网
人工智能·计算机视觉·docker·目标跟踪·flask·dify
William一直在路上5 小时前
kube-proxy 中 IPVS 与 iptables
云原生
vortex57 小时前
dockerfile 最佳实践
linux·docker·云技术