vLLM + Kubernetes:大模型推理生产级部署与弹性伸缩实战 给线上跑大模型推理最揪心的从来不是“模型跑不跑得起来”而是“扛不扛得住流量、能不能稳稳扩缩容、发一版要不要折腾半夜”。vLLM 解决前一半把单张 GPU 的吞吐吃到接近硬件极限K8s 解决后一半把 GPU 当成可随时调度的普通资源。这篇文章不聊玩具 demo直接从 vLLM K8s 的组合出发架构怎么拆、GPU 调度怎么做、弹性伸缩怎么接、故障怎么排尽量把一套能进生产的方案完整讲清楚。适合正在搭推理平台的后端、运维、SRE 工程师也适合算法团队里想亲手把服务交付得规矩一点的同学们。1. 为什么说 vLLM 和 K8s 是天生一对1.1 vLLM 先解决单机推理效率的天花板vLLM 为什么快简单说它把 Transformer 推理里最占地方、最碎片化的 KV Cache 单独拎出来管理用类似操作系统虚拟内存的分页方式把显存里一块块不连续的空隙全部用起来。过去一个长会话一旦提前分配好缓存后面哪怕没用到这块显存也锁在那里别人想借也借不走现在可以让多个请求互相借用这些空隙缓存利用率一下子提了一大截。再加上 continuous batching连续批处理它不再等一个 batch 整体跑完才释放资源而是每跑到下一个 token 就看看有没有请求结束、有没有新请求排队哪个先结束就先腾位子给新来的。这个机制让推理吞吐量提升 2 到 4 倍属于那种不换硬件、只换引擎就能看到的收益。但这里有个容易被忽略的点vLLM 在把单机效率拉高之后服务的资源消耗特征会变得更“动态”。同一个模型可能某一秒还在疯狂占显存、算力打满下一秒请求少了GPU 又空转。这种抖动如果只能靠人工看监控、手动改副本数是根本跟不上的。这也是为什么要把它放进 K8s 里管。1.2 K8s 再补上多副本状态与资源隔离的一环K8s 在推理场景里最直接的价值就三件事第一多副本管理。线上模型服务至少得有两个副本节点挂了服务不停流量能切到另一个 Pod第二资源隔离。GPU 卡、显存、CPU、内存都通过 Resource Quota 和 Namespace 分隔开不同团队的任务不再互相踩脚第三弹性伸缩的底座。只有资源层是可编程的扩容缩容才能变成“规则驱动”而不是“人类手动盯着 Grafana 按按钮”。我见过很多团队在没有 K8s 的时候是怎么跑模型的一台服务器上跑一个 API 服务脚本检测进程死了就重启新模型发版靠 scp 拷权重、手动改 shell 脚本。这种模式在单卡单模型的小流量阶段没问题一旦涉及多模型、多 GPU、多租户问题马上失控。K8s 本质上是把“哪台机器有空闲 GPU”“哪个 Pod 把卡占了”“扩容命令怎么执行”这些事从人肉运维里抽出来变成声明式配置。不过也要说句公道话K8s 默认调度器对 GPU 的理解非常粗浅它只知道节点上有几个可分配 GPU不知道你的模型适不适合这张卡、显存够不够、多卡并行时卡间通信能不能满足。所以这套架构真正考验人的是把 K8s 的调度机制摸透之后按自己的模型规格去定义资源规则。1.3 组合之后运维重点从“服务进程”转移到“队列与容量”单机跑 vLLM 的时候你盯着的是进程活不活、端口通不通。上了 K8s 之后进程存活由 Kubelet 和探针管端口由 Service 管GPU 挂没挂由 device plugin 管。你要盯的事情变成了三层队列有多深、GPU 利用率多高、请求耗时有没有踩线。这个转变非常关键。因为 vLLM 的本质是排队系统每个 Pod 内部有运行中的请求、等待中的请求、以及 KV Cache 的占用水位。你真正需要做决策的是当一个 Pod 的等待队列超过多少时加一个副本当多个副本的空闲时间超过多少时再缩回来。这些指标在单个进程维度是看不到的必须靠 Prometheus 拉取 vLLM 暴露的指标再反馈给 KEDA 或 HPA。后面第 4 章我会把这条链路一步步拆开。2. 实际部署前必须想清楚的架构与调度设计2.1 一条完整推理链路上的角色分工先看整体链路。客户端请求先打到网关或者 Ingress然后转发到 Kubernetes 的 ServiceService 按负载均衡策略把请求分发给后端的 vLLM Pod。每个 Pod 通常是一个真正的 vLLM 推理进程Pod 内部会挂载模型权重目录启动时把权重从模型存储加载到显存。同时每个 Pod 通过 /metrics 端口暴露 Prometheus 指标Prometheus 抓取之后KEDA 根据指标决定是否需要扩缩容。这里面最容易被忽略的是“模型权重从哪来”。很多人直接把权重打进镜像模型几个 GB 或者几十个 GB镜像构建一次要等很久每台 GPU 节点都要重新拉一遍发版慢且浪费带宽。更合理的做法是单独准备一个权重存储可以是共享文件系统、对象存储挂载也可以是本地节点缓存通过 PVC 挂载进 Pod。这样镜像体积小、启动快多个副本能并发读同一份权重。2.2 用节点池、污点和亲和性管住 GPUGPU 节点一定是整个集群里最贵、最稀缺的资源所以绝不能让普通业务 Pod 和推理 Pod 混在一张 GPU 卡上。两个机制配合使用节点池把显卡规格类似的机器归到一组污点让不相关的 Pod 默认进不来再做节点亲和性让推理 Pod 明确落在带 GPU 的节点池上。一个典型的做法是给 GPU 节点打上node-typegpu-a这种标签同时给节点加污点gpu:NoSchedule。普通 Pod 没有对应容忍调度器会自动避开这些节点。推理 Pod 的 spec 里则显式写上nodeSelector: node-type: gpu-a tolerations: - key: gpu operator: Exists effect: NoSchedule这样写的好处是把“GPU 节点只服务推理”变成基础设施层的约束而不是靠运维团队口头约定。后面哪怕有新人误提交了一个普通 CPU 任务调度器也会直接拒绝不会出现“GPU 被莫名其妙的任务占着”这种尴尬。2.3 GPU 资源请求怎么写隔离方案怎么选在 K8s 里申请 GPU 不是在 resources 里乱填request 和 limit 都要写同一个 GPU 资源字段。设备插件会在每个 GPU 节点上注册资源比如常见的资源名长这样example.com/gpu。你部署前先查一下节点上的资源列表然后按下面的方式申请resources: requests: cpu: 4 memory: 12Gi example.com/gpu: 1 limits: cpu: 8 memory: 16Gi example.com/gpu: 1GPU 的 request 和 limit 必须一致因为 GPU 不能像 CPU 那样被超卖这是当前大多数 GPU 调度插件的行为。如果同一个 GPU 厂商支持 MIG 显存切片可以把整卡切成 1g、2g 之类的规格按切片粒度申请适合小模型多副本的场景。切片的收益是单卡利用率更高代价是每份切片的算力也被割裂大模型多卡并行时很吃亏所以取舍要按模型规模来定。2.4 模型权重不是容器里的“乘客”是队友容器本身是无状态的跑完就扔但权重文件是持续存在的大块数据。我建议把权重放到一个独立的 PVC 上比如 NFS 或者分布式存储里Pod 启动时通过 volumeMount 挂载进去。vLLM 启动命令里直接指向挂载路径不需要把权重复制进镜像。权重 PV 要注意读写模式。多个 vLLM Pod 同时启动时是并发读同一个权重文件所以 PVC 要么是 ReadWriteMany要么是每个副本挂自己的 ReadWriteOnce 副本。挂在共享存储上时网络吞吐直接影响模型加载时间。之前遇到一个集群从对象存储拉 70B 级别权重带宽不够Pod 起来十分钟模型还没加载完用户这边已经超时重试到崩溃了。正确做法是给权重缓存区域预留足够的吞吐或者提前在节点本地预热权重。3. 手把手把 vLLM 服务搬上 K8s3.1 镜像和模型权重怎么准备先把基础镜像准备好。vLLM 官方会发对应的服务镜像里面已经包含 Python 环境、CUDA 相关依赖和入口脚本。实际部署时建议锁定一个具体版本不要让生产环境漂在 latest 上。镜像体积通常不小节点多的话建议在集群内部搭一个镜像仓库做中转避免每台机器都去公网拉。权重方面先用模型下载工具把权重下载到一个可以共享的存储目录。注意区分不同的模型格式比如直接加载 safetensors 原始权重还是跑量化过的 AWQ/GPTQ 版本。vLLM 启动时通过--quantization参数告知使用哪种量化方式权重加载方式和显存占用完全不同。3.2 一份能跑的 Deployment 配置下面这份配置是精简版但足够当模板。注意里面把 GPU 资源写成了example.com/gpu实际要替换成你环境里设备插件注册的名字apiVersion: apps/v1 kind: Deployment metadata: name: vllm-inference namespace: inference spec: replicas: 2 selector: matchLabels: app: vllm-inference template: metadata: labels: app: vllm-inference spec: nodeSelector: node-type: gpu-a tolerations: - key: gpu operator: Exists effect: NoSchedule containers: - name: vllm image: registry.internal.example.com/vllm:v0.6.3 command: [python3, -m, vllm.entrypoints.openai.api_server] args: - --model - /models/example-llm - --served-model-name - example-llm - --tensor-parallel-size - 1 - --max-model-len - 8192 - --gpu-memory-utilization - 0.9 - --port - 8000 - --trust-remote-code ports: - containerPort: 8000 resources: requests: cpu: 4 memory: 12Gi example.com/gpu: 1 limits: cpu: 8 memory: 16Gi example.com/gpu: 1 readinessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 60 periodSeconds: 10 volumeMounts: - name: weights mountPath: /models volumes: - name: weights persistentVolumeClaim: claimName: model-weights-pvcreadinessProbe 非常重要。vLLM 加载大模型的时间很长如果探针设置过短Pod 会在启动过程中被判定不健康、被反复重启。我习惯设 initialDelaySeconds 在 60 秒以上具体按权重大小和磁盘读取速度调整。探测接口用 /healthvLLM 就绪之后会返回 200这才允许流量进来。3.3 用 Service 和 Ingress 把这个服务供出去Deployment 只管 Pod真正让流量进来的还要 Service 和 Ingress。Service 如果不做特殊配置K8s 默认用 iptables 转发对长连接和大量并发不够友好建议开启 sessionAffinity 或者干脆配合 Ingress 使用。apiVersion: v1 kind: Service metadata: name: vllm-inference namespace: inference spec: selector: app: vllm-inference ports: - port: 80 targetPort: 8000 sessionAffinity: NoneIngress 层要留意超时时间。LLM 推理接口常常要等十几秒甚至几十秒如果 Ingress 默认读超时只有 60 秒长上下文请求会直接被打断。不同 Ingress 控制器的参数不一样一般是设置 proxy-read-timeout 或者类似的字段。另一个点是请求体大小大模型接口传的是 JSON输入 token 多了请求体也可能很大千万别在网关口被默认大小限制截断。3.4 vLLM 启动参数里值得反复调的几个值启动参数里最需要认真调的是下面这几个。--max-model-len决定模型能接受的最大序列长度也直接决定 KV Cache 的最大占用。开得越大能处理的上下文越长但显存被锁定的部分也越多单卡能并发服务的请求数反而会下降。它不是越长越好而是按业务实际需求来。如果业务几乎都是短文本开到 2048 或者 4096 就够别平白无故为高峰值预留资源。--gpu-memory-utilization默认是 0.9意思是允许 vLLM 用掉 90% 的显存。如果你的模型权重很大0.9 可能根本装不下启动时会直接报错这时候要往下调。反过来如果模型权重很小、希望多塞一些并发也可以往上探但我很少建议超过 0.92留给驱动和运行时一点余量是更稳妥的做法。--tensor-parallel-size是多卡并行切分参数。单卡能放下的模型就写 1多卡并行时按实际数据并行度设置。这个参数要和 Pod 请求的 GPU 卡数匹配不能请求了 2 张卡却只让 vLLM 用 1 张那是浪费也不能请求 1 张卡而指定并行度 2那直接启动失败。--trust-remote-code是一个容易被忽略的安全开关。很多开源模型需要运行自定义代码才能加载默认不允许经常导致启动失败。如果确实需要加上这个参数如果不确定模型是否需要先别加通过报错信息判断。关于参数我建议做一个小表格记下来参数作用调参经验--max-model-len最大序列长度结合业务能小则小--gpu-memory-utilizationKV Cache 可用显存比例0.85 到 0.92 之间常见--tensor-parallel-size多卡并行度必须匹配 GPU 卡数--served-model-name接口里暴露的模型名保持唯一避免混淆--trust-remote-code允许自定义模型代码确认安全后再开4. 弹性伸缩别让 GPU 白白睡觉4.1 CPU 指标在推理场景里为什么失灵大多数刚接触 K8s 弹性伸缩的人第一反应是用 CPU 使用率做 HPA。这个思路在常规 Web 服务里没问题但推理场景完全不一样。vLLM 推理的瓶颈通常是 GPU 算力或显存带宽CPU 只负责数据预处理、调度这些边际工作。你经常能看到 GPU 已经被请求撑满但 Pod 的 CPU 使用率还只有 30%HPA 觉得一切正常实际上队列已经在不断拉长。反过来如果 GPU 空闲的时候 CPU 反而高一点HPA 可能误判为负载高然后扩出一堆实际上无用的 Pod。所以 CPU 指标在推理场景里基本是“睁眼瞎”真要可靠扩容必须找到能反映推理引擎真实负载的信号。vLLM 自身暴露了一套 Prometheus 指标里面有 running 请求数、waiting 请求数、队列长度、吞吐量、缓存使用率等核心数据。如果是 OpenAI 兼容接口服务直接在 /metrics 路径就能抓到。我们的任务就是把“队列里有几个请求等待”变成扩缩容依据。4.2 用 vLLM 自带指标接 Prometheus 和 KEDA最省事的方案是把 vLLM 的 /metrics 接入 Prometheus然后用 KEDA 的 Prometheus Scaler 自动扩缩容。KEDA 本质上是一个把各种外部指标转换成 HPA 所要指标的组件它支持 Prometheus 作为触发源比手写 custom metrics adapter 简单很多。一个典型的 ScaledObject 长这样apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: vllm-scaler namespace: inference spec: scaleTargetRef: name: vllm-inference pollingInterval: 15 cooldownPeriod: 300 minReplicaCount: 1 maxReplicaCount: 6 triggers: - type: prometheus metadata: serverAddress: http://prometheus.monitoring.svc.cluster.local:9090 metricName: vllm_request_queue threshold: 8 query: | sum(vllm_request_queue_size{pod~vllm-inference-.*}) by (pod)这里需要注意几个细节。threshold: 8的含义是每个副本允许平均承担的队列深度超过 8 就准备扩容。想清楚了再决定阈值不要拍脑袋。pollingInterval: 15是指每 15 秒查询一次指标太频繁没意义还会给 Prometheus 增加压力。cooldownPeriod: 300是缩容冷却时间防止刚扩容完还没跑稳就立刻缩掉这个值我一般不会低于 5 分钟。4.3 伸缩策略背后的容量账GPU 扩容和普通 CPU 扩容有一个本质区别CPU 节点通常现成可调度Pod 秒级启动GPU 节点如果已经有空闲卡那还好说如果集群里本来就没有空闲 GPUK8s 只能让 Pod 一直 Pending等人工加机器或等其它任务释放。所以做弹性伸缩之前先算清楚容量账。一种稳妥的策略是保留一个“热池”的概念也就是集群内随时保持若干台空闲 GPU 节点或者至少预留足够的可抢占配额。这样 KEDA 触发扩容时Pod 能在几分钟内调度上去。如果业务上允许预算波动可以在低谷期把 minReplicaCount 压到 1高峰期把 maxReplicaCount 拉高但不要天真地追求 scale to zero。LLM 启动时加载权重、构建 KV Cache冷启动通常要几分钟这个延迟对实时推理业务是致命的。scale to zero 更适合异步任务不适合在线接口。还有一点要注意Deployment 扩缩容和 GPU 调度的顺序。默认的 rolling update 策略在推理场景下要小心如果 maxUnavailable 设大了发版时会短暂出现服务能力下降。我习惯设 maxUnavailable: 0maxSurge: 1保证至少有一个老副本能继续服务。5. 实战中常踩的坑和排查方法5.1 节点够了Pod 却一直 Pending最典型的问题是“明明看到节点上有 GPU但 Pod 就是 Pending”。先别急着怪 K8s按顺序查。第一检查 GPU 资源是否真的可分配。可以看节点详情里的 Allocatable如果 GPU 资源显示为 0通常是设备插件没有正常运行或者驱动版本与容器运行时不匹配。这时候需要检查设备插件 DaemonSet 的日志而不是盲改 Deployment。第二检查污点和容忍。节点如果有 NoSchedule 污点而你的 Pod 没有加对应容忍调度器连节点看都不看。用 kubectl describe node 去看 Taints 字段再对比 Pod 的 tolerations。第三检查亲和性和反亲和。如果两个大模型服务都要求独占某类 GPU 节点而集群只剩下一张卡新 Pod 会一直等。要合理使用 podAntiAffinity 把不同副本分散到不同节点但别把它设成必须满足的强约束否则集群碎片化之后会互相卡死。第四检查是不是资源请求本身写错了。GPU 资源名如果写错K8s 会把 GPU 请求当成普通自定义资源节点上没有任何一个对象能满足Pod 也会一直 Pending而且报错信息非常难懂。先在节点上确认准确的资源名再写配置。5.2 首字迟迟不出现队列却越积越长首字延迟也就是 TTFT直接决定用户体感。排查思路是先看队列长度、running 请求数、吞吐量这三个指标。如果 waiting 请求数一直很高说明系统已经是过载状态仅仅靠缩容参数也救不回来需要扩副本。如果 running 请求数没有打满但延迟仍然高问题可能在单请求的长上下文处理或者被某些慢请求占住了执行槽。这时候可以检查 vLLM 的日志看当前 batch 里是否有特别长的序列。还有一个容易被忽略的问题是客户端连接池。发往 vLLM 的请求通常是通过 HTTP 长连接如果客户端连接的并发数设置太低即便服务端还有余量请求也只能排队等空闲连接这个队列在 K8s Service 层不会反映在 vLLM 内部指标里。遇到这种问题优先看客户端连接池配置别在服务端瞎调参数。5.3 显存 OOM 与 max-model-len 的撕扯OOM 在 vLLM 里是很常见的。很多时候不是显存真的不够而是配置里的默认值太大。比如 8GB 显存的卡要跑一个大模型权重占掉大半剩下的显存如果 max-model-len 默认给到 8192KV Cache 一初始化直接超出预算。处理 OOM 的正确路径是先算账。显存总容量减去模型权重占用量再减去一些运行时开销剩下的才是 KV Cache 的空间。KV Cache 占用和模型层数、头数、维度、序列长度都有关没必要手算直接做实验把 gpu-memory-utilization 调低把 max-model-len 调小逐个往上试探。另外注意vLLM 的显存 OOM 不一定是进程崩溃也可能是返回 500 错误提示“out of memory”或者“KV cache block allocation failed”。出现这个报错时第一反应不是加显存而是先把 max-model-len 降下来。这个教训我至少踩过三次调参顺序一定是先缩长度再扩卡。5.4 版本升级带来的“半夜惊魂”vLLM 的迭代速度很快API 和启动参数的兼容性偶有破坏。升级小版本之后之前能跑的命令行参数可能被废弃量化权重格式也可能不再被旧版本加载。我的习惯是把版本固定好并且建立镜像清单每次升级前先在一台非生产节点跑一轮完整验证。还有一个隐藏坑是指标名称的变化。KEDA 依赖的 Prometheus 指标如果因为升级改名ScaledObject 里的 query 会查不到数据弹性伸缩会悄悄失灵。现象就是某天流量涨了Pod 却一直不扩容。排查方式是在 Prometheus 里直接跑 query看是否返回非空结果同时看 KEDA Operator 的日志有没有报错。遇到这种问题最快的方法是回滚到上一个已验证版本而不是在深夜现场研究新版本的 breaking change。生产环境最怕的不是版本旧而是没人验证过的新版本。我把对应的排障套路整理成一张速查表方便大家直接对症状找原因现象可能原因首选排查动作Pod 一直 PendingGPU 资源不可分配或污点未容忍kubectl describe node 查看资源与污点启动反复重启探针时间太短、权重加载慢查看 logs延长 readinessProbe 时间请求超时Ingress 超时、队列积压、连接池太小检查指标调大超时与服务副本返回 500 或 OOMmax-model-len 过大降低 max-model-len 或 gpu-memory-utilization扩容不触发指标名失效、KEDA query 为空在 Prometheus 中验证 query 结果发版后服务抖动rollingUpdate 策略太激进设置 maxUnavailable: 0最后说几句实在话。vLLM 和 K8s 这套组合真正运行起来之后最花时间的往往不是部署本身而是对容量、队列、GPU 碎片、版本节奏这些事建立自己的判断。每个团队的负载特征不一样没法有一份万能参数表。我能给的建议是先把指标采集链路做扎实让扩容缩容每一步都有数据支撑再根据线上表现去调整阈值。踩上几回坑之后你会发现这套组合的稳定性上限其实比绝大多数人想象中要高得多。