2026-07-29 04:00:00

在 Kubernetes 上启动一个推理服务并不难。如果只有一个模型,vLLM + Deployment 确实够了。但服务多起来以后,模型从哪里加载、使用哪个 Runtime、GPU 怎么分配、服务怎么暴露,每个服务都要重复处理一遍,配置很快就会散落在一堆 YAML 里。KServe 解决的不是怎么启动 vLLM,而是怎么用统一的方式管理这些模型服务。
本文从零安装 KServe Standard 模式和 Envoy Gateway,通过本地 PVC 部署 Qwen2.5-0.5B-Instruct 模型。
Standardized Distributed Generative and Predictive AI Inference Platform for Scalable, Multi-Framework Deployment on Kubernetes.
KServe 是一个面向 Kubernetes 的可扩展、多框架部署的标准化分布式生成式和预测式 AI 推理平台
即:KServe 是一个 AI 推理平台。

KServe 不直接执行模型计算,它负责管理模型服务。我们创建一个 InferenceService,写清楚模型地址、模型格式和资源需求,KServe Controller 就会选择对应的 Runtime,并创建底层的 Deployment、Service、HTTPRoute 等资源。
KServe 最初叫 KFServing,由 Google、IBM、Bloomberg、NVIDIA 和 Seldon 等团队在 2019 年共同发起。2021 年,项目从 Kubeflow 组织迁移到独立的 KServe GitHub 组织,并正式更名。现在 KServe 是独立的 CNCF 孵化项目,同时仍是 Kubeflow 生态中的重要组件,既可以随 Kubeflow 使用,也可以独立安装。
本文使用 Kubernetes 1.32+。假设节点已经安装 NVIDIA 驱动、Container Toolkit 和 Device Plugin,开始前先确认节点能够看到 GPU:
|
|
输出中的 GPU 应该大于 0。本文使用标准的 nvidia.com/gpu 资源;如果这里是 <none>,后面的推理 Pod 会一直处于 Pending。
这里需要分成两层理解。
InferenceService 是通用模型服务 API,它有 Standard 和 Knative 两种部署模式:
LLMInferenceService 是另一套独立 API,面向大语言模型的分布式工作负载、Prefill/Decode 分离和智能路由等场景。它不是 InferenceService 的第三种部署模式;如果只是部署单 GPU vLLM,使用 InferenceService Standard 模式通常更简单。
本文后面的安装流程只覆盖 InferenceService。LLMInferenceService 需要额外安装 Addon 和相关依赖,系列最后一篇再单独展开。
可以根据需求选择:
|
|
这里我们选择比较简单的 InferenceService(Standard) 模式进行演示。
KServe 依赖 cert-manager,需要提前安装,支持的最低版本是 1.15.0。
|
|
查看 Pod:
|
|
|
|
KServe 可以通过 Gateway API 或 Ingress 暴露服务。本文使用 Gateway API,并选择 Envoy Gateway 作为具体实现。
使用 Helm 安装 Envoy Gateway,包括 Gateway API CRD 和 Controller:
|
|
先创建 GatewayClass:
|
|
然后创建 Gateway。本文的测试集群没有 LoadBalancer,因此使用 EnvoyProxy 将 Envoy Service 配置为 NodePort;如果集群已经有可用的 LoadBalancer,可以删掉 EnvoyProxy 以及 Gateway 中的 parametersRef,直接使用默认配置。
|
|
|
|
|
|
|
|
查看 HuggingFaceServer Runtime 使用的基础镜像:
|
|
这里配置的是 v0.18.0 基础标签;InferenceService 申请 NVIDIA GPU 后,KServe 会自动在标签后追加 -gpu,最终 Pod 使用的是 v0.18.0-gpu。
查看 KServe Controller:
|
|
|
|
KServe 支持从 Hugging Face、PVC、S3 等位置加载模型。由于当前环境不能稳定访问 Hugging Face,本文提前从 ModelScope 下载模型,再通过 PVC 挂载。
|
|
检查模型文件:
|
|
本文只有一个 GPU 节点,因此使用 hostPath 静态 PV。生产环境更适合使用 CephFS、NFS、JuiceFS 等共享存储。
|
|
|
|
然后部署 InferenceService:
|
|
核心参数是这部分:
|
|
model.modelFormat:声明模型格式,会用来匹配 Runtime。当前指定的 huggingface 会匹配到 ClusterServingRuntime/kserve-huggingfaceserver。storageUri:模型来源。当前 pvc://qwen-model 说明模型在 qwen-model 这个 PVC 里面。args:传给 HuggingFaceServer 的启动参数。--model_name 设置 API 中的模型名;其余三个参数用于 vLLM,分别限制最大上下文长度、单次调度最多处理的序列数和 GPU 显存使用比例。
|
|
服务就绪后,READY 会变成 True:
首次启动需要读取模型、初始化 CUDA 并完成 CUDA Graph 预热。普通环境通常只需要几分钟,但磁盘较慢的单节点环境可能需要十几分钟,因此这里将等待上限设置为 30 分钟。
|
|
Standard 模式下,KServe 会为这个 InferenceService 创建 Deployment、Service 和 HTTPRoute:
|
|

查看模型服务日志:
|
|
日志中出现下面的内容,说明 HuggingFaceServer 已经使用 vLLM 加载 /mnt/models:
|
|
KServe 创建的 HTTPRoute 使用域名匹配:
|
|
如果没有配置 DNS,可以直接访问 Envoy 的 NodePort,同时手动设置 Host Header。
查看 Envoy Service:
|
|
NodePort 是动态分配的,先读取实际访问地址:
|
|
|
|
返回结果:
|
|
|
|
返回 chat.completion,说明下面这条链路已经打通:
|
|
Qwen2.5-0.5B-Instruct 主要用于验证部署链路,模型规模很小,输出质量不能代表生产模型。
到这里,我们已经用 KServe 跑通了一条完整的 LLM 推理链路,从本地 PVC 加载模型,再通过 Envoy Gateway 调用 OpenAI 兼容接口。
KServe 的价值不只是启动一个模型进程,而是把模型地址、Runtime、GPU 资源和访问入口收敛到 InferenceService 中,让推理服务也能沿用 Kubernetes 的声明式方式统一管理。
这也是理解 KServe 的起点。下一篇继续往下拆,看看一份 InferenceService YAML 提交之后,KServe 在集群里到底创建了什么。
2026-07-19 04:00:00

事情是这样的。
前几天有读者跟我反馈,说博客变慢了。
不是偶尔卡一下,是点开文章以后,文字出来了,图片还要在原地转上好几秒。
我自己试了几次,确实不对劲。更奇怪的是,前不久我才压缩过图片。
一个纯静态博客,为什么会突然慢成这样?
我第一反应是服务器扛不住了。
毕竟这就是一台很普通的小服务器,2C2G。真遇上请求高峰,被打得喘不过气也不奇怪。
结果我打开监控一看,CPU 使用率还不到 10%。
机器闲得很。
真正出问题的,是带宽。
我这台服务器的公网出口只有 3 Mbps。正常情况下,机器的流出带宽很低,只有 70.793 Kbit/s。

但被大量请求刷起来以后,曲线完全变了。把粒度缩到 1 分钟,公网流出带宽直接冲到 3.289 Mbit/s,贴着 3M 上限跑。

从 70.793 Kbit/s 到 3.289 Mbit/s,相差超过 46 倍。
Caddy 返回静态页面几乎不费 CPU,所以没有收到报警信息。但出口一旦被占满,正常读者再打开一张图片,就只能在后面排队。
顺着带宽曲线往下查,我发现 Caddy Access Log 最近轮转得比平时快了不少。
我一开始还没太当回事。博客嘛,被搜索引擎和 RSS 阅读器抓一抓很正常。
直到我把 27 天的轮转日志全部合到一起,才发现里面有一批访问频率异常的请求。
这份日志快照实际覆盖 27.46 天,统计结果如下:
| 指标 | 数值 |
|---|---|
| 全站请求 | 1,132,456 |
| 异常请求 | 477,314 |
| 异常请求占比 | 42.15% |
| 异常响应流量 | 9.00 GB |
| 异常 IP | 75 个 |
| 返回 200 | 475,806 次 |
47 万是累计总量,短时间内的请求频率更值得看。
| 行为 | 观测结果 |
|---|---|
| 最高一天 | 25,936 次 |
| 最高一小时 | 6,864 次,占该小时全站请求 85.98% |
| 最高一分钟 | 627 次,全部来自同一个 IP |
| 最快 100 次 | 6.281 秒 |
| 最快 500 次 | 43.412 秒 |
| 最长连续突发 | 7 分 28 秒内请求 3,539 次 |
那段最长突发里,相邻请求没有一次停顿超过 1 秒。这段时间共切换了 45 种 User-Agent,成功请求文章页面 3,308 次。
从文章覆盖范围看,这些请求也不是偶尔访问几篇。
当前 sitemap.xml 中有 278 篇文章。这批 IP 访问了全部 278 篇,仅对这些文章就请求了 394,112 次。按篇数粗略折算,相当于把整个文章库读取了 1,418 遍。
这就离谱😂
单篇文章被读取的中位数是 1,163 次,152 篇文章超过 1,000 次,最高一篇达到 5,551 次。
不过,请求多并不能直接说明这些访问不正常。
搜索引擎、RSS 阅读器,甚至某个读者连续打开很多文章,都可能制造高峰。还需要继续看这些请求的具体特征。
先看 IP。
27 天里一共出现了 75 个相关地址,单个 IP 通常只活跃 1~6 天,随后换成同一地址空间里的另一个。
再看 User-Agent。同一个 IP 在不到一秒内留下过这样的记录:
|
|
Chrome 100~144 一共 45 个版本全部出现,每个版本的请求量都在 9,441~10,045 次之间。排除每个 IP 的第一条记录后,97.77% 的相邻请求都会更换 User-Agent。
正常浏览器不会请求一次就换一个 Chrome 版本。
再看请求目标。这批地址主要请求 RSS 和文章 HTML,几乎不加载文章图片。
我第一反应就是封 IP。
但对方 27 天换了 75 个 IP,逐个封禁只能短期止血,批量封禁又容易误伤正常用户。
继续判断请求来自谁没有太大意义,我只需要限制访问频率。
问题变成了,每分钟允许多少次,既能拦住抓取,又不影响正常读者?
为了避免阈值设得太紧,误伤正常访问,我先拿最近 24 小时的日志做了一次固定自然分钟回放:
| 阈值 | 回放结果 |
|---|---|
| 全部请求 60 次/分钟 | 命中 3 个主要异常 IP 和 1 个额外 IP |
| 全部请求 120 次/分钟 | 只命中 3 个主要异常 IP |
| 页面请求 30 次/分钟 | 命中 3 个主要异常 IP 和 1 个额外 IP |
| 页面请求 60 次/分钟 | 只命中 3 个主要异常 IP |
结合回放结果,我设置了两个独立限流区:
|
|
页面请求同时计入两个区。HTML、分类、标签和 RSS 每分钟最多 60 次,CSS、JavaScript 等其他资源只受每分钟 120 次的兜底限制。
日志回放按固定自然分钟统计,后面使用的模块是滑动窗口,两者不会完全一致。这组阈值只对应当时的访问情况,后续还要根据 429 和误伤情况调整。
Caddy v2.11.4 的官方标准二进制不包含 rate_limit,需要通过 xcaddy 构建自定义版本。本文使用的是第三方模块 mholt/caddy-ratelimit。
为了以后换服务器还能复现,我固定了 Caddy、xcaddy 和插件提交:
|
|
构建完成后确认模块已经包含在二进制中:
|
|
限流配置如下:
|
|
将配置写入实际使用的 Caddyfile 后,先用新二进制校验配置,再替换当前版本并重启服务:
|
|
只替换磁盘上的二进制还不够,正在运行的旧进程不会自动加载新模块,因此这里需要重启 Caddy。
配置生效后,我从同一出口 IP 并发请求页面:
|
|
超过窗口额度后,客户端开始收到 429:
|
|
看到 429 后,可以确认限流配置已经生效。
限流在 2026-07-16 10:55:50 随新 Caddy 进程正式生效。
前面的云监控截图中,带宽打满发生在 2026-07-15 20:29~21:28。我把这 1 小时作为上线前窗口,再取限流上线后相同钟点的 2026-07-16 20:29~21:28 做对比。
上线后的同一小时内,异常 IP 成功获取的 HTML 减少了 4,478 次,Caddy 少返回了 89.46 MB 响应数据,还有 1,287 次请求被直接挡在 429。
| 指标 | 上线前 | 上线后 | 实际变化 |
|---|---|---|---|
| 异常地址成功返回 HTML | 5,297 | 819 | 减少 4,478(-84.5%) |
| 异常地址响应流量 | 112.03 MB | 22.57 MB | 减少 89.46 MB(-79.9%) |
| 异常地址返回 200 | 5,664 | 932 | 减少 4,732(-83.5%) |
| 异常地址返回 429 | 0 | 1,287 | 新增 1,287 次拦截 |
| 异常地址请求 | 5,684 | 2,223 | 减少 3,461(-60.9%) |
| 全站请求 | 6,637 | 3,071 | 减少 3,566(-53.7%) |
成功返回的 HTML 和异常响应流量分别下降 84.5% 和 79.9%,限流生效后有 1,287 次请求返回 429。全站请求量也下降了 53.7%,不过里面混着正常访问,只能作为参考。
单个小时还不能代表长期效果,后续仍要观察 429 来源以及搜索引擎是否被误伤。
文章发出来,本来就是给大家看的。有人搜索、订阅,甚至批量抓取文章,我其实都无所谓。我介意的是,持续的高频请求已经影响到正常读者打开文章和图片。
120 次/分钟 和 60 次/分钟 不是通用答案,只是我根据这次日志回放选出的起点,后面还要继续观察是否存在误伤。
限流只是控制访问节奏,让真正来看文章的人能够顺畅打开页面。
2026-07-15 06:00:00

上一篇我们用 Kueue + NVIDIA 原生 DRA 跑通了 GPU 整卡配额:Job 先进入队列,Kueue 判断配额够不够,够了再放行给调度器。
但整卡只是第一步。真实的 GPU 集群里,一张 GPU 往往不会只给一个 Pod 用,HAMi 可以继续按显存和算力切成 vGPU。问题也跟着来了:切完之后,队列系统还能不能知道每个任务用了多少?能不能做到两个任务放行、第三个因为显存或算力配额不够继续排队?
这篇就围绕这个问题跑一遍:HAMi 把 GPU 切给 Pod,Kueue 在 Job 准入阶段先把 vGPU、显存和算力配额算清楚。重点不是“能不能切卡”,而是“切完之后还能不能管起来”。
环境基本沿用上一篇 Kueue + NVIDIA DRA 的测试环境。区别只有两点:
本文的命令和输出来自这套环境:
|
|
K8s 集群、GPU Operator、Kueue 的安装过程前几篇已经写过,这里不再重复。需要确认的一点是:后面由 HAMi 接管 GPU 切分,所以 GPU Operator 安装时要关闭 NVIDIA DevicePlugin:
|
|
节点上能看到 T4 即可:
|
|
HAMi DRA Webhook 需要 TLS 证书,因此需要提前安装 cert-manager 用于自动签发。
|
|
给节点打上 gpu=on 标签。未标记的节点不会被 HAMi 接管。
|
|
安装 HAMi,并通过 --set dra.enabled=true 开启 DRA 模式:
|
|
注意:DRA 模式与传统模式不兼容,请勿同时启用。
gpu=on 主要给 HAMi 做节点选择。后面 ResourceFlavor 用的 nvidia.com/gpu.product=Tesla-T4,则是 GPU Operator / GFD 自动打到节点上的标签。
另外,如果 GPU 驱动是主机预装,非 GPU Operator 安装,则安装时需额外指定:
|
|
HAMi 正常启动后,hami-system 下会看到三个核心组件:
|
|
底层发布出来的 GPU 资源也要确认一下:
|
|
ResourceSlice 里记录了这张 T4 的显存和算力容量:
|
|
需要注意的是 allowMultipleAllocations: true:同一张物理 GPU 可以被多个 ResourceClaim 消费,只要显存和算力容量还够,切卡才有空间。
对 HAMi 不熟悉的同学可以先看看这篇文章:Kubernetes GPU 虚拟化实战:HAMi DRA 模式完整指南
HAMi DRA 现在有两种使用方式:
| 模式 | 用户怎么写 | 谁创建底层设备申请 | 更适合什么场景 |
|---|---|---|---|
| DRA 原生模式 | 手写 ResourceClaim / ResourceClaimTemplate
|
用户 | 新业务直接接 DRA API |
| 兼容模式 | 写 nvidia.com/gpu/gpumem/gpucores
|
HAMi webhook | 存量 HAMi 业务迁移 |
我这次实测下来,Kueue 接 HAMi 原生 DRA 时,更容易先做到按 claim / 设备数量做准入;但要把 gpumem/gpucores 也纳入 Kueue 配额,兼容模式反而更顺:业务侧仍然写 nvidia.com/gpu/gpumem/gpucores,Kueue 通过 ResourceTransformation 把它们折算成队列里的总量配额资源。
所以这篇走兼容模式,业务 YAML 继续写熟悉的 HAMi 资源:
|
|
含义是:
| 资源 | 含义 |
|---|---|
nvidia.com/gpu: 1 |
申请 1 个 vGPU 设备实例 |
nvidia.com/gpumem: 4096 |
每个 vGPU 申请 4096Mi 显存 |
nvidia.com/gpucores: 50 |
每个 vGPU 申请 50% 算力 |
HAMi webhook 会拦截这个 Pod,把上面的资源申请转换成底层设备申请。调度时,kube-scheduler 看到的是 HAMi 发布出来的 GPU 设备,以及对应的显存、算力容量。
这里用一个最小例子把链路跑通。先验证 HAMi 能不能切出 4Gi / 50 cores;确认没有问题以后,再接入 Kueue,观察它会不会在准入阶段扣配额。
为了把变量降到最低,先不接 Kueue,直接提交一个 Pod:
|
|
Pod 创建后,HAMi webhook 会把原始资源申请改成 ResourceClaim:
|
|
对应的 ResourceClaim 如下:
|
|
容器里看到的显存也变成了 4096Mi:
|
|
到这里可以确认 HAMi vGPU 已经生效:业务 YAML 仍然写 nvidia.com/gpu/gpumem/gpucores,容器里实际只看到被切分后的 4096Mi 显存。
HAMi 验证通过后,就可以把 Kueue 接进来了。接入前需要先把配额口径捋清楚:HAMi 的 nvidia.com/gpumem 和 nvidia.com/gpucores 是“每个 vGPU”的资源。比如这次 Demo 里有两个相同规格的 Job,每个 Job 都申请:
|
|
那么队列整体在配额上应该按总量扣减:
|
|
Kueue 管队列配额时应该看总量,所以要在 Kueue 配置里加 ResourceTransformation。这次用的是 Kueue 0.18.1:
|
|
这几行最关键的是两个动作:
nvidia.com/gpumem
nvidia.com/total-gpumem
gpucores 同理,转成 nvidia.com/total-gpucores
multiplyBy: nvidia.com/gpu 表示先乘以 vGPU 个数,再进入 Kueue 配额修改 kueue-manager-config 后重启 Kueue:
|
|
这次只用一个队列,把资源扣减先看清楚。
场景:
|
|
Kueue 配置:
|
|
这里的显存配额写 8192,和业务侧的 nvidia.com/gpumem: 4096 保持同一个口径:单位都是 Mi。单个 Job 会被 Kueue 统计成 nvidia.com/total-gpumem: 4096,两个相同 Job 加起来就是 8192。
提交一个 4Gi / 50 cores 的 Job:
|
|
结果:
|
|
看 Workload 的准入结果:
|
|
这个结果就是 Kueue 介入后的关键变化:Pod 还没进入调度阶段,Workload 已经先按 vGPU 个数、总显存、总算力扣了一次配额。
HAMi 生成的 ResourceClaim 也可以对上:
|
|
容器里也能看到 4096Mi 显存上限:
|
|
继续提交两个相同规格的 Job:
|
|
队列总配额是:
|
|
因此第 2 个 Job 可以准入,第 3 个 Job 会继续留在队列里:
|
|
ClusterQueue 的用量已经打满:
|
|
Pending Workload 里能看到原因:
|
|
这也是我更关心的点:不是等 Pod 到调度阶段才 Pending,而是在 Job 准入阶段就把超配额任务留在队列里。
第一个坑是小数 GPU。不要写 nvidia.com/gpu: "0.5"。
Kubernetes 扩展资源必须是整数,而且 GPU 这类不可超卖资源要写在 limits 里。正确写法是:
|
|
半张卡 这种说法在 HAMi 里应该理解成:1 个 vGPU 实例 + 部分显存 + 部分算力,不是 0.5 个 nvidia.com/gpu。
只把 nvidia.com/gpu 放进 Kueue,只能限制 vGPU 个数,管不了显存和算力。
要把 HAMi 的切分资源纳入 Kueue 配额,需要做这两个转换:
|
|
ClusterQueue 里也要同时配置这三个资源:
|
|
这一篇跑完后,Kueue 系列基本就从 CPU 队列一路串到了 GPU 整卡和 HAMi vGPU。真落到 GPU 集群里,光能切卡还不够,准入和配额也一样重要: GPU 虚拟化解决的是"怎么切",Kueue 解决的是"谁先用、谁能用多少"。两者结合起来,GPU 集群才真正具备了多租户管理能力。
2026-07-02 06:00:00

前面两篇:Kubernetes 官方出品:一个 Controller 搞定 Job 排队和资源配额 和 终于搞懂 Kueue:5 个核心对象一次讲透 把 Kueue 的基本玩法和核心对象走了一遍,不过 Demo 都跑在 CPU 上。
真到 GPU 集群里,大家更关心的是另一个问题:
Kueue 能不能管理 DRA 模式下的 GPU?
这一篇就把它跑通。NVIDIA DRA Driver 负责把整卡 GPU 发布成 DeviceClass / ResourceSlice,Kueue 在 Job 准入阶段读取 DRA 设备申请,判断这个 Job 能不能进入队列。
对 DRA 不熟悉的同学,可以先看这篇:DRA P1:DRA 能解决什么问题?从部署到使用的完整体验
环境准备分几段走:先创建 K8s 集群,再用 GPU Operator 把 GPU Driver / Container Runtime 准备好,最后装 Kueue 和 NVIDIA DRA Driver。
集群使用 KubeClipper 创建。KubeClipper 1.6.0 默认支持 Kubernetes 1.36.1、containerd 2.2.4,和本文环境一致,详细步骤可以参考:KubeClipper 1.6.0 发布:kcctl 优化与 K8s 1.36 支持。
快速创建单节点集群的命令如下:
|
|
集群起来后确认版本:
|
|
GPU Driver、NVIDIA Container Toolkit 等基础组件使用 GPU Operator 安装。完整说明可以参考之前这篇:GPU 环境搭建指南:使用 GPU Operator 加速 Kubernetes GPU 环境搭建。
本文后面会安装 NVIDIA DRA Driver,所以安装 GPU Operator 时需要关闭 DevicePlugin:
|
|
--set devicePlugin.enabled=false:关闭 DevicePlugin,避免与后续安装的 DRA Driver 冲突。
安装完成后,确认 GPU Operator 组件正常运行:
|
|
再确认节点能看到 GPU:
|
|
Kueue 使用 0.18.1,安装方式和前两篇一样:
|
|
如果测试环境访问 registry.k8s.io 不稳定,也可以用 GitHub Release 里的 chart 包:
|
|
最后安装 NVIDIA DRA Driver 25.12.0:
|
|
安装完成后,先看 DRA Driver 组件:
|
|
再看 DeviceClass:
|
|
整卡调度用的是 gpu.nvidia.com。对应的 ResourceSlice 里能看到节点上的 T4:
|
|
后面的 Job 会直接引用 gpu.nvidia.com 这个 DeviceClass,实际可分配设备则来自这些 ResourceSlice。
DRA 和 Kueue 使用的资源名称并不是同一个。ResourceClaimTemplate 里写的是 deviceClassName: gpu.nvidia.com,而 ClusterQueue 里扣配额用的是资源名,所以这里需要做一次映射:
|
|
这段配置的意思是:只要 Workload 通过 ResourceClaimTemplate 申请 gpu.nvidia.com 这个 DeviceClass,Kueue 就把它折算成 nvidia.com/gpu 这个逻辑资源来扣配额。

修改 Kueue manager config 后,需要重启 kueue-controller-manager 让新配置生效。
这次只用一个队列,GPU 配额只给 1 张 T4。这样后面再提交第二个 Job 时,Pending 状态会看得很清楚。
|
|
注意这里的 coveredResources 里包含 nvidia.com/gpu。这是映射后的逻辑资源名,不是 Pod 里直接写的扩展资源。
使用 DRA 之后,Job 不再写 resources.limits.nvidia.com/gpu: 1,而是引用一个独立的 ResourceClaimTemplate。
这里 Kueue 做的事情很直接:读取 Workload 引用的 ResourceClaimTemplate,识别里面的 deviceClassName 和 count,再通过 deviceClassMappings 折算成 ClusterQueue 里的配额资源。
本文不展开 extended resource 路径,避免把两种 DRA 接入方式混在一起。
先创建 ResourceClaimTemplate:
|
|
这里有两个细节容易混:
deviceClassName 是 gpu.nvidia.com,不是 nvidia.com/gpu
ExactCount + count: 1 表示申请 1 张整卡再提交 Kueue 管理的 Job:
|
|
Job 被 Kueue 准入后,会从 Suspended 变成 Running:
|
|
ResourceClaim 已经分配:
|
|
容器里能看到 T4:
|
|
直接看 Workload 的准入结果:
|
|
这说明 deviceClassMappings 已经生效:用户写的是 ResourceClaimTemplate,Kueue 扣的是 nvidia.com/gpu 这个逻辑配额。
再看看 ClusterQueue,可以发现 GPU 配额已经被扣掉了:
|
|
队列里只有 1 张 GPU 配额。如果再提交一个同样申请 single-gpu 的 Job:
|
|
|
|
再看 Workload 状态,就能发现为什么第二个 Job 一直起不来:
|
|
ResourceClaimTemplate 里写的是:
|
|
ClusterQueue 里写的是:
|
|
两者靠 Kueue 配置里的 deviceClassMappings 关联起来。少了这段映射,Workload 会被标成 Inadmissible,原因类似:
|
|
这两个名字最容易看混:
single-gpu:前面单独创建的 ResourceClaimTemplate,定义“我要 1 张 gpu.nvidia.com 整卡”。gpu-claim:Pod 里的本地 claim 名字,后面容器通过它来使用 GPU。Pod 里这段不是重新定义一个模板,而是引用已经存在的 single-gpu 模板:
|
|
容器里再通过同一个 gpu-claim 关联到这次申请到的设备:
|
|
所以完整关系是:ResourceClaimTemplate(single-gpu) -> Pod resourceClaims(gpu-claim) -> container resources.claims(gpu-claim)。
这里要注意一点:Kueue 只是负责“准入”,真正把 GPU 分配给 Pod 的还是 kube-scheduler 和 DRA Driver。
所以生产环境里建议同时关注 ResourceClaim 状态和 Pod 状态。如果希望 Workload 在 Pod 长时间起不来时释放 Kueue 配额,可以结合 waitForPodsReady 做保护。
到这里可以看到,Kueue 并没有直接参与 GPU 分配,而是站在 Job 准入这一层,通过 deviceClassMappings 把 DRA 的设备申请转换成队列里的配额资源。这样既保留了 DRA 的设备模型,也让 GPU 可以继续纳入 Kueue 的统一配额管理。
| 组件 | 负责什么 |
|---|---|
| NVIDIA DRA Driver | 把 GPU 作为 gpu.nvidia.com DeviceClass / ResourceSlice 发布出来 |
| Kueue | 通过 deviceClassMappings 把 DRA 设备折算成 nvidia.com/gpu 配额 |
| kube-scheduler | 在 Pod 调度阶段完成 ResourceClaim 的实际设备分配 |
下一篇继续往前走一步,把 HAMi 引入进来:一张 GPU 被切成多份 vGPU 之后,Kueue 是否还能继续管理显存和算力配额。
2026-07-01 06:00:00

很多人刚接触 Kueue 时最大的困惑,不是 YAML 怎么写,而是看着一堆 CRD:ResourceFlavor、ClusterQueue、LocalQueue、Cohort、Workload,不知道它们之间到底是什么关系。本文不会逐个照着 API 文档介绍字段,而是把这五个对象放到同一条资源准入链路中,一次讲清楚它们各自负责什么、为什么要存在,以及它们之间如何协作。
上一篇文章中分析了 Kueue 的完整工作流:

涉及到多个核心对象,下面逐个拆开讲。
Kueue 要管资源,第一步得知道集群里有哪些"种类"的资源。集群里的 CPU、GPU 通常不是同构的:
ResourceFlavor 就是干这个的——给资源分个类,贴个标签,后面 ClusterQueue 按这个分类来管配额。
Kueue 在 Admission 阶段,会根据 ClusterQueue 中配置的 Flavor 顺序、Quota 是否满足、Flavor 是否匹配 Pod 等因素,选择一个可用的 ResourceFlavor。
如果集群资源是同构的,或者不需要为不同资源规格分别管理配额,那直接创建一个不包含任何标签或污点的空 ResourceFlavor 即可:
|
|
这类 ResourceFlavor 不承担节点筛选或污点控制的职责,仅作为统一的资源抽象存在,方便后续扩展不同资源类型。
|
|
新增参数:
spec.nodeLabels :通过 label 关联到对应的节点。具体实现上则是通过将 flavor 中 nodeLabels 部分自动注入到 Pod Spec 中的 nodeSelector,从而达到只将 Pod 调度到该 flavor 关联的节点上。这类 Flavor 实现了根据 label 将节点进行分类,算是名副其实。管理员先给节点打上对应 label 即可实现分类:
|
|
但也只是简单做了分类,不是该 Flavor 中的 Job 也能手动调度到这些 GPU 节点。比较推荐的做法是给节点再打上污点,这样就不是随便能调度上去了:
|
|
节点打上污点后,Flavor 中的 Job 也不能调度了。Kueue 提供了两种模式来解决这个问题:
|
|
新增参数:
spec.tolerations :声明该 flavor 关联节点上的污点容忍信息。Kueue 准入时自动注入到 Pod 的 .spec.template.spec.tolerations,确保 Pod 能容忍节点污点从而正常调度。
|
|
新增参数:
spec.nodeTaints :在 flavor 上定义准入门槛。Kueue 不会自动注入容忍度,Pod 必须自己带对应 toleration 才能通过准入、拿到配额。用户提交 Job 时必须自己带上 toleration,否则 Kueue 不批配额:
|
|
注意 :
spec.nodeTaints通常应与节点上的真实污点保持一致。否则可能出现 Pod 过了 Kueue 准入、拿到配额,但 K8s 调度器发现节点有真污点而 Pod 没对应 toleration,最终调度失败——白白占了配额。本质就是把调度层面的拦截提前到 Kueue 准入层面。
自动模式(spec.tolerations) |
手动模式(spec.nodeTaints) |
|
|---|---|---|
| 谁给 Pod 加 toleration | Kueue 自动注入 | 用户自己写 |
| 用户需要关心节点污点吗 | 不需要,提交 Job 就行 | 必须自己写 toleration |
| 用在哪 | 生产默认,对用户最友好 | 保护昂贵资源,强制用户显式声明 |
选型建议:优先用自动模式;只有当你需要强制用户显式声明才能使用某种昂贵资源时,才用手动模式。
一句话记住:ResourceFlavor 定义资源的属性(标签、污点、容忍等),真正决定资源配额和公平性的仍然是 ClusterQueue。
如果 ResourceFlavor 是给资源分类,那真正决定谁能用、用多少的是谁?答案就是 ClusterQueue。它才是 Kueue 配额管理的核心,定义了使用上限和公平共享规则。
一个基础的 ClusterQueue 示例如下:
|
|
字段解析:
spec.namespaceSelector :指定该 ClusterQueue 可以在哪些 namespace 被使用。当前 ClusterQueue 可以被任意 Namespace 使用。resourceGroups :资源组,定义 ClusterQueue 的资源额度,可以有多个,每个组都是独立的。
coveredResources :管哪些资源,同一个 group 里面的资源必须在同一个 flavor 里面分配。flavors :可以有多个,分配是按顺序尝试。
name :使用名称引用前面创建的 ResourceFlavor。resources :在这个 flavor 下,每种资源的配额上限。
nominalQuota :保底配额,表示该 ClusterQueue 的名义配额,优先保障自身使用;未使用的部分可以按照 borrowing/lending 规则被其他 Queue 借用。borrowingLimit :你最多能从 Cohort 里借入多少,所以你最多能用 nominalQuota + borrowingLimit。lendingLimit :你最多允许别人从你这借走多少(当你空闲时),需要时可通过预抢占收回。以 cpu 为例,基于上面三个配额字段,你可以使用的范围是 nominalQuota ~ (nominalQuota + borrowingLimit)。
在基础配置之上,ClusterQueue 还支持以下进阶字段,按用途分组:
|
|
BestEffortFIFO(默认) :前面的 Job 拿不到配额,后面的能插队先跑,资源利用率高。StrictFIFO :前面的 Job 拿不到配额,后面的必须等,适合有顺序依赖的 Pipeline。
|
|
cohortName :关联到一个 Cohort,同一个 Cohort 里的 ClusterQueue 可以互相共享资源。当 ClusterQueue 或其 Cohort 中没有足够的配额时,新进入的 Workload 可以触发预抢占,挤掉低优先级的 Workload。涉及两个配置项:
预抢占策略
|
|
withinClusterQueue :同队列内,当待处理 Workload 不适合配额时,是否可抢占同队列中的活动 Workload。Never(默认)不抢占;LowerPriority 仅抢占低优先级;LowerOrNewerEqualPriority 抢占低优先级或同优先级的。reclaimWithinCohort :是否可抢占 Cohort 中使用了超过其名义配额的 Workload。Never(默认)不抢占;LowerPriority 仅抢占低优先级;Any 可抢占任意优先级。borrowWithinCohort :当需要借用配额时是否触发抢占。Never(默认)不触发;LowerPriority 仅抢占 Cohort 中低优先级的 Workload(需同时启用 reclaimWithinCohort)。注意:只能配置经典抢占,不能与 Fair Sharing 一同使用。优先抢占还是借用
当 ClusterQueue 有多个 flavor 时,Kueue 按顺序尝试。当当前 flavor 配额用完时,你可以影响 Kueue 的行为:
whenCanBorrow :能从 Cohort 借的时候。MayStopSearch(默认):借了就停,不再试下一个;TryNextFlavor:不借,先试下一个 flavor。whenCanPreempt :能抢占低优先级 Job 的时候。TryNextFlavor(默认):先试下一个 flavor;MayStopSearch:不试了,直接抢占。
|
|
控制队列的运行状态,ClusterQueue 和 LocalQueue 都支持。
None(默认) :正常运行,新 Job 正常准入,已运行的不受影响。Hold :停止新的准入,已准入的不受影响,适合维护、配额调整等临时场景。HoldAndDrain :停止新的准入 + 触发已准入工作负载的驱逐,适合紧急情况需要立刻清场。维护完恢复为 None 或直接删掉这个字段即可。这是运维操作,不是常态配置。
|
|
一个包含所有字段的完整 ClusterQueue:
|
|
一句话记住:ClusterQueue 才是真正管配额和公平性的地方。 第一次看会和 LocalQueue 搞混,其实记住一句就够了——ClusterQueue 管资源,LocalQueue 管用户。
ClusterQueue 是集群级别的,但用户不能直接往里塞 Job。中间还需要一层 LocalQueue——它是一个命名空间对象,指向一个 ClusterQueue,作为用户提交 Job 的入口。
|
|
注意:
kubectl get queues是kubectl get localqueues的别名,更方便日常使用。
| 维度 | LocalQueue | ClusterQueue |
|---|---|---|
| 作用域 | 命名空间 | 集群 |
| 谁创建 | 团队自己 | 批处理管理员 |
| 职责 | 将同一租户的工作负载分组,指向一个 ClusterQueue | 管理资源池的配额和公平共享规则 |
| 一对一? | 多个 LocalQueue 可以指向同一个 ClusterQueue | — |
|
|
一句话记住:LocalQueue 就是 Namespace 访问 ClusterQueue 的入口。 它不管配额,只管"我这个 namespace 的 Job 往哪个 ClusterQueue 送"。
如果每个 ClusterQueue 只能用自己的保底配额,那 team-a 闲着 3 核、team-b 想多跑 1 核也借不到,资源就浪费了。Cohort 就是解决这个的——可以理解成一个"资源共享联盟",加进同一个 Cohort 的 ClusterQueue 可以互相借配额。
第一次看 Cohort 我也没太理解。后来发现,它本身还能定义 resourceGroups(共享配额池),这些资源是管理员额外划拨给整个联盟的公共池,而不是把各个 ClusterQueue 的 nominalQuota 自动汇总得到的。

|
|
上面的配置意味着:
nominalQuota(即使值为 0)Cohort 可以组织成树形结构(CohortTree),适合大型组织:
|
|
|
|
同一个 CohortTree 中的 ClusterQueue 可以使用其中的资源,遵循为 Cohort 和 ClusterQueue 指定的借用和借出限制。
一句话记住:Cohort 让多个 ClusterQueue 可以互相借资源。
前面四个都是"配置",那 Kueue 真正调度、准入的对象是什么?是 Workload——一个要运行至完成的应用,由一个或多个 Pod 组成。Kueue 的 Admission、Quota Accounting、Preemption 全是围绕它展开的。
通常你不需要手动创建 Workload,Kueue 会为每个 Job 自动创建。但理解它的结构有助于排查问题。
|
|
Workload 的优先级影响准入顺序,有两种设置方式:
batch/v1.Job,Kueue 根据 Job 的 Pod 模板的 Pod 优先级设置 Workload 的优先级
|
|
使用方式:在 Job 的 label 中指定:
|
|
Kueue 将 Workload 的总资源使用量计算为每个 podSet 资源请求的总和:
podSet 的资源使用量 = Pod 规格的资源请求 × count
Kueue 会根据 Limit Ranges 和 Runtime Class Overhead 调整资源使用量。
一句话记住:Workload 才是 Kueue 真正调度和准入的对象,前面四个对象都是给它配规则的。
理论讲完了,下面用一个 demo 把五大核心对象串起来,验证配额管控和 Cohort 借用能力。环境是一个单节点 K8s 集群(8 核 CPU)+ Kueue v0.18.1。
|
|
|
|
|
|
|
|
|
|
30 秒后 Job A 跑完,验证 Kueue 自动注入了 toleration 和 nodeSelector:
|
|
team-b 保底只有 2 核,要 3 核需要从 team-a 借 1 核空闲配额(此时 Job A 还在跑,用了 3 核,team-a 空闲 1 核)。 此时提交 JobB:
|
|
借用过程:team-a 保底 4 核用了 3 核,空闲 1 核 → team-b 保底 2 核不够(要 3 核)→ 从 team-a 借了 1 核 → 准入成功。
30 秒后 Job B 跑完,team-b 的 3 核配额归还。等 Job A 也跑完后,整个 Cohort 就空了:team-a 保底 4 核全空闲,team-b 保底 2 核全空闲,总共 6 核可借。
Job A、Job B 都已跑完,配额全部归还。现在 team-b 最多能用保底 2 + 借 3 = 5 核(team-a 空闲 4 核,足够借)。
|
|
结果:
|
|
|
|
结果:
|
|
| Job | 团队 | 请求 CPU | 保底 | 借用 | 结果 |
|---|---|---|---|---|---|
| Job A | team-a | 3 核 | 4 核 | 0 | ✅ 准入 |
| Job B | team-b | 3 核 | 2 核 | 借 1 核 | ✅ 准入 |
| Job D | team-b | 5 核 | 2 核 | 借 3 核 | ✅ 准入(到 borrowingLimit 上限) |
| Job E | team-b | 1 核 | 已满 | 无法借 | ❌ Pending |
5 个核心对象在这里全部串联起来了。
Kueue 的核心,其实不是“调度算法”,而是围绕 Workload 的准入与资源分配模型。
五个对象分别扮演了不同角色,但可以用一句话快速串起来:
第一次接触 Kueue 时,很容易把这些对象看成彼此独立的 CRD,但实际上它们共同组成了一条完整的资源准入链路:
|
|
理解了这条链路,再去看 Borrowing、Fair Sharing、Preemption 等高级能力,就会发现它们都只是围绕这套模型做扩展,而不是新的概念。

2026-06-25 06:00:00

多个团队共用一个 Kubernetes 集群,A 团队提交了一批训练任务,几十张 GPU 很快就被占满;B 团队新提交的 Job 只能一直 Pending。 因为,而是 Kubernetes 原生采用"先到先得"的调度方式,没有 Job 队列,也没有多租户配额管理。
Kueue 正是 Kubernetes 官方为此提供的解决方案。它不替换 kube-scheduler,只负责 Job 的排队和准入,在此基础上实现资源配额管理和公平调度。
Kueue 是 Kubernetes SIGs 维护的官方 Job 级队列管理系统,负责决定 Job 何时准入(Admit)、何时被驱逐(Preempt),核心目标是管理资源配额和多租户公平调度。
和 Volcano 最大的区别:Kueue 不替换 kube-scheduler,它只管"排队和准入",调度还是交给原生调度器。
|
|
多租户配额管理:通过 ClusterQueue 为不同团队划分资源配额
公平调度:基于 Dominant Resource Shares(DRS)的公平共享算法,防止资源被单一团队长期独占
队列排队:Job 提交后按优先级排队,配额不足时挂起等待
Cohort 借调:同一 Cohort 内的 ClusterQueue 可以借用彼此空闲配额,用完归还
弹性配额:支持 nominalQuota(保底)+ borrowingLimit(借用上限)+ lendingLimit(出借上限)三层额度控制
标准兼容:原生支持 K8s Job / JobSet / PyTorchJob / TFJob / RayJob 等,无需改 Job YAML
或者说 Kueue 解决了什么原生 K8s 解决不了的问题?
原生 Kubernetes 的资源管理是"先到先得",没有队列的概念:
| 场景 | 原生 Kubernetes | Kueue | 优势 |
|---|---|---|---|
| 配额管理 | ResourceQuota 粗粒度控制 | ClusterQueue 细粒度配额(按 GPU 型号/节点池划分) | 精确到资源类型和 Flavor |
| 多租户公平 | 先到先得,无法保障公平 | Fair Sharing 基于 DRS 的公平分配 | 防止资源独占 |
| Job 排队 | Pending 后只能等 | 支持优先级队列、FIFO 策略 | 按优先级有序调度 |
| 跨团队借调 | 不支持 | Cohort 机制,空闲配额自动借出、按需归还 | 提高集群利用率 |
举一个典型场景:公司有两个 AI 团队共享一个 GPU 集群,各有 50% 的 A100 配额。团队 A 没任务时,团队 B 可以借用全部 GPU;团队 A 提交任务后,Kueue 会通过 preemption 让团队 B 释放借用的资源,回到公平状态。
原生 K8s 做不到这一点。
| 维度 | Kueue | Volcano |
|---|---|---|
| 定位 | 队列 + 配额管理(轻量) | 批处理调度平台(重量) |
| 架构 | 不替换调度器,旁路管理 | 自定义调度器,替换 kube-scheduler |
| 归属 | Kubernetes SIGs 官方 | CNCF(华为发起) |
| 部署复杂度 | 一个 Controller | Scheduler + Controller + Admission 三个组件 |
| Gang Scheduling | 通过 All-or-Nothing with Ready Pods(超时机制) | 原生 Gang Scheduling |
| Job API | 标准 K8s Job(自动创建 Workload) | 自定义 VolcanoJob(vcjob) |
更多参考:Kueue 官方文档、Volcano 系列文章
在部署之前,先理解 Kueue 的 5 个核心对象:
|
|
ResourceFlavor:Flavor,代表不同类型的资源(如 A100 vs T4、Spot vs On-Demand),可以绑定 nodeLabels / taints
ClusterQueue:集群级队列,定义资源配额(nominalQuota / borrowingLimit / lendingLimit),是配额管理的核心
LocalQueue:命名空间级队列,用户直接和它打交道,指向一个 ClusterQueue
Cohort:ClusterQueue 的组,同组内可以互相借调空闲配额
Workload:Kueue 的调度单元,Kueue 会为每个 Job 自动创建对应的 Workload,用户一般不需要手动创建
一个 Job 从提交到运行,经过 Kueue 的完整流程:
|
|

关键点:Kueue 只负责"准入"决策,Pod 真正调度到哪个节点还是 kube-scheduler 决定。
Kueue 部署非常轻量,只需要一个 Controller。
Kueue 要求 Kubernetes 1.29+,本文使用 Kubernetes v1.36.1 部署最新的 Kueue v0.18.1。
官方提供了两种安装方式,推荐 Helm:
|
|
|
|
无法访问
registry.k8s.io可以从 GitHub 下载 chart 包安装:
1 2 3 4helm install kueue https://github.com/kubernetes-sigs/kueue/releases/download/v0.18.1/kueue-0.18.1.tgz \ --namespace kueue-system \ --create-namespace \ --wait --timeout 300s
|
|
|
|
|
|
一个 Pod 就搞定了,这就是 Kueue 轻量的地方。
接下来我们跑一个最小化 Demo:创建 ResourceFlavor → ClusterQueue → LocalQueue → 提交 Job,完整走一遍 Kueue 的工作流程。
Kueue 使用三种资源来管理作业排队:
|
|
|
|
|
|
|
|
队列创建成功,当前没有 Workload。
注意看,这里用的就是标准的 K8s Job,只需要加一个 label 就能接入 Kueue:
|
|
|
|
|
|
|
|
可以看到 Kueue 自动为 Job 创建了对应的 Workload,Workload 状态为 Admitted(已准入),3 个 Pod 正常运行。
|
|
|
|
Job 完成后,Workload 也会被标记为 Finished:
|
|
|
|
到这里,我们完整走了一遍 Kueue 的工作流程:创建队列 → 提交 Job → Kueue 自动准入 → Pod 运行 → 任务完成。
整个过程中,我们用的是标准的 K8s Job,唯一的变化就是加了一个 kueue.x-k8s.io/queue-name 标签。这就是 Kueue “不替换调度器、旁路管理"设计哲学的体现。
Kueue 是 Kubernetes SIGs 官方出品的 Job 队列与配额管理系统,它的核心设计哲学是”只管排队和准入,不动调度器",这让它非常轻量且对现有集群侵入性极小。
本文介绍了 Kueue 的背景、核心概念和部署方式,并通过一个最小化 Demo 走完了"创建队列 → 提交 Job → 自动准入 → 任务完成"的完整流程。
下一篇我们将深入解析 Kueue 的五大核心对象(ResourceFlavor / ClusterQueue / LocalQueue / Cohort / Workload),搞清楚它们各自的职责和配置细节。