1. 为什么 LLM 服务的健康检查不能照搬普通 Web 服务
LLM 服务上线之后,我见过太多团队把健康检查写成同一个/health接口,liveness 和 readiness 指向同一个 URL,返回一个{"status":"ok"}就完事了。普通 CRUD 服务这么干,问题不大,顶多是滚动更新时多几次无效流量。但放到 LLM 服务上,这套做法会直接把你的集群搞崩。
原因在于 LLM 服务的启动和运行特征跟传统服务完全不同。传统服务启动通常几百毫秒到几秒,依赖数据库连接池预热一下就能对外服务。LLM 服务不一样:模型权重加载动辄几十 GB,从磁盘读进显存、做量化反量化、初始化 KV Cache 池、编译推理图,冷启动几分钟到十几分钟都是常态。如果你用同一个探针,Kubernetes 会在模型还没加载完的时候判定容器"活着但没准备好"——这还算好的;更糟的是有些团队把 liveness 也设成同一个接口,结果模型加载期间 liveness 超时,kubelet 直接把容器杀掉重启,于是陷入"加载→被杀→再加载→再被杀"的死循环,Pod 永远起不来。
所以核心结论先摆在这里:liveness 回答的是"这个进程是不是死锁了、需不需要重启",readiness 回答的是"这个实例现在能不能接流量"。这两个问题的答案在 LLM 场景下几乎永远不一致,必须分开写,而且判定逻辑要完全独立。
我先把两者的职责边界用一张表说清楚,后面所有实现都围绕这张表展开。
| 维度 | liveness | readiness |
|---|---|---|
| 回答的问题 | 进程是否陷入不可恢复状态 | 实例当前能否处理请求 |
| 失败后果 | 容器被重启 | 从 Service Endpoints 摘除 |
| 检查频率 | 低频(如 30s) | 高频(如 5s) |
| 超时容忍 | 宽松(如 10s) | 严格(如 2s) |
| 是否检查下游 | 绝对不检查 | 可以检查关键依赖 |
| LLM 场景典型判据 | 主循环心跳、事件循环未阻塞 | 模型已加载、显存充足、队列未满 |
这张表里最容易踩的坑是最后两行。很多人习惯在 liveness 里连一下 Redis、查一下数据库,觉得"依赖挂了服务就没意义了"。这在 LLM 服务里是灾难:向量库抖动一下,所有 Pod 的 liveness 同时失败,Kubernetes 把整个集群的推理实例全部重启,本来只是检索慢一点,结果变成全量不可用。liveness 只应该反映进程自身的健康,任何外部依赖都不能进 liveness。
2. LLM 服务启动阶段的真实时间线拆解
要把健康检查写对,得先搞清楚一个 LLM 容器从docker run到能接流量,中间到底经历了什么。我按自己部署过的一个 13B 模型(FP16,约 26GB 权重)的实际日志时间线来拆:
- 进程启动到框架初始化(0-3s):Python 解释器起来,导入 torch、transformers 或 vLLM 这类框架。这一步如果镜像里依赖没打好,可能直接卡在 import 上。
- 权重加载(3s-90s):从本地盘或网络存储读取 safetensors 分片,逐层搬到显存。这一步是 IO 和 PCIe 带宽瓶颈,网络盘会明显更慢。
- 显存分配与 KV Cache 池初始化(90s-150s):框架按
gpu_memory_utilization参数预留显存,切分出 KV Cache 块。这一步决定了后面能并发多少请求。 - 推理图编译与 warmup(150s-240s):CUDA graph 捕获、算子编译、跑几条 dummy 请求把 kernel 预热。第一次请求特别慢,就是因为没 warmup。
- 就绪(240s 之后):可以接真实流量。
看清楚这条时间线,很多设计就顺理成章了。initialDelaySeconds如果按传统服务设成 5 秒,那 liveness 会在第 5 秒就开始探测,而此时进程还在加载权重,探测必然失败。连续失败 3 次(默认failureThreshold)后容器被杀,模型加载前功尽弃。
正确的做法是给 liveness 一个足够大的initialDelaySeconds,或者更优雅地用startupProbe把启动阶段单独隔离出来。startupProbe 成功之前,liveness 和 readiness 都不会执行,这样启动慢就不会误杀。我一般这样配:
startupProbe: httpGet: path: /healthz/startup port: 8000 initialDelaySeconds: 10 periodSeconds: 10 failureThreshold: 60 # 10 + 60*10 = 610s 的启动预算 timeoutSeconds: 3failureThreshold: 60配合periodSeconds: 10,等于给了 10 分钟启动窗口。这个值不是拍脑袋来的,要按你最大模型的冷启动时间乘以 1.5 到 2 倍的安全系数。如果你的模型在共享存储上、或者节点有 GPU 争抢,启动时间会波动,留足余量比事后调参省事得多。
注意:startupProbe 的
failureThreshold设太大也有副作用——如果进程真的在启动阶段就崩了(比如权重文件损坏),你要等满 10 分钟才会看到重启。所以 startup 接口本身要能区分"还在加载"和"加载失败",加载失败直接返回 500,让探针快速失败。
3. liveness 接口到底该检查什么
现在进入正题,liveness 接口的实现。我反复强调的原则是:liveness 只反映进程自身是否还能正常工作,不碰任何外部依赖。那具体检查什么?
对于基于 asyncio 的 Python 推理服务(FastAPI、vLLM 的 OpenAI 兼容 server 都是这类),最核心的判据是事件循环有没有被阻塞。LLM 推理是 CPU/GPU 密集操作,如果不小心在事件循环里跑了同步的 tokenize 或后处理,整个 loop 会被卡住,此时进程"活着"但已经无法响应任何请求。这种情况重启是唯一解,正好是 liveness 该管的。
我的做法是在服务里维护一个后台心跳协程,每隔固定时间往一个内存变量写时间戳,liveness 接口检查这个时间戳是否在合理范围内:
import asyncio import time _last_heartbeat = time.time() async def _heartbeat_loop(): global _last_heartbeat while True: _last_heartbeat = time.time() await asyncio.sleep(1) @app.get("/healthz/live") async def liveness(): # 心跳超过 10 秒没更新,说明事件循环被阻塞 if time.time() - _last_heartbeat > 10: return JSONResponse(status_code=500, content={"status": "event_loop_stuck"}) return {"status": "alive"}这个心跳协程在服务启动时用asyncio.create_task拉起。它的巧妙之处在于:如果事件循环被某个同步操作阻塞,心跳协程根本没机会执行,_last_heartbeat就会停止更新,liveness 自然失败。这比单纯返回{"status":"ok"}有意义得多——后者哪怕进程已经卡死,只要 HTTP 框架还能响应就永远返回成功。
那 GPU 状态要不要进 liveness?我的答案是不要。GPU 掉卡、ECC 报错、显存溢出这些确实会导致推理失败,但它们属于"实例不可用",应该由 readiness 处理,让流量切走,而不是重启容器。因为重启容器并不能修复硬件问题,反而可能让问题扩散。GPU 健康检查更适合放在 readiness 或者独立的监控告警里。
还有一个常见误区:把 liveness 接口写成会做重活的接口。比如有人为了"确认模型真的能推理",在 liveness 里跑一次前向。这绝对不行——liveness 会被 kubelet 高频调用,每次都跑推理会白白占用 GPU,还可能因为排队导致探针超时。liveness 必须是轻量的、纯内存判断的。
4. readiness 接口要覆盖的依赖与状态
readiness 是 LLM 服务健康检查里信息量最大的部分,因为它要综合判断"现在能不能接流量"。我把判据分成三类,按重要性排序。
第一类是模型就绪状态。这是硬门槛,模型没加载完,readiness 必须失败。实现上通常用一个全局标志位,加载完成后置为 True:
_model_ready = False @app.get("/healthz/ready") async def readiness(): if not _model_ready: return JSONResponse(status_code=503, content={"status": "model_loading"}) # 后续检查...注意这里返回 503 而不是 500。503 语义上是"暂时不可用",Kubernetes 会把它当作未就绪处理,从 Endpoints 摘除;500 虽然也能触发未就绪,但语义上更像"出错了",容易和真正的故障混淆。这个细节在排查问题时很有用。
第二类是资源水位。LLM 服务最怕的是请求堆积把显存打爆。如果 KV Cache 池已经用满,新请求进来只会排队甚至 OOM。所以 readiness 里应该检查当前队列深度和显存占用:
MAX_QUEUE = 32 @app.get("/healthz/ready") async def readiness(): if not _model_ready: return JSONResponse(status_code=503, content={"status": "model_loading"}) if request_queue.qsize() > MAX_QUEUE: return JSONResponse(status_code=503, content={"status": "queue_full"}) if gpu_mem_used_ratio() > 0.95: return JSONResponse(status_code=503, content={"status": "gpu_pressure"}) return {"status": "ready"}队列阈值怎么定?我的经验是取"单实例稳定吞吐下允许的最大排队数"。比如你的实例在 P99 延迟可接受的前提下最多能扛 32 个并发,那阈值就设 32。超过就摘流量,让其他实例分担,而不是硬扛到雪崩。
第三类是外部依赖。这里要非常克制。LLM 服务通常依赖向量库(做 RAG)、对象存储(读 prompt 模板或缓存)、限流组件等。这些依赖要不要进 readiness,取决于"它挂了是否导致本实例完全无法服务"。
我的判断标准是:如果依赖挂了,服务降级后还能返回有意义的结果,就不要进 readiness;如果依赖挂了服务直接报错,才进。举个例子,RAG 场景下向量库挂了,你其实可以降级成纯 LLM 回答(不带检索),那向量库就不该进 readiness,否则向量库一抖动整个 LLM 集群全部摘流量,损失更大。反过来,如果你的服务强依赖某个鉴权中心,鉴权挂了所有请求都 401,那它就该进 readiness。
提示:readiness 里检查外部依赖一定要设短超时(比如 500ms),并且用缓存结果。如果每次 readiness 都实时去 ping 向量库,高频探测本身就会给依赖造成压力,形成"探针把依赖打挂"的荒诞局面。
5. 探针参数怎么调:一份可直接抄的配置
接口写对了,参数配错一样白搭。我把 LLM 服务常用的探针参数整理成一份配置,并解释每个数字背后的理由。
livenessProbe: httpGet: path: /healthz/live port: 8000 initialDelaySeconds: 30 periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /healthz/ready port: 8000 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 2 successThreshold: 1逐个说理由。liveness 的periodSeconds: 30是刻意放慢的——liveness 探测本身有成本,而且进程卡死不是秒级事件,30 秒的检测延迟完全可以接受。failureThreshold: 3意味着最坏情况下 90 秒才重启,给足了偶发抖动的容忍空间。timeoutSeconds: 5比 readiness 宽松,因为 liveness 接口虽然轻量,但事件循环偶尔被一个长推理任务占用几百毫秒是正常的,超时设太短会误判。
readiness 反过来,periodSeconds: 5要快,因为流量切换的及时性直接影响用户体验。timeoutSeconds: 2要短,readiness 探测不能拖,否则探测请求自己就堆积了。failureThreshold: 2表示连续两次失败才摘流量,避免单次网络抖动导致误摘。
这里有个容易被忽略的点:readiness 的successThreshold保持 1,但恢复时要考虑模型是否需要重新 warmup。有些场景下实例被摘流量是因为队列满,等队列消化完自然就恢复了,successThreshold 为 1 没问题。但如果你的实例经历过 OOM 恢复,可能需要重新跑 warmup 才能接流量,这时候应该在 readiness 接口里加一个"warmup 完成"的判断,而不是靠探针参数解决。
另外,initialDelaySeconds和 startupProbe 不要同时用得太激进。如果配了 startupProbe,liveness 和 readiness 的 initialDelaySeconds 其实可以设小一点,因为 startupProbe 成功前它们不会执行。但为了保险,我还是会给 liveness 留 30 秒,防止 startupProbe 刚过、服务还没完全稳定时被 liveness 误杀。
6. 那些让我熬夜的坑:从 no static resource 到探针雪崩
讲几个我真实踩过的坑,这些在官方文档里基本找不到。
第一个坑:no static resource actuator/health/readiness。这个报错我在 Spring Boot 项目里见过,在 Python 服务里也见过类似变体。根因是探针路径写错了,请求打到了一个不存在的路由,框架返回 404 或者"找不到静态资源"。Kubernetes 把 404 当作探测失败,于是容器一直不就绪。排查时第一件事就是kubectl exec进容器,用curl localhost:8000/healthz/ready手动打一下,确认路径和端口都对。别小看这一步,我见过端口写成 8080 结果服务监听 8000 的,排查了两小时。
第二个坑:探针雪崩。有一次线上向量库响应变慢,我们把向量库健康检查放进了 readiness,结果所有 LLM 实例的 readiness 同时超时,Kubernetes 把全部实例摘流量,服务瞬间 100% 不可用。更糟的是,摘流量后实例空转,探针继续打向量库,向量库压力更大,形成正反馈。后来我们把向量库检查从 readiness 移除,改成服务内部降级 + 监控告警,问题才解决。教训:readiness 里的依赖检查要么去掉,要么加熔断,绝不能让它成为级联故障的放大器。
第三个坑:liveness 重启风暴。早期我们把 liveness 的failureThreshold设成 1,想着"快速失败快速恢复"。结果一次 GC 停顿导致 liveness 超时,容器被重启,重启后又要加载模型 4 分钟,这 4 分钟里流量全压到其他实例,其他实例负载升高、延迟变大、liveness 也超时……整个集群像多米诺骨牌一样倒下去。后来把failureThreshold调到 3,并且给 liveness 加了"连续失败才判定"的逻辑,才稳住。LLM 服务的重启成本极高,liveness 必须保守再保守。
第四个坑:滚动更新时新旧实例的 readiness 竞争。Kubernetes 默认滚动更新会先起新 Pod,等新 Pod ready 后再杀旧 Pod。但 LLM 新 Pod 启动要几分钟,这期间旧 Pod 还在扛流量。如果maxSurge和maxUnavailable配得不合理,可能出现旧 Pod 被提前杀掉、新 Pod 还没 ready 的空窗期。我的配置是maxSurge: 1, maxUnavailable: 0,保证任何时候都有足量 ready 实例。代价是更新慢,但 LLM 服务本来就不该追求秒级发布。
7. 把健康检查接入可观测性:别让探针成为黑盒
健康检查写完之后,最大的问题是"看不见"。探针失败了,你只知道 Pod 被重启或摘流量,但不知道为什么。所以必须把探针的判定过程暴露出来。
我的做法是给每个健康检查接口加详细的响应体和指标。响应体里带上具体是哪一项检查失败:
{ "status": "not_ready", "checks": { "model_loaded": true, "queue_depth": 45, "queue_limit": 32, "gpu_mem_ratio": 0.97, "event_loop_lag_ms": 12 }, "failed": ["queue_full", "gpu_pressure"] }同时把这些数值打成 Prometheus 指标,比如llm_health_queue_depth、llm_health_gpu_ratio、llm_event_loop_lag_seconds。这样在 Grafana 上就能看到"readiness 失败是因为队列深度超限",而不是干瞪眼。
事件循环延迟这个指标特别值得单独说。它的采集方式是在心跳协程里记录"期望唤醒时间"和"实际唤醒时间"的差值:
async def _heartbeat_loop(): global _last_heartbeat, _loop_lag interval = 1.0 next_tick = time.time() + interval while True: await asyncio.sleep(max(0, next_tick - time.time())) now = time.time() _loop_lag = now - next_tick _last_heartbeat = now next_tick += interval_loop_lag超过几百毫秒就说明事件循环被阻塞了,这个指标比单纯的 liveness 成功/失败更能提前预警。我一般会设一个告警:llm_event_loop_lag_seconds > 0.5持续 1 分钟,这时候还没到 liveness 失败的程度,但已经该去查是哪个同步操作在捣乱了。
还有一个实践:把 readiness 的状态变化记成事件。每次 readiness 从 ready 变 not_ready,或者反过来,都打一条带原因的日志。这样在排查"为什么这个 Pod 突然不接流量了"时,直接看日志时间线就行,不用去猜。
8. 不同部署形态下的健康检查差异
最后聊聊部署形态对健康检查的影响,这块很多人会忽略。
单机 Docker 部署没有 Kubernetes 的探针机制,但健康检查依然重要,通常配合 Docker 的HEALTHCHECK指令或者外部的负载均衡器使用。这时候 liveness 和 readiness 的区分没那么严格,但我还是建议分成两个接口,因为负载均衡器(比如 Nginx upstream 的健康检查)需要的是 readiness 语义,而进程守护(比如 systemd 或 supervisor)需要的是 liveness 语义。
Kubernetes 部署就是本文的主场景,startup/liveness/readiness 三件套齐全。这里补充一个细节:如果你的 LLM 服务用了 GPU,节点上的 GPU 设备插件(device plugin)本身也有健康检查。GPU 掉卡时,device plugin 会把节点标记为不健康,Kubernetes 会把 Pod 驱逐。这种情况下你的应用层 readiness 可能还返回 ready,但 Pod 已经被调度走了。所以 GPU 相关的健康判断要分两层:应用层管"模型能不能推理",节点层管"GPU 设备在不在"。
Serverless 或托管推理平台(比如各种模型托管服务)通常不暴露探针配置,平台自己管健康检查。这时候你能做的是在应用内部实现优雅退出和就绪信号,让平台能正确判断。比如收到 SIGTERM 后先把 readiness 置为 false,等正在处理的请求跑完再退出,避免请求被硬中断。
多副本 + 自动扩缩容场景下,readiness 还影响 HPA 的决策。如果 readiness 频繁抖动,HPA 会看到指标忽高忽低,扩缩容就会震荡。所以扩缩容场景下 readiness 的判据要更稳定,队列阈值可以设得宽松一点,避免因为瞬时排队就摘流量触发缩容。
我在实际项目里最终沉淀下来的原则就一句话:liveness 保守到几乎不失败,readiness 敏感但绝不级联。前者保证进程不会因为误判被反复重启,后者保证流量总是流向真正能服务的实例。这两条线分开画清楚,LLM 服务的稳定性会上一个台阶。至于具体的阈值和参数,没有银弹,得根据你的模型大小、硬件配置、流量特征去实测调优,但只要你理解了每个参数背后的"为什么",调起来就有方向,不会瞎试。