MoreRSS

site iconLixueduan | 李学端修改

博客名:指月小筑。专注云原生,Go,坚持分享最佳实践、经验干货。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Lixueduan | 李学端的 RSS 预览

一个 Deployment 就能跑 vLLM,为什么还需要 KServe?

2026-07-29 04:00:00

KServe 入门:部署第一个 vLLM 推理服务

在 Kubernetes 上启动一个推理服务并不难。如果只有一个模型,vLLM + Deployment 确实够了。但服务多起来以后,模型从哪里加载、使用哪个 Runtime、GPU 怎么分配、服务怎么暴露,每个服务都要重复处理一遍,配置很快就会散落在一堆 YAML 里。KServe 解决的不是怎么启动 vLLM,而是怎么用统一的方式管理这些模型服务。

本文从零安装 KServe Standard 模式和 Envoy Gateway,通过本地 PVC 部署 Qwen2.5-0.5B-Instruct 模型。

KServe 是什么

KServe 官方给出的定位是:

Standardized Distributed Generative and Predictive AI Inference Platform for Scalable, Multi-Framework Deployment on Kubernetes.

KServe 是一个面向 Kubernetes 的可扩展、多框架部署的标准化分布式生成式和预测式 AI 推理平台

即:KServe 是一个 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:

1
2
kubectl get nodes \
 -o custom-columns=NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu

输出中的 GPU 应该大于 0。本文使用标准的 nvidia.com/gpu 资源;如果这里是 <none>,后面的推理 Pod 会一直处于 Pending

服务 API 与部署模式

这里需要分成两层理解。

InferenceService 是通用模型服务 API,它有 Standard 和 Knative 两种部署模式:

  • Standard:使用原生 Kubernetes Deployment、Service 和 Pod 承载模型服务,依赖少、链路清晰,可以通过 HPA 或 KEDA 扩缩容,适合传统模型以及常驻的 GPU、vLLM 推理服务。
  • Knative/Serverless:使用 Knative Serving 承载 InferenceService,支持 Scale to Zero、请求驱动扩缩容和流量切分,适合突发或不可预测的流量,但会增加 Knative Serving 及对应网络层依赖,部署和排障更加复杂。

LLMInferenceService 是另一套独立 API,面向大语言模型的分布式工作负载、Prefill/Decode 分离和智能路由等场景。它不是 InferenceService 的第三种部署模式;如果只是部署单 GPU vLLM,使用 InferenceService Standard 模式通常更简单。

本文后面的安装流程只覆盖 InferenceService。LLMInferenceService 需要额外安装 Addon 和相关依赖,系列最后一篇再单独展开。

可以根据需求选择:

1
2
3
4
5
6
是否需要高级 LLM 分布式推理能力?
├── 是 → LLMInferenceService
└── 否
 └── 是否需要 Scale to Zero、Revision 和请求驱动扩缩容?
 ├── 是 → InferenceService(Knative)
 └── 否 → InferenceService(Standard)

这里我们选择比较简单的 InferenceService(Standard) 模式进行演示。

安装 cert-manager

KServe 依赖 cert-manager,需要提前安装,支持的最低版本是 1.15.0。

1
2
3
4
5
6
7
8
helm repo add cert-manager https://charts.jetstack.io
helm repo update

helm upgrade --install cert-manager cert-manager/cert-manager \
 -n cert-manager --create-namespace \
 --version v1.21.0 \
 --set crds.enabled=true \
 --wait --timeout=10m

查看 Pod:

1
kubectl -n cert-manager get pod
1
2
3
4
NAME READY STATUS RESTARTS AGE
cert-manager-6c8f88f5b9-4b8zv 1/1 Running 0 25m
cert-manager-cainjector-8577b58487-r6cnt 1/1 Running 0 25m
cert-manager-webhook-7c7694dfd-df4ft 1/1 Running 0 25m

安装 Network Controller

KServe 可以通过 Gateway API 或 Ingress 暴露服务。本文使用 Gateway API,并选择 Envoy Gateway 作为具体实现。

安装 Envoy Gateway

使用 Helm 安装 Envoy Gateway,包括 Gateway API CRD 和 Controller:

1
2
3
4
5
6
helm upgrade --install eg \
 oci://docker.io/envoyproxy/gateway-helm \
 --version v1.8.2 \
 --namespace envoy-gateway-system \
 --create-namespace \
 --wait --timeout=10m

创建 Gateway

先创建 GatewayClass:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cat <<'EOF' > gatewayclass.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
 name: envoy
spec:
 controllerName: gateway.envoyproxy.io/gatewayclass-controller
EOF

kubectl apply -f gatewayclass.yaml

然后创建 Gateway。本文的测试集群没有 LoadBalancer,因此使用 EnvoyProxy 将 Envoy Service 配置为 NodePort;如果集群已经有可用的 LoadBalancer,可以删掉 EnvoyProxy 以及 Gateway 中的 parametersRef,直接使用默认配置。

 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
46
47
cat <<'EOF' > gateway.yaml
apiVersion: v1
kind: Namespace
metadata:
 name: kserve
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
 name: kserve-envoy-proxy
 namespace: kserve
spec:
 provider:
 type: Kubernetes
 kubernetes:
 envoyService:
 type: NodePort
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
 name: kserve-ingress-gateway
 namespace: kserve
spec:
 gatewayClassName: envoy
 listeners:
 - name: http
 protocol: HTTP
 port: 80
 allowedRoutes:
 namespaces:
 from: All
 infrastructure:
 labels:
 serving.kserve.io/gateway: kserve-ingress-gateway
 parametersRef:
 group: gateway.envoyproxy.io
 kind: EnvoyProxy
 name: kserve-envoy-proxy
EOF

kubectl apply -f gateway.yaml

kubectl wait --for=condition=Programmed \
 gateway/kserve-ingress-gateway \
 -n kserve \
 --timeout=5m

安装 KServe

  1. 安装 KServe CRD:
1
2
3
4
5
helm -n kserve install kserve-crd \
 oci://ghcr.io/kserve/charts/kserve-crd \
 --version v0.18.0 \
 --create-namespace \
 --wait --timeout=10m
  1. 安装 KServe Controller,使用 Standard + Gateway API:
1
2
3
4
5
6
7
8
helm -n kserve upgrade --install kserve \
 oci://ghcr.io/kserve/charts/kserve-resources \
 --version v0.18.0 \
 --set kserve.controller.deploymentMode=Standard \
 --set kserve.controller.gateway.disableIstioVirtualHost=true \
 --set kserve.controller.gateway.ingressGateway.enableGatewayApi=true \
 --set kserve.controller.gateway.ingressGateway.kserveGateway=kserve/kserve-ingress-gateway \
 --wait --timeout=10m
  1. 安装默认 ClusterServingRuntime:
1
2
3
4
5
6
helm -n kserve upgrade --install kserve-runtime-configs \
 oci://ghcr.io/kserve/charts/kserve-runtime-configs \
 --version v0.18.0 \
 --set kserve.servingruntime.enabled=true \
 --set kserve.llmisvcConfigs.enabled=false \
 --wait --timeout=10m

查看 HuggingFaceServer Runtime 使用的基础镜像:

1
2
kubectl get clusterservingruntime kserve-huggingfaceserver \
 -o jsonpath='{.spec.containers[0].image}{"\n"}'

这里配置的是 v0.18.0 基础标签;InferenceService 申请 NVIDIA GPU 后,KServe 会自动在标签后追加 -gpu,最终 Pod 使用的是 v0.18.0-gpu

查看 KServe Controller:

1
kubectl -n kserve get pod
1
2
NAME READY STATUS RESTARTS AGE
kserve-controller-manager-54bb5b957f-m82fd 2/2 Running 0 4m35s

使用

下载模型

KServe 支持从 Hugging Face、PVC、S3 等位置加载模型。由于当前环境不能稳定访问 Hugging Face,本文提前从 ModelScope 下载模型,再通过 PVC 挂载。

1
2
3
4
5
pip install modelscope

modelscope download \
 --model Qwen/Qwen2.5-0.5B-Instruct \
 --local-dir /opt/models/Qwen2.5-0.5B-Instruct

检查模型文件:

1
ls -lh /opt/models/Qwen2.5-0.5B-Instruct

创建 PV 和 PVC

本文只有一个 GPU 节点,因此使用 hostPath 静态 PV。生产环境更适合使用 CephFS、NFS、JuiceFS 等共享存储。

1
kubectl create namespace kserve-test
 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
cat <<'EOF' > qwen-model.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
 name: qwen-model
spec:
 capacity:
 storage: 3Gi
 accessModes:
 - ReadOnlyMany
 persistentVolumeReclaimPolicy: Retain
 storageClassName: ""
 hostPath:
 path: /opt/models/Qwen2.5-0.5B-Instruct
 type: Directory
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
 name: qwen-model
 namespace: kserve-test
spec:
 accessModes:
 - ReadOnlyMany
 storageClassName: ""
 volumeName: qwen-model
 resources:
 requests:
 storage: 3Gi
EOF

kubectl apply -f qwen-model.yaml

创建 InferenceService

然后部署 InferenceService:

 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
cat <<'EOF' > qwen-llm.yaml
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
 name: qwen-llm
 namespace: kserve-test
spec:
 predictor:
 model:
 modelFormat:
 name: huggingface
 storageUri: pvc://qwen-model
 args:
 - --model_name=qwen
 - --max_model_len=4096
 - --max-num-seqs=32
 - --gpu-memory-utilization=0.8
 resources:
 requests:
 cpu: "1"
 memory: 4Gi
 nvidia.com/gpu: "1"
 limits:
 cpu: "2"
 memory: 6Gi
 nvidia.com/gpu: "1"
EOF

kubectl apply -f qwen-llm.yaml

核心参数是这部分:

1
2
3
4
5
6
7
8
9
model:
 modelFormat:
 name: huggingface
 storageUri: pvc://qwen-model
 args:
 - --model_name=qwen
 - --max_model_len=4096
 - --max-num-seqs=32
 - --gpu-memory-utilization=0.8
  • model.modelFormat:声明模型格式,会用来匹配 Runtime。当前指定的 huggingface 会匹配到 ClusterServingRuntime/kserve-huggingfaceserver
  • storageUri:模型来源。当前 pvc://qwen-model 说明模型在 qwen-model 这个 PVC 里面。
  • args:传给 HuggingFaceServer 的启动参数。--model_name 设置 API 中的模型名;其余三个参数用于 vLLM,分别限制最大上下文长度、单次调度最多处理的序列数和 GPU 显存使用比例。

查看推理服务状态

1
2
3
4
5
6
7
kubectl wait --for=condition=Ready \
 inferenceservice/qwen-llm \
 -n kserve-test \
 --timeout=30m

kubectl get inferenceservice qwen-llm -n kserve-test
kubectl get pod,deploy,svc,httproute -n kserve-test

服务就绪后,READY 会变成 True

首次启动需要读取模型、初始化 CUDA 并完成 CUDA Graph 预热。普通环境通常只需要几分钟,但磁盘较慢的单节点环境可能需要十几分钟,因此这里将等待上限设置为 30 分钟。

1
2
NAME URL READY
qwen-llm http://qwen-llm-kserve-test.example.com True

Standard 模式下,KServe 会为这个 InferenceService 创建 Deployment、Service 和 HTTPRoute:

1
2
3
4
5
InferenceService/qwen-llm
├── Deployment/qwen-llm-predictor
├── Service/qwen-llm-predictor
├── HTTPRoute/qwen-llm
└── HTTPRoute/qwen-llm-predictor

KServe InferenceService 资源创建与请求链路

查看模型服务日志:

1
2
3
kubectl logs -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm \
 --tail=200

日志中出现下面的内容,说明 HuggingFaceServer 已经使用 vLLM 加载 /mnt/models

1
2
3
4
Initializing a V1 LLM engine (v0.19.0)
model='/mnt/models'
Using max model len 4096
Uvicorn running on http://0.0.0.0:8080

请求 API

KServe 创建的 HTTPRoute 使用域名匹配:

1
qwen-llm-kserve-test.example.com

如果没有配置 DNS,可以直接访问 Envoy 的 NodePort,同时手动设置 Host Header。

查看 Envoy Service:

1
2
kubectl get service -n envoy-gateway-system \
 -l gateway.envoyproxy.io/owning-gateway-name=kserve-ingress-gateway

NodePort 是动态分配的,先读取实际访问地址:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
ENVOY_SERVICE=$(kubectl get service -n envoy-gateway-system \
 -l gateway.envoyproxy.io/owning-gateway-name=kserve-ingress-gateway \
 -o jsonpath='{.items[0].metadata.name}')

NODE_PORT=$(kubectl get service -n envoy-gateway-system "$ENVOY_SERVICE" \
 -o jsonpath='{.spec.ports[?(@.port==80)].nodePort}')

NODE_IP=$(kubectl get node \
 -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')

echo "http://${NODE_IP}:${NODE_PORT}"

查看模型列表

1
2
curl -H "Host: qwen-llm-kserve-test.example.com" \
 "http://${NODE_IP}:${NODE_PORT}/openai/v1/models"

返回结果:

1
2
3
4
5
6
7
8
9
{
 "object": "list",
 "data": [
 {
 "id": "qwen",
 "object": "model"
 }
 ]
}

发送 Chat Completions 请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
curl -H "Host: qwen-llm-kserve-test.example.com" \
 -H "Content-Type: application/json" \
 "http://${NODE_IP}:${NODE_PORT}/openai/v1/chat/completions" \
 -d '{
 "model": "qwen",
 "messages": [
 {
 "role": "user",
 "content": "请用一句话介绍 Kubernetes。"
 }
 ],
 "max_tokens": 64,
 "temperature": 0.2
 }'

返回 chat.completion,说明下面这条链路已经打通:

1
2
NodePort -> Envoy Gateway -> HTTPRoute -> Service
 -> KServe HuggingFaceServer -> vLLM -> GPU

Qwen2.5-0.5B-Instruct 主要用于验证部署链路,模型规模很小,输出质量不能代表生产模型。

总结

到这里,我们已经用 KServe 跑通了一条完整的 LLM 推理链路,从本地 PVC 加载模型,再通过 Envoy Gateway 调用 OpenAI 兼容接口。

KServe 的价值不只是启动一个模型进程,而是把模型地址、Runtime、GPU 资源和访问入口收敛到 InferenceService 中,让推理服务也能沿用 Kubernetes 的声明式方式统一管理。

这也是理解 KServe 的起点。下一篇继续往下拆,看看一份 InferenceService YAML 提交之后,KServe 在集群里到底创建了什么。

我的博客,被 47 万次请求刷慢了

2026-07-19 04:00:00

我的博客,被 47 万次请求刷慢了

事情是这样的。

前几天有读者跟我反馈,说博客变慢了。

不是偶尔卡一下,是点开文章以后,文字出来了,图片还要在原地转上好几秒。

我自己试了几次,确实不对劲。更奇怪的是,前不久我才压缩过图片。

一个纯静态博客,为什么会突然慢成这样?

1. CPU 没忙,3M 带宽却满了

我第一反应是服务器扛不住了。

毕竟这就是一台很普通的小服务器,2C2G。真遇上请求高峰,被打得喘不过气也不奇怪。

结果我打开监控一看,CPU 使用率还不到 10%。

机器闲得很。

真正出问题的,是带宽。

我这台服务器的公网出口只有 3 Mbps。正常情况下,机器的流出带宽很低,只有 70.793 Kbit/s

正常情况下,公网流出带宽只有几十 Kbit/s

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

大量请求出现后,公网流出带宽达到 3.289 Mbit/s

70.793 Kbit/s3.289 Mbit/s,相差超过 46 倍。

Caddy 返回静态页面几乎不费 CPU,所以没有收到报警信息。但出口一旦被占满,正常读者再打开一张图片,就只能在后面排队。

顺着带宽曲线往下查,我发现 Caddy Access Log 最近轮转得比平时快了不少。

我一开始还没太当回事。博客嘛,被搜索引擎和 RSS 阅读器抓一抓很正常。

直到我把 27 天的轮转日志全部合到一起,才发现里面有一批访问频率异常的请求。

2. 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 阅读器,甚至某个读者连续打开很多文章,都可能制造高峰。还需要继续看这些请求的具体特征。

3. 从日志识别异常抓取

先看 IP。

27 天里一共出现了 75 个相关地址,单个 IP 通常只活跃 1~6 天,随后换成同一地址空间里的另一个。

再看 User-Agent。同一个 IP 在不到一秒内留下过这样的记录:

1
2
3
4
5
6
10:36:19.436 Chrome/127 /posts/kubernetes/58-dra-p4-my-dra-driver/
10:36:19.955 Chrome/110 /index.xml
10:36:20.029 Chrome/115 /tags/rtk/
10:36:20.075 Chrome/133 /posts/kubernetes/59-kubeclipper-release-1.6.0/
10:36:20.148 Chrome/120 /posts/cloudnative/01-metallb/
10:36:20.321 Chrome/109 /posts/kubernetes/57-dra-p3-workflow/

Chrome 100~144 一共 45 个版本全部出现,每个版本的请求量都在 9,441~10,045 次之间。排除每个 IP 的第一条记录后,97.77% 的相邻请求都会更换 User-Agent。

正常浏览器不会请求一次就换一个 Chrome 版本。

再看请求目标。这批地址主要请求 RSS 和文章 HTML,几乎不加载文章图片。

4. 从 IP 黑名单转向请求频率限流

我第一反应就是封 IP。

但对方 27 天换了 75 个 IP,逐个封禁只能短期止血,批量封禁又容易误伤正常用户。

继续判断请求来自谁没有太大意义,我只需要限制访问频率。

问题变成了,每分钟允许多少次,既能拦住抓取,又不影响正常读者?

为了避免阈值设得太紧,误伤正常访问,我先拿最近 24 小时的日志做了一次固定自然分钟回放:

阈值 回放结果
全部请求 60 次/分钟 命中 3 个主要异常 IP 和 1 个额外 IP
全部请求 120 次/分钟 只命中 3 个主要异常 IP
页面请求 30 次/分钟 命中 3 个主要异常 IP 和 1 个额外 IP
页面请求 60 次/分钟 只命中 3 个主要异常 IP

结合回放结果,我设置了两个独立限流区:

1
2
全部请求:120 次/分钟/IP
页面请求: 60 次/分钟/IP

页面请求同时计入两个区。HTML、分类、标签和 RSS 每分钟最多 60 次,CSS、JavaScript 等其他资源只受每分钟 120 次的兜底限制。

日志回放按固定自然分钟统计,后面使用的模块是滑动窗口,两者不会完全一致。这组阈值只对应当时的访问情况,后续还要根据 429 和误伤情况调整。

5. 在 Caddy 中启用 Rate Limit

Caddy v2.11.4 的官方标准二进制不包含 rate_limit,需要通过 xcaddy 构建自定义版本。本文使用的是第三方模块 mholt/caddy-ratelimit

为了以后换服务器还能复现,我固定了 Caddy、xcaddy 和插件提交:

1
2
3
4
5
go install github.com/caddyserver/xcaddy/cmd/[email protected]

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
 xcaddy build v2.11.4 \
 --with github.com/mholt/caddy-ratelimit@5625512f24f6f59d6f64fb3aafe5eecff0b286db

构建完成后确认模块已经包含在二进制中:

1
2
./caddy version
./caddy list-modules | grep -Fx http.handlers.rate_limit

限流配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
rate_limit {
 zone blog_all {
 key {remote_host}
 events 120
 window 1m
 ipv6_prefix 64
 }

 zone blog_pages {
 match {
 method GET HEAD
 path / /posts/* /tags/* /categories/* /about/* /index.xml
 }
 key {remote_host}
 events 60
 window 1m
 ipv6_prefix 64
 }

 log_key
}

将配置写入实际使用的 Caddyfile 后,先用新二进制校验配置,再替换当前版本并重启服务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
install -m 0755 ./caddy /usr/local/bin/caddy.new

/usr/local/bin/caddy.new validate \
 --config /etc/caddy/Caddyfile

cp /usr/local/bin/caddy \
 /usr/local/bin/caddy.$(date +%Y%m%d%H%M%S).bak

mv /usr/local/bin/caddy.new /usr/local/bin/caddy
systemctl restart caddyd
systemctl status caddyd --no-pager

只替换磁盘上的二进制还不够,正在运行的旧进程不会自动加载新模块,因此这里需要重启 Caddy。

配置生效后,我从同一出口 IP 并发请求页面:

1
2
3
4
seq 1 65 | xargs -P 10 -I {} \
 curl --http1.1 -s -o /dev/null -w '%{http_code}\n' \
 https://www.lixueduan.com/posts/ \
 | sort | uniq -c

超过窗口额度后,客户端开始收到 429:

1
2
3
4
HTTP/1.1 429 Too Many Requests
Retry-After: 58
Server: Caddy
Content-Length: 0

看到 429 后,可以确认限流配置已经生效。

6. 限流上线后的同一时段

限流在 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 来源以及搜索引擎是否被误伤。

7. 总结

文章发出来,本来就是给大家看的。有人搜索、订阅,甚至批量抓取文章,我其实都无所谓。我介意的是,持续的高频请求已经影响到正常读者打开文章和图片。

120 次/分钟60 次/分钟 不是通用答案,只是我根据这次日志回放选出的起点,后面还要继续观察是否存在误伤。

限流只是控制访问节奏,让真正来看文章的人能够顺畅打开页面。

Kueue + HAMi vGPU 实战:显存与算力配额管理

2026-07-15 06:00:00

Kueue + HAMi vGPU 实战:显存与算力配额管理

上一篇我们用 Kueue + NVIDIA 原生 DRA 跑通了 GPU 整卡配额:Job 先进入队列,Kueue 判断配额够不够,够了再放行给调度器。

但整卡只是第一步。真实的 GPU 集群里,一张 GPU 往往不会只给一个 Pod 用,HAMi 可以继续按显存和算力切成 vGPU。问题也跟着来了:切完之后,队列系统还能不能知道每个任务用了多少?能不能做到两个任务放行、第三个因为显存或算力配额不够继续排队?

这篇就围绕这个问题跑一遍:HAMi 把 GPU 切给 Pod,Kueue 在 Job 准入阶段先把 vGPU、显存和算力配额算清楚。重点不是“能不能切卡”,而是“切完之后还能不能管起来”

1. 环境准备

环境基本沿用上一篇 Kueue + NVIDIA DRA 的测试环境。区别只有两点:

  • GPU Operator 关闭 DevicePlugin 安装
  • 安装 HAMi 开启 DRA 模式

本文的命令和输出来自这套环境:

1
2
3
4
5
6
7
节点:lixd-test-gpu
GPU:1 x Tesla T4,15Gi 显存
K8s:1.36.1
containerd:2.2.4
Kueue:0.18.1
HAMi:2.9.0
HAMi Chart:2.9.0

K8s 集群、GPU Operator、Kueue 的安装过程前几篇已经写过,这里不再重复。需要确认的一点是:后面由 HAMi 接管 GPU 切分,所以 GPU Operator 安装时要关闭 NVIDIA DevicePlugin:

1
--set devicePlugin.enabled=false

节点上能看到 T4 即可:

1
2
$ kubectl get node lixd-test-gpu -o jsonpath='{.metadata.labels.nvidia\.com/gpu\.product}{"\n"}'
Tesla-T4

1.1 安装 HAMi

HAMi DRA Webhook 需要 TLS 证书,因此需要提前安装 cert-manager 用于自动签发。

1
2
3
4
5
6
helm repo add cert-manager https://charts.jetstack.io
helm repo update

helm install cert-manager cert-manager/cert-manager \
 -n cert-manager --create-namespace \
 --set crds.enabled=true

给节点打上 gpu=on 标签。未标记的节点不会被 HAMi 接管。

1
2
# kubectl label nodes {nodeid} gpu=on
kubectl label nodes lixd-test-gpu gpu=on --overwrite

安装 HAMi,并通过 --set dra.enabled=true 开启 DRA 模式:

1
2
3
4
5
6
7
8
helm repo add hami-charts https://project-hami.github.io/HAMi/
helm repo update

helm -n hami-system install hami hami-charts/hami \
 --version 2.9.0 \
 --set dra.enabled=true \
 --set devicePlugin.enabled=false \
 --create-namespace

注意:DRA 模式与传统模式不兼容,请勿同时启用。

gpu=on 主要给 HAMi 做节点选择。后面 ResourceFlavor 用的 nvidia.com/gpu.product=Tesla-T4,则是 GPU Operator / GFD 自动打到节点上的标签。

另外,如果 GPU 驱动是主机预装,非 GPU Operator 安装,则安装时需额外指定:

1
--set hami-dra.drivers.nvidia.containerDriver=false

1.2 验证 HAMi 已接管 GPU

HAMi 正常启动后,hami-system 下会看到三个核心组件:

1
2
3
4
5
$ kubectl get pods -n hami-system
NAME READY STATUS
hami-dra-driver-kubelet-plugin-fb4zm 1/1 Running
hami-hami-dra-monitor-7b8df84bd-jsjrd 1/1 Running
hami-hami-dra-webhook-7bb65cbcc5-g5742 1/1 Running

底层发布出来的 GPU 资源也要确认一下:

1
2
3
4
5
6
$ kubectl get deviceclass,resourceslice
NAME AGE
deviceclass.resource.k8s.io/hami-core-gpu.project-hami.io 32m

NAME NODE DRIVER
resourceslice.resource.k8s.io/lixd-test-gpu-hami-core-gpu.project-hami.io-2drs2 lixd-test-gpu hami-core-gpu.project-hami.io

ResourceSlice 里记录了这张 T4 的显存和算力容量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
spec:
 devices:
 - allowMultipleAllocations: true
 attributes:
 productName:
 string: Tesla T4
 type:
 string: hami-gpu
 capacity:
 cores:
 value: "100"
 memory:
 value: 15Gi
 name: hami-gpu-0
 driver: hami-core-gpu.project-hami.io

需要注意的是 allowMultipleAllocations: true:同一张物理 GPU 可以被多个 ResourceClaim 消费,只要显存和算力容量还够,切卡才有空间。

2. 资源模型:为什么选择兼容模式

对 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 资源:

1
2
3
4
5
resources:
 limits:
 nvidia.com/gpu: 1
 nvidia.com/gpumem: 4096
 nvidia.com/gpucores: 50

含义是:

资源 含义
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 设备,以及对应的显存、算力容量。

3. Demo:Kueue 管理 HAMi vGPU 配额

这里用一个最小例子把链路跑通。先验证 HAMi 能不能切出 4Gi / 50 cores;确认没有问题以后,再接入 Kueue,观察它会不会在准入阶段扣配额。

3.1 先跑通 HAMi vGPU

为了把变量降到最低,先不接 Kueue,直接提交一个 Pod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
apiVersion: v1
kind: Pod
metadata:
 name: hami-compatible-smoke
spec:
 restartPolicy: Never
 containers:
 - name: cuda
 image: nvcr.io/nvidia/cuda:12.6.0-base-ubuntu24.04
 command: ["bash", "-c", "nvidia-smi && sleep 3600"]
 resources:
 limits:
 nvidia.com/gpu: 1
 nvidia.com/gpumem: 4096
 nvidia.com/gpucores: 50

Pod 创建后,HAMi webhook 会把原始资源申请改成 ResourceClaim

1
2
3
4
5
6
7
8
9
spec:
 containers:
 - name: cuda
 resources:
 claims:
 - name: hami-kueue-demo-hami-compatible-smoke-cuda
 resourceClaims:
 - name: hami-kueue-demo-hami-compatible-smoke-cuda
 resourceClaimName: hami-kueue-demo-hami-compatible-smoke-cuda

对应的 ResourceClaim 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
spec:
 devices:
 requests:
 - exactly:
 allocationMode: ExactCount
 capacity:
 requests:
 cores: "50"
 memory: "4294967296"
 count: 1
 deviceClassName: hami-core-gpu.project-hami.io
 name: gpu
status:
 allocation:
 devices:
 results:
 - consumedCapacity:
 cores: "50"
 memory: 4Gi
 device: hami-gpu-0
 driver: hami-core-gpu.project-hami.io

容器里看到的显存也变成了 4096Mi:

1
2
$ kubectl exec hami-compatible-smoke -- nvidia-smi
| 0 Tesla T4 ... | 0MiB / 4096MiB | 0% Default |

到这里可以确认 HAMi vGPU 已经生效:业务 YAML 仍然写 nvidia.com/gpu/gpumem/gpucores,容器里实际只看到被切分后的 4096Mi 显存。

3.2 让 Kueue 能看懂 gpumem/gpucores

HAMi 验证通过后,就可以把 Kueue 接进来了。接入前需要先把配额口径捋清楚:HAMi 的 nvidia.com/gpumemnvidia.com/gpucores 是“每个 vGPU”的资源。比如这次 Demo 里有两个相同规格的 Job,每个 Job 都申请:

1
2
3
nvidia.com/gpu: 1
nvidia.com/gpumem: 4096
nvidia.com/gpucores: 50

那么队列整体在配额上应该按总量扣减:

1
2
3
vGPU 个数:2
总显存:2 x 4096Mi = 8192Mi
总算力:2 x 50 = 100

Kueue 管队列配额时应该看总量,所以要在 Kueue 配置里加 ResourceTransformation。这次用的是 Kueue 0.18.1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: config.kueue.x-k8s.io/v1beta2
kind: Configuration
integrations:
 frameworks:
 - batch/job
resources:
 transformations:
 - input: nvidia.com/gpumem
 strategy: Replace
 outputs:
 nvidia.com/total-gpumem: 1
 multiplyBy: nvidia.com/gpu
 - input: nvidia.com/gpucores
 strategy: Replace
 outputs:
 nvidia.com/total-gpucores: 1
 multiplyBy: nvidia.com/gpu

这几行最关键的是两个动作:

  • Workload 里不再直接统计 nvidia.com/gpumem
  • 改成统计 nvidia.com/total-gpumem
  • gpucores 同理,转成 nvidia.com/total-gpucores
  • multiplyBy: nvidia.com/gpu 表示先乘以 vGPU 个数,再进入 Kueue 配额

修改 kueue-manager-config 后重启 Kueue:

1
2
kubectl rollout restart deployment/kueue-controller-manager -n kueue-system
kubectl rollout status deployment/kueue-controller-manager -n kueue-system

3.3 配置 Kueue 队列

这次只用一个队列,把资源扣减先看清楚。

场景:

1
2
3
4
5
6
7
8
物理资源:1 x Tesla T4,15Gi 显存
队列配额:最多准入 2 个 vGPU、8192Mi 显存、100 cores
单个 Job:1 个 vGPU、4Gi 显存、50 cores

预期:
第 1 个 Job:准入
第 2 个 Job:准入
第 3 个 Job:配额不够,继续 Suspended

Kueue 配置:

 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
apiVersion: v1
kind: Namespace
metadata:
 name: hami-kueue-demo
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
 name: hami-t4
spec:
 nodeLabels:
 nvidia.com/gpu.product: Tesla-T4
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: hami-cq
spec:
 namespaceSelector: {}
 resourceGroups:
 - coveredResources:
 - nvidia.com/gpu
 - nvidia.com/total-gpumem
 - nvidia.com/total-gpucores
 flavors:
 - name: hami-t4
 resources:
 - name: nvidia.com/gpu
 nominalQuota: 2
 - name: nvidia.com/total-gpumem
 nominalQuota: 8192
 - name: nvidia.com/total-gpucores
 nominalQuota: 100
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
 namespace: hami-kueue-demo
 name: hami-queue
spec:
 clusterQueue: hami-cq

这里的显存配额写 8192,和业务侧的 nvidia.com/gpumem: 4096 保持同一个口径:单位都是 Mi。单个 Job 会被 Kueue 统计成 nvidia.com/total-gpumem: 4096,两个相同 Job 加起来就是 8192

3.4 提交 Kueue 管理的 HAMi Job

提交一个 4Gi / 50 cores 的 Job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: batch/v1
kind: Job
metadata:
 namespace: hami-kueue-demo
 name: hami-kueue-running-a
 labels:
 kueue.x-k8s.io/queue-name: hami-queue
spec:
 suspend: true
 parallelism: 1
 completions: 1
 template:
 spec:
 restartPolicy: Never
 containers:
 - name: cuda
 image: nvcr.io/nvidia/cuda:12.6.0-base-ubuntu24.04
 command: ["bash", "-c", "nvidia-smi && echo HAMI_KUEUE_RUNNING_A && sleep 3600"]
 resources:
 limits:
 nvidia.com/gpu: 1
 nvidia.com/gpumem: 4096
 nvidia.com/gpucores: 50

结果:

1
2
3
4
5
6
7
8
9
$ kubectl get job,pod,workload
NAME STATUS COMPLETIONS
job.batch/hami-kueue-running-a Running 0/1

NAME READY STATUS
pod/hami-kueue-running-a-dzjrz 1/1 Running

NAME QUEUE RESERVED IN ADMITTED
workload.kueue.x-k8s.io/job-hami-kueue-running-a-59997 hami-queue hami-cq True

看 Workload 的准入结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
status:
 admission:
 clusterQueue: hami-cq
 podSetAssignments:
 - count: 1
 flavors:
 nvidia.com/gpu: hami-t4
 nvidia.com/total-gpucores: hami-t4
 nvidia.com/total-gpumem: hami-t4
 resourceUsage:
 nvidia.com/gpu: "1"
 nvidia.com/total-gpucores: "50"
 nvidia.com/total-gpumem: "4096"

这个结果就是 Kueue 介入后的关键变化:Pod 还没进入调度阶段,Workload 已经先按 vGPU 个数、总显存、总算力扣了一次配额。

HAMi 生成的 ResourceClaim 也可以对上:

1
2
3
4
5
6
7
8
9
status:
 allocation:
 devices:
 results:
 - consumedCapacity:
 cores: "50"
 memory: 4Gi
 device: hami-gpu-0
 driver: hami-core-gpu.project-hami.io

容器里也能看到 4096Mi 显存上限:

1
2
$ kubectl exec hami-kueue-running-a-dzjrz -- nvidia-smi
| 0 Tesla T4 ... | 0MiB / 4096MiB | 0% Default |

3.5 验证配额阻塞

继续提交两个相同规格的 Job:

1
2
3
hami-kueue-running-a:1 vGPU + 4096Mi + 50 cores
hami-kueue-running-b:1 vGPU + 4096Mi + 50 cores
hami-kueue-pending-c:1 vGPU + 4096Mi + 50 cores

队列总配额是:

1
2 vGPU + 8192Mi + 100 cores

因此第 2 个 Job 可以准入,第 3 个 Job 会继续留在队列里:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ kubectl get job,workload
NAME STATUS
job.batch/hami-kueue-running-a Running
job.batch/hami-kueue-running-b Running
job.batch/hami-kueue-pending-c Suspended

NAME QUEUE RESERVED IN ADMITTED
workload.kueue.x-k8s.io/job-hami-kueue-running-a-59997 hami-queue hami-cq True
workload.kueue.x-k8s.io/job-hami-kueue-running-b-9d737 hami-queue hami-cq True
workload.kueue.x-k8s.io/job-hami-kueue-pending-c-d854d hami-queue

ClusterQueue 的用量已经打满:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
status:
 admittedWorkloads: 2
 flavorsUsage:
 - name: hami-t4
 resources:
 - name: nvidia.com/gpu
 total: "2"
 - name: nvidia.com/total-gpucores
 total: "100"
 - name: nvidia.com/total-gpumem
 total: "8192"
 pendingWorkloads: 1

Pending Workload 里能看到原因:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
status:
 conditions:
 - reason: Pending
 message: 'couldn''t assign flavors to pod set main: insufficient unused quota
 for nvidia.com/gpu in flavor hami-t4, 1 more needed, insufficient unused quota
 for nvidia.com/total-gpucores in flavor hami-t4, 50 more needed'
 resourceRequests:
 - resources:
 nvidia.com/gpu: "1"
 nvidia.com/total-gpucores: "50"
 nvidia.com/total-gpumem: "4096"

这也是我更关心的点:不是等 Pod 到调度阶段才 Pending,而是在 Job 准入阶段就把超配额任务留在队列里。

4. 容易踩的坑

不要写小数 GPU

第一个坑是小数 GPU。不要写 nvidia.com/gpu: "0.5"

Kubernetes 扩展资源必须是整数,而且 GPU 这类不可超卖资源要写在 limits 里。正确写法是:

1
2
3
4
5
resources:
 limits:
 nvidia.com/gpu: 1
 nvidia.com/gpumem: 4096
 nvidia.com/gpucores: 50

半张卡 这种说法在 HAMi 里应该理解成:1 个 vGPU 实例 + 部分显存 + 部分算力,不是 0.5nvidia.com/gpu

Kueue 需要做资源转换

只把 nvidia.com/gpu 放进 Kueue,只能限制 vGPU 个数,管不了显存和算力。

要把 HAMi 的切分资源纳入 Kueue 配额,需要做这两个转换:

1
2
nvidia.com/gpumem -> nvidia.com/total-gpumem
nvidia.com/gpucores -> nvidia.com/total-gpucores

ClusterQueue 里也要同时配置这三个资源:

1
2
3
4
coveredResources:
- nvidia.com/gpu
- nvidia.com/total-gpumem
- nvidia.com/total-gpucores

5. 小结

这一篇跑完后,Kueue 系列基本就从 CPU 队列一路串到了 GPU 整卡和 HAMi vGPU。真落到 GPU 集群里,光能切卡还不够,准入和配额也一样重要: GPU 虚拟化解决的是"怎么切",Kueue 解决的是"谁先用、谁能用多少"。两者结合起来,GPU 集群才真正具备了多租户管理能力

Kueue 如何管理 DRA 模式下的 GPU 配额

2026-07-02 06:00:00

kueue-p3-dra.jpg

前面两篇: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 能解决什么问题?从部署到使用的完整体验

1. 环境准备

环境准备分几段走:先创建 K8s 集群,再用 GPU Operator 把 GPU Driver / Container Runtime 准备好,最后装 Kueue 和 NVIDIA DRA Driver。

1.1 创建 Kubernetes 集群

集群使用 KubeClipper 创建。KubeClipper 1.6.0 默认支持 Kubernetes 1.36.1、containerd 2.2.4,和本文环境一致,详细步骤可以参考:KubeClipper 1.6.0 发布:kcctl 优化与 K8s 1.36 支持

快速创建单节点集群的命令如下:

1
2
3
curl -sfL https://oss.kubeclipper.io/get-kubeclipper.sh | KC_REGION=cn bash -
kcctl deploy
kcctl create cluster --name demo --master <NODE_IP> --untaint-master

集群起来后确认版本:

1
2
3
$ kubectl get node -owide
NAME STATUS ROLES VERSION CONTAINER-RUNTIME
lixd-test-gpu Ready control-plane v1.36.1 containerd://2.2.4

1.2 GPU 环境准备(GPU Operator)

GPU Driver、NVIDIA Container Toolkit 等基础组件使用 GPU Operator 安装。完整说明可以参考之前这篇:GPU 环境搭建指南:使用 GPU Operator 加速 Kubernetes GPU 环境搭建

本文后面会安装 NVIDIA DRA Driver,所以安装 GPU Operator 时需要关闭 DevicePlugin:

1
2
3
4
5
6
7
8
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia && helm repo update

helm upgrade --install --wait gpu-operator \
 -n gpu-operator --create-namespace \
 nvidia/gpu-operator \
 --version=v26.3.1 \
 --set driver.enabled=true \
 --set devicePlugin.enabled=false

--set devicePlugin.enabled=false:关闭 DevicePlugin,避免与后续安装的 DRA Driver 冲突。

安装完成后,确认 GPU Operator 组件正常运行:

1
2
3
4
5
$ kubectl get pods -n gpu-operator
NAME READY STATUS
gpu-operator-7bcbd487f5-h8dsd 1/1 Running
nvidia-container-toolkit-daemonset-zbpbv 1/1 Running
nvidia-operator-validator-8l2r5 1/1 Running

再确认节点能看到 GPU:

1
2
$ kubectl get node lixd-test-gpu -o jsonpath='{.metadata.labels.nvidia\.com/gpu\.product}{"\n"}'
Tesla-T4

1.3 安装 Kueue

Kueue 使用 0.18.1,安装方式和前两篇一样:

1
2
3
4
5
helm install kueue oci://registry.k8s.io/kueue/charts/kueue \
 --version=0.18.1 \
 --namespace kueue-system \
 --create-namespace \
 --wait --timeout 300s

如果测试环境访问 registry.k8s.io 不稳定,也可以用 GitHub Release 里的 chart 包:

1
2
3
4
5
helm 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

1.4 接入 NVIDIA DRA Driver

最后安装 NVIDIA DRA Driver 25.12.0:

1
2
3
4
5
6
7
helm upgrade --install nvidia-dra-driver-gpu \
 nvidia/nvidia-dra-driver-gpu \
 --version 25.12.0 \
 -n nvidia-dra-driver-gpu \
 --set gpuResourcesEnabledOverride=true \
 --create-namespace \
 --wait --timeout 300s

安装完成后,先看 DRA Driver 组件:

1
2
3
4
$ kubectl get pods -n nvidia-dra-driver-gpu
NAME READY STATUS
nvidia-dra-driver-gpu-controller-76864c66cb-s8tch 1/1 Running
nvidia-dra-driver-gpu-kubelet-plugin-grkbg 2/2 Running

再看 DeviceClass:

1
2
3
4
5
6
7
$ kubectl get deviceclass
NAME AGE
compute-domain-daemon.nvidia.com 2m47s
compute-domain-default-channel.nvidia.com 2m47s
gpu.nvidia.com 2m47s
mig.nvidia.com 2m47s
vfio.gpu.nvidia.com 2m47s

整卡调度用的是 gpu.nvidia.com。对应的 ResourceSlice 里能看到节点上的 T4:

1
2
3
$ kubectl get resourceslice
NAME NODE DRIVER POOL
lixd-test-gpu-gpu.nvidia.com-p8cdq lixd-test-gpu gpu.nvidia.com lixd-test-gpu

后面的 Job 会直接引用 gpu.nvidia.com 这个 DeviceClass,实际可分配设备则来自这些 ResourceSlice。

2. Demo:使用 Kueue 调度 DRA 模式下的 GPU

2.1 修改 Kueue DRA 配置

DRA 和 Kueue 使用的资源名称并不是同一个。ResourceClaimTemplate 里写的是 deviceClassName: gpu.nvidia.com,而 ClusterQueue 里扣配额用的是资源名,所以这里需要做一次映射:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
apiVersion: config.kueue.x-k8s.io/v1beta2
kind: Configuration
integrations:
 frameworks:
 - batch/job
resources:
 deviceClassMappings:
 - name: nvidia.com/gpu
 deviceClassNames:
 - gpu.nvidia.com

这段配置的意思是:只要 Workload 通过 ResourceClaimTemplate 申请 gpu.nvidia.com 这个 DeviceClass,Kueue 就把它折算成 nvidia.com/gpu 这个逻辑资源来扣配额。

deviceClassMappings 将 DRA DeviceClass 映射为 Kueue 配额资源

修改 Kueue manager config 后,需要重启 kueue-controller-manager 让新配置生效。

2.2 创建队列

这次只用一个队列,GPU 配额只给 1 张 T4。这样后面再提交第二个 Job 时,Pending 状态会看得很清楚。

 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
apiVersion: v1
kind: Namespace
metadata:
 name: p3-dra-demo
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
 name: p3-t4
spec:
 nodeLabels:
 nvidia.com/gpu.product: Tesla-T4
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: p3-dra-cq
spec:
 namespaceSelector: {}
 resourceGroups:
 - coveredResources:
 - cpu
 - memory
 - nvidia.com/gpu
 flavors:
 - name: p3-t4
 resources:
 - name: cpu
 nominalQuota: "8"
 - name: memory
 nominalQuota: 16Gi
 - name: nvidia.com/gpu
 nominalQuota: 1
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
 name: p3-dra-queue
 namespace: p3-dra-demo
spec:
 clusterQueue: p3-dra-cq

注意这里的 coveredResources 里包含 nvidia.com/gpu。这是映射后的逻辑资源名,不是 Pod 里直接写的扩展资源。

2.3 用 ResourceClaimTemplate 申请整卡

使用 DRA 之后,Job 不再写 resources.limits.nvidia.com/gpu: 1,而是引用一个独立的 ResourceClaimTemplate

这里 Kueue 做的事情很直接:读取 Workload 引用的 ResourceClaimTemplate,识别里面的 deviceClassNamecount,再通过 deviceClassMappings 折算成 ClusterQueue 里的配额资源。

本文不展开 extended resource 路径,避免把两种 DRA 接入方式混在一起。

先创建 ResourceClaimTemplate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
 name: single-gpu
 namespace: p3-dra-demo
spec:
 spec:
 devices:
 requests:
 - name: gpu
 exactly:
 deviceClassName: gpu.nvidia.com
 allocationMode: ExactCount
 count: 1

这里有两个细节容易混:

  • deviceClassNamegpu.nvidia.com,不是 nvidia.com/gpu
  • ExactCount + count: 1 表示申请 1 张整卡

再提交 Kueue 管理的 Job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: batch/v1
kind: Job
metadata:
 name: kueue-dra-running-a
 namespace: p3-dra-demo
 labels:
 kueue.x-k8s.io/queue-name: p3-dra-queue
spec:
 parallelism: 1
 completions: 1
 suspend: true
 template:
 spec:
 resourceClaims:
 - name: gpu-claim
 resourceClaimTemplateName: single-gpu
 restartPolicy: Never
 containers:
 - name: cuda-test
 image: nvcr.io/nvidia/cuda:12.6.0-base-ubuntu24.04
 command: ["bash", "-c", "nvidia-smi -L && echo KUEUE_DRA_RUNNING_A && sleep 3600"]
 resources:
 claims:
 - name: gpu-claim

Job 被 Kueue 准入后,会从 Suspended 变成 Running

1
2
3
4
5
6
7
8
9
$ kubectl get job,pod,workload -n p3-dra-demo
NAME STATUS COMPLETIONS
job.batch/kueue-dra-running-a Running 0/1

NAME READY STATUS
pod/kueue-dra-running-a-kj7nw 1/1 Running

NAME QUEUE RESERVED IN ADMITTED
workload.kueue.x-k8s.io/job-kueue-dra-running-a-c3b3c p3-dra-queue p3-dra-cq True

ResourceClaim 已经分配:

1
2
3
$ kubectl get resourceclaim -n p3-dra-demo
NAME STATE
kueue-dra-running-a-kj7nw-gpu-claim-c6j4j allocated,reserved

容器里能看到 T4:

1
2
3
$ kubectl logs -n p3-dra-demo -l job-name=kueue-dra-running-a
GPU 0: Tesla T4 (UUID: GPU-6d8fdeb7-6247-e04e-81a7-7b42dc45180c)
KUEUE_DRA_RUNNING_A

2.4 看 Kueue 是否真的扣了 GPU 配额

直接看 Workload 的准入结果:

1
2
3
4
5
6
7
8
9
status:
 admission:
 clusterQueue: p3-dra-cq
 podSetAssignments:
 - count: 1
 flavors:
 nvidia.com/gpu: p3-t4
 resourceUsage:
 nvidia.com/gpu: "1"

这说明 deviceClassMappings 已经生效:用户写的是 ResourceClaimTemplate,Kueue 扣的是 nvidia.com/gpu 这个逻辑配额。

再看看 ClusterQueue,可以发现 GPU 配额已经被扣掉了:

1
2
3
4
5
6
7
status:
 admittedWorkloads: 1
 flavorsUsage:
 - name: p3-t4
 resources:
 - name: nvidia.com/gpu
 total: "1"

2.5 第二个整卡 Job 会被 Kueue 卡住

队列里只有 1 张 GPU 配额。如果再提交一个同样申请 single-gpu 的 Job:

1
2
kueue-dra-running-a:1 x gpu.nvidia.com
kueue-dra-pending-b:1 x gpu.nvidia.com
1
2
3
4
5
6
7
8
$ kubectl get job,workload -n p3-dra-demo
NAME STATUS
job.batch/kueue-dra-running-a Running
job.batch/kueue-dra-pending-b Suspended

NAME QUEUE RESERVED IN ADMITTED
workload.kueue.x-k8s.io/job-kueue-dra-running-a-c3b3c p3-dra-queue p3-dra-cq True
workload.kueue.x-k8s.io/job-kueue-dra-pending-b-494a3 p3-dra-queue

再看 Workload 状态,就能发现为什么第二个 Job 一直起不来:

1
2
3
4
5
6
7
8
9
status:
 conditions:
 - reason: Pending
 message: 'couldn''t assign flavors to pod set main: insufficient unused quota
 for nvidia.com/gpu in flavor p3-t4, 1 more needed'
 resourceRequests:
 - name: main
 resources:
 nvidia.com/gpu: "1"

3. 几个容易踩坑的点

DeviceClass 和配额资源不是同一个东西

ResourceClaimTemplate 里写的是:

1
deviceClassName: gpu.nvidia.com

ClusterQueue 里写的是:

1
2
coveredResources:
- nvidia.com/gpu

两者靠 Kueue 配置里的 deviceClassMappings 关联起来。少了这段映射,Workload 会被标成 Inadmissible,原因类似:

1
DeviceClass gpu.nvidia.com is not mapped in DRA configuration

ResourceClaimTemplate 要单独创建

这两个名字最容易看混:

  • single-gpu:前面单独创建的 ResourceClaimTemplate,定义“我要 1 张 gpu.nvidia.com 整卡”。
  • gpu-claim:Pod 里的本地 claim 名字,后面容器通过它来使用 GPU。

Pod 里这段不是重新定义一个模板,而是引用已经存在的 single-gpu 模板:

1
2
3
resourceClaims:
- name: gpu-claim
 resourceClaimTemplateName: single-gpu

容器里再通过同一个 gpu-claim 关联到这次申请到的设备:

1
2
3
resources:
 claims:
 - name: gpu-claim

所以完整关系是:ResourceClaimTemplate(single-gpu) -> Pod resourceClaims(gpu-claim) -> container resources.claims(gpu-claim)

Kueue 准入不等于设备已经分配

这里要注意一点:Kueue 只是负责“准入”,真正把 GPU 分配给 Pod 的还是 kube-scheduler 和 DRA Driver。

所以生产环境里建议同时关注 ResourceClaim 状态和 Pod 状态。如果希望 Workload 在 Pod 长时间起不来时释放 Kueue 配额,可以结合 waitForPodsReady 做保护。

4. 小结

到这里可以看到,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 是否还能继续管理显存和算力配额。

5. 参考文章

终于搞懂 Kueue:5 个核心对象一次讲透

2026-07-01 06:00:00

kueue-p2-core.jpg

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

上一篇文章中分析了 Kueue 的完整工作流:

kueue-workflow.png

涉及到多个核心对象,下面逐个拆开讲。

1. ResourceFlavor:资源长什么样

Kueue 要管资源,第一步得知道集群里有哪些"种类"的资源。集群里的 CPU、GPU 通常不是同构的:

  • 价格和可用性 :竞价型 vs 按需型虚拟机
  • 架构 :x86 vs ARM CPU
  • 品牌和型号 :Nvidia A100 vs T4 GPU

ResourceFlavor 就是干这个的——给资源分个类,贴个标签,后面 ClusterQueue 按这个分类来管配额。

Kueue 在 Admission 阶段,会根据 ClusterQueue 中配置的 Flavor 顺序、Quota 是否满足、Flavor 是否匹配 Pod 等因素,选择一个可用的 ResourceFlavor。

空 Flavor

如果集群资源是同构的,或者不需要为不同资源规格分别管理配额,那直接创建一个不包含任何标签或污点的空 ResourceFlavor 即可:

1
2
3
4
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
 name: default-flavor

这类 ResourceFlavor 不承担节点筛选或污点控制的职责,仅作为统一的资源抽象存在,方便后续扩展不同资源类型。

只分类不加污点

1
2
3
4
5
6
7
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
 name: "gpu"
spec:
 nodeLabels:
 instance-type: gpu

新增参数:

  • spec.nodeLabels :通过 label 关联到对应的节点。具体实现上则是通过将 flavor 中 nodeLabels 部分自动注入到 Pod Spec 中的 nodeSelector,从而达到只将 Pod 调度到该 flavor 关联的节点上。

这类 Flavor 实现了根据 label 将节点进行分类,算是名副其实。管理员先给节点打上对应 label 即可实现分类:

1
2
root@lixd-dev-4:~# kubectl label node lixd-dev-4 instance-type=gpu
node/lixd-dev-4 labeled

但也只是简单做了分类,不是该 Flavor 中的 Job 也能手动调度到这些 GPU 节点。比较推荐的做法是给节点再打上污点,这样就不是随便能调度上去了:

1
kubectl taint node xxx nvidia.com/gpu=true:NoSchedule

节点打上污点后,Flavor 中的 Job 也不能调度了。Kueue 提供了两种模式来解决这个问题:

  1. 自动容忍模式 :将污点容忍信息写到 ResourceFlavor,Kueue 自动给使用该 Flavor 的 Pod 加上容忍
  2. 手动容忍模式 :将污点信息写到 ResourceFlavor,Kueue 做匹配拦截,只允许带了对应容忍的 Pod 使用该 Flavor

带污点

自动污点容忍调度

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
 name: "spot"
spec:
 nodeLabels:
 instance-type: spot
 tolerations:
 - key: "spot-taint" # 节点上已有污点的 key
 operator: "Exists"
 effect: "NoSchedule" # 支持 NoSchedule 和 NoExecute

新增参数:

  • spec.tolerations :声明该 flavor 关联节点上的污点容忍信息。Kueue 准入时自动注入到 Pod 的 .spec.template.spec.tolerations,确保 Pod 能容忍节点污点从而正常调度。

手动污点容忍

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
 name: "spot"
spec:
 nodeLabels:
 instance-type: spot
 nodeTaints:
 - effect: NoSchedule  # 只支持 NoSchedule 和 NoExecute,PreferNoSchedule 会被忽略
 key: spot
 value: "true"

新增参数:

  • spec.nodeTaints :在 flavor 上定义准入门槛。Kueue 不会自动注入容忍度,Pod 必须自己带对应 toleration 才能通过准入、拿到配额。

用户提交 Job 时必须自己带上 toleration,否则 Kueue 不批配额:

1
2
3
4
5
6
7
spec:
 template:
 spec:
 tolerations:
 - key: spot
 operator: Exists
 effect: NoSchedule

注意spec.nodeTaints 通常应与节点上的真实污点保持一致。否则可能出现 Pod 过了 Kueue 准入、拿到配额,但 K8s 调度器发现节点有真污点而 Pod 没对应 toleration,最终调度失败——白白占了配额。本质就是把调度层面的拦截提前到 Kueue 准入层面

两种模式怎么选

自动模式(spec.tolerations 手动模式(spec.nodeTaints
谁给 Pod 加 toleration Kueue 自动注入 用户自己写
用户需要关心节点污点吗 不需要,提交 Job 就行 必须自己写 toleration
用在哪 生产默认,对用户最友好 保护昂贵资源,强制用户显式声明

选型建议:优先用自动模式;只有当你需要强制用户显式声明才能使用某种昂贵资源时,才用手动模式。

一句话记住:ResourceFlavor 定义资源的属性(标签、污点、容忍等),真正决定资源配额和公平性的仍然是 ClusterQueue。

2. ClusterQueue:谁是配额的真正管家

如果 ResourceFlavor 是给资源分类,那真正决定谁能用、用多少的是谁?答案就是 ClusterQueue。它才是 Kueue 配额管理的核心,定义了使用上限和公平共享规则。

基础用法

一个基础的 ClusterQueue 示例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: "cluster-queue"
spec:
 namespaceSelector: {} # 匹配所有命名空间。
 resourceGroups:
 - coveredResources: ["cpu", "memory", "pods"]
 flavors:
 - name: "default-flavor"
 resources:
 - name: "cpu"
 nominalQuota: 9
 - name: "memory"
 nominalQuota: 36Gi
 - name: "pods"
 nominalQuota: 5

字段解析:

  • 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 还支持以下进阶字段,按用途分组:

调度顺序:排队策略

1
2
spec:
 queueingStrategy: BestEffortFIFO
  • BestEffortFIFO(默认) :前面的 Job 拿不到配额,后面的能插队先跑,资源利用率高。
  • StrictFIFO :前面的 Job 拿不到配额,后面的必须等,适合有顺序依赖的 Pipeline。

资源共享:加入 Cohort

1
2
spec:
 cohortName: research-cohort
  • cohortName :关联到一个 Cohort,同一个 Cohort 里的 ClusterQueue 可以互相共享资源。

抢占相关

当 ClusterQueue 或其 Cohort 中没有足够的配额时,新进入的 Workload 可以触发预抢占,挤掉低优先级的 Workload。涉及两个配置项:

预抢占策略

1
2
3
4
5
6
7
spec:
 preemption:
 reclaimWithinCohort: Any  # 可抢占 Cohort 中超过名义配额的 Workload
 borrowWithinCohort:
 policy: LowerPriority  # 借用时只抢占低优先级(不能与 Fair Sharing 一同使用)
 maxPriorityThreshold: 100 # 且仅抢占优先级 ≤ 100 的 Workload
 withinClusterQueue: LowerPriority  # 同队列内,低优先级让路给高优先级
  • 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:不试了,直接抢占。
1
2
3
4
spec:
 flavorFungibility:
 whenCanBorrow: TryNextFlavor
 whenCanPreempt: MayStopSearch

运维控制:停止策略

控制队列的运行状态,ClusterQueue 和 LocalQueue 都支持。

  • None(默认) :正常运行,新 Job 正常准入,已运行的不受影响。
  • Hold :停止新的准入,已准入的不受影响,适合维护、配额调整等临时场景。
  • HoldAndDrain :停止新的准入 + 触发已准入工作负载的驱逐,适合紧急情况需要立刻清场。

维护完恢复为 None 或直接删掉这个字段即可。这是运维操作,不是常态配置。

1
2
spec:
 stopPolicy: Hold

把上面的都放在一起

一个包含所有字段的完整 ClusterQueue:

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: ai-team-cq
spec:
 # 1. 加入 Cohort,允许和其他 ClusterQueue 共享资源
 cohortName: research-cohort

 # 2. 排队策略
 queueingStrategy: BestEffortFIFO  # 默认。StrictFIFO 会阻塞

 # 3. 命名空间选择器(谁能用这个 ClusterQueue)
 namespaceSelector:
 matchLabels:
 team: ai

 # 4. 抢占策略
 preemption:
 reclaimWithinCohort: Any  # 可抢占 Cohort 中超过名义配额的 Workload
 borrowWithinCohort:
 policy: LowerPriority  # 借用时只抢占低优先级(不能与 Fair Sharing 一同使用)
 maxPriorityThreshold: 100 # 且仅抢占优先级 ≤ 100 的 Workload
 withinClusterQueue: LowerPriority  # 同队列内,低优先级让路给高优先级

 # 5. 配置是优先抢占还是借用
 flavorFungibility:
 whenCanBorrow: TryNextFlavor
 whenCanPreempt: MayStopSearch

 # 6. 停止策略
 # stopPolicy: Hold

 # 7. 资源组定义
 resourceGroups:
 - coveredResources: ["cpu", "memory"] # 第一组:计算资源
 flavors:
 - name: default-flavor
 resources:
 - name: cpu
 nominalQuota: 16 # 保底配额
 borrowingLimit: 8 # 最多能从 Cohort 借多少
 lendingLimit: 8 # 最多允许别人借多少
 - name: memory
 nominalQuota: 64Gi
 borrowingLimit: 32Gi
 lendingLimit: 32Gi

 - coveredResources: ["nvidia.com/gpu"] # 第二组:GPU 资源
 flavors:
 - name: a100
 resources:
 - name: nvidia.com/gpu
 nominalQuota: 4
 borrowingLimit: 2
 - name: t4
 resources:
 - name: nvidia.com/gpu
 nominalQuota: 8

一句话记住:ClusterQueue 才是真正管配额和公平性的地方。 第一次看会和 LocalQueue 搞混,其实记住一句就够了——ClusterQueue 管资源,LocalQueue 管用户。

3. LocalQueue:用户怎么进队列

ClusterQueue 是集群级别的,但用户不能直接往里塞 Job。中间还需要一层 LocalQueue——它是一个命名空间对象,指向一个 ClusterQueue,作为用户提交 Job 的入口。

1
2
3
4
5
6
7
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
 namespace: team-a
 name: team-a-queue
spec:
 clusterQueue: cluster-queue

注意:kubectl get queueskubectl get localqueues 的别名,更方便日常使用。

LocalQueue vs ClusterQueue

维度 LocalQueue ClusterQueue
作用域 命名空间 集群
谁创建 团队自己 批处理管理员
职责 将同一租户的工作负载分组,指向一个 ClusterQueue 管理资源池的配额和公平共享规则
一对一? 多个 LocalQueue 可以指向同一个 ClusterQueue
1
2
3
4
5
命名空间 team-a:
 LocalQueue: training-queue ──┐
 LocalQueue: inference-queue ──┼──→ ClusterQueue: ai-team-cq
命名空间 team-b: │
 LocalQueue: batch-queue ──────┘

一句话记住:LocalQueue 就是 Namespace 访问 ClusterQueue 的入口。 它不管配额,只管"我这个 namespace 的 Job 往哪个 ClusterQueue 送"。

4. Cohort:队列之间怎么借资源

如果每个 ClusterQueue 只能用自己的保底配额,那 team-a 闲着 3 核、team-b 想多跑 1 核也借不到,资源就浪费了。Cohort 就是解决这个的——可以理解成一个"资源共享联盟",加进同一个 Cohort 的 ClusterQueue 可以互相借配额。

第一次看 Cohort 我也没太理解。后来发现,它本身还能定义 resourceGroups(共享配额池),这些资源是管理员额外划拨给整个联盟的公共池,而不是把各个 ClusterQueue 的 nominalQuota 自动汇总得到的。

ClusterQueue 与 Cohort 的关系

基本用法

 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
46
47
# 创建 Cohort
apiVersion: kueue.x-k8s.io/v1beta2
kind: Cohort
metadata:
 name: hello-cohort
spec:
 resourceGroups:
 - coveredResources: ["cpu"]
 flavors:
 - name: default-flavor
 resources:
 - name: cpu
 nominalQuota: 12 # Cohort 级别的共享配额(额外资源)
---
# ClusterQueue A 加入 Cohort
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: team-a-cq
spec:
 cohortName: hello-cohort
 resourceGroups:
 - coveredResources: ["cpu"]
 flavors:
 - name: default-flavor
 resources:
 - name: cpu
 nominalQuota: 4
 borrowingLimit: 4 # 最多借 4 核
 lendingLimit: 2 # 最多出借 2 核
---
# ClusterQueue B 也加入同一个 Cohort
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: team-b-cq
spec:
 cohortName: hello-cohort
 resourceGroups:
 - coveredResources: ["cpu"]
 flavors:
 - name: default-flavor
 resources:
 - name: cpu
 nominalQuota: 4
 borrowingLimit: 4
 lendingLimit: 2

上面的配置意味着:

  • 两个队列各有 4 核保底,Cohort 自己配置的一组共享配额 12 核。(总计可用 20 核)
  • 空闲时可以互相借用
  • 如果 ClusterQueue 想从 Cohort 借用资源,它必须为该资源和 Flavor 定义 nominalQuota(即使值为 0)

分层 Cohort(Hierarchical Cohorts)

Cohort 可以组织成树形结构(CohortTree),适合大型组织:

 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
# 根 Cohort
apiVersion: kueue.x-k8s.io/v1beta2
kind: Cohort
metadata:
 name: root-cohort
---
# AI 部门 Cohort
apiVersion: kueue.x-k8s.io/v1beta2
kind: Cohort
metadata:
 name: ai-dept
spec:
 parentName: root-cohort  # 父节点
 fairSharing:
 weight: "0.75"
---
# 大数据部门 Cohort
apiVersion: kueue.x-k8s.io/v1beta2
kind: Cohort
metadata:
 name: bigdata-dept
spec:
 parentName: root-cohort  # 父节点
 fairSharing:
 weight: "0.25"
1
2
3
4
5
6
7
root-cohort(总资源池)
├── ai-dept(权重 0.75 → 趋向使用 75% 公共资源)
│ ├── team-a-cq
│ └── team-b-cq
└── bigdata-dept(权重 0.25 → 趋向使用 25% 公共资源)
 ├── spark-cq
 └── flink-cq

同一个 CohortTree 中的 ClusterQueue 可以使用其中的资源,遵循为 Cohort 和 ClusterQueue 指定的借用和借出限制。

一句话记住:Cohort 让多个 ClusterQueue 可以互相借资源。

5. Workload:Kueue 真正在调度什么

前面四个都是"配置",那 Kueue 真正调度、准入的对象是什么?是 Workload——一个要运行至完成的应用,由一个或多个 Pod 组成。Kueue 的 Admission、Quota Accounting、Preemption 全是围绕它展开的。

通常你不需要手动创建 Workload,Kueue 会为每个 Job 自动创建。但理解它的结构有助于排查问题。

关键字段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: kueue.x-k8s.io/v1beta2
kind: Workload
metadata:
 name: sample-job
 namespace: team-a
spec:
 active: true # 设为 false 可停止 Workload(已运行的会被驱逐且不重新排队)
 queueName: team-a-queue  # 指向 LocalQueue
 podSets: # Pod 组定义(Kueue 从 Job 自动提取)
 - name: main
 count: 3 # Pod 数量
 template:
 spec:
 containers:
 - name: container
 image: registry.k8s.io/e2e-test-images/agnhost:latest
 resources:
 requests:
 cpu: "1"
 memory: 200Mi
 maximumExecutionTimeSeconds: 3600 # Workload 处于 Admitted 状态超过此秒数后自动停用

优先级

Workload 的优先级影响准入顺序,有两种设置方式:

  • Pod 优先级 :对于 batch/v1.Job,Kueue 根据 Job 的 Pod 模板的 Pod 优先级设置 Workload 的优先级
  • WorkloadPriorityClass :独立管理工作负载的排队和抢占优先级,与 Pod 调度优先级分离
1
2
3
4
5
6
apiVersion: kueue.x-k8s.io/v1beta2
kind: WorkloadPriorityClass
metadata:
 name: high-priority
value: 1000
description: "高优先级训练任务"

使用方式:在 Job 的 label 中指定:

1
2
3
metadata:
 labels:
 kueue.x-k8s.io/priority-class: high-priority

资源请求计算

Kueue 将 Workload 的总资源使用量计算为每个 podSet 资源请求的总和: podSet 的资源使用量 = Pod 规格的资源请求 × count

Kueue 会根据 Limit Ranges 和 Runtime Class Overhead 调整资源使用量。

一句话记住:Workload 才是 Kueue 真正调度和准入的对象,前面四个对象都是给它配规则的。

6. Demo:串联五大核心对象

理论讲完了,下面用一个 demo 把五大核心对象串起来,验证配额管控和 Cohort 借用能力。环境是一个单节点 K8s 集群(8 核 CPU)+ Kueue v0.18.1。

1
2
3
4
5
Cohort(team-ab)
├── team-a-cq(保底 4 核,最多借到 6 核)
│ └── team-a-queue
└── team-b-cq(保底 2 核,最多借到 5 核)
 └── team-b-queue

Step 1:给节点打 label 和 taint

1
2
3
4
5
# 打 label,标记节点类型
kubectl label node lixd-dev-4 node-type=cpu

# 加 taint,防止没有容忍的 Pod 调度上来
kubectl taint node lixd-dev-4 dedicated=team:NoSchedule

Step 2:创建 ResourceFlavor(tolerations 自动模式)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
 name: cpu-flavor
spec:
 nodeLabels:
 node-type: cpu  # 匹配节点
 tolerations: # Kueue 自动注入 Pod,容忍节点污点
 - key: dedicated
 operator: Exists
 effect: NoSchedule

Step 3:创建 Cohort + 两个 ClusterQueue + 两个 LocalQueue

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# Cohort:让两个队列共享配额
apiVersion: kueue.x-k8s.io/v1beta2
kind: Cohort
metadata:
 name: team-ab
---
# team-a:保底 4 核,最多借到 6 核
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: team-a-cq
spec:
 cohortName: team-ab
 namespaceSelector: {}
 resourceGroups:
 - coveredResources: ["cpu", "pods"]
 flavors:
 - name: cpu-flavor
 resources:
 - name: cpu
 nominalQuota: 4
 borrowingLimit: 2
 - name: pods
 nominalQuota: 3
---
# team-b:保底 2 核,最多借到 5 核
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: team-b-cq
spec:
 cohortName: team-ab
 namespaceSelector: {}
 resourceGroups:
 - coveredResources: ["cpu", "pods"]
 flavors:
 - name: cpu-flavor
 resources:
 - name: cpu
 nominalQuota: 2
 borrowingLimit: 3
 - name: pods
 nominalQuota: 3
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
 namespace: default
 name: team-a-queue
spec:
 clusterQueue: team-a-cq
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
 namespace: default
 name: team-b-queue
spec:
 clusterQueue: team-b-cq

Step 4:提交 Job A(team-a,3 核,保底内)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: batch/v1
kind: Job
metadata:
 name: job-a
 labels:
 kueue.x-k8s.io/queue-name: team-a-queue
spec:
 template:
 spec:
 containers:
 - name: sleep
 image: busybox
 command: ['sleep', '30']
 resources:
 requests:
 cpu: '3'
 restartPolicy: Never

30 秒后 Job A 跑完,验证 Kueue 自动注入了 toleration 和 nodeSelector:

1
2
3
4
5
$ kubectl get pod job-a-<pod-suffix> -o jsonpath='{.spec.tolerations[0]}'
{"effect":"NoSchedule","key":"dedicated","operator":"Exists"} # ← 自动注入的

$ kubectl get pod job-a-<pod-suffix> -o jsonpath='{.spec.nodeSelector}'
{"node-type":"cpu"} # ← 自动注入的

Step 5:提交 Job B(team-b,3 核,需要借用)

team-b 保底只有 2 核,要 3 核需要从 team-a 借 1 核空闲配额(此时 Job A 还在跑,用了 3 核,team-a 空闲 1 核)。 此时提交 JobB:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: batch/v1
kind: Job
metadata:
 name: job-b
 labels:
 kueue.x-k8s.io/queue-name: team-b-queue
spec:
 template:
 spec:
 containers:
 - name: sleep
 image: busybox
 command: ['sleep', '30']
 resources:
 requests:
 cpu: '3'
 restartPolicy: Never

借用过程: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 核可借。

Step 6:提交 Job D(team-b,5 核,测试 borrowingLimit 上限)

Job A、Job B 都已跑完,配额全部归还。现在 team-b 最多能用保底 2 + 借 3 = 5 核(team-a 空闲 4 核,足够借)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: batch/v1
kind: Job
metadata:
 name: job-d
 labels:
 kueue.x-k8s.io/queue-name: team-b-queue
spec:
 template:
 spec:
 containers:
 - name: sleep
 image: busybox
 command: ['sleep', '600']
 resources:
 requests:
 cpu: '5'
 restartPolicy: Never

结果:

1
2
3
$ kubectl get workloads
NAME QUEUE ADMITTED
job-job-d-fd3f3 team-b-queue True # 保底2+借3=5,刚好到上限,准入

Step 7:提交 Job E(team-b 再多 1 核,超过 borrowingLimit)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: batch/v1
kind: Job
metadata:
 name: job-e
 labels:
 kueue.x-k8s.io/queue-name: team-b-queue
spec:
 template:
 spec:
 containers:
 - name: sleep
 image: busybox
 command: ['sleep', '120']
 resources:
 requests:
 cpu: '1'
 restartPolicy: Never

结果:

1
2
3
4
5
6
7
8
$ kubectl get workloads
NAME QUEUE ADMITTED
job-job-d-fd3f3 team-b-queue True # 5核已用满
job-job-e-918d8 team-b-queue False # 再多 1 核,Pending

$ kubectl get workload job-job-e-918d8 -o jsonpath='{.status.conditions[0].message}'
insufficient unused quota for cpu in flavor cpu-flavor, 1 more needed
# team-b 最多 5 核,Job D 已用 5 核,没有余量了

小结

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 个核心对象在这里全部串联起来了。

7. 小结

Kueue 的核心,其实不是“调度算法”,而是围绕 Workload 的准入与资源分配模型。

五个对象分别扮演了不同角色,但可以用一句话快速串起来:

  • ResourceFlavor :定义“资源长什么样”(节点/污点/标签)
  • ClusterQueue :定义“资源怎么分、给谁多少”
  • LocalQueue :定义“哪个命名空间用哪个资源池”
  • Cohort :定义“多个队列之间如何共享资源”
  • Workload :真正被调度和准入的执行单元

第一次接触 Kueue 时,很容易把这些对象看成彼此独立的 CRD,但实际上它们共同组成了一条完整的资源准入链路:

1
Job → LocalQueue → ClusterQueue → ResourceFlavor → Admission → Workload

理解了这条链路,再去看 Borrowing、Fair Sharing、Preemption 等高级能力,就会发现它们都只是围绕这套模型做扩展,而不是新的概念。

Kueue 核心对象总结

Kubernetes 官方出品:一个 Controller 搞定 Job 排队和资源配额

2026-06-25 06:00:00

kueue-p1-intro.jpg

多个团队共用一个 Kubernetes 集群,A 团队提交了一批训练任务,几十张 GPU 很快就被占满;B 团队新提交的 Job 只能一直 Pending。 因为,而是 Kubernetes 原生采用"先到先得"的调度方式,没有 Job 队列,也没有多租户配额管理。

Kueue 正是 Kubernetes 官方为此提供的解决方案。它不替换 kube-scheduler,只负责 Job 的排队和准入,在此基础上实现资源配额管理和公平调度。

1. Kueue 简介

Kueue 是什么?

Kueue 是 Kubernetes SIGs 维护的官方 Job 级队列管理系统,负责决定 Job 何时准入(Admit)、何时被驱逐(Preempt),核心目标是管理资源配额和多租户公平调度。

和 Volcano 最大的区别:Kueue 不替换 kube-scheduler,它只管"排队和准入",调度还是交给原生调度器。

1
2
Volcano = 自定义调度器 + 队列管理 + 作业管理
Kueue = 队列管理 + 配额管理(调度交给 kube-scheduler)

Kueue 能做什么?

  • 多租户配额管理:通过 ClusterQueue 为不同团队划分资源配额

  • 公平调度:基于 Dominant Resource Shares(DRS)的公平共享算法,防止资源被单一团队长期独占

  • 队列排队:Job 提交后按优先级排队,配额不足时挂起等待

  • Cohort 借调:同一 Cohort 内的 ClusterQueue 可以借用彼此空闲配额,用完归还

  • 弹性配额:支持 nominalQuota(保底)+ borrowingLimit(借用上限)+ lendingLimit(出借上限)三层额度控制

  • 标准兼容:原生支持 K8s Job / JobSet / PyTorchJob / TFJob / RayJob 等,无需改 Job YAML

为什么需要 Kueue?

或者说 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 vs Volcano 快速对比

维度 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 系列文章

2. 核心概念

在部署之前,先理解 Kueue 的 5 个核心对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
┌─────────────────────────────────────────────────────┐
│ Cohort │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ClusterQueue A │ │ ClusterQueue B │ │
│ │ (nominal: 5GPU) │ │ (nominal: 5GPU) │ │
│ │ borrowLimit: 5 │ │ borrowLimit: 5 │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ ResourceFlavor: A100 / T4 / CPU │
└─────────────────────────────────────────────────────┘
 │ │
 LocalQueue A LocalQueue B ← Namespaced
 (team-a) (team-b)
 │ │
 Workload 1 Workload 2 ← 用户的 Job
  • 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 的完整流程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
用户提交 Job (带 kueue.x-k8s.io/queue-name 标签)
Kueue 自动创建 Workload 对象
Workload 进入 LocalQueue → 找到对应的 ClusterQueue
检查 ClusterQueue 是否有足够配额?(允许从 Cohort 借调)
 ├── 没有 → 询问 Cluster Autoscaler 能否扩容? → 等待扩容
 ↓ (有配额 或 扩容完成)
检查 ClusterQueue 是否配置了 AdmissionCheck?
 ├── 有 → 等待所有 Check 通过 → 最终准入
 └── 没有 → 直接最终准入
Kueue 注入 nodeAffinity/tolerations (锁定算力)
Job Controller 创建 Pod
Kube Scheduler 分配 Pod 到具体节点
节点资源不足? → 触发 Cluster Autoscaler 真正扩容节点 (Provision)

kueue-workflow.png

关键点:Kueue 只负责"准入"决策,Pod 真正调度到哪个节点还是 kube-scheduler 决定。

3. Kueue 部署

Kueue 部署非常轻量,只需要一个 Controller。

版本兼容性

Kueue 要求 Kubernetes 1.29+,本文使用 Kubernetes v1.36.1 部署最新的 Kueue v0.18.1

安装

官方提供了两种安装方式,推荐 Helm:

方式一:kubectl 直接安装

1
kubectl apply --server-side -f https://github.com/kubernetes-sigs/kueue/releases/download/v0.18.1/manifests.yaml

方式二:Helm 安装(推荐)

1
2
3
4
5
helm install kueue oci://registry.k8s.io/kueue/charts/kueue \
 --version=0.18.1 \
 --namespace kueue-system \
 --create-namespace \
 --wait --timeout 300s

无法访问 registry.k8s.io 可以从 GitHub 下载 chart 包安装:

1
2
3
4
helm 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

卸载

1
helm uninstall kueue --namespace kueue-system

验证

1
kubectl -n kueue-system get po
1
2
NAME READY STATUS RESTARTS AGE
kueue-controller-manager-665fc6d58-whxcz 1/1 Running 0 60m

一个 Pod 就搞定了,这就是 Kueue 轻量的地方。

4. 快速上手:一个完整的 Demo

接下来我们跑一个最小化 Demo:创建 ResourceFlavor → ClusterQueue → LocalQueue → 提交 Job,完整走一遍 Kueue 的工作流程。

4.1 创建基础资源

Kueue 使用三种资源来管理作业排队:

  • ResourceFlavor:描述作业可用的硬件配置
  • ClusterQueue:定义资源池的配额(CPU、内存等)
  • LocalQueue:用户提交作业的命名空间级队列,映射到 ClusterQueue
 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
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
 name: "default-flavor"
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: "cluster-queue"
spec:
 namespaceSelector: {} # 允许所有命名空间
 resourceGroups:
 - coveredResources: ["cpu", "memory"]
 flavors:
 - name: "default-flavor"
 resources:
 - name: "cpu"
 nominalQuota: 9
 - name: "memory"
 nominalQuota: 9Gi
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
 namespace: "default"
 name: "user-queue"
spec:
 clusterQueue: "cluster-queue"
1
kubectl apply -f queue.yaml

4.2 验证队列状态

1
2
3
4
5
# ClusterQueue 是否激活
kubectl get clusterqueue cluster-queue -o wide

# LocalQueue 是否就绪
kubectl get localqueue user-queue -n default
1
2
3
4
5
6
7
root@lixd-dev-4:~# kubectl get clusterqueue cluster-queue -o wide
NAME COHORT STRATEGY PENDING WORKLOADS ADMITTED WORKLOADS
cluster-queue BestEffortFIFO 0 0

root@lixd-dev-4:~# kubectl get localqueue user-queue -n default
NAME CLUSTERQUEUE PENDING WORKLOADS ADMITTED WORKLOADS
user-queue cluster-queue 0 0

队列创建成功,当前没有 Workload。

4.3 提交 Job

注意看,这里用的就是标准的 K8s Job,只需要加一个 label 就能接入 Kueue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: batch/v1
kind: Job
metadata:
 name: sample-job
 namespace: default
 labels:
 kueue.x-k8s.io/queue-name: user-queue  # 接入 Kueue 的关键
spec:
 parallelism: 3
 completions: 3
 template:
 spec:
 containers:
 - name: dummy-job
 image: registry.k8s.io/e2e-test-images/agnhost:2.53
 command: ["/bin/sh"]
 args: ["-c", "sleep 30"]
 resources:
 requests:
 cpu: "1"
 memory: "200Mi"
 restartPolicy: Never
1
kubectl apply -f job.yaml

4.4 验证

1
2
3
4
5
# 查看 Kueue 自动创建的 Workload
kubectl get workloads -n default

# 查看 Pod
kubectl get pods -n default
1
2
3
4
5
6
7
8
9
root@lixd-dev-4:~# kubectl get workloads -n default
NAME QUEUE RESERVED IN ADMITTED FINISHED AGE
job-sample-job-555d8 user-queue cluster-queue True 9s

root@lixd-dev-4:~# kubectl get pods -n default
NAME READY STATUS RESTARTS AGE
sample-job-j6xdp 1/1 Running 0 14s
sample-job-k4mtm 1/1 Running 0 14s
sample-job-nvjkz 1/1 Running 0 14s

可以看到 Kueue 自动为 Job 创建了对应的 Workload,Workload 状态为 Admitted(已准入),3 个 Pod 正常运行。

4.5 等待任务完成

1
2
# 等待任务完成
kubectl get jobs -n default -w
1
2
3
root@lixd-dev-4:~# kubectl get jobs -n default -w
NAME STATUS COMPLETIONS DURATION AGE
sample-job Complete 3/3 34s 44s

Job 完成后,Workload 也会被标记为 Finished:

1
kubectl get workloads -n default
1
2
3
root@lixd-dev-4:~# kubectl get workloads -n default
NAME QUEUE RESERVED IN ADMITTED FINISHED AGE
job-sample-job-555d8 user-queue cluster-queue True True 49s

到这里,我们完整走了一遍 Kueue 的工作流程:创建队列 → 提交 Job → Kueue 自动准入 → Pod 运行 → 任务完成

整个过程中,我们用的是标准的 K8s Job,唯一的变化就是加了一个 kueue.x-k8s.io/queue-name 标签。这就是 Kueue “不替换调度器、旁路管理"设计哲学的体现。

5. 小结

Kueue 是 Kubernetes SIGs 官方出品的 Job 队列与配额管理系统,它的核心设计哲学是”只管排队和准入,不动调度器",这让它非常轻量且对现有集群侵入性极小。

本文介绍了 Kueue 的背景、核心概念和部署方式,并通过一个最小化 Demo 走完了"创建队列 → 提交 Job → 自动准入 → 任务完成"的完整流程。

下一篇我们将深入解析 Kueue 的五大核心对象(ResourceFlavor / ClusterQueue / LocalQueue / Cohort / Workload),搞清楚它们各自的职责和配置细节。