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>/status 的 NSpid 字段可以显示这些编号:
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、没有重新挂载 /proc,ps 可能仍然读取宿主的 /proc,表现为看到宿主进程。这不是 PID namespace 失效,而是 /proc 本身仍然是旧挂载视图。
3. PID 1 不是普通进程
PID namespace 的第一个进程承担类似 init 的职责:
- 接收孤儿进程的重新托管。
- 负责回收子进程,避免僵尸进程堆积。
- 其退出会导致该 PID namespace 中剩余进程被内核终止。
- 该 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 创建的挂载。
但这个实验有两个前提:
- 当前用户必须具有挂载所需的权限;普通用户通常需要先创建 User namespace。
- 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. chroot、pivot_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
宿主的 eth0、ens3 等设备不会直接出现在新 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 出现。典型配置需要:
- 创建两个 Network namespace;
- 创建 veth pair;
- 将一端移动到目标 namespace;
- 给两端配置地址;
- 启用设备和 loopback;
- 通过路由、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_map 和 setgroups 的顺序
手动配置 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 和对应文件系统挂载规则的系统上,这可能成功。原因是:
- 当前进程在新 User namespace 中拥有该 namespace 的 root 身份;
- 它获得了该 User namespace 中的
CAP_SYS_ADMIN; - 新 Mount namespace 由该 User namespace 所拥有;
- 内核允许在这种权限关系下执行该挂载。
但这不表示它获得了宿主的 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 容器初始化程序]
关键因果关系是:
- User namespace 先建立,后续 namespace 的权限操作才可以由 namespace 内 root 执行;
- PID namespace 需要 fork,创建者自身不会变成新 namespace 的 PID 1;
- Mount namespace 必须重新处理
/proc,否则工具看到的仍可能是宿主进程列表; - Network namespace 需要独立配置 loopback、veth、路由和 DNS;
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_SETUID、CAP_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()。
因此,一个容器若要作为沙箱运行,通常会:
- 创建所需 namespaces;
- 配置最小 rootfs 和挂载;
- 删除不需要的 Capabilities;
- 安装 Seccomp 规则;
- 加入 cgroup;
- 再执行不可信程序。
顺序不是唯一固定的,但必须在执行不可信代码前完成。若程序已经以过高权限运行,再事后补限制,可能已经来不及。
八、常见失败表现和诊断路径
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
逐项检查:
lo是否为UP;- veth 是否存在;
- veth 是否配置了地址;
- 对端是否位于预期 namespace;
- 是否有直连路由;
- 若访问外部网络,是否有默认路由;
- 宿主是否开启转发;
- 防火墙和 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 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Linux cgroups v2 深入:CPU、Memory、IO、PID、Pressure 和委派
- 下一篇:Linux Seccomp 与 Capabilities:系统调用过滤、最小权限和沙箱
- 延伸:Linux cgroups 与 namespaces:资源控制、隔离和容器底层机制
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论