好不容易把預算批下來,你的部門買了一張 RTX 4090,24GB 的記憶體,插在那台大家共用的機器上。
第一個來申請的是隔壁組做資料分析的同事。他想開個 notebook 看看資料長什麼樣子,順便試跑一個小模型,於是他寫了一份再平常不過的 yaml:
apiVersion: v1
kind: Pod
metadata:
name: data-explore
namespace: ds-team
spec:
containers:
- name: notebook
image: quay.io/jupyter/pytorch-notebook:latest
ports:
- containerPort: 8888
resources:
limits:
nvidia.com/gpu: 1
透過 nvidia.com/gpu: 1 來申請一張 GPU 的寫法,就是 device plugin 在做的事。
那 Pod 也順利起來,一切正常。他進去下了 nvidia-smi 並看了一眼,24GB 的卡,用了 3GB。
隔天,另一組的同事也想跑點東西,他把同一份 yaml 改個名字 apply 了出去,但這次,他的 Pod 停在了 Pending。
$ kubectl describe pod data-explore-2 -n ds-team
...
Events:
Warning FailedScheduling ... 0/1 nodes are available:
1 Insufficient nvidia.com/gpu.
這位同仁很納悶,明明那張卡上還有 21GB 是空閒的,但 Kubernetes 就是跟你說:沒有卡了。
要理解為什麼會這樣,得先知道 nvidia.com/gpu: 1 這一行背後到底發生了什麼事。
Kubernetes 核心原生支援的運算資源只有 CPU、記憶體、ephemeral storage、hugepages 等少數幾種,沒有內建任何 GPU 專屬的資源型別,也不認得 CUDA 等廠商驅動。因此,kubelet 無法自行偵測節點上的 GPU,也無法直接將 GPU 分配給容器。要讓 Pod 使用這類硬體,必須先解決兩個問題:kubelet 如何得知節點上有哪些裝置,以及這些裝置在 Kubernetes 中以什麼名稱表示。
Device Plugin 就是為此設計的擴充框架,讓硬體廠商無須修改 Kubernetes 核心程式碼,就能將自家裝置接入叢集。廠商依照框架定義的 gRPC 介面實作 plugin,通常以 DaemonSet 部署在每個節點上。plugin 向 kubelet 註冊後,負責回報該節點上可用的裝置及其健康狀態。kubelet 將這些裝置登記為節點的 extended resource,寫入節點狀態的 capacity 與 allocatable,供排程器分配時參考。
device plugin 回報的資源名稱採用以下格式:
vendor-domain/resourcetype
常見的例子包括 nvidia.com/gpu、amd.com/gpu 與 gpu.intel.com/i915。Kubernetes 不維護資源名稱清單,廠商也不需要事先註冊。名稱只需帶有網域形式的前綴,且不得使用 Kubernetes 保留的 kubernetes.io 網域;斜線後方的資源類型名稱由廠商自行決定,Kubernetes 不解讀其語意。換言之,對 Kubernetes 而言,nvidia.com/gpu 只是一個可以計數的資源名稱,它並不知道這個名稱背後代表的是 GPU。
Device Plugin 的運作分為四個階段:初始化 → 啟動 gRPC 服務 → 向 kubelet 註冊 → 服務模式
/var/lib/kubelet/device-plugins/ 下建立 Unix socket,並提供 gRPC 服務。--root-dir 或其他設定影響。需實作的介面如下:
| RPC 方法 | 用途 | 是否必須 |
|---|---|---|
GetDevicePluginOptions |
回傳 plugin 的選項給 Device Manager | 必須 |
ListAndWatch |
以 stream 回傳裝置清單;裝置狀態改變或消失時,推送新清單 | 必須 |
Allocate |
建立容器時呼叫,讓 plugin 執行裝置相關操作,並告訴 kubelet 如何讓容器使用該裝置 | 必須 |
GetPreferredAllocation |
從可用裝置中回傳「偏好」的分配組合(僅供參考,不保證最終採用) | 選用 |
PreStartContainer |
若註冊時有聲明,會在每個容器啟動前呼叫,例如用來重置裝置 | 選用 |
重點:
GetPreferredAllocation() 和 PreStartContainer() 可以不提供實際功能。GetDevicePluginOptions() 回傳的 DevicePluginOptions 中用旗標標示。GetDevicePluginOptions() 確認。/var/lib/kubelet/device-plugins/kubelet.sock 向 kubelet 註冊。註冊成功後,plugin 持續執行兩件事:
(a) 監控裝置健康狀態
(b) 處理 Allocate 請求
AllocateResponse,內含容器 runtime 存取裝置所需的設定。AllocateResponse 包含 0 個或多個 ContainerAllocateResponse。ContainerAllocateResponse 定義要對容器做哪些修改,才能讓容器存取裝置,可包含:
CDI 相關注意事項:
DevicePluginCDIDevices feature gate。Pod 透過 resources 欄位請求 device plugin 所提供的裝置,語法與 CPU、記憶體等原生資源相同。不過 Kubernetes 對這類 extended resource 另外訂有兩項限制:
- Extended resources are only supported as integer resources and cannot be overcommitted.
- Devices cannot be shared between containers.
第一項限制規範請求的形式:extended resource 只能以整數請求,且不可超額分配(overcommit),因此 request 與 limit 必須相等。第二項限制規範分配的結果:裝置一旦分配給某個容器,即由該容器獨占,不會同時分配給其他容器。
兩項限制合在一起,就形成「全拿或全不拿」的分配行為。容器無法請求一個裝置的一部分,也無法與其他容器共用同一個裝置。這不是尚未實作的功能,而是 device plugin 模型的設計前提。
回顧 device plugin 的運作流程,從裝置回報、資源請求到裝置分配,每個環節都沒有表達「部分裝置」的機制。
裝置回報。 ListAndWatch 回傳的裝置清單中,每個 Device 只包含 ID、健康狀態,以及選填的 NUMA 拓撲資訊,沒有描述裝置容量(例如顯存大小)的欄位。kubelet 統計健康裝置的數量,登記為節點上的可分配資源,例如單卡節點上的 nvidia.com/gpu: 1。
資源請求。 在 Pod spec 中,原生資源可以使用非整數值,例如 cpu: 500m、memory: 1.5Gi。extended resource 則只接受整數,nvidia.com/gpu: 0.5 是不合法的設定。
裝置分配。 Allocate 接收的參數是裝置 ID。每個 ID 代表一個完整的裝置,介面中沒有指定裝置某一比例的方式。
在 device plugin 模型中,裝置只以數量計算,沒有容量的概念。從資源計數的角度來看,一張 24GB 的 RTX 4090 和一張 80GB 的 H100 沒有差別,兩者都記為 nvidia.com/gpu,數量 1。使用者可以透過 node label 將 Pod 排程到特定型號的節點,但分配單位始終是一個完整裝置。
換言之,Kubernetes 在這個模型下不掌握 GPU 的顯存資訊,只記錄節點上可分配的裝置數量,以及每個裝置是否已被分配。由於每個裝置只有「已分配」和「未分配」兩種狀態,分配結果也只有兩種:全拿,或全不拿。
老實說,Device Plugin 的限制就是 GPU 在 Kubernetes 上用不滿的大元兇。一張卡被一個 Pod 拿走,剩下的算力和 VRAM 就跟著一起鎖住,不管那個 Pod 實際上用了多少。
但這不是它設計得不好。它要解決的是「怎麼讓 Kubernetes 支援它不認識的硬體」,而它做到了,到今天為止我們還在用它,而且用了很多年。
然而,隨著 DRA 在 Kubernetes v1.34 正式 GA,這件事終於有了轉機,DRA 把「要一張卡」從填一個整數,換成提出一份可以描述條件的申請,這個神奇的功能也正是我們明天的主題。
Kubernetes Documentation — Device Plugins