Kubernetes服务pod的健康检测liveness和readiness详解

接下来给大家讲解下在K8S上,我们如果对我们的业务服务进行健康检测。

Health Check、restartPolicy

这里我们再进一步,来聊聊K8s上面服务的健康检测特性。在K8s上,强大的自愈能力是这个容器编排引擎的非常重要的一个特性,自愈的默认实现方式是通过自动重启发生故障的容器,使之恢复正常。除此之外,我们还可以利用Liveness 和 Readiness检测机制来设置更为精细的健康检测指标,从而实现如下的需求:

  • 零停机部署
  • 避免部署无效的服务镜像
  • 更加安全地滚动升级

下面我们先来实践学习下K8s的Healthz Check功能,我们先来学习下K8s默认的健康检测机制:

每个容器启动时都会执行一个进程,此进程是由Dockerfile的CMD 或 ENTRYPOINT来指定,当容器内进程退出时返回状态码为非零,则会认为容器发生了故障,K8s就会根据restartPolicy来重启这个容器,以达到自愈的效果。

下面我们来动手实践下,模拟一个容器发生故障时的场景 :

# 先来生成一个pod的yaml配置文件,并对其进行相应修改
# kubectl run  busybox --image=busybox --dry-run=client -o yaml > testHealthz.yaml
# vim testHealthz.yaml
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: null
  labels:
    run: busybox
  name: busybox
spec:
  containers:
  - image: busybox
    name: busybox
    resources: {}
    args:
    - /bin/sh
    - -c
    - sleep 10; exit 1       # 并添加pod运行指定脚本命令,模拟容器启动10秒后发生故障,退出状态码为1
  dnsPolicy: ClusterFirst
  restartPolicy: OnFailure # 将默认的Always修改为OnFailure
status: {}

执行配置创建pod

# kubectl apply -f testHealthz.yaml
pod/busybox created

# 观察几分钟,利用-w 参数来持续监听pod的状态变化
# kubectl  get pod -w
NAME                     READY   STATUS              RESTARTS   AGE
busybox                  0/1     ContainerCreating   0          4s
busybox                  1/1     Running             0          6s
busybox                  0/1     Error               0          16s
busybox                  1/1     Running             1          22s
busybox                  0/1     Error               1          34s
busybox                  0/1     CrashLoopBackOff    1          47s
busybox                  1/1     Running             2          63s
busybox                  0/1     Error               2          73s
busybox                  0/1     CrashLoopBackOff    2          86s
busybox                  1/1     Running             3          109s
busybox                  0/1     Error               3          2m
busybox                  0/1     CrashLoopBackOff    3          2m15s
busybox                  1/1     Running             4          3m2s
busybox                  0/1     Error               4          3m12s
busybox                  0/1     CrashLoopBackOff    4          3m23s
busybox                  1/1     Running             5          4m52s
busybox                  0/1     Error               5          5m2s
busybox                  0/1     CrashLoopBackOff    5          5m14s

上面可以看到这个测试pod被重启了5次,然而服务始终正常不了,就会保持在CrashLoopBackOff了,等待运维人员来进行下一步错误排查
注:kubelet会以指数级的退避延迟(10s,20s,40s等)重新启动它们,上限为5分钟
这里我们是人为模拟服务故障来进行的测试,在实际生产工作中,对于业务服务,我们如何利用这种重启容器来恢复的机制来配置业务服务呢,答案是`liveness`检测

Liveness

Liveness检测让我们可以自定义条件来判断容器是否健康,如果检测失败,则K8s会重启容器,我们来个例子实践下,准备如下yaml配置并保存为liveness.yaml:

apiVersion: v1
kind: Pod
metadata:
  labels:
    test: liveness
  name: liveness
spec:
  restartPolicy: OnFailure
  containers:
  - name: liveness
    image: busybox
    args:
    - /bin/sh
    - -c
    - touch /tmp/healthy; sleep 30; rm -f /tmp/healthy; sleep 600
    livenessProbe:
      exec:
        command:
        - cat
        - /tmp/healthy
      initialDelaySeconds: 10   # 容器启动 10 秒之后开始检测
      periodSeconds: 5          # 每隔 5 秒再检测一次

启动进程首先创建文件 /tmp/healthy,30 秒后删除,在我们的设定中,如果 /tmp/healthy 文件存在,则认为容器处于正常状态,反正则发生故障。

livenessProbe 部分定义如何执行 Liveness 检测:

检测的方法是:通过 cat 命令检查 /tmp/healthy 文件是否存在。如果命令执行成功,返回值为零,K8s 则认为本次 Liveness 检测成功;如果命令返回值非零,本次 Liveness 检测失败。

initialDelaySeconds: 10 指定容器启动 10 之后开始执行 Liveness 检测,我们一般会根据应用启动的准备时间来设置。比如某个应用正常启动要花 30 秒,那么 initialDelaySeconds 的值就应该大于 30。

periodSeconds: 5 指定每 5 秒执行一次 Liveness 检测。K8s 如果连续执行 3 次 Liveness 检测均失败,则会杀掉并重启容器。

接着来创建这个Pod:

# kubectl apply -f liveness.yaml
pod/liveness created

从配置文件可知,最开始的 30 秒,/tmp/healthy 存在,cat 命令返回 0,Liveness 检测成功,这段时间 kubectl describe pod liveness 的 Events部分会显示正常的日志

# kubectl describe pod liveness
......
Events:
  Type     Reason     Age              From               Message
  ----     ------     ----             ----               -------
  Normal   Scheduled  53s              default-scheduler  Successfully assigned default/liveness to 10.0.1.203
  Normal   Pulling    52s              kubelet            Pulling image "busybox"
  Normal   Pulled     43s              kubelet            Successfully pulled image "busybox"
  Normal   Created    43s              kubelet            Created container liveness
  Normal   Started    42s              kubelet            Started container liveness

35 秒之后,日志会显示 /tmp/healthy 已经不存在,Liveness 检测失败。再过几十秒,几次检测都失败后,容器会被重启。

Events:
  Type     Reason     Age                  From               Message
  ----     ------     ----                 ----               -------
  Normal   Scheduled  3m53s                default-scheduler  Successfully assigned default/liveness to 10.0.1.203
  Normal   Pulling    73s (x3 over 3m52s)  kubelet            Pulling image "busybox"
  Normal   Pulled     62s (x3 over 3m43s)  kubelet            Successfully pulled image "busybox"
  Normal   Created    62s (x3 over 3m43s)  kubelet            Created container liveness
  Normal   Started    62s (x3 over 3m42s)  kubelet            Started container liveness
  Warning  Unhealthy  18s (x9 over 3m8s)   kubelet            Liveness probe failed: cat: can't open '/tmp/healthy': No such file or directory
  Normal   Killing    18s (x3 over 2m58s)  kubelet            Container liveness failed liveness probe, will be restarted

除了 Liveness 检测,Kubernetes Health Check 机制还包括 Readiness 检测。

Readiness

我们可以通过Readiness检测来告诉K8s什么时候可以将pod加入到服务Service的负载均衡池中,对外提供服务,这个在生产场景服务发布新版本时非常重要,当我们上线的新版本发生程序错误时,Readiness会通过检测发布,从而不导入流量到pod内,将服务的故障控制在内部,在生产场景中,建议这个是必加的,Liveness不加都可以,因为有时候我们需要保留服务出错的现场来查询日志,定位问题,告之开发来修复程序。

Readiness 检测的配置语法与 Liveness 检测完全一样,下面是个例子:

apiVersion: v1
kind: Pod
metadata:
  labels:
    test: liveness
  name: liveness
spec:
  restartPolicy: OnFailure
  containers:
  - name: liveness
    image: busybox
    args:
    - /bin/sh
    - -c
    - touch /tmp/healthy; sleep 30; rm -f /tmp/healthy; sleep 600
    readinessProbe:    # 这里将livenessProbe换成readinessProbe即可,其它配置都一样
      exec:
        command:
        - cat
        - /tmp/healthy
      initialDelaySeconds: 10   # 容器启动 10 秒之后开始检测
      periodSeconds: 5          # 每隔 5 秒再检测一次

保存上面这个配置为readiness.yaml,并执行它生成pod:

# kubectl apply -f readiness.yaml
pod/liveness created

# 观察,在刚开始创建时,文件并没有被删除,所以检测一切正常
# kubectl  get pod
NAME                     READY   STATUS    RESTARTS   AGE
liveness                 1/1     Running   0          50s

# 然后35秒后,文件被删除,这个时候READY状态就会发生变化,K8s会断开Service到pod的流量
# kubectl  describe pod liveness
......
Events:
  Type     Reason     Age               From               Message
  ----     ------     ----              ----               -------
  Normal   Scheduled  56s               default-scheduler  Successfully assigned default/liveness to 10.0.1.203
  Normal   Pulling    56s               kubelet            Pulling image "busybox"
  Normal   Pulled     40s               kubelet            Successfully pulled image "busybox"
  Normal   Created    40s               kubelet            Created container liveness
  Normal   Started    40s               kubelet            Started container liveness
  Warning  Unhealthy  5s (x2 over 10s)  kubelet            Readiness probe failed: cat: can't open '/tmp/healthy': No such file or directory

# 可以看到pod的流量被断开,这时候即使服务出错,对外界来说也是感知不到的,这时候我们运维人员就可以进行故障排查了
# kubectl  get pod
NAME                     READY   STATUS    RESTARTS   AGE
liveness                 0/1     Running   0          61s

下面对 Liveness 检测和 Readiness 检测做个比较:

Liveness 检测和 Readiness 检测是两种 Health Check 机制,如果不特意配置,Kubernetes 将对两种检测采取相同的默认行为,即通过判断容器启动进程的返回值是否为零来判断检测是否成功。

两种检测的配置方法完全一样,支持的配置参数也一样。不同之处在于检测失败后的行为:Liveness 检测是重启容器;Readiness 检测则是将容器设置为不可用,不接收 Service 转发的请求。

Liveness 检测和 Readiness 检测是独立执行的,二者之间没有依赖,所以可以单独使用,也可以同时使用。用 Liveness 检测判断容器是否需要重启以实现自愈;用 Readiness 检测判断容器是否已经准备好对外提供服务。

Health Check 在 业务生产中滚动更新(rolling update)的应用场景

对于运维人员来说,将服务的新项目代码更新上线,确保其稳定运行是一项很关键,且重复性很高的任务,在传统模式下,我们一般是用saltsatck或者ansible等批量管理工具来推送代码到各台服务器上进行更新,那么在K8s上,这个更新流程就被简化了,在后面高阶章节我会讲到CI/CD自动化流程,大致就是开发人员开发好代码上传代码仓库即会触发CI/CD流程,这之间基本无需运维人员的参与。那么在这么高度自动化的流程中,我们运维人员怎么确保服务能稳定上线呢?Health Check里面的Readiness 能发挥很关键的作用,这个其实在上面也有讲过,这里我们再以实例来说一遍,加深印象:

# cat myapp-v1.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mytest
spec:
  replicas: 10     # 这里准备10个数量的pod
  selector:
    matchLabels:
      app: mytest
  template:
    metadata:
      labels:
        app: mytest
    spec:
      containers:
      - name: mytest
        image: busybox
        args:
        - /bin/sh
        - -c
        - sleep 10; touch /tmp/healthy; sleep 30000
        readinessProbe:
          exec:
            command:
            - cat
            - /tmp/healthy
          initialDelaySeconds: 10
          periodSeconds: 5

运行这个配置

# kubectl apply -f myapp-v1.yaml --record
deployment.apps/mytest created

# 等待一会,可以看到所有pod已正常运行
# kubectl  get pod
NAME                     READY   STATUS    RESTARTS   AGE
mytest-d9f48585b-2lmh2   1/1     Running   0          3m22s
mytest-d9f48585b-5lh9l   1/1     Running   0          3m22s
mytest-d9f48585b-cwb8l   1/1     Running   0          3m22s
mytest-d9f48585b-f6tzc   1/1     Running   0          3m22s
mytest-d9f48585b-hb665   1/1     Running   0          3m22s
mytest-d9f48585b-hmqrw   1/1     Running   0          3m22s
mytest-d9f48585b-jm8bm   1/1     Running   0          3m22s
mytest-d9f48585b-kxm2m   1/1     Running   0          3m22s
mytest-d9f48585b-lqpr9   1/1     Running   0          3m22s
mytest-d9f48585b-pk75z   1/1     Running   0          3m22s

接着我们来准备更新这个服务,并且人为模拟版本故障来进行观察,新准备一个配置myapp-v2.yaml

# cat myapp-v2.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mytest
spec:
  strategy:
    rollingUpdate:
      maxSurge: 35%   # 滚动更新的副本总数最大值(以10的基数为例):10 + 10 * 35% = 13.5 --> 14
      maxUnavailable: 35%  # 可用副本数最大值(默认值两个都是25%): 10 - 10 * 35% = 6.5  --> 7
  replicas: 10
  selector:
    matchLabels:
      app: mytest
  template:
    metadata:
      labels:
        app: mytest
    spec:
      containers:
      - name: mytest
        image: busybox
        args:
        - /bin/sh
        - -c
        - sleep 30000   # 可见这里并没有生成/tmp/healthy这个文件,所以下面的检测必然失败
        readinessProbe:
          exec:
            command:
            - cat
            - /tmp/healthy
          initialDelaySeconds: 10
          periodSeconds: 5

很明显这里因为我们更新的这个v2版本里面不会生成/tmp/healthy文件,那么自动是无法通过Readiness 检测的,详情如下:

# kubectl apply -f myapp-v2.yaml --record
deployment.apps/mytest configured

# kubectl get deployment mytest
NAME     READY   UP-TO-DATE   AVAILABLE   AGE
mytest   7/10    7            7           4m58s
# READY 现在正在运行的只有7个pod
# UP-TO-DATE 表示当前已经完成更新的副本数:即 7 个新副本
# AVAILABLE 表示当前处于 READY 状态的副本数

# kubectl get pod
NAME                      READY   STATUS    RESTARTS   AGE
mytest-7657789bc7-5hfkc   0/1     Running   0          3m2s
mytest-7657789bc7-6c5lg   0/1     Running   0          3m2s
mytest-7657789bc7-c96t6   0/1     Running   0          3m2s
mytest-7657789bc7-nbz2q   0/1     Running   0          3m2s
mytest-7657789bc7-pt86c   0/1     Running   0          3m2s
mytest-7657789bc7-q57gb   0/1     Running   0          3m2s
mytest-7657789bc7-x77cg   0/1     Running   0          3m2s
mytest-d9f48585b-2bnph    1/1     Running   0          5m4s
mytest-d9f48585b-965t4    1/1     Running   0          5m4s
mytest-d9f48585b-cvq7l    1/1     Running   0          5m4s
mytest-d9f48585b-hvpnq    1/1     Running   0          5m4s
mytest-d9f48585b-k89zs    1/1     Running   0          5m4s
mytest-d9f48585b-wkb4b    1/1     Running   0          5m4s
mytest-d9f48585b-wrkzf    1/1     Running   0          5m4s
# 上面可以看到,由于 Readiness 检测一直没通过,所以新版本的pod都是Not ready状态的,这样就保证了错误的业务代码不会被外界请求到

# kubectl describe deployment mytest
# 下面截取一些这里需要的关键信息
......
Replicas:               10 desired | 7 updated | 14 total | 7 available | 7 unavailable
......
Events:
  Type    Reason             Age    From                   Message
  ----    ------             ----   ----                   -------
  Normal  ScalingReplicaSet  5m55s  deployment-controller  Scaled up replica set mytest-d9f48585b to 10
  Normal  ScalingReplicaSet  3m53s  deployment-controller  Scaled up replica set mytest-7657789bc7 to 4  # 启动4个新版本的pod
  Normal  ScalingReplicaSet  3m53s  deployment-controller  Scaled down replica set mytest-d9f48585b to 7 # 将旧版本pod数量降至7
  Normal  ScalingReplicaSet  3m53s  deployment-controller  Scaled up replica set mytest-7657789bc7 to 7  # 新增3个启动至7个新版本

综合上面的分析,我们很真实的模拟一次K8s上次错误的代码上线流程,所幸的是这里有Health Check的Readiness检测帮我们屏蔽了有错误的副本,不至于被外面的流量请求到,同时保留了大部分旧版本的pod,因此整个服务的业务并没有因这此更新失败而受到影响。

接下来我们详细分析下滚动更新的原理,为什么上面服务新版本创建的pod数量是7个,同时只销毁了3个旧版本的pod呢?

原因就在于这段配置:

  strategy:
    rollingUpdate:
      maxSurge: 35%
      maxUnavailable: 35%

滚动更新通过参数maxSurge和maxUnavailable来控制pod副本数量的更新替换。

maxSurge

这个参数控制滚动更新过程中pod副本总数超过设定总副本数量的上限。maxSurge 可以是具体的整数(比如 3),也可以是百分比,向上取整。maxSurge 默认值为 25%

在上面测试的例子里面,pod的总副本数量是10,那么在更新过程中,总副本数量的上限大最值计划公式为:

10 + 10 * 35% = 13.5 --> 14

我们查看下更新deployment的描述信息:

Replicas: 10 desired | 7 updated | 14 total | 7 available | 7 unavailable

旧版本available 的数量7个 + 新版本unavailable`的数量7个 = 总数量 14 total

maxUnavailable

这个参数控制滚动更新过程中不可用的pod副本总数量的值,同样,maxUnavailable 可以是具体的整数(比如 3),也可以是百分百,向下取整。maxUnavailable 默认值为 25%。

在上面测试的例子里面,pod的总副本数量是10,那么要保证正常可用的pod副本数量为:

10 - 10 * 35% = 6.5 --> 7

所以我们在上面查看的描述信息里,7 available 正常可用的pod数量值就为7

maxSurge 值越大,初始创建的新副本数量就越多;maxUnavailable 值越大,初始销毁的旧副本数量就越多。

正常更新理想情况下,我们这次版本发布案例滚动更新的过程是:

  1. 首先创建4个新版本的pod,使副本总数量达到14个
  2. 然后再销毁3个旧版本的pod,使可用的副本数量降为7个
  3. 当这3个旧版本的pod被 成功销毁后,可再创建3个新版本的pod,使总的副本数量保持为14个
  4. 当新版本的pod通过Readiness 检测后,会使可用的pod副本数量增加超过7个
  5. 然后可以继续销毁更多的旧版本的pod,使整体可用的pod数量回到7个
  6. 随着旧版本的pod销毁,使pod副本总数量低于14个,这样就可以继续创建更多的新版本的pod
  7. 这个新增销毁流程会持续地进行,最终所有旧版本的pod会被新版本的pod逐渐替换,整个滚动更新完成

而我们这里的实际情况是在第4步就卡住了,新版本的pod数量无法能过Readiness 检测。上面的描述信息最后面的事件部分的日志也详细说明了这一切:

Events:
  Type    Reason             Age    From                   Message
  ----    ------             ----   ----                   -------
  Normal  ScalingReplicaSet  5m55s  deployment-controller  Scaled up replica set mytest-d9f48585b to 10
  Normal  ScalingReplicaSet  3m53s  deployment-controller  Scaled up replica set mytest-7657789bc7 to 4  # 启动4个新版本的pod
  Normal  ScalingReplicaSet  3m53s  deployment-controller  Scaled down replica set mytest-d9f48585b to 7 # 将旧版本pod数量降至7
  Normal  ScalingReplicaSet  3m53s  deployment-controller  Scaled up replica set mytest-7657789bc7 to 7  # 新增3个启动至7个新版本

这里按正常的生产处理流程,在获取足够的新版本错误信息提交给开发分析后,我们可以通过kubectl rollout undo 来回滚到上一个正常的服务版本:

# 先查看下要回滚版本号前面的数字,这里为1
# kubectl rollout history deployment mytest
deployment.apps/mytest
REVISION  CHANGE-CAUSE
1         kubectl apply --filename=myapp-v1.yaml --record=true
2         kubectl apply --filename=myapp-v2.yaml --record=true

# kubectl rollout undo deployment mytest --to-revision=1
deployment.apps/mytest rolled back

# kubectl get deployment mytest
NAME     READY   UP-TO-DATE   AVAILABLE   AGE
mytest   10/10   10           10          96m

# kubectl get pod
NAME                     READY   STATUS    RESTARTS   AGE
mytest-d9f48585b-2bnph   1/1     Running   0          96m
mytest-d9f48585b-8nvhd   1/1     Running   0          2m13s
mytest-d9f48585b-965t4   1/1     Running   0          96m
mytest-d9f48585b-cvq7l   1/1     Running   0          96m
mytest-d9f48585b-hvpnq   1/1     Running   0          96m
mytest-d9f48585b-k89zs   1/1     Running   0          96m
mytest-d9f48585b-qs5c6   1/1     Running   0          2m13s
mytest-d9f48585b-wkb4b   1/1     Running   0          96m
mytest-d9f48585b-wprlz   1/1     Running   0          2m13s
mytest-d9f48585b-wrkzf   1/1     Running   0          96m

OK,到这里为止,我们真实的模拟了一次有问题的版本发布及回滚,并且可以看到,在这整个过程中,虽然出现了问题,但我们的业务依然是没有受到任何影响的,这就是K8s的魅力所在。

pod小怪战斗(作业)

# 把上面整个更新及回滚的案例,自己再测试一遍,注意观察其中的pod变化,加深理解
05-22 19:28