当你在一个容器里执行 ps aux,只看到自己的进程;执行 ifconfig,只看到自己的网卡;执行 hostname,只看到自己的主机名,这不是魔法,而是 Linux Namespace 在工作。Namespace 让每个进程拥有独立的系统资源视图,仿佛运行在专属的操作系统中。
Namespace 的概念并非 Linux 首创。1992 年,Bell Labs 的 Plan 9 操作系统率先提出了 “per-process namespace” 的理念,每个进程可以拥有独立的文件系统命名空间。这一思想深刻影响了后来的 Linux 内核设计。2002 年,Linux 2.4.19 引入了第一个 Namespace,Mount Namespace,允许进程拥有独立的文件系统挂载视图。此后十余年间,内核逐步添加了其余 7 种 Namespace:
Mount Namespace
2.4.19,文件系统挂载视图隔离,Linux 第一个 Namespace
UTS Namespace
2.6.19,主机名与域名隔离
IPC Namespace
2.6.19,System V IPC 隔离(POSIX 消息队列隔离至 2.6.30 加入)
PID Namespace
2.6.24,进程号隔离
Network Namespace
2.6.29,网络栈隔离(网卡、路由、端口);CLONE_NEWNET 于 2.6.24 引入,至 2.6.29 完整可用
User Namespace
3.8,用户与组 ID 隔离;框架引入于 2.6.23,3.8 才支持非特权创建
Cgroup Namespace
4.6,Cgroup 视图隔离
Time Namespace
5.6,系统时钟与引导时钟隔离
2013 年 Docker 诞生后,Namespace 从”内核开发者的小众特性”一跃成为容器技术的基石,容器的”视图隔离”全部由 Namespace 实现。这些差异根植于它们各自的历史背景和设计目标。
前置知识
Linux 系统编程基础:
clone()、unshare()、setns()三个系统调用是操作 Namespace 的主要接口Ch01 容器全景:从 chroot 到 OCI:建立容器技术的全景认知,理解 Namespace 在容器架构中的位置
Linux
/proc文件系统:Namespace 的信息通过/proc/[pid]/ns/目录暴露
Namespace 决定进程”能看到什么”,Cgroup 决定进程”能用多少”,两者经常被混淆。
Namespace 有 8 种,行为差异很大,User Namespace 允许非特权创建,Network Namespace 却需要 CAP_NET_ADMIN。为什么?答案藏在各自的历史背景和设计目标里。下面逐一拆解。
一、Namespace 基本概念
1.1 什么是 Namespace?
Namespace 的设计思想是资源隔离:将全局资源包装为一个抽象,让 Namespace 内的进程看起来拥有独立的资源实例。内核为每种资源维护了一个映射表,将 Namespace 内的虚拟 ID 映射到全局的实际 ID。
// Linux 内核中 Namespace 的核心数据结构(简化)struct nsproxy { struct uts_namespace *uts_ns; // UTS: 主机名 struct ipc_namespace *ipc_ns; // IPC: 进程间通信 struct mnt_namespace *mnt_ns; // Mount: 挂载点 struct pid_namespace *pid_ns; // PID: 进程 ID struct net *net_ns; // Network: 网络栈 struct cgroup_namespace *cgroup_ns; // Cgroup: Cgroup 视图 struct user_namespace *user_ns; // User: 用户 ID struct time_namespace *time_ns; // Time: 时钟};
// 每个进程的 task_struct 包含 nsproxy 指针struct task_struct { struct nsproxy *nsproxy; // ...};1.2 三个系统调用
操作 Namespace 只有三个系统调用,区别在于作用对象不同:clone() 给新进程一个新 Namespace,unshare() 把当前进程自己搬进新 Namespace,setns() 让当前进程钻进一个已经存在的 Namespace。
| 系统调用 | 作用对象 | 关键参数 |
|---|---|---|
clone() | 新建的子进程 | CLONE_NEWPID, CLONE_NEWNET, … |
unshare() | 当前进程自己 | 同上 |
setns() | 已存在的 Namespace | fd(指向 /proc/PID/ns/ 的文件描述符) |
clone 和 unshare 都靠同一组 CLONE_NEW* 标志指定要隔离哪些资源;setns 要加入的 Namespace 已经存在,参数换成指向 /proc/PID/ns/ 下某个文件的 fd。
日常用得最多的是命令行封装:
# unshare + fork 语义:先 unshare 把当前进程搬进新 Namespace,再 fork 出子进程(子进程继承新 Namespace)sudo unshare --mount --pid --fork --mount-proc /bin/bash
# unshare 语义:不 fork,把当前 shell 自己关进新 Namespacesudo unshare --mount /bin/bash
# setns 语义:钻进已有容器的 Namespacesudo nsenter -t $(docker inspect -f '{{.State.Pid}}' mycontainer) -n ip addrNamespace 的继承与嵌套关系,见 1.3。
1.3 Namespace 的继承与嵌套
Namespace 支持嵌套,一个 Namespace 可以是另一个 Namespace 的子 Namespace。子 Namespace 中的资源对父 Namespace 可见(反之不一定),这取决于具体的 Namespace 类型。
二、PID Namespace
2.1 原理
PID Namespace 隔离进程 ID 号空间。在新的 PID Namespace 中,第一个进程的 PID 为 1,后续进程依次递增。宿主机上,这些进程仍然有全局唯一的 PID。
# 在容器中看到的 PIDdocker exec mycontainer ps aux# PID USER COMMAND# 1 root nginx: master process# 10 nginx nginx: worker process
# 在宿主机上看到的同一进程ps aux | grep nginx# root 12345 ... nginx: master process# 101 12346 ... nginx: worker process2.2 PID 1 的特殊性
PID 1 在 Linux 中有特殊地位:
| 特性 | 说明 |
|---|---|
| 信号处理 | PID 1 默认忽略 SIGINT 和 SIGTERM,除非显式注册处理函数 |
| 僵尸回收 | PID 1 负责回收所有子进程的退出状态(wait) |
| 优雅退出 | 容器停止时,PID 1 收到 SIGTERM,应优雅关闭子进程 |
PID 1 默认忽略 SIGTERM/SIGINT 不是约定俗成,而是内核有意为之:内核担心 init 进程被误杀会导致整个命名空间不可用,所以在信号投递路径上对 PID 1 做了特殊处理,没有显式注册 handler 的终止类信号会被丢弃。这也是为什么容器里若直接用 shell 当 PID 1,docker stop 发的 SIGTERM 会被忽略,超时后只能靠 SIGKILL 强杀。
很多容器因为 PID 1 进程选择不当(如使用 shell 脚本启动应用),导致无法优雅退出或僵尸进程堆积。推荐使用 tini 或 dumb-init 作为 PID 1 进程。
2.3 PID Namespace 的限制
- PID Namespace 嵌套最多 32 层
- 从父 Namespace 可以看到子 Namespace 的进程(但 PID 不同)
- 从子 Namespace 无法看到父 Namespace 的进程
kill系统调用只能发送信号给同一 Namespace 内的进程(除非有 CAP_KILL)
三、Mount Namespace
3.1 原理
Mount Namespace 隔离文件系统挂载点视图。不同 Mount Namespace 中的进程可以看到不同的挂载点列表。
# 创建新的 Mount Namespacesudo unshare --mount /bin/bash
# 在新 Namespace 中挂载 tmpfsmount -t tmpfs tmpfs /mntmount | grep /mnt# tmpfs on /mnt type tmpfs
# 在另一个终端(宿主 Namespace)查看mount | grep /mnt# 看不到 /mnt 的挂载:因为 Mount Namespace 隔离了挂载点视图3.2 共享子树(Shared Subtrees)
Mount Namespace 的关键特性是共享子树传播类型,它决定了挂载事件如何在 Namespace 之间传播:
| 传播类型 | 说明 | 典型用途 |
|---|---|---|
shared | 挂载/卸载事件传播到对等组 | 系统默认,USB 热插拔 |
slave | 只接收对等组的传播,不反向传播 | 容器只读共享宿主挂载 |
private | 不传播也不接收 | 容器独立挂载 |
unbindable | 不能被 bind mount | 防止递归挂载 |
# 查看挂载点的传播类型findmnt -o TARGET,PROPAGATION
# 将挂载点设为 private(容器常用)mount --make-private /
# 将挂载点设为 slavemount --make-slave /sysLinux 内核默认将根文件系统的传播类型设为 shared。这个默认值对桌面和服务器场景很合理:插入 U 盘后,宿主挂载了 /run/media/user/usb1,所有 Mount Namespace 都能看到这个新挂载点,不用手动同步。但在容器场景下,shared 传播会破坏隔离。
假设容器继承了宿主的 shared 传播类型,容器内执行 mount -t tmpfs tmpfs /mnt,这个挂载事件会反向传播到宿主的 Mount Namespace,宿主机上也会出现 /mnt 的 tmpfs 挂载。反过来,宿主插入 U 盘触发的新挂载也会传播进容器,容器内凭空多出一个 /run/media/user/usb1,这既破坏了容器的文件系统视图隔离,也可能带来安全问题:容器内的恶意进程可以通过挂载事件窥探宿主的设备变动。
runc 在创建容器时,第一步就是将根挂载点设为 private:
mount --make-rprivate /--make-rprivate 的 r 表示递归,将根目录下所有挂载点的传播类型都设为 private。这样一来,容器内的挂载/卸载操作不会泄漏到宿主,宿主的挂载变动也不会渗入容器,每个 Mount Namespace 的挂载视图完全独立。
如果确实需要宿主和容器共享某些挂载点(比如 Kubernetes 的 HostPath 卷),可以在 private 的基础上,对特定挂载点单独设为 slave,只允许单向接收宿主的传播,不允许反向泄漏。
3.3 容器中的 Mount 操作
runc 在创建容器时,不会继承宿主的 /proc、/sys、/dev 等关键文件系统,而是在新的 Mount Namespace 中重新挂载。原因很简单:宿主的这些文件系统暴露的是宿主内核的状态,直接继承等于放弃隔离。
| 挂载操作 | 隔离原因 |
|---|---|
mount -t proc proc /proc | 宿主的 /proc 暴露宿主所有进程的 PID、状态、命令行,容器必须挂载自己的 procfs,只显示容器 PID Namespace 内的进程 |
mount -t sysfs sysfs /sys | 宿主的 /sys 暴露所有网络设备、内核模块、电源状态,容器重新挂载 sysfs 后只能看到自己的网络设备和内核参数 |
mount -t devtmpfs devtmpfs /dev | 宿主的 /dev 包含所有物理设备节点(磁盘、USB、GPU),容器重新挂载 devtmpfs 后只看到通用设备节点,再通过白名单(device cgroup 或 device whitelist)进一步限制可访问的设备 |
mount -t tmpfs tmpfs /dev/shm | /dev/shm 是 POSIX 共享内存的挂载点,必须用 tmpfs 提供独立的内存文件系统。如果共享宿主的 /dev/shm,不同容器可以通过共享内存文件互相读写,破坏 IPC 隔离 |
mount -t tmpfs tmpfs /run | /run 存放运行时临时文件(PID 文件、socket 文件),每个容器需要独立的 /run 避免与宿主或其他容器的运行时状态冲突 |
mount -t cgroup2 cgroup2 /sys/fs/cgroup | 容器内需要看到自己的 Cgroup 视图,配合 Cgroup Namespace 让容器以为自己在 Cgroup 根目录,而不是宿主 Cgroup 子树的某个子节点 |
# runc 的典型 mount 操作(简化)mount --make-rprivate / # 先设为 private,阻断传播mount -t proc proc /proc # 挂载 procfsmount -t sysfs sysfs /sys # 挂载 sysfsmount -t devtmpfs devtmpfs /dev # 挂载 devtmpfsmount -t tmpfs tmpfs /dev/shm # 挂载共享内存mount -t tmpfs tmpfs /run # 挂载运行时目录mount -t cgroup2 cgroup2 /sys/fs/cgroup # 挂载 Cgroup v2其中 /dev/shm 的隔离容易被忽视。POSIX 共享内存通过 shm_open() 在 /dev/shm 下创建文件,多个进程可以 mmap 同一文件实现内存共享。如果两个容器共享宿主的 /dev/shm,容器 A 创建的共享内存文件,容器 B 可以直接打开并读写,IPC Namespace 的隔离就被绕过了。所以 runc 为每个容器挂载独立的 tmpfs 到 /dev/shm,确保共享内存的作用域限制在容器内部。
四、Network Namespace
4.1 原理
Network Namespace 隔离网络栈,包括网络设备、IP 地址、路由表、端口号、iptables 规则等。
# 创建 Network Namespacesudo ip netns add ns1sudo ip netns add ns2
# 查看 Namespace 中的网络设备sudo ip netns exec ns1 ip link# 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN# 只有 loopback 设备,且处于 DOWN 状态
# 创建 veth pair 连接两个 Namespacesudo ip link add veth1 type veth peer name veth2sudo ip link set veth1 netns ns1sudo ip link set veth2 netns ns2
# 配置 IP 地址sudo ip netns exec ns1 ip addr add 10.0.0.1/24 dev veth1sudo ip netns exec ns1 ip link set veth1 upsudo ip netns exec ns1 ip link set lo up
sudo ip netns exec ns2 ip addr add 10.0.0.2/24 dev veth2sudo ip netns exec ns2 ip link set veth2 upsudo ip netns exec ns2 ip link set lo up
# 测试连通性sudo ip netns exec ns1 ping -c 3 10.0.0.24.2 容器网络模式
Docker 支持多种网络模式,每种模式对 Network Namespace 的使用不同:
| 网络模式 | Namespace 策略 | 特点 |
|---|---|---|
| bridge | 独立 Network NS + veth pair | 默认模式,容器有独立 IP |
| host | 共享宿主 Network NS | 性能最好,无网络隔离 |
| none | 独立 Network NS,仅 loopback | 无网络,用于安全隔离 |
| container | 共享另一容器的 Network NS | Pod 内容器共享网络栈 |
4.3 Network Namespace 与 CNI
Kubernetes 通过 CNI(Container Network Interface)插件配置 Pod 的网络:
{ "cniVersion": "0.4.0", "name": "bridge-network", "type": "bridge", "bridge": "cni0", "ipam": { "type": "host-local", "subnet": "10.244.0.0/16", "routes": [{"dst": "0.0.0.0/0"}] }}Network Namespace 是容器网络的基础,但 CNI 插件负责了 veth pair 创建、bridge 连接、IP 分配、路由配置等复杂工作。详见 Ch11 容器网络。
五、User Namespace
5.1 原理
User Namespace 隔离用户和组 ID。最强大的特性是UID 映射:容器内的 root(UID 0)可以映射到宿主机上的普通用户(UID 100000),实现 rootless 容器。
# 创建 User Namespace,映射 UIDsudo unshare --user --map-root-user /bin/bash
# 在新 User Namespace 中id# uid=0(root) gid=0(root)
# 但在宿主机上,这个进程的实际 UID 是普通用户# 从另一个终端查看ps -o pid,uid,ruid,comm -p $(pgrep -f "unshare")5.2 UID/GID 映射
User Namespace 通过 /proc/PID/uid_map 和 /proc/PID/gid_map 定义映射关系:
# 查看 Docker 容器的 UID 映射cat /proc/$(docker inspect -f '{{.State.Pid}}' mycontainer)/uid_map# 0 100000 65536# 含义:容器内 UID 0-65535 → 宿主机 UID 100000-165535
# 手动配置 UID 映射echo "0 100000 65536" > /proc/$PID/uid_mapecho "0 100000 65536" > /proc/$PID/gid_map5.3 User Namespace 与 Capability
User Namespace 的一个关键特性:在新的 User Namespace 中,进程拥有全部 Capability,但这些 Capability 只在该 Namespace 内有效。
六、IPC / UTS / Cgroup / Time Namespace
6.1 IPC Namespace
IPC Namespace 隔离 System V IPC 对象和 POSIX 消息队列:
# 查看 System V IPC 对象ipcs -q # 消息队列ipcs -m # 共享内存ipcs -s # 信号量
# 在新 IPC Namespace 中,看不到宿主的 IPC 对象sudo unshare --ipc /bin/bashipcs -q # 空的6.2 UTS Namespace
UTS Namespace 隔离主机名和域名(源自 UNIX Time-Sharing System):
# 创建新 UTS Namespace 并设置主机名sudo unshare --uts /bin/bashhostname mycontainerhostname# mycontainer
# 宿主机的主机名不受影响6.3 Cgroup Namespace
Cgroup Namespace 隔离 Cgroup 根目录视图。在容器内,进程只能看到自己的 Cgroup 子树:
# 不使用 Cgroup Namespacecat /proc/self/cgroup# 0::/system.slice/docker-abc123.scope → 看到完整路径
# 使用 Cgroup Namespacesudo unshare --cgroup /bin/bashcat /proc/self/cgroup# 0::/ → 看到的是根目录,不知道自己在子树中6.4 Time Namespace
Time Namespace(Linux 5.6+)隔离 CLOCK_BOOTTIME 和 CLOCK_MONOTONIC 时钟,主要用于容器迁移场景(如 checkpoint/restore):
# 创建 Time Namespacesudo unshare --time /bin/bash
# 修改 Time Namespace 的偏移# /proc/PID/timens_offsets七、Namespace 组合与容器配置
7.1 runc 的默认 Namespace 配置
runc 创建容器时,默认配置 6 种 Namespace:
{ "linux": { "namespaces": [ { "type": "pid" }, { "type": "mount" }, { "type": "ipc" }, { "type": "uts" }, { "type": "network" }, { "type": "cgroup" } ] }}7.2 Namespace 组合对比
| 组合方式 | 用途 | 示例 |
|---|---|---|
| 全部独立 | 标准容器 | docker run |
| 共享 Network NS | Pod 内容器 | Kubernetes Pod |
| 共享 IPC NS | 进程间通信 | System V 共享内存 |
| 共享 PID NS | 进程可见 | 调试 sidecar |
| User NS + 其他 NS | Rootless 容器 | Podman rootless |
Kubernetes Pod 中多个容器共享 Network Namespace 时,应用之间可以通过 localhost 直接通信,但端口不能冲突。设计 Pod 时要为每个容器规划好监听端口,避免端口抢占导致启动失败。
7.3 Namespace 与安全
Namespace 做的是视图隔离,不是安全边界。它决定进程能看到什么,却不决定进程能对内核做什么。把 Namespace 当成安全防线,是容器事故里最常见的认知错位。下面的图把 Namespace 放进整个安全层级,并标出几条已知的逃逸路径,重点不在”哪层能挡住哪条路”,而在”每层都只是缓解、而非阻断”。
图里的”可缓解”是刻意没用”可以阻止”。以 /proc/sysrq-trigger 为例,写入它需要 CAP_SYS_ADMIN,只有 drop 掉这个 capability 才算堵住;特权容器默认带着它,Capability 层就形同虚设。内核漏洞(E2)更彻底,seccomp 能拦截掉被滥用的具体系统调用,但挡不住漏洞本身存在于内核里。每层防护都依赖前置条件是否配齐,不存在哪一层能单挑哪条逃逸路径。
八、动手实践
8.1 用 Go 创建隔离进程
package main
import ( "fmt" "os" "os/exec" "syscall")
func main() { switch os.Args[1] { case "run": run() case "child": child() default: panic("invalid command") }}
func run() { cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr
// 创建新的 Namespace cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC, }
must(cmd.Run())}
func child() { fmt.Printf("Running %v as PID %d\n", os.Args[2:], os.Getpid())
// 挂载 proc(在新 Mount Namespace 中) must(syscall.Mount("proc", "/proc", "proc", 0, ""))
cmd := exec.Command(os.Args[2], os.Args[3:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr must(cmd.Run())}
func must(err error) { if err != nil { panic(err) }}8.2 Namespace 可视化脚本
#!/bin/bash# 可视化进程的 Namespace 关系
echo "=== 容器进程的 Namespace ==="for pid in $(docker top mycontainer -o pid | tail -n +2); do echo "PID $pid:" ls -la /proc/$pid/ns/ 2>/dev/null | awk '{print " " $NF}'done
echo ""echo "=== 宿主进程的 Namespace ==="echo "PID 1 (systemd):"ls -la /proc/1/ns/ | awk '{print " " $NF}'
echo ""echo "=== Namespace 差异 ==="echo "容器进程与宿主进程的 Namespace 不同 = 隔离生效"附、实践:用 unshare 手工创建隔离环境
本节用 unshare 命令手工创建各种 Namespace,观察隔离效果。所有命令在 Linux 系统上以 root 权限运行。
附.1 观察宿主进程的 Namespace
每个进程的 Namespace 信息通过 /proc/[pid]/ns/ 目录暴露:
ls -la /proc/self/ns/cgroup:[4026531835]ipc:[4026532213]mnt:[4026532224]net:[4026531840]pid:[4026532226]time:[4026531834]user:[4026531837]uts:[4026532225]方括号中的数字是 Namespace 的 inode 号。同一 inode 号表示同一 Namespace。
附.2 UTS Namespace:独立主机名
# 在新 UTS Namespace 中设置主机名unshare --uts sh -c 'hostname isolated-host && hostname'# isolated-host
# 宿主主机名未变hostname# LAPTOP-NMOAUL8EUTS Namespace 让每个隔离环境拥有独立的主机名,容器中的 hostname 命令只影响自己的 Namespace。
附.3 PID Namespace:独立进程树
# 创建新的 PID Namespace,--fork 必须,--mount-proc 让 ps 只看到新 Namespace 的进程unshare --pid --fork --mount-proc sh -c 'echo "容器内 PID: $$" && ps aux'容器内 PID: 1USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMANDroot 1 0.0 0.0 2800 1664 ? S 01:32 0:00 sh -c echo "容器内 PID: $$" && ps auxroot 2 0.0 0.0 11320 4352 ? R 01:32 0:00 ps aux关键观察:在新 PID Namespace 中,进程的 PID 从 1 开始,ps aux 只显示同一 Namespace 中的进程。宿主进程的 PID 完全不同:
echo "宿主 PID: $$"# 宿主 PID: 3361650--mount-proc 会重新挂载 /proc,这是 ps 命令能正确显示新 PID Namespace 进程的前提。如果不加 --mount-proc,ps 仍会读取宿主的 /proc,显示所有进程。
附.4 Network Namespace:独立网络栈
# 创建新的 Network Namespaceunshare --net sh -c 'ip link show'1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00新 Network Namespace 只有 loopback 接口,且状态为 DOWN,没有 eth0、没有 IP 地址、无法通信。这正是容器网络需要 CNI 插件配置的原因(详见 Ch11 容器网络)。
附.5 用 setns 加入已有 Namespace
nsenter 命令可以进入一个正在运行的容器的 Namespace:
# 找到容器的 PIDCONTAINER_PID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
# 进入容器的 Namespacensenter -t $CONTAINER_PID -m -p -u -i -n -- /bin/sh这等价于 docker exec,但 nsenter 更底层,它直接调用 setns() 系统调用,不经过 Docker API。
九、本章小结
上一章从全景视角介绍了容器全景与三大内核基石。本章逐一拆解了 8 种 Namespace 的隔离范围与行为差异,从 PID 1 的特殊性到 Mount Namespace 的共享子树传播,从 User Namespace 的 UID 映射到 Network Namespace 的 veth pair 组网。这些差异根植于各自的历史背景和设计目标,理解它们才能在容器配置中做出合理选择。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






