Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)列出八种:

namespaceclone 标志隔离的资源引入版本/proc/PID/ns 条目
MountCLONE_NEWNS挂载点、挂载传播2.4.19mnt
UTSCLONE_NEWUTS主机名、NIS 域名2.6.19uts
IPCCLONE_NEWIPCSystem V IPC、POSIX 消息队列2.6.19ipc
PIDCLONE_NEWPIDPID 编号空间、/proc 进程视图2.6.24pid、pid_for_children
NetworkCLONE_NEWNET网卡、路由、端口、netfilter、sysctl2.6.24net
UserCLONE_NEWUSERuid/gid 映射、capabilities 作用域3.8user
CgroupCLONE_NEWCGROUPcgroup 根目录视图4.6cgroup
TimeCLONE_NEWTIMECLOCK_MONOTONIC/BOOTTIME 偏移5.6time、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。

定义 5.2.1 · 命名空间(namespace)

命名空间是内核对某类全局资源的一份具名实例。进程通过 nsproxy 持有若干 namespace 的引用;创建新 namespace 就是新建一份实例并让进程(或其子进程)改指向它。namespace 之间默认互不可见,但存在父子关系(PID、user、time),父 namespace 可以看到子 namespace 里的进程,反之不行。

关于 namespace 数量的三个争论
  1. 「Linux 有六种 namespace」——这是 3.8 之前的教材。4.6 加了 cgroup,5.6 加了 time,man 7 namespaces 现在是八种。
  2. 「user namespace 也是容器隔离的一部分,所以一共七种」——user namespace 特殊:它不隔离某类「资源」,而是隔离身份与权限的作用域,并且是唯一允许非特权用户创建的。把它和 mnt/net 并列会掩盖这个差异。
  3. 「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]

三个细节值得停一下:

  1. 子进程的 getpid() 是 1,getppid() 是 0。子进程是新 PID namespace 的 1 号;它的父进程在祖先 namespace 里,对子进程不可见,所以 getppid() 返回 0。
  2. 父进程看到的 clone 返回值是宿主 PID(4212),不是 1。clone 的返回值按调用者所在 namespace 解释——同一个进程在不同 namespace 里有不同编号。
  3. 三个 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直接把子进程放进指定 cgroupLinux 5.7+,配合 clone3() 的 cgroup 字段
CLONE_PARENT子进程的父进程设为调用者的父进程容器 init 常用,避免多一层中间进程

现代代码应优先用 clone3(2):参数放进 struct clone_args,可扩展,且支持 set_tid(指定 PID)、CLONE_INTO_CGROUP。clone() 的 flags 位已经快用完了。

例 5.2.1 · 为什么 `clone` 的栈要自己传

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` 不是魔法

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

三个常被忽略的事实:

  1. inode 号是 namespace 的身份。同一 namespace 的所有成员,/proc/PID/ns/* 的 st_dev/st_ino 相同。容器运行时判断「这个进程是否在目标 netns」,用的就是这个比较,而不是看 cgroup 路径。
  2. fd 能延长 namespace 的寿命。namespace 的默认生命周期是「最后一个进程退出」,但只要还有一个 fd 或 bind mount 指向它,它就不会被销毁。ip netns add 正是利用这一点:在 /run/netns/<name> 上 bind mount /proc/self/ns/net,于是没有进程的 netns 也能一直存在。
  3. 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* 值)
读 `/proc/PID/ns` 的四个坑
  1. 「readlink 看到路径,就能 cd 进去」——不能。它是魔法链接,open 得到 fd,cd 会失败或进入 nsfs。
  2. 「inode 号就是 PID」——不是。pid:[4026531836] 里的数字是 namespace 对象的 inode,与进程号无关;所有进程的初始 PID namespace 都是同一个 inode。
  3. 「进程退出,namespace 就没了」——只要有 fd、bind mount、子 namespace、或引用它的 proc/mqueue 挂载,namespace 就继续存在。
  4. 「/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 有三条特殊规则:

  1. 没有处理器的信号会被忽略,包括同一个 namespace 内发来的 SIGKILL/SIGSTOP。只有祖先 namespace 发来的 SIGKILL/SIGSTOP 能杀死它。
  2. 孤儿进程会重新挂到它名下(PR_SET_CHILD_SUBREAPER 可以让别的进程承担这个角色)。所以 PID 1 必须 wait,否则僵尸会堆积。
  3. 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 已被清理"
定理 5.2.1 · PID 1 的信号语义

对某个 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 的五个坑
  1. 「user namespace 里的 root 什么都能干」——不能。它只能操作由该 user namespace 拥有的资源;挂载真实块设备、修改宿主网络、ptrace 宿主进程都被拒绝。
  2. 「uid_map 可以分多次写」——每个映射文件只能写一次,且写入后不可修改。要改只能销毁 namespace 重建。
  3. 「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。
  4. 「user namespace 越多越安全」——它扩大了内核攻击面。历史上多个提权漏洞(如 CVE-2022-0185)与 user namespace 相关;Ubuntu 24.04 默认限制它,正是权衡后的选择。
  5. 「容器里创建的文件在宿主上也是 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 容器依赖 user 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一句话验证常见误区
netip netns exec n1 ip addr以为 lo 默认是 up
ipcunshare --ipc ipcs -m把 POSIX mqueue 当成文件系统隔离
utsunshare --uts hostname x以为改 hostname 会影响宿主
cgroupunshare --cgroup cat /proc/self/cgroup以为它限制了资源
timeunshare --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 的推荐顺序:

  1. 先确认目标 PID:docker inspect -f '{{.State.Pid}}'、crictl inspect -o go-template ... 或 pgrep。
  2. 只进需要的 namespace:排网络用 -n,看文件系统用 -m,调试 PID 视图用 -p -f --mount-proc。
  3. 需要完整环境时用 -a 加 -r -w:nsenter -t PID -a -r -w 会进入全部 namespace,并把根目录与工作目录切到目标进程的。
  4. 退出后确认没有留下挂载:findmnt | grep <mountpoint>。
# 需要 root;一个完整的「进入容器」命令
pid=$(docker inspect -f '{{.State.Pid}}' mycontainer)
sudo nsenter -t "$pid" -a -r -w
容器 namespace 排障清单
  1. 进程在不在? 宿主 ps -fp <pid> 看状态;/proc/<pid>/status 看 NSpid 字段(显示该进程在各层 PID namespace 的编号)。
  2. 在哪个 namespace? lsns -p <pid>;比较 /proc/<pid>/ns/net 与目标进程的 st_ino。
  3. 网络不通? 先 nsenter -t <pid> -n ip -brief addr,确认地址与路由;再 ip netns exec 对照。
  4. 文件系统不对? nsenter -t <pid> -m findmnt -T /,看根挂载是不是 overlay。
  5. PID 1 不回收僵尸? nsenter -t <pid> -p -f --mount-proc ps -ef,看是否有大量 Z 状态进程。
  6. 权限被拒? 检查调用者是否在目标 user namespace 里有 CAP_SYS_ADMIN;capsh --print 与 /proc/<pid>/uid_map 一起看。

5.2.10 结构体与生命周期:namespace 什么时候真正消失

这一节回答两个排障时最实际的问题:为什么容器进程退出了,netns 还在?为什么 setns 拿到的 fd 在目标进程死后仍然可用?答案在引用计数与 ns_common 里。

五个与生命周期有关的结构体

结构体定义位置关键字段生命周期含义
struct nsproxyinclude/linux/nsproxy.hcount、mnt_ns、pid_ns_for_children、net_ns、time_ns、cgroup_ns进程持有;最后一个引用释放时销毁
struct ns_commoninclude/linux/ns_common.hstashed、ops、inum、count所有 namespace 的公共头;count 是「对象引用」
struct pid_namespaceinclude/linux/pid_namespace.hidr、level、parent、child_reaper、pid_allocated、ns有层级;child_reaper 退出会杀全组
struct user_namespaceinclude/linux/user_namespace.hparent、level、owner、uid_map、gid_map、ns子 ns 持有父 ns 引用,父不能先死
struct pidinclude/linux/pid.hcount、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 由三类持有者增加:

持有者增加方式典型例子
进程的 nsproxyget_nsproxy / 各 get_*_ns容器里每个进程都持有 netns 引用
nsfs 的打开文件open("/proc/PID/ns/net")nsenter、运行时的监控 fd
nsfs 的 bind mountmount --bind /proc/PID/ns/net /run/netns/xip netns add 的持久化

这解释了两个现象:

  1. 容器退出后 ip netns list 里还有条目——bind mount 还在,net 对象的 count 不为零。删掉 /run/netns/x 或 ip netns del 才会真正释放。
  2. 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 listbind mount 的 nsfs 文件
mount ns/proc/PID/mountinfo、findmnt挂载树与传播属性
time ns/proc/PID/timens_offsetsmonotonic/boottime 偏移
配额/proc/sys/user/max_*_namespaces每 user ns 的上限
定义 5.2.2 · namespace 的「身份」与「生命周期」

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
netnetns_install目标 user ns 的 CAP_SYS_ADMIN + CAP_NET_ADMINEPERM
mntmntns_install目标 user ns 的 CAP_SYS_ADMIN;路径必须可达EPERM
pidpidns_install目标 user ns 的 CAP_SYS_ADMIN;只能进入后代 ns(new->level >= active->level)EPERM / EINVAL
utsutsns_install目标 user ns 的 CAP_SYS_ADMINEPERM
ipcipcns_install目标 user ns 的 CAP_SYS_ADMINEPERM
cgroupcgroupns_install目标 user ns 的 CAP_SYS_ADMINEPERM
timetimens_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` 进 PID ns 要 fork

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 nsEPERM(owner 不在调用者 ns 的祖先链上)对 user ns 自己调用返回它的父 userns
NS_GET_PARENT_IO(0xb7, 0x2)新 fd,指向父 nsEINVAL(该类型无层级)、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 的边界。

nsfs ioctl 的八个坑
  1. 把 NS_GET_NSTYPE 的返回值当 fd 关闭——它返回的是 CLONE_NEW* 常量;close(0x40000000) 会关掉一个无关 fd 或返回 EBADF。
  2. 忘记关闭 NS_GET_USERNS/NS_GET_PARENT 返回的 fd——每个都是一次 open_related_ns,长期运行的工具会泄漏 fd;lsns 刷新频繁时尤其明显。
  3. 对普通文件 fd 调 NS_GET_NSTYPE——返回 -1/ENOTTY,但 0x40000000 这样的正数让「成功」判断失效;先确认 fd 来自 /proc/*/ns/*,或检查 fstat 的 st_dev 是否 nsfs。
  4. 以为 NS_GET_PARENT 对所有类型都可用——只有 pid 与 user 实现了 get_parent;net/mnt/uts/ipc/cgroup/time 返回 EINVAL,不要把它当成「没有父」以外的错误。
  5. 把 NS_GET_OWNER_UID 的 65534 当成错误——它是 from_kuid_munged 的结果,表示 owner uid 在调用者的 user ns 里没有映射;-1/EINVAL 才是调用失败。
  6. 打开 /proc/PID/ns/* 时忽略 ptrace 检查——读别的进程的 ns 链接受 PTRACE_MODE_READ_FSCREDS 限制,同 uid 但 dumpable=0(如 setuid 后)也可能 EACCES;sudo 能读不代表普通用户能读。
  7. 用 inode 号做持久标识——proc_alloc_inum() 会在 namespace 销毁后复用 inode;跨重启、跨容器比较 net:[4026531992] 没有意义,只能在两者都存活时比较。
  8. 在容器里用 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 是「与」的关系,不是「或」。

两个实验的五个操作坑
  1. NSpid 只有一段——/proc 是目标 ns 自己挂的,段数从 procfs 所属 ns 算起;想看到多层,读外层或宿主的 procfs。
  2. unshare --pid 后 getpid() 没变——没有 --fork,调用者不在新 ns 里;PID ns 只影响后代。
  3. setns 返回 EINVAL 而不是 EPERM——试图进入父 PID ns,或 fd 类型与 nstype 不匹配;先 readlink 确认类型。
  4. open /run/netns/demo 成功但 setns 失败——open 只检查文件权限,setns 才检查 capability 与 user ns 归属。
  5. 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_processesfd 只保对象,不保进程
3setns 进 PID ns 返回 EINVAL只能进入后代 ns,不能向上在宿主进容器,不要在容器进宿主
4setns 进 net ns 返回 EPERM缺目标 user ns 的 CAP_SYS_ADMIN/CAP_NET_ADMIN用 root,或进入同一 user ns
5unshare --user 后无法 setns 回宿主 nsuser ns 层级方向限制在父 ns 里操作,或重新设计
6max_user_namespaces 触顶报 ENOSPCper-user-ns 配额耗尽清理泄漏的 ns;调 sysctl user.max_user_namespaces
7写 ns_last_pid 想固定下一个 PID它只是分配起点提示用 clone3 的 set_tid(需 CAP_CHECKPOINT_RESTORE)
8cgroup ns 里 cat /proc/self/cgroup 是 0::/,但限额仍在cgroup ns 只改视图限额看宿主侧 cgroup 文件与 cgroup.procs
9time ns 里 date 没变只偏移 MONOTONIC/BOOTTIME,不改 REALTIME用 clock_gettime(CLOCK_MONOTONIC) 验证
10nsenter -a 报 EPERM包含 user ns 时顺序不对先 -U 再其他,或只进需要的 ns
11nsenter -p 后 ps 还是宿主进程没进 mount ns 或没挂新 /proc加 -m -f --mount-proc
12PID 被复用导致杀错进程PID 是编号不是身份用 pidfd(CLONE_PIDFD/pidfd_open)
13lsns 里某 netns 的 NPROCS 为 0持久 ns,没有进程正常;用 ip netns exec 验证
14AppArmor 拒绝非特权 unshare --userUbuntu 24.04 默认限制sysctl kernel.apparmor_restrict_unprivileged_userns=0
三个「隔离了但没完全隔离」的例子
  1. net ns 不隔离内核参数:net.core.somaxconn 是 per-netns 的,但 kernel.pid_max、vm.swappiness 是全局的;容器里改不了,也不该改。
  2. UTS ns 不影响 /etc/hostname:sethostname 改的是内核里的字符串,文件是用户态工具写的;容器里 hostname 与 /etc/hostname 可能不一致。
  3. 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()==05.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
解释挂载传播四种状态会在容器里设 rprivate5.2.7
用 nsenter 进入容器并清理不留下挂载与进程5.2.9
说出 nsproxy/ns_common 的字段与生命周期能解释「fd 保活对象、不保活进程」5.2.10
画出 create_new_namespaces 的复制顺序至少五个 copy_*_ns5.2.11
解释 setns 进 PID ns 为什么只能向下提到 level 比较与 EINVAL5.2.11
用 NSpid 读出多层编号知道段数取决于读的是谁的 procfs5.2.12
即时自测 · 5.2

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-pagesnamespaces(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 的位置)
OCIruntime-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.c5.2.10–5.2.11精选(create_new_namespaces、alloc_pid、setns 的 install 回调)
man-pagesioctl_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 树。