健康检查全绿,服务其实已经死了:LLM 推理服务的"假活"陷阱
一个跑在 K8s 上的大模型推理服务:Pod Ready、/health 200、模型列表能查、指标正常往外吐,可用户发一条对话过去就是转圈到超时。排查到最后发现推理引擎进程早没了,所有健康信号都在替一具尸体说"我很好"。讲清为什么浅探针天然探不到引擎死活、两种死法里为什么"延迟引爆"最阴险,以及怎么用一条真正触 GPU 的深度探测把雷提前踩掉。道理不止于 LLM:进程活着,从来不等于服务活着。
前阵子遇到一个很邪门的故障。一个跑在 K8s 上的大模型推理服务,Pod 状态 Ready,/health 返回 200,/v1/models 能列出模型,/metrics 里的指标一行行往外吐,监控面板一片绿。但用户发一条对话请求过去,转圈,转圈,最后 504。
第一反应当然是怀疑上层:是不是网关配错了?是不是业务代码哪里把请求吞了?压测脚本姿势不对?在这几个方向上绕了不短的时间。直到有人进容器敲了一句 ps,发现推理引擎的进程根本不在了,显存也归了零。日志里干干净净,没有一行死亡记录。
服务死了,但所有健康信号都说它活着。这篇就说这个”假活”。2026 年推理服务正在成规模地搬进 K8s(llm-d 进 CNCF 沙箱就是个信号),这个坑会有越来越多人踩到。
探针只敲到了前门
要理解为什么全绿,得先知道现代推理框架长什么样。以 vLLM 为例,它不是一个进程,是两个:前面一个 HTTP 服务进程,负责收请求、回响应;后面一个引擎核心进程,负责调度批次、管理 KV cache、真正调用 GPU 算东西。新版本的 vLLM 把这两块拆得很明确,API server 和 EngineCore 各跑各的。
再看我们依赖的那几个健康信号都由谁应答:
/health:HTTP 进程回一个 200。/v1/models:HTTP 进程读一份配置,回模型列表。/metrics:HTTP 进程读一份内存里的计数器。- Pod Ready:kubelet 的探针打的就是上面这些接口,或者干脆只探 TCP 端口通不通。
发现问题了吗?这些信号没有一个需要 GPU 参与。只有真正的推理请求,才会穿过 HTTP 层、进引擎核心、触发 CUDA 调用。引擎核心死了,前面的 HTTP 进程照样活蹦乱跳,照样回 200,照样吐指标。探针敲的是前门,门房应一声”在呢”,至于后院的人早就不在了,门房也不知道。

vLLM 新一些的版本在 /health 里会顺带看一眼引擎进程还在不在,进程真没了能报出来。但”进程在,人已经不行了”这种,比如引擎卡死在某个 CUDA 调用里、或者 GPU 上下文已经失效,照样探不出来。所以这不是某个版本的 bug,是浅探针的天然盲区。
顺便说一个当时定下来的判定口径,后来省了很多扯皮:模型列表能查、对话不出字,就是引擎死了。到这一步就可以停止怀疑网关、业务代码和压测脚本了。它们都在引擎前面,引擎不出 token,谁来都没用。
两种死法,第二种更阴险
后来复盘,这类假活有两种典型死法。
第一种是静默被杀。容器里 ps 看不到引擎进程,nvidia-smi 里显存归零,但容器日志里没有任何记录。段错误这类死法,痕迹往往不在容器日志里,而在节点的内核日志里:到节点上 dmesg -T | grep -iE 'segfault|invalid opcode',时间戳对上了就是它。用了 vGPU 切分之类拦截库的环境尤其如此,容器日志经常干净得像什么都没发生。
第二种是延迟引爆,这个更阴险。GPU 相关的组件被动过之后,比如驱动升级、device plugin 重建、容器运行时的 GPU 钩子更新、vGPU 拦截库换版本,已经在跑的推理 Pod 不会立刻死。它手里那套 GPU 用户态状态已经和底下的组件对不上了,但只要没有请求来触发 CUDA 调用,它就能继续”健康”地运行几个小时甚至几天。直到生命周期里第一个真正的推理请求到来,砰,退出码 139。
这就解释了一个经典困惑:“昨天还好好的,今天早上第一个用户一用就挂。“因为昨天半夜运维动了 GPU 栈,而从那时到早上,没有一个请求碰过 GPU。雷是昨天埋的,今天才有人踩。
排查时有一条很好用的线索:把服务死亡的时间线和集群里 GPU 栈组件的重启、重建记录摆在一起看。两条时间线对齐了,基本就是实锤。
踩过这次之后,我把排查顺序固定成了四步,每一步都只回答一个问题:
- 是不是引擎死了? 查模型列表,再发一条最短的对话。前者通、后者挂,就是。
- 进程还在不在? 进容器
ps找引擎进程,nvidia-smi看显存有没有归零。 - 它是怎么死的? 到节点上翻
dmesg,找段错误一类的内核记录,对时间戳。 - 是谁埋的雷? 拉出同一时间窗内 GPU 栈组件的变更记录,看两条时间线能不能对上。
四步走完,基本就能说清楚”死没死、什么时候死的、被谁弄死的”。以前没有这个顺序,一上来就翻业务日志、改压测参数,一天就过去了。
解法:探针得走到”收钱的那条路”
短期处置有两条。一是 GPU 栈动荡期间,压测和排查业务代码都没有意义,先和运维对齐操作是否结束。二是 GPU 栈变更结束后,主动滚动重启该节点上全部存量的 GPU 业务 Pod。不重启的话,每个 Pod 都揣着一颗等第一个用户来引爆的雷。
长期解法只有一个:给推理服务加一条真正触 GPU 的深度健康检查。原则很简单,探针要走到你真正卖钱的那条路上。这个服务卖的是”输入一段话、吐出一段话”,那探针就该真的输入一句话,看它能不能吐出一个 token。
具体做法不复杂,一个 exec 探针,对本地端口发一条最小推理请求,max_tokens 设成 1:
readinessProbe:
exec:
command:
- sh
- -c
- |
curl -sf --max-time 25 http://127.0.0.1:8000/v1/completions \
-H 'Content-Type: application/json' \
-d '{"model":"demo-model","prompt":"hi","max_tokens":1}' \
| grep -q '"text"'
periodSeconds: 60
timeoutSeconds: 30
failureThreshold: 3
镜像里没有 curl 就换成几行 python,意思一样。有几个细节值得说:
探测频率别太高。一次真实推理会占一小片算力和调度队列里的一个位置,一分钟一次完全够用,几秒一次就是在给自己制造负载。
超时要给足。探测请求和用户请求一样要排队,服务忙的时候等个十几秒很正常。超时设短了,忙碌会被误判成死亡。
readiness 和 liveness 分开想。readiness 失败只是把 Pod 从 Service 后面摘掉,安全,可以激进一点。liveness 失败会重启 Pod,一个几十 GB 的模型重新加载要好几分钟,误杀的代价很大。但只挂 readiness 也不行:引擎真死了,Pod 会一直 NotReady 挂在那儿,没人来拉它一把。我的做法是深度探测同时挂 readiness 和 liveness,但 liveness 的阈值放得很宽,比如连续失败 5 次、每次间隔 60 秒。5 分钟都吐不出一个 token 的服务,重启它不冤。
startupProbe 也要配。模型加载动辄几分钟,没有 startupProbe 的话,liveness 会在加载途中就把它杀了,陷入起不来的死循环。
如果不想让探针本身带副作用,还有一个互补的做法:在集群外跑一个金丝雀。一个定时任务,每分钟走真实的入口,经网关发一条最短的对话请求,失败就告警。它和探针分工不同:探针守的是单个 Pod 能不能干活,金丝雀守的是用户走的整条路能不能通,包括网关、路由和鉴权。两个都有,才算把”能不能干那件事”这个问题问全了。
深度探测还有一个意外的好处:它把延迟引爆的雷提前踩了。GPU 栈半夜被动过,一分钟后探针就会真的去触一次 CUDA,当场炸、当场重启,早上用户来的时候 Pod 已经是新的了。雷还是那颗雷,但踩雷的从用户变成了探针。

复盘:进程活着不等于服务活着
这件事让我记了很久的一句话:进程活着,不等于服务活着。
这不是 LLM 特有的问题,只是 LLM 服务把它放大了:HTTP 层和干活的层拆成两个进程,中间还隔着一块 GPU,浅探针和真实健康度之间的距离比传统服务大得多。但传统服务里同样的坑到处都是。数据库连接池耗尽了,/health 照样 200,因为它没去连一下数据库。消息队列的消费线程死了,Web 端口照样通。线程池满了,请求全在排队,探针打的那个接口偏偏不走线程池。
就在昨天,我的 Mac 突然变得很卡,风扇狂转,负载均值飙到 20 多。top 一看,是系统的一个通讯录守护进程在死循环:进程好好活着,CPU 跑满,什么正事都没干。也是一种假活。
所以设计健康检查的时候,别问”进程在不在”,要问”它现在能不能干那件我雇它来干的事”。前一个问题谁都会答是,后一个问题才值得每分钟问一次。
评论