History

Deployment、StatefulSet和DaemonSet主要编排的对象,都是在线业务,即长作业(Long Runing Task),比如Nginx和MySQL等。这些应用一旦运行起来,除非出错或者停止,它的容器进程会一直保持在Running状态。

而对于离线业务,或者叫作计算业务(Batch Job),这种业务在计算完成后就直接退出了,而此时如果依然是用Deployment来管理这种业务的话,就会发现Pod会在计算结束后退出,然后被Deployment Controller不断地重启;而像滚动更新这样的功能,更无从谈起了。

所以,早在Borg项目中,Google就已经对作业进行了分类处理,提出了LRS(Long Running Service)和Batch Jobs两种作业形态,对它们进行“分别管理”和“混合调度”。

Job

Job API对象的定义非常简单,举个例子,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: batch/v1
kind: Job
metadata:
name: pi
spec:
template:
spec:
containers:
- name: pi
image: resouer/ubuntu-bc
command: ["sh", "-c", "echo 'scale=10000; 4*a(1)' | bc -l"]
restartPolicy: Never
backoffLimit: 4

在这个Job的YAML文件里,spec.template字段定义了Pod模板。这个Pod模板定义了一个Ubuntu镜像的容器,它运行的程序是:

1
echo "scale=10000; 4*a(1)" | bc -l

bc命令是Linux的计算器;-l表示使用标准数学库;a(1)表示调用数学库中的arctangent函数,计算atan(1)

中学知识中,tan(π/4) = 1,那么,4*atan(1) = π。所以这就是一个计算π值的容器,通过scale=10000指定了输出的小数点后的位数是10000。

Practice

创建上面的Job对象:

1
$ kubectl create -f pi-job.yaml 

创建成功后,查看一下这个Job对象,如下所示:

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
$ kubectl describe jobs/pi
Name: pi
Namespace: default
Selector: controller-uid=9bb33016-b732-46c7-b682-1bce756ad9a7
Labels: controller-uid=9bb33016-b732-46c7-b682-1bce756ad9a7
job-name=pi
Annotations: <none>
Parallelism: 1
Completions: 1
Completion Mode: NonIndexed
Start Time: Mon, 28 Mar 2022 12:16:19 +0800
Pods Statuses: 1 Running / 0 Succeeded / 0 Failed
Pod Template:
Labels: controller-uid=9bb33016-b732-46c7-b682-1bce756ad9a7
job-name=pi
Containers:
pi:
Image: resouer/ubuntu-bc
Port: <none>
Host Port: <none>
Command:
sh
-c
echo 'scale=10000; 4*a(1)' | bc -l
Environment: <none>
Mounts: <none>
Volumes: <none>
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 21s job-controller Created pod: pi--1-q7tcm

可以看到,这个Job对象在创建后,它的Pod模板,被自动加上了一个controller-uid=<一个随机字符串>这样的Label。而这个Job对象本身,则被自动加上了这个Label对应的Selector,从而保证了Job与它所管理的Pod之间的匹配关系。

而Job Controller之所以要使用这种携带了UID的Label,就是为了避免不同Job对象所管理的Pod发生重合。需要注意的是,这种自动生成的Label对用户来说并不友好,所以不太适合推广到Deployment等长作业编排对象上。

接下来,可以看到这个Job创建的Pod进入了Running状态,这意味着它正在计算Pi的值。

1
2
3
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi--1-q7tcm 1/1 Running 0 10s

而几分钟后计算结束,这个Pod就会进入Completed状态:

1
2
3
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi--1-q7tcm 0/1 Completed 0 9m29s

这也是需要在Pod模板中定义restartPolicy=Never的原因;离线计算的Pod永远都不应该被重启,否则它们会再重新计算一遍。

事实上,restartPolicy在Job对象里之允许被设置为Never和OnFailure;而在Deployment对象里,restartPolicy只允许被设置为Always。

此时,可以通过kubectl logs查看一下这个Pod的日志,就可以看到计算得到的Pi值已经被打印出来了:

1
2
3
$  kubectl logs pi--1-q7tcm
3.141592653589793238462643383279502884197169399375105820974944592307\
81640628620899862803482534211706798214808651328230664709384460955058...

Problem

(1)上面计算Pi值的Job无法restart,那么这个离线作业失败了怎么办?

离线作业失败后,Job Controller就会不断地尝试创建一个新Pod,如下所示:

1
2
3
4
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi-55h89 0/1 ContainerCreating 0 2s
pi-tqbcz 0/1 Error 0 5s

可以看到,这时候会不断有新Pod被创建出来。

当然,这个尝试肯定不能无限进行,spec.backoffLimit字段里定义了重试次数为4,这个字段的默认值是6。

需要注意的是,Job Controller重新创建Pod的间隔是呈指数增加的,即下一次重新创建Pod的动作会分别发生在10s、20s、40s ···后。

而如果定义的restartPolicy=OnFailure,那么离线作业失败后,Job Controller就不会去尝试创建新的Pod。但是,它会不断地尝试重启Pod里的容器。

(2)当一个Job的Pod运行结束后,它会进入Completed状态。但是,如果这个Pod因为某种原因一直不肯结束呢?

在Job的API对象里,有一个spec.activeDeadlineSeconds字段可以设置最长运行时间,比如:

1
2
3
spec:
backoffLimit: 5
activeDeadlineSeconds: 100

一旦运行超过100s,这个Pod的所有Pod都会被终止。并且,可以在Pod的状态里看到终止的原因是 reason: DeadlineExceeded。

Job Controller

在Job对象中,负责并行控制的参数有两个:

  1. spec.parallelism,它定义的是一个Job在任意时间最多可以启动多少个Pod同时运行;
  2. spec.completions,它定义的是Job至少要完成的Pod数目,即Job的最小完成数。

在上面计算Pi的Job里,添加这两个字段:

1
2
3
4
5
...
spec:
parallelism: 2
completions: 4
...

创建这个Job对象:

1
$ kubectl create -f pi-job.yaml

查看Job的Pod状态:

1
2
3
4
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi--1-gcdqf 1/1 Running 0 11s
pi--1-qbnm8 1/1 Running 0 11s

可以看到,这个Job首先创建了两个并行运行的Pod来计算Pi。

大概两分钟后,这两个Pod相继完成计算。

再次查看Pod状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi--1-96x59 0/1 Pending 0 20s
pi--1-bmdhn 0/1 Pending 0 20s
pi--1-gcdqf 0/1 Completed 0 2m
pi--1-qbnm8 0/1 Completed 0 2m

$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi--1-96x59 0/1 ContainerCreating 0 30s
pi--1-bmdhn 0/1 Pending 0 30s
pi--1-gcdqf 0/1 ContainerCreating 0 2m10s
pi--1-qbnm8 0/1 Completed 0 2m10s

$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi--1-96x59 1/1 Running 0 50s
pi--1-bmdhn 1/1 Running 0 50s
pi--1-gcdqf 0/1 Completed 0 2m30s
pi--1-qbnm8 0/1 Completed 0 2m30s

可以看到,每当有一个Pod完成计算进入Completed状态时,就会有一个新的Pod被自动创建出来,并且快速地从Pending状态进入到ContainerCreating状态,紧接着都进入了Running状态。

最终,完成计算任务后,都进入了Completed状态:

1
2
3
4
5
6
7
8
9
10
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi--1-96x59 0/1 Completed 0 18m
pi--1-bmdhn 0/1 Completed 0 18m
pi--1-gcdqf 0/1 Completed 0 20m
pi--1-qbnm8 0/1 Completed 0 20m

$ kubectl get job
NAME COMPLETIONS DURATION AGE
pi 4/4 3m48s 20m

梳理Job Controller的工作原理:

首先,Job Controller控制的对象,直接就是Pod。

其次,Job Controller在控制循环中进行的调谐(Reconcile)操作,是根据实际在Running状态Pod的数目、已经成功退出的Pod的数目,以及parallelism、completions参数的值共同计算出在这个周期里,应该创建或者删除的Pod数目,然后调用Kubernetes API来执行这个操作。

以创建Pod为例。在上面计算Pi的值这个例子中,当Job一开始创建出来时,实际处于Running状态Pod的数目、已经成功退出的Pod的数目,以及paralleism、completions参数的值共同计算出在这个周期里,应该创建或者删除的Pod数目,然后调用Kubernetes API来执行这个操作。

count(需要创建的Pod) = completions - count(Running状态的Pod) - count(Completed状态的Pod)

如果定义了parallelism,那么Job Controller会对上面的计算结果做一个修正,然后就并发地向kube-apiserver发起两个创建Pod的请求。如果在一个调谐周期里,Job Controller发现实际在Running状态的Pod数目,比parallelism还大,那么它就会删除一些Pod,使两者相等。

Extension

三种常用的、使用Job对象的方法。

Method1

第一种用法,也是醉简单粗暴的用法:外部管理器 + Job模板。

这种模式的特定用法是:把Job的YAML文件定义为一个“模板”,然后用一个外部工具监控这些“模板”来生成Job。

Job的定义方式如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

apiVersion: batch/v1
kind: Job
metadata:
name: process-item-$ITEM
labels:
jobgroup: jobexample
spec:
template:
metadata:
name: jobexample
labels:
jobgroup: jobexample
spec:
containers:
- name: c
image: busybox
command: ["sh", "-c", "echo Processing item $ITEM && sleep 5"]
restartPolicy: Never

可以看到,在这个Job的YAML里,定义了$ITEM这样的变量。

所以,在控制这种Job时,只要注意如下两个方面即可:

  1. 创建Job时,替换掉$ITEM这样的变量;
  2. 所有来自于同一个模板的Job,都有一个jobgroup:jobexample标签,也就是说这一组Job使用这样一个相同的标识。

为了实现第一点,可以通过shell替换$ITEM

1
2
3
4
5
$ mkdir ./jobs
$ for i in apple banana cherry
do
cat job-tmpl | sed "s/\$ITEM/$i/" > ./jobs/job-$i.yaml
done

这样,一组来自于同一个模板的不同Job的yaml就生成了。接下来,就可以通过一句kubectl create指令创建这些Job了:

1
2
3
4
5
6
7
8
9
$ kubectl create -f ./jobs    
job.batch/process-item-apple created
job.batch/process-item-banana created
job.batch/process-item-cherry created
$ kubectl get pods -l jobgroup=jobexample
NAME READY STATUS RESTARTS AGE
process-item-apple--1-dvwbw 0/1 Completed 0 38s
process-item-banana--1-jtdss 0/1 Completed 0 38s
process-item-cherry--1-p6mvt 0/1 Completed 0 38s

这个模式看起来虽然很“傻”,但却是Kubernetes社区里使用Job的一个很普遍的模式。

原因很简单:大多数用户在需要管理Batch Job的时候,都已经有了一套自己的方案,需要做的往往就是集成工作。这时候,Kubernetes项目对这些方案来说最有价值的,就是Job这个API对象。所以,只需要编写一个外部工具来管理这些Job即可。

这种模式最典型的应用,就是TensorFlow社区的KubeFlow项目。

很容易理解:在这种模式下使用Job对象,completions和parallslism这两个字段都应该使用默认值1,而不应该由我们自行设置。而作业Pod的并行控制,应该完全由外部工具来进行管理。

Method2

第二种用法:拥有固定任务数目的并行Job。

这种模式下,只关心最后是否有指定数目(spec.completions)个任务成功退出。至于执行时的并行度是多少,并不需要关心。

比如这个计算Pi值的例子,就是这样一个典型的、拥有固定任务数目(completions=4)的应用场景。它的parallslism值是2;或者,干脆不指定parallelism,直接使用默认的并行度(即:1)。

此外,还可以使用一个工作队列(Work Queue)进行任务分发。这时,Job的YAML文件定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: batch/v1
kind: Job
metadata:
name: job-wq-1
spec:
completions: 8
parallelism: 2
template:
metadata:
name: job-wq-1
spec:
containers:
- name: c
image: myrepo/job-wq-1
env:
- name: BROKER_URL
value: amqp://guest:guest@rabbitmq-service:5672
- name: QUEUE
value: job1
restartPolicy: OnFailure

我们可以看到,它的completions的值是:8,这意味着我们总共要处理的任务数目是8个。也就是说,总共会有8个任务会被逐一放入工作队列里(可以运行一个外部小程序作为生产者,来提交任务)。

在这个实例中,选择充当工作队列的说一个运行在Kubernetes里的RabbitMQ。所以,需要在Pod模板里定义BROKER_URL,来作为消费者。

所以,一旦使用kubectl create创建了这个Job,它就会以并发度为2的方式,每两个Pod一组,创建出8个Pod。每个Pob都回去连接BROKER_URL,从RabbitMQ里领取任务,然后各自进行处理。这个Pod里的执行逻辑,可以用这样一段伪代码来表示:

1
2
3
4
queue := newQueue($BROKER_URL, $QUEUE)
task := queue.Pop()
process(task)
exit

可以看到,每个Pod只需要将任务信息读取出来,处理完成,然后退出即可。而作为用户,我只关心最终一共有8个计算任务启动并且退出,只要这个目标达成,我就可以认为整个Job处理完成了。所以说,这种用法,对应的就是“任务总数固定”的场景。

Method3

第三种用法,指定并行度,但不设置固定的completions的值。

这种情况,就必须自己想办法,来决定什么启动新Pod,什么时候Job才算执行完成。任务的总数是未知的,所以不仅需要一个工作队列来负责任务分发,还需要能够判断工作队列已经为空。

这时候,Job的定义基本上没变化,只不过是不再需要定义completions的值了而已:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: batch/v1
kind: Job
metadata:
name: job-wq-2
spec:
parallelism: 2
template:
metadata:
name: job-wq-2
spec:
containers:
- name: c
image: gcr.io/myproject/job-wq-2
env:
- name: BROKER_URL
value: amqp://guest:guest@rabbitmq-service:5672
- name: QUEUE
value: job2
restartPolicy: OnFailure

而对应的Pod逻辑会稍微复杂一些,可以用伪代码来描述:

1
2
3
4
5
6
for !queue.IsEmpty($BROKER, $QUEUE) {
task := queue.Pop()
process(task)
}
print("Queue empty, exiting")
exit

由于任务数目的总数不固定,所以每一个Pod必须能够知道,自己什么时候可以退出。在这个例子中,简单地以“队列为空”,作为任务全部完成的标志。所以说,这种用法,对应的是“任务总数不固定”的场景。

CronJob

CronJob描述的就是定时任务,它的API对象如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: hello
spec:
schedule: "*/1 * * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: hello
image: busybox
args:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
restartPolicy: OnFailure

在这个YAML文件中,最重要的关键词就是jobTemplate,所以CronJob是一个Job对象的控制器(Controller)。

其中schedule字段是一个标准的Unix Cron表达式,”*/1 * * * * *”表示每分钟执行一次。

这个CronJob对象在创建1分钟后,就会有一个Job产生了,如下所示:

1
2
3
4
5
6
7
8
9
10
$ kubectl create -f ./cronjob.yaml
cronjob.batch/hello created

# 一分钟后
$ kubectl get pods
hello-27477146--1-c5pdt 0/1 Completed 0 10s

$ kubectl get cronjob hello
NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
hello */1 * * * * False 0 35s 66s

需要注意的是,由于定时任务的特殊性,很可能某个Job还没有执行完,另外一个新Job就产生了。这时候,可以通过spec.concurrencyPolicy字段来定义具体的处理策略。比如:

  1. concurrencyPolicy=Allow,这也是默认情况,这意味着这些Pod可以同时存在;
  2. concurrencyPolicy=Forbid,这意味着不会创建新的Pod,该创建周期被跳过;
  3. concurrencyPolicy=Replace,这意味着新产生的Job会替换旧的、没有执行完的Job。

而如果某一次Job创建失败,这次创建就会被标记为”miss”。当在指定的时间窗口内,miss的数目达到100(写死的)时,那么CronJob会停止再创建这个Job。

这个时间窗口内,可以由spec.startingDeadlineSeconds字段指定。比如startingDeadlineSeconds=200,意味着在过去200s里,如果miss的数目达到了100个,那么这个Job就不会被创建执行了。