iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Kubernetes

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

【Day 19】Kubernetes LeaderWorkerSet 與 DisaggregatedSet

  • 分享至 

  • xImage
  •  

Day 2 的最後我們提到,vLLM 在 Kubernetes 上是以一個 Pod 為單位運行,理論上應該要以 Deployment 去部署,而那也是 prefill pool 和 decode pool 的形狀。但 Deployment 對這個場景有它的痛點,所以 Kubernetes 生態多了一組新的 API 物件來解決這個問題。

今天我們要來探討這組新物件:LeaderWorkerSet 與 DisaggregatedSet。


Deployment 和 StatefulSet 卡在哪

Deployment 的複本單位是一個 Pod。 它眼裡沒有「同一組」這件事:沒有 leader、沒有組內編號、名字每次重建都換,也沒有穩定的網路身份。而滾動更新是按 Pod 走的,所以會出現新版本已經在服務、舊版本還在跑的狀態。對無狀態的 API 服務這沒問題,對一個切成好幾塊的模型就是版本對不起來。

StatefulSet 解掉一半。 它給穩定的名字和序號,搭配 headless service 給穩定的 DNS,所以「我是第幾號、我要去找誰」算得出來。但還剩三個缺口:

缺口 內容
只有一份範本 leader 和 worker 不能跑不一樣的指令,也不能吃不一樣的資源
預設一個一個起 一個 Pod 起來並就緒才起下一個,而一組要同時上線的工作等不了
壞一個只修一個 一個 Pod 被重建,其他 Pod 不會被處理

最後那一列是關鍵。一組四個 Pod 跑同一個推論,其中一個被重建,剩下三個手上的狀態就不對了,但 StatefulSet 不會管那三個。

所以缺的東西是「一組」這個單位本身。


LeaderWorkerSet 是什麼

把一組 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,只重啟壞掉的那一個。

https://ithelp.ithome.com.tw/upload/images/20261003/20183759XMpf3WDBNF.png


DisaggregatedSet 是什麼

比 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 一起、同步地換版本

https://ithelp.ithome.com.tw/upload/images/20261003/20183759WEccVCPF65.png


實驗環境

今天要測的是 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

一、一組 Pod 的身份

# 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 給不了。

Pod 上實際有的標籤和 annotation

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"
}

DNS 名字算得出來

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

雙 template

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。

status 的單位是組

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 數。

scale 一次長出一整組

# 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

四、一個物件展開成 N 個 LWS

# 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

status 彙總每個角色

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 物件彙總上來的。

自動建的 Service

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。

只開 prefill 不開 decode 會被擋掉

# 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 歸零之後被回收。

改 slices 不觸發 rollout

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 從頭到尾沒被碰過。 擴容和換版本是兩件事。

預設不保證 slice 落在哪

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 注入了什麼

跟上一節只差 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 工作負載


上一篇
【Day 18】都快下班了,服務怎麼還沒跑起來:Kubernetes 上 vLLM 的冷啟動與優化
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言