什么是 Linux Namespace?它解决了什么问题?

简单来说,Linux Namespace 是操作系统内核在不同进程间实现的一种「环境隔离机制」。

举例来说:现在有两个进程A,B。他们处于两个不同的 PID Namespace 下:ns1, ns2。在ns1下,A 进程的 PID 可以被设置为1,在 ns2 下,B 进程的 PID 也可以设置为1。但是它们两个并不会冲突,因为 Linux PID Namespace 对 PID 这个资源在进程 A,B 之间做了隔离。A 进程在 ns1下是不知道 B 进程在 ns2 下面的 PID 的。

这种环境隔离机制是实现容器技术的基础。因为在整个操作系统的视角下,一个容器表现出来的就是一个进程。

Linux 一共构建了 6 种不同的 Namespace,用于不同场景下的隔离:

  1. Mount - isolate filesystem mount points
  2. UTS - isolate hostname and domainname
  3. IPC - isolate interprocess communication (IPC) resources
  4. PID - isolate the PID number space
  5. Network - isolate network interfaces
  6. User - isolate UID/GID number spaces

Docker 的网络隔离机制——Linux Network Namespace

Docker 使用的网络模型是 CNM(Container Network Model),根据官方的设计文档,它的结构大致如下:

CNM 模型一共需要三个组件:

  • NetworkSandbox: 在 docker 中的实现对应 Linux Network Namespace
  • Endpoint: 在 docker 中的实现对应 VETH (一种虚拟网卡设备)
  • Network: 在 docker 中的实现对应 Linux Bridge

什么是 VETH ? 什么是 Linux Bridge ?什么是 Linux Network Namespace

Linux Bridge 是 Linux 提供的一种虚拟网络设备,它可以实现多个不同容器在一个以太网内进行通信。

Bridge 默认情况下工作在二层网络,可以在同一网络根据一定的规则过滤和转发以太网包。若给一个 Linux Bridge 设备分配一个 IP 地址,就会开启它的三层工作模式。

若你在一台安装了 docker 的 Linux 主机上执行 ip addr命令,就可以看到一个名为 docker0的Linux Bridge。默认情况下在这台宿主机上启动的容器都会链接到这个 Bridge 上。因为是在同一个网络下,且通过 Bridge 链接在一起,所以不同的容器之间可以进行网络通信。否则,不同的容器之间会因为链接的 Bridge 不同而产生网络隔离。

VETH 也是 Linux 提供的一种网络设备,它在行为上类似操作系统的 Pipe。因为 VETH 总是成对出现,一端为输入端,一端为输出端。每一个 VETH 设备都可以被赋予一个 IP 地址,然后参与三层网络通信的过程。

Linux Network Namespace 是 Linux 提供的在不同进程之间的一种网络环境隔离机制。这里可以简单的理解为,每一个进程在自己的 NS 下,都独享了一套完整的网络环境(与宿主机对比)。特定 NS 内的网络环境对外部来说是不可见的,并且在其中对一些网络设置做修改也不会影响到外部(如路由规则)。

若只考虑两个容器在宿主机上面的网络模型,它的结构如下图所示:

Docker 的 Hostname 隔离机制——Linux UTS Namespace

简单来说,这是 Linux 提供的一种针对多个进程间的 Hostname 的隔离机制。它允许一个进程在其内部设置自己的 hostname。让我们通过一个例子来了解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
下面的实例来自于陈皓老师的博客(https://coolshell.cn/articles/17010.html),感谢陈皓老师的文章
*/
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
 
/* 定义一个给 clone 用的栈,栈大小1M */
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
 
char* const container_args[] = {
    "/bin/bash",
    NULL
};

int container_main(void* arg)
{
    printf("Container - inside the container!\n");
    **sethostname("container",10); /* 设置hostname */**
    execv(container_args[0], container_args);
    printf("Something's wrong!\n");
    return 1;
}
 
int main()
{
    printf("Parent - start a container!\n");
    int container_pid = clone(container_main, container_stack+STACK_SIZE, 
            **CLONE_NEWUTS | SIGCHLD, NULL); /*启用CLONE_NEWUTS Namespace隔离 */**
    waitpid(container_pid, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

上面的例程是通过创建一个子进程的方式来测试 Linux UTS Namespace 提供的隔离机制。其中被双星号标记的两行代码是比较关键的部分: CLONE_NEWUTS 是启动 Hostname 隔离机制的一个系统调用参数,当以这个参数创建进程的时候,就会开启隔离。sethostname同样是一个系统的调用,它在自己进程内部设置了单独的 Hostname,且不会影响到宿主机。反之,如果没有上面的两行代码的话,子进程中的 Hostname 和宿主机应该是一致的。

Docker 的 IPC 隔离机制——Linux IPC Namespace

Linux 在实现进程间通信时用了以下几种方法:

  1. 管道
  2. 共享内存
  3. 信号量
  4. 消息队列

这些结构在被创建出来的时候,都会在全局范围内有一个唯一的 ID。所以,如果想要在单独的进程空间中,有一套自己的 IPC 标识并且对宿主机环境屏蔽,这就是 Linux 的 IPC 隔离机制。 IPC Namespace 的实现其实和 UTS 是差不多的:在创建进程的时候加入CLONE_NEWIPC标志即可。

当子进程和父进程都被创建之后,在子进程中通过ipcmk -Q命令创建的消息队列不会在宿主机上被发现,而在宿主机上创建的也不会被子进程发现。

Docker 的 Mount 隔离机制——Linux Mount Namespace

Linux Mount Namespace 实现了在不同进程间对于文件系统「挂载点」的隔离机制。每一个进程所持有的挂载点信息都 可以在 /proc/mounts 和 /proc/mountinfo 和 /proc/mountstats 中找到。/proc 是 Linux 提供的一种虚拟文件系统。此目录下保存的文件和目录信息描述了该操作系统一些运行时的信息。我们既可以通过改变目录下的一些内容来影响操作系统运行的结果,也可以查询该目录下的信息以便获得当前操作系统的运行情况。/proc 目录下的东西并不是真的文件和目录,它实际上是存在于内存中的。

如果想开启这种隔离机制,需要在创建子进程的时候使用CLONE_NEWNS参数。默认情况下,子进程的挂载点信息一般都是从父进程的 mount namespace 下拷贝的。但是在子进程创建完成之后,两者之间的 mount namespace 以及相应的挂载点信息就没有任何关系了。在子进程中对挂载点信息的操作是不会影响到父进程的。

PS: 这里一定要提醒读者的是,Linux Mount Namespace 提供的仅仅是对「挂载点」的隔离,并不是对文件系统的隔离。事实上,即使是在已经建立了Mount Namespace 隔离的两个进程中执行 mount/umount 操作也同样会影响到宿主机的文件系统。

Docker 的 PID 隔离机制——Linux PID Namespace

在众多 Linux 中的进程中,有一个进程是比较特殊的:init 进程(PID 为1)。它是操作系统内核初始化后第一个启动的进程,也是整个操作系统范围内的父进程,即祖先进程。之后所有的进程都是从它派生而来。最终形成一个具有层级结构的进程树。Init 进程有很多特殊的权限,如屏蔽一些信号或检查它派生的进程的状态。Init 进程在检查到一些孤儿进程的时候,会对他们进行回收。

如果能做到 PID 在容器内外部也是隔离的,那么在容器内部看起来进程就好像运行在了一个单独的操作系统中。特定容器内部或者说特定进程空间下的 PID 是可以和宿主机的 PID 取值相同的,并且不会发生冲突。以此类推,若进程和容器内部也有一个PID 为1的进程,它将会独立的管理其创建出的子进程。

Docker 的 User 隔离机制——Linux User Namespace

Linux User Namespace 提供的隔离机制允许多个不同的进程间各有自己独立的一套 UID/GID 体系,并且可以将进程内部的 UID/GID 与宿主机的 UID/GID 进行映射。开启这个隔离机制的方法也很简单:在创建子进程的时候传入CLONE_NEWUSER参数即可。至于 UID/GID 的映射,可以在/proc/<pid>/uid_map/proc/<pid>/gid_map 两个文件中,按照 ID-inside-ns ID-outside-ns length的形式写入映射记录。

这里有一个实现进程间「安全机制」的 Case,是通过 Linux User Namespace 来实现的:

在创建子进程的时候,父进程通过对/proc/<子进程pid>/uid_map/proc/<子进程pid>/gid_map 两个文件的写入,将子进程的PID 映射为子进程内部值为0的 uid 和 gid。子进程启动的时候,会因为我们设置了 uid 为0,从而自动切换到 root 用户。这样一来,我们就实现了使用一般用户创建子进程,但是在子进程的内部确是以 root 用户的身份来运行的效果。

总结

到此为止,Linux Namespace 的隔离机制就全部介绍完了。它是容器技术中「隔离机制」的基础。其实对于这些隔离机制来说,如果想理解透彻,还是要仔细琢磨 Namespace 的概念。这个概念在很多编程语言中都有出现。如果从最简单的字面意思上来理解的话,它就是一个名字空间。不同空间中可以有同一个标识,但是同一个空间中不能出现两个同样的标识。而上面所提到的 PID,Hostname,UID/GID 等等其实本质上都是一种名字的隔离,只有 Network 的部分比较特殊。尤其是在理解 Mount 隔离机制的时候,一定不要忘记一点:我们所做的一切操作都是在宿主机的文件系统上的,隔离的仅仅只是挂载点的记录而已。

Linux Namespace 的隔离,说到底还是一个逻辑上的概念,它不能切断任何进程和操作系统的链接,所以再怎么隔离是也不彻底的。不同容器或者说进程依赖的都是操作系统的资源,稍有不慎,一些操作还是会影响宿主机系统的。