最近一个做推理平台的朋友问我Kubernetes、Ray、vLLM 都在调度为什么有时候集群明明有 GPU 却还是排队他这个问题背后其实藏着一个更基础的问题这三个系统口中的“scheduler”各自到底在调度什么、决定了什么。我把这三个系统放在一起拆了一遍之后发现大多数人对“调度”的理解都停留在“把任务分给机器”这一个模糊概念上。但严格说起来Kubernetes 的调度、Ray 的调度、vLLM 的调度决策的东西完全不一样决策的频率也差了不止一个数量级。搞混这三层排查问题的时候就会非常难受——你以为是集群没资源实际上是 vLLM 内部的队列策略没调好你以为是任务调度卡住了实际上是 K8s 的 GPU 分配方式有问题。这篇文章我尽量把三者的边界讲清楚顺便把我在生产环境里实际遇到过、踩过的坑也放进来。无论你是做大模型推理平台、训练平台还是单纯在 Kubernetes 上部署过 vLLM 服务都应该能从中找到自己需要的那一部分答案。1. 先搞清楚三个“调度器”调度的根本不是同一个东西为什么这个词会被混在一起用因为从用户视角看三个系统都在做同一件事——把某种工作负载“安排”到合适的计算资源上去执行。但如果你站到系统内部去看它们各自的处理对象、决策粒度和决策频率是截然不同的。我用一个稍微生活化的类比来拆解。你把一次大模型推理服务想成一家餐厅Kubernetes 是物业管理员。它管的是“哪张桌子放在哪个房间”也就是决定一个服务实例Pod/容器跑在哪台物理机器上。桌子一旦摆好物业一般不会再搬来搬去。Ray 是餐厅的现场领班。它负责决定“哪位厨师去炒哪道菜”也就是把一个个任务分给具体的 worker 进程。如果这道菜需要的食材恰好在这位厨师手边领班会优先把任务派给他省得来回传盘子。vLLM 是炉灶旁的炒菜师傅自己。他面对的不是一台台机器而是眼前这一锅菜GPU 显存里的 KV cache——下一轮翻炒下一次前向迭代到底放哪些菜入锅哪些菜先在外头等着哪些菜做到一半被端下去。这三个角色的决策范围完全不同。为了更直观我把它们的核心差别整理成一张表对比维度Kubernetes 调度器Ray 调度器vLLM 调度器调度对象Pod / 容器Task / Actor请求序列与 KV cache 块核心决策决定 Pod 绑定到哪台节点决定任务交给哪个 worker 执行决定下一迭代哪些序列进入前向计算决策频率提交时一次之后基本不再变更任务到达时实时决策可能伴随迁移每个 iteration 都重新决策毫秒级资源维度CPU、内存、整卡 GPU、拓扑约束CPU、内存、GPU、对象存储局部性显存中 KV cache 的预算与分配失败接管Deployment Controller 重建 PodRaylet 重启 Actor / 重试 Task请求失败由上层客户端重试所以当你问“它们到底各自决定了什么”最简短的答案是Kubernetes 决定 Pod 在哪台机器上Ray 决定任务在哪个进程里跑vLLM 决定下一次前向迭代把显存分给哪些请求。这三层不是替代关系而是上下层叠的关系。理解了这个前提后面所有细节才有意义。2. Kubernetes 的调度一次绑定定终身管的是工作负载的落脚点Kubernetes 的调度器kube-scheduler做的其实是一件非常简单、也非常“绝情”的事情为每一个待调度的 Pod 选择一台最合适的节点然后写入绑定关系。这个绑定是一次性的除非 Pod 被删掉重建否则调度器不会再对这个 Pod 做第二次决策。2.1 过滤阶段先剔除不合格节点打分阶段再选最优Kubernetes 默认调度器的决策分两个阶段过滤Filtering和打分Scoring。过滤阶段做的事情是把明显不符合条件的节点直接干掉。比如 Pod 声明了需要 16 核 CPU、64GB 内存那么资源不够的节点全部出局比如节点上有污点Taint而 Pod 没有对应的容忍Toleration那这个节点也被淘汰再比如 Pod 配置了 nodeAffinity 或者 nodeSelector那么不符合标签要求的节点也进不了下一轮。等过滤完剩下的都是“可用节点”打分阶段才开始工作。默认打分策略通常偏向资源分配率更低的节点也就是把新 Pod 尽量放到负载相对轻的机器上这种做法叫 Least Requested。如果你希望服务尽量集中在一起则可以换成 Bin Packing 策略把 Pod 塞进更少的节点省下整机资源给其他任务。这里有个很多新手会误解的地方Kubernetes 调度器只看 requests 和 limits它并不知道你的进程真实占用了多少 CPU 和内存。你的 vLLM 容器如果写了 requests: 8Gi 内存但实际吃掉了 30Gi调度器是管不着的它只会根据声明值做决策。这也是为什么很多 AI 负载明明把节点内存打爆了Kubernetes 却毫无反应——它管的是“约定”不是“事实”。2.2 “调度一次终身不改”是 Kubernetes 最大的特点我特意强调“一次性”是因为这一点对 AI 工作负载影响非常大。举个例子你有一个推理服务一开始被调度到了节点 A。跑了一段时间之后节点 A 的 GPU 因为其他任务的干扰变慢了你希望这个服务移到节点 B 上去。但 Kubernetes 默认不会做这件事调度器不会主动迁移已经在运行的 Pod。你要么手动删除 Pod 让它重新调度要么引入 descheduler 这类组件做重平衡。所以在训练场景里如果某个节点上的任务把 GPU 显存占满了而旁边还有一个新任务在等资源Kubernetes 只会把它放到其他有空闲 GPU 的节点上绝不会考虑“把正在运行的任务挪一挪腾出空间”。这种不干预运行中负载的设计保证了系统的稳定性和可预期性但代价就是资源碎片化。典型的表现就是集群总显存足够但每个节点都只剩 1 张卡而你刚好需要 2 张卡跑一个分布式任务于是任务永远处于 Pending 状态。2.3 GPU 场景下 Kubernetes 调度的“整卡思维”说到 GPU这里才是大模型负载和普通 Web 服务调度差异最大的地方。Kubernetes 默认把 GPU 当成一个“整卡资源”来调度。你在 Pod 里写nvidia.com/gpu: 1调度器就会去找一张完整的 GPU 卡绑给你。它不会考虑你这张卡实际用了一半还是只用了一点也不允许你申请 0.5 张卡。这个设计对训练任务其实是合理的因为训练进程通常就是整卡占用。但对推理任务来说就有点浪费了一个小模型请求量不高时一张 80GB 的卡可能只用了 10GB但 Kubernetes 依然认为这张卡已经被 Pod 独占。如果没有借助额外的 GPU 切分方案比如 MIG、时间切片或者显存虚拟化设备插件这张卡上的资源就浪费掉了。我在生产环境里见过太多这样的案例节点上 8 张卡8 个推理 Pod每个 Pod 内模型显存占用不到 30%但新请求就是进不来因为底层看 GPU 已经“满”了。要解决这个问题不能只靠 Kubernetes 调度器本身你得在设备插件层做文章。3. Ray 的调度机器确定之后任务与依赖的“现场指挥”如果把 Kubernetes 比作物业那 Ray 更像是真正在指挥干活的人。Ray 的调度发生在 Kubernetes 已经分配好机器之后它面对的问题是集群里有很多 worker 进程我手里收到一大堆任务每个任务依赖不同的数据、需要不同的资源我应该把它们派给谁3.1 Ray 的调度架构GCS 做总览Raylet 管执行Ray 集群里有一个比较核心的组件叫 GCSGlobal Control Service全局控制服务它维护整个集群的元数据与状态信息。每个节点上运行着一个 Raylet 进程Raylet 向 GCS 汇报本节点的资源情况同时负责管理本节点上的 worker 进程。当你用ray.submit或者ray.remote装饰一个函数并调用它时调度请求经由 GCS 统一协调后落到某个 Raylet 上执行。Raylet 检查本节点的可用资源如果足够就启动或复用对应的 worker如果不够任务会在该节点的队列里等待或者被协调到其他节点。这里有一个关键点Ray 的资源是和 Kubernetes 无关的。Ray 不知道也不关心你底下跑的是 Docker 还是 K8s它只知道自己从 YAML 配置或启动参数里获得了多少 CPU、多少内存、多少 GPU。如果你在 Kubernetes 上部署 Ray 集群你需要负责把节点的真实资源正确地上报给 Ray。3.2 依赖任务的调度策略数据局部性就是王Ray 调度单个无依赖任务时核心策略是数据局部性。什么意思呢假设你有一个任务需要读取存在于分布式对象存储中的一个大数组而这个数组恰好放在节点 A 的共享内存里那么 Ray 会优先把这个任务派发到节点 A 上执行。这样任务进程可以直接在本地读取对象省掉了跨节点的网络传输。这个策略对机器学习任务极其重要。尤其在做数据并行训练时每轮迭代都需要从对象存储里取数据或中间结果。如果任务被派发到数据不在的节点每次都要走一遍网络训练速度会呈指数级下降。Ray 在调度时通过一个权重算法对“数据所在节点”给予更高的调度优先度这个细节经常被很多人忽略但在实际调优中往往比单纯的资源调度更关键。有依赖的任务调度则是另一套逻辑。Ray 会先看任务之间的依赖关系把没有依赖的任务先派发下去等上游任务完成其输出写入对象存储之后再根据产出的位置去调度下游任务。这个过程类似于执行一个有向无环图DAG只是调度粒度更细、容错处理也更多。3.3 Actor 与 Placement Group另一种“调度思维”除了无状态的 TaskRay 还有有状态的 Actor。一个 Actor 本质上是一个常驻进程拥有自己的内部状态。调度 Actor 时Ray 不仅要考虑资源是否足够还要考虑这个 Actor 一旦启动就在某个节点上长期存活。任务可以被反复重试并换节点执行Actor 不行——它的生命周期更长迁移成本也更高。如果某个 AIGC 服务里用了 Ray Serve 来部署多个模型你可能会同时启动好几个 Actor。这些 Actor 之间需要通过网络互相调用调度时最好让它们分布在同一个节点或相邻节点上以减少通信延迟。Ray 为此提供了 Placement Group 机制允许你指定一批 Actor 是集中在同一台机器PACK还是分散到不同机器SPREAD。我举个例子。你在 4 个节点的 Kubernetes 集群上部署 Ray每个节点 8 卡 GPU。你想启动一个 8 进程的分布式推理服务每个进程用 1 张卡。如果 PACK 策略那 8 个 Actor 会被尽量塞进同一台机器如果是 SPREAD则会被尽量分散到不同节点。这两种策略各有适用场景PACK 适合对通信延迟敏感的场景SPREAD 适合高可用容灾。这个决策是“布置在哪里”的决策和 Kubernetes 每个 Pod 放哪台机器有本质上的层次差异。3.4 Kubernetes 和 Ray 的交接KubeRay 的角色在实际部署中Ray 和 Kubernetes 并不是相互独立的。最常见的做法是用 KubeRay Operator 在 Kubernetes 上管理 Ray 集群——Kubernetes 负责为 Ray 集群创建和调度 Pod也就是给 Ray 提供“物理机”Ray 负责在这些 Pod 里调度真正的任务。这一层接得顺不顺直接影响你对整条链路的理解。如果 Kubernetes 调度阶段就把 Ray 的 Worker Pod 都放到了不合理的节点上那么 Ray 就算内部调度算法再聪明也无济于事因为候选节点已经被 K8s 过滤过一轮了。所以排查问题时我总是习惯先从最底层看起Kubernetes 的 Pod 的节点分布是否合理再有针对性地看 Ray 内部的任务分布。4. vLLM 的调度每一轮迭代都在重新决策管的是显存与响应如果说 Kubernetes 看的是机器Ray 看的是 worker那 vLLM 内部 Scheduler 管的就是 GPU 显存这块“小地盘”。这是三个调度器里决策最频繁、最微妙的一个。4.1 continuous batching不再等请求收齐而是边来边做传统的 batch 推理方式是先收集一批请求组成一个 batch一起前向推理等整批跑完再接收下一批。这个模式的缺点是慢请求会拖住整个 batch 后面的请求吞吐和延迟都没法兼顾。vLLM 采用 continuous batching也叫迭代级调度思路非常“极端”把“排队”的单位从一批请求缩小到一次前向迭代。前向传播的每一轮Scheduler 都会重新审视当前 GPU 显存里 KV cache 的余量、处于等待状态的请求、正在运行的请求然后决定这一轮到底跑哪些序列哪些序列继续跑哪些先被换出去。这也是为什么 vLLM 在官方架构里叫做 LLMParser / EngineCoreScheduler 和 Executor 的交互是整个引擎的“心脏”Scheduler 规划好本轮要执行的序列组合Executor 按照这个计划真正启动 GPU 计算。热词里提到的“vLLM enginecore与scheduler、executor交互流程”本质上就是这一条决策—执行链路。4.2 调度器每一次决策加入、等待、抢占、换出在 vLLM 的调度器里每个请求会被拆成一个或多个 Sequence序列每个 Sequence 有一个状态机WAITING、RUNNING、FINISHED。调度器要做的决策可以总结为四个动作把等待中的新请求加进 running batch前提是 KV cache 还有余量如果显存不够继续让部分请求在等待队列里待着如果 running 队列中的请求太多需要抢占preempt一部分被抢占的请求可以选择换出到 CPU 内存swap或者以后重算recompute。这里面最值得展开的是抢占策略。假设你的推理服务同时来了 10 个请求GPU 显存只能支撑 6 个同时跑就必然有 4 个被挂起。vLLM 会优先让新请求等待保证已经在跑的不中断但如果等待时间过长新的长请求可能一直挤不进来形成“饥饿”状态。真实的调度器实现里会有一套基于 FCFS先来先服务和优先级的选择策略来选择下一轮接入哪些请求。4.3 显存碎片与 KV cache 块调度器真正管理的东西vLLM 和其他推理框架最本质的区别在于它不按“请求数量”来管理显存而是把显存划分成固定大小的物理块block。每个请求的 KV cache 按需申请逻辑块逻辑块再映射到物理块。调度器每次决策时都要动态地建立和解除这种映射这个机制就是 PagedAttention分页注意力。细看你会发现这其实跟操作系统里的虚拟内存分页是一个思路。对于一个长时间运行的模型服务不同请求的生命周期交错出现一个请求生成完了它占用的 KV cache 块就可以被回收重新分配给新请求。因为有了这种块级管理vLLM 的调度器才能在每轮迭代做出非常细粒度的显存分配决策避免显存碎片化导致大量空闲但不可用。理解这一点之后再看 vLLM 的关键参数就顺理成章了gpu_memory_utilization决定调度器最多能用多少比例的显存来做 KV cache剩下的留给模型权重和激活max_num_seqs限制一个迭代里最多同时处理多少请求避免调度器把所有 KV cache 都分给少量大请求max_num_batched_tokens限制每个迭代 batch 的最大 token 总数防止单次前向的显存开销超限。这些参数本质上是给调度器划定“决策边界”。你不想让调度器做出超出显存预算的分配决策就得把它关进这几个参数的笼子里。4.4 Prefix Caching 如何影响调度顺序vLLM 还支持 prefix caching前缀缓存。当多个请求共享同一段前缀上下文时比如系统提示词完全相同它们的 KV cache 前缀可以被复用。调度器会优先把能命中缓存的请求调进来提高缓存利用率减少重复计算。这会导致一个很有趣的现象某些请求明明晚到却可能先被调度执行因为它的前缀正好在缓存里。调度顺序从“纯先来先服务”变成了“先来先服务 缓存命中优先”。如果你不熟悉这套逻辑看到监控里请求执行顺序乱掉会很困惑其实这是调度器在做全局收益权衡。5. 三层调度如何接力Deployment、Ray、Engine 的完整链路第三章节讲了 Ray第四章节讲了 vLLM但真实的生产环境里这三者往往同时存在。把它们拼一条完整链路出来你会更清楚地看到每一层“决定”了什么。5.1 从 KubeRay 提交到 GPU 输出一次完整调度旅程假设集群底层是 Kubernetes上面跑着 KubeRay Operator 管理的 Ray 集群Ray 集群里挂着 vLLM 推理服务。整个调度链条大致是这样Kubernetes 调度器把 Ray 的 Head Pod 和 Worker Pod 分配到具体节点。这一步决定了 Ray 集群拥有哪些“机器资源”。用户向 Ray 提交一个推理任务Ray 的调度器决定这个任务交给哪个已存在的 vLLM Actor 去执行。如果没有现成的 ActorRay 会先创建 Actor并把 Actor 调度到有空闲 GPU 的节点。vLLM Engine 收到请求后内部的 Scheduler 对这个请求做 token 级调度计算它需要多少 KV cache 块、是否命中前缀缓存、当前 running batch 有没有空位然后决定立刻开始推理还是排队等待。这三层决策发生在三个完全不同的时间尺度上Kubernetes 可能几十秒才调度一个 PodRay 的任务调度是毫秒到秒级vLLM 的迭代调度则是几十毫秒一次。5.2 “谁负责管理机器谁负责管理任务谁负责管理显存”在实际架构设计里我最常给出的原则就是这三句话。Kubernetes 管机器Ray 管任务vLLM 管显存。不要跨层越权。举个例子你想通过给 Ray 的 Actor 设定更大的 worker 数量来提升吞吐却发现 K8s 节点上的 GPU 已经被分配出去。这时候你应该去 K8s 层增加节点或者让 K8s 调度器重新分布 Pod而不是在 Ray 配置里死磕。反过来如果节点资源充足但你发现请求响应时间很长那大概率问题出在 vLLM 调度器的等待队列上而不是底层资源不足。5.3 失败接管语义谁该负责重试三层调度的另一个重要区别是它们对失败处理的态度。Kubernetes 对 Pod 的失败是比较“冷漠”的Pod 挂了负责重建它的往往是 Deployment 或 StatefulSet 的 Controller而不是调度器本身。调度器只在重建出来的新 Pod 进入待调度状态时介入一次。Ray 更积极一些。对于无状态 taskRay 会自动重试对于有状态的 actorRay 会尝试重新创建 actor 进程并尽最大努力保留其状态。但这里的“重试”不是无限制的它受重启策略和故障域限制。vLLM 内部基本不做请求级别的重试。如果生成过程中某个序列因为显存不足被换出它会以 recompute 的方式重新开始但不是应用层的重试。更上层的重试逻辑比如请求失败后换个端点再发一次通常由网关实现。这三层失败语义很容易被搞混。我见过一个团队排查故障时误以为“某个推理请求被 vLLM 丢弃了为什么没有自动重试”查了半天才发现底层 K8s Pod 已经被 OOMKilled 又重建了请求自然丢了。分清每一层的职责才能快速定位问题在哪一层。6. 生产环境中最容易踩的调度坑最后分享几个我在实际运维中踩过、也帮别人排查过的坑。这些坑都属于“不看源码根本意识不到”的类型但它们的影响都非常直接。6.1 坑一在 K8s 上掐 requests 就想限制 vLLM 的显存占用有人为了防止模型服务吃光整张显卡在 Kubernetes 的 Pod 上把nvidia.com/gpu: 1和内存 requests 设得很低。结果发现显存还是被打满了服务直接 OOM。原因很简单Kubernetes 的 GPU 资源申请是“整卡”语义它只负责把一张卡分配给 Pod并不限制这张卡的显存使用量。vLLM 到底用多少显存是由它自己的gpu_memory_utilization参数控制的。你在 K8s 层设置限制基本上等于把调度器的手脚绑住却指望它能帮你捂嘴——根本没有这个功能。正确做法是把显存预算控制完全交给 vLLM 层比如设置--gpu-memory-utilization0.8K8s 只负责保证每张卡有且仅有一个独占进程。6.2 坑二只看 K8s 的节点用量不看 Ray 的队列堆积有一类问题特别迷惑人K8s 节点负载很低GPU 利用率很高但 Ray 的任务提交速度很慢。很多人怀疑是网络问题或者模型本身变慢了最后发现是 Ray 的 worker 数量不足任务全堆在 Ray 的内部队列里。K8s 的节点利用率解决的是“机器有没有被分配出去”它看不出来某个 worker 进程上的排队情况。如果你用了 Ray遇到任务堆积时应该首先去看 Ray Dashboard 的队列长度、每个 worker 的资源占用而不是盯着 K8s 的 CPU 和内存图表。这不是监控工具好不好的问题而是两层系统的指标口径本来就不一样。6.3 坑三max_num_seqs 设得太小显存有余但请求进不去这个坑在 vLLM 上尤其隐蔽。你按显存大小估算觉得 80GB 的卡跑一个 7B 模型、同时服务 20 个请求绝无问题但实际请求一拥而入时却发现延迟飙升。打开 vLLM 日志你可能会看到类似 “available KV cache blocks after adding sequence group” 这种提示这说明调度器没有足够的空闲块容纳新序列。这通常不是因为显存真的不足而是max_num_seqs限制了每个迭代能同时处理的序列数导致大量序列在等待队列里排队KV cache 却因为 batch 被限制而没有填满。正确的调优思路是显存足够时适当调大max_num_seqs和max_num_batched_tokens让每个迭代能容纳更多序列显存紧张时再去降低gpu_memory_utilization或者开启更积极的抢占策略。不要只盯着显存本身要同时关注序列池的大小和 KV cache 块的分配率。6.4 坑四把 vLLM 的请求队列当作优先级做忽略了饥饿问题vLLM 的调度策略默认是抢占式的长请求和短请求混在一起时长请求因为不断被抢占延迟可能高到离谱。使用 OpenAI 兼容接口时很多平台只给用户暴露了max_tokens这类参数并没有暴露 vLLM 内部的优先调度配置。我个人的建议是如果业务里有大量短请求比如聊天机器人和极少数长请求比如长文总结混跑一定要在网关层提前做分流或者给 vLLM 配置请求优先级避免短请求被长请求反复挤掉导致超时。这个层面其实已经超出了一般意义上的“调度”讨论但它正是调度决策失衡的典型产物。最后说一点我长期形成的工作习惯。每次遇到三层调度相关的性能问题我不会一上来就开监控面板而是先问自己三层里每一层各自有没有做对决策集群层有没有给任务找到合适的机器任务层有没有把任务派给合适的进程引擎层有没有把显存分给合适的请求。这个问题问完问题基本就解决了一半。剩下的调试过程才是真正的技术活。