详细解读 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<T4<=T1 200/404 少量 404,主要看应用的 webservice 如何关闭,如果关闭的优雅,只有 200
T1<T4 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 请求

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,233评论 6 495
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,357评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,831评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,313评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,417评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,470评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,482评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,265评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,708评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,997评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,176评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,827评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,503评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,150评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,391评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,034评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,063评论 2 352

推荐阅读更多精彩内容