Linux内核分析之进程管理-01
9.1 命名空间概述与设计哲学
9.1.1 为什么需要命名空间
传统的 UNIX 系统设计中,许多核心系统资源是全局共享的。所有进程共享同一个进程 ID 空间:PID 1 永远是 init 进程;所有进程看到同一棵挂载树:mount 和 umount 操作对全局可见;所有进程共享同一个主机名:通过 uname() 系统调用获取;所有进程使用同一套网络栈:网卡、路由表、iptables 规则全局唯一。
这种全局共享的设计在服务器虚拟化和应用隔离场景下暴露了严重问题。假设一台物理服务器上需要同时运行多个互不干扰的服务,全局资源意味着:一个进程的错误操作(如卸载文件系统)会影响所有其他进程;无法为不同应用提供独立的网络配置;无法限制进程的 PID 可见范围;无法为不同应用分配不同的主机名。
命名空间的核心目的就是将这些全局资源 虚拟化。它为每组进程创建独立的资源视图,使得不同命名空间中的进程仿佛运行在各自独立的系统上。这种隔离不是硬件级别的虚拟化(如 KVM 那样模拟完整的硬件),而是操作系统级别的虚拟化——在共享同一个内核的前提下,通过修改内核的资源访问路径来实现隔离。
9.1.2 设计哲学:单一职责与组合
Linux 命名空间的设计遵循一个清晰的原则:每种命名空间类型只负责隔离一类系统资源。Mount 命名空间只管挂载点,PID 命名空间只管进程 ID,Network 命名空间只管网络栈,以此类推。
这种单一职责的设计带来了极大的灵活性。用户不需要一次性创建所有类型的命名空间,而是可以按需选择。例如,只需要隔离网络配置的应用可以仅创建 Network 命名空间,而不影响文件系统视图和进程 ID。Docker 这样的容器运行时则会创建所有类型的命名空间,实现完整的隔离。
多个命名空间类型的组合是通过 struct nsproxy 实现的。该结构体定义在 include/linux/nsproxy.h(第 32-42 行):
struct nsproxy {
refcount_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns;
struct time_namespace *time_ns;
struct time_namespace *time_ns_for_children;
struct cgroup_namespace *cgroup_ns;
};
每个进程的 task_struct 持有一个指向 nsproxy 的指针。当多个进程共享完全相同的命名空间集合时,它们共享同一个 nsproxy 实例(通过引用计数 count 管理)。当某个进程需要切换其中一个命名空间时,内核会复制整个 nsproxy(写时复制语义),在新的 nsproxy 中替换对应的命名空间指针。
注意 nsproxy 中存在两个特殊的 PID/Time 命名空间指针:pid_ns_for_children 和 time_ns_for_children。这些代表的是未来子进程将使用的命名空间,而非当前进程自身所处的命名空间。当前进程的 PID 命名空间通过 task_active_pid_ns() 获取,它追溯的是进程创建时绑定的 struct pid 中记录的命名空间。
9.1.3 八种命名空间类型
Linux 7.0 内核支持 8 种命名空间类型,以下逐一介绍。
Mount 命名空间 (CLONE_NEWNS, 0x00020000)
Mount 命名空间是 Linux 内核中最早实现的命名空间类型,于 2002 年的 Linux 2.4.19 版本引入。它隔离的是进程看到的文件系统挂载点列表。每个 Mount 命名空间拥有独立的挂载树,在一个命名空间中执行 mount 或 umount 操作不会影响其他命名空间(除非使用了共享子树传播机制)。
Mount 命名空间是实现容器独立文件系统视图的基础。容器通常会在自己的 Mount 命名空间中挂载独立的根文件系统(rootfs),从而与宿主机的文件系统完全隔离。
标志常量定义在 include/uapi/linux/sched.h 第 20 行:
#define CLONE_NEWNS 0x00020000 /* New mount namespace group */
UTS 命名空间 (CLONE_NEWUTS, 0x04000000)
UTS 命名空间(取自 UNIX Time-Sharing System)隔离的是系统的主机名(hostname)和域名(domain name),即 uname() 系统调用返回的 nodename 和 domainname 字段。每个 UTS 命名空间可以独立设置主机名,使得容器内部看到的主机名与宿主机不同。
#define CLONE_NEWUTS 0x04000000 /* New utsname namespace */
IPC 命名空间 (CLONE_NEWIPC, 0x08000000)
IPC 命名空间隔离的是 System V IPC 对象(信号量、共享内存、消息队列)和 POSIX 消息队列。每个 IPC 命名空间拥有独立的 IPC 资源标识符空间,不同命名空间中的进程无法互相访问对方的 IPC 对象。
#define CLONE_NEWIPC 0x08000000 /* New ipc namespace */
PID 命名空间 (CLONE_NEWPID, 0x20000000)
PID 命名空间隔离的是进程 ID 号。每个 PID 命名空间拥有独立的从 1 开始的 PID 分配空间。在子 PID 命名空间中创建的第一个进程获得 PID 1,充当该命名空间的 init 进程。同一个进程在不同的 PID 命名空间层级中拥有不同的 PID 值。
PID 命名空间是层级化的——子命名空间中的进程在所有祖先命名空间中均可见(但拥有不同的 PID),但父命名空间中的进程在子命名空间中不可见。
#define CLONE_NEWPID 0x20000000 /* New pid namespace */
Network 命名空间 (CLONE_NEWNET, 0x40000000)
Network 命名空间提供完整的网络栈隔离,包括独立的网络设备接口、IP 地址、路由表、端口号空间、iptables/nftables 规则、/proc/net 和 /sys/class/net 目录内容等。每个 Network 命名空间是一套完全独立的网络协议栈实例。
#define CLONE_NEWNET 0x40000000 /* New network namespace */
User 命名空间 (CLONE_NEWUSER, 0x10000000)
User 命名空间隔离的是用户 ID 和组 ID。它允许在命名空间内部将普通用户映射为 root(UID 0),而在外部仍然是普通用户。这是实现"无特权容器"的关键机制——非 root 用户可以创建 User 命名空间,在其中拥有 root 权限,但这个 root 权限被限制在命名空间内部。
User 命名空间也是层级化的,它还控制着其他命名空间的创建权限:创建大多数其他命名空间需要 CAP_SYS_ADMIN 能力,而这个能力检查是在目标 User 命名空间中进行的。
#define CLONE_NEWUSER 0x10000000 /* New user namespace */
Cgroup 命名空间 (CLONE_NEWCGROUP, 0x02000000)
Cgroup 命名空间隔离的是进程看到的 cgroup 层级视图。在 Cgroup 命名空间中,进程看到的 cgroup 根目录是命名空间创建时进程所在的 cgroup 目录,而非系统真正的 cgroup 根目录。这使得容器内部的进程无法看到宿主机的 cgroup 全貌。
#define CLONE_NEWCGROUP 0x02000000 /* New cgroup namespace */
Time 命名空间 (CLONE_NEWTIME, 0x00000080)
Time 命名空间是 8 种命名空间中最晚加入的,于 Linux 5.6 引入。它隔离的是 CLOCK_MONOTONIC 和 CLOCK_BOOTTIME 两种时钟。进程通过 clock_gettime() 获取的时间值可以根据命名空间的不同而产生偏移。这对容器检查点/恢复(checkpoint/restore)场景至关重要——恢复后的容器可以将时钟调整到被检查点时的值。
#define CLONE_NEWTIME 0x00000080 /* New time namespace */
9.1.4 创建命名空间的三种方式
Linux 提供了三个系统调用来创建或切换命名空间。
clone() 系统调用
clone() 和 clone3() 系统调用在创建新进程时可以通过传入 CLONE_NEW* 标志来同时创建新的命名空间。新进程将自动成为新命名空间的成员。这是最传统的命名空间创建方式。
clone3() 系统调用使用 struct clone_args 结构体传参(定义在 include/uapi/linux/sched.h 第 92-104 行),支持更多的选项,例如 set_tid 字段允许指定新进程在各级 PID 命名空间中的 PID。
unshare() 系统调用
unshare() 系统调用将调用进程自身从当前命名空间移动到新创建的命名空间中。它不会创建新进程,而是修改当前进程的命名空间上下文。调用之后,当前进程及其后续创建的子进程将处于新的命名空间中。
在内核中的实现位于 kernel/nsproxy.c 的 unshare_nsproxy_namespaces() 函数(第 211-235 行)。该函数首先检查是否传入了任何 CLONE_NEW* 标志,然后检查调用者是否拥有 CAP_SYS_ADMIN 能力,最后调用 create_new_namespaces() 创建新的命名空间集合。
int unshare_nsproxy_namespaces(unsigned long unshare_flags,
struct nsproxy **new_nsp, struct cred *new_cred, struct fs_struct *new_fs)
{
struct user_namespace *user_ns;
int err = 0;
if (!(unshare_flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
CLONE_NEWNET | CLONE_NEWPID | CLONE_NEWCGROUP |
CLONE_NEWTIME)))
return 0;
user_ns = new_cred ? new_cred->user_ns : current_user_ns();
if (!ns_capable(user_ns, CAP_SYS_ADMIN))
return -EPERM;
*new_nsp = create_new_namespaces(unshare_flags, current, user_ns,
new_fs ? new_fs : current->fs);
...
}
setns() 系统调用
setns() 系统调用将调用进程加入到一个已存在的命名空间中。它接受一个指向命名空间的文件描述符(通过打开 /proc/[pid]/ns/ 下的符号链接获得)和命名空间类型标志。setns() 不创建新命名空间,而是切换到已有的命名空间。
setns() 的实现位于 kernel/nsproxy.c 第 563-601 行。它首先准备一个 nsset 结构体,然后通过 validate_nsset() 验证目标命名空间是否可以安装,最后通过 commit_nsset() 提交切换。
SYSCALL_DEFINE2(setns, int, fd, int, flags)
{
CLASS(fd, f)(fd);
struct ns_common *ns = NULL;
struct nsset nsset = {};
int err = 0;
if (fd_empty(f))
return -EBADF;
if (proc_ns_file(fd_file(f))) {
ns = get_proc_ns(file_inode(fd_file(f)));
if (flags && (ns->ns_type != flags))
err = -EINVAL;
flags = ns->ns_type;
} else if (!IS_ERR(pidfd_pid(fd_file(f)))) {
err = check_setns_flags(flags);
} else {
err = -EINVAL;
}
...
}
9.1.5 命名空间的层级关系
不同类型的命名空间在层级关系上存在重要差异,这直接影响着命名空间之间的可见性和交互规则。
扁平命名空间
大多数命名空间类型是扁平的,不存在层级关系:UTS、IPC、Network、Cgroup 命名空间各自独立,不同实例之间没有父子关系。一个 Network 命名空间中的网络栈与另一个 Network 命名空间中的网络栈完全独立,不存在"嵌套"概念。
层级化命名空间
PID 命名空间和 User 命名空间是层级化的。PID 命名空间可以嵌套,子命名空间中的进程在所有祖先命名空间中都可见(但拥有不同的 PID),反之则不可见。User 命名空间同样支持嵌套,内层的 User 命名空间的能力受到外层的约束。
Mount 命名空间通过共享子树(Shared Subtrees)机制建立了一种类似层级的传播关系,但本质上每个 Mount 命名空间都是独立的。挂载事件的传播是通过 peer group 实现的,而非严格的父子关系。
9.1.6 /proc/[pid]/ns/ 目录
Linux 内核通过 /proc/[pid]/ns/ 目录向用户空间暴露进程的命名空间信息。该目录为每个进程展示一系列符号链接,每个链接代表该进程所属的一种命名空间实例。
fs/proc/namespaces.c 中的 ns_entries 数组(第 15-40 行)定义了所有支持的命名空间条目:
static const struct proc_ns_operations *const ns_entries[] = {
#ifdef CONFIG_NET_NS
&netns_operations,
#endif
#ifdef CONFIG_UTS_NS
&utsns_operations,
#endif
#ifdef CONFIG_IPC_NS
&ipcns_operations,
#endif
#ifdef CONFIG_PID_NS
&pidns_operations,
&pidns_for_children_operations,
#endif
#ifdef CONFIG_USER_NS
&userns_operations,
#endif
&mntns_operations,
#ifdef CONFIG_CGROUPS
&cgroupns_operations,
#endif
#ifdef CONFIG_TIME_NS
&timens_operations,
&timens_for_children_operations,
#endif
};
每个符号链接指向一个 nsfs 文件系统中的 inode。关键特性包括:
相同的 inode 表示相同的命名空间。可以通过 stat() 系统调用获取符号链接的目标 inode 号。如果两个进程的 /proc/[pid]/ns/pid 指向同一个 inode 号,则它们处于同一个 PID 命名空间中。
打开符号链接可以获得命名空间的文件描述符。通过 open() 打开这些符号链接(使用 O_RDONLY 标志),可以获得引用该命名空间的文件描述符。即使该命名空间中已经没有任何进程,只要文件描述符保持打开,命名空间就不会被销毁。
可以通过 setns() 加入命名空间。将上述文件描述符传递给 setns() 系统调用,即可将调用进程加入到目标命名空间中。
每个命名空间的操作通过 struct proc_ns_operations 描述,该结构定义在 include/linux/proc_ns.h(第 17-25 行):
struct proc_ns_operations {
const char *name;
const char *real_ns_name;
struct ns_common *(*get)(struct task_struct *task);
void (*put)(struct ns_common *ns);
int (*install)(struct nsset *nsset, struct ns_common *ns);
struct user_namespace *(*owner)(struct ns_common *ns);
struct ns_common *(*get_parent)(struct ns_common *ns);
} __randomize_layout;
其中 get 操作从指定任务获取命名空间引用,put 释放引用,install 在 setns() 流程中安装命名空间,owner 返回拥有该命名空间的 User 命名空间,get_parent 返回父命名空间(仅对层级化命名空间有意义)。
9.1.7 ns_common 结构——命名空间的公共基础
所有类型的命名空间结构体都内嵌了一个 struct ns_common 成员,作为命名空间的公共基础设施。该结构体定义在 include/linux/ns/ns_common_types.h(第 110-122 行):
struct ns_common {
struct {
refcount_t __ns_ref; /* 主引用计数,控制内存生命周期 */
} ____cacheline_aligned_in_smp;
u32 ns_type; /* 命名空间类型标志 */
struct dentry *stashed; /* VFS 缓存的 dentry */
const struct proc_ns_operations *ops; /* 命名空间操作方法表 */
unsigned int inum; /* 命名空间 inode 号 */
union {
struct ns_tree; /* 命名空间树节点 */
struct rcu_head ns_rcu; /* RCU 回收 */
};
};
Linux 7.0 对命名空间生命周期管理采用了双层引用计数模型:
-
__ns_ref(refcount_t):主引用计数,跟踪命名空间结构的内存生命周期。当它降至零时,命名空间被销毁并回收内存。 -
__ns_ref_active(atomic_t,嵌入在 ns_tree 中):活跃引用计数,跟踪命名空间是否处于活跃状态。任何使用该命名空间的活跃任务(通过 nsproxy 或 cred)、打开的文件描述符或绑定挂载都持有活跃引用。当活跃引用降至零时,命名空间变为非活跃状态,用户空间无法再发现或打开它,但结构本身仍然存在。
这种双层设计允许命名空间在"活跃"和"非活跃"之间转换,而不立即释放内存。例如,当所有使用某个网络命名空间的进程退出后,如果还有 socket 文件描述符通过 UNIX 域套接字传递给其他进程,该命名空间会变为非活跃但仍存在。
ns_common 结构体通过 C11 _Generic 关键字实现了类型安全的转换宏。to_ns_common() 宏(第 124-141 行)根据传入的具体命名空间指针类型,自动选择正确的 ->ns 成员偏移。类似地,ns_common_type() 宏(第 187-196 行)将具体命名空间类型映射到对应的 CLONE_NEW* 标志常量。
9.1.8 copy_namespaces() —— fork 时的命名空间复制
当新进程通过 fork() 或 clone() 创建时,内核调用 copy_namespaces() 函数来处理命名空间的继承或创建。该函数定义在 kernel/nsproxy.c 第 167-205 行:
int copy_namespaces(u64 flags, struct task_struct *tsk)
{
struct nsproxy *old_ns = tsk->nsproxy;
struct user_namespace *user_ns = task_cred_xxx(tsk, user_ns);
struct nsproxy *new_ns;
/* 如果没有请求任何新命名空间,直接共享父进程的 nsproxy */
if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
CLONE_NEWPID | CLONE_NEWNET |
CLONE_NEWCGROUP | CLONE_NEWTIME)))) {
if ((flags & CLONE_VM) ||
likely(old_ns->time_ns_for_children == old_ns->time_ns)) {
get_nsproxy(old_ns);
return 0;
}
} else if (!ns_capable(user_ns, CAP_SYS_ADMIN))
return -EPERM;
...
new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);
if (IS_ERR(new_ns))
return PTR_ERR(new_ns);
if ((flags & CLONE_VM) == 0)
timens_on_fork(new_ns, tsk);
nsproxy_ns_active_get(new_ns);
tsk->nsproxy = new_ns;
return 0;
}
这段代码揭示了一个关键的快速路径优化:当没有设置任何 CLONE_NEW* 标志时,子进程直接共享父进程的 nsproxy,仅增加引用计数。这是最常见的场景,因此被标记为 likely()。
当请求了新的命名空间时,函数首先检查调用者是否在对应的 User 命名空间中拥有 CAP_SYS_ADMIN 能力。然后调用 create_new_namespaces()(第 87-161 行)创建全新的 nsproxy,其中依次调用各命名空间类型的 copy_*() 函数:
create_new_namespaces()
├── copy_mnt_ns() → 复制或创建 Mount 命名空间
├── copy_utsname() → 复制或创建 UTS 命名空间
├── copy_ipcs() → 复制或创建 IPC 命名空间
├── copy_pid_ns() → 创建新的 PID 命名空间
├── copy_cgroup_ns() → 创建新的 Cgroup 命名空间
├── copy_net_ns() → 创建新的 Network 命名空间
├── copy_time_ns() → 创建新的 Time 命名空间
└── get_time_ns() → 共享当前的 Time 命名空间
注意 create_new_namespaces() 中的错误处理采用了级联回退模式:任何一个 copy_*() 失败,都会回退之前所有已成功创建的命名空间。每个 goto out_* 标签依次释放之前创建的命名空间引用。
9.1.9 能力权限要求
创建或加入命名空间需要满足特定的能力要求:
大多数命名空间(Mount、UTS、IPC、PID、Network、Cgroup、Time)的创建需要 CAP_SYS_ADMIN 能力,且这个能力检查是在拥有该命名空间的 User 命名空间中进行的。由于 User 命名空间可以将普通用户映射为 root,因此实际上非特权用户也可以创建这些命名空间——前提是他们首先创建自己的 User 命名空间。
User 命名空间本身是特例。创建 User 命名空间不需要任何特权,任何普通用户都可以创建。但创建后在 User 命名空间内部的能力是受限的——命名空间内的 root(UID 0)只在该命名空间所拥有的资源上拥有完整权限。
对于 setns() 操作,PID 命名空间有额外的限制:目标 PID 命名空间必须是调用者当前活跃 PID 命名空间的子孙(或自身)。这个限制在 pidns_install() 函数(kernel/pid_namespace.c 第 401-425 行)中通过 pidns_is_ancestor() 检查实现,目的是确保 fork() 返回的 PID 在调用者的命名空间中是有效的。
static int pidns_install(struct nsset *nsset, struct ns_common *ns)
{
struct nsproxy *nsproxy = nsset->nsproxy;
struct pid_namespace *active = task_active_pid_ns(current);
struct pid_namespace *new = to_pid_ns(ns);
if (!ns_capable(new->user_ns, CAP_SYS_ADMIN) ||
!ns_capable(nsset->cred->user_ns, CAP_SYS_ADMIN))
return -EPERM;
/* 只允许进入当前 PID 命名空间或其后代 */
if (!pidns_is_ancestor(new, active))
return -EINVAL;
put_pid_ns(nsproxy->pid_ns_for_children);
nsproxy->pid_ns_for_children = get_pid_ns(new);
return 0;
}
9.1.10 小结
Linux 命名空间机制通过将全局系统资源封装进独立的命名空间实例,为进程提供了资源隔离能力。其设计哲学是"每类资源一个命名空间",通过组合实现不同粒度的隔离。nsproxy 作为聚合层将所有命名空间绑定到进程上,ns_common 作为基础层提供统一的引用计数和操作接口。
三种创建/切换方式(clone()、unshare()、setns())分别服务于不同的使用场景:新建进程并隔离、运行时切换隔离、加入已有隔离环境。/proc/[pid]/ns/ 目录提供了用户空间可见的命名空间标识和文件描述符接口,是容器运行时管理命名空间生命周期的关键机制。
接下来的章节将深入分析每种具体命名空间类型的内核实现细节,从 PID 命名空间和 Mount 命名空间开始。
9.2 PID Namespace —— 进程 ID 隔离
9.2.1 PID 命名空间隔离概述
在传统的 Linux 系统中,进程 ID(PID)是一个全局唯一的整数,由内核统一分配。PID 1 固定分配给 init 进程,它是所有用户态进程的祖先,承担着孤儿进程回收、系统初始化等关键职责。当系统中的 PID 耗尽时,新进程将无法创建。
PID 命名空间打破了这种全局 PID 空间的限制。它允许在同一个内核中存在多套独立的 PID 分配空间。每个 PID 命名空间拥有自己的 PID 1 进程、自己的 PID 分配范围和自己的进程可见性规则。这使得容器技术成为可能——容器内部拥有从 PID 1 开始的完整进程空间,仿佛运行在独立的系统上。
PID 命名空间是 Linux 内核中唯一具有严格层级关系的命名空间类型。这种层级化设计深刻影响着 PID 分配、进程可见性和命名空间生命周期管理。
9.2.2 核心数据结构
struct pid_namespace
PID 命名空间的内核表示是 struct pid_namespace,定义在 include/linux/pid_namespace.h(第 26-50 行):
struct pid_namespace {
struct idr idr; /* IDR 用于 PID 分配 */
struct rcu_head rcu;
unsigned int pid_allocated; /* 已分配 PID 数量(含 PIDNS_ADDING 标志) */
#ifdef CONFIG_SYSCTL
#if defined(CONFIG_MEMFD_CREATE)
int memfd_noexec_scope; /* memfd_noexec 安全策略 */
#endif
struct ctl_table_set set;
struct ctl_table_header *sysctls;
#endif
struct task_struct *child_reaper; /* 本命名空间的 init 进程 */
struct kmem_cache *pid_cachep; /* struct pid 的 slab 缓存 */
unsigned int level; /* 嵌套深度(根命名空间为 0) */
int pid_max; /* 本命名空间的最大 PID 值 */
struct pid_namespace *parent; /* 父命名空间指针 */
#ifdef CONFIG_BSD_PROCESS_ACCT
struct fs_pin *bacct;
#endif
struct user_namespace *user_ns; /* 拥有此命名空间的 User 命名空间 */
struct ucounts *ucounts; /* 用户计数器 */
int reboot; /* 重启时的组退出码 */
struct ns_common ns; /* 命名空间公共基类 */
struct work_struct work; /* 延迟销毁工作队列 */
} __randomize_layout;
逐字段解析:
idr:IDR(ID Radix tree)是内核中的整数 ID 分配器。每个 PID 命名空间使用独立的 IDR 来分配和管理 PID 号。IDR 基于基数树实现,支持 O(log n) 的分配和查找操作。
pid_allocated:跟踪已分配的 PID 数量。它还使用最高位(PIDNS_ADDING,定义为 (1U << 31))作为标志位,表示命名空间是否仍在接受新的 PID 分配。当命名空间的 init 进程退出时,此标志被清除,阻止新进程进入。
child_reaper:指向该 PID 命名空间的 init 进程(PID 1)的 task_struct。它承担着回收孤儿进程的职责——当命名空间中的某个进程的父进程退出时,该进程会被重新挂载到 child_reaper 下。根 PID 命名空间的 child_reaper 是全局的 init_task。
pid_cachep:该命名空间层级的 struct pid 的 slab 分配器缓存。由于不同嵌套深度的 struct pid 大小不同(numbers[] 数组长度随 level 增长),每个层级需要独立的 slab 缓存。
level:命名空间在层级中的深度。根命名空间(init_pid_ns)的 level 为 0,其子命名空间为 1,以此类推。最大嵌套深度为 MAX_PID_NS_LEVEL(32),定义在同一文件第 15 行:
#define MAX_PID_NS_LEVEL 32
pid_max:该命名空间中允许的最大 PID 值。新建的命名空间默认设置为 PID_MAX_LIMIT(通常为 4194304)。
parent:指向父 PID 命名空间。通过此字段可以向上遍历整个 PID 命名空间层级树。根命名空间的 parent 为 NULL。
struct pid
struct pid 是内核内部对进程 ID 的抽象表示,定义在 include/linux/pid.h(第 58-75 行):
struct pid {
refcount_t count; /* 引用计数 */
unsigned int level; /* 该 PID 所在的最高层级 */
spinlock_t lock;
struct {
u64 ino;
struct rhash_head pidfs_hash;
struct dentry *stashed;
struct pidfs_attr *attr;
};
struct hlist_head tasks[PIDTYPE_MAX]; /* 使用此 PID 的任务链表 */
struct hlist_head inodes;
wait_queue_head_t wait_pidfd; /* pidfd 通知等待队列 */
struct rcu_head rcu;
struct upid numbers[]; /* 柔性数组:每层一个 upid */
};
level:表示该 struct pid 被创建时所在的 PID 命名空间的层级。
numbers[]:这是 PID 命名空间层级设计的核心。这是一个柔性数组,长度为 level + 1。每个元素是一个 struct upid,记录了该进程在对应层级 PID 命名空间中的 PID 号:
struct upid {
int nr; /* 数字 PID 值 */
struct pid_namespace *ns; /* 对应的 PID 命名空间 */
};
例如,如果一个进程在 level 2 的 PID 命名空间中被创建,那么 numbers[0] 记录它在根命名空间中的 PID,numbers[1] 记录它在 level 1 命名空间中的 PID,numbers[2] 记录它在 level 2 命名空间中的 PID。
tasks[PIDTYPE_MAX]:这是一个哈希链表头数组,按 PID 类型(PID、TGID、PGID、SID)组织使用该 struct pid 的进程。多个线程共享同一个 TGID 类型的 PID。
init_pid_ns —— 根 PID 命名空间
系统启动时内核创建的全局根 PID 命名空间定义在 kernel/pid.c(第 72-83 行):
struct pid_namespace init_pid_ns = {
.ns = NS_COMMON_INIT(init_pid_ns),
.idr = IDR_INIT(init_pid_ns.idr),
.pid_allocated = PIDNS_ADDING,
.level = 0,
.child_reaper = &init_task,
.user_ns = &init_user_ns,
.pid_max = PID_MAX_DEFAULT,
#if defined(CONFIG_SYSCTL) && defined(CONFIG_MEMFD_CREATE)
.memfd_noexec_scope = MEMFD_NOEXEC_SCOPE_EXEC,
#endif
};
根 PID 命名空间的 level 为 0,child_reaper 指向 init_task(即 PID 1 的 idle 进程/init 进程),pid_max 初始化为 PID_MAX_DEFAULT(默认 32768)。
9.2.3 PID 命名空间的层级结构
PID 命名空间具有严格的层级关系。每一层子命名空间只能嵌套在父命名空间内部。这种层级关系通过 parent 指针和 level 字段共同维护。
init_pid_ns (level=0)
|
┌────────┴────────┐
| |
pid_ns_A (level=1) pid_ns_B (level=1)
|
pid_ns_A1 (level=2)
在这个结构中,如果进程 P 在 pid_ns_A1(level=2)中被创建,那么:
- 在
pid_ns_A1中,P 拥有一个 PID(例如 PID 42) - 在
pid_ns_A中,P 拥有另一个 PID(例如 PID 2345) - 在
init_pid_ns中,P 拥有全局 PID(例如 PID 12345)
这些 PID 号存储在 struct pid 的 numbers[] 数组中:numbers[0] 存全局 PID 12345,numbers[1] 存 pid_ns_A 中的 PID 2345,numbers[2] 存 pid_ns_A1 中的 PID 42。
可见性规则
层级结构决定了进程的可见性:
- 子命名空间中的进程在所有祖先命名空间中可见(但 PID 不同)
- 父命名空间中的进程在子命名空间中不可见
这意味着从 init_pid_ns 可以看到系统中的所有进程(每个进程都有一个全局 PID),但从 pid_ns_A1 只能看到同一命名空间内的进程。
pidns_is_ancestor() —— 祖先关系检查
内核通过 pidns_is_ancestor() 函数(kernel/pid_namespace.c 第 389-399 行)判断两个 PID 命名空间的祖先关系:
bool pidns_is_ancestor(struct pid_namespace *child,
struct pid_namespace *ancestor)
{
struct pid_namespace *ns;
if (child->level < ancestor->level)
return false;
for (ns = child; ns->level > ancestor->level; ns = ns->parent)
;
return ns == ancestor;
}
该函数从 child 开始沿 parent 链向上遍历,直到到达与 ancestor 相同层级的命名空间,然后比较两者是否相同。这个函数在 setns() 的 PID 命名空间安装中被调用,确保进程只能进入当前 PID 命名空间的子孙命名空间。
9.2.4 PID 分配机制
IDR 基础
PID 的分配使用 IDR(ID Radix tree)机制。IDR 是内核提供的一种将整数 ID 与指针关联的数据结构,基于基数树实现,支持高效的分配、查找和删除操作。
每个 PID 命名空间拥有独立的 idr 实例,PID 在各命名空间中独立分配。当新进程被创建时,内核需要在该进程所属 PID 命名空间以及所有祖先命名空间中分别为其分配一个 PID 号。
alloc_pid() —— PID 分配的核心函数
alloc_pid() 定义在 kernel/pid.c 第 160 行起,是 PID 分配的核心入口:
struct pid *alloc_pid(struct pid_namespace *ns, pid_t *arg_set_tid,
size_t arg_set_tid_size)
{
int set_tid[MAX_PID_NS_LEVEL + 1] = {};
int pid_max[MAX_PID_NS_LEVEL + 1] = {};
struct pid *pid;
enum pid_type type;
int i, nr;
struct pid_namespace *tmp;
struct upid *upid;
...
分配流程如下:
第一步:分配 struct pid 结构。struct pid 的大小取决于命名空间层级——numbers[] 柔性数组需要 level + 1 个元素。因此内核为每个层级维护了独立的 slab 缓存(通过 pid_cachep 字段),缓存大小精确匹配该层级所需的 struct pid 尺寸。
slab 缓存的创建逻辑在 kernel/pid_namespace.c 的 create_pid_cachep() 函数(第 40-62 行)中:
static struct kmem_cache *create_pid_cachep(unsigned int level)
{
struct kmem_cache **pkc = &pid_cache[level - 1];
...
snprintf(name, sizeof(name), "pid_%u", level + 1);
len = struct_size_t(struct pid, numbers, level + 1);
...
*pkc = kmem_cache_create(name, len, 0,
SLAB_HWCACHE_ALIGN | SLAB_ACCOUNT, NULL);
...
}
struct_size_t(struct pid, numbers, level + 1) 计算了包含 level + 1 个 upid 元素的 struct pid 的精确大小。
第二步:在每一层命名空间中分配 PID 号。alloc_pid() 从目标命名空间开始,沿 parent 链一直向上到根命名空间,在每一层的 IDR 中分配一个 PID 号:
for (tmp = ns, i = ns->level; i >= 0;) {
int tid = set_tid[ns->level - i];
if (tid) {
/* 指定 PID(用于 CRIU 检查点/恢复) */
nr = idr_alloc(&tmp->idr, NULL, tid, tid + 1, GFP_ATOMIC);
} else {
/* 自动分配 PID */
int pid_min = 1;
if (idr_get_cursor(&tmp->idr) > RESERVED_PIDS)
pid_min = RESERVED_PIDS;
nr = idr_alloc_cyclic(&tmp->idr, NULL, pid_min,
pid_max[ns->level - i], GFP_ATOMIC);
}
...
}
注意几个关键细节:
idr_alloc_cyclic()使用循环分配策略,从上次分配结束的位置继续,避免每次都从 PID 1 开始搜索空闲槽位。RESERVED_PIDS(定义为 300)以下的 PID 号被保留给内核使用。当 IDR 的游标超过RESERVED_PIDS后,用户空间的 PID 分配将从 300 开始。clone3()系统调用支持通过set_tid字段指定新进程在各层命名空间中的 PID。这主要用于检查点/恢复(CRIU)场景。
第三步:将 struct pid 注册到各层 IDR 中。分配完成后,alloc_pid() 持有 pidmap_lock 自旋锁,将 struct pid 指针存入各层 IDR:
spin_lock(&pidmap_lock);
for (tmp = ns, i = ns->level; i >= 0; i--) {
...
upid = pid->numbers + i;
idr_replace(&tmp->idr, pid, upid->nr);
ns_ref_active_get(to_ns_common(tmp));
tmp = tmp->parent;
}
spin_unlock(&pidmap_lock);
PID 号在不同命名空间中的查询
内核提供了多个辅助函数来在不同命名空间中查询进程的 PID 号:
/* 全局 PID(根命名空间中的 PID 号) */
static inline pid_t pid_nr(struct pid *pid)
{
pid_t nr = 0;
if (pid)
nr = pid->numbers[0].nr;
return nr;
}
/* 在指定命名空间中的 PID 号 */
pid_t pid_nr_ns(struct pid *pid, struct pid_namespace *ns)
{
struct upid *upid;
pid_t nr = 0;
if (pid && ns && ns->level <= pid->level) {
upid = &pid->numbers[ns->level];
if (upid->ns == ns)
nr = upid->nr;
}
return nr;
}
/* 在当前进程的 PID 命名空间中的 PID 号 */
pid_t pid_vnr(struct pid *pid)
{
return pid_nr_ns(pid, task_active_pid_ns(current));
}
pid_nr_ns() 的实现揭示了一个重要的可见性规则:只有当请求的命名空间层级 ns->level 小于等于 pid->level 时,才能获得有效的 PID 号。这对应了"进程在所有祖先命名空间中可见"的规则。
9.2.5 pid_ns_for_children 与 task_active_pid_ns() 的区别
这是 PID 命名空间中最容易混淆的概念之一。内核中存在两个不同的"当前 PID 命名空间"概念。
pid_ns_for_children
nsproxy 结构体中的 pid_ns_for_children 字段代表的是未来子进程将使用的 PID 命名空间。当进程调用 unshare(CLONE_NEWPID) 或 setns(pid_fd, CLONE_NEWPID) 时,修改的是 pid_ns_for_children,而不是进程自身的 PID 命名空间。
这意味着 unshare(CLONE_NEWPID) 之后,调用进程自身的 PID 不会改变——它仍然在原来的 PID 命名空间中。只有在此之后创建的子进程才会进入新的 PID 命名空间。
task_active_pid_ns()
task_active_pid_ns() 返回的是进程当前实际所处的 PID 命名空间:
struct pid_namespace *task_active_pid_ns(struct task_struct *tsk)
{
return ns_of_pid(task_pid(tsk));
}
它通过 task_pid(tsk) 获取进程的 thread_pid(即 struct pid),然后通过 ns_of_pid() 取 numbers[pid->level].ns——即该进程创建时所在的最深层 PID 命名空间。
static inline struct pid_namespace *ns_of_pid(struct pid *pid)
{
struct pid_namespace *ns = NULL;
if (pid)
ns = pid->numbers[pid->level].ns;
return ns;
}
为什么需要区分
这种设计是 PID 命名空间层级性质的必然结果。一旦进程被创建并分配了 struct pid,它在所有祖先命名空间中的 PID 号就确定了。如果允许进程在运行时切换到任意的 PID 命名空间,那么进程的 PID 号可能在目标命名空间中没有对应的 upid 条目。
因此,PID 命名空间采用"只影响子进程"的策略:setns() 和 unshare() 只修改 pid_ns_for_children,而进程自身的 PID 命名空间在创建时就固定了。
9.2.6 child_reaper —— 命名空间的 init 进程
每个 PID 命名空间的 PID 1 进程承担着 init 进程的职责,称为 child_reaper。child_reaper 的核心职责是:
-
回收孤儿进程。当命名空间中的某个进程的父进程先于它退出时,该进程会被重新挂载到
child_reaper下。child_reaper通过wait()系统调用来回收这些孤儿进程的资源。 -
命名空间生命周期管理。当
child_reaper退出时,整个 PID 命名空间将被销毁(详见 9.2.8 节)。 -
信号处理。命名空间内部的所有进程最终都可以追溯到
child_reaper作为其祖先。
is_child_reaper() 辅助函数(include/linux/pid.h 第 163-166 行)检查一个 struct pid 是否是命名空间的 PID 1:
static inline bool is_child_reaper(struct pid *pid)
{
return pid->numbers[pid->level].nr == 1;
}
9.2.7 PID 命名空间的创建流程
创建新的 PID 命名空间通过 copy_pid_ns() 函数实现,定义在 kernel/pid_namespace.c 第 175-183 行:
struct pid_namespace *copy_pid_ns(u64 flags,
struct user_namespace *user_ns, struct pid_namespace *old_ns)
{
if (!(flags & CLONE_NEWPID))
return get_pid_ns(old_ns);
if (task_active_pid_ns(current) != old_ns)
return ERR_PTR(-EINVAL);
return create_pid_namespace(user_ns, old_ns);
}
这里有一个关键的合法性检查:task_active_pid_ns(current) != old_ns。这个检查确保只有当调用者当前实际处于的 PID 命名空间与 nsproxy 中的 pid_ns_for_children 一致时,才能创建新的子 PID 命名空间。这防止了在已经 unshare(CLONE_NEWPID) 但尚未 fork() 的情况下再次创建 PID 命名空间。
create_pid_namespace() 函数(第 76-138 行)执行实际的创建工作:
static struct pid_namespace *create_pid_namespace(struct user_namespace *user_ns,
struct pid_namespace *parent_pid_ns)
{
struct pid_namespace *ns;
unsigned int level = parent_pid_ns->level + 1;
...
if (level > MAX_PID_NS_LEVEL)
goto out;
...
idr_init(&ns->idr);
ns->pid_cachep = create_pid_cachep(level);
...
ns->level = level;
ns->parent = get_pid_ns(parent_pid_ns);
ns->user_ns = get_user_ns(user_ns);
ns->ucounts = ucounts;
ns->pid_allocated = PIDNS_ADDING;
...
return ns;
}
关键步骤:
- 层级检查:新命名空间的
level为父命名空间level + 1,如果超过MAX_PID_NS_LEVEL(32)则返回-ENOSPC。 - 用户计数器检查:通过
inc_pid_namespaces()检查用户是否超过了允许创建的 PID 命名空间数量上限。 - 初始化 IDR:为新命名空间创建空的 IDR,用于后续 PID 分配。
- 创建 slab 缓存:通过
create_pid_cachep(level)为该层级创建大小精确匹配的struct pidslab 缓存。 - 设置父子关系:
parent指向父命名空间,user_ns记录拥有者 User 命名空间。
注意 child_reaper 在此时并未被设置。它要等到新命名空间中的第一个进程(PID 1)被 alloc_pid() 分配 PID 后,由 copy_process() 设置。
9.2.8 命名空间清理 —— zap_pid_ns_processes()
当 PID 命名空间的 init 进程(PID 1)退出时,内核需要清理该命名空间中的所有进程。这个清理过程由 zap_pid_ns_processes() 函数(kernel/pid_namespace.c 第 192-283 行)完成。
void zap_pid_ns_processes(struct pid_namespace *pid_ns)
{
int nr;
int rc;
struct task_struct *task, *me = current;
int init_pids = thread_group_leader(me) ? 1 : 2;
struct pid *pid;
/* 不再允许新进程进入 */
disable_pid_allocation(pid_ns);
/* 忽略 SIGCHLD,使子进程自动回收 */
spin_lock_irq(&me->sighand->siglock);
me->sighand->action[SIGCHLD - 1].sa.sa_handler = SIG_IGN;
spin_unlock_irq(&me->sighand->siglock);
/* 遍历 IDR,向所有进程发送 SIGKILL */
rcu_read_lock();
read_lock(&tasklist_lock);
nr = 2;
idr_for_each_entry_continue(&pid_ns->idr, pid, nr) {
task = pid_task(pid, PIDTYPE_PID);
if (task && !__fatal_signal_pending(task))
group_send_sig_info(SIGKILL, SEND_SIG_PRIV, task, PIDTYPE_MAX);
}
read_unlock(&tasklist_lock);
rcu_read_unlock();
/* 等待所有子进程退出 */
do {
clear_thread_flag(TIF_SIGPENDING);
clear_thread_flag(TIF_NOTIFY_SIGNAL);
rc = kernel_wait4(-1, NULL, __WALL, NULL);
} while (rc != -ECHILD);
/* 等待所有进程完全消失 */
for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
if (pid_ns->pid_allocated == init_pids)
break;
schedule();
}
__set_current_state(TASK_RUNNING);
...
}
清理流程的关键步骤:
-
禁止新的 PID 分配:调用
disable_pid_allocation()清除PIDNS_ADDING标志位,此后该命名空间不再接受新进程。 -
忽略 SIGCHLD 信号:将 init 进程的 SIGCHLD 处理器设为
SIG_IGN,使已退出的子进程自动回收而非变为僵尸进程。这加快了清理速度。 -
向所有进程发送 SIGKILL:通过
idr_for_each_entry_continue()从 IDR 中遍历 PID 2 开始的所有 PID(跳过 PID 1 即自身),向对应的进程发送 SIGKILL 信号。注意这里使用PIDTYPE_MAX作为信号类型,确保信号能发送到线程组中的所有线程。 -
等待子进程退出:在
kernel_wait4()循环中等待所有直接子进程退出。 -
等待 PID 计数归零:在最后的无限循环中,
child_reaper进入TASK_INTERRUPTIBLE睡眠状态,直到pid_allocated降至init_pids(仅剩 init 自身的 PID)。当free_pid()释放最后一个 PID 时,会调用wake_up_process(ns->child_reaper)唤醒它。
这个等待机制确保了 PID 命名空间的清理是彻底的——即使有进程通过 setns() 从外部进入该命名空间后再退出,child_reaper 也会等待它们完全消失。
free_pid() —— PID 释放时的唤醒
当进程退出并释放其 struct pid 时,free_pid() 函数(kernel/pid.c 第 110-146 行)会在每一层命名空间中减少 pid_allocated 计数,并在计数降至特定阈值时唤醒 child_reaper:
void free_pid(struct pid *pid)
{
...
spin_lock(&pidmap_lock);
for (i = 0; i <= pid->level; i++) {
struct upid *upid = pid->numbers + i;
struct pid_namespace *ns = upid->ns;
switch (--ns->pid_allocated) {
case 2:
case 1:
/* 命名空间中只剩 reaper,唤醒它 */
wake_up_process(ns->child_reaper);
break;
case PIDNS_ADDING:
/* 第一个进程 fork 失败的处理 */
WARN_ON(ns->child_reaper);
ns->pid_allocated = 0;
break;
}
idr_remove(&ns->idr, upid->nr);
}
spin_unlock(&pidmap_lock);
...
}
9.2.9 /proc 在 PID 命名空间中的行为
在 PID 命名空间内部,/proc 文件系统只显示同一命名空间中的进程。这是因为 /proc 的实现使用 task_active_pid_ns() 来确定当前的 PID 命名空间,然后只列出属于该命名空间的进程。
为了让容器内部的 /proc 正常工作,需要在容器的 Mount 命名空间中重新挂载 /proc:
# 在新的 PID 命名空间中
unshare --pid --fork --mount-proc
--mount-proc 选项会在新的 Mount 命名空间中重新挂载 /proc,使其反映新 PID 命名空间中的进程列表。
9.2.10 reboot_pid_ns() —— 容器内的重启
在 PID 命名空间内部,init 进程(PID 1)可以调用 reboot() 系统调用来触发命名空间级别的重启。reboot_pid_ns() 函数(kernel/pid_namespace.c 第 319-346 行)处理这种情况:
int reboot_pid_ns(struct pid_namespace *pid_ns, int cmd)
{
if (pid_ns == &init_pid_ns)
return 0;
switch (cmd) {
case LINUX_REBOOT_CMD_RESTART2:
case LINUX_REBOOT_CMD_RESTART:
pid_ns->reboot = SIGHUP;
break;
case LINUX_REBOOT_CMD_POWER_OFF:
case LINUX_REBOOT_CMD_HALT:
pid_ns->reboot = SIGINT;
break;
default:
return -EINVAL;
}
read_lock(&tasklist_lock);
send_sig(SIGKILL, pid_ns->child_reaper, 1);
read_unlock(&tasklist_lock);
do_exit(0);
return 0;
}
容器内的 reboot 行为与全局 reboot 完全不同:它不会重启整个系统,而是向 child_reaper 发送 SIGKILL 信号并触发命名空间清理。reboot 字段被设置为 SIGHUP 或 SIGINT,在 zap_pid_ns_processes() 完成后作为整个命名空间的组退出码传递给父进程。
9.2.11 PID 命名空间的销毁
PID 命名空间的销毁采用延迟释放策略。当 pid_allocated 降至最低值(仅剩 init 的 PID)且 init 进程退出后,put_pid_ns() 被调用。它将销毁工作提交到工作队列:
void put_pid_ns(struct pid_namespace *ns)
{
if (ns && ns_ref_put(ns))
schedule_work(&ns->work);
}
工作队列的处理函数 destroy_pid_namespace_work()(第 161-173 行)会沿 parent 链向上级联释放:
static void destroy_pid_namespace_work(struct work_struct *work)
{
struct pid_namespace *ns =
container_of(work, struct pid_namespace, work);
do {
struct pid_namespace *parent;
parent = ns->parent;
destroy_pid_namespace(ns);
ns = parent;
} while (ns != &init_pid_ns && ns_ref_put(ns));
}
这种级联释放机制确保了当最内层的 PID 命名空间被销毁时,如果中间层的命名空间也没有其他引用者,它们也会被一并释放,直到遇到根命名空间或仍有引用者的命名空间为止。
9.2.12 小结
PID 命名空间通过层级化的设计实现了进程 ID 的隔离。struct pid 的 numbers[] 柔性数组在每个层级记录一个 PID 号,使得同一进程在不同命名空间中拥有不同的标识。child_reaper 作为每个命名空间的 init 进程,承担孤儿进程回收和命名空间生命周期管理的关键职责。PID 分配基于 IDR 机制,在创建进程时于所有祖先命名空间中同时分配。pid_ns_for_children 与 task_active_pid_ns() 的区分体现了 PID 命名空间"创建时确定、不可运行时切换"的设计决策。命名空间的销毁通过 zap_pid_ns_processes() 实现彻底清理,确保所有子进程被终止后才释放命名空间资源。
9.3 Mount Namespace —— 文件系统视图隔离
9.3.1 Mount 命名空间概述
Mount 命名空间是 Linux 内核中最早实现的命名空间类型,于 2002 年的 Linux 2.4.19 版本引入,标志常量为 CLONE_NEWNS(New NameSpace)。它隔离的是进程看到的文件系统挂载点集合——每个 Mount 命名空间拥有独立的挂载树,在一个命名空间中执行的 mount 和 umount 操作默认不会影响其他命名空间。
在 Mount 命名空间出现之前,所有进程共享同一个全局挂载表,任何挂载或卸载操作对全系统可见。这在多用户环境和服务器整合场景中带来了严重的安全和管理问题。Mount 命名空间的出现为解决这些问题提供了内核级别的隔离机制。
值得注意的是,CLONE_NEWNS 的标志值是 0x00020000,它是所有命名空间标志中值最小的一个——因为它是第一个被引入的,当时还没有为未来的命名空间类型预留更大的值空间。后来的命名空间类型使用了更高位的标志值。
#define CLONE_NEWNS 0x00020000 /* New mount namespace group */
9.3.2 核心数据结构
struct mnt_namespace
Mount 命名空间的内核表示是 struct mnt_namespace,定义在 fs/mount.h(第 11-32 行):
struct mnt_namespace {
struct ns_common ns; /* 命名空间公共基类 */
struct mount * root; /* 指向根挂载点 */
struct {
struct rb_root mounts; /* 红黑树:所有挂载点 */
struct rb_node *mnt_last_node; /* 最右(最后一个)挂载节点 */
struct rb_node *mnt_first_node;/* 最左(第一个)挂载节点 */
};
struct user_namespace *user_ns; /* 拥有此命名空间的 User 命名空间 */
struct ucounts *ucounts; /* 用户计数器 */
wait_queue_head_t poll; /* poll 等待队列(用于 mountinfo 通知) */
u64 seq_origin; /* 来源命名空间的序列号 */
u64 event; /* 事件计数器(用于通知挂载变化) */
#ifdef CONFIG_FSNOTIFY
__u32 n_fsnotify_mask;
struct fsnotify_mark_connector __rcu *n_fsnotify_marks;
#endif
unsigned int nr_mounts; /* 命名空间中的挂载点数量 */
unsigned int pending_mounts; /* 待处理的挂载点数量 */
refcount_t passive; /* 非活跃引用计数 */
bool is_anon; /* 是否为匿名命名空间 */
} __randomize_layout;
逐字段解析:
root:指向该命名空间的根挂载点(struct mount)。每个命名空间都有一棵以 root 为根的挂载树。
mounts(rb_root):这是 Linux 7.0 中 Mount 命名空间的一个关键数据结构改进。命名空间中的所有挂载点被组织在一棵红黑树中,而非传统的链表。红黑树提供了 O(log n) 的查找性能,在挂载点数量较多时(容器场景中可能达到数千个)优势明显。mnt_last_node 和 mnt_first_node 分别缓存最右和最左节点,用于优化顺序遍历。
nr_mounts:记录命名空间中挂载点的总数。内核通过 sysctl_mount_max(默认 100000,定义在 fs/namespace.c 第 42 行)限制单个命名空间的最大挂载点数量。
user_ns:记录创建此 Mount 命名空间的 User 命名空间。后续对挂载操作的能力检查将在此 User 命名空间中进行。
passive:非活跃引用计数,跟踪那些不"钉住"挂载树的引用。这用于支持命名空间的延迟销毁——当所有活跃用户退出后,命名空间可以进入非活跃状态但仍保持内存结构。
is_anon:标识匿名命名空间。匿名命名空间用于 mount_subtree() 等内部场景,不与任何进程直接关联。
struct mount
struct mount 是内核内部对挂载点的完整表示,定义在 fs/mount.h(第 45-94 行):
struct mount {
struct hlist_node mnt_hash; /* 全局挂载哈希表节点 */
struct mount *mnt_parent; /* 父挂载点 */
struct dentry *mnt_mountpoint; /* 挂载点 dentry */
struct vfsmount mnt; /* VFS 层的挂载信息 */
union {
struct rb_node mnt_node; /* 命名空间红黑树节点 */
struct rcu_head mnt_rcu; /* RCU 回收 */
struct llist_node mnt_llist; /* 无锁链表节点 */
};
#ifdef CONFIG_SMP
struct mnt_pcp __percpu *mnt_pcp; /* 每 CPU 计数器 */
#else
int mnt_count; /* 引用计数 */
int mnt_writers; /* 写者计数 */
#endif
struct list_head mnt_mounts; /* 子挂载点链表 */
struct list_head mnt_child; /* 兄弟挂载点链表 */
...
struct list_head mnt_share; /* 共享 peer 链表 */
struct hlist_head mnt_slave_list; /* slave 挂载点链表 */
struct hlist_node mnt_slave; /* slave 链表节点 */
struct mount *mnt_master; /* slave 的 master */
struct mnt_namespace *mnt_ns; /* 所属的命名空间 */
struct mountpoint *mnt_mp; /* 挂载点位置 */
...
int mnt_t_flags; /* 传播类型标志 */
int mnt_id; /* 挂载 ID(可复用) */
u64 mnt_id_unique; /* 唯一挂载 ID */
int mnt_group_id; /* peer 组 ID */
...
} __randomize_layout;
struct mount 是内核内部使用的"丰富版"挂载表示,而 VFS 层使用的 struct vfsmount(嵌入为 mnt 字段)是更精简的表示。两者通过 real_mount() 宏互相转换:
static inline struct mount *real_mount(struct vfsmount *mnt)
{
return container_of(mnt, struct mount, mnt);
}
传播相关字段:
mnt_share:共享 peer 组的循环链表。属于同一 peer 组的挂载点通过此链表连接。mnt_slave_list:以该挂载为 master 的 slave 挂载点链表。mnt_slave:作为 slave 挂载点的链表节点。mnt_master:指向 slave 的 master 挂载点。mnt_group_id:peer 组的标识符。mnt_t_flags:传播类型标志,定义在fs/mount.h(第 96-110 行):
enum {
T_SHARED = 1, /* 挂载点为共享类型 */
T_UNBINDABLE = 2, /* 挂载点为不可绑定类型 */
T_MARKED = 4, /* 内部标记,用于传播算法 */
T_UMOUNT_CANDIDATE = 8, /* 用于传播卸载 */
T_SHARED_MASK = T_UNBINDABLE, /* 设置共享时需清除的标志 */
};
红黑树操作
Linux 7.0 将 Mount 命名空间中的挂载点从链表改为红黑树组织。相关的操作函数定义在 fs/mount.h 中:
static inline bool mnt_ns_attached(const struct mount *mnt)
{
return !RB_EMPTY_NODE(&mnt->mnt_node);
}
static inline bool mnt_ns_empty(const struct mnt_namespace *ns)
{
return RB_EMPTY_ROOT(&ns->mounts);
}
static inline void move_from_ns(struct mount *mnt)
{
struct mnt_namespace *ns = mnt->mnt_ns;
WARN_ON(!mnt_ns_attached(mnt));
if (ns->mnt_last_node == &mnt->mnt_node)
ns->mnt_last_node = rb_prev(&mnt->mnt_node);
if (ns->mnt_first_node == &mnt->mnt_node)
ns->mnt_first_node = rb_next(&mnt->mnt_node);
rb_erase(&mnt->mnt_node, &ns->mounts);
RB_CLEAR_NODE(&mnt->mnt_node);
}
move_from_ns() 从红黑树中移除挂载点时会更新缓存的 mnt_last_node 和 mnt_first_node 指针,确保顺序遍历的正确性。红黑树的键值是挂载 ID(mnt_id),保证了查找的高效性。
9.3.3 共享子树 (Shared Subtrees)
共享子树是 Mount 命名空间中最复杂也最强大的机制。它解决了 Mount 命名空间之间的挂载事件传播问题。如果没有共享子树机制,不同 Mount 命名空间中的挂载点是完全独立的——在一个命名空间中挂载的光盘驱动器在其他命名空间中完全不可见,这可能不是期望的行为。
四种传播类型
每个挂载点可以被设置为以下四种传播类型之一:
| 类型 | 标志 | 行为 |
|---|---|---|
| MS_SHARED | T_SHARED |
双向传播:接收和发送挂载/卸载事件 |
| MS_SLAVE | 无特殊标志 | 单向接收:只从 master 接收事件,不回传 |
| MS_PRIVATE | 无特殊标志 | 完全隔离:不参与任何传播 |
| MS_UNBINDABLE | T_UNBINDABLE |
不可绑定:不能作为 bind mount 的源 |
共享类型 (MS_SHARED)
当挂载点被标记为 MS_SHARED 时,它属于一个 peer group(对等组)。同一 peer 组中的所有挂载点互相传播挂载和卸载事件。例如,如果在 peer 组的一个成员上挂载了新的文件系统,这个挂载事件会自动传播到 peer 组的所有其他成员。
peer 组通过 mnt_group_id 标识,通过 mnt_share 循环链表连接。设置共享类型的函数是 set_mnt_shared()。
从属类型 (MS_SLAVE)
Slave 挂载点从一个 master 接收传播事件,但不会将自己的挂载事件回传给 master。这提供了一种"只读式"的传播关系——可以看到 master 的挂载变化,但自己的变化不会影响 master。
在 change_mnt_propagation() 中(fs/pnode.c 第 93-124 行),设置 slave 类型的代码:
void change_mnt_propagation(struct mount *mnt, int type)
{
...
if (type == MS_SLAVE) {
mnt->mnt_master = m;
if (m)
hlist_add_head(&mnt->mnt_slave, &m->mnt_slave_list);
} else {
mnt->mnt_master = NULL;
if (type == MS_UNBINDABLE)
mnt->mnt_t_flags |= T_UNBINDABLE;
else
mnt->mnt_t_flags &= ~T_UNBINDABLE;
}
}
私有类型 (MS_PRIVATE)
私有挂载点不参与任何传播。这是最简单的类型——挂载和卸载操作仅影响当前命名空间。
不可绑定类型 (MS_UNBINDABLE)
不可绑定挂载点除了不参与传播外,还不能作为 bind mount 的源。这种类型主要用于防止递归 bind mount 导致的挂载爆炸问题。例如,如果 / 被递归 bind mount 到 /mnt/root,没有 MS_UNBINDABLE 的话,/mnt/root/mnt/root 也会包含 / 的完整副本,导致无限递归。
传播规则总结
┌──────────────────────────────────────┐
│ 共享子树传播规则 │
├──────────────────────────────────────┤
│ │
│ SHARED ←──→ SHARED 双向传播 │
│ │
│ SLAVE ←── MASTER 单向传播 │
│ (接收) (发送) │
│ │
│ PRIVATE 独立,不传播 │
│ │
│ UNBINDABLE 独立 + 不可 bind mount │
│ │
└──────────────────────────────────────┘
9.3.4 copy_mnt_ns() —— Mount 命名空间的复制
当创建新的 Mount 命名空间时(通过 clone(CLONE_NEWNS) 或 unshare(CLONE_NEWNS)),内核需要为新命名空间复制一份挂载树。这个工作由 copy_mnt_ns() 完成,定义在 fs/namespace.c 第 4230-4300 行:
struct mnt_namespace *copy_mnt_ns(u64 flags, struct mnt_namespace *ns,
struct user_namespace *user_ns, struct fs_struct *new_fs)
{
struct mnt_namespace *new_ns;
struct mount *p, *q;
struct mount *old;
struct mount *new;
int copy_flags;
BUG_ON(!ns);
/* 如果没有请求新的 Mount 命名空间,直接共享 */
if (likely(!(flags & CLONE_NEWNS))) {
get_mnt_ns(ns);
return ns;
}
old = ns->root;
/* 分配新的 mnt_namespace 结构 */
new_ns = alloc_mnt_ns(user_ns, false);
if (IS_ERR(new_ns))
return new_ns;
guard(namespace_excl)();
/* 第一遍:复制挂载树拓扑 */
copy_flags = CL_COPY_UNBINDABLE | CL_EXPIRE;
if (user_ns != ns->user_ns)
copy_flags |= CL_SLAVE;
new = copy_tree(old, old->mnt.mnt_root, copy_flags);
...
new_ns->root = new;
/* 第二遍:更新 fs_struct 引用并标记归属 */
p = old;
q = new;
while (p) {
mnt_add_to_ns(new_ns, q);
new_ns->nr_mounts++;
if (new_fs) {
if (&p->mnt == new_fs->root.mnt) {
new_fs->root.mnt = mntget(&q->mnt);
...
}
if (&p->mnt == new_fs->pwd.mnt) {
new_fs->pwd.mnt = mntget(&q->mnt);
...
}
}
p = next_mnt(p, old);
q = next_mnt(q, new);
...
}
ns_tree_add_raw(new_ns);
return new_ns;
}
复制流程分为两个阶段:
第一遍(拓扑复制):调用 copy_tree() 从原始命名空间的根挂载点开始,递归复制整棵挂载树。复制时使用 CL_COPY_UNBINDABLE 标志跳过不可绑定的挂载点,CL_EXPIRE 标志标记可过期的挂载点。如果新命名空间属于不同的 User 命名空间,还会添加 CL_SLAVE 标志,使新树中的挂载点成为原始挂载点的 slave——这意味着原始命名空间中的挂载变化会传播到新命名空间,但反过来不会。
第二遍(引用更新):遍历新旧两棵树的对应节点,将每个挂载点加入新命名空间的红黑树,更新 nr_mounts 计数。同时,如果进程的 fs_struct 中记录了根目录和当前工作目录的挂载点引用,需要将这些引用从旧树的挂载点切换到新树中对应的挂载点。
copy_flags 的影响
copy_flags 中 CL_SLAVE 标志的行为尤其重要。当新命名空间与原始命名空间属于不同的 User 命名空间时,新树中的挂载点被设置为 slave。这意味着:
- 宿主机上的挂载变化(如插入 USB 设备)会传播到容器的命名空间中(如果相关挂载点是共享的)
- 容器内部的挂载操作不会影响宿主机或其他容器
这是容器隔离模型的一个重要安全特性。
9.3.5 挂载树的组织方式
Linux 7.0 中 Mount 命名空间的挂载点以两种数据结构组织:
父子关系(树结构)
挂载点之间的父子关系通过 mnt_parent、mnt_mounts 和 mnt_child 三个字段维护。mnt_parent 指向父挂载点,mnt_mounts 是子挂载点链表的表头,mnt_child 将挂载点链接到父挂载的子链表中。
root mount
/ \
mount A mount B
/ \ \
mount C mount D mount E
这种树结构用于路径名查找(path lookup)——当内核解析路径时,需要在挂载树中确定每个路径组件对应的挂载点。
扁平集合(红黑树)
所有挂载点同时作为节点存在于命名空间的红黑树 mounts 中。红黑树以挂载 ID(mnt_id)为键值,支持 O(log n) 的查找、插入和删除操作。
红黑树主要用于以下场景:
- 挂载点查找:通过挂载 ID 快速定位挂载点。
/proc/[pid]/mountinfo生成:遍历红黑树生成挂载信息。- 命名空间管理:添加和移除挂载点时的快速操作。
将挂载点加入命名空间红黑树的操作由 mnt_add_to_ns() 函数完成:
/* 在 fs/namespace.c 中 */
static void mnt_add_to_ns(struct mnt_namespace *ns, struct mount *mnt)
{
...
rb_add(&mnt->mnt_node, &ns->mounts, mnt_less);
...
}
rb_add() 是内核红黑树的辅助函数,使用 mnt_less 比较函数确定插入位置,同时自动平衡树。mnt_first_node 和 mnt_last_node 在插入时被更新以维护顺序访问的效率。
9.3.6 pivot_root() 与 chroot()
在容器环境中建立独立的文件系统视图时,有两个关键系统调用:pivot_root() 和 chroot()。
pivot_root()
pivot_root() 将进程的根文件系统从旧根切换到新根。它的实现位于 fs/namespace.c 第 4726-4744 行:
SYSCALL_DEFINE2(pivot_root, const char __user *, new_root,
const char __user *, put_old)
{
struct path new __free(path_put) = {};
struct path old __free(path_put) = {};
int error;
error = user_path_at(AT_FDCWD, new_root,
LOOKUP_FOLLOW | LOOKUP_DIRECTORY, &new);
if (error)
return error;
error = user_path_at(AT_FDCWD, put_old,
LOOKUP_FOLLOW | LOOKUP_DIRECTORY, &old);
if (error)
return error;
return path_pivot_root(&new, &old);
}
pivot_root() 接受两个参数:new_root(新的根目录)和 put_old(旧根将被放置的位置)。它的核心行为是:
- 将
new_root设为新的根挂载点 - 将旧的根挂载点挂载到
put_old位置 - 更新进程的根目录和当前工作目录
pivot_root() 与 chroot() 的关键区别在于它同时修改了挂载树的结构——不仅仅是修改路径查找的起点,而是真正地交换了挂载点的位置。
chroot()
chroot() 仅修改调用进程的路径查找根目录。它通过修改进程的 fs_struct->root 实现。但 chroot() 存在安全限制:
- 不修改挂载树结构
- 可以通过文件描述符或
/proc逃逸(如果权限配置不当) - 不改变当前工作目录
对比
| 特性 | pivot_root() | chroot() |
|---|---|---|
| 修改挂载树 | 是 | 否 |
| 修改根目录 | 是 | 是 |
| 修改当前工作目录 | 是 | 否 |
| 需要新挂载点 | 是(需要 new_root 和 put_old) | 否 |
| 安全性 | 较高 | 较低(可能被逃逸) |
| 典型使用 | 容器初始化 | 简单的目录隔离 |
容器运行时(如 Docker、runc)通常使用 pivot_root() 来建立容器的根文件系统,因为它提供了更强的隔离保证。
9.3.7 容器文件系统的构建:OverlayFS + Mount 命名空间
容器运行时构建隔离文件系统视图的典型方法是将 OverlayFS 与 Mount 命名空间结合使用。
基本流程
-
创建新的 Mount 命名空间:通过
clone(CLONE_NEWNS)或unshare(CLONE_NEWNS)创建新的 Mount 命名空间。 -
挂载 OverlayFS:将容器镜像层(lowerdir)和可写层(upperdir)叠加为 OverlayFS 文件系统:
mount -t overlay overlay \ -olowerdir=/images/base:/images/layer1 \ -oupperdir=/containers/ctr1/rw \ -oworkdir=/containers/ctr1/work \ /containers/ctr1/mergedOverlayFS 使得容器看起来拥有完整的文件系统,但实际上只有修改过的文件存储在 upperdir 中,基础层通过 lowerdir 只读共享。 -
pivot_root():将 OverlayFS 的合并目录设为新的根文件系统。
-
挂载 /proc、/dev 等:在新命名空间中挂载必要的虚拟文件系统。
挂载传播在容器中的作用
当容器创建新的 Mount 命名空间时,挂载传播类型决定了容器内部和宿主机之间的挂载事件如何交互:
- 如果根文件系统是
MS_SHARED,容器内部的挂载操作会传播到宿主机——这是不安全的 - 容器运行时通常将挂载点设置为
MS_PRIVATE或MS_SLAVE,防止容器的挂载操作泄露到外部 MS_SLAVE允许宿主机的挂载变化(如/dev/sda1的重新挂载)传播到容器内部,但容器内的变化不会反向传播
9.3.8 /proc/[pid]/mountinfo
/proc/[pid]/mountinfo 文件展示了进程所在 Mount 命名空间中的挂载信息。每一行描述一个挂载点,格式如下:
36 35 98:0 / /sys rw,nosuid,nodev,noexec - sysfs sysfs rw
各字段含义依次为:挂载 ID、父挂载 ID、设备号(major:minor)、根路径、挂载点、挂载选项、可选字段、分隔符、文件系统类型、设备源、超级块选项。
mountinfo 的实现通过 fs/namespace.c 中的 mounts_op 序列操作完成,它遍历 Mount 命名空间红黑树中的所有挂载点,为每个挂载点输出一行信息。
值得注意的是,/proc/[pid]/mountinfo 只显示目标进程所在 Mount 命名空间中的挂载点——即使该进程能看到其他命名空间中的挂载点(通过共享子树传播),mountinfo 也只列出属于其自身命名空间的挂载。
9.3.9 mount --bind 与命名空间交互
mount --bind(绑定挂载)是一种将已有目录树挂载到另一个位置的机制。绑定挂载与 Mount 命名空间的交互受到传播类型的显著影响。
绑定挂载的行为
执行 mount --bind /source /target 时,内核创建一个新的 struct mount 实例,它共享 /source 所在的超级块(superblock),但拥有独立的挂载选项和传播设置。
传播类型的影响
- MS_SHARED:在共享挂载点上创建的绑定挂载会传播到 peer 组中的所有成员
- MS_PRIVATE:绑定挂载仅影响当前命名空间
- MS_SLAVE:绑定挂载仅影响当前命名空间(与 PRIVATE 类似,但保持从 master 接收传播)
- MS_UNBINDABLE:不能作为绑定挂载的源
递归绑定挂载
mount --rbind(递归绑定挂载)会递归地绑定源目录下的所有子挂载点。如果源目录下有大量嵌套挂载点,递归绑定可能导致挂载点数量急剧增长。MS_UNBINDABLE 类型正是为了防止这种递归爆炸而设计的。
9.3.10 "Mount 传播地狱"问题
在容器技术早期,开发者经常遇到所谓的"Mount 传播地狱"(Mount Propagation Hell)问题。这种问题表现为:
问题描述
- 宿主机的根文件系统默认是
MS_SHARED(由 systemd 设置) - 容器创建新的 Mount 命名空间时,
copy_mnt_ns()复制了共享属性 - 容器内部的挂载操作通过传播机制影响到了宿主机
- 宿主机上的挂载操作也意外地传播到容器内部
- 卸载操作在不同命名空间之间产生意外的级联效应
典型场景
宿主机 (init_mnt_ns):
/ MS_SHARED
├── /var/lib/docker MS_SHARED (继承)
└── /mnt MS_SHARED (继承)
容器 (container_mnt_ns):
/ MS_SHARED (从宿主机继承)
├── /var MS_SHARED (从宿主机继承)
└── /proc MS_SHARED (新挂载)
问题:容器内 mount /dev/sda1 /mnt → 传播到宿主机的 /mnt!
共享子树的解决方案
解决方案是在创建容器时将根挂载点显式设置为 MS_PRIVATE 或 MS_SLAVE:
# 在新的 Mount 命名空间中
mount --make-rprivate / # 递归设为 PRIVATE
# 或
mount --make-rslave / # 递归设为 SLAVE
MS_PRIVATE 完全切断传播,MS_SLAVE 允许接收来自宿主机的传播但不回传。容器运行时(如 runc)在创建容器时会在新的 Mount 命名空间中执行这些操作,确保挂载传播被正确控制。
runc 的典型启动流程中,创建 Mount 命名空间后的第一步操作就是:
unshare(CLONE_NEWNS)
mount("", "/", "", MS_REC | MS_PRIVATE, "")
MS_REC 标志表示递归操作,将整个挂载树的所有挂载点都设置为 PRIVATE。这彻底切断了新命名空间与宿主机之间的传播链路。
9.3.11 命名空间的全局锁与并发控制
Mount 命名空间的操作受到全局读写信号量 namespace_sem 的保护。该信号量定义在 fs/namespace.c 第 83 行:
static DECLARE_RWSEM(namespace_sem);
- 读锁(共享):用于查看挂载信息(如读取
/proc/mounts) - 写锁(排他):用于修改挂载树(mount、umount、pivot_root、传播操作)
namespace_sem 与 mount_lock(seqlock_t)配合使用:namespace_sem 保护挂载树的结构修改,mount_lock 保护挂载点的引用计数和序列号读取。
在 copy_mnt_ns() 中,使用了 guard(namespace_excl)() RAII 守卫来自动管理 namespace_sem 的获取和释放:
guard(namespace_excl)(); // 获取 namespace_sem 写锁
9.3.12 Mount 命名空间的限制
Linux 7.0 对 Mount 命名空间施加了以下限制:
挂载点数量上限:sysctl_mount_max(默认 100000)限制单个命名空间中的最大挂载点数量。当 nr_mounts + pending_mounts 超过此限制时,新的挂载操作将返回 -ENOSPC。
用户计数器限制:每个用户可以创建的 Mount 命名空间数量受到 UCOUNT_MNT_NAMESPACES 计数器的限制。通过 max_mnt_namespaces sysctl 可调整。
权限要求:创建新的 Mount 命名空间需要在拥有该命名空间的 User 命名空间中拥有 CAP_SYS_ADMIN 能力。在 Mount 命名空间内执行 mount/umount 操作也需要 CAP_SYS_ADMIN。
9.3.13 小结
Mount 命名空间是 Linux 内核最早实现的命名空间类型,它为进程提供了独立的文件系统挂载视图。struct mnt_namespace 以红黑树组织挂载点集合,struct mount 完整描述每个挂载点的属性和传播关系。共享子树机制通过四种传播类型(SHARED、SLAVE、PRIVATE、UNBINDABLE)控制不同命名空间之间的挂载事件传播。copy_mnt_ns() 在创建新命名空间时复制挂载树,并根据 User 命名空间的差异决定是否设置 slave 传播。pivot_root() 为容器提供安全的根文件系统切换能力。容器运行时通过合理配置传播类型(通常为 PRIVATE 或 SLAVE)来避免"Mount 传播地狱"问题,确保容器内部与宿主机之间的挂载事件互不干扰。
9.4 Network Namespace —— 网络栈隔离
网络命名空间(Network Namespace)是 Linux 内核中最为复杂的命名空间之一。它提供了对整个网络协议栈的完整隔离,使得每个网络命名空间都拥有独立的网络设备、IP 地址、路由表、端口号空间、防火墙规则等。正是这种全面的网络隔离能力,使得 Docker、Kubernetes 等容器技术能够为每个容器构建独立的网络环境。
9.4.1 隔离范围:完整的网络协议栈
网络命名空间的隔离范围极为广泛,几乎覆盖了网络协议栈的所有层面。当一个新的网络命名空间被创建时,以下所有资源都是全新且独立的:
- 网络接口:每个命名空间拥有自己的网络设备列表。全局的
eth0在不同命名空间中可以指向不同的物理或虚拟设备。 - IP 地址和路由表:每个命名空间维护独立的 IPv4/IPv6 地址配置和路由表,互不干扰。
- iptables/nftables 规则:防火墙规则完全隔离,一个命名空间中的 iptables 规则不会影响其他命名空间。
- 端口号空间:每个命名空间拥有完整的 0-65535 端口范围。这意味着不同命名空间中的进程可以同时绑定到 80 端口而不会发生冲突。
- /proc/net 和 /sys/class/net:这两个 procfs 和 sysfs 接口在命名空间中呈现各自的网络状态信息。
- Socket 抽象命名空间:Unix 域套接字的抽象地址空间也是隔离的。
9.4.2 struct net:庞大的网络命名空间核心
网络命名空间的核心数据结构是 struct net,定义在 include/net/net_namespace.h(第 62-204 行)。它是 Linux 内核中最大的命名空间结构体之一,因为网络协议栈本身就是一个极其复杂的子系统。
// include/net/net_namespace.h, 第 62-204 行
struct net {
/* 第一个缓存行可能被频繁写入,
* 不要在此处放置只读字段。 */
refcount_t passive; /* 用于决定何时释放网络命名空间 */
spinlock_t rules_mod_lock;
unsigned int dev_base_seq; /* 由 rtnl_mutex 保护 */
u32 ifindex;
spinlock_t nsid_lock;
atomic_t fnhe_genid;
struct list_head list; /* 网络命名空间链表节点 */
struct list_head exit_list; /* 死亡命名空间的退出链表 */
struct llist_node defer_free_list;
struct llist_node cleanup_list; /* 死亡队列中的命名空间 */
struct list_head ptype_all;
struct list_head ptype_specific;
struct user_namespace *user_ns; /* 所属的用户命名空间 */
struct ucounts *ucounts;
struct idr netns_ids; /* 命名空间间标识符 */
struct ns_common ns; /* 通用命名空间抽象 */
struct ref_tracker_dir refcnt_tracker;
struct ref_tracker_dir notrefcnt_tracker;
struct list_head dev_base_head; /* 网络设备链表头 */
struct proc_dir_entry *proc_net; /* /proc/net 目录 */
struct proc_dir_entry *proc_net_stat;
struct ctl_table_set sysctls; /* sysctl 表集合 */
struct sock *rtnl; /* rtnetlink 套接字 */
struct sock *genl_sock; /* generic netlink 套接字 */
struct uevent_sock *uevent_sock; /* uevent 套接字 */
struct hlist_head *dev_name_head; /* 设备名哈希表 */
struct hlist_head *dev_index_head;/* 设备索引哈希表 */
struct xarray dev_by_index; /* 按索引查找设备的 xarray */
struct raw_notifier_head netdev_chain;
u32 hash_mix;
bool is_dying;
struct net_device *loopback_dev; /* 回环设备 */
struct list_head rules_ops; /* 路由规则操作列表 */
struct netns_core core; /* 核心网络参数 */
struct netns_mib mib; /* SNMP 统计信息 */
struct netns_packet packet; /* AF_PACKET 相关 */
struct netns_unix unx; /* Unix 域套接字 */
struct netns_nexthop nexthop; /* 下一跳 */
struct netns_ipv4 ipv4; /* IPv4 协议栈 */
struct netns_ipv6 ipv6; /* IPv6 协议栈 */
struct netns_nf nf; /* netfilter/iptables */
struct netns_ct ct; /* conntrack 连接跟踪 */
struct netns_nftables nft; /* nftables */
struct netns_xfrm xfrm; /* IPsec */
struct netns_bpf bpf; /* BPF 程序 */
u64 net_cookie; /* 全局唯一标识符,只写一次 */
/* ... 以及更多网络子系统 */
};
这个结构体的规模反映了网络协议栈的复杂性。其中一些关键字段的作用如下:
dev_base_head(第 102 行):该命名空间中所有网络设备链表的头部。每个网络设备(struct net_device)通过其name_list和index_list成员挂载到此链表上。ipv4(第 138 行):包含整个 IPv4 协议栈的独立状态,包括独立的路由表、IP 配置、TCP/UDP 参数等。ipv6(第 140 行):IPv6 协议栈的独立状态。nf(第 149 行):Netfilter 子系统的独立状态,意味着每个命名空间拥有独立的防火墙规则。ct(第 151 行):连接跟踪表的独立实例。nft(第 154 行):nftables 规则的独立实例。proc_net(第 103 行):每个命名空间有独立的/proc/net视图。loopback_dev(第 126 行):指向该命名空间的回环设备。user_ns(第 93 行):指向拥有此网络命名空间的用户命名空间,用于权限检查。netns_ids(第 95 行):用于跨命名空间标识的 IDR 结构。
9.4.3 网络设备迁移与虚拟设备
网络命名空间的一大核心能力是支持网络设备在不同命名空间之间的迁移。这通过 netlink 接口实现:
# 将 eth0 移动到指定命名空间
ip link set dev eth0 netns mycontainer
# 也可以通过 PID 指定目标命名空间
ip link set dev eth0 netns 1234
在内核层面,设备的迁移通过修改 struct net_device 中的 nd_net 字段(即设备所属的 struct net 指针)来完成。迁移时需要持有 rtnl_mutex 锁,以确保操作的原子性和一致性。设备从一个命名空间注销后,立即在目标命名空间中重新注册,其 ifindex 可能改变,但设备名可以保持不变。
值得注意的是,并非所有网络设备都可以在命名空间之间迁移。某些绑定到特定硬件的设备(如某些物理网卡的特定功能)可能不支持迁移。
veth 设备对(Virtual Ethernet Pair)是容器网络中最常用的虚拟网络设备。veth 设备总是成对创建,像一条虚拟网线一样连接两个网络命名空间:
+------------------+ +------------------+
| Container NS | | Host NS |
| | | |
| eth0 (vetha) |<------>| vethb (veth pair)|
| 172.17.0.2/16 | | 172.17.0.1/16 |
| | | | |
+------------------+ | docker0 (bridge)|
+------------------+
创建 veth 对并将其一端移入容器的命令如下:
# 创建 veth 对
ip link add veth-host type veth peer name veth-container
# 将一端移入容器命名空间
ip link set veth-container netns <container-pid>
# 配置容器端的 IP 地址
ip netns exec <container-pid> ip addr add 172.17.0.2/16 dev veth-container
除了 veth 对,还有 macvlan 和 ipvlan 等技术允许在同一个物理网卡上创建多个虚拟网络接口,每个接口属于不同的命名空间,各自拥有独立的 MAC 地址或 IP 地址。
9.4.4 回环设备(Loopback Device)
每个网络命名空间在创建时都会自动获得一个回环设备 lo。回环设备是一种特殊的虚拟网络设备,所有发送到它的数据包都会直接返回给发送者,用于本地进程间的网络通信。
回环设备的实现位于 drivers/net/loopback.c。其初始化流程如下:
// drivers/net/loopback.c, 第 200-237 行
static void loopback_setup(struct net_device *dev)
{
gen_lo_setup(dev, (64 * 1024), &loopback_ethtool_ops, ð_header_ops,
&loopback_ops, loopback_dev_free);
}
static __net_init int loopback_net_init(struct net *net)
{
struct net_device *dev;
int err;
err = -ENOMEM;
dev = alloc_netdev(0, "lo", NET_NAME_PREDICTABLE, loopback_setup);
if (!dev)
goto out;
dev_net_set(dev, net);
err = register_netdev(dev);
if (err)
goto out_free_netdev;
BUG_ON(dev->ifindex != LOOPBACK_IFINDEX);
net->loopback_dev = dev;
return 0;
out_free_netdev:
free_netdev(dev);
out:
if (net_eq(net, &init_net))
panic("loopback: Failed to register netdevice: %d\n", err);
return err;
}
struct pernet_operations __net_initdata loopback_net_ops = {
.init = loopback_net_init,
};
关键要点:
loopback_net_init()为每个新的网络命名空间分配并注册名为lo的回环设备。- 通过
dev_net_set(dev, net)将设备绑定到新创建的命名空间。 - 注册完成后,通过
net->loopback_dev = dev将回环设备指针保存在struct net中。 - 回环设备的
ifindex固定为LOOPBACK_IFINDEX(通常为 1),这一约束通过BUG_ON宏来保证。 - 对于初始命名空间
init_net,如果回环设备注册失败则直接 panic,因为系统无法在没有回环设备的情况下运行。
该操作通过 register_pernet_device(&loopback_net_ops) 注册(在 net/core/dev.c 第 13306 行),确保回环设备始终是每个命名空间中的第一个网络设备。
9.4.5 copy_net_ns():网络命名空间的创建流程
创建新的网络命名空间的核心函数是 copy_net_ns(),定义在 net/core/net_namespace.c(第 549-596 行):
// net/core/net_namespace.c, 第 549-596 行
struct net *copy_net_ns(u64 flags,
struct user_namespace *user_ns, struct net *old_net)
{
struct ucounts *ucounts;
struct net *net;
int rv;
if (!(flags & CLONE_NEWNET))
return get_net(old_net);
ucounts = inc_net_namespaces(user_ns);
if (!ucounts)
return ERR_PTR(-ENOSPC);
net = net_alloc();
if (!net) {
rv = -ENOMEM;
goto dec_ucounts;
}
rv = preinit_net(net, user_ns);
if (rv < 0)
goto dec_ucounts;
net->ucounts = ucounts;
get_user_ns(user_ns);
rv = down_read_killable(&pernet_ops_rwsem);
if (rv < 0)
goto put_userns;
rv = setup_net(net);
up_read(&pernet_ops_rwsem);
if (rv < 0) {
put_userns:
ns_common_free(net);
put_user_ns(user_ns);
net_passive_dec(net);
dec_ucounts:
dec_net_namespaces(ucounts);
return ERR_PTR(rv);
}
return net;
}
创建流程分为以下步骤:
步骤一:计数检查(第 559-561 行)
调用 inc_net_namespaces() 检查当前用户命名空间中是否还可以创建新的网络命名空间。该函数通过 inc_ucount() 在用户命名空间层级链中逐级递增计数,如果任何一级超过了 ucount_max[UCOUNT_NET_NAMESPACES] 限制,则返回 NULL,copy_net_ns() 返回 -ENOSPC。
步骤二:分配 struct net(第 563-566 行)
通过 net_alloc() 从 slab 分配器分配 struct net 结构体。该函数同时分配 net_generic 结构,用于存储各网络子系统的私有数据。
// net/core/net_namespace.c, 第 481-513 行
static struct net *net_alloc(void)
{
struct net *net = NULL;
struct net_generic *ng;
ng = net_alloc_generic();
if (!ng)
goto out;
net = kmem_cache_zalloc(net_cachep, GFP_KERNEL);
if (!net)
goto out_free;
rcu_assign_pointer(net->gen, ng);
out:
return net;
out_free:
kfree(ng);
goto out;
}
步骤三:预初始化(第 569-572 行)
preinit_net() 执行基础的初始化工作,设置引用计数、随机哈希种子、用户命名空间指针等:
// net/core/net_namespace.c, 第 402-431 行
static __net_init int preinit_net(struct net *net, struct user_namespace *user_ns)
{
int ret;
ret = ns_common_init(net);
if (ret)
return ret;
refcount_set(&net->passive, 1);
ref_tracker_dir_init(&net->refcnt_tracker, 128, "net_refcnt");
ref_tracker_dir_init(&net->notrefcnt_tracker, 128, "net_notrefcnt");
get_random_bytes(&net->hash_mix, sizeof(u32));
net->dev_base_seq = 1;
net->user_ns = user_ns;
idr_init(&net->netns_ids);
spin_lock_init(&net->nsid_lock);
INIT_LIST_HEAD(&net->ptype_all);
INIT_LIST_HEAD(&net->ptype_specific);
preinit_net_sysctl(net);
return 0;
}
步骤四:子系统初始化(第 579 行)
setup_net() 遍历所有已注册的 pernet_operations,依次调用其 init() 回调函数:
// net/core/net_namespace.c, 第 436-465 行
static __net_init int setup_net(struct net *net)
{
/* 必须在持有 pernet_ops_rwsem 的情况下调用 */
const struct pernet_operations *ops;
LIST_HEAD(net_exit_list);
int error = 0;
net->net_cookie = ns_tree_gen_id(net);
list_for_each_entry(ops, &pernet_list, list) {
error = ops_init(ops, net);
if (error < 0)
goto out_undo;
}
down_write(&net_rwsem);
list_add_tail_rcu(&net->list, &net_namespace_list);
up_write(&net_rwsem);
ns_tree_add_raw(net);
out:
return error;
out_undo:
list_add(&net->exit_list, &net_exit_list);
ops_undo_list(&pernet_list, ops, &net_exit_list, false);
rcu_barrier();
goto out;
}
初始化成功后,新的命名空间被加入全局的 net_namespace_list 链表。如果某个子系统的初始化失败,则回滚之前所有已成功的初始化操作。
9.4.6 pernet_operations:按命名空间注册子系统
Linux 内核的网络子系统通过 struct pernet_operations 机制来注册自己的命名空间感知能力。该结构定义在 include/net/net_namespace.h(第 459-492 行):
// include/net/net_namespace.h, 第 459-492 行
struct pernet_operations {
struct list_head list;
int (*init)(struct net *net);
void (*pre_exit)(struct net *net);
void (*exit)(struct net *net);
void (*exit_batch)(struct list_head *net_exit_list);
void (*exit_rtnl)(struct net *net, struct list_head *dev_kill_list);
unsigned int * const id;
const size_t size;
};
各回调函数的调用时机如下:
init():当新的网络命名空间创建时被调用,用于初始化该子系统在此命名空间中的状态。pre_exit():在命名空间销毁的早期阶段调用,此时大部分子系统仍处于正常工作状态。exit():命名空间销毁时调用,用于清理该子系统的状态。在pre_exit()和exit()之间保证有一个synchronize_rcu()宽限期。exit_batch():批量处理多个待销毁命名空间的退出操作,减少synchronize_rcu()的调用次数。exit_rtnl():持有 RTNL 锁的情况下调用,用于需要在锁保护下执行的网络设备清理操作。id和size:用于分配和索引该子系统在net_generic中的私有数据。
内核提供两种注册方式:
-
register_pernet_subsys()(第 1432-1440 行):注册核心网络子系统。这些子系统的退出操作在所有网络设备被移除后才执行。 -
register_pernet_device()(第 1478-1488 行):注册需要与网络设备共存的操作。这些操作在网络子系统的设备列表之前注册,确保设备在子系统之前初始化。
// net/core/net_namespace.c, 第 1432-1488 行
int register_pernet_subsys(struct pernet_operations *ops)
{
int error;
down_write(&pernet_ops_rwsem);
error = register_pernet_operations(first_device, ops);
up_write(&pernet_ops_rwsem);
return error;
}
int register_pernet_device(struct pernet_operations *ops)
{
int error;
down_write(&pernet_ops_rwsem);
error = register_pernet_operations(&pernet_list, ops);
if (!error && (first_device == &pernet_list))
first_device = &ops->list;
up_write(&pernet_ops_rwsem);
return error;
}
first_device 指针是一个关键的分界点。设备类操作(如回环设备)被插入到 pernet_list 的前面,而子系统类操作则插入到 first_device 之后。这确保了在销毁命名空间时,设备在子系统之前被清理。
几乎所有网络子系统都通过此机制注册了自己的按命名空间初始化逻辑:
| 子系统 | 注册方式 | 功能 |
|---|---|---|
| loopback | register_pernet_device |
回环设备 |
| IPv4 | register_pernet_subsys |
IPv4 协议栈 |
| IPv6 | register_pernet_subsys |
IPv6 协议栈 |
| netfilter | register_pernet_subsys |
防火墙规则 |
| conntrack | register_pernet_subsys |
连接跟踪 |
| nftables | register_pernet_subsys |
nftables 规则 |
| TCP/UDP | register_pernet_subsys |
传输层协议 |
9.4.7 网络命名空间的清理
当最后一个引用网络命名空间的进程退出后,命名空间进入清理阶段。清理工作通过工作队列异步执行,核心函数是 cleanup_net()(第 662-725 行):
// net/core/net_namespace.c, 第 662-725 行
static void cleanup_net(struct work_struct *work)
{
struct llist_node *net_kill_list;
struct net *net, *tmp, *last;
LIST_HEAD(net_exit_list);
WRITE_ONCE(cleanup_net_task, current);
/* 原子性地获取待清理的命名空间快照 */
net_kill_list = llist_del_all(&cleanup_list);
down_read(&pernet_ops_rwsem);
/* 不让其他进程找到这些命名空间 */
down_write(&net_rwsem);
llist_for_each_entry(net, net_kill_list, cleanup_list) {
ns_tree_remove(net);
list_del_rcu(&net->list);
net->is_dying = true;
}
last = list_last_entry(&net_namespace_list, struct net, list);
up_write(&net_rwsem);
/* 清理 nsid 映射 */
unhash_nsid(last);
/* 将待清理命名空间加入退出链表 */
llist_for_each_entry(net, net_kill_list, cleanup_list) {
idr_destroy(&net->netns_ids);
list_add_tail(&net->exit_list, &net_exit_list);
}
/* 按逆序调用所有 pernet_ops 的 pre_exit/exit/exit_batch */
ops_undo_list(&pernet_list, NULL, &net_exit_list, true);
up_read(&pernet_ops_rwsem);
rcu_barrier();
net_complete_free();
/* 最终释放网络命名空间结构 */
list_for_each_entry_safe(net, tmp, &net_exit_list, exit_list) {
list_del_init(&net->exit_list);
ns_common_free(net);
dec_net_namespaces(net->ucounts);
put_user_ns(net->user_ns);
net_passive_dec(net);
}
WRITE_ONCE(cleanup_net_task, NULL);
}
清理流程的关键步骤:
-
从全局链表中移除(第 677-681 行):首先获取
net_rwsem写锁,将所有待清理命名空间从net_namespace_list中移除,并标记is_dying = true。此后新的命名空间不会引用这些正在消亡的命名空间。 -
清理跨命名空间标识(第 695 行):
unhash_nsid()遍历所有仍然存活的命名空间,删除指向正在消亡命名空间的 nsid 映射。 -
调用退出回调(第 702 行):
ops_undo_list()按注册的逆序调用pre_exit()、exit_rtnl()、exit()和exit_batch()。这里使用synchronize_rcu_expedited()来加速等待 RCU 宽限期。 -
最终释放(第 714-723 行):释放
struct net结构体本身,递减 ucounts 计数,释放对用户命名空间的引用。
__put_net() 函数是清理的触发点。当 struct net 的引用计数降为 0 时,它将命名空间加入 cleanup_list 并调度 net_cleanup_work 工作队列:
// net/core/net_namespace.c, 第 745-752 行
void __put_net(struct net *net)
{
ref_tracker_dir_exit(&net->refcnt_tracker);
if (llist_add(&net->cleanup_list, &cleanup_list))
queue_work(netns_wq, &net_cleanup_work);
}
9.4.8 netns_ids:跨命名空间标识
网络命名空间通过 netns_ids 机制实现了跨命名空间的标识和查找。每个网络命名空间维护一个 IDR(Integer ID Resource),将其他命名空间映射为本地整数 ID。
// net/core/net_namespace.c, 第 314-348 行
int peernet2id_alloc(struct net *net, struct net *peer, gfp_t gfp)
{
int id;
if (!check_net(net))
return NETNSA_NSID_NOT_ASSIGNED;
spin_lock(&net->nsid_lock);
id = __peernet2id(net, peer);
if (id >= 0) {
spin_unlock(&net->nsid_lock);
return id;
}
if (!maybe_get_net(peer)) {
spin_unlock(&net->nsid_lock);
return NETNSA_NSID_NOT_ASSIGNED;
}
id = alloc_netid(net, peer, -1);
spin_unlock(&net->nsid_lock);
put_net(peer);
if (id < 0)
return NETNSA_NSID_NOT_ASSIGNED;
rtnl_net_notifyid(net, RTM_NEWNSID, id, 0, NULL, gfp);
return id;
}
这种设计允许网络命名空间通过 netlink 接口(RTM_GETNSID/RTM_NEWNSID)相互引用和识别。这在容器网络中特别重要,例如当 OVS(Open vSwitch)等网络软件需要在多个命名空间之间建立连接时。
反向查找则通过 get_net_ns_by_id() 完成(第 372-387 行),它从 IDR 中根据 ID 值查找对应的 struct net 指针。
9.4.9 权限检查与命名空间安装
当进程试图通过 setns() 系统调用切换到另一个网络命名空间时,内核会进行严格的权限检查。这些检查在 netns_install() 函数中完成(第 1529-1541 行):
// net/core/net_namespace.c, 第 1529-1541 行
static int netns_install(struct nsset *nsset, struct ns_common *ns)
{
struct nsproxy *nsproxy = nsset->nsproxy;
struct net *net = to_net_ns(ns);
if (!ns_capable(net->user_ns, CAP_SYS_ADMIN) ||
!ns_capable(nsset->cred->user_ns, CAP_SYS_ADMIN))
return -EPERM;
put_net(nsproxy->net_ns);
nsproxy->net_ns = get_net(net);
return 0;
}
这里有两个关键的权限检查:
ns_capable(net->user_ns, CAP_SYS_ADMIN):检查调用者是否在目标网络命名空间的所属用户命名空间中拥有CAP_SYS_ADMIN能力。ns_capable(nsset->cred->user_ns, CAP_SYS_ADMIN):检查调用者是否在自己的当前用户命名空间中拥有CAP_SYS_ADMIN能力。
这两个条件必须同时满足,防止了权限提升攻击。
9.4.10 实践:容器网络架构
现代容器运行时利用网络命名空间构建了完整的容器网络解决方案。以下分别介绍 Docker 和 Kubernetes 的网络模型。
Docker 网络模型
Docker 默认使用 bridge 网络模式,其工作原理如下:
+-----------------------------------------------------------+
| Host Network Namespace |
| |
| +---------+ +--------------------------------------+ |
| | docker0 |-----| veth12345 (veth pair 的宿主端) | |
| | (bridge)| +--------------------------------------+ |
| | 172.17 | | |
| | .0.1 | | |
| +---------+ | (NAT/iptables) |
| | | |
+-------|-------------------|------------------------------+
| |
| +-------------------------------------------+
| | Container Network Namespace |
| | |
| | eth0 (veth pair 的容器端) |
| +-- 172.17.0.2/16 |
| | |
| | lo (127.0.0.1) |
| +-------------------------------------------+
|
[外部网络]
关键配置步骤包括:
- 创建 veth 设备对。
- 将 veth 一端移入容器的网络命名空间。
- 在宿主机端创建或加入网桥(docker0)。
- 配置 iptables NAT 规则,使容器可以通过宿主机的 IP 访问外部网络。
- 容器内的默认路由指向网桥的 IP 地址。
Kubernetes CNI 网络模型
Kubernetes 通过 CNI(Container Network Interface)插件来管理 Pod 的网络。常见的 CNI 插件包括 Calico、Flannel 和 Cilium。以 Calico 为例,它使用 BGP 协议在节点间传播路由信息,每个 Pod 都获得一个真实的、可路由的 IP 地址。
+-------------------+ +-------------------+
| Node 1 | BGP | Node 2 |
| |<--------->| |
| +------+ +----+ | 路由交换 | +----+ +------+ |
| | PodA | |PodB| | | |PodC| |PodD | |
| | NetNS| |NS | | | |NS | |NS | |
| +------+ +----+ | | +----+ +------+ |
+-------------------+ +-------------------+
Cilium 则更进一步,利用 eBPF 程序在网络命名空间的底层实现高性能的数据包过滤、负载均衡和安全策略。每个 Pod 的网络命名空间中都会加载特定的 BPF 程序,实现了网络、安全和可观测性的一体化。
9.4.11 全局初始化
系统启动时,net_ns_init() 函数初始化网络命名空间子系统(第 1259-1302 行):
// net/core/net_namespace.c, 第 1259-1302 行
void __init net_ns_init(void)
{
struct net_generic *ng;
net_cachep = kmem_cache_create("net_namespace", sizeof(struct net),
SMP_CACHE_BYTES, SLAB_PANIC|SLAB_ACCOUNT, NULL);
netns_wq = create_singlethread_workqueue("netns");
if (!netns_wq)
panic("Could not create netns workq");
ng = net_alloc_generic();
if (!ng)
panic("Could not allocate generic netns");
rcu_assign_pointer(init_net.gen, ng);
if (preinit_net(&init_net, &init_user_ns))
panic("Could not preinitialize the initial network namespace");
down_write(&pernet_ops_rwsem);
if (setup_net(&init_net))
panic("Could not setup the initial network namespace");
init_net_initialized = true;
up_write(&pernet_ops_rwsem);
if (register_pernet_subsys(&net_ns_ops))
panic("Could not register network namespace subsystems");
rtnl_register_many(net_ns_rtnl_msg_handlers);
}
该函数创建 slab 缓存 net_cachep 用于后续分配 struct net,创建 netns 工作队列用于异步清理,初始化全局命名空间 init_net,并注册 netlink 消息处理器。
9.4.12 小结
网络命名空间是 Linux 容器网络的核心基石。通过 struct net 这个庞大的数据结构,内核为每个网络命名空间提供了从物理层到应用层的完整协议栈隔离。pernet_operations 机制使得各网络子系统能够独立地注册自己的按命名空间初始化和清理逻辑。netns_ids 机制则解决了跨命名空间引用的问题。理解网络命名空间的实现对于深入理解容器网络、调试网络问题以及开发网络虚拟化解决方案都至关重要。
9.5 User Namespace —— 用户身份隔离
用户命名空间(User Namespace)是 Linux 命名空间体系中安全意义最为重大的一种。它实现了 UID/GID 的隔离与映射,允许非特权用户在容器内部拥有 root 权限,同时保持宿主机上的普通用户身份。正是用户命名空间的存在,使得"无特权容器"(rootless containers)成为可能——这是现代容器安全架构的核心支柱之一。
9.5.1 用户命名空间的核心意义
在没有用户命名空间的世界里,容器中的进程如果要绑定 80 端口、修改文件权限或执行其他需要 root 权限的操作,就必须在宿主机上也拥有真实的 root 权限。这意味着一旦容器被攻破,攻击者将直接获得宿主机的 root 权限。
用户命名空间彻底改变了这一局面。它允许我们将容器内部的 UID 0(root)映射到宿主机上的一个普通用户 UID(例如 100000),从而实现:
- 容器内部看来,进程是 root,拥有完整的能力集(capabilities)。
- 在宿主机看来,该进程只是一个普通用户,权限受到严格限制。
- 即使容器被攻破,攻击者在宿主机上仍然只是普通用户,无法破坏系统。
这种设计从根本上降低了容器运行的安全风险,是容器"纵深防御"策略的关键一环。
9.5.2 核心数据结构
struct user_namespace
用户命名空间的核心数据结构定义在 include/linux/user_namespace.h(第 76-117 行):
// include/linux/user_namespace.h, 第 76-117 行
struct user_namespace {
struct uid_gid_map uid_map; /* UID 映射表 */
struct uid_gid_map gid_map; /* GID 映射表 */
struct uid_gid_map projid_map; /* Project ID 映射表 */
struct user_namespace *parent; /* 父用户命名空间 */
int level; /* 嵌套深度 */
kuid_t owner; /* 创建者的内核 UID */
kgid_t group; /* 创建者的内核 GID */
struct ns_common ns; /* 通用命名空间抽象 */
unsigned long flags; /* 标志位(如 SETGROUPS_ALLOWED) */
bool parent_could_setfcap; /* 创建者是否有 CAP_SETFCAP */
struct ucounts *ucounts; /* 用户计数器 */
long ucount_max[UCOUNT_COUNTS]; /* 各类命名空间的最大计数 */
long rlimit_max[UCOUNT_RLIMIT_COUNTS]; /* 资源限制上限 */
};
各字段详解:
uid_map、gid_map、projid_map:三张映射表,分别定义 UID、GID 和 Project ID 在此命名空间与父命名空间之间的转换关系。parent:指向父用户命名空间。用户命名空间形成一棵树,根节点是init_user_ns。level:嵌套深度。init_user_ns的 level 为 0,每创建一级子命名空间,level 加 1。owner和group:记录创建此命名空间的进程的有效 UID/GID。这在映射写权限检查中使用。flags:包含USERNS_SETGROUPS_ALLOWED标志,控制是否允许在该命名空间中调用setgroups()。parent_could_setfcap:记录创建者在创建时是否拥有CAP_SETFCAP能力,用于后续的 UID 0 映射安全检查。ucounts:指向该命名空间中用户的计数器结构,用于限制各类命名空间的创建数量。ucount_max[]:各类命名空间的最大允许数量。rlimit_max[]:RLIMIT_NPROC、RLIMIT_MSGQUEUE、RLIMIT_SIGPENDING、RLIMIT_MEMLOCK 四种资源限制在该命名空间中的上限。
struct uid_gid_map 与映射机制
UID/GID 映射的核心是 struct uid_gid_map(第 25-36 行)和 struct uid_gid_extent(第 19-23 行):
// include/linux/user_namespace.h, 第 19-36 行
struct uid_gid_extent {
u32 first; /* 命名空间内的起始 ID */
u32 lower_first; /* 父命名空间中的起始 ID */
u32 count; /* 连续映射的 ID 数量 */
};
struct uid_gid_map { /* 64 字节 -- 1 个缓存行 */
union {
struct {
struct uid_gid_extent extent[UID_GID_MAP_MAX_BASE_EXTENTS]; /* 最多 5 个 */
u32 nr_extents; /* 当前映射条目数 */
};
struct {
struct uid_gid_extent *forward; /* 正向映射数组(动态分配) */
struct uid_gid_extent *reverse; /* 反向映射数组(动态分配) */
};
};
};
映射机制的设计非常巧妙。当映射条目数不超过 UID_GID_MAP_MAX_BASE_EXTENTS(5 个)时,映射直接存储在结构体内部的 extent 数组中,无需额外的内存分配。当映射条目超过 5 个时(最多可达 UID_GID_MAP_MAX_EXTENTS 即 340 个),内核会动态分配 forward 和 reverse 两个数组。
forward 数组按 first(命名空间内 ID)排序,用于从命名空间内部 ID 向父命名空间 ID 的转换(即 map_id_down)。reverse 数组按 lower_first(父命名空间 ID)排序,用于从父命名空间 ID 向命名空间内部 ID 的转换(即 map_id_up)。
一个典型的映射条目 "0 100000 65536" 表示:
first = 0 (命名空间内起始 UID)
lower_first = 100000 (父命名空间中起始 UID)
count = 65536 (连续映射 65536 个 UID)
这意味着该命名空间的 UID 0-65535 被映射到父命名空间的 UID 100000-165535。容器中的 root(UID 0)在宿主机上实际上是 UID 100000。
命名空间内 映射关系 父命名空间
UID 0 <-------------> UID 100000
UID 1 <-------------> UID 100001
UID 2 <-------------> UID 100002
... <-------------> ...
UID 65535 <-------------> UID 165535
9.5.3 UID/GID 映射的读写
映射写入通过 /proc/[pid]/uid_map 和 /proc/[pid]/gid_map 文件完成。写入操作的实现位于 kernel/user_namespace.c。
映射写入规则
写入映射时,内核执行严格的权限检查(第 932-1118 行):
// kernel/user_namespace.c, 第 932-1118 行(节选关键部分)
static ssize_t map_write(struct file *file, const char __user *buf,
size_t count, loff_t *ppos,
int cap_setid,
struct uid_gid_map *map,
struct uid_gid_map *parent_map)
{
/* ... */
mutex_lock(&userns_state_mutex);
/* 映射只能写入一次! */
if (map->nr_extents != 0)
goto out;
/* 写入者必须在目标命名空间中拥有 CAP_SYS_ADMIN */
if (cap_valid(cap_setid) && !file_ns_capable(file, map_ns, CAP_SYS_ADMIN))
goto out;
/* 解析用户数据,每行格式:first lower_first count */
/* ... */
/* 验证新映射是否被允许 */
if (!new_idmap_permitted(file, map_ns, cap_setid, &new_map))
goto out;
/* 将 lower_first 从父命名空间映射到内核全局 ID 空间 */
for (idx = 0; idx < new_map.nr_extents; idx++) {
/* ... */
lower_first = map_id_range_down(parent_map,
e->lower_first, e->count);
if (lower_first == (u32) -1)
goto out;
e->lower_first = lower_first;
}
/* 排序映射(超过 5 个条目时使用二分查找) */
ret = sort_idmaps(&new_map);
/* 安装映射 */
smp_wmb(); /* 保证写入顺序 */
WRITE_ONCE(map->nr_extents, new_map.nr_extents);
/* ... */
}
关键的安全规则包括:
-
一次性写入:映射只能写入一次(第 980-981 行)。一旦
nr_extents不为零,后续任何写入都会被拒绝。这防止了映射被恶意篡改。 -
写入者身份限制(
proc_uid_map_write(),第 1120-1135 行):只有目标命名空间自身或其直接父命名空间中的进程才能写入映射。这通过seq_ns != ns && seq_ns != ns->parent检查来实现。 -
映射验证(
new_idmap_permitted(),第 1172-1212 行):对于单条映射(映射自身一个 ID),不需要特殊权限。对于多条或范围映射,写入者必须在父命名空间中拥有CAP_SETUID或CAP_SETGID能力。
setgroups 控制
/proc/[pid]/setgroups 文件控制着在该命名空间中是否允许调用 setgroups() 系统调用。这是一个重要的安全措施。
// kernel/user_namespace.c, 第 1225-1290 行
ssize_t proc_setgroups_write(struct file *file, const char __user *buf,
size_t count, loff_t *ppos)
{
/* ... */
mutex_lock(&userns_state_mutex);
if (setgroups_allowed) {
/* 一旦禁用,就不能再启用 */
if (!(ns->flags & USERNS_SETGROUPS_ALLOWED))
goto out_unlock;
} else {
/* 在 gid_map 被写入后不能禁用 */
if (ns->gid_map.nr_extents != 0)
goto out_unlock;
ns->flags &= ~USERNS_SETGROUPS_ALLOWED;
}
mutex_unlock(&userns_state_mutex);
/* ... */
}
setgroups 控制的必要性在于:如果没有 gid_map 映射,一个在用户命名空间中的进程可以通过 setgroups() 将自己的组设置为 0(root 组),然后通过 setgid() 获得组权限。通过在写入 gid_map 之前先禁用 setgroups,可以防止这种权限提升攻击。
9.5.4 层次化设计与能力委托
用户命名空间形成一棵层次树。init_user_ns 是根节点(level = 0),每个子命名空间的 parent 指向其父命名空间,level 比父命名空间大 1。
init_user_ns (level=0)
|
+-- user_ns_1 (level=1, parent=init_user_ns)
| |
| +-- user_ns_3 (level=2, parent=user_ns_1)
|
+-- user_ns_2 (level=1, parent=init_user_ns)
内核通过 ns_capable() 和 capable() 函数来区分命名空间内的能力检查和全局能力检查。定义在 include/linux/capability.h 中:
// include/linux/capability.h, 第 148 行
extern bool ns_capable(struct user_namespace *ns, int cap);
capable(cap):检查调用者是否在初始用户命名空间(init_user_ns)中拥有指定能力。这等价于传统的 root 权限检查。ns_capable(ns, cap):检查调用者是否在指定的用户命名空间ns中拥有指定能力。能力检查会沿着parent链向上查找,直到找到匹配的映射。
能力委托的核心规则是:
- 父命名空间中拥有
CAP_SYS_ADMIN的进程可以创建子用户命名空间。 - 子命名空间的创建者自动在子命名空间中拥有全部能力(
CAP_FULL_SET)。 - 但这些能力仅在子命名空间及其后代命名空间的范围内有效。
- 在子命名空间中拥有
CAP_SYS_ADMIN的进程可以创建更低级别的子命名空间。
这种委托机制正是无特权容器的基础。一个普通用户进程可以创建自己的用户命名空间,然后在该命名空间中获得全部能力,进而创建其他类型的命名空间(PID、Mount、Network 等)。
9.5.5 create_user_ns():用户命名空间的创建
创建用户命名空间通过 CLONE_NEWUSER 标志触发,定义在 include/uapi/linux/sched.h(第 31 行):
// include/uapi/linux/sched.h, 第 31 行
#define CLONE_NEWUSER 0x10000000 /* New user namespace */
与其他命名空间不同,创建用户命名空间不需要任何特权。这是用户命名空间最独特的设计——它允许非特权用户创建自己的隔离环境。
实际的创建逻辑在 create_user_ns() 函数中(第 83-175 行):
// kernel/user_namespace.c, 第 83-175 行
int create_user_ns(struct cred *new)
{
struct user_namespace *ns, *parent_ns = new->user_ns;
kuid_t owner = new->euid;
kgid_t group = new->egid;
struct ucounts *ucounts;
int ret, i;
/* 限制嵌套深度为 32 层 */
ret = -ENOSPC;
if (parent_ns->level > 32)
goto fail;
/* 递增用户命名空间计数,检查是否超限 */
ucounts = inc_user_namespaces(parent_ns, owner);
if (!ucounts)
goto fail;
/* 不允许在 chroot 环境中创建 */
ret = -EPERM;
if (current_chrooted())
goto fail_dec;
/* 创建者必须在父命名空间中有有效的 UID/GID 映射 */
ret = -EPERM;
if (!kuid_has_mapping(parent_ns, owner) ||
!kgid_has_mapping(parent_ns, group))
goto fail_dec;
/* LSM 安全检查 */
ret = security_create_user_ns(new);
if (ret < 0)
goto fail_dec;
/* 分配并初始化 user_namespace 结构 */
ret = -ENOMEM;
ns = kmem_cache_zalloc(user_ns_cachep, GFP_KERNEL);
if (!ns)
goto fail_dec;
ns->parent_could_setfcap = cap_raised(new->cap_effective, CAP_SETFCAP);
ret = ns_common_init(ns);
if (ret)
goto fail_free;
ns->parent = parent_ns;
ns->level = parent_ns->level + 1;
ns->owner = owner;
ns->group = group;
INIT_WORK(&ns->work, free_user_ns);
/* 初始化各命名空间计数上限为 INT_MAX */
for (i = 0; i < UCOUNT_COUNTS; i++) {
ns->ucount_max[i] = INT_MAX;
}
/* 继承父命名空间的 rlimit 上限 */
set_userns_rlimit_max(ns, UCOUNT_RLIMIT_NPROC, enforced_nproc_rlimit());
set_userns_rlimit_max(ns, UCOUNT_RLIMIT_MSGQUEUE, rlimit(RLIMIT_MSGQUEUE));
set_userns_rlimit_max(ns, UCOUNT_RLIMIT_SIGPENDING, rlimit(RLIMIT_SIGPENDING));
set_userns_rlimit_max(ns, UCOUNT_RLIMIT_MEMLOCK, rlimit(RLIMIT_MEMLOCK));
ns->ucounts = ucounts;
/* 继承父命名空间的 SETGROUPS_ALLOWED 标志 */
mutex_lock(&userns_state_mutex);
ns->flags = parent_ns->flags;
mutex_unlock(&userns_state_mutex);
/* 设置新凭证的用户命名空间 */
set_cred_user_ns(new, ns);
ns_tree_add(ns);
return 0;
/* ... 错误处理 ... */
}
创建流程中的关键安全检查:
-
嵌套深度限制(第 92-93 行):
parent_ns->level > 32,防止无限嵌套导致的栈溢出或资源耗尽。 -
ucounts 检查(第 95-97 行):通过
inc_user_namespaces()在用户命名空间层级链中逐级递增计数。如果任何一级超限,则返回NULL,创建失败。 -
chroot 限制(第 106-107 行):不允许在 chroot 环境中创建用户命名空间,防止文件系统逃逸。
-
映射有效性检查(第 113-116 行):创建者必须在父命名空间中有有效的 UID 和 GID 映射。
-
LSM 钩子(第 118-120 行):通过
security_create_user_ns()允许安全模块(如 SELinux、AppArmor)对用户命名空间的创建进行额外的策略检查。 -
能力初始化(
set_cred_user_ns(),第 44-61 行):新命名空间中的进程获得完整的能力集,但这些能力仅在新命名空间范围内有效。
9.5.6 ucounts 机制:防止命名空间 Fork 炸弹
用户命名空间引入了一种新型的拒绝服务攻击风险:恶意用户可以通过不断创建嵌套的用户命名空间来消耗系统资源。为了应对这一威胁,内核引入了 ucounts 机制。
struct ucounts
// include/linux/user_namespace.h, 第 119-127 行
struct ucounts {
struct hlist_nulls_node node; /* 哈希表节点 */
struct user_namespace *ns; /* 所属的用户命名空间 */
kuid_t uid; /* 用户 ID */
struct rcu_head rcu; /* RCU 回收 */
rcuref_t count; /* 引用计数 */
atomic_long_t ucount[UCOUNT_COUNTS]; /* 各类命名空间的计数 */
atomic_long_t rlimit[UCOUNT_RLIMIT_COUNTS]; /* 资源限制计数 */
};
命名空间计数类型
// include/linux/user_namespace.h, 第 44-62 行
enum ucount_type {
UCOUNT_USER_NAMESPACES, /* 用户命名空间 */
UCOUNT_PID_NAMESPACES, /* PID 命名空间 */
UCOUNT_UTS_NAMESPACES, /* UTS 命名空间 */
UCOUNT_IPC_NAMESPACES, /* IPC 命名空间 */
UCOUNT_NET_NAMESPACES, /* 网络命名空间 */
UCOUNT_MNT_NAMESPACES, /* 挂载命名空间 */
UCOUNT_CGROUP_NAMESPACES, /* Cgroup 命名空间 */
UCOUNT_TIME_NAMESPACES, /* 时间命名空间 */
UCOUNT_INOTIFY_INSTANCES, /* inotify 实例 */
UCOUNT_INOTIFY_WATCHES, /* inotify 监视 */
UCOUNT_FANOTIFY_GROUPS, /* fanotify 组 */
UCOUNT_FANOTIFY_MARKS, /* fanotify 标记 */
UCOUNT_COUNTS, /* 计数类型总数 */
};
计数递增机制
inc_ucount() 函数(kernel/ucount.c,第 214-235 行)实现了层级化的计数控制:
// kernel/ucount.c, 第 214-235 行
struct ucounts *inc_ucount(struct user_namespace *ns, kuid_t uid,
enum ucount_type type)
{
struct ucounts *ucounts, *iter, *bad;
struct user_namespace *tns;
ucounts = alloc_ucounts(ns, uid);
for (iter = ucounts; iter; iter = tns->ucounts) {
long max;
tns = iter->ns;
max = READ_ONCE(tns->ucount_max[type]);
if (!atomic_long_inc_below(&iter->ucount[type], max))
goto fail;
}
return ucounts;
fail:
bad = iter;
for (iter = ucounts; iter != bad; iter = iter->ns->ucounts)
atomic_long_dec(&iter->ucount[type]);
put_ucounts(ucounts);
return NULL;
}
这个函数的设计体现了层次化限制的思想。当创建新的命名空间时,它从当前用户命名空间开始,沿着 parent 链向上遍历,在每一级都递增对应的计数器。如果在任何一级超过了该级配置的 ucount_max[type] 限制,整个操作就会失败,并回滚已经递增的计数。
这种设计确保了:
- 即使恶意用户创建了多层嵌套的用户命名空间,每层都可以限制下级的命名空间数量。
- 限制是按用户和命名空间层级组合计算的,不会影响其他合法用户。
sysctl 控制
每种类型的命名空间限制都可以通过 sysctl 接口进行配置:
// kernel/ucount.c, 第 75-92 行
static const struct ctl_table user_table[] = {
UCOUNT_ENTRY("max_user_namespaces"),
UCOUNT_ENTRY("max_pid_namespaces"),
UCOUNT_ENTRY("max_uts_namespaces"),
UCOUNT_ENTRY("max_ipc_namespaces"),
UCOUNT_ENTRY("max_net_namespaces"),
UCOUNT_ENTRY("max_mnt_namespaces"),
UCOUNT_ENTRY("max_cgroup_namespaces"),
UCOUNT_ENTRY("max_time_namespaces"),
/* ... inotify 和 fanotify ... */
};
管理员可以通过 /proc/sys/user/max_user_namespaces 等文件来调整限制。例如,要禁止非特权用户创建用户命名空间:
echo 0 > /proc/sys/user/max_user_namespaces
RLIMIT 层次化限制
除了命名空间计数之外,ucounts 还管理着 RLIMIT 类型的限制。这些限制同样是层次化的:
// include/linux/user_namespace.h, 第 64-70 行
enum rlimit_type {
UCOUNT_RLIMIT_NPROC, /* 用户最大进程数 */
UCOUNT_RLIMIT_MSGQUEUE, /* 消息队列字节数 */
UCOUNT_RLIMIT_SIGPENDING, /* 待处理信号数 */
UCOUNT_RLIMIT_MEMLOCK, /* 锁定内存字节数 */
UCOUNT_RLIMIT_COUNTS,
};
inc_rlimit_ucounts() 函数在用户命名空间层级链中递增 RLIMIT 计数,并在每一级检查是否超过该级配置的 rlimit_max[type] 限制:
// kernel/ucount.c, 第 247-262 行
long inc_rlimit_ucounts(struct ucounts *ucounts, enum rlimit_type type, long v)
{
struct ucounts *iter;
long max = LONG_MAX;
long ret = 0;
for (iter = ucounts; iter; iter = iter->ns->ucounts) {
long new = atomic_long_add_return(v, &iter->rlimit[type]);
if (new < 0 || new > max)
ret = LONG_MAX;
else if (iter == ucounts)
ret = new;
max = get_userns_rlimit_max(iter->ns, type);
}
return ret;
}
这种层次化的 RLIMIT 机制解决了传统 RLIMIT 在容器环境中的局限性。在一个用户命名空间中创建的子命名空间,其进程计数不能超过父命名空间配置的 NPROC 上限。
9.5.7 ID 转换函数
用户命名空间的核心功能是 ID 转换。内核提供了两对核心转换函数:
命名空间 ID 到内核 ID(map_id_down)
// kernel/user_namespace.c, 第 422-426 行
kuid_t make_kuid(struct user_namespace *ns, uid_t uid)
{
return KUIDT_INIT(map_id_down(&ns->uid_map, uid));
}
map_id_down() 将命名空间内的 UID 转换为内核内部的全局 kuid。这是"向下映射"——从命名空间层面映射到内核层面。转换过程通过遍历映射表(或使用二分查找)来找到匹配的 extent,然后计算:
kernel_uid = (uid - extent->first) + extent->lower_first
内核 ID 到命名空间 ID(map_id_up)
// kernel/user_namespace.c, 第 441-446 行
uid_t from_kuid(struct user_namespace *targ, kuid_t kuid)
{
return map_id_up(&targ->uid_map, __kuid_val(kuid));
}
map_id_up() 将内核内部的全局 kuid 转换为指定命名空间内的 UID。这是"向上映射"——从内核层面映射到命名空间层面。转换使用 reverse 数组(或遍历 extent 数组)来查找:
ns_uid = (kuid_val - extent->lower_first) + extent->first
如果找不到匹配的映射,返回 (u32)-1(即溢出 ID)。
map_id_down 和 map_id_up 的实现
映射查找的实现考虑了性能和正确性。当映射条目不超过 5 个时,使用线性搜索:
// kernel/user_namespace.c, 第 299-316 行
static struct uid_gid_extent *
map_id_range_down_base(unsigned extents, struct uid_gid_map *map, u32 id, u32 count)
{
unsigned idx;
u32 first, last, id2;
id2 = id + count - 1;
for (idx = 0; idx < extents; idx++) {
first = map->extent[idx].first;
last = first + map->extent[idx].count - 1;
if (id >= first && id <= last &&
(id2 >= first && id2 <= last))
return &map->extent[idx];
}
return NULL;
}
当映射条目超过 5 个时,使用二分查找以提高效率:
// kernel/user_namespace.c, 第 281-292 行
static struct uid_gid_extent *
map_id_range_down_max(unsigned extents, struct uid_gid_map *map, u32 id, u32 count)
{
struct idmap_key key;
key.map_up = false;
key.count = count;
key.id = id;
return bsearch(&key, map->forward, extents,
sizeof(struct uid_gid_extent), cmp_map_id);
}
from_kuid_munged() 是 from_kuid() 的安全变体(第 466-475 行)。当映射查找失败时,它返回 overflowuid(通常为 65534)而不是 (uid_t)-1,确保系统调用(如 stat()、getuid())始终能返回一个有效的 UID。
这些 ID 转换函数在内核中被大量调用。每次权限检查、文件访问控制、信号发送、审计记录等操作都可能涉及 ID 转换。因此,这些函数的性能对系统整体性能有直接影响。
9.5.8 用户命名空间与其他命名空间的关系
用户命名空间在整个命名空间体系中扮演着"所有者"的角色。每种其他类型的命名空间都有一个 user_ns 字段,指向创建该命名空间的用户命名空间。
例如,网络命名空间中的 struct net 包含 user_ns 字段(include/net/net_namespace.h 第 93 行):
struct user_namespace *user_ns; /* Owning user namespace */
这种所有权关系决定了权限检查的规则。当一个进程试图对某个命名空间执行特权操作时(如通过 setns() 切换到该命名空间),内核会检查该进程是否在该命名空间的所属用户命名空间中拥有相应的能力。
以网络命名空间的安装为例(net/core/net_namespace.c 第 1529-1541 行):
static int netns_install(struct nsset *nsset, struct ns_common *ns)
{
struct nsproxy *nsproxy = nsset->nsproxy;
struct net *net = to_net_ns(ns);
if (!ns_capable(net->user_ns, CAP_SYS_ADMIN) ||
!ns_capable(nsset->cred->user_ns, CAP_SYS_ADMIN))
return -EPERM;
put_net(nsproxy->net_ns);
nsproxy->net_ns = get_net(net);
return 0;
}
这里 ns_capable(net->user_ns, CAP_SYS_ADMIN) 检查的是目标网络命名空间的所属用户命名空间中的 CAP_SYS_ADMIN。
无特权容器的实现原理
正是因为用户命名空间拥有其他命名空间的所有权,无特权容器才能工作。其实现原理如下:
步骤 1: 普通用户进程 (UID 1000) 创建用户命名空间
→ 在新用户命名空间中获得全部能力(包括 CAP_SYS_ADMIN)
步骤 2: 在新用户命名空间中创建 PID 命名空间
→ 因为在新用户命名空间中有 CAP_SYS_ADMIN,所以允许创建
步骤 3: 在新用户命名空间中创建网络命名空间
→ 因为在新用户命名空间中有 CAP_SYS_ADMIN,所以允许创建
步骤 4: 在新用户命名空间中创建挂载命名空间
→ 因为在新用户命名空间中有 CAP_SYS_ADMIN,所以允许创建
步骤 5: 写入 uid_map 和 gid_map
→ 需要父命名空间中有权限的进程协助
结果: 完整的容器环境,但在宿主机上仍然是普通用户
这解释了为什么用户命名空间是其他所有命名空间的基础——没有用户命名空间提供的能力委托,非特权用户就无法创建其他类型的命名空间。
9.5.9 用户命名空间的清理
当用户命名空间的最后一个引用被释放时,通过 __put_user_ns() 触发异步清理:
// kernel/user_namespace.c, 第 231-234 行
void __put_user_ns(struct user_namespace *ns)
{
schedule_work(&ns->work);
}
实际的清理工作在 free_user_ns() 中完成(第 197-229 行):
// kernel/user_namespace.c, 第 197-229 行
static void free_user_ns(struct work_struct *work)
{
struct user_namespace *parent, *ns =
container_of(work, struct user_namespace, work);
do {
struct ucounts *ucounts = ns->ucounts;
parent = ns->parent;
ns_tree_remove(ns);
if (ns->gid_map.nr_extents > UID_GID_MAP_MAX_BASE_EXTENTS) {
kfree(ns->gid_map.forward);
kfree(ns->gid_map.reverse);
}
if (ns->uid_map.nr_extents > UID_GID_MAP_MAX_BASE_EXTENTS) {
kfree(ns->uid_map.forward);
kfree(ns->uid_map.reverse);
}
if (ns->projid_map.nr_extents > UID_GID_MAP_MAX_BASE_EXTENTS) {
kfree(ns->projid_map.forward);
kfree(ns->projid_map.reverse);
}
retire_userns_sysctls(ns);
key_free_user_ns(ns);
ns_common_free(ns);
kfree_rcu(ns, ns.ns_rcu);
dec_user_namespaces(ucounts);
ns = parent;
} while (ns_ref_put(parent));
}
这个函数的一个巧妙设计是使用了 do-while 循环。当子命名空间被释放时,它会尝试释放其父命名空间(如果父命名空间的引用计数也降为零)。这种级联释放机制确保了在嵌套命名空间的清理过程中,整条链路都能被正确地释放。
9.5.10 安全考量与缓解措施
用户命名空间逃逸攻击
用户命名空间历史上曾出现多个安全漏洞。攻击者通常利用的路径是:
- 创建用户命名空间获得全部能力。
- 利用某个内核子系统在命名空间内能力检查不充分的缺陷,执行超越命名空间范围的操作。
- 通过组合使用多种命名空间和特定子系统,实现权限提升。
内核社区的应对措施包括:
- 严格的 LSM 钩子:
security_create_user_ns()允许 SELinux、AppArmor 等安全模块对用户命名空间的创建施加额外的策略限制。 parent_could_setfcap检查(verify_root_map(),第 890-930 行):如果要将父命名空间的 UID 0 映射到新命名空间中,需要验证写入映射文件的进程在父命名空间中是否拥有CAP_SETFCAP。这防止了无特权进程通过映射 UID 0 来设置文件 capabilities。sysctl开关:通过/proc/sys/user/max_user_namespaces可以完全禁用非特权用户创建用户命名空间的能力。
命名空间计数溢出防御
ucounts 机制通过以下方式防止计数溢出攻击:
- 每个用户命名空间的
ucount_max[]初始化为INT_MAX,但可以通过 sysctl 进行调整。 atomic_long_inc_below()使用原子操作和比较交换(cmpxchg)来确保计数的原子性递增和上限检查。- 层级链中的每一级都有独立的计数和限制,形成多层防御。
映射表的内存管理
映射表的内存管理也需要注意。当映射条目超过 5 个时,insert_extent() 函数动态分配 340 个 extent 的空间(第 789-820 行)。sort_idmaps() 函数为反向映射创建独立的排序副本(第 856-876 行)。这些动态分配的内存在命名空间销毁时通过 free_user_ns() 正确释放。
9.5.11 ns_install 与 setns 系统调用
当进程通过 setns() 系统调用加入一个用户命名空间时,userns_install() 函数负责设置新的凭证(第 1343-1375 行):
// kernel/user_namespace.c, 第 1343-1375 行
static int userns_install(struct nsset *nsset, struct ns_common *ns)
{
struct user_namespace *user_ns = to_user_ns(ns);
struct cred *cred;
/* 不允许重新进入同一个用户命名空间 */
if (user_ns == current_user_ns())
return -EINVAL;
/* 线程组中的所有线程必须共享相同的用户命名空间 */
if (!thread_group_empty(current))
return -EINVAL;
/* 文件系统上下文的引用计数必须为 1 */
if (current->fs->users != 1)
return -EINVAL;
/* 必须在目标用户命名空间中有 CAP_SYS_ADMIN */
if (!ns_capable(user_ns, CAP_SYS_ADMIN))
return -EPERM;
cred = nsset_cred(nsset);
if (!cred)
return -EINVAL;
put_user_ns(cred->user_ns);
set_cred_user_ns(cred, get_user_ns(user_ns));
if (set_cred_ucounts(cred) < 0)
return -EINVAL;
return 0;
}
这里有几个重要的安全约束:
- 不允许重新进入:防止进程通过重新进入当前的用户命名空间来获取新的能力集。
- 线程组约束:多线程进程中的所有线程必须处于相同的用户命名空间中,否则线程间的权限不一致会导致安全问题。
- 文件系统上下文约束:
current->fs->users != 1确保没有其他进程共享文件系统上下文,防止通过chroot/chdir影响其他进程。 set_cred_ucounts():更新凭证的 ucounts 指针,确保 RLIMIT 计数正确地与新的用户命名空间层级关联。
9.5.12 小结
用户命名空间是 Linux 容器安全体系的核心组件。通过 UID/GID 映射机制,它实现了容器内外身份的隔离;通过层次化的能力委托,它使得非特权用户也能创建完整的容器环境;通过 ucounts 计数机制,它防止了命名空间资源的滥用。
从数据结构层面看,struct user_namespace 的设计体现了多个精妙的工程决策:使用联合体来优化小映射的内联存储,使用双排序数组来加速双向映射查找,通过 parent 指针和 level 字段构建清晰的层次结构。这些设计在安全性和性能之间取得了良好的平衡。
用户命名空间与其他命名空间的"所有权"关系(每个命名空间的 user_ns 字段指向其所属的用户命名空间)构成了整个容器权限模型的基础。理解这一关系对于理解 Linux 容器的安全边界至关重要。
9.6 UTS、IPC、Cgroup 与 Time Namespace
在前面几节中,我们已经深入分析了 PID Namespace、Mount Namespace、Network Namespace 和 User Namespace 这四种核心命名空间。本章将目光投向其余四种相对简单或更专用的命名空间类型:UTS Namespace、IPC Namespace、Cgroup Namespace 和 Time Namespace。它们虽然各自隔离的资源范围不同、实现复杂度各异,但共同构成了 Linux 容器技术的完整隔离基石。
9.6.1 UTS Namespace(CLONE_NEWUTS, 0x04000000)
历史与命名
UTS 是 "Unix Time-Sharing System" 的缩写,这个名称源自早期 Unix 系统中 struct utsname 结构体的传统命名。尽管名称中包含 "Time-Sharing",但 UTS Namespace 实际上与时间毫无关系——它隔离的是系统的标识信息,其中最重要的是主机名(hostname)和域名(domain name)。
UTS Namespace 是 Linux 内核中最早实现的命名空间之一,于内核 2.6.19(2006 年)引入。它是所有命名空间中最简单的一种,仅封装了一个 struct new_utsname 结构体的副本。
数据结构
UTS Namespace 的核心数据结构定义在 include/linux/uts_namespace.h(第 11-16 行):
// include/linux/uts_namespace.h, 第 11-16 行
struct uts_namespace {
struct new_utsname name;
struct user_namespace *user_ns;
struct ucounts *ucounts;
struct ns_common ns;
} __randomize_layout;
其中 name 字段是 UTS Namespace 的核心,它包含了系统标识的全部信息。new_utsname 结构体定义在 include/uapi/linux/utsname.h(第 25-32 行):
// include/uapi/linux/utsname.h, 第 25-32 行
struct new_utsname {
char sysname[__NEW_UTS_LEN + 1]; // "Linux"
char nodename[__NEW_UTS_LEN + 1]; // 主机名
char release[__NEW_UTS_LEN + 1]; // 内核版本,如 "7.0.10"
char version[__NEW_UTS_LEN + 1]; // 版本信息
char machine[__NEW_UTS_LEN + 1]; // 硬件架构,如 "x86_64"
char domainname[__NEW_UTS_LEN + 1]; // 域名
};
每个字段长度为 65 字节(__NEW_UTS_LEN 为 64,加上结尾的 '\0')。内核提供了便捷函数来访问当前进程的 UTS 信息,定义在 include/linux/utsname.h(第 29-32 行):
// include/linux/utsname.h, 第 29-32 行
static inline struct new_utsname *utsname(void)
{
return ¤t->nsproxy->uts_ns->name;
}
这条访问路径 current->nsproxy->uts_ns->name 清晰地展示了 UTS Namespace 在进程命名空间层次中的位置:每个进程通过其 nsproxy 指针找到当前所属的 UTS Namespace,再从中读取 name 字段。
隔离范围
UTS Namespace 仅隔离以下两个可修改的系统标识:
- nodename(主机名):通过
sethostname()系统调用设置,通过gethostname()或uname()读取。在容器场景中,这允许每个容器拥有独立的主机名,例如容器 A 的主机名为 "web-server-1",容器 B 的主机名为 "db-primary"。 - domainname(NIS 域名):通过
setdomainname()系统调用设置。在大多数现代容器部署中,域名隔离使用较少,但某些依赖 NIS(Network Information Service)的应用场景仍然需要它。
其余字段(sysname、release、version、machine)虽然存在于 new_utsname 结构中,但它们反映的是内核版本和硬件架构信息,在同一个内核上运行的所有容器自然共享这些值。
创建与复制:copy_utsname()
UTS Namespace 的创建流程实现在 kernel/utsname.c 中。核心函数 copy_utsname() 定义在第 79-94 行:
// kernel/utsname.c, 第 79-94 行
struct uts_namespace *copy_utsname(u64 flags,
struct user_namespace *user_ns, struct uts_namespace *old_ns)
{
struct uts_namespace *new_ns;
BUG_ON(!old_ns);
get_uts_ns(old_ns);
if (!(flags & CLONE_NEWUTS))
return old_ns;
new_ns = clone_uts_ns(user_ns, old_ns);
put_uts_ns(old_ns);
return new_ns;
}
该函数的逻辑清晰明了:
- 无 CLONE_NEWUTS 标志:直接增加旧命名空间的引用计数并返回旧命名空间,父子进程共享同一个 UTS Namespace。
- 有 CLONE_NEWUTS 标志:调用
clone_uts_ns()创建一个全新的 UTS Namespace。
实际的克隆操作在 clone_uts_ns() 中完成(第 36-71 行):
// kernel/utsname.c, 第 36-63 行
static struct uts_namespace *clone_uts_ns(struct user_namespace *user_ns,
struct uts_namespace *old_ns)
{
struct uts_namespace *ns;
struct ucounts *ucounts;
int err;
err = -ENOSPC;
ucounts = inc_uts_namespaces(user_ns);
if (!ucounts)
goto fail;
err = -ENOMEM;
ns = kmem_cache_zalloc(uts_ns_cache, GFP_KERNEL);
if (!ns)
goto fail_dec;
err = ns_common_init(ns);
if (err)
goto fail_free;
ns->ucounts = ucounts;
down_read(&uts_sem);
memcpy(&ns->name, &old_ns->name, sizeof(ns->name));
ns->user_ns = get_user_ns(user_ns);
up_read(&uts_sem);
ns_tree_add(ns);
return ns;
// ... 错误处理省略
}
值得注意的要点:
- 用户命名空间配额:通过
inc_uts_namespaces()检查用户命名空间中的 UTS Namespace 计数是否超过限制,防止资源耗尽攻击。 - 内存分配:使用专用 slab 缓存
uts_ns_cache分配。该缓存在uts_ns_init()(第 155-164 行)中通过kmem_cache_create_usercopy()创建,特别指定了name字段的可拷贝范围,这是为了支持uname()系统调用安全地将数据拷贝到用户空间。 - 数据复制:在
uts_sem读写信号量的读锁保护下,通过memcpy()将父命名空间的name结构完整拷贝。子命名空间初始时继承父命名空间的所有标识值,之后可以独立修改。 - 所属关系:新命名空间的
user_ns指向创建者的用户命名空间,决定了谁拥有管理这个 UTS Namespace 的权限。
内核接口
与 UTS Namespace 相关的系统调用和 procfs 接口:
uname()/newuname():读取当前 UTS Namespace 中的所有标识信息。sethostname():修改当前 UTS Namespace 的nodename字段。需要 CAP_SYS_ADMIN 权限。setdomainname():修改当前 UTS Namespace 的domainname字段。需要 CAP_SYS_ADMIN 权限。/proc/sys/kernel/hostname:通过 sysctl 接口读取或设置主机名。/proc/sys/kernel/domainname:通过 sysctl 接口读取或设置域名。/proc/[pid]/ns/uts:每个进程的 UTS Namespace 对应的 procfs 文件,可用于setns()加入到该命名空间。
容器中的使用场景
UTS Namespace 在容器技术中扮演着基础但重要的角色。考虑以下 Docker 容器创建示例:
# 创建一个主机名为 "my-container" 的容器
docker run --hostname my-container ubuntu:22.04 hostname
# 输出: my-container
# 而在宿主机上
hostname
# 输出: host-machine-name (不受容器内 hostname 的影响)
对于依赖主机名进行服务发现、日志记录或认证的分布式应用来说,UTS Namespace 隔离是必不可少的。例如,Kubernetes 中每个 Pod 都有自己的 UTS Namespace,其主机名默认被设置为 Pod 名称。
9.6.2 IPC Namespace(CLONE_NEWIPC, 0x08000000)
概述
IPC(Inter-Process Communication)Namespace 隔离的是 System V IPC 标识符和 POSIX 消息队列。它使得不同 IPC Namespace 中的进程无法通过 IPC 机制相互通信,从而提供了进程间通信层面的完全隔离。
IPC Namespace 于内核 2.6.19(2006 年)引入,与 UTS Namespace 同期。它的引入解决了容器间 IPC 资源冲突的问题:在没有 IPC 隔离的情况下,不同容器中的进程可能意外地通过共享内存、信号量或消息队列进行非预期的通信。
数据结构
IPC Namespace 的核心数据结构定义在 include/linux/ipc_namespace.h(第 31-81 行):
// include/linux/ipc_namespace.h, 第 31-81 行
struct ipc_namespace {
struct ipc_ids ids[3]; // 三种 System V IPC 的 ID 表
int sem_ctls[4]; // 信号量控制参数
int used_sems; // 已使用的信号量数
unsigned int msg_ctlmax; // 消息最大字节数
unsigned int msg_ctlmnb; // 消息队列最大字节数
unsigned int msg_ctlmni; // 消息队列最大数量
struct percpu_counter percpu_msg_bytes;
struct percpu_counter percpu_msg_hdrs;
size_t shm_ctlmax; // 共享内存段最大字节数
size_t shm_ctlall; // 共享内存总大小限制
unsigned long shm_tot; // 已使用的共享内存页数
int shm_ctlmni; // 共享内存段最大数量
int shm_rmid_forced; // 是否强制 RMID
struct notifier_block ipcns_nb;
struct vfsmount *mq_mnt; // POSIX 消息队列的挂载点
unsigned int mq_queues_count; // 消息队列计数
unsigned int mq_queues_max; // 最大队列数
unsigned int mq_msg_max; // 单队列最大消息数
unsigned int mq_msgsize_max; // 单消息最大大小
unsigned int mq_msg_default; // 默认消息数
unsigned int mq_msgsize_default; // 默认消息大小
struct ctl_table_set mq_set; // sysctl 接口
struct ctl_table_header *mq_sysctls;
struct ctl_table_set ipc_set;
struct ctl_table_header *ipc_sysctls;
struct user_namespace *user_ns; // 所属用户命名空间
struct ucounts *ucounts;
struct llist_node mnt_llist; // 异步释放链表
struct ns_common ns;
} __randomize_layout;
其中 ids[3] 数组是最关键的字段,它包含了三种 System V IPC 机制的 ID 管理:
// include/linux/ipc_namespace.h, 第 18-29 行
struct ipc_ids {
int in_use; // 当前使用的 IPC 对象数
unsigned short seq; // 序列号(用于构造 IPC 标识符)
struct rw_semaphore rwsem; // 读写信号量
struct idr ipcs_idr; // IDR 树,存储 IPC 对象
int max_idx;
int last_idx;
struct rhashtable key_ht; // 键值哈希表(用于 ftok 生成的键)
};
三种 System V IPC 分别对应 ids[0](信号量,sem_ids)、ids[1](消息队列,msg_ids)和 ids[2](共享内存,shm_ids)。每个 IPC Namespace 拥有独立的 ipc_ids 结构,因此不同命名空间中的 IPC 标识符完全独立。
隔离范围
IPC Namespace 隔离以下 IPC 资源:
-
System V 信号量(semaphore):
semget()、semop()、semctl()操作的标识符和信号量数组。每个 IPC Namespace 有自己的信号量集合,不同命名空间中相同的 IPC key 指向不同的信号量对象。 -
System V 消息队列(message queue):
msgget()、msgsnd()、msgrcv()、msgctl()操作的标识符和消息队列。消息队列是按 IPC Namespace 隔离的。 -
System V 共享内存(shared memory):
shmget()、shmat()、shmdt()、shmctl()操作的标识符和共享内存段。共享内存段只在同一 IPC Namespace 中可见。 -
POSIX 消息队列(POSIX message queue):
mq_open()、mq_send()、mq_receive()等操作的队列。每个 IPC Namespace 挂载自己的mqueuefs文件系统实例(mq_mnt字段),POSIX 消息队列通过此文件系统进行管理。 -
IPC 键值映射:
ftok()生成的键到 IPC 标识符的映射。每个ipc_ids中的key_ht哈希表维护独立的映射关系。
可以使用 ipcs 命令查看当前 IPC Namespace 中的资源:
# 在容器内执行
ipcs -q # 查看消息队列
ipcs -m # 查看共享内存
ipcs -s # 查看信号量
不同容器中的 ipcs 命令只能看到各自 IPC Namespace 中的资源。
CLONE_NEWIPC 与 CLONE_SYSVSEM 的冲突
在 kernel/nsproxy.c 的 copy_namespaces() 函数中(第 191-193 行),存在一个重要的合法性检查:
// kernel/nsproxy.c, 第 184-193 行
/*
* CLONE_NEWIPC must detach from the undolist: after switching
* to a new ipc namespace, the semaphore arrays from the old
* namespace are unreachable. In clone parlance, CLONE_SYSVSEM
* means share undolist with parent, so we must forbid using
* it along with CLONE_NEWIPC.
*/
if ((flags & (CLONE_NEWIPC | CLONE_SYSVSEM)) ==
(CLONE_NEWIPC | CLONE_SYSVSEM))
return -EINVAL;
这段注释清晰地解释了冲突的原因:CLONE_SYSVSEM 标志要求子进程与父进程共享 System V 信号量撤销列表(undo list),而 CLONE_NEWIPC 要求创建新的 IPC Namespace。在新的 IPC Namespace 中,旧的信号量数组是不可达的,因此共享撤销列表就失去了意义——撤销操作将找不到目标信号量。内核选择直接禁止这种矛盾的组合,返回 -EINVAL。
创建与复制:copy_ipcs()
IPC Namespace 的创建由 ipc/namespace.c 中的 copy_ipcs() 函数负责(第 111-117 行):
// ipc/namespace.c, 第 111-117 行
struct ipc_namespace *copy_ipcs(u64 flags,
struct user_namespace *user_ns, struct ipc_namespace *ns)
{
if (!(flags & CLONE_NEWIPC))
return get_ipc_ns(ns);
return create_ipc_ns(user_ns, ns);
}
当 CLONE_NEWIPC 标志不存在时,函数增加旧命名空间的引用计数并返回共享的命名空间。否则,调用 create_ipc_ns() 创建全新的 IPC Namespace。
create_ipc_ns() 的实现(第 39-109 行)比 UTS Namespace 的克隆函数复杂得多,因为它需要初始化多种 IPC 子系统:
// ipc/namespace.c, 第 39-92 行
static struct ipc_namespace *create_ipc_ns(struct user_namespace *user_ns,
struct ipc_namespace *old_ns)
{
struct ipc_namespace *ns;
struct ucounts *ucounts;
int err;
err = -ENOSPC;
again:
ucounts = inc_ipc_namespaces(user_ns);
if (!ucounts) {
if (flush_work(&free_ipc_work))
goto again;
goto fail;
}
err = -ENOMEM;
ns = kzalloc_obj(struct ipc_namespace, GFP_KERNEL_ACCOUNT);
if (ns == NULL)
goto fail_dec;
err = ns_common_init(ns);
if (err)
goto fail_free;
ns_tree_gen_id(ns);
ns->user_ns = get_user_ns(user_ns);
ns->ucounts = ucounts;
err = mq_init_ns(ns); // 初始化 POSIX 消息队列
if (err)
goto fail_put;
if (!setup_mq_sysctls(ns)) // 初始化消息队列 sysctl
goto fail_mq_mount;
if (!setup_ipc_sysctls(ns)) // 初始化 IPC sysctl
goto fail_mq_sysctls;
err = msg_init_ns(ns); // 初始化 System V 消息队列
if (err)
goto fail_ipc;
sem_init_ns(ns); // 初始化 System V 信号量
shm_init_ns(ns); // 初始化 System V 共享内存
ns_tree_add_raw(ns);
return ns;
// ... 错误处理省略
}
注意一个有趣的重试机制:当 inc_ipc_namespaces() 因配额不足而失败时,代码会尝试 flush_work(&free_ipc_work) 来等待正在异步释放的 IPC Namespace 完成。如果确实有 IPC Namespace 正在被释放,就重试分配,因为释放后配额会恢复。这种设计在 ipc/namespace.c 第 46-57 行:
// ipc/namespace.c, 第 46-57 行
ucounts = inc_ipc_namespaces(user_ns);
if (!ucounts) {
/*
* IPC namespaces are freed asynchronously, by free_ipc_work.
* If frees were pending, flush_work will wait, and
* return true. Fail the allocation if no frees are pending.
*/
if (flush_work(&free_ipc_work))
goto again;
goto fail;
}
IPC Namespace 的释放是异步的,通过工作队列 free_ipc_work 完成。这是因为 IPC Namespace 挂载了 mqueuefs 文件系统,其释放过程需要等待 RCU 宽限期。put_ipc_ns() 函数(第 202-212 行)将待释放的命名空间加入 free_ipc_list,然后调度工作队列:
// ipc/namespace.c, 第 202-212 行
void put_ipc_ns(struct ipc_namespace *ns)
{
if (ns_ref_put_and_lock(ns, &mq_lock)) {
mq_clear_sbinfo(ns);
spin_unlock(&mq_lock);
ns_tree_remove(ns);
if (llist_add(&ns->mnt_llist, &free_ipc_list))
schedule_work(&free_ipc_work);
}
}
9.6.3 Cgroup Namespace(CLONE_NEWCGROUP, 0x02000000)
概述
Cgroup Namespace 是为虚拟化 cgroup 文件系统视图而设计的命名空间,于 Linux 4.6(2016 年)引入。在 cgroup namespace 出现之前,容器内的进程可以通过读取 /proc/[pid]/cgroup 文件看到宿主机上的 cgroup 层次路径,这不仅暴露了宿主机信息,还可能导致管理上的混乱。
Cgroup Namespace 通过让每个容器看到相对于自身 cgroup 根的路径,解决了这一问题。
数据结构
Cgroup Namespace 的数据结构非常简洁,定义在 include/linux/cgroup_namespace.h(第 7-12 行):
// include/linux/cgroup_namespace.h, 第 7-12 行
struct cgroup_namespace {
struct ns_common ns;
struct user_namespace *user_ns;
struct ucounts *ucounts;
struct css_set *root_cset;
};
各字段含义:
- ns:通用的命名空间结构,包含引用计数、procfs 接口等通用信息。
- user_ns:拥有此 cgroup namespace 的用户命名空间。
- ucounts:用户命名空间中的资源计数,用于限制每个用户可创建的 cgroup namespace 数量。
- root_cset:指向创建该 cgroup namespace 时进程所在的
css_set(cgroup subsystem state set)。这个字段是 cgroup namespace 的核心——它定义了该命名空间的 cgroup 根在哪里。
root_cset 本质上记录了进程在创建 cgroup namespace 时所处的 cgroup 位置。在此之后,/proc/[pid]/cgroup 报告的路径将是相对于这个根位置的路径。
虚拟化 cgroup 路径
考虑一个实际例子。假设宿主机上的 cgroup v2 层次结构如下:
/ (cgroup 根)
├── sys.slice
│ ├── docker-abc123.scope
│ │ └── container 内的进程
├── user.slice
在 cgroup namespace 隔离之前,容器内的进程通过 /proc/self/cgroup 看到:
0::/sys.slice/docker-abc123.scope
这暴露了宿主机的 cgroup 层次结构信息。当容器拥有独立的 cgroup namespace 后,同一个进程看到的路径变为:
0::/
容器进程看到的 cgroup 根就是它创建 cgroup namespace 时所处的 cgroup。对于 cgroup v2,cgroup_path_ns() 函数负责计算相对于 root_cset 的路径。
创建与复制:copy_cgroup_ns()
Cgroup Namespace 的创建逻辑在 kernel/cgroup/namespace.c(第 48-90 行):
// kernel/cgroup/namespace.c, 第 48-90 行
struct cgroup_namespace *copy_cgroup_ns(u64 flags,
struct user_namespace *user_ns,
struct cgroup_namespace *old_ns)
{
struct cgroup_namespace *new_ns;
struct ucounts *ucounts;
struct css_set *cset;
BUG_ON(!old_ns);
if (!(flags & CLONE_NEWCGROUP)) {
get_cgroup_ns(old_ns);
return old_ns;
}
/* Allow only sysadmin to create cgroup namespace. */
if (!ns_capable(user_ns, CAP_SYS_ADMIN))
return ERR_PTR(-EPERM);
ucounts = inc_cgroup_namespaces(user_ns);
if (!ucounts)
return ERR_PTR(-ENOSPC);
/* It is not safe to take cgroup_mutex here */
spin_lock_irq(&css_set_lock);
cset = task_css_set(current);
get_css_set(cset);
spin_unlock_irq(&css_set_lock);
new_ns = alloc_cgroup_ns();
if (IS_ERR(new_ns)) {
put_css_set(cset);
dec_cgroup_namespaces(ucounts);
return new_ns;
}
new_ns->user_ns = get_user_ns(user_ns);
new_ns->ucounts = ucounts;
new_ns->root_cset = cset;
ns_tree_add(new_ns);
return new_ns;
}
这段代码的要点如下:
- 权限检查:创建 cgroup namespace 需要 CAP_SYS_ADMIN 权限(第 64 行),这比其他一些 namespace 多了一层显式检查。
- 获取当前 css_set:在
css_set_lock自旋锁保护下(注意注释说明这里不能使用cgroup_mutex,因为可能在中断上下文中被调用),获取当前进程的css_set并增加引用计数。这个css_set就成为新 cgroup namespace 的root_cset。 - 分配与初始化:通过
alloc_cgroup_ns()分配新命名空间,设置其所属用户命名空间和资源计数。
与 cgroup v2 的交互
Cgroup Namespace 与 cgroup v2 的协作方式值得特别说明。在 cgroup v2 中,进程只能查看和管理其当前 cgroup 及其子孙 cgroup 的资源。Cgroup Namespace 进一步虚拟化了视图:
- 容器内的
/proc/[pid]/cgroup显示的路径是相对于root_cset所对应 cgroup 的路径。 - 容器内挂载 cgroup2 文件系统时,看到的根目录实际上是
root_cset对应的宿主机 cgroup 目录。 - 这意味着容器可以管理自己的 cgroup 子树,而不需要看到或影响宿主机的 cgroup 层次结构。
释放
Cgroup Namespace 的释放在 free_cgroup_ns() 中完成(第 36-45 行):
// kernel/cgroup/namespace.c, 第 36-45 行
void free_cgroup_ns(struct cgroup_namespace *ns)
{
ns_tree_remove(ns);
put_css_set(ns->root_cset);
dec_cgroup_namespaces(ns->ucounts);
put_user_ns(ns->user_ns);
ns_common_free(ns);
/* Concurrent nstree traversal depends on a grace period. */
kfree_rcu(ns, ns.ns_rcu);
}
释放时需要释放对 root_cset 的引用,递减用户命名空间中的 cgroup namespace 计数,然后通过 RCU 延迟释放命名空间结构本身。
9.6.4 Time Namespace(CLONE_NEWTIME, 0x00000080)
概述与历史
Time Namespace 是目前 Linux 内核中最新的命名空间类型,于 Linux 5.6(2020 年 3 月)合并入主线内核,由 Andrei Vagin 和 Dmitry Safonov 开发。它的主要目的是为容器迁移(checkpoint/restore,特别是 CRIU 项目)和测试场景提供时钟偏移支持。
Time Namespace 隔离的是 CLOCK_MONOTONIC 和 CLOCK_BOOTTIME 两种单调时钟。这意味着不同 time namespace 中的进程可以观测到不同的单调时间流逝。然而,CLOCK_REALTIME(墙上时钟时间)不在隔离范围内——所有进程共享相同的系统时间。
为什么需要 Time Namespace?
Time Namespace 的引入有明确的实际需求驱动:
-
容器迁移(Checkpoint/Restore):当使用 CRIU(Checkpoint/Restore In Userspace)将运行中的容器从一个主机迁移到另一个主机时,迁移过程中存在时间差。如果容器内的应用依赖
CLOCK_MONOTONIC进行超时检测或性能测量,迁移后的时间跳变可能导致应用行为异常。Time Namespace 允许在迁移后通过设置偏移量来补偿时间差。 -
测试与时间旅行:某些测试场景需要模拟时间流逝,例如测试证书过期、定时任务触发、缓存超时等。Time Namespace 允许测试进程在不影响系统其他部分的情况下调整时钟。
-
两阶段时钟同步:在分布式系统中,容器可能需要在启动时与外部时钟源同步,Time Namespace 提供了在命名空间级别设置时钟偏移的能力。
数据结构
Time Namespace 的数据结构定义在 include/linux/time_namespace.h(第 18-31 行):
// include/linux/time_namespace.h, 第 18-31 行
struct timens_offsets {
struct timespec64 monotonic;
struct timespec64 boottime;
};
struct time_namespace {
struct user_namespace *user_ns;
struct ucounts *ucounts;
struct ns_common ns;
struct timens_offsets offsets;
struct page *vvar_page;
/* If set prevents changing offsets after any task joined namespace. */
bool frozen_offsets;
} __randomize_layout;
各字段含义:
- offsets:核心数据,包含
CLOCK_MONOTONIC和CLOCK_BOOTTIME两种时钟的偏移量。以timespec64结构存储,精度为纳秒级。 - vvar_page:VDSO(Virtual Dynamic Shared Object)数据页。这是 Time Namespace 性能优化的关键——通过将偏移量写入 VDSO 数据页,用户空间程序可以在不陷入内核的情况下获取带偏移的时间值。
- frozen_offsets:偏移量冻结标志。一旦为
true,就不再允许修改偏移量。这种设计保证了时间一致性:如果已经有进程在使用某个 time namespace 的偏移量,就不应该再修改它,否则可能导致同一命名空间内的进程看到不一致的时间。
初始的 time namespace 定义在 kernel/time/namespace.c(第 480-484 行):
// kernel/time/namespace.c, 第 480-484 行
struct time_namespace init_time_ns = {
.ns = NS_COMMON_INIT(init_time_ns),
.user_ns = &init_user_ns,
.frozen_offsets = true,
};
注意 init_time_ns 的 frozen_offsets 为 true——初始命名空间的偏移量不允许被修改,因为它是所有进程的默认时间命名空间。
偏移量的冻结机制
偏移量冻结机制是 Time Namespace 设计中最精巧的部分。其工作流程如下:
-
创建时:新创建的 time namespace 的
frozen_offsets为false(见clone_time_ns()第 107 行),此时可以通过/proc/[pid]/timens_offsets设置偏移量。 -
首次使用时冻结:当第一个进程加入该 time namespace 时,
timens_set_vvar_page()函数(第 218-251 行)会将偏移量写入 VDSO 数据页,并设置frozen_offsets = true。此后,偏移量不可再被修改。
// kernel/time/namespace.c, 第 218-251 行
static void timens_set_vvar_page(struct task_struct *task,
struct time_namespace *ns)
{
// ...
/* Fast-path, taken by every task in namespace except the first. */
if (likely(ns->frozen_offsets))
return;
mutex_lock(&offset_lock);
/* Nothing to-do: vvar_page has been already initialized. */
if (ns->frozen_offsets)
goto out;
ns->frozen_offsets = true;
vdata = page_address(ns->vvar_page);
// ... 设置 VDSO 时钟数据 ...
out:
mutex_unlock(&offset_lock);
}
- 双检查锁定:注意函数中使用了双检查锁定模式(先无锁检查
frozen_offsets,然后在锁内再次检查),这避免了在热路径上的锁竞争。
nsproxy 中的双字段设计
Time Namespace 在 struct nsproxy 中拥有两个字段,这是它与其他 namespace 不同的地方:
// include/linux/nsproxy.h, 第 39-40 行
struct time_namespace *time_ns;
struct time_namespace *time_ns_for_children;
这种设计的原因在于 Time Namespace 独特的语义:
- time_ns:当前进程正在使用的时间命名空间,影响当前进程的时间读取。
- time_ns_for_children:未来子进程将使用的时间命名空间。
为什么要区分这两个字段?因为 Time Namespace 的偏移量在创建后、进程加入前可以自由设置,但一旦有进程加入就会被冻结。因此需要一个"待分配"的时间命名空间来设置偏移量,等子进程通过 fork() 或当前进程通过 exec() 加入时才真正生效。
timens_on_fork():子进程的时间命名空间切换
当子进程通过 fork() 创建时(不使用 CLONE_NEWTIME),timens_on_fork() 函数负责将子进程切换到 time_ns_for_children(如果它与 time_ns 不同):
// kernel/time/namespace.c, 第 329-343 行
void timens_on_fork(struct nsproxy *nsproxy, struct task_struct *tsk)
{
struct ns_common *nsc = &nsproxy->time_ns_for_children->ns;
struct time_namespace *ns = to_time_ns(nsc);
/* create_new_namespaces() already incremented the ref counter */
if (nsproxy->time_ns == nsproxy->time_ns_for_children)
return;
get_time_ns(ns);
put_time_ns(nsproxy->time_ns);
nsproxy->time_ns = ns;
timens_commit(tsk, ns);
}
这个函数在 copy_namespaces() 中被调用(kernel/nsproxy.c 第 199-200 行),条件是 !(flags & CLONE_VM),即非线程创建。timens_commit() 会调用 timens_set_vvar_page() 来冻结偏移量,并通过 vdso_join_timens() 更新进程的 VDSO 映射。
exec_task_namespaces():exec 时的时间命名空间切换
除了 fork() 之外,exec() 系统调用也会触发 time namespace 的切换。这实现在 kernel/nsproxy.c 第 276-291 行:
// kernel/nsproxy.c, 第 276-291 行
int exec_task_namespaces(void)
{
struct task_struct *tsk = current;
struct nsproxy *new;
if (tsk->nsproxy->time_ns_for_children == tsk->nsproxy->time_ns)
return 0;
new = create_new_namespaces(0, tsk, current_user_ns(), tsk->fs);
if (IS_ERR(new))
return PTR_ERR(new);
timens_on_fork(new, tsk);
switch_task_namespaces(tsk, new);
return 0;
}
当进程执行 exec() 时,如果 time_ns_for_children 与 time_ns 不同,内核会创建一个新的 nsproxy,将 time_ns_for_children 赋值给 time_ns,然后原子切换。这意味着时间偏移量的真正生效时机是 fork() 或 exec(),而非 unshare() 调用时。
VDSO 集成:零系统调用的时间偏移
Time Namespace 最显著的性能特征是其 VDSO 集成。在现代 Linux 系统上,大多数时间读取操作(如 clock_gettime(CLOCK_MONOTONIC, ...)) 不通过系统调用,而是通过 VDSO(Virtual Dynamic Shared Object)在用户空间直接读取内核映射的数据页来获取时间值。
Time Namespace 通过 vvar_page 字段实现了零系统调用的偏移时间读取:
- 每个 time namespace 有一个独立的
vvar_page。 - 当进程加入 time namespace 时,VDSO 数据页被替换为该命名空间的
vvar_page。 - VDSO 页面中包含了
timens_setup_vdso_clock_data()设置的偏移量信息(第 178-192 行)。 - 用户空间的
clock_gettime()实现读取 VDSO 数据页,如果检测到clock_mode为VDSO_CLOCKMODE_TIMENS,就自动应用偏移量。
// kernel/time/namespace.c, 第 178-192 行
static void timens_setup_vdso_clock_data(struct vdso_clock *vc,
struct time_namespace *ns)
{
struct timens_offset *offset = vc->offset;
struct timens_offset monotonic = offset_from_ts(ns->offsets.monotonic);
struct timens_offset boottime = offset_from_ts(ns->offsets.boottime);
vc->seq = 1;
vc->clock_mode = VDSO_CLOCKMODE_TIMENS;
offset[CLOCK_MONOTONIC] = monotonic;
offset[CLOCK_MONOTONIC_RAW] = monotonic;
offset[CLOCK_MONOTONIC_COARSE] = monotonic;
offset[CLOCK_BOOTTIME] = boottime;
offset[CLOCK_BOOTTIME_ALARM] = boottime;
}
注意 CLOCK_MONOTONIC、CLOCK_MONOTONIC_RAW 和 CLOCK_MONOTONIC_COARSE 共享相同的偏移量(monotonic),而 CLOCK_BOOTTIME 和 CLOCK_BOOTTIME_ALARM 共享 boottime 偏移量。
/proc/[pid]/timens_offsets 接口
用户空间可以通过 procfs 接口读取和设置 time namespace 的偏移量:
/proc/[pid]/timens_offsets:显示或设置time_ns_for_children的偏移量。
读取格式(由 proc_timens_show_offsets() 生成,第 368-381 行):
monotonic 3600 0
boottime 10 0
第一列为时钟类型(monotonic 或 boottime),第二列为秒偏移,第三列为纳秒偏移。
设置偏移量通过 proc_timens_set_offset() 函数完成(第 383-461 行),需要 CAP_SYS_TIME 权限。该函数会进行严格的安全检查:
- 偏移量不能导致时间溢出
KTIME_SEC_MAX。 - 如果
frozen_offsets已经为true(已有进程加入),设置操作返回-EACCES。 - 设置过程中使用
offset_lock互斥锁保护并发写入。
创建与复制:copy_time_ns()
Time Namespace 的创建由 copy_time_ns() 函数负责(kernel/time/namespace.c 第 132-139 行):
// kernel/time/namespace.c, 第 132-139 行
struct time_namespace *copy_time_ns(u64 flags,
struct user_namespace *user_ns, struct time_namespace *old_ns)
{
if (!(flags & CLONE_NEWTIME))
return get_time_ns(old_ns);
return clone_time_ns(user_ns, old_ns);
}
clone_time_ns() 的实现在第 79-119 行:
// kernel/time/namespace.c, 第 79-109 行
static struct time_namespace *clone_time_ns(struct user_namespace *user_ns,
struct time_namespace *old_ns)
{
struct time_namespace *ns;
struct ucounts *ucounts;
int err;
err = -ENOSPC;
ucounts = inc_time_namespaces(user_ns);
if (!ucounts)
goto fail;
err = -ENOMEM;
ns = kzalloc_obj(*ns, GFP_KERNEL_ACCOUNT);
if (!ns)
goto fail_dec;
ns->vvar_page = alloc_page(GFP_KERNEL_ACCOUNT | __GFP_ZERO);
if (!ns->vvar_page)
goto fail_free;
err = ns_common_init(ns);
if (err)
goto fail_free_page;
ns->ucounts = ucounts;
ns->user_ns = get_user_ns(user_ns);
ns->offsets = old_ns->offsets; // 继承父命名空间的偏移量
ns->frozen_offsets = false; // 新命名空间允许修改偏移量
ns_tree_add(ns);
return ns;
// ... 错误处理省略
}
关键要点:
- VVAR 页面分配:每个 time namespace 分配一个独立的物理页面作为 VDSO 数据页。
- 偏移量继承:新命名空间继承父命名空间的偏移量。这确保了默认情况下子进程看到的时间与父进程一致。
- 未冻结状态:新命名空间的
frozen_offsets为false,允许在进程加入前设置偏移量。
内核辅助函数
内核提供了一组内联函数,用于在内核代码中透明地应用时间偏移:
// include/linux/time_namespace.h, 第 74-86 行
static inline void timens_add_monotonic(struct timespec64 *ts)
{
struct timens_offsets *ns_offsets = ¤t->nsproxy->time_ns->offsets;
*ts = timespec64_add(*ts, ns_offsets->monotonic);
}
static inline void timens_add_boottime(struct timespec64 *ts)
{
struct timens_offsets *ns_offsets = ¤t->nsproxy->time_ns->offsets;
*ts = timespec64_add(*ts, ns_offsets->boottime);
}
这些函数在内核的时钟和时间管理子系统中被调用,确保从内核空间获取的时间值也正确反映了 time namespace 的偏移量。
timens_ktime_to_host() 函数(第 105-113 行)用于将命名空间时间转换为宿主机时间,主要用于内核定时器和定时事件的设置:
// include/linux/time_namespace.h, 第 105-113 行
static inline ktime_t timens_ktime_to_host(clockid_t clockid, ktime_t tim)
{
struct time_namespace *ns = current->nsproxy->time_ns;
if (likely(ns == &init_time_ns))
return tim;
return do_timens_ktime_to_host(clockid, tim, &ns->offsets);
}
注意快速路径优化:如果当前进程在初始 time namespace 中(绝大多数情况),直接返回原始值,不进行偏移计算。likely() 提示编译器将这个快速路径作为分支预测的默认方向。
实际使用流程
以下是一个完整的时间命名空间使用示例:
# 1. 创建新的 time namespace
unshare --time /bin/bash
# 2. 此时 time_ns 和 time_ns_for_children 相同
# 3. 设置偏移量(monotonic 前进 1 小时,boottime 前进 10 秒)
echo "monotonic 3600 0" > /proc/self/timens_offsets
echo "boottime 10 0" > /proc/self/timens_offsets
# 4. 通过 fork 使偏移量生效(新子进程将使用新时间)
# 当前进程的时间不受影响,因为 time_ns 未变
# 5. 查看 /proc/self/timens_offsets
cat /proc/self/timens_offsets
# monotonic 3600 0
# boottime 10 0
8 种命名空间的标志值总结
下表总结了 Linux 内核中所有八种命名空间类型及其对应的 clone 标志:
+--------------------+-----------------+-----------+--------+
| 命名空间类型 | Clone 标志 | 值 | 引入 |
+--------------------+-----------------+-----------+--------+
| Mount Namespace | CLONE_NEWNS | 0x00020000| 2.4.19 |
| UTS Namespace | CLONE_NEWUTS | 0x04000000| 2.6.19 |
| IPC Namespace | CLONE_NEWIPC | 0x08000000| 2.6.19 |
| User Namespace | CLONE_NEWUSER | 0x10000000| 3.8 |
| PID Namespace | CLONE_NEWPID | 0x20000000| 2.6.24 |
| Network Namespace | CLONE_NEWNET | 0x40000000| 2.6.29 |
| Cgroup Namespace | CLONE_NEWCGROUP | 0x02000000| 4.6 |
| Time Namespace | CLONE_NEWTIME | 0x00000080| 5.6 |
+--------------------+-----------------+-----------+--------+
值得注意的是,CLONE_NEWTIME 的值(0x00000080)与其它命名空间的值域完全不同,这反映了它是后来添加的特性,当时常规的标志位空间已经被占用。
小结
本节介绍的四种命名空间各有侧重:
- UTS Namespace 最简单,只隔离主机名和域名,实现仅需拷贝一个
new_utsname结构体。 - IPC Namespace 隔离三种 System V IPC 和 POSIX 消息队列,内部维护独立的 ID 表和挂载点,释放需要异步处理。
- Cgroup Namespace 虚拟化 cgroup 文件系统视图,通过
root_cset确定命名空间的 cgroup 根,使容器看到相对路径。 - Time Namespace 是最复杂的,采用双字段设计(
time_ns和time_ns_for_children),通过偏移量冻结机制和 VDSO 集成实现高性能的时间隔离。
这四种命名空间与前几节介绍的 PID、Mount、Network 和 User Namespace 一起,构成了 Linux 内核完整的命名空间隔离框架。在下一节中,我们将深入分析管理这些命名空间的核心基础设施——nsproxy 结构体以及 setns()、unshare() 和 pivot_root() 等关键系统调用。
9.7 nsproxy 与 setns/unshare/pivot_root
在前几节中,我们逐一分析了 Linux 内核中的八种命名空间类型。每种命名空间各司其职,隔离着不同维度的系统资源。然而,这些命名空间并非孤立存在——它们需要一套统一的基础设施来协调管理。nsproxy 结构体就是这套基础设施的核心:它是进程与所有命名空间之间的桥梁,也是 clone()、unshare()、setns() 等系统调用操纵命名空间的关键数据结构。本节将从 nsproxy 的设计理念出发,深入分析命名空间管理的完整生命周期,并覆盖 pivot_root() 这一与容器初始化密切相关的系统调用。
9.7.1 nsproxy 聚合设计
设计理念
Linux 内核的设计者选择用一个统一的 nsproxy 结构体来持有进程的所有命名空间指针,而非将各个命名空间指针直接嵌入 task_struct。这种聚合设计带来了几个重要的优势。
首先,原子切换。当进程通过 setns() 或 unshare() 同时加入多个新命名空间时,内核只需要一次性替换 nsproxy 指针,就能实现所有命名空间的原子切换。如果每个命名空间指针都独立存储在 task_struct 中,则需要逐个替换,中间状态的可见性将非常难以管理。
其次,写时复制(Copy-on-Write)。大多数进程与其父进程共享完全相同的命名空间集合。通过共享同一个 nsproxy 结构体,这些进程只需维护一个引用计数,避免了为每个进程复制所有命名空间指针的开销。只有当某个进程通过 unshare() 或 clone() 创建新命名空间时,才会复制 nsproxy。
nsproxy 结构体
nsproxy 的定义位于 include/linux/nsproxy.h(第 32-42 行):
// include/linux/nsproxy.h, 第 32-42 行
struct nsproxy {
refcount_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns;
struct time_namespace *time_ns;
struct time_namespace *time_ns_for_children;
struct cgroup_namespace *cgroup_ns;
};
各字段说明:
- count:引用计数。记录有多少个
task_struct通过其nsproxy指针引用此结构。注意,这个计数跟踪的是"持有此 nsproxy 的进程数",而非命名空间本身的引用计数——每个命名空间有自己独立的引用计数机制。 - uts_ns:UTS Namespace 指针,控制进程看到的主机名和域名。
- ipc_ns:IPC Namespace 指针,控制进程看到的 System V IPC 和 POSIX 消息队列。
- mnt_ns:Mount Namespace 指针,控制进程看到的文件系统挂载树。
- pid_ns_for_children:子进程的 PID Namespace 指针。注意这不是当前进程的 PID Namespace,而是未来通过
fork()创建的子进程将进入的 PID Namespace。当前进程自身的 PID Namespace 通过task_active_pid_ns()获取。 - net_ns:Network Namespace 指针,控制进程看到的网络栈。
- time_ns:当前进程使用的 Time Namespace,影响进程读取的时间值。
- time_ns_for_children:子进程将使用的 Time Namespace。与 PID Namespace 类似,Time Namespace 的切换是延迟到子进程创建时才生效的。
- cgroup_ns:Cgroup Namespace 指针,控制进程看到的 cgroup 路径。
注意 User Namespace 不在此结构体中。用户命名空间通过凭证(credentials)机制管理,存储在 task_struct->cred->user_ns 中,因为用户身份本质上是安全凭证的一部分,而非命名空间状态的一部分。
访问规则
include/linux/nsproxy.h 第 69-93 行的注释详细说明了命名空间的访问规则:
规则 1:只有当前进程本身可以修改自己的 tsk->nsproxy 指针或
nsproxy 中的任何指针。修改时必须持有 task_lock。
规则 2:访问当前进程自身的命名空间时不需要任何锁保护——
直接解引用即可。因为只有当前进程自己能修改这些指针,
不存在竞争条件。
规则 3:访问其他进程的命名空间时,必须按以下步骤:
task_lock(task);
nsproxy = task->nsproxy;
if (nsproxy != NULL) {
// 在此处操作命名空间
// 例如获取某个命名空间的引用
}
// NULL 的 nsproxy 意味着目标进程即将消亡(zombie)
task_unlock(task);
这些规则基于一个关键假设:进程只会修改自己的命名空间,不会修改其他进程的。setns()、unshare() 和 clone() 都只影响调用者自身或其子进程。
9.7.2 init_nsproxy:初始命名空间代理
系统的第一个进程(PID 0,即 idle/swapper 进程)使用 init_nsproxy 作为其命名空间代理。它的定义在 kernel/nsproxy.c(第 33-51 行):
// kernel/nsproxy.c, 第 33-51 行
struct nsproxy init_nsproxy = {
.count = REFCOUNT_INIT(1),
.uts_ns = &init_uts_ns,
#if defined(CONFIG_POSIX_MQUEUE) || defined(CONFIG_SYSVIPC)
.ipc_ns = &init_ipc_ns,
#endif
.mnt_ns = NULL,
.pid_ns_for_children = &init_pid_ns,
#ifdef CONFIG_NET
.net_ns = &init_net,
#endif
#ifdef CONFIG_CGROUPS
.cgroup_ns = &init_cgroup_ns,
#endif
#ifdef CONFIG_TIME_NS
.time_ns = &init_time_ns,
.time_ns_for_children = &init_time_ns,
#endif
};
几点重要的观察:
-
mnt_ns 为 NULL:在系统启动的最早期,还没有设置 Mount Namespace。
mnt_ns会在kernel_init()线程执行mount_root()时被设置。这是因为挂载根文件系统的过程本身就需要 Mount Namespace 的支持,而此时根文件系统还未就绪。 -
条件编译:许多字段被
#ifdef包裹,反映了这些命名空间可以通过内核配置选项禁用。在实际的发行版内核中,这些选项通常全部启用。 -
初始引用计数为 1:
REFCOUNT_INIT(1)确保初始命名空间代理不会被意外释放。 -
time_ns 和 time_ns_for_children 相同:在初始命名空间中,两者都指向
init_time_ns,因为没有"待分配"的时间命名空间。
所有通过 fork() 而不指定 CLONE_NEW* 标志的子进程都将共享这个 init_nsproxy(准确地说是共享其引用计数增加后的同一实例),直到某个子进程通过 unshare() 或 clone() 创建新命名空间为止。
9.7.3 copy_namespaces():fork 时的命名空间复制
当进程通过 fork()/clone() 创建子进程时,copy_namespaces() 负责为新进程设置命名空间代理。这个函数定义在 kernel/nsproxy.c(第 167-205 行):
// kernel/nsproxy.c, 第 167-205 行
int copy_namespaces(u64 flags, struct task_struct *tsk)
{
struct nsproxy *old_ns = tsk->nsproxy;
struct user_namespace *user_ns = task_cred_xxx(tsk, user_ns);
struct nsproxy *new_ns;
if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
CLONE_NEWPID | CLONE_NEWNET |
CLONE_NEWCGROUP | CLONE_NEWTIME)))) {
if ((flags & CLONE_VM) ||
likely(old_ns->time_ns_for_children == old_ns->time_ns)) {
get_nsproxy(old_ns);
return 0;
}
} else if (!ns_capable(user_ns, CAP_SYS_ADMIN))
return -EPERM;
/*
* CLONE_NEWIPC must detach from the undolist: after switching
* to a new ipc namespace, the semaphore arrays from the old
* namespace are unreachable. In clone parlance, CLONE_SYSVSEM
* means share undolist with parent, so we must forbid using
* it along with CLONE_NEWIPC.
*/
if ((flags & (CLONE_NEWIPC | CLONE_SYSVSEM)) ==
(CLONE_NEWIPC | CLONE_SYSVSEM))
return -EINVAL;
new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);
if (IS_ERR(new_ns))
return PTR_ERR(new_ns);
if ((flags & CLONE_VM) == 0)
timens_on_fork(new_ns, tsk);
nsproxy_ns_active_get(new_ns);
tsk->nsproxy = new_ns;
return 0;
}
函数的执行路径分为三种情况:
情况一:无 CLONE_NEW* 标志(最常见路径)
likely() 提示编译器这是最常见的分支。在没有指定任何 CLONE_NEW* 标志时,函数检查两个条件:
- 如果设置了
CLONE_VM(线程创建),直接共享父进程的nsproxy。 - 如果
time_ns_for_children == time_ns(没有待分配的时间命名空间),也直接共享父进程的nsproxy。 - 仅当
time_ns_for_children != time_ns时,即使没有CLONE_NEW*标志,也需要创建新的 nsproxy 来应用时间命名空间切换。
情况二:有 CLONE_NEW* 标志
需要 CAP_SYS_ADMIN 权限。然后进入创建新命名空间的流程。
情况三:CLONE_NEWIPC + CLONE_SYSVSEM 冲突
如前所述,这两种标志不能同时使用,直接返回 -EINVAL。
create_new_namespaces():创建新命名空间集合
create_new_namespaces() 是创建新命名空间集合的核心函数(第 87-161 行):
// kernel/nsproxy.c, 第 87-161 行
static struct nsproxy *create_new_namespaces(u64 flags,
struct task_struct *tsk, struct user_namespace *user_ns,
struct fs_struct *new_fs)
{
struct nsproxy *new_nsp;
int err;
new_nsp = create_nsproxy();
if (!new_nsp)
return ERR_PTR(-ENOMEM);
new_nsp->mnt_ns = copy_mnt_ns(flags, tsk->nsproxy->mnt_ns,
user_ns, new_fs);
if (IS_ERR(new_nsp->mnt_ns)) {
err = PTR_ERR(new_nsp->mnt_ns);
goto out_ns;
}
new_nsp->uts_ns = copy_utsname(flags, user_ns, tsk->nsproxy->uts_ns);
if (IS_ERR(new_nsp->uts_ns)) {
err = PTR_ERR(new_nsp->uts_ns);
goto out_uts;
}
new_nsp->ipc_ns = copy_ipcs(flags, user_ns, tsk->nsproxy->ipc_ns);
if (IS_ERR(new_nsp->ipc_ns)) {
err = PTR_ERR(new_nsp->ipc_ns);
goto out_ipc;
}
new_nsp->pid_ns_for_children =
copy_pid_ns(flags, user_ns, tsk->nsproxy->pid_ns_for_children);
if (IS_ERR(new_nsp->pid_ns_for_children)) {
err = PTR_ERR(new_nsp->pid_ns_for_children);
goto out_pid;
}
new_nsp->cgroup_ns = copy_cgroup_ns(flags, user_ns,
tsk->nsproxy->cgroup_ns);
if (IS_ERR(new_nsp->cgroup_ns)) {
err = PTR_ERR(new_nsp->cgroup_ns);
goto out_cgroup;
}
new_nsp->net_ns = copy_net_ns(flags, user_ns, tsk->nsproxy->net_ns);
if (IS_ERR(new_nsp->net_ns)) {
err = PTR_ERR(new_nsp->net_ns);
goto out_net;
}
new_nsp->time_ns_for_children = copy_time_ns(flags, user_ns,
tsk->nsproxy->time_ns_for_children);
if (IS_ERR(new_nsp->time_ns_for_children)) {
err = PTR_ERR(new_nsp->time_ns_for_children);
goto out_time;
}
new_nsp->time_ns = get_time_ns(tsk->nsproxy->time_ns);
return new_nsp;
out_time:
put_net(new_nsp->net_ns);
out_net:
put_cgroup_ns(new_nsp->cgroup_ns);
out_cgroup:
put_pid_ns(new_nsp->pid_ns_for_children);
out_pid:
put_ipc_ns(new_nsp->ipc_ns);
out_ipc:
put_uts_ns(new_nsp->uts_ns);
out_uts:
put_mnt_ns(new_nsp->mnt_ns);
out_ns:
kmem_cache_free(nsproxy_cachep, new_nsp);
return ERR_PTR(err);
}
这个函数的设计体现了几个重要的工程原则:
创建顺序:命名空间按以下顺序创建——Mount -> UTS -> IPC -> PID -> Cgroup -> Network -> Time。这个顺序并非随意排列:Mount Namespace 先创建是因为后续某些命名空间(如 IPC 的 mqueuefs 挂载)可能依赖 Mount Namespace。
错误回滚:每个命名空间的创建都有可能失败(内存不足、配额耗尽等)。函数使用 goto 标签实现精确的错误回滚——如果第 N 个命名空间创建失败,前面 N-1 个已创建的命名空间会被正确释放。这种"跳转到对应清理标签"的模式在内核错误处理中非常普遍。
Time Namespace 的特殊处理:注意第 142 行 new_nsp->time_ns = get_time_ns(tsk->nsproxy->time_ns)。即使 time_ns_for_children 可能是新创建的,time_ns 仍然指向父进程的当前 time namespace。子进程的时间切换会在稍后的 timens_on_fork() 或 exec_task_namespaces() 中完成。
9.7.4 unshare() 系统调用
unshare() 系统调用允许调用进程将其自身从当前共享的命名空间中分离出来,创建新的命名空间。它是不通过 fork() 创建新命名空间的唯一方式。
ksys_unshare() 实现
unshare() 的核心实现是 kernel/fork.c 中的 ksys_unshare() 函数。从第 3123 行开始:
// kernel/fork.c, 第 3123-3130 行
int ksys_unshare(unsigned long unshare_flags)
{
struct fs_struct *fs, *new_fs = NULL;
struct files_struct *new_fd = NULL;
struct cred *new_cred = NULL;
struct nsproxy *new_nsproxy = NULL;
int do_sysvsem = 0;
int err;
函数按照以下顺序逐步处理各种 unshare 请求:
unshare_fs():文件系统信息(根目录和当前工作目录)unshare_fd():文件描述符表unshare_userns():用户命名空间凭证unshare_nsproxy_namespaces():所有命名空间
unshare_nsproxy_namespaces()
命名空间的 unshare 操作委托给 kernel/nsproxy.c 中的 unshare_nsproxy_namespaces()(第 211-235 行):
// kernel/nsproxy.c, 第 211-235 行
int unshare_nsproxy_namespaces(unsigned long unshare_flags,
struct nsproxy **new_nsp, struct cred *new_cred, struct fs_struct *new_fs)
{
struct user_namespace *user_ns;
int err = 0;
if (!(unshare_flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
CLONE_NEWNET | CLONE_NEWPID | CLONE_NEWCGROUP |
CLONE_NEWTIME)))
return 0;
user_ns = new_cred ? new_cred->user_ns : current_user_ns();
if (!ns_capable(user_ns, CAP_SYS_ADMIN))
return -EPERM;
*new_nsp = create_new_namespaces(unshare_flags, current, user_ns,
new_fs ? new_fs : current->fs);
if (IS_ERR(*new_nsp)) {
err = PTR_ERR(*new_nsp);
goto out;
}
out:
return err;
}
此函数的逻辑相对简单:
- 如果没有指定任何
CLONE_NEW*标志,直接返回 0(无需操作)。 - 检查调用者是否在所属用户命名空间中拥有
CAP_SYS_ADMIN权限。 - 调用
create_new_namespaces()创建新的命名空间集合。
值得注意的是,unshare_nsproxy_namespaces() 只负责创建新的 nsproxy,并不负责切换。实际的切换在 ksys_unshare() 中通过 switch_task_namespaces() 完成(第 3196-3199 行):
// kernel/fork.c, 第 3196-3199 行
if (new_nsproxy) {
switch_task_namespaces(current, new_nsproxy);
new_nsproxy = NULL;
}
此外,如果 unshare_flags 包含 CLONE_NEWIPC,还需要清理旧 IPC Namespace 中的共享内存段(第 3190-3194 行):
// kernel/fork.c, 第 3190-3194 行
if (unshare_flags & CLONE_NEWIPC) {
/* Orphan segments in old ns (see sem above). */
exit_shm(current);
shm_init_task(current);
}
9.7.5 setns() 系统调用
setns() 系统调用允许进程加入一个已存在的命名空间。与 unshare() 创建新命名空间不同,setns() 是加入别人已经创建好的命名空间。这在容器管理场景中非常常见——例如,调试工具需要加入容器的命名空间来检查其内部状态。
系统调用入口
setns() 的实现在 kernel/nsproxy.c 第 563-601 行:
// kernel/nsproxy.c, 第 563-601 行
SYSCALL_DEFINE2(setns, int, fd, int, flags)
{
CLASS(fd, f)(fd);
struct ns_common *ns = NULL;
struct nsset nsset = {};
int err = 0;
if (fd_empty(f))
return -EBADF;
if (proc_ns_file(fd_file(f))) {
ns = get_proc_ns(file_inode(fd_file(f)));
if (flags && (ns->ns_type != flags))
err = -EINVAL;
flags = ns->ns_type;
} else if (!IS_ERR(pidfd_pid(fd_file(f)))) {
err = check_setns_flags(flags);
} else {
err = -EINVAL;
}
if (err)
goto out;
err = prepare_nsset(flags, &nsset);
if (err)
goto out;
if (proc_ns_file(fd_file(f)))
err = validate_ns(&nsset, ns);
else
err = validate_nsset(&nsset, pidfd_pid(fd_file(f)));
if (!err) {
commit_nsset(&nsset);
perf_event_namespaces(current);
}
put_nsset(&nsset);
out:
return err;
}
setns() 支持两种类型的文件描述符:
a) /proc/[pid]/ns/* 文件描述符
当 fd 指向 /proc/[pid]/ns/ 下的某个命名空间文件时(如 /proc/1234/ns/net),进程加入该文件所代表的单个命名空间。此时 flags 参数可以为 0(自动检测类型)或与文件代表的命名空间类型匹配的标志。如果不匹配,返回 -EINVAL。
b) pidfd 文件描述符(Linux 5.8+)
当 fd 是一个 pidfd 时,进程加入目标进程的所有命名空间。此时 flags 参数必须明确指定要加入哪些命名空间,check_setns_flags() 会验证标志的有效性。
nsset 机制:三阶段提交
setns() 的核心是 nsset 结构体和三阶段提交机制。这确保了命名空间切换的原子性——要么所有命名空间都成功安装,要么全部回滚。
阶段一:prepare_nsset()(准备)
// kernel/nsproxy.c, 第 348-378 行
static int prepare_nsset(unsigned flags, struct nsset *nsset)
{
struct task_struct *me = current;
nsset->nsproxy = create_new_namespaces(0, me, current_user_ns(), me->fs);
if (IS_ERR(nsset->nsproxy))
return PTR_ERR(nsset->nsproxy);
if (flags & CLONE_NEWUSER)
nsset->cred = prepare_creds();
else
nsset->cred = current_cred();
if (!nsset->cred)
goto out;
/* Only create a temporary copy of fs_struct if we really need to. */
if (flags == CLONE_NEWNS) {
nsset->fs = me->fs;
} else if (flags & CLONE_NEWNS) {
nsset->fs = copy_fs_struct(me->fs);
if (!nsset->fs)
goto out;
}
nsset->flags = flags;
return 0;
out:
put_nsset(nsset);
return -ENOMEM;
}
此阶段创建一个临时的 nsproxy(通过 create_new_namespaces(0, ...) 创建,不指定任何 CLONE_NEW* 标志,因此它复制当前的命名空间配置)。如果需要切换用户命名空间,还会准备一份可修改的凭证副本。如果涉及 Mount Namespace 和其他命名空间的联合切换,还会复制 fs_struct。
阶段二:validate_nsset()(验证)
对于 pidfd 类型的 setns(),validate_nsset()(第 392-518 行)负责安装目标进程的所有命名空间到临时 nsproxy 中:
// kernel/nsproxy.c, 第 392-518 行
static int validate_nsset(struct nsset *nsset, struct pid *pid)
{
// ... 获取目标进程的 nsproxy 快照 ...
// 安装顺序:user -> mount -> uts -> ipc -> pid -> cgroup -> net -> time
#ifdef CONFIG_USER_NS
if (flags & CLONE_NEWUSER) {
ret = validate_ns(nsset, &user_ns->ns);
// ...
}
#endif
if (flags & CLONE_NEWNS) {
ret = validate_ns(nsset, from_mnt_ns(nsp->mnt_ns));
// ...
}
// ... 其余命名空间类似 ...
}
每个命名空间的安装通过 validate_ns() 完成,它调用该命名空间类型的 install 操作:
// kernel/nsproxy.c, 第 380-383 行
static inline int validate_ns(struct nsset *nsset, struct ns_common *ns)
{
return ns->ops->install(nsset, ns);
}
用户命名空间必须最先安装,因为后续命名空间的权限检查依赖于新的用户命名空间。
阶段三:commit_nsset()(提交)
当所有命名空间都成功验证后,commit_nsset() 执行最终的切换(第 529-561 行):
// kernel/nsproxy.c, 第 529-561 行
static void commit_nsset(struct nsset *nsset)
{
unsigned flags = nsset->flags;
struct task_struct *me = current;
#ifdef CONFIG_USER_NS
if (flags & CLONE_NEWUSER) {
/* transfer ownership */
commit_creds(nsset_cred(nsset));
nsset->cred = NULL;
}
#endif
/* We only need to commit if we have used a temporary fs_struct. */
if ((flags & CLONE_NEWNS) && (flags & ~CLONE_NEWNS)) {
set_fs_root(me->fs, &nsset->fs->root);
set_fs_pwd(me->fs, &nsset->fs->pwd);
}
#ifdef CONFIG_IPC_NS
if (flags & CLONE_NEWIPC)
exit_sem(me);
#endif
#ifdef CONFIG_TIME_NS
if (flags & CLONE_NEWTIME)
timens_commit(me, nsset->nsproxy->time_ns);
#endif
/* transfer ownership */
switch_task_namespaces(me, nsset->nsproxy);
nsset->nsproxy = NULL;
}
提交阶段的关键操作:
- 提交新凭证(如果切换了用户命名空间):通过
commit_creds()原子地替换进程的凭证。 - 更新文件系统根和当前目录:如果同时切换了 Mount Namespace 和其他命名空间,需要更新
fs_struct的根目录和当前工作目录。 - 退出 SysV 信号量撤销列表:如果切换了 IPC Namespace,必须退出旧的信号量撤销列表,因为旧 IPC Namespace 的信号量在新命名空间中不可达。
- 提交 Time Namespace:如果切换了 Time Namespace,调用
timens_commit()来设置 VDSO 数据页。 - 切换 nsproxy:最后,通过
switch_task_namespaces()原子地将进程的nsproxy指针替换为新的。这是不可回退的操作点(point of no return)。
三阶段提交的必要性
这种"准备-验证-提交"的三阶段设计确保了:
- 如果任何一个命名空间的安装失败,已经安装的命名空间可以被正确回滚(只需释放临时
nsproxy)。 - 最终的切换是原子的:
switch_task_namespaces()只需替换一个指针。 - 其他进程看到的命名空间状态始终是一致的——不会观察到"一半命名空间已切换,另一半还没切换"的中间状态。
9.7.6 switch_task_namespaces():命名空间切换的原子操作
switch_task_namespaces() 是所有命名空间切换操作的最终汇聚点,无论是 fork()、unshare()、setns() 还是 exec() 中的命名空间切换,最终都通过此函数完成:
// kernel/nsproxy.c, 第 237-253 行
void switch_task_namespaces(struct task_struct *p, struct nsproxy *new)
{
struct nsproxy *ns;
might_sleep();
if (new)
nsproxy_ns_active_get(new);
task_lock(p);
ns = p->nsproxy;
p->nsproxy = new;
task_unlock(p);
if (ns)
put_nsproxy(ns);
}
这个函数虽然短小,但每个步骤都至关重要:
-
might_sleep():调试断言,确认当前上下文允许睡眠。因为
task_lock()是一个可能会睡眠的互斥锁。 -
预先增加新 nsproxy 的活跃引用:
nsproxy_ns_active_get(new)在获取task_lock之前调用,确保新的nsproxy在切换过程中不会被释放。 -
原子替换:在
task_lock保护下,将p->nsproxy指向新的nsproxy。task_lock确保了与其他可能读取此进程命名空间的操作(如通过/proc/[pid]/ns/查询)之间的互斥。 -
释放旧引用:
put_nsproxy(ns)减少旧nsproxy的引用计数。如果计数降为零,触发deactivate_nsproxy()->nsproxy_free()链路释放所有引用的命名空间。
9.7.7 命名空间生命周期
命名空间从创建到销毁经历以下生命周期阶段:
创建阶段 活跃阶段 销毁阶段
======== ======== ========
clone/unshare/setns ─────> refcount > 0 ─────> refcount == 0
| | |
v v v
copy_*() 正常使用 deactivate_nsproxy()
| 进程读写 |
v 命名空间数据 v
新命名空间 nsproxy_free()
引用计数=1 释放各命名空间引用
创建
命名空间在以下场景中被创建:
clone()时指定CLONE_NEW*标志,通过copy_namespaces()->create_new_namespaces()-> 各copy_*()函数创建。unshare()时指定CLONE_NEW*标志,通过unshare_nsproxy_namespaces()->create_new_namespaces()创建。setns()时不需要创建新命名空间,而是加入已存在的命名空间。
活跃
命名空间处于活跃状态时,有一个或多个进程通过其 nsproxy 引用它。每个命名空间维护自己的引用计数。对于 nsproxy 而言,其 count 字段记录有多少个进程共享此结构。
销毁
当 nsproxy 的引用计数降为零时,put_nsproxy() 触发 deactivate_nsproxy():
// kernel/nsproxy.c, 第 76-80 行
void deactivate_nsproxy(struct nsproxy *ns)
{
nsproxy_ns_active_put(ns);
nsproxy_free(ns);
}
nsproxy_free()(第 63-74 行)释放对所有命名空间的引用:
// kernel/nsproxy.c, 第 63-74 行
static inline void nsproxy_free(struct nsproxy *ns)
{
put_mnt_ns(ns->mnt_ns);
put_uts_ns(ns->uts_ns);
put_ipc_ns(ns->ipc_ns);
put_pid_ns(ns->pid_ns_for_children);
put_time_ns(ns->time_ns);
put_time_ns(ns->time_ns_for_children);
put_cgroup_ns(ns->cgroup_ns);
put_net(ns->net_ns);
kmem_cache_free(nsproxy_cachep, ns);
}
每个 put_*() 函数减少对应命名空间的引用计数。如果某个命名空间的引用计数也降为零,则触发该命名空间的释放函数。命名空间的释放可能是同步的(如 UTS、cgroup、time),也可能是异步的(如 IPC,通过工作队列延迟释放)。
特殊情况:PID Namespace 的生命周期
PID Namespace 有独特的生命周期规则:它不仅仅由引用计数决定何时销毁。即使没有进程通过 nsproxy 引用某个 PID Namespace,只要该命名空间中还有进程存在,它就不能被销毁。这是因为 PID Namespace 既是命名空间又是进程树的根——命名空间中的 init 进程(PID 1)在命名空间中扮演着孤儿进程收养者的角色。
9.7.8 exec_task_namespaces()
exec_task_namespaces() 处理 exec() 系统调用期间的命名空间切换,专门为 Time Namespace 设计(kernel/nsproxy.c 第 276-291 行):
// kernel/nsproxy.c, 第 276-291 行
int exec_task_namespaces(void)
{
struct task_struct *tsk = current;
struct nsproxy *new;
if (tsk->nsproxy->time_ns_for_children == tsk->nsproxy->time_ns)
return 0;
new = create_new_namespaces(0, tsk, current_user_ns(), tsk->fs);
if (IS_ERR(new))
return PTR_ERR(new);
timens_on_fork(new, tsk);
switch_task_namespaces(tsk, new);
return 0;
}
这个函数只在 time_ns_for_children != time_ns 时执行操作,即存在一个待分配的 Time Namespace。为什么 Time Namespace 需要在 exec() 时切换?这涉及 Time Namespace 的设计哲学:
- 通过
unshare(CLONE_NEWTIME)创建新的 Time Namespace 后,当前进程的time_ns_for_children被设置为新命名空间,但time_ns不变。 - 这意味着
unshare()后,当前进程读取的时间值不受影响,但通过fork()创建的子进程将使用新的时间偏移。 - 然而,某些容器运行时(如 LXC、runc)使用
unshare()+exec()模式(而非clone())来创建容器。如果不处理exec(),这些运行时就无法利用 Time Namespace。 - 因此,
exec_task_namespaces()在exec()时将time_ns_for_children赋值给time_ns,使偏移量在exec()后的新程序中生效。
这也是为什么 nsproxy 中需要 time_ns 和 time_ns_for_children 两个独立的字段——前者是"当前"的时间命名空间,后者是"未来"的时间命名空间。
9.7.9 pivot_root() 系统调用
pivot_root() 系统调用改变当前进程的根文件系统,是容器设置文件系统环境的关键步骤。虽然它本身不是命名空间操作,但与 Mount Namespace 紧密配合,是容器初始化流程中不可或缺的一环。
语义
pivot_root() 的语义定义在 fs/namespace.c 第 4701-4725 行的注释中:
pivot_root 语义:
将当前进程的根文件系统移动到 put_old 目录,
将 new_root 作为新的根文件系统,
并将所有以当前根为 root/cwd 的进程的 root/cwd 设置为 new_root。
限制:
- new_root 和 put_old 必须是目录
- 它们不能与当前进程的根在同一个文件系统上
- put_old 必须位于 new_root 之下
- put_old 上不能挂载其他文件系统
- new_root 必须是一个挂载点
实际实现
pivot_root() 的系统调用入口在 fs/namespace.c 第 4726-4744 行:
// fs/namespace.c, 第 4726-4744 行
SYSCALL_DEFINE2(pivot_root, const char __user *, new_root,
const char __user *, put_old)
{
struct path new __free(path_put) = {};
struct path old __free(path_put) = {};
int error;
error = user_path_at(AT_FDCWD, new_root,
LOOKUP_FOLLOW | LOOKUP_DIRECTORY, &new);
if (error)
return error;
error = user_path_at(AT_FDCWD, put_old,
LOOKUP_FOLLOW | LOOKUP_DIRECTORY, &old);
if (error)
return error;
return path_pivot_root(&new, &old);
}
核心逻辑在 path_pivot_root() 中(第 4630-4699 行):
// fs/namespace.c, 第 4630-4699 行
int path_pivot_root(struct path *new, struct path *old)
{
struct path root __free(path_put) = {};
struct mount *new_mnt, *root_mnt, *old_mnt, *root_parent, *ex_parent;
int error;
if (!may_mount())
return -EPERM;
error = security_sb_pivotroot(old, new);
if (error)
return error;
get_fs_root(current->fs, &root);
// ... 挂载点锁定和验证 ...
lock_mount_hash();
umount_mnt(new_mnt);
if (root_mnt->mnt.mnt_flags & MNT_LOCKED) {
new_mnt->mnt.mnt_flags |= MNT_LOCKED;
root_mnt->mnt.mnt_flags &= ~MNT_LOCKED;
}
/* mount new_root on / */
attach_mnt(new_mnt, root_parent, root_mnt->mnt_mp);
umount_mnt(root_mnt);
/* mount old root on put_old */
attach_mnt(root_mnt, old_mnt, old_mp.mp);
touch_mnt_namespace(current->nsproxy->mnt_ns);
/* A moved mount should not expire automatically */
list_del_init(&new_mnt->mnt_expire);
unlock_mount_hash();
mnt_notify_add(root_mnt);
mnt_notify_add(new_mnt);
chroot_fs_refs(&root, new);
return 0;
}
pivot_root() 的核心操作可以形象地理解为"挂载树的旋转":
pivot_root 之前:
/ (root_mnt)
/ \
new_root/ other_dirs/
|
put_old/
pivot_root 之后:
/ (new_mnt, 原 new_root)
/
put_old/ (root_mnt, 原来的根被挂载在这里)
具体步骤:
- 权限检查:调用
may_mount()检查 CAP_SYS_ADMIN 权限,以及security_sb_pivotroot()进行 LSM 安全检查。 - 验证约束:检查 new_root 是否是挂载点、put_old 是否在 new_root 下、是否会导致循环等。
- 挂载树操作:在
mount_hash全局锁保护下,执行两个关键的挂载操作: - 将new_mnt挂载到原来根的位置(attach_mnt(new_mnt, root_parent, ...)) - 将原来的根挂载到put_old位置(attach_mnt(root_mnt, old_mnt, ...)) - 更新进程引用:
chroot_fs_refs()更新所有以旧根为root/cwd的进程的文件系统引用。
pivot_root() 与 chroot() 的区别
pivot_root() 和 chroot() 都能改变进程看到的根目录,但两者有本质区别:
| 特性 | pivot_root() | chroot() |
|---|---|---|
| 修改范围 | 改变实际的根挂载点 | 仅改变路径查找的起点 |
| 安全性 | 旧根被移走,无法通过 ".." 逃逸 | 可通过 ".." 逃逸(如果不小心) |
| Mount Namespace | 与 Mount Namespace 配合,真正隔离 | 不依赖 Mount Namespace |
| 调用者 | 容器运行时 | 简单的路径隔离 |
| 权限 | 需要 CAP_SYS_ADMIN | 需要 CAP_SYS_CHROOT |
chroot() 只是修改了进程的文件系统根指针,但实际的 VFS 挂载树并未改变。这意味着进程可能通过文件描述符或 /proc 下的特殊文件逃逸出 chroot 环境。而 pivot_root() 实际上修改了挂载树的结构,旧根被移到了新根的子目录中,进程无法通过常规路径操作回到旧根。
9.7.10 完整的容器创建流程
将本章所有内容串联起来,我们可以描绘出一个完整的容器创建流程。这是容器运行时(如 runc、LXC)创建一个完全隔离的容器所执行的关键步骤:
====================================================================
容器创建完整流程
====================================================================
阶段 1: clone() — 创建新的命名空间
--------------------------------------------------------------------
容器运行时(宿主机进程)调用:
clone(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET |
CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWCGROUP,
child_stack, ...)
内核执行路径:
kernel_clone()
→ copy_process()
→ copy_namespaces(flags, child) // kernel/nsproxy.c:167
→ create_new_namespaces(flags, tsk, ...) // kernel/nsproxy.c:87
→ copy_mnt_ns() // 新的挂载树
→ copy_utsname() // 新的主机名空间
→ copy_ipcs() // 新的 IPC 空间
→ copy_pid_ns() // 新的 PID 空间(子进程为 PID 1)
→ copy_cgroup_ns() // 新的 cgroup 视图
→ copy_net_ns() // 新的网络协议栈
→ copy_time_ns() // 新的时间偏移空间
→ timens_on_fork() // 应用时间命名空间
此时子进程状态:
- 拥有独立的 nsproxy
- 每种命名空间都是全新创建的
- PID 1 在新的 PID Namespace 中
- 挂载树是从父进程复制来的(还是宿主机的根文件系统)
- 网络栈是空的(只有 loopback 设备)
- 主机名继承自父进程
--------------------------------------------------------------------
阶段 2: 子进程 — 设置主机名
--------------------------------------------------------------------
子进程执行:
sethostname("my-container")
内核路径:
utsname() 系统调用
→ 修改 current->nsproxy->uts_ns->name.nodename
→ 只影响此容器的 UTS Namespace
→ 宿主机和其他容器不受影响
--------------------------------------------------------------------
阶段 3: 子进程 — pivot_root() 切换根文件系统
--------------------------------------------------------------------
子进程执行:
mount("overlay", "/newroot", "overlay", ...)
// 或者: mount("/dev/sda1", "/newroot", "ext4", ...)
mkdir("/newroot/oldroot")
pivot_root("/newroot", "/newroot/oldroot")
chdir("/")
内核路径:
pivot_root()
→ path_pivot_root() // fs/namespace.c:4630
→ attach_mnt(new_mnt, ...) // 新根挂载到 /
→ attach_mnt(root_mnt, ...) // 旧根挂载到 /oldroot
→ chroot_fs_refs() // 更新进程的 root/cwd
→ touch_mnt_namespace() // 标记 Mount Namespace 已修改
此时子进程状态:
- 根文件系统已切换为容器的 rootfs
- 宿主机的根文件系统挂载在 /oldroot
--------------------------------------------------------------------
阶段 4: 子进程 — 卸载旧根并挂载 procfs
--------------------------------------------------------------------
子进程执行:
umount("/oldroot") // 卸载宿主机根文件系统
rmdir("/oldroot") // 清理
mount("proc", "/proc", "proc", ...) // 挂载 procfs
mount("tmpfs", "/dev", "tmpfs", ...)// 挂载 devtmpfs
mount("devpts", "/dev/pts", ...) // 挂载伪终端
此时子进程状态:
- 宿主机的文件系统已不可见
- /proc 显示的是新 PID Namespace 中的进程
- 设备文件系统已就绪
--------------------------------------------------------------------
阶段 5: 子进程 — 配置网络
--------------------------------------------------------------------
容器运行时(从宿主机)执行:
// 将 veth 设备的一端移入容器的 Network Namespace
ip link set veth0 netns <container-pid>
子进程视角:
ip link set lo up // 启用回环设备
ip addr add 172.17.0.2/16 dev eth0 // 配置 IP 地址
ip route add default via 172.17.0.1 // 配置默认路由
此时子进程状态:
- 拥有独立的网络接口
- IP 地址、路由表、iptables 规则完全隔离
- 通过 veth pair 与宿主机通信
--------------------------------------------------------------------
阶段 6: 子进程 — execve() 启动容器 init 进程
--------------------------------------------------------------------
子进程执行:
execve("/sbin/init", ["/sbin/init"], envp)
// 或者: execve("/bin/bash", ["bash"], envp)
内核路径:
do_execve()
→ exec_task_namespaces() // kernel/nsproxy.c:276
→ 如果 time_ns_for_children != time_ns:
创建新 nsproxy
timens_on_fork() 切换时间命名空间
→ 开始执行新程序
此时容器进程状态:
- 所有命名空间已完全隔离
- 根文件系统是容器的 rootfs
- 进程在容器中显示为 PID 1
- 网络栈独立配置
- IPC 资源完全隔离
- cgroup 路径相对于容器根
- 时间偏移已生效(如果配置了)
====================================================================
容器运行时的实际代码映射
将上述流程映射到内核函数调用链:
用户空间容器运行时 (runc/LXC) 内核函数
============================= =========
clone(CLONE_NEWNS|...) → kernel_clone()
→ copy_process()
→ copy_namespaces() // nsproxy.c:167
→ create_new_namespaces() // nsproxy.c:87
├─ copy_mnt_ns() // 新挂载树
├─ copy_utsname() // 新 UTS
├─ copy_ipcs() // 新 IPC
├─ copy_pid_ns() // 新 PID
├─ copy_cgroup_ns() // 新 cgroup
├─ copy_net_ns() // 新网络
└─ copy_time_ns() // 新时间
→ timens_on_fork()
sethostname("container") → sys_sethostname()
→ utsname() 修改 uts_ns->name
pivot_root("/rootfs", "/old") → sys_pivot_root()
→ path_pivot_root() // namespace.c:4630
→ attach_mnt() (旋转挂载树)
→ chroot_fs_refs()
mount("proc", "/proc", ...) → sys_mount()
→ do_mount()
→ graft_tree()
execve("/sbin/init", ...) → sys_execve()
→ do_execve()
→ exec_task_namespaces() // nsproxy.c:276
→ 开始执行 init 程序
nsproxy 引用计数追踪
在整个容器创建过程中,nsproxy 的引用计数变化如下:
1. 初始状态:
父进程 -> init_nsproxy (count = N, N为共享进程数)
2. clone() 创建子进程:
copy_namespaces() 创建新 nsproxy
新 nsproxy: count = 1 (子进程持有)
init_nsproxy: count = N (父进程继续持有)
3. 子进程 exec():
如果 time_ns_for_children != time_ns:
exec_task_namespaces() 创建另一个新 nsproxy
旧 nsproxy: count -> 0 -> 释放
更新后的 nsproxy: count = 1
4. 容器运行期间:
nsproxy count = 1 (只有容器 init 进程持有)
各命名空间有自己独立的引用计数
5. 容器终止:
容器 init 进程退出
exit_nsproxy_namespaces() -> switch_task_namespaces(p, NULL)
nsproxy: count -> 0 -> deactivate_nsproxy() -> nsproxy_free()
各命名空间: 引用计数递减,可能触发异步释放
9.7.11 小结
nsproxy 结构体是 Linux 命名空间子系统的核心枢纽。它通过聚合设计将所有命名空间指针统一管理,实现了高效的共享和原子切换。copy_namespaces()、unshare_nsproxy_namespaces() 和 setns() 三个主要入口点分别服务于 fork()、unshare() 和 setns() 系统调用,最终都通过 switch_task_namespaces() 完成实际的命名空间切换。
setns() 的三阶段提交机制(prepare_nsset() -> validate_nsset() -> commit_nsset())确保了多命名空间联合切换的原子性和正确性。pivot_root() 虽然不是命名空间操作,但与 Mount Namespace 配合,为容器提供了安全的根文件系统切换能力。
整个容器创建流程从 clone() 开始,经过命名空间创建、主机名设置、根文件系统切换、网络配置,最终通过 exec() 启动容器 init 进程。每一步都精确地利用了内核提供的命名空间隔离能力,使得容器内的进程仿佛运行在一个独立的操作系统实例上。
理解 nsproxy 的工作原理和命名空间管理的完整生命周期,是深入理解 Linux 容器技术底层机制的关键。它不仅解释了容器如何实现隔离,也为排查容器相关的权限问题、命名空间泄漏和性能问题提供了理论基础。