Day 25 在兩張 L40S 的叢集上把 llm-d 跑起來,過程是三份 values 檔、一層 kustomize,再自己補三個檔案讓 DRA 能用。跑起來之後回頭看,那些檔案裡大部分的內容不是在描述要跑什麼模型,是在描述這些物件之間要怎麼接。
KServe 的 LLMInferenceService 處理的是這一段。今天跑它的最小範例,看一份宣告實際生出哪些物件,以及哪些沒生出來。
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 抓下來的。
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 帶進來的。
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 改過幾次留下的,只有一個在跑。
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 都沒上場,因為宣告裡沒有寫到那些事。
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 的。
範例裡跟 GPU 有關的只有這一行,resources 裡沒有任何加速器資源:
annotations:
serving.kserve.io/exp-dra-device-class: gpu.nvidia.com
gpu.nvidia.com 是這個叢集的 DRA driver 註冊的名字。後面那個 claim 配到卡,狀態是 allocated,reserved,就表示同名的 DeviceClass 在叢集裡存在。
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 的名字帶著 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