KubeVirt virt-handler Device Manager 超深度分析
基于源码级深度剖析,覆盖 Device Manager 全部核心文件
一、模块定位
1.1 业务职责
KubeVirt Device Manager 是 virt-handler 中负责主机设备发现与 Kubernetes Device Plugin 暴露的核心子系统。其核心使命是:
将宿主机上的物理设备(GPU、PCI设备、vGPU中介设备、KVM、VFIO、USB等)转化为 Kubernetes 可调度、可分配的 Extended Resource,使虚拟机工作负载能够通过标准的 K8s 资源请求机制获取所需的硬件设备。
具体而言,Device Manager 解决了以下关键问题:
| 问题 | 解决方案 |
|---|---|
| K8s 不感知宿主机硬件设备 | 实现 Kubernetes Device Plugin API,将设备注册为 Extended Resource |
| 虚拟机需要 GPU/PCI 直通 | 发现 VFIO 设备组,通过 Device Plugin 暴露 /dev/vfio/* |
| vGPU 场景需要中介设备 | 自动发现 mdev 类型、创建/销毁 mdev 实例、注册 mdev 资源 |
| 设备动态变化(热插拔) | fsnotify 监控设备路径,实时更新设备健康状态 |
| 配置驱动的设备策略 | 监听 KubeVirt ConfigMap,动态启用/禁用设备 Plugin |
| 安全与权限 | SELinux relabel、cgroup misc capacity、socket 权限管理 |
1.2 在 KubeVirt 架构中的位置
┌─────────────────────────────────────────────────────────────────────┐
│ KubeVirt 控制面 │
│ ┌───────────────┐ ┌────────────────┐ ┌──────────────────────┐ │
│ │ virt-api │ │ virt-controller│ │ kubevirt-config CM │ │
│ └───────────────┘ └────────────────┘ └──────────────────────┘ │
└───────────────────────────┬─────────────────────────────────────────┘
│ ConfigMap Watch / Node Informer
┌───────────────────────────▼─────────────────────────────────────────┐
│ virt-handler Pod (DaemonSet) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Device Manager │ │
│ │ ┌──────────────┐ ┌──────────────────────────────────┐ │ │
│ │ │DeviceController│──│ Generic/Mediated/PCI/Socket/USB │ │ │
│ │ │ (编排层) │ │ (各设备类型 Plugin) │ │ │
│ │ └──────────────┘ └──────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ gRPC (Unix Socket) │
│ ┌─────────────────────────▼───────────────────────────────────┐ │
│ │ Kubelet Device Plugin Manager │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ Extended Resource
┌───────▼───────┐
│ Pod 调度/分配 │
└───────────────┘
1.3 核心交互对象
- Kubelet Device Plugin Manager:通过 gRPC over Unix Socket 注册设备、报告状态、响应分配请求
- KubeVirt ClusterConfig:监听
kubevirt-configConfigMap,获取允许的设备列表、Feature Gate 开关 - Node Informer:获取当前 Node 对象,用于读取 mdev 资源注解
- 宿主机 sysfs/设备文件系统:
/sys/bus/pci、/sys/bus/mdev、/dev/vfio、/dev/kvm等 - virt-chroot:创建/删除 mdev 实例时通过 chroot 执行系统命令
二、模块整体结构
2.1 DeviceController 结构体全字段详解
type DeviceController struct {
permanentPlugins map[string]Device // 永久性设备插件(KVM/tun/vhost-net等)
startedPlugins map[string]controlledDevice // 已启动的设备插件(包含永久+动态)
startedPluginsMutex sync.Mutex // 保护 startedPlugins 的互斥锁
host string // 当前节点主机名
maxDevices int // 每种设备的最大实例数
permissions string // 设备权限(如 "rw"、"mrw")
backoff []time.Duration // 重试退避时间序列
virtConfig *virtconfig.ClusterConfig // 集群配置(ConfigMap Watcher)
mdevTypesManager *MDEVTypesManager // mdev 类型管理器
nodeStore cache.Store // Node Informer Store
mdevRefreshWG *sync.WaitGroup // mdev 刷新的并发控制 WaitGroup
lastTDXAttestationConfig *tdxConfigState // TDX 远程认证配置状态缓存
}
各字段深度解读:
-
permanentPlugins:存储节点上必须始终存在的设备插件。由PermanentHostDevicePlugins()函数创建,默认包含:hypervisorDevice(通常为kvm)→/dev/kvmtun→/dev/net/tunvhost-net→/dev/vhost-net
这些设备是虚拟化运行的基础,不可动态禁用。
-
startedPlugins:所有正在运行的设备插件实例,键为resourceName,值为controlledDevice。包含永久插件和动态发现/配置驱动的插件(PCI、mdev、USB、TDX 等)。由startedPluginsMutex保护。 -
startedPluginsMutex:关键并发保护。refreshPermittedDevices()可被多个 informer 回调或 ConfigMap 快速连续更新触发,此锁防止同一设备被重复 start/stop。 -
host:用于从nodeStore中查找当前 Node 对象的关键字。 -
maxDevices:传递给每个 Generic/Socket Device Plugin 的最大设备数,决定 K8s 资源容量。默认值为配置中传入的数值(通常是 1000 或类似值)。 -
permissions:设备文件权限字符串,如"rw"或"mrw"(m= mmap, r= read, w= write),传递给 DeviceSpec.Permissions。 -
backoff:controlledDevice.Start()失败时的退避序列,默认[1s, 2s, 5s, 10s]。最大退避为最后一个元素。 -
virtConfig:ClusterConfig 实例,提供:GetPermittedHostDevices()— 获取允许的 PCI/mdev/USB 设备列表WorkloadEncryptionTDXEnabled()— TDX Feature GateWorkloadEncryptionSEVEnabled()— SEV Feature GateVSOCKEnabled()— VSOCK Feature GateGetDesiredMDEVTypes()— 从 Node 注解获取期望的 mdev 类型SetConfigModifiedCallback()— 注册 ConfigMap 变更回调
-
mdevTypesManager:管理 mdev 类型的生命周期——创建、删除、发现可配置的 mdev 类型。 -
nodeStore:Kubernetes Node Informer 的本地缓存,用于读取 Node 对象的注解(如 mdev 期望类型列表)。 -
mdevRefreshWG:确保 mdev 刷新操作在 DeviceController 退出前完成,避免资源泄露。 -
lastTDXAttestationConfig:缓存上一次 TDX 配置(QGS socket 路径 + 是否需要 QGS),用于检测配置变更并重启 TDX Device Plugin。
2.2 controlledDevice 结构体
type controlledDevice struct {
devicePlugin Device // 被控制的设备插件实例
started bool // 是否已启动
stopChan chan struct{} // 停止信号通道
backoff []time.Duration // 重试退避序列
}
controlledDevice 是 DeviceController 对每个 Device Plugin 的管理封装。核心行为:
Start():如果未启动,创建 stop channel,启动一个 goroutine 循环调用dev.Start(stop)。失败时按 backoff 序列重试。成功后retries归零。Stop():关闭 stop channel,触发 goroutine 退出。GetName():返回设备资源名称。
重试逻辑的关键细节:
retries = int(math.Min(float64(retries+1), float64(len(backoff)-1)))
失败时 retries 递增,但不超过 len(backoff)-1(最后一个元素为最大退避)。成功则归零。
2.3 设备类型接口与实现
2.3.1 Device 接口
type Device interface {
Start(stop <-chan struct{}) error
ListAndWatch(*pluginapi.Empty, pluginapi.DevicePlugin_ListAndWatchServer) error
PreStartContainer(context.Context, *pluginapi.PreStartContainerRequest) (*pluginapi.PreStartContainerResponse, error)
Allocate(context.Context, *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error)
GetDeviceName() string
GetInitialized() bool
}
这是所有设备插件必须实现的核心接口,直接映射 Kubernetes Device Plugin v1beta1 API:
| 方法 | 对应 gRPC | 作用 |
|---|---|---|
Start | — | 启动 gRPC Server、注册到 Kubelet |
ListAndWatch | ListAndWatch | 流式推送设备列表与健康状态 |
Allocate | Allocate | 响应 Kubelet 的设备分配请求 |
PreStartContainer | PreStartContainer | 容器启动前准备(本模块全部返回空) |
GetDeviceName | — | 返回资源名称标识 |
GetInitialized | — | 返回插件是否已完成初始化 |
2.3.2 五种设备类型实现
┌─────────── Device (Interface) ───────────┐
│ Start / ListAndWatch / Allocate / ... │
└─────────────────┬───────────────────────┘
│
┌─────────────────────────┼──────────────────────────┐
│ │ │
┌───────▼──────┐ ┌───────────────▼──────────┐ ┌───────────▼──────────┐
│GenericDevice │ │ DevicePluginBase │ │ USBDevicePlugin │
│ Plugin │ │ (嵌入式公共基类) │ │ (独立实现) │
└──────────────┘ └───────────┬──────────────┘ └──────────────────────┘
│
┌───────────┼────────────────┐
│ │ │
┌────────▼───┐ ┌────▼──────────┐ ┌───▼──────────────┐
│MediatedDevice│ │PCIDevicePlugin│ │SocketDevicePlugin│
│ Plugin │ │ │ │ │
└──────────────┘ └───────────────┘ └──────────────────┘
各实现特点对比:
| 特性 | Generic | Mediated | PCI | Socket | USB |
|---|---|---|---|---|---|
| 基类 | 独立实现 | 嵌入DevicePluginBase | 嵌入DevicePluginBase | 嵌入DevicePluginBase | 嵌入DevicePluginBase |
| 设备ID来源 | 名字+序号 | IOMMU Group | IOMMU Group | 名字+序号 | 资源名+随机串+序号 |
| Allocate返回 | DeviceSpec | DeviceSpec+Envs | DeviceSpec+Envs | Mount | DeviceSpec+Envs |
| 健康检查 | fsnotify(设备文件) | fsnotify(VFIO设备) | fsnotify(VFIO设备) | fsnotify(Socket文件) | fsnotify(USB设备路径) |
| NUMA拓扑 | 无 | 有 | 有 | 无 | 无 |
| 权限管理 | 无 | 无 | 无 | SELinux+Chown | Chown |
| 典型设备 | /dev/kvm, /dev/tun | vGPU mdev | GPU NIC SR-IOV | QGS socket, PR helper | USB 直通 |
2.3.3 DevicePluginBase 公共基类
type DevicePluginBase struct {
devs []*pluginapi.Device // 设备列表
server *grpc.Server // gRPC 服务器
socketPath string // Device Plugin socket 路径
stop <-chan struct{} // 停止信号
health chan deviceHealth // 健康状态变更通道
resourceName string // K8s 资源名(如 devices.kubevirt.io/kvm)
done chan struct{} // 插件完成信号
initialized bool // 是否初始化完成
lock *sync.Mutex // 保护 initialized
deregistered chan struct{} // 反注册完成信号
devicePath string // 宿主机设备路径
deviceRoot string // 设备根路径(通常为 /proc/1/root)
deviceName string // 设备名称
}
DevicePluginBase 提供了四个公共方法实现:
-
ListAndWatch:监听healthchannel,收到健康变更后更新对应设备的 Health 字段并推送。支持按DevId精确更新或全量更新(DevId == "")。退出时发送空列表触发 Kubelet 反注册。 -
PreStartContainer/GetDevicePluginOptions:直接返回空/默认响应,KubeVirt 不需要容器预启动操作。 -
stopDevicePlugin:等待deregisteredchannel 最多1秒(给 Kubelet 时间处理空设备列表),然后停止 gRPC Server,清理 socket 文件。 -
register:通过 gRPC 连接 Kubelet socket (/var/lib/kubelet/device-plugins/kubelet.sock),发送RegisterRequest。 -
cleanup:删除 Device Plugin socket 文件。
2.4 核心方法清单
DeviceController 层
| 方法 | 作用 |
|---|---|
NewDeviceController | 构造函数 |
Run | 主循环入口,启动永久插件、注册回调、阻塞等待退出 |
Initialized | 所有已启动插件是否完成初始化 |
NodeHasDevice | 检查宿主机设备路径是否存在 |
updatePermittedHostDevicePlugins | 根据配置发现并构建允许的设备插件列表 |
refreshPermittedDevices | 对比新旧设备列表,启动新增/停止移除的插件 |
splitPermittedDevices | 将设备列表拆分为待启动和待停止两个集合 |
startDevice / stopDevice | 管理单个设备的生命周期 |
RefreshMediatedDeviceTypes | 外部触发的 mdev 类型刷新 |
refreshMediatedDeviceTypes | 实际的 mdev 配置更新逻辑 |
refreshTDXConfig | 检测 TDX 配置变更 |
updateTdxDevice | 构建 TDX Device Plugin |
getExternallyProvidedMdevs | 获取外部提供的 mdev 资源映射 |
MDEVTypesManager 层
| 方法 | 作用 |
|---|---|
updateMDEVTypesConfiguration | 更新 mdev 类型配置(删除不需要的、发现可配置的、创建期望的) |
discoverConfigurableMDEVTypes | 发现同时满足"期望"和"可配置"的 mdev 类型 |
configureDesiredMDEVTypes | 按轮询策略在可用父设备上创建 mdev 实例 |
getAlreadyConfiguredMdevParents | 获取已有 mdev 实例的父 PCI 地址 |
initMDEVTypesRing | 初始化环形缓冲区用于轮询分配 |
DeviceHandler 接口(硬件操作层)
| 方法 | 作用 |
|---|---|
GetDeviceIOMMUGroup | 读取 PCI 设备的 IOMMU 组号 |
GetDeviceDriver | 读取 PCI 设备绑定的驱动 |
GetDeviceNumaNode | 读取 PCI 设备的 NUMA 节点 |
GetDevicePCIID | 读取 PCI vendor:device ID |
GetMdevParentPCIAddr | 获取 mdev 的父 PCI 地址 |
CreateMDEVType | 创建 mdev 实例(通过 virt-chroot) |
RemoveMDEVType | 删除 mdev 实例(通过 virt-chroot) |
ReadMDEVAvailableInstances | 读取 mdev 类型的可用实例数 |
HasVGPUProfile | 检查 NVIDIA 设备是否有 vGPU 配置 |
IsPhysicalFunction | 检查 PCI 设备是否为 SR-IOV PF |
三、核心业务逻辑深度解析
3.1 Device Manager 启动流程
virt-handler 启动
│
▼
NewDeviceController(host, maxDevices, permissions, permanentPlugins, clusterConfig, nodeStore)
│
│ 构建永久插件: kvm, tun, vhost-net
│ 初始化 MDEVTypesManager
▼
DeviceController.Run(stop)
│
├─1. 启动永久插件(加锁)
│ for name, dev := range c.permanentPlugins:
│ c.startDevice(name, dev)
│
├─2. 注册 ConfigMap 变更回调
│ c.virtConfig.SetConfigModifiedCallback(refreshMediatedDeviceTypesFn)
│ c.virtConfig.SetConfigModifiedCallback(c.refreshPermittedDevices)
│
├─3. 首次刷新允许的设备
│ c.refreshPermittedDevices()
│
└─4. 阻塞等待 stop 信号
<-stop
停止所有插件 + WaitGroup 等待
单个 Device Plugin 的启动细节:
controlledDevice.Start()
│
▼
go func() {
for {
dev.Start(stop) ←────────────────────────────┐
│ │
├─1. cleanup() 删除旧 socket │
├─2. preOpen: 打开设备文件触发 modprobe │
├─3. net.Listen("unix", socketPath) │
├─4. grpc.NewServer + Register │
├─5. server.Serve(sock) // goroutine │
├─6. waitForGRPCServer(socket, 5s) │
├─7. register() → Kubelet │
├─8. healthCheck() // goroutine │
├─9. setInitialized(true) │
└─10. 等待错误 ── 失败?── 退避重试 ────────┘
│
│ 成功运行直到 stop/错误
▼
返回 err
}
}()
关键步骤解析:
Step 2 - preOpen:某些设备(如 tun、vhost-net)的内核模块按需加载。打开设备文件触发 modprobe,确保设备可用。GenericDevicePlugin 中 preOpen 字段控制此行为——仅非 hypervisor 设备(tun、vhost-net)启用。
Step 7 - register:连接 Kubelet 的 Device Plugin Registry socket,发送 RegisterRequest:
reqt := &pluginapi.RegisterRequest{
Version: "v1beta1",
Endpoint: "kubevirt-kvm.sock", // socket 文件名
ResourceName: "devices.kubevirt.io/kvm", // 资源名
}
Kubelet 收到后,开始通过该 socket 调用 ListAndWatch 获取设备列表,并将该资源注册到 Node 的 Capacity 和 Allocatable 中。
3.2 Generic Device:通用设备发现与分配
3.2.1 结构体
type GenericDevicePlugin struct {
devs []*pluginapi.Device // 设备列表(虚拟多实例)
server *grpc.Server
socketPath string // /var/lib/kubelet/device-plugins/kubevirt-<name>.sock
stop <-chan struct{}
health chan deviceHealth
devicePath string // 如 /dev/kvm
deviceName string // 如 "kvm"
resourceName string // 如 "devices.kubevirt.io/kvm"
done chan struct{}
deviceRoot string // /proc/1/root (容器内宿主根)
preOpen bool // 是否在启动时打开设备文件
initialized bool
lock *sync.Mutex
permissions string // 如 "rw"
deregistered chan struct{}
}
3.2.2 设备发现
Generic Device 的"发现"极为简单——它不扫描 sysfs,而是在构造时直接创建 maxDevices 个虚拟设备实例:
for i := 0; i < maxDevices; i++ {
deviceId := dpi.deviceName + strconv.Itoa(i) // "kvm0", "kvm1", ...
dpi.devs = append(dpi.devs, &pluginapi.Device{
ID: deviceId,
Health: pluginapi.Healthy,
})
}
这反映了通用设备的本质:/dev/kvm 只有一个物理实例,但可以被多个虚拟机共享。通过创建多个虚拟 Device ID,K8s 将其视为多个独立资源,调度器可以据此限制并发 VM 数量。
3.2.3 分配逻辑
func (dpi *GenericDevicePlugin) Allocate(ctx context.Context, r *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
response := pluginapi.AllocateResponse{}
containerResponse := new(pluginapi.ContainerAllocateResponse)
dev := new(pluginapi.DeviceSpec)
dev.HostPath = dpi.devicePath // /dev/kvm
dev.ContainerPath = dpi.devicePath // /dev/kvm
dev.Permissions = dpi.permissions // "rw"
containerResponse.Devices = []*pluginapi.DeviceSpec{dev}
response.ContainerResponses = []*pluginapi.ContainerAllocateResponse{containerResponse}
return &response, nil
}
关键特点:
- 无论请求多少个 Device ID,Allocate 始终返回同一个设备路径
- 不设置 Envs — 通用设备不需要环境变量传递
- DeviceSpec 的 HostPath 和 ContainerPath 相同,意味着设备在容器内的路径与宿主机一致
3.2.4 健康检查
Generic Device 的健康检查基于 fsnotify 监控设备文件的父目录。当 /dev/kvm 被创建/删除时,触发健康状态变更,通过 health channel 传递给 ListAndWatch,由其推送给 Kubelet。
3.3 Mediated Device:GPU/vGPU 中介设备
3.3.1 MDEV 结构体
type MDEV struct {
UUID string // mdev 实例 UUID
typeName string // mdev 类型名(如 "nvidia-223")
parentPciAddress string // 父设备 PCI 地址
iommuGroup string // IOMMU 组号
numaNode int // NUMA 节点
}
3.3.2 MediatedDevicePlugin 结构体
type MediatedDevicePlugin struct {
*DevicePluginBase
iommuToMDEVMap map[string]string // IOMMU Group → mdev UUID 映射
}
iommuToMDEVMap 是 mdev 分配的核心映射。K8s Device Plugin 使用 IOMMU Group 作为 Device ID(因为 VFIO 设备通过 IOMMU Group 访问),而 mdev UUID 是实际的中介设备标识。分配时通过此映射将 IOMMU Group 转换为 mdev UUID。
3.3.3 设备发现流程
关键细节:
- mdev 实体在
/sys/bus/mdev/devices/下以 UUID 命名的符号链接形式存在 getMdevTypeName先尝试读mdev_type/name文件,若不存在则读mdev_type符号链接的 basename- 空格替换为下划线:
GRID T4-1Q→GRID_T4-1Q,与 ConfigMap 中的 selector 格式对齐 - NUMA 拓扑信息从父 PCI 设备的
numa_node文件读取,附加到 Device Topology
3.3.4 分配逻辑
func (dpi *MediatedDevicePlugin) Allocate(_ context.Context, r *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
for _, request := range r.ContainerRequests {
for _, devID := range request.DevicesIDs {
// IOMMU Group → mdev UUID
mdevUUID, exist := dpi.iommuToMDEVMap[devID]
// 验证设备仍然存在
_, err := os.Stat(filepath.Join(dpi.deviceRoot, dpi.devicePath, devID))
// 生成 VFIO DeviceSpec
formattedVFIO := formatVFIODeviceSpecs(devID)
deviceSpecs = append(deviceSpecs, formattedVFIO...)
}
// 设置环境变量: KUBEVIRT_MEDIALIZED_RESOURCE_<RESOURCE> = <UUID1>,<UUID2>,...
envVar[resourceNameEnvVar] = strings.Join(allocatedDevices, ",")
}
}
formatVFIODeviceSpecs 生成的设备规范:
// 1. /dev/vfio/vfio (VFIO 容器接口)
&pluginapi.DeviceSpec{
HostPath: "/dev/vfio/vfio",
ContainerPath: "/dev/vfio/vfio",
Permissions: "mrw",
}
// 2. /dev/vfio/<iommuGroup> (具体设备)
&pluginapi.DeviceSpec{
HostPath: "/dev/vfio/<iommuGroup>",
ContainerPath: "/dev/vfio/<iommuGroup>",
Permissions: "mrw",
}
环境变量格式:KUBEVIRT_MDEV_RESOURCE_DEVICES_KUBEVIRT_IO_NVIDIA_223=uuid1,uuid2
3.4 PCI Device:PCI 设备直通
3.4.1 PCIDevice 结构体
type PCIDevice struct {
pciID string // vendor:device ID(如 "10de:1eb8")
driver string // 绑定的驱动名(如 "vfio-pci")
pciAddress string // PCI 地址(如 "0000:65:00.0")
iommuGroup string // IOMMU 组号
numaNode int // NUMA 节点
}
3.4.2 设备发现流程
NVIDIA vGPU 特殊处理(nvidia.go):
PCI 设备发现有一个关键的特殊逻辑:对于非 vfio-pci 驱动的设备,原则上应跳过(因为 VFIO 直通需要 vfio-pci 驱动)。但 NVIDIA vGPU VF 是例外——它们使用 nvidia 驱动而非 vfio-pci,但仍然可以通过 mdev 机制进行 vGPU 分配。
func shouldPermitNvidia(driver, pciBasePath, pciAddress, pciID string) bool {
if driver != "nvidia" {
return false
}
if handler.IsPhysicalFunction(pciBasePath, pciAddress) {
return false // PF 不可分配
}
if !handler.HasVGPUProfile(pciBasePath, pciAddress) {
return false // 没有 vGPU 配置的 VF 不可分配
}
return true // 有 vGPU 配置的 NVIDIA VF 允许分配
}
IsPhysicalFunction 通过检查 /sys/bus/pci/devices/<addr>/sriov_totalvfs 是否存在来判断。只有 PF 才有此文件。
HasVGPUProfile 通过读取 /sys/bus/pci/devices/<addr>/nvidia/current_vgpu_type 判断,非空且非 “0” 表示有 vGPU 配置。
3.4.3 分配逻辑
PCI 分配与 Mediated 类似,都使用 IOMMU Group 作为 Device ID,生成 VFIO DeviceSpec:
func (dpi *PCIDevicePlugin) Allocate(_ context.Context, r *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
for _, request := range r.ContainerRequests {
for _, devID := range request.DevicesIDs {
devPCIAddress, exist := dpi.iommuToPCIMap[devID] // IOMMU → PCI 地址
allocatedDevices = append(allocatedDevices, devPCIAddress)
deviceSpecs = append(deviceSpecs, formatVFIODeviceSpecs(devID)...)
}
envVar[resourceNameEnvVar] = strings.Join(allocatedDevices, ",")
}
}
环境变量格式:KUBEVIRT_PCI_RESOURCE_DEVICES_KUBEVIRT_IO_NVIDIA_GPU=0000:65:00.0,0000:66:00.0
3.5 Socket Device:Unix Socket 设备
3.5.1 结构体
type SocketDevicePlugin struct {
*DevicePluginBase
socketRoot string // Socket 根路径(/ 或 /proc/1/root)
socketDir string // Socket 目录
socket string // Socket 文件名
socketName string // 设备名称
executor selinux.Executor // SELinux 执行器
p PermissionManager // 权限管理器
healthChecks bool // 是否启用健康检查
hostRootMount string // 宿主根挂载路径
}
3.5.2 使用场景
Socket Device Plugin 用于需要通过 Unix Socket 通信的场景:
- TDX QGS (Quote Generation Service):Intel TDX 远程认证需要 VM 访问 QGS socket
- Persistent Reservation Helper:SCSI PR 命令需要通过 socket 代理
两种构造函数:
// 标准 Socket Device Plugin — 健康检查启用
NewSocketDevicePlugin(socketName, socketDir, socket, maxDevices, executor, p, useHostRootMount)
// 可选 Socket Device Plugin — 健康检查禁用(设备始终健康)
NewOptionalSocketDevicePlugin(socketName, socketDir, socket, maxDevices, executor, p, useHostRootMount)
TDX 场景中,RequireQGS 为 true 时用标准版(QGS 必须存在才健康),否则用可选版(QGS 不存在也健康)。
3.5.3 分配逻辑
Socket 设备的分配不使用 DeviceSpec,而是使用 Mount:
func (dpi *SocketDevicePlugin) Allocate(ctx context.Context, r *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
m := new(pluginapi.Mount)
m.HostPath = dpi.socketDir // 如 /var/run/kubevirt-private/tdx-qgs
m.ContainerPath = dpi.socketDir
m.ReadOnly = false
containerResponse.Mounts = []*pluginapi.Mount{m}
return &response, nil
}
为什么不直接挂载 socket 文件而挂载目录? 因为 Kubernetes Mount 不支持单个文件挂载的原子性保证。挂载整个目录确保 socket 文件在被创建/重建后仍然可访问。
3.5.4 权限管理
Socket Device 有独特的权限管理需求:
func (dpi *SocketDevicePlugin) setSocketPermissions() error {
// 1. 解析安全路径
prSock, err := safepath.JoinAndResolveWithRelativeRoot(dpi.socketRoot, dpi.socketDir, dpi.socket)
// 2. Chown 为 NonRoot UID
err = dpi.p.ChownAtNoFollow(prSock, util.NonRootUID, util.NonRootUID)
// 3. SELinux relabel
if se, exists, err := dpi.executor.NewSELinux(); err == nil && exists {
selinux.RelabelFilesUnprivileged(se.IsPermissive(), prSock)
}
}
这确保了以非 root 用户运行的 QEMU 进程可以访问 socket。ChownAtNoFollow 避免符号链接攻击。设备出现(fsnotify Create 事件)时也会重新设置权限。
3.6 USB Device:USB 设备直通
3.6.1 USBDevice 结构体
type USBDevice struct {
Name string // 设备名
Manufacturer string // 厂商
Vendor int // 厂商 ID(十六进制)
Product int // 产品 ID(十六进制)
BCD int // 设备版本
Bus int // 总线号
DeviceNumber int // 设备号
Serial string // 序列号
DevicePath string // /dev/bus/usb/BBB/DDD
}
唯一标识:Vendor:Product-Bus:DeviceNumber(如 1d6b:0002-01:01)
3.6.2 设备发现流程
关键设计:
- LocalDevices 按 Vendor ID 建立索引,加速查找
fetch()方法找到一组匹配的 USB 设备后从列表中移除,避免同一个物理 USB 设备被分配给多个 K8s 资源- 支持多 selector:一个
USBHostDevice可以指定多个 Vendor:Product 对,需要全部找到才算匹配 PluginDevices的 ID 格式:<resourceName>-<4位随机串>-<index>,确保唯一性
3.6.3 分配逻辑
func (plugin *USBDevicePlugin) Allocate(_ context.Context, allocRequest *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
for _, request := range allocRequest.ContainerRequests {
for _, id := range request.DevicesIDs {
pluginDevices := plugin.FindDevice(id)
for _, dev := range pluginDevices.Devices {
// Chown 设备文件为 NonRootUID
safepath.ChownAtNoFollow(spath, util.NonRootUID, util.NonRootUID)
// DeviceSpec
&pluginapi.DeviceSpec{
ContainerPath: dev.DevicePath, // /dev/bus/usb/001/002
HostPath: dev.DevicePath,
Permissions: "mrw",
}
// 环境变量: KUBEVIRT_USB_RESOURCE_<NAME>=Bus:DeviceNum,Bus:DeviceNum
env[key] = fmt.Sprintf("%d:%d", dev.Bus, dev.DeviceNumber)
}
}
}
}
USB 分配的权限为 "mrw",支持 mmap(某些 USB 设备需要)。环境变量传递 Bus:DeviceNumber 格式,virt-launcher 可据此定位具体设备。
3.7 NVIDIA GPU 专用管理
nvidia.go 是一个精简但关键的策略文件,核心函数 shouldPermitNvidia 仅20行,但解决了 PCI 设备发现中的一个重要边缘场景:
NVIDIA GPU 在 KubeVirt 中的两种使用模式:
┌──────────────────┐
│ PCI 直通模式 │
│ 驱动: vfio-pci │
│ 通过 VFIO 访问 │
└──────────────────┘
┌────────────────────┐ ▲
│ NVIDIA vGPU 模式 │ │
│ 驱动: nvidia │──── 允许通过 ───────┘
│ 通过 mdev 分配 │ shouldPermitNvidia
│ VF 有 vGPU profile │ │
└────────────────────┘ │
│ 跳过
┌─────┴──────────────┐
│ 普通 NVIDIA GPU │
│ 驱动: nvidia │
│ 无 vGPU profile │
│ (不能直接 VFIO 直通) │
└─────────────────────┘
为何需要此逻辑?
在 NVIDIA vGPU 部署中,物理 GPU 被拆分为多个 Virtual Functions (VFs)。这些 VFs 使用 nvidia 驱动(而非 vfio-pci),但每个 VF 配置了 vGPU profile,可以通过 mdev 机制创建 vGPU 实例。
如果不做特殊处理,discoverPermittedHostPCIDevices 会因驱动不是 vfio-pci 而跳过这些设备,导致 vGPU 资源无法被发现。shouldPermitNvidia 正确识别了这一场景。
3.8 设备权限与 cgroup 管理
3.8.1 DeviceSpec 权限模型
Kubernetes Device Plugin 的 DeviceSpec.Permissions 使用三个标志位:
| 标志 | 含义 | 使用场景 |
|---|---|---|
r (read) | 读取权限 | 所有设备 |
w (write) | 写入权限 | 所有设备 |
m (mmap) | 内存映射 | VFIO 设备、USB 设备 |
各设备类型的权限配置:
| 设备类型 | 权限 | 原因 |
|---|---|---|
| Generic (KVM/tun/vhost-net) | 可配置(默认 “rw”) | 用户传入 permissions 参数 |
| PCI/Mediated | “mrw” | VFIO 需要 mmap 访问设备寄存器 |
| USB | “mrw” | USB 设备可能需要 mmap |
| Socket | 无 DeviceSpec | 使用 Mount 而非 DeviceSpec |
3.8.2 Socket 权限与 SELinux
Socket Device 的权限管理涉及三层:
- 文件所有权 (Chown):
safepath.ChownAtNoFollow(path, NonRootUID, NonRootUID)— 将 socket 文件的所有权改为非 root 用户,使 QEMU 可以访问 - SELinux Label:
selinux.RelabelFilesUnprivileged(isPermissive, path)— 重新标记 SELinux 上下文,确保 virt-launcher 域可以访问 - 目录权限:不仅设置 socket 文件本身,还设置其父目录的权限
3.8.3 TDX cgroup 容量管理
TDX (Trust Domain Extensions) 设备的数量由 cgroup misc capacity 控制:
func (c *DeviceController) updateTdxDevice() (Device, error) {
maxTDXVMs, err := cgroup.GetMiscCapacity("tdx")
// 从 /sys/fs/cgroup/misc.capacity/ 读取 tdx 的容量
if maxTDXVMs > 0 {
// 创建 Socket Device Plugin,maxDevices = maxTDXVMs
if c.virtConfig.RequireQGS() {
NewSocketDevicePlugin(services.TdxDeviceName, socketDir, socketFile, maxTDXVMs, ...)
} else {
NewOptionalSocketDevicePlugin(services.TdxDeviceName, socketDir, socketFile, maxTDXVMs, ...)
}
}
}
这确保了 TDX VM 的数量不会超过硬件/内核配置的容量限制。
3.9 热插拔设备处理
3.9.1 设备热插拔检测机制
Device Manager 通过 fsnotify 实现热插拔检测,这是所有设备类型的统一健康检查基础设施:
各设备类型的监控对象:
| 设备类型 | 监控路径 | 特殊行为 |
|---|---|---|
| Generic | 设备文件父目录 + Plugin Socket 目录 | 设备 Create→Healthy, Remove/Rename→Unhealthy |
| Mediated | /dev/vfio/<iommuGroup> 每个 VFIO 设备 | 每个 mdev 单独监控 |
| PCI | /dev/vfio/<iommuGroup> 每个 VFIO 设备 | 每个 PCI 设备单独监控 |
| Socket | <socketRoot>/<socketDir>/<socket> | Create 时重新设置权限 |
| USB | <hostRootMount>/<DevicePath> 每个设备 | 支持 per-device 健康状态 |
3.9.2 USB 热插拔的精细化处理
USB 设备的热插拔最为复杂,因为它支持 per-device 健康状态:
func (plugin *USBDevicePlugin) setDeviceHealth(usbID string, isHealthy bool) {
pd := plugin.FindDeviceByUSBID(usbID)
isDifferent := pd.isHealthy != isHealthy
pd.isHealthy = isHealthy
if isDifferent {
plugin.update <- struct{}{} // 触发 ListAndWatch 推送
}
}
与 Generic/Mediated/PCI 不同,USB 的 ListAndWatch 不监听 health channel,而是监听 update channel。当任何单个 USB 设备的健康状态变化时,整个设备列表被重新推送。
3.9.3 配置驱动的动态设备管理
除了物理设备的热插拔,Device Manager 还支持配置驱动的动态设备变更——当 KubeVirt ConfigMap 更新时,设备列表可能改变:
splitPermittedDevices 的集合运算逻辑:
func (c *DeviceController) splitPermittedDevices(devices []Device) (map[string]Device, map[string]struct{}) {
devicePluginsToRun := make(map[string]Device)
devicePluginsToStop := make(map[string]struct{})
// 1. 将所有非永久的已启动插件标记为待停止
for resourceName := range c.startedPlugins {
_, isPermanent := c.permanentPlugins[resourceName]
if !isPermanent {
devicePluginsToStop[resourceName] = struct{}{}
}
}
// 2. 遍历新设备列表
for _, device := range devices {
if _, isRunning := c.startedPlugins[device.GetDeviceName()]; !isRunning {
// 新设备 → 待启动
devicePluginsToRun[device.GetDeviceName()] = device
} else {
// 已在运行 → 从待停止中移除
delete(devicePluginsToStop, device.GetDeviceName())
}
}
return devicePluginsToRun, devicePluginsToStop
}
这本质上是一个集合差运算:
ToRun= 新集合 - 旧集合ToStop= 旧集合 - 新集合 - 永久集合
3.9.4 MDEV 类型的动态创建与删除
MDEVTypesManager 管理中介设备类型的生命周期:
删除流程:
func removeUndesiredMDEVs(desiredTypesMap map[string]struct{}) {
files, err := os.ReadDir(mdevBasePath) // /sys/bus/mdev/devices/
for _, file := range files {
if shouldRemoveMDEV(file.Name(), desiredTypesMap) {
handler.RemoveMDEVType(file.Name())
// 写入 /sys/bus/mdev/devices/<UUID>/remove
}
}
}
创建流程(轮询分配策略):
关键设计决策:每个 GPU 卡只配置一个 mdev 类型。 这是 NVIDIA vGPU 的硬件限制——一块物理 GPU 同时只能运行一种 vGPU profile。getNextAvailableParentToConfigure 找到第一个未配置的 parent,创建该类型的所有可用实例,然后标记 parent 为已配置。
createMdevTypes 为某个 parent 上的某个 mdev 类型创建所有可用实例:
func createMdevTypes(mdevType string, parentID string) error {
instances, err := handler.ReadMDEVAvailableInstances(mdevType, parentID)
// 读取 /sys/class/mdev_bus/<parent>/mdev_supported_types/<type>/available_instances
for i := 0; i < instances; i++ {
handler.CreateMDEVType(mdevType, parentID)
// 通过 virt-chroot 写入 /sys/class/mdev_bus/<parent>/mdev_supported_types/<type>/create
}
}
四、Mermaid 架构图集
4.1 DeviceManager 整体架构图
4.2 DeviceController 类图
4.3 设备类型层次图
4.4 Generic Device 流程图
4.5 Mediated Device 流程图
4.6 PCI Device 流程图
4.7 Socket Device 流程图
4.8 Nvidia GPU 流程图
4.9 设备注册时序图
4.10 设备分配时序图
4.11 cgroup/权限管理图
4.12 热插拔处理图
4.13 USB 设备发现与分配详细流程
五、关键设计模式与深度分析
5.1 Device Plugin 标准接口与 KubeVirt 扩展
KubeVirt Device Manager 严格遵循 Kubernetes Device Plugin v1beta1 API,同时做了以下扩展:
-
环境变量传递设备信息:PCI 和 Mediated Device 在 Allocate 响应中设置环境变量(
KUBEVIRT_PCI_RESOURCE_*、KUBEVIRT_MDEV_RESOURCE_*),virt-launcher 容器启动后读取这些变量获取具体的 PCI 地址或 mdev UUID,用于配置 QEMU 命令行参数。 -
Socket Mount 而非 Device Spec:Socket Device 使用 Mount 而非 DeviceSpec,因为 Unix Socket 不是字符设备,不能用 DeviceSpec 传递。
-
Feature Gate 驱动的设备发现:TDX、SEV、VSOCK 等设备只在对应 Feature Gate 启用时才暴露,避免不必要的资源注册。
-
ExternalResourceProvider 排除:对于由外部 Device Plugin(如 NVIDIA GPU Operator)管理的设备,KubeVirt 跳过注册,避免重复暴露。
5.2 并发安全设计
Device Manager 的并发模型:
-
startedPluginsMutex:保护startedPlugins的读写,防止refreshPermittedDevices并发执行导致的重复 start/stop。 -
mdevRefreshWG:确保 mdev 刷新操作在 DeviceController 退出前完成。 -
DevicePluginBase.lock:保护initialized标志的原子读写。 -
controlledDevice.stopChan:通过 channel 关闭实现优雅停止,避免 data race。 -
MDEVTypesManager.mdevsConfigurationMutex:保护 mdev 配置操作的串行化。
5.3 错误恢复与重试机制
-
controlledDevice 退避重试:Device Plugin 启动失败时自动重试,退避序列
[1s, 2s, 5s, 10s]。 -
Kubelet 重启检测:当 Device Plugin 的 socket 被删除(Kubelet 重启时会清理),healthCheck goroutine 退出,触发 controlledDevice 的重试循环重新注册。
-
反注册优雅退出:
stopDevicePlugin先发送空设备列表,等待最多 1 秒让 Kubelet 处理,然后才停止 Server。
5.4 资源命名规范
Generic: devices.kubevirt.io/<deviceName> (如 devices.kubevirt.io/kvm)
PCI: <配置中指定的 resourceName> (如 nvidia.com/GPU)
Mediated: <配置中指定的 resourceName> (如 nvidia.com/A100-PCIE-40GB)
Socket: devices.kubevirt.io/<socketName> (如 devices.kubevirt.io/tdx)
USB: <配置中指定的 resourceName> (如 kubevirt.io/storage)
Socket 路径命名:
/var/lib/kubelet/device-plugins/kubevirt-kvm.sock
/var/lib/kubelet/device-plugins/kubevirt-tun.sock
/var/lib/kubelet/device-plugins/kubevirt-nvidia---GPU.sock (PCI, / → -)
/var/lib/kubelet/device-plugins/kubevirt-usb-GPU.sock (USB)
5.5 NUMA 拓扑感知
PCI 和 Mediated Device 支持通过 TopologyInfo 报告 NUMA 亲和性:
if pciDevice.numaNode >= 0 {
dpiDev.Topology = &pluginapi.TopologyInfo{
Nodes: []*pluginapi.NUMANode{
{ID: int64(pciDevice.numaNode)},
},
}
}
Kubernetes 1.26+ 的 TopologyManager 可以利用此信息,将 VM Pod 调度到正确的 NUMA 节点,避免跨 NUMA 访问的性能损失。
5.6 virt-chroot 与安全边界
mdev 的创建和删除通过 virt-chroot 执行,而非直接系统调用:
func (h *DeviceUtilsHandler) CreateMDEVType(mdevType string, parentID string) error {
_, err := virt_chroot.CreateMDEVType(mdevType, parentID, string(uid)).Output()
}
这是因为 virt-handler 容器可能以非 root 运行,对 /sys/class/mdev_bus/ 的写操作需要提升权限。virt-chroot 通过 chroot 到宿主根文件系统并以适当权限执行命令来实现。
六、总结
KubeVirt Device Manager 是一个设计精巧的子系统,它:
- 完全兼容 Kubernetes Device Plugin API,无需修改 Kubelet
- 支持5种设备类型,覆盖虚拟化的所有硬件直通场景
- 动态配置,通过 ConfigMap Watch 实现设备策略的实时变更
- 热插拔感知,通过 fsnotify 实时跟踪设备状态变化
- 安全设计,通过 SELinux relabel、ChownAtNoFollow、virt-chroot 确保权限边界
- NUMA 拓扑,支持 TopologyManager 的 NUMA 亲和调度
- NVIDIA vGPU 特殊处理,正确识别 nvidia 驱动下的 vGPU VF
- 容错恢复,指数退避重试 + Kubelet 重启自动重新注册
其架构体现了关注点分离的原则:DeviceController 负责编排(发现、启停、配置变更),各 DevicePlugin 负责与 Kubelet 的 gRPC 交互,DeviceHandler 抽象硬件操作,MDEVTypesManager 管理 mdev 生命周期。每一层都可以独立测试和替换。

KubeVirt virt-handler Device Manager 超深度分析&spm=1001.2101.3001.5002&articleId=161632230&d=1&t=3&u=9c22dd5f8b6d467790b6dd2bbefa5faa)
315

被折叠的 条评论
为什么被折叠?



