生产部署与运维:Kubernetes、模型加载、弹性与发布¶
前面的章节关心"一个实例怎样跑得快";上线之后,更多的问题出在实例之外:GPU 节点怎样调度、几百 GB 的权重怎样快速加载、实例什么时候算"就绪"、怎样在不中断服务的情况下发布新版本、扩缩容看什么信号、出了问题从哪里查。这一章把这些运维问题按"部署 → 加载 → 探活 → 发布 → 扩缩容 → 可观测"的顺序过一遍,并用一个小模型比较几种发布策略的时间和容量代价。
自测:能答上来就可以跳过本章
- 一个跨两台机器的推理实例(比如 TP=8 × PP=2),在 Kubernetes 上怎么部署?
- 大模型的实例为什么要单独配置启动探针?就绪探针应该检查什么?
- 滚动发布时 maxUnavailable 和 maxSurge 怎么选?
- 自动扩缩容为什么不看 GPU 利用率?
- 怎样把冷启动从几分钟缩短到几十秒?
自测参考答案(先自己答,再展开对照)
- 用 LeaderWorkerSet 这类工作负载:一个实例是一组 Pod(一个 leader 加若干 worker),配合 gang 调度,要么两台机器都调度上、要么都不调度;再用拓扑标签和亲和性让它们落在网络上相邻的位置,配好 RDMA 网卡和本地 NVMe 的权重缓存。
- 大模型加载权重、编译、录制 CUDA Graph 要几分钟,存活探针在这期间会误判它死了、反复重启;启动探针给足时间。就绪探针应该发一个真实的小请求,确认能正常生成,而不只是检查端口开着。
- 同时下线的实例数(maxUnavailable)不能超过容量的余量,否则剩下的实例扛不住流量;有余量或有资源时用先加后减(maxSurge 大于 0、maxUnavailable 为 0),新实例就绪之后再下线旧的。
- 推理服务的 GPU 利用率几乎总是很高(连续批处理会把 GPU 塞满),不能反映是否过载;要看排队长度、KV 使用率、TTFT 的趋势。扩得快、缩得慢,最小实例数不为 0。
- 本地 NVMe 缓存权重(不从远端拉)、流式并行加载、预先切分好的权重(按 TP 切)、预先量化、编译缓存和 CUDA Graph 缓存,把冷启动从几分钟降到几十秒。
在 Kubernetes 上部署¶
这一节只是速览
Kubernetes 本身(核心对象与控制器、调度器、GPU 怎么被管起来、探针与滚动发布、多机多卡与分离式部署、Operator 与排障)在本书的 Kubernetes 与推理平台 一篇里有六章展开,并在本地 k3s 上逐条验证过。这里只列推理服务落到集群上时要做的决定。
| 问题 | 做法 |
|---|---|
| GPU 资源 | NVIDIA GPU Operator(驱动、设备插件、DCGM 监控);Pod 按整卡申请 nvidia.com/gpu;节点打上型号、NVLink 拓扑、网卡的标签,用亲和性调度 |
| 多机实例 | 一个实例跨多台机器(大 TP × PP、大规模 EP)时,用 LeaderWorkerSet 这类"一组 Pod 作为一个整体"的工作负载:一起创建、一起重启、一起扩缩;配合 gang 调度(Kueue、Volcano),避免只调度上一半的 Pod 占着卡等另一半 |
| 网络 | 机间的 RDMA 网卡通过设备插件或 SR-IOV 暴露给 Pod;NCCL / 传输引擎的环境变量(网卡、GID)在 Pod 规格里固定下来 |
| 存储 | 权重放在节点本地的 NVMe 缓存(首次从对象存储拉取),多个实例共享同一台机器上的缓存;不要每次启动都从远端下载 |
| 流量入口 | 推理网关(缓存感知路由,见全局调度);Kubernetes 的 Gateway API 推理扩展、llm-d 等提供了按前缀缓存和负载选择后端的能力 |
模型加载¶
冷启动的大头是加载权重。以 70B 模型(BF16 约 140 GB)为例,从对象存储按 1 GB/s 拉取要两分多钟,本地 NVMe 约 20 秒,从已经在运行的实例经 RDMA 拉取不到 1 秒(系统设计参考答案里算过)。常见的加速手段:
- safetensors + 内存映射:按需读取、零拷贝,多个进程共享页缓存;
- 流式并行加载:边读边往 GPU 拷贝、多线程并行读取多个文件(vLLM 的
--load-format runai_streamer,以及tensorizer); - 预切分:按目标 TP 度数预先切好保存(vLLM 的
sharded_state格式),每个 rank 只读自己的那一份; - 预量化:直接保存量化后的权重,省掉加载后的在线量化;
- 缓存编译产物:CUDA Graph、
torch.compile、DeepGEMM 等 JIT kernel 的编译结果放在持久化的缓存目录里,否则每次启动都要重新编译几十秒。
探活与优雅退出¶
- 启动探针要给足时间(加载 + 编译 + 预热可能好几分钟),否则 Kubernetes 会在加载到一半时把容器杀掉重启,陷入循环;
- 就绪探针不能只看进程和端口:要发一个真实的小请求(生成几个 token),确认模型加载完、CUDA Graph 录好、能正常出结果,才接流量;
- 存活探针要保守:推理引擎在处理长请求时可能几秒不响应健康检查,误杀的代价是一次冷启动;
- 优雅退出:收到终止信号后先从负载均衡里摘除、不接新请求,等在途请求跑完(
terminationGracePeriodSeconds要大于最长请求的时长),再退出。长的流式请求可以设一个上限,超时的由网关用已生成的内容续写。
发布策略¶
推理实例的冷启动很慢、每个实例都很贵,发布策略要在时间、容量和额外 GPU 之间权衡。16 个实例、流量是满容量的 80%、冷启动 3 分钟、排空在途请求 2 分钟:
import math
N, LOAD, COLD, DRAIN = 16, 0.8, 180, 120 # 16 个实例;流量是满容量的 80%;新实例冷启动 3 分钟;旧实例排空在途请求 2 分钟
for name, unavail, surge in (("逐个替换(maxUnavailable=1)", 1, 0), ("一次替换 4 个(maxUnavailable=4)", 4, 0),
("先加后减(maxSurge=2)", 0, 2), ("蓝绿(整套新实例)", 0, N)):
step = unavail + surge # 每一轮替换几个
rounds = math.ceil(N / step)
minutes = rounds * (COLD + DRAIN) / 60
low = (N - unavail) / N # 替换过程中的最低容量
extra = surge / N # 需要额外准备的 GPU 比例
verdict = "不过载" if low >= LOAD else "过载"
print(f"{name}:{rounds} 轮、约 {minutes:.0f} 分钟;最低容量 {low:.0%}(流量 {LOAD:.0%},{verdict});额外 GPU {extra:.0%}")
逐个替换(maxUnavailable=1):16 轮、约 80 分钟;最低容量 94%(流量 80%,不过载);额外 GPU 0%
一次替换 4 个(maxUnavailable=4):4 轮、约 20 分钟;最低容量 75%(流量 80%,过载);额外 GPU 0%
先加后减(maxSurge=2):8 轮、约 40 分钟;最低容量 100%(流量 80%,不过载);额外 GPU 12%
蓝绿(整套新实例):1 轮、约 5 分钟;最低容量 100%(流量 80%,不过载);额外 GPU 100%
- 逐个替换最省 GPU 但很慢(一个多小时),发布期间出了问题回滚也同样慢;
- 一次替换太多会让剩下的实例过载——流量 80% 时,同时下线的实例不能超过 20%;
- 先加后减(maxSurge)需要一点额外的 GPU,但容量始终不降,是 GPU 有余量时的首选;
- 蓝绿最快、回滚最简单,但要双倍的 GPU,通常只在低峰期或小规模时使用。
模型版本发布还要额外注意:先灰度一小部分流量,对比新旧版本的质量指标(线上评测、用户反馈)和延迟指标,再逐步扩大;前缀缓存和 KV 缓存池必须按模型版本隔离(KV 依赖权重);PD 分离时 prefill 和 decode 两侧要用同一个版本,发布时成对切换。
扩缩容¶
扩缩容的信号用排队长度、KV 使用率、TTFT 的趋势,而不是 GPU 利用率——decode 时 GPU 的 SM 利用率天然不高,按它扩容会永远扩不出去或者乱扩。实现上用 KEDA 或 HPA 读取引擎暴露的指标:vLLM 的 vllm:num_requests_waiting、vllm:kv_cache_usage_perc,SGLang 的 sglang:num_queue_reqs、sglang:token_usage。策略:
- 按日内曲线提前扩容,突发流量靠热备实例吸收(热备的数量按冷启动时间内的流量涨幅算);
- 扩得快、缩得慢:缩容要等一段稳定期,并且只缩空闲的实例;
- 最小实例数不为 0(大模型的冷启动太慢,缩到 0 等于停服)。
可观测性¶
| 层次 | 指标 | 用途 |
|---|---|---|
| 用户体验 | TTFT、TPOT / ITL 的 P50 / P99,goodput,错误率,按模型、租户、提示词长度分桶 | SLO 与告警 |
| 引擎 | 运行中 / 排队中的请求数、KV 使用率、抢占次数、前缀缓存命中率、每步的 batch 大小和耗时 | 扩缩容信号、容量规划、调参 |
| 资源 | GPU 显存、功耗、温度、NVLink / 网卡的吞吐与错误(DCGM)、XID 错误 | 硬件故障的早期发现 |
| 请求级 | 每个请求的时间线:排队、prefill、首 token、各段 decode、传输(OpenTelemetry 追踪) | 定位个别慢请求 |
GPU 的硬件故障(ECC 错误、NVLink 降速、XID)在大规模集群里是常态:要有自动的节点隔离和替换流程,否则一张坏卡会让它所在的整个 TP / EP 组变慢或挂掉。
怎么讲清楚
"怎样上线一个 70B 模型并保证不中断",按顺序讲:部署(多机实例用 LeaderWorkerSet + gang 调度、拓扑感知)、加载(本地 NVMe 缓存、流式并行加载、预切分、编译缓存,把冷启动从分钟级降到几十秒)、探活(启动探针给足时间、就绪探针发真实请求、优雅退出排空在途请求)、发布(先加后减,同时下线的比例不超过余量,模型版本灰度并隔离缓存)、扩缩容(看排队和 KV 使用率,不看 GPU 利用率,扩快缩慢、最小实例数不为 0)。
练习¶
1. 为什么就绪探针要发真实请求? 进程已经在监听端口了,还可能出什么问题?
参考答案
端口可用只说明 HTTP 服务起来了,模型可能还在加载、CUDA Graph 还在录制、某张卡初始化失败(多卡实例里一个 rank 挂掉,其他 rank 会卡在第一次集合通信上)、权重加载错了版本或者量化配置不对导致输出乱码。发一个固定的小请求并检查输出(比如固定提示词的贪心输出等于预期),能发现这些问题,避免把流量打到"看起来活着、实际不可用"的实例上。
2. 发布期间的前缀缓存。 滚动发布新版本模型时,新旧版本的实例并存。路由器应该怎样处理前缀缓存的索引?
参考思路
索引按模型版本分开:同一个前缀在旧版本实例上的缓存对新版本无效。路由时先按灰度比例决定请求走哪个版本,再在该版本的实例里做缓存感知路由;会话最好在一个版本内完成(会话粘性),否则中途切到新版本,整个上下文都要重新 prefill。灰度结束后,旧版本的索引随实例下线一起删除。
小结¶
- Kubernetes 上:GPU Operator、拓扑标签与亲和性;多机实例用 LeaderWorkerSet + gang 调度;RDMA 网卡与本地 NVMe 权重缓存。
- 冷启动靠本地缓存、流式并行加载、预切分、预量化和编译缓存缩短。
- 启动探针给足时间,就绪探针发真实请求,优雅退出排空在途请求。
- 发布:同时下线的实例不能超过容量余量;有余量用先加后减,模型版本灰度发布并按版本隔离缓存。
- 扩缩容看排队、KV 使用率和 TTFT 趋势,扩快缩慢;可观测性分体验、引擎、资源、请求四层,硬件故障要自动隔离。
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题