撬动离线业务:Job与CronJob
对于前几篇文章分享的 Deployment、StatefulSet 以及 DaemonSet,实际上主要编排的对象都是“在线业务”,即 Long Running Task。比如常用的 Nginx、Tomcat 和 MySQL 等等。这些应用一旦运行起来,除非出错或者停止,它的容器进程会一直保持在 Running 状态。
但是,有一类作业显然不满足这样的条件,那就是“离线业务”,或者叫做 Batch Job(计算业务)。这种业务在计算完成后就直接退出了,而此时如果你依然用 Deployment 来管理这种业务的话,就会发现 Pod 会在计算结束后退出,然后被 Deployment Controller 不断地重启。
所以,早在 Borg 项目中,Google 就已经对作业进行了分类处理,提出了 LRS(Long Running Service)和 Batch Job 两种作业形态,对它们进行“分别管理”和“混合调度”。
不过,直到 Kubernetes v1.4 版本之后,社区才逐步设计出了一个用来描述离线业务的 API 对象,它的名字就是:Job。
Job API 对象的定义非常简单,如下所示:
1 | apiVersion: batch/v1 |
在这个 Job 的 YAML 文件里,也出现了 Pod 模版,即 spec.template
字段。
在这个 Pod 模版中,我定义了一个 Ubuntu 镜像的容器(准确地说是一个安装了 bc 命令的 Ubuntu 镜像),它运行的程序是:
1 | echo "scale=10000; 4*a(1)" | bc -l |
这其实就是一个计算 π 值的容器。而通过 scale=10000,指定了输出的小数点后的位数是 10000。
但是,跟其他控制器不同的是,Job 对象并不要求你定义一个 spec.selector 来描述要控制哪些 Pod。具体原因马上会讲解到。
现在,我们可以创建这个 Job 了。
1 | $ kubectl create -f job.yaml |
在成功创建后,我们来查看一下这个 Job 对象,如下所示:
1 | $ kubectl describe jobs/pi |
可以看到,这个 Job 对象在被创建后,它的 Pod 模版,被自动加上了一个 controller-uid=<random string>
这样的 Label。而这个 Job 对象本身,则被加上了这个 Label 对应的 Selector,从而保证了 Job 与它管理的 Pod 之间的匹配关系。
而 Job Controller 之所以要携带这种携带了 UID 的 Label,就是为了避免不同的 Job 对象所管理的 Pod 发生重合。
而在 Pod 模版中定义 restartPolicy=Never
的原因也很容易想到:离线计算的 Pod 永远都不应该被重启,否则它们会再重新计算一遍。
事实上,restartPolicy 在 Job 对象里面只允许被设置为 Never 和 OnFailure;而在 Deployment 对象里,restartPolicy 则只允许被设置为 Always
那如果这个离线作业失败了怎么办?
比如,当前我们定义了 restartPolicy=Never
,那么离线作业失败后 Job Controller 就会不断地尝试创建一个新 Pod。
当然,这个尝试不能无限进行下去,所以我们就在 Job 对象的 spec.backoffLimit
字段里定义了重试次数为 4(即,backoffLimit=4
),这个字段的默认值是 6。
需要注意的是,重新创建 Pod 得间隔是呈指数增加的,即下一次重新创建 Pod 的动作会分别发生在 10s、20s、30s …后。
而如果你定义的是 restartPolicy=OnFailure
,那么离线作业失败后,Job Controller 就不会去尝试创建一个新的 Pod。但是,它会不断地尝试重启 Pod 里的容器。
当一个 Job 的 Pod 运行结束后,它会进入 Completed 状态,但是如果这个 Pod 因为某种原因一直不肯结束呢?
有一个 spec.activeDeadlineSeconds
字段可以设置最长运行时间,比如:
1 | spec: |
一旦运行超过了 100s,这个 Job 的所有 Pod 都会被终止。并且你可以在 Pod 的状态里看到终止的原因是 reason: DeadlineExceeded
。
以上就是一个 Job API 对象最主要的概念和用法了。不过,离线业务之所以被称为 Batch Job,当然是因为它们可以以“Batch”,也就是并行的方式去运行。
接下来,就来看看 Job Controller 对并行作业的控制方法。
在 Job 对象中,负责并行控制的参数有两个:
spec.parallelism
,它定义的是一个 Job 在任意时间最多可以启动多少个 Pod 同时运行;spec.completions
,它定义的是 Job 至少要完成的 Pod 数目,即 Job 的最小完成数。
举个例子,在之前计算 Pi 值的 Job 里,添加这两个参数:
1 | apiVersion: batch/v1 |
这样,我们就指定了这个 Job 的最大并行数是 2,而最小的完成数是 4。
创建这个 Job 对象后可以看到,这个 Job 其实也维护了两个状态字段,即 DESIRED 和 SUCCESSFUL,如下所示:
1 | $ kubectl get job |
其中,DESIRED 的值,正是 completions 定义的最小完成数。
然后我们可以看到,这个 Job 首先创建了两个并行运行的 Pod 来计算 Pi:
1 | $ kubectl get pods |
而在一段时间后,这两个 Pod 相继完成了计算。
这时,每当有一个 Pod 进入 Completed 状态时,就会有一个新的 Pod 被创建出来,并且快速地从 Pending 状态进入到 ContainerCreating 状态,紧接着进入 Running 状态,最后 Completed。
这时,由于所有的 Pod 均已经成功退出,这个 Job 也就执行完了,所以 SUCCESSFUL 字段的值变成了 4:
1 | $ kubectl get pods |
通过上述 Job 的 DESIRED 和 SUCCESSFUL 字段的关系,我们就可以很容易地理解 Job Controller 的工作原理了。
首先,Job Controller 控制的对象,直接就是 Pod。
其次,Job Controller 在控制循环中进行的调谐(Reconcile)操作,是根据实际在 Running 状态 Pod 的数目、已经成功退出的 Pod 的数目,以及 parallelism、completions 参数的值共同计算出在这个周期里,应该创建或者删除的 Pod 数目,然后调用 Kubernetes API 来执行这个操作。
在上面计算 Pi 值的这个例子中,当 Job 一开始创建出来时,实际处于 Running 状态的 Pod 数目 =0,已经成功退出的 Pod 数目 =0,而用户定义的 completions,也就是最终用户需要的 Pod 数目 =4。
所以,在这个时刻,需要创建的 Pod 数目 = 最终需要的 Pod 数目 - 实际在 Running 状态 Pod 数目 - 已经成功退出的 Pod 数目 = 4 - 0 - 0= 4。也就是说,Job Controller 需要创建 4 个 Pod 来纠正这个不一致状态。
可是,我们又定义了这个 Job 的 parallelism=2。也就是说,我们规定了每次并发创建的 Pod 个数不能超过 2 个。所以,Job Controller 会对前面的计算结果做一个修正,修正后的期望创建的 Pod 数目应该是:2 个。
这时候,Job Controller 就会并发地向 kube-apiserver 发起两个创建 Pod 的请求。
类似地,如果在这次调谐周期里,Job Controller 发现实际在 Running 状态的 Pod 数目,比 parallelism 还大,那么它就会删除一些 Pod,使两者相等。
综上所述,Job Controller 实际上控制了,作业执行的并行度,以及总共需要完成的任务数这两个重要参数。而在实际使用时,你需要根据作业的特性,来决定并行度(parallelism)和任务数(completions)的合理取值。
接下来再来看看三种常用的使用 Job 对象的方法。
第一种,也是最简单粗暴的用法:外部管理器 + Job 模版。
这种模式的特定用法是:把 Job 的 YAML 文件定义为一个“模版”,然后用一个外部工具控制这些“模版”来生成 Job。这时,Job 的定义方式如下所示:
1 | apiVersion: batch/v1 |
可以看到,我们在这个 Job 的 YAML 里,定义了 $ITEM
这样的“变量”。
所以在控制这种 Job 时,我们只要注意如下两个方面即可:
- 创建 Job 时,替换掉这个变量;
- 所有来自于同一个模版的 Job,都有一个
jobgroup:jobexample
标签,也就是说这一组 Job 使用这样一个相同的标识。
而第一点很简单,通过一句这样的 shell 就可以把$ITEM
替换掉:这样,一组来自于同一个模版的不同 Job 的 YAML 就生成了。接下来,就可以通过一句1
2
3
4
5$ mkdir ./jobs
$ for i in apple banana cherry
do
cat job-tmpl.yaml | sed "s/\$ITEM/$i/" > ./jobs/job-$i.yaml
donekubectl create
指令来创建这些 Job 了:1
2
3
4
5
6$ kubectl create -f ./jobs
$ kubectl get pods -l jobgroup=jobexample
NAME READY STATUS RESTARTS AGE
process-item-apple-kixwv 0/1 Completed 0 4m
process-item-banana-wrsf7 0/1 Completed 0 4m
process-item-cherry-dnfu9 0/1 Completed 0 4m
第二种用法:拥有固定任务数目的并行 Job。
这种模式下,我只关心最后是否有指定数目(spec.completions)个任务成功退出。至于执行时的并行度是多少,我并不关心。
比如这个计算 Pi 值的例子,就是这样一个典型的、拥有固定任务的数目(completions=4)的应用场景。它的 parallelism 的值是 2;或者你可以干脆不指定 parallelism,直接使用默认的并行度。
此外,你还可以使用一个工作队列(Work Queue)进行任务分发。这时,Job 的 YAML 文件定义如下所示:
1 | apiVersion: batch/v1 |
你只要创建了这个 Job,它就会以并发度为 2 的方式,每两个 Pod 为一组,创建出 8 个 Pod。每个 Pod 都会去连接 BROKER_URL,从 MQ 中读取任务,然后各自进行处理。
每个 Pod 都只需要将任务信息读取出来,处理完成,然后退出即可。而作为用户,我只关心最终有 8 个计算任务启动并且退出,只要这个目标达到,我就认为整个 Job 处理完成了。所以说,这种用法,对应的就是“任务总数固定”的场景。
第三种用法,也是很常用的一种用法:指定并行度(parallelism),但不设置固定的 completions 的值。
此时,就必须自己想办法来决定什么时候启动新 Pod,什么时候 Job 才算执行完成。在这种情况下,任务的总数是未知的,所以你不仅需要一个工作队列来负责任务分发,还需要能够判断工作队列已经为空(即:所有的工作已经结束了)。
这时候,Job 的定义基本上没变化,只不过不需要再定义 completions 的值了:
1 | apiVersion: batch/v1 |
而对应的 Pod 的处理逻辑会稍微复杂一些,我可以用这样一段伪代码来描述:
1 | /* job-wq-2的伪代码 */ |
由于任务总数不固定,所以每个 Pod 必须能够知道自己什么时候可以退出。比如这个例子中,可以简单地以“队列为空”作为任务全部完成的标志。所以说,这种用法对应的是“任务总数不固定”的场景。
不过实际上你需要处理的条件往往会非常复杂。比如任务完成后的输出、每个任务 Pod 之间是不是有资源的竞争和协同等等。
最后,再来看一个非常有用的 Job 对象,叫做 CronJob。
顾名思义,CronJob 描述的就是定时任务。它的 API 对象,如下所示:
1 | apiVersion: batch/v1beta1 |
在这个 YAML 文件中,最重要的关键词就是 jobTemplate。其实,CronJob 就是一个专门用来管理 Job 对象的控制器。只不过,它创建和删除 Job 的依据,是 schedule 字段定义的、一个标准的 Unix Cron 格式的表达式。
当每个 Job 执行时,CronJob 对象会记录下这次 Job 的执行时间:
1 | $ kubectl get cronjob hello |
需要注意的是,由于定时任务的特殊性,很可能某个 Job 还没有执行完,另外一个新 Job 就产生了。这时候,你可以通过 spec.concurrencyPolicy
字段来定义具体的处理策略。比如:
- concurrencyPolicy=Allow,这也是默认情况,意味着这些 Job 可以同时存在;
- concurrencyPolicy=Forbid,这意味着不会创建新的 Pod,该创建周期被跳过;
- concurrencyPolicy=Replace,这意味着新产生的 Job 会替换旧的、没有执行完的 Job。
而如果某一次 Job 创建失败,这次创建就会被标记为“miss”。当在指定的时间窗口内,miss 的次数到达 100 时,CronJob 就会停止再创建这个 Job。
这个时间窗口,可以由 spec.startingDeadlineSeconds
字段指定。