跳到主要内容

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* 标志的参数时,内核会执行以下两步走:

  1. 创建新地盘:调用 unshare_nsproxy_namespaces(),进而调用 create_new_namespaces()。根据你指定的标志位(比如 CLONE_NEWNET),它会创建一个新的 nsproxy 对象,并在这个新代理里挂载相应的新命名空间。因为参数可以是位掩码,所以你可以一次性「单飞」好几个命名空间。
  2. 搬家:调用 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,先记着这个名字。

各立山头:子系统们的独立实现

虽然顶层逻辑都在 nsproxyfork 代码里,但每个子系统(网络、挂载、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_NEWUTScopy_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。根文件系统的挂载发生在内核启动稍后的阶段。

六大门派详述

最后,我们快速过一遍这六位大侠的简历:

  1. Mount Namespaces (CLONE_NEWNS)

    • 作用:隔离文件系统挂载点。你在容器里 mount 一个硬盘,宿主机看不见。反之亦然。
    • 历史:最早实现(2.4.19),老前辈。
    • 规则:新建的命名空间会继承一份父命名空间的挂载视图。后续的挂载操作相互隔离。
    • 高级特性:引入了 Shared Subtrees(共享子树)机制,有一堆复杂的传播标志(MS_SHARED, MS_PRIVATE 等)。用来解决「父目录挂载了,子目录看不看得到」这种连锁反应问题。主要实现在 fs/namespace.c
  2. PID Namespaces (CLONE_NEWPID)

    • 作用:隔离进程 ID。最重要的是,每个新 PID 命名空间的第一个进程都是 PID 1
    • 重要性:它是容器(Container)技术的基石。有了它,容器里才有自己的 init 进程,才能负责回收孤儿进程。
    • 坑点:前面提到了,unshare(CLONE_NEWPID) 不会改变当前进程的 PID,只影响子进程。而且,PID 1 是杀不死的(SIGKILL 无效),除非从父命名空间杀死它——这会清空整个子命名空间。主要实现在 kernel/pid_namespace.c
  3. Network Namespaces (CLONE_NEWNET)

    • 作用:隔离网络栈。包括网络设备(lo, eth0)、路由表、iptables 规则、Socket 状态等。
    • 结构:核心是 struct net
    • 隔离度:非常彻底。新建的 netns 默认只有一张 lo 网卡,你得自己创建 veth pair 把它连到外面去。这是本章后面的重点。实现在 net/core/net_namespace.c
  4. IPC Namespaces (CLONE_NEWIPC)

    • 作用:隔离 System V IPC 和 POSIX 消息队列。
    • 含义:你在容器里创建了一个消息队列,在宿主机上用 msgctl 是读不到的。
    • 支持:System V IPC 支持较早(2.6.19),POSIX 消息队列稍晚(2.6.30)。实现在 ipc/namespace.c
  5. UTS Namespaces (CLONE_NEWUTS)

    • 作用:隔离主机名和域名。
    • 来源uname() 系统调用的数据结构叫 utsname
    • 评价:最简单的命名空间,没有之一。改个 hostname 就能测试。实现在 kernel/utsname.c
  6. 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,其他的也就不难触类旁通了。