推理集群稳定性最佳实践
以一个推理平台为例,讲 Google SRE 的稳定性做法。平台用 Go 开发,负责鉴权、查配额和转发,调用方和配额存在数据库里;推理由多个 vLLM 完成,模型是千问 8B,每个 GPU 节点跑 1 个副本。用户只经过 ingress-nginx。可观测性用 Prometheus 采指标,Loki 存日志,OpenTelemetry 采链路,Grafana 统一展示。
SRE 把可靠性当功能来管:用 SLI 度量用户的感受,用 SLO 写下承诺,用错误预算决定还能承担多少失败、能不能发版。不追求 100% 可用。
案例架构
#mermaid-svg-MDBDmRHaGfzqamOP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MDBDmRHaGfzqamOP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MDBDmRHaGfzqamOP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MDBDmRHaGfzqamOP .error-icon{fill:#552222;}#mermaid-svg-MDBDmRHaGfzqamOP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MDBDmRHaGfzqamOP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MDBDmRHaGfzqamOP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MDBDmRHaGfzqamOP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MDBDmRHaGfzqamOP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MDBDmRHaGfzqamOP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MDBDmRHaGfzqamOP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MDBDmRHaGfzqamOP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MDBDmRHaGfzqamOP .marker.cross{stroke:#333333;}#mermaid-svg-MDBDmRHaGfzqamOP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MDBDmRHaGfzqamOP p{margin:0;}#mermaid-svg-MDBDmRHaGfzqamOP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MDBDmRHaGfzqamOP .cluster-label text{fill:#333;}#mermaid-svg-MDBDmRHaGfzqamOP .cluster-label span{color:#333;}#mermaid-svg-MDBDmRHaGfzqamOP .cluster-label span p{background-color:transparent;}#mermaid-svg-MDBDmRHaGfzqamOP .label text,#mermaid-svg-MDBDmRHaGfzqamOP span{fill:#333;color:#333;}#mermaid-svg-MDBDmRHaGfzqamOP .node rect,#mermaid-svg-MDBDmRHaGfzqamOP .node circle,#mermaid-svg-MDBDmRHaGfzqamOP .node ellipse,#mermaid-svg-MDBDmRHaGfzqamOP .node polygon,#mermaid-svg-MDBDmRHaGfzqamOP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MDBDmRHaGfzqamOP .rough-node .label text,#mermaid-svg-MDBDmRHaGfzqamOP .node .label text,#mermaid-svg-MDBDmRHaGfzqamOP .image-shape .label,#mermaid-svg-MDBDmRHaGfzqamOP .icon-shape .label{text-anchor:middle;}#mermaid-svg-MDBDmRHaGfzqamOP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MDBDmRHaGfzqamOP .rough-node .label,#mermaid-svg-MDBDmRHaGfzqamOP .node .label,#mermaid-svg-MDBDmRHaGfzqamOP .image-shape .label,#mermaid-svg-MDBDmRHaGfzqamOP .icon-shape .label{text-align:center;}#mermaid-svg-MDBDmRHaGfzqamOP .node.clickable{cursor:pointer;}#mermaid-svg-MDBDmRHaGfzqamOP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MDBDmRHaGfzqamOP .arrowheadPath{fill:#333333;}#mermaid-svg-MDBDmRHaGfzqamOP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MDBDmRHaGfzqamOP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MDBDmRHaGfzqamOP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MDBDmRHaGfzqamOP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MDBDmRHaGfzqamOP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MDBDmRHaGfzqamOP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MDBDmRHaGfzqamOP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MDBDmRHaGfzqamOP .cluster text{fill:#333;}#mermaid-svg-MDBDmRHaGfzqamOP .cluster span{color:#333;}#mermaid-svg-MDBDmRHaGfzqamOP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MDBDmRHaGfzqamOP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MDBDmRHaGfzqamOP rect.text{fill:none;stroke-width:0;}#mermaid-svg-MDBDmRHaGfzqamOP .icon-shape,#mermaid-svg-MDBDmRHaGfzqamOP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MDBDmRHaGfzqamOP .icon-shape p,#mermaid-svg-MDBDmRHaGfzqamOP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MDBDmRHaGfzqamOP .icon-shape .label rect,#mermaid-svg-MDBDmRHaGfzqamOP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MDBDmRHaGfzqamOP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MDBDmRHaGfzqamOP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MDBDmRHaGfzqamOP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Kubernetes · 多个节点
客户端
ingress-nginx
Go 推理平台
多副本
多个 vLLM
千问 8B
每 GPU 节点 1 个
数据库
用户调用 POST /v1/chat/completions:平台先查库核对调用方和配额,再转发给其中一个 vLLM。下文的 SLO、告警、发布都只针对这一次推理。
1. 这套方法在看什么
| SRE 里的概念 | 含义 | 推理案例 |
|---|---|---|
| SLI | 衡量用户感受的指标 | 推理成功率;首 token 是否在 2.5 秒内 |
| SLO | SLI 的目标 | 30 天内成功率 99.9%;首 token 2.5 秒内的占 99% |
| 错误预算 | 100% 减去 SLO,按请求次数算 | 30 天里 0.1% 的推理可以失败 |
| 可观测性 | 指标、日志、链路三类数据,回答用户为什么慢、为什么失败 | Prometheus + Loki + OpenTelemetry,Grafana 展示,用 trace_id 串起来 |
| 四个黄金信号 | 延迟、流量、错误、饱和,Grafana 看板按它来排 | 入口看前三个;vLLM 看排队和 KV cache;平台看数据库连接 |
| 告警、值班、巡检、复盘 | 按紧急程度通知,值班人接;平时主动巡检,恢复后复盘 | P0 电话;P1、P2 邮件和飞书群;每个工作日巡检一次 |
| 自动化 | 手工、重复、随规模变多的操作做成自动 | 扩 GPU、重启卡死的副本不再靠人 |
预算够,就按计划发版;预算花得快,就停新功能、只修可靠性。这是提前定好的错误预算政策,不是出了事再商量。
2. SLI、SLO、错误预算
量什么
SLI 只取用户能感知的结果。探针是否成功、GPU 利用率高低用来查原因,不当 SLO。
- 可用性 :在 ingress-nginx 量,看用户拿到的状态码。带
ingress="api"、method="POST",只算推理请求。 - 首 token 时间 :在 vLLM 量。入口的
request_duration_seconds记到生成结束,header_duration_seconds记到响应头,都不是首 token。 - 整段生成时间:随输出长度变化,不当 SLO。
什么算一次失败
| 用户看到的 | 可用性 nginx_ingress_controller_requests |
首 token vllm:time_to_first_token_seconds |
|---|---|---|
| 200 | 成功,计入分子 | 计入直方图 |
| 400 参数或上下文超长、401 鉴权失败 | 留在分母,不算失败 | 不记,没有首 token |
| 429 限流、500 / 503 | 失败 | 不记 |
| 用户自己断开(入口 499) | 不计入 | 不记 |
vllm:request_success_total 看不到入口 502 和被拒绝的 429,不用来算可用性。
目标与预算
按 30 天滚动窗口算。请求量:峰值每秒 30 次,全天平均约每秒 5 次,30 天约 5 × 86400 × 30 = 12,960,000 次。上线后以实际为准,分母这样取:
promql
sum(increase(nginx_ingress_controller_requests{ingress="api", method="POST", status!="499"}[30d]))
首 token 目标是 2.5 秒,不是 2 秒:vLLM 默认直方图的桶从 1 秒直接跳到 2.5 秒,没有 2 秒这一档。
| SLI | 指标 | SLO | 30 天预算 |
|---|---|---|---|
| 可用性 | 分子 status="200",分母 status!="499" |
99.9% | 12,960,000 × 0.1% = 12,960 次失败 |
| 首 token | vllm:time_to_first_token_seconds_bucket{le="2.5"} ÷ _count |
99% | 129,600 次慢请求 |
- 预算按请求次数算,不按停机分钟算:每 1000 次推理可以有 1 次失败。
- 首 token 的分母是已经吐出第一个 token 的次数,失败已经记在可用性里。
- 用桶的比例,不用平均耗时:失败通常返回得很快,混进平均值会把耗时拉低。
promql
sum(increase(vllm:time_to_first_token_seconds_bucket{le="2.5", model_name="qwen3-8b"}[30d]))
/
sum(increase(vllm:time_to_first_token_seconds_count{model_name="qwen3-8b"}[30d]))
预算政策:
- 预算充足:正常发版。
- 用掉一半,或 P0 在响:冻结推理相关功能,只允许修复和回滚。
- 耗尽:先恢复推理,连续 7 天回到目标内,再继续做功能。
- 每笔消耗都要能对应到一次发布或一次 vLLM 故障。对不上,说明 SLI 还不可信。
3. 高可用:把推理留在 SLO 里
目标:某个副本挂了、正在发版、vLLM 排队时,失败次数仍在错误预算内。请求进平台后会用到数据库和 vLLM,所以按集群、数据库、vLLM 三层讲。
先定两个基础量:
- 副本数。 峰值每秒 30 次,单个 vLLM 约每秒 10 次(压测所得,见第 6 节),3 个扛峰值,再加 1 个应对故障,共 4 个。Go 平台 3 个。
- 超时。 内层先放弃,由平台给用户一个明确的 503:数据库 200 毫秒,vLLM 30 秒。两者都比 2.5 秒宽,只决定何时放弃这一次推理。
1. 集群
下面是 vLLM 的配置。Go 平台同样多副本、分散到不同节点、配 PDB,只是资源是 CPU 和内存,没有 GPU。
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm
namespace: inference
labels:
app: vllm
spec:
replicas: 4
revisionHistoryLimit: 5
progressDeadlineSeconds: 900
selector:
matchLabels:
app: vllm
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: vllm
spec:
terminationGracePeriodSeconds: 120
nodeSelector:
nvidia.com/gpu.present: "true"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: vllm
containers:
- name: vllm
image: registry.example.com/inference/vllm-openai:v0.23.0
imagePullPolicy: IfNotPresent
command: ["vllm", "serve", "/models/Qwen3-8B"]
args:
- --served-model-name=qwen3-8b
- --host=0.0.0.0
- --port=8000
- --max-model-len=8192
- --max-num-seqs=16
- --gpu-memory-utilization=0.90
- --shutdown-timeout=100
env:
- name: HF_HUB_OFFLINE
value: "1"
ports:
- name: http
containerPort: 8000
resources:
requests:
cpu: "4"
memory: 32Gi
nvidia.com/gpu: "1"
limits:
cpu: "8"
memory: 32Gi
nvidia.com/gpu: "1"
volumeMounts:
- name: model
mountPath: /models
readOnly: true
- name: shm
mountPath: /dev/shm
startupProbe:
httpGet: { path: /health, port: http }
periodSeconds: 10
failureThreshold: 60
readinessProbe:
httpGet: { path: /health, port: http }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
tcpSocket: { port: http }
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["sleep", "10"]
volumes:
- name: model
persistentVolumeClaim:
claimName: qwen3-8b-model
readOnly: true
- name: shm
emptyDir:
medium: Memory
sizeLimit: 4Gi
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: vllm
namespace: inference
spec:
maxUnavailable: 1
selector:
matchLabels:
app: vllm
---
apiVersion: v1
kind: Service
metadata:
name: vllm
namespace: inference
labels:
app: vllm
spec:
selector:
app: vllm
ports:
- name: http
port: 8000
targetPort: http
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm
namespace: inference
spec:
scaleTargetRef:
name: vllm
minReplicaCount: 4
maxReplicaCount: 8
pollingInterval: 15
fallback:
failureThreshold: 3
replicas: 4
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 120
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring.svc:9090
query: sum(vllm:num_requests_running{model_name="qwen3-8b"}) + sum(vllm:num_requests_waiting{model_name="qwen3-8b"})
threshold: "16"
- 多副本。
replicas: 4。坏掉 1 个,剩 3 个仍扛得住峰值。 - 分散到不同节点。
topologySpreadConstraints按kubernetes.io/hostname打散。一个 Pod 要 1 张 GPU,一台节点只放 1 个,一台机器挂了不会带走多个副本。nodeSelector和容忍让 Pod 只落到 GPU 节点。 - PDB(PodDisruptionBudget,Pod 干扰预算)。
maxUnavailable: 1:节点维护、集群升级驱逐 Pod 时,同时最多停 1 个,剩下 3 个仍扛得住峰值。 - 资源。 request 和 limit 都写。vLLM:内存 32Gi,CPU request 4 核、limit 8 核,GPU 1 张。Go 平台:内存 256Mi,CPU request 500m、limit 1 核。
- KEDA 按请求数扩缩容。 原生 HPA 只能看 CPU 和内存,所以用 KEDA。查所有 vLLM 副本里在跑和在排队的请求总数,每 16 个对应 1 个副本,和
--max-num-seqs=16对齐。最少 4 个,最多 8 个,8 是现有 GPU 张数。KEDA 会自己建 HPA,不要再手写一份。不看 GPU 利用率。- Prometheus 取不到数时,
fallback保持 4 个副本。 - 缩容先等 5 分钟,每 2 分钟最多减 1 个。新副本加载模型要几分钟,缩得太快会反复加载。
- Prometheus 取不到数时,
- 优雅启动。
startupProbe:vLLM 加载千问要几分钟,这期间只跑它,不跑就绪和存活,也不接流量。每 10 秒一次、最多失败 60 次,最长等 10 分钟。readinessProbe:startupProbe通过后才开始跑。通过,Pod 才进 Service,开始接流量。livenessProbe:同样在startupProbe通过后开始,只看端口还在不在听。
- 优雅停止。
preStop先睡 10 秒,等 Service 把这个副本摘掉,新请求去其他副本。没有它,退出信号一到进程,入口还在送新请求,这些请求会失败。--shutdown-timeout=100是 vLLM 的参数:新请求直接拒绝,已接进来的请求最多再等 100 秒。全部结束就退出,到点没结束的被中断。terminationGracePeriodSeconds设 120 秒,盖住 10 秒和 100 秒。Kubernetes 默认 30 秒,盖不住一次生成。command直接起vllm serve,不套 shell,退出信号才能到 vLLM 进程。
2. 数据库
配额和调用方存在数据库里,每次推理进 vLLM 之前都要查一次。
- 查库有时限。 超过 200 毫秒,平台直接返回 503,不再往 vLLM 转。库不可用时继续转发,GPU 白白占着,请求还在堆积,首 token 和可用性一起恶化。
- 连接池不能把库打满。 每个 Go 副本
MaxOpenConns为 10,3 个副本共 30 条。数据库max_connections按 150 留余量,平台加副本时,副本数 × 10 要小于这个余量。等连接也算在那 200 毫秒里,池子满了同样 503。 - 用量回写不挡首 token。 核对调用方在转发前完成;token 用量等生成结束后异步写回。回写变慢,用户已经拿到 token,这次仍按入口的 200 算成功。
3. vLLM
- 限制同时跑的请求数。
--max-num-seqs=16:一个副本最多同时跑 16 条,第 17 条排队,vllm:num_requests_waiting上升。等待超过 30 秒,平台取消并返回 503。不设上限,请求全堆进引擎,KV cache 被占满,正在跑的推理一起变慢。 - 盯住 KV cache。
vllm:kv_cache_usage_perc接近 1 时,GPU 算力还有空,新请求也排不进去,首 token 会先越过 2.5 秒。它比 GPU 利用率更早说明该加副本或该拒绝。
4. 可观测性建设
可观测性要回答「用户为什么慢、为什么失败」。三类数据各管一件事,用同一个 trace_id 串起来:指标发现问题,链路定位在哪一层,日志看现场。
| 类型 | 工具 | 回答什么 | 这个案例里 |
|---|---|---|---|
| 指标 | Prometheus | 有没有问题,多严重 | 入口请求数和状态码、vLLM 首 token、排队、KV cache、数据库连接 |
| 日志 | Loki,Grafana Alloy 采集 | 这一次请求发生了什么 | 入口、Go 平台、vLLM 的日志 |
| 链路 | OpenTelemetry,存 Tempo | 时间耗在哪一层 | 入口 → 平台 → 数据库 / vLLM |
| 展示 | Grafana | 把三类数据放在一处,互相跳转 | 一块推理看板,见第 5 节 |
1. 指标
- 谁来暴露。 ingress-nginx 在 10254 端口,vLLM 在 8000 的
/metrics,Go 平台自己暴露/metrics,由 Prometheus 抓取。 - 平台要自己补三类指标。 请求数和耗时、在途请求数、数据库连接池(打开数、等待次数、等待时长)。耗时用直方图,不用 Summary,Summary 的分位数无法跨副本合并。
- 标签只用低基数的值。
route(用路由模板,不用带参数的原始路径)、status、model_name。不放用户 ID、trace_id、提示词,否则时间序列数量会撑爆 Prometheus。 - 保留期至少 30 天。 SLO 在 30 天窗口上算,保留期短于 30 天,第 2 节的预算就算不出来。
- 耗时带 exemplar。 平台的耗时直方图把
trace_id挂在样本上,Prometheus 开启 exemplar 存储(--enable-feature=exemplar-storage),Grafana 就能从一根慢的曲线直接跳到对应链路。
2. 日志
- 写标准输出,不写文件。 由 Grafana Alloy 以 DaemonSet 采集,送到 Loki。
- 平台用 JSON 结构化日志。 每条带
trace_id、route、status、耗时。不记提示词和生成内容全文,只记长度和 token 数,既保护隐私,也控制体积。 - 入口日志里加上
trace_id。 在 ingress-nginx 的log-format-upstream里加trace_id=$opentelemetry_trace_id,入口那一行才能和链路对上。vLLM 的日志没有trace_id,按 Pod 和时间查,它那一段的耗时看链路里的 span。 - Loki 标签只放低基数的值。
namespace、app、container。trace_id不能做标签,每个值都不一样,会让 Loki 生成海量日志流。放到结构化元数据(structured metadata)里,仍然可以按它过滤:{app="inference-platform"} | trace_id="..."。
3. 链路
- 同一次推理用同一个 trace。 靠 W3C 的
traceparent请求头往下传:- ingress-nginx 开 OpenTelemetry,生成第一个 span,默认会接上调用方传来的 trace。
- Go 平台用 OpenTelemetry SDK 记录收到的请求、查库、调用 vLLM,调用 vLLM 时带上
traceparent。 - vLLM 加
--otlp-traces-endpoint,把收到的traceparent当父 span,导出自己的 span。镜像里要装好 OpenTelemetry 的包。
- 链路走 Collector。 各处按 OTLP 协议把数据发给 OpenTelemetry Collector,由它统一处理后存进 Tempo。
- 采样在 Collector 做,入口和 SDK 全采。 错误请求、首 token 慢的请求要全部保留,其余留 5%。入口和 SDK 若先丢了,Collector 就没法再选。首 token 慢,由平台在 span 上打
slow_first_token=true标记。同一条链路的 span 要到同一个 Collector 才能做这个判断。 - span 里放模型名、token 数、是否排队,不放提示词。
- 采样后的链路只用来排障,不当 SLI。 SLO 只用 Prometheus 的全量数据。
入口的配置:
yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
data:
enable-opentelemetry: "true"
otlp-collector-host: otel-collector.observability.svc
otlp-collector-port: "4317"
otel-service-name: ingress-nginx
opentelemetry-trust-incoming-span: "true"
log-format-upstream: '$remote_addr "$request" $status $request_time $upstream_response_time trace_id=$opentelemetry_trace_id'
Collector 的尾部采样:
yaml
processors:
tail_sampling:
decision_wait: 30s
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-first-token
type: boolean_attribute
boolean_attribute: { key: slow_first_token, value: true }
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 5 }
4. 三者怎么串起来
Grafana 里把 Prometheus、Loki、Tempo 配成三个数据源,并配好它们之间的跳转,一次排查是这样走的:
- 告警发现问题,点告警里的链接打开推理看板。
- 看板按四个黄金信号排,确认是哪一类信号变坏。
- 点慢的曲线上的 exemplar,跳到链路,看时间耗在入口、平台、数据库还是 vLLM。
- 用这条链路的
trace_id去 Loki 查入口和平台那几行日志,看具体报错。
5. 建设顺序
- 先把 SLI 的指标做出来。 入口的状态码、vLLM 的首 token,没有这两个,SLO 和告警都无从谈起。
- 再统一日志。 平台改成 JSON,带上
trace_id,入口日志加上trace_id。 - 最后接链路。 入口、平台、vLLM 依次接入,再配好尾部采样和三者的跳转。
5. Grafana 看板:按四个黄金信号
《Site Reliability Engineering》用四个信号描述在线服务:延迟、流量、错误、饱和。前三个说明用户现在怎样,饱和说明离打满还有多远,会先于前三个变坏。
只做一块推理看板,自上而下:先看 SLO 好不好,再按四个信号找是哪一类变坏,最后从面板跳到链路和日志。值班人打开它,30 秒内要能回答「现在好不好、哪里不好」。
看板怎么排
- 顶部:SLO 概览。 四个数字,值班人先看这里。
- 30 天可用性:
sum(increase(nginx_ingress_controller_requests{ingress="api", method="POST", status="200"}[30d])) / sum(increase(nginx_ingress_controller_requests{ingress="api", method="POST", status!="499"}[30d])),目标 99.9%。 - 首 token 达标率:第 2 节的首 token 查询,目标 99%。
- 可用性预算剩余:
1 - 失败数 / 12960,失败数是 30 天内status=~"5..|429"的次数。 - 首 token 预算剩余:
1 - 慢请求数 / 129600,慢请求数是总数减去le="2.5"的次数。
- 30 天可用性:
- 下面四行,每行一个信号。 每行最左放一个当前值,右边放趋势图,趋势图上画出告警阈值线。
- 每行都带跳转。 慢的曲线点 exemplar 到 Tempo,错误面板点开到 Loki。
- 看板顶部放变量。
pod、时间范围,方便只看某一个 vLLM 副本。
1. 延迟:首 token 耗时
- 分位数曲线。 P50、P95、P99:
histogram_quantile(0.95, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket{model_name="qwen3-8b"}[5m])))。画一条 2.5 秒的线。 - 2.5 秒内占比。 与第 7 节 P1 告警同一个查询(15 分钟窗口)。目标 99%,低于它就是在消耗预算,慢请求超过 5% 触发 P1。
- 分位数只是近似。 vLLM 的桶是 1 秒直接跳到 2.5 秒,P99 在这一段只能看出趋势,达标与否以占比为准。
- 按副本拆开看。
sum by (pod),某一个副本慢,多半是它排队或 KV cache 满了。
2. 流量:每秒多少次推理
- 每秒请求数。
sum(rate(nginx_ingress_controller_requests{ingress="api", method="POST"}[5m])),对着 5 rps 的平均值和 30 rps 的峰值看。 - 每秒 token 数。
sum(rate(vllm:prompt_tokens_total[5m]))和sum(rate(vllm:generation_tokens_total[5m]))。请求数没变而 token 数翻倍,是用户在发更长的提示或要更长的输出,vLLM 会先吃紧。 - 每个 vLLM 副本分到的流量。 看是否均匀,副本数是否跟得上,对应 KEDA 的扩缩容。
3. 错误:用户拿到的失败
- 失败率。
429 + 5xx除以status!="499",与 P0、P1 同一个查询。 - 按状态码拆开。
sum by (status) (rate(nginx_ingress_controller_requests{ingress="api", method="POST"}[5m]))。429 多是限流或排队,500 多是平台或 vLLM 出错,503 多是超时;400、401 单独一色,它们不算失败,但突然变多是调用方出了问题。 - 错误日志。 Loki 面板:
{app="inference-platform"} | json | level="error",每行带trace_id,点开跳到 Tempo。 - 预算消耗对照。 把发布时间标在曲线上(Grafana 注解),错误突增能对上哪一次发布。
4. 饱和:离打满还有多远
- vLLM 排队。
sum by (pod) (vllm:num_requests_running{model_name="qwen3-8b"})和vllm:num_requests_waiting,运行数画--max-num-seqs=16的线,排队数应该大多为 0。 - KV cache。
vllm:kv_cache_usage_perc,画 0.8 的线,对应 P2 告警。 - 数据库连接。 平台用
collectors.NewDBStatsCollector暴露连接池,go_sql_in_use_connections / go_sql_max_open_connections,画 80% 的线;rate(go_sql_wait_duration_seconds_total[5m])上升,说明请求在等连接。 - 可用副本数。
kube_deployment_status_replicas_available{deployment="vllm"},对着 4 个的基线,低于 3 就扛不住峰值。 - GPU 利用率只放一个小图,不作告警。 vLLM 的饱和看队列和 KV cache,一条请求就能把 GPU 打满,利用率高不代表接满了。
看板要这样管
- 阈值线和告警一致。 2.5 秒、0.8、80% 这些线,与第 7 节的告警用同一组数,看板上看到越线,就是告警要响的时候。
- 看板写成代码。 看板 JSON 存进 Git,用 Grafana 的 provisioning 或 Terraform 部署,改动走评审,避免有人在页面上改了没人知道。
- 告警链接直达看板。 告警正文带看板地址,并已选好时间范围和相关变量。
- 不堆图。 一屏放得下才算看板,细分的排查图放第二层的钻取看板,不放首页。
6. 发布与容量
多数用户可见故障来自变更。发布解决「怎么放新版本」,容量解决「卡够不够」。
1. 发布
- 灰度放量。 新镜像、新模型、改了启动参数,都先让 Go 平台把 5% 的推理转给新版本的 vLLM,再放到 25%、100%。每一步观察 15 分钟,成功率和首 token 慢于 2.5 秒的比例都不能比旧版本差。
- 随时能回。 任何一步不达标,把比例调回 0。旧版本保留到新版本全量跑满一天再下线。
- 发布要有空闲 GPU,也要避开高峰。 滚动更新先起新副本再停旧副本,需要 1 张空闲 GPU。新副本加载千问要几分钟,4 个副本逐个换完不是几秒的事。
- 预算决定能不能发。 预算充足正常发;用掉一半或 P0 在响,只允许修复和回滚(第 2 节)。平台配置、集群变更同样适用。
2. 容量
- 压测找拐点。 在
--max-num-seqs=16下逐步加压,找到首 token 刚好还在 2.5 秒内的最大请求量,就是单个副本的拐点:每秒约 10 次。 - 副本数 = 峰值 ÷ 拐点 + 1。 30 ÷ 10 = 3,再加 1 个应对故障,共 4 个。
- 扩容上限看 GPU。 8 张卡,留 1 张给发布,最多 7 个副本承载,约每秒 70 次。流量再涨就要加卡,或在平台上限流。
- 尖峰提前扩。 KEDA 看到队列变长才扩,新副本加载要几分钟,来不及。已知的活动、新客户上线,提前把最小副本调高。
- 变了就重压。 换模型、改
--max-num-seqs、升级 vLLM 之后,拐点会变,要重新压测。
7. 告警、值班、巡检与复盘
这四件事各管一段:告警决定叫不叫人,值班决定谁来接,巡检在没有告警时主动找隐患,复盘决定下次怎样少发生。GPU 利用率高不告警,只在看板上看,由巡检来盯。
1. 告警级别
按用户现在有多紧急来分。条件要持续达到表里的时间再发出,避免一次抖动就升级。
| 级别 | 紧急程度 | 通知 | 这次推理里什么情况 |
|---|---|---|---|
| P0 | 紧急。用户大面积推不成推理 | 电话打给主值班 | 最近 5 分钟入口 429 与 5xx 超过 10%,且失败不少于 20 次;或千问 8B 的就绪副本为 0 |
| P1 | 严重。推理还能用,但明显差于 SLO | 邮件、飞书值班群 | 最近 15 分钟失败超过 1%;或首 token 慢于 2.5 秒的比例超过 5% |
| P2 | 注意。多数用户正常,再发展会变成 P1 | 邮件、飞书值班群 | 等待队列持续 10 分钟不为 0;或 vllm:kv_cache_usage_perc 超过 0.8;或数据库连接占用超过池子的 80% |
P0 的入口失败率:
promql
(
sum(rate(nginx_ingress_controller_requests{ingress="api", method="POST", status=~"5..|429"}[5m]))
/
sum(rate(nginx_ingress_controller_requests{ingress="api", method="POST", status!="499"}[5m]))
) > 0.1
and
sum(increase(nginx_ingress_controller_requests{ingress="api", method="POST", status=~"5..|429"}[5m])) > 20
P1 的首 token:慢于 2.5 秒的比例,最近 15 分钟超过 5%。
promql
(
1 - (
sum(rate(vllm:time_to_first_token_seconds_bucket{le="2.5", model_name="qwen3-8b"}[15m]))
/
sum(rate(vllm:time_to_first_token_seconds_count{model_name="qwen3-8b"}[15m]))
)
) > 0.05
告警正文带三样东西:推理看板、把灰度流量调回 0 的做法、现在有几个 vLLM 副本。
2. 值班
设主值班和备值班,一周轮换一次。值班表提前排好;请假、出差要在换班前找人替,主备不能同一天都不在。
- 谁接。 P0 电话打给主值班,5 分钟没接转备值班。P1、P2 发到邮件和飞书值班群,由主值班认领。
- 响应时限。 P0 立刻响应,先恢复用户使用,再查原因;未关闭前冻结相关功能发布,只允许修复和回滚。P1 当天给出处理结论。P2 第二个工作日处理完。
- 升级。 主值班处理不了,或超过时限没人接手,升级给备值班和服务负责人。负责人指定下一步谁来处理,写在飞书值班群里。
- 交接。 换班时在飞书值班群留下未关闭的 P0 和 P1:影响了哪些用户、做到哪一步、下一步谁负责。
3. 巡检
告警只在已经出问题时才响。巡检是没有告警时,主动查还没触发告警的隐患:预算花得偏快、水位在涨、副本悄悄少了一个、告警通道本身坏了。
- 谁来巡、什么时候。 当周主值班负责,每个工作日上午固定时间,每次不超过 30 分钟。大促、重要客户上线、发版前后,加一次。
- 每天:从推理看板往下看,再用几条命令确认。
- 错误预算。 看板顶部两个预算剩余,近 7 天消耗是否比平时快。
- 副本。
kubectl get pods -n inference -o wide:vLLM 4 个、Go 平台 3 个都 Ready;RESTARTS过去 24 小时有没有增加;有没有 Pending、Evicted。 - 节点和 GPU。
kubectl get nodes里 GPU 节点是否 NotReady;GPU 的 Xid 错误、ECC 错误、温度(DCGM exporter 指标)是否有异常。 - 水位。 昨天高峰时排队请求数、
vllm:kv_cache_usage_perc、数据库连接占用的最高值,对比上周。 - 告警。 未关闭的 P1、P2 有没有超过处理时限,有没有反复出现的同类告警。
- 监控本身。 Prometheus 的抓取目标有没有
up == 0,Loki、Tempo 在不在写入,每个节点上的 Alloy 是否在跑。每天应收到一条 Prometheus 的Watchdog常响告警,没收到,说明告警通道断了。
- 每周:看趋势和积压。
- 本周 P0、P1、P2 的数量和原因。
- 容量:峰值每秒请求数、副本数,离 GPU 上限(7 个副本承载)还有多远。
- 数据库:慢查询、磁盘使用、连接数峰值、最近一次备份是否成功。
- 存储:模型所在 PVC 的剩余空间,Prometheus 和 Loki 的磁盘使用与保留期是否够用。
- 复盘改进项有没有按期完成。
- 每月:查会过期、会过时的东西。
- 入口 TLS 证书、各类密钥和 Token:30 天内到期的,当月换掉。
- vLLM 镜像、GPU 驱动、Kubernetes 版本:有没有已知安全漏洞或明显落后。
- 值班表:下个月已排好,每个人都知道自己哪天值班。
- 演练一项:电话通知(让主值班确认能接到 P0 电话)、回滚(把灰度比例调回 0)、数据库备份恢复,三选一,轮流做。
- 发现了怎么办。 已经影响用户的,按 P0、P1 处理。暂时没影响的,登记到飞书值班群,写清负责人和完成时间,和复盘改进项一起跟踪。同类问题连续出现,就补一条告警,不再靠人看。
- 留记录。 每次巡检结果发到飞书值班群,固定格式:日期、巡检人、预算剩余、异常项、跟进项。一切正常也要发。没发就是没巡。
4. 故障复盘
P0 恢复后一个工作日内开会复盘,明显偏离 SLO 的 P1 也要复盘。不追究个人,只查规则有没有执行、下次怎样避免。
- 谁参加。 当值的主备值班和服务负责人,需要别的组配合时由负责人邀请。
- 写什么。 用户受到的影响、从出错到告警发出用了多久、从告警到恢复用了多久、当时谁在值班、做了哪些处理。
- 改进项。 每条写负责人和完成时间,与功能需求放进同一次排期。没有负责人的不算提出。
- 关闭。 复盘记录发到飞书值班群。改进项做完之前,这条故障不算结案。
参考
- Google《Site Reliability Engineering》:用错误预算接受风险、SLI/SLO、四个黄金信号、用自动化去掉手工重复操作、值班与无指责复盘。
- Google《The Site Reliability Workbook》:错误预算政策。