跳至主要内容

Day 12:容器與 Kubernetes — GPU 排程與那些會讓 Pod 重啟的坑

· 閱讀時間約 7 分鐘
Willis Chen
Tech Instructor

今天進入 MLOps 職缺中出現頻率最高的領域:AI/ML 平臺與基礎設施(58.5%)。

本日不包含 Kubernetes 的全部——那是一本書的量,而且新入職的你優先事項不包含建立叢集(Day 04 的止損點清單裡有說明)。今天只講跑 ML 工作負載時,與跑一般 Web 服務不一樣的那些地方

今天要解決的問題

一個新手 MLOps 工程師的第一週,通常長這樣:

$ kubectl get pods
NAME READY STATUS RESTARTS AGE
model-api-7d4b8c9f5-x2p8q 0/1 CrashLoopBackOff 7 12m

然後花一小時以上,最後發現原因是:模型載入要 40 秒,但 liveness probe 的 initialDelaySeconds 設了 10 秒。Kubernetes 以為服務死了,殺掉重啟;重啟又要 40 秒,又被殺掉——完美的無限迴圈。

這個坑只需要改一行 YAML。今天就把這類「ML 特有的坑」一次講完。

📊 職缺訊號

58.5% 的 MLOps 職缺提到 AI/ML 平臺與基礎設施,是所有任務訊號中最高的一項。但要注意 JD 的用詞差異:寫「熟悉 K8s」通常是要你會部署與排錯;寫「建置與維運 K8s 叢集」才是要你建叢集。前者的職缺數遠多於後者——先把「會用」練到位,投報率最高。


一、現象:ML 工作負載的三個特殊性

Kubernetes 上的 ML 工作負載型態

圖 12-1:Kubernetes 上的三種 ML 工作負載(示意架構)。訓練用 Job(跑完就結束)、推論用 Deployment(長期存活)、批次推論用 CronJob(定期執行),三者的資源與排程需求完全不同。

特殊性一般 Web 服務ML 服務造成的問題
啟動時間1–3 秒30 秒–5 分鐘(要載入模型)探針設定不當 → 無限重啟
記憶體用量穩定、可預測尖峰高(載入時)、與批次大小相關設錯 limits → OOMKilled
運算資源CPU 為主GPU,且不可分割共享一個 Pod 佔一張卡,資源浪費

第三點值得展開:CPU 可以要 0.5 顆,GPU 預設不行——一個 Pod 要嘛拿到整張卡,要嘛拿不到。這是 GPU 利用率經常只有 12%(Day 15 會看到這個數字)的結構性原因。

二、原理:GPU 在 Kubernetes 裡是「擴充資源」

很多人的第一個困惑是:為什麼 YAML 裡寫了 nvidia.com/gpu: 1,Pod 卻一直 Pending?

image

圖 12-2:GPU 成為可調度資源的前置條件(示意流程)。缺任何一步,Pod 都會卡在 Pending。

關鍵觀念:GPU 不像 CPU/記憶體是 Kubernetes 內建認識的資源,它是擴充資源(Extended Resource),必須由 device plugin 註冊之後,排程器才知道它存在。

當一張卡對一個工作負載太大時,有三種共享方式:

方式原理適合
Time-slicing多個 Pod 輪流用同一張卡開發測試、低流量推論;沒有記憶體隔離
MIG(Multi-Instance GPU)硬體層切成多個獨立實例生產環境多租戶;限特定卡種
MPS多程序共用 CUDA context小模型高併發

⚠️ time-slicing 的陷阱

它沒有記憶體隔離——A 服務吃光顯存,B 服務就會 OOM。生產環境要隔離請用 MIG;time-slicing 適合開發環境提高利用率。

三、動手:一份能跑的 ML 服務 YAML

apiVersion: apps/v1
kind: Deployment
metadata:
name: model-api
spec:
replicas: 2
selector:
matchLabels: { app: model-api }
template:
metadata:
labels: { app: model-api }
spec:
containers:
- name: api
image: registry.example.com/model-api:0.3.1 # 永遠用明確版本,不用 latest
ports: [{ containerPort: 8000 }]

# ── 坑 1:資源設定 ──
resources:
requests: # 排程依據:至少要有這麼多才排得進去
memory: "4Gi"
cpu: "1"
limits: # 上限:超過記憶體會被 OOMKilled
memory: "8Gi" # 模型載入的尖峰通常是穩態的 1.5–2 倍
cpu: "2"
nvidia.com/gpu: 1 # GPU 只能寫在 limits,且必須是整數

# ── 坑 2:探針設定(本文開頭那個無限重啟的元凶)──
startupProbe: # 專門給「啟動慢」的服務用,這是關鍵
httpGet: { path: /health, port: 8000 }
failureThreshold: 30 # 最多等 30 × 10 = 300 秒
periodSeconds: 10
readinessProbe: # 沒 ready 就不會有流量進來
httpGet: { path: /health, port: 8000 }
periodSeconds: 5
livenessProbe: # 掛了才重啟;有 startupProbe 就不會誤殺
httpGet: { path: /health, port: 8000 }
periodSeconds: 20
failureThreshold: 3

# ── 坑 3:金鑰用 Secret 注入,不寫在映像裡(Day 05)──
env:
- name: MODEL_REGISTRY_URI
valueFrom:
secretKeyRef: { name: mlflow-secret, key: tracking-uri }

# ── 坑 4:優雅關機,避免處理到一半的請求被砍 ──
lifecycle:
preStop:
exec: { command: ["sleep", "10"] }
terminationGracePeriodSeconds: 60

startupProbe 是本篇最有價值的一行。 它的作用是:在服務啟動完成前,暫停 liveness 的檢查。這樣你可以給 liveness 一個緊湊的檢查週期(快速偵測真正的死亡),同時容許很長的啟動時間——兩者不再互相衝突。

本地驗證:用 kind 建一個測試叢集

# kind:用 Docker 容器當節點,筆電就能跑(無 GPU,但足以驗證 YAML 與探針)
brew install kind kubectl # 或見官方安裝說明
kind create cluster --name mlops-lab

kind load docker-image model-api:0.3.1 --name mlops-lab # 把本地映像載入叢集
kubectl apply -f deployment.yaml
kubectl get pods -w # 觀察狀態變化

# 排錯三連——順序不要變
kubectl describe pod <pod-name> # 1. 看 Events:排程失敗?OOMKilled?探針失敗?
kubectl logs <pod-name> --previous # 2. 看上一輪的日誌(重啟前的錯誤在這裡)
kubectl exec -it <pod-name> -- sh # 3. 進去看看(環境變數、檔案掛載對不對)

💡 --previous 是排 CrashLoopBackOff 的關鍵

Pod 一直重啟時,kubectl logs 看到的是新一輪剛啟動的日誌,通常什麼都沒有。真正的錯誤訊息在上一輪的日誌裡,必須加 --previous 才看得到。這是新手最常卡住的地方。

四、取捨:什麼時候不需要 Kubernetes

Kubernetes 很強,但它的複雜度是真實成本:

情境建議理由
單一模型、流量穩定、無需自動擴縮docker compose 或雲端容器服務K8s 的維運成本高於它解決的問題
多個模型、需要自動擴縮與資源配額Kubernetes這正是它的主場
只有推論、想要極簡雲端 Serverless 容器(Cloud Run 等)免維運,但注意冷啟動與 GPU 支援限制
需要 GPU 共享與多租戶Kubernetes + MIG/時間切片目前沒有更好的替代
團隊沒有人熟 K8s先別上半夜叢集出事時,沒人會修才是真正的風險

4.1 五個最常見的 ML 服務故障與對應根因

把實務上遇到的故障整理成一張對照表。這張表建議存起來,值班時查得到:

症狀最可能的根因怎麼確認
Pod 一直 PendingGPU 資源不足或 device plugin 沒裝;節點選擇器條件無法滿足kubectl describe pod 看 Events 的排程訊息
CrashLoopBackOff 且日誌空白探針逾時(本文開頭那個坑),或啟動時就 panickubectl logs --previous
OOMKilledlimits 記憶體不足;模型被重複載入(例如多個 worker 各載一份)describe 看終止原因;檢查 worker 數與載入邏輯
服務啟動成功但預測全錯掛載到錯的模型版本;特徵順序不一致檢查 registry 別名與模型簽章(Day 10)
偶發性逾時、P99 特別高冷啟動(剛擴縮出新 Pod)、GPU 被同節點的其他工作搶佔比對逾時時間點與 Pod 建立時間

第四列值得特別注意:這是唯一一個「Kubernetes 全綠、服務全綠、但結果全錯」的情況——它屬於 Day 14 的無聲壞掉,用 K8s 的工具查不出來。這也再次說明為什麼 MLOps 不等於 DevOps。

4.2 給主軸二讀者:你至少要會的三件事

如果你走生成式 AI 應用路線,不需要精通 K8s,但這三件事會直接影響你能不能把東西上線:

  1. 看得懂 kubectl describelogs --previous——你的 RAG 服務出事時,第一時間能自己判斷是應用問題還是叢集問題。
  2. 知道自己的服務要多少記憶體——載入嵌入模型與向量索引後的常駐記憶體,是 limits 設定的依據。答不出來,維運團隊也無從幫你。
  3. 理解探針的意義——你的服務可能要花 60 秒載入嵌入模型與索引,如果沒告訴維運團隊,就會遇到本文開頭那個無限重啟。

📌 給求職者的實話

你不需要會建叢集,但你必須會排錯。面試最常考的就是本文的排錯三連加上「Pod 一直 Pending / CrashLoopBackOff 你怎麼查」。能有條理地說出檢查順序與各自對應的根因,比背得出十個 API 物件更有說服力。


今日小結

  • ML 工作負載的三個特殊性:啟動慢(探針要調)、記憶體尖峰高(limits 要留餘裕)、GPU 不可分割(利用率天生偏低)。
  • GPU 是擴充資源,需要驅動 + device plugin 註冊後排程器才看得見;Pod 一直 Pending 多半是這一步沒做。
  • startupProbe 是最值錢的一行 YAML:讓 liveness 檢查在啟動完成前暫停,解決無限重啟的假故障。
  • 排錯三連:describe(看 Events)→ logs --previous(看重啟前的錯誤)→ exec(進去看環境)。
  • 團隊沒人熟 K8s 就先別上——半夜沒人會修才是真正的風險

限制與提醒

  • 本文尚未討論分散式與極大模型的載入瓶頸:針對 70B+ 等需要多卡分散式推論(vLLM / TensorRT-LLM)或跨節點通訊的場景,只靠單 Pod 單卡 limits 與常規 startupProbe 無法涵蓋權重分片、NCCL 初始化超時與 Shared Memory (/dev/shm) 不足等問題。

  • 本文尚未討論冷啟動與自動擴縮(HPA/KEDA)的延遲代價評估:設定了長達 300 秒的 startupProbe 雖然能保證啟動成功,但在流量爆發引發自動水平擴展時,長達數分鐘的冷啟動會導致請求嚴重堆積與 P99 延遲飆高,文中未提及 Warm-pool 或預載入等解法。

  • MIG 的硬體限制與成本門檻未細化:本文提及 MIG 是生產隔離最佳解,但 MIG 僅支援 NVIDIA A100/H100 等高階資料中心卡,對中小企業常用的 L4、T4 或 RTX 系列不適用也請讀者留意。

明天預告

服務跑起來了,明天談它跑得好不好:延遲與吞吐為什麼不可兼得、動態批次的取捨曲線長什麼樣、以及那個所有人都會問但很多人答不好的問題——P99 延遲為什麼比平均值重要

延伸閱讀



← Day 11Day 13 →

本文同步發布於 iThome 鐵人賽;Blog 版本為站內閱讀與系列導覽的主要版本。