AI时代的Kubernetes教程36招-第9招-Pod生命周期、Probe、Init Container和Sidecar怎么正确设计?

深入Pod生命周期:探针、Init Container 与优雅终止

Pod 是 Running,不等于你的服务能用。

[读者] 在 Kubernetes 上跑 AI 推理与实时会话的后端/平台工程师
[痛点] Pod 反复重启、Gateway 502、会话被硬切,却查不到原因
[现在读] 模型加载要几分钟,探针与终止窗口必须重新建模
[读完] 能用 Phase、Condition、探针、钩子把 Pod 生命周期管住
[旧方案] 一个 livenessProbe 管到底
    |
    v
[新需求] 慢启动模型 + 多副本滚动 + 实时会话不能断
    |
    v
[冲突] 探针看的是进程,SIGTERM 是异步的,业务看的是可用
    |
    v
[后果] CrashLoopBackOff、502、会话被拦腰截断

我是老李,这几年一直在做后端服务和部署这件事。最近在整理一个语音会话平台的部署清单,目录是 deploy/base/pods/,里面就三个 YAML:asr-deployment.yaml、livekit-deployment.yaml、gateway.yaml。

系统的样子并不复杂。Gateway 把外部连接路由到 ASR Pod;ASR Pod 里跑一个推理进程,启动时要把几百 MB 的声学模型读进显存;LiveKit Pod 负责实时音视频会话,一个房间可能持续几十分钟,中途断一下,用户是能听出来的。

变化来自三件事同时发生:模型换大了,冷启动从十几秒变成几分钟;副本数从 1 变成 3,开始滚动更新;Gateway 上线,流量开始按就绪状态分发。

为什么 Pod 显示 Running、容器 1/1,Gateway 还是回 502?

为什么模型还在加载,容器就被 kubelet 杀掉了?

为什么滚动更新的时候,老 Pod 上的会话是被直接掐断的?

01、故事

现场是一份接过来的清单,最早只有一份 Deployment 和一条 livenessProbe:

          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 3

这条探针的语义是:容器起来 30 秒后开始每 10 秒打一次 /healthz,连续失败 3 次就重启容器。模型小的时候,20 秒能加载完,30 秒的延迟刚刚好,探测也能及时暴露进程假死。

任务是让这套东西在滚动更新和模型热加载的前提下不掉线,所以我决定不再靠「调大延迟」这种手感,而是先老老实实把 Pod 的生命周期读一遍。

读的过程中我意识到一件事:我一直在用「进程是否活着」的假设,去回答「业务是否可用」的问题。这两个问题,在 Kubernetes 里根本是两套东西。

冲突就摆在这里:模型加载要几分钟,探针却在几十秒内就开始判定;Gateway 只认就绪状态,配置里却没有任何就绪信号;删除 Pod 时 kubelet 发的是 SIGTERM,而 LiveKit 的会话清理是异步的。

清单只三行
语义却千钧
运行非可用
此问始开头

02、问题

旧方案失效得很彻底。「一个 livenessProbe 管到底」的做法,在三个维度上同时崩。

第一,启动期被误判。模型加载期间 HTTP 端口已经监听,但 /healthz 后面的推理引擎还没起来,探针拿到的必然是失败。

第二,就绪期没有信号。配置里没有 readinessProbe,kubelet 只能认为容器 Running 即 Ready,Gateway 于是把流量打进还在加载的 Pod。

第三,终止期没有缓冲。Pod 被删除时,EndpointSlice 摘除和 SIGTERM 发送是并发进行的,进程可能在流量还被转发过来的瞬间就开始关会话。

业务影响是很具体的:用户侧看到连接被拒、语音卡顿、会话中途断开;运维侧看到 Pod 反复重启,日志却只显示进程被信号杀死。

技术表现可以归纳为三类可观测现象:

  • 容器状态在 Waiting(CrashLoopBackOff)和 Running 之间来回跳,Last State 是 Terminated,退出码是 137。
  • Pod 的 READY 列是 1/1,但 EndpointSlice 里的地址被 Gateway 打回来 502。
  • 滚动更新期间,老 Pod 的日志里出现 SIGTERM 之后紧跟着会话被清理的记录,但新 Pod 还没就绪。

本篇的完成标准是三条可验证的判定:

  1. 模型加载期间,liveness 探针不触发重启,容器状态稳定在 Running。
  2. 未就绪的 Pod 不出现在 EndpointSlice 的就绪地址里。
  3. 删除 Pod 时,进程在宽限期内自行退出,退出码为 0。

探针误判时
重启即成灾
流量无门入
会话断如刀

03、原理

先摆事实。Pod 的生命周期在 API 层有三层描述,它们的分工完全不同。

第一层是 status.phase:Pending、Running、Succeeded、Failed、Unknown。这是一个粗粒度的摘要,描述 Pod 在调度和运行上的整体位置。

第二层是 status.conditions:PodScheduled、Initialized、ContainersReady、Ready 等。这里是结构化的布尔判断,Ready 才是流量接入的依据。

第三层是 status.containerStatuses[].state:Waiting、Running、Terminated。Waiting 带 reason(比如 CrashLoopBackOff),Terminated 带退出码和信号。

反直觉判断:phase: Running 和「服务可用」之间没有因果关系。只要 Pod 里还有一个容器在跑,Phase 就可以是 Running,而这个容器可能正卡在模型加载、连不上下游、或者根本没监听端口。Phase 是给人看的摘要,不是给流量用的开关。

真正给流量用的开关是 Ready 这个 Condition,而它由 readinessProbe 决定。

三类探针的分工是这篇的核心。startupProbe 只解决「启动完成了吗」,它成功之前 liveness 和 readiness 都不会执行——是被屏蔽,不是并行;它的最大容忍时间约等于 failureThreshold × periodSeconds。livenessProbe 只解决「进程还活着吗」,失败就重启容器,它是恢复手段,不是健康评估手段。readinessProbe 只解决「现在能接流量吗」,失败不重启,只把 Pod 从 Service 的 EndpointSlice 里摘掉。

另一个反直觉判断:探针失败不等于业务出错,探针成功也不等于业务正确。探针只是 kubelet 能拿到的一个外部信号,它的价值完全取决于你让它调用什么端点。

restartPolicy 有 Always、OnFailure、Never 三种,作用于 Pod 内所有容器;kubelet 的重启带指数退避,退避上限为 5 分钟,这就是 CrashLoopBackOff 的由来。

Init Container 和 Sidecar 的分工,是「一次性准备」和「长期协同」的区别。Init Container 按声明顺序串行执行,每个都必须成功退出,下一个才开始;它不支持 lifecycle、livenessProbe、readinessProbe、startupProbe。原生 Sidecar 是 init container 的一种特例:把 restartPolicy 设为 Always,它在主容器之前启动但不阻塞主容器,并在 Pod 终止时最后退出——这正是代理类容器想要的语义。

终止路径值得单独记一遍:Pod 被删除到 EndpointSlice 控制器开始摘除地址,kubelet 执行 preStop hook,preStop 结束后才发送 SIGTERM,terminationGracePeriodSeconds 计时结束仍未退出则 SIGKILL。注意摘除和 SIGTERM 之间没有顺序保证,这就是为什么需要在 preStop 里主动制造一小段缓冲。

相位是摘要
条件才算数
探针分三类
各司一段程

04、架构

[输入] Pod 创建 / 删除请求
    |
    v
[模块] kubelet + 探针执行器 + EndpointSlice 控制器
    |
    v
[数据/状态] phase / conditions / containerStatuses
    |
    v
[处理] Init 串行启动 -> Sidecar 前置启动 -> 主容器启动 -> startup -> readiness -> liveness -> preStop -> SIGTERM -> SIGKILL
    |
    v
[输出] 就绪地址 / SIGTERM / 退出码

这张图的边界很清楚:kubelet 只负责把状态机跑对,它不知道你的模型加载到哪一步;EndpointSlice 控制器只负责同步地址,它不知道你的进程还能不能接住请求;Gateway 只读就绪地址,它不看 Phase。

收益是:把「服务可用」拆成三个可独立观测、可独立配置的信号,每一段都有自己的时间预算。代价是:多出一组探针端点需要维护,端点写错比不写更危险——一个永远返回 200 的 /readyz 等于没配。

适用条件是:只要出现「启动慢」或者「多副本接流量」这两个特征之一,就必须显式配置 startupProbe 和 readinessProbe。

一入一出间
状态即契约
边界画得清
故障自可寻

05、实战一次

环境是 Kubernetes 1.29、kubectl、kustomize,镜像用私有仓库占位。先把 V1 落地,只保留最原始的一条 livenessProbe。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: asr
  namespace: voice
  labels:
    app: asr
spec:
  replicas: 1
  selector:
    matchLabels:
      app: asr
  template:
    metadata:
      labels:
        app: asr
    spec:
      containers:
        - name: asr
          image: registry.example.com/voice/asr:0.1.0
          ports:
            - name: http
              containerPort: 8080
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
  name: asr
  namespace: voice
spec:
  selector:
    app: asr
  ports:
    - name: http
      port: 8080
      targetPort: http

部署与观察:

kubectl apply -k deploy/base/pods/
kubectl get pod -n voice -l app=asr -w
kubectl get endpointslice -n voice -l kubernetes.io/service-name=asr -o wide

未在当前环境实测,以下为预期结果。

NAME                   READY   STATUS             RESTARTS      AGE
asr-6c9f7b8d4-2xkzp    0/1     Running            3 (20s ago)   95s

V1 能跑通的部分是:容器如期启动,端口可达,Service 有地址。留下的问题是:RESTARTS 从 1 涨到 3,/healthz 在加载完成前一直不通过,liveness 在 90 秒左右就把容器杀了。这就是下一步要处理的第一个故障。

先跑通一条
再谈优化路
未证不称果
有据才言改

06、排查

诊断一,CrashLoopBackOff 与退出码 137。

现象:Pod 的 READY 长期是 0/1,RESTARTS 持续增长,STATUS 在 Running 和 CrashLoopBackOff 之间跳。

怀疑:是 OOMKilled,还是探针误判?

检查:

kubectl describe pod -n voice asr-6c9f7b8d4-2xkzp
kubectl get events -n voice --field-selector involvedObject.name=asr-6c9f7b8d4-2xkzp
kubectl logs -n voice asr-6c9f7b8d4-2xkzp --previous --tail=50

证据(示例输出):

Events:
  Type     Reason     Age                From     Message
  ----     ------     ----               ----     -------
  Normal   Started    100s               kubelet  Started container asr
  Warning  Unhealthy  60s (x3 over 30s)  kubelet  Liveness probe failed: Get "http://10.244.1.7:8080/healthz": dial tcp 10.244.1.7:8080: connect: connection refused
  Normal   Killing    60s                kubelet  Container asr failed liveness probe, will be restarted

根因:容器进程在启动后约 30 秒就被判定存活,而推理引擎要三分钟左右才完成模型加载并挂上 /healthz 处理函数,探针在窗口内连续失败三次。

修复:把启动期从 liveness 的判定范围里移出去,改用 startupProbe 覆盖启动期。

错误尝试: 我做的第一件事是把 initialDelaySeconds 从 30 直接改成 600。这个改法错在两点。其一,initialDelaySeconds 只影响第一次探测的起点,它把 liveness 变成了一段长期盲区——模型加载完之后如果进程真的假死,600 秒内没有任何信号能发现。其二,它把「启动慢」这个业务事实硬编码成固定秒数,一旦换更大的模型或者节点磁盘变慢,600 秒同样会被打穿。正确的做法是让启动期有明确的结束信号,也就是由进程自己决定什么时候算启动完成。

诊断二,Running 却是 502。

现象:Pod 显示 1/1 Running,Gateway 返回 502。

怀疑:Service selector 错了,还是 readiness 没生效?

检查:

kubectl get endpointslice -n voice -l kubernetes.io/service-name=asr -o yaml
kubectl get pod -n voice asr-6c9f7b8d4-2xkzp -o jsonpath='{.status.conditions}'

证据(示例输出):

- addresses:
  - 10.244.1.7
  conditions:
    ready: true

根因:因为没有配置 readinessProbe,kubelet 采用默认语义,容器 Running 即视为 Ready,EndpointSlice 的 ready 直接是 true,Gateway 于是把一个还在加载模型的 Pod 当成后端。

修复:补上 readinessProbe,端点必须真正校验模型可用性,而不是只返回一个常量 200。

诊断三,滚动更新时会话被硬切。

现象:执行 kubectl rollout restart 期间,LiveKit 的会话日志出现异常结束。

怀疑:是进程崩了,还是被终止信号打断了?

检查:

kubectl get deployment livekit -n voice -o jsonpath='{.spec.template.spec.terminationGracePeriodSeconds}'
kubectl get pod -n voice -l app=livekit -o yaml | grep -A3 lifecycle

证据:宽限期是默认值,容器没有定义 preStop,日志中 SIGTERM 之后紧跟着会话清理,而 EndpointSlice 中的地址尚未摘除。

根因:没有 preStop 缓冲,也没有足够宽的宽限期,SIGTERM 一到进程就开始关会话,而这时流量可能还在被转发过来。

修复:见下一章。

先看事件流
再验状态量
错改亦为证
根因自现身

07、优化

基于上一章的三个根因,V2 做四处修改。

根因一,启动期被 liveness 判定。修改是新增 startupProbe:

          startupProbe:
            httpGet:
              path: /startupz
              port: http
            periodSeconds: 10
            failureThreshold: 30
            timeoutSeconds: 2

原因是 startupProbe 成功之前,liveness 和 readiness 都会被挂起,启动期的失败不再计入 liveness 的重启判定。最大容忍时间约 300 秒,覆盖当前模型加载时长,并且这个上限由探针字段显式表达,比一个神秘的 initialDelaySeconds: 600 更容易读。

新行为:加载期间 Pod 保持在 Running 且不重启,加载完成后 /startupz 返回成功,探针切换到正常巡检。

根因二,缺少就绪信号。修改是新增 readinessProbe,并让 Init Container 先把模型拉到本地卷:

      initContainers:
        - name: model-fetch
          image: registry.example.com/voice/asr-model:2024.09
          command: ["/bin/sh", "-c", "cp -r /models/. /work/models/"]
          volumeMounts:
            - name: models
              mountPath: /work/models
      containers:
        - name: asr
          readinessProbe:
            httpGet:
              path: /readyz
              port: http
            periodSeconds: 5
            failureThreshold: 2

原因是就绪语义必须由推理引擎自己回答,/readyz 在校验模型句柄和显存分配之后才返回 200。Init Container 承担启动前准备的职责,按顺序串行执行,失败会阻止主容器启动,比在主容器 ENTRYPOINT 里塞一段 shell 更容易观测。

新行为:模型未就绪的 Pod 不会进入 EndpointSlice 的就绪地址,Gateway 不会把流量打进来。

根因三,终止期没有缓冲。修改是加 preStop 并放大宽限期:

      terminationGracePeriodSeconds: 120
      lifecycle:
        preStop:
          exec:
            command: ["/bin/sh", "-c", "curl -s -X POST http://127.0.0.1:8080/drain && sleep 5"]

原因是 kubelet 在 preStop 完成之后才发送 SIGTERM,这段 sleep 5 给了 EndpointSlice 摘除和 Gateway 更新后端列表留出时间,/drain 让进程主动停止接收新会话。terminationGracePeriodSeconds 从删除开始计时,必须大于 preStop 时长加上进程自身清理会话的最长时间。

新行为:删除 Pod 时,进程收到 SIGTERM 后先停止接受新会话,再逐个关闭房间,最后自行退出,退出码为 0。

关于 Sidecar,LiveKit 侧的指标代理改写成原生 Sidecar 形式:

      initContainers:
        - name: metrics-proxy
          image: registry.example.com/voice/metrics-proxy:0.3.0
          restartPolicy: Always
          ports:
            - containerPort: 9100

原因是 restartPolicy: Always 让它成为一个不阻塞主容器的 Sidecar,主容器启动前它已经在跑,Pod 终止时它最后退出,从而能在会话排空期间持续采集指标。

验证方案(结果为预期,不构成实测数据):

kubectl rollout status deployment/asr -n voice
kubectl get pod -n voice -l app=asr -w
kubectl delete pod -n voice asr-xxxx --wait=false

观察三点:滚动过程中新 Pod 只有在 /readyz 通过后才进入就绪地址;删除 Pod 时日志中出现 SIGTERM 处理记录;容器退出码为 0。

根因定一条
改动只一处
行为可复现
才算真优化

08、演进

[同一输入] 删除一个正在承载会话的 ASR Pod
    |
    +--[V1] 直接 SIGTERM:进程立即关会话;宽限期 30s;代价是会话硬切、502
    |
    +--[V2] preStop drain + SIGTERM + 120s 宽限期:代价是删除变慢、需要维护 /drain
    |
[Trade-off]
得到什么:会话可排空、流量可摘净
失去什么:单次删除耗时变长、副本缩容变慢
适用边界:长连接与会话型服务;短请求型服务不必如此

正确性上,V1 把探针语义用错了对象,V2 把启动、就绪、存活三个判断分开,语义对齐。

稳定性上,V1 会因为加载时长波动而随机重启,V2 的重启只由真实的存活失败触发。

复杂度上,V1 只有一个字段,V2 多了三个探针端点、一个 Init Container、一个 Sidecar 和一段 preStop 脚本,需要团队维护这些端点的语义。

成本上,V2 需要更长的删除时间和更宽的宽限期,副本数调整的节奏会变慢。

适用范围上,V2 适合会话长、启动慢的服务;对无状态短请求服务,startupProbe 和 preStop 的收益会小很多。

遗留问题:/readyz 只能证明加载完成,不能证明下游依赖可用;如果 ASR 依赖的特征库不可达,readiness 需要把这个依赖纳入判断,否则会把单点故障放大成整批 Pod 不就绪。

旧路求稳快
新路求可控
得失写得明
演进才不虚

09、洞见

9.1 探针的语义是判断权归属,不是阈值调参

反直觉判断:探针配置的核心问题不是阈值调多少,而是这个判断应该由谁来做。进程活着由 kubelet 判断,模型可用只能由进程自己判断。把后者的判断权交给 kubelet,就只能得到基于端口的粗粒度结论。

9.2 Running 是一个摘要,Ready 才是契约

反直觉判断:越靠近调度的状态字段,越不适合做流量决策。Phase 是给人和控制器看的总览,Condition 才是可以被 Gateway、EndpointSlice 消费的契约。依赖 Phase 做灰度,等于依赖一个摘要做精确判断。

9.3 Init Container 与 Sidecar 是同一种机制的两个方向

两者可以用 restartPolicy 区分:一次性的必须成功退出,长期的在主容器之前起、之后退。把这个区分想清楚,就不会出现「在 ENTRYPOINT 里写一段 cp 命令」这种既难观测又难排障的写法。

9.4 优雅终止是一个时间预算问题

从删除请求发出到 SIGKILL,中间的每一段都在消耗 terminationGracePeriodSeconds:preStop 的执行、SIGTERM 后的处理、会话排空。宽限期不是越大越好,它要覆盖最长的一次排空,同时不能让缩容等待无限延长。

判断权归位
契约才生效
预算算得清
终止亦从容

10、系统落地

原来有的是:一份只带 livenessProbe 的 Deployment,一个不带就绪信号的 Service,一段默认 30 秒的终止窗口。

本篇新增的是:覆盖启动期的 startupProbe、回答就绪语义的 readinessProbe、承担启动前准备的 Init Container、长期协同的 Sidecar,以及由 preStop 与 terminationGracePeriodSeconds 组成的终止预算。

现在能做的:模型加载期间 Pod 不再被误杀;未就绪的 Pod 不会接到 Gateway 的流量;删除 Pod 时会话可以被排空。

还缺的:/readyz 没有覆盖下游依赖;startupProbe 的上限能否覆盖未来更大的模型尚未验证;Sidecar 的退出顺序只在支持原生 Sidecar 的 Kubernetes 版本上成立,其他版本需按当前官方文档核验;探针端点本身还缺少对应的测试。

下一步演进:把启动耗时做成可观测指标,用实际加载时间反推 failureThreshold;把 /readyz 扩展为依赖探测;在测试环境里用 kubectl delete pod --wait=false 做终止路径的回归。

一处添一能
一分增一险
清单即系统
改动即演进

11、小结

Q1 -> Phase=Running 只是摘要,Ready 才是流量契约;缺 readinessProbe 时容器 Running 即视为 Ready
Q2 -> 启动期的失败不该由 liveness 判定,startupProbe 成功前 liveness 与 readiness 都被挂起
Q3 -> 终止是异步的,需要 preStop 缓冲 + SIGTERM 处理 + 足够宽的 terminationGracePeriodSeconds
状态 -> deploy/base/pods/ 具备慢启动、按就绪接流、可排空终止的能力

三问皆有答
有据方成文
系统至此稳
余事待后章

12、作业

12.1 理解题:为什么 readinessProbe 失败不会重启容器,而 livenessProbe 失败会?

参考答案:两者的语义不同。readinessProbe 回答「现在能否接流量」,失败时控制器把 Pod 从 Service 的就绪地址中移除,进程继续运行;livenessProbe 回答「进程是否应该被重启」,失败时 kubelet 按 restartPolicy 重启容器。把两者混用,要么造成无谓重启,要么造成流量打到不可用实例。

12.2 实战题:模型加载约三分钟,如何配置探针与终止预算?

参考答案:用 startupProbe 覆盖启动期,例如 periodSeconds: 10 与 failureThreshold: 30,最大容忍约 300 秒,成功后再交给 liveness 与 readiness;readinessProbe 指向真正校验模型可用性的端点;terminationGracePeriodSeconds 设为 preStop 时长加上最长会话排空时间;preStop 里做一次短暂等待,为 EndpointSlice 摘除留出时间。

12.3 排障题:Pod 显示 1/1 Running,Gateway 却返回 502,如何定位?

参考答案:先看该 Pod 是否出现在 EndpointSlice 的就绪地址中;再看 status.conditions 里的 Ready,以及容器是否配置了 readinessProbe;若没有配置,kubelet 默认容器 Running 即 Ready。随后核对 Service 的 selector、targetPort 与容器端口是否一致,以及探针端点是否只返回常量 200。

12.4 架构判断题:把模型下载放到 Init Container,还是放到主容器启动脚本里?

参考答案:倾向 Init Container。它按顺序串行执行、必须成功退出,失败会阻止主容器启动,并且状态可直接观测;放进主容器启动脚本,会让「启动慢」和「服务不可用」混在同一个容器状态里,探针和排障都会变难。若下载耗时长且需要复用缓存,可配合 PVC 或镜像内预置模型。

题问理解处
答在证据中
判断留边界
复用即成法

13、思考

这篇的起点是一个很朴素的误解:把 Pod 的生命周期状态当成了业务的健康状态。Phase、Condition、Container State 描述的是 Kubernetes 自己关心的东西——调度有没有完成、进程有没有在跑、容器是哪一种退出方式。业务是不是可用,只能由业务自己回答。

所以三类探针的本质不是三个阈值,而是三个不同的判断权归属:启动完成由进程回答,就绪与否由进程回答,存活与否可以交给 kubelet 的外部观测。把这个归属理清楚,参数是水到渠成的事;理不清楚,调多少秒都是猜。

优雅终止同理。它不是一个「加个 sleep」的技巧,而是一段时间预算的分配问题:从删除请求到 SIGKILL 之间的每一段——摘除、缓冲、SIGTERM 处理、排空——都要有明确的时间归属和明确的负责人。

下次再看到 Running 而服务不可用,先别急着调参数,先问一句:这个判断,应该由谁来做?

状态非业务
判断须归位
预算有归属
取舍自清明

posted @ 2026-10-12 10:24  李福春  阅读(2)  评论(0)    收藏  举报