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

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

Pod 数据持久化

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

使用 hostpath 挂载

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

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

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

# 若存在旧的同名服务,先删除掉
$ 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 的用法会在之后介绍

服务健康检查

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

以下是两种机制的介绍。

LivenessProbe 探针 (存活探针)

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

...
  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)。

...
  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 内,则健康检查失败

tartupProbe 探针 (启动探针)

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

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

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

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

...
  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则表示容器健康

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

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

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

    ...
      livenessProbe:
        grpc:
          port: 8002
    

...

另外,对于 HTTP 和 TCP 存活检测可以使用命名的 port
```yaml
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 文件,我们改造一下,添加上面的探针配置

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都不会重启该容器。

演示重启策略:

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,去掉,即让容器的进程正常退出的话,则不会重启
  • 只有非正常退出状态才会重启
  1. 使用Never时,退出了就不再重启

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

镜像拉取策略

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 两种类型参数对资源进行预分配和使用限制。
将最开始的文件加上目前的所有字段,完整文件如下:

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 表示对使用的资源不做限制, 可无限的使用
      设置为 0 表示对使用的资源不做限制, 可无限的使用
      当 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 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=0CPU 共享权值(相对权重)
cpu-period=0限制 CPU CFS 的周期,范围从 100ms~1s,即[1000, 1000000]
--cpu-quota=0限制 CPU CFS 配额,必须不小于1ms,即 >= 1000,绝对限制

举例如下:

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提供了两类资源,configMap Secret,可以用来实现业务配置的统一管理, 允许将配置文件与镜像文件分离,以使容器化的应用程序具有可移植性 。

configMap

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

apiVersion: v1
kind: ConfigMap
metadata:
  name: myblog
  namespace: test
data:
  MYSQL_HOST: "127.0.0.1"
  MYSQL_DATABASE: "myblog"

创建并查看configmap:

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

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

$ 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类似,可以通过文件或者命令的方式创建,比如:

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:

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

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

$ 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:

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
# 获取系统命名空间的所有资源,根据资源本身的yaml文件,可以进行修改
$ kubectl -n kube-system -o yaml get po,deployment,ds
  1. 学会在官网查找, https://kubernetes.io/docs/home/
  2. 从kubernetes-api文档中查找,https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.16/#pod-v1-core
  3. kubectl explain 查看具体字段含义

Pod 状态与生命周期

下图展示了一个 Pod 的完整生命周期过程,其中包含 Init ContainerPod Hook健康检查 三个主要部分。
生命周期示意图:
22E36BD2-A6A8-42FE-BD4D-7D45E3D3F2D8.png
启动和关闭示意:
92F3BA5A-0C26-43DA-A896-674185746766.png
Pod 的基本状态如下表所示,这些信息都是集群自动维护的:

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

初始化容器

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

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

主要用来实现如下目的:

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

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 测试:

$ 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

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

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

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

java

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);
 }
 }
}   
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

#!/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 文件:

#!/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 进程,即让新启动的进程成为主进程:

    #!/bin/bash
    exec python /app/my-app.py
    
    然后业务进程就可以正常接收所有信号了,实现优雅退出当然也可以了。
    
  2. 多进程场景
    通常我们一个容器只会有一个进程,但有些时候我们不得不启动多个进程,比如从传统部署迁移到 Kubernetes 的过渡期间,使用了富容器,即单个容器中需要启动多个业务进程,这时候我们可以通过 shell 来启动,但却无法使用上面的 exec 方式来传递信号了,因为 exec 只能让一个进程替代当前 shell 成为主进程。
    这个时候我们可以在 shell 中使用 trap 来捕获信号,当收到信号后触发回调函数来将信号通过 kill 命令传递给业务进程。

    #! /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 示例:

    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 示例:

    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 脚本中当然也可以是多个进程:

    #! /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等,下面就会详细介绍其中的几类。