先从结论说起:struct user_namespace 在整个 Linux 内核的 namespace 体系里是一个非常特殊的存在。网络命名空间隔离的是 IP 地址和端口,挂载命名空间隔离的是文件系统视图,PID 命名空间隔离的是进程号——它们都只管某一类"资源"。而用户命名空间隔离的是权限身份本身,也就是"你是谁、你能干什么"。这个"身份"一旦被隔离,其他所有命名空间里关于权限的判定都要重新基于它来计算。这也是为什么容器场景里 rootless 方案绕不开 user namespace,也是为什么非特权用户能凭空捏出一个"自己是 root"的隔离环境却又伤害不到宿主机。
这篇文章不打算从头科普 namespace 是什么,我会直接以 struct user_namespace 这个结构体为线索,把用户命名空间的内核设计、Uid/Gid 映射机制、能力模型、生命周期管理,以及实际工程里容易踩的坑一条条拆开讲。适合已经写过容器、调过 unshare、或者正在被 rootless 容器权限问题折磨的开发者,也适合想真正理解内核 namespace 底层逻辑的运维和内核爱好者。
1. 先从 namespace 的"身份"问题说起
我们平时看到的 lsns 输出里,有 mnt、pid、net、ipc、uts、cgroup、time 这些 namespace,排在最后或者单独一个类别的是 user。从使用频率上看,user namespace 是大多数开发者接触最少的,但它在内核里承担的职责却是最基础的。
1.1 为什么"身份"也需要隔离
普通进程在系统里的一切行为,几乎都绕不开 uid/gid 和 capabilities 的检查。你打开一个文件,内核要查 inode 的属主和你的 fsuid 是否匹配;你 mount 一个文件系统,内核要检查你是否有 CAP_SYS_ADMIN;你发一个信号给别的进程,内核要比较双方的真实 uid。这些检查依赖的"你是谁"这个事实,在传统 Linux 里是全局唯一的——uid 0 就是 root,uid 1000 就是普通用户。
问题来了:如果我想在自己的目录里建立一套完整可用的 Linux 环境,里面有 systemd、有 sshd、甚至想跑 docker,但我又不是 root,怎么办?传统的做法是给这个用户 sudo 权限,这等于把整个系统交给他。另一种思路是:能不能让这个用户在某个隔离的视图里"看起来是 root",但出了这个视图,他仍然只是一个普通用户?这就是 user namespace 的出发点。
struct user_namespace 就是用来记录"这个隔离视图里 UID/GID 如何映射到外层"以及"这个视图的边界在哪里"的核心数据结构。每一个进程在创建时,它的 struct cred 里都会有一个 user_ns 指针,指向它当前所在的用户命名空间。权限检查时,内核拿到一个 uid,先通过这个指针找到对应的 user namespace,再经过映射翻译成外层的真实 uid,才能决定"他到底有没有权限做这件事"。
1.2 和普通结构体完全不同的设计出发点
如果把 struct net 或者 struct nsproxy 拿出来看,会发现那些结构体里装的是"资源清单":有哪些网卡、有哪些挂载点、进程树长什么样。但 struct user_namespace 的设计出发点不是资源清单,而是映射关系和边界关系。
它要回答三个问题:
- 这个命名空间里的 uid 0,映射到父命名空间里的哪个 uid?
- 这个命名空间的拥有者是谁?谁能在这里拥有完整权限?
- 这个命名空间的父级是谁?权限判定要追溯到哪一层?
这三个问题的答案,直接决定了 user namespace 的所有行为。理解了这一点,再看结构体里的字段就不会觉得奇怪了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解剖 struct user_namespace:内核是怎么设计这个结构体的
下面这段代码取自内核的 include/linux/user_namespace.h(以最近几个 6.x 稳定版为准),我先完整贴出来,再逐字段分析:
c复制struct user_namespace {
struct uid_gid_map uid_map;
struct uid_gid_map gid_map;
struct uid_gid_map projid_map;
atomic_t count;
struct user_namespace *parent;
struct uid_gid_map parent_uid_map;
struct uid_gid_map parent_gid_map;
struct uid_gid_map parent_projid_map;
kuid_t owner;
kgid_t group;
struct ns_common ns;
unsigned long flags;
struct ucounts *ucounts;
int ucount_max[UCOUNT_COUNTS];
} __randomize_layout;
注意最后这个 __randomize_layout 宏。内核开启了 RANDSTRUCT 加固时,编译期会随机打乱结构体字段顺序,所以绝对不要依赖字段的内存布局写外部代码,也不要在调试时按固定偏移去访问字段。这一点后面讲调试工具时还会再提。
2.1 三个映射表:uid_map、gid_map、projid_map
这是结构体里最核心的部分。分别对应 UID 映射、GID 映射、文件项目 ID(project ID)映射。每个字段的类型都是 struct uid_gid_map,这个结构体本身也不大:
c复制struct uid_gid_map {
unsigned int nr_extents;
union {
struct uid_gid_extent extent[UID_GID_MAP_MAX_EXTENTS];
struct {
struct uid_gid_extent *forward;
struct uid_gid_extent *reverse;
};
};
};
struct uid_gid_extent {
u32 first;
u32 lower_first;
u32 count;
};
extent 表示一段连续的映射区间:把"本命名空间里从 first 开始的 count 个 ID"映射到"父命名空间里从 lower_first 开始的 count 个 ID"。因为要求连续,所以一条映射记录只需要三个整数就能完整表达。UID_GID_MAP_MAX_EXTENTS 等于 5,意思是最多只能写五段不连续的映射区间。之后写映射文件时,内核会检查 nr_extents 是否超过 5。
还有一个值得注意的细节:nr_extents 小于等于 5 时,映射数据直接存在结构体里,不需要额外分配内存;超过 5 时,union 切换到 forward 和 reverse 两个指针,指向动态分配的数组。老版本内核直接规定了最多 5 段,为什么后来要支持动态扩展?因为某些场景需要大量不连续的映射段,比如把容器里 65536 个 uid 交错映射到宿主机一组子 uid 上。不过目前默认的 new_idmap_permitted 校验仍然把一次性写入限制在 5 段以内。
2.2 parent 指针与三个 parent 映射表
parent 指向创建当前命名空间的那个父用户命名空间。parent_uid_map、parent_gid_map、parent_projid_map 三个字段就更有意思了——它们不是"父命名空间自己的映射表",而是"当前命名空间看到的、父命名空间里 ID 映射到更上层的映射关系"。
你可以这样理解:当进程从父命名空间进入子命名空间时,它身上的 uid 要先由子命名空间的 uid_map 翻译成父命名空间里的 uid,再由父命名空间的 parent 关系翻译到更外层,以此类推,最终追溯到初始用户命名空间。parent_uid_map 缓存的就是"当前命名空间语境下,父命名空间的映射入口",避免每次做权限判定时都要沿着 parent 链走好几层去查映射。
这里有个隐藏的设计意图:方便快速回溯。比如一个深度嵌套的容器环境里,内层进程的 uid 要换算成宿主机真实 uid,内核需要沿着 parent 链逐层翻译。把 parent 的映射关系缓存下来,本质上是一个空间换时间的优化。
2.3 owner 和 group:命名空间归属权
每个用户命名空间都有归属者。owner 字段保存的是该命名空间在父命名空间语境下的所有者 uid,group 是所有者主组 gid。这个归属关系有什么用?最直接的作用是判断"谁能操作这个命名空间"。
比如你要进入别人的 user namespace,内核会检查你是否是该命名空间的 owner;你要给一个 user namespace 写 uid_map,也要满足特定的 owner 关系。再比如,非特权用户创建嵌套用户命名空间时,子命名空间的 owner 会被设置为当前用户在父命名空间中的映射 uid。
一个比较特殊的细节:初始用户命名空间(init_user_ns)的 owner 是全局 root uid 0,parent 为 NULL。它没有父级,是所有用户命名空间追溯的终点。内核代码里大量地方会检查 user_ns == &init_user_ns,因为这个命名空间拥有整个内核最高的权限语境,一旦权限判定落到它身上,通常就意味着"不再有外层限制了"。
2.4 count 引用计数与 ns_common
count 是原子引用计数,记录有多少个对象引用了这个 user namespace。哪些对象会引用它呢?进程的 struct cred、挂载命名空间的 mnt_idmap、文件系统挂载点、tty 结构体、IPC 对象,都会引用对应的 user namespace。每次引用时 get_user_ns 递增计数,释放时 put_user_ns 递减,归零才真正释放内存。
ns_common 是所有 namespace 的通用头,里面包含 inum(namespace 的 inode 编号)和 ops 指针。inum 就是你在 /proc/self/ns/user 里看到的那个数字的底层来源。lsns 输出里的 NS 列,显示的就是这个 inode 编号。进程访问 /proc/PID/ns/user 时,内核返回的就是该 namespace 对应的 inode。
2.5 flags 与 ucounts:后加的两个字段
flags 用来标记命名空间的一些状态位,比如 USERNS_SETGROUPS 表示该命名空间是否允许调用 setgroups 系统调用,这个标记在权限收紧的场景里很关键。
ucounts 和 ucount_max 是后来引入的资源控制机制。ucounts 指向当前命名空间的"用户资源计数"结构体,ucount_max 数组记录这个命名空间里各种资源的最大数量限制,包括可创建的子用户命名空间数量、PID 命名空间数量、挂载命名空间数量、网络命名空间数量等。这个机制的引入是因为早期版本中,一个用户可以在自己的 user namespace 里无限创建嵌套 namespace,导致内核对象耗尽。现在每个用户能创建的 namespace 总数是受限的,超过限制会返回 EAGAIN。
在内核代码中,资源计数单位是 struct ucounts,它内部维护了一个从"用户 ID + 用户命名空间"到资源使用量的映射关系。当 unshare 一个新的 user namespace 时,内核会检查当前用户在这条链上的命名空间数量是否已经达到 ucount_max 中的上限。这是一个非常典型的资源耗尽攻击防护设计。
3. 创建与继承:一个新用户命名空间如何诞生
这一节我想完整走一遍"从 unshare(CLONE_NEWUSER) 到新 user namespace 可用的整个过程",这对理解后续的映射操作和权限检查很有帮助。
3.1 创建入口:create_user_ns 的流程
内核里创建用户命名空间的主入口是 create_user_ns,它由 copy_user_ns 调用,而 copy_user_ns 则是在 copy_creds 里被触发的。当进程调用 clone 或 unshare 时,如果 flags 里带了 CLONE_NEWUSER,内核会走 create_new_namespaces → copy_namespaces → copy_creds → copy_user_ns 这条链路。
create_user_ns 的核心步骤可以概括为:
- 检查当前进程是否有权限创建新的 user namespace。非特权用户默认允许,但受到
ucount_max资源限制,以及内核配置kernel.unprivileged_userns_clone的限制。部分发行版出于安全考虑会把这个参数设成 0,禁止非特权用户创建 user namespace。 - 分配新的
struct user_namespace,并设置parent = current_user_ns()。 - 计算 owner:把当前进程的 fsuid 映射到父命名空间语境下,作为新命名空间的 owner。
- 把父命名空间的 uid_map/gid_map/projid_map 复制到新命名空间的
parent_uid_map、parent_gid_map、parent_projid_map。 - 初始化新命名空间的 uid_map/gid_map/projid_map 为空(因为映射还没写入)。
- 初始化 ucounts 资源计数。
注意第 4 步非常关键:新命名空间刚创建时,它的 uid_map 字段是空的。也就是说,在写入映射之前,新命名空间里的所有 uid/gid 都还没有映射到父命名空间,处于"悬空"状态。这也是为什么进程刚进入一个新的 user namespace 时,id 命令会显示 uid=65534(nobody)——因为 65534 是内核保留的"未映射"标志值。
3.2 写入映射:为什么必须是"先映射自己"
新命名空间创建后,进程要写入 /proc/self/uid_map 和 /proc/self/gid_map 才能让 UID/GID 生效。写入规则有一个非常容易误解的点:写入者必须有权限。
你可能会想:我自己创建了命名空间,难道没有权限写吗?答案是:你只是"在新命名空间里拥有完整 capabilities",但写入 /proc/self/uid_map 的权限检查是在父命名空间语境下做的——需要父命名空间里的 CAP_SETUID 能力,或者满足"映射的是你自己的 uid"这个特例规则。
实际上内核里有一个特例:非特权用户虽然没有父命名空间的 CAP_SETUID,但可以写入一条"把父命名空间的调用者 uid 映射到子命名空间的某个 uid"的映射记录。这就是 unshare -Ur 能工作的底层逻辑。如果写入的不是调用者自己的 uid,也不是 /etc/subuid 里分配给该用户的范围,就会收到 EPERM。
GID 映射还有一个额外的限制:在写入 gid_map 之前,不能调用 setgroups。如果想在子命名空间里使用 setgroups 系统调用,必须先在 gid_map 对应文件里写入 deny。unshare -r 工具会自动处理这个顺序,但你要是手动操作,顺序错了就会失败。
3.3 为什么嵌套深度限制是 32
内核在 kernel/user_namespace.c 里定义了 MAX_USER_NAMESPACES_DEPTH = 32。每次 create_user_ns 时都会从当前命名空间沿着 parent 链向上数,如果深度超过 32 就返回 EAGAIN。
为什么是 32 而不是 128 或者无限制?原因有两个。其一是防止疯狂嵌套导致的内核栈溢出——权限判定时经常要沿着 parent 链递归,每层都会消耗栈空间;其二是映射查找性能——每次 uid 翻译最坏情况要沿着链走到头,层数越多,延迟越不可控。32 层对绝大多数实际场景(容器套容器)已经完全够用,所以这个限制一直没有改动。
从我在云平台观察到的实际情况来看,常见的嵌套场景最多四到五层:宿主机 → 系统容器 → 用户容器 → 容器内再隔离一层。五层以内性能完全无感,到了 10 层以上才开始能感受到映射查找的延迟。
3.4 生命周期:引用计数与回收
用户命名空间的释放不是由创建者决定的,而是由引用计数决定的。我见过很多刚接触内核的同学误以为"进程退出,namespace 就销毁",其实不完全对——进程退出只会释放进程对该 namespace 的一个引用,但只要还有别的对象引用它,它就不会被销毁。
哪些算引用?只要有一个进程的 cred 指向它,或者有一个挂载点还挂着该命名空间的 idmap,或者 /proc/PID/ns/user 文件被打开,引用计数就大于 0。在宿主机上看到 lsns 里的 user namespace 数量比实际运行容器数多,往往是因为容器退出后,挂载点或文件句柄还没释放,导致 namespace 残留。这在实际运维中很常见,排查思路一般是 lsof /proc/*/ns/user 或者找挂载点。
4. UID/GID 映射机制:那套"五段线性映射"的数学逻辑
映射机制是整个 user namespace 最容易让初学者困惑的地方,也是在实际生产中出问题最多的点。我把这块单独拿出来讲透。
4.1 三段式映射的数学计算
假设一个用户命名空间里有一条映射记录:
code复制first = 0
lower_first = 100000
count = 65536
那么命名空间里的 uid 5,在父命名空间里对应的 uid 就是 lower_first + (5 - first) = 100005。反过来,父命名空间里的 uid 100010,在这个命名空间里对应的 uid 就是 first + (100010 - lower_first) = 10。
这个线性映射关系是内核里所有 ID 翻译的基础,核心函数是 map_id_up 和 map_id_down。map_id_up 把"当前命名空间的 ID"翻译成"父命名空间的 ID",map_id_down 反向操作。每次翻译都要遍历 uid_gid_map 里的所有 extent,找到包含目标 ID 的那一段,然后做一次加减运算。
内核里有个优化:如果映射段数超过 5,会构建 forward 和 reverse 两个数组,分别用于正向和反向查找,避免每次遍历所有段。不过大多数场景下五段线性查找的开销已经足够小,这个优化平时触发不到。
4.2 写入映射时的校验逻辑
向 /proc/self/uid_map 写入内容时,内核的 map_write 函数会做一系列检查,我挑几个关键的讲:
- 每个映射文件只能成功写入一次。如果已经写入过(
uid_map的nr_extents不为 0),再次写入直接返回EPERM。 - 写入的文本必须是三列数字,数字之间用空格分隔,第一列是
first,第二列是lower_first,第三列是count。count不能为 0,且三列之和不能溢出 32 位无符号整数。 - 段之间不能重叠,且各段的
first区间必须按顺序排列(实际上内核要求严格递增)。 - 新映射必须覆盖当前进程的 fsuid 对应的"本命名空间映射到父命名空间的关系"吗?不强制,但如果写入的映射没有包含当前进程在新命名空间里的 uid/gid,那么进程仍然是 nobody,但写入本身还是允许的,只要满足权限规则。
- 每条 extent 的
lower_first + count不能超过 2^32,防止溢出。
这些检查很多都是历史上踩过坑之后补的。比如"只能成功写入一次"这一条,就是为了防止竞态条件下映射被篡改。以前有人利用重复写入映射做权限提升尝试,所以现在内核直接把这条路堵死了。
4.3 /etc/subuid 与子 ID 范围:大范围映射背后的秘密
非特权用户创建一个 user namespace 后,默认只能映射自己这一个 uid(0 1000 1)。但容器运行时要映射大量 ID(比如容器里 65536 个 uid 都要映射到宿主机),总不能把某个普通用户的整个 uid 范围都让出来,那样权限边界就乱了。
于是就有了 /etc/subuid 和 /etc/subgid 这两个文件。它们给每个用户分配一段或多段"子 ID 范围",专门用于 user namespace 映射。典型内容:
code复制zhang: 100000 65536
li: 200000 65536
第一列是用户名,第二列是起始 ID,第三列是范围长度。当非特权用户创建工作在 user namespace 里的容器时,映射工具(比如 unshare、newuidmap、containers 运行时)会读取这个文件,把分配到的子 ID 范围作为 lower_first 写入映射。
写入大范围映射时有个权限细节:普通进程自己写 /proc/self/uid_map 时,内核只允许映射自己的 ID,要映射 /etc/subuid 里的大范围,必须调用 newuidmap 这个 setuid 辅助程序。它以 root 身份执行,读 /etc/subuid,验证当前用户确实有对应范围,然后再写入映射文件,这是 rootless 容器工具链的标准做法。
我在给容器运行时排障时发现,很多人一遇到 uid 映射失败,第一反应是"权限不够"或者"内核限制",但十有八九是 /etc/subuid 配置的起始 ID 和实际写入的 lower_first 对不上。容器里进程启动后显示 owner 是 nobody,多数时候是映射文件配置的错误,而不是内核 bug。
4.4 映射影响文件所有权,而不只是进程权限
这个问题在容器场景非常典型:宿主机上看到的容器文件属主往往是 100000、100001 这样的大数字,而不是 0。因为容器进程以 uid 0 创建文件时,inode 上记录的实际 uid 是映射到父命名空间后的值,也就是 lower_first + 0。当容器进程删除、修改文件时,内核会反向翻译,从而匹配上属主。
但是,如果有一个进程在宿主机直接操作这个文件,它看到的是 100000 这个 uid,需要人为意识到这个数字对应容器里的 root。如果宿主机的安全审计工具(比如查文件属主分类、定期扫描异常 uid)不够智能,这些大数字 uid 会造成大量误报。我见过有团队的安全策略直接把 uid > 60000 的文件全部标记为可疑,结果误伤了大量正常容器数据。解决方式通常是让安全扫描工具解析容器的 uid_map,把容器里的 uid 翻译回来再判断。
5. Capabilities、权限边界与安全边界:为什么"自己是 root"并不是真的 root
很多人刚接触 user namespace 的时候都会很兴奋:我是不是可以随便在容器里搞破坏了?答案当然是否定的。这套机制的精髓在于"边界内的自由"和"边界外的隔离"。
5.1 capability 在不同 user namespace 之间的传递规则
Linux 的 capability 是绑定在用户命名空间上的。一个进程有 CAP_SYS_ADMIN,必须同时说明是在哪个 user namespace 里拥有这个能力。内核检查能力时,实际上是从当前进程的 user_ns 向上查找,如果在某个 ancestor namespace 里找到了对应的 capability,就算通过。
举个例子:容器内的进程在新 user namespace 里拥有 CAP_SYS_ADMIN,它可以 mount 一个文件系统(在容器内),但它在宿主机上并没有 CAP_SYS_ADMIN,所以不能挂载宿主机看到的根文件系统,也不能操作宿主机的网络设备。能力的作用范围被限制在"该 capability 所属的 user namespace 及其子孙命名空间"内。
反过来的情况也要注意:如果进程在父命名空间里有 CAP_SYS_ADMIN,那么它进入子命名空间后,同样拥有子命名空间里的 CAP_SYS_ADMIN。权限是向下继承的,不会因为进入更深的命名空间而丢失能力。
5.2 一个经典的安全模型图景
可以把 user namespace 的安全模型理解成"一栋楼里的楼层门禁":你拥有第 3 层的门禁卡,可以在第 3 层自由出入,但这个卡打不开第 28 层的门。内核检查权限时,从 28 层往下找,找不到对应的门禁卡记录,就拒绝访问。
这个模型的强大之处在于,原本 Linux 的权限模型是"全局单点"的——root 就是 root,到处都是 root。user namespace 把"root"这个词的意义局部化了。容器里的 root 对宿主机而言,很可能只是一个 uid 为 100000 的普通用户。这就解决了无根容器(rootless container)最核心的问题:你不需要宿主机 root,也能在隔离环境里拥有完整的管理体验。
5.3 安全边界:攻击面与防御
另一方面,user namespace 也扩大了攻击面。非特权用户创建 user namespace 等于获得了部分宿主机管理能力,比如在命名空间内挂载文件系统、创建网络设备、加载某些内核接口等。这些原本只有 root 才能做的操作,现在普通用户在自己的隔离命名空间里也能做,如果内核某个接口在命名空间隔离上做得不彻底,就可能被利用。
因此一些严格的安全环境会直接设置 kernel.unprivileged_userns_clone=0 或者通过 AppArmor / seccomp 限制非特权 user namespace 的创建。这个决策没有绝对的对错,取决于你对运行在内核里的应用信任度。比如运行 untrusted 第三方应用的环境,禁止非特权 user namespace 通常更稳妥;而跑合法容器工作负载的云主机,则需要给容器运行时留出开启 user namespace 的空间。
我个人的建议是:如果只是自己折腾,保持默认开启没问题;如果是在多租户生产环境,建议先审计你的业务容器到底需不需要 user namespace,不需要就在系统层面统一收紧,需要就针对性地给可信进程放行。不做审计就一刀切关闭,可能导致容器运行时大面积异常,这个我也踩过坑。
5.4 rootless 容器里的实际应用
rootless 一词现在已经被大量使用,它其实跟 user namespace 绑定得非常紧。以 Podman 的 rootless 模式为例:安装时会为运行用户配置 /etc/subuid 和 /etc/subgid,创建容器进程时调用 unshare(CLONE_NEWUSER),把 UID 0 映射到该用户的一个子 uid,再把容器内的用户映射到后续的子 uid。因为容器进程在宿主机上只是一个普通用户,所以它不能打开宿主机的低端口(除非 sysctl net.ipv4.ip_unprivileged_port_start=0)、不能直接管理宿主机网络设备,这反而带来了一层天然的安全隔离。
在很多企业内部,rootless 容器被视为一种默认更安全的工作负载运行方式。我刚接触时也不太理解为什么容器运行时要有"rootless"和"rootful"之分,直到自己排查了一次容器内 mount 失败的问题:rootless 容器里的 mount 走的是用户命名空间内的挂载逻辑,和宿主机挂载完全隔离,很多内核文件系统特性是不支持的,不是"加上 --privileged 就能解决"的。
6. 动手观察:从用户态看 user namespace 的内部状态
前面几节讲的都是内核视角,这一节带你从用户态实际操作一把,把 struct user_namespace 的各个维度映射到你能看到的文件和行为上。
6.1 最直接的入口:/proc/self/ns/user 与 /proc/self/uid_map
先看一个最简单的实验:
bash复制$ id -u
1000
$ unshare -Ur
# id
uid=0(root) gid=0(root) groups=0(root)
# cat /proc/self/uid_map
0 1000 1
# cat /proc/self/gid_map
0 1000 1
# ls -l /proc/self/ns/user
lrwxrwxrwx 1 root root 0 Mar 1 10:30 /proc/self/ns/user -> 'user:[4026533352]'
# exit
uid_map 里的三列就是 first=0、lower_first=1000、count=1,直接把当前用户的 1000 映射成了命名空间里的 root。这里有个非常反直觉的点:如果你没有配置 /etc/subuid,unshare -Ur 映射的只是当前用户自己,不会给你映射出一大串 uid。所以容器里只有一个 root 用户,其他所有用户都映射不到外层,看起来都是 nobody。
user:[4026533352] 里的数字就是 namespace 的 inode 号,对应结构体里 ns_common.inum 字段。同一个 user namespace 的进程,这个数字必然相同。
6.2 对比父子命名空间的视图差异
再来一个稍微进阶的验证。先记录当前 shell 的 user namespace,再开一个子命名空间:
bash复制$ readlink /proc/self/ns/user
user:[4026531837]
$ unshare -Ur sh -c 'echo child: $(readlink /proc/self/ns/user); echo parent of child: $(readlink /proc/$$/ns/user)'
可以看到父命名空间的 inode 是 4026531837(这个数值在几乎所有 Linux 系统上都是初始 user namespace 的 inode),子命名空间是不同的号码。如果你在子命名空间里执行 cat /proc/self/status | grep CapEff,会发现 CapEff 是一串满值,因为你在新命名空间里拥有全部 capabilities。
再强调一次:这些 capabilities 都作用在子 user namespace 及其内部,出了边界就失效。
6.3 用 nsenter 进入已有容器查看映射
当环境里有运行中的容器时,可以用 nsenter -t <PID> -U 进入容器的 user namespace(如果容器启用了 user namespace)。进入后同样 cat /proc/self/uid_map,观察容器里 uid 0 映射到宿主机哪个范围。
这个操作我建议在测试环境做一次。因为真实容器的映射往往不是一行,而是多个 extent。比如:
code复制0 100000 65536
或者更复杂的多段结构。理解这些输出能帮你快速定位"容器里文件 owner 显示异常"的问题。
6.4 bpftrace / 内核调试视角补充
如果你想直接观察内核结构体行为,可以使用 bpftrace 跟踪 map_id_up 或者 create_user_ns 这些函数。不过要注意,现代内核开了 RANDSTRUCT 之后,结构体字段布局是随机的,直接按偏移读字段不可行。推荐的调试路径是用 ftrace 或者 bpftrace 的 kprobe 抓函数参数和返回值,而不是去解析结构体内存。
一个我在实践里常用的验证思路:写一个超短 C 程序,调用 unshare(CLONE_NEWUSER),然后手动写映射,打印 UID/GID 变化:
c复制#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
static void write_file(const char *path, const char *data) {
int fd = open(path, O_WRONLY);
if (fd < 0) { perror(path); exit(1); }
if (write(fd, data, strlen(data)) < 0) { perror("write"); exit(1); }
close(fd);
}
int main(void) {
if (unshare(CLONE_NEWUSER) < 0) { perror("unshare"); exit(1); }
write_file("/proc/self/uid_map", "0 1000 1\n");
write_file("/proc/self/gid_map", "0 1000 1\n");
printf("uid=%d euid=%d\n", getuid(), geteuid());
return 0;
}
编译运行后,进程里打印出来的 uid/euid 都是 0。这就是用户命名空间让你"变成 root"的最直观证据。去掉两个 write_file 再运行一次,你会看到 uid=euid=65534,也就是 nobody,这正好印证了前面说的"未映射就是 nobody"。
7. 实际项目中的踩坑记录与工程建议
最后分享几个我在真实项目里反复踩过的坑,每一个都对应到 struct user_namespace 的某个具体设计点上,理解了原理,你就知道该怎么避开。
7.1 坑一:gid_map 写不进去,"setgroups: Operation not permitted"
这个报错当年让我查了很久。现象是:手动在 user namespace 里执行 cat /proc/self/gid_map 能看到已有内容,但用工具去写新的 gid_map 总是失败,日志里带 setgroups: Operation not permitted。
原因就在于 user namespace 的 setgroups 限制:在 gid_map 尚未写入且新命名空间的 flag 没有标记允许 setgroups 之前,内核禁止调用 setgroups。很多容器运行时初始化时会先尝试调 setgroups,如果没处理顺序,就会触发这个错误。解决办法是:先写 deny 到 /proc/self/setgroups,再写 gid_map;或者让运行时有权限直接覆盖 setgroups 状态后再映射。内核里 USERNS_SETGROUPS 这个 flag 就是干这个的,你可以通过 cat /proc/self/setgroups 查看当前状态。
7.2 坑二:容器退出后 user namespace 不消失
我在生产环境排查过一次 namespace 泄漏问题:一晚上跑了上百个短生命周期容器,但 lsns -t user 显示的 user namespace 数量只增不减,最后触发了系统的 namespace 数量上限。
用 lsof 查 /proc/*/ns/user 之后发现,大部分残留的 user namespace 是被已退出的容器挂载点引用着。容器虽然停了,但宿主机上还残留着挂载引用没有清理。解决办法是检查所有相关挂载点并卸载,同时审视容器运行时的清理钩子。说到底,就是 struct user_namespace 里的引用计数 count 没有归零,所以对象没被释放。这类问题用 lsof 能定位,但定位之后要下决心修运行时清理逻辑,而不是靠重启宿主机硬扛。
7.3 坑三:挂载 procfs/sysfs 时的权限判定差异
在 user namespace 里 mount -t proc proc /proc 是常见的初始化操作,但很多发行版的内核配置(比如 hidepid 选项、/proc 的挂载权限)会导致 mount 后部分目录不可读。这是因为 proc 文件系统的权限判定,除了看进程的 capability 之外,还会参考挂载点的 uid/gid 映射关系和 net namespace 等上下文。
我遇到的具体表现是:容器内 ps aux 能跑,但某些进程的 /proc/PID/status 读不出来,报 Permission denied。排查下来发现是容器把 CAP_SYS_PTRACE 没给全,而 proc 里的某些文件需要 CAP_SYS_PTRACE 权限。遇到这类问题,不要只盯着 user namespace 映射,要同时检查容器的 capability 配置和挂载选项。
7.4 坑四:嵌套映射导致的 uid 混乱
我协助排查过一个诡异问题:宿主机某目录下出现了一堆 uid 为 1200000 左右的文件,安全团队看到大 uid 就报警,结果发现是三层嵌套容器环境。最外层容器把 0 500000 65536 映射到宿主机,第二层容器又在这个基础上做了 0 100000 65536 的子映射,到了第三层,实际文件属主被翻译成了 500000 + 100000 = 600000 开头的数字,看起来特别像异常账号。
这类问题的排查方法非常明确:沿着进程的 user namespace 链逐层检查 uid_map,把每层的 lower_first 做加法,算出最终落在宿主机上的 uid,再和文件属主比对。理解了 parent 指针和逐层翻译机制,这个加法其实是一道送分题。
7.5 几条经验法则
- 测试用户命名空间相关功能时,先确认宿主机内核版本和
kernel.unprivileged_userns_clone参数。不同内核版本的校验行为有差异,尤其涉及setgroups和子 ID 范围时。 - 写映射的顺序不要搞反:先写
uid_map,再写gid_map,setgroups在写gid_map前设置好。 - 不要在生产环境盲目关闭非特权 user namespace。很多现代容器运行时(尤其是 rootless 模式)默认依赖它,关闭后会出现各种奇怪的权限错误。要做灰度验证后再决策。
- 排查容器文件属主异常时,第一件事是读容器的 uid_map/gid_map,而不是在宿主机上猜。映射关系透明后,大部分问题都能快速定位。
8. 写在最后
用户命名空间这套机制,用一句话概括就是:内核用"映射"和"边界"重新定义了权限的局部性。struct user_namespace 作为这套机制的载体,每个字段背后都对应着一个具体的设计决策:映射表解决"内外身份如何转换",parent 链解决"边界如何回溯",owner 解决"归属权归谁",ucounts 解决"资源如何防滥用"。
我在实际工程里最大的体会是:很多容器和安全问题,根因不在容器运行时,也不在安全策略,而是在内核这层看似不起眼的结构体上。搞清楚 struct user_namespace 的语义,等于掌握了一把能同时解容器权限、文件属主、安全加固三类问题的钥匙。技术上看起来复杂,但只要沿着"映射"和"边界"这两条主线去理解,一切都会变得非常清晰。
