pod 常用设置和配置

  |  

全部的 K8S学习笔记总目录,请点击查看。

以下主要是介绍数据持久化、健康检查、重启策略、镜像拉取策略、资源限制等常用设置、 confingmap&secret 以及 Pod 生命周期的配置的使用。

本节所有的内容都基于假设我们创建了一个mysql的数据库以及一个基于 Django 的 web 服务的 Pod 之后的操作。

Pod 数据持久化

如果我们因为各种原因删除了 Pod,由于 mysql 的数据都在容器内部,会造成数据丢失,因此需要数据进行持久化,数据持久化的数据会保存在对应的硬盘上,不随容器的关闭而删除。

使用 hostpath 挂载

因为保存在对应的宿主机上,所以需要使用 nodeSelector 定点,下面是 pod-with-volume.yaml 的文件内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
apiVersion: v1
kind: Pod
metadata:
name: myblog
namespace: test
labels:
component: myblog
spec:
# 声明挂载的卷
volumes:
- name: mysql-data
hostPath:
path: /opt/mysql/data
# 使用节点选择器将 Pod 调度到指定label的节点
nodeSelector:
component: mysql
containers:
- name: myblog-api
image: harbor.mydomain.com/test/myblog:v1
env:
- name: MYSQL_HOST
value: "127.0.0.1"
- name: MYSQL_PASSWD
value: "123456"
ports:
- containerPort: 8002
- name: mysql
image: mysql:5.7
args:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
value: "123456"
- name: MYSQL_DATABASE
value: "myblog"
volumeMounts:
# 使用上面声明的卷挂载到容器内部
- name: mysql-data
# 挂载到容器内部的路径
mountPath: /var/lib/mysql

有了上面的文件就可以进行创建了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 若存在旧的同名服务,先删除掉
$ kubectl -n test delete pod myblog
## 创建 Pod
$ kubectl create -f pod-with-volume.yaml

# 此时 Pod 状态Pending
$ kubectl -n test get po
NAME READY STATUS RESTARTS AGE
myblog 0/2 Pending 0 32s

# 查看原因,提示调度失败,因为节点不满足node selector
$ kubectl -n test describe po myblog
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 12s (x2 over 12s) default-scheduler 0/3 nodes are available: 3 node(s) didn't match node selector.

# 为节点打标签
$ kubectl label node k8s-node1 component=mysql

# 再次查看,已经运行成功
$ kubectl -n test get po
NAME READY STATUS RESTARTS AGE IP NODE
myblog 2/2 Running 0 3m54s 10.244.1.150 k8s-node1

# 到 k8s-node1 节点,查看 /opt/mysql/data
$ ll /opt/mysql/data/
total 188484
-rw-r----- 1 polkitd input 56 Mar 29 09:20 auto.cnf
-rw------- 1 polkitd input 1676 Mar 29 09:20 ca-key.pem
-rw-r--r-- 1 polkitd input 1112 Mar 29 09:20 ca.pem
drwxr-x--- 2 polkitd input 8192 Mar 29 09:20 sys
...

# 执行 migrate,创建数据库表,然后删掉 Pod,再次创建后验证数据是否存在
$ kubectl -n test exec -ti myblog python3 manage.py migrate

# 访问服务,正常
$ curl 10.244.1.150:8002/blog/index/

# 删除 Pod
$ kubectl delete -f pod-with-volume.yaml

# 再次创建 Pod
$ kubectl create -f pod-with-volume.yaml

# 查看 Pod ip并访问服务
$ kubectl -n test get po -o wide
NAME READY STATUS RESTARTS AGE IP NODE
myblog 2/2 Running 0 7s 10.244.1.151 k8s-node1

# 未重新做migrate,服务正常
$ curl 10.244.1.151:8002/blog/index/

使用 PV+PVC连接分布式存储解决方案

hostpath 方式只能将 Pod 固定在特定的机器上,但是 k8s 支持网络存储,可以将数据存储在网络存储上,这样就可以实现 Pod 的迁移,下面是支持 PV+PVC 的协议方式。

  • ceph
  • glusterfs
  • nfs

具体关于 PV+PVC 的用法会在之后介绍,具体目录可以参考 k8s 学习笔记

服务健康检查

检测容器服务是否健康的手段,若不健康,会根据设置的重启策略(restartPolicy)进行操作,三种检测机制可以分别单独设置,若不设置,默认认为 Pod 是健康的。

以下是两种机制的介绍。

LivenessProbe 探针 (存活探针)

存活性探测:用于判断容器是否存活,即 Pod 是否为 running 状态,如果 LivenessProbe 探针探测到容器不健康,则 kubelet 将 kill 掉容器,并根据容器的重启策略是否重启,如果一个容器不包含 LivenessProbe 探针,则 Kubelet 认为容器的 LivenessProbe 探针的返回值永远成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
...
containers:
- name: myblog
image: harbor.mydomain.com/test/myblog:v1
livenessProbe:
httpGet:
path: /blog/index/
port: 8002
scheme: HTTP
initialDelaySeconds: 10 # 容器启动后第一次执行探测是需要等待多少秒
periodSeconds: 10 # 执行探测的频率
timeoutSeconds: 2 # 探测超时时间
...

可配置的参数如下:

  1. initialDelaySeconds:容器启动后第一次执行探测是需要等待多少秒。
  2. periodSeconds:执行探测的频率。默认是10秒,最小1秒。
  3. timeoutSeconds:探测超时时间。默认1秒,最小1秒。
  4. successThreshold:探测失败后,最少连续探测成功多少次才被认定为成功。默认是1。
  5. failureThreshold:探测成功后,最少连续探测失败多少次。默认是3,最小值是1。

上面例子配置的情况,健康检查的逻辑为:K8S 将在 Pod 开始启动 20s(initialDelaySeconds) 后探测 Pod 内的 8000 端口是否可以建立 TCP 连接,并且每 15 秒钟探测一次,如果连续 3 次探测失败,则 kubelet 重启该容器

ReadinessProbe 探针 (就绪探针)

可用性探测:用于判断容器是否正常提供服务,即容器的 Ready 是否为 True,是否可以接收请求,如果 ReadinessProbe 探测失败,则容器的 Ready 将为 False, Endpoint Controller 控制器将此 Pod 的 Endpoint 从对应的 service 的 Endpoint 列表中移除,不再将任何请求调度此 Pod 上,直到下次探测成功。(剔除此 Pod 不参与接收请求不会将流量转发给此 Pod)。

1
2
3
4
5
6
7
8
9
10
11
12
13
...
containers:
- name: myblog
image: harbor.mydomain.com/test/myblog:v1
readinessProbe:
httpGet:
path: /blog/index/
port: 8002
scheme: HTTP
initialDelaySeconds: 10
timeoutSeconds: 2
periodSeconds: 10
...

上面例子配置的情况,健康检查的逻辑为:K8S 将在 Pod 开始启动 10s(initialDelaySeconds) 后利用 HTTP 访问 8002 端口的 /blog/index/,如果超过 2s 或者返回码不在 200~399 内,则健康检查失败

StartupProbe 探针 (启动探针)

启动探针:kubelet 使用 startup probe 来指示容器中的应用是否已经启动。如果提供了启动探针,则所有其他探针都会被禁用,直到该探针成功为止。此探针主要用于判断容器是否已经启动完成,如果 StartupProbe 探针探测失败,则 kubelet 将 kill 掉容器,并根据容器的重启策略是否重启,如果一个容器不包含 StartupProbe 探针,则 Kubelet 认为容器的 StartupProbe 探针的返回值永远成功。

如果你的容器需要在启动期间加载大型数据、配置文件等操作,那么这个时候我们可以使用启动探针。

该探针在 Kubernetes v1.20 版本才变成稳定状态,对于所包含的容器需要较长时间才能启动就绪的 Pod 而言,启动探针是有用的。你不再需要配置一个较长的存活态探测时间间隔,只需要设置另一个独立的配置选项,对启动期间的容器执行探测,从而允许使用远远超出存活态时间间隔所允许的时长。

如果你的容器启动时间通常超出 initialDelaySeconds + failureThreshold × periodSeconds 总值,你应该设置一个启动探针,对存活态探针所使用的同一端点执行检查。 periodSeconds 的默认值是 10 秒,还应该将其failureThreshold 设置得足够高,以便容器有充足的时间完成启动,并且避免更改存活态探针所使用的默认值。 这一设置有助于减少死锁状况的发生。

1
2
3
4
5
6
7
8
9
10
11
12
...
containers:
- name: myblog
image: harbor.mydomain.com/test/myblog:v1
startupProbe:
httpGet:
path: /blog/index/
port: 8002
scheme: HTTP
failureThreshold: 30 # 尽量设置大一点,避免死锁
periodSeconds: 10
...

检查方式

probe 是由 kubelet 对容器执行的定期诊断,要执行诊断,kubelet 既可以在容器内执行代码,也可以发出一个网络请求。使用探针来检查容器有四种不同的方法。每个探针都必须准确定义为这四种机制中的一种:

  • exec:通过执行命令来检查服务是否正常,返回值为0则表示容器健康

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ...
    livenessProbe:
    exec:
    command:
    - cat
    - /tmp/healthy
    initialDelaySeconds: 5
    periodSeconds: 5
    ...
  • httpGet方式:通过发送http请求检查服务是否正常,返回200-399状态码则表明容器健康,如上面的例子

  • tcpSocket:通过容器的IP和Port执行TCP检查,如果能够建立TCP连接,则表明容器健康。实际上就是检查容器的端口。

    1
    2
    3
    4
    5
    6
    7
    8
    ...
    livenessProbe:
    tcpSocket:
    port: 8002
    initialDelaySeconds: 10 # 容器启动后第一次执行探测是需要等待多少秒
    periodSeconds: 10 # 执行探测的频率
    timeoutSeconds: 2 # 探测超时时间
    ...
  • grpc:使用 gRPC 执行一个远程过程调用,目标应该实现 gRPC 健康检查。如果响应的状态是 SERVING,则认为诊断成功。不过需要注意 gRPC 探针是一个 Alpha 特性,只有在启用了 GRPCContainerProbe 特性门户时才可用。

    1
    2
    3
    4
    5
    ...
    livenessProbe:
    grpc:
    port: 8002
    ...

另外,对于 HTTP 和 TCP 存活检测可以使用命名的 port

1
2
3
4
5
6
7
8
9
10
11
ports:
- name: liveness-port
containerPort: 8002
hostPort: 8002
protocol: TCP

livenessProbe:
httpGet:
path: /blog/index/
port: liveness-port
scheme: HTTP

每次探测都将获得以下三种结果之一:

  • Success(成功):容器通过了诊断。
  • Failure(失败):容器未通过诊断。
  • Unknown(未知):诊断失败,因此不会采取任何行动。

不过需要注意应尽量避免使用 TCP 探测,因为 TCP 探测实际就是 kubelet 向指定端口发送 TCP SYN 握手包,当端口被监听内核就会直接响应 ACK,探测就会成功。当程序死锁或 hang 住的情况,这些并不影响端口监听,所以探测结果还是健康,流量打到表面健康但实际不健康的 Pod 上,就无法处理请求,从而引发业务故障。

健康检查示例

比如上面的 pod-with-volume.yaml 文件,我们改造一下,添加上面的探针配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
apiVersion: v1
kind: Pod
metadata:
name: myblog
namespace: test
labels:
component: myblog
spec:
# 声明挂载的卷
volumes:
- name: mysql-data
hostPath:
path: /opt/mysql/data
# 使用节点选择器将 Pod 调度到指定label的节点
nodeSelector:
component: mysql
containers:
- name: myblog-api
image: harbor.mydomain.com/test/myblog:v1
env:
- name: MYSQL_HOST
value: "127.0.0.1"
- name: MYSQL_PASSWD
value: "123456"
ports:
- containerPort: 8002
livenessProbe:
httpGet:
path: /blog/index/
port: 8002
scheme: HTTP
initialDelaySeconds: 10 # 容器启动后第一次执行探测是需要等待多少秒
periodSeconds: 10 # 执行探测的频率
timeoutSeconds: 2 # 探测超时时间
readinessProbe:
httpGet:
path: /blog/index/
port: 8002
scheme: HTTP
initialDelaySeconds: 10
timeoutSeconds: 2
periodSeconds: 10
- name: mysql
image: mysql:5.7
args:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
value: "123456"
- name: MYSQL_DATABASE
value: "myblog"
volumeMounts:
# 使用上面声明的卷挂载到容器内部
- name: mysql-data
# 挂载到容器内部的路径
mountPath: /var/lib/mysql

上面的文件已经给 myblog-api 添加了探针配置, K8S 将在 Pod 开始启动 10s(initialDelaySeconds)后利用 HTTP 访问 8002 端口的 /blog/index/ ,如果超过2s或者返回码不在 200~399 内,则健康检查失败

Readiness 决定了 Service 是否将流量导入到该 Pod,Liveness 决定了容器是否需要被重启

重启策略

Pod 的重启策略(RestartPolicy)应用于 Pod 内的所有容器,并且仅在 Pod 所处的 Node 上由 kubelet 进行判断和重启操作。当某个容器异常退出或者健康检查失败时,kubelet 将根据 RestartPolicy 的设置来进行相应的操作。

Pod 的重启策略包括 Always、OnFailure 和 Never,默认值为 Always。

  • Always:当容器进程退出后,由kubelet自动重启该容器;
  • OnFailure:当容器终止运行且退出码不为0时,由kubelet自动重启该容器;
  • Never:不论容器运行状态如何,kubelet都不会重启该容器。

演示重启策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Pod
metadata:
name: test-restart-policy
spec:
restartPolicy: Always
containers:
- name: busybox
image: busybox
args:
- /bin/sh
- -c
- sleep 10
  1. 使用默认的重启策略,即 restartPolicy: Always ,无论容器是否是正常退出,都会自动重启容器
  2. 使用OnFailure的策略时
    • 如果把exit 1,去掉,即让容器的进程正常退出的话,则不会重启
    • 只有非正常退出状态才会重启
  3. 使用Never时,退出了就不再重启

可以看出,若容器正常退出,Pod 的状态会是Completed,非正常退出,状态为CrashLoopBackOff

镜像拉取策略

1
2
3
4
5
spec:
containers:
- name: myblog
image: harbor.mydomain.com/test/myblog:v1
imagePullPolicy: IfNotPresent

设置镜像的拉取策略,默认为IfNotPresent

  • Always,总是拉取镜像,即使本地有镜像也从仓库拉取
  • IfNotPresent ,本地有则使用本地镜像,本地没有则去仓库拉取
  • Never,只使用本地镜像,本地没有则报错

Pod 资源限制

为了保证充分利用集群资源,且确保重要容器在运行周期内能够分配到足够的资源稳定运行,因此平台需要具备 Pod 的资源限制的能力。 对于一个 Pod 来说,资源最基础的2个的指标就是:CPU和内存。

Kubernetes 提供了个采用 requests 和 limits 两种类型参数对资源进行预分配和使用限制。

将最开始的文件加上目前的所有字段,完整文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
apiVersion: v1
kind: Pod
metadata:
name: myblog
namespace: test
labels:
component: myblog
spec:
# 重启策略
restartPolicy: Always
volumes:
- name: mysql-data
hostPath:
path: /opt/mysql/data
nodeSelector:
component: mysql
containers:
- name: myblog-api
image: harbor.mydomain.com/test/myblog:v1
# 镜像拉取策略
imagePullPolicy: IfNotPresent
env:
- name: MYSQL_HOST
value: "127.0.0.1"
- name: MYSQL_PASSWD
value: "123456"
ports:
- containerPort: 8002
livenessProbe:
httpGet:
path: /blog/index/
port: 8002
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /blog/index/
port: 8002
scheme: HTTP
initialDelaySeconds: 10
timeoutSeconds: 2
periodSeconds: 10
# 资源限制
resources:
requests:
cpu: 100m
memory: 100Mi
limits:
cpu: 200m
memory: 200Mi

- name: mysql
image: mysql:5.7
args:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
value: "123456"
- name: MYSQL_DATABASE
value: "myblog"
volumeMounts:
# 使用上面声明的卷挂载到容器内部
- name: mysql-data
# 挂载到容器内部的路径
mountPath: /var/lib/mysql
  • requests:

    • 容器使用的最小资源需求,作用于 schedule 阶段,作为容器调度时资源分配的判断依赖
    • 只有当前节点上可分配的资源量 >= request 时才允许将容器调度到该节点
    • request 参数不限制容器的最大可使用资源
    • requests.cpu 被转成 docker 的 –cpu-shares 参数,与 cgroup cpu.shares 功能相同 (无论宿主机有多少个 cpu 或者内核,–cpu-shares 选项都会按照比例分配 cpu 资源)
    • requests.memory 没有对应的 docker 参数,仅作为 k8s 调度依据
  • limits:

    • 容器能使用资源的最大值
    • 设置为 0 表示对使用的资源不做限制, 可无限的使用
    • 当 Pod 内存超过 limit 时,会被 oom
    • 当 cpu 超过 limit 时,不会被 kill,但是会限制不超过 limit 值
    • limits.cpu 会被转换成 docker 的 –cpu-quota 参数。与 cgroup cpu.cfs_quota_us 功能相同
    • limits.memory 会被转换成 docker 的 –memory 参数。用来限制容器使用的最大内存

对于 CPU,我们知道计算机里 CPU 的资源是按 “时间片” 的方式来进行分配的,系统里的每一个操作都需要 CPU 的处理,所以,哪个任务要是申请的 CPU 时间片越多,那么它得到的 CPU 资源就越多。

然后还需要了解下 CGroup 里面对于 CPU 资源的单位换算:

1
1 CPU =  1000 millicpu(1 Core = 1000m)

这里的 m 就是毫、毫核的意思,Kubernetes 集群中的每一个节点可以通过操作系统的命令来确认本节点的 CPU 内核数量,然后将这个数量乘以1000,得到的就是节点总 CPU 总毫数。比如一个节点有四核,那么该节点的 CPU 总毫量为 4000m。

docker run命令和 CPU 限制相关的所有选项如下:

选项 描述
--cpuset-cpus="" 允许使用的 CPU 集,值可以为 0-3,0,1
-c,--cpu-shares=0 CPU 共享权值(相对权重)
cpu-period=0 限制 CPU CFS 的周期,范围从 100ms~1s,即[1000, 1000000]
--cpu-quota=0 限制 CPU CFS 配额,必须不小于1ms,即 >= 1000,绝对限制

举例如下:

1
docker run -it --cpu-period=50000 --cpu-quota=25000 ubuntu:16.04 /bin/bash

将 CFS 调度的周期设为 50000,将容器在每个周期内的 CPU 配额设置为 25000,表示该容器每 50ms 可以得到 50% 的 CPU 运行时间。

注意:若内存使用超出限制,会引发系统的 OOM 机制,因 CPU 是可压缩资源,不会引发 Pod 退出或重建

配置优化

yaml 的环境变量中有很多敏感的信息,比如账号密码,直接暴漏在 yaml 文件中存在安全性问题。对于开发、测试、生产环境,由于配置均不同,每套环境部署的时候都要修改yaml,带来额外的开销。

k8s提供了两类资源,configMapSecret,可以用来实现业务配置的统一管理, 允许将配置文件与镜像文件分离,以使容器化的应用程序具有可移植性 。

configMap

通常用来管理应用的配置文件或者环境变量

1
2
3
4
5
6
7
8
apiVersion: v1
kind: ConfigMap
metadata:
name: myblog
namespace: test
data:
MYSQL_HOST: "127.0.0.1"
MYSQL_DATABASE: "myblog"

创建并查看configmap:

1
2
$ kubectl create -f configmap.yaml
$ kubectl -n test get configmap myblog -oyaml

或者可以使用命令的方式,从文件中创建,比如:

1
2
3
4
5
$ cat env-configs.txt
MYSQL_HOST=127.0.0.1
MYSQL_DATABASE=myblog

$ kubectl -n test create configmap myblog --from-env-file=env-configs.txt

Secret

管理敏感类的信息,默认会base64编码存储,有三种类型

  • Service Account :用来访问 Kubernetes API ,由 Kubernetes 自动创建,并且会自动挂载到 Pod 的 /run/secrets/kubernetes.io/serviceaccount 目录中;创建 ServiceAccount 后,Pod 中指定 serviceAccount 后,自动创建该 ServiceAccount 对应的 secret;
  • Opaque : base64编码格式的 Secret,用来存储密码、密钥等;
  • kubernetes.io/dockerconfigjson :用来存储私有docker registry的认证信息。

secret的创建和configmap类似,可以通过文件或者命令的方式创建,比如:

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Secret
metadata:
name: myblog-secret
namespace: test
type: Opaque
data:
# 获取方法,shell下执行:注意加-n参数, echo -n root|base64
# 解析方法,shell下执行:echo cm9vdA==|base64 -d
MYSQL_USER: cm9vdA==
# echo -n 123456|base64
MYSQL_PASSWD: MTIzNDU2

创建并查看secret:

1
2
$ kubectl create -f secret.yaml
$ kubectl -n test get secret myblog-secret -oyaml

或者可以使用命令的方式,从文件中创建,比如:

1
2
3
4
5
6
$ cat env-secret.txt
MYSQL_USER=root
MYSQL_PASSWD=MTIzNDU2

$ kubectl -n test create secret generic myblog-secret --from-env-file=env-secret.txt
$ kubectl -n test get secret myblog-secret -oyaml

使用示例

在配置中使用 configmap 和 secret:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
apiVersion: v1
kind: Pod
metadata:
name: myblog
namespace: test
labels:
component: myblog
spec:
restartPolicy: Always
volumes:
- name: mysql-data
hostPath:
path: /opt/mysql/data
nodeSelector:
component: mysql
containers:
- name: myblog-api
image: harbor.mydomain.com/test/myblog:v1
imagePullPolicy: IfNotPresent
env:
# 环境变量全部使用 configmap 和 secret 中的配置
- name: MYSQL_HOST
valueFrom:
configMapKeyRef:
name: myblog
key: MYSQL_HOST
- name: MYSQL_PASSWD
valueFrom:
secretKeyRef:
name: myblog-secret
key: MYSQL_PASSWD
ports:
- containerPort: 8002
livenessProbe:
httpGet:
path: /blog/index/
port: 8002
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /blog/index/
port: 8002
scheme: HTTP
initialDelaySeconds: 10
timeoutSeconds: 2
periodSeconds: 10
# 资源限制
resources:
requests:
cpu: 100m
memory: 100Mi
limits:
cpu: 200m
memory: 200Mi

- name: mysql
image: mysql:5.7
args:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
ports:
- containerPort: 3306
env:
# 环境变量全部使用 configmap 和 secret 中的配置
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: myblog-secret
key: MYSQL_PASSWD
- name: MYSQL_DATABASE
valueFrom:
configMapKeyRef:
name: myblog
key: MYSQL_DATABASE
volumeMounts:
# 使用上面声明的卷挂载到容器内部
- name: mysql-data
# 挂载到容器内部的路径
mountPath: /var/lib/mysql

使用场景

在部署不同的环境时,Pod 的 yaml 无须再变化,只需要在每套环境中维护一套 ConfigMapSecret 即可。但是注意 configmapsecret 不能跨 namespace 使用,且更新后,Pod 内的 env 不会自动更新,重建后才会更新。

如何编写资源yaml

这里主要说一下如何编写资源yaml,主要是一些技巧。

  1. 从机器中已有的资源中提取yaml

    1
    2
    # 获取系统命名空间的所有资源,根据资源本身的yaml文件,可以进行修改
    $ kubectl -n kube-system -o yaml get po,deployment,ds
  2. 学会在官网查找, https://kubernetes.io/docs/home/

  3. 从kubernetes-api文档中查找, https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.16/#pod-v1-core

  4. kubectl explain 查看具体字段含义

Pod 状态与生命周期

下图展示了一个 Pod 的完整生命周期过程,其中包含 Init ContainerPod Hook健康检查 三个主要部分。

生命周期示意图:

pod-lifecycle-status

启动和关闭示意:

pod-lifecycle-start-stop

Pod 的基本状态如下表所示,这些信息都是集群自动维护的:

状态值 描述
Pending API Server已经创建该 Pod,等待调度器调度
ContainerCreating 拉取镜像启动容器中
Running Pod 内容器均已创建,且至少有一个容器处于运行状态、正在启动状态或正在重启状态
Succeeded|Completed Pod 内所有容器均已成功执行退出,且不再重启
Failed|Error Pod 内所有容器均已退出,但至少有一个容器退出为失败状态
CrashLoopBackOff Pod 内有容器启动失败,比如配置文件丢失导致主进程启动失败
Unknown 由于某种原因无法获取该 Pod 的状态,可能由于网络通信不畅导致

初始化容器

Init Container:顾名思义,用于初始化工作,执行完就结束,可以理解为一次性任务。可以是一个或者多个,如果有多个的话,这些容器会按定义的顺序依次执行。我们知道一个 Pod 里面的所有容器是共享数据卷和 Network Namespace的,所以 Init Container 里面产生的数据可以被主容器使用到。从上面的 Pod 生命周期的图中可以看出初始化容器是独立与主容器之外的,只有所有的初始化容器执行完之后,主容器才会被启动。

含有以下特点:

  • 支持大部分应用容器配置,但不支持健康检查
  • 优先应用容器执行

主要用来实现如下目的:

  • 验证业务应用依赖的组件是否均已启动
  • 修改目录的权限
  • 调整系统参数
  • 初始化应用配置,比如集群的配置信息等
  • 比如将 Pod 注册到注册中心、配置中心等

参考配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
apiVersion: v1
kind: Pod
metadata:
name: pod-init
namespace: test
labels:
component: pod-init
spec:
initContainers:
- name: myblog-api-init
command:
- /sbin/sysctl
- -w
- vm.max_map_count=262144
image: alpine:3.6
imagePullPolicy: IfNotPresent
resources: {}
securityContext:
privileged: true
- name: myblog-api-change-permission
command: ["/bin/sh", "-c", "chown -R 1000:1000 /var/www/html"]
image: alpine:3.6
imagePullPolicy: IfNotPresent
resources: {}
securityContext:
privileged: true
containers:
- name: myblog-api
image: harbor.mydomain.com/test/myblog:v1
imagePullPolicy: IfNotPresent
env:
- name: MYSQL_HOST
valueFrom:
configMapKeyRef:
name: myblog
key: MYSQL_HOST
- name: MYSQL_PASSWD
valueFrom:
secretKeyRef:
name: myblog-secret
key: MYSQL_PASSWD
ports:
- containerPort: 8002
volumeMounts:
- name: workdir
mountPath: /var/www/htm
volumes:
- name: workdir
emptyDir: {} # emptyDir{} 是一个临时的目录,数据会保存在 kubelet 的工作目录下面,生命周期等同于 Pod 的生命周期

Pod Hook

我们知道 Pod 是 Kubernetes 集群中的最小单元,而 Pod 是由容器组成的,所以在讨论 Pod 的生命周期的时候我们可以先来讨论下容器的生命周期。

实际上 Kubernetes 为我们的容器提供了生命周期的钩子,就是我们说的 Pod Hook。Pod Hook 是由 kubelet 发起的,当容器中的进程启动前或者容器中的进程终止之前运行,这是包含在容器的生命周期之中。我们可以同时为 Pod 中的所有容器都配置 hook。

Kubernetes 为我们提供了两种钩子函数:

  • PostStart:Kubernetes 在容器创建后立即发送 postStart 事件,然而 postStart 处理函数的调用不保证早于容器的入口点(entrypoint) 的执行。 postStart 处理函数与容器的代码是异步执行的,但 Kubernetes的容器管理逻辑会一直阻塞等待 postStart 处理函数执行完毕。只有 postStart 处理函数执行完毕,容器的状态才会变成 RUNNING 。
  • PreStop:这个钩子在容器终止之前立即被调用。它是阻塞的,所以它必须在删除容器的调用发出之前完成。主要用于优雅关闭应用程序、通知其他系统等。

PostStart用的不是很多,而PreStop用的相对很多.
Kubernetes 只有在 Pod 结束(Terminated)的时候才会发送 preStop 事件,这意味着在 Pod 完成(Completed)时 preStop 的事件处理逻辑不会被触发

验证Pod生命周期

编写以下 yaml 文件,包括 initContainer、lifecycle、readinessProbe、livenessProbe、preStop、postStart的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
apiVersion: v1
kind: Pod
metadata:
name: pod-lifecycle
namespace: test
labels:
component: pod-lifecycle
spec:
initContainers:
- name: init
image: busybox
command: ['sh', '-c', 'echo $(date +%s): INIT >> /record/record.txt']
volumeMounts:
- name: record
mountPath: /record/record.txt
containers:
- name: main
image: busybox
command: ['sh', '-c', 'echo $(date +%s): START >> /record/record.txt; sleep 10; echo $(date +%s): END >> /record/record.txt;']
volumeMounts:
- name: record
mountPath: /record/record.txt
livenessProbe:
exec:
command: ['sh', '-c', 'echo $(date +%s): LIVENESS >> /record/record.txt']
readinessProbe:
exec:
command: ['sh', '-c', 'echo $(date +%s): READINESS >> /record/record.txt']
lifecycle:
postStart:
exec:
command: ['sh', '-c', 'echo $(date +%s): POST-START >> /record/record.txt']
preStop:
exec:
command: ['sh', '-c', 'echo $(date +%s): PRE-STOP >> /record/record.txt']
volumes:
- name: record
hostPath:
path: /tmp/record

创建 Pod 测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ kubectl create -f pod-lifecycle.yaml

## 查看demo状态
$ kubectl -n test get po -o wide -w

## 查看调度节点的/tmp/record/record.txt
$ cat /tmp/record/record.txt
1585424708: INIT
1585424746: START
1585424746: POST-START
1585424754: READINESS
1585424756: LIVENESS
1585424756: END

上面的输出没有 PRE-STOP 是因为必须主动杀掉 Pod 才会触发 pre-stop hook,如果是 Pod 自己 Down 掉,则不会执行 pre-stop hook ,且杀掉Pod进程前,进程必须是正常运行状态,否则不会执行pre-stop钩子。

Hook 调用的日志没有暴露给 Pod,如果处理程序由于某种原因失败,它将产生一个事件。对于 PostStart,这是FailedPostStartHook 事件,对于 PreStop,是 FailedPreStopHook 事件,我们可以通过运行 kubectl -n test describe pod pod-lifecycle 来查看事件。

Pod 的终止

Pod 的终止有两种方式:正常终止和强制终止。

正常终止

终止流程如下:

  1. 用户发出删除 Pod 指令,Pod 被删除,状态变为 Terminating,从 API 层面看就是 Pod metadata 中的deletionTimestamp 字段会被标记上删除时间。
  2. kube-proxy watch 到了就开始更新转发规则,将 Pod 从 service 的 endpoints 列表中摘除掉,新的流量不再转发到该 Pod。
  3. kubelet watch 到了就开始销毁 Pod。
  4. 如果 Pod 中有 container 配置了 preStop Hook ,则 Pod 被标记为 Terminating 状态时,以同步的方式启动执行;若宽限期结束后 preStop 仍未执行结束,则会额外获得一个 2 秒的小宽限期。
  5. 发送 SIGTERM 信号给容器内主进程以通知容器进程开始优雅停止。
  6. 等待 container 中的主进程完全停止,如果在宽限期结束后还未完全停止,就发送 SIGKILL 信号将其强制杀死。
  7. 所有容器进程终止,清理 Pod 资源。
  8. 通知 APIServer Pod 销毁完成,完成 Pod 删除。

对于长连接类型的业务,比如游戏类应用,我们可以将 terminationGracePeriodSeconds 设置大一点,避免过早的被 SIGKILL 杀死,但是具体多长时间是不好预估的,所以最好在业务层面进行优化。比如 Pod 销毁时的优雅终止逻辑里面主动通知下客户端,让客户端连到新的后端,然后客户端来保证这两个连接的平滑切换。等旧 Pod 上所有客户端连接都连切换到了新 Pod 上,才最终退出。

强制终止 Pod

默认情况下,所有的删除操作都会有 30 秒钟的宽限期限。kubectl delete 命令支持 grace-period=<seconds> 选项,允许你重载默认值,设定自己希望的期限值。

将宽限期限强制设置为 0 意味着立即从 APIServer 删除 Pod,如果 Pod 仍然运行于某节点上,强制删除操作会触发 kubelet 立即执行清理操作。 注意:你必须在设置 grace-period=0 的同时额外设置 force 参数才能发起强制删除请求。

执行强制删除操作时,APIServer 不再等待来自 kubelet 关于 Pod 已经在原来运行的节点上终止执行的确认消息。APIServer 直接删除 Pod 对象,这样新的与之同名的 Pod 即可以被创建。在节点侧,被设置为立即终止的 Pod 仍然会在被强行杀死之前获得一点点的宽限时间。

对于已失败的 Pod 而言,对应的 API 对象仍然会保留在集群的 API 服务器上,直到用户或者控制器进程显式地将其删除。

控制器组件会在 Pod 个数超出所配置的阈值 (根据 kube-controller-manager 的 terminated-pod-gc-threshold 设置)时删除已终止的 Pod(phase 值为 Succeeded 或 Failed)。这一行为会避免随着时间不断创建和终止 Pod 而引起的资源泄露问题。

当然,这种方法伴随着一些风险,所以用强制删除 Pod 的命令需要慎用。

业务代码处理 SIGTERM 信信号

要实现优雅退出,我们需要业务代码得支持下优雅退出的逻辑,在业务代码里面处理下 SIGTERM 信号,一般主要逻辑就是”排水”,即等待存量的任务或连接完全结束,再退出进程。下面我们给出几种常用编程语言实现优雅退出的示例。

Golang

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
package main

import (
"fmt"
"os"
"os/signal"
"syscall"
)

func main() {
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)

//registers the channel
signal.Notify(sigs, syscall.SIGTERM)

go func() {
sig := <-sigs
fmt.Println("Caught SIGTERM, shutting down")
// Finish any outstanding requests, then...
done <- true
}()

fmt.Println("Starting application")
// Main logic goes here
<-done
fmt.Println("exiting")
}

Python

1
2
3
4
5
6
7
8
9
10
11
import signal, time, os

def shutdown(signum, frame):
print('Caught SIGTERM, shutting down')
# Finish any outstanding requests, then...
exit(0)

if __name__ == '__main__':
# Register handler
signal.signal(signal.SIGTERM, shutdown)
# Main logic goes here

Node JS

1
2
3
4
5
process.on('SIGTERM', () => {
console.info('SIGTERM signal received.');
// Finish any outstanding requests, then...
process.exit(0);
});

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import sun.misc.Signal;
import sun.misc.SignalHandler;
public class ExampleSignalHandler {
public static void main(String"". args) throws InterruptedException {
final long start = System.nanoTime();
Signal.handle(new Signal("TERM"), new SignalHandler() {
public void handle(Signal sig) {
System.out.format("\nProgram execution took %f seconds\n",
(System.nanoTime() - start) / 1e9f);
System.exit(0);
}
});
int counter = 0;
while(true) {
System.out.println(counter"2);
Thread.sleep(500);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import sun.misc.Signal;
import sun.misc.SignalHandler;

public class ExampleSignalHandler {
public static void main(String[] args) throws InterruptedException {
final long start = System.nanoTime();
Signal.handle(new Signal("TERM"), new SignalHandler() {
public void handle(Signal sig) {
System.out.format("\nProgram execution took %f seconds\n",
(System.nanoTime() - start) / 1e9f);
System.exit(0);
}
});
int counter = 0;
while (true) {
System.out.println(counter++);
Thread.sleep(500);
}
}
}

SHELL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#!/bin/bash

# Redirecting Filehanders
ln -sf /proc/$$/fd/1 /log/stdout.log
ln -sf /proc/$$/fd/2 /log/stderr.log

# Pre execution handler
pre_execution_handler() {
# Pre Execution
# put your pre execution steps here
: # delete this nop
}

# Post execution handler
post_execution_handler() {
# Post Execution
# put your post execution steps here
: # delete this nop
}

# Sigterm Handler
sigterm_handler() {
if [ $pid -ne 0 ]; then
# the above if statement is important because it ensures
# that the application has already started. without it you
# could attempt cleanup steps if the application failed to
# start, causing errors.
kill -15 "$pid"
wait "$pid"
post_execution_handler
fi
exit 143; # 128 + 15 = SIGTERM
}

# Setup signal trap
# on callback execute the specified handler
trap 'sigterm_handler' SIGTERM

# Initialization
pre_execution_handler

# Start Process
# run process in background and record PID
>/log/stdout.log 2>/log/stderr.log "$@" &
pid="$!"

# Application can log to stdout/stderr, /log/stdout.log or /log/stderr.log
# Wait forever until app dies
wait "$pid"
return_code="$?"

# Cleanup
post_execution_handler

# echo the return code of the application
exit $return_code

收不到 SIGTERM 信信号

上面我们给出了几种常见的捕捉 SIGTERM 信号的代码,然后我们就可以执行停止逻辑以实现优雅退出了。在 Kubernetes环境中,业务发版时经常会对工作负载进行滚动更新,当旧版本 Pod 被删除时,K8s 会对 Pod 中各个容器中的主进程发送 SIGTERM 信号,当达到退出宽限期后进程还未完全停止的话,就会发送 SIGKILL 信号将其强制杀死。但是有的场景下在 Kubernetes 环境中实际运行时,有时候可能会发现在滚动更新时,我们业务的优雅终止逻辑并没有被执行,现象是在等了较长时间后,业务进程直接被 SIGKILL 强制杀死了。

这是什么原因造成的呢?通常情况下这都是因为容器启动入口使用了 shell,比如使用了类似 /bin/sh -c my-app 这样的启动入口。或者使用 /entrypoint.sh 这样的脚本文件作为入口,在脚本中再启动业务进程,比如下面的entrypoint.sh 文件:

1
2
3
#!/bin/bash

python /app/my-app.py

这就可能就会导致容器内的业务进程收不到 SIGTERM 信号,原因是:

  1. 容器主进程是 shell,业务进程是在 shell 中启动的,变成了 shell 进程的子进程了。
  2. shell 进程默认会忽略 SIGTERM 信号,父进程不退出,子进程也不会退出,导致业务逻辑不会触发停止逻辑。
  3. 等到 k8s 的 terminationGracePeriodSeconds 时间(默认30秒)到了,shell 进程被 SIGKILL 强制杀死,子进程也会被 SIGKILL 强制杀死。

怎么解决这个问题呢?解决方法有几种:

  1. 使用 exec 启动

    在 shell 中启动二进制的命令前加一个 exec 命令即可让该二进制启动的进程代替当前 shell 进程,即让新启动的进程成为主进程:

    1
    2
    3
    #!/bin/bash

    exec python /app/my-app.py

    然后业务进程就可以正常接收所有信号了,实现优雅退出当然也可以了。

  2. 多进程场景

    通常我们一个容器只会有一个进程,但有些时候我们不得不启动多个进程,比如从传统部署迁移到 Kubernetes 的过渡期间,使用了富容器,即单个容器中需要启动多个业务进程,这时候我们可以通过 shell 来启动,但却无法使用上面的 exec 方式来传递信号了,因为 exec 只能让一个进程替代当前 shell 成为主进程。

    这个时候我们可以在 shell 中使用 trap 来捕获信号,当收到信号后触发回调函数来将信号通过 kill 命令传递给业务进程。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #! /bin/bash

    /bin/app1 & pid1="$!" # 启动第一个业务进程并记录 pid
    echo "app1 started with pid $pid1"

    /bin/app2 & pid2="$!" # 启动第二个业务进程并记录 pid
    echo "app2 started with pid $pid2"

    handle_sigterm() {
    echo "[INFO] Received SIGTERM"
    kill -SIGTERM $pid1 $pid2 # 传递 SIGTERM 给业务进程
    wait $pid1 $pid2 # 等待所有业务进程完全终止
    }

    trap handle_sigterm SIGTERM # 捕获 SIGTERM 信号并回调 handle_sigterm 函数
    wait # 等待回调执行完,主进程再退出
  3. 使用 tini

    前面一种方案实际是用脚本实现了一个极简的 init 系统 (或 supervisor ) 来管理所有子进程,只不过它的逻辑很简陋,仅仅简单的透传指定信号给子进程,其实社区有更完善的方案,dumb-init (https://github.com/Yelp/dumb-init)和 tini (https://github.com/krallin/tini)都可以作为 init 进程,作为主进程 (PID 1) 在容器中启动,然后它再运行 shell 来执行我们指定的脚本 (shell 作为子进程),shell 中启动的业务进程也成为它的子进程,当它收到信号时会将其传递给所有的子进程,从而也能完美解决 shell 无法传递信号问题,并且还有回收僵尸进程的能力,这也是我们强烈推荐的一种方式。

    如下所示是一个以 dumb-init 制作镜像的 Dockerfile 示例:

    1
    2
    3
    4
    5
    6
    7
    FROM ubuntu:22.04
    RUN apt-get update && apt-get install -y dumb-init
    ADD start.sh /
    ADD app1 /bin/app1
    ADD app2 /bin/app2
    ENTRYPOINT ["dumb-init", "*"]
    CMD ["/start.sh"]

    下面则是以 tini 为例制作镜像的 Dockerfile 示例:

    1
    2
    3
    4
    5
    6
    7
    FROM ubuntu:22.04
    ENV TINI_VERSION v0.19.0
    ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
    COPY entrypoint.sh /entrypoint.sh
    RUN chmod +x /tini /entrypoint.sh
    ENTRYPOINT ["/tini", "*"]
    CMD ["/start.sh"]

    此时 start.sh 脚本中当然也可以是多个进程:

    1
    2
    3
    4
    #! /bin/bash
    /bin/app1 &
    /bin/app2 &
    wait

优雅退出是 K8s 中非常重要的一个特性,对于实现应用零宕机滚动更新非常重要。

小结

  1. 实现 k8s 平台与特定的容器运行时解耦,提供更加灵活的业务部署方式,引入了 Pod 概念
  2. k8s 使用 yaml 格式定义资源文件,yaml比json更加简洁
  3. 通过kubectl apply | get | exec | logs | delete 等操作k8s资源,必须指定 namespace
  4. 每启动一个 Pod ,为了实现网络空间共享,会先创建 Infra 容器(也就是 pause 容器),并把其他容器网络加入该容器,实现Pod内所有容器使用同一个网络空间
  5. 通过 livenessProbe和readinessProbe 实现 Pod 的存活性和就绪健康检查
  6. 通过 requests 和 limit 分别限定容器初始资源申请与最高上限资源申请
  7. Pod 通过 initContainer 和 lifecycle 分别来执行初始化、Pod 启动和删除时候的操作,使得功能更加全面和灵活
  8. 编写 yaml 讲究方法,学习 k8s,养成从官方网站查询知识的习惯

那么只使用 Pod ,会有哪些问题呢?

  1. 业务应用启动多个副本怎么做?
  2. Pod重建后IP会变化,外部如何访问Pod服务?
  3. 运行业务Pod的某个节点挂了,可以自动帮我把Pod转移到集群中的可用节点启动起来?
  4. 我的业务应用功能是收集节点监控数据,需要把Pod运行在k8集群的各个节点上?

为了解决上面的问题,k8s 需要使用Controller来管理Pod,比如Deployment、StatefulSet、DaemonSet、Job、CronJob等,下面就会详细介绍其中的几类。

文章目录
  1. 1. Pod 数据持久化
    1. 1.1. 使用 hostpath 挂载
    2. 1.2. 使用 PV+PVC连接分布式存储解决方案
  2. 2. 服务健康检查
    1. 2.1. LivenessProbe 探针 (存活探针)
    2. 2.2. ReadinessProbe 探针 (就绪探针)
    3. 2.3. StartupProbe 探针 (启动探针)
    4. 2.4. 检查方式
    5. 2.5. 健康检查示例
  3. 3. 重启策略
  4. 4. 镜像拉取策略
  5. 5. Pod 资源限制
  6. 6. 配置优化
    1. 6.1. configMap
    2. 6.2. Secret
    3. 6.3. 使用示例
    4. 6.4. 使用场景
  7. 7. 如何编写资源yaml
  8. 8. Pod 状态与生命周期
    1. 8.1. 初始化容器
    2. 8.2. Pod Hook
    3. 8.3. 验证Pod生命周期
    4. 8.4. Pod 的终止
      1. 8.4.1. 正常终止
      2. 8.4.2. 强制终止 Pod
      3. 8.4.3. 业务代码处理 SIGTERM 信信号
        1. 8.4.3.1. Golang
        2. 8.4.3.2. Python
        3. 8.4.3.3. Node JS
        4. 8.4.3.4. Java
        5. 8.4.3.5. SHELL
      4. 8.4.4. 收不到 SIGTERM 信信号
  9. 9. 小结