Docker背后的内核知识:命名空间资源隔离
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 0Jan804:12 ipc -> ipc:[4026531839]
- lrwxrwxrwx.1 mtk mtk 0Jan804:12 mnt -> mnt:[4026531840]
- lrwxrwxrwx.1 mtk mtk 0Jan804:12 net -> net:[4026531956]
- lrwxrwxrwx.1 mtk mtk 0Jan804:12 pid -> pid:[4026531836]
- lrwxrwxrwx.1 mtk mtk 0Jan804:12 user->user:[4026531837]
- lrwxrwxrwx.1 mtk mtk 0Jan804: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 count=0;
- fpid=fork();
- if(fpid <0)printf("error in fork!");
- elseif(fpid ==0){
- printf("I am child. Process id is %d/n",getpid());
- }
- else{
- printf("i am parent. Process id is %d/n",getpid());
- }
- return0;
- }
编译并执行,结果如下。
- root@local:~# gcc -Wall fork_example.c &&./a.out
- I am parent.Process id is28365
- I am child.Process id is28366
使用fork()后,父进程有义务监控子进程的运行状态,并在子进程退出后自己才能正常退出,否则子进程就会成为“孤儿”进程。
下面我们将分别对六种namespace进行详细解析。