一句话结论:GPU 在容器里的可见性,不是由 --gpus 或 --device 这一层的写法决定的,而是由最外层那一次分配定死的——下游每一层都只能在集合内部重分配,一张也变不出来。

三条推论:

  1. rootless 影响的不是 request/limit 生效(那发生在宿主机的 kubelet / device plugin 进程里),而是运行时能不能把设备节点塞进去;
  2. privileged 与 device 是两条正交的轴,且 privileged 的默认行为是连设备一起给你——「门全开但屋里没东西」必须靠显式 opt-out 才能达成;
  3. dind 里 --gpus all 的 all 永远是局部 all——它能重分配外层给它的集合,但一张也变不出来。

这篇是《容器运行时拆解·补篇》的续篇。原篇把 runc create 之后的世界切成四个侧面——权限、记账、I/O、退出;这篇顺着同一条责任链往下走,聚焦读者最常追问的三个场景。

本文路线

全文分四段,可以按需跳读:

段落 章节 回答什么
① 原理 一 ~ 六 三个时刻怎么切;privileged 与 device 到底是不是正交;mknod 能不能造设备;devices cgroup 有几档;rootless 换的是什么坐标系;legacy 与 CDI 差在哪
② 三问 七 ~ 九 Q1 GPU 的 request/limit 在哪一层生效;Q2 privileged 但不带 --device 的权限模型;Q3 dind 里 --gpus all 为什么是「局部 all」
③ 取舍 十 把镜头拉远,看这套实现整体上做过的三个设计选择
④ 收束 十一 ~ 十三 一张责任链总图、一份踩坑清单、以及回到原篇的那句修正

为了让讨论不悬空,本文对照了一套生产级托管 dind 的真实实现——一个跑在 K8s 上的开发机容器平台,下文称 Sky-DevBox。它的方案和教科书路径都不一样,恰好把责任链上几个「想当然」的地方照了出来;第三、七、八、九、十节都会用到它,其中第十节还会讲到一处「设计稿里想做、最后没做」的取舍。为便于公开讨论,下文中该项目的内网 API 组统一写作 <api-group>,镜像仓库地址一律隐去。

核对说明:本文所有关于 containerd / nerdctl / 内核 / Kubernetes / NVIDIA 的结论,均逐条核对自对应版本的源码或官方文档,关键处附原文。核对过程中发现原稿有 六处需要更正,已在文中以 ⚠️ 标出。所有源码引用基于 containerd v1.7.27、nerdctl v1.7.7、CDI v0.7.2。


① 原理 · 一 ~ 六 —— 先把坐标系立起来:谁在什么时刻决定了什么。

一、先把责任链的三个时刻钉死

原篇的收束句是「runc 只在 create 的那一刻存在,之后的一切都由其他组件接手」。这句话对,但如果把它读成「create 之后就定型了」,就会踩坑。准确的说法是把 GPU 的一生切成三个时刻:

时刻 谁在做 决定了什么
create 之前 kubelet + device plugin 这个 Pod 被分配了哪几张卡(记账、调度、准入)
create 那一刻 运行时(含运行时包装器)+ runc 这些卡以什么形式出现在容器的 /dev 里(设备节点、cgroup 规则、env、驱动库)
create 之后 节点上的监控 / 运维组件 这些卡怎么被观测、怎么被回收——runc 早已退出

⚠️ 更正一(原稿最根本的一处):原稿写「GPU 的可见性是 runc create 时一次性注入到容器 spec 里的……之后 kubelet 不再介入、runc 也早已退出……它能看到的 /dev 就被这一刻固定了」。

设备节点在 create 之后仍然可以被增删。 只要有一个进程能进入容器的 mount namespace,它就可以在里面 mknod 出新节点、或 rm 掉旧节点——这不需要容器重启,也不需要重新 create。本文第七、九节要讲的 Sky-DevBox,其 GPU 热插拔功能就建立在这一点上:它 nsenter 进正在运行的容器,mknod 一个 /dev/nvidiaN 出来,GPU 立刻可用。

所以真正被「固定」的不是 /dev 的内容,而是最外层那一次分配给出的集合。下游每一层都只能在这个集合内部重新分配,不能把它撑大。这个区别是本文后面所有讨论的基石。

三个时刻里,第一个时刻发生在宿主机侧的 root 进程里,与容器内进程是谁完全无关;第二个时刻才是运行时真正「动手」的地方;第三个时刻属于观测平面。三个问题分别落在这三个时刻上。


二、privileged 与 device:两条正交的轴

这是原稿问题最大的一节,我们直接从 containerd 的源码看起。

2.1 containerd CRI 的 privileged 分支

pkg/cri/server/container_create_linux.go(containerd v1.7.27)里,privileged 的处理是三段式的:

1
2
3
4
5
6
7
8
9
10
11
12
if securityContext.GetPrivileged() {
if !sandboxConfig.GetLinux().GetSecurityContext().GetPrivileged() {
return nil, errors.New("no privileged container allowed in sandbox")
}
specOpts = append(specOpts, oci.WithPrivileged)
if !ociRuntime.PrivilegedWithoutHostDevices {
specOpts = append(specOpts, oci.WithHostDevices, oci.WithAllDevicesAllowed)
} else if ociRuntime.PrivilegedWithoutHostDevicesAllDevicesAllowed {
// allow rwm on all devices for the container
specOpts = append(specOpts, oci.WithAllDevicesAllowed)
}
}

注意这里:oci.WithPrivileged 本身一个设备都不碰。它展开是这八个(oci/spec_opts.go):

1
2
3
4
5
6
7
8
9
10
var WithPrivileged = Compose(
WithAllCurrentCapabilities,
WithMaskedPaths(nil),
WithReadonlyPaths(nil),
WithWriteableSysfs,
WithWriteableCgroupfs,
WithSelinuxLabel(""),
WithApparmorProfile(""),
WithSeccompUnconfined,
)

设备是另外两个 opt 加上去的,而它们挂在 !PrivilegedWithoutHostDevices 这个条件上——也就是说:

⚠️ 更正二(原稿的核心错误):原稿写「privileged 不打开的部分:/dev 里的设备节点本身。privileged 只是说’如果 /dev 里有什么,你都能访问’」。

这是反的。privileged_without_host_devices 的默认值是 false,意思是「privileged 容器默认拿到宿主机全部设备」。 containerd 官方 CRI 配置文档原文:

privileged_without_host_devices — 「allows overloading the default behaviour of passing host devices through to privileged containers」 / 「Defaults to false - i.e pass host devices through to privileged containers.」

所以一个默认配置下的 privileged 容器,/dev 里是有宿主机全部设备节点的(oci.WithHostDevices 把宿主机设备全量灌进 spec.Linux.Devices),devices cgroup 也是全开的。「门全开但屋里没东西」不是 privileged 的默认形态,而是一个需要显式 opt-out 才能达成的特殊形态——恰恰是 Sky-DevBox 刻意选择的那一种(见第八节)。

2.2 「privileged 看不到新设备」的正确读法

原稿引用的 moby issue 是真实存在的,但结论要精确化。issue #16160 的标题就是 「New devices on host are not exposed to privileged container」,报告者说得很清楚:脚本在容器里挂载 EBS 卷,宿主机的 /dev/xvdc 出现了,容器里没有。社区结论是容器的 /dev 是一份在创建时装配好的快照,不是宿主机 /dev 的实时镜像——有人形容它 「looks like a manually assembled /dev/, and not inherited from the hosts」。

所以准确的表述是:

  • 创建时就存在的设备 → privileged 容器默认就有;
  • 创建之后才出现的设备(热插拔硬盘、后加载的驱动建出来的节点)→ 不会有,因为 /dev 已经装配完了;
  • 想绕开,社区土办法是 -v /dev:/dev --privileged(把宿主机 /dev 整个 bind 进来),但 fabiand 在同帖里指出 /dev/pts、/dev/ptmx、/dev/mqueue、/dev/shm 会有诡异问题。

这条「快照语义」正是热插拔功能存在的原因:要么在创建时把设备给全,要么在创建后想办法把节点补进去。

2.3 nerdctl:同名开关的 CLI 形态

Docker / nerdctl 这一侧走的是另一条路,但内核完全一样。nerdctl v1.7.7 的 pkg/cmd/container/run_security_linux.go:

1
2
3
4
5
6
7
8
9
10
11
var privilegedOpts = []oci.SpecOpts{
oci.WithPrivileged,
oci.WithAllDevicesAllowed,
oci.WithHostDevices,
oci.WithNewPrivileges,
}

var privilegedWithoutDevicesOpts = []oci.SpecOpts{
oci.WithPrivileged,
oci.WithNewPrivileges,
}

差集一目了然:privileged-without-host-devices=true 拿掉的就是 WithAllDevicesAllowed(devices cgroup 全开)和 WithHostDevices(全量宿主设备节点)。而且这个开关必须配合 --privileged 使用:

1
2
3
if privilegedWithoutHostDevices && !privileged {
return nil, errors.New("flag `--security-opt privileged-without-host-devices` can't be used without `--privileged` enabled")
}

一个容易踩的坑:这里有两个同名但不同层的开关。

形态 出现位置 作用对象
privileged_without_host_devices(下划线) containerd 的 config.toml,runtimes.<name> 段 CRI 创建的容器(即 K8s Pod)
privileged-without-host-devices(连字符) Docker / nerdctl 的 --security-opt dockerd/nerdctl 创建的容器

一个管 K8s 那条路,一个管 docker 那条路。在 dind 场景里两者会同时出现——这本身就是一条线索(见第八节)。

2.4 小结:两条轴,四个象限

把「权限」和「设备」拆成两条轴,四个象限就清楚了:

无设备注入 有设备注入
非 privileged 默认容器:/dev 只有基础节点,devices cgroup 是白名单 标准 GPU 容器:--device / device plugin 注入
privileged 需要显式 opt-out:privileged-without-host-devices privileged 的默认形态:全量宿主设备 + cgroup 全开

原稿把左下角当成了右下角,这是它后面 Q2、Q3 推理跑偏的源头。


三、mknod 不是空壳:设备节点的真相

⚠️ 更正三:原稿写「即使 privileged + 所有 capabilities 都在,容器内 mknod /dev/nvidia0 c 195 0 也造不出一个可用的 GPU 设备——设备节点只是个入口……而 mknod 出来的节点只是个空壳。所以 privileged-without-device 在 GPU 场景下等价于完全无 GPU」。

设备节点不是空壳,它就是设备的入口本身。 一个字符设备节点的全部内容就是 (type, major, minor) 三元组——内核拿到这三个数就去 cdev 表里找对应的驱动。只要驱动已加载、devices cgroup 放行,mknod 出来的节点和 --device 挂进来的节点在功能上没有任何区别。

这一点不是我推理出来的,是 Sky-DevBox 的生产代码在依赖它:它的 GPU 热插拔实现就是「读宿主节点的主次设备号 → 在容器里 mknod 一个同号的节点」。

3.1 真正拦人的是 capability 的坐标系

mknod 系统调用对字符/块设备节点要求 CAP_MKNOD。关键在于这个检查用哪个 user namespace。user_namespaces(7) 把 CAP_MKNOD 和 CAP_SYS_TIME、CAP_SYS_MODULE 归为一类:

「Only a process with privileges in the initial user namespace can perform such operations.」

也就是说,内核用的是 capable(CAP_MKNOD)(对照 init userns),不是 ns_capable()(对照容器自己的 userns)。这解释了两件事:

  • rootless 容器里 mknod 设备节点一定失败,哪怕容器内的进程「是 root」、capability 全在——因为它的 capability 只在自己的 userns 里有效。
  • 但「从外面伸手进去 mknod」可以成功,只要伸手的那个进程在 init userns 里有 CAP_MKNOD。

runc 自己的代码是这条规则最直白的注脚(libcontainer/rootfs_linux.go):

1
2
3
4
5
6
7
8
9
10
11
12
func createDevices(config *configs.Config) error {
useBindMount := userns.RunningInUserNS() || config.Namespaces.Contains(configs.NEWUSER)
for _, node := range config.Devices {
// ...
// containers running in a user namespace are not allowed to mknod
// devices so we can just bind mount it from the host.
if err := createDeviceNode(config.Rootfs, node, useBindMount); err != nil {
return err
}
}
return nil
}

而 createDeviceNode 是三分支的:

1
2
3
4
5
6
7
8
9
10
11
if bind {
return bindMountDeviceNode(rootfs, dest, node)
}
if err := mknodDevice(dest, node); err != nil {
if errors.Is(err, os.ErrExist) {
return nil
} else if errors.Is(err, os.ErrPermission) {
return bindMountDeviceNode(rootfs, dest, node) // 撞 EPERM 就退回 bind mount
}
// ...
}

三件事一次说清了:非 userns → mknod;userns → bind mount;mknod 撞 EPERM → 自动退回 bind mount。

⚠️ 这里要补一句前面说得太绝对的话:userns 容器拿不到 mknod 能力,不等于它拿不到设备节点。runc 会替它把宿主机上的节点 bind mount 进去——bind mount 是一次挂载操作,受 mount 权限管辖,不是 CAP_MKNOD。

所以第五节那张表里的「rootless 容器:容器内无法 mknod」,准确读法是「无法自己造节点」;节点仍然进得来,只是必须由外面的运行时替它挂。这也正好解释了 CDI 为什么在 rootless 下能工作——它走的就是「运行时替你把节点和库配好」这条路,而不是让容器自己 mknod。

3.2 nsenter 的 flag 里没有 -U

Sky-DevBox 的热插拔实现(pkg/dind/server/gpu_hotplug.go)是这样的:

1
2
cmd := exec.Command("nsenter", "-m", "-p", "-u", "-i", "-n", "-t", fmt.Sprintf("%d", pid), "sh", "-c",
fmt.Sprintf("rm -f %s; mknod %s c %s %s; chmod 666 %s", device, device, major, minor, device))

数一下 flag:-m mount、-p pid、-u uts、-i ipc、-n net——没有 -U(user),也没有 -a(all)。

这是个非常刻意的设计:

  • 进入 mount namespace,所以 mknod 创建的文件落在容器的 /dev 里;
  • 不进入 user namespace,所以进程的凭据仍是宿主 root,capable(CAP_MKNOD) 检查通过;
  • 主次设备号是先从宿主机的同名节点上读出来的(ls -l 取第 5、6 字段),保证造出来的节点指向同一个设备。

-m 和 -U 的取舍不是可有可无的细节——如果顺手加了 -a,这个功能就会立刻失效:进程会掉进容器的 userns,capable(CAP_MKNOD) 随即失败。

顺带说,同一个仓库里另一处 nsenter 用法(pkg/utils/cmd_runner.go 里的 HostNSEnterCommandRunner)用的是 --mount --pid --net --ipc --uts --cgroup --target 1——同样没有 --user。在一个代码库里两次独立地做出同一个选择,基本可以确认这是有意为之。

3.3 一个能跑的 GPU 到底需要什么

反过来,也要说清 mknod 单独不够的地方。/dev/nvidia0 只是入口,一个 CUDA 程序要真正跑起来还需要:

要素 谁提供 缺了会怎样
/dev/nvidiaN 设备节点(mknod 或 --device) nvidia-smi 看不到这张卡
/dev/nvidiactl 驱动控制节点 NVML 初始化失败
/dev/nvidia-uvm、/dev/nvidia-uvm-tools UVM 节点 CUDA 上下文创建失败(Failed to initialize NVML / cudaErrorNoDevice 类症状)
devices cgroup 放行 spec.Linux.Resources.Devices open() 被 EPERM 拒绝
用户态驱动库(libcuda.so、libnvidia-ml.so…) NVIDIA Container Toolkit 注入 二进制找不到库
环境变量(NVIDIA_VISIBLE_DEVICES 等) device plugin 的 Allocate 响应 运行时不知道该注入哪些卡

所以正确的结论是:mknod 出来的节点是可用的,但只 mknod 一张 nvidia0 通常不够用——因为 nvidiactl / nvidia-uvm 和用户态库往往不在。手工挂 /dev/nvidia0 之所以「看起来不行」,原因在缺少其余几项,而不是节点本身是假的。


四、devices cgroup:三档,不是两档

设备访问控制有两套独立机制,经常被混为一谈:

  1. 设备节点是否存在(mount namespace 里的文件);
  2. devices cgroup 是否放行(open() 时的检查)。

第 2 条在 OCI spec 里是 linux.resources.devices,元素类型是 LinuxDeviceCgroup。containerd 的 oci.WithDevices 一次写两处:

1
2
3
4
5
6
7
8
9
10
for i := range devs {
s.Linux.Devices = append(s.Linux.Devices, devs[i])
s.Linux.Resources.Devices = append(s.Linux.Resources.Devices, specs.LinuxDeviceCgroup{
Allow: true,
Type: devs[i].Type,
Major: &devs[i].Major,
Minor: &devs[i].Minor,
Access: permissions,
})
}

这就是为什么 --device 能同时解决「看得见」和「打得开」——它不是一个开关,是两个。

三档状态:

档位 linux.resources.devices 的值 效果 谁在这一档
基础白名单 containerd 默认追加的一组 allow:/dev/null(1:3)、/dev/zero(1:5)、/dev/full(1:7)、/dev/random(1:8)、/dev/urandom(1:9)、/dev/tty(5:0)、/dev/console(5:1)、/dev/ptmx(5:2)、/dev/pts/*(136:*) 叠加在运行时的隐式 deny-all 之上,只放行这几个 默认容器;privileged-without-host-devices
白名单 + 注入 上面那组追加(append)上逐条 {Allow:true, Type, Major, Minor, Access} 基础设备 + 指定设备 --device / device plugin 注入
全开 [{Allow:true, Access:"rwm"}](oci.WithAllDevicesAllowed,注意是整体赋值覆盖,不是追加) 所有设备可访问 privileged 的默认路径

所以 privileged 的默认路径落到第三档;privileged-without-host-devices 则停在基础白名单——注意它不是「没有规则」,containerd 的 oci.WithDefaultUnixDevices 已经放行了 /dev/null、/dev/tty、/dev/ptmx 这一组。

一个真实世界的边角案例:Sky-DevBox 全仓库唯一一处写 devices cgroup 的地方,是 cgroup v1 下的这一行(pkg/cgroups/cgroupv1.go):

1
err := cgroups.WriteFile(path, 「devices.allow」, 「c 5:1 rwm」)

c 5:1 是字符设备 5 号主设备、1 号次设备——/dev/console。它同时写在 Pod 的 cgroup 和其下新建的 dind-containers 子 cgroup 上。

这里有个值得较真的细节:/dev/console 本来就在 containerd 的默认列表里(见上表)。所以这行代码不是在「补一个默认缺失的设备」,而是在给两类拿不到 containerd 容器级默认规则的 cgroup 开口子——Pod 层的 cgroup,以及 dind 里那些内层容器将要挂进去的 dind-containers 父 cgroup。

它是「这套实现确实没走 allow-all」的一条旁证:如果 privileged 走的是默认路径([{Allow:true, Access:"rwm"}] 整体覆盖),这两处 devices.allow 就完全没有必要存在。


五、rootless:换的是坐标系,不是权限大小

第二节讲的是权限轴,这一节讲坐标系轴。rootless 的关键不是“权限更小”,而是换了一套 uid 映射——理解了这一点,后面所有的“为什么这里会 EPERM”都好解释了。

5.1 机制:容器内的 0 是宿主机的另一个数

rootless 的本质是 user namespace 的 ID 映射。容器内的 uid 0 被映射到宿主机上的一个高位 uid(典型的 /etc/subuid 布局是 alice:100000:65536),于是:

  • 容器内进程 id 显示 uid=0(root),自我感觉良好;
  • 宿主机上 ps aux 看到的是 uid 100000;
  • 所有 capability 检查都发生在初始 user namespace,容器内那个「root」在宿主机上什么特权都没有。

5.2 K8s 里的 rootless 长什么样

这里要补一个原稿没提、但很关键的事实:Kubernetes 官方并不叫它「rootless 容器」,它叫 user namespaces,入口是 spec.hostUsers。官方文档原文:

「A pod can opt-in to use user namespaces by setting the pod.spec.hostUsers field to false.」

1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: Pod
metadata:
name: secured-pod
spec:
hostUsers: false # 这一个字段就是"K8s 里的 rootless"
containers:
- name: worker
image: nvcr.io/nvidia/cuda:12.4.0-base-ubuntu22.04

它的版本线是:1.25 alpha → 1.30 beta → 1.33 默认开启 → 1.36 GA(feature gate 已锁定,无法关闭)。

⚠️ 更正六(概念混淆,但很常见):原稿给的 Pod YAML 里,把「租户容器不给 root」的实现点写成了:

1
2
securityContext:
runAsNonRoot: true # 原稿注释:「这里是'租户容器不给 root'的实现点」

runAsNonRoot 不是 rootless。 它是 kubelet 的一个准入检查——如果镜像的 USER 是 root(或没写 USER),容器会被拒绝启动。它只回答「这个容器以哪个 uid 运行」,完全不涉及 user namespace。容器内 uid 1000 在宿主机上仍然是 uid 1000;文件权限、capability 检查、mknod 规则统统没变。

真正打开 user namespace 的是 hostUsers: false。两者可以叠加,但作用完全不同:

字段 作用层 效果
runAsNonRoot: true kubelet 准入 + 进程 uid 容器不以 uid 0 运行
hostUsers: false 内核 user namespace 容器内的 0 被映射到宿主机的高位 uid

原稿后面所有「rootless 下 mknod / BPF 会被拒」的推理,前提是 hostUsers: false,不是 runAsNonRoot。如果只写了 runAsNonRoot,容器仍然跑在初始 user namespace 里,那些限制一个都不会出现。

前置条件不是免费的:

  • 内核 ≥ 6.3(tmpfs 从 6.3 起支持 idmap mount,而 ServiceAccount token、Secret 都走 tmpfs);
  • 节点上 /var/lib/kubelet/pods/ 所在的文件系统要支持 idmap mount(ext4 / xfs(ftype=1) / btrfs / tmpfs / overlayfs);
  • containerd ≥ 2.0 或 CRI-O ≥ 1.25;runc ≥ 1.2 或 crun ≥ 1.9(推荐 1.13+)。

capability 的行为也被官方点明了:

「CAP_SYS_MODULE does not have any effect if granted to a pod using user namespaces, the pod isn’t able to load kernel modules.」

「CAP_SYS_ADMIN is limited to the pod’s user namespace and invalid outside of it.」

5.3 三道关卡

把前面几节的结论合起来,rootless 下的 GPU 有三道互相独立的关卡:

关卡 root 容器 rootless 容器 卡在哪
设备节点可见性 可 mknod、可 bind mount、可 CDI 容器内无法 mknod(capable(CAP_MKNOD) 在 init userns) 只能用「外面伸进来」或「创建时注入」的现成节点
devices cgroup 写权限 运行时以 root 写 devices.allow / 加载 BPF 无 CAP_BPF / CAP_SYS_ADMIN bpf_prog_query(BPF_CGROUP_DEVICE): operation not permitted
驱动 ioctl 权限 capabilities 全开 capability 只在本 userns 有效 个别 ioctl 仍可能被拒

第二道就是下一节的主角。


六、legacy 与 CDI:为什么 rootless 下 legacy 会崩

换了坐标系之后,第一个撞墙的是 NVIDIA 那一层。这一节讲清墙在哪、以及 CDI 是怎么绕过去的——它也是后文讨论“这套实现为什么不用 CDI”时的参照系。

6.1 legacy 模式:prestart hook 要改 cgroup

NVIDIA Container Toolkit 的 legacy 模式下,nvidia-container-runtime 会在 OCI spec 里注入一个 prestart hook,容器创建时由 nvidia-container-cli 去挂驱动库、并把设备规则写进 devices cgroup。

在 cgroup v2 上,devices controller 是 eBPF 程序。要往里面追加规则,得先查询当前挂载的过滤器——这一步需要调用方在初始 user namespace 里有 CAP_BPF/CAP_SYS_ADMIN。rootless 下没有,于是整个容器创建失败。

6.2 两次真实回归(不是一次)

⚠️ 更正四:原稿写「NVIDIA Container Toolkit 1.18.0 在 rootless docker 上曾因此大面积崩溃,降回 1.17.x 才能跑」。

这个错误本身是对的,但它不是唯一一次,而且 1.17.x 并不是安全区。 实际有两次独立的回归:

版本 影响面 现象 出处
1.17.7 Slurm + Pyxis/Enroot、LXC/Proxmox 同一句 bpf_prog_query 报错;LXC 下还有 open failed: /proc/1/root/: permission denied nvidia-container-toolkit issue #1093 「Update to version 1.17.7-1 on Ubuntu breaks pyxis/enroot in Slurm」
1.18.0 rootless Docker 「Docker Rootless fails with v1.18.0 due to cgroup BPF permission error (bpf_prog_query),regression from v1.17.8」 issue #1487

完整报错原文是:

1
2
3
nvidia-container-cli: mount error: failed to add device rules:
unable to find any existing device filters attached to the cgroup:
bpf_prog_query(BPF_CGROUP_DEVICE) failed: operation not permitted

所以「降回 1.17.x」这个建议本身是有坑的——1.17.7 也坏,得降到 1.17.6;而 1.18.0 是相对 1.17.8 的新回归。给生产环境的建议应该是「锁在已知可用的具体小版本」,而不是「降到某个大版本」。

6.3 CDI:把特权操作换成纯数据合并

CDI(Container Device Interface) 的思路完全不同:把「访问一个设备需要什么」(设备节点、挂载、env、附加 GID)写成一份 YAML,运行时在 create 时读这份 spec 并合并进 OCI spec。这是一次纯数据合并,不需要 mknod、不需要写 cgroup、不需要 BPF。

NVIDIA 官方文档里那句关键的话是:

「CDI also improves the compatibility of the NVIDIA container stack with certain features such as rootless containers.」

版本线(均出自官方 release notes / CDI 文档):

  • 1.12.0:开始支持生成 CDI spec;
  • 1.18.0:默认模式改为 JIT-CDI——「The default mode of the NVIDIA Container Runtime has been updated to make use of a just-in-time-generated CDI specification」,同时 legacy 模式被标记为 deprecated;
  • 1.19.1:CDI spec schema 默认升到 v0.7.0,会让老运行时(containerd < 1.7.16、docker < 26.1.0、podman < 5.1.0、crio < 1.30.0)不认,需要 no-additional-gids-for-device-nodes 特性开关。

切换命令与产物路径(官方原文):

1
sudo nvidia-ctk config --in-place --set nvidia-container-runtime.mode=cdi
  • CDI spec 写在 /var/run/cdi/nvidia.yaml;
  • 由 nvidia-cdi-refresh 这个 systemd service(.path + .service 两个 unit)自动生成;
  • 运行时在设备以 CDI 名请求时也会自动切到该模式。

一个容易忽略的版本细节:containerd 1.7 默认 enable_cdi = false,2.0 起才默认启用——所以 2.0 的配置参考里已经看不到这个开关。跨版本排查设备注入问题时很容易在这里卡住。


② 三问 · 七 ~ 九 —— 原理铺完了,正面回答三个高频追问。

七、Q1:GPU 的 request/limit 到底在哪一层生效

原理铺完了,从这一节开始正面回答三个问题。第一问最常被问到:写下 resources.limits.nvidia.com/gpu: 2 之后,这个 2 是在哪一层被兑现的?

7.1 标准答案:生效在 create 之前,与容器内 uid 无关

把链路铺平:

  1. 调度:resources.limits.nvidia.com/gpu: 2 让调度器只看 node 的 allocatable;
  2. 准入:kubelet 的 Device Manager 调 device plugin 的 Allocate;
  3. 注入:Allocate 的返回值被塞进 CRI 的 ContainerConfig,containerd 构造 OCI spec;
  4. 落地:运行时(runc,或被包装的 runc)在 create 时把 linux.devices、linux.resources.devices、env、mounts 写进新容器。

官方文档对 Allocate 返回值的描述是:

「An AllocateResponse contains zero or more ContainerAllocateResponse objects. In these, the device plugin defines modifications that must be made to a container’s definition to provide access to the device. These modifications include: Annotations / device nodes / environment variables / mounts / fully-qualified CDI device names.」

第 1、2 步都发生在宿主机侧的 kubelet / plugin 进程里,它们以 root 身份运行。 容器内进程是 root 还是 alice,对这两步没有任何影响。

另外两条硬约束(K8s 官方文档原文):

「The API server restricts quantities of extended resources to whole numbers.」

「Extended resources cannot be overcommitted, so request and limit must be equal if both are present in a container spec.」

所以 limits: 2 = 独占两张卡;想做「一卡多人」必须换机制(MIG / time-slicing / MPS),不能靠 request/limit。

rootless 真正影响的是第 3、4 步——能不能把设备塞进去。这就回到第六节:CDI 能,legacy 在 rootless 下会崩。

7.2 第三条路:让运行时自己去调 device plugin

到这里教科书答案就结束了。但 Sky-DevBox 的做法提示了还有第三条路——它既不用 CDI,也不依赖 containerd CRI 的默认注入,而是在运行时包装器里重新走了一遍设备分配。

先看它的位置。它的 containerd 配置里,sky runtime 的 BinaryName 指向一个自研包装器:

1
2
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.sky.options]
BinaryName = "/opt/sky/bin/sky-devbox-runtime"

这个包装器在 runc 之前介入,读 bundle 里的 config.json、改完再写回、然后 exec 真正的低层运行时。它注册的 spec modifier 链是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
originalProvider := providers.NewDevicesProvider(logger, kc)          // 直接问 kubelet
odm := modifier.NewDevicesModifier(logger, nil, originalProvider)

containerProvider := providers.NewContainerSync(logger, kc) // 从别的容器"同步"设备
podProvider := providers.NewPodSync(logger, kc) // 从整个 Pod 聚合
directProvider := providers.NewDirect(logger) // 从注解直接读
provider := providers.Merge(containerProvider, podProvider, directProvider)
dm := modifier.NewDevicesModifier(logger, dpFactory, provider)
rdm := modifier.Repeat(dm) // 迭代到不动点

rmm := modifier.NewReplaceMountsModifier(logger) // 与设备无关,一并列出
csm := modifier.NewContainerServiceModifier(logger)
gm := modifier.NewGenericModifier(logger, nil)

return modifier.Merge(odm, rdm, rmm, csm, gm, newCPUSetModdifier()), nil

拆开看,它做了三件标准路径不做的事。

(a) 它自己问 kubelet 要设备清单。 通过 kubelet 的 PodResources API(socket 在 /var/lib/kubelet/pod-resources/kubelet.sock)按 namespace/name/container 查出这个容器被分配了哪些设备 ID。

(b) 它自己调 device plugin 的 Allocate。 拿到设备 ID 后,直接连 device plugin 的 socket(/var/lib/kubelet/device-plugins/*.sock),发起标准 gRPC:

1
2
3
4
5
6
req := &devicepluginapi.AllocateRequest{
ContainerRequests: []*devicepluginapi.ContainerAllocateRequest{
{DevicesIDs: deviceids},
},
}
resp, err := c.client.Allocate(ctx, req)

然后把 ContainerAllocateResponse 的四类内容分别合并进 OCI spec:

1
2
3
4
mergeEnv(containerInfo.Envs, &spec.Process.Env)              // NVIDIA_VISIBLE_DEVICES 等
mergeAnnotation(containerInfo.Annotations, &spec.Annotations)
mergeMount(containerInfo.Mounts, &spec.Mounts) // 驱动库等
mergeDevice(containerInfo.Devices, spec) // 设备节点 + cgroup 规则

注意最后一行——mergeDevice 内部调的是 containerd CRI 自己的 opts.WithDevices,也就是和 containerd 官方路径用的是同一个函数,最终仍然落到第四节的「节点 + cgroup 规则」两处。

© 它用不动点迭代解决链式引用。 这是最巧的一处。Repeat 修饰器的实现是:把 spec 序列化成 JSON,反复跑 modifier,直到序列化结果不再变化为止:

1
2
3
4
5
6
7
8
9
for {
err = m.modifier.Modify(spec)
...
newdata, err := json.Marshal(spec)
if reflect.DeepEqual(olddata, newdata) {
break
}
olddata = newdata
}

为什么要迭代?因为设备引用可以链式。代码注释里列了三种真实场景,以 spot+dind 为例:

  • 第 1 轮:从主容器同步「抢占实例」的设备 ID,形如 GPU-xxxx-spot,调 spot GPU 的 device plugin,得到一条注解 {"nvidia.com/gpu":["GPU-xxxx"]};
  • 第 2 轮:direct provider 读到这条注解,拿真实 GPU ID 去调 nvidia 的 device plugin,才拿到最终的设备节点。

一轮解不出最终形态,所以要迭代到收敛。这是把「设备 ID 的解析」当成一个重写系统来做的。

这条路线的意义:它证明了「GPU request/limit 生效」并不必然绑定在 containerd CRI 的标准注入路径上。当平台需要做跨容器设备同步(把 A 容器的设备给 B 容器用)、抢占实例的设备重映射(spot → 真实卡)这类标准 K8s 表达不了的事情时,把设备解析搬到运行时包装器里是一个可行解。

代价也很明显:运行时要自己保证幂等与收敛(Repeat 的 JSON 比较是有成本的),要自己处理 kubelet / plugin 不可用时的降级(代码里 Allocate 失败时是 return nil 跳过而不是报错——这个取舍值得单独讨论),而且设备记账的事实来源变成了两处(kubelet 的分配 + 运行时的解析),排查时需要同时看两边。

7.3 排查清单

nvidia-smi 报 Failed to initialize NVML 时,按这个顺序切:

现象 大概率原因
kubectl describe node 里没有 nvidia.com/gpu device plugin 没注册成功(看 /var/lib/kubelet/device-plugins/ 下的 socket)
kubectl get events 有 Allocate 失败 plugin 与驱动 / toolkit 版本不匹配
容器内 ls /dev/nvidia* 为空 注入没发生:CDI spec 没生成、没切 CDI 模式、或运行时侧解析失败
容器内 nvidia-smi 报 operation not permitted 大概率还在 legacy 模式,prestart hook 在 rootless 下改 devices cgroup 被拒
有 /dev/nvidia0 但 CUDA 报错 缺 nvidiactl / nvidia-uvm / 用户态库(见 3.3 表)

八、Q2:privileged-without-device 的权限模型

Q1 的答案是“生效在 create 之前,与容器内 uid 无关”。但还有个更基础的问题没答:如果一个容器 privileged 却不带 --device,它到底能干什么、不能干什么?

8.1 逐项拆开

能力 有 / 没有 由哪个 opt 提供
capabilities ✅ 全开(WithAllCurrentCapabilities) oci.WithPrivileged
seccomp ✅ 关闭(WithSeccompUnconfined) 同上
AppArmor / SELinux ✅ 不应用 同上
/proc、/sys 的 masked / readonly paths ✅ 清空 同上
/sys、/sys/fs/cgroup 可写 ✅ WithWriteableSysfs / WithWriteableCgroupfs
devices cgroup 全开 ❌ 没有 被 opt-out 掉的 WithAllDevicesAllowed
全量宿主设备节点 ❌ 没有 被 opt-out 掉的 WithHostDevices
mknod 系统调用 ✅ 可以调用 capabilities 里有 CAP_MKNOD

最后一行是这个形态最微妙的地方:容器内可以 mknod,但能不能 mknod 出设备节点,取决于它在不在初始 user namespace。在一个没有 userns remap 的 privileged 容器里(容器 root = 宿主 root),mknod 设备节点是能成功的——privileged-without-device 只是不主动给你设备,并不阻止你自己造。

这也解释了为什么这个形态的隔离强度是有限的:它是「减少默认暴露面」,不是「阻断设备访问」。真正的强隔离需要 userns(第五节)或 VM 级隔离。

8.2 一个真实的生产形态

Sky-DevBox 里托管 dind 的创建参数(pkg/dind/controller/create_helpers.go,已隐去镜像地址)是:

1
2
3
4
5
6
7
8
9
10
11
12
createOptions := types.ContainerCreateOptions{
Runtime: "/opt/sky/bin/sky-devbox-runtime",
Cgroupns: "host",
Privileged: true,
SecurityOpt: []string{
"seccomp=unconfined",
"apparmor=unconfined",
"privileged-without-host-devices=true",
},
CapAdd: []string{"SYS_ADMIN", "NET_ADMIN"},
// 其余字段(日志驱动、镜像拉取策略、内存/CPU 上限等)与本节结论无关,略
}

这就是本文 Q2 讨论的那个组合的生产实例:privileged: true + 显式 opt-out 设备。dockerd 要 SYS_ADMIN(挂载、网络命名空间、cgroup),要关 seccomp/AppArmor(要跑 mount、iptables、mknod),但平台不想让它默认拿到宿主机的全部设备——因为那等于把整台机器的 /dev 交给租户。

为什么是 privileged 而不是 rootless? 这不是随手选的默认值——设计稿里原本写的是 rootless,最后没落地。原因、证据和取舍见第十节 10.3。

设备从哪里来?只有一条路:显式继承。创建 dind 容器时,它的 spec 会把主容器的设备列表原样复制过去:

1
dindSpec.Linux.Devices = append(dindSpec.Linux.Devices, mainSpec.Linux.Devices...)

也就是说,dind 能看到的 GPU,严格等于外层 Pod 被分配的那几张。这句话不是推断,是代码里的一行 append。

8.3 更狠的一层:把 docker API 也管起来

光靠启动参数约束不住租户——租户在 dind 里跑 docker run 时还能自己加参数。所以这套实现还在 dind 里放了一个 HTTP 反向代理,拦在 /run/docker.sock 前面,对 Docker API 的请求体做改写与校验(pkg/dind/server/hijack*.go)。

它拒绝的东西(CreateConfig.Verify()):

1
2
3
4
5
6
7
8
9
10
if config.HostConfig.Privileged { return ErrPrivilegedNotSupported }
if len(config.HostConfig.CapAdd) > 0 { return ErrCapabilitiesSettingNotSupported }
if len(config.HostConfig.CapDrop) > 0 { return ErrCapabilitiesSettingNotSupported }
if len(config.HostConfig.SecurityOpt) > 0 { return ErrSecurityOptionsNotSupported }
if config.HostConfig.Resources.CgroupParent != "" { return ErrCgroupParentNotSupported }
if config.HostConfig.NetworkMode.IsBridge() { return ErrBridgeNetworkNotSupported }
if config.HostConfig.PidMode.IsHost() { return ErrHostPidNsNotSupported }
if config.HostConfig.UsernsMode.IsHost() { return ErrHostUserNsNotSupported }
if config.HostConfig.UTSMode.IsHost() { return ErrHostUTSNsNotSupported }
if config.HostConfig.Runtime != "" { return ErrRuntimeNotSupported }

它改写的东西:

  • 挂载路径重写:把所有 bind mount 的 source 加上 /mainroot 前缀——/mainroot 是主容器 overlay rootfs 在 dind 里的挂载点,dockerd 眼中的「宿主机路径」实际是这个前缀下的路径。错误信息里再把这个前缀抹掉(strings.ReplaceAll(err, " /mainroot/", " /")),对租户保持透明。
  • GPU 请求自动补全:带 gpu-hotplug=true 标签的容器,如果没有显式 DeviceRequests,自动补一条:
1
2
3
config.HostConfig.DeviceRequests = []container.DeviceRequest{
{Driver: "nvidia", Count: -1, Capabilities: [][]string{{"gpu"}}},
}

(Count: -1 在 Docker API 里表示「全部可用设备」。)

这一层是整个设计的枢纽:启动参数决定了 dind 容器能做什么,hijack 层决定了租户被允许做什么。前者是能力边界,后者是策略边界。而它拦住 privileged 的意义在于——如果租户能在 dind 里跑 docker run --privileged,那前面所有关于 privileged-without-host-devices 的努力都会被绕过一层。

注意这里出现了一个有趣的对称:平台对 dind 容器自己用了 privileged + opt-out 设备,却对租户容器完全禁止 privileged。这说明 privileged 不是「好」或「坏」,而是信任层级的表达——dockerd 是被信任的基础设施组件,租户工作负载不是。


九、Q3:dind 里的 --gpus all 为什么是「局部 all」

前两问都在单个容器的尺度上。第三问把尺度放大到嵌套——一个容器里再跑一个容器,GPU 的边界是怎么一层层传下去的?

9.1 闭集是一层层传下来的

场景:

1
2
3
4
宿主机(8 张卡)
└── 租户开发机容器(K8s Pod,通过 device plugin 拿到 2 张)
└── dind 容器(privileged + privileged-without-host-devices)
└── 用户在 dind 里跑 docker run --gpus all

现象:--gpus all 只挂到 2 张卡。三层原因,逐层回溯:

第一层:外层 Pod 的 /dev 是闭集。 kubelet 调 Allocate 拿到的是具体的设备节点,这些节点被写进 OCI spec 的 linux.devices,由运行时在 create 时创建/挂载进容器。外层容器的 /dev 里只有这 2 张卡的节点,/dev/nvidia2 ~ /dev/nvidia7 根本不存在。

第二层:dind 继承外层的集合。 这就是第八节那行代码——dindSpec.Linux.Devices = append(..., mainSpec.Linux.Devices...)。dind 即使 privileged: true,也只能看到外层给它的那 2 张。privileged 不创造设备节点,它只(默认)搬运已存在的节点。

第三层:--gpus all 的 all 是「当前可见的全部」。 NVIDIA 文档对 NVIDIA_VISIBLE_DEVICES 的定义是「controls which GPUs are accessible inside the container」,取值 all = 「All GPUs are accessible」、none = 「No GPU is accessible, but driver capabilities are enabled」、void/未设置 = 「same behavior as runc」。所以 all = 2——但**「当前可见」到底是谁在数、怎么数**,值得单独拆一节(见 9.2)。

9.2 all 到底是谁在数:一条七步链路

「运行时枚举的依据是设备节点」这个说法方向对,但太粗——Docker 自己一张卡都不数。把链路拆开:

① --gpus all 被翻译成一个结构体。 Docker CLI 把它变成:

1
DeviceRequest{Driver: "nvidia", Count: -1, Capabilities: [][]string{{"gpu"}}}

(Count: -1 在 Docker API 里表示「全部可用设备」。)

② dockerd 不枚举,只写一个字符串加一个 hook。 daemon/nvidia_linux.go:

1
2
3
4
5
6
7
8
9
10
const nvidiaHook = "nvidia-container-runtime-hook"
...
} else if req.Count < 0 {
s.Process.Env = append(s.Process.Env, "NVIDIA_VISIBLE_DEVICES=all")
}
...
s.Hooks.Prestart = append(s.Hooks.Prestart, specs.Hook{
Path: path,
Args: []string{nvidiaHook, "prestart"},
})

Docker 只往内层容器的 OCI spec 里塞了「NVIDIA_VISIBLE_DEVICES=all」这个字符串和一个 prestart hook 的路径。它不知道这台机器上有几张卡,也不打算知道。

同一个文件的 init() 里藏着一条部署约束:

1
2
3
4
if _, err := exec.LookPath(nvidiaHook); err != nil {
// do not register Nvidia driver if helper binary is not present.
return
}

hook 不在 PATH 上,nvidia device driver 根本不注册,--gpus 会直接报 could not select device driver "" with capabilities: [[gpu]]。dind 镜像必须装 NVIDIA Container Toolkit,原因就在这一行。

③ hook 跑在 dind 的 namespace 里。 prestart hook 由 runc 在 create 时执行(rootfs 就绪、容器进程启动之前),它是 dockerd 的子进程——所以它继承的是 dind 容器的 mount namespace。这一步决定了「可见」的范围。

④ hook 调 nvidia-container-cli,后者用 NVML 枚举。 libnvidia-container 的 src/driver.c:

1
2
call_nvml(err, ctx, nvmlDeviceGetCount_v2, &count)            /* driver_get_device_count */
call_nvml(err, ctx, nvmlDeviceGetHandleByIndex_v2, idx, &h) /* driver_get_device */

没有 procfs 回退——它不走 /proc/driver/nvidia/gpus。

⑤ NVML 的 count 是「可访问设备数」。 这是整条链的枢纽,NVML 官方文档原文:

「On some platforms not all devices may be accessible due to permission restrictions. In these cases the device count will reflect only the GPUs that NVML has access to.」

而「可访问」的判据,就是 /dev/nvidiaN 节点在这个进程的 mount namespace 里存不存在。

⑥ 于是 all 在 dind 里被解析成 2。 注入的 2 个设备节点、驱动库挂载、devices cgroup 规则,被写进内层容器的 OCI spec。

⑦ 内层容器的 runc 把节点造出来。 走 3.1 节那三分支:非 userns 用 mknod,userns 用 bind mount。

串起来:

所以「卡数一样」不是「dind 把主容器的分配转发下去了」,而是「dind 自己只看得见 2 张,而 all 就是它看得见的全部」。 内层容器数出 2,是因为它站在 dind 的肩膀上数。

反事实很干脆:把 9.1 节那行 dindSpec.Linux.Devices = append(...) 删掉,NVML 在 dind 里就数出 0,--gpus all 注入空集,内层 nvidia-smi 报 No devices were found。

一个容易混淆的对照:/proc/driver/nvidia/gpus 不按设备可见性分区——它反映的是宿主内核状态,容器里照样能列出全部 8 张(它不在 containerd 的默认 masked paths 里)。这正是为什么平台自己的 CLI 能靠它把 GPU UUID 解析成 /dev/nvidiaN,而运行时那边只数得出 2 张。两套枚举、两种口径:排查时不要拿 /proc 的结果去推断容器里能看到几张。

⚠️ 更正五:原稿说「如果 dind 里配了 CDI 模式……即便 spec 说有 8 张,实际能访问的还是由外层注入的 /dev 节点数决定,因为 CDI spec 里的设备节点路径在外层容器里不存在,create 时会失败或者被忽略」。

「失败」和「被忽略」是两种完全不同的行为,不能含糊。CDI spec 只是数据;注入时运行时要在它自己的文件系统里解析 deviceNodes[].path。路径不存在时,这是一个硬错误,不是静默忽略。NVIDIA 仓库里 rootless + CDI 的 issue #434 报的就是这一类:

1
2
Rootless Docker CDI Injection: error modifying OCI spec:
failed to inject CDI devices: unresolvable CDI devices nvidia.com/gpu=all: unknown.

unresolvable 这个词用得很准——CDI 名解析不到实体,注入直接失败。所以「挂个完整 spec 进去就能看到 8 张卡」是不成立的,挂进去只会让容器起不来。

9.3 但集合可以在内部重分配

第一节说过「设备节点在 create 之后仍可增删」,Sky-DevBox 把它做成了产品功能。

控制面:一个 CLI 命令写一份 YAML。

1
2
3
4
5
containers:
- name: my-container
gpus:
- /dev/nvidia0
- /dev/nvidia3

CLI 跑在主容器里,写的是 /gpu-hotplug,写入用 flock(/gpu-hotplug.lock)串行化。而 dind 侧的 reconciler 监听的是 /mainroot/gpu-hotplug——/mainroot 是主容器 rootfs 在 dind 里的挂载点,两者是同一个文件。这个「同一文件、两个路径」的对应关系,正是第八节那套 /mainroot 路径重写机制的产物。

用户给的是 GPU UUID 时,它从 /proc/driver/nvidia/gpus/*/information 里读出 GPU UUID: 和 Device Minor: 两行,拼成 /dev/nvidia<minor>。

数据面:dind 里常驻一个 reconciler,两个触发源——

  • fsnotify 监听那个 YAML 文件的 Write 事件;
  • Docker events 流,过滤 type=container + event=start + label=gpu-hotplug=true(容器启动后要等它真正 ready 再补设备,代码里会轮询 State.Running && State.Pid != 0,最多 60 次、每次 1 秒)。

协调逻辑是标准的 desired vs current 三段式(为突出主干,下面省略了逐条错误处理):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for containerName, desiredDevices := range desiredState {
currentDevices := currentState[containerName]

// 设备在配置中但不在容器中 → attach
for _, device := range desiredDevices {
if !slices.Contains(currentDevices, device) {
gm.attachGPUDevice(containerName, device)
}
}

// 设备不在配置中但在容器中 → detach
for _, device := range currentDevices {
if !slices.Contains(desiredDevices, device) {
gm.detachGPUDevice(containerName, device)
}
}
}

// 容器不在配置中 → 全部 detach
for containerName := range currentState { ... }

current 状态怎么来的很有意思:先 ls /dev | grep -E 'nvidia[0-9]+' 列出 dind 自己的 GPU 节点,再对每个带标签的容器用 nsenter ... test -e <device> 探测这个节点在不在容器里。也就是说,「当前状态」是探测出来的,不是记账来的。

这正好是 9.2 那条链的镜像:--gpus all 走的是「NVML 枚举」,热插拔走的是「ls /dev 探测」。两者口径一致(都以 dind 的 /dev 为准),所以能互相配合——热插拔能补的,恰好就是 all 能看见的。

attach / detach 的实现:

1
2
3
4
5
6
// attach
nsenter -m -p -u -i -n -t <pid> sh -c "rm -f D; mknod D c MAJ MIN; chmod 666 D"

// detach
nsenter -m -p -u -i -n -t <pid> sh -c "umount D" // 失败只告警
nsenter -m -p -u -i -n -t <pid> sh -c "rm -f D" // 失败只告警

umount 先试、失败只记日志,是因为设备可能正被占用(挂载点还在),此时直接 rm 也删不掉。这是很务实的写法。

这个功能恰好证明了第 3.1 节的结论:nsenter 的 flag 里没有 -U,进程保持宿主 root 凭据,capable(CAP_MKNOD) 通过,mknod 成功。如果当初顺手写了 -a(进入所有 namespace,含 user),这个功能一行都跑不起来。

一个实践上的边界:mknod 补进去的节点,对之后新启动的进程立刻生效(新敲一条 nvidia-smi、新起的训练进程都会看到它);但已经在跑、且已经初始化过 NVML 的进程通常感知不到——NVML 是在进程内初始化时确定设备列表的。所以这个功能定位是「给运行中的开发容器补卡」,而不是「给运行中的训练任务动态加卡」。这也和 9.2 的结论闭环:卡数在进程初始化那一刻就被 NVML 数完了。

但它改变不了集合的边界。 reconciler 的输入 ls /dev | grep nvidia 列的是 dind 自己的 /dev——也就是外层 Pod 给它的那 2 张卡。它能在 2 张里做任意分配(给 A 容器 1 张、B 容器 1 张、或都给 A),但一张也变不出来。

这就是本文最核心的那句话:闭集不是「create 时定死的」,而是「由最外层那一次分配定死的」;下游每一层都只能在内部重分配,不能扩张。

9.4 两条路径与一条不推荐

路径 做法 适用
A(推荐) 不在 dind 里控卡,让外层 K8s 控 外层 Pod 直接申请 N 张,dind 里 --gpus all 继承子集。责任链最干净,记账不漂移
B dind 里用 CDI 名 / NVIDIA_VISIBLE_DEVICES 精确指定 需要外层挂载设备节点;可选范围仍被外层注入集合封顶
C(不推荐) 外层 hostPath 挂全 /dev 见下

路径 C 的问题不只是安全:

  • 它绕过了 K8s 的记账。这 8 张卡在 device plugin 眼里仍然是「已分配给这个 Pod 2 张」,剩下 6 张在调度器看来是空闲的,可以被别的 Pod 抢走——于是两个 Pod 同时用同一张卡,双重记账 / 记账漂移就此发生。
  • 在托管 dind 里这条路通常已经被堵死:Sky-DevBox 的 hijack 层拒绝 IpcMode == "host",并把所有 mount source 重写到 /mainroot 前缀下——你想挂宿主机的 /dev,挂到的是主容器的 /dev。

③ 取舍 · 十 —— 从一个真实实现往回看,设计是怎么被约束反推出来的。

十、把镜头拉远:这套实现的三个设计取舍

三问答完了。七、八、九节分别讲了「运行时怎么把设备拿到手」「权限模型长什么样」「嵌套之后边界怎么传」——但它们都是局部视角。这一节退一步,看这套实现整体上做过的三个取舍。它们都不是实现细节,而是在 K8s 的能力边界之外做选择——也正是本文标题里「责任链」三个字最具体的含义。

10.1 为什么会有这套东西:从 sidecar 到 sibling

最初的 dind 是一个 sidecar 容器,和主容器同处一个 Pod:

1
2
3
4
Pod
├── 主容器(用户工作空间)
└── dind sidecar(跑 dockerd)
↑ 用户通过网络端口访问它

重构方案的设计稿把它的限制列成了四条:

限制 具体问题 用户感受
文件系统隔离 sidecar 挂不到主容器的根目录,只能同步共享存储路径 docker run -v 的挂载路径受限
可观测性缺失 用户看不见、也控制不了 sidecar 容器 出问题拿不到反馈,没法自查自修
配置不可控 改不了 dockerd 配置(比如代理设置) 无法定制,体验差
资源不互通 sidecar 与主容器各有各的 CPU/内存限额 配额不能互相借用,用不灵活

设计稿的结论很干脆:这四条归根结底是 K8s 框架本身的限制——K8s 无法单独管理 Pod 内某个容器的生命周期,也无法绕过自己的资源分配体系让容器之间共享配额。所以方向定为脱离 K8s,从宿主机侧提供 dind 服务。

dind 因此从 sidecar 变成了 sibling:由宿主机的 agent 直接在 containerd 里创建,与主容器平级,K8s 完全不知情。

这一步是理解后面所有设计的钥匙。 一旦 dind 不再是 K8s 管理的容器,K8s 就管不了它——没有 Pod 边界、没有 securityContext、没有 ResourceQuota 覆盖它。平台必须自己把这套治理补回来。 第八节那个 HTTP 劫持层、下面 10.2 的 cgroup 嵌套、dockerctl 的 token 鉴权,全都是在补这一课。

10.2 cgroup 嵌套:让 dind 花主容器的预算

资源互通这一条,落地形态是 cgroup 嵌套。当前代码里的布局是:

两行代码决定了这个布局:

1
2
3
4
5
6
7
8
// dind 容器本身:把 Pod slice 的名字改造成同级的 -dind.slice
dindParentCgroupSlice := strings.ReplaceAll(podCgroupSlice, ".slice", "-dind.slice")
createOptions.CgroupParent = dindParentCgroupSlice

// dind 里的用户容器:父级指向主容器的 cgroup
dindContianersCgroupParent := filepath.Join(mainContainerCgroupPath, "dind-containers")
// → 以 CONTAINERS_CGROUP_PARENT 环境变量传给 dind 容器,
// 由入口脚本变成 dockerd 的 --cgroup-parent

关键在第二段:用户在 dind 里 docker run 起的容器,cgroup 父级是主容器。于是它消耗的 CPU/内存直接记在主容器账上——主容器那 4C8G 的限额,被「主容器里的进程」和「dind 里的容器」共同瓜分。这正是设计稿里「二者共享同一资源配额、使用无区别」的落地方式,也是 K8s 原生表达不了的东西。

代码里还留了 old mode 的兼容路径:早期 dind 容器直接作为主容器 cgroup 的子节点(nerdctl-<id>.scope),findDindContainersByCgroup 两种布局都扫。双模式并存本身就是「布局换过」的证据。

副作用是 cgroup v2 的 cgroup.subtree_control:要让新建的子 cgroup 真正生效,父级必须打开对应控制器。所以代码里先清空 Pod cgroup 的 subtree_control,再按 Pod 注解 <api-group>/cgroup-subtree-control 重新打开,并把 dind-containers 标成 cgroup.type = threaded。第四节提到的那两个动作,动机就在这里。

10.3 设计稿里的 rootless,为什么没有落地

这是最能说明「设计意图 ≠ 落地形态」的一处,也是它和第六节的直接呼应。

重构方案的设计稿里,dind 的启动流程明确带 rootless:

1
2
3
4
5
6
7
8
以 root 身份启动 dind 容器
→ 配置 rootless 用户
→ 创建 /run/docker、/var/lib/docker 等目录并改权限
→ 读取主容器的 daemon.json 初始化配置
→ 初始化系统目录与网络
→ 启动 Docker Hijack 服务
→ 切换到 rootless 身份 ← 关键的一步
→ 启动 dockerd

但落地的代码里没有 rootless。 三条证据:

  1. 入口脚本以当前身份直接起 dockerd——dist/dind/entrypoint-slim.sh 里没有任何 setuid / su / rootlesskit 的影子;只剩 uid="$(id -u)" 这一行和一句提到 「both standard or rootless」 的注释,都是残留。
  2. socket 探测保留了 rootless 路径——findDockerSocket() 在找不到 /run/docker.sock 时,会去扫 /run/user/<uid>/docker.sock,而这正是 rootless docker 的 socket 位置。说明代码要兼容两种形态,但默认走前者。
  3. daemon 配置校验明确拒绝 userns remap——hijack_init.go 里:
1
2
3
if config.RemappedRoot != "" {
return ErrRemappedRootNotSupported
}

为什么放弃?把前几节的结论拼起来就很清楚:

  • dockerd 要的能力和 rootless 正面冲突:挂载、iptables、建网络命名空间、mknod——都要 CAP_SYS_ADMIN,而 userns 里的 CAP_SYS_ADMIN 只在 pod 自己的 userns 内有效(§5.2 的官方原文)。dind 要的恰恰是「对外部世界生效」的那部分特权。
  • rootless dockerd 自己就要一整套配套:rootlesskit、slirp4netns、/etc/subuid 映射,外加 cgroup v2 的委派链。
  • NVIDIA 那一层当时还没准备好:6.2 节那两次回归全都出在 rootless 路径上,legacy 模式在 rootless 下直接崩。

于是这套实现选了另一条路:容器 privileged(要 SYS_ADMIN 就给全),但把设备默认收走(privileged-without-host-devices),再用运行时侧的设备解析(7.2 节)把该给的设备精确补回来,最后用 API 策略层(8.3 节)把租户能做的事框住。

换句话说——它用「显式白名单 + 策略层」换掉了「userns 隔离」。隔离强度不如 userns,但工程上可落地、和 GPU 生态兼容。这是个很务实的取舍,也是本文想传达的核心:责任链上的每一层都在做这种取舍,没有免费的隔离。

一个排查时值得记住的小漂移:设计稿的伪代码写的是 pod.Annotations["<api-group>/dind-enabled"] == "true",而代码里实际是 pod.Annotations["<api-group>/dind"] == "enabled"。设计稿在前、实现在后,这类漂移很正常,但排查时永远以代码为准。


④ 收束 · 十一 ~ 十三 —— 一张图、一份清单、一句修正。

十一、把三问放进一张责任链总图

把三问的结论画进同一张图。

图里三个结论:

  • rootless 的 request/limit 生效在 kubelet / device plugin 这一层,与容器内 uid 无关;rootless 真正约束的是注入能力(mknod、写 devices cgroup / BPF)。
  • privileged 与 device 是两条独立的轴,且 privileged 的默认行为是连设备一起给;「privileged 但无设备」必须显式 opt-out。
  • dind 的 GPU 集合是层层闭集:外层给 N 张 → dind 里就是 N 张 → dind 内的容器最多也是 N 张。all 是局部 all。

十二、踩坑清单

按症状查,每条都指回正文对应的小节。

  • 「rootless + legacy nvidia-container-cli = 启动失败」:症状是 bpf_prog_query(BPF_CGROUP_DEVICE) failed: operation not permitted。根治是切 CDI 模式(nvidia-ctk config --in-place --set nvidia-container-runtime.mode=cdi,1.18.0 起已默认 JIT-CDI)。版本要锁到具体小版本——1.17.7 和 1.18.0 都踩过这个坑(见 6.2)。
  • 「privileged 但 nvidia-smi 看不到卡」:先确认是不是真的「privileged 且无设备」。默认 privileged 是带全量宿主设备的;看不到卡,说明有人显式开了 privileged_without_host_devices / privileged-without-host-devices,那就得走 --device 或 CDI 显式注入。别在 privileged 本身找原因。
  • 「容器里有 /dev/nvidia0,CUDA 还是报错」:检查 nvidiactl、nvidia-uvm、nvidia-uvm-tools 和用户态驱动库是否齐全(见 3.3)。手工 mknod 一张卡通常不够。
  • 「dind 里 --gpus all 只看到一部分」:不是 bug,是外层注入的闭集。想扩,改外层 Pod 的 request;想在 dind 里控,走 CDI 或热插拔。
  • 「CDI 名解析失败」:unresolvable CDI devices 说明 spec 里的设备节点路径在运行时自己的文件系统里不存在。CDI spec 不是「挂进去就生效」的配置,它是需要在当前文件系统里解析的实体引用。
  • 「containerd 1.7 上 CDI 不生效」:1.7 默认 enable_cdi = false,2.0 起才默认启用。
  • 「kubelet 重启后 nvidia.com/gpu 从 node status 消失」:这是 device plugin 的已知重启语义(kubelet 重建 socket 目录、插件需要重新注册),排查思路见 为什么 Device Plugin 不必比 kubelet 先启动。
  • 「Pod 里写了 limits 但没生效」:先看 cgroup driver 是否两边一致,再看 GPU 是否走了 device plugin 之外的旁路(例如 hostPath 挂 /dev,导致 kubelet 完全不记账)。

十三、回到原篇

原篇的收束句是「runc 只在 create 的那一刻存在,之后的一切都由其他组件接手」。套到 GPU 上,可以再具体一层——但要修正一处:

  • GPU 的 request/limit 生效发生在 create 之前(kubelet 调 Allocate 拿到设备清单)。它根本不经过 runc;在 Sky-DevBox 那条路线上,它甚至还会在 create 期间被运行时包装器重放一遍。
  • GPU 的可见性发生在 create 那一刻,但不止于那一刻。runc 在这里只做「注入」,不做「决策」;而注入进来的节点,之后还可以被 nsenter + mknod 增删。
  • GPU 的记账与观测发生在 create 之后(DCGM / NVML 采样、cgroup 读数),runc 已经退出。

所以「每一层的能与不能,都是某一次 create 时注入的闭集决定的」这句话,应该改成:

每一层的能与不能,都是由最外层那一次分配给出的闭集决定的。下游可以在这个集合内部任意重分配,但没有任何一层能把它撑大。

这个修正看起来只是措辞,实际影响排查方向:当你在 dind 里发现少了两张卡,不要往下游找——下游(reconciler、--gpus、CDI)都只能重分配。要往上游找,去找那一次 Allocate 给了几张。

这也解释了为什么「谁在什么时刻决定了什么」这个结构比任何具体命令都重要:命令会变(legacy → CDI → JIT-CDI),版本会回归(1.17.7、1.18.0),坐标系会换(rootless 与否),但闭集的边界由最外层分配决定这件事不会变。

最后补一句第十节留下的结论:责任链上的每一层都在做取舍,而取舍会被上游的约束反推着走。 Sky-DevBox 的设计稿写了 rootless,落地时换成了 privileged + opt-out 设备 + 运行时侧设备解析 + API 策略层——不是因为 rootless 不好,而是因为 dockerd 要的特权、rootless 的配套成本、以及当时 NVIDIA 在 rootless 路径上的两次回归,共同把这个选项排除了。看懂这种「约束反推设计」的链条,比记住任何一层的具体配置都更有用。


参考来源

源码(本地版本)

  • containerd v1.7.27 — pkg/cri/server/container_create_linux.go(privileged 分支)、oci/spec_opts.go(WithPrivileged / WithAllDevicesAllowed)、oci/spec_opts_linux.go(WithDevices / WithHostDevices)、pkg/cri/opts/spec_linux_opts.go
  • nerdctl v1.7.7 — pkg/cmd/container/run_security_linux.go(privilegedOpts / privilegedWithoutDevicesOpts)
  • containerd v1.7.x — docs/cri/config.md(privileged_without_host_devices、cdi_spec_dirs)
  • CDI v0.7.2 — pkg/cdi/spec.go
  • runc v1.2.6 — libcontainer/rootfs_linux.go(createDevices / createDeviceNode / mknodDevice:userns 走 bind mount 的三分支逻辑)
  • docker v26.1.3 — daemon/nvidia_linux.go(setNvidiaGPUs:Count < 0 → NVIDIA_VISIBLE_DEVICES=all + prestart hook 注入)

内核 / 官方文档

  • user_namespaces(7) — 「Only a process with privileges in the initial user namespace can perform such operations.」(CAP_MKNOD 的坐标系)
  • Kubernetes — Device Plugins(Allocate 返回值、注册与 socket 路径)
  • Kubernetes — Resource Management for Pods and Containers(extended resources 整数约束与不可超卖)
  • Kubernetes — User Namespaces(hostUsers、前置条件、capability 行为)
  • Kubernetes Blog — v1.36: User Namespaces are finally GA(版本线)
  • NVIDIA Container Toolkit — Support for Container Device Interface(rootless 兼容性、nvidia-ctk config、spec 路径与 nvidia-cdi-refresh)
  • NVIDIA Container Toolkit — Release Notes(1.18.0 默认 JIT-CDI、1.19.1 schema v0.7.0)
  • NVIDIA Container Toolkit — Docker 专用说明(NVIDIA_VISIBLE_DEVICES 取值语义)
  • NVIDIA GPU Operator — Time-Slicing GPUs(ConfigMap sharing.timeSlicing.resources[].replicas)
  • NVML API Reference — nvmlDeviceGetCount(「the device count will reflect only the GPUs that NVML has access to」——all 的边界从哪来)
  • libnvidia-container — src/driver.c(driver_get_device_count → nvmlDeviceGetCount_v2,无 procfs 回退)

Issue 与实证

  • moby/moby #16160 — 「New devices on host are not exposed to privileged container」
  • NVIDIA/nvidia-container-toolkit #1093 — 1.17.7 破坏 Slurm/Pyxis(bpf_prog_query)
  • NVIDIA/nvidia-container-toolkit #1487 — 1.18.0 rootless Docker 回归(bpf_prog_query)
  • NVIDIA/nvidia-container-toolkit #434 — rootless Docker CDI 注入 unresolvable CDI devices

对照实现(Sky-DevBox)

  • 该项目的 dind 重构方案设计稿(sidecar → sibling 的四条限制、cgroup 嵌套目标、含 rootless 的启动流程)
  • 该项目的 DinD 重构技术详解(三层架构、HTTP 劫持、GPU 热插拔、privileged-without-host-devices 的使用)
  • 其源码:pkg/dind/controller/(创建与生命周期)、pkg/dind/server/(HTTP 劫持 + GPU 热插拔)、pkg/runtime/(运行时包装器与设备解析)、pkg/cgroups/

站内相关