详细解读 Kubernetes 中 Pod 优雅退出,帮你解决大问题...

我是 LEE,老李,一个在 IT 行业摸爬滚打 16 年的技术老兵。

事件背景

不少研发小伙伴在使用 Pod 作为自己应用承载平台的时候,从来不重视应用的优雅停机(Graceful Stop)。我记得一个研发跟我说到:“你只要保证我的 Pod 能够被关闭,让我做优雅停机不是多此一举嘛?”。 真的是多此一举嘛? 这个研发所在的业务组在最近大版本更迭的时候,计划是平滑升级,使用 Pod 的滚动更新,可是万万没有想到,因为应用没有优雅停机,导致大量应用没有正常关闭业务请求,请求出现很多的 404 和 502 的情况,对业务的连续性带来了很多压力。

现象获取

收集了不少其他业务的问题反馈,都有类似的情况,而且是在发布新应用的时候。因为 Deployment 中 Image 字段发生了变化,触发 K8S 滚动更新,新 Pod 创建,老 Pod 关闭。(我想后面的关闭应用 Pod 也会有这样的问题,都是有 Pod 被 Kill)

发布新版本

总结下刚才的问题:

  1. 可能会出现 Pod 未将正在处理的请求处理完成的情况下被删除,如果该请求不是幂等性的,则会导致状态不一致的 bug。(此时会出现 404)
  2. 可能会出现 Pod 已经被删除,Kubernetes 仍然将流量导向该 Pod,从而出现用户请求处理失败,带来比较差的用户体验。 (此时会出现 502)

在 Kubernetes Pod 的删除过程中,同时会存在两条并行的时间线,如下图所示。

  1. 一条时间线是网络规则的更新过程。
  2. 另一条时间线是 Pod 的删除过程。
Pod 删除过程(没有 preStop 和 GracefulStop)

原理分析

表面上看起来是应用没有正常关闭之前的会话,导致业务连续性出现了问题。我想说实际上是:对关闭 Pod 关闭过程中信号机的状态理解不够。 既然要讲清这个问题,一切都从 TerminationGracePeriodSeconds 开始说起,我们回顾下 k8s 关闭 Pod 的流程过程。

网络层面

  1. Pod 被删除,状态置为 Terminating。
  2. Endpoint Controller 将该 Pod 的 ip 从 Endpoint 对象中删除。
  3. Kube-proxy 根据 Endpoint 对象的改变更新 iptables 规则,不再将流量路由到被删除的 Pod。
  4. 如果还有其他 Gateway 依赖 Endpoint 资源变化的,也会改变自己的配置(比如我们的 traefik)。

Pod 层面

  1. Pod 被删除,状态置为 Terminating。
  2. Kubelet 捕获到 ApiServer 中 Pod 状态变化,执行 syncPod 动作。
  3. 如果 Pod 配置了 preStop Hook ,将会执行。
  4. kubelet 对 Pod 中各个 container 发送调用 cri 接口中 StopContainer 方法,向 dockerd 发送 stop -t 指令,用 SIGTERM 信号以通知容器内应用进程开始优雅停止。
  5. 等待容器内应用进程完全停止,如果在 terminationGracePeriodSeconds (默认 30s) - preStop 执行时间内还未完全停止,就发送 SIGKILL 信号强制杀死应用进程。
  6. 所有容器进程终止,清理 Pod 资源。

我们重点关注下几个信号:K8S_EVENT, SIGTERM, SIGKILL

  • K8S_EVENT: SyncPodKill,kubelet 监听到了 apiServer 关闭 Pod 事件,经过一些处理动作后,向内部发出了一个 syncPod 动作,完成当前真实 Pod 状态的改变。
  • SIGTERM: 用于终止程序,也称为软终止,因为接收 SIGTERM 信号的进程可以选择忽略它。
  • SIGKILL: 用于立即终止,也称为硬终止,这个信号不能被忽略或阻止。这是杀死进程的野蛮方式,只能作为最后的手段。

了解信号的解释以后,我们可以关系下 kubelet 是怎么关闭 Pod。我们一起看下流程图(包含 preStop 和 GracefulStop):

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
}

通过上面的代码,验证了之前架构图中流程。我们这边可以简单的终结下一些内容:

  1. kubelet 作为观察者监控着 ApiServer 中的变化,只是机械的调用 syncPod 方法去完成当前 node 内的 Pod 状态更新。(删除 Pod 也算是一种 Pod 的状态更新)
  2. 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 200/404 少量 404,主要看应用的 webservice 如何关闭,如果关闭的优雅,只有 200
T1 502 Bad Gateway,后面的 Pod 已经消失了,但是网络层还没有完成变更,导致流量还在往不存在的 Pod 转发

处理方法

心思新密的小伙伴可能逐渐发现,要解决问题,实际就是做一个巧妙的动作调整时间差,满足业务 pod 能够真正的正确的关闭。

知道了原因,知道了逻辑,那顺理成章的就有了解决方案:

  1. 容器应用进程中要有优雅退出代码,能够执行优雅退出;
  2. 增加 preStopHook,能够执行一定时间的 sleep;
  3. 修改 TerminationGracePeriodSeconds,每一个业务根据实际需要修改;

当然还有关键的时间点需要考虑:

  1. 尽量满足 T3 >= T4,这样能够保证新建请求能转移到新 Pod 上。
  2. 合理配置 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 的值,模拟版本发布,我们记录这一段时间的日志信息,观察是否存在有异常的情况。

模拟发版后,Pod 响应 Http 请求

最后发现一切正常,满足了我们的预期。

你可能感兴趣的:(详细解读 Kubernetes 中 Pod 优雅退出,帮你解决大问题...)