Linux 基础体系 · 第 36/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux namespaces 深入:PID、Mount、Network、User 和隔离组合

Linux namespace(命名空间)是一组由内核维护的隔离视图。进程仍然运行在同一个 Linux 内核上,但内核在处理“这个进程能看到什么、使用哪个编号、访问哪组资源”时,会根据进程所属的 namespace 选择不同的视图。

容器并不是某一种 namespace,而是多个 namespace、cgroups、Capabilities、Seccomp、文件系统根目录和运行时管理逻辑的组合。namespace 主要解决“看见什么”和“命名空间内的身份是什么”,cgroups 主要解决“最多使用多少资源”,Capabilities 和 Seccomp 主要解决“允许执行哪些特权操作和系统调用”。


一、namespace 的基本模型

1. 进程不是只有一个 namespace

一个进程同时属于多个 namespace。例如:

PID namespace      决定进程看到的 PID
Mount namespace    决定挂载树和路径视图
Network namespace  决定网络设备、路由、端口空间
User namespace     决定 UID/GID 映射和能力集合
IPC namespace      决定 System V IPC、POSIX 消息队列等
UTS namespace      决定 hostname 和 domainname
Cgroup namespace   决定 /proc/self/cgroup 等 cgroup 视图
Time namespace     决定部分进程时钟视图

一个进程可以处于如下组合中:

进程 P
├── PID namespace:     N1
├── Mount namespace:   M2
├── Network namespace: N3
├── User namespace:    U4
└── IPC namespace:     I1

这些维度可以独立变化。进入一个新的 Network namespace,并不会自动进入新的 Mount namespace;创建新的 User namespace,也不会自动创建新的 PID namespace。

Linux 通过 clone()unshare()setns() 操作 namespace:

  • clone():创建子进程时,让子进程进入新的 namespace。
  • unshare():让调用线程脱离当前 namespace,进入新创建的 namespace。
  • setns():让调用线程加入一个已经存在的 namespace。

对应的命令行工具通常是 unshare(1)nsenter(1)ip netns

namespace 对象本身由内核持有。如果没有进程使用它,并且没有其他引用,namespace 通常会被释放。将 /proc/<pid>/ns/* bind mount 到文件,可以保留一个 namespace 的引用,使其在创建者退出后继续存在。


2. 查看进程属于哪些 namespace

ls -l /proc/self/ns

典型输出类似:

lrwxrwxrwx 1 user user 0 ... cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 user user 0 ... ipc    -> ipc:[4026531839]
lrwxrwxrwx 1 user user 0 ... mnt    -> mnt:[4026531841]
lrwxrwxrwx 1 user user 0 ... net    -> net:[4026531840]
lrwxrwxrwx 1 user user 0 ... pid    -> pid:[4026531836]
lrwxrwxrwx 1 user user 0 ... user   -> user:[4026531837]
lrwxrwxrwx 1 user user 0 ... uts    -> uts:[4026531838]

方括号内的数字是 namespace inode。两个进程的对应 inode 相同,通常表示它们位于同一个 namespace。

也可以查看某个进程:

readlink /proc/$$/ns/{pid,mnt,net,user}

这里的 $$ 是当前 shell 的 PID。注意,shell 的普通 PID 和它在 PID namespace 中显示的 PID 可能不同;后文会解释这一点。


二、PID namespace:隔离进程编号和进程树

1. PID namespace 的核心语义

PID namespace 隔离的是进程编号视图,而不是进程本身。一个进程可以同时拥有多个 PID:

宿主 PID namespace:       41200
容器 PID namespace:           1
更外层 PID namespace:      41200

进程在每个祖先 PID namespace 中都有一个 PID。/proc/<pid>/statusNSpid 字段可以显示这些编号:

grep '^NSpid:' /proc/self/status

可能得到:

NSpid:  41200  1

表示当前进程在外层 namespace 中是 41200,在当前 PID namespace 中是 1。实际输出的层数取决于当前 namespace 嵌套深度。

PID namespace 形成父子层级。创建新 PID namespace 的进程称为该 namespace 的 init 进程,它在新 namespace 中始终获得 PID 1。

一个进程在某个 PID namespace 中“可见”,大致要求该进程属于该 namespace,或者属于它的后代 namespace。外层 namespace 可以看到内层进程,内层 namespace 看不到外层进程。

因此,隔离关系不是简单的“两个 namespace 互相看不到”:

外层 PID namespace
├── 外层进程 A
└── 内层 PID namespace
    ├── 内层 PID 1
    └── 内层 PID 2

外层可以通过宿主 PID 访问内层进程;内层只能看到 PID 1 和 PID 2,不会看到外层进程 A。


2. 为什么创建 PID namespace 通常必须配合 --fork

PID namespace 对创建者的生效有一个容易忽略的规则:

调用 unshare(CLONE_NEWPID) 的进程不会迁移到新 PID namespace;它之后创建的子进程才会成为新 namespace 中的第一个进程。

因此,单独执行:

unshare --pid bash

并不能让当前 bash 立刻成为新 namespace 的 PID 1。需要让 unshare 创建一个子进程:

unshare --pid --fork bash

bash 才会在新 PID namespace 中作为 PID 1 启动。

要同时观察 Mount namespace 和 PID namespace,可以执行:

unshare \
  --mount \
  --pid \
  --fork \
  --mount-proc \
  bash

进入后运行:

echo "shell pid: $$"
ps -ef
readlink /proc/self/ns/pid

预期现象:

  • $$ 通常为 1,或者 shell 为执行命令创建的其他很小的 PID。
  • ps -ef 只显示新 PID namespace 内可见的进程。
  • --mount-proc 会在新的 Mount namespace 中重新挂载 /proc,使 ps 读取到新 PID namespace 的进程视图。

如果只创建 PID namespace、没有重新挂载 /procps 可能仍然读取宿主的 /proc,表现为看到宿主进程。这不是 PID namespace 失效,而是 /proc 本身仍然是旧挂载视图。


3. PID 1 不是普通进程

PID namespace 的第一个进程承担类似 init 的职责:

  1. 接收孤儿进程的重新托管。
  2. 负责回收子进程,避免僵尸进程堆积。
  3. 其退出会导致该 PID namespace 中剩余进程被内核终止。
  4. 该 namespace 不再接受新的进程创建,相关操作会失败。

因此,容器中作为 PID 1 运行的程序与普通前台进程有一个实际差异:它必须处理子进程回收和信号转发。

一个常见错误是:

PID 1 启动应用
应用启动 worker
PID 1 退出或没有 wait()
worker 变成孤儿或僵尸

即使应用在宿主上作为普通进程运行正常,直接将它放在容器 PID 1 位置也可能出现信号处理和子进程回收问题。生产环境通常需要让应用自身正确处理这些职责,或者使用专门的 init 进程。

PID 1 对信号还有特殊行为:对于没有安装处理器的信号,普通进程通常会执行默认动作,但 namespace init 对部分信号不会简单地按普通进程规则退出。外层 namespace 仍然可以在满足权限时向它发送强制终止信号。实际终止容器中的 PID 1 时,应区分“应用没有处理信号”和“内核无法发送信号”。


4. PID namespace 的边界

PID namespace 不会隔离:

  • CPU 时间;
  • 内存用量;
  • 文件描述符数量;
  • 进程数量上限;
  • 磁盘空间;
  • 系统调用能力。

例如,一个 PID namespace 内的进程仍可能消耗宿主的大量内存。如果要限制这些资源,需要使用 cgroups。

同样,PID namespace 也不等于进程安全边界。宿主上的特权进程仍可通过宿主 PID 看到、调试或终止内层进程,前提是权限、Capabilities、LSM 和其他安全策略允许。


三、Mount namespace:隔离挂载树,而不是复制文件系统

1. Mount namespace 隔离的对象

Mount namespace 保存一棵挂载树。进程访问:

/
├── proc
├── sys
├── dev
├── etc
└── app

时,路径解析会基于它所属的 Mount namespace。

创建新的 Mount namespace 后,进程可以:

  • 挂载新的文件系统;
  • 卸载原有挂载;
  • 创建 bind mount;
  • 修改挂载传播属性;
  • 将某个目录作为新的根目录。

这些操作默认只影响当前 Mount namespace,而不是其他不共享该 namespace 的进程。

最简单的实验:

unshare --mount bash

在新 shell 中执行:

mount -t tmpfs tmpfs /mnt
findmnt /mnt

再打开另一个宿主 shell:

findmnt /mnt

如果 /mnt 原来没有该挂载,宿主 shell 通常看不到新 shell 创建的挂载。

但这个实验有两个前提:

  1. 当前用户必须具有挂载所需的权限;普通用户通常需要先创建 User namespace。
  2. Mount namespace 的传播属性可能使挂载事件传播到其他 namespace。

2. Mount namespace 不会自动复制挂载数据

创建 Mount namespace 时,内核通常复制的是挂载结构和挂载对象引用,而不是把文件内容复制一份。

因此:

两个 Mount namespace
        │
        └── 都指向同一个 ext4 文件系统

它们可能拥有不同的挂载点、只读属性或路径可见性,但底层 inode 和块设备仍然可能相同。

例如,两个 namespace 都能访问 /data 中的同一个文件时:

  • 在一个 namespace 中修改文件,另一个 namespace 可能能看到内容变化;
  • 在一个 namespace 中卸载 /data,不一定会使另一个 namespace 的 /data 消失;
  • 如果两者都能访问同一个块设备,则挂载隔离不能阻止对底层设备的直接访问。

Mount namespace 解决的是“路径和挂载关系的隔离”,不是“存储内容的复制”或“文件访问权限的替代品”。


3. 挂载传播:为什么“新建 mount namespace”仍可能影响宿主

Linux 挂载点可以有传播属性:

  • shared:挂载和卸载事件可以在共享组成员之间传播;
  • private:事件不传播;
  • slave:接收上游传播,但不向上游传播;
  • unbindable:不能用于 bind mount。

现代发行版由 systemd 管理时,根挂载树可能默认带有 shared 属性。此时,简单执行:

unshare --mount bash
mount /somewhere

并不一定意味着挂载事件只存在于当前 namespace。

常见的隔离步骤是:

unshare --mount bash
mount --make-rprivate /

--make-rprivate / 将根挂载树递归设置为 private,使后续挂载、卸载事件不向其他共享挂载树传播。

验证传播属性:

findmnt -o TARGET,PROPAGATION /
findmnt -o TARGET,PROPAGATION /proc

可能看到:

TARGET PROPAGATION
/      shared

或:

TARGET PROPAGATION
/      private

实际结果依赖发行版、启动系统和当前挂载配置。生产环境中,如果没有确认传播属性,执行卸载或挂载操作可能影响宿主或其他容器。


4. /proc/sys/dev 是组合隔离的关键

只创建 Mount namespace,不会自动生成容器需要的伪文件系统视图:

  • /proc 通常需要重新挂载,才能反映新的 PID namespace;
  • /sys 与硬件、内核对象和 cgroup 视图有关,通常需要谨慎限制;
  • /dev 通常需要构造最小设备树,而不是直接暴露宿主完整 /dev

一个相对安全的教学实验:

unshare \
  --user \
  --map-root-user \
  --mount \
  --pid \
  --fork \
  --mount-proc \
  bash

进入后:

id
mount --make-rprivate /
echo "pid=$$"
ps -ef

这里:

  • --user --map-root-user 创建 User namespace,并把当前真实用户映射为 namespace 内的 UID 0;
  • --mount 创建新的 Mount namespace;
  • --pid --fork 创建新的 PID namespace,并让子进程成为 PID 1;
  • --mount-proc 在新的 Mount namespace 中挂载与新 PID namespace 关联的 /proc

但这个示例并没有建立新的根文件系统。进入后仍然能看到原来的目录树,只是挂载视图被隔离。因此它不是完整容器,也不能作为生产沙箱。


5. chrootpivot_root 和 Mount namespace 的区别

chroot() 只改变进程路径解析时的根目录。它有几个重要限制:

  • 不创建新的进程视图;
  • 不创建新的挂载视图;
  • 如果进程拥有足够权限或保留了目录文件描述符,可能绕过预期限制;
  • 它不是完整的安全边界。

Mount namespace 让挂载树本身独立;pivot_root() 可以在新的 Mount namespace 中将一个目录设置为新的根,并把旧根移动到另一个目录,再卸载旧根。

典型容器初始化流程的逻辑是:

创建 Mount namespace
    ↓
设置挂载传播为 private
    ↓
准备新的 rootfs
    ↓
准备 /proc、/sys、/dev、/tmp 等挂载点
    ↓
pivot_root(new_root, old_root)
    ↓
卸载 old_root
    ↓
execve(container_init)

pivot_root() 对目录布局和挂载关系有严格要求,不能简单把任意普通目录直接当作参数。现代容器运行时会处理这些细节;手写代码时还必须处理当前工作目录、文件描述符、挂载传播和错误回滚。


四、Network namespace:隔离网络栈和端口空间

1. Network namespace 管理哪些状态

Network namespace 隔离了大量网络状态,包括:

  • 网络设备;
  • IP 地址;
  • 路由表;
  • 邻居表;
  • 防火墙规则;
  • socket 的端口空间;
  • 网络接口统计信息;
  • 部分网络 sysctl;
  • loopback 设备状态。

创建 Network namespace 后,通常只有一个 loopback 设备,并且它初始可能处于 DOWN 状态:

unshare --net bash
ip link
ip addr

在 namespace 内执行:

ip link set lo up
ip addr show lo

宿主的 eth0ens3 等设备不会直接出现在新 namespace 中。

网络 namespace 隔离的是网络栈,不是网络硬件本身。一个物理网卡只能位于一个 Network namespace 中;要让两个 namespace 通信,通常使用 veth pair、bridge、macvlan、ipvlan 或其他虚拟设备。


2. veth pair 的数据流

veth pair 是一对虚拟以太网设备:

namespace A                         namespace B

veth-A  ─────────── 内核 ───────────  veth-B
  IP: 10.0.0.1/24                    IP: 10.0.0.2/24

veth-A 发出的以太网帧,会从 veth-B 出现。典型配置需要:

  1. 创建两个 Network namespace;
  2. 创建 veth pair;
  3. 将一端移动到目标 namespace;
  4. 给两端配置地址;
  5. 启用设备和 loopback;
  6. 通过路由、bridge 或 NAT 连接外部网络。

下面是一个需要 root 权限的最小实验。它不配置外网,只验证两个 namespace 之间能否通信:

set -eux

ip netns add ns-a
ip netns add ns-b

ip link add veth-a type veth peer name veth-b
ip link set veth-a netns ns-a
ip link set veth-b netns ns-b

ip -n ns-a link set lo up
ip -n ns-b link set lo up

ip -n ns-a addr add 10.200.0.1/24 dev veth-a
ip -n ns-b addr add 10.200.0.2/24 dev veth-b

ip -n ns-a link set veth-a up
ip -n ns-b link set veth-b up

ip netns exec ns-a ping -c 2 10.200.0.2

预期最后能看到两个 ICMP 回复。

每一步成立的原因是:

  • ip netns add 创建并持久化 Network namespace;
  • veth 的两端初始在创建者 namespace;
  • ip link set ... netns 将设备移动到目标 namespace;
  • 两端配置在同一个 /24 子网,不需要额外路由;
  • lo 和 veth 都必须启用,否则即使地址存在也不能正常通信;
  • ping 使用的是 ns-a 的网络栈,目标地址通过 veth 到达 ns-b。

清理:

ip netns del ns-a
ip netns del ns-b

如果脚本中途失败,部分对象可能仍然存在。生产脚本应使用 trap 做清理,并在删除前检查 namespace 是否存在。


3. 让 Network namespace 访问外网

要让容器 namespace 访问外网,通常需要如下路径:

容器进程
  ↓
容器 netns 中的 veth
  ↓
宿主 bridge 或宿主接口
  ↓
宿主路由和 NAT
  ↓
外部网络

除了 veth 配置,还需要:

  • 宿主开启 IPv4 转发;
  • 容器配置默认路由;
  • 宿主配置合适的防火墙和 MASQUERADE/NAT;
  • DNS 配置正确;
  • 如果使用 bridge,还要考虑桥接过滤和网络策略。

例如,容器内可以有:

ip route add default via 10.200.0.1

但这只表示容器将数据包交给网关,并不保证宿主会转发或进行 NAT。若宿主没有转发路径,现象通常是:

容器能 ping 同一 veth 网段
容器不能访问宿主外部地址

诊断时应按路径逐段检查:

ip -n ns-a addr
ip -n ns-a route
ip -n ns-a link
ip addr
ip route
sysctl net.ipv4.ip_forward

再结合:

tcpdump -ni veth-a
tcpdump -ni <宿主接口>

确定数据包消失在容器、宿主路由还是 NAT 环节。


4. Network namespace 与端口隔离

两个不同 Network namespace 可以同时监听相同的 TCP 端口:

ns-a: 0.0.0.0:8080
ns-b: 0.0.0.0:8080

因为它们拥有不同的端口空间。将容器端口发布到宿主,仍然需要额外机制,例如:

  • DNAT;
  • host network;
  • 用户态端口代理;
  • eBPF 或其他转发方案。

因此,“容器内监听 8080”不等于“宿主可以直接访问 8080”。

已经打开的 socket 也不会因为进程之后调用 setns() 就自动迁移。Network namespace 主要影响后续网络操作和新建 socket;已有文件描述符仍然引用原来的内核对象。这是调试和网络代理设计中经常被忽略的生命周期问题。


五、User namespace:UID/GID 映射和能力边界

1. User namespace 解决的是身份映射

User namespace 允许同一个进程在不同 namespace 中拥有不同的 UID/GID。

例如:

宿主 user namespace:       UID 1000
子 User namespace:         UID 0

子 namespace 内的 UID 0 只是该 namespace 内的 root,不等于宿主 UID 0。

映射由以下文件描述:

/proc/<pid>/uid_map
/proc/<pid>/gid_map

一行映射包含:

namespace 内 ID   宿主 ID   连续数量

例如:

0 1000 1

表示:

子 namespace UID 0  ↔  宿主 UID 1000

再如:

0 100000 65536

表示 namespace 内的 UID 0 到 65535 映射到宿主 UID 100000 到 165535。

没有映射的 UID/GID 在跨 namespace 显示时可能显示为 nobody 或其他无效身份。映射范围不能随意重叠,写入 uid_map 通常需要满足创建者身份和权限规则。


2. 创建一个“当前用户是 root”的 User namespace

现代 util-linux 可以直接执行:

unshare --user --map-root-user bash

进入后:

id
cat /proc/self/uid_map
cat /proc/self/gid_map

可能看到:

uid=0(root) gid=0(root) groups=0(root)
         0       1000          1
         0       1000          1

这里的 root 只在该 User namespace 内有效。验证它不是宿主 root:

touch /tmp/userns-test
ls -ln /tmp/userns-test

如果 /tmp 是同一个底层文件系统,宿主可能看到文件所有者是 UID 1000,而不是 UID 0。这正是 UID 映射的效果。


3. gid_mapsetgroups 的顺序

手动配置 User namespace 时,GID 映射比 UID 映射多一个安全步骤。

常见流程是:

创建 User namespace
    ↓
读取子进程 PID
    ↓
向 /proc/<pid>/setgroups 写入 deny
    ↓
写入 /proc/<pid>/uid_map
    ↓
写入 /proc/<pid>/gid_map

典型代码片段:

echo deny > /proc/$child/setgroups
echo "0 $host_uid 1" > /proc/$child/uid_map
echo "0 $host_gid 1" > /proc/$child/gid_map

在允许写入 gid_map 之前,内核要求处理 setgroups 限制,否则可能通过组身份获得超出预期的文件访问权限。具体可写规则还受调用者是否拥有目标 User namespace 中的权限影响。

unshare --map-root-user 会替调用者处理常见映射流程,因此适合实验;容器运行时或自定义 launcher 则必须自己严格处理错误和竞态。


4. User namespace 中的 root 为什么能挂载 tmpfs

一个容易产生误解的实验是:

unshare --user --map-root-user --mount bash
mount -t tmpfs tmpfs /mnt

在支持 unprivileged user namespace 和对应文件系统挂载规则的系统上,这可能成功。原因是:

  1. 当前进程在新 User namespace 中拥有该 namespace 的 root 身份;
  2. 它获得了该 User namespace 中的 CAP_SYS_ADMIN
  3. 新 Mount namespace 由该 User namespace 所拥有;
  4. 内核允许在这种权限关系下执行该挂载。

但这不表示它获得了宿主的 CAP_SYS_ADMIN。它仍然不能任意管理宿主资源,也不能把 namespace 内的 root 直接当作宿主 root。

权限判断通常涉及两个 User namespace:

  • 当前进程拥有哪些 Capability;
  • 被操作对象的 namespace 由哪个 User namespace 所拥有。

因此,“我在容器里是 root”不是完整的权限结论。还要继续检查:

  • 该 namespace 是否是 rootless 创建;
  • 目标资源归哪个 User namespace 管理;
  • 是否缺少某个 Capability;
  • LSM 是否拒绝;
  • sysctl 是否禁用了该功能;
  • 文件系统或设备是否允许该操作。

5. User namespace 与安全边界

User namespace 能降低宿主 UID 权限,但它不是绝对安全沙箱。

风险来源包括:

  • 内核系统调用、文件系统和驱动代码仍然由宿主内核执行;
  • 新增的 namespace root 可能拥有一些 namespace 内 Capability;
  • 某些内核对象可通过间接接口影响更大范围的资源;
  • 内核漏洞可能导致越权;
  • LSM、Seccomp 和设备访问策略配置错误会扩大攻击面。

发行版可能通过以下方式限制 User namespace:

sysctl kernel.unprivileged_userns_clone
sysctl user.max_user_namespaces

不同发行版的 sysctl 名称、默认值和安全策略可能不同。即使 unshare --user 在开发机上成功,也不代表生产主机允许普通用户创建 User namespace。


六、namespace 的进入、保存和生命周期

1. 保存 namespace 引用

Network namespace 可以通过 ip netns 管理:

ip netns add demo
ip netns list
ip netns exec demo ip link

ip netns add 通常会在 /run/netns//var/run/netns/ 下建立持久化引用。即使创建命令退出,namespace 仍可被后续命令使用。

对任意 namespace,可以使用 bind mount 保存引用:

mkdir -p /run/ns-demo
touch /run/ns-demo/mnt
mount --bind /proc/$$/ns/mnt /run/ns-demo/mnt

随后可以通过:

nsenter --mount=/run/ns-demo/mnt bash

进入该 Mount namespace。

如果保存的是 PID namespace,要注意 setns() 的语义:调用进程加入新的 PID namespace 后,调用者自身的 PID 不会在当前进程生命周期中改变;它之后创建的子进程才会进入目标 PID namespace。因此,nsenter --pid 通常需要配合 --fork 才能让新 shell 真正出现在目标 PID namespace 中:

nsenter \
  --mount=/run/ns-demo/mnt \
  --pid=/run/ns-demo/pid \
  --fork \
  bash

2. setns() 的文件描述符和线程问题

底层 setns() 接收一个指向 namespace 文件的文件描述符:

int setns(int fd, int nstype);

fd 可以来自:

/proc/<pid>/ns/net
/run/netns/demo

调用成功后,调用线程的 namespace 关联会改变。多线程程序必须特别小心:

  • 线程可能处于不同的 namespace;
  • 某些 namespace 的切换受 CLONE_THREAD 和用户身份限制;
  • 只有调用 setns() 的线程发生变化;
  • 其他线程不会自动跟随;
  • 同一个进程的线程间可能因此出现资源视图不一致。

容器运行时通常在 exec 前以单线程初始化进程,避免在复杂多线程状态下切换 namespace。

namespace 文件描述符还必须保持打开,直到完成进入或其他需要引用它的操作。进程退出后,若没有文件描述符、bind mount、网络管理器或其他引用,namespace 可能被销毁。


七、组合隔离:多个 namespace 如何共同构成容器

1. 一个最小组合的状态变化

下面的命令创建一个适合教学的组合:

unshare \
  --user \
  --map-root-user \
  --mount \
  --pid \
  --net \
  --uts \
  --ipc \
  --fork \
  --mount-proc \
  bash

进入后可检查:

id
hostname
echo "$$"
ps -ef
ip link
mount | head
readlink /proc/self/ns/{user,mnt,pid,net,uts,ipc}

状态变化可以表示为:

flowchart TD
    A[宿主进程] --> B[创建 User namespace]
    B --> C[建立 UID/GID 映射]
    C --> D[创建 Mount/PID/Network/UTS/IPC namespace]
    D --> E[fork]
    E --> F[子进程成为 PID namespace 内的 PID 1]
    F --> G[重新挂载 proc]
    G --> H[配置 rootfs、网络和能力]
    H --> I[exec 容器初始化程序]

关键因果关系是:

  1. User namespace 先建立,后续 namespace 的权限操作才可以由 namespace 内 root 执行;
  2. PID namespace 需要 fork,创建者自身不会变成新 namespace 的 PID 1;
  3. Mount namespace 必须重新处理 /proc,否则工具看到的仍可能是宿主进程列表;
  4. Network namespace 需要独立配置 loopback、veth、路由和 DNS;
  5. execve() 只替换进程映像,不会自动创建任何 namespace。

2. Mount、PID 和 User 的交互算例

假设容器初始化进程具有以下状态:

PID namespace:
    容器内 PID = 1
    宿主 PID = 42000

User namespace:
    容器内 UID = 0
    宿主 UID = 100000

Mount namespace:
    /proc 是新挂载
    /etc、/app 来自新 rootfs
    /host 未挂载

Network namespace:
    eth0 = 10.0.2.15

此时:

容器内 getpid()          返回 1
宿主 /proc/42000/status  可以观察到宿主 PID
容器 /proc/1/status      只指向容器内 init
容器 id                  显示 uid=0
宿主 ls -ln 文件           可能显示 uid=100000
容器 ip addr              只显示容器网络设备

这几个结果分别来自不同机制:

  • getpid() 的返回值由当前 PID namespace 视图决定;
  • UID 显示由 User namespace 的映射决定;
  • /proc 目录内容由其挂载时绑定的 PID namespace 和当前 Mount namespace 决定;
  • ip 查询的是当前 Network namespace;
  • /app 是否存在取决于 Mount namespace 的根目录和挂载树。

如果只创建 User namespace,而不创建 Mount namespace,那么 namespace 内 root 可能仍然看到宿主的完整路径树。此时身份被映射了,但文件系统视图没有隔离。

如果只创建 Mount namespace,而不创建 User namespace,那么挂载操作通常仍要求宿主权限;文件所有权也仍然使用宿主 UID/GID。

如果只创建 Network namespace,那么进程可能看不到宿主接口,但仍可通过共享的文件系统访问宿主敏感文件。隔离维度不能互相替代。


3. Capabilities 是组合隔离中的权限层

Linux Capability 将传统 root 权限拆成多个能力,例如:

  • CAP_SYS_ADMIN:大量管理操作,包括若干 namespace、挂载和系统管理操作;
  • CAP_NET_ADMIN:网络设备、路由和部分防火墙配置;
  • CAP_SYS_CHROOT:执行某些 chroot 操作;
  • CAP_KILL:绕过部分信号发送权限检查;
  • CAP_SETUIDCAP_SETGID:改变进程身份。

容器运行时通常会删除不需要的 Capabilities。namespace 只限制资源视图,Capabilities 决定进程能否对这些视图执行管理操作。

例如:

Network namespace 隔离了网络设备
CAP_NET_ADMIN 决定进程能否创建、移动或配置这些设备

两者缺一不可。仅有 Network namespace,不代表普通进程可以修改路由;仅删除 CAP_NET_ADMIN,也不代表它会看到宿主的网络接口。

Capabilities 本身也有 User namespace 作用域。一个进程在子 User namespace 中拥有 CAP_NET_ADMIN,并不意味着它可以管理宿主 Network namespace 中的设备。


4. Seccomp 是系统调用入口层

Seccomp 通过 BPF 规则限制进程可以调用哪些系统调用以及参数。它和 namespace 的关系可以概括为:

namespace:隔离资源名称和视图
Capability:限制特权操作
Seccomp:限制系统调用入口
cgroup:限制资源使用量
LSM:限制安全策略和对象访问

例如,创建 namespace 的系统调用通常需要相应权限;即使进程拥有 namespace 内的能力,Seccomp 仍可以拒绝 unshare()setns()

因此,一个容器若要作为沙箱运行,通常会:

  1. 创建所需 namespaces;
  2. 配置最小 rootfs 和挂载;
  3. 删除不需要的 Capabilities;
  4. 安装 Seccomp 规则;
  5. 加入 cgroup;
  6. 再执行不可信程序。

顺序不是唯一固定的,但必须在执行不可信代码前完成。若程序已经以过高权限运行,再事后补限制,可能已经来不及。


八、常见失败表现和诊断路径

1. unshare: Operation not permitted

可能原因包括:

  • 当前用户没有所需 Capability;
  • User namespace 被 sysctl 或发行版策略禁用;
  • user.max_user_namespaces 为 0 或已耗尽;
  • Seccomp 拒绝了相关系统调用;
  • LSM 策略拒绝;
  • 目标 namespace 不属于当前用户 namespace 可管理的范围;
  • 当前进程是受限容器,宿主已经禁止再次创建 namespace。

诊断:

id
capsh --print 2>/dev/null
sysctl user.max_user_namespaces 2>/dev/null
sysctl kernel.unprivileged_userns_clone 2>/dev/null
dmesg | tail
journalctl -k -n 50

不同发行版不一定提供相同 sysctl;读取失败本身不能直接证明功能不存在。


2. 新 PID namespace 中 ps 仍看到宿主进程

优先检查:

findmnt /proc
readlink /proc/self/ns/pid
grep '^NSpid:' /proc/self/status

最常见原因是没有使用:

--mount-proc

或者 /proc 仍然通过共享挂载传播,导致挂载视图不符合预期。应在新 Mount namespace 中设置:

mount --make-rprivate /
mount -t proc proc /proc

手动重新挂载 /proc 前必须确认当前进程确实位于新的 Mount namespace,并且拥有相应权限。


3. 新 Network namespace 没有网络

执行:

ip link
ip addr
ip route

逐项检查:

  1. lo 是否为 UP
  2. veth 是否存在;
  3. veth 是否配置了地址;
  4. 对端是否位于预期 namespace;
  5. 是否有直连路由;
  6. 若访问外部网络,是否有默认路由;
  7. 宿主是否开启转发;
  8. 防火墙和 NAT 是否允许流量。

查看设备所在 namespace:

readlink /proc/<pid>/ns/net
ip link

设备移动后,宿主 namespace 中看不到该设备是正常现象,不是设备丢失。


4. 容器内 root 无法访问文件

首先不要只看 id,还要看数字 UID:

id
ls -ln /path/to/file
cat /proc/self/uid_map
cat /proc/self/gid_map

可能原因:

  • 文件的宿主 UID 没有映射到容器;
  • 目录权限拒绝;
  • ACL 或 LSM 拒绝;
  • Mount namespace 中根本不存在该路径;
  • 文件系统以只读方式挂载;
  • 缺少需要的 Capability;
  • 使用了 rootless 模式,无法访问宿主设备或挂载点。

User namespace 的 root 只改变身份解释,不会绕过所有文件系统和安全策略。


九、版本、权限和生产风险

1. 规范保证与实现差异

namespace 的核心接口由 Linux 内核提供,但命令行为来自 util-linux、iproute2 和容器运行时,不能把命令参数当作内核 ABI。

需要区分:

  • clone()unshare()setns() 是内核系统调用;
  • unshare --map-root-user 是 util-linux 对 User namespace 映射的封装;
  • ip netns 是 iproute2 的管理方式;
  • /run/netns 是常见实现路径,不是所有程序都必须使用的固定目录;
  • systemd、发行版启动脚本可能改变挂载传播和默认权限;
  • Time namespace、cgroup namespace 等能力的可用性依赖内核版本和发行版配置。

生产程序若依赖某个 namespace 功能,应在目标内核上验证,而不能只依据开发机的命令帮助或内核版本号。


2. Namespace 不是资源限制

下面的错误配置不会阻止资源耗尽:

创建了 PID namespace  ≠ 限制进程数量
创建了 Mount namespace ≠ 限制磁盘空间
创建了 Network namespace ≠ 限制带宽
创建了 User namespace ≠ 获得完整安全沙箱

对应的限制通常由 cgroups、quota、tc、Capabilities、Seccomp 和 LSM 实现。尤其是 PID namespace 只改变编号和可见性;需要限制进程数时,应配置 cgroup 的 pids.max 等资源控制项。


3. 生产环境中的主要风险

以下操作具有较高风险:

  • 在未确认传播属性时修改或卸载挂载;
  • 将宿主根目录、设备目录或 Docker/containerd socket 暴露给容器;
  • 保留过多 Capabilities,特别是 CAP_SYS_ADMIN
  • 允许不可信程序创建任意 User、Mount 或 Network namespace;
  • 让容器 PID 1 不处理信号和子进程;
  • 依赖 /proc/sys 的默认挂载而不检查其实际内容;
  • 误以为 rootless namespace 可以访问宿主所有文件;
  • 在多线程进程中使用 setns(),导致线程处于不同资源视图;
  • 只用 namespace 做隔离,却没有配置 cgroup 和 Seccomp。

namespace 降低了资源视图之间的耦合,但所有进程仍共享宿主内核。对抗不可信代码时,必须把内核攻击面、设备访问、系统调用、Capabilities、LSM、运行时版本和补丁状态一起纳入安全边界。


十、从 namespace 到容器的完整心智模型

一个容器化进程的运行状态可以抽象为:

进程身份
  └── User namespace + UID/GID map + Capabilities

进程编号
  └── PID namespace + /proc 挂载

文件系统视图
  └── Mount namespace + rootfs + bind mounts + propagation

网络视图
  └── Network namespace + veth/bridge/route/NAT

进程间通信
  └── IPC namespace + Unix socket 等

资源上限
  └── cgroups

系统调用和内核对象访问
  └── Seccomp + LSM + device policy

其中最重要的因果关系是:

namespace 改变进程所处的内核视图
cgroup 改变资源使用上限
Capability 改变特权操作集合
Seccomp 改变可进入内核的系统调用集合
rootfs 改变用户态看到的文件树

只有把这些层组合起来,才能得到接近现代容器的行为。单独创建 PID、Mount、Network 或 User namespace 都只能解决一个维度的问题;正确的隔离组合还需要处理 namespace 的创建顺序、User/GID 映射、/proc 和挂载传播、网络设备生命周期、PID 1 行为、权限降级以及失败清理路径。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。