Docker 背后的内核知识——Namespace 资源隔离
Docker 这么火,喜欢技术的朋友可能也会想,如果要自己实现一个资源隔离的容器,应该从哪些方面下手呢?也许你第一反应可能就是 chroot 命令,这条命令给用户最直观的感觉就是使用后根目录 / 的挂载点切换了,即文件系统被隔离了。然后,为了在分布式的环境下进行通信和定位,容器必然需要一个独立的 IP、端口、路由等等,自然就想到了网络的隔离。同时,你的容器还需要一个独立的主机名以便在网络中标识自己。想到网络,顺其自然就想到通信,也就想到了进程间通信的隔离。可能你也想到了权限的问题,对用户和用户组的隔离就实现了用户权限的隔离。最后,运行在容器中的应用需要有自己的 PID, 自然也需要与宿主机中的 PID 进行隔离。
由此,我们基本上完成了一个容器所需要做的六项隔离,Linux 内核中就提供了这六种 namespace 隔离的系统调用,如下表所示。
Namespace |
系统调用参数 |
隔离内容 |
UTS |
CLONE_NEWUTS |
主机名与域名 |
IPC |
CLONE_NEWIPC |
信号量、消息队列和共享内存 |
PID |
CLONE_NEWPID |
进程编号 |
Network |
CLONE_NEWNET |
网络设备、网络栈、端口等等 |
Mount |
CLONE_NEWNS |
挂载点(文件系统) |
User |
CLONE_NEWUSER |
用户和用户组 |
表 namespace 六项隔离
实际上,Linux 内核实现 namespace 的主要目的就是为了实现轻量级虚拟化(容器)服务。在同一个 namespace 下的进程可以感知彼此的变化,而对外界的进程一无所知。这样就可以让容器中的进程产生错觉,仿佛自己置身于一个独立的系统环境中,以此达到独立和隔离的目的。
需要说明的是,本文所讨论的 namespace 实现针对的均是 Linux 内核 3.8 及其以后的版本。接下来,我们将首先介绍使用 namespace 的 API,然后针对这六种 namespace 进行逐一讲解,并通过程序让你亲身感受一下这些隔离效果(参考自http://lwn.net/Articles/531114/)。
1. 调用 namespace 的 API
namespace 的 API 包括 clone()、setns() 以及 unshare(),还有 /proc 下的部分文件。为了确定隔离的到底是哪种 namespace,在使用这些 API 时,通常需要指定以下六个常数的一个或多个,通过|(位或)操作来实现。你可能已经在上面的表格中注意到,这六个参数分别是 CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER 和 CLONE_NEWUTS。
(1)通过 clone() 创建新进程的同时创建 namespace
使用 clone() 来创建一个独立 namespace 的进程是最常见做法,它的调用方式如下。
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
clone() 实际上是传统 UNIX 系统调用 fork() 的一种更通用的实现方式,它可以通过 flags 来控制使用多少功能。一共有二十多种 CLONE_* 的 flag(标志位)参数用来控制 clone 进程的方方面面(如是否与父进程共享虚拟内存等等),下面外面逐一讲解 clone 函数传入的参数。
- 参数 child_func 传入子进程运行的程序主函数。
- 参数 child_stack 传入子进程使用的栈空间
- 参数 flags 表示使用哪些 CLONE_* 标志位
- 参数 args 则可用于传入用户参数
在后续的内容中将会有使用 clone() 的实际程序可供大家参考。
(2)查看 /proc/[pid]/ns 文件
从 3.8 版本的内核开始,用户就可以在 /proc/[pid]/ns 文件下看到指向不同 namespace 号的文件,效果如下所示,形如 [4026531839] 者即为 namespace 号。
$ ls -l /proc/$$/ns <<-- $$ 表示应用的 PID total 0 lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 ipc -> ipc:[4026531839] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 mnt -> mnt:[4026531840] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 net -> net:[4026531956] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 pid -> pid:[4026531836] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 user->user:[4026531837] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 uts -> uts:[4026531838]
如果两个进程指向的 namespace 编号相同,就说明他们在同一个 namespace 下,否则则在不同 namespace 里面。/proc/[pid]/ns 的另外一个作用是,一旦文件被打开,只要打开的文件描述符(fd)存在,那么就算 PID 所属的所有进程都已经结束,创建的 namespace 就会一直存在。那如何打开文件描述符呢?把 /proc/[pid]/ns 目录挂载起来就可以达到这个效果,命令如下。
# touch ~/uts # mount --bind /proc/27514/ns/uts ~/uts
如果你看到的内容与本文所描述的不符,那么说明你使用的内核在 3.8 版本以前。该目录下存在的只有 ipc、net 和 uts,并且以硬链接存在。
(3)通过 setns() 加入一个已经存在的 namespace
上文刚提到,在进程都结束的情况下,也可以通过挂载的形式把 namespace 保留下来,保留 namespace 的目的自然是为以后有进程加入做准备。通过 setns() 系统调用,你的进程从原先的 namespace 加入我们准备好的新 namespace,使用方法如下。
int setns(int fd, int nstype);
- 参数 fd 表示我们要加入的 namespace 的文件描述符。上文已经提到,它是一个指向 /proc/[pid]/ns 目录的文件描述符,可以通过直接打开该目录下的链接或者打开一个挂载了该目录下链接的文件得到。
- 参数 nstype 让调用者可以去检查 fd 指向的 namespace 类型是否符合我们实际的要求。如果填 0 表示不检查。
为了把我们创建的 namespace 利用起来,我们需要引入 execve() 系列函数,这个函数可以执行用户命令,最常用的就是调用 /bin/bash 并接受参数,运行起一个 shell,用法如下。
fd = open(argv[1], O_RDONLY); /* 获取 namespace 文件描述符 */ setns(fd, 0); /* 加入新的 namespace */ execvp(argv[2], &argv[2]); /* 执行程序 */
假设编译后的程序名称为 setns。
# ./setns ~/uts /bin/bash # ~/uts 是绑定的 /proc/27514/ns/uts
至此,你就可以在新的命名空间中执行 shell 命令了,在下文中会多次使用这种方式来演示隔离的效果。
(4)通过 unshare() 在原先进程上进行 namespace 隔离
最后要提的系统调用是 unshare(),它跟 clone() 很像,不同的是,unshare() 运行在原先的进程上,不需要启动一个新进程,使用方法如下。
int unshare(int flags);
调用 unshare() 的主要作用就是不启动一个新进程就可以起到隔离的效果,相当于跳出原先的 namespace 进行操作。这样,你就可以在原进程进行一些需要隔离的操作。Linux 中自带的 unshare 命令,就是通过 unshare() 系统调用实现的,有兴趣的读者可以在网上搜索一下这个命令的作用。
(5)延伸阅读:fork()系统调用
系统调用函数 fork() 并不属于 namespace 的 API,所以这部分内容属于延伸阅读,如果读者已经对 fork() 有足够的了解,那大可跳过。
当程序调用 fork()函数时,系统会创建新的进程,为其分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的进程中,只有少量数值与原来的进程值不同,相当于克隆了一个自己。那么程序的后续代码逻辑要如何区分自己是新进程还是父进程呢?
fork() 的神奇之处在于它仅仅被调用一次,却能够返回两次(父进程与子进程各返回一次),通过返回值的不同就可以进行区分父进程与子进程。它可能有三种不同的返回值:
- 在父进程中,fork 返回新创建子进程的进程 ID
- 在子进程中,fork 返回 0
- 如果出现错误,fork 返回一个负值
下面给出一段实例代码,命名为 fork_example.c。
#include <unistd.h> #include <stdio.h> int main (){ pid_t fpid; //fpid 表示 fork 函数返回的值 int c