当前位置: > Linux服务器 > Docker >

Docker背后的内核知识:命名空间资源隔离(2)

时间:2015-03-16 01:24来源:linux.it.net.cn 作者:IT



3. IPC(Interprocess Communication)namespace

容器中进程间通信采用的方法包括常见的信号量、消息队列和共享内存。然而与虚拟机不同的是,容器内部进程间通信对宿主机来说,实际上是具有相同PID namespace中的进程间通信,因此需要一个唯一的标识符来进行区别。申请IPC资源就申请了这样一个全局唯一的32位ID,所以IPC namespace中实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。在同一个IPC namespace下的进程彼此可见,而与其他的IPC namespace下的进程则互相不可见。

IPC namespace在代码上的变化与UTS namespace相似,只是标识位有所变化,需要加上CLONE_NEWIPC参数。主要改动如下,其他部位不变,程序名称改为ipc.c。{测试方法参考自:http://crosbymichael.com/creating-containers-part-1.html}


  1. //[...]
  2. int child_pid = clone(child_main, child_stack+STACK_SIZE,
  3. CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
  4. //[...]

我们首先在shell中使用ipcmk -Q命令创建一个message queue。


  1. root@local:~# ipcmk -Q
  2. Message queue id: 32769

通过ipcs -q可以查看到已经开启的message queue,序号为32769。


  1. root@local:~# ipcs -q
  2. ------ Message Queues --------
  3. key msqid owner perms used-bytes messages
  4. 0x4cf5e29f 32769 root 644 0 0

然后我们可以编译运行加入了IPC namespace隔离的ipc.c,在新建的子进程中调用的shell中执行ipcs -q查看message queue。


  1. root@local:~# gcc -Wall ipc.c -o ipc.o && ./ipc.o
  2. 程序开始:
  3. 在子进程中!
  4. root@NewNamespace:~# ipcs -q
  5. ------ Message Queues --------
  6. key msqid owner perms used-bytes messages
  7. root@NewNamespace:~# exit
  8. exit
  9. 已退出

上面的结果显示中可以发现,已经找不到原先声明的message queue,实现了IPC的隔离。

目前使用IPC namespace机制的系统不多,其中比较有名的有PostgreSQL。Docker本身通过socket或tcp进行通信。


 

 
 
 

4. PID namespace

PID namespace隔离非常实用,它对进程PID重新标号,即两个不同namespace下的进程可以有同一个PID。每个PID namespace都有自己的计数程序。内核为所有的PID namespace维护了一个树状结构,最顶层的是系统初始时创建的,我们称之为root namespace。他创建的新PID namespace就称之为child namespace(树的子节点),而原先的PID namespace就是新创建的PID namespace的parent namespace(树的父节点)。通过这种方式,不同的PID namespaces会形成一个等级体系。所属的父节点可以看到子节点中的进程,并可以通过信号等方式对子节点中的进程产生影响。反过来,子节点不能看到父节点PID namespace中的任何内容。由此产生如下结论{![部分内容引自:http://blog.dotcloud.com/under-the-hood-linux-kernels-on-dotcloud-part]}。

  • 每个PID namespace中的第一个进程“PID 1“,都会像传统Linux中的init进程一样拥有特权,起特殊作用。
  • 一个namespace中的进程,不可能通过kill或ptrace影响父节点或者兄弟节点中的进程,因为其他节点的PID在这个namespace中没有任何意义。
  • 如果你在新的PID namespace中重新挂载/proc文件系统,会发现其下只显示同属一个PID namespace中的其他进程。
  • 在root namespace中可以看到所有的进程,并且递归包含所有子节点中的进程。

到这里,可能你已经联想到一种在外部监控Docker中运行程序的方法了,就是监控Docker Daemon所在的PID namespace下的所有进程即其子进程,再进行删选即可。

下面我们通过运行代码来感受一下PID namespace的隔离效果。修改上文的代码,加入PID namespace的标识位,并把程序命名为pid.c。


					
  1. //[...]
  2. int child_pid = clone(child_main, child_stack+STACK_SIZE,
  3. CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS
  4. | SIGCHLD, NULL);
  5. //[...]

编译运行可以看到如下结果。


					
  1. root@local:~# gcc -Wall pid.c -o pid.o && ./pid.o
  2. 程序开始:
  3. 在子进程中!
  4. root@NewNamespace:~# echo $$
  5. 1 <<--注意此处看到shell的PID变成了1
  6. root@NewNamespace:~# exit
  7. exit
  8. 已退出

打印$$可以看到shell的PID,退出后如果再次执行可以看到效果如下。


					
  1. root@local:~# echo $$
  2. 17542

已经回到了正常状态。可能有的读者在子进程的shell中执行了ps aux/top之类的命令,发现还是可以看到所有父进程的PID,那是因为我们还没有对文件系统进行隔离,ps/top之类的命令调用的是真实系统下的/proc文件内容,看到的自然是所有的进程。

此外,与其他的namespace不同的是,为了实现一个稳定安全的容器,PID namespace还需要进行一些额外的工作才能确保其中的进程运行顺利。

(1)PID namespace中的init进程

当我们新建一个PID namespace时,默认启动的进程PID为1。我们知道,在传统的UNIX系统中,PID为1的进程是init,地位非常特殊。他作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为程序错误成为了“孤儿”进程,init就会负责回收资源并结束这个子进程。所以在你要实现的容器中,启动的第一个进程也需要实现类似init的功能,维护所有后续启动进程的运行状态。

看到这里,可能读者已经明白了内核设计的良苦用心。PID namespace维护这样一个树状结构,非常有利于系统的资源监控与回收。Docker启动时,第一个进程也是这样,实现了进程监控和资源回收,它就是dockerinit。

(2)信号与init进程

PID namespace中的init进程如此特殊,自然内核也为他赋予了特权——信号屏蔽。如果init中没有写处理某个信号的代码逻辑,那么与init在同一个PID namespace下的进程(即使有超级权限)发送给它的该信号都会被屏蔽。这个功能的主要作用是防止init进程被误杀。

那么其父节点PID namespace中的进程发送同样的信号会被忽略吗?父节点中的进程发送的信号,如果不是SIGKILL(销毁进程)或SIGSTOP(暂停进程)也会被忽略。但如果发送SIGKILL或SIGSTOP,子节点的init会强制执行(无法通过代码捕捉进行特殊处理),也就是说父节点中的进程有权终止子节点中的进程。

一旦init进程被销毁,同一PID namespace中的其他进程也会随之接收到SIGKILL信号而被销毁。理论上,该PID namespace自然也就不复存在了。但是如果/proc/[pid]/ns/pid处于被挂载或者打开状态,namespace就会被保留下来。然而,保留下来的namespace无法通过setns()或者fork()创建进程,所以实际上并没有什么作用。

我们常说,Docker一旦启动就有进程在运行,不存在不包含任何进程的Docker,也就是这个道理。

(3)挂载proc文件系统

前文中已经提到,如果你在新的PID namespace中使用ps命令查看,看到的还是所有的进程,因为与PID直接相关的/proc文件系统(procfs)没有挂载到与原/proc不同的位置。所以如果你只想看到PID namespace本身应该看到的进程,需要重新挂载/proc,命令如下。


					
  1. root@NewNamespace:~# mount -t proc proc /proc
  2. root@NewNamespace:~# ps a
  3. PID TTY STAT TIME COMMAND
  4. 1 pts/1 S 0:00 /bin/bash
  5. 12 pts/1 R+ 0:00 ps a

可以看到实际的PID namespace就只有两个进程在运行。

注意:因为此时我们没有进行mount namespace的隔离,所以这一步操作实际上已经影响了 root namespace的文件系统,当你退出新建的PID namespace以后再执行ps a就会发现出错,再次执行mount -t proc proc /proc可以修复错误。

(4)unshare()和setns()

在开篇我们就讲到了unshare()和setns()这两个API,而这两个API在PID namespace中使用时,也有一些特别之处需要注意。

unshare()允许用户在原有进程中建立namespace进行隔离。但是创建了PID namespace后,原先unshare()调用者进程并不进入新的PID namespace,接下来创建的子进程才会进入新的namespace,这个子进程也就随之成为新namespace中的init进程。

类似的,调用setns()创建新PID namespace时,调用者进程也不进入新的PID namespace,而是随后创建的子进程进入。

为什么创建其他namespace时unshare()和setns()会直接进入新的namespace而唯独PID namespace不是如此呢?因为调用getpid()函数得到的PID是根据调用者所在的PID namespace而决定返回哪个PID,进入新的PID namespace会导致PID产生变化。而对用户态的程序和库函数来说,他们都认为进程的PID是一个常量,PID的变化会引起这些进程奔溃。

换句话说,一旦程序进程创建以后,那么它的PID namespace的关系就确定下来了,进程不会变更他们对应的PID namespace。




5. Mount namespaces

Mount namespace通过隔离文件系统挂载点对隔离文件系统提供支持,它是历史上第一个Linux namespace,所以它的标识位比较特殊,就是CLONE_NEWNS。隔离后,不同mount namespace中的文件结构发生变化也互不影响。你可以通过/proc/[pid]/mounts查看到所有挂载在当前namespace中的文件系统,还可以通过/proc/[pid]/mountstats看到mount namespace中文件设备的统计信息,包括挂载文件的名字、文件系统类型、挂载位置等等。

进程在创建mount namespace时,会把当前的文件结构复制给新的namespace。新namespace中的所有mount操作都只影响自身的文件系统,而对外界不会产生任何影响。这样做非常严格地实现了隔离,但是某些情况可能并不适用。比如父节点namespace中的进程挂载了一张CD-ROM,这时子节点namespace拷贝的目录结构就无法自动挂载上这张CD-ROM,因为这种操作会影响到父节点的文件系统。

2006 年引入的挂载传播(mount propagation)解决了这个问题,挂载传播定义了挂载对象(mount object)之间的关系,系统用这些关系决定任何挂载对象中的挂载事件如何传播到其他挂载对象{![参考自:http://www.ibm.com/developerworks/library/l-mount-namespaces/]}。所谓传播事件,是指由一个挂载对象的状态变化导致的其它挂载对象的挂载与解除挂载动作的事件。

  • 共享关系(share relationship)。如果两个挂载对象具有共享关系,那么一个挂载对象中的挂载事件会传播到另一个挂载对象,反之亦然。
  • 从属关系(slave relationship)。如果两个挂载对象形成从属关系,那么一个挂载对象中的挂载事件会传播到另一个挂载对象,但是反过来不行;在这种关系中,从属对象是事件的接收者。

一个挂载状态可能为如下的其中一种:

  • 共享挂载(shared)
  • 从属挂载(slave)
  • 共享/从属挂载(shared and slave)
  • 私有挂载(private)
  • 不可绑定挂载(unbindable)

传播事件的挂载对象称为共享挂载(shared mount);接收传播事件的挂载对象称为从属挂载(slave mount)。既不传播也不接收传播事件的挂载对象称为私有挂载(private mount)。另一种特殊的挂载对象称为不可绑定的挂载(unbindable mount),它们与私有挂载相似,但是不允许执行绑定挂载,即创建mount namespace时这块文件对象不可被复制。

图1 mount各类挂载状态示意图

共享挂载的应用场景非常明显,就是为了文件数据的共享所必须存在的一种挂载方式;从属挂载更大的意义在于某些“只读”场景;私有挂载其实就是纯粹的隔离,作为一个独立的个体而存在;不可绑定挂载则有助于防止没有必要的文件拷贝,如某个用户数据目录,当根目录被递归式的复制时,用户目录无论从隐私还是实际用途考虑都需要有一个不可被复制的选项。

默认情况下,所有挂载都是私有的。设置为共享挂载的命令如下。


  1. mount --make-shared <mount-object>

从共享挂载克隆的挂载对象也是共享的挂载;它们相互传播挂载事件。

设置为从属挂载的命令如下。


  1. mount --make-slave <shared-mount-object>

从从属挂载克隆的挂载对象也是从属的挂载,它也从属于原来的从属挂载的主挂载对象。

将一个从属挂载对象设置为共享/从属挂载,可以执行如下命令或者将其移动到一个共享挂载对象下。


  1. mount --make-shared <slave-mount-object>

如果你想把修改过的挂载对象重新标记为私有的,可以执行如下命令。


  1. mount --make-private <mount-object>

通过执行以下命令,可以将挂载对象标记为不可绑定的。


  1. mount --make-unbindable <mount-object>

这些设置都可以递归式地应用到所有子目录中,如果读者感兴趣可以搜索到相关的命令。

在代码中实现mount namespace隔离与其他namespace类似,加上CLONE_NEWNS标识位即可。让我们再次修改代码,并且另存为mount.c进行编译运行。


  1. //[...]
  2. int child_pid = clone(child_main, child_stack+STACK_SIZE,
  3. CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC
  4. | CLONE_NEWUTS | SIGCHLD, NULL);
  5. //[...]

执行的效果就如同PID namespace一节中“挂载proc文件系统”的执行结果,区别就是退出mount namespace以后,root namespace的文件系统不会被破坏,此处就不再演示了。



(责任编辑:IT)
------分隔线----------------------------
栏目列表
推荐内容