Docker基于mamespace、cgroups、chroot等技术来构建容器,不是一个系统调用就能搞定,容器是一个用户态的概念。本文中Docker软件工程师Michael Crosby深入探讨了Docker对namespace技术的应用。
相信你在很多地方都看到过“Docker基于mamespace、cgroups、chroot等技术来构建容器”的说法,但你有没有想过为何容器的构建需要这些技术? 为什么不是一个简单的系统调用就可以搞定?原因在于Linux内核中并不存在“linux container”这个概念,容器是一个用户态的概念。
Docker软件工程师Michael Crosby将撰写一些列博客,深入到Docker运行的背后,探索在 docker run这段代码的背后发生了什么,这是系列博客的第一篇,深入探讨Docker对namespace技术的应用。
Namespaces
在第一部分,我会在文章中讨论Docker在使用Linux namespace时,如何创建Linux namespace。在之后的博客中我们会讨论namespace如何与其它特性如cgroups和隔离的文件系统相结合,去实现更多有用的功能。
从根本上说,namespace是Linux系统的底层概念,有一些不同类型的命名空间被部署在核内。跟踪docker run -it --privileged --net host crosbymichael/make-containers这段代码,我们就可以深入到每个不同的namespace。开始会有一些预加载文件和配置。尽管我们也会在用Docker为我们运行的容器中创建namespace,不要让他影响到你,我选择提供一个容器预加载所有依赖项的方法。我使用 --net host标志,这样可以在容器内看到host的网络接口。也需要提供--privilged标签,以保证拥有正确的权限去通过容器创建新的namespace。
以下是Dockerfile内的内容:
我会使用C语言来解释这个例子,因为它比Go语言更容易去解释底层的细节。
NET Namespace
network namespaces为你的系统网络协议栈提供了自己的视图。这个协议栈包括你的本地主机(localhost)。确认你在目录crosbymichael/make-containers下,并运行 ip a查看所有运行在你的主机上的网络接口。
这就是当前在我的主机系统中的所有网络接口。现在让我们写一段代码创建一个新的network interface。为此,我们将写一个C语言库的框架,系统调用了clone。我们将从调用clone开始,文件skeleton.c应该在demo容器的工作目录中,我们将利用这个文件作为我们例子的基础。下面是例子的代码:
这是个小的C程序,可以让你执行./a.out ip a。它把你通过命令行传入的参数,作为任何你想使用的进程的参数。不用担心具体实施太多,因为我们将要做的事情将要发生有趣的变化。它将会用任何你想要的参数执行你希望的程序。这意味着如果你想执行下面的demo之一,同时它会产生一个shell会话,这样你就可以在你的namespace中“闲逛”。你可以自己的方式探索与检查这些不同的namespace。因此让我们复制这个文件,并开始使用network namespace。
在这个文件里有一个很特殊的变量,叫做clone_flags,大部分的变化将在此处发生。namespace主要由clone标志控制。network namespace的clone标记是CLONE_NEWNET。我们需要把int clone_flags = SIGCHLD;这一行改为 int clone_flags = CLONE_NEWNET | SIGCHLD;。这样调用clone就为我们创建一个新的network namespace。在network.c中保存这一修改,然后编译运行。
这次运行的结果与首次运行ip a相比看起来很不相同。这一次我们只看到了一个 loopback接口。这是因为我们创建的进程,只有一个它自己的network namespace视图,而不是整个host。这就是如何创建一个新的network namespace的方法。
Docker使用新的network namespace启动一个veth接口,这样你的容器将拥有它自己的桥接ip地址,通常是docker0。接下来,我们不再继续讲述如何在namespace安装接口。相关内容将在另一篇文章中讲述。
MNT Namespace
mount namespace可以让看到系统中所有挂载点在某个范围下的目录视图。人们经常把它和在chroot中禁锢进程混淆在一起,或者是认为他们是相似的,还有人说容器使用mount namespac来把进程禁锢在它的根文件系统中,这都是不对的!
让我们再来拷贝一份skeleton.c用来做挂载相关的修改。可以快速的构建和运行, 从而看下执行mount命令后我们当前的挂载点的样子。
上面是在我的demo容器中看到的挂载点。为了创建一个新的mount namespace,我们使用CLONE_NEWNS标志位。大家可以注意到这个标志位名称有点奇怪,为什么不是CLONE_NEWMOUNT或者CLONE_NEWMNT呢?这是因为mount namespace是Linux中的第一个命名空间,所以这里的这里的标记位参数名字有点不符合常规,经常我们在编码实现一个新的特性或者应用的时候,我们往往不能预想到最终结果全貌。不论怎样,我们就把 CLONE_NEWNS添加到clone_flags变量中,结果就是, int clone_flags = CLONE_NEWNS | SIGCHLD;,再来编译mount.c并运行同样的命令。
这次没有任何变化,为何?因为运行在新的mount namespace中的进程,在底层系统下仍然拥有一个/proc视图。结果就是新的进程继承了底层挂载点的视图。我们有一些方法来阻止出现这样的结果,比如说,使用 pivot_root,将在后续的博客中详细介绍。
然而,有一种方法,我们可以尝试在新的mount namespace上挂载点东西。比如在/mytmp下挂载新的 tmpfs。我们在C代码中执行mount命令,并把需要新挂载的挂载点作为参数写进去。为了达到这个目标,我们需要在 child_exec函数中调用execvp之前增加代码,代码如下:
在编译和执行之前,我们需要创建一个目录/mytmp,并运行以上的改变。
这里去掉了一些常见的输出。
从结果中就能看出,多了一个新的tmpfs挂载点。赞!继续在当前shell下执行mount来对比下。
注意到了为何tmpfs挂载点为何没有显示出来了么?这是因为我们创建的挂载点是在我们自己的mount namespace下,不是在父namespace下。
前面我说过mount namespace和filesystem jail是不同的,继续执行我们的./mount和 ls命令,就能给出证明了。
UTS Namespace
UTS namespace(UNIX Timesharing System包含了运行内核的名称、版本、底层体系结构类型等信息)用于系统标识。包含了hostname 和域名domainname 。它使得一个容器拥有属于自己hostname标识,这个主机名标识独立于宿主机系统和其上的其他容器。让我们开始,拷贝 skeleton.c 然后借助他运行hostname 命令。
这个会显示你的系统主机名(在我的案例中应该是 development)。就像先前做的,让我们添加clone标识给UTS namespace的clone_flags变量。这个变量值应该是CLONE_NEWUTS。当你编译并运行他的时候,你会看到其输出竟然是一样的。这些UTS namespace的值是继承他的母系统。好吧,也就是在这个新的namespace中,我们能修改其主机名而不会对他的宿主系统和其宿主系统的其他容器造成影响,他们有隔离的UTS namespace。
让我们在 child_exec 函数中修改hostname。为此,我们需要添加#include <unistd.h>头文件使其能访问到sethostname函数,同时还需要添加#include <string.h>头文件使得 setthostname函数能够调用 strlen 函数。修改完的 child_exec 应该如下:
请确保在你的main函数中的clone_flags的变量应该是这样的nt clone_flags = CLONE_NEWUTS | SIGCHLD;,然后编译并用同样的命令参数运行。这个时候你可以看到用它执行 hostname命令的返回值。同时为了核查这个变动不会影响你当前的shell环境,我们将执行 hostname 并确认返回了先前原始的值。
IPC Namespace
IPC namespace用于隔离进程间通信,像SysV的消息队列,让我们为这个命名空间创建一个skeleton.c的副本。
我们测试IPC命名空间的方法,是通过在主机上创建一个消息队列,当我们在IPC namespace中产生一个新进程时确保我们不能看到它。让我们首先在当前的shell中创建一个消息队列,运行skeleton副本代码来查看队列。
不使用新的IPC namespace,你可以看到同样的消息队列被创建。现在让我们增加CLONE_NEWIPC标签给我们的 clone_flags变量,去为我们的进程创建一个新的IPC namespace。clone_flags变量可以看作是 int clone_flags = CLONE_NEWIPC | SIGCHLD;,重新编译和再次执行同样的命令:
完成!子进程现在在一个新的IPC namespace中,并拥有完全独立的视图,还能访问消息队列。
PID Namespace
这部分是非常有趣的。PID (Process Identification,OS里指进程识别号)namespace是划分那些一个进程可以查看并与之交互的PID的方式。当我们创建一个新的 PID namespace时,第一个进程的PID会被赋值为1。进程退出时,内核会杀死这个namespace内的其他进程。让我们来通过制作skeleton.c副本开始我们的改变。
创建一个新的 PID namespace,我们需要设置clone_flags为 CLONE_NEWPID. 该变量应该看起来像int clone_flags = CLONE_NEWPID | SIGCHLD;,我们在shell中运行 ps aux,然后以相同的参数编译和运行我们的pid.c二进制文件。
在我们预期中ps aux的PID是1,或至少看不到任何从其他父进程进来的 pid 。为什么会这样?我们孵化的进程仍然会有一个来自父进程的/proc视图,也就是说 /proc挂载在主机系统上。那么如何我们解决这个问题?我们如何确保我们新的进程只可以查看在它所在的namespace内的pid呢? 我们可以通过重新挂载/proc 开始。
因为我们会用mount来处理,我们可以借此机会使用从MNT namespace所了解到的内容,并结合PID namespace,以确保我们不会将其与自己的主机系统的 /proc搞混。
我们可以通过包含为PID namespace设置的clone flag和为MNT namespace设置的clone flag来启动。他们看起来像是 int clone_flags = CLONE_NEWPID | CLONE_NEWNS | SIGCHLD;。我们需要编辑 child_exec函数和重新挂载proc。系统调用unmount和 mount即可。因为我们正在创造一个新的MNT namespace,这不会搞乱我们的主机系统。结果应如下所示:
再一次生成并运行看看会发生什么?
完美 !我们新的 PID namespace已经在MNT namespace的帮助下正常运作!
USER Namespace
User namespace是最新的子用户空间,它允许你创建独立于其他namespace之外的用户。这是通过GID和UID映射实现的。
这里有一个未指定映射的实例应用程序。如果我们添加CLONE_NEWUSER到clone_flags,然后运行id或ls -la,会得到nobody的输出,因为当前用户还未被创建。
这是个很简单地例子,但是细想你会发现通过user namespace,可以以root权限在容器中运行(不是主机系统中的root)。不要忘记你可以随时更改 ls -la到bash中,并通过shell深入了解namespace。
总结
本文中,我们回顾了mount,network,user,PID,UTS和IPC Linux namespace,并没有修改太多代码,只是增加了一些flag。复杂的工作集中在管理多个内核子系统的交互。就像我一开始提到的,namespace只是我们用来创建容器的一种工具,我希望PID的例子能使我们理解,是如何使多个namespace协同来创建容器的。
在未来的博客中,我们会详细介绍如何将容器进程禁锢在root文件系统中(又称为docker镜像),以及如何使用cgroups。最后的博文中会综合介绍容器是如何被创建。
|