5.2 namespace 深入
Linux 到底有几种 namespace?clone、unshare、setns 三个入口分别在什么时候用?/proc/PID/ns 里那些 net:[4026531992] 是文件还是句柄?PID 1 为什么杀不死,user namespace 的 uid 映射又怎么算?
用一套 C 程序和几条命令,把八种 namespace 逐个造出来并验证隔离;解释 PID 1 的信号语义与孤儿回收;写出正确的 uid_map;用 lsns/nsenter 进入目标命名空间排障。
上一章把 namespace 当成「容器三件套」里的第一件,只说了它决定「看得到什么」。这一章把这句话拆开:每一种 namespace 隔离的是一类全局资源,创建它就是把某个进程从「共享这份全局表」改成「持有自己的一份」。内核里的实现是 nsproxy 指向一组 *_namespace 对象,task_struct 通过它找到自己该用哪一份。
理解 namespace 的关键不是记住八种名字,而是记住三个动作:创建(clone/unshare)、加入(setns)、观察(/proc/PID/ns 与 lsns)。容器运行时的绝大多数「隔离」工作,都可以还原成这三类系统调用加一些挂载操作。
这一章会写一个 clone 的完整 C 程序,再逐种验证隔离效果。所有命令都在 Ubuntu 24.04(util-linux 2.39、Linux 6.8)上验证;需要 root 的地方会标注,并给出清理命令。
5.2.1 八种 namespace 与「七种」的由来
先给全表。man 7 namespaces(man-pages 6.19)列出八种:
| namespace | clone 标志 | 隔离的资源 | 引入版本 | /proc/PID/ns 条目 |
|---|---|---|---|---|
| Mount | CLONE_NEWNS | 挂载点、挂载传播 | 2.4.19 | mnt |
| UTS | CLONE_NEWUTS | 主机名、NIS 域名 | 2.6.19 | uts |
| IPC | CLONE_NEWIPC | System V IPC、POSIX 消息队列 | 2.6.19 | ipc |
| PID | CLONE_NEWPID | PID 编号空间、/proc 进程视图 | 2.6.24 | pid、pid_for_children |
| Network | CLONE_NEWNET | 网卡、路由、端口、netfilter、sysctl | 2.6.24 | net |
| User | CLONE_NEWUSER | uid/gid 映射、capabilities 作用域 | 3.8 | user |
| Cgroup | CLONE_NEWCGROUP | cgroup 根目录视图 | 4.6 | cgroup |
| Time | CLONE_NEWTIME | CLOCK_MONOTONIC/BOOTTIME 偏移 | 5.6 | time、time_for_children |
常说的「七种」是 5.6 之前的说法(mount/UTS/IPC/PID/net/user/cgroup)。time namespace 是第八种,主要服务于 CRIU 这类检查点恢复工具:恢复后的进程需要看到「和崩溃前一致」的单调时钟,而宿主时钟早已前进。本章把它单列在 5.2.8,主表仍按八种讲。
┌─────────────────────────────┐
│ task_struct │
│ nsproxy ──────────┐ │
└─────────────────────┼───────┘
│
┌──────────────┬──────────────┬───────┴──────┬──────────────┐
▼ ▼ ▼ ▼ ▼
mnt_namespace pid_namespace net_namespace uts_namespace user_namespace
挂载树/传播 PID 编号空间 网卡/路由/端口 主机名/域名 uid/gid 映射
│ │ │ │ │
└──────────────┴──────┬───────┴──────────────┴──────────────┘
▼
ipc_namespace cgroup_namespace time_namespace
SysV IPC/mqueue cgroup 根视图 时钟偏移
读这张图要抓住「一个指针、多个对象」:nsproxy 本身可以被多个进程共享(fork 不创建新 namespace 时就是共享),所以「同一个 namespace」在 /proc/PID/ns 里表现为相同的 inode 号。判断两个进程是否在同一 namespace,不要比较路径字符串,要比较 stat 的 st_dev/st_ino。
命名空间是内核对某类全局资源的一份具名实例。进程通过 nsproxy 持有若干 namespace 的引用;创建新 namespace 就是新建一份实例并让进程(或其子进程)改指向它。namespace 之间默认互不可见,但存在父子关系(PID、user、time),父 namespace 可以看到子 namespace 里的进程,反之不行。
- 「Linux 有六种 namespace」——这是 3.8 之前的教材。4.6 加了 cgroup,5.6 加了 time,
man 7 namespaces现在是八种。 - 「user namespace 也是容器隔离的一部分,所以一共七种」——user namespace 特殊:它不隔离某类「资源」,而是隔离身份与权限的作用域,并且是唯一允许非特权用户创建的。把它和 mnt/net 并列会掩盖这个差异。
- 「PID namespace 隔离后宿主看不到容器进程」——错。父 namespace 始终能看到子 namespace 的全部进程,只是 PID 编号不同。容器进程在宿主上有一个宿主 PID,
ps能看到它。
5.2.2 clone:一次创建进程与命名空间
clone(2) 是「创建进程」的通用接口,fork() 与 pthread 的 clone 都是它的特例。它的 flags 参数里,CLONE_NEW* 表示「为子进程创建新 namespace」,其余位控制内存、文件描述符、信号等共享方式。
下面这个程序创建带新 UTS/PID/mount namespace 的子进程,并在子进程里把 /proc 重新挂载、改主机名、打印两套 PID 视图。
/* nsclone.c — 用 clone(2) 创建 UTS/PID/mount 命名空间;需要 root */
#define _GNU_SOURCE
#include <errno.h>
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/wait.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static void show_ns(const char *path)
{
char buf[128];
ssize_t n = readlink(path, buf, sizeof buf - 1);
if (n < 0) {
fprintf(stderr, "readlink %s: %s\n", path, strerror(errno));
return;
}
buf[n] = '\0';
printf(" %-22s %s\n", path, buf);
}
static int child_main(void *arg)
{
(void)arg;
/* 子进程的 mount namespace 是父进程的副本;先切断传播再挂载 */
if (mount("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) == -1) {
perror("mount private");
return 1;
}
/* 新 PID namespace 需要一份新的 procfs 才能看到新编号 */
if (mount("proc", "/proc", "proc", 0, NULL) == -1) {
perror("mount proc");
return 1;
}
if (sethostname("inside", 6) == -1) {
perror("sethostname");
return 1;
}
printf("child : pid=%d ppid=%d\n", (int)getpid(), (int)getppid());
show_ns("/proc/self/ns/pid");
show_ns("/proc/self/ns/uts");
show_ns("/proc/self/ns/mnt");
sleep(2);
return 0;
}
int main(void)
{
printf("parent: pid=%d\n", (int)getpid());
show_ns("/proc/self/ns/pid");
show_ns("/proc/self/ns/uts");
show_ns("/proc/self/ns/mnt");
char *stack = malloc(STACK_SIZE);
if (stack == NULL) {
perror("malloc");
return EXIT_FAILURE;
}
int flags = CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD;
pid_t pid = clone(child_main, stack + STACK_SIZE, flags, NULL);
if (pid == -1) {
perror("clone");
free(stack);
return EXIT_FAILURE;
}
printf("parent: clone 返回子进程的宿主 PID %d\n", (int)pid);
if (waitpid(pid, NULL, 0) == -1)
perror("waitpid");
free(stack);
return EXIT_SUCCESS;
}
编译与运行:
# 需要 root:CLONE_NEWNS/CLONE_NEWUTS 需要 CAP_SYS_ADMIN
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g nsclone.c -o nsclone
sudo ./nsclone
预期输出(inode 号每次不同):
parent: pid=4211
/proc/self/ns/pid pid:[4026531836]
/proc/self/ns/uts uts:[4026531838]
/proc/self/ns/mnt mnt:[4026531840]
parent: clone 返回子进程的宿主 PID 4212
child : pid=1 ppid=0
/proc/self/ns/pid pid:[4026532411]
/proc/self/ns/uts uts:[4026532412]
/proc/self/ns/mnt mnt:[4026532413]
三个细节值得停一下:
- 子进程的
getpid()是 1,getppid()是 0。子进程是新 PID namespace 的 1 号;它的父进程在祖先 namespace 里,对子进程不可见,所以getppid()返回 0。 - 父进程看到的
clone返回值是宿主 PID(4212),不是 1。clone的返回值按调用者所在 namespace 解释——同一个进程在不同 namespace 里有不同编号。 - 三个 inode 都变了。
pid:[...]里的数字是 namespace 对象的 inode 号,不是 PID。两个进程的同一类 namespace 若 inode 相同,才说明它们在同一个 namespace。
clone 的 flags 里还有几个与容器相关的位:
| 标志 | 作用 | 注意 |
|---|---|---|
CLONE_NEWNS | 新 mount namespace | 需要 CAP_SYS_ADMIN;默认继承挂载树与传播属性 |
CLONE_NEWPID | 子进程成为新 PID namespace 的 1 号 | 只影响子进程;unshare 版本只影响后代 |
CLONE_NEWUSER | 新 user namespace | 非特权可创建;创建后在该 ns 内拥有全部 capabilities |
CLONE_NEWCGROUP | 新 cgroup namespace | 只改 /proc/self/cgroup 的根视图,不改变实际 cgroup |
CLONE_INTO_CGROUP | 直接把子进程放进指定 cgroup | Linux 5.7+,配合 clone3() 的 cgroup 字段 |
CLONE_PARENT | 子进程的父进程设为调用者的父进程 | 容器 init 常用,避免多一层中间进程 |
现代代码应优先用 clone3(2):参数放进 struct clone_args,可扩展,且支持 set_tid(指定 PID)、CLONE_INTO_CGROUP。clone() 的 flags 位已经快用完了。
fork() 由内核复制当前栈;clone() 允许子进程从任意栈顶开始执行,所以调用者必须提供一块栈空间。这也是 pthread 能高效创建线程的原因:每个线程的栈由 libc 的线程库分配,clone 只负责切换。上面的程序用 malloc 分配 1 MiB,把 stack + STACK_SIZE 作为栈顶传入(栈向低地址增长)。忘传栈或传错方向,子进程第一次压栈就会踩坏内存。
5.2.3 unshare 与 setns:不创建进程也能换视图
clone 的语义是「新进程 + 新 namespace」。另外两个入口解决另外两种需求:
| 接口 | 创建新 namespace | 谁进入新 namespace | 需要 fd | 典型场景 |
|---|---|---|---|---|
clone | 是 | 新创建的子进程 | 否 | 容器 init、线程 |
unshare | 是 | 调用者本身(PID/time 例外:影响后代) | 否 | 调试、给当前进程加隔离 |
setns | 否 | 调用者本身(PID 例外) | 是,指向 /proc/PID/ns/* | 进入已有容器、排障 |
unshare(1) 是最直观的验证工具:
# UTS:不需要 fork,unshare 后直接 exec
sudo unshare --uts sh -c 'hostname inside; hostname'
# inside
# PID:必须 --fork,否则当前 shell 不在新 ns 里
sudo unshare --pid --fork --mount-proc ps -ef
# PID 1 就是 ps 自己
# net:新 netns 只有 lo,而且 lo 默认是 down
sudo unshare --net --fork ip -brief addr
# lo DOWN 127.0.0.1/8
# 组合:一个没有特权的「伪容器」(Ubuntu 24.04 需先放开 AppArmor 限制)
unshare --user --map-root-user --mount --pid --fork --mount-proc sh
setns 则要一个指向目标 namespace 的 fd。最常用的载体是 nsenter(1):
# 需要 root;先拿到容器进程的宿主 PID
pid=$(docker inspect -f '{{.State.Pid}}' mycontainer)
sudo nsenter -t "$pid" -n ip -brief addr # 只进 net namespace
sudo nsenter -t "$pid" -m -p -f --mount-proc ps -ef # 进 mount + PID
setns 对 PID namespace 有一个与 unshare 相同的例外:调用者自己的 PID namespace 不变,变的是它以后创建的子进程。所以 nsenter --pid 必须配 --fork,让新起的命令成为目标 namespace 里的进程;否则你会以为「进去了」,其实 ps 还是旧视图。
nsenter 做的事就是:打开 /proc/PID/ns/* 得到一组 fd,逐个 setns,可选 chdir/chroot,最后 exec 目标命令。它没有任何内核特权,能成功是因为调用者(root)在目标 namespace 的所属 user namespace 里有 CAP_SYS_ADMIN。理解这一点,就能自己用 C 写一个最小 nsenter。
5.2.4 /proc/PID/ns:把命名空间变成 fd
/proc/PID/ns/ 下的每个条目都是一个魔法符号链接。readlink 看到的是 类型:[inode],但 open 它得到的是一个指向 namespace 对象的 fd,可以直接传给 setns。
# 看当前 shell 的全部 namespace
ls -l /proc/self/ns
# 只取类型与 inode
readlink /proc/self/ns/net
# net:[4026531992]
# 比较两个进程是否在同一 net namespace
stat -Lc '%d:%i' /proc/$$/ns/net /proc/1/ns/net
三个常被忽略的事实:
- inode 号是 namespace 的身份。同一 namespace 的所有成员,
/proc/PID/ns/*的st_dev/st_ino相同。容器运行时判断「这个进程是否在目标 netns」,用的就是这个比较,而不是看 cgroup 路径。 - fd 能延长 namespace 的寿命。namespace 的默认生命周期是「最后一个进程退出」,但只要还有一个 fd 或 bind mount 指向它,它就不会被销毁。
ip netns add正是利用这一点:在/run/netns/<name>上 bind mount/proc/self/ns/net,于是没有进程的 netns 也能一直存在。 pid_for_children与time_for_children是「下一个孩子用哪个」。unshare --pid之后,调用者的pid链接不变,但pid_for_children指向新 namespace;下一个fork的子进程才会进去。
# 需要 root:创建持久 netns,观察 bind mount 保活
sudo ip netns add n1
ls -l /run/netns/n1
# -rw-r--r-- 1 root root 0 ... /run/netns/n1
sudo ip netns exec n1 ip link set lo up
sudo ip netns pids n1 # 没有进程,但 netns 仍存在
sudo ip netns del n1
/proc/PID/ns 还支持三个 ioctl(见 ioctl_nsfs(2)),lsns 就是靠它们工作的:
| ioctl | 作用 |
|---|---|
NS_GET_USERNS | 取得拥有该 namespace 的 user namespace 的 fd |
NS_GET_PARENT | 取得父 namespace 的 fd(PID/user 有层级) |
NS_GET_NSTYPE | 返回 namespace 类型(CLONE_NEW* 值) |
- 「
readlink看到路径,就能cd进去」——不能。它是魔法链接,open得到 fd,cd会失败或进入nsfs。 - 「inode 号就是 PID」——不是。
pid:[4026531836]里的数字是 namespace 对象的 inode,与进程号无关;所有进程的初始 PID namespace 都是同一个 inode。 - 「进程退出,namespace 就没了」——只要有 fd、bind mount、子 namespace、或引用它的
proc/mqueue挂载,namespace 就继续存在。 - 「
/proc/PID/ns/user的 inode 能直接拿来写 uid_map」——不能。写映射要打开/proc/PID/uid_map,而且必须在目标进程的 user namespace 里、或由特权进程操作。
5.2.5 PID namespace:1 号进程的特权与陷阱
PID namespace 是容器最直观的隔离:容器里的 ps 只看到自己的进程树。它的实现是一棵层级树:每个 PID namespace 有一个父,PID 在每个 namespace 里独立编号;同一个进程在不同层里可以有不同 PID。
宿主 PID namespace(init 所在)
PID 1000 bash
PID 1001 unshare --pid --fork --mount-proc sh
│
└── 子 PID namespace
PID 1 sh ← 该 namespace 的 init
PID 2 sleep 100 ← sh 的子进程
父 namespace 能看到子 namespace 里的进程(PID 1001 的 sh 在父里是 1001,在子里是 1),子 namespace 看不到父里的进程。这种单向可见性决定了容器监控的做法:在宿主上 ps 能看到容器进程,只是它们的宿主 PID 与容器内 PID 不同。
PID 1 有三条特殊规则:
- 没有处理器的信号会被忽略,包括同一个 namespace 内发来的
SIGKILL/SIGSTOP。只有祖先 namespace 发来的SIGKILL/SIGSTOP能杀死它。 - 孤儿进程会重新挂到它名下(
PR_SET_CHILD_SUBREAPER可以让别的进程承担这个角色)。所以 PID 1 必须wait,否则僵尸会堆积。 - PID 1 退出时,内核向该 namespace 内所有进程发
SIGKILL,整个 namespace 一起消亡。
# 需要 root:验证 PID 1 杀不死
sudo unshare --pid --fork --mount-proc sh -c '
echo "my pid=$$"
kill -9 1 && echo "kill -9 1 返回成功"
sleep 1
echo "PID 1 还活着"
'
输出会是「返回成功、还活着」。kill 返回成功只代表信号被投递,不代表被处理——内核在 sig_task_ignored() 里把 init 的致命信号丢掉了。
第二个实验验证「init 退出,全组被杀」:
# 需要 root;后台 sleep 会被内核一起杀掉
sudo unshare --pid --fork --mount-proc sh -c 'sleep 100 & exit'
sleep 1
ps -ef | grep 'sleep 100' | grep -v grep || echo "sleep 已被清理"
对某个 PID namespace 的 init(该 namespace 内 PID 为 1 的进程):
- 若它没有为某信号安装处理器,则该信号被忽略,即使该信号是
SIGKILL或SIGSTOP; - 例外:信号来自祖先 PID namespace 时,
SIGKILL/SIGSTOP仍然生效,因为祖先内核路径不走「同 namespace init」的判断; - init 退出时,内核向该 namespace 内所有进程发送
SIGKILL并销毁 namespace。
这解释了为什么 docker stop 对不处理 SIGTERM 的容器只能等超时后 SIGKILL,也解释了为什么容器里的 1 号进程必须自己回收僵尸。
--mount-proc 不是可选项。PID namespace 只改编号,不改 /proc 的挂载;如果沿用宿主的 /proc,ps 读到的仍是宿主进程列表。unshare --mount-proc 会在新 mount namespace 里挂一份新的 procfs,从而让 /proc 的视图与新 PID namespace 匹配。
5.2.6 user namespace 与 uid 映射
user namespace 与其他七种都不同:它隔离的是身份。创建之后,进程在这个 namespace 内拥有全部 capabilities,但这些 capabilities 只对「由这个 user namespace 拥有的资源」有效。容器里的 uid=0 可以通过映射,对应到宿主上的一个普通 uid。
映射写在 /proc/PID/uid_map 与 /proc/PID/gid_map,每行三个数:
<namespace 内的 ID> <宿主上的 ID> <长度>
例如:
# 不需要 root(Ubuntu 24.04 默认可能被 AppArmor 拦住,见下)
unshare --user --map-root-user 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
这表示「ns 内的 0 → 宿主 1000,长度 1」。进程在 ns 内是 root,在宿主上仍是 uid 1000。它不能 chown 宿主上属于别人的文件,也不能挂载宿主块设备——capabilities 的作用域被限制在这个 user namespace 及其子 namespace。
要映射多个 ID(例如让容器内 0–65535 都可用),需要 /etc/subuid、/etc/subgid 与 setuid 的 newuidmap/newgidmap:
grep "$USER" /etc/subuid /etc/subgid
# /etc/subuid:alice:100000:65536
# /etc/subgid:alice:100000:65536
newuidmap PID 0 100000 65536 会把「ns 内 0 起、宿主 100000 起、共 65536 个」写进映射。普通用户不能直接写多行映射,否则就能把宿主上任意 uid 的文件“认领”成自己的;newuidmap 会核对 /etc/subuid 的授权范围。
写 gid_map 还有一个额外门槛:非特权进程必须先往 /proc/PID/setgroups 写 deny,否则内核拒绝写映射。这是为了防止「用 gid 映射绕过组权限」:
# 非特权用户在写 gid_map 前的标准动作
echo deny > /proc/self/setgroups
- 「user namespace 里的 root 什么都能干」——不能。它只能操作由该 user namespace 拥有的资源;挂载真实块设备、修改宿主网络、ptrace 宿主进程都被拒绝。
- 「uid_map 可以分多次写」——每个映射文件只能写一次,且写入后不可修改。要改只能销毁 namespace 重建。
- 「
unshare -r在 Ubuntu 24.04 上一定成功」——24.04 默认kernel.apparmor_restrict_unprivileged_userns=1,非特权unshare --user会被 AppArmor 拒绝(EPERM)。临时放开:sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0。 - 「user namespace 越多越安全」——它扩大了内核攻击面。历史上多个提权漏洞(如
CVE-2022-0185)与 user namespace 相关;Ubuntu 24.04 默认限制它,正是权衡后的选择。 - 「容器里创建的文件在宿主上也是 root 所有」——只有映射到宿主 0 时才如此。开了 userns-remap 后,文件在宿主上属于
100000这类 subordinate uid。
查看当前系统的限制:
sysctl kernel.apparmor_restrict_unprivileged_userns 2>/dev/null
# kernel.apparmor_restrict_unprivileged_userns = 1
cat /proc/sys/user/max_user_namespaces
# 15000 左右(不同发行版不同)
# 当前进程的 user namespace 与能力
readlink /proc/self/ns/user
grep '^Cap' /proc/self/status
/proc/sys/user/max_*_namespaces 是每个用户在每个 user namespace 里能创建的上限;触顶时 clone/unshare 返回 ENOSPC。这是防止 namespace 泄漏(创建大量空 namespace 耗尽内存)的兜底。
rootless Docker/Podman 以普通用户身份运行,却要在容器里提供「root」。做法是:先创建 user namespace 并映射 0→当前用户,容器内的 root 操作在这个 ns 内合法;网络用 slirp4netns/pasta 在用户态转发;文件系统用 fuse-overlayfs(因为内核 overlayfs 在非特权 userns 里不允许挂载)。代价是性能与兼容性:不是所有镜像都能跑,ping 需要额外配置,overlayfs 的 inode 语义也会变。理解这条链,才能判断「rootless 能不能上生产」。
5.2.7 mount namespace 与挂载传播
mount namespace 隔离的是挂载树:每个 namespace 有自己的 struct mnt_namespace,里面是一棵 vfsmount 树。clone(CLONE_NEWNS) 或 unshare --mount 创建时,新 namespace 是父 namespace 的副本——挂载点都在,但此后的 mount/umount 互不影响。
副本带来一个副作用:挂载传播(mount propagation)。每个挂载点有一个传播状态:
| 状态 | 含义 | 典型用途 |
|---|---|---|
shared | 挂载/卸载事件传播到同 peer group 的所有挂载 | systemd 默认把 / 设为 shared |
slave | 只接收来自 master 的传播,不向外发 | 容器接收宿主新挂载 |
private | 不传播 | 容器运行时通常设为 private |
unbindable | 不能被 bind mount | 特殊场景 |
查看与修改:
# 查看根挂载的传播属性
findmnt -o TARGET,PROPAGATION /
# 递归改成 private(容器进入 mount namespace 后的标准动作)
sudo mount --make-rprivate /
# 其余三种
sudo mount --make-rslave /
sudo mount --make-rshared /
sudo mount --make-runbindable /
/proc/self/mountinfo 的可选字段会直接标出传播关系:shared:1、master:2、propagate_from:1、unbindable。容器运行时(runc)在 config.json 里用 linux.rootfsPropagation 控制新 mount namespace 的传播状态,默认是 rprivate。
一个可以自己验证传播的实验(会短暂影响宿主,务必按清理步骤做):
# 需要 root;先记录宿主的传播状态
findmnt -o TARGET,PROPAGATION /
sudo mkdir -p /tmp/prop
# 在子 mount namespace 里挂一个 tmpfs;不设 private
sudo unshare --mount sh -c 'mount -t tmpfs tmpfs /tmp/prop; echo child mounted'
# 如果 / 是 shared,宿主上也能看到这个挂载
findmnt /tmp/prop && echo "发生了传播" || echo "没有传播(/ 可能已是 private)"
# 清理(无论是否传播都要执行)
sudo umount /tmp/prop 2>/dev/null || true
sudo rmdir /tmp/prop
如果输出「发生了传播」,说明子 namespace 的挂载沿 shared 关系回到了宿主。容器运行时之所以在启动时执行 mount --make-rprivate /,就是为了避免这种「容器里的挂载泄漏到宿主」。反过来,如果你希望宿主上新插入的磁盘自动出现在容器里,就要把容器的根挂载设为 rslave——这正是 Kubernetes 里某些存储插件的行为差异来源。
mount namespace 与 pivot_root 的配合在 5.1.3 已经演示过;这里补一个常见需求:把某个 namespace 保活。ip netns 用 bind mount 实现,容器运行时则常把 /proc/PID/ns/net bind 到 /var/run/netns/<name>,之后即使容器进程退出,netns 仍然存在,方便抓包与排障。
5.2.8 net、ipc、uts、cgroup、time
剩下五种各管一类资源,用法可以直接用命令行验证。
network namespace
net namespace 隔离网卡、路由表、ARP 表、netfilter 规则、socket、conntrack,以及大部分网络 sysctl(net.ipv4.ip_forward 是 per-netns 的,不是全局的)。
# 需要 root;新 netns 只有 lo,且 lo 默认 down
sudo ip netns add n1
sudo ip netns exec n1 ip link set lo up
sudo ip netns exec n1 ip -brief addr
# lo UNKNOWN 127.0.0.1/8
# 查看与清理
sudo ip netns list
sudo ip netns del n1
IPC namespace
隔离 System V 共享内存、信号量、消息队列,以及 POSIX 消息队列(/dev/mqueue 的挂载也随 IPC namespace 走)。
# 宿主上创建一个 SysV 共享内存段
ipcmk -M 4096
ipcs -m | tail -2
# 新 IPC namespace 里看不到它
sudo unshare --ipc ipcs -m
# 清理(记下 shmid)
ipcrm -m SHMID # 用 ipcs 输出第二列的 shmid 替换 SHMID
UTS namespace
只隔离 hostname 与 domainname 两个字符串。它最简单,也最容易被忽略:容器里 hostname 与宿主不同,靠的就是它。
sudo unshare --uts sh -c 'hostname web-1; hostname; domainname example.test; domainname'
cgroup namespace
cgroup namespace 把 /proc/self/cgroup 与 cgroup 文件系统的根改成创建者的 cgroup。它不改变进程实际所在的 cgroup,只改变「看到哪里是 /」。
# 需要 root;在容器里 /proc/self/cgroup 通常显示 0::/
sudo unshare --cgroup cat /proc/self/cgroup
time namespace
time namespace 为 CLOCK_MONOTONIC 与 CLOCK_BOOTTIME 提供偏移,写入 /proc/PID/timens_offsets。它主要给 CRIU 恢复用:恢复后的进程看到的时间要接上崩溃前的单调时钟。
# 需要 root;设置 boottime 偏移 86400 秒(1 天)
sudo unshare --time --boottime=86400 sh -c 'cat /proc/self/timens_offsets'
# monotonic 0 0
# boottime 86400 0
五种 namespace 的对照:
| namespace | 一句话验证 | 常见误区 |
|---|---|---|
| net | ip netns exec n1 ip addr | 以为 lo 默认是 up |
| ipc | unshare --ipc ipcs -m | 把 POSIX mqueue 当成文件系统隔离 |
| uts | unshare --uts hostname x | 以为改 hostname 会影响宿主 |
| cgroup | unshare --cgroup cat /proc/self/cgroup | 以为它限制了资源 |
| time | unshare --time cat /proc/self/timens_offsets | 以为它改 CLOCK_REALTIME(不改) |
5.2.9 lsns、nsenter 与排障顺序
lsns(8) 把 /proc/*/ns/* 聚合成一张表,是排查「这个进程在哪个 namespace」的第一条命令。
# 列出所有 net namespace,按进程数排序
sudo lsns -t net -o NS,TYPE,NPROCS,PID,USER,COMMAND --sort=NPROCS
# 看某个进程的全部 namespace
sudo lsns -p "$pid" -o NS,TYPE,PATH
# 只看容器进程
sudo lsns -t mnt -p "$(docker inspect -f '{{.State.Pid}}' mycontainer)"
进入目标 namespace 的推荐顺序:
- 先确认目标 PID:
docker inspect -f '{{.State.Pid}}'、crictl inspect -o go-template ...或pgrep。 - 只进需要的 namespace:排网络用
-n,看文件系统用-m,调试 PID 视图用-p -f --mount-proc。 - 需要完整环境时用
-a加-r -w:nsenter -t PID -a -r -w会进入全部 namespace,并把根目录与工作目录切到目标进程的。 - 退出后确认没有留下挂载:
findmnt | grep <mountpoint>。
# 需要 root;一个完整的「进入容器」命令
pid=$(docker inspect -f '{{.State.Pid}}' mycontainer)
sudo nsenter -t "$pid" -a -r -w
- 进程在不在? 宿主
ps -fp <pid>看状态;/proc/<pid>/status看NSpid字段(显示该进程在各层 PID namespace 的编号)。 - 在哪个 namespace?
lsns -p <pid>;比较/proc/<pid>/ns/net与目标进程的st_ino。 - 网络不通? 先
nsenter -t <pid> -n ip -brief addr,确认地址与路由;再ip netns exec对照。 - 文件系统不对?
nsenter -t <pid> -m findmnt -T /,看根挂载是不是 overlay。 - PID 1 不回收僵尸?
nsenter -t <pid> -p -f --mount-proc ps -ef,看是否有大量Z状态进程。 - 权限被拒? 检查调用者是否在目标 user namespace 里有
CAP_SYS_ADMIN;capsh --print与/proc/<pid>/uid_map一起看。
5.2.10 结构体与生命周期:namespace 什么时候真正消失
这一节回答两个排障时最实际的问题:为什么容器进程退出了,netns 还在?为什么 setns 拿到的 fd 在目标进程死后仍然可用?答案在引用计数与 ns_common 里。
五个与生命周期有关的结构体
| 结构体 | 定义位置 | 关键字段 | 生命周期含义 |
|---|---|---|---|
struct nsproxy | include/linux/nsproxy.h | count、mnt_ns、pid_ns_for_children、net_ns、time_ns、cgroup_ns | 进程持有;最后一个引用释放时销毁 |
struct ns_common | include/linux/ns_common.h | stashed、ops、inum、count | 所有 namespace 的公共头;count 是「对象引用」 |
struct pid_namespace | include/linux/pid_namespace.h | idr、level、parent、child_reaper、pid_allocated、ns | 有层级;child_reaper 退出会杀全组 |
struct user_namespace | include/linux/user_namespace.h | parent、level、owner、uid_map、gid_map、ns | 子 ns 持有父 ns 引用,父不能先死 |
struct pid | include/linux/pid.h | count、level、numbers[](struct upid) | 一个 pid 对象承载多层编号 |
struct pid 的 numbers[] 是理解 NSpid 的关键:每个 struct upid 里存着「在该层 namespace 里的编号」与指向该 namespace 的指针。一个进程在 N 层 PID namespace 里,就对应 N 个 upid:
/* include/linux/pid.h(Linux 6.8,节选) */
struct upid {
int nr; /* 在该 namespace 里的 PID */
struct pid_namespace *ns;
};
struct pid {
refcount_t count;
unsigned int level; /* 所在的最深 namespace 层级 */
spinlock_t lock;
struct hlist_head tasks[PIDTYPE_MAX];
struct hlist_head in_hash;
struct upid numbers[]; /* 变长数组,逐层一个 */
};
level 是「这个进程属于的最深一层」,numbers[i] 对应从 init namespace(i=0)到最深一层的编号。alloc_pid 从目标 namespace 往上逐层分配 nr,任何一层耗尽(pid_allocated 到上限)都会回滚并返回 -EAGAIN。
namespace 的三种「存活凭据」
namespace 对象的销毁条件是 ns_common.count 归零,而 count 由三类持有者增加:
| 持有者 | 增加方式 | 典型例子 |
|---|---|---|
进程的 nsproxy | get_nsproxy / 各 get_*_ns | 容器里每个进程都持有 netns 引用 |
| nsfs 的打开文件 | open("/proc/PID/ns/net") | nsenter、运行时的监控 fd |
| nsfs 的 bind mount | mount --bind /proc/PID/ns/net /run/netns/x | ip netns add 的持久化 |
这解释了两个现象:
- 容器退出后
ip netns list里还有条目——bind mount 还在,net对象的count不为零。删掉/run/netns/x或ip netns del才会真正释放。 nsenter进程退出后 fd 关闭,namespace 仍在——只要目标容器还有进程持有nsproxy,count就不会归零。
PID namespace 是例外:它不能被「单独保活」。PID ns 的 child_reaper(该 ns 的 1 号进程)退出时,zap_pid_ns_processes 会给 namespace 内所有进程发 SIGKILL,即使还有 fd 指向它,里面也不会再有活进程。fd 只能保住对象内存,保不住进程。
user namespace 是另一条例外:它是严格的树。子 user ns 持有父的引用,父的 count 因此在子存在时不会归零;反过来,父 ns 的 capabilities 可以覆盖子 ns,子 ns 的 capabilities 不能作用于父。这个方向性决定了「容器里的 root 为什么不能操作宿主资源」。
上限与分配提示
| 文件 | 含义 | 触顶行为 |
|---|---|---|
/proc/sys/user/max_*_namespaces | 每个 user ns 里某类 ns 的数量上限 | clone/unshare 返回 ENOSPC |
/proc/sys/kernel/pid_max | 全局 PID 上限(默认 32768 或 4194304) | fork 返回 EAGAIN |
/proc/sys/kernel/ns_last_pid | 某 PID ns 下一次分配的起点提示 | 写入可影响下一个 PID,不保证连续 |
max_*_namespaces 是 per-user-namespace 的配额,不是全局。一个普通用户在初始 user ns 里创建 ns 的数量受 max_net_namespaces 等限制;进入自己的 user ns 后,重新按该 ns 的 ucounts 计。这是防止「创建大量空 namespace 耗尽内存」的兜底,也是 Kubernetes 节点上 failed to create new namespace: ENOSPC 的常见原因。
ns_last_pid 容易被误解为「当前最大 PID」。它只是分配器的起点提示:内核从它开始找空闲 PID,绕回 0 再找。写入一个值只影响下一次分配的搜索起点,不能用来「预留」PID。
观测文件对照
| 对象 | 观测入口 | 能看到什么 |
|---|---|---|
| 进程的全部 ns | /proc/PID/ns/ | 八个魔法链接与 inode |
| 多层 PID 编号 | /proc/PID/status 的 NSpid | 从 procfs 所属 ns 到最深层的编号 |
| user ns 映射 | /proc/PID/uid_map、gid_map、setgroups | 映射区间与 setgroups 策略 |
| net ns 持久化 | /run/netns/*、ip netns list | bind mount 的 nsfs 文件 |
| mount ns | /proc/PID/mountinfo、findmnt | 挂载树与传播属性 |
| time ns | /proc/PID/timens_offsets | monotonic/boottime 偏移 |
| 配额 | /proc/sys/user/max_*_namespaces | 每 user ns 的上限 |
namespace 的身份是 (nsfs st_dev, st_ino) 这对值,由 ns_common.inum 决定;它的生命周期由 ns_common.count 的引用计数决定,持有者包括进程的 nsproxy、打开的 nsfs fd、nsfs 的 bind mount。身份与生命周期是两件事:同一个 namespace 可以有多个 fd,fd 全关也不一定销毁(还有进程持有);反过来,fd 还在也不代表里面还有进程(PID ns 的 init 退出后会被清空)。
5.2.11 内核路径:创建、进入与销毁
这一节把 clone/unshare/setns 三条入口在内核里的分叉画出来。三者的用户态语义不同,但最终都落到 nsproxy 与各 namespace 的 *_install 回调上。
创建:create_new_namespaces
copy_namespaces 在 flags 含 CLONE_NEW* 时调用 create_new_namespaces,后者按固定顺序复制七类 namespace:
/* kernel/nsproxy.c(Linux 6.8,节选,省略错误处理) */
static struct nsproxy *create_new_namespaces(unsigned long flags,
struct task_struct *tsk, struct user_namespace *user_ns,
struct fs_struct *new_fs)
{
struct nsproxy *new_nsp = create_nsproxy();
new_nsp->mnt_ns = copy_mnt_ns(flags, tsk->nsproxy->mnt_ns, user_ns, new_fs);
new_nsp->uts_ns = copy_utsname(flags, user_ns, tsk->nsproxy->uts_ns);
new_nsp->ipc_ns = copy_ipcs(flags, user_ns, tsk->nsproxy->ipc_ns);
new_nsp->pid_ns_for_children =
copy_pid_ns(flags, user_ns, tsk->nsproxy->pid_ns_for_children);
new_nsp->cgroup_ns = copy_cgroup_ns(flags, user_ns, tsk->nsproxy->cgroup_ns);
new_nsp->net_ns = copy_net_ns(flags, user_ns, tsk->nsproxy->net_ns);
new_nsp->time_ns = copy_time_ns(flags, user_ns, tsk->nsproxy->time_ns);
return new_nsp;
}
顺序不是随意的:copy_mnt_ns 需要 new_fs(新进程的 fs_struct)来确定根挂载;copy_net_ns 可能触发网络设备初始化,必须放在 user ns 解析之后;copy_time_ns 最后,因为它依赖已经确定的 user ns 来做权限检查。任何一步失败都会走 free_nsproxy 回滚已创建的对象。
copy_pid_ns 只改 pid_ns_for_children,不改当前进程的 PID 编号。新进程的 PID 在 alloc_pid 时按 pid_ns_for_children 分配,所以 fork 出来的子进程才是新 ns 的 1 号;调用者自己的 /proc/self/ns/pid 仍然是旧的。
进入:setns 的三段式
setns(fd, nstype)
→ prepare_nsset() 从 fd 取 ns_common,校验 nstype 与类型匹配
→ validate_nsset() 检查调用者是否有权限、是否单线程等
→ commit_nsset()
→ switch_task_namespaces(current, nsset->nsproxy)
→ 各 ns 的 install 回调(netns_install、mntns_install、pidns_install…)
prepare_nsset 只负责「fd → namespace 对象」,真正的权限检查在 validate_nsset 与各 *_install 里:
| namespace | 安装回调 | 权限/层级检查 | 失败 errno |
|---|---|---|---|
| net | netns_install | 目标 user ns 的 CAP_SYS_ADMIN + CAP_NET_ADMIN | EPERM |
| mnt | mntns_install | 目标 user ns 的 CAP_SYS_ADMIN;路径必须可达 | EPERM |
| pid | pidns_install | 目标 user ns 的 CAP_SYS_ADMIN;只能进入后代 ns(new->level >= active->level) | EPERM / EINVAL |
| uts | utsns_install | 目标 user ns 的 CAP_SYS_ADMIN | EPERM |
| ipc | ipcns_install | 目标 user ns 的 CAP_SYS_ADMIN | EPERM |
| cgroup | cgroupns_install | 目标 user ns 的 CAP_SYS_ADMIN | EPERM |
| time | timens_install | 目标 user ns 的 CAP_SYS_ADMIN;只能进入后代 | EPERM / EINVAL |
「PID ns 只能进入后代」是一条常被忽略的限制:你可以在宿主上 nsenter -p 进容器,但不能在容器里 setns 回宿主的 PID ns。原因是一旦允许「向上」进入,容器内进程就能看到宿主的全部 PID,隔离立刻失效。EINVAL 而不是 EPERM,是因为这不是权限不足,而是层级方向不合法。
销毁:谁最后放手
进程 exit
→ exit_task_namespaces()
→ switch_task_namespaces(tsk, NULL)
→ put_nsproxy()
→ 各 put_*_ns():count 减一
→ count == 0 → free_*_ns()
PID ns 的 init 退出
→ zap_pid_ns_processes()
→ 向 ns 内所有进程发 SIGKILL
→ 等待它们退出,再释放 pid_namespace
free_nsproxy 只是释放 nsproxy 结构本身;每个 namespace 的释放由各自的 put_*_ns 决定。put_mnt_ns 在最后一个引用释放时调用 free_mnt_ns,但挂载树里的每个 vfsmount 还要各自 mntput;put_net 在最后一个引用释放时清理路由表、netfilter、socket。这些清理是异步的(RCU + workqueue),所以「进程退出」与「内存回收」之间可能有一段延迟。
setns 改的是调用者的 nsproxy->pid_ns_for_children,不是调用者自己的 PID 编号——PID 编号在进程出生时就固定了,无法「换一个编号重贴」。所以 setns 到 PID ns 之后,只有之后创建的子进程会在新 ns 里编号。nsenter -p 因此必须 fork 一个子进程来执行目标命令;nsenter 的 --no-fork 选项会让你留在旧编号里,一般只用于调试。
5.2.12 nsfs ioctl:四个调用与 namespace 谱系
5.2.4 只列了三个 ioctl。实际上 include/uapi/linux/nsfs.h 定义了四个,而且每一个的返回值语义都不同:有的返回 fd,有的直接返回整数,有的把结果写进指针。lsns、nsenter、CRIU 都靠它们判断「这个 fd 是什么、属于谁、父是谁」。
| ioctl | 编码(6.8 uapi) | 返回值 | 失败 | 语义与边界 |
|---|---|---|---|---|
NS_GET_NSTYPE | _IO(0xb7, 0x3) | 直接返回 CLONE_NEW* 值(如 0x40000000) | 非 ns fd:ENOTTY | 不是 fd,不能 close;用来校验 fd 类型 |
NS_GET_USERNS | _IO(0xb7, 0x1) | 新 fd,指向拥有该 ns 的 user ns | EPERM(owner 不在调用者 ns 的祖先链上) | 对 user ns 自己调用返回它的父 userns |
NS_GET_PARENT | _IO(0xb7, 0x2) | 新 fd,指向父 ns | EINVAL(该类型无层级)、EPERM(父不在祖先链上) | 只有 pid、user 两类实现了 get_parent |
NS_GET_OWNER_UID | _IO(0xb7, 0x4) | 0,uid 写入 uid_t * 参数 | EINVAL(非 user ns)、EFAULT(坏指针) | uid 经 from_kuid_munged 映射,未映射得 65534 |
后两个调用的 EPERM 不是「文件权限不足」,而是谱系边界。内核的实现很直接:
/* kernel/pid_namespace.c(Linux 6.8,节选) */
static struct ns_common *pidns_get_parent(struct ns_common *ns)
{
struct pid_namespace *active = task_active_pid_ns(current);
struct pid_namespace *pid_ns, *p;
pid_ns = p = to_pid_ns(ns)->parent;
for (;;) {
if (!p)
return ERR_PTR(-EPERM);
if (p == active)
break;
p = p->parent;
}
return &get_pid_ns(pid_ns)->ns;
}
active 是调用者当前所在的 PID namespace。循环从目标 ns 的父开始,沿父链向上找,只有父链经过 active 才允许返回。换句话说:你只能从自己的 ns 向下(向子孙)看一层父,不能从子 ns 向上看兄弟或祖先。NS_GET_USERNS 的 ns_get_owner 用同一套逻辑检查 owner user namespace 是否在调用者的 user ns 祖先链上。
内核路径:一次 ioctl 走到 nsfs inode
ioctl(fd, NS_GET_PARENT)
→ do_vfs_ioctl() fs/ioctl.c
→ vfs_ioctl() → filp->f_op->unlocked_ioctl
→ ns_ioctl() fs/nsfs.c
→ open_related_ns(ns, ns->ops->get_parent)
→ get_ns() 具体实现,如 pidns_get_parent()
→ __ns_get_path() 复用 ns->stashed 缓存的 dentry,
否则 new_inode_pseudo() + inode->i_ino = ns->inum
→ dentry_open() + fd_install()
ns_common 里的 stashed 是 dentry 缓存,inum 是 inode 号;初始 namespace 的 inode 是固定常量(如 PROC_PID_INIT_INO = 0xEFFFFFFC,十进制 4026531836),非初始的由 proc_alloc_inum() 分配,销毁后可能复用。所以 inode 适合做存活期内的身份,不适合写进数据库当持久 ID。
5.2.13 两个补充实验:读 namespace 元数据与谱系边界
实验一:用 ioctl 打印每个 ns 的类型、owner 与父
这段程序对 /proc/<pid>/ns/* 逐个打开,调用四个 ioctl,并把返回的 fd 通过 /proc/self/fd/N 反读成 类型:[inode]。需要 Linux 5.6+ 与头文件 <linux/nsfs.h>。
/* nsid.c — 读取 namespace 元数据;cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror nsid.c -o nsid */
#include <errno.h>
#include <fcntl.h>
#include <linux/nsfs.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
static void show_fd(const char *label, int fd)
{
char path[64], buf[128];
ssize_t n;
if (fd < 0) { printf(" %s=-1(%s)", label, strerror(errno)); return; }
snprintf(path, sizeof(path), "/proc/self/fd/%d", fd);
n = readlink(path, buf, sizeof(buf) - 1);
if (n < 0) { printf(" %s=?", label); }
else { buf[n] = '\0'; printf(" %s=[%s]", label, buf); }
close(fd);
}
int main(int argc, char **argv)
{
const char *pid = (argc > 1) ? argv[1] : "self";
const char *names[] = { "net", "pid", "user", "mnt", "time", "pid_for_children" };
for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
char path[64];
int fd, ufd, pfd;
uid_t uid = (uid_t)-1;
snprintf(path, sizeof(path), "/proc/%s/ns/%s", pid, names[i]);
fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) {
printf("%-17s open: %s\n", names[i], strerror(errno));
continue;
}
printf("%-17s type=0x%08lx", names[i], (unsigned long)ioctl(fd, NS_GET_NSTYPE));
ufd = ioctl(fd, NS_GET_USERNS);
show_fd("user", ufd);
pfd = ioctl(fd, NS_GET_PARENT);
show_fd("parent", pfd);
if (ioctl(fd, NS_GET_OWNER_UID, &uid) < 0)
printf(" owner-uid=-1(%s)", strerror(errno));
else
printf(" owner-uid=%u", (unsigned)uid);
putchar('\n');
close(fd);
}
return 0;
}
net type=0x40000000 user=[user:[4026531837]] parent=-1(Invalid argument) owner-uid=-1(Invalid argument)
pid type=0x20000000 user=[user:[4026531837]] parent=-1(Operation not permitted) owner-uid=-1(Invalid argument)
user type=0x10000000 user=[user:[4026531837]] parent=[user:[4026531837]] owner-uid=0
net 的 parent 是 EINVAL,因为它的 proc_ns_operations.get_parent 为空;pid 的 parent 是 EPERM 而不是 EINVAL,因为初始 pid ns 的 parent 是 NULL,循环走到底返回 EPERM;user 的 parent 返回父 userns,owner-uid=0 是初始 userns 的 owner。
实验二:谱系边界——在子 PID namespace 里读不到父
用同一个程序,从两个视角读同一个 PID namespace:一次在宿主初始 ns 里读,一次在新建的 PID ns 里读自己。
# 需要 root;在初始 ns 里读一个子进程的 ns
unshare --pid --fork --mount-proc sleep 60 &
sleep 0.2; child=$(pgrep -P "$!" | head -1)
sudo ./nsid "$child" | grep -E '^(pid|net) '
# 在子 PID ns 内部读自己
sudo unshare --pid --fork --mount-proc ./nsid | grep -E '^(pid|net) '
预期差异:
# 宿主视角(child 的 pid ns)
pid type=0x20000000 ... parent=[pid:[4026531836]] ...
# 子 ns 内部(self 的 pid ns)
pid type=0x20000000 ... parent=-1(Operation not permitted) ...
同一个 namespace 对象,在宿主里能拿到它的父(初始 pid ns),在它内部却返回 EPERM。原因就是 5.2.12 的循环:active 是子 ns,父链从初始 ns 开始,永远不等于 active。这不是 bug,而是防止容器内的进程沿父链枚举宿主 namespace 的边界。
- 把
NS_GET_NSTYPE的返回值当 fd 关闭——它返回的是CLONE_NEW*常量;close(0x40000000)会关掉一个无关 fd 或返回EBADF。 - 忘记关闭
NS_GET_USERNS/NS_GET_PARENT返回的 fd——每个都是一次open_related_ns,长期运行的工具会泄漏 fd;lsns刷新频繁时尤其明显。 - 对普通文件 fd 调
NS_GET_NSTYPE——返回-1/ENOTTY,但0x40000000这样的正数让「成功」判断失效;先确认 fd 来自/proc/*/ns/*,或检查fstat的st_dev是否 nsfs。 - 以为
NS_GET_PARENT对所有类型都可用——只有 pid 与 user 实现了get_parent;net/mnt/uts/ipc/cgroup/time 返回EINVAL,不要把它当成「没有父」以外的错误。 - 把
NS_GET_OWNER_UID的 65534 当成错误——它是from_kuid_munged的结果,表示 owner uid 在调用者的 user ns 里没有映射;-1/EINVAL才是调用失败。 - 打开
/proc/PID/ns/*时忽略 ptrace 检查——读别的进程的 ns 链接受PTRACE_MODE_READ_FSCREDS限制,同 uid 但dumpable=0(如 setuid 后)也可能EACCES;sudo能读不代表普通用户能读。 - 用 inode 号做持久标识——
proc_alloc_inum()会在 namespace 销毁后复用 inode;跨重启、跨容器比较net:[4026531992]没有意义,只能在两者都存活时比较。 - 在容器里用
NS_GET_USERNS反查宿主 userns 做「逃逸检测」——ns_get_owner只允许 owner 在调用者祖先链上,容器内通常拿不到宿主 userns 的 fd,返回EPERM;这不是「没有 userns」,而是谱系边界。
5.2.14 两个实验:嵌套 PID 编号与 setns 权限边界
实验一:两层 PID namespace 里的 NSpid
NSpid 字段列出进程在「procfs 所属 namespace 及其所有后代」里的编号。用一个只读 /proc/self/status 的小程序,就能看到编号如何逐层叠加:
/* pidview.c — 打印当前进程的 getpid 与 NSpid;编译:cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror pidview.c -o pidview */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
char line[256];
FILE *fp = fopen("/proc/self/status", "r");
if (fp == NULL) {
perror("fopen");
return 1;
}
printf("getpid=%d getppid=%d\n", (int)getpid(), (int)getppid());
while (fgets(line, sizeof line, fp) != NULL) {
if (strncmp(line, "NSpid:", 6) == 0) {
printf("%s", line);
break;
}
}
fclose(fp);
return 0;
}
运行两层:
# 需要 root
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror pidview.c -o pidview
# 第一层:新 PID ns 的 1 号,/proc 也是新的
unshare --pid --fork --mount-proc ./pidview
# getpid=1 getppid=0
# NSpid: 1
# 第二层:外层 sh 在 L1 里是某个非 1 的 PID;内层 unshare 不再挂 /proc,
# 于是 pidview 读到的仍是 L1 的 procfs,NSpid 显示 L1 与 L2 两段编号
unshare --pid --fork --mount-proc sh -c 'unshare --pid --fork ./pidview'
# getpid=1 getppid=0
# NSpid: 2 1
读法:第二行 NSpid: 2 1 表示「这个进程在 L1 里是 2 号,在 L2 里是 1 号」。如果在内层也加 --mount-proc,/proc 变成 L2 自己的 procfs,NSpid 就只剩 1——NSpid 的段数取决于你读的是谁的 procfs,这正是它容易被误读的原因。
把最内层进程的宿主编号也看到,需要在宿主上读 /proc/<hostpid>/status。三层嵌套时,宿主看到的是 NSpid: <host> <L1> <L2> 三段。容器运行时报告「容器内 PID」时,取的通常是最右一段。
实验二:setns 进入 netns 与 EPERM
nsenter 的本质是「open ns fd + setns + exec」。下面这个 30 行程序把它拆开,顺便验证权限边界:
/* setns-net.c — 进入一个已存在的 net namespace;编译:cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror setns-net.c -o setns-net */
#include <errno.h>
#include <fcntl.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s /proc/PID/ns/net | /run/netns/NAME\n", argv[0]);
return 2;
}
int fd = open(argv[1], O_RDONLY | O_CLOEXEC);
if (fd < 0) {
perror("open");
return 1;
}
if (setns(fd, CLONE_NEWNET) == -1) {
fprintf(stderr, "setns: %s\n", strerror(errno));
return 1;
}
close(fd);
execlp("ip", "ip", "-brief", "addr", (char *)NULL);
perror("execlp");
return 1;
}
# 需要 root;准备一个带地址的 netns
ip netns add demo
ip netns exec demo ip link set lo up
ip netns exec demo ip addr add 10.77.0.1/24 dev lo
# 1. root 进入:看到 demo 的 lo
./setns-net /run/netns/demo
# lo UNKNOWN 10.77.0.1/24
# 2. 普通用户进入 root 拥有的 netns:EPERM
sudo -u nobody ./setns-net /run/netns/demo
# setns: Operation not permitted
# 3. 清理
ip netns del demo
三行输出对应三条规则:/run/netns/demo 是 nsfs 文件的 bind mount,可以像普通文件一样 open;setns 需要目标 user namespace 的 CAP_SYS_ADMIN(net ns 还要 CAP_NET_ADMIN),普通用户在初始 user ns 里没有;失败返回 EPERM 而不是 EACCES,因为这是 capability 检查,不是文件权限检查。
如果把第 2 步改成「在同一个 user namespace 里、但只缺 CAP_NET_ADMIN」,比如 capsh --drop=cap_net_admin 后再执行,setns 仍会失败——两个 capability 是「与」的关系,不是「或」。
NSpid只有一段——/proc是目标 ns 自己挂的,段数从 procfs 所属 ns 算起;想看到多层,读外层或宿主的 procfs。unshare --pid后getpid()没变——没有--fork,调用者不在新 ns 里;PID ns 只影响后代。setns返回EINVAL而不是EPERM——试图进入父 PID ns,或 fd 类型与nstype不匹配;先readlink确认类型。open /run/netns/demo成功但setns失败——open 只检查文件权限,setns 才检查 capability 与 user ns 归属。ip netns add在容器里失败——它要创建/run/netns并 bind mount,需要 mount ns 与CAP_SYS_ADMIN;容器里通常没有。
5.2.15 本章陷阱清单
| # | 现象 | 原因 | 修复/验证 |
|---|---|---|---|
| 1 | 容器退出后 ip netns list 仍有条目 | bind mount 持有 nsfs 引用 | ip netns del 或删 /run/netns/* |
| 2 | 有 ns fd 但 PID ns 里没有进程 | init 退出触发 zap_pid_ns_processes | fd 只保对象,不保进程 |
| 3 | setns 进 PID ns 返回 EINVAL | 只能进入后代 ns,不能向上 | 在宿主进容器,不要在容器进宿主 |
| 4 | setns 进 net ns 返回 EPERM | 缺目标 user ns 的 CAP_SYS_ADMIN/CAP_NET_ADMIN | 用 root,或进入同一 user ns |
| 5 | unshare --user 后无法 setns 回宿主 ns | user ns 层级方向限制 | 在父 ns 里操作,或重新设计 |
| 6 | max_user_namespaces 触顶报 ENOSPC | per-user-ns 配额耗尽 | 清理泄漏的 ns;调 sysctl user.max_user_namespaces |
| 7 | 写 ns_last_pid 想固定下一个 PID | 它只是分配起点提示 | 用 clone3 的 set_tid(需 CAP_CHECKPOINT_RESTORE) |
| 8 | cgroup ns 里 cat /proc/self/cgroup 是 0::/,但限额仍在 | cgroup ns 只改视图 | 限额看宿主侧 cgroup 文件与 cgroup.procs |
| 9 | time ns 里 date 没变 | 只偏移 MONOTONIC/BOOTTIME,不改 REALTIME | 用 clock_gettime(CLOCK_MONOTONIC) 验证 |
| 10 | nsenter -a 报 EPERM | 包含 user ns 时顺序不对 | 先 -U 再其他,或只进需要的 ns |
| 11 | nsenter -p 后 ps 还是宿主进程 | 没进 mount ns 或没挂新 /proc | 加 -m -f --mount-proc |
| 12 | PID 被复用导致杀错进程 | PID 是编号不是身份 | 用 pidfd(CLONE_PIDFD/pidfd_open) |
| 13 | lsns 里某 netns 的 NPROCS 为 0 | 持久 ns,没有进程 | 正常;用 ip netns exec 验证 |
| 14 | AppArmor 拒绝非特权 unshare --user | Ubuntu 24.04 默认限制 | sysctl kernel.apparmor_restrict_unprivileged_userns=0 |
- net ns 不隔离内核参数:
net.core.somaxconn是 per-netns 的,但kernel.pid_max、vm.swappiness是全局的;容器里改不了,也不该改。 - UTS ns 不影响
/etc/hostname:sethostname改的是内核里的字符串,文件是用户态工具写的;容器里hostname与/etc/hostname可能不一致。 - IPC ns 不隔离 POSIX 信号量:POSIX 信号量在
/dev/shm下,属于 mount ns 与 tmpfs 的范畴;只加 IPC ns 仍能看到宿主的/dev/shm。
5.2.16 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
说出八种 namespace 与各自的 CLONE_NEW* | 不把 time 漏掉 | 5.2.1 |
用 clone 写一个带新 PID/UTS/mount 的程序 | 能解释 getpid()==1、getppid()==0 | 5.2.2 |
区分 clone/unshare/setns 的语义 | 说出 PID namespace 的例外 | 5.2.3 |
| 用 inode 判断两个进程是否同一 namespace | 会读 /proc/PID/ns/* | 5.2.4 |
| 解释 PID 1 为什么杀不死 | 提到「无处理器信号被忽略」 | 5.2.5 |
写出正确的 uid_map 并解释 setgroups=deny | 知道 24.04 的 AppArmor 限制 | 5.2.6 |
| 解释挂载传播四种状态 | 会在容器里设 rprivate | 5.2.7 |
用 nsenter 进入容器并清理 | 不留下挂载与进程 | 5.2.9 |
说出 nsproxy/ns_common 的字段与生命周期 | 能解释「fd 保活对象、不保活进程」 | 5.2.10 |
画出 create_new_namespaces 的复制顺序 | 至少五个 copy_*_ns | 5.2.11 |
解释 setns 进 PID ns 为什么只能向下 | 提到 level 比较与 EINVAL | 5.2.11 |
用 NSpid 读出多层编号 | 知道段数取决于读的是谁的 procfs | 5.2.12 |
Q1 一个进程在容器里的 PID 是 1,在宿主上是 12345。它的 getppid() 在容器内返回什么?为什么?
Q2 unshare --pid --fork --mount-proc ps -ef 里,ps 显示 PID 1 是什么?如果去掉 --mount-proc 会怎样?
Q3 两个进程的 /proc/PID/ns/net 的 readlink 输出都是 net:[4026531992],说明什么?如果 inode 不同但都能 ping 通对方,可能是什么情况?
Q4 为什么非特权用户写 gid_map 之前要先写 setgroups=deny?不写会怎样?
Q5 在 unshare --mount 里 mount -t tmpfs tmpfs /tmp/x,宿主上也能看到。原因是什么?怎么避免?
Q6 容器进程已经退出,ip netns list 里却还有它的 netns。这个 netns 什么时候才会真正消失?
Q7 在容器里执行 nsenter -t 1 -p 想回到宿主 PID namespace,为什么会失败?失败码是什么?
答案
A1 返回 0。子进程是新 PID namespace 的 1 号,它的父进程在祖先 namespace 里,对子进程不可见;getppid() 因此返回 0。宿主上看到的 12345 是同一个进程在父 namespace 的编号。
A2 ps 自己就是 PID 1(它是 unshare --fork 创建的第一个进程,exec 成了 ps)。去掉 --mount-proc 后,/proc 仍是宿主的 procfs,ps 读到的是宿主进程列表,PID 1 是宿主的 init,而不是 ps。
A3 inode 相同说明两个进程在同一个 net namespace。inode 不同但能互通,说明它们在不同的 net namespace 里,但通过 veth/bridge/路由连在一起——隔离的是「网络栈实例」,不是「连通性」。
A4 因为 gid 映射可以被用来「认领」宿主上属于某个组的文件;如果允许非特权用户随意写 gid_map,就能绕过组权限。setgroups=deny 表示这个 namespace 里不再允许 setgroups(2),内核以此为前提才接受非特权写的 gid_map。不写会得到 EPERM。
A5 因为 / 是 shared 挂载,子 mount namespace 里的挂载沿 peer group 传播回宿主。避免方法是在挂载前执行 mount --make-rprivate /(或让运行时把根挂载设为 rprivate)。
A6 因为 /run/netns/<name> 是 nsfs 文件的 bind mount,它持有 net namespace 的引用计数。只要这个 bind mount 还在,即使没有进程,netns 也不会销毁;ip netns del <name>(或手工 umount 并删除文件)后,等最后一个进程/fd 引用释放,netns 才真正消失。
A7 因为 setns 到 PID namespace 只允许进入后代(目标 level >= 当前活动 PID ns 的 level),容器内的 PID ns 层级比宿主深,往上是非法方向。内核返回 EINVAL 而不是 EPERM,表示「层级方向不对」,不是权限不足。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 28 章 详述进程创建和程序执行(28.2 clone()、28.2.1 flags) | 5.2.2–5.2.3 | 完整(clone 标志与 unshare/setns 对照) |
| K | 第 24 章 进程的创建、第 26 章 监控子进程 | 5.2.5 | 完整(PID 1 的 wait 与孤儿回收) |
| N | 第 10 章 容器网络虚拟化(10.3 网络命名空间) | 5.2.8、5.6 | 精选(netns 的创建与 veth 配合) |
| man-pages | namespaces(7)、user_namespaces(7)、pid_namespaces(7)、mount_namespaces(7) | 5.2.1–5.2.7 | 完整(API 语义以 man-pages 6.19 为准) |
| R | 第 3 章 进程管理(3.1 进程的诞生、3.1.2 fork、clone flags) | 5.2.2、5.2.5、5.2.11 | 背景(copy_process 的 flags 检查、copy_namespaces 的位置) |
| OCI | runtime-spec(linux.namespaces、uidMappings、rootfsPropagation) | 5.2.6–5.2.7、5.5.2 | 精选(配置字段与内核语义的对应) |
| 内核源码 | kernel/nsproxy.c、kernel/pid.c、fs/namespace.c、kernel/user_namespace.c | 5.2.10–5.2.11 | 精选(create_new_namespaces、alloc_pid、setns 的 install 回调) |
| man-pages | ioctl_nsfs(2)、time_namespaces(7)、pid_namespaces(7) | 5.2.10–5.2.13 | 精选(NS_GET_*、NSpid、timens_offsets) |
下一站:5.3 cgroups v2 接手「用得到多少」:统一层级、
cgroup.subtree_control的父子规则、四大控制器的文件格式,以及 systemd 如何把 slice 映射成 cgroup 树。