Day 2 的最後我們提到,vLLM 在 Kubernetes 上是以一個 Pod 為單位運行,理論上應該要以 Deployment 去部署,而那也是 prefill pool 和 decode pool 的形狀。但 Deployment 對這個場景有它的痛點,所以 Kubernetes 生態多了一組新的 API 物件來解決這個問題。
今天我們要來探討這組新物件:LeaderWorkerSet 與 DisaggregatedSet。
Deployment 的複本單位是一個 Pod。 它眼裡沒有「同一組」這件事:沒有 leader、沒有組內編號、名字每次重建都換,也沒有穩定的網路身份。而滾動更新是按 Pod 走的,所以會出現新版本已經在服務、舊版本還在跑的狀態。對無狀態的 API 服務這沒問題,對一個切成好幾塊的模型就是版本對不起來。
StatefulSet 解掉一半。 它給穩定的名字和序號,搭配 headless service 給穩定的 DNS,所以「我是第幾號、我要去找誰」算得出來。但還剩三個缺口:
| 缺口 | 內容 |
|---|---|
| 只有一份範本 | leader 和 worker 不能跑不一樣的指令,也不能吃不一樣的資源 |
| 預設一個一個起 | 一個 Pod 起來並就緒才起下一個,而一組要同時上線的工作等不了 |
| 壞一個只修一個 | 一個 Pod 被重建,其他 Pod 不會被處理 |
最後那一列是關鍵。一組四個 Pod 跑同一個推論,其中一個被重建,剩下三個手上的狀態就不對了,但 StatefulSet 不會管那三個。
所以缺的東西是「一組」這個單位本身。
把一組 Pod 當成一個複本單位的 API。
Deployment 一個複本是一個 Pod,LeaderWorkerSet 一個複本是一組:一個 leader 加上其餘的 worker。replicas 是有幾組,size 是每組幾個 Pod。
| 欄位 | 內容 |
|---|---|
replicas |
幾組,預設 1 |
size |
每組總共幾個 Pod,含 leader,最小 1,預設 1 |
leaderTemplate |
可省略,省略就跟 worker 用同一份範本 |
restartPolicy |
整組重建、啟動完才整組重建、只重啟壞掉那一個,三種 |
startupPolicy |
leader 就緒才建 worker,或是 leader 一建立就同時建 worker |
rolloutStrategy |
更新時最多多開幾組、最多幾組可以不可用 |
三種 restartPolicy 的差別是整篇的重點。預設那個在「組裡任何一個 Pod 被重建」或「任何一個容器被重啟」時把整組全部重建,而最寬鬆那個的行為就是 StatefulSet,只重啟壞掉的那一個。

比 LeaderWorkerSet 高一層的 API,它自己不管 Pod,管的是好幾個 LeaderWorkerSet。
prefill 和 decode 是推論的兩個階段,把它們拆成兩群 Pod 各自跑就是分離式推論,而這兩群的硬體需求和擴縮節奏都不一樣。DisaggregatedSet 把這樣的一群叫做一個 role,而一個 role 對應一個獨立的 LeaderWorkerSet,建在同一個 namespace,名字的規則是 <DisaggregatedSet 名稱>-<slice>-<revision 雜湊>-<role 名稱>。
YAML 裡要寫的就三個欄位:
| 欄位 | 內容 |
|---|---|
roles |
有哪些階段,例如 prefill、decode、encode |
slices |
整套拓樸要複製幾份,一份就是一組完整的 prefill 加 decode |
placementPolicy |
同一個 slice 的 Pod 要擠在一起,還是分散到不同的 domain |
要把這兩個階段分開的理由是瓶頸不同:prefill 在做的是從輸入的 prompt 產生最初的 KV cache,受算力限制,適合比較大的 Pod 組;decode 是一個 token 一個 token 的自迴歸生成,受記憶體頻寬限制,可以跑在比較小的組。
所以每個 role 各自帶一份 leaderWorkerTemplate,size 可以不一樣。 這也是為什麼一個 LeaderWorkerSet 做不到:它的 leader 和 worker 範本是一組、size 只有一個,所以每一組的形狀都一樣,表達不了「兩群各自不同形狀」。
兩個 API 的分工:
| 用 LeaderWorkerSet | 用 DisaggregatedSet |
|---|---|
| 所有推論 Pod 長得一樣 | prefill 和 decode 要不同的卡、不同的映像或不同的組大小 |
| 不需要把 prefill 和 decode 拆開 | 想讓 prefill 和 decode 各自看流量擴縮 |
| 需要細控組內的放置或整組重啟的規則 | 需要多個 role 一起、同步地換版本 |

今天要測的是 Kubernetes 的行為,不需要 GPU,所以我們在沒有 GPU 的 EC2 再用 kind 開一個三節點的叢集。
# kind.yaml
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
name: lws-lab
nodes:
- role: control-plane
image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
- role: worker
image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
- role: worker
image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
兩個 worker 是刻意的,leader 和 worker 要落在不同節點才看得出東西。
kind create cluster --config kind.yaml
kubectl get nodes
NAME STATUS ROLES VERSION
lws-lab-control-plane Ready control-plane v1.36.1
lws-lab-worker Ready <none> v1.36.1
lws-lab-worker2 Ready <none> v1.36.1
helm upgrade -i lws oci://registry.k8s.io/lws/charts/lws --version v0.11.1 \
-n lws-system --create-namespace --wait --timeout 5m
NAME: lws
STATUS: deployed
chart 自己處理 webhook 憑證,不需要 cert-manager。
kubectl get crd -o name | grep -E "leaderworkerset|disaggregated"
customresourcedefinition.apiextensions.k8s.io/disaggregatedsetrolescalers.disaggregatedset.x-k8s.io
customresourcedefinition.apiextensions.k8s.io/disaggregatedsets.disaggregatedset.x-k8s.io
customresourcedefinition.apiextensions.k8s.io/leaderworkersets.leaderworkerset.x-k8s.io
一個 chart 長出三個 CRD,而 DisaggregatedSet 在自己的 API 群組。
預設值直接從裝起來的 CRD 讀,不用猜:
kubectl get crd leaderworkersets.leaderworkerset.x-k8s.io -o json | python3 -c "
import json,sys
p=json.load(sys.stdin)['spec']['versions'][0]['schema']['openAPIV3Schema']['properties']['spec']['properties']
r=p['leaderWorkerTemplate']['properties']['restartPolicy']
print('restartPolicy enum :', r.get('enum'))
print('restartPolicy default:', r.get('default'))
print('spec 頂層欄位 :', sorted(p.keys()))
"
restartPolicy enum : ['Default', 'RecreateGroupOnPodRestart', 'RecreateGroupAfterStart', 'None']
restartPolicy default: RecreateGroupOnPodRestart
spec 頂層欄位 : ['leaderWorkerTemplate', 'networkConfig', 'replicas', 'rolloutStrategy', 'startupPolicy']
整組重建是預設行為,要單獨重啟才得自己寫 None。
六個實驗:
| 實驗 | 哪個 API | 看什麼 | |
|---|---|---|---|
| 一 | 一組 Pod 的身份 | LeaderWorkerSet | 索引、role 標籤、三個 LWS_* 環境變數、DNS 名字 |
| 二 | 壞一個就整組重建 | LeaderWorkerSet | 刪一個 worker,leader 的 UID 變不變(加一組 restartPolicy: None 當對照) |
| 三 | 滾動更新以組為單位 | LeaderWorkerSet | template-revision-hash,一組裡會不會新舊混雜 |
| 四 | 一個物件展開成 N 個 LWS | DisaggregatedSet | 階層、四個標籤、roleStatuses、CEL 把 1 對 0 退掉 |
| 五 | 跨角色同步滾動更新 | DisaggregatedSet | 只改 prefill,decode 動不動;改 slices 會不會觸發 rollout |
| 六 | placementPolicy 注入了什麼 |
DisaggregatedSet | None 對 ExclusiveSlice,看 Pod 的 affinity |
# exp1-identity.yaml
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
name: demo
spec:
replicas: 1
leaderWorkerTemplate:
size: 4
leaderTemplate:
metadata:
labels: { tier: leader }
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo 我是 LEADER; sleep infinity"]
workerTemplate:
metadata:
labels: { tier: worker }
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo 我是 WORKER; sleep infinity"]
兩個 template 刻意寫得不一樣,才看得出雙 template 真的生效。
kubectl apply -f exp1-identity.yaml
kubectl wait --for=condition=Available lws/demo --timeout=3m
leaderworkerset.leaderworkerset.x-k8s.io/demo condition met
kubectl get pod -o wide
NAME READY STATUS IP NODE
demo-0 1/1 Running 10.244.2.5 lws-lab-worker
demo-0-1 1/1 Running 10.244.1.5 lws-lab-worker2
demo-0-2 1/1 Running 10.244.1.6 lws-lab-worker2
demo-0-3 1/1 Running 10.244.2.6 lws-lab-worker
kubectl get pod -L leaderworkerset.sigs.k8s.io/group-index,leaderworkerset.sigs.k8s.io/worker-index,tier
NAME READY STATUS GROUP-INDEX WORKER-INDEX TIER
demo-0 1/1 Running 0 0 leader
demo-0-1 1/1 Running 0 1 worker
demo-0-2 1/1 Running 0 2 worker
demo-0-3 1/1 Running 0 3 worker
role 這個標籤不在 Pod 上,在 StatefulSet 上:
kubectl get sts -L leaderworkerset.sigs.k8s.io/role,leaderworkerset.sigs.k8s.io/name
NAME READY ROLE NAME
demo 1/1 leader demo
demo-0 3/3 worker demo
LWS 底下其實是 StatefulSet,一個放 leader,一個放那一組的三個 worker。
for p in demo-0 demo-0-1 demo-0-2 demo-0-3; do printf '%-10s ' "$p"; kubectl get pod $p -o jsonpath='{range .spec.containers[0].env[*]}{.name}={.value} {end}'; echo; done
demo-0 LWS_LEADER_ADDRESS=demo-0.demo.default LWS_GROUP_SIZE=4 LWS_WORKER_INDEX=0
demo-0-1 LWS_LEADER_ADDRESS=demo-0.demo.default LWS_GROUP_SIZE=4 LWS_WORKER_INDEX=1
demo-0-2 LWS_LEADER_ADDRESS=demo-0.demo.default LWS_GROUP_SIZE=4 LWS_WORKER_INDEX=2
demo-0-3 LWS_LEADER_ADDRESS=demo-0.demo.default LWS_GROUP_SIZE=4 LWS_WORKER_INDEX=3
多節點推論引擎靠這三個變數知道自己是誰、要去找誰,而 Deployment 給不了。
kubectl get pod demo-0-1 -o jsonpath='{.metadata.labels}' | python3 -m json.tool
{
"apps.kubernetes.io/pod-index": "1",
"controller-revision-hash": "demo-0-855776f64d",
"leaderworkerset.sigs.k8s.io/group-index": "0",
"leaderworkerset.sigs.k8s.io/group-key": "b9a8a5b224a2524489ebaec06e03819894eb9e92",
"leaderworkerset.sigs.k8s.io/name": "demo",
"leaderworkerset.sigs.k8s.io/template-revision-hash": "6f4cc67c76",
"leaderworkerset.sigs.k8s.io/worker-index": "1",
"statefulset.kubernetes.io/pod-name": "demo-0-1",
"tier": "worker"
}
kubectl get pod demo-0-1 -o jsonpath='{.metadata.annotations}' | python3 -m json.tool
{
"leaderworkerset.sigs.k8s.io/leader-address": "demo-0.demo.default",
"leaderworkerset.sigs.k8s.io/leader-name": "demo-0",
"leaderworkerset.sigs.k8s.io/size": "4"
}
kubectl get svc demo -o wide
NAME TYPE CLUSTER-IP PORT(S) SELECTOR
demo ClusterIP None <none> leaderworkerset.sigs.k8s.io/name=demo
for p in demo-0 demo-0-1 demo-0-3; do printf '%-10s hostname=%s subdomain=%s\n' "$p" "$(kubectl get pod $p -o jsonpath='{.spec.hostname}')" "$(kubectl get pod $p -o jsonpath='{.spec.subdomain}')"; done
demo-0 hostname=demo-0 subdomain=demo
demo-0-1 hostname=demo-0-1 subdomain=demo
demo-0-3 hostname=demo-0-3 subdomain=demo
每個 Pod 的 hostname 就是它的名字,subdomain 是那個 headless service,所以任兩個 Pod 之間的位址不用查就算得出來。
kubectl exec demo-0 -- nslookup demo-0-3.demo.default.svc.cluster.local
Name: demo-0-3.demo.default.svc.cluster.local
Address: 10.244.2.6
kubectl exec demo-0-3 -- sh -c 'ping -c2 $LWS_LEADER_ADDRESS'
PING demo-0.demo.default (10.244.2.5): 56 data bytes
64 bytes from 10.244.2.5: seq=0 ttl=63 time=0.051 ms
64 bytes from 10.244.2.5: seq=1 ttl=63 time=0.059 ms
for p in demo-0 demo-0-1 demo-0-3; do printf '%-10s %s\n' "$p" "$(kubectl logs $p | head -1)"; done
demo-0 我是 LEADER
demo-0-1 我是 WORKER
demo-0-3 我是 WORKER
kubectl get pod -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.creationTimestamp}{"\n"}{end}'
demo-0 2026-10-03T06:16:06Z
demo-0-1 2026-10-03T06:16:06Z
demo-0-2 2026-10-03T06:16:06Z
demo-0-3 2026-10-03T06:16:06Z
kubectl get sts demo demo-0 -o jsonpath='{range .items[*]}{.metadata.name} podManagementPolicy={.spec.podManagementPolicy}{"\n"}{end}'
kubectl get lws demo -o jsonpath='startupPolicy={.spec.startupPolicy}{"\n"}'
demo podManagementPolicy=Parallel
demo-0 podManagementPolicy=Parallel
startupPolicy=LeaderCreated
StatefulSet 預設是 OrderedReady,一個起來才起下一個。LWS 把它改成 Parallel。
kubectl get lws demo -o jsonpath='{.status}' | python3 -m json.tool
{
"conditions": [
{ "type": "Progressing", "status": "False", "reason": "GroupsProgressing",
"message": "Replicas are progressing" },
{ "type": "Available", "status": "True", "reason": "AllGroupsReady",
"message": "All replicas are ready" }
],
"hpaPodSelector": "leaderworkerset.sigs.k8s.io/name=demo,leaderworkerset.sigs.k8s.io/worker-index=0",
"observedGeneration": 1,
"readyReplicas": 1,
"replicas": 1,
"updatedReplicas": 1
}
四個 Pod,但 replicas 是 1。 而 hpaPodSelector 只挑 worker-index=0,也就是 leader,所以 HPA 數的是組數不是 Pod 數。
# exp7-scale.yaml
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
name: sc
spec:
replicas: 1
leaderWorkerTemplate:
size: 3
workerTemplate:
spec:
containers:
- name: app
image: busybox:1.37
command: ["sleep", "infinity"]
kubectl apply -f exp7-scale.yaml && kubectl wait --for=condition=Available lws/sc --timeout=3m
kubectl get lws sc; kubectl get pod --no-headers | grep -c '^sc-'
NAME READY DESIRED UP-TO-DATE
sc 1 1 1
3
kubectl scale lws/sc --replicas=2
kubectl wait --for=condition=Available lws/sc --timeout=3m
kubectl get lws sc
kubectl get pod -L leaderworkerset.sigs.k8s.io/group-index,leaderworkerset.sigs.k8s.io/worker-index --no-headers | grep '^sc-'
NAME READY DESIRED UP-TO-DATE
sc 2 2 2
sc-0 1/1 Running 0 0
sc-0-1 1/1 Running 0 1
sc-0-2 1/1 Running 0 2
sc-1 1/1 Running 1 0
sc-1-1 1/1 Running 1 1
sc-1-2 1/1 Running 1 2
3 個 Pod 變 6 個,DESIRED 從 1 變 2。 連同上面那個 replicas: 1 和 hpaPodSelector,三個證據指向同一件事:單位是組。
kubectl delete lws sc
要量的是:殺掉一個 worker,leader 和另外兩個 worker 會不會跟著被重建。
同時起兩個 LeaderWorkerSet,設定一模一樣,只差 restartPolicy 一行:一個用預設的 RecreateGroupOnPodRestart,一個寫 None。然後各殺掉一個 worker。
# exp2-none.yaml,跟 exp1-identity.yaml 只差 restartPolicy 一行
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
name: nogrp
spec:
replicas: 1
leaderWorkerTemplate:
size: 4
restartPolicy: None
leaderTemplate:
metadata:
labels: { tier: leader }
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo 我是 LEADER; sleep infinity"]
workerTemplate:
metadata:
labels: { tier: worker }
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo 我是 WORKER; sleep infinity"]
重建出來的 Pod 名字一模一樣,所以要看 UID。
# uid.sh
#!/bin/bash
for g in demo nogrp; do
printf '%-7s ' "$g"
for i in "" -1 -2 -3; do
u=$(kubectl get pod ${g}-0${i} -o jsonpath='{.metadata.uid}' 2>/dev/null | cut -c1-8)
printf '%s=%s ' "${g}-0${i}" "${u:-缺}"
done
echo
done
kubectl apply -f exp2-none.yaml && kubectl wait --for=condition=Available lws/nogrp --timeout=3m
./uid.sh
demo demo-0=fbc69f55 demo-0-1=07b6f0ea demo-0-2=f00355ae demo-0-3=25123cc4
nogrp nogrp-0=d0699739 nogrp-0-1=76dd321a nogrp-0-2=48e8f4eb nogrp-0-3=c13b135e
kubectl delete pod demo-0-1 nogrp-0-1 --wait=false
kubectl wait --for=condition=Available lws/demo lws/nogrp --timeout=3m
./uid.sh
demo demo-0=d21cfed2 demo-0-1=e3b916b5 demo-0-2=10d20d24 demo-0-3=28bceb91
nogrp nogrp-0=d0699739 nogrp-0-1=286a9042 nogrp-0-2=48e8f4eb nogrp-0-3=c13b135e
demo(預設) |
nogrp(None) |
|
|---|---|---|
| leader | fbc69f55 → d21cfed2 換了 |
d0699739 不變 |
| 被刪的 worker 1 | 07b6f0ea → e3b916b5 |
76dd321a → 286a9042 |
| worker 2 | f00355ae → 10d20d24 換了 |
48e8f4eb 不變 |
| worker 3 | 25123cc4 → 28bceb91 換了 |
c13b135e 不變 |
建立時間也看得出來:
kubectl get pod -o custom-columns='NAME:.metadata.name,CREATED:.metadata.creationTimestamp'
demo-0 2026-10-03T06:18:47Z
demo-0-1 2026-10-03T06:18:47Z
demo-0-2 2026-10-03T06:18:47Z
demo-0-3 2026-10-03T06:18:47Z
nogrp-0 2026-10-03T06:17:48Z
nogrp-0-1 2026-10-03T06:18:47Z
nogrp-0-2 2026-10-03T06:17:48Z
nogrp-0-3 2026-10-03T06:17:48Z
demo 原本四個都是 06:16:06,殺掉一個 worker 之後四個都變 06:18:47。你殺的是 worker,leader 也跟著重建。
而 nogrp 只有被殺的那一個換了時間,那就是 StatefulSet 的行為。
kubectl delete lws demo nogrp
# exp3-rollout.yaml
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
name: roll
spec:
replicas: 2
leaderWorkerTemplate:
size: 2
workerTemplate:
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo v1; sleep infinity"]
readinessProbe:
exec: { command: ["true"] }
initialDelaySeconds: 20
periodSeconds: 2
initialDelaySeconds 設 20 秒,讓每個 Pod 的就緒慢下來,才看得到過程。
kubectl apply -f exp3-rollout.yaml && kubectl wait --for=condition=Available lws/roll --timeout=5m
kubectl get pod -L leaderworkerset.sigs.k8s.io/group-index,leaderworkerset.sigs.k8s.io/template-revision-hash
NAME READY STATUS GROUP-INDEX TEMPLATE-REVISION-HASH
roll-0 1/1 Running 0 5745dd8c84
roll-0-1 1/1 Running 0 5745dd8c84
roll-1 1/1 Running 1 5745dd8c84
roll-1-1 1/1 Running 1 5745dd8c84
sed -i 's/echo v1/echo v2/' exp3-rollout.yaml && kubectl apply -f exp3-rollout.yaml
for i in $(seq 0 45); do printf '%3ds ' $((i*5)); kubectl get pod -o jsonpath='{range .items[*]}{.metadata.name}:{.metadata.labels.leaderworkerset\.sigs\.k8s\.io/template-revision-hash} {end}'; echo; sleep 5; done
0s roll-0:5745dd8c84 roll-0-1:5745dd8c84 roll-1:5745dd8c84 roll-1-1:5745dd8c84
30s roll-0:5745dd8c84 roll-0-1:5745dd8c84 (roll-1 刪除中) roll-1-1:5745dd8c84
35s roll-0:5745dd8c84 roll-0-1:5745dd8c84 roll-1:5d989f774c roll-1-1:5745dd8c84
70s roll-0:5745dd8c84 roll-0-1:5745dd8c84 roll-1:5d989f774c (roll-1-1 刪除中)
75s roll-0:5745dd8c84 roll-0-1:5745dd8c84 roll-1:5d989f774c roll-1-1:5d989f774c
110s roll-0:5745dd8c84 roll-0-1:5745dd8c84 roll-1:5d989f774c roll-1-1:5d989f774c
… roll-0:5d989f774c roll-0-1:5745dd8c84 …
… roll-0:5d989f774c roll-0-1:5d989f774c …
一組一組換,不是一個 Pod 一個 Pod 換。 group 1 在 75 秒就兩個都是新版本了,而 group 0 到 110 秒還一個都沒動。maxSurge 預設 0、maxUnavailable 預設 1,所以同時只有一組在換,而順序是索引由大往小。
但一組之內不是同時換的。 看 35 秒那一行:group 1 的 leader 已經是新的 5d989f774c,它的 worker 還是舊的 5745dd8c84。這個狀態一直持續到 70 秒,worker 才被換掉。
原因是 leader 和 worker 住在兩個不同的 StatefulSet,而 worker 那邊是照 leader 的版本重建的,所以順序固定:leader 先換,等它起來之後 worker 才跟。
那段混雜的長度就是新 leader 從建立到就緒的時間。換成真的推論引擎,那會是載模型加編譯的幾分鐘,而這幾分鐘裡同一組的 leader 和 worker 跑的是不同版本。
kubectl delete lws roll
# exp4-dset.yaml
apiVersion: disaggregatedset.x-k8s.io/v1
kind: DisaggregatedSet
metadata:
name: pd
spec:
slices: 1
roles:
- name: prefill
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo PREFILL v1; sleep infinity"]
- name: decode
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo DECODE v1; sleep infinity"]
roles[].spec 內嵌的就是一整份 LeaderWorkerSetSpec,角色數量至少 2 個、最多 10 個。
kubectl apply -f exp4-dset.yaml && kubectl wait --for=condition=Available disaggregatedset/pd --timeout=3m
disaggregatedset.disaggregatedset.x-k8s.io/pd condition met
kubectl get disaggregatedset; kubectl get lws; kubectl get sts; kubectl get pod -o wide
NAME AGE
pd 25s
NAME READY DESIRED UP-TO-DATE
pd-0-67738d19-decode 1 1 1
pd-0-67738d19-prefill 1 1 1
NAME READY
pd-0-67738d19-decode 1/1
pd-0-67738d19-prefill 1/1
NAME READY STATUS IP NODE
pd-0-67738d19-decode-0 1/1 Running 10.244.2.16 lws-lab-worker
pd-0-67738d19-prefill-0 1/1 Running 10.244.2.15 lws-lab-worker
一個 DisaggregatedSet 變成兩個 LWS、兩個 StatefulSet、兩個 Pod。
kubectl get pod -l disaggregatedset.x-k8s.io/role=prefill -o jsonpath='{.items[0].metadata.labels}' | python3 -c "
import json,sys
for k,v in sorted(json.load(sys.stdin).items()):
if 'disaggregatedset' in k: print(f' {k} = {v}')
"
disaggregatedset.x-k8s.io/name = pd
disaggregatedset.x-k8s.io/revision = 67738d19
disaggregatedset.x-k8s.io/role = prefill
disaggregatedset.x-k8s.io/slice = 0
kubectl get disaggregatedset pd -o jsonpath='{.status}' | python3 -m json.tool
{
"conditions": [
{ "type": "Progressing", "status": "False", "reason": "AllRolesReady",
"message": "All roles have reached their desired replica count, ready and updated to the current revision" },
{ "type": "Available", "status": "True", "reason": "AllRolesReady",
"message": "All roles have reached their desired replica count, ready and updated to the current revision" }
],
"roleStatuses": [
{ "name": "prefill", "replicas": 1, "readyReplicas": 1, "updatedReplicas": 1 },
{ "name": "decode", "replicas": 1, "readyReplicas": 1, "updatedReplicas": 1 }
]
}
roleStatuses 是從它底下那些 LWS 物件彙總上來的。
kubectl get svc -o custom-columns='NAME:.metadata.name,CLUSTER-IP:.spec.clusterIP,SELECTOR:.spec.selector'
NAME CLUSTER-IP SELECTOR
pd-0-67738d19-decode None map[leaderworkerset.sigs.k8s.io/name:pd-0-67738d19-decode]
pd-0-67738d19-prefill None map[leaderworkerset.sigs.k8s.io/name:pd-0-67738d19-prefill]
一個 role 一份 headless Service,而那就是每個 LWS 自己的那份,selector 指的是 LWS 的名字。所以 Service 名字裡也帶著 revision。
# exp4-bad.yaml
apiVersion: disaggregatedset.x-k8s.io/v1
kind: DisaggregatedSet
metadata:
name: pd-bad
spec:
roles:
- name: prefill
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers: [{ name: app, image: busybox:1.37, command: ["sleep","infinity"] }]
- name: decode
spec:
replicas: 0
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers: [{ name: app, image: busybox:1.37, command: ["sleep","infinity"] }]
kubectl apply -f exp4-bad.yaml
The DisaggregatedSet "pd-bad" is invalid: spec: Invalid value:
replicas must be zero for all non-External roles or non-zero for all non-External roles
這就是「prefill 和 decode 是一個單位」在 API 上的樣子,它不讓你只開一邊。 規則寫在 CRD 的 CEL 裡,所以 controller 都還沒跑就被擋掉了。
kubectl delete disaggregatedset pd
兩個角色都加 20 秒的 probe,然後只改 prefill。
# exp5-crossrole.yaml
apiVersion: disaggregatedset.x-k8s.io/v1
kind: DisaggregatedSet
metadata:
name: pd
spec:
slices: 1
roles:
- name: prefill
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo PREFILL v1; sleep infinity"]
readinessProbe:
exec: { command: ["true"] }
initialDelaySeconds: 20
periodSeconds: 2
- name: decode
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo DECODE v1; sleep infinity"]
readinessProbe:
exec: { command: ["true"] }
initialDelaySeconds: 20
periodSeconds: 2
kubectl apply -f exp5-crossrole.yaml && kubectl wait --for=condition=Available disaggregatedset/pd --timeout=5m
sed -i 's/echo PREFILL v1/echo PREFILL v2/' exp5-crossrole.yaml
grep -n "echo PREFILL\|echo DECODE" exp5-crossrole.yaml
21: command: ["sh", "-c", "echo PREFILL v2; sleep infinity"]
36: command: ["sh", "-c", "echo DECODE v1; sleep infinity"]
kubectl apply -f exp5-crossrole.yaml
for i in $(seq 0 21); do printf '%3ds ' $((i*5)); kubectl get pod -o jsonpath='{range .items[*]}{.metadata.labels.disaggregatedset\.x-k8s\.io/role}:{.metadata.labels.disaggregatedset\.x-k8s\.io/revision} {end}'; echo; sleep 5; done
0s decode:3e51cbad prefill:3e51cbad
5s decode:3e51cbad prefill:3e51cbad decode:7970dde9 prefill:7970dde9
50s decode:3e51cbad prefill:3e51cbad decode:7970dde9 prefill:7970dde9
55s decode:7970dde9 prefill:7970dde9
三件事:
只改 prefill,但兩個角色都換了 revision。 decode 的 spec 一個字都沒動,還是被重建,因為 revision 是整份 DisaggregatedSet spec 的雜湊。換成真的推論服務,decode 那一邊會重新載一次模型。
先起新的再砍舊的。 50 秒內四個 Pod 並存,容量從頭到尾沒有低於原本。
兩個 role 的新 Pod 同時出現,都在 5 秒那一格。
而 rollout 是換物件不是原地改:revision 寫在子 LWS 的名字裡,所以換版本等於生一組新名字的 LWS,舊的 DESIRED 歸零之後被回收。
sed -i 's/ slices: 1/ slices: 2/' exp5-crossrole.yaml && kubectl apply -f exp5-crossrole.yaml
for i in $(seq 0 9); do printf '%3ds ' $((i*8)); kubectl get pod -o jsonpath='{range .items[*]}{.metadata.labels.disaggregatedset\.x-k8s\.io/role}/s{.metadata.labels.disaggregatedset\.x-k8s\.io/slice}:{.metadata.labels.disaggregatedset\.x-k8s\.io/revision} {end}'; echo; sleep 8; done
0s decode/s0:7970dde9 prefill/s0:7970dde9 prefill/s1:7970dde9
8s decode/s0:7970dde9 prefill/s0:7970dde9 decode/s1:7970dde9 prefill/s1:7970dde9
72s decode/s0:7970dde9 prefill/s0:7970dde9 decode/s1:7970dde9 prefill/s1:7970dde9
revision 全程維持 7970dde9,slice 0 的 Pod 從頭到尾沒被碰過。 擴容和換版本是兩件事。
kubectl get pod -o custom-columns='NAME:.metadata.name,SLICE:.metadata.labels.disaggregatedset\.x-k8s\.io/slice,ROLE:.metadata.labels.disaggregatedset\.x-k8s\.io/role,NODE:.spec.nodeName'
NAME SLICE ROLE NODE
pd-0-7970dde9-decode-0 0 decode lws-lab-worker
pd-0-7970dde9-prefill-0 0 prefill lws-lab-worker
pd-1-7970dde9-decode-0 1 decode lws-lab-worker
pd-1-7970dde9-prefill-0 1 prefill lws-lab-worker
kubectl get pod -o jsonpath='{range .items[*]}{.metadata.name}{" affinity="}{.spec.affinity}{"\n"}{end}'
pd-0-7970dde9-decode-0 affinity=
pd-0-7970dde9-prefill-0 affinity=
pd-1-7970dde9-decode-0 affinity=
pd-1-7970dde9-prefill-0 affinity=
兩個 slice、四個 Pod,排程器全部塞到同一台,而且一條 affinity 都沒注入。 這是下一節的對照組。
kubectl delete disaggregatedset pd
跟上一節只差 placementPolicy 三行。
# exp6-exclusiveslice.yaml
apiVersion: disaggregatedset.x-k8s.io/v1
kind: DisaggregatedSet
metadata:
name: pd
spec:
slices: 2
placementPolicy:
type: ExclusiveSlice
topology: kubernetes.io/hostname
roles:
- name: prefill
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo PREFILL v2; sleep infinity"]
readinessProbe:
exec: { command: ["true"] }
initialDelaySeconds: 20
periodSeconds: 2
- name: decode
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "echo DECODE v2; sleep infinity"]
readinessProbe:
exec: { command: ["true"] }
initialDelaySeconds: 20
periodSeconds: 2
type 有三個值:None(預設)、ExclusiveSlice、ExclusiveTopology,而不是 None 的時候 topology 必填。
kubectl apply -f exp6-exclusiveslice.yaml && kubectl wait --for=condition=Available disaggregatedset/pd --timeout=5m
kubectl get pod -o custom-columns='NAME:.metadata.name,SLICE:.metadata.labels.disaggregatedset\.x-k8s\.io/slice,ROLE:.metadata.labels.disaggregatedset\.x-k8s\.io/role,NODE:.spec.nodeName'
NAME SLICE ROLE NODE
pd-0-4016ecd1-decode-0 0 decode lws-lab-worker
pd-0-4016ecd1-prefill-0 0 prefill lws-lab-worker
pd-1-4016ecd1-decode-0 1 decode lws-lab-worker2
pd-1-4016ecd1-prefill-0 1 prefill lws-lab-worker2
一個 slice 一台,兩個 slice 分開,跟上一節四個全擠一台正好對照。
而 controller 注入的是這個:
kubectl get lws -l disaggregatedset.x-k8s.io/role=prefill -o jsonpath='{.items[0].spec.leaderWorkerTemplate.workerTemplate.spec.affinity}' | python3 -m json.tool
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- { key: disaggregatedset.x-k8s.io/name, operator: In, values: [pd] }
- { key: disaggregatedset.x-k8s.io/slice, operator: In, values: ["0"] }
topologyKey: kubernetes.io/hostname
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- { key: disaggregatedset.x-k8s.io/name, operator: In, values: [pd] }
- { key: disaggregatedset.x-k8s.io/slice, operator: Exists }
- { key: disaggregatedset.x-k8s.io/slice, operator: NotIn, values: ["0"] }
topologyKey: kubernetes.io/hostname
兩條規則合起來就是一句話:同名同 slice 的要在同一個 hostname,同名不同 slice 的要在不同 hostname。
所以 ExclusiveSlice 綁的是「一個 slice 的所有角色擠在同一個 topology domain」。第三個值 ExclusiveTopology 是在這之上再加 domain 獨佔,一個 domain 最多放一個 slice,而且跨所有 DisaggregatedSet 都算。
kubectl delete disaggregatedset --all; kubectl delete lws --all
kind delete cluster --name lws-lab
LeaderWorkerSet 把「一組 Pod」變成 Kubernetes 認得的單位。 四個 Pod 的 status.replicas 是 1、hpaPodSelector 只挑 leader、scale --replicas=2 一次長出一整組,三個證據指的是同一件事。
而這個語意最明顯的地方是殺掉一個 worker:預設整組一起重建,連 leader 都換;寫 None 就退回 StatefulSet,只有那一個回來。
DisaggregatedSet 把好幾個 LeaderWorkerSet 綁成一個物件。 一個 role 一個子 LWS,而它不讓你只開 prefill 不開 decode。換版本是跨 role 同步的,只改 prefill 連 decode 都會重建;改 slices 則不會,擴容和換版本是兩件事。
kubernetes-sigs/lws
LeaderWorkerSet 與 DisaggregatedSet 的分工
LeaderWorkerSet API
Labels, Annotations and Environment Variables
Roles in DisaggregatedSet
Slices in DisaggregatedSet
DisaggregatedSet 總覽
sigs.k8s.io/lws/api/disaggregatedset/v1
LeaderWorkerSet:為 LLM 推理服務設計的 Kubernetes 工作負載