iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Kubernetes

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

【Day 25】llm-d:Kubernetes 上從單機 vLLM 到分散式推理

  • 分享至 

  • xImage
  •  

Day 15 把 vLLM 在 Kubernetes 上跑起來,一個 Pod 就是一個引擎,自己管自己的記憶體、自己的 prefix cache。後面幾天加的東西多半也還在那一台裡面,換引擎、把一台拆成 prefill 和 decode 兩段。

但一台不夠的時候要開第二台第三台,而它們互相不知道對方手上有什麼。Day 24 補上了誰來決定請求去哪一台,不過那天掛的 LWEPP 用輪詢,不看後端的任何狀態,所以看到的是分工,不是品質。

llm-d 是把這些引擎當成一組來服務的那一層。Day 2 提過它的輪廓,說它會先想過再決定請求去哪一台,依據是每台手上有哪些算好的結果、還剩多少記憶體、佇列排多長。今天把它裝起來,看它挑得不一樣在哪裡。


llm-d 是什麼

llm-d 是一套 Kubernetes 原生的分散式 LLM 推理堆疊,CNCF Sandbox 專案。它自己不服務請求:

負責什麼
vLLM 載入模型、管 KV cache、算 token、提供 OpenAI 格式的 HTTP endpoint。請求最後是它處理的
llm-d 決定有幾台 vLLM、它們怎麼擺、每一發請求去哪一台

引擎除了 vLLM 也支援 SGLang。

它交付的是一組測過的部署配方,涵蓋一個最佳化過的基準、延遲預測路由、精確的 prefix cache 感知路由、分層 prefix cache、prefill 和 decode 分離、MoE 的 wide expert parallelism、流量控制與公平性、推理池自動擴縮。

今天從這份清單裡只取排程這一件。

挑副本的那個元件

一組 Pod 跑同一個模型的時候,請求要落在哪一台,這件事由一個獨立的服務決定,叫 EPP。請求進來之後 Envoy 先暫停,把請求送給它,拿到「打哪一台」的答案再送出去。

EPP 怎麼決定,由裝在它裡面的外掛決定。llm-d 把這個過程拆成三段:

階段 做什麼
Filters 先刪掉不該考慮的候選
Scorers 剩下的各給一個分數,可以加權
Picker 依分數挑一個

EPP 要挑的是哪一組 Pod、以及要問哪個 EPP,寫在一個叫 InferencePool 的物件裡。它用 label 選出成員,再指名負責挑選的那個服務。


實驗環境

兩台 EC2 g6e.2xlarge,各一張 L40S 46068 MiB,k3s。

這個叢集的 GPU 由 DRA 分配

kubectl get resourceslices
00000-gpu.nvidia.com-ip-172-31-26-39-9fcql   ip-172-31-26-39   gpu.nvidia.com
00000-gpu.nvidia.com-ip-172-31-30-5-6bx85    ip-172-31-30-5    gpu.nvidia.com

一台一筆,裝置類別是 gpu.nvidia.com。卡的資訊登記在這裡,由 DRA 負責分配。

kubectl get nodes -o json | python3 -c "
import json,sys
for n in json.load(sys.stdin)['items']:
    a=n['status']['allocatable']
    print(n['metadata']['name'], {k:v for k,v in a.items() if 'nvidia' in k.lower()} or '(沒有)')
"
ip-172-31-26-39 (沒有)
ip-172-31-30-5 (沒有)

節點的可配置資源裡沒有 nvidia.com/gpu。所以要卡的 Pod 不能在 resources.limits 寫那個資源,得改成宣告一筆 claim。

模型伺服器的樣板要改

llm-d 的模型伺服器樣板是對著 device plugin 和大機器寫的:

grep -A6 "resources:" guides/optimized-baseline/modelserver/gpu/vllm/base/patch-vllm.yaml
          resources:
            limits:
              cpu: '16'
              memory: 128Gi
              nvidia.com/gpu: 2
樣板預設 這兩台
卡 每個副本兩張,nvidia.com/gpu: 2 每台一張,而且要走 claim
模型 Qwen3-32B 單張 46 GB 放不下
cpu limits 16、requests 8 整台 8 vCPU
memory limits 128Gi、requests 96Gi 整台 61 GiB

replicas: 2 不用改。配上 TP1 剛好一台一個副本,而那是後面要挑副本的前提。

要一張卡:ResourceClaimTemplate

用 DRA 取得 GPU 的 Pod 不直接寫要多少資源,它宣告的是一筆 claim,而 claim 長什麼樣子寫在 ResourceClaimTemplate 裡。Deployment 每生一個 Pod,就照這份樣板產生一筆 claim。

cd ~/llm-d/guides/optimized-baseline/modelserver/gpu/vllm/base
cat > resource-claim-templates.yaml <<'YAML'
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
  name: nvidia-decode-claim
  labels:
    llm-d.ai/role: decode
spec:
  spec:
    devices:
      requests:
        - name: nvidia
          exactly:
            deviceClassName: gpu.nvidia.com
            count: 1
YAML

deviceClassName 填 gpu.nvidia.com,就是前面 resourceslices 登記的那個類別。count: 1 是一個 Pod 要一張卡。

讓名字對得上:nameReference

這份 kustomization 有 namePrefix,套用的時候會把所有物件的名字加上前綴,nvidia-decode-claim 會變成 optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim。

但 Pod 裡引用樣板的那個欄位 resourceClaimTemplateName 不在 kustomize 預設會改的名單上。它會留著原本的名字,而那個名字的物件已經不存在了。

nameReference 就是告訴 kustomize 那個欄位也要跟著改:

cat > namereference.yaml <<'YAML'
nameReference:
  - kind: ResourceClaimTemplate
    group: resource.k8s.io
    fieldSpecs:
      - path: spec/template/spec/resourceClaims/resourceClaimTemplateName
        kind: Deployment
YAML

兩個檔案都要掛進 kustomization.yaml,一個當資源,一個當設定:

resources:
  - ../../../../../recipes/modelserver/base/single-host/default
  - resource-claim-templates.yaml

namePrefix: optimized-baseline-nvidia-gpu-vllm-

configurations:
  - namereference.yaml

改 patch-vllm.yaml

diff patch-vllm.yaml.orig patch-vllm.yaml
14c14
<             - "Qwen/Qwen3-32B"
---
>             - "Qwen/Qwen2.5-7B-Instruct"
16,19c16
<             - "--tensor-parallel-size=2"
<             - "--enable-auto-tool-choice"
<             - "--tool-call-parser=hermes"
<             - "--reasoning-parser=qwen3"
---
>             - "--tensor-parallel-size=1"
45,47c42,43
<               cpu: '16'
<               memory: 128Gi
<               nvidia.com/gpu: 2
---
>               cpu: '4'
>               memory: 24Gi
49,51c45,48
<               cpu: '8'
<               memory: 96Gi
<               nvidia.com/gpu: 2
---
>               cpu: '2'
>               memory: 16Gi
>             claims:
>               - name: nvidia-decode-claim
73a71,73
>       resourceClaims:
>         - name: nvidia-decode-claim
>           resourceClaimTemplateName: nvidia-decode-claim
改了什麼 為什麼
模型換成 Qwen2.5-7B-Instruct 單張 46 GB 放不進 32B
--tensor-parallel-size 從 2 改成 1 每台只有一張卡
拿掉三個 Qwen3 專屬旗標 Qwen2.5 不是 reasoning 模型
cpu 降到 4 和 2、memory 降到 24Gi 和 16Gi 整台只有 8 vCPU 和 61 GiB。照抄的話模型伺服器吃光一個節點,EPP 和 Envoy 會排不進去
拿掉 nvidia.com/gpu,Pod 層加 resourceClaims、容器層加 claims Pod 層宣告要這筆 claim,容器層說這個容器用它

有一處刻意不動:這份 kustomization 的標籤還是 llm-d.ai/model: Qwen3-32B。它帶著 includeSelectors: true,改了會連 Deployment 的 selector 和 EPP 的比對一起動到。

套用前先看展開的結果

kubectl kustomize . | grep -nE "resourceClaimTemplateName|^  name:|deviceClassName|nvidia.com/gpu|tensor-parallel|Qwen"
42:        - Qwen/Qwen2.5-7B-Instruct
44:        - --tensor-parallel-size=1
109:        resourceClaimTemplateName: optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim
125:kind: ResourceClaimTemplate
133:  name: optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim
140:          deviceClassName: gpu.nvidia.com

行 109 引用的名字和行 133 物件的名字一致,nameReference 生效了。整份輸出裡沒有 nvidia.com/gpu。

裝 router

chart 的預設是 EPP 四核加 Envoy sidecar 四核,而一台只有八核,兩個加起來剛好把節點佔滿。覆寫成各一核。

Envoy 的 --concurrency 也要一起降。它決定 Envoy 開幾個工作執行緒,不設的話會照實體核心數開,跟 cgroup 給的配額對不上。

cat ~/llm-d/small-node.values.yaml
router:
  epp:
    resources:
      requests: { cpu: "1", memory: 2Gi }
      limits:   { memory: 4Gi }
  proxy:
    resources:
      requests: { cpu: "1", memory: 2Gi }
      limits:   { memory: 4Gi }
    args:
      - "--service-node"
      - "envoy-sidecar"
      - "--log-level"
      - "warn"
      - "--concurrency"
      - "2"
      - "--drain-strategy"
      - "immediate"
      - "--drain-time-s"
      - "60"
      - "-c"
      - "/etc/envoy/envoy.yaml"
helm install optimized-baseline oci://ghcr.io/llm-d/charts/llm-d-router-standalone \
  -f guides/recipes/router/base.values.yaml \
  -f guides/optimized-baseline/router/optimized-baseline.values.yaml \
  -f ~/llm-d/small-node.values.yaml \
  -n llm-d --version v0
Pulled: ghcr.io/llm-d/charts/llm-d-router-standalone:v0
Digest: sha256:1adebe98369a77757cac0fa3b2c65a6f716fb70449cada2d822d726266bf223f
NAME: optimized-baseline   STATUS: deployed   REVISION: 1
kubectl -n llm-d rollout status deploy/optimized-baseline-epp --timeout=180s
deployment "optimized-baseline-epp" successfully rolled out

裝模型伺服器

kubectl apply -n llm-d -k ~/llm-d/guides/optimized-baseline/modelserver/gpu/vllm/base/
serviceaccount/optimized-baseline-nvidia-gpu-vllm-sa created
deployment.apps/optimized-baseline-nvidia-gpu-vllm-decode created
resourceclaimtemplate.resource.k8s.io/optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim created
kubectl -n llm-d get resourceclaims
optimized-baseline-nvidia-gpu-vllm-decode-nvidia-decodejcd4v   allocated,reserved   6s
optimized-baseline-nvidia-gpu-vllm-decode-nvidia-decodesz9kq   allocated,reserved   6s

兩個 Pod 各產生一筆 claim,狀態都是 allocated,reserved,表示卡已經配下去而且被佔住了。

kubectl -n llm-d get pod -o wide
optimized-baseline-epp-7c6b55fd99-p89sl                      2/2   Running   10.42.1.203   ip-172-31-30-5
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg   1/1   Running   10.42.0.145   ip-172-31-26-39
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx   1/1   Running   10.42.1.204   ip-172-31-30-5

兩個副本一台一個。


一、一份宣告展開成什麼

kubectl -n llm-d get all,inferencepool,resourceclaimtemplate
pod/optimized-baseline-epp-7c6b55fd99-p89sl                      2/2   Running
pod/optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg   1/1   Running
pod/optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx   1/1   Running

service/optimized-baseline-epp   ClusterIP   10.43.181.94   9002/TCP,9090/TCP,80/TCP

deployment.apps/optimized-baseline-epp                      1/1
deployment.apps/optimized-baseline-nvidia-gpu-vllm-decode   2/2

inferencepool.inference.networking.k8s.io/optimized-baseline
resourceclaimtemplate.resource.k8s.io/optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim

InferencePool 在這裡出現了,是 chart 自己建的。它也可以不建,router.inferencePool.create=false 就會跳過,預設是建。

EPP 的 Pod 是 2/2,Envoy sidecar 跟 EPP 同住一個 Pod。一個 Service 開三個 port:

port 誰用
9002 EPP 的 ext_proc gRPC,Envoy 問它要打哪一台
9090 EPP 的指標
80 Envoy sidecar 的 HTTP 入口,客戶端打這個

EPP 自己印出實際生效的設定

kubectl -n llm-d logs deploy/optimized-baseline-epp -c epp | grep "parsed config"
"body":"parsed config","scheduler-config":"{ProfileHandler: single-profile-handler,
 Profiles: map[default:{
   Filters: [prefix-cache-affinity-filter, utilization-detector],
   Scorers: [token-load-scorer: 1.000000],
   Picker:  max-score-picker}]}"

裡面是它實際載入的外掛。

外掛 做什麼
prefix-cache-affinity-filter filter,留下可能已經有這段開頭快取的那幾台
utilization-detector filter,判斷後端是不是已經忙不過來
token-load-scorer scorer,依 token 的負載給每台打分
max-score-picker picker,取分數最高的那台

這份清單有兩個地方跟 values 檔對不起來。

utilization-detector 沒有寫在 values 檔裡,是解析的時候自己加進去的。

而 llm-d 還有 kv-cache-utilization-scorer 和 queue-scorer 可以用,這份部署沒有啟用,實際打分的只有 token-load-scorer 一個。

所以可以選的外掛有哪些,跟一份部署實際跑哪幾個,是兩回事。要知道跑的是哪幾個,看 EPP 的這一行,不要看 values 檔。

有一個參數是照別的硬體調出來的

prefix-cache-affinity-filter 有一個參數 peakPrefillThroughput,預設值 15928。這個數字是在 H100 80 GB 上跑 Qwen3-32B、TP2 量出來的,換硬體或換模型要用 guides/recipes/router/calibration 重新量一次。

這輪是 L40S、Qwen2.5-7B、TP1,所以那個數字不適用。後面看到的挑選行為是真的,但挑得好不好,在這個環境下談不了。


二、落點不在回應裡,在 vLLM 的日誌裡

kubectl -n llm-d port-forward svc/optimized-baseline-epp 8080:80 >/tmp/pf.log 2>&1 &
echo $! > /tmp/pf.pid
sleep 5
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
x-went-into-resp-headers: true
date: Fri, 09 Oct 2026 06:23:11 GMT
server: uvicorn
content-type: application/json
transfer-encoding: chunked

{"id":"chatcmpl-0cb337dd-...","model":"Qwen/Qwen2.5-7B-Instruct",
 "choices":[{"message":{"role":"assistant","content":"Hello! How can I assist you today?"},
   "finish_reason":"stop"}],
 "system_fingerprint":"vllm-0.30.0-ad8c4a52",
 "usage":{"prompt_tokens":30,"total_tokens":40,"completion_tokens":10}}

回應標頭裡沒有落點。x-went-into-resp-headers 是 Envoy 加的標記,表示回應經過它處理過,跟打到哪一台無關。

Envoy 的 access log 本來記得到落點,但這個 chart 配的格式只輸出兩個欄位:

kubectl -n llm-d logs deploy/optimized-baseline-epp -c envoy-proxy --tail=1
{"start_time":"2026-10-09T06:23:12.445Z","method":"POST",...}

那個 ... 是字面的,日誌裡就長這樣,只有 start_time 和 method,沒有 upstream_host。

可以用的訊號在 vLLM 自己那邊:

kubectl -n llm-d logs optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg --tail=3
(APIServer pid=1) INFO:     Application startup complete.
(APIServer pid=1) INFO 10-09 06:23:12 [loggers.py:315] Engine 000: Avg prompt throughput: 3.0 tokens/s, ...
(APIServer pid=1) INFO:     10.42.1.203:45508 - "POST /v1/chat/completions HTTP/1.1" 200 OK

10.42.1.203 是 EPP Pod 的 IP,而這一行只出現在 bb8lg 的日誌裡,所以那一發是它服務的。

後面就數每個 vLLM Pod 的 POST /v1/chat/completions 行數,不用改任何設定。


三、同一個 prompt 會不會黏在同一台

cat > ~/count.sh <<'SH'
#!/bin/bash
for p in $(kubectl -n llm-d get pod -o name | grep vllm | sed 's|pod/||'); do
  n=$(kubectl -n llm-d logs "$p" | grep -c 'POST /v1/chat/completions')
  printf '  %-62s %s\n' "$p" "$n"
done
SH
chmod +x ~/count.sh
~/count.sh
  optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg     1
  optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx     0

起點不是零比零,前面那一發已經落在 bb8lg 上。

ask() {
  curl -s -o /dev/null -X POST localhost:8080/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d "{\"model\":\"Qwen/Qwen2.5-7B-Instruct\",\"messages\":[{\"role\":\"user\",\"content\":\"$1\"}],\"max_tokens\":8}"
}
for i in 1 2 3; do ask "Explain Kubernetes DRA in one sentence."; sleep 2; done
~/count.sh
  optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg     4
  optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx     0

同一個 prompt 連打三發,三發都落在 bb8lg。

這排除了輪詢。兩台副本輪流發的話,三發會是兩比一。

但還有一個解釋沒排除:兩台都閒著的時候 token-load-scorer 給出平手,而 picker 在平手時固定挑同一台。那樣也會是三比零,跟 prompt 無關。要分開這兩種解釋,換掉 prompt 再打一次。

ask "What is the capital of France?"; sleep 2
ask "Write a haiku about storage classes."; sleep 2
ask "List three Linux signals."; sleep 2
~/count.sh
  optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg     6
  optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx     1
kill $(cat /tmp/pf.pid)

三個互不相干的 prompt,落點變成兩比一,不再全部黏在同一台。

落點 排除了什麼
同一個 prompt 三發 3 比 0 輪詢,輪詢會是兩比一
不同的 prompt 三發 2 比 1 固定挑同一台,那樣會是三比零

兩組各排除一種固定的規則。剩下的解釋是落點取決於請求的內容。

這就是 prefix-cache-affinity-filter 的作用:共用開頭的請求會被帶回已經有那段 prefix cache 的那一台。

第二組的兩比一不能拿來說它會平均分配,三發的樣本講不了分佈。這一節證明的是落點會因為 prompt 而改變,不是分佈長什麼樣。

Day 15 量過引擎那一側:同一個開頭的請求可以重用已經算好的 block,省掉重算。但那只在同一台機器上成立,請求換一台就沒有那些 block。所以兩層要配合,引擎留得住,路由也得把請求送回原來那台。


小結

llm-d 做的事是把幾台各自獨立的 vLLM 變成一個服務。請求進來有人決定去哪一台,而不是隨便挑一台有空的。

這個決定可以依據請求的內容,也可以依據後端當下的狀態。這天看到的是前者:共用開頭的請求會回到同一台,換了開頭就不會。輪詢做不到這件事,因為輪詢不看請求。

而決定的方式是設定出來的,不是寫死的。篩選、打分、挑選三段各自換外掛,就換了一套策略。

排程只是 llm-d 的其中一條配方,prefill 和 decode 分離、推理池自動擴縮、流量控制那些都在同一套東西裡。


參考資料

llm-d
llm-d/llm-d
llm-d-router
Artifacts
EPP Configuration
llm-d-modelservice


上一篇
【Day 24】Gateway API Inference Extension:突破 Kubernetes 傳統路由限制的推理負載平衡
下一篇
【Day 26】KServe LLMInferenceService:一份宣告在 Kubernetes 上展開成 vLLM 與 DRA 物件
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言