从零搞懂 GPU 集群调度:K8s、万卡治理、资源池化、弹性伸缩到 vLLM/SGLang/Slurm 实战

这篇长文写给算法工程师和平台工程师,从最基础的"一张 GPU 卡怎么被 K8s 认识"讲起,到"一万张卡怎么不崩",再到"大模型服务怎么在集群上跑起来",最后聊 Slurm。六大主题:K8s GPU 调度原理、万卡集群治理、资源池化、弹性伸缩、vLLM/SGLang 集群化部署、Slurm 实战。


一、开篇:为什么 GPU 调度比 CPU 调度难十倍

如果你做过 K8s 的 CPU/内存调度,可能觉得资源调度就是"分配数字资源,加个 request/limit 完事"。但当资源变成 GPU 之后,画风完全变了,原因有五个:

1. GPU 不可分割(默认情况下)。 CPU 可以按 0.1 核这样细粒度分配,内存可以按 MB 分配,但一张物理 GPU 卡,默认情况下,K8s 只能把它当作一个"整数资源"分配给一个容器——要么给你一整张卡,要么不给。这就导致一个尴尬场景:推理服务只需要 2GB 显存,却要独占一张 80GB 的 A100,资源利用率可能不到 5%。

2. GPU 之间不是对等的。 同样是"一张 GPU",A100、H100、L20、消费级 4090,算力和显存差异巨大。而且同一台机器上的多张 GPU 之间,通过 NVLink 还是 PCIe 互联,带宽能差 10 倍以上。调度的时候如果不考虑这些拓扑差异,训练任务的通信效率可能腰斩。

3. 多卡任务需要"凑对"分配。 一个大模型训练任务经常需要 8 卡、16 卡甚至上千卡同时启动、同时通信(Gang Scheduling)。如果调度器一张一张地分配,分配到一半发现资源不够,任务卡死、其他任务的资源也被占着不能用——这就是有名的"资源死锁"问题。

4. 故障率是硬件级别的常态。 一台机器 1000 小时不出故障很正常,但当你有一万张卡的时候,统计学告诉你,几乎每天都有卡在掉线、降频、ECC 错误。调度系统必须把"故障是常态"作为设计前提,而不是异常处理。

5. 网络是新的"计算资源"。 大模型训练/推理高度依赖卡间通信,网络拓扑本身就是需要被调度感知的资源,这在传统 CPU 调度里几乎不存在。

理解了这五个难点,后面所有的技术方案本质上都是在解决它们。我们先从最基础的 K8s 层面讲起。


二、Kubernetes GPU 调度原理:从 Device Plugin 讲起

2.1 K8s 原生根本不认识 GPU

这是很多人的误区:kube-scheduler 原生设计只认识 CPU、内存这类"可度量的标准资源",它压根不知道什么是 GPU。要让 K8s 能调度 GPU,靠的是一套叫 Device Plugin 的扩展机制。

可以把 Device Plugin 理解成"翻译官":它跑在每个节点上,负责把"这台机器上有几张 GPU、每张卡的健康状态如何"这些信息,翻译成 K8s 认识的语言,汇报给 kubelet,再由 kubelet 汇报给 API Server。

GPU 从"被发现"到"被使用"的完整链路:

  1. 节点启动时:NVIDIA Device Plugin(以 DaemonSet 形式跑在每个 GPU 节点上)调用 NVML(NVIDIA Management Library)扫描本机的 GPU,发现比如 8 张卡;
  2. 上报资源:Device Plugin 通过 gRPC 接口(ListAndWatch)把这 8 张卡注册成 nvidia.com/gpu: 8 这样的 Extended Resource,汇报给本机 kubelet;
  3. kubelet 上报节点容量:kubelet 把这个信息写进 Node 对象的 status.capacity 和 status.allocatable 字段。这时候执行 kubectl describe node 就能看到 nvidia.com/gpu: 8;
  4. 用户提交 Pod:用户在 Pod spec 里声明 resources.limits: {nvidia.com/gpu: 1};
  5. 调度决策:kube-scheduler 在调度这个 Pod 时,会把"该节点是否有足够的 nvidia.com/gpu"作为过滤条件之一,选出满足条件的节点;
  6. 设备分配:Pod 被调度到节点后,kubelet 调用 Device Plugin 的 Allocate 接口,由 Device Plugin 决定"具体分配哪几张物理卡"(比如挑选 GPU 0),并把对应的设备文件路径(/dev/nvidia0)、驱动库挂载信息返回给 kubelet;
  7. 容器启动:kubelet 按照这些信息生成容器运行时配置,把设备文件挂载进容器,容器里的进程就能通过 CUDA 直接访问这张物理 GPU。

一个简单的 Pod 声明:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Pod
metadata:
name: gpu-demo
spec:
containers:
- name: cuda-container
image: nvidia/cuda:12.2.0-base-ubuntu22.04
resources:
limits:
nvidia.com/gpu: 1 # 申请1张卡,注意:limits和requests必须相等,GPU不支持超卖(原生机制下)

2.2 为什么原生调度粒度这么"粗"

细心的你可能发现:nvidia.com/gpu: 1 只能填整数,不能填 0.5。这是因为 Device Plugin 机制设计之初的假设就是"设备是独占资源",类似于一个 USB 摄像头只能被一个容器用。这也是为什么后面会有专门的"资源池化"方案(第四章会详细讲 vGPU、MIG、时间片共享)。

2.3 调度器怎么做"更聪明"的决策

原生 kube-scheduler 只能做"有没有卡"这种简单判断,但真实场景需要更复杂的调度逻辑:

  • 拓扑感知:同一节点内,优先分配同一 NVLink 组内的卡,让卡间通信更快;
  • 批量任务组调度:一个训练任务需要 8 个 Pod 同时启动,要么全部调度成功,要么都不调度,避免"占了资源却启动不了"的资源浪费;
  • 优先级与抢占:高优先级的在线推理任务可以抢占低优先级的训练任务资源;
  • 多队列公平调度:多个团队共享集群,需要按配额、按优先级公平分配。

这些能力,原生 kube-scheduler 都不支持,业界的做法是通过 Scheduler Framework 扩展点或者自定义调度器来实现。目前最主流的两个开源方案是:

  • Volcano(CNCF 项目,原华为开源):专门为批处理、AI 训练场景设计的调度器,原生支持 Gang Scheduling、队列、公平调度、抢占等能力。几乎是目前 K8s 上跑 AI 训练任务的事实标准;
  • Kueue(K8s 官方 sig-scheduling 孵化项目):更轻量,专注于"任务排队 + 配额管理",常和 JobSet、Kubeflow Training Operator 搭配使用,适合作为团队级配额治理的准入层。

一个使用 Volcano 实现 Gang Scheduling 的训练任务示例(8 卡分布式训练,必须 8 个 Pod 同时起来才算调度成功):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
name: pytorch-training-job
spec:
minAvailable: 8 # 关键字段:至少8个Pod就绪才算这个Job调度成功
schedulerName: volcano # 指定用volcano调度器而不是默认的kube-scheduler
tasks:
- replicas: 8
name: worker
template:
spec:
containers:
- name: pytorch
image: pytorch-training:latest
resources:
limits:
nvidia.com/gpu: 1
restartPolicy: OnFailure

如果集群里暂时没有 8 张空闲卡,Volcano 会让这个 Job 整体排队等待,而不是先起 5 个 Pod 占着资源、剩下 3 个一直 Pending——这在原生调度器下是很容易发生资源死锁的经典场景。


三、万卡级别 GPU 集群治理:规模化后的世界完全不同

当集群规模从几十张卡增长到成百上千甚至上万张卡时,不是简单的"复制粘贴放大",而是要面对几个质变的挑战。

3.1 故障是常态,自愈是刚需

万卡集群里,GPU 硬件故障(掉卡、ECC 错误、XID 错误)、网卡故障、节点宕机几乎每天都在发生。治理体系必须包含:

  • 健康检查:通过 DCGM(NVIDIA Data Center GPU Manager)持续采集每张卡的温度、功耗、ECC 错误计数、Xid 错误码等指标;
  • 节点自动隔离:一旦检测到某张卡异常,自动给节点打上 Taint,让调度器不再往这台机器调度新任务,同时把这台机器上正在跑的任务驱逐并在健康节点上重新调度;
  • 故障自愈闭环:结合硬件运维系统,自动触发工单、重启、甚至硬件更换流程,减少人工介入。

业界比较成熟的组件是 NVIDIA DCGM + node-problem-detector + descheduler 的组合拳:DCGM 负责采集指标,node-problem-detector 负责把异常翻译成 K8s Node Condition,descheduler 负责把有问题节点上的 Pod 驱逐重调度。

3.2 拓扑感知调度:让"物理距离"参与决策

万卡集群的网络架构通常是分层的:单机内 NVLink 互联 → 机内 PCIe/NVSwitch → 机间 InfiniBand/RoCE 网络(常见 Fat-Tree 或 Rail-optimized 拓扑)。同一台机器内的卡通信延迟是纳秒级,跨机器通信是微秒级,差了 3 个数量级。

拓扑感知调度的核心思想是:尽量把同一个分布式任务的所有 Pod,调度到网络拓扑上"距离最近"的机器组。具体做法包括:

  • 给节点打拓扑标签(比如按机架、按 Leaf 交换机分组打 label);
  • 调度器在调度时,优先把同一 Job 的 Pod 打散到同一个 Leaf 交换机下的机器(减少跨 Leaf 通信);
  • 对超大规模训练(千卡以上),还需要考虑"网络分区连续性",即分配到的机器在物理网络上尽量连续,避免产生大量跨 Spine 层的流量。

这也是为什么很多云厂商会提供"物理机架感知"的调度插件,或者要求用户提交任务时指定"网络拓扑约束"。

3.3 多租户隔离与配额治理

一个万卡集群通常是多个团队、多条业务线共享的。治理体系要解决"既要隔离,又不能太浪费"的矛盾:

  • 命名空间 + ResourceQuota:每个团队一个 Namespace,通过 ResourceQuota 限制其能使用的 GPU 总量;
  • 队列化调度:Volcano/Kueue 都支持"队列"概念,每个队列有权重、有配额上限,支持"借用"机制——A 团队暂时不用的资源可以被 B 团队借用,A 团队需要时可以抢占回来;
  • 优先级体系:通常分为在线推理(最高优先级,不能被抢占)、周期性任务、大规模训练、探索性实验(最低优先级,随时可被抢占)几个层级。

3.4 大规模调度性能:etcd 和 scheduler 都会成为瓶颈

当集群节点数、Pod 数达到万级别,原生 K8s 的几个组件会先后暴露性能瓶颈:

  • etcd:所有对象的存储和 watch 都要过 etcd,节点/Pod 数量暴涨后,etcd 的写入延迟、数据库大小都会成为瓶颈,通常需要做 etcd 分片或者用更高性能的存储后端;
  • API Server:大量 List/Watch 请求会打满 API Server,需要开启 APF(API Priority and Fairness)限流,减少不必要的全量 List;
  • kube-scheduler:默认调度器的调度吞吐(每秒能调度多少 Pod)在大规模集群下会成为瓶颈,需要调优 percentageOfNodesToScore 等参数,减少每次调度需要打分的节点数量。

业界应对超大规模集群的常见做法是联邦化 / 多集群化:与其把一万张卡塞进一个 K8s 集群死磕控制面性能,不如拆分成多个几千卡规模的子集群,上层用多集群编排层(比如 Karmada、Clusternet,或者自研的全局调度层)做统一的资源视图和跨集群调度。

3.5 可观测性:没有监控的万卡集群等于裸奔

治理的基础是"看得见"。一套完整的可观测体系至少要包含:

  • 硬件层:DCGM Exporter 采集 GPU 利用率、显存占用、温度、功耗、NVLink 带宽;
  • 调度层:各队列的排队时长、资源利用率、抢占次数;
  • 业务层:训练任务的 loss 曲线、吞吐、推理服务的 QPS、延迟(TTFT/TPOT)。

这些指标通常汇总进 Prometheus + Grafana,配合告警规则,做到"卡异常 5 分钟内被发现、任务卡死 10 分钟内被感知"。


四、GPU 资源池化:让一张卡能被"切开"用

前面提到,K8s 原生的 GPU 分配是"整卡独占",这在训练场景问题不大(训练本来就吃满卡),但在推理场景下会造成严重浪费——很多推理服务并不需要一整张卡的算力和显存。资源池化就是为了解决这个问题。

4.1 三种池化技术路线

方案一:时间片共享。 多个容器共享同一张物理卡,通过驱动层的时间片轮转来实现"看起来像并行,实际上分时复用"。NVIDIA 官方的 Device Plugin 就自带这个能力,配置也很简单:

1
2
3
4
5
6
7
8
version: v1
flags:
migStrategy: none
sharing:
timeSlicing:
resources:
- name: nvidia.com/gpu
replicas: 4 # 一张物理卡虚拟成4个可分配单位

优点是配置简单、兼容性好;缺点是没有真正的隔离——显存没有硬性划分,一个容器把显存吃满,其他容器直接 OOM;算力也没有强隔离,容易互相干扰。所以时间片共享通常只适合"轻量级、非核心"的场景,比如开发调试、低峰期资源复用。

方案二:MIG。 这是 NVIDIA 从 A100 开始提供的硬件级别切分能力,可以把一张物理 GPU 在硬件层面真正切成最多 7 个独立实例,每个实例有独立的显存、独立的算力核心(SM)、独立的缓存,实例之间物理隔离,互不干扰。

MIG 的优点是隔离性最强(接近独立物理卡的体验),缺点是切分粒度是预设的几种规格(比如 A100 80G 可以切成 1g.10gb / 2g.20gb / 3g.40gb 等固定组合),不能像内存那样任意指定,而且切分之后需要重启才能调整规格,灵活性较差。

方案三:软件层虚拟化(vGPU / 第三方方案)。 这类方案通过软件层(通常是拦截 CUDA API 调用)实现显存和算力的精细化切分和硬隔离,比如显存可以精确限制到 MB 级别,算力可以按百分比限制。国内比较有代表性的开源方案是 HAMi(Heterogeneous AI Computing Virtualization Middleware,原 4paradigm 开源的 k8s-vgpu-scheduler),它可以做到:

  • 单卡切分成任意数量的虚拟卡,每个虚拟卡指定显存大小和算力百分比;
  • 显存硬隔离(超出申请量直接报错而不是抢占别人的显存);
  • 算力软隔离(通过 CUDA Kernel 劫持实现按比例限流)。

使用 HAMi 之后,Pod 的资源声明会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Pod
metadata:
name: inference-pod
spec:
containers:
- name: inference
image: my-inference-service:latest
resources:
limits:
nvidia.com/gpu: 1 # 逻辑上申请1张"虚拟卡"
nvidia.com/gpumem: 8000 # 精确申请8000MB显存
nvidia.com/gpucores: 30 # 精确申请30%算力

这样一来,一张 80GB 的 A100 理论上可以同时承载十几个这样的小型推理服务,大幅提升利用率。

4.2 三种方案怎么选

方案 隔离性 灵活性 适用场景
时间片共享 弱(无显存隔离) 高 开发测试、低优先级任务
MIG 强(硬件级) 低(固定规格、需重启) 对隔离性要求高的多租户推理
软件虚拟化(HAMi 等) 中强(可硬隔离显存) 高(任意粒度) 生产级推理服务池化,是目前业界主流选择

4.3 资源池化架构:从"卡"到"池"的抽象

真正做好池化,不只是切分技术本身,还需要一整套调度和管理体系:

  • 统一资源池视图:把集群里的物理卡抽象成一个个可分配的"资源单元",对上层业务屏蔽底层是整卡还是切片;
  • 调度器感知虚拟资源:调度器要知道每张物理卡已经被切了多少、还剩多少可分配,避免超卖;
  • 动态调整能力:根据业务负载,动态调整某张卡上运行的虚拟卡数量和规格(这一步目前业界仍在探索,很多方案仍需重启生效);
  • 计量计费:池化之后,一张物理卡被多个团队共享,需要精确到"某团队用了多少显存 × 多少时间"来计费,这对企业内部成本核算很重要。

五、弹性伸缩:GPU 场景下的伸缩不是加个副本那么简单

5.1 传统 HPA 为什么在 GPU 推理场景水土不服

K8s 原生的 HPA(Horizontal Pod Autoscaler)默认基于 CPU/内存利用率做伸缩决策,但在 GPU 推理服务场景,这个指标基本没用:

  • 大模型推理服务的瓶颈往往不是 CPU,而是显存占用和请求排队情况;
  • GPU 利用率(nvidia-smi 看到的百分比)本身是一个很粗糙的指标,持续 99% 不代表吞吐已经跑满,也可能是显存带宽瓶颈;
  • 推理服务最关心的其实是业务指标:排队请求数、P99 延迟(TTFT 首字延迟、TPOT 单 token 延迟)、QPS。

所以 GPU 场景的弹性伸缩,通常要接入自定义指标,而不是用 CPU/内存这类默认指标。这也是为什么 KEDA(Kubernetes Event-Driven Autoscaling)在 AI 推理场景很受欢迎——它支持接入几十种外部指标源(Prometheus、消息队列长度等),可以直接基于"当前排队请求数"或者"vLLM 暴露的自定义 Prometheus 指标"来做伸缩决策。

一个基于 KEDA + Prometheus 指标(比如 vLLM 暴露的等待队列长度)做弹性伸缩的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm-inference-scaler
spec:
scaleTargetRef:
name: vllm-deployment
minReplicaCount: 1
maxReplicaCount: 20
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
metricName: vllm_num_requests_waiting
threshold: "5" # 排队请求数超过5,就触发扩容
query: sum(vllm:num_requests_waiting)

5.2 GPU 弹性伸缩的三个"额外痛点"

即便接入了自定义指标,GPU 场景的弹性伸缩依然比 CPU 服务难得多,原因有三:

痛点一:启动慢。 一个大模型推理 Pod 从调度到能真正对外服务,要经历:拉取镜像(可能几十 GB)→ 加载模型权重(几十到几百 GB)→ CUDA 上下文初始化 → 预热。整个过程可能要几分钟甚至更久,而流量高峰往往是分钟级别的突发,等你扩容完,高峰已经过去了。

应对办法通常是:

  • 模型权重预热/缓存:用本地 NVMe 缓存或者高速分布式文件系统(比如阿里云的 CPFS、火山引擎的 TOS 加速)加速权重加载;
  • 镜像瘦身与预拉取:提前在节点池上预拉取常用推理镜像;
  • 保持最小副本数:GPU 推理服务通常不建议缩容到 0,而是保留一个小的"温池",牺牲一部分成本换取响应速度;
  • 快照/热迁移(进阶方案):部分云厂商支持 GPU 显存快照,加速冷启动。

痛点二:集群侧扩容更慢。 如果 Pod 扩容时集群里已经没有空闲 GPU 节点了,还需要触发 Cluster Autoscaler 去申请新的物理机/云上实例,这个过程涉及硬件开机、驱动初始化,可能要 5-15 分钟,比 Pod 级别扩容慢一个数量级。所以成熟的方案通常会做分层弹性:

  • 秒级:利用资源池化,在已有卡上快速切一个新的虚拟卡实例;
  • 分钟级:Pod 级扩容(有空闲整卡节点的情况下);
  • 十分钟级:节点级扩容。

痛点三:缩容要小心"打断在途请求"。 GPU 推理请求耗时可能长达数十秒(长文本生成),缩容时如果粗暴杀掉 Pod,会导致正在生成的请求失败。需要配置优雅终止:收到缩容信号后,先停止接收新请求,等当前请求处理完(或超时)再真正终止 Pod,这通常通过 preStop hook 和适当延长 terminationGracePeriodSeconds 实现。

5.3 训练任务的"弹性"是另一套逻辑

训练任务的弹性伸缩和推理完全不是一回事——训练任务通常需要"弹性容错"而不是"弹性扩缩容":当某些 Worker 挂掉,任务能自动缩减并行度继续跑,而不是直接失败重启(这在千卡以上的长周期训练中非常关键,因为完全重跑成本极高)。这方面比较成熟的方案是 PyTorch 的 Elastic Training(torchrun 的弹性模式)配合 K8s 上的 Training Operator,可以做到:训练进程组感知到 Worker 数量变化后,自动重新做 rendezvous(节点发现和分组),从最近的 checkpoint 恢复训练。


六、vLLM 集群化部署实战

前面几章讲的是"地基"——调度、治理、池化、弹性。接下来我们看"盖房子"——真正把大模型服务跑起来。先讲 vLLM。

6.1 vLLM 核心原理速览(为什么它能撑起集群化)

vLLM 最核心的创新是 PagedAttention:传统推理框架给每个请求的 KV Cache(注意力缓存)分配连续显存,导致显存碎片化严重、利用率低;PagedAttention 借鉴操作系统"分页"的思想,把 KV Cache 切成固定大小的小块,按需分配、非连续存储,显存利用率大幅提升,从而能同时处理更多并发请求。

配合 Continuous Batching(连续批处理):传统批处理是"一批请求必须一起开始一起结束",vLLM 支持请求动态加入/退出批次——一个请求生成完就立刻腾出空间给排队的新请求,而不用等整批都结束,极大提升吞吐。

理解这两点很重要,因为它决定了 vLLM 单实例的核心能力边界是显存(能装多少并发请求的 KV Cache)和算力(每步能算多快)。当单张卡装不下模型或者扛不住并发量的时候,就需要集群化。

6.2 vLLM 的三种"变大"方式

方式一:单机多卡——张量并行。 当模型本身大到一张卡装不下(比如 70B 模型用 FP16 需要 140GB 显存),需要把模型的每一层"切开"分布到多张卡上,每次前向计算时多卡协同完成一层的计算,这就是张量并行。vLLM 原生支持这个能力,只需要指定 tensor_parallel_size:

1
2
3
vllm serve meta-llama/Llama-3.1-70B-Instruct \
--tensor-parallel-size 4 \
--port 8000

张量并行要求卡间通信带宽足够高(通常要求同机内 NVLink 互联),因为每一层计算后都需要做一次 All-Reduce 通信,跨机走普通网络会成为严重瓶颈。所以张量并行一般限制在单机内(比如单机 8 卡)。

方式二:跨机扩展——流水线并行。 当模型大到单机 8 卡都装不下(比如超大规模 MoE 模型),需要把模型按层切分到不同机器上,像流水线一样,一台机器算完几层交给下一台机器接着算。vLLM 支持 pipeline_parallel_size 配合 tensor_parallel_size 一起使用:

1
2
3
4
vllm serve deepseek-ai/DeepSeek-V3 \
--tensor-parallel-size 8 \
--pipeline-parallel-size 2 \
--port 8000

这个例子是:每 8 张卡做张量并行组成一个"阶段",两个阶段(共 16 张卡、2 台机器)做流水线并行。流水线并行对通信带宽要求相对宽松一些,可以跨机器部署。

方式三:多实例横向扩展——集群化服务(这才是本节重点)。 前两种方式解决的是"一个模型副本怎么塞进多张卡",而生产环境真正要解决的是"怎么用一堆这样的模型副本,对外提供一个统一、高可用、能扛住高并发的推理服务"。这就需要在 K8s 上做真正的集群化部署。

6.3 K8s 上部署 vLLM 集群

场景 A:单机可装下的模型(最常见)——用 Deployment + Service 做多副本。

如果模型单机(比如 8 卡)就能装下并跑张量并行,那么"集群化"本质上就是"多起几个这样的 8 卡副本,前面挂负载均衡"。这是最常见、最简单的生产部署模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-llama3-70b
spec:
replicas: 4 # 4个副本,每个副本占用4张卡(TP=4)
selector:
matchLabels:
app: vllm-llama3-70b
template:
metadata:
labels:
app: vllm-llama3-70b
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
args:
- "--model=meta-llama/Llama-3.1-70B-Instruct"
- "--tensor-parallel-size=4"
- "--gpu-memory-utilization=0.9"
- "--max-model-len=8192"
resources:
limits:
nvidia.com/gpu: 4
ports:
- containerPort: 8000
readinessProbe: # 就绪探针很重要:模型加载完才算Ready
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: vllm-llama3-70b-svc
spec:
selector:
app: vllm-llama3-70b
ports:
- port: 80
targetPort: 8000

这里有个关键细节容易被忽视:Pod 反亲和性与拓扑约束。要保证每个 4 卡副本内部的通信效率,必须保证这 4 张卡在同一台机器、同一个 NVLink 域内,不能被调度器拆散到不同机器上。生产环境通常配合前面提到的拓扑感知调度器(Volcano)加上 Pod 内 Gang Scheduling 语义来保证这一点。

场景 B:模型超大,单机装不下——用 LeaderWorkerSet 做跨机部署。

对于像 DeepSeek-V3、Llama 3.1 405B 这种需要跨机器流水线并行的超大模型,一个"副本"本身就横跨多台机器,原生 Deployment 无法表达这种"一组 Pod 是一个逻辑整体"的语义。K8s 社区为此专门推出了 LeaderWorkerSet(LWS) 这个 API,由一个 Leader Pod 和若干 Worker Pod 组成一个逻辑单元,天然支持"多机作为一个服务副本"的场景,vLLM 官方也提供了配套的部署示例。核心思路是:

  • Leader Pod 负责接收请求、协调整个多机推理组;
  • Worker Pod 通过 Ray 或者 vLLM 内置的分布式通信组建立跨机通信;
  • 整组 Pod 作为一个原子单位被调度、被伸缩(要扩容就是整组复制一份,而不是单个 Pod 加加减减)。

一个简化的 LWS 部署片段示意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
name: vllm-deepseek-v3
spec:
replicas: 2 # 2组完整的多机推理实例
leaderWorkerTemplate:
size: 2 # 每组包含1个leader + 1个worker,共2台机器
leaderTemplate:
spec:
containers:
- name: vllm-leader
image: vllm/vllm-openai:latest
args: ["--model=deepseek-ai/DeepSeek-V3", "--tensor-parallel-size=8",
"--pipeline-parallel-size=2", "--distributed-executor-backend=ray"]
resources:
limits:
nvidia.com/gpu: 8
workerTemplate:
spec:
containers:
- name: vllm-worker
image: vllm/vllm-openai:latest
resources:
limits:
nvidia.com/gpu: 8

6.4 生产级必须补的几块拼图

1. 请求路由与负载均衡要"感知 KV Cache"。 普通的轮询负载均衡在 LLM 推理场景并不是最优的——如果能把"上下文相近"的请求(比如同一个多轮对话)路由到同一个之前处理过它的实例,可以命中已有的 KV Cache 前缀,减少重复计算(这叫 Prefix Caching 感知路由)。vLLM 社区推出了 vLLM Production Stack,内置了基于 KV Cache 感知的智能路由层,以及一个可选的分布式 KV Cache 共享层(LMCache),可以让多个 vLLM 实例之间共享 KV Cache,进一步提升集群整体的缓存命中率和吞吐。

2. Prefill 和 Decode 分离部署(PD 分离)。 大模型推理有两个阶段:Prefill(处理输入的 prompt,计算密集、并行度高)和 Decode(逐 token 生成,访存密集、并行度低)。这两个阶段的资源特性完全不同,混在一起跑会互相拖累(长 Prefill 请求会让 Decode 请求排队变长,拉高延迟)。业界(包括 vLLM 社区)正在推进的做法是把 Prefill 和 Decode 拆成两组独立的实例池,Prefill 完成后把 KV Cache 通过高速网络(RDMA)传输给 Decode 实例继续生成。这是目前大规模 LLM 推理集群优化的前沿方向,能显著改善 TTFT 和整体吞吐的平衡。

3. 可观测性。 vLLM 原生暴露 Prometheus 格式的指标(/metrics 端点),包括排队请求数、GPU KV Cache 使用率、生成吞吐、TTFT/TPOT 延迟分布等,这些指标既是监控告警的基础,也是前面提到的 KEDA 弹性伸缩的数据来源。


七、SGLang 集群化部署实战

7.1 SGLang 和 vLLM 的关系:不是替代,是不同侧重

SGLang 同样是一个高性能 LLM 推理引擎,核心创新是 RadixAttention:用基数树结构管理所有请求的 KV Cache,自动识别和复用不同请求之间的公共前缀(不仅是同一对话内的前缀,还包括不同请求之间偶然共享的前缀,比如相同的 System Prompt、Few-shot 示例),复用粒度比 vLLM 传统的 Prefix Caching 更细致、更自动化。

另外 SGLang 还提供了一套结构化生成的编程接口(适合做 Agent、复杂工作流场景的约束生成),以及在多轮对话、批量结构化输出场景下往往有更好的吞吐表现。两者在工程实现和适用场景上各有侧重,很多团队会两者都部署,按场景选择。

7.2 SGLang 单机集群化能力

SGLang 同样原生支持张量并行和数据并行:

1
2
3
4
5
python -m sglang.launch_server \
--model-path meta-llama/Llama-3.1-70B-Instruct \
--tp 4 \
--dp 2 \
--port 30000

这里 --tp 4 表示张量并行度为 4,--dp 2 表示数据并行度为 2(即启动 2 份独立的 TP=4 推理组,内部做负载均衡),一共占用 8 张卡。

7.3 K8s 上部署 SGLang 集群

部署模式和 vLLM 高度类似,同样是"单机能装下用 Deployment 多副本、装不下用多机方案"。以单机 4 卡为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
apiVersion: apps/v1
kind: Deployment
metadata:
name: sglang-llama3-70b
spec:
replicas: 3
selector:
matchLabels:
app: sglang-llama3-70b
template:
metadata:
labels:
app: sglang-llama3-70b
spec:
containers:
- name: sglang
image: lmsysorg/sglang:latest
command: ["python3", "-m", "sglang.launch_server"]
args:
- "--model-path=meta-llama/Llama-3.1-70B-Instruct"
- "--tp=4"
- "--host=0.0.0.0"
- "--port=30000"
resources:
limits:
nvidia.com/gpu: 4
ports:
- containerPort: 30000
readinessProbe:
httpGet:
path: /health
port: 30000
initialDelaySeconds: 90
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: sglang-svc
spec:
selector:
app: sglang-llama3-70b
ports:
- port: 80
targetPort: 30000

超大模型跨机部署方面,SGLang 同样支持多节点部署(通过指定 --nnodes、--node-rank、--dist-init-addr 等参数手动组网,或结合 Ray),在 K8s 上一样可以用 LeaderWorkerSet 或者自定义 StatefulSet 的方式来编排多机协同的一组 Pod。

7.4 SGLang 集群特有的注意点

RadixAttention 的缓存亲和性路由更重要。 由于 SGLang 的核心优势就是自动前缀复用,如果负载均衡策略是纯随机/轮询,会打散原本可以复用的前缀缓存,削弱 RadixAttention 的收益。生产部署建议在前面加一层"缓存感知"的路由层(可以基于请求 hash 前缀做一致性路由),让相似请求尽量落到同一实例。

结构化输出场景的资源评估。 如果业务大量使用 SGLang 的约束解码(比如强制输出 JSON Schema),这类请求的计算模式和纯文本生成不同,压测和容量规划时要单独覆盖这类场景。


八、Slurm:HPC 世界的调度王者,原理与实战

聊完了 K8s 生态,我们换个赛道,聊聊 Slurm(Simple Linux Utility for Resource Management)。如果说 K8s 是互联网服务化世界的调度标准,那 Slurm 就是高性能计算(HPC)和超算中心几十年的事实标准,今天依然是很多大模型训练集群(尤其是学术界和部分大厂的训练专用集群)的首选调度系统。

8.1 Slurm 是干什么的,和 K8s 有什么本质区别

Slurm 的设计目标很纯粹:在一堆物理机器上,公平高效地调度批处理作业,尤其擅长"几百上千个进程需要同时启动、协同做一次大规模并行计算"这种场景——这正是大模型分布式训练最典型的负载模式。

和 K8s 相比,核心区别是:

维度 Slurm Kubernetes
设计初衷 批处理计算任务(跑完就结束) 长期运行的服务(持续对外提供服务)
调度单位 作业,原生支持多节点作业 Pod,多 Pod 协同需要额外机制(如 Volcano)
资源分配 原生支持节点级、核心级、GPU 级精细分配 需要 Device Plugin 等扩展机制
网络/存储假设 假设所有节点共享同一套高速网络和共享文件系统 假设节点相对独立,服务发现靠 Service
生态定位 科学计算、HPC、AI 训练集群 云原生服务、微服务、也扩展支持 AI 负载

一句话总结:Slurm 天生就是为"一次性提交、批量并行跑完就走"的计算任务设计的,而 K8s 天生是为"长期在线服务"设计的。 这也是为什么大模型训练(本质是批处理计算任务)在学术界和很多训练团队里 Slurm 依然非常主流,而推理服务(本质是长期在线服务)在 K8s 上部署是更自然的选择。

8.2 Slurm 的架构原理

Slurm 集群由几个核心组件构成:

  • slurmctld(控制守护进程):整个集群的"大脑",跑在管理节点上,负责接收作业提交请求、维护资源状态、做调度决策(决定哪个作业该在哪些节点上跑、什么时候跑);
  • slurmd(节点守护进程):跑在每一台计算节点上,负责真正启动/监控/杀死作业进程,并定期向 slurmctld 汇报本节点的资源状态和健康状况;
  • slurmdbd(数据库守护进程,可选):负责作业历史记录、账户信息、计费数据的持久化存储,支撑后续的审计和公平共享调度;
  • 客户端命令行工具:用户通过 sbatch、srun、squeue 等命令与 slurmctld 交互,提交、查询、管理作业。

整个调度流程可以类比成一个"食堂排队打饭"系统:slurmctld 是排队管理员,手里有一份"每个窗口(节点)现在有多少菜(资源)"的清单;用户提交作业相当于递交一张"我要几份什么菜"的申请单;管理员根据优先级、公平性规则决定谁先打饭,分配好之后,由每个窗口的服务员(slurmd)实际执行打饭动作(启动进程)。

8.3 Slurm 里的 GPU 资源管理:GRES 机制

Slurm 通过 GRES(Generic Resource) 机制来管理 GPU 这类"通用资源"。在节点配置文件(slurm.conf / gres.conf)里,管理员需要声明每个节点有哪些 GPU。

gres.conf 示例(定义某节点有 8 张 GPU,每张卡对应哪个设备文件):

1
NodeName=gpu-node-01 Name=gpu Type=a100 File=/dev/nvidia[0-7]

slurm.conf 里对应节点也要声明总资源量:

1
NodeName=gpu-node-01 CPUs=128 RealMemory=1024000 Gres=gpu:a100:8 State=UNKNOWN

用户提交作业时,通过 --gres 参数申请 GPU:

1
srun --gres=gpu:a100:4 --nodes=1 --ntasks=4 python train.py

这行命令的意思是:申请 1 个节点、4 张 A100 GPU、启动 4 个任务进程。Slurm 会保证调度到的节点确实有 4 张空闲 A100,并且通过 cgroup 机制做设备隔离,确保这个作业只能访问分配给它的那 4 张卡,不会和同节点上其他作业的卡冲突。

8.4 实战:一个完整的多节点分布式训练作业怎么提交

Slurm 最强大的地方在于原生支持多节点批处理作业的一键提交,不需要像 K8s 那样额外引入 Volcano 才能做 Gang Scheduling——这本来就是它的设计初衷。

一个典型的多节点 PyTorch 分布式训练提交脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
#SBATCH --job-name=llama-pretrain
#SBATCH --nodes=4
#SBATCH --ntasks-per-node=8
#SBATCH --gres=gpu:a100:8
#SBATCH --cpus-per-task=16
#SBATCH --time=48:00:00
#SBATCH --partition=gpu-training
#SBATCH --output=logs/%j.out

# 设置分布式训练所需的环境变量
export MASTER_ADDR=$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1)
export MASTER_PORT=29500
export WORLD_SIZE=$SLURM_NTASKS

# srun会在所有分配到的节点上,按ntasks-per-node并行启动这个命令
srun torchrun \
--nnodes=$SLURM_NNODES \
--nproc_per_node=8 \
--rdzv_id=$SLURM_JOB_ID \
--rdzv_backend=c10d \
--rdzv_endpoint=$MASTER_ADDR:$MASTER_PORT \
train.py --config configs/llama_70b.yaml

提交作业只需要一条命令:

1
sbatch train.slurm

Slurm 会自动:①在集群里寻找 4 台各有 8 张空闲 A100 的节点(如果暂时凑不齐,作业进入排队队列);②通过 cgroup 把这 4 台节点上的 CPU/内存/GPU 隔离给这个作业;③在 4 台节点上并行启动 32 个训练进程(4 节点 × 8 进程);④通过环境变量把 MASTER_ADDR/WORLD_SIZE 等分布式训练所需的元数据注入到每个进程里;⑤监控作业运行,超时或异常时自动清理。


九、总结:从"一张卡"到"万卡集群"的完整知识地图

回顾全文,GPU 集群调度这条知识主线的脉络其实是:

  • 入口是 K8s 的 Device Plugin 机制:GPU 作为 Extended Resource 被节点发现、上报、调度、挂载,这是一切的起点;但原生机制只能整卡独占、只能做"有没有卡"的简单判断;
  • 向上需要调度器增强:Volcano/Kueue 补上 Gang Scheduling、队列、公平调度、抢占等批处理语义,Kueue 做配额准入;大规模场景还要引入拓扑感知;
  • 向下需要资源池化:时间片共享、MIG、HAMi 三条路线解决"推理场景一整卡浪费"的问题,让一张 80GB A100 能承载十几个小推理服务;
  • 弹性要分层看:推理用 KEDA 接自定义指标(排队数/延迟),训练用 PyTorch Elastic 做"弹性容错";启动慢、扩容慢、缩容打断在途请求是三大工程难点;
  • 推理引擎层面:vLLM 靠 PagedAttention + Continuous Batching 起家,集群化路径是"TP 单机切卡 → PP 跨机切层 → 多实例横向扩展",K8s 上单机用 Deployment、跨机用 LeaderWorkerSet;SGLang 的 RadixAttention 对前缀复用更细,但路由必须做缓存亲和;
  • 调度器选型上:训练(批处理)天然适合 Slurm,推理(长期服务)天然适合 K8s,两个世界正在互相借鉴——Volcano 给 K8s 补上了 Slurm 的能力,而 Slurm 也在向容器化、云原生的方向演化。

对想进一步动手实践的读者,建议的路线是:先在一个小集群上跑通"NVIDIA Device Plugin + Volcano + 一个 8 卡分布式训练任务",再引入 HAMi 做推理池化,最后用 KEDA + vLLM 的 Prometheus 指标搭一个完整的弹性推理服务——这条路径能把本文提到的所有关键组件都过一遍手。


延伸资源

转载说明:本文由微信公众号文章《从零搞懂 GPU 集群调度:K8s、万卡治理、资源池化、弹性伸缩到 vLLM/SGLang/Slurm 实战》沉淀整理而成,文中代码示例、表格与核心技术观点均出自原文;排版与部分表述为博客化重构,如与原文有出入,以原文为准。版权归原作者所有。