我是 LEE,老李,一个在 IT 行业摸爬滚打 16 年的技术老兵。
事件背景
不少研发小伙伴在使用 Pod 作为自己应用承载平台的时候,从来不重视应用的优雅停机(Graceful Stop)。我记得一个研发跟我说到:“你只要保证我的 Pod 能够被关闭,让我做优雅停机不是多此一举嘛?”。 真的是多此一举嘛? 这个研发所在的业务组在最近大版本更迭的时候,计划是平滑升级,使用 Pod 的滚动更新,可是万万没有想到,因为应用没有优雅停机,导致大量应用没有正常关闭业务请求,请求出现很多的 404 和 502 的情况,对业务的连续性带来了很多压力。
现象获取
收集了不少其他业务的问题反馈,都有类似的情况,而且是在发布新应用的时候。因为 Deployment 中 Image 字段发生了变化,触发 K8S 滚动更新,新 Pod 创建,老 Pod 关闭。(我想后面的关闭应用 Pod 也会有这样的问题,都是有 Pod 被 Kill)
总结下刚才的问题:
- 可能会出现 Pod 未将正在处理的请求处理完成的情况下被删除,如果该请求不是幂等性的,则会导致状态不一致的 bug。(此时会出现 404)
- 可能会出现 Pod 已经被删除,Kubernetes 仍然将流量导向该 Pod,从而出现用户请求处理失败,带来比较差的用户体验。 (此时会出现 502)
在 Kubernetes Pod 的删除过程中,同时会存在两条并行的时间线,如下图所示。
- 一条时间线是网络规则的更新过程。
- 另一条时间线是 Pod 的删除过程。
原理分析
表面上看起来是应用没有正常关闭之前的会话,导致业务连续性出现了问题。我想说实际上是:对关闭 Pod 关闭过程中信号机的状态理解不够。 既然要讲清这个问题,一切都从 TerminationGracePeriodSeconds 开始说起,我们回顾下 k8s 关闭 Pod 的流程过程。
网络层面
- Pod 被删除,状态置为 Terminating。
- Endpoint Controller 将该 Pod 的 ip 从 Endpoint 对象中删除。
- Kube-proxy 根据 Endpoint 对象的改变更新 iptables 规则,不再将流量路由到被删除的 Pod。
- 如果还有其他 Gateway 依赖 Endpoint 资源变化的,也会改变自己的配置(比如我们的 traefik)。
Pod 层面
- Pod 被删除,状态置为 Terminating。
- Kubelet 捕获到 ApiServer 中 Pod 状态变化,执行 syncPod 动作。
- 如果 Pod 配置了 preStop Hook ,将会执行。
- kubelet 对 Pod 中各个 container 发送调用 cri 接口中 StopContainer 方法,向 dockerd 发送 stop -t 指令,用 SIGTERM 信号以通知容器内应用进程开始优雅停止。
- 等待容器内应用进程完全停止,如果在 terminationGracePeriodSeconds (默认 30s) - preStop 执行时间内还未完全停止,就发送 SIGKILL 信号强制杀死应用进程。
- 所有容器进程终止,清理 Pod 资源。
我们重点关注下几个信号:K8S_EVENT, SIGTERM, SIGKILL
- K8S_EVENT: SyncPodKill,kubelet 监听到了 apiServer 关闭 Pod 事件,经过一些处理动作后,向内部发出了一个 syncPod 动作,完成当前真实 Pod 状态的改变。
- SIGTERM: 用于终止程序,也称为软终止,因为接收 SIGTERM 信号的进程可以选择忽略它。
- SIGKILL: 用于立即终止,也称为硬终止,这个信号不能被忽略或阻止。这是杀死进程的野蛮方式,只能作为最后的手段。
了解信号的解释以后,我们可以关系下 kubelet 是怎么关闭 Pod。我们一起看下流程图(包含 preStop 和 GracefulStop):
当然有了图虽然是有真相,但是过一遍真正的代码,才是真正的真相。
Kubernetes 代码
pkg/kubelet/types/pod_update.go
// SyncPodType classifies pod updates, eg: create, update.
type SyncPodType int
const (
// SyncPodSync is when the pod is synced to ensure desired state
SyncPodSync SyncPodType = iota
// SyncPodUpdate is when the pod is updated from source
SyncPodUpdate
// SyncPodCreate is when the pod is created from source
SyncPodCreate
// SyncPodKill is when the pod is killed based on a trigger internal to the kubelet for eviction.
// If a SyncPodKill request is made to pod workers, the request is never dropped, and will always be processed.
SyncPodKill // 关闭 Pod 动作类型
)
pkg/kubelet/kubelet.go
// If any step of this workflow errors, the error is returned, and is repeated
// on the next syncPod call.
//
// This operation writes all events that are dispatched in order to provide
// the most accurate information possible about an error situation to aid debugging.
// Callers should not throw an event if this operation returns an error.
func (kl *Kubelet) syncPod(o syncPodOptions) error {
// pull out the required options
...
updateType := o.updateType
// if we want to kill a pod, do it now!
if updateType == kubetypes.SyncPodKill {
killPodOptions := o.killPodOptions
if killPodOptions == nil || killPodOptions.PodStatusFunc == nil {
return fmt.Errorf("kill pod options are required if update type is kill")
}
apiPodStatus := killPodOptions.PodStatusFunc(pod, podStatus)
// 修改 Pod 的状态
kl.statusManager.SetPodStatus(pod, apiPodStatus)
// 这里事件类型是关闭 Pod,这里开始执行 Pod 的关闭过程,至此 SyncPodKill 信号的作用结束
if err := kl.killPod(pod, nil, podStatus, killPodOptions.PodTerminationGracePeriodSecondsOverride); err != nil {
kl.recorder.Eventf(pod, v1.EventTypeWarning, events.FailedToKillPod, "error killing pod: %v", err)
// there was an error killing the pod, so we return that error directly
utilruntime.HandleError(err)
return err
}
return nil
}
...
return nil
}
pkg/kubelet/kuberuntime/kuberuntime_container.go
// killContainer kills a container through the following steps:
// * Run the pre-stop lifecycle hooks (if applicable).
// * Stop the container.
func (m *kubeGenericRuntimeManager) killContainer(pod *v1.Pod, containerID kubecontainer.ContainerID, containerName string, message string, gracePeriodOverride *int64) error {
...
if len(message) == 0 {
message = fmt.Sprintf("Stopping container %s", containerSpec.Name)
}
m.recordContainerEvent(pod, containerSpec, containerID.ID, v1.EventTypeNormal, events.KillingContainer, message)
// 空壳函数,没有实际作用,估计是为了以后的扩展用的
if err := m.internalLifecycle.PreStopContainer(containerID.ID); err != nil {
return err
}
// 这里真正执行 deployment 中 lifecycle preStop 设置的动作或命令
if containerSpec.Lifecycle != nil && containerSpec.Lifecycle.PreStop != nil && gracePeriod > 0 {
gracePeriod = gracePeriod - m.executePreStopHook(pod, containerID, containerSpec, gracePeriod) // 计算 TerminationGracePeriodSeconds 与 lifecycle preStop 执行时间的差值
}
// 如果剩余时间比 2秒少,就修改剩余时间为 2秒,也就是说不论什么情况,最小至少有2秒的强行关闭的时间
if gracePeriod < minimumGracePeriodInSeconds {
gracePeriod = minimumGracePeriodInSeconds
}
if gracePeriodOverride != nil {
gracePeriod = *gracePeriodOverride
klog.V(3).Infof("Killing container %q, but using %d second grace period override", containerID, gracePeriod)
}
klog.V(2).Infof("Killing container %q with %d second grace period", containerID.String(), gracePeriod)
// 调用 dockershim 的接口,然后向 dockerd 调用 /container/{containerID}/stop 接口,执行 gracePeriod 超时时间的优雅停机
err := m.runtimeService.StopContainer(containerID.ID, gracePeriod)
if err != nil {
klog.Errorf("Container %q termination failed with gracePeriod %d: %v", containerID.String(), gracePeriod, err)
} else {
klog.V(3).Infof("Container %q exited normally", containerID.String())
}
// 清理资源
m.containerRefManager.ClearRef(containerID)
return err
}
Docker 代码
moby/daemon/stop.go
// containerStop sends a stop signal, waits, sends a kill signal.
func (daemon *Daemon) containerStop(ctx context.Context, ctr *container.Container, options containertypes.StopOptions) (retErr error) {
...
var (
// 获得配置的 StopSignal 值,一般我们不会做配置,所以这里默认就是 SIGTERM
stopSignal = ctr.StopSignal()
...
)
...
// 1. 发送关闭信号 SIGTERM
err := daemon.killPossiblyDeadProcess(ctr, stopSignal)
if err != nil {
wait = 2 * time.Second
}
...
// 2. 启动一个超时等待器,等待超时(TerminationGracePeriodSeconds (默认 30s) - preStop 执行时间的差)
if status := <-ctr.Wait(subCtx, container.WaitConditionNotRunning); status.Err() == nil {
// container did exit, so ignore any previous errors and return
return nil
}
...
// 3. 如果超时,发送关闭信号 SIGKILL
if err := daemon.Kill(ctr); err != nil {
// got a kill error, but give container 2 more seconds to exit just in case
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
status := <-ctr.Wait(subCtx, container.WaitConditionNotRunning)
if status.Err() != nil {
logrus.WithError(err).WithField("container", ctr.ID).Errorf("error killing container: %v", status.Err())
return err
}
// container did exit, so ignore previous errors and continue
}
return nil
}
通过上面的代码,验证了之前架构图中流程。我们这边可以简单的终结下一些内容:
- kubelet 作为观察者监控着 ApiServer 中的变化,只是机械的调用 syncPod 方法去完成当前 node 内的 Pod 状态更新。(删除 Pod 也算是一种 Pod 的状态更新)
- kubelet 不对 Pod 内的 container 应用程序发送任何信号,包括信号的之间时间差如何控制,这个是由 CRI 接口实现体来操作。(一般我们都是 dockerd,这里就是由 docker 向 container 发送信号,并控制不同信号的之间的时间间隔和逻辑)
隐含的时间轴
- TerminationGracePeriodSeconds(T1): 总体 Pod 关闭容忍时间。这个值并不是一个固定参考值,每一个应用对着值的要求也不一样,它跟着 Deployment 走,所以这个值有明确的业务属性。
- Lifecycle PreStop Hook 执行时间(T2): 等待应用进程关闭前需要执行动作的执行时间,这个主要是影响 “新建请求” 到业务 Pod,因为在执行 preStop 的时候 k8s 网络层的变更也在执行。
- Container Graceful Stop 执行时间(T3): 等待应用自主关闭已有请求的连接,同时结束到数据库之类后端数据写入工作,保证数据都落库或者落盘。
- Kubernetes 网络层变更时间(T4)
原则公式:T1 = T2 + T3
复杂的逻辑:
这里总结下 Kubernetes 网络层变更时间与 TerminationGracePeriodSeconds 之间在不同情况下,有可能对 http 业务的影响。
场景 | HTTP_响应代码 | 描述 |
---|---|---|
T4<=T2 | 200 | 正常 |
T2<T4<=T1 | 200/404 | 少量 404,主要看应用的 webservice 如何关闭,如果关闭的优雅,只有 200 |
T1<T4 | 502 | Bad Gateway,后面的 Pod 已经消失了,但是网络层还没有完成变更,导致流量还在往不存在的 Pod 转发 |
处理方法
心思新密的小伙伴可能逐渐发现,要解决问题,实际就是做一个巧妙的动作调整时间差,满足业务 pod 能够真正的正确的关闭。
知道了原因,知道了逻辑,那顺理成章的就有了解决方案:
- 容器应用进程中要有优雅退出代码,能够执行优雅退出;
- 增加 preStopHook,能够执行一定时间的 sleep;
- 修改 TerminationGracePeriodSeconds,每一个业务根据实际需要修改;
当然还有关键的时间点需要考虑:
- 尽量满足 T3 >= T4,这样能够保证新建请求能转移到新 Pod 上。
- 合理配置 T1 和 T2 的值,留下合理的时间 T3 给 Pod 内的应用做优雅关闭。
举个栗子
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: abc
name: abc
spec:
replicas: 1
selector:
matchLabels:
app: abc
template:
metadata:
labels:
app: abc
spec:
containers:
image: xxxx.com/abc:v1
imagePullPolicy: IfNotPresent
name: app
ports:
- containerPort: 8086
name: http
protocol: TCP
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"] # 延迟关闭 10秒 -- T2
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 60 # 容忍关闭时间 60秒 -- T1, T3时间就是 60 - 10 = 50秒
最终效果
最终我们用脚本平凡修改 Deployment 中的 Image 的值,模拟版本发布,我们记录这一段时间的日志信息,观察是否存在有异常的情况。
最后发现一切正常,满足了我们的预期。