为什么需要 user namespace:容器里的 root 之惑
容器共享内核,也默认共享用户身份空间:容器里的 UID 0 就是宿主机的 UID 0。日常这是便利——bind mount 的权限直通、镜像里 chown 过的文件语义一致、root 直接读写任何属主的文件;但安全账很难看:容器内以 root 跑的进程一旦利用内核漏洞或挂载配置逃逸,落到宿主机就是如假包换的 root;镜像里的 setuid 程序获得的也是真实 root。第 6 篇讲过 capability 把「全权的 root」拆成离散权限,但它改变的是「能做什么」,不改变「我是谁」——一个持有 CAP_DAC_OVERRIDE 的 uid 0,在宿主机文件系统面前依然是 uid 0。user namespace(内核 3.8 起支持非特权创建)改变的是后者:把容器内的 UID/GID 号段与宿主机的号段隔开,中间垫一张映射表。容器内 uid=0 时,「root 的一切 namespace 内语义」都保留——能挂载自己 namespace 里的文件系统、能管理自己 namespace 内的进程、能改自己文件系统的属主——但这些操作落到宿主机文件系统与进程表上,执行者是 uid=100000 的普通用户。即便进程利用内核漏洞逃出了 mount/pid 等其他 namespace,它在宿主机上的权限上限也只是一个普通用户。这是 rootless 容器(podman rootless、Docker rootless 模式、BuildKit rootless)的理论基座,也是 Kubernetes userns 支持的底层机制。
flowchart LR
subgraph CT["容器内视角(user namespace)"]
P1["进程 uid=0(root)<br/>可挂载 ns 内文件系统<br/>可管理 ns 内进程"]
M1["uid_map<br/>容器 0..65535 → 宿主 100000..165535"]
end
subgraph HOST["宿主机真实世界"]
P2["同一进程实际 uid=100000<br/>普通用户,无任何特权"]
F1["它写出的文件属主 = 165536 区间的普通用户"]
end
P1 == "内核逐 UID 翻译" ==> P2
M1 -. "映射规则" .- F1
原理:uid_map / gid_map 的映射语义
映射表挂在每个进程上,从 /proc/PID/uid_map 与 /proc/PID/gid_map 读取,每行三个字段:容器内起始 ID、宿主机起始 ID、映射长度。比如 0 100000 65536 读作「容器内的 0~65535 对应宿主机的 100000~165535」。规则有四条:每个映射文件最多 5 行(可以拼出不连续号段);映射只能写入一次,写入后不可修改;写入 gid_map 前通常要先向 /proc/PID/setgroups 写 deny(防止 setgroups 越过 GID 映射);非特权用户创建 user namespace 还受 user.max_user_namespaces 配额约束(发行版历史上另有过 Ubuntu 的 kernel.unprivileged_userns_clone 开关,24.04 起改用 AppArmor 对非特权 userns 收紧)。没有映射覆盖的 ID 在另一侧显示为溢出值 65534(nobody)——这正是「宿主机上看到属主 165536 或 65534」这类现象的来源。user namespace 还可以嵌套(默认上限 32 层):userns 里再 unshare 出的子 userns 相对父层做映射,rootless 之上再跑 rootless 的沙箱嵌套由此可行,每层映射由内核逐级翻译,溢出值规则在每一层同样成立。映射的意义不只是一层改名:内核在权限计算时使用的是「翻译后的 ID」,所以容器内 uid 0 对宿主机文件系统的所有访问,内核都以 uid 100000 的身份去做权限位与 capability 判定——「伪 root」的安全性正来自这里:它不是藏起来的 root,是真的不是 root。
动手:unshare 创建与逐项验证
普通用户就能做完整的实验,不需要任何宿主机特权:
$ sysctl user.max_user_namespaces
user.max_user_namespaces = 28633
$ id -u
1000
$ unshare --user --map-root-user --mount --pid --fork --mount-proc /bin/bash
# id
uid=0(root) gid=0(root) 组=0(root)
# cat /proc/self/uid_map
0 0 1
--map-root-user 把「当前用户」一对一映射成新 namespace 里的 root,所以映射表只有一行:容器 0 → 宿主 1000。验证「伪 root」的边界,最直观的是 mount:新 namespace 里挂 tmpfs 完全放行(这在自己 namespace 内是合法 root 操作),但对宿主机路径则处处碰壁。从宿主机的另一个终端看同一进程,身份感立现:
$ ps -o pid,uid,cmd -p 21345
PID UID CMD
21345 1000 unshare --user --map-root-user --mount --pid --fork --mount-proc /bin/bash
$ cat /proc/21345/uid_map
0 1000 1
生产级容器要的是整段号段映射,用 newuidmap/newgidmap(setuid 助手,读 /etc/subuid 与 /etc/subgid 的授权号段):
$ grep alishang /etc/subuid
alishang:100000:65536
$ unshare --user -- /bin/bash &
[1] 21780
$ newuidmap 21780 0 100000 65536
$ newgidmap 21780 0 100000 65536
$ cat /proc/21780/uid_map
0 100000 65536
容器运行时(rootless 模式)做的正是这件事:为每个容器分配独立号段、建立双向映射,让容器内整套「root 文件系统」在宿主机上是一堆无特权文件。
联动:capability 的 namespace 化与 rootless 网络的由来
user namespace 单独开没有太大意义,它的价值在「解锁其他 namespace 的非特权使用」。第一条线索是 capability 的作用域规则:capability 已经按 user namespace 划界——进程在自己的 userns 里持有全部 capability(--map-root-user 之后 id 显示 uid=0 且能力齐全),但这些能力只对「本 namespace 拥有的对象」有效:对映射号段内的文件可以随便 chown(内核翻译成宿主机上 165536 号段的 chown,普通用户对自己的文件本来就有这个权),对宿主机文件则一个比特都不多给。mount、pid、net 这几个 namespace 的创建原本需要 CAP_SYS_ADMIN 或 CAP_NET_ADMIN,而这些 capability 在新 userns 里人人都有——于是普通用户可以合法地组装出「私有文件系统视图 + 私有进程空间 + 私有网络栈」的完整沙箱三件套:
# 非 root 用户的完整容器沙箱:userns + mount + net + pid
$ unshare --user --map-root-user --mount --net --pid --fork --mount-proc /bin/bash
# ip link set lo up && ip addr show lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN
# mount -t tmpfs none /mnt && echo 挂载成功
挂载成功
rootless 容器网络(slirp4netns、pasta)的由来也在这条规则里:新网络 namespace 里的进程先凭 userns 里的 CAP_NET_ADMIN 配置自己的接口与路由,出网流量再经用户态代理转发——全程无需宿主机特权。文件系统侧的配套件是 idmapped mounts(内核 5.12+):把一个已有文件系统挂载进 userns 时声明映射规则,同一份磁盘数据在两个 userns 里呈现不同的属主号段,rootless 运行时与 Kubernetes 的卷属主映射都用它实现。理解了这套联动就能看懂 rootless 容器的全部魔法没有一行是「提权」——全是「先给自己一个小王国,再在小王国里当国王」。
Docker 的 userns-remap:配置、收益与代价
Docker 守护进程级开启:daemon.json 写 "userns-remap": "default"(自动创建 dockremap 用户与号段)后重启,容器内 root 从此映射到宿主机高位号段,镜像与容器数据的宿主机属主全部变成 165536 一类的数值:
$ docker run --rm alpine sh -c 'id; head -1 /proc/self/uid_map'
uid=0(root) gid=0(root) groups=0(root)
0 165536 1
$ ls -ln /var/lib/docker/165536.165536/overlay2 | head -2
drwx------ 5 165536 165536 4096 Nov 5 12:00 03a1c2...
收益是实打实的:容器逃逸者拿到的是 dockremap 的权限,宿主机大部分文件系统对它只读甚至不可见。但代价清单必须在开启前过一遍。其一,存储空间整体搬家:启用后镜像与容器数据迁到 /var/lib/docker/165536.165536/,旧目录里的镜像全部「消失」(文件还在,按需迁移),要预留一次全量重新拉取。其二,bind mount 权限失配是最高频的运维摩擦:命名卷 Docker 会自动 chown 到映射号段,bind mount 则原样保留宿主属主——容器内 root(宿主 165536)写宿主 uid 1000 的目录直接 EACCES,要么 chown -R 165536:165536,要么为 remap 配专用账号并规划目录属主。其三,host namespace 共享全部禁用:--pid=host、--network host、--ipc=host 的容器会拒绝启动,「特权侧车」模式与 userns 二选一。其四,与 SELinux 等标签方案的叠加需要额外规划,在 RHEL 系主机上多数部署是二选一。性能上,主要开销在容器启动期的 chown,运行期 IO 路径几乎无感——别高估代价,更别低估权限规划的工作量。症状速查三连:启用 remap 后「容器起不来报 EACCES」,九成是 bind mount 属主没跟着迁(docker inspect 看 Mounts、ls -ln 看属主是否 165536 系);「改了 remap 配置后镜像全没了」不是数据丢失,是存储树搬家(新旧号段目录各自独立);「compose 的卷挂载失败」同理,为项目统一建好映射属主的数据目录即可。反向操作也留一手:从 daemon.json 移除 userns-remap 并重启后存储树再搬回默认位置,迁移完成前两棵树并存、磁盘双倍占用,别急着删旧目录。
Kubernetes 的 user namespace 支持
Kubernetes 走的是 Pod 级开关:1.25 以 alpha 特性门控起步(当时仅支持无卷的 Pod),1.33 起 GA。用法是把 pod.spec.hostUsers 设为 false——kubelet 为该 Pod 分配一段独占的高位 UID 区间(Pod 之间互不重叠),容器内 root 落到节点上只是这段区间里的普通用户:
apiVersion: v1
kind: Pod
metadata:
name: userns-demo
spec:
hostUsers: false # 启用 user namespace:容器内 root ≠ 节点 root
containers:
- name: app
image: alpine
command: ["sh", "-c", "id; cat /proc/self/uid_map; sleep 3600"]
前置条件有三件:节点内核要支持 idmapped mounts(5.12 起,卷属主映射的完整体验在 6.x 更顺),容器运行时要达标(containerd 1.7+ 或同等版本),存储侧的卷插件要支持属主映射。另外注意 Pod 之间、Pod 与节点用户之间的号段分配由 kubelet 统一管理,重启节点后号段会重新分配——把容器内属主落在宿主机磁盘上的数据当作「可漂移资产」来设计,别对具体 UID 数值做硬编码依赖。安全收益可以量化:100 个 Pod 启用 userns 后,任意一个 Pod 的内核漏洞逃逸到宿主机文件系统时,手里只有一个 65536 段位普通用户的权限,既碰不到宿主机系统文件,也碰不到其他 Pod 的文件——横向影响面从「整个节点」缩到「一段无人使用的 UID」。生产启用的检查顺序:先在测试节点验证存储插件与内核版本,再按宿主机视角重新规划卷权限,最后灰度放量。
局限与最佳实践:为什么你的应用会受这个影响
userns 的局限大多不是缺陷而是设计意图,但症状会真实砸到应用头上。共享卷的权限账:容器内 root 写出的文件在宿主机属主是 165536 一类高位 UID,宿主机运维脚本、备份工具、编辑器看到的就是一串数字或 nobody(65534 溢出值)——启用 userns 必须同步修订宿主机侧的运维脚本与权限基线。setuid 失效是特性:容器内 setuid 程序拿到的只是「容器内 root」,无法借它触碰宿主机真实 root;依赖 setuid 语义的旧镜像需要改造,这也是 userns 天然瓦解一类逃逸手法的原因。特权能力的组合要逐项验证:user namespace 收窄的是身份语义,CAP_SYS_MODULE/CAP_SYS_RAWIO 这类能力与 userns 的叠加行为(能力在哪个 namespace 有效)有独立规则,监控 agent、存储客户端这类「贴内核」的组件上线前要在真实环境回归。最佳实践按优先级排:能 rootless 就 rootless(podman rootless / Docker rootless 从第一天就长在 userns 上,比给 rootful daemon 补挂 userns-remap 干净);rootful 环境用 userns-remap 时把权限规划写进部署文档;K8s 按 hostUsers: false 加灰度推进。纵深防御的完整拼图是:hostUsers: false + readOnlyRootFilesystem + capabilities 全 drop + seccomp RuntimeDefault + LSM 画像,每层独立生效——排错时按「报错 → 身份(id / uid_map)→ 能力(capsh)→ 策略(DENIED 日志)」的顺序定位,身份这一层现在有了自己的名字。
小结
user namespace 让容器从「共享内核的进程」变成「拥有独立身份的隔离单元」——它是容器安全的基石——但也是容器性能与兼容性的代价——「安全的层层加固」:user namespace + seccomp + capabilities drop + read-only rootfs——每一层都在缩小攻击面——「纵深防御」是容器安全的终极答案。
读者留言
COMMENTS 暂无还没有留言,来说第一句?