摘要:Kubernetes 提供了 Device Plugin 机制,用于向 kubelet 上报硬件信息并配置容器资源。本文以 NVIDIA GPU Plugin 为例,通俗易懂 并 深入浅出 地剖析注册、ListAndWatch、Allocate 及 kubelet 管理流程,介绍常见问题和配置要点。
先用一张原理概览图把 Device Plugin 和 kubelet 之间的交互勾勒出来,让大家感受下插件技术的整体架构(示例以 NVIDIA 插件为例):
Kubernetes 集群中每个节点上的“管家”,负责管理本节点的容器生命周期(如启动、停止容器),并与 Kubernetes 控制平面通信。Device Plugin 机制扩展了 kubelet 的能力,使其能够管理 GPU 等第三方硬件。
在 Linux 中,常见的 “.sock 文件” 实际上是一种 Unix 域套接字(Unix Domain Socket)。它类似于网络通信中的 TCP/UDP Socket,但只在本机进程之间进行高效数据交换,不会走网络接口。
在本文中,你会看到 kubelet.sock、nvidia.sock 等文件路径,意味着 kubelet 与 Device Plugin 的所有请求和响应都是通过这些 Socket 文件来传递的。
当我们说 ListAndWatch、Allocate 等 “RPC 接口” 时,指的是依赖 gRPC 协议来调用。gRPC 让不同进程可以像调用本地函数一样,在 Socket 之间 发送请求、获取结果。
可以简单理解为:“kubelet” 会通知 “插件” 要求列出设备、分配设备,双方都要实现 gRPC 的“请求-响应”流程。
这是 NVIDIA 官方提供的一套 C 语言库,封装了查询 GPU 信息、监控健康状态等方法。我们常用它获取 GPU UUID、温度、功耗等数据。
许多 NVIDIA GPU Device Plugin 都会依赖 NVML 来确定有哪些 GPU 可用、是否健康。
DaemonSet 是 Kubernetes 的一种高级调度机制,可以让某个 Pod 在 集群里每台节点 都有一份副本在运行。
之所以用 DaemonSet 来部署 GPU Device Plugin,是因为我们希望 每个节点上都要启动一个插件,在新增节点的同时自动启动新插件实例,帮助 kubelet 管理当前节点的 GPU。
Container Runtime(容器运行时)如 Docker、containerd 等,是在 kubelet 命令下,具体执行 “创建容器、运行镜像” 的软件层。
NVIDIA_VISIBLE_DEVICES 等环境变量,是给 nvidia-container-runtime(NVIDIA 提供的一种特殊容器运行时)用来控制 容器能够使用哪些 GPU。这样可以根据调度分配的 GPU ID,有选择地暴露给容器,而不是让容器看到所有 GPU 资源。
在 Kubernetes 中,如果我们想让某些容器使用 GPU(或 FPGA、NIC 等特殊硬件),就需要想办法让 kubelet “认识” 这些硬件设备,并且在调度和容器启动时正确地让容器访问到设备。
传统方法:在 Kubernetes 1.8 之前,往往需要修改 kubelet 源码来适配具体厂商的硬件,这样一来就会导致:
Device Plugin 机制:从 Kubernetes 1.8 开始,官方提供了可插拔的 Device Plugin 框架。厂商可以自己实现一个 Device Plugin —— 一个自定义的守护进程(daemon),通过 gRPC 与 kubelet 通信,把硬件信息“上报”给 kubelet。kubelet 不必自带厂商逻辑,只管和这个 Device Plugin 标准对接即可。
因此,NVIDIA 的 GPU Device Plugin(即 nvidia/k8s-device-plugin)就是这么一个“外部插件”。它以 DaemonSet 的方式部署到集群每个节点上,让节点上的 GPU 被 kubelet 发现、上报给 Kubernetes。接下来用户只需要在 Pod spec 里声明 resources.limits.nvidia.com/gpu: 1
就可以使用对应节点上的 GPU 了。
/var/lib/kubelet/device-plugins/kubelet.sock
上监听 gRPC 服务,用于 “接受插件的注册”;kubelet.sock
进行 gRPC 注册(Register()
);/var/lib/kubelet/device-plugins/nvidia.sock
),去调用 ListAndWatch
了解 GPU 列表,并在有容器需要 GPU 时调用 Allocate
协商如何设置容器环境变量、挂载、等信息。对照代码,这个流程最关键的函数有两类:
Register(...)
(把自己注册给 kubelet)、ListAndWatch(...)
(向 kubelet 报告设备列表)、Allocate(...)
(提供给 kubelet),等等。ManagerImpl.Register(...)
、ListAndWatch(...)
回调处理,最终将资源更新到 Node Status。下面以 nvidia/k8s-device-plugin 为例,结合其 主要函数 以及 简化的核心代码 来看一下每一步是怎么做的。
常见版本中,代码结构大概如下(只列一些主要文件):
.
├── main.go # 插件入口
├── nvidia_device_plugin.go # 实现 DevicePlugin 接口的核心逻辑
├── nvml # NVIDIA 提供的NVML库封装,用于获取GPU信息
└── ...
main.go
大概就是初始化、启动一个 NvidiaDevicePlugin
,并在发现 kubelet 重启时,会重新注册。
nvidia_device_plugin.go
里实现了对 Device Plugin API 的几个接口(ListAndWatch()
, Allocate()
, …)。
代码示意(简化):
// main.go 中最核心的逻辑片段:
func main() {
// 1. 初始化 NVML 以获取 GPU 信息
if err := nvml.Init(); err != nil {
log.Fatal("Failed to init NVML")
}
// 2. 监听文件变化(Watch /var/lib/kubelet/device-plugins/kubelet.sock)
// 如果 kubelet.sock 重新被创建,说明 kubelet 重启,需要重新注册
watcher, _ := newFSWatcher(pluginapi.DevicePluginPath)
var devicePlugin *NvidiaDevicePlugin
restart := true
for {
if restart {
// 每次要“重启”时,都先停止旧的,然后重新New、Serve
if devicePlugin != nil {
devicePlugin.Stop()
}
devicePlugin = NewNvidiaDevicePlugin()
err := devicePlugin.Serve() // <-- 核心
if err != nil {
...
}
restart = false
}
// 监听文件事件或系统信号,一旦发现kubelet.sock被删除/新建,就要重启
select {
case event := <-watcher.Events:
if event.Name == pluginapi.KubeletSocket && event.Op&fsnotify.Create == fsnotify.Create {
restart = true
}
...
}
}
}
Serve()
func (m *NvidiaDevicePlugin) Serve() error {
err := m.Start()
if err != nil {
return err
}
// 1. 启动自身 gRPC server(监听 nvidia.sock),并准备好ListAndWatch等实现
// 2. ...
// Register 向kubelet注册:
err = m.Register(pluginapi.KubeletSocket, "nvidia.com/gpu")
if err != nil {
// 若注册失败,停止自己的server
m.Stop()
return err
}
log.Printf("Registered nvidia device plugin with Kubelet")
return nil
}
重点:Serve()
函数里会先 Start()
(负责在 /var/lib/kubelet/device-plugins/nvidia.sock
开启 gRPC),再 Register()
(把 Endpoint=/var/lib/kubelet/device-plugins/nvidia.sock、ResourceName=nvidia.com/gpu 上报给 kubelet)。
Start()
func (m *NvidiaDevicePlugin) Start() error {
// 1. 先清理旧的 socket 文件
err := m.cleanup()
if err != nil {
return err
}
// 2. 在 nvidia.sock 上启动 gRPC Server
sock, err := net.Listen("unix", m.socket)
if err != nil { return err }
m.server = grpc.NewServer()
pluginapi.RegisterDevicePluginServer(m.server, m) // 注册我们的Server实现
go m.server.Serve(sock) // 开启服务
// 3. 尝试拨号,确保server真的起来了
conn, err := dial(m.socket, 5*time.Second)
if err != nil { return err }
conn.Close()
// 4. 启动GPU健康检查goroutine(见后文healthcheck),用于后续ListAndWatch上报
go m.healthcheck()
return nil
}
pluginapi.RegisterDevicePluginServer(m.server, m)
就把 ListAndWatch
, Allocate
等接口都“挂载”到 grpc 上了;healthcheck()
用来检测 GPU 是否出现 nvmlEventTypeXidCriticalError,一旦检测到某块 GPU 异常,就会在后面 ListAndWatch
中汇报给 kubelet。Register()
func (m *NvidiaDevicePlugin) Register(kubeletEndpoint, resourceName string) error {
// 1. 连接到 /var/lib/kubelet/device-plugins/kubelet.sock
conn, err := dial(kubeletEndpoint, 5*time.Second)
if err != nil {
return err
}
defer conn.Close()
// 2. 构造 RegisterRequest
client := pluginapi.NewRegistrationClient(conn)
req := &pluginapi.RegisterRequest{
Version: pluginapi.Version, // v1beta1
Endpoint: path.Base(m.socket), // "nvidia.sock"
ResourceName: resourceName, // "nvidia.com/gpu"
}
// 3. 调用kubelet的 Registration.Register()
_, err = client.Register(context.Background(), req)
return err
}
kubelet 端会在 kubelet.sock
里监听一个 Register(RegisterRequest)
的 gRPC。收到此请求后,kubelet 就记下 ResourceName: nvidia.com/gpu
,以及 Endpoint: nvidia.sock
。
一旦注册成功,kubelet 会反过来拨号 /var/lib/kubelet/device-plugins/nvidia.sock
,调用 ListAndWatch()
来获取设备列表,并持续接收设备健康状态更新。
在 NVIDIA 插件中,ListAndWatch()
代码大概如下(简化):
func (m *NvidiaDevicePlugin) ListAndWatch(_ *pluginapi.Empty,
s pluginapi.DevicePlugin_ListAndWatchServer) error {
// 1. 第一次调用时,立刻把当前所有GPU列表发送给kubelet
s.Send(&pluginapi.ListAndWatchResponse{Devices: m.devs})
// 2. 然后死循环,监控health管道
for {
select {
case <-m.stop:
return nil
case d := <-m.health:
// 若某块GPU出现异常,就更新它的Health=Unhealthy
d.Health = pluginapi.Unhealthy
// 把最新的 m.devs(带Unhealthy的)再发给kubelet
s.Send(&pluginapi.ListAndWatchResponse{Devices: m.devs})
}
}
}
m.devs
里保存了 GPU 的 ID
(通常是 GPU UUID)以及其 Health
(默认 Healthy
);Unhealthy
,再刷新发给 kubelet,kubelet 就会减少可用 GPU 数量,并更新 Node Status;ListAndWatchResponse
,更新其本地缓存。当用户创建一个 Pod,需要 nvidia.com/gpu: 1
之类的资源时,调度器把该 Pod 分配给节点后,kubelet 在 创建容器 的流程里会调用 Device Plugin 的 Allocate()
来获取容器运行时需要的一些配置信息,比如 环境变量
/ Mount
/ Device
映射等等。
func (m *NvidiaDevicePlugin) Allocate(ctx context.Context,
reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
responses := pluginapi.AllocateResponse{}
for _, req := range reqs.ContainerRequests {
// 1. 拿到Container请求使用的 GPU ID 列表
devIDs := req.DevicesIDs
// 2. 构造 ContainerAllocateResponse
resp := &pluginapi.ContainerAllocateResponse{
Envs: map[string]string{
// NVIDIA_VISIBLE_DEVICES 是nvidia-container-runtime识别的变量,
// 指定容器可见的GPU
"NVIDIA_VISIBLE_DEVICES": strings.Join(devIDs, ","),
},
}
// 3. 这里也可添加 Mounts, Devices, 等信息(若需要访问 /dev/nvidiaX )
// resp.Devices = ...
// resp.Mounts = ...
responses.ContainerResponses = append(responses.ContainerResponses, resp)
}
return &responses, nil
}
DevicesIDs
打包到 NVIDIA_VISIBLE_DEVICES
环境变量,依靠 nvidia-container-runtime 的机制让容器只看见指定 GPU;resp.Devices
字段里声明要挂载 /dev/specialX
到容器里,或者设置更多 Annotations
等。这都取决于硬件的实际接入方式。启动阶段:
/var/lib/kubelet/device-plugins/kubelet.sock
重建事件,如果 kubelet 重启,会重新向 kubelet 注册;nvidia.sock
开 gRPC 服务,实现 ListAndWatch
, Allocate
等。ListAndWatch 阶段:
Healthy
);Unhealthy
并再次上报。Allocate 阶段:
NVIDIA_VISIBLE_DEVICES
环境变量,加上 /dev/nvidia*
访问权限),让容器能正确访问到对应 GPU。在前文中,我们已经重点介绍了:Device Plugin 如何通过 ListAndWatch 和 Allocate 告诉 kubelet 自己有多少设备、分配设备时要怎么配置容器。本节我们再补充一下和 调度器 的配合机制,从而串联起一个 Pod 从“请求 GPU 资源”到“成功使用 GPU 设备”的完整流程。
首先,kubelet 内部有一个 Device Manager(位于 pkg/kubelet/cm/devicemanager/
)。它的核心思路是:
Register()
报告 ResourceName
,kubelet 便将这类硬件视为 统一的“可分配资源”;nvidia.com/gpu: 4
,然后在调度时,Scheduler 就会用通用的 资源匹配逻辑 来判断 “哪个节点满足 nvidia.com/gpu >= 1
” 等;为了让你清楚 kubelet 的这种 “统一抽象” 是怎么实现的,可以看一下 kubelet 源码中 ManagerImpl
的几个关键数据结构(简化):
type ManagerImpl struct {
allDevices map[string]map[string]pluginapi.Device
healthyDevices map[string]sets.String
unhealthyDevices map[string]sets.String
...
}
allDevices
:保存了所有插件上报的设备,map[ResourceName] -> map[DeviceID] -> Device信息
;healthyDevices
:记录每个 ResourceName 下,哪些设备ID是 Healthy 状态;unhealthyDevices
:记录每个 ResourceName 下,哪些设备ID是 Unhealthy 状态。当 plugin 通过 ListAndWatch
告知 kubelet 设备列表时,kubelet 会调用下面这样的方法进行更新:
func (m *ManagerImpl) PluginListAndWatchReceiver(resourceName string, resp *pluginapi.ListAndWatchResponse) {
var devices []pluginapi.Device
for _, d := range resp.Devices {
devices = append(devices, *d)
}
m.genericDeviceUpdateCallback(resourceName, devices)
}
func (m *ManagerImpl) genericDeviceUpdateCallback(resourceName string, devices []pluginapi.Device) {
// 遍历该插件发来的所有设备
for _, dev := range devices {
m.allDevices[resourceName][dev.ID] = dev
if dev.Health == pluginapi.Healthy {
m.healthyDevices[resourceName].Insert(dev.ID)
} else {
m.unhealthyDevices[resourceName].Insert(dev.ID)
}
}
// ...
}
然后 kubelet 会在更新 Node Status 的阶段,把 healthyDevices
的数量当作该节点的可用容量,比如 nvidia.com/gpu: 4
。这就是为什么 scheduler 能看到这个节点有 4 个 GPU 可以用——因为 kubelet 做了“抽象”,把真实的硬件数量记到 Node.Status.Allocatable
里。
当一个 Pod 需要 GPU 资源时(例如 nvidia.com/gpu: 1
),调度器并不需要有什么特殊的 GPU 逻辑,它只是在 Filter 阶段检查:
nvidia.com/gpu
有多少?如果满足,就认为 “在资源层面” 通过了。这部分可参考调度器的源码(示例):
func (f *Fit) Filter(ctx context.Context, cycleState *framework.CycleState,
pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
// 1. 先汇总 podRequest: Pod 需要的 CPU/Memory/... 以及 Extended Resource (如 nvidia.com/gpu)
podRequest := calculatePodResourceRequest(pod)
// 2. 比较 nodeInfo 上的可用资源 vs. podRequest,如果不足,就返回失败
insufficientRes := fitsRequest(podRequest, nodeInfo, ...)
if len(insufficientRes) > 0 {
// 代表某些资源不足
return framework.NewStatus(framework.Unschedulable, "...")
}
return nil
}
func fitsRequest(podRequest *preFilterState, nodeInfo *framework.NodeInfo, ...) []InsufficientResource {
...
for rName, rQuant := range podRequest.ScalarResources {
if rQuant > (nodeInfo.Allocatable.ScalarResources[rName] - nodeInfo.Requested.ScalarResources[rName]) {
// 资源不足
insufficientResources = append(insufficientResources, InsufficientResource{ ResourceName: rName, ... })
}
}
...
}
rName
是 "nvidia.com/gpu"
时,这里就去比对 “Node 上 GPU 的 Allocatable - 已分配量” 是否够。由此可见,在调度阶段,插件对接 的 GPU 资源会跟 CPU/Memory 一样被当作“资源”来 Filter,没有特殊专门的 GPU 调度逻辑。
当调度器最终确定 某节点 能满足 Pod 需求,Pod 就被 绑定 到该节点上。
然后,该节点上的 kubelet 启动容器前,会在 “Device Manager → Plugin” 调用 Allocate()
,让 plugin 返回如何设置容器。NVIDIA plugin 这里,就会返回 NVIDIA_VISIBLE_DEVICES
环境变量,或 /dev/nvidia*
等特定挂载,让最终的容器只看到被分配到的 GPU。
简化示意(对照前文):
Allocate()
;DeviceManager
(在 /var/lib/kubelet/device-plugins/kubelet.sock
开启一个 gRPC server),等着外部的 Device Plugin 来 “Register()”。endpointImpl
去拨号插件的 socket(例如 nvidia.sock
),调用 ListAndWatch()
,拿到 GPU 列表。nvidia.com/gpu = 4
。Allocate()
获取容器启动参数(环境变量 / 设备挂载 / …),最终把这些注入到容器 runtime。/etc/docker/daemon.json
里,"default-runtime": "nvidia"
, 并配置好 nvidia-container-runtime,这样容器启动时才可以识别并正确加载 GPU 驱动;NVIDIA_VISIBLE_DEVICES
失效,容器会看到所有 GPU,这就违背了 Kubernetes “整卡分配” 的隔离逻辑;NVIDIA drivers >= 384.81
,nvidia-docker >= 2.0
,并且 Kubernetes 版本 >= 1.10 等;ListAndWatch()
阶段把一张 GPU 虚拟成多张 ID,然后在 Allocate()
里做对应的合并映射。业界也有更复杂的 GPU 虚拟化方案(比如 vGPU);/var/lib/kubelet/pod-resources/kubelet.sock
的 List
gRPC 来查询哪些 Pod 正在使用哪些 GPU 设备,这个属于 “PodResources API”。通过以上分析过程,我们可以看到 GPU Device Plugin 的总体原理相对清晰:
ListAndWatch
, Allocate
等接口Allocate()
让插件告诉它怎么配置容器因为 Device Plugin 框架将 “硬件逻辑” 和 “kubelet 核心” 解耦,厂商可以各自实现插件,大大降低了 Kubernetes 自身的复杂度。NVIDIA/k8s-device-plugin 即是其中的一个典型实现,也是目前使用最广泛的 GPU Plugin。