推理集群稳定性最佳实践

推理集群稳定性最佳实践

以一个推理平台为例,讲 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]))

预算政策:

  1. 预算充足:正常发版。
  2. 用掉一半,或 P0 在响:冻结推理相关功能,只允许修复和回滚。
  3. 耗尽:先恢复推理,连续 7 天回到目标内,再继续做功能。
  4. 每笔消耗都要能对应到一次发布或一次 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"
  1. 多副本。 replicas: 4。坏掉 1 个,剩 3 个仍扛得住峰值。
  2. 分散到不同节点。 topologySpreadConstraints 按 kubernetes.io/hostname 打散。一个 Pod 要 1 张 GPU,一台节点只放 1 个,一台机器挂了不会带走多个副本。nodeSelector 和容忍让 Pod 只落到 GPU 节点。
  3. PDB(PodDisruptionBudget,Pod 干扰预算)。 maxUnavailable: 1:节点维护、集群升级驱逐 Pod 时,同时最多停 1 个,剩下 3 个仍扛得住峰值。
  4. 资源。 request 和 limit 都写。vLLM:内存 32Gi,CPU request 4 核、limit 8 核,GPU 1 张。Go 平台:内存 256Mi,CPU request 500m、limit 1 核。
  5. KEDA 按请求数扩缩容。 原生 HPA 只能看 CPU 和内存,所以用 KEDA。查所有 vLLM 副本里在跑和在排队的请求总数,每 16 个对应 1 个副本,和 --max-num-seqs=16 对齐。最少 4 个,最多 8 个,8 是现有 GPU 张数。KEDA 会自己建 HPA,不要再手写一份。不看 GPU 利用率。
    • Prometheus 取不到数时,fallback 保持 4 个副本。
    • 缩容先等 5 分钟,每 2 分钟最多减 1 个。新副本加载模型要几分钟,缩得太快会反复加载。
  6. 优雅启动。
    • startupProbe:vLLM 加载千问要几分钟,这期间只跑它,不跑就绪和存活,也不接流量。每 10 秒一次、最多失败 60 次,最长等 10 分钟。
    • readinessProbe:startupProbe 通过后才开始跑。通过,Pod 才进 Service,开始接流量。
    • livenessProbe:同样在 startupProbe 通过后开始,只看端口还在不在听。
  7. 优雅停止。
    • preStop 先睡 10 秒,等 Service 把这个副本摘掉,新请求去其他副本。没有它,退出信号一到进程,入口还在送新请求,这些请求会失败。
    • --shutdown-timeout=100 是 vLLM 的参数:新请求直接拒绝,已接进来的请求最多再等 100 秒。全部结束就退出,到点没结束的被中断。
    • terminationGracePeriodSeconds 设 120 秒,盖住 10 秒和 100 秒。Kubernetes 默认 30 秒,盖不住一次生成。
    • command 直接起 vllm serve,不套 shell,退出信号才能到 vLLM 进程。

2. 数据库

配额和调用方存在数据库里,每次推理进 vLLM 之前都要查一次。

  1. 查库有时限。 超过 200 毫秒,平台直接返回 503,不再往 vLLM 转。库不可用时继续转发,GPU 白白占着,请求还在堆积,首 token 和可用性一起恶化。
  2. 连接池不能把库打满。 每个 Go 副本 MaxOpenConns 为 10,3 个副本共 30 条。数据库 max_connections 按 150 留余量,平台加副本时,副本数 × 10 要小于这个余量。等连接也算在那 200 毫秒里,池子满了同样 503。
  3. 用量回写不挡首 token。 核对调用方在转发前完成;token 用量等生成结束后异步写回。回写变慢,用户已经拿到 token,这次仍按入口的 200 算成功。

3. vLLM

  1. 限制同时跑的请求数。 --max-num-seqs=16:一个副本最多同时跑 16 条,第 17 条排队,vllm:num_requests_waiting 上升。等待超过 30 秒,平台取消并返回 503。不设上限,请求全堆进引擎,KV cache 被占满,正在跑的推理一起变慢。
  2. 盯住 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. 指标

  1. 谁来暴露。 ingress-nginx 在 10254 端口,vLLM 在 8000 的 /metrics,Go 平台自己暴露 /metrics,由 Prometheus 抓取。
  2. 平台要自己补三类指标。 请求数和耗时、在途请求数、数据库连接池(打开数、等待次数、等待时长)。耗时用直方图,不用 Summary,Summary 的分位数无法跨副本合并。
  3. 标签只用低基数的值。 route(用路由模板,不用带参数的原始路径)、status、model_name。不放用户 ID、trace_id、提示词,否则时间序列数量会撑爆 Prometheus。
  4. 保留期至少 30 天。 SLO 在 30 天窗口上算,保留期短于 30 天,第 2 节的预算就算不出来。
  5. 耗时带 exemplar。 平台的耗时直方图把 trace_id 挂在样本上,Prometheus 开启 exemplar 存储(--enable-feature=exemplar-storage),Grafana 就能从一根慢的曲线直接跳到对应链路。

2. 日志

  1. 写标准输出,不写文件。 由 Grafana Alloy 以 DaemonSet 采集,送到 Loki。
  2. 平台用 JSON 结构化日志。 每条带 trace_id、route、status、耗时。不记提示词和生成内容全文,只记长度和 token 数,既保护隐私,也控制体积。
  3. 入口日志里加上 trace_id。 在 ingress-nginx 的 log-format-upstream 里加 trace_id=$opentelemetry_trace_id,入口那一行才能和链路对上。vLLM 的日志没有 trace_id,按 Pod 和时间查,它那一段的耗时看链路里的 span。
  4. Loki 标签只放低基数的值。 namespace、app、container。trace_id 不能做标签,每个值都不一样,会让 Loki 生成海量日志流。放到结构化元数据(structured metadata)里,仍然可以按它过滤:{app="inference-platform"} | trace_id="..."。

3. 链路

  1. 同一次推理用同一个 trace。 靠 W3C 的 traceparent 请求头往下传:
    • ingress-nginx 开 OpenTelemetry,生成第一个 span,默认会接上调用方传来的 trace。
    • Go 平台用 OpenTelemetry SDK 记录收到的请求、查库、调用 vLLM,调用 vLLM 时带上 traceparent。
    • vLLM 加 --otlp-traces-endpoint,把收到的 traceparent 当父 span,导出自己的 span。镜像里要装好 OpenTelemetry 的包。
  2. 链路走 Collector。 各处按 OTLP 协议把数据发给 OpenTelemetry Collector,由它统一处理后存进 Tempo。
  3. 采样在 Collector 做,入口和 SDK 全采。 错误请求、首 token 慢的请求要全部保留,其余留 5%。入口和 SDK 若先丢了,Collector 就没法再选。首 token 慢,由平台在 span 上打 slow_first_token=true 标记。同一条链路的 span 要到同一个 Collector 才能做这个判断。
  4. span 里放模型名、token 数、是否排队,不放提示词。
  5. 采样后的链路只用来排障,不当 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 配成三个数据源,并配好它们之间的跳转,一次排查是这样走的:

  1. 告警发现问题,点告警里的链接打开推理看板。
  2. 看板按四个黄金信号排,确认是哪一类信号变坏。
  3. 点慢的曲线上的 exemplar,跳到链路,看时间耗在入口、平台、数据库还是 vLLM。
  4. 用这条链路的 trace_id 去 Loki 查入口和平台那几行日志,看具体报错。

5. 建设顺序

  1. 先把 SLI 的指标做出来。 入口的状态码、vLLM 的首 token,没有这两个,SLO 和告警都无从谈起。
  2. 再统一日志。 平台改成 JSON,带上 trace_id,入口日志加上 trace_id。
  3. 最后接链路。 入口、平台、vLLM 依次接入,再配好尾部采样和三者的跳转。

5. Grafana 看板:按四个黄金信号

《Site Reliability Engineering》用四个信号描述在线服务:延迟、流量、错误、饱和。前三个说明用户现在怎样,饱和说明离打满还有多远,会先于前三个变坏。

只做一块推理看板,自上而下:先看 SLO 好不好,再按四个信号找是哪一类变坏,最后从面板跳到链路和日志。值班人打开它,30 秒内要能回答「现在好不好、哪里不好」。

看板怎么排

  1. 顶部: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" 的次数。
  2. 下面四行,每行一个信号。 每行最左放一个当前值,右边放趋势图,趋势图上画出告警阈值线。
  3. 每行都带跳转。 慢的曲线点 exemplar 到 Tempo,错误面板点开到 Loki。
  4. 看板顶部放变量。 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 打满,利用率高不代表接满了。

看板要这样管

  1. 阈值线和告警一致。 2.5 秒、0.8、80% 这些线,与第 7 节的告警用同一组数,看板上看到越线,就是告警要响的时候。
  2. 看板写成代码。 看板 JSON 存进 Git,用 Grafana 的 provisioning 或 Terraform 部署,改动走评审,避免有人在页面上改了没人知道。
  3. 告警链接直达看板。 告警正文带看板地址,并已选好时间范围和相关变量。
  4. 不堆图。 一屏放得下才算看板,细分的排查图放第二层的钻取看板,不放首页。

6. 发布与容量

多数用户可见故障来自变更。发布解决「怎么放新版本」,容量解决「卡够不够」。

1. 发布

  1. 灰度放量。 新镜像、新模型、改了启动参数,都先让 Go 平台把 5% 的推理转给新版本的 vLLM,再放到 25%、100%。每一步观察 15 分钟,成功率和首 token 慢于 2.5 秒的比例都不能比旧版本差。
  2. 随时能回。 任何一步不达标,把比例调回 0。旧版本保留到新版本全量跑满一天再下线。
  3. 发布要有空闲 GPU,也要避开高峰。 滚动更新先起新副本再停旧副本,需要 1 张空闲 GPU。新副本加载千问要几分钟,4 个副本逐个换完不是几秒的事。
  4. 预算决定能不能发。 预算充足正常发;用掉一半或 P0 在响,只允许修复和回滚(第 2 节)。平台配置、集群变更同样适用。

2. 容量

  1. 压测找拐点。 在 --max-num-seqs=16 下逐步加压,找到首 token 刚好还在 2.5 秒内的最大请求量,就是单个副本的拐点:每秒约 10 次。
  2. 副本数 = 峰值 ÷ 拐点 + 1。 30 ÷ 10 = 3,再加 1 个应对故障,共 4 个。
  3. 扩容上限看 GPU。 8 张卡,留 1 张给发布,最多 7 个副本承载,约每秒 70 次。流量再涨就要加卡,或在平台上限流。
  4. 尖峰提前扩。 KEDA 看到队列变长才扩,新副本加载要几分钟,来不及。已知的活动、新客户上线,提前把最小副本调高。
  5. 变了就重压。 换模型、改 --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. 值班

设主值班和备值班,一周轮换一次。值班表提前排好;请假、出差要在换班前找人替,主备不能同一天都不在。

  1. 谁接。 P0 电话打给主值班,5 分钟没接转备值班。P1、P2 发到邮件和飞书值班群,由主值班认领。
  2. 响应时限。 P0 立刻响应,先恢复用户使用,再查原因;未关闭前冻结相关功能发布,只允许修复和回滚。P1 当天给出处理结论。P2 第二个工作日处理完。
  3. 升级。 主值班处理不了,或超过时限没人接手,升级给备值班和服务负责人。负责人指定下一步谁来处理,写在飞书值班群里。
  4. 交接。 换班时在飞书值班群留下未关闭的 P0 和 P1:影响了哪些用户、做到哪一步、下一步谁负责。

3. 巡检

告警只在已经出问题时才响。巡检是没有告警时,主动查还没触发告警的隐患:预算花得偏快、水位在涨、副本悄悄少了一个、告警通道本身坏了。

  1. 谁来巡、什么时候。 当周主值班负责,每个工作日上午固定时间,每次不超过 30 分钟。大促、重要客户上线、发版前后,加一次。
  2. 每天:从推理看板往下看,再用几条命令确认。
    • 错误预算。 看板顶部两个预算剩余,近 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 常响告警,没收到,说明告警通道断了。
  3. 每周:看趋势和积压。
    • 本周 P0、P1、P2 的数量和原因。
    • 容量:峰值每秒请求数、副本数,离 GPU 上限(7 个副本承载)还有多远。
    • 数据库:慢查询、磁盘使用、连接数峰值、最近一次备份是否成功。
    • 存储:模型所在 PVC 的剩余空间,Prometheus 和 Loki 的磁盘使用与保留期是否够用。
    • 复盘改进项有没有按期完成。
  4. 每月:查会过期、会过时的东西。
    • 入口 TLS 证书、各类密钥和 Token:30 天内到期的,当月换掉。
    • vLLM 镜像、GPU 驱动、Kubernetes 版本:有没有已知安全漏洞或明显落后。
    • 值班表:下个月已排好,每个人都知道自己哪天值班。
    • 演练一项:电话通知(让主值班确认能接到 P0 电话)、回滚(把灰度比例调回 0)、数据库备份恢复,三选一,轮流做。
  5. 发现了怎么办。 已经影响用户的,按 P0、P1 处理。暂时没影响的,登记到飞书值班群,写清负责人和完成时间,和复盘改进项一起跟踪。同类问题连续出现,就补一条告警,不再靠人看。
  6. 留记录。 每次巡检结果发到飞书值班群,固定格式:日期、巡检人、预算剩余、异常项、跟进项。一切正常也要发。没发就是没巡。

4. 故障复盘

P0 恢复后一个工作日内开会复盘,明显偏离 SLO 的 P1 也要复盘。不追究个人,只查规则有没有执行、下次怎样避免。

  1. 谁参加。 当值的主备值班和服务负责人,需要别的组配合时由负责人邀请。
  2. 写什么。 用户受到的影响、从出错到告警发出用了多久、从告警到恢复用了多久、当时谁在值班、做了哪些处理。
  3. 改进项。 每条写负责人和完成时间,与功能需求放进同一次排期。没有负责人的不算提出。
  4. 关闭。 复盘记录发到飞书值班群。改进项做完之前,这条故障不算结案。

参考

  • Google《Site Reliability Engineering》:用错误预算接受风险、SLI/SLO、四个黄金信号、用自动化去掉手工重复操作、值班与无指责复盘。
  • Google《The Site Reliability Workbook》:错误预算政策。
相关推荐
Smoothcloud润云3 小时前
PyTorch推理输入长度分桶与Padding优化实战:减少无效计算
人工智能·pytorch·python·深度学习·gpu算力
沐曦股份MetaX1 天前
沐曦股份携手FlagOS完成面壁智能MiniCPM-V4.7端侧模型Day 0适配
科技·搜索引擎·自然语言处理·开源·开源软件·gpu算力
强哥聊算力2 天前
《算力江湖·英伟达传奇》第七集:ChatGPT出现前夜,英伟达发生了什么?
服务器·人工智能·算法·ai·chatgpt·gpu算力
meijinmeng2 天前
将 Kubernetes 部署扩展到多个Region区域:GitOps 参考架构
k8s·cicd
张洛闻Eren2 天前
Ansible【第二课】:实施 Ansible Playbook
linux·运维·服务器·云原生·kubernetes·k8s·ansible
算力百科小星2 天前
想用RTX 4090做项目,选哪个算力平台?先比较24GB显存之外的整机配置
gpu算力
Smoothcloud润云3 天前
PyTorch大模型权重加载与启动耗时优化实战:定位磁盘、内存与显存瓶颈
人工智能·pytorch·python·深度学习·gpu算力
Imagination官方博客3 天前
PowerVR 上的 LLM 性能与加速
人工智能·架构·llama·gpu算力
Imagination官方博客3 天前
如何在 E 系列GPU上使用 llama.cpp 和 千问3.5 运行 OpenCode
软件构建·llama·gpu算力