14. Namespaces Implementation
章节引子:看不见的墙
想象一下,你正在一台服务器上调试一个网络服务。你按照文档修改了全局的路由表,啪,回车。一秒钟后,监控报警响了——不是你的服务挂了,是隔壁那个本来跑得好好的数据库突然连不上了。
为什么?因为你们共享的是同一张路由表。
这就是传统 Linux 系统的「默认行为」:所有的进程都生活在同一个全局视图里。同一个网络栈,同一个文件系统挂载树,同一个 PID 空间。这对于单机时代来说没问题,但对于容器化、微服务、或者仅仅是想在同一台机器上隔离测试环境的我们来说,这简直是在雷区跳舞。
我们想要的是一种更细粒度的隔离机制。不是虚拟机那种沉重的完全隔离,而是轻量级的、让进程以为自己独占系统的幻觉。
**Namespaces(命名空间)**就是 Linux 给出的答案。它允许系统在同一个内核内,虚拟出多个相互隔离的「全局」资源视图。
但内核是怎么做到这一点的?它怎么知道进程 A 看到的 eth0 和进程 B 看到的 eth0 不是同一个东西?当我们创建一个容器时,内核底层到底发生了什么?
这一章,我们要剥开这层幻觉的外衣,直接看内核的骨架。我们会看到为了实现这种隔离,内核引入了哪些新的数据结构,哪些新的系统调用,以及——这一点最重要——旧的架构是如何被巧妙地重构来容纳这些新特性的。
这不仅仅是容器的底层原理,这是理解现代 Linux 资源管理的关键。
14.1 Namespaces Implementation —— 当「全局」不再全局
截至目前,Linux 内核实现了六种命名空间。要把这些特性塞进现有的内核架构里,并且还得保持高性能和可维护性,这并不是一件容易的事。内核开发者们做了一系列的架构调整和新增,主要是为了在内核层面支持命名空间,并在用户空间提供操作接口。
我们这就来拆解这些变动。
nsproxy:为了性能的代理人
首先登场的是一个叫 nsproxy(namespace proxy,命名空间代理)的结构体。
你可能会问,为什么不直接在进程描述符 task_struct 里塞六个指针,每个指向一个命名空间?这也是我一开始的想法。
但这里有一个微妙的性能考量。task_struct 里确实需要一个指向命名空间的入口,但如果是六个独立的指针,每次 fork() 创建子进程时,如果子进程还要继承父进程的环境,我们就得对这六个命名空间分别做引用计数加一(get operation)。这在高频调用的路径上,开销是不可忽视的。
于是,nsproxy 作为一种优化手段被引入了。它像是一个包裹,把五个命名空间指针(注意,是五个,不是六个)打包在了一起。
为什么是五个?
这就是 User Namespace(用户命名空间)的特殊之处了。nsproxy 里没有指向 user_namespace 的指针。但是,其他五个命名空间结构体内部,都有一个名为 user_ns 的指针,指向拥有它们的用户命名空间。这形成了一种倒置的依赖关系。
而 User Namespace 自己,它挂在进程的凭证结构体 cred 下。cred 代表了进程的安全上下文。每个 task_struct 有两个 cred 对象,分别用于 effective 和 objective credentials。这涉及到安全模型的细节,本书不展开讨论。你只需要知道:User Namespace 是个特例,它是安全凭证的一部分,而不是 nsproxy 的一部分。
一个 nsproxy 对象由 create_nsproxy() 方法创建,由 free_nsproxy() 方法释放。进程描述符 task_struct 里的 nsproxy 字段,就是指向这个代理结构体的指针。
来看一眼 nsproxy 的定义(include/linux/nsproxy.h):
struct nsproxy {
atomic_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children; // 注意这里,3.11 内核后改名了
struct net *net_ns;
};
非常直观。除了没有 user_ns,其他五个都在。那个 count 成员是个原子计数器,create_nsproxy() 创建时初始化为 1,get_nsproxy() 时加一,put_nsproxy() 时减一。
这就像是一个「套餐」。当你复制进程环境时,内核只需要复制一份套餐指针,然后把套餐的引用计数加一,而不是去给套餐里的每一道菜单独记账。这大大简化了 fork() 路径的逻辑。
顺便提一句,pid_ns 成员在内核 3.11 之后被重命名为 pid_ns_for_children。这名字其实更准确——它不仅是指针,更暗示了这个 PID 命名空间是给这个进程的子进程用的。
unshare():我要单飞
有了数据结构,还得有操作它们的接口。第一个登场的是 unshare() 系统调用。
这个名字很形象——「我不共享了」。它允许当前进程(或其部分)从原本共享的命名空间中分离出来,自立门户。
unshare() 的参数是一个位掩码,由 CLONE_* 标志位组成。当你传入包含 CLONE_NEW* 标志的参数时,内核会执行以下两步走:
- 创建新地盘:调用
unshare_nsproxy_namespaces(),进而调用create_new_namespaces()。根据你指定的标志位(比如CLONE_NEWNET),它会创建一个新的nsproxy对象,并在这个新代理里挂载相应的新命名空间。因为参数可以是位掩码,所以你可以一次性「单飞」好几个命名空间。 - 搬家:调用
switch_task_namespaces(),把当前进程的nsproxy指针指向刚才创建的新对象。
这里有个非常特殊的例外,也是个大坑:CLONE_NEWPID。
如果你传了 CLONE_NEWPID,你会发现调用 unshare() 的那个进程,它的 PID 并没有变!真正进入新 PID 命名空间的,是它之后创建的第一个子进程。
这很容易让人困惑,但当你理解了 PID 命名空间的层级关系(PID 1 必须是 init 进程),你就会明白这是必须的——你不能让一个已经运行着的进程突然变成 PID 1,这会打乱整个进程树的管理逻辑。除了 PID 命名空间,其他五个命名空间的 unshare() 都是立即生效的——也就是调用者自己马上进入新空间。
unshare() 的实现在 kernel/fork.c 里。
setns():我想去隔壁
如果说 unshare() 是「建新房」,那 setns() 就是「搬旧房」。
它允许一个线程加入到一个已经存在的命名空间里。这在管理容器时非常有用——比如你有一个调试工具,需要进入某个正在运行的容器的网络命名空间里去抓包。
原型是:int setns(int fd, int nstype);
参数有两个:
fd:文件描述符。你可能会问,命名空间怎么会有文件描述符?还记得/proc文件系统吗?内核把每个进程关联的命名空间都映射成了/proc/<pid>/ns/目录下的符号链接。打开这些链接,就能拿到代表该命名空间的文件描述符。nstype:可选的校验参数。如果你传 0,内核不管fd是什么类型的命名空间,直接让你进。但如果你传了CLONE_NEWNET,内核就会检查fd对应的是不是网络命名空间。如果类型不匹配,直接返回-EINVAL。这是一个很好的安全锁,防止你走错门。
setns() 的实现在 kernel/nsproxy.c。
六大门派:CLONE_NEW* 标志位
为了支持这六种命名空间,内核在 clone() 系统调用的标志位里新增了六位:
CLONE_NEWNS:Mount Namespace(挂载命名空间)。注意,虽然名字叫 NEWNS,但它是最早实现的(2.4.19),后来才改成统一的命名空间框架。CLONE_NEWUTS:UTS Namespace(主机名和域名)。CLONE_NEWIPC:IPC Namespace(消息队列、信号量)。CLONE_NEWPID:PID Namespace(进程 ID)。CLONE_NEWNET:Network Namespace(网络栈)。CLONE_NEWUSER:User Namespace(用户和组 ID)。
clone() 本来是用来创建进程的,现在被扩展了一把:如果带了这些标志,它创建的不仅是新进程,还是带着新命名空间的新进程。
我们在这一章后面会频繁用到 CLONE_NEWNET,先记着这个名字。
各立山头:子系统们的独立实现
虽然顶层逻辑都在 nsproxy 和 fork 代码里,但每个子系统(网络、挂载、IPC 等)都有自己的一套命名空间实现逻辑。
- 挂载命名空间由
mnt_namespace结构体表示。 - 网络命名空间由
net结构体表示(后面细讲)。
为了创建这些具体对象,内核提供了一个通用的工厂方法:create_new_namespaces()(位于 kernel/nsproxy.c)。
它接收一个 CLONE_NEW* 标志位作为菜单,然后按单炒菜:
static struct nsproxy *create_new_namespaces(unsigned long flags,
struct task_struct *tsk, struct user_namespace *user_ns,
struct fs_struct *new_fs)
{
struct nsproxy *new_nsp;
int err;
// 第一步:先把套餐盒(nsproxy)造出来,引用计数置为 1
new_nsp = create_nsproxy();
if (!new_nsp)
return ERR_PTR(-ENOMEM);
. . .
拿到空盒子后,就开始往里面填菜。
首先是 UTS 命名空间(copy_utsname()):
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;
}
. . .
这里有个细节:如果 flags 里没设 CLONE_NEWUTS,copy_utsname() 根本不会创建新的,它直接把父进程的 uts_ns 指针返回来。这就叫「共享」。如果设了标志,它才会调用 clone_uts_ns() 去分配新的内存,并把父进程的主机名拷贝过去。
接着是 IPC(copy_ipcs())、PID(copy_pid_ns())和网络(copy_net_ns())。
关于网络命名空间的 copy_net_ns(),逻辑也是一样的:
- 没设
CLONE_NEWNET?直接返回父进程的net_ns。 - 设了?调用
net_alloc()分配新的net结构体,用setup_net()初始化,最后把它挂到全局的net_namespace_list链表上。
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;
}
return new_nsp;
}
值得注意的是,setns() 在实现时也调用了 create_new_namespaces(),但它传的第一个参数是 0。这意味着它只造了一个空的 nsproxy 盒子,并没有创建任何新的命名空间。随后,它会根据传入的 fd 找到那个已存在的命名空间,把它挂到这个盒子里,再把盒子给当前线程。
这就是 setns() 为什么叫「加入」而不是「创建」。
退出与销毁:exit_task_namespaces()
进程终有一死。当一个进程通过 do_exit() 退出时,内核会调用 exit_task_namespaces()(kernel/nsproxy.c)。
这个函数极其简单,它就是调用了 switch_task_namespaces(),把进程的 nsproxy 指针设为 NULL。
void exit_task_namespaces(struct task_struct *p)
{
switch_task_namespaces(p, NULL);
}
switch_task_namespaces() 会把旧的 nsproxy 的引用计数减一(put_nsproxy())。如果计数归零,说明没其他进程用了,这块内存就被释放了。那些具体的命名空间对象(如 net_ns)也会随之被销毁,前提是它们的引用计数也都归零了。
查找命名空间:通过 PID 还是 FD?
有时候我们在内核代码里手里只有一个 PID,想知道这个进程属于哪个网络命名空间。这时候 get_net_ns_by_pid() 就派上用场了。它通过 PID 找到 task_struct,再顺着 nsproxy 摸到 net_ns。
还有时候,我们手里拿着个文件描述符(fd),这是用户空间从 /proc 里打开传进来的。get_net_ns_by_fd() 负责通过 fd 找到对应的 inode,再找到关联的 net_namespace。
这两个函数是连接用户空间操作和内核对象的关键桥梁。
/proc//ns:看得见的命名空间
为了让用户空间能操作这些看不见的结构,内核在 /proc/<pid>/ns/ 目录下暴露了六个符号链接。
这不仅仅是为了给人看,更重要的是为了保持引用。只要这个文件(或指向它的 bind mount)还被打开着,底层的命名空间就不会被销毁,哪怕里面已经没有进程了。这对于一些运维操作非常关键。
你可以用 ls -al 或者 readlink 来看这些链接。它们指向的格式是 type:[inode]。
ls -al /proc/1/ns/
total 0
dr-x--x--x 2 root root 0 Nov 3 13:32 .
dr-xr-xr-x 8 root root 0 Nov 3 12:17 ..
lrwxrwxrwx 1 root root 0 Nov 3 13:32 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Nov 3 13:32 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 Nov 3 13:32 net -> net:[4026531956]
lrwxrwxrwx 1 root root 0 Nov 3 13:32 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Nov 3 13:32 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Nov 3 13:32 uts -> uts:[4026531838]
注意看方括号里的数字,那是 proc 文件系统唯一的 inode 编号。每个命名空间在创建时都会由 proc_alloc_inum() 分发一个唯一的身份证号,销毁时回收。有了这个号,你就能确认两个进程是不是在同一个「房间里」了。
为了让这些 proc 文件能正常工作,每个命名空间都定义了一个 proc_ns_operations 结构体。里面全是回调函数:inum 用来返回那个唯一的 inode 号,install 用来在 setns() 时执行具体的挂载操作。
utsns_operations(kernel/utsname.c)ipcns_operations(ipc/namespace.c)mntns_operations(fs/namespace.c)pidns_operations(kernel/pid_namespace.c)userns_operations(kernel/user_namespace.c)netns_operations(net/core/net_namespace.c)
初始命名空间:万物之源
系统启动时,并不是一片空白。内核预先定义了一组「初始命名空间」,也就是上帝视角的命名空间。
init_uts_ns(init/version.c)init_ipc_ns(ipc/msgutil.c)init_pid_ns(kernel/pid.c)init_net(net/core/net_namespace.c)init_user_ns(kernel/user.c)
除此之外,还有一个初始的代理对象 init_nsproxy。它就是所有进程的祖先——init 进程——手里拿的那个「套餐盒」。
struct nsproxy init_nsproxy = {
.count = ATOMIC_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
};
这里有个例外:mnt_ns 初始为 NULL。根文件系统的挂载发生在内核启动稍后的阶段。
六大门派详述
最后,我们快速过一遍这六位大侠的简历:
-
Mount Namespaces (CLONE_NEWNS)
- 作用:隔离文件系统挂载点。你在容器里
mount一个硬盘,宿主机看不见。反之亦然。 - 历史:最早实现(2.4.19),老前辈。
- 规则:新建的命名空间会继承一份父命名空间的挂载视图。后续的挂载操作相互隔离。
- 高级特性:引入了 Shared Subtrees(共享子树)机制,有一堆复杂的传播标志(
MS_SHARED,MS_PRIVATE等)。用来解决「父目录挂载了,子目录看不看得到」这种连锁反应问题。主要实现在fs/namespace.c。
- 作用:隔离文件系统挂载点。你在容器里
-
PID Namespaces (CLONE_NEWPID)
- 作用:隔离进程 ID。最重要的是,每个新 PID 命名空间的第一个进程都是 PID 1。
- 重要性:它是容器(Container)技术的基石。有了它,容器里才有自己的 init 进程,才能负责回收孤儿进程。
- 坑点:前面提到了,
unshare(CLONE_NEWPID)不会改变当前进程的 PID,只影响子进程。而且,PID 1 是杀不死的(SIGKILL 无效),除非从父命名空间杀死它——这会清空整个子命名空间。主要实现在kernel/pid_namespace.c。
-
Network Namespaces (CLONE_NEWNET)
- 作用:隔离网络栈。包括网络设备(
lo,eth0)、路由表、iptables 规则、Socket 状态等。 - 结构:核心是
struct net。 - 隔离度:非常彻底。新建的 netns 默认只有一张
lo网卡,你得自己创建 veth pair 把它连到外面去。这是本章后面的重点。实现在net/core/net_namespace.c。
- 作用:隔离网络栈。包括网络设备(
-
IPC Namespaces (CLONE_NEWIPC)
- 作用:隔离 System V IPC 和 POSIX 消息队列。
- 含义:你在容器里创建了一个消息队列,在宿主机上用
msgctl是读不到的。 - 支持:System V IPC 支持较早(2.6.19),POSIX 消息队列稍晚(2.6.30)。实现在
ipc/namespace.c。
-
UTS Namespaces (CLONE_NEWUTS)
- 作用:隔离主机名和域名。
- 来源:
uname()系统调用的数据结构叫utsname。 - 评价:最简单的命名空间,没有之一。改个 hostname 就能测试。实现在
kernel/utsname.c。
-
User Namespaces (CLONE_NEWUSER)
- 作用:隔离用户和组 ID 映射。这意味着你可以在容器里是 root(UID 0),但在宿主机眼里你只是一个普通用户(比如 UID 1000)。
- 复杂性:最复杂的命名空间。因为它涉及到全局安全模型的变更。
- 特性:允许 Capabilities(权能)在不同命名空间里有不同的定义。实现在
kernel/user_namespace.c。
用户空间的工具链
光有内核支持还不够,还得有趁手的工具。这波浪潮催生了四个主要软件包的更新:
- util-linux:
unshare命令:直接调用系统调用,让你在 shell 里就能进新空间。nsenter命令:就是setns()的命令行包装,专用于「钻进」某个容器里调试。
- iproute2:
ip netns命令:这是管理网络命名空间的王者命令。后面全是它的戏份。ip link命令:可以把物理网卡「移动」到另一个网络命名空间里。
- ethtool:
- 支持
NETIF_F_NETNS_LOCAL标志的查询。如果这个 flag 被置位,说明这个网卡是「本地特产」,不能移动(比如某些特殊的硬件接口)。
- 支持
- iw (wireless):
- 支持移动无线接口。
系统调用全家福
最后,把这三个系统调用放在一起总结一下:
| 系统调用 | 动作 | 典型用途 |
|---|---|---|
| clone() | 创建新进程 + (可选) 新命名空间 | 容器启动 |
| unshare() | 当前进程分离出(部分)新命名空间 | 隔离当前会话 |
| setns() | 加入已有的命名空间 | 容器调试 (nsenter) |
走向深入
注意,内核里的命名空间并没有「名字」。
你可能会问,ip netns add my_net 这不就是给命名空间起名了吗?不,那是 iproute2 软件包在 /var/run/netns/ 下给你挂载的一个 bind mount 而已。内核本身只知道 inode 号,不知道名字。
这很重要。如果内核要维护一个全局的名字字符串表,又会引入锁竞争、死锁等一系列复杂的并发问题,而且做 checkpoint/restore(进程迁移)时会非常麻烦。现在的设计只靠文件描述符和 inode 号,既简单又健壮。
在深入探讨最复杂的网络命名空间之前,我们不妨先看一眼最简单的 UTS 命名空间是怎么实现的。麻雀虽小,五脏俱全,理解了 UTS,其他的也就不难触类旁通了。