在05,内侧 root 被映射为外侧 UID 1001,说明“程序显示 UID 0”不足以推出宿主特权。另一种常被混写的配置是 Dockerfile USER 65532:它指定应用在容器内的身份,并没有规定 daemon 本身用哪个宿主 UID 工作。rootless 模式则涉及 daemon 和容器进程的宿主权限前提,资源与网络能力也随委派和实现改变。

三个身份分别记账

先记录 daemon 宿主身份、容器外侧 UID 映射与应用内侧 UID,再问低端口绑定、设备访问、overlay/snapshotter、cgroup v2 控制器和网络转发在所选 rootless 实现中如何实现。不应把“daemon 非 root”和“应用 USER 非 root”合并成一项。rootless 不会凭空委派宿主 cgroup 子树,也不是应用任意设备访问的许可证。不同运行时和 Linux 版本对辅助工具、存储与网络的要求不同;适用范围必须限定到实测版本。

正常实验在专用 VM 用同一探针分别以 rootful daemon + USER 65532 与 rootless daemon + 相同应用 UID 启动,对照 daemon PID/UID、应用内外 UID/map、端口和 cgroup 可写项。边界例在没有资源委派时设置 CPU limit:配置请求也许被拒绝或不起作用,必须读 cgroup 实际内容及官方限制再解释,不能拿两次 id 输出当限额已经生效。入口给出清单,自含源码与 13 共用 Dockerfile。

本机 userns 可用,但无 rootless daemon、cgroup v2 委派和 runtime。05 的映射记录只证明 userns 的一部分;20 的 daemon/容器对照及端口、资源限制均 NOT_RUN。

三种“不是 root”指向不同的检查对象

先把身份放在同一张有方向的图上:Docker CLI 只是将创建请求发给选定的 daemon;daemon 的宿主 UID 与能力集决定它在宿主上可以做哪些事;运行时依托 user namespace 可以让内侧 UID 与外侧 UID 不同;镜像的 USER 或启动参数则选择应用进程在容器视图中使用哪个 UID。USER 65532 并不改变 dockerd 的启动身份,rootful daemon 也完全可以运行 UID 65532 的应用。反过来,rootless daemon 即使启动内侧 UID 0 的应用,也不能因此变成宿主 UID 0 的 daemon。读 /proc/<daemon_pid>/status 的 Uid 和 CapEff、应用内的 /proc/self/uid_map、宿主应用进程的 Uid,应当分成三列,不能用容器里一次 id 替代。

Linux user namespace 的 ID 映射决定“内侧 UID 0”落在外侧什么数字上。映射不是字符串显示技巧:权限检查要结合当前进程的 user namespace、被访问对象的所属用户 namespace 及挂载、文件的所有权来判断。05 的有界例子只说明非特权用户可以创建一个新的 user namespace 并在里面看到 UID 0;它既没有启动 dockerd,也没有证明该用户能设置宿主 cgroup 或映射任意宿主 UID。/etc/subuid 与 /etc/subgid 的委派范围、newuidmap/newgidmap 工具和系统配置必须在专用 VM 上逐项核验。官方 Docker Rootless mode 文档明确把这两个工具与足够的 subordinate ID 范围列作前提;仅创建 unshare -Ur 的单 UID 映射不足以满足完整的镜像文件所有权映射需求。

Docker v28.4.0 tag 下的 contrib/dockerd-rootless.sh 是一次版本定点阅读,并非本机安装的启动脚本。脚本先检查 XDG_RUNTIME_DIR 可写、HOME 是否存在及 rootlesskit 是否可用;非特权父进程会检查当前 UID 不得为 0,并选择网络辅助实现。该脚本当前会在 slirp4netns 满足版本检查时优先采用它,否则寻找 vpnkit,找不到时退出。它还使用 DOCKERD_ROOTLESS_ROOTLESSKIT_* 变量选择网络与端口驱动,默认设置包含阻止访问宿主 loopback 的选项。不能因为脚本里有这些分支就断定某台 VM 实际选用了 slirp4netns,更不能把 RootlessKit、用户态网络和所有 rootless runtime 视为同一套实现;必须读取当次 daemon 的启动进程、参数及网络驱动。

为何 userns 不会自动带来 CPU 委派

资源限制属于 cgroup 层级控制,和 UID 映射是两套内核对象。cpu.max 描述 cgroup v2 的带宽限制;要由非特权服务为容器设置限制,所在的父级需有可用控制器和恰当委派,还要有相应的可写路径、操作权限和运行时实现支持。即使 rootless daemon 能够运行程序,也不能从 docker run --cpus=... 的 CLI 语法推出对应 cpu.max 已写入,更不能凭探针在短时间内响应变慢判断 CPU 配额生效。06 已说明 cpu.weight 的相对竞争与 cpu.max 的绝对带宽不同,在没有竞争负载时权重没有与配额相同的观察条件。验证应先记 VM 实际 stat -fc %T /sys/fs/cgroup、所在组的控制器与委派状态,再查具体子组的配置、cpu.stat 中节流计数和有界负载下的变化。没有控制器、参数被拒绝和计数没有增长属于不同结论;如果 VM 使用 cgroup v1,不能按 v2 的路径强行解读。

当前共享云端 cgroup v1 分层为只读,CapEff=0 且没有 Docker。即使在本机执行 unshare -Ur 后得到 UID 0,也不能以此说明 rootless Docker 的内存、CPU 或 I/O 限制已经工作。在专用 VM 的失败对照中,应在实验用户自己的 daemon 上申请一个小而非危险的 CPU 上限,再记录 CLI 返回、目标 cgroup 文件与 cpu.stat。若父级没有委派,预期可能因版本、配置而表现为配置失败或限制没有落实;写作不能在执行前填一个固定错误文本。修复时只对测试用户服务作授权或选择有合适委派的实验 VM,不修改共享宿主的默认 cgroup 配置,更不能用 root 权限绕开问题后声称 rootless 条件成立。

网络与存储为何也需要额外证据

Rootful daemon 创建 bridge 与端口规则通常有宿主级操作能力,rootless 实现则需要在无对应宿主特权的条件下提供网络连接能力。依据前述脚本,测试者至少要确认所选网络辅助进程、外层端口转发实现和应用监听地址三者。容器内 0.0.0.0:18080 正在监听,不代表宿主 127.0.0.1:18080 的请求已经到达;端口发布可能在辅助进程层失败,DNS 和容器出站还可能走不同路径。若实验小于 1024 的宿主端口,需要单独看所选实现、内核和宿主策略对非特权端口的规则,不因为容器内 UID 0 就假定能绑定。为了避免侵入其他服务,本系列只映射专用 VM loopback 上未占用的高端口,不修改 net.ipv4.ip_unprivileged_port_start,也不在共享宿主开启转发或放开防火墙。

存储也不能只看“能运行”。镜像解包、快照、bind mount 到实验目录、文件属主转换分别跨越镜像层与用户映射层;根文件系统能读,不说明任意宿主路径可写。用同一个 probe-app 执行 /state 写入实验用户拥有的专属目录,同时保存宿主目录的数值 UID/GID、容器内 id 与 uid_map,才能区分“镜像 USER 与目录所有权不匹配”和“rootless daemon 没有操作宿主路径的权限”。如果 Docker 在 rootless 环境使用不同的存储实现,先记 docker info 的 storage driver 与内核版本;一次写入耗时不能推导 OverlayFS、用户态快照和所有 runtime 的通用性能损耗。卷和 bind 的生命周期与数据持久性回到 16,20 只追这次身份是否有读写权限。

两组正例和两组边界

正向对照固定同一份镜像实际 ID:镜像 Dockerfile 设置 USER 65532:65532,分别用实验 rootful daemon 和同一实验用户的 rootless daemon 运行,镜像在两个独立 image store 中需核对最终 ID 一致;远端仓库 digest 仅在实际取得时填写。两个容器分别使用不冲突的本地端口,保存 /identity 返回的 UID、PID、namespace inode 与 cgroup,再从对应 daemon 主机的 /proc/<pid>/status 和 /proc/<pid>/uid_map 获取宿主视角。预期两个应用内 UID 都是 65532;daemon 的宿主 UID 则应随实际运行模式而不同。若 VM 检查发现根本连错 daemon context,这次对照无效,先修正 socket 与 context,而不是拿两次同一 daemon 的输出解释实现差异。

第一边界是 rootful daemon 加 USER 65532:它只收窄应用入口身份,不撤销 rootful daemon 自身特权,也不为容器施加 userns 映射。检查 daemon 宿主 PID 与容器的 uid_map;若 daemon 配了 userns-remap,还得明确第三种配置,不能假定 rootful 一定没有 user namespace。第二边界是 rootless daemon 加容器 USER 0:即使容器内 shell 报 root,宿主可写范围仍取决于映射和外部目录权限。用自建的只允许实验用户访问的目录测试读写即可,不尝试提权或访问其他用户文件。若应用能在内侧以 root 写入实验目录,这不能证明其能写任意宿主路径;反例只需要看映射范围与宿主路径权限。

再加上资源/端口限制,两组身份测试才算解释完整。逐一确认 docker info 的 SecurityOptions 与 cgroup driver,记录 rootless 服务是否在用户级 systemd 下运行及对应环境变量;再申请有界 CPU 配额,尝试连接本机高端口,按结果分别定位委派或端口转发。如果想比较内侧 root 与非 root 应用的行为,只改 --user,daemon 保持不变;如果想比较 daemon 模式,就固定应用 USER 和镜像内容,切换到明确标出的两个 context。两类自变量同时改变,就无法把一次访问失败归因为 daemon 身份。

失败时先确定是哪个主体发起了操作

Docker CLI 命令行报 permission denied 并不指明拒绝发生在哪一层:客户端访问 Unix socket 可以被文件权限拒绝;daemon 可以在准备挂载时失败;应用也可以在创建状态目录时收到 EACCES。先保存命令 stderr、完整容器 ID 和对应的状态;没有 ID 则检查 socket 指向哪一台 daemon、客户端是否有访问资格,不能直接修改容器的 USER 来碰运气。有 ID 且 PID 始终为 0,核对启动阶段的挂载、网络或 runtime 错误;有 PID 和应用异常日志,核对 uid_map 与目标路径的所有权。权限问题的主体是“哪个进程拿哪个凭据访问哪个对象”,报错文字只是开始取证的线索。

启动 rootless 服务时用户自己的运行目录也有生命周期。XDG_RUNTIME_DIR 通常关联该用户的会话或用户级服务;当 CLI 没指定 DOCKER_HOST,可能仍连系统 rootful socket,表现为运行成功但观察到的 daemon UID 不对。官方 rootless 安装工具源码对可写的运行目录和 socket 做检查,并给出了本用户 Unix socket 的提示。因此实验不能只写“已经启用 rootless”,必须在每条命令之前打印 docker context show、对应 socket 路径和 docker info 安全选项;在两个 context 之间切换时使用命令级 --context,不去全局改写其他任务正在使用的默认 context。若服务因登出停止,应把用户服务生命周期作为变量记录,不能当作容器进程必然被宿主内核结束的证明;21 才会比较停止与重启。

网络的“localhost”也要明确属于哪一侧。容器内 127.0.0.1 是容器自己的 network namespace 回环;RootlessKit 可能在用户态转发连接到宿主,但是否允许从容器连回宿主 loopback 是另一个配置。脚本里的 --disable-host-loopback 默认值是一个提示,不是这台 VM 的实际抓包记录。应用响应 /ready 的本地请求能说明应用正在服务该 namespace 的 loopback,不代表外部发布端口也可达。建议沿应用监听、用户态网络、宿主发布端口三个位置依次检查连接与实际返回;若没有所选工具,记缺口而不是用另一台机器的示意抓包填空。

资源限制还有一个方向性的陷阱:daemon 自身所处的组不一定是允许它创建和配置子组的那个组。读取自己的 /proc/self/cgroup 只能定位当前进程,不能证明父级开启了 +cpu 或对子组文件做了正确委派。测试时先从 rootless daemon 的宿主 PID 找其 cgroup 路径,读取父级可用控制器、实际委派边界,再根据具体容器 ID 找到子组;在固定时间窗内读取限制前后两次 cpu.stat。负载脚本来自系列共用包,只运行预设上限且先在独占 VM 确认不会压垮宿主。若 cgroup v2 不可用、控制器未委派,完整实验维持 NOT_RUN;不能把 docker info 中一个驱动字符串当作 CPU throttling 的现场证据。

这套身份区分还改变了安全结论的表达方式。把 daemon 从宿主 root 降为普通用户,确实能缩小 daemon 进程直接对宿主可施加的权限,但它不意味着镜像内容可信、容器内进程不会滥用可访问的用户数据,或服务 socket 对同一用户下其他程序安全。rootful daemon 对外暴露的 socket 本来就应作为高权限入口管理;rootless socket 也应限制访问,不应为了演示把它对所有人开放。镜像的 USER 则是应用最小权限的一个组成部分,需要与 08 的 capabilities、seccomp 和 LSM、12 的制品来源一起评估。不能用“rootless 已启用”四个字替代主机授权、镜像供应链与应用自身权限审查。

一个能迁移到不同实现的判断步骤是从主体向对象提问:发起访问的是 CLI、daemon、运行时辅助进程还是应用?它当时在哪个 user namespace,内外 UID 映射是什么?目标是 Unix socket、cgroup 文件、网络端口还是宿主目录?具体对象属于哪个宿主用户和挂载视图?缺少任何一环,就先把问题保留为待证,不做“容器里是 root,故一定能写”的预测。若用户准备移植到 Podman、nerdctl 或其他 rootless 实现,可以沿相同问题采证,但不能将 Moby 的 dockerd-rootless.sh 辅助进程和参数强加于所有工具。

如何复核异常与退出后的环境

实验示例只使用隔离 VM 的两个测试 daemon;rootless 由专用非特权帐号拥有,rootful 由 VM 管理者预先配置。先保证镜像制品内容一致并核对各自 image ID;在两个 context 使用唯一的容器名称和不同 loopback 高端口。正常轮查询 /identity 后保留 daemon UID、镜像 ID、容器内外 UID、uid_map/gid_map 与端口;失败轮只改变请求的资源配额或状态目录权限,不将两种错误混在一轮。清理时对每个 daemon 只删除本轮完整 ID,核查两套 docker ps -a、两个宿主端口和实验目录,不卸载宿主默认网络、不清理别人的容器和 daemon 数据。

如果 rootless daemon 根本没启动,记录安装条件、rootlesskit/映射工具是否存在与用户服务原始日志,停止这轮资源实验;这不影响继续研究 user namespace 文档和脚本源码。反过来,服务启动并且 /ready=200,仍需完成文件写入和 cpu.stat 对照,不能把“容器跑起来了”升级为“存储、网络、资源都已验收”。这就是 20 与 19 不同的中心问题:19 找启动链路最早失配处,20 在每个已取得的身份与对象上重新问“谁有权做什么”。两篇共用探针、镜像和失败记录格式,差别是这篇明确区分 daemon 和应用两个独立的权限面。

还要防止一个方向相反的推断:容器里看到 USER 65532,并不能仅凭这个数字知道宿主进程将显示 UID 65532。如果启用了 user namespace,映射表可能把内侧 65532 对应到完全不同的外侧数字,或者该数字不在映射范围内。判断文件访问之前先读容器进程的 uid_map,按表把相应 ID 转换,再结合文件系统权限;不要套用“UID 在容器内外总是一样”的省略模型。实验失败时如果应用没能创建 /tmp/containers-state,还应检查目标目录是否受 bind mount 覆盖,而不是只怪 Dockerfile 中那个非 root 用户。

rootful daemon 带 userns-remap 是重要的混淆变量。它也会为容器配置 UID/GID 映射,却没有把 daemon 自身变成非特权宿主进程。如果只看应用的 /proc/self/uid_map,两种模式可能都显示映射,仍无法判断 daemon 身份。应同时记录 daemon 的宿主 Uid、运行 socket、所选 daemon docker info 的安全选项与该容器的进程映射;把“rootful+userns-remap”单列为第三个实验分支,而不是从前两组推导其具体实现结果。这也说明为什么切换 docker context 的核验必须在访问实例前完成。

另一个边界是 rootless 服务管理的数据目录与普通应用 bind mount。rootless daemon 需要在实验用户可写的路径中保存镜像与元数据;它可以管理自己的数据目录,不意味着某个容器里的任意 UID 都能写同一目录,也不意味着宿主给实验用户只读的挂载会因内侧 UID 0 而变成可写。反向对照在 VM 新建一个仅属测试用户的目录,把它只读绑定到一个探针,提交固定小于 4 KiB 的 /state 请求;若请求失败,把应用 HTTP 状态、stderr、挂载方式和宿主目录权限放在同一张记录中。此处不需要选择真实系统目录做“攻击演示”,自建对象就能验证权限边界。

从安全角度不能用宿主某个进程的 CapEff 单独代表所有容器进程的能力。能力是按其 user namespace 解释的;在子 user namespace 中某些内核操作被允许,不意味着拥有初始 user namespace 的对应能力,更不意味着可绕过 seccomp、LSM 与文件系统 DAC。08 的探针在可信进程里演示单一系统调用被 seccomp 返回 EPERM,不是对 rootless daemon 的整体安全认证。要判断某操作在哪层被拒绝,应控制其他配置不变,读取错误来源与有效权限,并在相同 VM 的 rootful+非 root 应用下重试;即使两边表现不同,也应限定到本次版本和配置。

uid_map 的一行记录的是内侧起点、外侧起点和映射长度。推算时先确认要换算的 UID 落在这个区间,计算区间内偏移,再加到外侧起点;映射表之外的 UID 不能照搬“数字相等”。实际进程还可能处于嵌套 user namespace,因此检查的是该进程所属的那张映射表,不是在任何机器上随手读 /proc/self/uid_map。gid_map 同理,还涉及 setgroups 的允许状态与写映射顺序。本文不让测试者修改其他用户的映射配置;需要额外 subordinate 范围就由独占 VM 管理者分配给实验帐号。访问同一 bind 目录时,比较的对象是实际内核凭据与宿主路径权限,并非容器中 ls -l 显示的用户名文本。

本篇还应保存两套失败的原始返回:一种是 rootless 服务前提不具备,另一种是服务已运行但请求的资源限制无法落实。两种失败即使都带有“permission”字样,补救对象也不同。前者检查 socket、用户运行目录和辅助工具;后者检查 cgroup 层级及委派。只有把 docker info、实际 daemon PID 与配置请求的时间放在同一轮记录里,才能证明“资源参数确实交给了预定的 rootless daemon”。若本机缺任一前提就标注 NOT_RUN、保留可补跑命令,不把 Linux unshare 的成功返回改称 Docker 实验通过。

对照结束后除了删自建容器,还要核对实验用户服务是否仍保持原状态。若是管理员预先启动的 rootful daemon,不能因实验结束而停止它;若 rootless daemon 是专门为本轮创建的用户服务,按 VM 的服务管理流程停止并确认监听 socket、实验目录和本轮辅助进程的状态。清理结果需要实际输出支撑,不能从一条 docker rm 的零退出码推断 host 端口或进程已回收。下一次实验的端口占用和对象名称应从新环境重新检测,而不是沿用本次记录里的假定值。

现象 身份/对象 前提
应用 UID 65532 Dockerfile USER 与运行时用户 daemon 未必 rootless
daemon 宿主非 root daemon PID、uid_map 不推出 CPU 委派可用
端口绑定失败 端口、转发实现、权限 不只看应用 id

练习

  1. 先预测把 USER 65532 放入同一个 Dockerfile 后,宿主 daemon 身份会不会变化;在 VM 逐项读 daemon 与应用 UID/map。
  2. 预测 rootless 模式下设置 cpu.max 一定生效吗;先确认父组控制器和委派,再对照所选实现的错误、运行时配置与实际计数。

上一篇:19:run 到 exec;下一篇:21:停止与日志。

系列总目录:从进程隔离到运行时与编排。

参考资料