最近在研究 Kubernetes Pod 优雅退出的逻辑,在反复查看文档和实验的情况下基本清楚的七七八八了,正好可以记录一下。

优雅退出是什么

优雅退出(Graceful Exit)最早出现在单机软件上,从维基百科的描述上来看只是当程序遇到错误时一种可控的退出方式,例如打印错误信息 / 关闭文件等等。由于计算机领域一直在发展,每个时代都有不同的解释。

在微服务场景下,为了保证高可用,通常情况会使用多副本机制。此时优雅退出除了业务本身需要清理资源外,还需要有微服务架构和运维平台的配合。此时比较重要的一点是单点服务退出不能导致服务波动,即需要在退出前就保证流量不再往该节点发送,对于有状态的业务,此时可能还需要转交master角色。

本文主要讲述在 Kubernetes 场景下 Pod 优雅退出机制。

优雅退出使用到的功能

业务进程该如何退出

优雅退出首先得从业务本身说起。

在类UNIX环境下,通常会使用 kill 命令来退出,kill命令默认会发送 SIGTERM 给相应进程1。同样,macOS的桌面环境中,使用 CMD+Q 退出进程时首先会发送 Apple Events,如果没有注册Apple Events,也会发送 SIGTERM 给相应进程。SIGTERM 是进程退出逻辑中重要的一环。另一个相关的信号是 SIGINT,这个会在 CTRL-C 时发送给进程。

SIGTERM 及SIGINT 和 SIGKILL 的不同点在于他是可以被阻塞、处理和忽略的2。 在优雅退出的正确处理中,程序应该捕获 SIGTERM 信号,并进行相应的退出处理。例如可以做停止接受新的报文 / 释放文件 / 断开数据库连接 / 等待已有报文处理完成 / 释放socket 等操作。

在 Golang 中,可以很方便的使用 os/signal 进行信号捕获,并在协程中处理:

package main

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

func gracefulExit(c chan os.Signal) {
    for signal := range c {
        switch signal {
        case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
            fmt.Println("receive signal ", i.String(),", will exit.")
            os.Exit(0)
        }
    }
}

func main() {
	// Set up channel on which to send signal notifications.
	// We must use a buffered channel or risk missing the signal
	// if we're not ready to receive when the signal is sent.
	c := make(chan os.Signal, 1)
	signal.Notify(c, syscall.SIGTERM, SIGINT)

	// Block until a signal is received.
	go gracefulExit(c)

	// Other code
}

服务探针

probe 是由 kubelet 对容器执行的定期诊断。 要执行诊断,kubelet 既可以在容器内执行代码,也可以发出一个网络请求3

在Pod退出的逻辑中,我们主要会使用到 livenessProbereadinessProbe

  • livenessProbe:指示容器是否正在运行。如果存活态探测失败,则 kubelet 会杀死容器, 并且容器将根据其重启策略决定未来。如果容器不提供存活探针, 则默认状态为 Success
  • readinessProbe:指示容器是否准备好为请求提供服务。如果就绪态探测失败, 端点控制器将从与 Pod 匹配的所有服务的端点列表中删除该 Pod 的 IP 地址。 初始延迟之前的就绪态的状态值默认为 Failure。 如果容器不提供就绪态探针,则默认状态为 Success

我们需要明确的注意这两者的差别。livenessProbe 用于在异常情况下给k8s提供容器退出的依据;readinessProbe 用于告诉k8s服务是否可用,是否可以引流过来,和“退出”这件事没有强关联。

检测方式可以在 exec / grpc / httpGet / tcpSocket 中自由选择。

Pod终止通知

Pod终止时有两种方式可以通知到业务。

通常情况下,容器运行时会发送一个 TERM 信号到每个容器中的主进程(1号进程)。4

除此之外可以在容器中设置PreStop回调。在容器因 API 请求或者管理事件(诸如存活态探针、启动探针失败、资源抢占、资源竞争等) 而被终止之前,此回调会被调用5。 回调可以使用 exec 或者 http 两种方式。

我们需要注意在某些情况下,我们的业务进程在容器中并不是 1 号进程,我们可能会通过 shell 脚本预处理一些信息,并通过此脚本来拉起业务进程。脚本捕获信号的机制一般使用 trap 命令进行捕获,但实际使用中,脚本很难成功捕获 TERM 信号,并通知业务,详细资料可以看这篇文章:Shell脚本深入教程:trap信号捕捉用法详解 | 骏马金龙 。此时使用 PreStop 是一个好方法,可以通过 PreStop 给业务进程发送 TERM 信号,并且还能在业务出现问题时进行清理动作。

PreStop 与发送 TERM 会顺序执行。

优雅退出需要考虑的场景

Pod优雅退出的方式需要结合上述资料,并结合相应场景进行。在本文中,我们可以粗略地考虑两种退出场景。

  • Pod在滚动升级或驱逐等情况下需要退出;
  • Pod在出现异常情况下需要退出;

如何停止引流

作为服务,Pod停止引流是首先要被考虑到的动作。

在 Kubernetes 中, Service 是将运行在一个或一组 Pod 上的网络应用程序公开为网络服务的方法。Endpoints (该资源类别为复数)定义了网络端点的列表,通常由 Service 引用,以定义可以将流量发送到哪些 Pod。6

想要停止引流,只要能成功删除 EndPoint 即可。前面讲到了服务探针,当 readinessProbe 返回失败时,相应 EndPoint 就会从 EndPoints 中被删除。

对于面向云原生开发的服务,我们可以直接在程序中提供类似名为 /readyz 的API,并将 readinessProbe 的检测方式配置为 httpGet,此时可使 /readyz API 返回小于200或大于400的状态码即可,使用 503 Service Unavailable 是一个不错的选择。

在非退出场景时,如果服务暂时无法连接到其所依赖服务,可以将 readinessProbe 状态变为 Failure,来防止业务损失。

在Pod退出场景时,正在终止的端点始终将其 ready 状态设置为 false。 这是为了满足向后兼容的需求,确保现有的负载均衡器不会将 Pod 用于常规流量7。因此在K8S的逻辑中,Pod退出时无需业务主动设置探针为 Failure 即可停止引流。但考虑到业务流程统一,这边还是建议在优雅退出时使类似于 /readyz API 返回 503 Service Unavailable。

Pod 退出

Pod 该如何退出,在此已经有了大概雏形,我们可以将他拼接起来,并填补细节。具体的细节会在使用到的场景中讲解。在此之前需要先列出 Pod 举例的配置,并讲解其中的参数:

spec:
	containers:
	- image: nginx
	  # 设置 PreStop回调,sleep 20秒
	  lifecycle:
		  preStop:
			  exec:
				  command:
				  - sleep
				  - 20
	  # 存活探针
	  livenessProbe:
        # 探测http
		httpGet:
			path: /livez
			port: 9443
			scheme: HTTPS
		# 检测间隔为10s
		periodSeconds: 10
		# 失败3次后判定不再存活
		failureThreshold: 3
		# 检测超时时间1s
		timeoutSeconds: 1
		# 从为失败的容器触发终止操作到强制容器运行时停止该容器之前等待的宽限时长
		terminationGracePeriodSeconds: 30
	  # 就绪探针,同上
	  readinessProbe:
		  failureThreshold: 3
		  httpGet:
			  path: /readyz
			  port: 9443
			  scheme: HTTPS
		  periodSeconds: 5
		  successThreshold: 1
		  timeoutSeconds: 1

Pod 正常退出

Pod在滚动升级或驱逐等情况下会正常退出。处理流程大概是这样:

  1. 标记Pod为Terminating状态;
  2. ready 状态设置为 false
  3. K8S 删除 EndPoints 中相应 Pod IP;
  4. 触发PreStop回调;
  5. 发送 TERM 给 1号进程;
  6. 此时 1号进程应能正常退出;
  7. 当超过Pod级别 terminationGracePeriodSeconds 时限后
    1. 当仍处于PreStop回调阶段,将会给予2s宽限时间
  8. 将给所有进程发送 KILL 信号
stateDiagram-v2

[*] --> Pod开始删除

state fork_state <<fork>>

Pod开始删除 --> fork_state

fork_state --> 设置状态为Terminating

设置状态为Terminating --> ready状态设置为false

  

fork_state --> 删除EndPoints中相应PodIP

  

state join_state <<join>>

ready状态设置为false --> join_state

删除EndPoints中相应PodIP --> join_state

  

join_state --> Graceful退出

  

state Graceful退出 {

direction LR

PreStop回调 --> 发送TERM

发送TERM --> 业务Graceful退出

  

state 业务Graceful退出 {

state fork_state2 <<fork>>

  

设定/Readyz503 --> fork_state2

结束所有Req --> fork_state2

释放资源 --> fork_state2

fork_state2 --> Exit

}

}

  

state if_state <<choice>>

  

Graceful退出 --> if_state

  

if_state --> 发送SIGKILL到所有进程 : 如果超时

  

发送SIGKILL到所有进程 --> 删除Pod对象

Exit --> 删除Pod对象

Pod异常退出

Pod异常退出时。处理流程大概大致相同:

  1. Pod readinessProbe检测状态异常(设置时间较短)
  2. ready 状态设置为 false
  3. K8S 删除 EndPoints 中相应 Pod IP;
  4. Pod livenessProbe检测状态异常;
  5. 触发PreStop回调;
  6. 尝试发送 TERM 给 1号进程;
  7. 此时 1号进程可能不会正常退出;
  8. 当超过liveness探针级别 terminationGracePeriodSeconds 时限后
    1. 当仍处于PreStop回调阶段,将会给予2s宽限时间
  9. 将给所有进程发送 KILL 信号

其他问题

通过PreStop来关闭业务,是否需要保护进程退出时常

这个问题背景是:当TERM无法传递给业务进程,使用PreStop来发送TERM给业务,并关闭他时,是否需要再sleep 保护业务进程优雅退出的时间。

通过查阅资料,目前无论从官网还是《Kubernetes in Action》,亦或是以下16年的issue来判断,均不需要在PreStop流程中等待进程退出。

kubelet: pod should be terminated immediately once preStop complete · Issue #24695 · kubernetes/kubernetes · GitHub

官网文档 回调处理程序执行

PreStop 回调并不会与停止容器的信号处理程序异步执行;回调必须在可以发送信号之前完成执行。 如果 PreStop 回调在执行期间停滞不前,Pod 的阶段会变成 Terminating并且一直处于该状态, 直到其 terminationGracePeriodSeconds 耗尽为止,这时 Pod 会被杀死。 这一宽限期是针对 PreStop 回调的执行时间及容器正常停止时间的总和而言的。 例如,如果 terminationGracePeriodSeconds 是 60,回调函数花了 55 秒钟完成执行, 而容器在收到信号之后花了 10 秒钟来正常结束,那么容器会在其能够正常结束之前即被杀死, 因为 terminationGracePeriodSeconds 的值小于后面两件事情所花费的总时间(55+10)。

所有资料均表明,PreStop后会发送TERM,此时需要业务主动退出或等待 terminationGracePeriodSeconds。因此此问题背景PreStop发送TERM后,如果会立即退出,应该是其他原因。其后使用sleep保护,不失为一个好的规避方案。