跑过K8s里AI服务的人应该都有这种感觉:模型在本地GPU上推理明明挺快,一放进Kubernetes集群,QPS上不去、GPU利用率不到两成、POD动不动就重启,最后业务方一句"为什么线上比线下慢这么多"抛过来,你连锅都甩不出去。
这个项目踩的坑基本就在这条线上。把一套基于Transformer的文本生成模型完整部署到K8s集群,从模型文件准备、镜像构建、GPU资源调度到vLLM推理服务落地、QPS弹性伸缩,整个过程做完,单节点吞吐从最初不到12 QPS干到稳定140 QPS左右,尾延迟也从2.8秒压到800毫秒内。这篇文章不谈虚的,只把我实际跑通的做法、关键参数和踩过的几个大坑完整记录下来,做个参考。
1. 推理性能瓶颈拆解:算力、显存、调度谁在拖后腿
1.1 推理和训练的性能模型不是一回事
很多人拿着训练场景的思路去做推理优化,上来就堆GPU。但推理服务和训练任务在资源画像上完全是两种物种。训练是吞吐密集型的离线长任务,可以忍受几小时甚至几天的批处理等待,系统设计的重心是算力利用率;推理则是时延敏感型的在线服务,一个用户的请求从发出到拿到第一个token,中间任何环节多出几十毫秒都会被明显感知。更关键的是,推理阶段的瓶颈往往不是FLOPS算力,而是显存带宽和访存延迟。
文本模型在推理时是一个自回归过程,每生成一个token都要把完整的模型权重从显存搬运到计算单元上跑一遍。这意味着推理的耗时跟模型参数量基本成正比,而跟输入输出长度的关系反而更复杂。参数量14B的模型在FP16精度下权重就有约28GB,哪怕一张A100的卡有80GB显存,实际做单次推理时计算单元大部分时间也浪费在等待权重搬运上,这就是所谓的memory-bound。抓住这个本质之后,后面选择显存复用、批量调度、连续批处理这些优化手段的原因就顺理成章了。
1.2 K8s在推理场景中引入的三层额外开销
在裸机上跑推理,进程直接使用GPU,资源是独占的。但一旦上K8s,整个系统会多出三个层面的开销需要算进预算里。
第一层是网络开销。服务化之后,每次请求都要经过从客户端到Ingress、再到Service、最后到Pod内部的完整链路,中间还有多次代理转发和HTTP序列化反序列化。实测下来,一个纯文本推理请求在K8s内部的网络链路耗时居然能占到端到端延迟的8%到12%,这是很多人忽略的隐性成本。
第二层是调度和资源隔离开销。K8s默认的调度器不会感知GPU的拓扑结构,也不会优先考虑已经在某个节点上的显存状态。如果Pod被调度到不同NUMA节点甚至经过PCIe交换机跨节点通信,GPU之间或者GPU与CPU之间的数据拷贝时延会成倍增加。
第三层是生命周期管理的开销。K8s的滚动更新、探针检测、健康检查都会周期性消耗Pod所在节点的CPU和网络资源。当线上服务处于高负载时,Kubelet的和容器运行时自身的资源占用会被放大。这就要求在部署推理服务时,必须给系统预留至少1个CPU核和1GB内存的冗余,否则到了流量高峰,节点资源耗尽,K8s会错误地把进程OOM归因为应用异常,然后不断重启Pod。
1.3 加速思路的整体框架
经过上面的拆解,推理加速的任务就变成了四条线并行:
- 减少单次请求的显存访问量,也就是模型精简和量化
- 提高显存的有效利用率,让一次权重搬运服务尽可能多的请求,也就是批处理优化
- 让每个Pod尽量待在最合适的节点上,减少跨拓扑访问,也就是调度优化
- 让服务跟着真实流量变化弹性伸缩,避免低峰期空转、高峰期排队,也就是资源弹性
这篇博文的主体内容也围绕这四块展开,每一块落地到K8s上都有对应的具体配置和参数。
2. 模型与镜像的准备阶段:从源头上减少等待
2.1 模型文件获取的路径选择与下载策略
在准备阶段最容易翻车的就是模型文件的获取。文本生成模型的参数文件动辄几十GB,从海外平台直接拉取经常遇到网络不稳定、速度只有几百KB/s或者直接中断的情况。我在这个项目里先把模型上传到国内可访问的模型社区,再通过内网下载,整个过程大概十几分钟就把14B参数量的权重文件完整拉下来了。如果你的情况不方便这么操作,也可以先在一台有完整网络的机器上下载,再通过内部的文件分发通道同步到GPU节点。重点是别在集群构建的关键路径上赌外网稳定性,否则一次断流就能卡住整个部署流程。
对于所有模型文件,务必用sha256sum核对一遍完整性。模型下载工具通常自带校验逻辑,但如果是通过脚本或手动方式下载的,缺一个分片就可能导致推理时出现诡异的输出结果。我遇到过一次加载后模型生成的内容全部是乱码,排查了两个小时最后发现是第二组分片文件不完整。
2.2 镜像瘦身与多阶段构建
基础镜像不要选带完整CUDA工具链的"全家桶"镜像,动辄5GB以上。推理服务只需要运行时环境,不是开发环境。用多阶段构建把编译工具和应用依赖分开:第一阶段装好Python编译依赖和推理框架的wheel包,第二阶段只拷贝编译后的产物复制到干净的基础镜像上。
最终我用的镜像各部分构成大概是这样:
| 组件 | 版本策略 | 体积影响 |
|---|---|---|
| 基础系统 | 精简版Linux发行版,不带桌面和开发工具 | 减少约40% |
| CUDA运行时 | 只保留满足推理框架最低要求的版本,不装nvvp等工具 | 减少约700MB |
| Python依赖 | 固定版本列表,用pip的约束文件锁定 | 避免体积膨胀和依赖漂移 |
| 推理框架 | vLLM的预编译wheel包 | 保持与CUDA版本严格匹配 |
这样处理之后镜像体积控制在2.1GB左右,配合nodes上的镜像预热策略,Pod冷启动时间从70秒降到了6秒内。如果你的集群规模大、节点多,建议在集群里部署一套镜像缓存组件,避免滚动更新时所有节点同时去仓库拉取同一个镜像把带宽打满。
2.3 模型热加载与探针配合
模型文件到位之后,推理服务的启动过程通常包含模型加载、权重初始化、预热推理几个阶段。对于一个14B的模型,在GPU上从冷启动到完成预热平均需要30到50秒。这个时间窗口内,如果K8s把Pod标记为Ready并开始转发流量,请求必然超时。
处理办法是把存活探针和就绪探针拆分开来做:
- 存活探针检测进程是否还活着,用
pgrep或者进程内部暴露的ping接口 - 就绪探针检测模型是否完成加载,我直接在应用里加了一个
/ready接口,只有模型全部加载并完成一次推理预热之后才返回200,在此之前K8s不会把Pod加入Service的端点列表
这样配置文件里的探针延迟时间可以拉长到120秒,但实际就绪时间完全由模型加载进度决定,既不会提前转发流量,也不会因为启动慢被误杀。
3. GPU资源分配:K8s里的显存博弈
3.1 device-plugin的工作机制与显存碎片化
K8s本身不感知GPU,需要通过NVIDIA的device-plugin组件把nvidia.com/gpu这种扩展资源注册到节点上,Kubelet才能把它分配给Pod。这个机制本身很稳定,问题出现在资源粒度和显存碎片上。
默认情况下,device-plugin暴露的是一个GPU卡作为最小分配单位。也就是说,如果一个模型的权重加KV Cache只需要24GB显存,而你有一张80GB的A100或者48GB的L40S,Pod声明1个GPU的时候,剩下的显存就被白白浪费了。更麻烦的是,集群里如果部署了不同显存需求的推理服务,大卡被小请求占住之后,后面排队的Pod只能继续等。
解决办法有两种:一是给GPU节点池划分不同规格,大显存节点跑大模型,中显存节点跑小模型,避免互相挤占;二是使用支持MIG(多实例GPU)模式的卡,比如A100或H100在物理层面切出多个实例,每个实例有独立的显存和计算单元,K8s通过device-plugin的子模式把每个实例作为一个可分配的GPU资源暴露出来。
这两种方案我都试过。实际使用下来,MIG模式适合显存需求固定的场景,比如同一份模型权重在这个显存规格下刚好跑满,不会造成碎片;如果推理负载的动态性很强,不同时间段的请求长短差异很大,还是用整卡+节点池划分更灵活。
3.2 显存预留与KV Cache的调优
在部署推理服务时,--gpu-memory-utilization这个参数特别关键,它决定了vLLM能够使用多大比例的GPU显存用于KV Cache缓存。
我一开始取的是默认值0.9,也就是vLLM会尝试使用90%的GPU显存。在只有单一推理服务独占GPU卡的场景下这样没问题。但在需要使用CUDA的其它计算组件(比如同时跑一个embedding模型或向量检索进程)时,两方会争抢显存,导致其中一方OOM。后面我把这个参数调到0.7,并且用--max-model-len显式限制输入输出序列的最大长度,避免极端长请求把KV Cache耗尽。显式声明之后,显存分配变得可以预期,Pod的稳定性大幅提高。
3.3 请求级显存隔离的适用场景
如果要做到让多个模型安全地共享同一张物理GPU卡,同时又不想引入MIG这种硬件层面的切分,可以让vLLM分别启动两个服务,再靠CUDA的虚拟显存机制控制每个服务占用的最大显存。这个方案可以让一张80GB的卡同时跑一个14B的大模型和两三个小的embedding模型,资源利用率提升比较明显。
不过需要提醒的是,这种方式在显存分配上不是完全隔离的,一旦某个服务的显存使用超过预设上限,CUDA会报错并可能拖垮同卡上的其他服务。所以我个人还是偏好用更稳妥的方式:大模型独占大卡,小模型打包在几张小卡上,不同安全级别的服务尽量不要混部。
4. 推理框架选型与核心参数:vLLM落地K8s的完整配置
4.1 为什么生产环境选vLLM
现在可选的推理框架不少,比如HuggingFace的原生transformers pipeline、英伟达的TensorRT-LLM、以及FasterTransformer等。但我在K8s场景下最终选择了vLLM,核心原因是它对批处理和显存管理有一套成熟机制,能够在不改模型结构的前提下直接提升吞吐。
vLLM的招牌是PagedAttention,这个思路借鉴了操作系统的虚拟内存分页管理。传统推理框架在显存里为每条请求预分配一整块连续空间来存放KV Cache,这个空间大小按最大序列长度来预留,导致大多数请求实际用不到的部分全部浪费。vLLM则把KV Cache切分成固定大小的块,按需分配给不同请求,需要多少分配多少,释放之后还能继续复用。配合内置的continuous batching,也就是不用等一个batch里的所有序列都完成生成再收下一批请求,而是每完成一条序列就立马腾出位置插入新的请求,显存利用率和吞吐因此大幅提升。
4.2 Kubernetes部署清单(Deployment + Service + 探针)
给出一个可以直接用的部署配置,这个配置在我的测试环境里验证过。
apiVersion: apps/v1 kind: Deployment metadata: name: llm-inference-server namespace: ai-production spec: replicas: 2 selector: matchLabels: app: llm-inference-server template: metadata: labels: app: llm-inference-server spec: nodeSelector: gpu-type: a100-80g containers: - name: vllm-engine image: registry.example.com/ai/vllm-server:14b-latest imagePullPolicy: IfNotPresent ports: - containerPort: 8000 name: http-api env: - name: MODEL_NAME value: "qwen14b" resources: requests: cpu: "8" memory: "32Gi" nvidia.com/gpu: "1" limits: cpu: "8" memory: "32Gi" nvidia.com/gpu: "1" startupProbe: httpGet: path: /ready port: http-api periodSeconds: 10 failureThreshold: 30 readinessProbe: httpGet: path: /health port: http-api periodSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: path: /health port: http-api periodSeconds: 15 failureThreshold: 3关键点解释一下:
nodeSelector把Pod绑定到带A100 80G卡的节点,避免调度到无卡节点上一直Pendingresources.limits里显式声明nvidia.com/gpu: "1",这样device-plugin才会把GPU卡真正分配到这个Pod,同时避免多个Pod争抢requests.cpu设置到8核,是因为vLLM在批处理时会同时使用多个CPU线程做tokenization、sampling和预处理,CPU给太少会成为瓶颈
4.3 核心启动参数:max-num-seqs、tensor-parallel-size
vLLM的启动参数里面,--max-num-seqs和--tensor-parallel-size对性能和稳定性的影响最大。
--max-num-seqs决定了一个batch内最多同时处理多少条请求。这个值调大,可以提高GPU利用率和吞吐,但会增大每个请求排队等待的时间,推高P99延迟。我压测时发现,保持默认值(通常为256或取决于显存容量)时显存利用率很低,把比值从每请求平均占用的KV Cache块数量反推异常后才意识到,在显存足够的前提下应该把批大小尽量拉高,让请求尽量在batch内完成计算。最终这个项目里根据显存带宽和平均请求长度,把它从默认值调整到512左右,吞吐直接翻了一倍。
--tensor-parallel-size则是在多卡场景下的模型并行度。对于14B模型,在40GB显存的卡上需要2卡才能放得下,但如果用A100 80G,单卡就够了。这里的原则是:能用单卡跑就别开多卡并行。张量并行会引入卡间通信开销,即使是通过NVLink连接,也可能有10%到20%的性能损失。只有当单卡显存放不下模型权重时再考虑多卡。
4.4 更进一步的吞吐优化参数
如果请求长度波动大,可以打开--enable-prefix-caching。多轮对话场景中,用户的历史消息在每轮请求里都会重新计算一遍KV Cache,这个优化能直接跳过这些重复计算,效果非常明显。我观测到长多轮对话场景下的Prompt处理延迟从40ms左右降到低于5ms。
另外在服务上还可以启用--enforce-eager参数来关闭CUDA图模式的编译。默认情况下vLLM会把计算图编译成CUDA graph来加速,但编译期间会额外占用显存来存储编译产物。对于显存紧张的小型推理任务,关闭这个功能可以释放一部分显存给KV Cache,但对模型的动态shape请求会有一些性能损失,是否需要根据实际情况权衡。
5. 弹性伸缩与调度策略:性能之外的加速
5.1 HPA为什么不够用
K8s内置的HPA默认支持CPU和内存作为伸缩指标,但推理服务恰恰不是普通的CPU密集型业务。一个文本生成模型的GPU利用率可以跑到95%以上,但CPU可能只有20%,内存更是远远用不满。这种情况下按CPU阈值去扩容,永远都不会触发,服务就出现了流量暴涨但副本不增加的状况。
更合理的伸缩指标应该是:推理队列长度、GPU利用率、token生成速率,或者最直接的每副本QPS。这些指标K8s默认的metrics-server是不提供的,需要自己把这些业务指标暴露给Prometheus,再通过KEDA这类组件把它们连接到HPA的伸缩逻辑里。
5.2 KEDA接入QPS伸缩的实操
KEDA可以监听外部指标源并动态调整Deployment的副本数,不需要改业务代码,只需要在K8s里部署一个ScaledObject对象。下面是这个项目中使用的配置:
apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: llm-inference-scaledobject namespace: ai-production spec: scaleTargetRef: name: llm-inference-server minReplicaCount: 1 maxReplicaCount: 8 triggers: - type: prometheus metadata: serverAddress: http://prometheus-server.monitoring.svc.cluster.local:9090 query: | sum(rate(vllm_requests_total{namespace="ai-production"}[1m])) threshold: "30"这里的核心思想是:当整个服务的QPS超过30时,KEDA会逐步增加副本;低于这个值时,会先把多余的副本缩掉,只保留最小副本数。需要提醒的是,QPS阈值一定要结合单副本的最大承载能力来设定。我这套部署的单副本大概能支撑70到80 QPS,所以把扩容阈值设成30,留出了约两倍的缓冲区间,避免高峰期扩容还没完成流量已经打到现有副本上的情况出现。
5.3 GPU节点维度的自动扩缩容
K8s的HPA和KEDA只能调整应用层的副本数,如果整个集群没有空的GPU节点,新扩容出来的Pod会一直处于Pending状态。解决这个问题需要配合集群层的自动扩缩容组件,比如在云环境里用的Cluster Autoscaler。
为了让Cluster Autoscaler高效工作,需要给不同的GPU型号划分独立的节点池,并为每个节点池加上对应的标签和污点,比如nvidia.com/gpu=present:NoSchedule。这样调度器只会把需要GPU的Pod调度到带对应GPU标签的节点上。节点池的自动扩缩容策略需要设置合理的扩容阈值和缩容保护时间。我建议缩容保护时间至少设置10分钟,否则流量一旦抖动,节点一会儿被缩走一会儿又要重新创建,GPU节点的初始化时间动辄3到5分钟,用户体验会非常糟糕。
5.4 调度策略里藏着20%的性能差异
默认情况下,K8s调度器按资源的Request值做筛选,但不会考虑Pod与GPU之间的NUMA拓扑距离。当一个Pod被调度到某张GPU卡上,但对应的CPU和内存跟这张卡不在同一个NUMA节点时,跨NUMA访问会显著增加时延。
这里可以使用CPU管理策略和拓扑管理策略来优化。把Kubelet的--cpu-manager-policy设置为static,把--topology-manager-policy设置为single-numa-node,可以保证Pod的CPU、内存和GPU都落在同一个NUMA节点上。在我的测试里,这个配置能让P99延迟降低约15%到20%,吞吐提升约10%——而代价仅仅是全局调度时可能会多等几秒钟,这笔账怎么算都划算。
另一个容易被忽略的是调度器的装箱策略。K8s默认的调度器倾向于把Pod打散到不同节点上以分散故障风险,但对于GPU这种昂贵资源来说,有时候反而是把尽量多的Pod集中在一台节点上更划算。通过配置调度器的过滤器开关,可以改成更紧实的装箱策略,尤其是当Pod数量不多、但每个Pod都需要整卡时,Binpack策略能显著减少空转GPU节点的数量,节省成本。
6. 实测与踩坑:从压测数据到问题排查
6.1 压测方法:不要只看平均延迟
压测推理服务不能只用一个并发连接反复打,那样测不出系统的真实上限。文本生成场景的输入输出长度变化非常大,请求之间互相影响也很大,因此压测需要模拟混合长度的请求流。
我用ghz配合自定义的请求体做了面向文本服务的压测。压测参数分为三组:
| 场景 | 并发数 | 输入token长度 | 输出token长度 | 预期指标 |
|---|---|---|---|---|
| 轻载 | 4 | 64 | 128 | P99延迟 < 500ms |
| 中载 | 16 | 256 | 512 | P99延迟 < 1s |
| 重载 | 64 | 1024 | 1024 | 吞吐 > 80 QPS |
每组压测持续至少5分钟,不能只跑几十秒就下结论。生成模型的显存使用是一个动态过程,短时间压测根本看不到KV Cache的累积效应。
压测中重点观察三个指标:吞吐量、P99延迟、GPU利用率。如果GPU利用率已经到98%但吞吐还是很低,说明模型权重访存是瓶颈,需要从模型量化方向优化;如果GPU利用率只有30%、但CPU已经打满,说明在数据预处理阶段有瓶颈,需要检查tokenizer和请求解析部分的并发能力。
6.2 踩过的三个真实大坑
第一个坑是探针导致的流量雪崩。上线第二天发现服务每隔几分钟就会出现一批500错误,查看日志发现是就绪探针的/health接口在服务高负载时返回超时,K8s把所有副本标记为未就绪,然后开始重启Pod。重启过程中Service把所有流量打到剩下还没被标记的Pod上,导致这些Pod也超时,最终整个服务短暂不可用。
排查后解决方法是把就绪探针改成更轻量的接口,不再参与模型状态的实时检查,只判断进程是否还活着;真正的模型加载状态检查交给启动探针去处理。同时在探针配置里把periodSeconds从2秒改成5秒,避免频繁探活本身成为额外负载。
第二个坑是显存碎片化导致的Pod调度失败。某个时间点开始新扩容出来的Pod一直Pending,但看GPU节点的显存明明有剩余。后来发现是device-plugin报告的剩余显存是节点级聚合计,但实际上整块显存已经被切分成了很多不连续的小块,单个Pod至少要20GB连续显存,而剩余的都是十几GB的碎片。这种情况没法靠调度器解决,只能调整部署策略,把碎片租给自己的小模型服务,而不是全部留给大模型。
第三个坑是滚动更新时新旧版本同时加载模型导致的显存超分。K8s的滚动更新策略默认先创建新Pod、等就绪后再销毁旧Pod,但在推理服务场景下,新版本Pod启动就会占用整卡显存,如果部署时没有留出足够的空闲节点,新Pod启动会直接把同节点的其它Pod挤到OOM。解决方法是把strategy.type改成Recreate,或者使用原地升级的机制,至少保证新旧Pod不会同时出现在同一张GPU卡上。
6.3 优化前后的数据对比
整套优化完成之后,我做了一次完整压测对比:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 单副本吞吐 | 12 QPS | 142 QPS | 约10.8倍 |
| P99延迟 | 2.8s | 780ms | 降低约72% |
| GPU显存利用率 | 35% | 78% | 提升约1.2倍 |
| Pod冷启动时间 | 70s | 6s | 降低约91% |
| 高峰期5xx错误率 | 3.7% | 0% | 消除 |
吞吐提升的核心来源是连续批处理和PagedAttention带来的batch效率提升;延迟优化的主要来源是拓扑调度与探针配置的修正;而服务规模的弹性伸缩则让整个系统在业务方突然猛拉流量的情况下能自动兜底,不用半夜爬起来手动扩容。
7. 几个可以继续做下去的优化方向
这套部署体系跑通之后,后续的优化空间仍然很大。如果你的业务对时延要求更高,可以考虑把模型量化为INT8或INT4,显存占用直接减半,推理速度还能再提升30%到50%;如果集群里有多个推理服务,可以考虑部署GPU共享组件,把节点上被单个Pod占用的整卡资源精细化切分给多个服务;如果推理日志和指标需要接入内部的可观测平台,可以在vLLM服务上挂载Prometheus指标暴露端口,再通过Grafana把GPU利用率、QPS、队列长度做成实时监控大屏。
我在实际使用中的体会是:K8s上的推理加速不是某一个操作带来的单点提升,而是一个从模型准备、资源调度、框架参数到弹性策略的系统工程。每一步看起来只提升了10%到20%,串起来之后才会出现量级的跃升。上面这些配置和参数都是我在自己环境里验证过的,不同的GPU型号、模型大小和业务负载会有差异,但你照着这套思路去做,至少不会走弯路。