默认 30 秒 liveness 探针会误杀加载需 4 分钟的模型服务器;正确做法是调高 initialDelaySeconds 并结合 readinessProbe 做流量路由。
那个不是 crash 的 CrashLoopBackOff
默认的探针配置有明确的文档记录,且都很短:periodSeconds 为 10,failureThreshold 为 3,timeoutSeconds 为 1,successThreshold 为 1,initialDelaySeconds 为 0。在这种配置下,liveness probe 大约给容器 30 秒的响应时间,超时后 kubelet 就会判定容器已死并重启它。
对于一个 Web 服务来说,这个时间预算还算合理;但对于推理服务器就完全不够用了——推理服务器需要从磁盘或对象存储读取权重文件、把权重加载到 GPU、常常还要编译或预热 kernel,之后才能开始服务请求。而重启循环会让情况更糟:每次重启都会重新读取权重,在从远程存储拉取的网络环境中,如果有多个 pod 同时重启,带宽会被占满,而这些 pod 恰好需要这些带宽才能完成加载。
一个看似可行的修复方案是在 liveness probe 上设置一个很大的 initialDelaySeconds。这确实有效,但代价是 liveness probe 本来的意义:设置四分钟延迟后,第二个月时一个卡住的容器仍然要等四分钟才会被检测到,因为这个延迟适用于每次重启,而不仅仅是第一次。startup probe 正是为了避免这个权衡而存在的。
三种探针,三个问题
Startup —— "启动完成了吗?" Kubernetes 文档指出,liveness probe 和 readiness probe 在 startup probe 成功之前不会运行。一旦成功,它就不会再为该容器运行。
Readiness —— "应该接收流量吗?" 探针失败会从 Service 中移除该 pod 的 endpoint,但不会重启任何东西,这使得它适合用来处理临时的状态,比如请求队列满了。
Liveness —— "应该被杀掉吗?" 探针失败超过阈值会重启容器。它应该测试的是重启真正能修复的问题:比如死锁的请求循环、卡住的 CUDA context。它不应该测试依赖关系,因为一个缓慢的下游服务就会导致整个集群重启。
如果服务器支持,给每个探针配置独立的 endpoint。把 liveness 和 readiness 指向同一个 handler,意味着任何让 pod 忙碌的条件都会导致它被判定为死亡。
设置启动预算
启动预算是 failureThreshold 乘以 periodSeconds。这个乘积是 kubelet 杀死容器并应用 pod 重启策略之前,容器可用的最大启动时间。
从成功启动的 pod 日志中测量真实的加载时间:即进程启动到服务器宣布开始监听之间的间隔。单独计算镜像拉取时间——它发生在容器启动之前,不在启动预算内,但在 rollout 总时间内。
乘以一个舒适的系数。冷页缓存、繁忙的节点、更慢的对象存储读取、更大的模型变体——这些都会把同一个容器推到超出按最佳情况设置的预算。
把结果表达为较长的 failureThreshold 配合较短的 periodSeconds,而不是反过来。failureThreshold: 60 配合 periodSeconds: 10 给出十分钟的预算,但一旦成功就能在十秒内感知到。failureThreshold: 2 配合 periodSeconds: 300 同样给出十分钟,但每次启动最多会浪费五分钟。
部署后用 kubectl describe pod 确认:启动探针在模型加载过程中失败是正常的,会显示为 probe failure 事件但不触发重启。如果出现了重启,说明预算时间设得太短了。
startupProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 10
failureThreshold: 60
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 5
failureThreshold: 3
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 20
failureThreshold: 3
timeoutSeconds: 10
这里的每个探针都把 timeoutSeconds 从默认值 1 提高了,在 GPU pod 上这比在其他地方更重要。繁忙推理工作的服务器可能需要超过一秒来调度响应 HTTP 健康检查的线程,所以一秒的超时会把重负载变成重启——这是对重负载最糟糕的回应。另外注意 timeout 必须比 period 短,否则探针会重叠。
通常还有两个相关配置需要和这个配置块一起设置。pod 上的 terminationGracePeriodSeconds 应该足够长,让进行中的生成任务能够完成,因为一个很长的流式响应在默认的 30 秒时被杀死,对用户来说就是一个被截断的答案。preStop hook 配一个简短的 sleep,给 endpoints controller 时间在进程停止接收连接之前把 pod 从 Service 中移除,这样就消除了每次 rollout 时伴随出现的连接错误高峰。
这对 rollout 意味着什么
Readiness 控制着 rollout 的进度。Deployment 在新 pod 变为 Ready 之前不会越过 maxUnavailable 和 maxSurge 限制,所以十分钟的启动预算意味着大型集群的滚动更新需要相应更长的时间。由此产生两个后果。
第一,Deployment 上的 progressDeadlineSeconds —— 默认十分钟—— 可能在慢加载的 pod 还未 Ready 之前就已过期,将 rollout 标记为失败,而实际上它正在正常进行。它需要超过启动预算加上镜像拉取的时间,而不仅仅是与之持平。
第二,GPU Deployment 上的 maxSurge 意味着额外的 GPU。在四副本集群上 surge 一个意味着 rollout 期间需要存在第五块 GPU,如果没有,的新 pod 就处于 Pending 状态,老 pod 无法退役,rollout 就卡住了——但不会报错——诊断方法在 GPU 不足的修复页面上。对于固定大小的 GPU 池,maxSurge: 0 配合 maxUnavailable: 1 才是能实际完成部署的配置,代价是在整个 rollout 期间少运行一个副本。
Fixing CrashLoopBackOff on a GPU Inference Pod
Health Checks in a Dockerfile for a Model-Serving Container
Scheduling GPU Pods on Kubernetes