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]
直接在 scheduler 源码的基础上,进行硬编码修改,然后重新编译 kube-scheduler,替换掉原来的 kube-scheduler
在创建资源时,可以设置 spec.schedulerName
字段来指定使用哪个调度器处理。这种方式下,一个集群共存多个调度器,每个调度器的 sheduler name 不同。
如上图,scheduler extender 提供了几个扩展点,当 kube-scheduler 调度流程进入该扩展阶段时,会向 sheduler extender 发送 http 请求,处理定制逻辑。部署时,可以使用 Deployment 部署 scheduler extender,修改 kube-scheduler 启动参数指向 scheduler extender 的地址即可。
如上图,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 相关代码
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, "%vn", err)
os.Exit(1)
}
}
|
main 使用 default-scheduler 的代码启动,然后注册自己的 plugin,可以注册多个。
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 接口。
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 无法在标记的节点上运行。
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 插件。
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 .
|
新建文件 /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. 参考
https://duyanghao.github.io/scheduler-extend/
https://www.infoq.cn/article/lYUw79lJH9bZv7HrgGH5
https://github.com/shaowenchen/demo/tree/master/scheduler-plugin