Kubernetes GCP 入门指南(三)
六、作业
批处理与持久性应用(如 web 服务器)的不同之处在于,一旦程序达到目标,它们就会完成并终止。批处理的典型示例包括数据库迁移脚本、视频编码作业和提取-加载-转换(ETL)操作手册。与 ReplicaSet 控制器类似,Kubernetes 有一个专用的 Job 控制器,用于管理 pod 以运行面向批处理的工作负载。
使用作业来检测批处理过程相对简单,我们将在这一简短的章节中很快了解到这一点。在第一部分,我们将学习完成和并行的概念,这是决定作业动态的两个基本变量。然后,我们将探索可以使用作业控制器实现的三种基本批处理类型:单个批处理、基于完成计数的批处理和*外部协调批处理。*最后,在接近尾声时,我们将看看与作业处理相关的典型管理任务:确定作业何时完成,用适当的超时阈值配置作业,以及在不处理作业结果的情况下删除作业。
完成和并行
Kubernetes 依靠标准的 Unix 退出代码来判断一个 Pod 是否成功地完成了它的任务。如果一个 Pod 的容器进入过程通过返回退出代码0而结束,则认为该 Pod 已经成功地完成了它的目标。否则,如果代码不为零,则认为 Pod 出现故障。
作业控制器使用两个关键概念来编排作业:完成和并行。使用job.spec.completions属性指定的完成决定了作业必须运行的次数,并以成功退出代码退出—换句话说,0。相反,使用job.spec.parallelism属性指定的并行性设置了可以并行运行的作业数量——换句话说,就是并发运行。
这两个属性值的组合将决定为实现给定完成数而创建的个唯一 pod的数量,以及可能加速旋转的个并行 pod(并行运行的 pod)的数量。表 6-1 提供了一组样本组合的结果。
表 6-1
各种完成和平行排列的效果
|completions
|
parallelism
|
独特的 POD
|
平行吊舱
| | --- | --- | --- | --- | | 未设置 | 未设置 | one | one | | one | 未设置 | one | one | | one | one | one | one | | three | one | three | one | | three | three | three | three | | one | three | one | one | | 未设置 | three | three | three |
现在没有必要花费太多的精力来计算出表 6-1 中给出的组合。在接下来的章节中,我们将学习completions和parallelism在实际用例中的正确用法。
批处理类型概述
批处理可以分为三种类型:单个批处理、基于完成计数的批处理和外部协调的批处理:
-
**单一批处理:**成功运行一次 Pod 就足以完成工作。
-
**基于完成计数的批处理:**一个 n 数量的 pod 必须成功完成才能完成任务——并行、顺序或两者结合。
-
**外部协调批处理:**一群工人在一个集中协调的任务上工作。所需完成的数量事先并不知道。
如前一节所述,成功的定义是 Pod 的容器流程终止,产生值为0的退出状态代码。
单批次过程
使用计划成功运行一次的 Pod 来实现单个批处理过程。这种作业可以以命令和声明的方式运行。使用kubectl create job <NAME> --image=<IMAGE>命令创建一个任务。让我们考虑一个计算两个时间表的 Bash 脚本:
for i in $(seq 10)
do echo $(($i*2))
done
我们可以将这个脚本作为一个作业以命令的方式运行,或者通过将它作为参数传递给alpine的sh命令:
$ kubectl create job two-times --image=alpine \
-- sh -c \
"for i in \$(seq 10);do echo \$((\$i*2));done"
job.batch/two-times created
注意
也可以使用traditional kubectl run <NAME> --image=<IMAGE> --restart=OnFailure命令创建作业。这种传统形式现在已被否决;使用kubectl run创建作业时,添加标志--restart=OnFailure必不可少;如果省略,将改为创建部署。
可以通过运行kubectl logs -l job-name=<NAME>命令来获得结果,该命令会将窗格(本例中只有一个)与作业名称的标签进行匹配。作业控制器将添加值job-name并将其设置为自己的名称,以便于识别作业和 pod 之间的父子关系:
$ kubectl logs -l job-name=two-times
2
4
6
8
10
12
14
16
18
20
我们刚刚运行了我们的第一个任务。尽管这一偶数序列可能看起来很简单,但应该理解,作为作业装备的工作负载可以像该示例一样简单,也可以像针对 SQL 数据库运行查询或训练机器学习算法的代码一样复杂。
需要记住的一个方面是,单批次过程不是“一劳永逸”的;它们将资源留在周围,必须手动清理,我们将在后面看到。事实上,同一个作业名不能使用两次,除非我们先删除第一个实例。
注意
我们可能需要添加--tail=-1标志来显示所有结果,只要我们看起来缺少行。当使用标签选择器时,如two-times.yaml的情况,行数限制为 10。
使用kubectl get jobs命令列出可用的作业,当考虑COMPLETIONS列下的值1/1时,将会发现一个预期完成,而一个实际上已存档:
$ kubectl get jobs
NAME COMPLETIONS DURATION AGE
two-times 1/1 3s 9m6sm
当我们键入kubectl get pods时,作业控制的单元可以通过作业的前缀来识别,在本例中为two-times:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
two-times-4lrp8 0/1 Completed 0 8m45s
接下来显示了该作业的声明性等效项,它是通过使用kubectl apply -f two-times.yaml命令运行的:
# two-times.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: two-times
spec:
template:
spec:
containers:
- command:
- /bin/sh
- -c
- for i in $(seq 10);do echo $(($i*2));done
image: alpine
name: two-times
restartPolicy: Never
请注意,job.spec.completions和job.spec.paralellism属性不存在;它们都将被赋予默认值1。
还要记住,除非我们使用kubectl delete job/<NAME>命令明确删除,否则作业控制器及其完成的 Pod 都不会被删除。如果我们想在尝试了具有相同作业名称的命令式表单后立即运行该清单,记住这一点很重要。
two-times.yaml作业是一个保证成功的简单脚本的例子。如果工作失败了怎么办?例如,考虑下面的脚本,它明确地打印主机名和日期,并带有一个不成功的退出代码1:
echo $HOSTNAME failed on $(date) ; exit 1
作业控制器对运行该脚本的反应方式主要取决于两个方面:
-
job.spec.backoffLimit属性(默认设置为 6)将决定作业控制器在放弃之前尝试运行 Pod 的次数。 -
job.spec.template.spec.restartPolicy属性可以是Never或OnFailure,它将决定每次重试是否会启动新的 pod。如果设置为前者,作业控制器将在每次尝试时旋转一个新的 Pod,而不处理失败的 Pod。相反,如果设置为后者,作业控制器将重新启动同一个 Pod,直到成功;然而,因为失效的吊舱被重新利用——通过重启它们——它们因失效而产生的输出丢失了。
在Never和OnFailure的restartPolicy值之间做出决定,取决于我们更能接受什么样的妥协。Never通常是最明智的选择,因为它不处理故障吊舱的输出,并允许我们排除故障;然而;将失效的 Pods 留在周围会占用更多的资源。理想情况下,工业作业解决方案应该将有价值的数据保存到永久存储介质中,例如第二章中演示的附件。
现在让我们看看每一个用例,以便更直观地了解每一个用例的副作用。接下来呈现的unlucky-never.yaml清单将backoffLimit属性设置为3,将restartPolicy属性设置为Never:
# unlucky-never.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: unlucky
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- command:
- /bin/sh
- -c
- echo $HOSTNAME failed on $(date) ; exit 1
name: unlucky
image: alpine
我们将通过发出kubectl apply -f unlucky-never.yaml命令来运行unlucky-never.yaml,但是首先,我们将打开一个单独的窗口,在该窗口中我们将运行kubectl get pods -w来查看作业控制器创建了什么 Pods,因为它对exit 1产生的故障做出反应:
$ kubectl get pods -w
unlucky-6qm98 0/1 Pending 0 0s
unlucky-6qm98 0/1 ContainerCreating 0 0s
unlucky-6qm98 0/1 Error 0 1s
unlucky-sxj97 0/1 Pending 0 0s
unlucky-sxj97 0/1 ContainerCreating 0 0s
unlucky-sxj97 1/1 Running 0 0s
unlucky-sxj97 0/1 Error 0 1s
unlucky-f5h9c 0/1 Pending 0 0s
unlucky-f5h9c 0/1 ContainerCreating 0 0s
unlucky-f5h9c 0/1 Error 0 0s
可以理解,创建了三个新的吊舱:unlucky-6qm98、unlucky-sxj97和unlucky-f5h9c。一切都以错误告终,但为什么呢?让我们检查他们的日志:
$ kubectl logs -l job-name=unlucky
unlucky-6qm98 failed on Sun Jul 14 15:45:18 UTC 2019
unlucky-f5h9c failed on Sun Jul 14 15:45:30 UTC 2019
unlucky-sxj97 failed on Sun Jul 14 15:45:20 UTC 2019
这就是将restartPolicy属性设置为Never的好处。如前所述,失败 pod 的日志被保留,这允许我们诊断错误的性质。最后一个有用的检查是通过kubectl describe job/<NAME>命令。接下来,我们看到当前没有 pod 在运行,零个成功,三个失败:
$ kubectl describe job/unlucky | grep Statuses
Pods Statuses: 0 Running / 0 Succeeded / 3 Failed
将restartPolicy属性设置为OnFailure会导致完全不同的行为。让我们再次进行同样的练习,但是使用一个名为unlucky-onFailure.yaml的新清单,其中唯一的变化是前面提到的属性:
# unlucky-onFailure.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: unlucky
spec:
backoffLimit: 3
template:
spec:
restartPolicy: OnFailure
containers:
- command:
- /bin/sh
- -c
- echo $HOSTNAME failed on $(date) ; exit 1
name: unlucky
image: alpine
在通过发出kubectl apply -f unlucky-onFailure.yaml命令来应用清单之前,我们将在一个单独的终端窗口中跟踪kubectl get pods -w的结果,就像我们之前所做的那样。
$ kubectl get pods -w
NAME READY STATUS RESTARTS AGE
unlucky-fgtq4 0/1 Pending 0 0s
unlucky-fgtq4 0/1 ContainerCreating 0 0s
unlucky-fgtq4 0/1 Error 0 0s
unlucky-fgtq4 0/1 Error 1 1s
unlucky-fgtq4 0/1 CrashLoopBackOff 1 2s
unlucky-fgtq4 0/1 Error 2 17s
unlucky-fgtq4 0/1 CrashLoopBackOff 2 30s
unlucky-fgtq4 1/1 Running 3 41s
unlucky-fgtq4 1/1 Terminating 3 41s
unlucky-fgtq4 0/1 Terminating 3 42s
与unlucky-never.yaml的restartPolicy是Never相比,我们在这里看到两个关键的不同:只有一个 Pod,unlucky-fgtq4,它被重启三次,而不是三个不同的 Pod,并且 Pod 在结束时终止,而不是以Error状态结束。最根本的副作用是日志被删除,因此我们无法诊断问题:
$ kubectl logs -l job-name=unlucky
# nothing
另一个值得注意的区别是kubectl describe job/unlucky命令会声称只有一个吊舱发生了故障。这是真的;只有一个吊舱确实发生了故障——尽管它根据backoffLimit设置重启了三次:
$ kubectl describe job/unlucky | grep Statuses
Pods Statuses: 0 Running / 0 Succeeded / 1 Failed
基于完成计数的批处理
运行一个 Pod 一次、两次或更多次,并以成功退出代码结束的概念被称为完成。在单个批处理过程的情况下,如前一节所示,job.spec.completions的值默认为1;相反,基于完成计数的批处理过程通常将completions属性设置为大于或等于 2 的值。
在这个用例中,pod 独立运行和,彼此没有意识。换句话说,每个 Pod 相对于其他 Pod 的结果和成果是孤立运行的。Pods 中运行的代码如何决定处理哪些数据,如何避免重复工作,以及将结果保存在哪里,这些都是作业控制能力无法解决的实现问题。实现共享状态的典型解决方案是外部队列、数据库或共享文件系统。由于进程是独立的,它们可以并行运行;可以并行运行的进程数量是使用job.spec.parallelism属性指定的。
通过组合使用job.spec.completions和job.spec.parallelism,我们可以控制一个流程必须运行多少次,以及有多少次将并行运行,以加快总体批处理时间。
为了内部化多个独立过程的工具,我们需要一个独立于其他实例的过程的例子,但是同时,原则上收集不同的结果。为此,我们设计了一个 Bash 脚本,它检查当前时间,如果当前秒是偶数则报告成功,如果是奇数则报告失败:
n=$(date +%S)
if [ $((n%2)) -eq 0 ]
then
echo SUCCESS: $n
exit 0
else
echo FAILURE: $n
exit 1
fi
作为一个首要目标,我们开始收集六个偶数秒的样本;这意味着我们将job.spec.completions设置为6。我们还想通过并行运行两个 pod 来加速这个过程,所以我们将job.spec.parallelism设置为2。最终的结果是下面的清单,命名为even-seconds.yaml:
# even-seconds.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: even-seconds
spec:
completions: 6
parallelism: 2
template:
spec:
restartPolicy: Never
containers:
- command:
- /bin/sh
- -c
- >
n=$(date +%S);
if [ $((n%2)) -eq 0 ];
then
echo SUCCESS: $n;
exit 0;
else
echo FAILURE: $n;
exit 1;
fi
name: even-seconds
image: alpine
为了理解这个例子的动态性,设置如下三个终端窗口(或标签/面板)是很方便的:
-
终端窗口 1 :通过运行
kubectl get job -w观察作业活动 -
终端窗口 2 :通过运行
kubectl get pods -w观察 Pod 活动 -
终端窗口 3 :发出
apply -f even-seconds.yaml命令运行even-seconds.yaml。
通过在前缀为Pod Statuses的行中运行kubectl describe job/even-seconds命令,可以获得一个有趣的摘要,该行是从运行时填充的job.status下的值获得的。
让我们应用even-seconds.yaml,并根据其编排的 pod 来观察作业的行为:
# Keep this running before running `kubectl apply`
$ while true; do kubectl describe job/even-seconds \
| grep Statuses ; sleep 1 ; done
Pods Statuses: 2 Running / 0 Succeeded / 0 Failed
Pods Statuses: 2 Running / 0 Succeeded / 1 Failed
Pods Statuses: 2 Running / 0 Succeeded / 2 Failed
Pods Statuses: 2 Running / 1 Succeeded / 3 Failed
Pods Statuses: 2 Running / 3 Succeeded / 3 Failed
Pods Statuses: 1 Running / 5 Succeeded / 3 Failed
Pods Statuses: 0 Running / 6 Succeeded / 3 Failed
结果输出是不确定的;失败和重启的次数,以及实际收集的结果,将取决于无数的因素:容器的启动时间、当前时间、CPU 速度等。
已经说明了读者可能会获得不同的结果,让我们分析输出。一开始,作业控制器启动了两个 pod,其中没有一个既没有成功也没有失败。然后,在第二行中,一个 Pod 发生故障,但是由于parallelism被设置为2,另一个 Pod 被启动,因此总是有两个并行运行。过程进行到一半时,作业控制器开始注册更多成功的 pod。在倒数第二行中,只有一个 Pod 正在运行,因为五个已经成功,只剩下一个。最后,在最后一行中,最后一个 Pod 成功完成了我们预期的完成数量:6。
我们跳过kubectl get pods -w的输出,留给读者作为练习(它将与显示不同状态的多个 pod 的早期输出一致:ContainerCreating、Error、Completed等)。).我们的首要目标是获得 6 个偶数秒的样本,看看这个目标是否已经实现,这很有意思。让我们检查日志,看看:
$ kubectl logs -l job-name=even-seconds \
| grep SUCCESS
SUCCESS: 34
SUCCESS: 32
SUCCESS: 34
SUCCESS: 30
SUCCESS: 32
SUCCESS: 36
这正是我们要实现的目标。失败的 POD 怎么办?如果我们将restartPolicy属性设置为OnFailure,的话,可能很难发现,但是因为我们特意选择了Never,我们可以从失败的作业中检索输出,并确认失败是由于奇数秒的采样造成的:
$ kubectl logs -l job-name=even-seconds \
| grep FAILURE
FAILURE: 27
FAILURE: 27
FAILURE: 29
在进入第三个用例之前,当设置job.spec.completions和job.spec.paralellism属性时,值得注意的一个方面是,作业控制器永远不会实例化多于预期(和/或剩余)完成数的并行 pod。例如,如果我们定义completions: 2和parallelism: 5,就好像我们已经将parallelism设置为2。同样,并行运行的 pod 的数量永远不会大于待完成的数量。
外部协调批处理
当有一个控制机制告诉每个 Pod 是否还有工作单元要完成时,就说批处理是外部协调的。在最基本的形式中,这被实现为一个队列:控制进程(生产者)将任务插入到一个队列中,然后由一个或多个工作进程(消费者)进行检索。
在这种情况下,作业控制器假设多个 Pod 针对相同的目标工作,并且无论何时 Pod 报告成功完成,总体批次目标完成,并且不需要进一步的 Pod 运行。想象一个由三个人组成的团队——Mary、Jane 和 Joe——为搬家公司工作,正在将家具装入货车。当简把最后一件家具搬进货车时,比如一把椅子,不仅简的工作完成了,玛丽和乔的工作也完成了;他们都会报告工作完成了。
配置一个作业来满足这个用例需要将job.spec.parallelism属性设置为期望的并行工作线程数,但是不设置job.spec.completions属性。本质上,我们只定义了池中工作进程的数量。这是将外部协调批处理过程与基于单个和完成计数的批处理过程区分开来的关键方面。
为了演示这个用例,我们首先需要建立某种形式的控制队列机制。为了确保我们专注于手头的学习目标——如何配置外部协调的作业——我们将避免引入大型队列或发布/订阅解决方案,如 RabbitMQ 相反,我们将定义一个简单的进程,它监听端口 1080,并为每个网络请求提供一个新的整数(从 1 开始):
i=1
while true; do
echo -n $i | nc -l -p 1080
i=$(($i+1))
done
这个脚本的工作方式与医院和邮局中的红色售票机完全一样,第一个客户得到票 1,第二个客户得到票 2,依此类推。不同之处在于,在我们基于 shell 的版本中,客户通过使用nc命令打开端口 1080 获得一个新的号码,而不是从分发器中取出一张票。还请注意,我们使用 Alpine 发布的 Netcat ( nc)命令来设置一个虚拟 TCP 服务器;值得注意的是,就可用标志的数量和类型而言,Netcat 的实现在不同的操作系统之间往往是相当分散的。
在我们创建一个作业来使用队列中的票证之前,让我们先看看运行中的脚本,以便熟悉它的操作。使用kubectl run命令将提供的 shell 脚本作为 Pod 启动,并使用--expose标志将其公开为服务,这样就可以从其他 Pod 使用queue主机名对其进行访问:
$ kubectl run queue --image=alpine \
--restart=Never \
--port=1080 \
--expose \
-- sh -c \
"i=1;while true;do echo -n \$i \
| nc -l -p 1080; i=\$((\$i+1));done"
service/queue created
pod/queue created
我们可以手动测试queue Pod,如下例所示,但是我们必须记住在运行其余示例之前重启部署的 Pod,以便计数器再次从1开始计数。或者,在章节的源文件夹中提供了一个名为startQueue.sh的脚本,它删除任何现有的运行队列并启动一个新队列:
$ kubectl run test --rm -ti --image=alpine \
--restart=Never -- sh
If you don't see a command prompt, try pressing enter.
/ # nc -w 1 queue 1080
1
/ # nc -w 1 queue 1080
2
/ # nc -w 1 queue 1080
3
/ # nc -w 1 queue 1080
...
请注意,nc命令引用的名为queue的主机是服务控制器创建的 DNS 条目,这是在前面的示例中启动队列 Pod 时添加标志--expose的结果。现在我们有一个中央程序来协调多个 pod 的工作。尽管可能很简单,但队列服务为每个消费者提供了一个独特的任务——由一个新的整数表示。现在让我们定义一个消费者流程,它的工作只是从队列服务中取出一个整数,然后乘以 2。当数字为 101 或更大时,总体目标被认为已经实现,脚本可以通过返回0——成功退出状态代码来宣告胜利:
while true
do n=$(nc -w 1 queue 1080)
if [ $(($n+0)) -ge 101 ]
then
exit 0
else r=$(($n*2))
echo -en "$r\n"
sleep 1
fi
done
如果这个脚本独立运行,它将产生一个数字序列2, 4, 6, ...,直到到达200。比如说,在 1080 端口访问queue的任何失败都会导致一个非零的退出代码。现在让我们将 shell 脚本嵌入到容器的隔间中,这样我们就可以使用alpine映像来运行它:
# multiplier.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: multiplier
spec:
parallelism: 3
template:
spec:
restartPolicy: Never
containers:
- command:
- /bin/sh
- -c
- >
while true;
do n=$(nc -w 1 queue 1080);
if [ $(($n+0)) -ge 101 ];
then
exit 0;
else r=$(($n*2))
echo -en "$r\n";
sleep 1;
fi;
done
name: multiplier
image: alpine
我们现在通过执行kubectl apply -f <FILE>命令来运行作业:
$ kubectl apply -f multiplier.yaml
job "multiplier" created
我们可以通过在单独的窗口或选项卡上运行kubectl get pods -w和kubectl get jobs -w命令来观察作业的行为,如本章前面所建议的。我们将看到三个 pod 将被实例化,几秒钟后,它们的状态将从Running转变为Complete。这几乎是同时发生的,因为它们几乎同时开始获得一个等于或大于 101 的数:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
multiplier-7zdm7 1/1 Running 0 7s
multiplier-gdjn2 1/1 Running 0 7s
multiplier-k9fz8 1/1 Running 0 7s
queue 1/1 Running 0 50s
# A few seconds later...
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
multiplier-7zdm7 0/1 Completed 0 36s
multiplier-gdjn2 0/1 Completed 0 36s
multiplier-k9fz8 0/1 Completed 0 36s
queue 1/1 Running 0 79s
所有三个 pod 的组合结果应该是从 2 到 200 的总共 100 个数字。我们可以通过计算已经生成的行数和检查整个列表本身来检查作业是否完全成功:
$ kubectl logs -l job-name=multiplier --tail=-1 | wc
100 100 347
$ kubectl logs -l job-name=multiplier --tail=-1 \
| sort -g
2
4
6
...
196
198
200
注意
--tail=-1标志是必要的,因为标签选择器-l的使用将尾部限制设置为 10。
如果我们想要一个更正式的成功证明,我们还可以对 2 到 200 之间的偶数列表求和,并证明合并后的对数之和是相同的:
$ n=0;for i in $(seq 100);do \
n=$(($n+($i*2)));done;echo $n
10100
$ n=0; \
list=$(kubectl logs -l job-name=multiplier \
--tail=-1); \
for i in $list;do n=$(($n+$i));done;echo $n
10100
在端口 1080 上运行的queue服务产生一系列整数,再加上multiplier作业读取这些数字并将它们乘以 2,这两者的结合展示了使用作业控制器如何实现高度可伸缩、可并行的批处理过程。每个 Pod 实例工作于乘以一个整数,这是相当随机的,取决于哪个 Pod 首先命中queue服务;但是所有独立计算的聚合结果会产生一个介于 2 和 200 之间的偶数的完整列表。
注意
如果multiplier作业在尝试访问queue TCP 服务器时发现一些错误,那么wc提供的计数可能会大于 100,因为它也会包含错误。
等待作业完成
有多种方法可以检查作业是否以特定方式完成。我们可以使用kubectl get jobs来查看成功完成的数量,或者使用kubectl get pod来查看作业的状态。但是,如果我们想将这种检查集成到编程场景中,我们需要直接询问作业对象。判断作业是否已经完成的一个简单方法是查询job.status.completionTime属性,该属性只填充相关作业完成的时间。
例如,以下 shell 表达式通过重复检查直到job.status.completionTime属性变为非空,一直等到multiplier作业完成:
$ until [ ! -z $(kubectl get job/multiplier \
-o jsonpath \
--template="{.status.completionTime}") ]; \
do sleep 1 ; echo waiting ... ; done ; echo done
暂停卡住的作业
一般来说,只要失败是因为依赖项不可用,让失败的作业继续运行是一个好主意。例如,在我们用来演示外部协调批处理的用例的multiplier作业的情况下,Pod 控制器将保持重启 Pod,直到queue服务变得可用——假设重试次数不大于backoffLimit。这种行为提高了去耦性和可靠性。
但是,在某些情况下,我们对作业在中止之前可能保持失败、未完成状态的最长时间有精确的预期。实现这一点的方法是将job.spec.activeDeadlineSeconds属性设置为所需的秒数。
例如,让我们采用之前使用过的相同的multiplier作业,并将job.spec.activeDeadlineSeconds值设置为 10:
# multiplier-timeout.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: multiplier
spec:
activeDeadlineSeconds: 10
...
如果我们运行作业,假设我们没有启动队列服务——我们可以通过运行kubectl delete all --all来清理环境——并且我们观察job.status的值,我们将看到作业最终会被取消:
$ kubectl apply -f multiplier-timeout.yaml
job "multiplier" created
$ kubectl get job/multiplier -o yaml -w \
| grep -e "^status:" -A 10
status:
active: 3
failed: 1
startTime: "2019-07-17T22:49:37Z"
--
status:
conditions:
- lastProbeTime: "2019-07-17T22:49:47Z"
lastTransitionTime: "2019-07-17T22:49:47Z"
message: Job was active longer
than specified deadline
reason: DeadlineExceeded
status: "True"
type: Failed
failed: 4
startTime: "2019-07-17T22:49:37Z"
正如在显示的输出中可以看到的,在由.status.startTime属性指示的时间之后十秒钟,由于DeadlineExceeded条件,作业结束。
另一种超时作业的方法是设置job.spec.backoffLimit属性,默认情况下是6。该属性定义了作业控制器在因终端错误而结束时应该创建新 Pod 的次数。但是,我们必须记住,默认情况下,每次重试的等待时间都比前一次(10 秒、20 秒、40 秒等)要长。)
最后,activeDeadlineSeconds属性为整个作业的持续时间设置一个超时,不管它是否处于失败状态。如果到达设定的截止日期,正在经历各种成功完成而没有一个失败的 Pod 的作业也将被中止。
管理摘要
与任何其他常规 Kubernetes 对象一样,可以使用kubectl get jobs命令列出作业对象,并使用kubectl delete job/<NAME>命令删除作业对象。类似于其他控制器,如 ReplicaSet one,delete命令有一个级联效果,在某种意义上,它也将删除作业标签选择器引用的窗格。如果我们只想删除作业本身,我们需要使用--cascade=false标志。例如,在接下来给出的命令序列中,我们运行了用于演示基于完成计数的批处理过程的two-times.yaml作业,我们删除了该作业,然后,最后,我们得到了由 pod 生成的结果:
# Create a Job
$ kubectl apply -f two-times.yaml
job.batch/two-times created
# Delete the Job with but not its Pods
$ kubectl delete job/two-times --cascade=false
job.batch "two-times" deleted
# Confirm that the Job is effectively deleted
$ kubectl get jobs
No resources found.
# Extract logs from the two-times Pods
$ kubectl logs -l job-name=two-times | wc
10 10 26
摘要
在本章中,我们了解到作业是一个 Pod 控制器,类似于 ReplicaSet 控制器,不同之处在于作业预计会在某个时间点终止。我们看到,作业中的基本工作单元是完成,它发生在一个状态代码为0的 Pod 存在时,并行性允许通过增加并发工作 Pod 的数量来扩展批处理吞吐量。
我们探讨了批处理用例的三种基本类型:单个批处理、基于完成计数的批处理和外部协调的批处理。我们强调了外部协调的批处理过程与基于单个和完成计数的批处理过程之间的关键区别在于,前者的成功标准取决于作业控制器外部的机制,通常是队列。
最后,我们研究了常规的管理任务,比如监视一个作业直到它完成,暂停停滞的作业,删除它们同时保留它们的结果。
七、Cron 作业
有些任务可能需要定期运行;例如,我们可能希望每周压缩和归档日志,或者每月创建备份。Bare Pods 可以用来装备所述任务,但是这种方法需要管理员在 Kubernetes 集群本身之外建立一个调度系统。
对周期性任务进行调度的需求使得 Kubernetes 团队设计了一种独特的控制器,这种控制器模仿了类 Unix 操作系统中的传统 cron 实用程序。不出所料,控制器名为 CronJob ,它使用与 crontab 文件相同的调度规范格式;例如,*/5 * * * *指定作业每五分钟运行一次。在实现方面,CronJob 对象类似于部署控制器,因为它不直接控制 Pods 它创建一个常规的作业对象,该对象反过来负责管理 pod——部署控制器使用一个 ReplicaSet 控制器来实现这个目的。
Kubernetes 的开箱即用 CronJob 控制器的一个关键优势是,与它的 Unix 表亲不同,它不需要一个“宠物”服务器来管理和恢复健康。
本章首先介绍一个简单的 CronJob,它既可以强制启动,也可以交互启动。然后,我们看一下循环任务的调度,其中我们描述了 crontab 字符串的语法。接下来,我们将介绍一次性任务的设置;如何增加作业的历史记录;CronJob 控制器、作业和 pod 之间的交互;以及挂起和恢复活动 CronJobs 的任务。
最后,我们解释了作业并发性,它决定了如何根据指定的设置处理重叠的作业,以及如何在跳过迭代时改变 CronJob 的“追赶”行为。最后,我们回顾一下 CronJob 的生命周期管理任务。
简单的 CronJob
基本的 CronJob 类型既可以使用kubectl run <NAME> --restart=Never --schedule=<STRING> --image=<URI>命令强制创建,也可以使用清单文件声明创建。前两个标志向kubectl run命令发出信号,表明需要 CronJob,而不是 Pod 或 Deployment。我们将首先看命令式版本。
默认的调度时间间隔——也是最低的计时器分辨率——是一分钟,它使用 crontab 字符串由五个连续的星号表示:* * * * * *。使用--schedule=<STRING>标志将字符串传递给kubectl run命令。我们将在下一节学习 crontab 字符串格式。在下面名为simple的 CronJob 中,我们还指定了alpine映像,并传递一个 shell 命令来打印日期和 Pod 的主机名:
$ kubectl run simple \
--restart=Never \
--schedule="* * * * *" \
--image=alpine \
-- /bin/sh -c \
"echo Executed at \$(date) on Pod \$HOSTNAME"
cronjob.batch/simple created
注意
Kubernetes 正在使用kubectl run命令反对创建 CronJobs。Kubernetes 版本 1.14 引入了kubectl create cronjob <NAME>命令,从这个版本开始,它将成为事实上必不可少的 CronJob 创建方法。该命令采用与kubectl run相同的标志,但不包括--restart标志。例如:
$ kubectl create cronjob simple \
--schedule="* * * * *" \
--image=alpine \
-- /bin/sh -c \
"echo Executed at \$(date) on Pod \$HOSTNAME"
在我们创建 CronJob 对象之后——甚至在此之前——我们可以在不同的终端窗口或选项卡上使用kubectl get cronjobs -w、kubectl get jobs -w和kubectl get pod -w命令开始观察作业和 Pod 活动;这是因为将会创建三种不同的对象——最终分别是:一个 CronJob、一组作业和一组 pod。我们将在接下来的例子中看到所有这些对象类型。
让我们先来看看在创建名为simple的 CronJob 对象后的最初几秒钟的结果,如前所示:
$ kubectl get cronjobs -w
NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
simple * * * * * False 0 <none> 0s
$ kubectl get jobs -w
$ # nothing
$ kubectl get pods -w
$ # nothing
正如我们在结果输出中看到的,CronJob 的ACTIVE值是0,并且既没有 Job 也没有 Pod 对象在运行。一旦 CronJob 控制器使用的时钟转到下一分钟,我们将看到ACTIVE——短暂地——转到1,一个新的作业和一个子 Pod 被创建:
$ kubectl get cronjobs -w
NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
simple * * * * * False 1 6s 29s
$ kubectl get jobs -w
NAME DESIRED SUCCESSFUL AGE
simple-1520847060 1 0 0s
simple-1520847060 1 1 1s
$ kubectl get pods -w
NAME READY STATUS
simple-1520847060-xrnlh 0/1 Pending
simple-1520847060-xrnlh 0/1 ContainerCreating
simple-1520847060-xrnlh 0/1 Completed
这个过程每分钟都会重复,无限期。我们可以使用kubectl logs <POD-NAME>来检查预定任务的结果。接下来,我们检查在kubectl get pods -w输出中显示的运行的第一个 Pod,以及第二个 Pod:
$ kubectl logs simple-1520847060-xrnlh
Executed at Fri Mar 9 10:02:04 UTC 2019 on Pod simple-1520847060-xrnlh
$ kubectl logs simple-1520847120-5fh5k
Executed at Fri Mar 9 10:03:04 UTC 2019 on Pod simple-1520847120-5fh5k
通过比较 pod 的回应时间,10:02:04和10:03:04,我们可以看到它们的执行间隔为一分钟,这不一定总是第二分钟,因为启动时间可能不同。如果我们保持kubectl get pod -w运行,我们将看到一个新的 Pod,用不同的名字,将被创建并每分钟运行。
接下来是声明性版本。请注意,在撰写本文时,该 API 仍处于测试阶段——因此有了apiVersion: batch/v1beta1属性——但它很可能会在发布后成为最终版本,因此读者可能最终想要尝试apiVersion: v1,而不是:
# simple.yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: simple
spec:
schedule: "* * * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: simple
image: alpine
args:
- /bin/sh
- -c
- >-
echo Executed at $(date)
on Pod $HOSTNAME
restartPolicy: OnFailure
与任何其他清单文件一样,使用了kubectl apply -f <FILE>命令。例如:
# clean up the environment first
$ kubectl apply -f simple.yaml
cronjob.batch/simple created
设置周期性任务
使用 CronJob 对象实现的任务的频率和计时细节是使用cronjob.spec.schedule属性(或使用kubectl run时的--schedule属性)定义的,该属性的值是一个 crontab 字符串。
这种格式的使用对于以前的类 Unix 操作系统管理员来说是一种福气,但是它可能会遭到那些在当今的 JSON 和 YAML 世界中期待更直观和用户友好的方法的人的怀疑。不过,使用这种看似古老的格式不会产生问题,因为我们将足够详细地说明这一点。
crontab 字符串格式有五个组成部分—分钟、小时、一月中的某一天、月和一周中的某一天,由空格分隔。这意味着最低分辨率是整整一分钟;任务不能被安排成每 15 或 30 秒运行一次。
表 7-1
Crontab 的组件和数字值的有效范围
|分钟
|
小时
|
日/月
|
月
|
日/周
|
| --- | --- | --- | --- | --- |
| 0-59 | 0-23 | 1-31 | 1-12 | 0-6 |
每个组件要么接受一个特定值,要么接受一个表达式:
-
Digits:每个日期成分的有效范围的特定值。例如,30 15 1 1 *会将任务设置为在每年 1 月 1 日的 15:30 运行。各部件的有效位数范围如表 7-1 所示。 -
*:任意值。例如,* * * * *表示任务将在一个月中的每一分钟、每一小时、每一天以及一周中的每一天运行。 -
,:值列表分隔符。我们用它来包含多个值。例如,0,30 * * * *表示“每半小时”,因为它指定了 0 分钟和 30 分钟。 -
-:取值范围。有时候写一个值的列表太繁琐了,所以我们不如指定一个范围。例如,* 0-12 * * *表示任务将每分钟运行一次,但仅在 00:00 到 12:00 (AM)之间运行。 -
/:步长值。如果任务以固定的时间间隔运行,例如每五分钟或每两小时运行一次,而不是使用值列表分隔符指定确切的时间,我们可以简单地单步执行。例如,对于所述设置,我们将分别使用*/5 * * * *和0 */2 * * *。
推理 crontab 字符串格式最简单的方法是将默认的字符串值* * * * *作为基线,并根据手头的需求修改它,使其粒度更小。按照这种思路,表 7-2 显示了一系列样本 crontab 值;宏是一个关键字,可以用来代替基于组件的字符串。
表 7-2
简单 crontab 值示例
|线
|
巨
|
意义
|
| --- | --- | --- |
| * * * * * | 不适用的 | 每一分钟 |
| 0 * * * * | @hourly | 每小时(每小时开始时) |
| 0 0 * * * | @daily | 每天(00:00) |
| 0 0 * * 0 | @weekly | 每周的第一天(星期日,00:00) |
| 0 0 1 * * | @monthly | 每月的第一天(00:00) |
| 0 0 1 1 * | @yearly | 每年的第一天(00:00) |
基于表 7-2 中给出的样本值,我们可以通过减少数字部分的规则性来进一步细化;示例如表 7-3 所示。
表 7-3
数位循环的细化
|线
|
意义
|
| --- | --- |
| */15 * * * * | 每一刻钟运行一次 |
| 0 0-5 * * * | 在上午 00:00 到 05:00 之间每小时运行一次 |
| 0 0 1,15 * * | 仅在每月的 1 号和 15 号运行 |
| 0 0 * * 0-5/2 | 从周日到周五,每两天运行一次 |
| 0 0 1 1,7 * | 仅在 1 月 1 日和 7 月 1 日运行 |
| 15 2 5 1 * | 每年 1 月 5 日凌晨 02:15 |
请注意,像@hourly这样的宏集是出现在 Unix 类操作系统中较新的 cron 实现中的一个特性,它在 Kubernetes 中的支持似乎是部分的。例如,@reboot宏没有实现,所以作者建议尽可能使用传统的字符串格式。
还有一个方便的网站, https://crontab.guru/ ,它允许理解和验证 crontab 字符串格式组合,以便它们产生有效的间隔。这个站点有助于回答一些迫切的疑问,比如我的 crontab 字符串格式是否正确?我的 CronJob 会在预期的时间运行吗?
设置一次性任务
作业控制器采用的 crontab 格式是传统格式,而不是扩展格式,这意味着它不接受年份成分。这意味着最细粒度的事件,如15 2 5 1 *(1 月 5 日凌晨 02:15),将使相关联的作业无限期地每年运行一次。在类 Unix 操作系统中,一次性任务通常使用at实用程序而不是cron来运行。在 Kubernetes 中,我们不需要单独的控制器,但是我们需要一些外部进程在 CronJob 运行后处理它,这样它就不会在下一年重复。或者,Pod 本身可以检查它是否在预定的年份运行。
作业历史
如果我们运行上一节中描述的简单 CronJob 示例,并不断重复运行kubectl get pods(例如,使用 Linux watch命令),我们将永远不会看到超过三个 pod。例如:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
simple-1520677440-swmj6 0/1 Completed 0 2m
simple-1520677500-qx4xc 0/1 Completed 0 1m
simple-1520677560-dcpp6 0/1 Completed 0 9s
当simple CronJob 将要再次运行时,最旧的 Pod—”—将被 CronJob 控制器处理掉。这种行为通常是可取的,因为它确保 Kubernetes 集群不会耗尽资源,但是,作为一个结果,这意味着我们将丢失关于被处置的 pod 的所有信息和日志。
幸运的是,可以使用cronjob.spec.successfulJobsHistoryLimit和cronjob.spec.failedJobsHistoryLimit属性来调整这种行为。这两个属性的默认值都是3。值0意味着 pod 将在完成后立即被处理,而正值指定将保留用于检查的 pod 的确切数量——包括日志提取。
但是,在大多数情况下,如果 CronJob 的结果非常重要,那么最好将所述结果保存到更永久的存储机制或外部日志记录系统,而不是保存到 STDOUT。
与 CronJob 的作业和窗格交互
使用 CronJob 对象时,一个典型的烦恼是定位其结果作业和 pod(出于故障排除的目的)可能会很繁琐。任务被赋予一个随机名称,然后用于 pod 中的pod.metadata.label.job-name标签。当问“请给我所有与给定 CronJob 匹配的作业或 pod”时,这没有帮助。解决方案是向 Pod 模板手动添加标签;这样的标签也会出现在作业对象中,所以不需要定义两个单独的标签。为了在标记 pod 时与作业对象使用的相同标签约定保持一致,作者建议将标签名称命名为cronjob-name。
以前面几节中使用的simple.yaml清单为例,应用建议的cronjob-name标签并将其保存为simple-label.yaml会产生以下清单:
# simple-label.yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: simple
spec:
schedule: "* * * * *"
jobTemplate:
spec:
template:
metadata:
labels:
cronjob-name: simple
spec:
containers:
- name: simple
image: alpine
args:
- /bin/sh
- -c
- >-
echo Executed at $(date)
on Pod $HOSTNAME
restartPolicy: OnFailure
我们现在可以使用标签选择器标志-l以一种方便的方式与 CronJob 的作业及其 pod 进行交互。接下来,我们应用simple-label.yaml清单,让它运行三分钟以上,列出它的作业,然后获取它的 pod 日志:
# clean up environment first
$ kubectl apply -f simple-label.yaml
# wait > 3 minutes
$ kubectl get jobs -l cronjob-name=simple
NAME DESIRED SUCCESSFUL AGE
simple-1520977740 1 1 2m
simple-1520977800 1 1 1m
simple-1520977860 1 1 17s
$ kubectl logs -l cronjob-name=simple
Executed at Tue Mar 13 21:49:04 UTC 2019 on Pod simple-1520977740-qcmr8
Executed at Tue Mar 13 21:50:04 UTC 2019 on Pod simple-1520977800-jqwl8
Executed at Tue Mar 13 21:51:04 UTC 2019 on Pod simple-1520977860-bcszj
挂起 CronJob
CronJobs 随时可能被暂停。它们也可以在暂停模式下启动,并且仅在特定时间激活;例如,我们可能有许多网络诊断工具在调试会话期间每分钟都在运行,但是我们希望禁用 CronJob,而不是删除它。CronJob 是否处于挂起状态由默认设置为false的cronjob.spec.suspend属性控制。
暂停和恢复一个活动的 CronJob 需要使用kubectl edit cronjob/<NAME>命令或者使用kubectl patch命令来编辑清单。假设simple CronJob 仍在运行,下面的命令将暂停它:
$ kubectl patch cronjob/simple \
--patch '{"spec" : { "suspend" : true }}'
cronjob "simple" patched
我们可以通过确保运行kubectl get jobs命令时SUSPEND列的值为True来验证kubectl patch命令是否成功:
$ kubectl get cronjob
NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE
simple * * * * * True 0 3m17s
恢复暂停的 CronJob 只是简单地将suspend属性设置回false:
$ kubectl patch cronjob/simple \
--patch '{"spec" : { "suspend" : false }}'
cronjob "simple" patched
请注意,考虑到打补丁过程的异步性质,以及键入kubectl get cronjob时报告的状态,有效的 CronJob 状态和观察到的状态之间可能会有一些暂时的差异。
作业并发
如果在到达下一个计划运行事件时作业尚未完成,会发生什么情况?这是一个好问题;CronJob 控制器对此场景的反应方式取决于cronjob.spec.concurrencyPolicy属性的值:
-
Allow(默认值):CronJob 将简单地启动一个新的作业,并让前一个作业保持并行运行。 -
CronJob 控制器将等待当前运行的作业完成,然后再启动新的作业。
-
CronJob 控制器将终止当前正在运行的作业,并启动一个新的作业。
现在让我们详细看看每个concurrencyPolicy用例,从默认的Allow值开始。为此,我们将修改simple.yaml CronJob 清单,将 shell 脚本替换为 150 秒等待状态(使用sleep 150命令),并在上述sleep语句前后打印时间戳和主机名:
echo $(date) on Pod $HOSTNAME - Start
sleep 150
echo $(date) on Pod $HOSTNAME - Finish
然后,我们将把新的清单保存为long-allow.yaml,它嵌入了所呈现的脚本,生成以下文件:
# long-allow.yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: long
spec:
schedule: "* * * * *"
jobTemplate:
spec:
template:
metadata:
labels:
cronjob-name: long
spec:
containers:
- name: long
image: alpine
args:
- /bin/sh
- -c
- echo $(date) on Pod $HOSTNAME - Start;
sleep 150;
echo $(date) on Pod $HOSTNAME - Finish
restartPolicy: OnFailure
使用kubectl apply -f long-allow.yaml应用清单并等待大约三分钟以上会产生以下日志输出:
$ kubectl logs -l cronjob-name=long | sort -g
22:17:07 on Pod long-1520979420-t62wq - Start
22:18:07 on Pod long-1520979480-kwqm8 - Start
22:19:07 on Pod long-1520979540-mh5c4 - Start
22:19:37 on Pod long-1520979420-t62wq - Finish
...
正如我们在这里看到的,吊舱t62wq、kwqm8和mh5c4在每一分钟结束后依次启动。第一个吊舱t62wq在启动后已经完成了 2 分 30 秒。此时,kwqm8和mh5c4仍然并行运行,因为它们还没有产生Finish消息。
这种重叠开始和结束工作时间的行为可能不是我们想要的;例如,它可能导致节点资源消耗失控。有可能任务是按顺序运行的,只有在前一个任务完成后才允许新的迭代。在这种情况下,我们将cronjob.spec.concurrencyPolicy属性设置为Forbid。
为了观察将concurrencyPolicy值设置为Forbid的行为,我们将如下修改 CronJob 清单:
# long-forbid.yaml
...
spec:
concurrencyPolicy: Forbid # New attribute
schedule: "* * * * *"
...
然后,我们将把新清单保存为long-forbid.yaml,并通过发出kubectl apply -f long-forbid.yaml命令来应用它——首先清理环境,不要忘记在填充日志之前我们必须等待几分钟:
$ kubectl logs -l cronjob-name=long | sort -g
22:39:10 on Pod long-1520980740-647m6 - Start
22:41:40 on Pod long-1520980740-647m6 - Finish
22:41:50 on Pod long-1520980860-d6dfb - Start
22:44:20 on Pod long-1520980860-d6dfb - Finish
如此处所示,作业的执行现在处于完美的顺序中。重叠消息的问题——如果真的有问题的话——现在似乎得到了解决,但是如果我们更仔细地观察,我们会发现作业不再精确地在每分钟之交运行。原因是每当cronjob.spec.concurrencyPolicy属性被设置为Forbid时,CronJob 对象将等待当前作业完成,然后再启动新的作业。
使用Forbid值的副作用是,如果作业花费的时间比 crontab 字符串间隔长得多,它们可能会被完全跳过。例如,让我们假设使用0 * * * * crontab 字符串计划每小时运行一次备份。如果备份作业需要六个小时,那么一天中可能只会产生 4 个备份,而不是 24 个。
如果我们不希望作业并行运行,但又希望避免错过预定的“运行槽”,那么唯一的解决方案就是终止当前正在运行的作业,并在预定事件时启动一个新的作业。这正是将cronjob.spec.concurrencyPolicy属性设置为Replace所实现的。让我们再次修改清单来设置这个值,并将其保存为long-replace.yaml:
# long-replace.yaml
...
spec:
concurrencyPolicy: Replace # New attribute
schedule: "* * * * *"
...
像往常一样,我们首先清理环境,通过发出kubectl apply -f long-replace.yaml命令应用清单,然后等待几分钟让日志填充:
$ kubectl logs -l cronjob-name=long | sort -g
23:37:07 on Pod long-1520984220-phrqc - Start
23:38:07 on Pod long-1520984280-vm67d - Start
...
通过观察产生的输出可以理解,Replace并发设置确实按照 crontab 字符串强制作业及时启动,但是有两个相当激进的副作用。首先是当前正在运行的作业被残酷地终止。这就是为什么我们看不到日志上打印的Finish句子。第二个问题是,假设正在运行的作业被终止而不是被允许完成,那么在作业在短时间内被删除之前,我们只有有限的时间来查询它们的日志。因此,Replace设置只对那些在下一个预定事件到来时还没有完成的任务有用。
换句话说,将concurrencyPolicy设置为Replace所产生的行为仅在底层 pod 所执行的工作负载具有幂等性质时才适用;它们可以安全地在中途被中断,而不会导致数据丢失或损坏,而不管它们当前的计算状态或它们的挂起输出。接下来,如果所述 Pods 碰巧有重要的事情要告诉世界,那么推荐使用比 STDOUT 更持久的支持服务。
作为本节的总结,表 7-4 总结了与cronjob.spec.concurrencyPolicy属性的每个值(Allow、Forbid和Replace)相关的主要行为。
表 7-4
每个concurrencyPolicy值的 CronJob 行为
行为
|
Allow
|
Forbid
|
Replace
| | --- | --- | --- | --- | | 多个作业可以并行运行 | 是 | 不 | 不 | | 工作结果的重叠 | 是 | 不 | 不 | | 计划事件的及时执行 | 是 | 不 | 是 | | 正在运行的作业突然终止 | 不 | 不 | 是 |
赶上错过的预定事件
正如我们在上一节中看到的,每当错过多个事件时,CronJob 控制器通常会试图赶上其中一个(但不是全部)错过的事件。运行与错过的预定事件相关联的作业的能力并不神奇;实际上是由cronjob.spec.startingDeadlineSeconds属性决定的。如果不指定该属性,则没有截止日期。
假设我们已经配置了一个持续 25 分钟的 CronJob 在第 0 分钟和第 1 分钟(0,1 * * * *)运行,并且我们还将cronjob.spec.concurrencyPolicy属性设置为Forbid。在这种情况下,第一个实例将正好在第 0 分钟运行,但是第二个实例仍将在第 25 分钟运行,即使它远离预定的第二分钟“槽”
如果我们碰巧给cronjob.spec.startingDeadlineSeconds属性分配了一个离散的正值,那么一旦达到预期的运行迭代,就可能不会发生“追赶”运行。例如,如果我们将该属性设置为300秒(五分钟),那么第二次运行肯定不会发生,因为 CronJob 控制器将在分钟1之后等待五分钟,然后,如果到那时前一个作业还没有完成,它将会放弃。这种行为虽然看起来有问题,但它防止了作业无限期排队的情况,从长远来看,这可能会导致资源消耗不断增加。
管理摘要
正在运行的 CronJobs 的当前列表是通过发出kubectl get cronjobs命令获得的,而一个特定的 CronJob 是通过添加-o json或-o yaml标志的kubectl describe cronjob/<NAME>或kubectl get cronjob/<NAME>来查询的,以便以结构化的格式获得进一步的细节。
如前几节所述,在 CronJob 清单中为 Pod 规范提供一个标签是很方便的,这样就很容易使用-l(匹配标签)标志来匹配 CronJob 的依赖作业和 Pod。
当使用kubectl delete cronjob/<NAME>命令删除一个 CronJob 时,其所有相关的正在运行和已完成的作业和 pod 也将被删除:
$ kubectl delete cronjob/long
job "long-1521468900" deleted
pod "long-1521468900-k6mkz" deleted
cronjob "long" deleted
如果我们想要删除 CronJob,但是不干扰当前正在运行的作业和 Pod,以便它们可以完成它们的活动,我们可以使用--cascade=false标志。例如:
$ kubectl delete cronjob/long --cascade=false
cronjob.batch "long" deleted
# After a few seconds...
$ kubectl get cronjobs
No resources found.
$ kubectl get jobs
NAME DESIRED SUCCESSFUL AGE
long-1521468540 1 0 45s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
long-1521468540-68dqd 1/1 Running 0 53s
摘要
在这一章中,我们了解到 CronJob 控制器类似于部署对象,因为它控制一个辅助控制器,一个作业,该作业反过来控制 Pods 这种关系在第一章的图 1-2 中说明。我们观察到,CronJob 控制器使用与我们熟悉的 Unix cron 实用程序相同的 crontab 字符串格式,最短的时间间隔是一分钟,而最长的时间间隔是一年。我们还指出,CronJob 的控制器相对于它的 Unix 表亲的优势在于,它通过避免维护额外的“宠物”服务器来托管 cron 实用程序,从而降低了复杂性(和潜在的故障)。
我们特别关注作业并发性,它决定了如何处理重叠的作业——当前一个作业尚未完成时,新的作业应该开始。我们看到,cronjob.spec.concurrencyPolicy属性的默认值Allow,只是让新的作业被创建并与现有的作业并行“堆积”,而Forbid让控制器等待直到前一个作业完成。Replace,第三个,也是最后一个可能的值,采取激进的方法;它只是在开始一个新的作业之前突然终止前一个正在运行的作业。
最后,我们学习了如何调整 CronJob 追赶错过的迭代的方式——当使用cronjob.spec.startingDeadlineSeconds属性将并发策略设置为Forbid时。
八、DaemonSet
DaemonSet 控制器确保每个节点运行一个 Pod 实例。这对于需要在节点级别部署的统一、水平的服务非常有用,例如日志收集器、缓存代理、代理或任何其他类型的系统级功能。但是,为什么像 Kubernetes 这样的分布式系统会把 pod 和“盒子”之间的“紧密耦合”作为一个特性来推广呢?因为性能优势——有时是完全必要的。部署在同一节点内的 pod 可以共享本地网络接口以及本地文件系统;其好处是,与“非机载”网络交互的情况相比,延迟损失要低得多。
在某种程度上,DaemonSet 以 pod 处理容器的方式处理节点:虽然 pod 确保两个或更多容器并置在一起,但 daemon set 保证守护程序(也作为 pod 实现)在每个节点上始终本地可用,以便消费者 Pods a 可以访问它们。
这一简短的章节组织如下:首先,我们将探索 DaemonSets 的两个广泛的连接性用例(TCP 和文件系统)。然后,我们将学习如何使用标签来定位特定的节点。最后,我们将描述部署和 DaemonSets 之间更新策略的差异,以及为什么在后者中不能使用maxSurge属性。
基于 TCP 的守护程序
基于 TCP 的守护程序是由 DaemonSet 控制器管理的常规 Pod,通过它可以通过 TCP 访问服务。不同之处在于,因为每个守护程序控制的 Pod 都保证部署在每个节点中,所以不需要服务发现机制,因为客户端 Pod 可以简单地建立到运行它们的本地节点的连接。我们稍后会看到所有这些是如何工作的。
首先,让我们使用 Netcat nc命令定义一个简单的节点级服务:一个监听端口6666并将所有日志请求附加到一个名为/var/node_log的文件的日志收集器:
nc -lk -p 6666 -e sh -c "cat >> /var/node_log"
下一步是将我们基于 shell 的日志收集器包装在一个 Pod 模板中,该模板又嵌入到一个 DaemonSet 清单中。与部署类似,标签和标签选择器必须匹配:
# logDaemon.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: logd
spec:
selector:
matchLabels:
name: logd
template:
metadata:
labels:
name: logd
spec:
containers:
- name: logd
image: alpine
args:
- /bin/sh
- -c
- >-
nc -lk -p 6666 -e
sh -c "cat >> /var/node_log"
ports:
- containerPort: 6666
hostPort: 6666
在应用了logDaemon.yaml清单之后,我们将在每个 Kubernetes 节点上部署一个廉价的自制日志收集服务:
$ kubectl apply -f logDaemon.yaml
daemonset.apps/logd created
由于 DaemonSet 应该为每个 worker 节点创建一个 Pod,因此 Kubernetes 集群中的 Pod 数量和 DaemonSet 控制的 Pod 数量应该匹配:
$ kubectl get nodes
NAME STATUS AGE
gke-*-ab1848a0-ngbp Ready 20m
gke-*-ab1848a0-pv2g Ready 20m
gke-*-ab1848a0-s9z7 Ready 20m
$ kubectl get pods -l name=logd -o wide
NAME STATUS AGE NODE
logd-95vnl Running 11m gke-*-s9z7
logd-ck495 Running 11m gke-*-pv2g
logd-zttf4 Running 11m gke-*-ngbp
既然日志收集器 DaemonSet 控制的 pod 在每个节点上都可用,我们将创建一个示例客户机来测试它。以下 shell 脚本每隔 15 秒生成一个问候,并将其发送到在端口6666上运行的 TCP 服务:
while true
do echo $(date) - Greetings from $HOSTNAME |
nc $HOST_IP 6666
sleep 15
done
这个脚本中有三个不同的参数:端口号,6666,,这是硬编码的;由 Pod 控制器自动填充的$HOSTNAME变量——可从容器内访问——和用户定义的$HOST_IP变量。Pod 不会明确知道运行 Pod 的节点的主机名或 IP 地址。这就产生了一个需要使用向下 API 来解决的新问题。
Downward API 允许查询 Pod 对象中的字段,并使它们作为环境变量可用于同一 Pod 的容器。在这个特例中,我们感兴趣的是pod.status.hostIP属性。为了将该属性的值“注入”到HOST_IP环境变量中,我们首先使用name属性声明变量的名称,然后使用valueFrom.fieldRef.fieldPath属性从 Pod 的对象中引用所需的属性——所有这些都在pod.spec.containers.env区间下:
...
env:
- name: HOST_IP
valueFrom:
fieldRef:
fieldPath: status.hostIP
...
定义了示例客户机和注入节点 IP 所需的额外配置后,我们现在将示例客户机的 shell 脚本和向下 API 查询结合起来,在名为logDaemonClient.yaml的单个部署清单中填充HOST_IP:
# logDaemonClient.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: client
spec:
replicas: 7
selector:
matchLabels:
name: client
template:
metadata:
labels:
name: client
spec:
containers:
- name: client
image: alpine
env:
- name: HOST_IP
valueFrom:
fieldRef:
fieldPath: status.hostIP
args:
- /bin/sh
- -c
- >-
while true;
do echo $(date) -
Greetings from $HOSTNAME |
nc $HOST_IP 6666;
sleep 15;
done
应用logDaemonClient.yaml将导致创建多个副本(总共七个),这些副本将位于不同的分类节点上,这可以通过使用-o wide标志运行kubectl get pods命令来观察到:
$ kubectl apply -f logDaemonClient.yaml
deployment.apps/client created
$ kubectl get pods -l name=client -o wide
NAME IP NODE
client-*-5hn9p 10.28.2.15 gke-*-ngbp
client-*-74ssw 10.28.0.14 gke-*-pv2g
client-*-h5fmm 10.28.2.16 gke-*-ngbp
client-*-rjgz8 10.28.1.14 gke-*-s9z7
client-*-tgk7r 10.28.0.13 gke-*-pv2g
client-*-twk5p 10.28.2.14 gke-*-ngbp
client-*-wg6th 10.28.1.15 gke-*-s9z7
如果我们随机选择一个由 DaemonSet 控制的 Pod 并查询其日志,我们将看到它们与由部署控制的客户端 Pod 来自同一个节点:
$ kubectl exec logd-8cgrb -- cat /var/node_log
08:58:56 - Greetings from client-*-tgk7r
08:58:57 - Greetings from client-*-74ssw
08:59:11 - Greetings from client-*-tgk7r
08:59:12 - Greetings from client-*-74ssw
08:59:26 - Greetings from client-*-tgk7r
08:59:27 - Greetings from client-*-74ssw
...
作为参考,这些是 DaemonSet 控制的吊舱已经着陆的节点:
$ kubectl get pods -l name=logd -o wide
NAME IP NODE
logd-8cgrb 10.28.0.12 gke-*-pv2g
logd-m5z4m 10.28.1.13 gke-*-s9z7
logd-zd9z9 10.28.2.13 gke-*-ngbp
注意,podlogd-8cgrb、client-*-tgk7r和client-*-74ssw都部署在同一个名为gke-*-pv2g的节点上:
$ kubectl get pod/logd-8cgrb -o jsonpath \
--template="{.spec.nodeName}"
gke-*-pv2g
$ kubectl get pod/client-5cbbb8f78-tgk7r \
-o jsonpath --template="{.spec.nodeName}"
gke-*-pv2g
$ kubectl get pod/client-5cbbb8f78-74ssw \
-o jsonpath --template="{.spec.nodeName}"
gke-*-pv2g
概括地说,要设置一个通用的基于 TCP 的 DaemonSet 解决方案,我们需要定义一个 DaemonSet 清单来部署守护进程本身,然后,为了使用 Pod,我们需要使用 Downward API 来注入 Pod 运行的节点的地址。向下 API 的使用包括查询特定 Pod 的对象属性,并通过环境变量使它们对 Pod 可用。
基于文件系统的守护程序
在上一节中,我们考虑了基于 TCP 的 DaemonSet 的情况,其特征在于客户端使用节点的 IP 地址(通过向下 API 注入)直接访问它,而不是使用服务对象。当部署在同一个节点上时,使用 DaemonSet 控制器部署的 pod 还有另一种相互通信的方式:文件系统。
让我们考虑一个守护进程的情况,它使用下面的 shell 脚本每 60 秒从在/var/log中找到的所有日志中创建一个 tarball :
while true
do tar czf \
/var/log/all-logs-`date +%F`.tar.gz /var/log/*.log
sleep 60
done
基于文件系统的 DaemonSet 的清单要求我们指定一个卷(对 Pod 中可用的驱动程序和目录的描述)和一个卷挂载(将卷绑定到适用容器内的文件路径)。
对于卷,我们指定了一个名为logdir的卷,它在pod.spec.volumes属性下指向节点的/var/log:
# at pod.spec
volumes:
- name: logdir
hostPath:
path: /var/log
然后,我们参考pod.spec.containers.volumeMounts隔间下的logdir卷,并确定它将被安装在我们的容器内的/var/log路径下:
# at pod.spec.containers
volumeMounts:
- name: logdir
mountPath: /var/log
最后,我们将给出的两个定义组合成一个名为logCompressor.yaml的 DaemonSet 清单:
# logCompressor.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: logcd
spec:
selector:
matchLabels:
name: logcd
template:
metadata:
labels:
name: logcd
spec:
volumes:
- name: logdir
hostPath:
path: /var/log
containers:
- name: logcd
image: alpine
volumeMounts:
- name: logdir
mountPath: /var/log
args:
- /bin/sh
- -c
- >-
while true;
do tar czf
/var/log/all-logs-`date +%F`.tar.gz
/var/log/*.log;
sleep 60;
done
在应用了logCompressor.yaml之后,我们可以查询一个随机的 Pod 来判断一个 tarball 文件是否已经在它所分配的节点中被创建:
# clean up the environment first
$ kubectl apply -f logCompressor.yaml
daemonset.apps/logcd created
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
logcd-gdxc7 1/1 Running 0 0s
logcd-krf2r 1/1 Running 0 0s
logcd-rd9mb 1/1 Running 0 0s
$ kubectl exec logcd-gdxc7 \
-- find /var/log -name "*.gz"
/var/log/all-logs-2019-04-26.tar.gz
既然我们基于文件系统的 DaemonSet 已经启动并运行,让我们继续修改客户端,以便它将输出发送到/var/log/$HOSTNAME.log而不是 TCP 端口 6666:
# logCompressorClient.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: client2
spec:
replicas: 7
selector:
matchLabels:
app: client2
template:
metadata:
labels:
app: client2
spec:
volumes:
- name: logdir
hostPath:
path: /var/log
containers:
- name: client2
image: alpine
volumeMounts:
- name: logdir
mountPath: /var/log
args:
- /bin/sh
- -c
- >
while true;
do echo $(date) -
Greetings from $HOSTNAME >> \
/var/log/$HOSTNAME.log;
sleep 15;
done
如果我们仔细观察,我们会发现logCompressorClient.yaml包含与logCompressor.yaml相同的volumes和volumeMounts区间。这是因为 DaemonSet 及其客户端都需要它们共享的文件系统的详细信息。
一旦应用了logCompressorClient.yaml,我们可以等待几分钟,并证明每个主机中生成的 tarball(由 DaemonSet 创建)是否包含部署生成的日志文件:
$ kubectl apply -f logCompressorClient.yaml
deployment.apps/client2 created
# after a few minutes...
$ kubectl exec logcd-gdxc7 -- \
tar -tf /var/log/all-logs-2019-04-26.tar.gz
var/log/cloud-init.log
var/log/kube-proxy.log
var/log/client2-5549f6854-c9mz2.log
var/log/client2-5549f6854-dvs4f.log
var/log/client2-5549f6854-lhgxx.log
var/log/client2-5549f6854-m29gb.log
var/log/client2-5549f6854-nl6nx.log
var/log/client2-5549f6854-trcgz.log
遵循$HOSTNAME.log命名约定的文件,如client2-5549f6854-c9mz2.log,确实是由logCompressorClient.yaml生成的。
注意
真实世界的日志解决方案很少依赖于节点的文件系统,而是依赖于云支持的外部驱动器(如 Google Cloud Platform Persistent Disk)。相反,CronJob(参见第七章)与 shell sleep语句相反,通常比 DaemonSet 更合适,因为它提供了全局调度功能。
仅在特定节点上运行的守护程序
在一些高级场景中,并非 Kubernetes 集群中的所有节点都是由商用硬件支持的同构、可任意使用的虚拟机。一些服务器可能具有图形处理单元(GPU)和快速固态硬盘(SSD)等特殊功能,甚至使用不同的操作系统。或者,我们可能想要在环境之间建立一个硬的而不是逻辑的隔离;换句话说,我们可能希望在节点级别而不是对象级别隔离环境——就像我们在使用名称空间时通常做的那样。这是我们将在本节中考虑的用例。
分离 DaemonSets,使它们落在特定的节点上,这要求我们对每个节点应用一个标签,以便能够区分。让我们首先列出我们目前拥有的节点:
$ kubectl get nodes
NAME STATUS AGE
gke-*-809q Ready 1h
gke-*-dvzf Ready 1h
gke-*-v0v7 Ready 1h
现在,我们可以将标签应用于每个列出的节点。标记方法依赖于我们在第二章中探讨过的kubectl label <RESOURCE-TYPE>/<OBJECT-IDENTIFIER>命令。我们将第一个节点gke-*-809q指定为prod(生产),将gke-*-dvzf、gke-*-v0v7指定为dev(开发):
$ kubectl label node/gke-*-809q env=prod
node "gke-*-809q" labeled
$ kubectl label node/gke-*-dvzf env=dev
node "gke-*-dvzf" labeled
$ kubectl label node/gke-*-v0v7 env=dev
node "gke-*-v0v7" labeled
然后,我们可以使用kubectl get nodes命令和-L env标志检查每个节点标签的值,该标志显示一个名为ENV的额外列:
$ kubectl get nodes -L env
NAME STATUS AGE ENV
gke-*-809q Ready 1h prod
gke-*-dvzf Ready 1h dev
gke-*-v0v7 Ready 1h dev
现在,我们要做的就是获取上一节中显示的logCompressor.yaml清单,并将pod.spec.nodeSelector属性添加到 Pod 模板中:
# at spec.template.spec
spec:
nodeselector:
env: prod
如果我们将新清单另存为logCompressorProd.yaml并应用它,结果将是 DaemonSet 的 Pod 将只部署到标签为prod的节点:
# clean up the environment first
$ kubectl apply -f logCompressorProd.yaml
daemonset.apps/logcd configured
$ kubectl get pods -o wide
NAME READY STATUS NODE
logcd-4rps8 1/1 Running gke-*-809q
注意
请注意,选择标记为prod(生产)和dev(开发)的节点仅仅是为了说明。分离 SDLC 阶段的实际环境通常是使用名称空间来实现的,有时甚至是完全不同的 Kubernetes 集群实例。
更新策略
当目标是产生零停机更新时,更新现有的 DaemonSet 与更新部署的工作方式并不完全相同。在典型的零停机部署更新中,会增加一个或多个额外的 Pod(使用deployment.spec.strategy.rollingUpdate.maxSurge属性进行控制),目的是在终止旧的 Pod 之前,始终至少有一个额外的 Pod 可用。该过程由服务控制器辅助,服务控制器可以随着迁移的进行将 pod 移入和移出负载均衡器。在 DaemonSet 的情况下,maxSurge属性不可用;我们会看到原因。
虽然由常规部署控制器控制的吊舱的位置(确切的着陆节点)是相当不合理的,但 DaemonSet 控制器的合同目标是确保每个节点正好有一个吊舱可用。因此,最小和最大“副本”的数量就是集群中的节点总数,不包括使用特殊节点选择器的情况,如上一节所示。此外,DaemonSet 控制器部署的 pod 通常使用文件系统或节点级 TCP 端口进行本地访问,而不是通过服务控制器管理的代理和 DNS 条目进行访问。简而言之,DaemonSet 的 Pod 是节点级的单例,而不是可伸缩对象群中的匿名成员。DaemonSets 实现系统级工作负载,与其他更短暂的应用(例如 web 服务器)相比,它承担着更基础的角色和更高的优先级。
让我们想象一下,假设将 DaemonSet 的maxSurge属性设置为 1 会有什么后果。如果可能的话,在 DaemonSet 更新过程中,可能会有数量超过集群中节点总数的 pod 存在一段时间。例如,在三个节点的 Kubernetes 集群中,1 的maxSurge将允许在 DaemonSet 更新期间存在四个节点。逻辑结果是额外的吊舱将落在已经有一个现有吊舱在运行的节点上;这违反了 DaemonSet 旨在保证的原则:每个节点只存在一个 Pod。结论是更新 DaemonSet(例如,选择新的映像)将涉及一些自然的停机时间,至少在本地节点级别。
DaemonSet 清单允许两种类型的更新策略:OnDelete和RollingUpdate。第一个命令指示 DaemonSet 控制器等待,直到每个 Pod 被手动删除之后,控制器才可以基于新清单中包含的模板用新 Pod 替换它。第二个操作类似于部署控制器的滚动更新声明,除了没有maxSurge属性,只有maxUnavailable。默认的更新策略实际上是RollingUpdate,其maxUnavailablity值为 1:
# at daemonset.spec (default)
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
这种默认配置导致每当产生更新时,一次更新一个节点。例如,如果我们再次运行本章前面介绍的logCompressor.yaml清单,并将其默认映像更改为busybox——我们默认使用alpine——我们将看到 DaemonSet 控制器将一次处理一个节点,终止其正在运行的 Pod,部署新的 Pod,然后再移动到下一个节点:
$ kubectl get pods -o wide
NAME READY STATUS IP NODE
logcd-k6kb4 1/1 Running 10.28.0.10 gke-*-h7b4
logcd-mtpnp 1/1 Running 10.28.2.12 gke-*-10gx
logcd-pgztn 1/1 Running 10.28.1.10 gke-*-lnxh
$ kubectl set image ds/logcd logcd=busybox
daemonset.extensions/logcd image updated
$ kubectl get pods -o wide -w
NAME READY STATUS IP NODE
logcd-k6kb4 1/1 Running 10.28.0.10 gke-*-h7b4
logcd-mtpnp 1/1 Running 10.28.2.12 gke-*-10gx
logcd-pgztn 1/1 Running 10.28.1.10 gke-*-lnxh
logcd-pgztn 1/1 Terminating 10.28.1.10 gke-*-lnxh
logcd-57tzz 0/1 Pending <none> gke-*-lnxh
logcd-57tzz 1/1 Running 10.28.1.11 gke-*-lnxh
logcd-k6kb4 1/1 Terminating 10.28.0.10 gke-*-h7b4
...
在来自kubectl get pods的输出中,我们可以看到,最初有三个节点,在发出kubectl set image命令后,节点gke-*-lnxh中的 Pod 被终止,并且在 DaemonSet 控制器选择不同的节点gke-*-h7b4以再次应用更新过程之前,创建一个新的 Pod。
管理摘要
使用kubectl get daemonsets命令列出活动 DaemonSet 控制器的数量。例如,在应用本章中用作示例的logCompressor.yaml清单后,结果将如下所示:
$ kubectl get daemonsets
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE
logcd 3 3 3 3 3
如果没有指定特定的节点选择器——在所示的输出中就是这种情况——DESIRED列下的数字应该与 Kubernetes 集群中的节点总数相匹配。还请注意,显示了一个名为NODE SELECTOR的列(由于空间限制,在显示的输出中省略了该列),该列指示 DaemonSet 是否绑定到特定节点。
对于特定 DaemonSet 的进一步询问,可使用kubectl get daemonset/<NAME>或kubectl describe daemonset/<NAME>命令。
删除操作与任何其他 Kubernetes 工作负载控制器一样。默认的删除命令kubectl delete daemonset/<NAME>将删除 DaemonSet 的所有相关窗格,除非应用了标志--cascade=false:
$ kubectl delete ds/logcd
daemonset.extensions "logcd" deleted
$ kubectl get pods
NAME READY STATUS
logcd-xgvm9 1/1 Terminating
logcd-z79xb 1/1 Terminating
logcd-5r5mn 1/1 Terminating
摘要
在本章中,我们了解到 DaemonSet 控制器用于在 Kubernetes 集群的每个节点中部署单个 Pod,以便可以使用 TCP 或文件系统对其进行本地访问,这两种方式通常都比其他类型的场外网络访问更快。本章举例说明的日志聚合器被用作教学范例;更多的工业用例包括健康监控代理和服务网格代理——或者所谓的边车。
虽然 DaemonSets 与部署类似,但我们看到了一个关键的区别,即它们不适合零停机更新场景,因为 pod 在被新版本替换之前就被终止了;此行为可能会暂时中断使用 TCP 回送设备或文件系统在节点级别访问 DaemonSet 的 pod 的本地 pod。因此,如果希望 daemon set pod 的客户端能够经受住 daemon set pod 的实时更新,则必须以容错方式设计这些客户端。
九、状态集
可以说,十二因素应用方法是云原生应用最广泛的原则之一。称为“后勤服务”的因素 IV 表示后勤服务应被视为附属资源。其中一段,在 https://12factor.net/backing-services 处,写着:
十二要素应用的代码没有区分本地服务和第三方服务。对于应用来说,两者都是附加的资源,可以通过 URL 或存储在配置中的其他定位器/凭证来访问。十二因素应用的部署应该能够在不改变应用代码的情况下,将本地 MySQL 数据库与第三方(如 Amazon RDS)管理的数据库进行交换。同样,本地 SMTP 服务器可以与第三方 SMTP 服务(如邮戳)交换,而无需更改代码。在这两种情况下,只有配置中的资源句柄需要更改。
十二因素应用方法没有说明交付我们自己的支持服务的最佳实践,因为它假设应用将在一个无状态的 PaaS(如 Heroku、Cloud Foundry 或 Google App Engine)上运行,这些都是完全托管的服务。如果我们需要实现自己的后台服务,而不是依赖于,比如说,Google Bigtable,会怎么样?Kubernetes 实现后台服务的答案是 StatefulSet 控制器。
后台服务与无状态的十二因素应用有着不同的动态。缩放不是一件小事;“向下”扩展可能会导致数据丢失,而向上扩展可能会导致现有群集的不适当复制或重新共享。一些后台服务根本不打算扩展,至少不能自动扩展。
后台服务在如何实现高可伸缩性方面也有很大的不同。有些使用管理-工作(主-从)策略(例如 MySQL 和 MongoDB),而有些使用多主架构,例如 Memcached 和 Cassandra。Kubernetes 中的 StatefulSet 控制器不能对每个数据存储的性质做出宽泛的假设;因此,它侧重于底层的、原始的属性,例如稳定的网络身份,这些属性可以根据离散的问题或手头需要的属性,有选择地帮助实现它们。
在这一章中,我们将从头开始构建一个原始的键/值数据存储,它将用于内部化实现 StatefulSets 的原则,而没有遗漏可能不适用于单个特定产品(如 MySQL 或 MongoDB)的细节的风险。随着本章的深入,我们将丰富所述原始键/值数据存储。在第一部分中,我们将介绍顺序 Pod 创建、稳定的网络身份和使用无头服务的原则——后者是发布后台服务的关键。然后,我们将查看 Pod 生命周期事件,我们可以利用这些事件来实现正常的启动和关闭功能。最后,我们将展示如何实现基于存储的持久性,这也是有状态性的最终目的。
原始键/值存储
我们将要看到的可能被认为是穷人的 Memcached 或 BerkleyDB。这个键/值存储只执行三项任务:保存键/值对,通过唯一键查找和检索值,以及列出所有现有键。密钥作为常规文件保存在文件系统中,其中文件名是密钥,其内容是值。没有输入验证、删除功能和任何种类的安全措施。前面提到的三个函数( save 、 *load、*和 allKeys )分别使用 Python 3:
#!/usr/bin/python3
# server.py
from flask import Flask
import os
import sys
if len(sys.argv) < 3:
print("server.py PORT DATA_DIR")
sys.exit(1)
app = Flask(__name__)
port = sys.argv[1]
dataDir = sys.argv[2] + '/'
@app.route('/save/<key>/<word>')
def save(key, word):
with open(dataDir + key, 'w') as f:
f.write(word)
return word
@app.route('/load/<key>')
def load(key):
try:
with open(dataDir + key) as f:
return f.read()
except FileNotFoundError:
return "_key_not_found_"
@app.route('/allKeys')
def allKeys():
keys = ".join(map(lambda x: x + ",",
filter(lambda f:
os.path.isfile(dataDir+'/'+f),
os.listdir(dataDir)))).rstrip(',')
return keys
if __name__ == '__main__':
app.run(host='0.0.0.0', port=port)
请注意,在本章的文件夹下找到的实际文件server.py具有额外的特性(换句话说就是代码),这些特性在给出的清单中被省略了。所述省略的特征有助于处理平稳的启动和关闭,并且将在前面的几个部分中讨论。
要在本地试验服务器,我们可以首先安装 Flask,然后通过传递端口号和数据目录作为参数来启动服务器:
$ sudo pip3 install Flask
$ mkdir -p /tmp/data
$ ./server.py 1080 /tmp/data
一旦服务器启动并运行,我们就可以通过插入和检索一些键/值对来“玩”它:
$ curl http://localhost:1080/save/title/Sapiens
Sapiens
$ curl http://localhost:1080/save/author/Yuval
Yuval
$ curl http://localhost:1080/allKeys
author,title
$ curl http://localhost:1080/load/title
Sapiens
$ curl http://localhost:1080/load/author
Yuval
最小状态集清单
在上一节中,我们已经介绍了用 Python 编写的基于键/值存储 HTTP 的服务器,现在我们将使用 StatefulSet 控制器运行它。
最小 StatefulSet 清单在很大程度上类似于部署清单:它允许定义副本的数量、具有一个或多个容器的 Pod 模板等等:
# wip/server.yaml
# Minimal manifest for running server.py
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: server
spec:
selector:
matchLabels:
app: server
serviceName: server
replicas: 3
template:
metadata:
labels:
app: server
spec:
containers:
- name: server
image: python:alpine
args:
- bin/sh
- -c
- >-
pip install flask;
python -u /var/scripts/server.py 80
/var/data
ports:
- containerPort: 80
volumeMounts:
- name: scripts
mountPath: /var/scripts
- name: data
mountPath: /var/data
volumes:
- name: scripts
configMap:
name: scripts
- name: data
emptyDir:
medium: Memory
我们使用的不是容器化的server.py,而是包含 Python 3 解释器的现成 Docker 映像python:alpine。文件server.py必须“上传”为名为scripts的配置图,其设置如下:
#!/bin/sh
# wip/configmap.sh
kubectl delete configmap scripts \
--ignore-not-found=true
kubectl create configmap scripts \
--from-file=../server.py
另外,请注意使用名为data的卷,该卷使用 RAM 内存设置为类型为emptyData的卷。这意味着我们的键/值存储目前作为内存中的缓存工作,而不是在服务器崩溃后仍然存在的持久性存储。我们将很快详细阐述这方面的内容。
现在,我们已经拥有了将键/值存储作为有状态集合运行所需的一切:
$ cd wip
$ ./configmap.sh
$ kubectl apply -f server.yaml
statefulset.apps/server created
当我们用kubectl get pods列出结果 Pod 时,我们可以注意到,与部署不同,Pod 名称遵循一个连续的顺序,从0,开始,而不是有一个随机的后缀。我们将在下一节讨论顺序 Pod 创建属性:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
server-0 1/1 Running 0 5s
server-1 1/1 Running 0 0s
server-2 0/1 Pending 0 0s
为了证明键/值存储服务器工作正常,我们可以用其中一个 pod 建立一个代理,设置一个键/值对,然后检索它的值:
$ kubectl port-forward server-0 1080:80
Forwarding from 127.0.0.1:1080 -> 80
...
# Set a key/value pair
$ curl http://localhost:1080/save/title/Sapiens
Sapiens
# Retrieve the value for the title key
$ curl http://localhost:1080/load/title
Sapiens
如果kubectl port-forward报告了一个错误,比如bind: address already in use,这意味着我们让server.py在端口 1080 上运行,或者其他进程正在使用这个端口。如果源代码中的端口碰巧被永久地分配给了某个其他应用,读者可能会更改该端口。
顺序 Pod 创建
默认情况下,部署控制器并行创建所有的 pod(除非升级时可能会应用.maxSurge约束),以加速该过程。相反,StatefulSet 控制器按顺序创建 pod,从0开始,直到定义的副本数减一。该行为由statefulset.spec.podManagementPolicy属性控制,其默认值为OrderedReady。另一个可能的值是Parallel,,它产生与部署和复制集控制器相同的行为。
在前面的部分中,我们可以通过在应用kubectl apply -f server.yaml之前运行kubectl get pods -w来看到连续的 Pod 创建过程:
$ kubectl get pods -w
NAME READY STATUS
server-0 0/1 Pending
server-0 0/1 ContainerCreating
server-0 1/1 Running
server-1 0/1 Pending
server-1 0/1 ContainerCreating
server-1 1/1 Running
server-2 0/1 Pending
server-2 0/1 ContainerCreating
server-2 1/1 Running
为什么顺序 Pod 创建很重要?因为后台服务通常具有依赖于可靠假设的语义,这些假设是关于先前已经创建了哪些确切的 pod 以及接下来将创建哪些 pod:
-
在基于管理人员-工作人员范例的后备存储中,比如 MongoDB 或 MySQL,工作人员 pod pod-1 和 pod-2 可能希望首先定义 pod-0(管理人员),以便可以向它注册。
-
Pod 创建序列可以包括按比例增加现有的集群,其中数据可以从 pod-0 复制到 pod-1、pod-2 和 pod-3。
-
pod 删除序列可能需要逐个注销工作线程。
正如在最后一点中提到的,顺序 Pod 创建的属性也反向工作:具有最高索引的 Pod 总是首先终止。例如,通过发出kubectl scale statefulset/server --replicas=0将键/存储集群减少到 0 个副本会导致以下行为:
$ kubectl get pods -w
NAME READY STATUS
server-0 1/1 Running
server-1 1/1 Running
server-2 1/1 Running
server-2 1/1 Terminating
server-2 0/1 Terminating
server-1 1/1 Terminating
server-1 0/1 Terminating
server-0 1/1 Terminating
server-0 0/1 Terminating
稳定的网络身份
在无状态应用中,每个副本的特定身份和位置是短暂的。只要我们能够到达负载均衡器,哪个特定的副本服务于我们的请求并不重要。对于后台服务来说,情况不一定如此,比如我们的原始键/值存储或 Memcached 之类的服务。许多多主存储在不产生中心争用点的情况下解决规模问题的方法是,让每个客户机(或等效的委托代理)知道每个服务器主机,以便客户机自己决定在哪里存储和检索数据。
因此,在 StatefulSets 的情况下,根据数据存储的水平扩展策略,对于客户端来说,确切地知道它们已经将数据保存到的 Pod 可能是至关重要的,以便在扩展、重启和失败事件之后,加载请求总是与用于原始保存请求的原始 Pod 相匹配。
例如,如果我们将键title设置在 Pod server-0上,我们知道我们可以稍后返回并从完全相同的 Pod 中检索它。相反,如果 Pod 由常规部署控制器管理,那么 Pod 将被赋予一个随机的名称,例如server-1539155708-55dqs或server-1539155708-pkj2w。即使客户端可以记住这样的随机名称,也不能保证存储的键/值对在删除或放大/缩小事件后仍然存在。
稳定网络身份的属性对于应用允许跨多个计算和数据资源扩展数据的分片机制至关重要。分片意味着给定的数据集被分解成块,每个块最终位于不同的服务器上。根据手头数据的类型和更均匀分布的字段或属性,将数据集分成块的标准可能会有所不同;例如,对于联系人实体,名字和姓氏是很好的属性,而性别则不是。
让我们假设我们的数据集由关键字a、b、c和d组成。我们如何在三台服务器上平均分配每封信?最简单的解决方案是应用模运算。通过获取每个字母的 ASCII 十进制代码并获取服务器数量的模,我们获得了一个廉价的分片解决方案,如表 9-1 所示。
表 9-1
将模运算符应用于 ASCII 字母
|钥匙
|
小数
|
以…为模
|
计算机网络服务器
| | --- | --- | --- | --- | | a | Ninety-seven | 97 % 3 = 1 | 服务器-1 | | b | Ninety-eight | 98 % 3 = 2 | 服务器-2 | | c | Ninety-nine | 99 % 3 = 0 | 服务器-0 | | d | One hundred | 100 % 3 = 1 | 服务器-1 |
Cassandra 或 Memached 等生产级后备存储中的实际哈希算法将更加复杂,并使用一致的哈希算法——这样,当添加或删除新服务器时,总的来说,密钥不会位于不同的服务器上——但基本原理是相同的。
这里的关键见解是,客户端要求服务器有一个稳定的网络身份,因为它们将为每个服务器分配一个密钥子集。这正是区分 StatefulSets 和 Deployments 的关键特性之一。
无头服务
如果客户机首先无法到达服务器单元,那么稳定的网络身份就没有多大用处。客户端对 StatefulSet 控制的 Pod 的访问不同于适用于部署控制的 Pod 的访问,因为跨随机 Pod 实例的负载均衡是不合适的;客户需要直接访问离散单元。然而,Pod 将到达的具体节点和 IP 地址只能在运行时确定,因此发现机制仍然是必要的。
在 StatefulSets 的情况下使用的解决方案仍然在于服务控制器(第四章),除了它被配置为提供所谓的无头服务。无头服务只提供一个 DNS 条目和代理,而不是一个负载均衡器,它是使用常规服务清单设置的,除了service.spec.clusterIP属性被设置为None:
# wip/service.yaml
apiVersion: v1
kind: Service
metadata:
name: server
labels:
app: server
spec:
ports:
- port: 80
clusterIP: None
selector:
app: server
让我们应用如下的service.yaml清单:
# Assume wip/server.yaml has been applied first
$ cd wip
$ kubectl apply -f service.yaml
service/server created
为server服务创建的 DNS 条目将为每个正在运行并准备就绪的 Pod 提供一个 DNS SRV 记录。我们可以使用nslookup命令来获得这样的 DNS SRV 记录:
$ kubectl run --image=alpine --restart=Never \
--rm -i test \
-- nslookup server
10.36.1.7 server-0.server.default.svc.cluster.local
10.36.1.8 server-2.server.default.svc.cluster.local
10.36.2.7 server-1.server.default.svc.cluster.local
我们的原始键/值存储的智能客户端
到目前为止,我们已经使用 StatefulSet 控制器成功运行了键/值存储的多个副本,并使用 headless 服务使感兴趣的消费者应用可以访问每个 Pod 端点。我们以前也解释过,可伸缩性是由客户机管理的,而不是由服务器本身管理的——在多主范例中,我们选择在本章中探讨。现在,让我们创建一个智能客户端,它允许我们进一步了解 StatefulSet 的行为。
在最后展示整个源代码之前,我们将描述我们的客户端的关键方面。客户端需要了解的第一件事是它将与之交互的服务器的确切集合,以及它是否应该以只读模式运行:
if len(sys.argv) < 2:
print('client.py SRV_1[:PORT],SRV_2[:PORT],' +
'... [readonly]')
sys.exit(1)
# Process input arguments
servers = sys.argv[1].split(',')
readonly = (True if len(sys.argv) >= 3
and sys.argv[2] == 'readonly' else False)
三副本服务器的典型调用如下——不要运行此示例;这仅用于说明:
./server.py \
server-0.server,server-1.server,server-2.server
我们的客户端保存了一组由定义数量的服务器上的英文字母组成的密钥,现在存储在servers变量中。当客户端启动时,它将首先打印由虚线下划线的完整字母表,以及通过获取每个密钥的 ASCII 码的模而被选择来存储每个密钥的服务器号:
# Print alphabet and selected server for each letter
sn = len(servers)
print(' ' * 20 + string.ascii_lowercase)
print(' ' * 20 + '-' * 26)
print(' ' * 20 + ".join(
map(lambda c: str(ord(c) % sn),
string.ascii_lowercase)))
print(' ' * 20 + '-' * 26)
如果三个服务器被定义为参数,这个代码片段将产生以下输出,这些参数又变成变量servers中的一个列表:
abcdefghijklmnopqrstuvwxyz
--------------------------
12012012012012012012012012
--------------------------
让字母表中的每个字母与数字 0、1 或 2 垂直对齐的含义是,按键将按照表 9-2 所示进行分配。
表 9-2
字母表字母和指定的服务器
|键
|
计算机网络服务器
|
| --- | --- |
| [c,f,i,l,o,r,u,x] | 服务器-0 .服务器 |
| [a,d,g,j,m,p,s,v,y] | 服务器-1 .服务器 |
| [b,e,h,k,n,q,t,w,z] | 服务器-2 .服务器 |
现在,在初始化之后,客户端将在一个循环中运行,检查是否在匹配的服务器中找到了每个密钥,如果没有,它将尝试插入它,除非它以只读模式运行:
# Iterate through the alphabet repeatedly
while True:
print(str(datetime.datetime.now())[:19] + ' ',
end=")
hits = 0
for c in string.ascii_lowercase:
server = servers[ord(c) % sn]
try:
r = curl('http://' + server + '/load/' + c)
# Key found and value match
if r == c:
hits = hits + 1
print('h',end=")
# Key not found
elif r == '_key_not_found_':
if readonly:
print('m',end=")
else:
# Save Key/Value (not read only)
r = curl('http://' + server +
'/save/' + c + '/' + c)
print('w',end=")
# Value mismatch
else:
print('x',end=")
except urllib.error.HTTPError as e:
print(str(e.getcode())[0],end=")
except urllib.error.URLError as e:
print('.',end=")
print(' | hits = {} ({:.0f}%)'
.format(hits,hits/0.26))
time.sleep(2)
每个键的结果将使用一个状态字母显示。以下是第一次运行客户端的示例,假设 StatefulSet 及其 headless 服务已启动并正在运行:
abcdefghijklmnopqrstuvwxyz
--------------------------
12012012012012012012012012
--------------------------
03:51 wwwwwwwwwwwwwwwwwwwwwwwwww | hits = 0 (0%)
03:53 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
03:55 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
...
表 9-3 给出了字母表中每个字母的含义,包括输出中显示的w和h。
表 9-3
字母表的字母栏下每个状态字母的含义
|信
|
描述
|
| --- | --- |
| w | 写:在服务器中没有找到密钥,所以保存了它。 |
| h | 命中:在服务器中找到了密钥。 |
| m | Miss:未找到密钥,不会保存(只读)。 |
| x | 异常:找到了键,但它与其值不匹配。 |
| . | 服务器不可访问或网络故障。 |
| 0-9 | 返回了 HTTP 服务器错误。例如,503 就是 5。 |
在仔细检查了每个代码片段之后,我们现在呈现完整的客户端 Python 脚本:
#!/usr/bin/python3
# client.py
import string
import time
import sys
import urllib.request
import urllib.error
import datetime
if len(sys.argv) < 2:
print('client.py SRV_1[:PORT],SRV_2[:PORT],' +
'... [readonly]')
sys.exit(1)
# Process input arguments
servers = sys.argv[1].split(',')
readonly = (True if len(sys.argv) >= 3
and sys.argv[2] == 'readonly' else False)
# Remove boilerplate from HTTP calls
def curl(url):
return urllib.request.urlopen(url).read().decode()
# Print alphabet and selected server for each letter
sn = len(servers)
print(' ' * 20 + string.ascii_lowercase)
print(' ' * 20 + '-' * 26)
print(' ' * 20 + ".join(
map(lambda c: str(ord(c) % sn),
string.ascii_lowercase)))
print(' ' * 20 + '-' * 26)
# Iterate through the alphabet repeatedly
while True:
print(str(datetime.datetime.now())[:19] + ' ',
end=")
hits = 0
for c in string.ascii_lowercase:
server = servers[ord(c) % sn]
try:
r = curl('http://' + server + '/load/' + c)
# Key found and value match
if r == c:
hits = hits + 1
print('h',end=")
# Key not found
elif r == '_key_not_found_':
if readonly:
print('m',end=")
else:
# Save Key/Value (not read only)
r = curl('http://' + server +
'/save/' + c + '/' + c)
print('w',end=")
# Value mismatch
else:
print('x',end=")
except urllib.error.HTTPError as e:
print(str(e.getcode())[0],end=")
except urllib.error.URLError as e:
print('.',end=")
print(' | hits = {} ({:.0f}%)'
.format(hits,hits/0.26))
time.sleep(2)
除了我们之前定义的server.py,我们还必须将client.py添加到名为scripts的配置图中。因此,我们定义了一个名为wip/configmap2.sh :的新文件
#!/bin/sh
# wip/configmap2.sh
kubectl delete configmap scripts \
--ignore-not-found=true
kubectl create configmap scripts \
--from-file=server.py --from-file=../client.py
最后,我们需要一个 Pod 清单来运行客户端,并为它提供预期的状态集 Pod 名称:
# client.yaml
apiVersion: v1
kind: Pod
metadata:
name: client
spec:
restartPolicy: Never
containers:
- name: client
image: python:alpine
args:
- bin/sh
- -c
- "python -u /var/scripts/client.py
server-0.server,server-1.server,\
server-2.server"
volumeMounts:
- name: scripts
mountPath: /var/scripts
volumes:
- name: scripts
configMap:
name: scripts
作为最后一个实验,从零开始重置我们的环境是很有趣的,首先启动客户端,并在没有serversstatefullset 的的情况下观察它的初始行为*:*
# Clean up the environment first
$ cd wip
$ ./configmap2.sh
configmap "scripts" deleted
configmap/scripts created
# Run the client
$ kubectl apply -f ../client.yaml
pod/client created
# Query the client's logs
$ kubectl logs -f client
abcdefghijklmnopqrstuvwxyz
--------------------------
12012012012012012012012012
--------------------------
00:21 .......................... | hits = 0 (0%)
00:24 .......................... | hits = 0 (0%)
00:26 .......................... | hits = 0 (0%)
00:28 .......................... | hits = 0 (0%)
.(点)字符表示没有可用于任何键的服务器。让我们继续启动服务器及其关联的 headless 服务,同时在一个单独的窗口中查看客户端的日志:
# Note: we are still under the wip directory
$ kubectl apply -f server.yaml
statefulset.apps/server created
$ kubectl apply -f service.yaml
service/server created
客户端将自动开始将密钥(w)保存到出现的每个服务器:
abcdefghijklmnopqrstuvwxyz
--------------------------
12012012012012012012012012
--------------------------
... ... ...
04:14 .......................... | hits = 0 (0%)
04:18 .......................... | hits = 0 (0%)
04:21 .......................... | hits = 0 (0%)
04:23 w..w..w..w..w..w..w..w..w. | hits = 0 (0%)
04:25 h..h..h..h..h..h..h..h..h. | hits = 9 (35%)
04:27 h.wh.wh.wh.wh.wh.wh.wh.wh. | hits = 9 (35%)
04:29 hwhhwhhwhhwhhwhhwhhwhhwhhw | hits = 17 (65%)
04:31 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
04:33 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
这是如何逐行解释发生的事情:
04:23 w..w..w..w..w..w..w..w..w. | hits = 0 (0%)
服务器server-1.server第一个出现,这导致密钥[a,d,g,j,m,p,s,v,y]被保存(w)到其中。服务器server-0.server和server-2.server还无法访问:
04:25 h..h..h..h..h..h..h..h..h. | hits = 9 (35%)
服务器server-1.server已经包含导致命中的密钥[a,d,g,j,m,p,s,v,y](h)。服务器server-0.server和server-2.server还不能访问。
04:27 h.wh.wh.wh.wh.wh.wh.wh.wh. | hits = 9 (35%)
现在服务器server-2.server已经启动,密钥[b,e,h,k,n,q,t,w,z]已经保存到其中。现在只有server-0.server还无法进入;
04:29 hwhhwhhwhhwhhwhhwhhwhhwhhw | hits = 17 (65%)
服务器server-0终于启动了,密钥[c,f,i,l,o,r,u,x]已经保存到其中:
04:31 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
密钥集现在分布在所有三台服务器上。
请注意,这个例子似乎违反了顺序创建 Pod 的原则。如果我们使用kubectl get pods -w观察 Pod 创建行为,我们将看到该原理仍然适用。但是,如果 Pods 启动得足够快,就绪探测变为活动状态的时间加上 DNS 缓存,可能会导致从客户端角度看起来无序的行为。如果要求客户端自己体验有保证的有序 Pod 创建,那么我们需要增加一些延迟,以允许就绪性探测器开始工作,并允许刷新和/或刷新 DNS 缓存。
控制后备存储器的创建和终止
正如我们在本章的介绍中所解释的,StatefulSet 控制器不能对后备存储的具体性质做出宽泛的假设,因为每个高可伸缩性和可用性范例以及每个产品的技术设计和约束(例如,MySQL 与 MongoDB)所采取的选择会导致大量的可能性。例如:
-
向主管登记员工
-
向控制器(如 ZooKeeper)注册副本
-
与工人一起复制主数据集
-
在将先行副本标记为就绪之前,等待集群的最后一个成员启动并运行
-
领导人改选(并向客户公布选举结果)
-
重新计算哈希值
话虽如此,我们可以对 StatefulSet 的生命周期进行推理,并理解 Kubernetes 管理员拥有哪些机会来实施控制。StatefulSet 控制器给我们施加所述控制的主要烹饪成分是
-
保证 Pod 的创建和终止是有序的,并且它们的身份是可预测的。例如,如果
$HOSTNAME的值是server-2,我们可以预计server-0和server-1将首先被创建。 -
使用 headless 服务到达同一组中的其他 pod 的机会:这与第一点有关;如果我们在
server-2内部,无头服务器将发布 DNS 条目以连接到server-0和sever-1。 -
在主容器运行之前,有机会运行一个或多个定制的初始化容器。这些在 StatefulSet 清单中的
statefulset.spec.template.spec.initContainers处定义。例如,我们可能希望在正式后备存储的容器运行之前,使用初始化容器从外部源导入 SQL 脚本。官方后台存储的 Docker 映像可能是供应商提供的,用自定义代码“污染它”可能不是一个好主意。 -
当每个主容器初始化时,以及当它们使用在
statefulset.spec.template.spec.containers.lifecycle声明的生命周期钩子如postStart和preStop终止时,运行命令的机会。在前面的小节中,我们将把这个特性用于我们的基本键/值存储。 -
捕捉 SIGTERM Linux 信号的机会,该信号在终止时被发送到每个主容器的每个第一个进程。当容器接收到 SIGTERM 信号时,它们可以在由
statefulset.spec.template.spec.terminationGracePeriodSeconds属性定义的秒数内运行正常关闭代码。 -
Pod 的默认活性和准备就绪探测器(见第二章)允许决定给定的 Pod 何时对世界可用。
Pod 生命周期事件的顺序
在上一节中,我们已经看到,在创建和销毁后备存储单元时,有多种运行代码的机会,但是我们没有讨论何时适合应用各种选项。例如,我们应该使用 Init Containers 还是 PostStart hook 为 MySQL 数据库设置初始表吗?要回答这些问题,有必要了解整个 Pod 生命周期中发生的事件的顺序。为此,我们将首先介绍的创建生命周期,然后是的终止生命周期。
注意
本节假设了各种对读者来说可能是新的一般概念:
-
宽限期:在进程被强制终止之前,允许它运行正常关闭任务的时间。
-
平稳启动和关闭:分别执行“拆除”和“拆除”任务,帮助最大限度地减少中断,防止系统、进程或数据处于不一致的状态。
-
钩子:一个占位符,允许插入触发器、脚本或其他代码执行机制。
-
Sigkill:发送给进程以使其立即终止的信号。这个信号通常不能被捕捉或忽略。
-
SIGTERM:发送给进程请求终止的信号。这个信号可以被进程捕获、解释或忽略。作为正常关闭策略的一部分,实现进程级清理代码是有帮助的。
-
稳态:其特征变量不随时间变化的状态。
创建生命周期包括 Pod 的启动(因为第一次创建 StatefulSet,或者由于缩放事件)。这意味着将一个 Pod 从不存在状态变为运行状态。表 9-4 显示了最相关的生命周期事件的顺序( C0…C4,S ),其中 C 代表创建, S 代表稳定状态。稳定状态是指 Pod 不会发生与启动或关闭过程无关的生命周期变化。在第二行中,P 代表挂起,而 R 代表运行。
表 9-4
状态集控制的 Pod 创建生命周期事件
|描述
|
无着丝粒的
|
C1
|
C2
|
C3
|
补体第四成份缺乏
|
S
| | --- | --- | --- | --- | --- | --- | --- | | Pod 状态 | P | P | 稀有 | 稀有 | 稀有 | 稀有 | | 初始化容器运行 | | ●执行下列操作 | | | | | | 主容器运行 | | | ●执行下列操作 | | | | | 启动后挂钩运行 | | | ●执行下列操作 | | | | | 活性探测运行 | | | | ●执行下列操作 | | | | 就绪探测运行 | | | | ●执行下列操作 | | | | 此端点已发布 | | | | | ●执行下列操作 | | | N-1 个端点已发布 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | | 发布了 N+1 个端点 | | | | | | ●执行下列操作 |
请注意,表 9-4 代表了一个粗略的指南,一些关键的考虑因素也适用:
-
Pod 状态是由
pod.status.phase属性提供的正式 Pod 阶段。虽然挂起(P)和运行(R)意味着是正式阶段,但是kubectl get pod命令可能显示中间状态,例如在 C0 和 C1 之间的初始化以及在 C1 和 C2 之间的pod 初始化。 -
如果 Init 容器因退出而失败,并返回一个非零退出代码,主容器将不会被执行。
-
主容器和与 PostStart 挂钩关联的命令并行运行,Kubernetes 不保证先运行哪一个。
-
在主容器启动之前,活性和准备就绪探测器开始运行。
-
适用的 Pod 的端点是在内部准备就绪探测为肯定之后的某个时间由服务控制器发布的*,但是 DNS 生存时间(TTL)设置(在服务器和客户端)和网络传播问题可能会延迟其他副本和客户端对 Pod 的可见性。*
** N-1 个端点(例如server-0.server和server-1.server如果参考箱 N 是server-2)被认为是可访问的,因为server-2仅在server-1变为就绪时被初始化;但是,它们在被查询时可能已经失败,或者可能暂时无法访问。由于这个原因,防弹代码应该总是 ping 并探测一个依赖的 Pod,而不是盲目地假设它必须启动并运行,因为有顺序的 Pod 创建保证。
* *N+2 个端点*(例如,如果参考 Pod N 是`server-2`,则为`server-3.server`和`server-4.server`)将仅在当前 Pod 准备就绪后被初始化。因此,如果某些代码需要等待将来的 Pods 变得可用,它们必须作为主容器运行,并且有一个 DNS 探测/ping 循环。*
*现在让我们来看一下终端 Pod 生命周期(表 9-5 ),每当一个状态集被缩减或一个单独的 Pod 被删除时,该生命周期从 ?? 开始。第一列 S ,表示触发终止事件之前的 Pod 的稳定状态。
表 9-5
状态集控制的 Pod 终止生命周期事件
|描述
|
S
|
一种网络的名称(传输率可达 1.54mbps)
|
??
|
??
| | --- | --- | --- | --- | --- | | Pod 状态 | 稀有 | T | T | T | | 主容器运行 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | | | 预停止挂钩运行 | | ●执行下列操作 | | | | 宽限期开始 | | ●执行下列操作 | | | | 宽限期已结束 | | | | ●执行下列操作 | | 主容器信号术语 | | | ●执行下列操作 | ●执行下列操作 | | 主容器信号 | | | | | | 此端点已发布 | ●执行下列操作 | | | | | N-1 个端点已发布 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | | 发布了 N+1 个端点 | | | | |
同样,当考虑表 9-5 时,相当值得注意的事项适用:
-
在 ?? 的终止事件是“残酷的”,没有留下人们可能需要的那么多优雅的关闭范围。特别是,被终止的 Pod 被立即从无头服务中删除;因此为什么在这个端点发布的上的●(点)字符在 ?? 本身就消失了。虽然底层应用对于那些已经预先通过 TCP 连接建立的客户端可能仍然是可访问的,但是那些恰好在 ?? 之后查询 DNS 服务或端点控制器的客户端将“看不到”终止的 Pod。
-
预停挂钩和宽限期将在 ?? 一起开始。 SIGTERM 信号将由主容器在*??处接收,在预停止挂钩完成之后,但在宽限期结束之前。根据
terminationGracePeriodSeconds属性的值,无论需要什么样的正常关机代码,都必须在分配的时间内完成。* -
在 ?? 处,当宽限期结束时,主容器将被杀死,不再有机会恢复或运行缓解代码。
使用 Pod 生命周期挂钩实现正常关机
在上两节中,我们已经讨论了可用于控制状态集生命周期的各种选项,而状态集又是其每个组成 pod 的单个生命周期的集合。现在,在这一节中,我们将回到面向实验室的工作流,并向我们的原始键/值存储添加一种简单形式的正常关闭。
我们的正常关机包括每当 Pod 终止时返回 503 HTTP 错误,而不是让客户端简单地超时。即使一旦确认了终止事件,就将 Pod 从无头服务 DNS 中删除,如果 Pod 突然超时而没有通知,则在上次检查 DNS 条目之前记得 IP 地址(和/或已经建立了 TCP 连接)的客户端可能会表现出不稳定的行为。尽管这个解决方案看起来很简单,但它可以帮助智能客户端“后退”一段时间,实现断路器模式,和/或从不同的源访问数据。
为此,我们将在处理任何 HTTP 请求之前检查是否存在名为_shutting_down_的文件:
if os.path.isfile(dataDir + '_shutting_down_'):
return "_shutting_down_", 503
前面的if语句用于指示服务是否即将关闭,现在位于更新后的server.py脚本中每个 Flask HTTP 函数的顶部。请注意,为了简洁和避免干扰,本章前面给出的server.py代码清单没有显示save()、load()和allKeys()函数之后的两行代码。
既然我们在客户端有了一个廉价的优雅关闭机制,我们需要在服务器端实现它。我们在这里需要做的是一旦收到终止事件就创建一个_shutting_down_文件,并在 Pod 启动时删除它。目的是使用该文件的存在与否来分别表示服务器是否将要关闭。为了实现该文件的创建和删除,我们将分别使用preStop和postStart pod 生命周期挂钩(参见表 9-4 和 9-5 ):
# server.yaml
...
spec:
template:
spec:
containers:
- name: server
lifecycle:
postStart:
exec:
command:
- /bin/sh
- -c
- rm -f /var/data/_shutting_down_
preStop:
exec:
command:
- /bin/sh
- -c
- touch /var/data/_shutting_down_
...
这个片段包含在新的和最终的 server.yaml清单中,直接位于章节的根目录下,而不是wip/,其中我们还将terminationGracePeriodSeconds设置为10,这样观察终止行为花费的时间就少了(默认为 30 秒)。为了简单起见,我们还在单个 YAML 文件中添加了服务清单,使用了---(三连字符)YAML 符号,这样我们只需一个命令就可以创建最终的服务器:
# Memory-based key/value store
# server.yaml
---
apiVersion: v1
kind: Service
metadata:
name: server
labels:
app: server
spec:
ports:
- port: 80
clusterIP: None
selector:
app: server
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: server
spec:
selector:
matchLabels:
app: server
serviceName: server
replicas: 3
template:
metadata:
labels:
app: server
spec:
terminationGracePeriodSeconds: 10
containers:
- name: server
image: python:alpine
args:
- bin/sh
- -c
- >-
pip install flask;
python -u /var/scripts/server.py 80
/var/data
ports:
- containerPort: 80
volumeMounts:
- name: scripts
mountPath: /var/scripts
- name: data
mountPath: /var/data
lifecycle:
postStart:
exec:
command:
- /bin/sh
- -c
- rm -f /var/data/_shutting_down_
preStop:
exec:
command:
- /bin/sh
- -c
- touch /var/data/_shutting_down_
volumes:
- name: scripts
configMap:
name: scripts
- name: data
emptyDir:
medium: Memory
观察状态集故障
在上一节中,我们通过利用postStart和preStop生命周期挂钩,在server.yaml和server.py中实现了优雅关闭功能。在本节中,我们将看到这种功能的实际应用。让我们从将当前工作目录更改为章节的根目录并运行新定义的文件开始:
# clean up the environment first
$ ./configmap.sh
configmap/scripts created
$ kubectl apply -f server.yaml
service/server created
statefulset.apps/server created
$ kubectl apply -f client.yaml
pod/client created
既然已经创建了服务器和客户机对象,我们可以再次跟踪客户机的日志:
$ kubectl logs -f client
abcdefghijklmnopqrstuvwxyz
--------------------------
12012012012012012012012012
--------------------------
24:41 wwwwwwwwwwwwwwwwwwwwwwwwww | hits = 0 (0%)
24:43 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
24:45 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
24:47 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
...
在保持kubectl logs -f client在单独的终端窗口上运行的同时,我们现在可以看到通过发出kubectl delete pod/server-1命令从 StatefulSet 中删除一个 Pod 的效果:
34:45 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
34:47 5hh5hh5hh5hh5hh5hh5hh5hh5h | hits = 17 (65%)
34:49 5hh5hh5hh5hh5hh5hh5hh5hh5h | hits = 17 (65%)
34:51 5hh5hh5hh5hh5hh5hh5hh5hh5h | hits = 17 (65%)
34:53 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
34:55 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
34:57 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
35:00 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
35:02 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
35:04 whhwhhwhhwhhwhhwhhwhhwhhwh | hits = 17 (65%)
35:06 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
在这个日志中,我们看到客户端在第34:47秒和第34:51秒之间为我们刚刚删除的服务器显示了一个数字5(代替503)。在此期间,服务器和客户端都可以实现代码以无影响的方式脱离。在34:53和35:02之间,客户端无法到达服务器 1,如.(点)所暗示的,直到它最终设法在35:04再次保存密钥,如w(写)所指示的。客户端最终在第二次35:06报告所有服务器上的h(命中)。
如果读者想知道为什么被删除的服务器会在一段时间后自动复活,这是因为 StatefulSet 控制器的职责是确保运行时规范与清单中声明的状态相匹配。假设我们已经删除了一个 Pod,StatefulSet 控件已经采取了纠正措施,以便声明的副本数量和有效运行的副本数量相匹配。
放大和缩小
在由部署控制器管理的无状态 pod 的情况下,零停机扩展的魔力在有状态集的情况下更难实现。我们将首先讨论向上扩展,然后讨论向下扩展,因为每个都有自己的挑战。
向上扩展事件会导致现有客户端可能不知道的新 pod 的出现,除非它们经常检查 DNS SRV 记录并依次更新它们自己。然而,这将打乱哈希算法,并导致后备存储中大量的未命中,就像我们的原始键/值 1 一样。
缩小规模的事件比扩大规模的事件更具破坏性,不仅仅是因为我们正在减少数据保存副本的数量,还因为在 Kubernetes 基于生命周期技术合同以仁慈的方式终止我们的 pod 之前,我们只有一段时间来做任何必要的数据重新分区。
结论是,如果我们的目标是将中断减少到最低限度,我们需要在针对状态集发出kubectl scale命令之前考虑和采取额外的步骤。
实际上,我们面临的挑战是,无论服务器数量是增加还是减少,我们都必须重新计算密钥哈希。表 9-6 以关键字a、b、c和d为例,给出了 2 和 3 副本的最终选定服务器。
表 9-6
对 a、b、c 和 d 应用模 2 和模 3 的效果
|钥匙
|
十二月
|
以…为模
|
N = 2
|
N = 3
| | --- | --- | --- | --- | --- | | a | Ninety-seven | 97 %氮 | 服务器-1 | 服务器-1 | | b | Ninety-eight | 98 %氮 | 服务器-0 | 服务器-2 | | c | Ninety-nine | 99 %氮 | 服务器-1 | 服务器-0 | | d | One hundred | 99 %氮 | 服务器-0 | 服务器-1 |
因此,如果服务器的数量从三个副本缩减到两个,我们首先应该将存储在服务器 0-2 中的键值对仅分发到服务器 0-1,反之亦然,如果从两个副本扩展到三个副本。我们已经创建了一个最简单的程序,在一个名为rebalance.py的脚本中捕获这个过程。该 Python 脚本采用现有服务器列表和未来服务器列表来执行必要的键/值对重新分区:
#!/usr/bin/python3
# rebalance.py
import sys
import urllib.request
if len(sys.argv) < 3:
print('rebalance.py AS_IS_SRV_1[:PORT],' +
'AS_IS_SRV_2[:PORT]... ' +
'TO_BE_SRV_1[:PORT],TO_BE_SRV_2[:PORT],...')
sys.exit(1)
# Process arguments
as_is_servers = sys.argv[1].split(',')
to_be_servers = sys.argv[2].split(',')
# Remove boilerplate from HTTP calls
def curl(url):
return urllib.request.urlopen(url).read().decode()
# Copy key/vale pairs from AS IS to TO BE servers
urls = []
for server in as_is_servers:
keys = curl('http://' + server +
'/allKeys').split(',')
print(server + ': ' + str(keys))
for key in keys:
print(key + '=',end=")
value = curl('http://' + server +
'/load/' + key)
sn = ord(key) % len(to_be_servers)
target_server = to_be_servers[sn]
print(value + ' ' + server +
'->' + target_server)
urls.append('http://' + target_server +
'/save/' + key + '/' + value)
for url in urls:
print(url,end=")
print(' ' + curl(url))
像在server.py和client.py的情况下一样,使用配置图上传脚本:
#!/bin/sh
# configmap.sh
kubectl delete configmap scripts \
--ignore-not-found=true
kubectl create configmap scripts \
--from-file=server.py \
--from-file=client.py --from-file=rebalance.py
如前所述,缩放并不简单,需要小心控制;因此,我们将考虑一个 Pod 清单来执行从三个到两个名为rebalance-down.yaml的副本的缩减迁移:
# rebalance-down.yaml
# Reduce key/store cluster to 2 replicas from 3
apiVersion: v1
kind: Pod
metadata:
name: rebalance
spec:
restartPolicy: Never
containers:
- name: rebalance
image: python:alpine
args:
- bin/sh
- -c
- "python -u /var/scripts/rebalance.py
server-0.server,server-1.server,\
server-2.server
server-0.server,server-1.server"
volumeMounts:
- name: scripts
mountPath: /var/scripts
volumes:
- name: scripts
configMap:
name: scripts
同样,我们还将rebalance-up.yaml定义为从两个副本扩展到三个副本:
# rebalance-up.yaml
# Scale key/store cluster to 3 replicas from 2
apiVersion: v1
kind: Pod
metadata:
name: rebalance
spec:
restartPolicy: Never
containers:
- name: rebalance
image: python:alpine
args:
- bin/sh
- -c
- "python -u /var/scripts/rebalance.py
server-0.server,server-1.server
server-0.server,server-1.server,\
server-2.server"
volumeMounts:
- name: scripts
mountPath: /var/scripts
volumes:
- name: scripts
configMap:
name: scripts
现在,我们已经定义了重新平衡脚本和清单来扩展和缩减我们的集群,我们可以清理环境并再次部署服务器和客户端,这样我们就不会受到 Kubernetes 集群中以前示例的干扰:
# clean up the environment first
$ ./configmap.sh
configmap/scripts created
$ kubectl apply -f server.yaml
service/server created
statefulset.apps/server created
$ kubectl apply -f client.yaml
pod/client created
分频
在上一节中,我们已经讨论了这样一个事实,即伸缩并不是微不足道的,直截了当地发出一个kubectl scale命令可能会导致不必要的中断。在这一节中,我们将看到如何以有序的方式缩减我们的原始键/值存储。
我们将经历以下步骤:
-
以只读模式运行新的目标客户端(以两个副本为目标),以便我们可以观察迁移的结果。
-
停止读/写三副本客户端窗格。
-
将键/值对从三个 Pod 集群迁移到两个 Pod 集群。
-
将三个副本的集群缩减为两个副本。
让我们从定义一个名为client-ro-2.yaml的 Pod 清单开始,以只读模式运行仅针对server-0和server-1的client.py:
# client-ro-2.yaml
apiVersion: v1
kind: Pod
metadata:
name: client-ro-2
spec:
restartPolicy: Never
containers:
- name: client-ro-2
image: python:alpine
args:
- bin/sh
- -c
- >
python -u /var/scripts/client.py
server-0.server,server-1.server readonly
volumeMounts:
- name: scripts
mountPath: /var/scripts
volumes:
- name: scripts
configMap:
name: scripts
现在让我们应用它并遵循它的日志:
$ kubectl apply -f client-ro-2.yaml
pod/client-ro-2 created
$ kubectl logs -f client-ro-2
abcdefghijklmnopqrstuvwxyz
--------------------------
10101010101010101010101010
--------------------------
33:33 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
33:35 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
33:37 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
请注意,在实际场景中,在重新分区完成后,我们将只运行针对新的较小 StatefulSet 的客户端。然而,在这里我们可以更快地实时观察迁移的效果。还要注意,现在只包括了服务器1和0,大多数键查找都会导致未命中。
现在是微妙的部分,读取密钥并重新计算较小的双副本集群的新散列,这是reblance-down.yaml清单的工作,它又执行rebalance.py。在我们这样做之前,我们必须首先阻止客户端 Pod 对即将弃用的*、*三副本集群执行写入操作:
$ kubectl delete --grace-period=1 pod/client
pod "client" deleted
$ kubectl apply -f rebalance-down.yaml pod/rebalance created
$ kubectl logs -f rebalance
server-0.server:
['x', 'u', 'r', 'o', 'l', 'i', 'f', 'c']
x=x server-0.server->server-0.server
u=u server-0.server->server-1.server
...
server-1.server:
['y', 'v', 's', 'p', 'm', 'j', 'g', 'd', 'a']
y=y server-1.server->server-1.server
v=v server-1.server->server-0.server
...
server-2.server:
['z', 'w', 't', 'q', 'n', 'k', 'h', 'e', 'b']
z=z server-2.server->server-0.server
w=w server-2.server->server-1.server
...
到pod/rebalance完成时,运行kubectl log -f client-ro-2的窗口将显示整个键集的点击次数:
28:45 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
28:47 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
28:49 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
28:51 hmhhhhhhhhhhhhhhhhhhhhhhhh | hits = 25 (96%)
28:53 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
28:55 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
28:57 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
此时,我们可以将群集缩减为两个副本,之后,可以针对现在更小的双副本群集以读/写模式启动客户端:
$ kubectl scale statefulset/server --replicas=2
statefulset.apps/server scaled
按比例放大
在上一部分中,我们刚刚将我们的群集从三个副本缩减到两个副本。为了观察正在进行的扩展操作,我们将以与之前类似的方式工作,首先启动一个只读客户端,目标是在client-ro-3.yaml中定义的三个副本:
# client-ro-3.yaml
apiVersion: v1
kind: Pod
metadata:
name: client-ro-3
spec:
restartPolicy: Never
containers:
- name: client-ro-3
image: python:alpine
args:
- bin/sh
- -c
- "python -u /var/scripts/client.py
server-0.server,server-1.server,\
server-2.server readonly"
volumeMounts:
- name: scripts
mountPath: /var/scripts
volumes:
- name: scripts
configMap:
name: scripts
当我们运行这个客户端时,我们期望在server-2上看到一个失败,用.(点)表示。这正是我们所期待的,因为server-2还没有运行:
$ kubectl apply -f client-ro-3.yaml
pod/client-ro-3 created
$ kubectl logs -f client-ro-3
abcdefghijklmnopqrstuvwxyz
--------------------------
12012012012012012012012012
--------------------------
43:57 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
43:59 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
44:01 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
44:03 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
由于启用了写功能的客户端不需要知道新的副本(client.yaml不被认为正在运行),因此可以安全地将群集扩展到三个副本,而无需更多操作:
$ kubectl scale statefulset/server --replicas=3
statefulset.apps/server scaled
紧接着,我们应该在运行kubectl logs -f client-ro-3的窗口上观察到由.(点表示的服务器故障变成由字母m表示的未命中:
...
49:54 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
49:56 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
49:58 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
50:00 hmhhmhhmhhmhhmhhmhhmhhmhhm | hits = 17 (65%)
50:02 hmhhmhhmhhmhhmhhmhhmhhmhhm | hits = 17 (65%)
50:06 hmhhmhhmhhmhhmhhmhhmhhmhhm | hits = 17 (65%)
...
到目前为止,一切顺利;我们现在可以通过应用rebalance-up.yaml将键/值对重新划分到一个三副本集群中:
$ kubectl delete pod/rebalance
pod "rebalance" deleted
$ kubectl apply -f rebalance-up.yaml
pod/rebalance created
每当我们跟踪client-ro-3日志的终端窗口显示未命中变成命中时,我们就成功地扩大了集群:
53:24 hmhhmhhmhhmhhmhhmhhmhhmhhm | hits = 17 (65%)
53:26 hmhhmhhmhhmhhmhhmhhhhhmhhh | hits = 19 (73%)
53:28 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
53:30 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
53:32 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
此时,针对三个副本的群集启用读/写客户端是安全的。
注意
Kubernetes 将 StatefulSet 的 pod 称为副本,因为我们在使用kubectl scale命令时使用了replicas属性和副本语义。但是,StatefulSet 的副本不一定是一字不差的无状态副本,因为它们通常是部署的副本。从逻辑的角度来看,假设我们使用每个 StatefulSet 的 Pod 实例来存储数据集的一个子集,那么将它们视为“分区”会有所帮助
关于扩大和缩小业务规模的结论
向上和向下扩展操作以一种原始的、相当手工的方式演示了每当集群大小改变时重新散列键的问题,并且数据块必须被重新安排到不同数量的服务器中。
大多数高级的现成后备存储(如 MongoDB)通过实现异步复制算法,使用户免受我们刚刚看到的那种手动操作。那何必呢?因为尽管我们原始的键/值存储可能过于简单,但它有助于直观地了解现成解决方案所采取的权衡,以提供几乎零停机扩展的假象。
在这方面,我们的原始键/值存储是可伸缩的,但不是高度可用的,这不是因为数据存储在内存中(我们将在本章结束之前解决这一问题),而是因为单个副本故障会导致数据不可访问。例如,Cassandra 不仅可以使用与本文中使用的哈希方案类似的方法进行扩展,而且还具有很高的可用性:可以对其进行配置,使得相同的数据在被认为“持久化”之前被写入两个或更多的节点。
潜在高度可用(除了高度可伸缩之外)的后备存储带来的复杂性是,它们带来了处理最终一致性的挑战。例如,我们可以很容易地修改我们的客户机,以便使用一个复制方案(如( totalReplicaCount + 1) % totalReplicaCount)将一个密钥保存到两个或多个副本中。然而,每当读取两个键并且它们的值不同时,客户端需要调用哪个键。在我们的原始键/值存储的情况下,我们可以很容易地修改它,以提供一个时间戳,这样客户端就可以将最近的一个作为有效的。
正确的有状态性:磁盘持久性
到目前为止,我们一直将键/值存储视为内存中的缓存,一旦副本打喷嚏,它就会丢失数据。这是故意的,因为我们到目前为止讨论的所有状态属性都与磁盘持久性相对正交:
-
稳定的网络身份
-
有序 Pod 创建
-
服务发现和无头服务
-
扩展策略
此外,我们决定通过简单地替换卷data的底层实现来区分 RAM 和磁盘存储。到目前为止,所有示例都依赖于基于 RAM 的文件系统,如下所示:
# server.yaml
...
volumes:
- name: data
emptyDir:
medium: Memory
降低磁盘持久性的途径是使用hostPath卷类型。hostPath卷类型允许直接在节点的文件系统上存储数据;然而,这种方法是有问题的,因为它只有在 Pods 总是被安排在相同的节点上运行时才有效。另一个问题是,节点级存储并不意味着是持久的:即使 Pods 被调度到相同的节点,节点的文件系统也不能保证在节点崩溃或重启后仍然存在。
读者可能已经猜到,我们需要的是附加网络存储,它的生命周期独立于 Kubernetes 工作节点的生命周期。在 GCP,这只是创建一个“持久磁盘”的问题,只需发出一条命令:
$ gcloud compute disks create my-disk --size=1GB
Created
NAME ZONE SIZE_GB TYPE STATUS
my-disk europe-west2-a 1 pd-standard READY
然后,我们可以将data卷与 StatefulSet 清单中名为my-disk的 GCP 磁盘相关联:
# server.yaml
...
volumes:
- name: data
gcePersistentDisk:
pdName: my-disk
fsType: ext4
上述方法的局限性在于只有一个 Pod 可以对my-disk进行读/写访问。所有其他窗格可能只有只读访问权限。如果我们通过修改server.yaml中的volumes声明来尝试上述方法,我们将看到只有server-0会成功启动,而server-1会失败(因此server-2不会被调度)。这是因为只有一个 Pod ( server-0)可以对永久磁盘my-disk进行读/写访问。
我们的键/值存储所采用的多主机方案要求所有副本(以及 pod)具有完全的读/写访问权限。此外,可伸缩系统的要点是数据分布在多个磁盘上,而不是存储在由多个服务器访问的单个中央磁盘上。理想情况下,我们需要为每个副本创建一个单独的磁盘。大致如下的东西:
# Example only, don't run these commands
$ gcloud compute disks create my-disk-server-0
$ gcloud compute disks create my-disk-server-1
$ gcloud compute disks create my-disk-server-2
这种方法需要预先规划给定数量的副本所需的磁盘数量。如果 Kubernetes 能够代表我们为每个需要持久存储的 Pod 发出前面的gcloud compute disks create命令(或者使用底层 API)会怎么样?好消息,可以!欢迎来到持续批量索赔。
持续量声明
持久卷声明可以理解为一种机制,允许 Kubernetes 根据每个 Pod 的身份按需创建磁盘,这样,如果一个 Pod 崩溃或被重新调度,每个 Pod 及其关联的卷之间就会保持 1:1 的链接(Kubernetes 行话中的绑定)。无论一个 Pod 在哪个节点上“醒来”,Kubernetes 总是会附加其对应的绑定卷,用于server-0、disk-0;对于server-1、disk-1;等等。
虽然 Kubernetes 可能运行在有多个能够授予卷(如disk-0和disk-1)的存储阵列的环境中,但这些存储阵列也因云供应商而异。例如,在 AWS 中,这种块存储功能被称为亚马逊弹性存储(EBS ),实现方式与 GCP 持久磁盘不同。问题是,Kubernetes 怎么知道向谁要一卷呢?嗯,存储阵列(或等效物)功能在 Kubernetes 中体现为一个名为存储类的对象。
针对给定的存储类执行持久卷声明。Google Kubernetes 引擎(GKE)提供了一个名为standard的现成存储类:
$ kubectl get storageclass
NAME PROVISIONER AGE
standard (default) kubernetes.io/gce-pd 40m
$ kubectl describe storageclass/standard
Name: standard
IsDefaultClass: Yes
Annotations: storageclass.*.kubernetes.io/*
Provisioner: kubernetes.io/gce-pd
Parameters: type=pd-standard
AllowVolumeExpansion: <unset>
MountOptions: <none>
ReclaimPolicy: Delete
VolumeBindingMode: Immediate
Events: <none>
除非另有说明,否则每当按需创建 GKE 磁盘(Kubernetes 中的卷)时,就会使用standard存储类。StorageClass 有一种驱动程序,它允许 Kubernetes 编排磁盘(或一般的块设备)的创建,而不需要管理员向外部存储接口(如gcloud compute disk create)发出手动命令。
让我们的 StatefulSet 清单请求磁盘到标准的 StorageClass 只需要很少的额外代码。这是在statefulset.spec下创建以下条目的问题:
# server-disk.yaml
...
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
在这个清单片段中,我们将卷命名为data,请求一个 1GB 的永久磁盘,并将访问模式设置为ReadWriteOnce,这意味着没有其他副本会对其提供的卷进行独占读/写访问。
如前所述,持久卷声明可以理解为一种代表我们使用云提供商的存储接口(在我们的例子中是 GCP)创建磁盘的机制。现在让我们看看这确实是真的。我们将修改原始的server.yaml文件以包含上面的持久卷声明,并删除旧的emptyDir卷定义。新生成的文件被称为server-disk.yaml。让我们再次清理我们的环境,启动我们新定义的基于磁盘的服务器:
# clean up the environment first
$ ./configmap.sh
configmap "scripts" deleted
configmap/scripts created
$ kubectl apply -f server-disk.yaml
service/server created
statefulset.apps/server created
几秒钟后,我们可以通过运行kubectl get pv检查是否已经创建了三个卷(每个副本一个)。请注意,为了简洁起见,已经删除和/或简化了一些细节:
$ kubectl get pv
NAME CAP STATUS CLAIM
pvc-42339fcc-* 1Gi Bound default/data-server-0
pvc-4cb81b93-* 1Gi Bound default/data-server-1
pvc-59792e64-* 1Gi Bound default/data-server-2
我们现在还可以看到,GCP 将相同的卷视为正确的 Google Cloud 持久磁盘,就好像我们手动创建了它们一样:
$ gcloud compute disks list
...
gke-my-cluster-f8fca-pvc-42339fcc-*
gke-my-cluster-f8fca-pvc-4cb81b93-*
gke-my-cluster-f8fca-pvc-59792e64-*
我们现在不仅有三个独立的卷,每个卷对应一个服务器(server-0、server-1、server-2),而且我们还有一个自称为一流 Kubernetes 公民的卷:
$ kubectl get pvc
NAME STATUS VOLUME CAP MODE
data-server-0 Bound pvc-42339fcc-* 1Gi RWO
data-server-1 Bound pvc-4cb81b93-* 1Gi RWO
data-server-2 Bound pvc-59792e64-* 1Gi RWO
持久卷声明的关键特征是 pod 的生命周期独立于卷的生命周期。换句话说,我们可以删除 pod,让它们崩溃,扩展 stateful set——甚至残酷地删除它。无论发生什么情况,与每个 Pod 关联的卷都将被重新连接。让我们启动我们的测试客户端,这样我们就可以证明事实确实如此:
$ kubectl apply -f client.yaml
pod/client created
$ kubectl logs -f client
abcdefghijklmnopqrstuvwxyz
--------------------------
12012012012012012012012012
--------------------------
53:25 wwwwwwwwwwwwwwwwwwwwwwwwww | hits = 0 (0%)
53:27 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
53:29 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
53:31 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
在第二次25之前,密钥已经被写入三个复制品一次。既然密钥已经由适当的持久性存储备份,我们应该会看到服务器故障,但不会再出现写操作(字母w)。我们将从发出kubectl delete pod/server-2命令删除server-2开始,看看这是否确实是真的:
57:04 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
57:06 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:08 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:10 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:12 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:15 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:17 h.hhhhhhhhhhhhhhhhhhhhhhhh | hits = 25 (96%)
58:25 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
这里我们看到在57:06和57:15之间,server-2正在关闭(数字five代表 HTTP 错误503)。然后在57:17服务器变得不可访问一段时间,然后再次恢复在线。请注意,既没有m(未命中)也没有w(写入)字母,因为server-2从未丢失任何数据。
现在让我们做一些更激进的事情,通过发出kubectl delete statefulset/server命令删除 StatefulSet 本身:
26:03 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
26:06 55555555555555555555555555 | hits = 0 (0%)
26:08 55555555555555555555555555 | hits = 0 (0%)
26:10 55555555555555555555555555 | hits = 0 (0%)
26:12 55555555555555555555555555 | hits = 0 (0%)
26:14 55555555555555555555555555 | hits = 0 (0%)
26:16 .......................... | hits = 0 (0%)
27:25 .......................... | hits = 0 (0%)
27:27 .......................... | hits = 0 (0%)
请注意,所有服务器都进入关闭模式,然后变得不可访问,如.(点)所示。在我们确认所有的 pod 都已终止后,我们通过发出kubectl apply -f server-disk.yaml命令再次启动 StatefulSet:
28:05 .......................... | hits = 0 (0%)
28:07 .......................... | hits = 0 (0%)
28:10 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:12 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:14 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:16 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:18 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:20 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:22 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:25 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:27 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:29 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:31 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
28:33 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
28:35 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
在这里,我们看到服务器逐渐联机,当它们联机时,客户端注册h (hit ),这意味着密钥已被成功检索,无需再次写入。请注意,在本例中,我们排除了由于部分写入、文件句柄未关闭或其他应用级故障导致磁盘上文件损坏的可能性。
摘要
在本章中,我们使用 StatefulSets 从头实现了一个键/值数据存储支持服务,帮助我们观察这种控制器类型保证的关键属性,如顺序 Pod 创建和稳定的网络身份。后者,稳定的网络身份是通过使用无头服务和一致的持久性来公开 pod 的基础,这两个特性也在本章中讨论。
我们还研究了 Pod 生命周期事件及其在集群设置和管理伸缩事件(以及首次启动有状态集群)中的相关性。对于读者来说,当考虑数据分区和复制方面时,即席缩放(使用kubectl scale)命令是困难的;秤事件前后通常都需要额外的步骤,如运行脚本和/或管理程序。*