runc 补篇之后:rootless 容器、privileged-without-device 与 dind 的 GPU 责任链
一句话结论:GPU 在容器里的可见性,不是由
--gpus或--device这一层的写法决定的,而是由最外层那一次分配定死的——下游每一层都只能在集合内部重分配,一张也变不出来。三条推论:
- rootless 影响的不是 request/limit 生效(那发生在宿主机的 kubelet / device plugin 进程里),而是运行时能不能把设备节点塞进去;
privileged与device是两条正交的轴,且privileged的默认行为是连设备一起给你——「门全开但屋里没东西」必须靠显式 opt-out 才能达成;- 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 | if securityContext.GetPrivileged() { |
注意这里:oci.WithPrivileged 本身一个设备都不碰。它展开是这八个(oci/spec_opts.go):
1 | var WithPrivileged = Compose( |
设备是另外两个 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 | var privilegedOpts = []oci.SpecOpts{ |
差集一目了然:privileged-without-host-devices=true 拿掉的就是 WithAllDevicesAllowed(devices cgroup 全开)和 WithHostDevices(全量宿主设备节点)。而且这个开关必须配合 --privileged 使用:
1 | if privilegedWithoutHostDevices && !privileged { |
一个容易踩的坑:这里有两个同名但不同层的开关。
形态 出现位置 作用对象 privileged_without_host_devices(下划线)containerd 的 config.toml,runtimes.<name>段CRI 创建的容器(即 K8s Pod) privileged-without-host-devices(连字符)Docker / nerdctl 的 --security-optdockerd/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 | func createDevices(config *configs.Config) error { |
而 createDeviceNode 是三分支的:
1 | if bind { |
三件事一次说清了:非 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 | cmd := exec.Command("nsenter", "-m", "-p", "-u", "-i", "-n", "-t", fmt.Sprintf("%d", pid), "sh", "-c", |
数一下 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:三档,不是两档
设备访问控制有两套独立机制,经常被混为一谈:
- 设备节点是否存在(mount namespace 里的文件);
- devices cgroup 是否放行(
open()时的检查)。
第 2 条在 OCI spec 里是 linux.resources.devices,元素类型是 LinuxDeviceCgroup。containerd 的 oci.WithDevices 一次写两处:
1 | for i := range devs { |
这就是为什么 --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.hostUsersfield tofalse.」
1 | apiVersion: v1 |
它的版本线是: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: truekubelet 准入 + 进程 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_MODULEdoes not have any effect if granted to a pod using user namespaces, the pod isn’t able to load kernel modules.」「
CAP_SYS_ADMINis 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 deniednvidia-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 无关
把链路铺平:
- 调度:
resources.limits.nvidia.com/gpu: 2让调度器只看 node 的 allocatable; - 准入:kubelet 的 Device Manager 调 device plugin 的
Allocate; - 注入:
Allocate的返回值被塞进 CRI 的ContainerConfig,containerd 构造 OCI spec; - 落地:运行时(runc,或被包装的 runc)在 create 时把
linux.devices、linux.resources.devices、env、mounts 写进新容器。
官方文档对 Allocate 返回值的描述是:
「An
AllocateResponsecontains zero or moreContainerAllocateResponseobjects. 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 | [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.sky.options] |
这个包装器在 runc 之前介入,读 bundle 里的 config.json、改完再写回、然后 exec 真正的低层运行时。它注册的 spec modifier 链是:
1 | originalProvider := providers.NewDevicesProvider(logger, kc) // 直接问 kubelet |
拆开看,它做了三件标准路径不做的事。
(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 | req := &devicepluginapi.AllocateRequest{ |
然后把 ContainerAllocateResponse 的四类内容分别合并进 OCI spec:
1 | mergeEnv(containerInfo.Envs, &spec.Process.Env) // NVIDIA_VISIBLE_DEVICES 等 |
注意最后一行——mergeDevice 内部调的是 containerd CRI 自己的 opts.WithDevices,也就是和 containerd 官方路径用的是同一个函数,最终仍然落到第四节的「节点 + cgroup 规则」两处。
© 它用不动点迭代解决链式引用。 这是最巧的一处。Repeat 修饰器的实现是:把 spec 序列化成 JSON,反复跑 modifier,直到序列化结果不再变化为止:
1 | for { |
为什么要迭代?因为设备引用可以链式。代码注释里列了三种真实场景,以 spot+dind 为例:
- 第 1 轮:从主容器同步「抢占实例」的设备 ID,形如
GPU-xxxx-spot,调 spot GPU 的 device plugin,得到一条注解{"nvidia.com/gpu":["GPU-xxxx"]}; - 第 2 轮:
directprovider 读到这条注解,拿真实 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 | createOptions := types.ContainerCreateOptions{ |
这就是本文 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 | if config.HostConfig.Privileged { return ErrPrivilegedNotSupported } |
它改写的东西:
- 挂载路径重写:把所有 bind mount 的 source 加上
/mainroot前缀——/mainroot是主容器 overlay rootfs 在 dind 里的挂载点,dockerd 眼中的「宿主机路径」实际是这个前缀下的路径。错误信息里再把这个前缀抹掉(strings.ReplaceAll(err, " /mainroot/", " /")),对租户保持透明。 - GPU 请求自动补全:带
gpu-hotplug=true标签的容器,如果没有显式DeviceRequests,自动补一条:
1 | config.HostConfig.DeviceRequests = []container.DeviceRequest{ |
(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 | 宿主机(8 张卡) |
现象:--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 | const nvidiaHook = "nvidia-container-runtime-hook" |
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 上,
nvidiadevice 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 | call_nvml(err, ctx, nvmlDeviceGetCount_v2, &count) /* driver_get_device_count */ |
没有 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。
串起来:
flowchart TD
A["docker run --gpus all"] --> B["dockerd<br/>DeviceRequest{Count:-1}<br/>只写 env + prestart hook<br/>不枚举任何 GPU"]
B --> C["runc 在 create 时执行 hook<br/>hook 进程在 dind 的 mount ns 里"]
C --> D["nvidia-container-cli<br/>NVML: nvmlDeviceGetCount"]
D --> E["count = dind 里存在的<br/>/dev/nvidiaN 个数"]
E --> F["注入节点 + 驱动库 + cgroup 规则<br/>到内层容器的 OCI spec"]
F --> G["内层 runc 创建节点"]所以「卡数一样」不是「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 | containers: |
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 | for containerName, desiredDevices := range desiredState { |
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 | // attach |
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 | Pod |
重构方案的设计稿把它的限制列成了四条:
| 限制 | 具体问题 | 用户感受 |
|---|---|---|
| 文件系统隔离 | 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 嵌套。当前代码里的布局是:
flowchart TD
POD["kubepods-burstable-podXXX.slice<br/>(Pod cgroup,K8s 记账在这一层)"]
MAIN["cri-containerd-{main}.scope<br/>主容器,limits 4C/8G 写在这"]
INNER["dind-containers/<br/>用户在 dind 里跑的容器"]
DIND["kubepods-...-podXXX-dind.slice<br/>→ nerdctl-{dind}.scope"]
POD --> MAIN
MAIN --> INNER
POD --> DIND两行代码决定了这个布局:
1 | // dind 容器本身:把 Pod slice 的名字改造成同级的 -dind.slice |
关键在第二段:用户在 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 | 以 root 身份启动 dind 容器 |
但落地的代码里没有 rootless。 三条证据:
- 入口脚本以当前身份直接起 dockerd——
dist/dind/entrypoint-slim.sh里没有任何setuid/su/rootlesskit的影子;只剩uid="$(id -u)"这一行和一句提到 「both standard or rootless」 的注释,都是残留。 - socket 探测保留了 rootless 路径——
findDockerSocket()在找不到/run/docker.sock时,会去扫/run/user/<uid>/docker.sock,而这正是 rootless docker 的 socket 位置。说明代码要兼容两种形态,但默认走前者。 - daemon 配置校验明确拒绝 userns remap——
hijack_init.go里:
1 | if config.RemappedRoot != "" { |
为什么放弃?把前几节的结论拼起来就很清楚:
- 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"。设计稿在前、实现在后,这类漂移很正常,但排查时永远以代码为准。
④ 收束 · 十一 ~ 十三 —— 一张图、一份清单、一句修正。
十一、把三问放进一张责任链总图
把三问的结论画进同一张图。
flowchart TB
subgraph host["宿主机(初始 user namespace)"]
H1["8x GPU 物理卡"]
H2["device plugin<br/>DaemonSet, privileged"]
H3["containerd<br/>+ 运行时包装器"]
end
subgraph pod["租户开发机容器(K8s Pod)"]
P1["resources.limits:<br/>nvidia.com/gpu: 2"]
P2["/dev/nvidia0, /dev/nvidia1<br/>linux.devices + resources.devices"]
P3["hostUsers: false 时<br/>容器内 uid0 = 宿主 uid 100000"]
end
subgraph dind["dind 容器"]
D1["privileged: true<br/>+ privileged-without-host-devices"]
D2["Linux.Devices =<br/>mainSpec.Linux.Devices 的复制"]
D3["reconciler: nsenter + mknod<br/>在集合内重分配"]
end
subgraph inner["dind 内的租户容器"]
I1["可见性 = dind /dev 的闭集<br/>= 外层 Pod 的分配"]
end
H1 --> H2
H2 -->|"ListAndWatch 上报容量"| K["kubelet"]
K -->|"Allocate 返回<br/>设备节点 / env / mounts / CDI 名"| H3
H3 -->|"create 时注入"| P2
P1 --> P2
P2 -->|"创建 dind 时整份复制"| D2
D1 --> D2
D2 --> D3
D3 -->|"mknod 进运行中的容器"| I1
P3 -.->|"只约束 mknod / BPF<br/>不影响 request/limit 生效"| P1图里三个结论:
- 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/
站内相关