1. 一次线上事故的复盘:推理服务“带病运行”有多可怕
先讲一个真实经历。之前我维护过一个内部 AI 推理平台,跑的是双塔向量召回模型,GPU 清一色 A10,部署在 Kubernetes 集群里。平时 P99 延迟稳定在 50ms 上下,深夜流量低谷也就 30ms 出头。
直到某天下午,告警群里突然有人反馈:某条核心链路的召回接口变慢了,P99 从 50ms 一路涨到 3 秒多,并且开始出现超时。我当时的第一反应是“去看 GPU”,因为推理服务慢,十有八九是算力打满了。然而 Grafana 打开以后,GPU 利用率只有 60% 左右,显存占用也正常,Java 侧的服务指标、下游向量数据库的延迟,全部在合理范围内。
这就很奇怪了——所有常规指标都正常,但用户就是在等。
折腾了快一个小时,最后落到日志里才发现:集群里有一台节点上的推理进程反复触发 CUDA OOM,进程进入异常重试状态,日志里全是 CUDA error: out of memory 和内存碎片清理记录。而这台节点的容器还活着,TCP 端口也能连通,Kubernetes 的探活检查依然认为实例健康,流量照打不误。
问题很快修复了,但这件事给我的触动非常大:一个带着病还继续接收流量的推理实例,如果没有任何可观测性接口,就是一颗定时炸弹。事故定位时间从“几分钟”拖到“一个多小时”,恰恰是因为服务没暴露任何 /health 和 /metrics 接口,所有判断都只能靠猜。
如果你也在做 AI 推理服务,不管是 PyTorch、Triton Inference Server、TensorRT-LLM,还是自己用 FastAPI 封装的一个模型 API,/health 和 /metrics 不是可选项,是必需品。这篇文章我就从这次事故出发,把这两个接口的工程意义、设计方法、落地姿势,以及我踩过的坑一次讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. /health 不只是探活:设计一个“会说话”的健康检查接口
2.1 liveness、readiness、startup 探针各自管什么
很多人一提到 /health,第一反应是“给 K8s 探活用”。这句话没错,但只对了一半。Kubernetes 里的探针分三类,对应三个完全不同的工程目标:
| 探针类型 | 判断依据 | 失败后动作 | 典型场景 |
|---|---|---|---|
| livenessProbe | 进程是否还能继续跑 | 杀掉容器并重启 | 死锁、CUDA 上下文损坏、无限阻塞 |
| readinessProbe | 是否还能接收并处理流量 | 从 Service Endpoints 摘除 | 模型未加载完、推理队列积压、依赖不可用 |
| startupProbe | 进程是否完成启动初始化 | 重启容器 | 模型加载耗时几十秒到几分钟的场景 |
这里有个很典型的误区:很多团队只配置了 livenessProbe,认为“进程活着就行”。但对推理服务来说,进程活着跟服务可用完全不是一回事。
模型加载阶段是最典型的反例。一个 6B 参数的模型,冷启动时要加载权重到显存、构建 CUDA context、做 warm-up,这个过程根据模型大小和磁盘速度,可能需要 30 秒甚至几分钟。如果只配置了 liveness,kubelet 在启动阶段就探测端口,探不通就杀掉重启,服务永远起不来。这是 AI 推理服务部署里最常见的翻车姿势。
2.2 一个合格 /health 的响应体应该长什么样
我见过的 /health 接口大致分三个档次。
第一个档次:返回一张白纸。无论什么情况都返回 200,响应体是空的。这种接口能骗过探针,却帮不了任何人。
第二个档次:返回 HTTP 状态码,但只有一堆固定的字符串。比如 200 的时候输出 OK。这种能区分“健康”和“不健康”,但无法告诉你为什么不健康。
第三个档次:状态码 + 结构化信息。这也是我推荐的姿势:
json复制{
"status": "healthy",
"model": "text-embedding-v3",
"model_version": "2024.06.15",
"gpu_health": true,
"queue_depth": 2,
"uptime": 86400
}
当服务健康时返回 200,status 为 healthy;当服务处于“能启动但不能接流量”的状态时返回 503,status 为 unhealthy,同时在 body 里告诉排查者原因字段,比如 reason: "cuda_oom" 或 reason: "model_not_loaded"。
这样做的价值在于:你不需要登进服务器翻日志,就能知道 pod 为什么被摘流量。
2.3 探活依赖怎么选,避免把自己“探”死
设计 /health 时有一个非常关键的边界问题:/health 要不要顺带检查下游依赖?
我的结论是:区分依赖类型,且一定加缓存和超时。
如果你的模型服务在推理时必须实时访问 Redis 里的用户特征,Redis 挂了请求确实会失败,那么 Redis 可以作为 readiness 的检查项。但如果你的下游只是一个每周更新一次的特征表,它抖动几秒钟并不会影响当前请求,那把它放进健康检查就是给自己找麻烦——下游临时抖动会让整个服务从 Endpoints 摘除,反而引起更大范围的流量雪崩。
另一个细节是:健康检查本身也要控制开销。不要在 /health 里每次实时去连数据库、实时去查显存状态,这样会把探活请求变成一次微型压测。比较稳妥的做法是:健康状态由一个后台线程每 5~10 秒刷新一次,/health 接口只返回这个缓存值,响应时间控制在 10ms 以内。
2.4 配上 Kubernetes 探针才是完整闭环
写好了 /health,还得让 K8s 真正用起来。我的 Deployment 探针配置通常是这样的:
yaml复制spec:
containers:
- name: inference-server
ports:
- containerPort: 8000
name: http
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
failureThreshold: 30
timeoutSeconds: 2
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
failureThreshold: 3
timeoutSeconds: 2
startupProbe 的 failureThreshold 设得大一些,是为了给模型加载留足时间。一旦启动完成,后续的 liveness 和 readiness 才接管。注意 readinessProbe 的 periodSeconds 比 livenessProbe 更短,因为流量摘除需要更快的反应速度。
从工程意义上讲,/health 解决的是 “这个实例能不能被可靠地调度” 的问题。它是 K8s 自动扩缩容、滚动发布、故障自愈的决策依据。但这只是第一层。
3. /metrics 是推理服务的“仪表盘”,但别只顾着搞 QPS
3.1 为什么系统指标不够,业务指标必须自己做
健康检查只能告诉你“有没有病”,但没法告诉你是谁在生病、病到什么程度、发展趋势如何。这就是 /metrics 接口存在的意义。
很多人最开始做监控,靠的是 Kubernetes 自带的 container_cpu_usage_seconds_total 和 container_memory_working_set_bytes。这些指标对普通 Web 服务够用,对 AI 推理服务却远远不够。
因为推理服务的核心资源在 GPU 上,显存、SM 利用率、温度这些指标容器层面拿不到,必须通过 DCGM Exporter 或 nvidia-smi 采集。而业务层面的指标,比如当前 batch 大小是多少、推理队列排了多长、单次推理耗时分布、模型加载花了多长时间,这些只有应用自己知道,运维工具是扒不出来的。
你不可能用容器 CPU 指标判断“显存碎片化导致 CUDA OOM 概率上升”,也不可能用内存指标判断“batch 变小导致吞吐下降”。所以推理服务必须暴露自己的业务指标。
3.2 推理服务专属指标清单
我建议从以下这几类指标开始,后续按业务需要再加:
| 指标名 | 类型 | 含义 | 告警依据 |
|---|---|---|---|
| inference_requests_total | Counter | 累计请求数 | QPS 突增或突降 |
| inference_duration_seconds | Histogram | 推理耗时分布 | P99 超基线 |
| inference_batch_size | Histogram | 实际 batch 大小 | batch 是否合理 |
| inference_queue_size | Gauge | 待处理请求数 | 队列积压 |
| inference_gpu_utilization | Gauge | GPU 利用率 | 利用率异常 |
| inference_errors_total | Counter | 推理失败数 | 错误率突增 |
| model_loading_duration_seconds | Gauge | 模型加载耗时 | 加载异常变慢 |
如果你在跑 LLM 服务,还要额外关注首 token 延迟(TTFT)、每 token 生成延迟、KV cache 使用率、并发请求数。这类指标和传统 Web 的 QPS、RT 完全是两套体系,需要单独设计。
3.3 指标类型选错会让告警失真:counter、gauge、histogram 的取舍
Prometheus 的指标类型听起来很简单,实际用的时候坑很多。
Counter 是单调递增的计数器,适合统计累计值,比如请求总数、错误总数。但要注意:服务重启后 Counter 归零,所以告警不要直接用原始值,要基于 rate() 函数算速率。
Gauge 是上下浮动的数值,适合当前状态,比如队列长度、显存使用量、GPU 利用率。直接用就可以。
Histogram 是最容易踩坑的类型。它通过一组桶(bucket)来近似计算延迟分位数,比如 P99。它的准确度取决于你设置的桶边界。如果你用默认桶(一般是 5ms 到 10s 的指数分布),对很多推理服务来说其实够用;但对极低延迟的服务,比如几百微秒级别的 MobileNet 推理,默认桶会浪费大量存储,而且算出来的 P99 不准。
我自己配置推理耗时 Histogram 时,会先压测一轮,拿到延迟的大致范围,再设置桶。比如某个模型延迟大概在 15ms 到 50ms 之间,那我就这样设:
code复制buckets=(0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.075, 0.1, 0.2, 0.5)
让桶密集地落在真实延迟区间附近,而不是默认的指数间隔。
3.4 用 FastAPI 快速暴露一套可用的推理指标
用 Python 写推理服务时,我习惯直接用 prometheus_client 库:
python复制from prometheus_client import Histogram, Counter, Gauge
import prometheus_client
from fastapi import FastAPI
from fastapi.responses import Response
import time
INFERENCE_REQUESTS = Counter(
"inference_requests_total",
"Total number of inference requests",
["model", "version"],
)
INFERENCE_DURATION = Histogram(
"inference_duration_seconds",
"Inference latency in seconds",
["model", "version"],
buckets=(0.005, 0.01, 0.015, 0.02, 0.025, 0.03,
0.04, 0.05, 0.075, 0.1, 0.2, 0.5),
)
QUEUE_SIZE = Gauge(
"inference_queue_size",
"Current number of queued requests",
)
app = FastAPI()
@app.post("/predict")
async def predict(request: dict):
model = request.get("model", "default")
version = request.get("version", "v1")
INFERENCE_REQUESTS.labels(model=model, version=version).inc()
QUEUE_SIZE.inc()
start_time = time.perf_counter()
try:
# 模型推理逻辑
result = model_predict(request)
finally:
INFERENCE_DURATION.labels(model=model, version=version).observe(
time.perf_counter() - start_time
)
QUEUE_SIZE.dec()
return result
@app.get("/metrics")
def metrics():
return Response(
prometheus_client.generate_latest(),
media_type="text/plain; version=0.0.4",
)
这里有个容易被忽略的点:QUEUE_SIZE 用 Gauge 表示队列长度,但要注意在异常路径里也要执行 QUEUE_SIZE.dec(),否则可能出现指标永久虚高。最好用 try/finally 包裹。
4. 有数据之后:大盘、告警与一次真实的故障定位过程
4.1 推理服务大盘该怎么摆,不能照搬 Web 服务模板
很多团队的 Prometheus/Grafana 大盘是从 Web 服务模板改的,上面全是 CPU、内存、网络 IO、QPS、P99。对推理服务来说,这些当然要看,但不应该是主角。
我个人经验是,推理服务大盘至少要有以下 6 个核心面板:
- 请求量(rps)与错误率
- 推理延迟分位数(P50 / P95 / P99)
- GPU 利用率与显存使用量
- 推理 batch 大小分布
- 推理队列深度
- 实例数/副本数与重启次数
为什么 batch 大小这么重要?因为很多推理框架支持动态 batching,高并发下它会自动把多个请求攒成一个 batch 处理,提升吞吐。如果你只看到请求量涨、延迟涨、GPU 利用率却不高,很可能是因为并发不够、攒不出大 batch,导致 GPU 大量时间在空转。
这类问题只有从业务指标里才能看出来。
4.2 告警阈值怎么定:压测基线比经验值靠谱
AI 推理服务的告警阈值,永远不要拍脑袋。同一个模型用 T4 和 A100 部署,P99 基线能差 5 倍以上。
我通常的做法是:服务上线后先压测,用压测工具(比如 ghz 或 wrk)在固定 QPS 下跑 10~15 分钟,采集一组稳定期的指标基线,再根据基线设置告警。举个例子:压测结果显示,在 200 QPS 下该模型 P99 稳定在 40ms,那么 P99 告警就设置在基线的 2~3 倍,也就是 80~120ms 之间。这样既不频繁误报,也能在性能劣化时及时暴露问题。
另外,GPU OOM 和队列堆积这两类告警,对推理服务尤其重要。很多模型框架在显存不足时会先报 warning 后重试,不会立刻崩溃。如果不做显存和队列监控,这种状态能持续很久不被发现。
4.3 一个真实定位案例:P99 上涨的三种可能
回到文章开头那个事故。如果我们当时有完善的 /metrics,定位过程会变成什么样?
假设告警触发后,我先打开大盘。如果 inference_duration_seconds 的 P99 上涨,但 inference_requests_total 的速率没有明显变化,我会按以下顺序排查:
- 如果
inference_queue_size上涨,说明要么并发处理能力下降,要么单请求处理时间变长。看 batch 大小分布,如果 batch 变小,说明并发不足,流量没凑成大 batch,GPU 在空转。2. 如果 batch 大小正常,但 GPU 利用率低、显存占用异常高,说明显存分配出了问题,很可能存在碎片化或泄漏。3. 如果 GPU 利用率也很高,说明模型本身计算变慢,可能和输入长度(LLM 场景的输入 token 数)有关。
这套排查链路,完全依赖 /metrics 提供的业务指标。没有这些指标,就只能回到 SSH 进服务器看日志、手动敲 nvidia-smi 的老路上,效率差距非常大。
4.4 验证指标可靠性的压测方法
说句实在话,不是所有服务暴露出来的 metrics 都是可信的。我见过有团队把 Histogram 的 observe 放错了位置,统计了排队时间 + 推理时间,告警阈值又照着纯推理时间设的,结果天天误报。
最简单的验证方法:用压测工具从外部打流量,然后同时记录两套数据——压测工具自己统计的延迟分位数,和 Prometheus 里 inference_duration_seconds 算出来的 P99。两者虽然不会完全相等(外部延迟会多一层网络开销),但趋势和数量级应该在合理范围内。如果两者差了 10 倍,那就要检查代码里指标打点位置是不是放错了。
5. 这五个坑,我踩过以后才记住
5.1 /health 全链路探测导致级联重启
我曾经在一个服务里把下游 Redis、数据库、对象存储全列入了 /health 检查项,觉得这样最保险。结果某天对象存储发生了一次短暂抖动,所有 Pod 的 readiness 全部变成 false,K8s 直接把流量全部摘除。等对象存储恢复后,服务重新接流量的瞬间又触发了限流,整条链路差点雪崩。
从那以后我给自己定了一条规矩:/health 里只检查“当前服务能否处理请求”直接相关的依赖,而且必须加超时和缓存。无关依赖一概不查。
5.2 metrics 裸奔在公网
/metrics 本身没有任何鉴权机制,任何人只要能访问到这个端口,就能看到你的 QPS、延迟、模型名、版本号。这些信息对攻击者来说就是一份“服务体检报告”。
在云环境里,我通常会把 /metrics 只暴露在集群内部,用 Prometheus 在集群内抓取。如果必须暴露到外部,也要加一层简单的 Basic Auth 或者 IP 白名单。不要觉得“反正内部服务没人看”,这类疏忽出过太多安全事故了。
5.3 标签基数爆炸:request_id 也能把你搞崩
Prometheus 对标签基数非常敏感。如果你把 request_id 或者 trace_id 加进标签,那是灾难——每个请求都会产生一组新的时序数据,Prometheus 内存很快就爆。
AI 推理服务里,model、version、device_id 这类标签是合适的,因为它们数量有限且是分维度统计的核心。但用户 ID、请求 ID、文本所属领域这种高基数维度,一律不要上标签。如果非要按请求维度统计,应该走日志链路而不是 metrics 链路。
5.4 histogram 桶范围没覆盖真实延迟分布
有一次我负责的服务延迟在 0.5ms 到 2ms 之间,但我用的是默认桶,最小的桶是 5ms,导致所有请求全部落在同一个桶里。算出来的 P99 一直是个固定值,完全失真,直到压测对比才发现问题。
后来我学到的教训是:每上一个新模型,都先空跑一轮压测,看看真实延迟分布,再决定桶边界。桶的覆盖范围要比真实延迟的最大值再高一个数量级左右,否则 P99 就算不准。
5.5 有 /health 没 /metrics,等于有心跳没体温
最后这个不算坑,算是我个人的强烈建议。
/health 能告诉你服务死没死,但只有 /metrics 能告诉你服务活得多累、多健康。这两个接口在工程上是互补的:/health 是面向调度的决策接口,/metrics 是面向监控和诊断的数据接口。只做一个,等于把自己眼睛蒙上一只。
我后来接手任何一个新推理服务,第一件事就是检查这两件事:Deployment 里的探针配置对不对,/metrics 能不能抓出有意义的业务指标。如果这两样没做好,代码写得再漂亮,也无非是一辆没有仪表盘的跑车。
现在每次写推理服务,我会先把 /health 和 /metrics 写进代码骨架,再开始填业务逻辑。这个习惯帮我避开了无数个本可以提前发现的故障。希望这篇东西能让你少走一些我走过的弯路。
