MoreRSS

site iconLixueduan | 李学端修改

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

Inoreader Feedly Follow Feedbin Local Reader

Lixueduan | 李学端的 RSS 预览

KServe + HAMi:一张 GPU 如何运行多个推理服务

2026-08-10 04:00:00

HAMi GPU 共享:KServe 原生 DRA 实战

前面已经用 KServe 跑起了 Qwen,但一个小模型独占一张 GPU 有些浪费。这篇则是在此基础上引入 HAMi,通过 HAMi 实现 GPU 共享,让多个小模型共用一张 GPU。

准备环境

本次测试环境如下:

1
2
3
4
5
6
7
Kubernetes v1.36.1
KServe v0.18.0
HAMi 0.2.1
HAMi NVIDIA DRA Driver v0.1.0
containerd 2.2.4
NVIDIA Driver 580.173.02
Tesla T4 15 GiB x 1

本文从 HAMi 安装和原生 DRA 资源声明开始,完整走通 KServe 集成 HAMi GPU 共享部分。

安装 GPU Operator

由 GPU Operator 安装 NVIDIA Driver、Container Toolkit 和监控组件,但关闭原生 NVIDIA Device Plugin,后续由 HAMi DRA Driver 管理 GPU:

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

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

如果节点已经预装 NVIDIA Driver,可以把 driver.enabled 改为 false。无论驱动由谁安装,devicePlugin.enabled=false 都不能省略,否则原生 Device Plugin 和 HAMi-DRA 会同时管理同一设备。

安装 cert-manager

HAMi-DRA Webhook 需要 TLS 证书,测试环境使用 cert-manager 签发:

1
2
3
4
5
6
7
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 \
 --set crds.enabled=true \
 --wait

安装 HAMi-DRA

为需要接管的 GPU 节点添加 gpu=on 标签,再安装本文实测的 HAMi-DRA 0.2.1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
helm repo add hami-dra https://project-hami.github.io/HAMi-DRA/
helm repo update

GPU_NODE=lixd-test-gpu # 替换为实际 GPU 节点名
kubectl label node "$GPU_NODE" gpu=on

helm upgrade --install hami-dra hami-dra/hami-dra \
 -n hami-dra \
 --create-namespace \
 --version 0.2.1 \
 --wait

上面的命令适用于 GPU Operator 安装 Driver 的场景。如果 NVIDIA Driver 由宿主机预装,则增加:

1
--set drivers.nvidia.containerDriver=false

确认 GPU 共享容量

安装完成后,HAMi 创建了一个 DeviceClass:

1
2
3
root@lixd-test-gpu:~# kubectl get deviceclass
NAME AGE
hami-core-gpu.project-hami.io 3d4h

同时节点插件通过 ResourceSlice 发布 GPU 信息:

1
kubectl get resourceslice -o yaml

输出中只保留本文关心的字段:

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

allowMultipleAllocations: true 表示同一个设备可以接受多份分配。这里的 memorycores 是 HAMi 用于调度和限制的可消耗容量,不是 Node 上的传统扩展资源。

创建共享 GPU 推理服务

KServe 0.18 版本已经支持原生 DRA,可以在 Predictor 级引用 ResourceClaimTemplate,再由容器级 resources.claims 使用对应的 Claim。

我们只需要提前创建一个 ResourceClaimTemplate,然后在 InferenceService 中引用即可,完整的 YAML 如下:

 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
cat <<'EOF' > qwen-llm-hami.yaml
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
 name: qwen-hami-gpu
 namespace: kserve-test
spec:
 spec:
 devices:
 requests:
 - name: gpu
 exactly:
 deviceClassName: hami-core-gpu.project-hami.io
 allocationMode: ExactCount
 count: 1
 capacity:
 requests:
 memory: 3Gi
 cores: "20"
---
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
 name: qwen-llm
 namespace: kserve-test
 annotations:
 serving.kserve.io/deploymentMode: Standard
spec:
 predictor:
 minReplicas: 2
 resourceClaims:
 - name: gpu
 resourceClaimTemplateName: qwen-hami-gpu
 model:
 modelFormat:
 name: huggingface
 image: docker.m.daocloud.io/kserve/huggingfaceserver:v0.18.0-gpu
 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
 limits:
 cpu: "2"
 memory: 6Gi
 claims:
 - name: gpu
EOF

kubectl apply -f qwen-llm-hami.yaml

kubectl wait --for=condition=Ready \
 inferenceservice/qwen-llm -n kserve-test --timeout=10m

minReplicas: 2 保证 Demo 期间至少存在两个 Predictor,用于验证它们能否同时获得共享 GPU 配额,不展开副本自动调整行为。

这里没有再声明 nvidia.com/gpunvidia.com/gpumemnvidia.com/gpucores。CPU 和内存仍使用普通 requests/limits,GPU 完全使用 DRA Claim 形式声明。

原生 DRA 通过 ResourceClaim 分配共享 GPU

确认 KServe 写入 DRA 引用

KServe 的 HuggingFace Runtime 原本根据 GPU limit 选择 -gpu 镜像。原生 DRA 配置里没有这个 limit,因此本文显式指定已经验证过的 GPU 镜像。docker.m.daocloud.io 是测试环境使用的镜像代理;如果环境可以直接访问 Docker Hub,可以改为 kserve/huggingfaceserver:v0.18.0-gpu

KServe 最终生成的 Deployment 保留了两级引用:

1
2
kubectl get deployment -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm -o yaml
1
2
3
4
5
6
7
8
9
spec:
 resourceClaims:
 - name: gpu
 resourceClaimTemplateName: qwen-hami-gpu
 containers:
 - name: kserve-container
 resources:
 claims:
 - name: gpu

Deployment 创建两个 Pod 后,Kubernetes 会根据同一个 ResourceClaimTemplate 为每个 Pod 生成独立 Claim。不能让多个副本直接引用一份固定 ResourceClaim,否则它们不会获得各自独立的 3Gi/20 配额。

验证 DRA 容量分配

查看 ResourceClaim 的申请与分配

Kubernetes 为每个 Pod 生成一份 Claim。下面只保留其中一份 Claim 的申请字段;它由 Pod 持有,Pod 删除后会一起清理:

1
2
CLAIM=$(kubectl get resourceclaim -n kserve-test -o name | head -n 1)
kubectl get -n kserve-test "$CLAIM" -o yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: resource.k8s.io/v1
kind: ResourceClaim
spec:
 devices:
 requests:
 - name: gpu
 exactly:
 allocationMode: ExactCount
 count: 1
 deviceClassName: hami-core-gpu.project-hami.io
 capacity:
 requests:
 memory: 3Gi
 cores: "20"

调度完成后,Claim 状态中的关键字段记录了实际分配:

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

ResourceClaimTemplate 只定义申请规格,Kubernetes 为每个 Pod 生成 Claim,并完成设备选择和容量扣减。HAMi DRA Driver 随后响应 kubelet 的 NodePrepareResources,生成 CDI 配置并返回设备信息,最终由 containerd 把对应 GPU 和 HAMi-Core 运行环境应用到容器。

查看容器内的显存限制

进入其中一个 Predictor 容器执行完整的 nvidia-smi

1
2
3
4
5
POD=$(kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm \
 -o name | head -n 1)

kubectl exec -n kserve-test "$POD" -- nvidia-smi

可以看到 HAMi 把同一张 GPU 的可见显存限制为 3072 MiB。下面是本次实测的完整状态表;命令前后的 HAMI 初始化和退出日志不属于 nvidia-smi 输出,这里没有混入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Thu Aug 6 03:46:03 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.173.02 Driver Version: 580.173.02 CUDA Version: 13.0 |
+-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 Tesla T4 Off | 00000000:00:06.0 Off | 0 |
| N/A 44C P0 28W / 70W | 3015MiB / 3072MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| No running processes found |
+-----------------------------------------------------------------------------------------+

验证两个副本共享同一张 GPU

检查 Pod 和 Claim

把 Predictor 的最小副本数设为 2,两份 Pod 使用相同的 3Gi、cores=20 配置。

整卡分配与 HAMi-DRA 共享对比

先查看 Pod 和 Claim:

1
2
3
4
5
6
7
8
9
kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm -o wide

kubectl get resourceclaim -n kserve-test

for claim in $(kubectl get resourceclaim -n kserve-test -o name); do
 kubectl get -n kserve-test "$claim" \
 -o jsonpath='{.metadata.name}{" device="}{.status.allocation.devices.results[0].device}{" memory="}{.status.allocation.devices.results[0].consumedCapacity.memory}{" cores="}{.status.allocation.devices.results[0].consumedCapacity.cores}{"\n"}'
done

本次新建的两份 Claim 都分配成功:

1
2
3
4
5
6
NAME STATE AGE
qwen-llm-predictor-544cc75b4-7fvcc-gpu-g8b6c allocated,reserved 3m3s
qwen-llm-predictor-544cc75b4-fz5hq-gpu-9lxw2 allocated,reserved 2m48s

qwen-llm-predictor-544cc75b4-7fvcc-gpu-g8b6c device=hami-gpu-0 memory=3Gi cores=20
qwen-llm-predictor-544cc75b4-fz5hq-gpu-9lxw2 device=hami-gpu-0 memory=3Gi cores=20

两个 Pod 都调度到 lixd-test-gpu,并分别看到 3072 MiB 显存:

1
2
3
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
qwen-llm-predictor-544cc75b4-7fvcc 1/1 Running 0 3m3s 172.25.118.13 lixd-test-gpu <none> <none>
qwen-llm-predictor-544cc75b4-fz5hq 1/1 Running 0 2m48s 172.25.118.3 lixd-test-gpu <none> <none>

检查两个容器的可见显存

逐个进入容器检查可见 GPU:

1
2
3
4
5
for pod in $(kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm -o name); do
 kubectl exec -n kserve-test "$pod" -- \
 nvidia-smi --query-gpu=name,memory.total --format=csv,noheader
done

两个容器都返回:

1
2
Tesla T4, 3072 MiB
Tesla T4, 3072 MiB

发起推理请求

通过 Gateway 调用接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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}')

GATEWAY_ADDR="${NODE_IP}:${NODE_PORT}"

curl -H 'Host: qwen-llm-kserve-test.example.com' \
 -H 'Content-Type: application/json' \
 "http://${GATEWAY_ADDR}/openai/v1/chat/completions" \
 -d '{
 "model": "qwen",
 "messages": [{"role": "user", "content": "Answer only with the number: 2+3"}],
 "max_tokens": 8,
 "temperature": 0
 }'

API 可以正常返回,说明在使用 GPU 共享之后,服务依旧可以正常运行。

清理资源

验证完成后删除 InferenceService 和 ResourceClaimTemplate:

1
2
kubectl delete inferenceservice qwen-llm -n kserve-test
kubectl delete resourceclaimtemplate qwen-hami-gpu -n kserve-test

两份由 Pod 生成的 ResourceClaim 会随 Pod 一起删除。

总结

KServe 可以直接通过 ResourceClaimTemplateresources.claims 使用 HAMi DRA 模式,不需要额外适配。每个 Predictor Pod 都会生成一份独立 ResourceClaim,再由 HAMi 分配显存和算力配额。

通过 HAMi 共享后,一张 GPU 可以同时承载多个推理副本或服务工作负载,避免小模型整卡独占,让空闲的显存和算力得到更充分的利用。

KServe 工作流程:从 InferenceService 到 vLLM 服务

2026-08-02 04:00:00

KServe 工作流程:从 InferenceService 到 vLLM 服务

上一篇完成了 KServe 的安装,并通过 InferenceService 跑通了一个 Qwen 模型服务。

当时提交的 InferenceService 只包含模型格式、存储地址、GPU 资源和启动参数,KServe 却创建出了 Deployment、Service 和 HTTPRoute。这份 YAML 中没有容器镜像,那么 KServe 如何选中 HuggingFaceServer,Pod 中又为什么会运行 vLLM?

这一篇先看 KServe 的整体架构和核心 CRD,再结合上一篇的 qwen-llm,梳理从提交 InferenceService 到请求进入 vLLM 的完整流程。

以下内容基于 KServe 0.18,部署模式为 Standard。

1. KServe 整体架构

KServe 是一个构建在 Kubernetes 之上的 AI 推理平台: KServe AI 推理平台架构概览

整体分为控制面和数据面:

KServe Standard 模式控制面与数据面架构

1.1 控制面:从 InferenceService 到推理服务

控制面负责创建和维护推理服务。整个过程从用户提交 InferenceService 开始:

  1. 用户通过 kubectl apply 将 InferenceService 提交到 Kubernetes API Server。
  2. Admission Webhook 对配置进行默认化和校验。例如补充默认部署模式,并拒绝不符合 API 约束的配置。
  3. InferenceService 写入集群后,KServe Controller 通过 Watch 感知对象变化,开始执行调谐。
  4. Controller 根据 modelFormat 匹配 ServingRuntime,从 Runtime 中取得模型服务器镜像和默认启动参数,再与 InferenceService 中的模型地址、运行参数和资源需求合并。
  5. 在 Standard 模式下,Controller 根据合并后的配置创建 Deployment、Service 和 HTTPRoute。Pod 创建时,KServe Pod Mutating Webhook 根据存储注解注入 PVC volume 和 volumeMount;接下来由 Kubernetes 负责调度 Pod、挂载存储和分配 GPU,Gateway Controller 负责让 HTTPRoute 生效。
  6. Controller 持续观察 Deployment 和 HTTPRoute 的状态。当 Predictor 可用、路由被 Gateway 接受后,会更新 PredictorReadyIngressReady 和最终的 Ready 状态。

所以,控制面管理的是推理服务的生命周期:创建、更新、状态同步,以及配置变更后的持续调谐。

1.2 数据面:访问已经创建的推理服务

数据面由实际运行模型和处理请求的资源组成。上一篇的环境使用 Envoy Gateway 作为 Gateway API 的实现。模型服务 Ready 后,客户端就可以通过数据面访问推理服务。

客户端访问模型域名时,请求先到达 Envoy Gateway 管理的 Envoy Proxy。Envoy Proxy 应用 HTTPRoute/qwen-llm 的规则,并根据其中指向 Service/qwen-llm-predictorbackendRef,将请求发送到 Ready 的 Predictor Pod。这里的 Service 用于标识和发现后端,并不是一个独立的代理进程。Pod 中运行的是 HuggingFaceServer,当前配置使用 vLLM 作为推理后端。vLLM 完成推理后,响应沿原链路返回客户端。

1
2
3
4
5
6
Client
 -> Envoy Proxy(匹配 HTTPRoute)
 -> Predictor Pod(后端由 Predictor Service 标识)
 -> HuggingFaceServer
 -> vLLM Backend
 -> NVIDIA GPU

控制面决定服务应该如何部署,并保证它处于期望状态;数据面负责服务创建后的请求转发和模型推理。在线请求不会经过 KServe Controller。

2. 核心 CRD

KServe 通过 CRD 描述模型服务、运行时、推理图和模型存储初始化方式。对于上一篇的 Qwen Demo,最核心的是 InferenceService 和 ClusterServingRuntime。

二者之间的关系如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
InferenceService
 ├── 模型格式
 ├── 模型地址
 ├── 启动参数
 └── 资源需求
 |
 | 根据 modelFormat 匹配
 v
ServingRuntime / ClusterServingRuntime
 ├── 模型服务器镜像
 ├── 默认启动参数
 ├── 支持的模型格式
 └── 推理协议

2.1 InferenceService

InferenceService 用于描述一个模型服务需要什么,包括模型格式、模型地址、运行参数、副本数和资源需求。

一个最小的 InferenceService 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
 name: qwen-llm
spec:
 predictor:
 model:
 modelFormat:
 name: huggingface
 storageUri: pvc://qwen-model

InferenceService 的 Spec 可以包含以下三个逻辑部分:

组成部分 是否必需 作用
Predictor 加载模型并执行预测或生成
Transformer 请求预处理和响应后处理
Explainer 生成模型解释

对于大模型推理,通常只需要 Predictor。上一篇的 Qwen 服务也只配置了 Predictor。

这里的 Predictor 是一个逻辑角色。到了 Standard 模式下,它最终会变成名为 qwen-llm-predictor 的 Deployment 和 Service。

2.2 ServingRuntime 和 ClusterServingRuntime

InferenceService 描述要运行什么模型,ServingRuntime 描述使用什么模型服务器运行。

Runtime API 按作用范围分为两种:

  • ServingRuntime 只在当前 Namespace 中生效
  • ClusterServingRuntime 则可以被整个集群中的 InferenceService 使用

一个简化后的 HuggingFace Runtime 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: serving.kserve.io/v1alpha1
kind: ClusterServingRuntime
metadata:
 name: kserve-huggingfaceserver
spec:
 containers:
 - name: kserve-container
 image: kserve/huggingfaceserver:v0.18.0
 supportedModelFormats:
 - name: huggingface
 version: "1"
 autoSelect: true

containers 定义模型服务器的镜像和启动方式,supportedModelFormats 声明它支持哪些模型格式。

当 InferenceService 没有显式指定 Runtime 时,KServe 会根据 modelFormat、版本、协议以及 Runtime 的 autoSelectpriority 自动匹配。

因此:

1
2
modelFormat:
 name: huggingface

表示模型格式为 huggingface,KServe 会选择支持该格式的 Runtime。它并不表示 Pod 中只能使用 Hugging Face Transformers 进行推理。

2.3 其他 CRD

除了 InferenceService 和 Runtime,KServe 还提供了一些面向特定场景的 CRD:

  • InferenceGraph 编排多个 InferenceService,实现路由、分支和服务组合
  • ClusterStorageContainer 定义不同模型存储协议对应的初始化容器和匹配规则
  • TrainedModel 在 ModelMesh 场景中声明需要加载或卸载的模型

上一篇的 Demo 没有使用这些资源,后续遇到对应场景时再单独分析。

3. 核心组件

CRD 只描述期望状态,真正读取这些对象并创建模型服务的是 KServe 控制面。

3.1 Webhook

Webhook 位于资源写入 Kubernetes API 的入口。InferenceService Admission Webhook 负责补充默认值,并拒绝不符合 API 约束的配置;Pod Mutating Webhook 则会在 Pod 创建时注入模型存储相关配置。

在当前环境中,Webhook 和 Controller 运行在同一个 kserve-controller-manager Pod 中,但承担不同职责。

3.2 KServe Controller

KServe Controller 持续 Watch InferenceService,并通过 Reconcile 循环让实际状态与期望状态保持一致。

它主要负责:

  • 匹配 ServingRuntime 或 ClusterServingRuntime
  • 合并 Runtime 与 InferenceService 中的容器配置
  • 创建工作负载、Service 和网络入口
  • 处理模型存储和健康检查配置
  • 观察底层资源状态并回写 InferenceService Status

Controller 只负责资源编排。Deployment 创建以后,Pod 由 Kubernetes Scheduler 调度,GPU 由 NVIDIA Device Plugin 分配;Gateway Controller 负责让 HTTPRoute 生效,实际流量由 Envoy Proxy 转发。

3.3 Predictor Pod 内部结构

以上一篇的 Qwen 服务为例,Predictor Pod 内部的层次如下:

1
2
3
4
Predictor Pod
 └── HuggingFaceServer(容器主进程)
 └── vLLM Backend
 └── NVIDIA GPU

Predictor Pod 是模型服务的运行载体,容器主进程是 HuggingFaceServer。它负责启动模型服务并提供 OpenAI 兼容接口。

当前模型使用 vLLM 作为 HuggingFaceServer 的推理后端。HuggingFaceServer 会在同一个容器中创建 vLLM 引擎,由 vLLM 使用 GPU 执行推理。

4. Qwen Demo 工作流程

前面介绍的 CRD 和组件,最终会通过 Controller 的调谐过程串起来。

上一篇的 qwen-llm 使用 Standard 模式,下面只分析该模式下的资源创建和请求流程。

上一篇使用的核心配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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:
 limits:
 nvidia.com/gpu: "1"

qwen-model PVC 是上一篇提前创建的,InferenceService 只负责引用它。

4.1 控制面资源创建流程

从提交 InferenceService 到服务 Ready,完整流程如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
1. 用户提交 InferenceService/qwen-llm
 |
2. Webhook 默认化并校验配置
 |
3. Controller 根据 huggingface 匹配 ClusterServingRuntime
 |
4. 合并启动参数、镜像和 GPU 资源,并写入模型存储注解
 |
5. 创建 Deployment、Service 和 HTTPRoute
 |
6. Pod Mutating Webhook 注入 PVC volume 和 volumeMount
 |
7. Kubernetes 调度 Pod、挂载 PVC 并分配 GPU
 |
8. Pod 启动并加载模型,Controller 汇总状态并回写 InferenceService

可以从 InferenceService Status 查看最终选择结果:

1
2
kubectl get inferenceservice qwen-llm -n kserve-test \
 -o jsonpath='{.status.deploymentMode}{"\n"}{.status.clusterServingRuntimeName}{"\n"}'
1
2
Standard
kserve-huggingfaceserver

Controller 选择 kserve-huggingfaceserver,再把 InferenceService 中的参数和 GPU 资源合并到 Runtime 提供的容器模板中。最终 Deployment 使用 GPU 版本的 HuggingFaceServer 镜像:

1
docker.m.daocloud.io/kserve/huggingfaceserver:v0.18.0-gpu

Standard 模式下创建的资源关系如下:

1
2
3
4
5
6
7
InferenceService/qwen-llm
├── Deployment/qwen-llm-predictor
│ └── ReplicaSet
│ └── Pod
├── Service/qwen-llm-predictor
├── HTTPRoute/qwen-llm
└── HTTPRoute/qwen-llm-predictor

Deployment 可用、HTTPRoute 就绪后,Controller 会把底层状态汇总到 InferenceService:

1
2
3
4
5
6
7
8
status:
 conditions:
 - type: IngressReady
 status: "True"
 - type: PredictorReady
 status: "True"
 - type: Ready
 status: "True"

4.2 模型 Pod 启动流程

storageUri 描述模型的来源,不是容器内的最终路径,也不是 HuggingFaceServer 的启动参数。Controller 会将 pvc://qwen-model 写入 Pod 模板注解;ReplicaSet 创建 Pod 时,KServe Pod Mutating Webhook 读取该注解,将 qwen-model 解析为 PVC 名称,并向模型容器注入 PVC volume 和挂载到 /mnt/models 的 volumeMount。

如果这里使用 hf:// 地址,Webhook 则会注入 Storage Initializer init container,先从 Hugging Face Hub 下载模型,再通过共享的 EmptyDir volume 将模型提供给模型容器。

HuggingFaceServer 启动后,backend 默认为 auto。GPU 镜像中包含 vLLM;当 vLLM 可用且模型架构在 vLLM 的支持列表中时,HuggingFaceServer 会创建 vLLM 后端并从 /mnt/models 加载模型。

实际日志如下:

1
2
3
Initializing a V1 LLM engine (v0.19.0)
device_config=cuda
Starting to load model /mnt/models...

kserve-huggingfaceserver 是本次部署选择的 Runtime,vLLM 是 HuggingFaceServer 容器内自动选择的推理后端。因此,这次部署不需要单独指定 vLLM Runtime;集群中是否还存在其他自定义 vLLM Runtime,与这条执行链路无关。

4.3 在线请求流程

服务 Ready 后,上一篇的 OpenAI Chat Completions 请求沿着下面的路径进入模型:

1
2
3
4
5
6
Client
 -> Envoy Proxy(匹配 HTTPRoute/qwen-llm)
 -> Predictor Pod:8080(backendRef: Service/qwen-llm-predictor:80)
 -> HuggingFaceServer
 -> vLLM Backend
 -> NVIDIA GPU

这条请求链路属于数据面。KServe Controller 会继续维护 Deployment 和 InferenceService 状态,但不会参与请求转发。

5. 总结

结合上一篇的 Qwen Demo,KServe 的整个工作过程可以概括为:

KServe Standard 模式控制面与数据面架构

  • InferenceService 描述模型服务需要什么
    • modelFormat 声明模型格式,供 KServe 匹配 Runtime
    • storageUri 指定模型存储地址
    • args 设置模型服务器和推理引擎的启动参数
    • resources 声明 CPU、内存和 GPU 等资源需求
  • ClusterServingRuntime/kserve-huggingfaceserver 提供模型服务器镜像和启动方式
  • KServe Controller 匹配 Runtime、合并配置,并创建 Deployment、Service 和 HTTPRoute
  • Kubernetes 负责调度 Pod、挂载 PVC 和分配 GPU
  • Envoy Proxy 应用 HTTPRoute 规则,通过 Predictor Service 标识的后端将请求发送到 Ready Pod
  • HuggingFaceServer 使用 vLLM Backend,由 vLLM 通过 GPU 执行推理

一个 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,说明下面这条链路已经打通。这里的 HTTPRoute 是 Envoy Proxy 使用的路由配置,Envoy Gateway Controller 不在在线请求链路中:

1
2
3
4
5
6
Client
 -> Envoy Proxy(应用 HTTPRoute/qwen-llm 的规则)
 -> Predictor Pod(后端由 Service/qwen-llm-predictor 标识)
 -> KServe HuggingFaceServer
 -> vLLM
 -> GPU

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

总结

到这里,我们已经用 KServe 跑通了一条完整的 LLM 推理链路,从本地 PVC 加载模型,再通过 Envoy Gateway 管理的 Envoy Proxy 调用 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. 参考文章