Please enable Javascript to view the contents

Tekton 优化之定制集群调度器

 ·  ☕ 7 分钟

1. 受限的构建环境无法满足构建需求

Tekton 是基于 Kubernetes 集群的 CICD 引擎,相较于 Jenkins 更加云原生。说人话就是,更好开发插件、更好扩容、更好可观测性、更好玩儿。

由于代码仅能落盘公司内网,导致构建集群仅能部署于办公内网。这导致了很多受限:

  • 硬件资源,没有弹性扩容能力
  • 网络受限,访问 github.com、docker.io、dl-cdn.alpinelinux.org 很慢
  • 可靠性受限,机房稳定环境得不到保障、硬件故障率高
  • 运维受限,维护系统必须先接入公司内网

但这些都是 CICD 研发的事情,没人会关注,我只能默默承受,想办法解决,谁让我是个打工人。

直到忍无可忍的业务研发,开始公开喷 “为什么 CDN 上传任务这么慢,以前不是这样的” 。那是因为,以前我没入职,我来了早就慢下来了。

没办法,业务研发是构建系统的用户,作为平台开发者只能再想想办法,就调研了一个云连接的方案。每个月 1w 人民币,可以接入华为云的海外 VPN 专线,直连海外。

但花钱的事儿,业务怎么肯干?程序员的事,他们怎么会愿意花钱?

2. 跨区域的 Kubernetes 构建集群

有意思的是,我上班的公司,是多区域办公,各个区域的内网互通,但是出口网络不一样。其中有一个区域,有很多海外业务线,出口网络质量格外好。

这种网络环境,正好可以用来搭建跨区域的 Kubernetes 集群,用于构建。如下图:

在访问海外质量好的区域,新增若干构建节点。

如上图,我们计划将访问海外资源的 CICD 任务调度到 Good to Google 节点上执行。因此,我们需要一个 Kubernetes 集群调度器,能够根据不同任务定制调度策略。

3. 常见的几种调度器扩展方式[1]

  • default-scheduler

直接在 scheduler 源码的基础上,进行硬编码修改,然后重新编译 kube-scheduler,替换掉原来的 kube-scheduler

  • custom scheduler

在创建资源时,可以设置 spec.schedulerName 字段来指定使用哪个调度器处理。这种方式下,一个集群共存多个调度器,每个调度器的 sheduler name 不同。

  • scheduler extender[2]

如上图,scheduler extender 提供了几个扩展点,当 kube-scheduler 调度流程进入该扩展阶段时,会向 sheduler extender 发送 http 请求,处理定制逻辑。

部署时,可以使用 Deployment 部署 scheduler extender,修改 kube-scheduler 启动参数指向 scheduler extender 的地址即可。

  • scheduler framework

如上图,scheduler framework 也提供了几个扩展点,根据处理的阶段,实现对应的接口。

部署时,直接替换 kube-scheduler 的镜像,添加配置文件说明启用哪些 plugin 即可。

4. 定制集群调度器

上述四种方式,第一种硬编码太硬核,第二种适用于多租户场景。第三种和第四种都是基于扩展点,但第三种需要部署额外的组件,第四种直接替换 kube-scheduler 镜像。

第三种调度时需要发起 http 请求、创建集群 Client 维护 Informer ,第四种效率应该会更高,也更新更有未来。网上相关的教程挺详细,这里主要记录下遇到的坑。

4.1 如何新建项目

打开 https://github.com/kubernetes-sigs/scheduler-plugins/ 切换到集群对应的 tag,构建集群是 Kubernetes 1.21 ,因此选择 v0.21.6。

这个项目下有很多可以参考的 plugin,我们可以直接拿来用,也可以根据自己的需求修改定制。

拷贝 go.mod 文件 replace 部分,新建一个自己的 go 项目。

这样做的目的是:

  • 让调度器的代码依赖版本与集群版本保持一致,否则跨版本太多会有参数不兼容问题
  • 避免编译时,依赖报错,处理起来很费时

4.2 快速设计

  • 怎么指定命名空间、任务,到特定节点
1
2
3
kubectl annotate namespace kube-system com.chenshaowen.scheduler-plugin.filterimage=cdn
kubectl annotate namespace kube-system com.chenshaowen.scheduler-plugin.nodes=node2
kubectl annotate namespace kube-system com.chenshaowen.scheduler-plugin.ns=default

由于是全局配置,直接将配置信息写在某个命名空间的 annotation 中,当数据库用。

通过 Pod 的镜像识别出是否为 CDN 任务。但并不是每个项目的 CDN 任务都需要走海外节点,还有上传国内的 CDN 任务。

上面数据下,最终的效果应该是,允许 default 命名空间下,镜像包含 cdn 字符串的 Pod 优先调度到 node2 节点;禁止镜像不包含 cdn 字符串的 Pod 调度到 node2 节点,禁止其他命名空间调度到 node2 节点。

  • 需要进行哪些扩展

Filter、Score 这两个扩展点就够了。如果不清楚使用哪些扩展,可以直接看一下 Plugin 的接口定义,查看接口参数和返回。

Filter 需要禁止非指定的命名空间 Pod 调度到指定节点,放行指定命名空间的特殊 Pod 的调度。

Score 需要优先将指定命名空间的特殊 Pod 调度到指定节点。

4.3 写 main.go 及 plugin 相关代码

  • main
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
	rand.Seed(time.Now().UnixNano())
	command := app.NewSchedulerCommand(
		app.WithPlugin(image.Name, image.New),
	)
	if err := command.Execute(); err != nil {
		println(os.Stderr, "%v\n", err)
		os.Exit(1)
	}
}

main 使用 default-scheduler 的代码启动,然后注册自己的 plugin,可以注册多个。

  • 定义插件 ImageNode
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type ImageNode struct {
	handle framework.Handle
}

// var _ = framework.PreFilterPlugin(&ImageNode{})
var _ = framework.FilterPlugin(&ImageNode{})
var _ = framework.ScorePlugin(&ImageNode{})

const Name = "ImageNode"

func (i *ImageNode) Name() string {
	return Name
}

func New(_ runtime.Object, h framework.Handle) (framework.Plugin, error) {
	return &ImageNode{
		handle: h,
	}, nil
}

这个格式是固定的,var _ = framework.FilterPlugin(&ImageNode{}) 是为了 ImageNode 一定要实现 FilterPlugin 接口,否则编译不通过。这里需要实现 FilterPlugin 和 ScorePlugin 接口。

  • 实现 FilterPlugin 接口
 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
func (i *ImageNode) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
	if pod == nil {
		return framework.NewStatus(framework.Error, "pod is nil")
	}
	node := nodeInfo.Node()
	if node == nil {
		klog.Infof("node is nil")
		return framework.NewStatus(framework.Error, "node is nil")
	}
	nodes, nss, filterImage := isSpecialNS(i.handle.ClientSet(), pod.Namespace)

	if len(nodes) == 0 || len(nss) == 0 || len(filterImage) == 0 {
		klog.Infof("nodes, nss, filterImage is nil")
		return framework.NewStatus(framework.Success, "default")
	}

	workload := ""
	if len(pod.ObjectMeta.OwnerReferences) > 0 {
		workload = pod.ObjectMeta.OwnerReferences[0].Kind
	}
	if workload == "DaemonSet" {
		klog.Info("DaemonSet pass")
		return framework.NewStatus(framework.Success, "DaemonSet pass")
	}

	if isStringInList(node.Name, nodes) {
		if isStringInList(pod.Namespace, nss) && isSpecialImage(pod, filterImage) {
			klog.Infof("plugin hit")
			return framework.NewStatus(framework.Success, "plugin hit")
		} else {
			klog.Info(fmt.Printf("plugin disable pod %s special node %s", pod.Name, node.Name))
			return framework.NewStatus(framework.Unschedulable, "plugin disable special node")
		}
	}
	klog.Info("default pass")
	return framework.NewStatus(framework.Success, "default")
}

这里有一个特殊的逻辑,放行 DaemonSet 的 Pod,否则会导致 DaemonSet 的 Pod 无法在标记的节点上运行。

  • 实现 ScorePlugin 接口
 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
func (i *ImageNode) Score(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
	nodes, nss, filterImage := isSpecialNS(i.handle.ClientSet(), pod.Namespace)
	if len(nodes) > 0 && len(filterImage) > 0 && len(nss) > 0 {
		if isStringInList(nodeName, nodes) && isSpecialImage(pod, filterImage) {
			retScore := framework.MaxNodeScore - rand.Int63n(10)
			klog.Infof("special node score %d", retScore)
			return retScore, framework.NewStatus(framework.Success, "special node score")
		}
	}
	retScore := rand.Int63n(framework.MaxNodeScore - 50)
	klog.Infof("rand node score %d", retScore)
	return retScore, framework.NewStatus(framework.Success, "rand node score")
}

func (i *ImageNode) ScoreExtensions() framework.ScoreExtensions {
	return i
}

func (i *ImageNode) NormalizeScore(ctx context.Context, state *framework.CycleState, pod *v1.Pod, scores framework.NodeScoreList) *framework.Status {
	// Find highest and lowest scores.
	var highest int64 = -math.MaxInt64
	var lowest int64 = math.MaxInt64
	for _, nodeScore := range scores {
		if nodeScore.Score > highest {
			highest = nodeScore.Score
		}
		if nodeScore.Score < lowest {
			lowest = nodeScore.Score
		}
	}

	// Transform the highest to lowest score range to fit the framework's min to max node score range.
	oldRange := highest - lowest
	newRange := framework.MaxNodeScore - framework.MinNodeScore
	for i, nodeScore := range scores {
		if oldRange == 0 {
			scores[i].Score = framework.MinNodeScore
		} else {
			scores[i].Score = ((nodeScore.Score - lowest) * newRange / oldRange) + framework.MinNodeScore
		}
	}

	return nil
}

Score 就是给节点打分,分数高的优先被调度。这里的处理比较粗糙,因为 CICD 任务的 Req 都不高,default-scheduler 的打分本来就不准确,直接给了随机分。

为了避免 CDN 任务一直被调度到同一个标记的节点,引入了一定的波动,随机给节点减去一定分值。

4.4 调试和部署

这是新手动手时,最花时间的地方。阅读一两篇文档很容易理解,但是实际操作时,往往会遇到各种各样的问题。

  • 本地新建一个文件 scheduler-plugin.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: kubescheduler.config.k8s.io/v1beta1
kind: KubeSchedulerConfiguration
clientConnection:
  kubeconfig: "/Users/shaowenchen/.kube/config"
profiles:
  - schedulerName: default-scheduler
    plugins:
      filter:
        enabled:
          - name: ImageNode
      score:
        enabled:
          - name: ImageNode

apiVersion 需要根据集群版本进行调整。kubeconfig 指向本地的 kubeconfig 文件。filter 和 score 都需要开启 ImageNode 插件。

  • 启动 kube-scheduler
1
2
3
4
5
6
7
8
go run main.go \
    --leader-elect=true \
    --feature-gates=RotateKubeletServerCertificate=true,TTLAfterFinished=true,ExpandCSIVolumes=true,CSIStorageCapacity=true \
    --authentication-kubeconfig=/Users/shaowenchen/.kube/config \
    --authorization-kubeconfig=/Users/shaowenchen/.kube/config \
    --kubeconfig=/Users/shaowenchen/.kube/config \
    --config=/Volumes/Data/Code/Github/demo/scheduler-plugin/scheduler-plugin.yaml \
    --v=5

启动参数,不同版本的集群可能不同,需要去集群上查看。加上 --v=5 能够看到更详细的日志。

  • 编译镜像

Dockerfile 如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
FROM golang:1.19 AS build

WORKDIR /go/src/kube-scheduler
COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -o kube-scheduler .

FROM alpine:3.14

COPY --from=build /go/src/kube-scheduler/kube-scheduler /usr/bin/kube-scheduler

CMD ["/usr/bin/kube-scheduler"]

执行编译命令

1
docker build -t shaowenchen/scheduler-plugin:latest .
  • 部署[每个 Master 节点]

新建文件 /etc/kubernetes/scheduler-plugin.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: kubescheduler.config.k8s.io/v1beta1
kind: KubeSchedulerConfiguration
clientConnection:
  kubeconfig: "/etc/kubernetes/scheduler.conf"
profiles:
  - schedulerName: default-scheduler
    plugins:
      filter:
        enabled:
          - name: ImageNode
      score:
        enabled:
          - name: ImageNode

此时的 kubeconfig 应该指向集群的 kubeconfig 文件。

编辑 /etc/kubernetes/manifests/kube-scheduler.yaml,添加如下内容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: v1
kind: Pod
metadata:
  name: kube-scheduler
  namespace: kube-system
spec:
  containers:
  - command:
    - kube-scheduler
    ...
    - --config=/etc/kubernetes/scheduler-plugin.yaml
    image: shaowenchen/scheduler-plugin:latest
    imagePullPolicy: Always
    volumeMounts:
    - mountPath: /etc/kubernetes/scheduler-plugin.yaml
      name: scheduler-plugin
      readOnly: true
  volumes:
  - hostPath:
      path: /etc/kubernetes/scheduler-plugin.yaml
      type: File
    name: scheduler-plugin

--config=/etc/kubernetes/scheduler-plugin.yaml 指定配置文件,volumeMounts、volumes 用于挂载配置文件。

需要注意的是,kube-scheduler 是一个静态 pod,需要编辑一下文件才能真正重启。使用 kubectl -n kube-system delete pod kube-scheduler-master1 仅能重启容器,不会使用最新的镜像。

5. 总结

本篇从 Kubernetes 集群调度的角度来优化基于 Tekton 的 CICD 系统,将指定的任务调度到指定的节点上,更好的利用现有的网络资源。

主要内容如下:

  • 鉴于业务诉求,我们准备使用多区域的节点进行构建,因此需要定制调度器
  • 调研了常见的四种调度器,最终选择了 scheduler framework 的扩展方式
  • 使用 sheduler framework 的方式,实现了一个简单的调度器插件
  • 本地调试和部署

但由此可定制的内容还有很多,比如:

  • 根据任务的优先级,选择不同的节点
  • 根据主机的负载,选择不同的节点
  • 根据任务的资源需求,选择不同的节点

这进一步扩展了 CICD 系统可以定制和优化的空间。

6. 参考

  1. https://duyanghao.github.io/scheduler-extend/
  2. https://www.infoq.cn/article/lYUw79lJH9bZv7HrgGH5
  3. https://github.com/shaowenchen/demo/tree/master/scheduler-plugin

微信公众号
作者
微信公众号