最近好几个朋友在群里问同一个问题:我明明用Kubernetes部署了Ray集群,又用Ray启动了vLLM服务,结果任务排队、请求超时,到底是谁在调度谁?这三个东西名字里都带“调度”,但它们管的事情完全不一样。Kubernetes调度的是Pod放到哪台机器,Ray调度的是分布式任务交给哪个进程执行,vLLM调度的是GPU显存里那个请求该进该出。如果不把这三层分开看,出了问题只会对着kubectl、ray status、vLLM日志来回纠结,根本无从下手。这篇文章把我自己读源码、跑集群、调参踩坑的体会梳理清楚,尤其适合正在用“K8s+Ray+vLLM”这套组合做LLM推理服务的人。
1. 三个“调度”到底在管什么
1.1 Kubernetes调度:决定Pod“坐在哪台机器上”
Kubernetes的调度器是集群层面的资源放置器。它的核心对象是Pod,决策结果是“未绑定节点(unscheduled)的Pod应该放到哪个Node上”。整个调度过程可以简化为三步:先从API Server监听那些没有nodeName的Pod,然后对集群中每个Node做过滤(Filtering),看资源是否够、端口是否冲突、是否满足亲和性和污点容忍,最后对满足条件的节点打分(Scoring),选出一个最合适的节点完成绑定(Binding)。你给Pod声明了nvidia.com/gpu: 1,调度器就会去找哪台节点当前有空闲的整张GPU卡,找到了才绑定,找不到Pod就一直Pending。
这一层调度不关心Pod里面跑了什么程序。它不知道某个Pod是Ray的head节点还是worker节点,也不关心进程内部有多少线程、显存被谁占用。它只知道资源总量和已分配量,按声明值去做加法。也就是说,即使每个vLLM容器实际只用了30%的显存,K8s也不会因为“空闲显存很多”就塞两个Pod进来——除非GPU资源声明支持可切片,比如MIG或显存虚拟化,否则默认还是一整卡地调度。这正是很多人在大模型场景里感觉“K8s调度太粗”的根本原因。
1.2 Ray调度:决定分布式任务“怎么拆、交给谁跑”
Ray是一个分布式执行引擎,它的调度对象是Task和Actor。你用@ray.remote定义一个函数,调用时返回一个future对象,Ray的调度器会把这次函数调用分配到集群中某个节点的某个worker进程上去执行。它需要考虑的不只是每个worker还有多少CPU、GPU余量,还会看数据局部性:如果某个Task依赖一个已经存在于某节点内存中的Object,Ray会优先把Task调度到那个节点,避免跨节点传输数据。如果是Actor这种常驻服务,调度器还要保证同一个Actor的后续方法调用都发送到固定节点。
Ray的调度和K8s调度之间存在明显的层次关系。Ray集群本身要跑起来,得先有若干Pod被K8s调度到节点上,然后Ray的调度器才能在“这些Pod提供出来的资源池”里分配任务。你可以把K8s看成“盖楼分配房间”,Ray看成“在房间里安排工位和业务流程”。K8s知道一个Pod占了多少内存,但不知道Pod内部的Ray如何调度;Ray知道自己的Task需要哪些资源,但如果资源不够,它根本接触不到节点信息,只能干等或者触发autoscaler申请新节点。
1.3 vLLM调度:决定GPU显存上的“请求何时进、何时走”
vLLM是整个链条最内层的调度器,它调度的是“正在被处理的推理请求”。给定一张GPU卡和有限的显存,vLLM的Scheduler要决定:当前这100个并发请求里,哪些进入这一轮的批处理(batch),哪些继续排队,哪些已经被赶出显存到CPU交换区,哪些提前终止。它使用PagedAttention把每个请求的KV Cache分成固定大小的块,物理上可以不连续,这样显存里的小碎片也能被利用。
传统深度学习推理服务里,一个batch只有等里面所有序列全部生成完,才会让位给新batch。vLLM的做法是Continuous Batching(连续批处理):每个step结束后,已经完成的Sequence退出,新进来的Sequence可以立刻加入运行集合,还在排队prefill的请求也能和正在decode的请求混合在一起。调度器每个step都会做一次“谁能最快出结果、谁该优先占显存”的决策。这一层调度的时间粒度是毫秒级,直接影响TTFT(首个Token延迟)和整体吞吐。它完全不关心Pod在哪个节点,只关心一个东西:当前显存里能放下多少页KV Cache。
2. 为什么会有三层调度:从一次大模型部署的复盘说起
2.1 从裸机到K8s:解决了“资源放哪”的问题
最早我部署vLLM模型时就是一台GPU服务器直接docker run,模型起来后,去web UI问一句答一句。那时候根本没有“调度”这个问题。跑了不到两个星期,问题来了:一封请求多的时候,单卡连续批处理再强也扛不住峰值,想扩建第二台机器,只能手动切DNS、手动同步模型权重、手动分流量。这时候K8s的价值才真正显现出来——它能把多台机器上的GPU变成一个大资源池,用声明式YAML描述“我要两个vLLM副本、每个要一张A100”,调度器自动把Pod放到合适的节点上,再通过Service做负载均衡。这个阶段我迫切需要的调度是K8s调度,它解决的是资源放置问题。
2.2 引入Ray:解决了“分布式执行”的问题
后来单纯部署推理服务已经满足不了需求。我在做一个RAG项目,既要跑文档切分、embedding生成,又要调vLLM服务做对话,还有一批异步任务在做模型评估。如果把所有模块都是一个Pod跑一个常驻服务,互相之间通信、重试、状态同步都要自己手写。Ray介入后,我把数据预处理、批量推理、评估逻辑全部写成Ray Task,由Ray调度器决定这些任务在哪些worker上并发执行。特别是当我有多个GPU worker时,Ray可以把“每个worker负责跑哪几个vLLM实例”这件事编排得很好。
但是这里出现了第一次混淆。我以为有了Ray就不需要K8s了,其实完全不是。Ray的Task调度基于一个资源表,这张表来自Ray集群中的每个节点。而这些节点是谁创建的?还是K8s。我在本机起Ray没有任何问题,一旦想把Ray集群跑在K8s里,就必须先让K8s把Ray head和worker的Pod调度起来。两个调度器是上下级关系,但并非一条完整链路。Ray调度器做决策很快,但如果底层没有Pod可加,它的决策永远无法执行。
2.3 vLLM内部:解决了“GPU利用率”的问题
即使Pod跑起来了,Ray任务也分好了,可我还是觉得GPU利用率不对劲。打开nvidia-smi,显存用了不少,可GPU利用率经常掉到40%以下。原因在于vLLM如果采用最朴素的批处理策略——比如一批最多只能塞8个请求,那么当8个请求里大部分已经结束时,新的请求还在前端缓冲,GPU只能空转。vLLM内部的调度器正是为了压出这部分的性能压榨空间而存在的。它管理的是请求在显存中的生命周期,而不是容器资源。
三层调度各管一摊,才构成完整的LLM推理服务。单机直跑vLLM只需要最内层调度,撑死再加一个进程守护;要做多机推理集群,才需要考虑K8s资源调度和Ray任务调度。理解这个层次,再看到“任务卡住”就不会慌:先判断是Pod没起来、Ray任务没分下去,还是vLLM内部的请求在排队。
3. 核心细节解析:Kubernetes调度器的工作方式
3.1 默认调度器的完整决策流程
Kubernetes默认调度器是kube-scheduler,它的大脑是“调度队列”。集群中所有未绑定的Pod都会进入这个队列,调度器循环执行下面四条流程:
- Filtering(过滤):遍历所有节点,把不满足硬约束的节点剔除。常见硬约束包括资源不足、端口冲突、不满足nodeSelector、不满足污点容忍。比如你声明了
nvidia.com/gpu: 1,那么没有GPU的节点直接被踢掉。 - Scoring(打分):对过滤后的节点打分,默认策略偏向“资源用量较小的节点”,也就是把新Pod尽量放到空闲机器上,达到负载均衡。也可以配置成Binpack策略,把Pod装到同一个节点,腾出更多整体空闲节点。
- Binding(绑定):将选定的节点写到Pod的
spec.nodeName字段。这一步完成后,kubelet才会真正拉镜像、启动容器。 - 随后调度器进入下一轮循环。
如果你用kubectl describe pod看到0/8 nodes are available,那就是过滤阶段没有节点通过。常见原因要么是GPU资源不够,要么是节点有污点你没容忍。此时应该看事件中的详细提示,比如Insufficient nvidia.com/gpu,意思是某个节点没有满足请求的GPU数量。
3.2 GPU资源调度与常见配置
K8s调度GPU依赖设备插件。NVIDIA官方驱动通过nvidia-device-plugin将每张卡上报为nvidia.com/gpu: 1,调度器只能看到整卡数量。因此一旦Pod指定limits.nvidia.com/gpu: 1,它就会独占一张卡,其他Pod默认不能再用它。如果你希望多个Pod共享一张物理卡,要么调整设备插件配置开启MIG模式,要么用显存虚拟化方案,但普通情况下不建议把同一张卡“超卖”。
实操中最容易犯的错是只写GPU数量不写CPU和内存。很多LLM Pod会用到大量CPU做tokenizer和数据处理,Ray的Object Store还要占用共享内存,如果容器资源限制太紧,OOM后会被反复重启,但看K8s调度事件又完全正常。我后来的习惯是GPU请求和CPU/内存请求成对声明。比如:
resources: requests: cpu: 8 memory: 32Gi nvidia.com/gpu: 1 limits: cpu: 10 memory: 40Gi nvidia.com/gpu: 13.3 自定义调度策略与扩展点
很多人以为K8s调度器只能按默认策略用,其实它提供大量扩展机制。你可以通过nodeAffinity指定“这个vLLM Pod一定要到大显存节点”,或者用podAntiAffinity让两副本不要落在同一个节点,避免单点故障。也可以写一个自定义调度器,在过滤阶段加入“节点当前GPU显存利用率”这种实时指标,K8s默认调度器是不知道显存实际使用率的,它只知道卡被分配给了谁。不过绝大多数场景下,默认调度器加合适的节点标签就够用了,万不得已不要自己造调度器。
4. Ray调度层的核心逻辑:任务、Actor与集群伸缩
4.1 Ray Task与Actor是如何被调度的
Ray的调度器不是传统意义上的中央调度器,而是由GCS(Global Control Service)协调、各节点Raylet执行的一套分布式调度体系。当你在driver端发起一个Task调用,GCS会包含这个Task的资源需求,比如num_cpus=2, num_gpus=0.5,然后各Raylet汇报自己的可用资源,调度器选择一个节点放置Task。Ray本身也支持把多个Task打包成一个Acton调度,因为Actor常驻内存,资源占用更长时间,调度器会采用更保守的策略,尽量放置在已有相同Actor的节点?
Ray默认的调度策略主要是“按资源计数”进行best-fit,另外有两种放置策略:Placement Group中的PACK和SPREAD。PACK会把关联的Task尽量放在同一节点,共享内存和本地通道,适合需要频繁数据交换的任务组;SPREAD则相反,会把组分散到不同节点容错。在大模型推理场景,如果你想在Ray上启动多个vLLM实例作为一组Worker,用PACK可以避免跨节点通信延迟,但如果是高可用部署,就需要SPREAD。
4.2 Ray与K8s的真实协作细节
Ray集群跑在K8s上通常有两种姿势。第一种是手动定义head和worker的Deployment/StatefulSet,head通过Service暴露GCS地址,worker加入集群。此时K8s只管“Pod可以被放到哪”,Ray的Task调度完全在Pod容器内部进行。第二种是使用KubeRay Operator,把RayCluster定义成CRD,由 Operator管理生命周期和弹性伸缩。无论哪种姿势,Ray的autoscaler在判断“当前资源不够”后,都会调用K8s API创建新的worker Pod,然后这些Pod还要再经过K8s调度器分配到节点。
这条链路里最容易踩的坑是“两个调度器互相等待”。比如Ray autoscaler觉得缺一个GPU worker,于是向K8s申请新Pod,但K8s当前所有节点都没有空闲GPU,新Pod一直Pending。Ray等Pod出现,Pod等节点释放,节点释放又等vLLM实例结束,整个过程完全停滞。这时候盲目调大Ray的autoscaler间隔没有用,应该先看底层节点资源水位。
4.3 Ray Serve的调度与vLLM的关系
Ray Serve可以负责任务路由和请求负载均衡,很多人也把它叫“调度”。它其实是在vLLM之上加了一层服务网关,根据路由规则把HTTP请求转发到不同后端worker。你可以用Ray Serve做比例灰度、多模型路由,但它本身不参与GPU显存内的连续批处理调度。也就是说,如果请求到达vLLM端口后还在等调度,那是vLLM的Scheduler在排队;如果请求在Ray Serve层就等不到可用后端worker,那是Ray的资源调度出了问题。
5. vLLM的调度器究竟在如何工作
5.1 从静态批处理到连续批处理
传统推理框架会把多个请求组成一个固定batch,同一batch的请求必须依次走完prefill和decode阶段,等最后一个请求结束后整个batch才退出。这样做有两个明显浪费:一是早完成的请求占着batch位置空转,二是新请求只能等下一轮batch,吞吐上限被锁死。vLLM把调度粒度从batch降到了单个Sequence。每过一个decoding step,调度器都会重新计算当前等待队列和运行队列,将已完成请求移出,把新请求的prefill插进空余位置。这样GPU的每个时钟周期都在处理有效token,而不是陪着已经结束的请求发呆。
5.2 PagedAttention与显存逻辑页
vLLM频繁调度的一个底气来自PagedAttention。模型推理时的KV Cache是中间产物,如果为每个请求预留连续显存,碎片化严重,而且很大一部分空间在下轮生成前都不会被用满。vLLM借鉴操作系统的内存分页思想,把KV Cache切成固定大小的块,物理页可以散落在显存的不同位置,逻辑上按顺序串起来。调度器判断“能不能让一个新请求进入运行集”,核心条件是剩余空闲物理页是否足够覆盖该请求的首个prefill页数。这也是vLLM为什么能在同一个GPU上同时运行几十个请求而不会爆显存的核心原因。
5.3 抢占、交换与重新计算
GPU显存总有限度,当所有物理页都被占满时,调度器必须赶走一些请求腾地方。vLLM提供两种抢占方式:一是Swap Out,把请求的KV Cache从GPU拷贝到CPU内存,等空间释放后再拷贝回来;二是Recompute,直接丢弃掉已生成的KV Cache,等下一次轮到该请求时重新prefill。前者保留前文状态但多了一次PCIe传输,后者省显存但浪费计算时间。实际使用中如果发现吞吐波动很厉害,可以看vLLM日志里preemption相关计数,如果频繁抢占,说明并发设置超过了显存物理上限,应该调小max_num_seqs或增大gpu_memory_utilization。
5.4 影响vLLM调度行为的核心参数
真正常见的问题是“参数那么多,我不知道调哪个”。这里列几个关键参数:
| 参数 | 作用 | 配置经验 |
|---|---|---|
--gpu-memory-utilization | 控制vLLM最多使用多少比例的GPU显存 | 一般0.85~0.92,太高容易OOM,太低浪费KV Cache空间 |
--max-num-batched-tokens | 每个step允许参与计算的最大token数 | 影响批大小,太小吞吐低,太大延迟不稳 |
--max-num-seqs | 最大并发序列数 | 超过这个值新的请求会排队,设太大会导致频繁抢占 |
--enable-chunked-prefill | 把长prompt的prefill切块,与decode交错 | 能显著降低TTFT波动,但对调度器实现有额外开销 |
--swap-space | 允许KV Cache交换到CPU的显存大小 | 适合短期高峰,过大会拖慢生成速度 |
5.5 调度优先级与长尾请求处理
vLLM默认按请求到达时间先来后到(FCFS),但长Context的请求prefill阶段占用的计算时间远超短请求。如果不加控制,一个超长文档对话可能让孩子短请求等待几十秒。有人会用--priority自定义调度策略,比如给交互式请求更高优先级;也有人在业务层把长对话和短对话分到不同vLLM实例,用K8s调度隔离。从实践来看,后者更稳定,因为长请求会持续占用显存和计算资源,拼在一起容易互相拖垮。
6. 三层调度如何协同:一张分工表与冲突排查
6.1 调度分工对照表
我把三层调度整理成一张表,排障时可以对照着看:
| 层次 | 调度器 | 决策对象 | 时间尺度 | 典型问题 |
|---|---|---|---|---|
| 资源层 | Kubernetes Scheduler | Pod到哪个Node | 秒级 | Pod Pending,节点资源不足 |
| 任务层 | Ray Scheduler | Task/Actor到哪个Worker | 毫秒到秒级 | 任务排队,等待资源或依赖数据 |
| 请求层 | vLLM Scheduler | 请求与KV Cache页的分配 | 毫秒级 | TTFT高,preemption频繁,OOM |
三层调度是串行依存关系:K8s把Ray Pod放好,Ray才有资源池供Task调度;Ray把vLLM Worker启动好,vLLM的Scheduler才能接收用户请求。反过来,vLLM的调度压力会转化为Ray任务的资源利用率,最终通过Ray的autoscaler影响K8s的节点数量。
6.2 常见的冲突场景
我实际踩过几个冲突,都在群里看到别人踩过一样的:
K8s HPA和Ray autoscaler抢控制权:如果部署vLLM用K8s HPA,同时又让Ray autoscaler根据GPU资源伸缩Ray worker,两个伸缩器会互相拉锯。更糟的是HPA按CPU指标伸缩,vLLM的GPU利用率很高但CPU很低,导致错误缩容。这类场景通常要关掉HPA,只让Ray管理worker副本,K8s只负责提供节点资源。
节点亲和性与Ray的数据局部性冲突:你给Ray worker Pod配置了K8s nodeAffinity必须到指定GPU型号节点,但Ray调度Task时发现最优数据节点和Pod的物理位置不一致,任务只能跨节点拷贝数据。解决方法是把“模型权重放在共享存储”或者用Ray的
@ray.remote(num_gpus=1)显式约束资源,而不是过度依赖数据局部性。vLLM的显存超卖导致同节点互相干扰:如果K8s节点上同时跑了两个vLLM Pod,每张卡各占一半显存,但vLLM的
gpu_memory_utilization都设成0.9,那么实际每个进程都会尝试占用90%的整卡显存,结果只有一个Pod能启动,另一个一直OOM。这就是K8s调度按整卡分配,而vLLM调度按百分比分配的误解。Ray Object Store占用的共享内存导致vLLM崩溃:vLLM容器在Ray中通常要使用
--ipc=host或者足够大的/dev/shm,否则Ray的Object Store和vLLM的PagedAttention都会共享内存竞争,日志里表现为No space left on device或shared memory full。
6.3 一个完整的排障案例复盘
一次线上事故:用户反馈问答服务卡住30秒,偶发超时。我按三层顺序排查:先kubectl get pods,发现vLLM Pod都是Running且新Pod也正常,排除K8s调度问题;再ray status看资源表,发现一个GPU worker的Tasks异常增多,但CPU没有满,也不像任务调度卡住;最后看vLLM日志,发现# Preemption: 12之类的计数暴涨,结合nvidia-smi看到显存已满,基本确定是并发请求太多触发了swap和recompute。做法是把max_num_seqs从128降到64,同时开了--enable-chunked-prefill,TTFT立刻稳定下来。整个过程没有动K8s和Ray任何配置,问题只出现在最内层调度参数的调整。
7. 实操心得与排查技巧
7.1 学会先判断“卡在哪一层”
我建议任何一个搭建过这套系统的人,都先给自己建立一个排查顺序:第一步看kubectl get pod,第二步看ray status,第三步看vLLM日志。Pod不是Running,那就是K8s调度或启动问题;Pod正常但任务没执行,就是Ray调度问题;任务在正常处理但请求响应差,就是vLLM调度问题。这个顺序我在团队内部讲过很多次,每次都能快速定位,不需要一上来就翻源码。
7.2 一个可直接套用的部署骨架
如果你要从零搭一个带vLLM的Ray集群,可以这样起步:K8s里装KubeRay Operator,然后定义一个RayCluster,其中head节点不带GPU,worker节点带GPU,worker启动命令使用vllm serve。这里的核心是让Ray负责worker的生命周期,让K8s负责节点放置。
apiVersion: ray.io/v1 kind: RayCluster metadata: name: vllm-cluster spec: headGroupSpec: rayStartParams: dashboard-host: 0.0.0.0 template: spec: containers: - name: ray-head image: rayproject/ray:2.30.0 resources: requests: cpu: 4 memory: 8Gi workerGroupSpecs: - groupName: vllm-gpu replicas: 2 rayStartParams: num-gpus: 1 template: spec: containers: - name: ray-worker image: ray-llm:latest resources: requests: cpu: 8 memory: 32Gi nvidia.com/gpu: 1 limits: cpu: 8 memory: 32Gi nvidia.com/gpu: 1 volumeMounts: - name: dshm mountPath: /dev/shm volumes: - name: dshm emptyDir: medium: Memory这里显式声明了nvidia.com/gpu: 1,并且给/dev/shm设置了内存卷,是避免Ray Object Store和vLLM抢共享内存的关键。
7.3 几条值得长期遵守的避坑原则
根据个人经验,有三条原则能让你少踩很多坑。第一,不要试图用一个调度器解决所有问题。K8s不该为vLLM的显存碎片负责,vLLM也不会帮你添加节点。第二,不要让两个自动伸缩器同时控制同一份资源。保留一个伸缩入口,其他层用固定数量或靠外层的流量调度来支撑。第三,调整vLLM参数后,不能只看吞吐,还要看TTFT的分布和preemption次数。如果preemption次数很高,单纯加大并发反而会让延迟恶化。
我个人在实际使用中最大的体会是:这三层调度像极了开一家餐厅。K8s决定客人坐在哪张桌子、用哪个包间,这是资源层调度;Ray决定后厨里配菜、炒菜、装盘这些工序由哪个灶眼来完成,这是任务层调度;vLLM则像前台领班,决定每一桌客人当下该上哪道菜、谁催得急就先给谁加菜,还得照顾后面排队的新客人。餐厅要高效运转,任何一层都不能掉链子。当你再遇到“调度问题”时,先分清是桌子、灶台还是上菜逻辑,然后再动手,方向就对了。