最近在和李老师、朱师兄一起开发Rhino这个项目,项目地址在这里。Rhino是为HPC开发者提供的一套框架,方便HPC开发者将自己开发的MPI应用部署到 k8s 集群中。目前我主要开发的是Operator项目,该项目是基于kubebuilder 这个脚手架的,在这篇文章中记录一些讨论的会议纪要,和自己的一些体会。

关于Operator开发的背景知识,可以参考这一篇文章:还在手写 Operator?是时候使用 Kubebuilder 了

1. 声明式API与Operator模式原理

当我们要创建资源(比如使用kubectl apply yaml文件,或者调用k8s提供的API创建资源)时,k8s返回资源创建成功,这个创建成功是什么意思呢?

我们要创建资源时,请求都会先发送到API server;API server收到请求之后,如果系统中没有这个资源,那么就会将资源创建出来:向数据库etcd中写一条记录,记录当前资源的期望状态,之后etcd会向API server发送确认,表示这条记录已经成功记录下来,此时API server会向客户端返回资源创建成功。

也就是说,当API server返回资源创建成功之后,并不是真正创建成功了,而是表明这条记录写到了etcd数据库里面。

2. RhinoJob 工作原理

关于RhinoJob的工作原理,更详细的可以看这篇文章

RhinoJob的任务是在k8s上将MPI运算任务启动起来。首先Reconciler会启动一个Launcher Job,来协调和管理MPI工作节点;当Launcher Job启动成功后,Reconciler会根据提交任务的并行度(Parallelism字段)来创建Workers Job,运算结束后,Launcher Job 和 Workers Job状态会发生改变,此时再次触发Reconciler,将对应的Job,Pod等删除,并据此修改RhinoJob的状态。

3. 技术细节实现

3.1 TTL

目前Rhino Operator中的CRD(Custom Resource Definition)为RhinoJob,其中包含TTL字段,表示在TTL秒后,之前创建的CR应该被销毁,但是这是如何实现的呢?

来看一下处理TTL的代码:

1
2
3
4
5
6
7
8
if *rhinojob.Spec.TTL > 0 {
ttl_left := rhinojob.CreationTimestamp.Add(time.Second * time.Duration(*rhinojob.Spec.TTL)).Sub(time.Now())
if ttl_left > 0 {
return ctrl.Result{RequeueAfter: ttl_left}, nil
} else {
r.Delete(ctx, &rhinojob)
}
}

每次触发Reconcile逻辑,都会对TLL进行处理,计算CR剩余存活时间。如果ttl_left > 0,那么返回ctrl.Result{RequeueAfter: ttl_left}这个结构体;否则调用k8s API删除资源。

上面的ctrl.Result的结构体是用来干什么的呢?先来看一下Reconciler的工作逻辑:

Reconciler 处理的事件按照队列排列,也就是图中的workQueue,Reconciler处理完一个事件之后,再从队列中取出下一个事件,进行处理。

回到TTL的处理逻辑上来,ctrl.Result结构体中RequeueAfter: ttl_left字段的意思就是,在ttl_left时间后,将事件放到WorkQueue中,让Reconciler处理。这样我们就能实现在TTL事件后,将CR删除,不会出现程序一直跑一直跑,CR不会被删除的情况。

同样的,由于Reconciler是用队列来处理事件的,并发只有1,所以在Reconciler中调用time.Sleep是非常糟糕的设计,这会导致后面所有的事件都会被阻塞在这个队列中,无法被Reconciler处理。

3.2 Scheme

Scheme实现的是Go struct 和 Custom Resource的对应,和Web后端开发中的model非常相似,其实就是数据库中表的结构。使用kubectl apply的yaml文件的结构,就可以转换成对应的结构体,实现上面所说的对应。

3.3 对CR的监视

我们定义的CR的状态发生改变时,会触发Reconciler逻辑,但这个是如何实现的呢?难不成有一个东西一直在watch?

我们使用kubebuilder搭建的Custom Controller会与k8s的API Server建立HTTP长连接,当有与我们定义的CR相关的资源状态变化时,API Server会向Controller发送一个Response,此后触发一系列逻辑,最终将事件加入到WorkQueue,之后触发Reconciler。

3.4 launcher和worker资源状态变化为何会触发Reconciler

为什么要设计成这样,让launcher job和workers job(都是k8s job)状态变化也会触发Reconciler?Link

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if errGetLauncherJob != nil || errGetWorkersJob != nil {
rhinojob.Status.JobStatus = rhinooprapiv1alpha1.Pending
} else {
if foundWorkersJob.Status.Failed+foundLauncherJob.Status.Failed > 0 {
rhinojob.Status.JobStatus = rhinooprapiv1alpha1.Failed
} else if foundWorkersJob.Status.Succeeded == *rhinojob.Spec.Parallelism && foundLauncherJob.Status.Succeeded == 1 {
rhinojob.Status.JobStatus = rhinooprapiv1alpha1.Completed
} else {
rhinojob.Status.JobStatus = rhinooprapiv1alpha1.Running
}
}
if err := r.Status().Update(ctx, &rhinojob); err != nil {
logger.Error(err, "Failed to update RhinoJob status")
return ctrl.Result{}, err
}

从代码可以看出,RhinoJob的状态取决于launcher job和workers job的状态,它们的每一次状态变化,都有可能触发RhinoJob的状态变化。

那么这是如何实现的呢?Link

1
ctrl.SetControllerReference(rj, job, r.Scheme)
1
2
3
4
5
6
func (r *RhinoJobReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&rhinooprapiv1alpha1.RhinoJob{}).
Owns(&kbatchv1.Job{}).
Complete(r)
}

只需要在创建这些job时,调用k8s runtime提供的API,并在实现SetupWithManager方法时,调用Owns方法即可。

3.5 Reconciler 返回 err 后发生了什么

当Reconciler执行出错时,会返回err。如果返回nil,表示Reconcile逻辑执行完毕;返回非nil error时,会让事件重新在队列里面排队。

3.6 什么情况下Reconciler可以忽略执行过程中的 err

Link

当Reconciler中调用Get方法获取正在运行的RhinoJob CR时,如果是IsNotFound类型的错误的话,我们不对该错误进行处理,因为这种错误通常是由资源被删除(主动删除;超时触发Reconciler被删除)导致的,是正常情况,我们不需要对其进行处理。

1
2
3
4
if errors.IsNotFound(err) {
logger.Info("Resource not found. Ignoring since object must be deleted")
return ctrl.Result{}, nil
}