iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Kubernetes

凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜系列 第 26 篇

【Day 26】KServe LLMInferenceService:一份宣告在 Kubernetes 上展開成 vLLM 與 DRA 物件

  • 分享至 

  • xImage
  •  

Day 25 在兩張 L40S 的叢集上把 llm-d 跑起來,過程是三份 values 檔、一層 kustomize,再自己補三個檔案讓 DRA 能用。跑起來之後回頭看,那些檔案裡大部分的內容不是在描述要跑什麼模型,是在描述這些物件之間要怎麼接。

KServe 的 LLMInferenceService 處理的是這一段。今天跑它的最小範例,看一份宣告實際生出哪些物件,以及哪些沒生出來。


LLMInferenceService 是一份宣告,controller 負責生出底下的物件

KServe 是 Kubernetes 上的模型服務專案,把一個訓練好的模型變成線上可以打的 endpoint。它的服務物件分兩種,InferenceService 是一般的模型服務,LLMInferenceService 是給 LLM 的,在 KServe 裡屬於兩個獨立的元件,chart 也分開。

會分出一個新物件,是因為 LLM 有幾件事一般的模型服務表達不出來:模型大到要獨佔一張卡或跨多張卡、要管 KV cache、要在多個副本之間挑一台、prefill 和 decode 可以拆開跑。這些就是 LLMInferenceService 的欄位在管的事。

一個 LLMInferenceService 物件描述要跑哪個模型、容器長什麼樣、流量怎麼進來、要幾個副本。controller 讀這份宣告,把對應的 Deployment、Service、Gateway、HTTPRoute 這些東西建出來。

層 誰 做什麼
引擎 vLLM 載入模型、管 KV cache、算 token、回應請求
編排與排程 llm-d 決定有幾台 vLLM、怎麼擺、每一發請求去哪一台
宣告 KServe 的 LLMInferenceService 寫一份,controller 把上面兩層的物件生出來

spec 的欄位對應這幾層:

欄位 內容
model 模型從哪裡來、請求裡用什麼名字叫它、要不要掛 LoRA
template 單節點的那個容器本身
worker 一個模型要拆到多個 Pod 的時候用,會長出 LeaderWorkerSet
prefill 把 prefill 單獨拆出來跑的時候用
router 流量那一層,底下分 gateway、ingress、route、scheduler。ingress 是給不走 Gateway API 的那條路用的,scheduler 就是 EPP
parallelism tensor、pipeline、data 三個數量,expert 一個開關,另有 dataLocal 和 dataRPCPort
replicas 幾個副本

這些欄位的預設值不寫在 controller 裡面,是一組 LLMInferenceServiceConfig 物件。範例裡 router 下面那三個空物件的意思是用預設,而預設要另外裝進叢集,chart 不會順便帶。

欄位存在也不等於有人用它。那組預設會把 tensor、data、expert 轉成 vLLM 的參數傳下去,而 pipeline 一次都沒被引用,所以這個欄位寫在宣告裡也不會傳到 vLLM。


實驗環境

兩台 EC2 g6e.2xlarge,各一張 L40S 46068 MiB,k3s v1.36.4,GPU 由 DRA 分配,沒有 device plugin。API 版本 serving.kserve.io/v1alpha2。

裝的東西分四段

第一段是 cert-manager,webhook 要憑證。

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.2/cert-manager.yaml
kubectl -n cert-manager rollout status deploy/cert-manager --timeout=300s
kubectl -n cert-manager rollout status deploy/cert-manager-webhook --timeout=300s
kubectl -n cert-manager rollout status deploy/cert-manager-cainjector --timeout=300s

第二段是 KServe 的兩個 chart。InferenceService 那一組的 kserve-crd 和 kserve-resources 沒裝,Knative 也沒裝,LLMInferenceService 照樣跑起來。

helm install kserve-llmisvc-crd \
  oci://ghcr.io/kserve/charts/kserve-llmisvc-crd \
  --version v0.21.0-rc1 --namespace kserve --create-namespace

helm install kserve-llmisvc-resources \
  oci://ghcr.io/kserve/charts/kserve-llmisvc-resources \
  --version v0.21.0-rc1 --namespace kserve \
  --set kserve.storage.resources.limits.memory=8Gi \
  --set kserve.storage.resources.requests.memory=2Gi

後面那兩個 --set 是前置條件,chart 預設給 storage initializer 的記憶體上限對 15 GB 的模型不夠。

第三段是那組預設。這一步跟 chart 分開,裝完之後叢集裡有十三個 LLMInferenceServiceConfig。

kubectl apply -k 'https://github.com/kserve/kserve/config/llmisvcconfig?ref=v0.21.0'

其中 config-llm-scheduler 裡面是 InferencePool 的欄位加上 Go template,endpointPickerRef 這一段長這樣:

endpointPickerRef:
  failureMode: FailOpen
  kind: Service
  name: |-
    {{ ChildName .ObjectMeta.Name `-epp-service` }}
  port:
    number: 9002

9002 是 EPP 的 ext_proc 埠。

第四段是範例本身。

kubectl create namespace kserve-demo
curl -sLO https://raw.githubusercontent.com/kserve/kserve/master/docs/samples/llmisvc/managed-dra/llm-inference-service-managed-dra-minimal.yaml
kubectl apply -n kserve-demo -f llm-inference-service-managed-dra-minimal.yaml
namespace/kserve-demo created
llminferenceservice.serving.kserve.io/qwen2-7b-instruct-dra-minimal created

這份宣告的全文:

apiVersion: serving.kserve.io/v1alpha2
kind: LLMInferenceService
metadata:
  name: qwen2-7b-instruct-dra-minimal
  # Minimal Managed DRA: request one device of the given DeviceClass.
  annotations:
    serving.kserve.io/exp-dra-device-class: gpu.nvidia.com
spec:
  model:
    uri: hf://Qwen/Qwen2.5-7B-Instruct
    name: Qwen/Qwen2.5-7B-Instruct
  replicas: 1
  router:
    scheduler: { }
    route: { }
    gateway: { }
  template:
    containers:
      - name: main
        resources:
          limits:
            cpu: '4'
            memory: 32Gi
          requests:
            cpu: '2'
            memory: 16Gi
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
            scheme: HTTPS
          initialDelaySeconds: 120
          periodSeconds: 30
          timeoutSeconds: 30
          failureThreshold: 5

整份只有二十幾行,而且沒有一個欄位在講 GPU 要怎麼拿,resources 裡只有 cpu 和 memory。model.uri 的 hf:// 決定了模型是由 initContainer 從 Hugging Face 抓下來的。

chart 帶進來的 CRD 裡有兩個掛在 llm-d 的 API group

kubectl get crd | grep -E "kserve|inference"
clusterstoragecontainers.serving.kserve.io              2026-10-09T13:07:35Z
inferencemodelrewrites.llm-d.ai                         2026-10-09T13:08:53Z
inferenceobjectives.llm-d.ai                            2026-10-09T13:08:53Z
inferencepools.inference.networking.k8s.io              2026-10-09T13:08:53Z
inferencepools.inference.networking.x-k8s.io            2026-10-09T13:08:53Z
llminferenceserviceconfigs.serving.kserve.io            2026-10-09T13:07:35Z
llminferenceservices.serving.kserve.io                  2026-10-09T13:08:53Z

inferenceobjectives 和 inferencemodelrewrites 的 API group 是 llm-d.ai。這兩個型別是從 Gateway API Inference Extension 搬到 llm-d/llm-d-router 的,KServe 把它們一起裝進叢集。

兩組時間戳對應兩個 chart,兩個 llm-d.ai 的型別是 -resources 帶進來的。

純 DRA 的叢集要補一個 LIBRARY_PATH

ghcr.io/llm-d/llm-d-cuda 的 LIBRARY_PATH 裡列的是 /usr/local/nvidia/lib 和 /usr/local/nvidia/lib64,那是 device plugin 注入驅動的位置。DRA 的 driver 把 libcuda.so.1 放在 /usr/lib/x86_64-linux-gnu/,而容器裡有元件在執行期編譯,需要連結器找得到它。所以在這個叢集上要把這個路徑加到最前面,原本那九個路徑原封不動留著:

env:
  - name: LIBRARY_PATH
    value: "/usr/lib/x86_64-linux-gnu:/usr/local/nvidia/lib:/usr/local/nvidia/lib64:..."

這是今天唯一一處改範例的地方,而且只有沒裝 device plugin 的叢集需要。


一、一份宣告展開成什麼

kubectl -n kserve-demo get llminferenceservice,all,inferencepool,resourceclaimtemplate,resourceclaims
NAME                                                                  URL   READY   REASON   AGE
llminferenceservice.serving.kserve.io/qwen2-7b-instruct-dra-minimal         True             77m

NAME                                                        READY   STATUS    RESTARTS   AGE
pod/qwen2-7b-instruct-dra-minimal-kserve-58885fb6c5-hx8wv   1/1     Running   0          7m8s

NAME                                                        TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/qwen2-7b-instruct-dra-minimal-kserve-workload-svc   ClusterIP   10.43.186.142   <none>        8000/TCP   73m

NAME                                                   READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/qwen2-7b-instruct-dra-minimal-kserve   1/1     1            1           73m

NAME                                                              DESIRED   CURRENT   READY   AGE
replicaset.apps/qwen2-7b-instruct-dra-minimal-kserve-5856c6b768    0         0         0       32m
replicaset.apps/qwen2-7b-instruct-dra-minimal-kserve-58885fb6c5    1         1         1       7m9s
replicaset.apps/qwen2-7b-instruct-dra-minimal-kserve-69bd84c5bd    0         0         0       73m
replicaset.apps/qwen2-7b-instruct-dra-minimal-kserve-7766879fdc    0         0         0       66m

NAME                                                                              AGE
resourceclaimtemplate.resource.k8s.io/qwen2-7b-instruct-dra-minimal-managed-dra   73m

NAME                                                                                         STATE                AGE
resourceclaim.resource.k8s.io/qwen2-7b-instruct-dra-minimal-kserve-58885f-managed-devgt2hv   allocated,reserved   7m10s

產物就這些:一個 Deployment、一個 Pod、一個 Service,加上 DRA 的 ResourceClaimTemplate 和 ResourceClaim。那四個 ReplicaSet 是這份 Deployment 改過幾次留下的,只有一個在跑。

跑起來的容器是 llm-d 的映像

kubectl -n kserve-demo get pod -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{range .spec.containers[*]}{"  main  "}{.image}{"\n"}{end}{range .spec.initContainers[*]}{"  init  "}{.name}{"  "}{.image}{"\n"}{end}{end}'
qwen2-7b-instruct-dra-minimal-kserve-58885fb6c5-hx8wv
  main  ghcr.io/llm-d/llm-d-cuda:v0.9.0
  init  storage-initializer  kserve/storage-initializer:v0.21.0-rc1

推理的容器是 llm-d 的,前面那個抓模型的 initContainer 才是 KServe 自己的。

那十三個預設裡引用的映像也可以一起看:

映像 用在哪
ghcr.io/llm-d/llm-d-router-endpoint-picker 挑副本的 EPP
ghcr.io/llm-d/llm-d-router-disagg-sidecar prefill 和 decode 分離
ghcr.io/llm-d/llm-d-latency-predictor-{prediction,training}-server 延遲預測
docker.io/vllm/vllm-openai-cpu tokenizer

十三個預設裡套用了三個

kubectl -n kserve-demo get llminferenceservice qwen2-7b-instruct-dra-minimal \
  -o jsonpath='{.status.appliedConfigs}' | jq
[
  { "name": "kserve-config-llm-scheduler",    "namespace": "kserve", "source": "Preset" },
  { "name": "kserve-config-llm-router-route", "namespace": "kserve", "source": "Preset" },
  { "name": "kserve-config-llm-template",     "namespace": "kserve", "source": "Preset" }
]

三筆的 namespace 都是 kserve,而這個 workload 在 kserve-demo,預設是跨 namespace 引用過來的。

status.annotations 裡列的十三個是這個叢集裡有哪些預設可以用,appliedConfigs 才是這一份宣告實際套到哪幾個。decode、prefill、worker-data-parallel、tokenizer、tracing、latency-predictor 都沒上場,因為宣告裡沒有寫到那些事。

RouterReady 為 True,而底下什麼都沒有

kubectl -n kserve-demo get llminferenceservice qwen2-7b-instruct-dra-minimal \
  -o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.status}{"\n"}{end}'
MainWorkloadReady	True
PresetsCombined	True
Ready	True
RouterReady	True
WorkloadsReady	True

RouterReady 是 True,而同一個時間:

kubectl get gatewayclass,gateway -A
error: the server doesn't have a resource type "gatewayclass"

叢集裡連一個 gateway.networking 的 CRD 都沒有。

InferencePool 的 CRD 是 KServe 裝進來的,所以它存在。前面那道 get 有把 inferencepool 列進去,而輸出裡沒有它的區塊,連一行 NAME 標頭都沒有,因為 kubectl 在物件數為零的時候不印空集合。EPP 的 Pod 也沒有,LLMInferenceService 的 URL 欄位是空白的。

宣告裡寫的 router 下面 gateway、route、scheduler 三個空物件
實際生出來的 一個 vLLM Deployment、一個 Service、DRA 兩個物件

RouterReady 這個 condition 在沒事可做的時候也會是 True。要讓排程那一層真的出現,叢集裡至少要有一個 Gateway API 的實作,這一輪沒裝。

打一發請求確認真的在推論

沒有 Gateway,就直接打那個 workload Service:

kubectl -n kserve-demo port-forward svc/qwen2-7b-instruct-dra-minimal-kserve-workload-svc 8080:8000 &
curl -si -X POST localhost:8080/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"Qwen/Qwen2.5-7B-Instruct","messages":[{"role":"user","content":"hi"}],"max_tokens":16}'
HTTP/1.1 200 OK
date: Fri, 09 Oct 2026 14:42:18 GMT
server: uvicorn
content-length: 697
content-type: application/json

{"id":"chatcmpl-91b1f4edc0b39b26","object":"chat.completion","created":1791556938,
 "model":"Qwen/Qwen2.5-7B-Instruct",
 "choices":[{"index":0,"message":{"role":"assistant",
   "content":"Hello! How can I assist you today?"},"finish_reason":"stop"}],
 "system_fingerprint":"vllm-0.26.0-f08abfa3",
 "usage":{"prompt_tokens":30,"total_tokens":40,"completion_tokens":10}}

server 這個 header 是 uvicorn,中間沒有任何 proxy,回應是那個容器裡的 vLLM 直接吐出來的。system_fingerprint 也是 vLLM 的。


二、一行 annotation 取代手寫的 claim 宣告

範例裡跟 GPU 有關的只有這一行,resources 裡沒有任何加速器資源:

annotations:
  serving.kserve.io/exp-dra-device-class: gpu.nvidia.com

gpu.nvidia.com 是這個叢集的 DRA driver 註冊的名字。後面那個 claim 配到卡,狀態是 allocated,reserved,就表示同名的 DeviceClass 在叢集裡存在。

生出來的 ResourceClaimTemplate 跟手寫的結構相同

kubectl -n kserve-demo get resourceclaimtemplate -o yaml | python3 -c "
import sys,yaml
d=yaml.safe_load(sys.stdin)
for it in d.get('items',[]):
    print('name:', it['metadata']['name'])
    print(yaml.dump({'spec': it['spec']}, allow_unicode=True, sort_keys=False))
"
name: qwen2-7b-instruct-dra-minimal-managed-dra
spec:
  metadata: {}
  spec:
    devices:
      requests:
      - exactly:
          allocationMode: ExactCount
          count: 1
          deviceClassName: gpu.nvidia.com
        name: device

Pod 層和容器層的接線也是 controller 補的:

P=$(kubectl -n kserve-demo get pod -o name | head -1)
kubectl -n kserve-demo get $P -o jsonpath='{"resourceClaims: "}{.spec.resourceClaims}{"\n"}{range .spec.containers[*]}{"container "}{.name}{" claims="}{.resources.claims}{"\n"}{end}'
resourceClaims: [{"name":"managed-device","resourceClaimTemplateName":"qwen2-7b-instruct-dra-minimal-managed-dra"}]
container main claims=[{"name":"managed-device"}]

那個 claim 的名字是 managed-device。

跟手寫的那份比:

KServe 生的 手寫的
deviceClassName gpu.nvidia.com gpu.nvidia.com
request 的 name device nvidia
Pod 層 resourceClaims 自動 寫在 patch-vllm.yaml
容器層 resources.claims 自動 寫在 patch-vllm.yaml

結構一模一樣,差的只有請求的名字。這同時說明手寫的那幾個檔案是對的。

上面讀出來的 allocationMode: ExactCount 和 count: 1 兩個欄位不在這張表裡,因為它們是 API server 補的預設,兩邊讀出來都一樣。count 要超過一張卡的時候才會由 annotation 寫進去。

要對照的是宣告 claim 的那一段:手寫要一份 resource-claim-templates.yaml 加上 patch-vllm.yaml 裡的兩處,這裡是一行 annotation。另外那個 namereference.yaml 不在這個對照裡,它處理的是 kustomize 的 namePrefix 改了樣板名字但引用欄位沒跟著改,跟 DRA 無關,而 KServe 沒有用 kustomize。

這組 annotation 只能描述一份裝置請求

這組 annotation 的名字帶著 exp- 前綴,一共四個,而四個合起來描述的是同一個 DeviceRequest。要哪一個 deviceClass、要幾張,加上一段 CEL 當條件,就是全部能表達的東西。

那一份請求會打進每一個 workload 的 pod template,template、worker、prefill.template、prefill.worker 四個都套同一個 deviceClass、同一個數量、同一組條件。prefill 和 decode 的算力需求本來就不對稱,兩邊要不同數量或不同型號的卡,這組 annotation 表達不出來。

能共存的是手寫的 claim。controller 只動名字是 managed-device 的那一個,自己另外寫的 claim 它不碰。


小結

LLMInferenceService 做的事是把一套部署寫成一份宣告。模型來源、容器、流量、加速器在同一個物件裡,controller 把對應的 Kubernetes 物件生出來。

生出來的東西取決於宣告裡寫了什麼,也取決於叢集裡有什麼。這天的範例只要一個副本,叢集裡也沒有 Gateway API 的實作,所以長出來的是一個 vLLM Deployment 和一個 Service,排程那一層沒有出現。RouterReady 是 True 不代表那一層存在,實際套用了哪些設定要看 appliedConfigs。

加速器這一段是宣告層幫得上忙的地方,一行 annotation 生出來的 ResourceClaimTemplate 跟手寫的結構相同。能表達的範圍就是一份裝置請求,四個 workload 的 pod template 共用那一份,prefill 和 decode 要不一樣的卡還是得自己寫。

底下那一層還是 llm-d。推理的映像來自 llm-d,CRD 裡有兩個掛在 llm-d.ai 這個 API group 下。宣告層讓要寫的東西變少,出問題的時候要調的還是同樣那些設定。


參考資料

Understanding LLMInferenceService - KServe
LLMInferenceService Configuration Guide - KServe
Control Plane API - KServe
Installation Concepts - KServe
Managed DRA for LLMInferenceService - kserve/kserve docs/samples/llmisvc/managed-dra/README.md
kserve/kserve PR #5352 Managed DRA support for LLMInferenceService
kserve/website issue #733 docs: add Managed DRA guide for LLMInferenceService
kserve/kserve releases


上一篇
【Day 25】llm-d:Kubernetes 上從單機 vLLM 到分散式推理
下一篇
【Day 27】NVIDIA Dynamo 與 Grove:另一套推理框架,和它底下另一種 Kubernetes 工作負載 API
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言