namespace
实现了 资源隔离,通过 cgroups
实现了 资源限制,通过写时复制机制( copy-on-write
)实现了 高效的文件操作。但当更进一步深入 namespace
和 cgroups
等技术细节时,大部分开发者都会感到茫然无措。所以在这里,希望先带领大家走进 Linux 内核,了解 namespace
和 cgroups
的技术细节。
Docker 大热之后,热衷技术的开发者就会思考,想要实现一个资源隔离的容器,应该从哪些方面下手?也许第一反应就是 chroot
命令,这条命令给用户最直观的感受就是在使用后根目录 /
的挂载点切换了,即 文件系统 被隔离了。接着,为了在分布式的环境下进行通信和定位,容器必然要有独立的 IP、端口、路由等,自然就联想到了 网络 的隔离。同时,容器还需要一个独立的 主机名 以便在网络中标识自己。有了网络,自然离不开通信,也就想到了 进程间通信 需要隔离。开发者可能也已经想到了权限的问题,对用户和用户组的隔离就实现了 用户权限 的隔离。最后,运行在容器中的应用需要有进程号(PID),自然也需要与宿主机中的 PID 进行隔离。
由此,基本上完成了一个容器所需要做的 6 项隔离,Linux 内核中提供了这 6 种 namespace
隔离的系统调用,如下表所示。当然,真正的容器还需要处理许多其他工作。
namespace | 系统调用参数 | 隔离内容 |
---|---|---|
UTS | CLONE_ NEWUTS | 主机名与域名 |
IPC | CLONE_NEWIPC | 信号量、消息队列和共享内存 |
PID | CLONE_NEWPID | 进程编号 |
Network | CLONE_NEWNET | 网络设备、网络栈、端口等 |
Mount | CLONE_NEWNS | 挂载点(文件系统) |
User | CLONE_NEWUSER | 用户和用户组 |
实际上,Linux 内核实现 namespace
的一个主要目的,就是实现轻量级虚拟化(容器)服务。在同一个 namespace
下的进程可以感知彼此的变化,而对外界的进程一无所知。这样就可以让容器中的进程产生错觉,仿佛自己置身于一个独立的系统环境中,以达到独立和隔离的目的。
需要说明的是,本文所讨论的 namespace
实现针对的均是 Linux 内核 3.8 3.8 3.8 及以后的版本(user namespace
在内核 3.8 3.8 3.8 版本以后才支持)。接下来,将首先介绍使用 namespace
的 API,然后对这 6 种 namespace
进行逐一讲解,并通过程序让读者切身感受隔离效果。
namespace
的 API 包括 clone()
、setns()
以及unshare()
,还有 /proc
下的部分文件。为了确定隔离的到底是哪 6 项 namespace
,在使用这些 API 时,通常需要指定以下 6 个参数中的一个或多个,通过 |
(位或)操作来实现。从上表可知,这 6 个参数分别是 CLONE_NEWIPC
、CLONE_NEWNS
、CLONE_NEWNET
、CLONE_NEWPID
、CLONE_NEWUSER
和 CLONE_NEWUTS
。
使用 clone()
来创建一个独立 namespace
的进程,是最常见的做法,也是 Docker 使用 namespace
最基本的方法,它的调用方式如下。
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
clone()
实际上是 Linux 系统调用 fork()
的一种更通用的实现方式,它可以通过 flags
来控制使用多少功能。一共有 20 多种 CLONE_*
的 flag
(标志位)参数用来控制 clone
进程的方方面面(如是否与父进程共享虚拟内存等),下面挑选与 namespace
相关的 4 个参数进行说明。
child func
传入子进程运行的程序主函数。child stack
传入子进程使用的栈空间。flags
表示使用哪些 CLONE_*
标志位,与 namespace
相关的主要包括 CLONE_NEWIPC
、CLONE_NEWNS
、CLONE_NEWNET
、CLONE_NEWPID
、CLONE_NEWUSER
和 CLONE_NEWUTS
。args
则可用于传入用户参数。从 3.8 3.8 3.8 版本的内核开始,用户就可以在 /proc/[pid]/ns
文件下看到指向不同 namespace
号的文件,效果如下所示,形如 [4026531839]
者即为 namespace
号。
$ ls -l /proc/$$/ns <<--$$是shell中表示当前运行的进程ID号
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
里设置这些 link
的另外一个作用是,一旦上述 link
文件被打开,只要打开的 文件描述符(fd
)存在,那么就算该 namespace
下的所有进程都已经结束,这个 namespace
也会一直存在,后续进程也可以再加入进来。在 Docker 中,通过文件描述符定位和加入一个存在的 namespace
是最基本的方式。
另外,把 /proc/[pid]/ns
目录文件使用 --bind
方式挂载起来可以起到同样的作用,命令如下:
# touch ~/uts
# mount --bind /proc/27514/ns/uts ~/uts
为了方便起见,后面的讲解中会使用这个 ~/uts
文件来代替 /proc/27514/ns/uts
。
注意:如果大家看到 ns
下的内容与本节所述不符,那可能是因为使用了 3.8 3.8 3.8 以前版本的内核。如在内核版本 2.6 2.6 2.6 中,该目录下存在的只有 ipc
、net
和 uts
,并且以硬链接方式存在。
上文提到,在进程都结束的情况下,也可以通过挂载的形式把 namespace
保留下来,保留 namespace
的目的是为以后有进程加入做准备。在 Docker 中,使用 docker exec
命令在已经运行着的容器中执行一个新的命令,就需要用到该方法。通过 setns()
系统调用,进程从原先的 namespace
加入某个已经存在的 namespace
,使用方法如下。通常为了不影响进程的调用者,也为了使新加入的 pid namespace
生效,会在 setns()
函数执行后使用 clone()
创建子进程继续执行命令,让原先的进程结束运行。
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-test
。
# ./setns-test ~/uts /bin/bash # ~/uts 是绑定的 /proc/27514/ns/uts
至此,就可以在新加入的 namespace
中执行 shell 命令了,下文会多次使用这种方式来演示隔离的效果。
最后要说明的系统调用是 unshare()
,它与 clone()
很像,不同的是,unshare()
运行在原先的进程上,不需要启动一个新进程。
int unshare(int flags);
调用 unshare()
的主要作用就是,不启动新进程就可以起到隔离的效果,相当于跳出原先的 namespace
进行操作。这样,就可以在原进程进行一些需要隔离的操作。Linux 中自带的 unshare
命令,就是通过 unshare()
系统调用实现的。Docker 目前并没有使用这个系统调用,这里不做展开,读者可以自行查阅资料学习该命令的知识。
系统调用函数 fork()
并不属于 namespace
的 API,这部分内容属于延伸阅读,如果读者已经对 fork()
有足够多的了解,可以忽略该部分。
当程序调用 fork()
函数时,系统会创建新的进程,为其分配资源,例如存储数据和代码的空间,然后把原来进程的所有值都复制到新进程中,只有少量数值与原来的进程值不同,相当于复制了本身。那么程序的后续代码逻辑要如何区分自己是新进程还是父进程呢?
fork()
的神奇之处在于它仅仅被调用一次,却能够返回两次(父进程与子进程各返回一次),通过返回值的不同就可以区分父进程与子进程。它可能有以下 3 种不同的返回值:
fork()
返回新创建子进程的进程 ID;fork()
返回 0;fork()
返回一个负值。下面给出一段实例代码,命名为 fork_example.c
。
#include
#include
int main (){
pid_t fpid; // fpid 表示 fork 函数返回的值
int count=0;
fpid=fork();
if (fpid < 0) printf("error in fork!");
else if (fpid == 0) {
printf("I am child. Process id is %d\n",getpid());
}
else {
printf("i am parent. Process id is %d\n",getpid());
}
return 0;
}
编译并执行,结果如下。
[root@local:~#] gcc -Wall fork_example.c && ./a.out
I am parent. Process id is 28365
I am child. Process id is 28366
代码执行过程中,在语句 fpid=fork()
之前,只有一个进程在执行这段代码,在这条语句之后,就变成父进程和子进程同时执行了。这两个进程几乎完全相同,将要执行的下一条语句都是 if (fpid < 0)
,同时 fpid=fork()
的返回值会依据所属进程返回不同的值。
使用 fork()
后,父进程有义务监控子进程的运行状态,并在子进程退出后自己才能正常退出,否则子进程就会成为 “孤儿” 进程。
后续将根据 Docker 内部对 namespace
资源隔离使用的方式分别对 6 种 namespace
进行详细的解析。