【Docker 内核 - 资源隔离】系列包含:
- 资源隔离(一):进行 namespace API 操作的 4 种方式
- 资源隔离(二):UTS namespace & IPC namespace
- 资源隔离(三):PID namespace
- 资源隔离(四):Mount namespace & Network namespace
- 资源隔离(五):User namespaces
namespace 资源隔离(三):PID namespace
PID namespace
隔离非常实用,它对进程 PID 重新标号,即两个不同 namespace
下的进程可以有相同的 PID。每个 PID namespace
都有自己的计数程序。内核为所有的 PID namespace
维护了一个树状结构,最顶层的是系统初始时创建的,被称为 root namespace
。它创建的新 PID namespace
被称为 child namespace
(树的子节点),而原先的 PID namespace
就是新创建的 PID namespace
的 parent namespace
(树的父节点)。通过这种方式,不同的 PID namespace
会形成一个层级体系。所属的父节点可以看到子节点中的进程,并可以通过信号等方式对子节点中的进程产生影响。反过来,子节点却不能看到父节点 PID namespace
中的任何内容,由此产生如下结论。
- 每个
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
。
// [...]
int child_pid = clone(child_main, child_stack + STACK_SIZE,CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
// [...]
编译运行可以看到如下结果。
root@local:~# gcc -Wall pid.c -o pid.o && ./pid.o
程序开始:
在子进程中!
root@NewNamespace:~# echo $$
1 <<--注意此处 shell 的 PID 变成了 1
root@NewNamespace:~# exit
exit
已退出
打印 $$ 可以看到 shell 的 PID,退出后如果再次执行可以看到效果如下。
root@local:~# echo $$
17542
已经回到了正常状态。有的读者可能在子进程的 shell 中执行了 ps aux
或 top
之类的命令,发现还是可以看到所有父进程的 PID,那是因为还没有对文件系统挂载点进行隔离,ps
或 top
之类的命令调用的是真实系统下的 /proc
文件内容,看到的自然是所有的进程。所以,与其他的 namespace
不同的是,为了实现一个稳定安全的容器,PID namespace
还需要进行一些额外的工作才能确保进程运行顺利,下面将逐一介绍。
1.PID namespace 中的 init 进程
在传统的 Unix 系统中,PID 为
1
1
1 的进程是 init
,地位非常特殊。它作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为父进程错误成为了 孤儿 进程,init
就会负责收养这个子进程并最终回收资源,结束进程。所以在要实现的容器中,启动的第一个进程也需要实现类似 init
的功能,维护所有后续启动进程的运行状态。
当系统中存在树状嵌套结构的 PID namespace
时,若某个子进程成为孤儿进程,收养该子进程的责任就交给了该子进程所属的 PID namespace
中的 init
进程。
至此,可能读者已经明白了内核设计的良苦用心。PID namespace
维护这样一个树状结构,有利于系统的资源监控与回收。因此,如果确实需要在一个 Docker 容器中运行多个进程,最先启动的命令进程应该是具有资源监控与回收等管理能力的,如 bash。
2.信号与 init 进程
内核还为 PID namespace
中的 init
进程赋予了其他特权 —— 信号屏蔽。如果 init
中没有编写处理某个信号的代码逻辑,那么与 init
在同一个 PID namespace
下的进程(即使有超级权限)发送给它的该信号都会被屏蔽。这个功能的主要作用是防止 init
进程被误杀。
那么,父节点 PID namespace
中的进程发送同样的信号给子节点中的 init
进程,这会被忽略吗?父节点中的进程发送的信号,如果不是 SIGKILL
(销毁进程)或 SIGSTOP
(暂停进程)也会被忽略。但如果发送 SIGKILL
或 SIGSTOP
,子节点的 init
会强制执行(无法通过代码捕捉进行特殊处理),也即是说父节点中的进程有权终止子节点中的进程。
一旦 init
进程被销毁,同一 PID namespace
中的其他进程也随之接收到 SIGKILL
信号而被销毁。理论上,该 PID namespace
也不复存在了。但是如果 /proc/[pid]/ns/pid
处于被挂载或者打开状态,namespace
就会被保留下来。然而,保留下来的 namespace
无法通过 setns()
或者 fork()
创建进程,所以实际上并没有什么作用。
当一个容器内存在多个进程时,容器内的 init
进程可以对信号进行捕获,当 SIGTERM
或 SIGINT
等信号到来时,对其子进程做信息保存、资源回收等处理工作。在 Docker daemon 的源码中也可以看到类似的处理方式,当结束信号来临时,结束容器进程并回收相应资源。
3.挂载 proc 文件系统
前文提到,如果在新的 PID namespace
中使用 ps
命令查看,看到的还是所有的进程,因为与 PID 直接相关的 /proc
文件系统(procfs
)没有挂载到一个与原 /proc
不同的位置。如果只想看到 PID namespace
本身应该看到的进程,需要重新挂载 /proc
,命令如下。
root@NewNamespace:~# mount -t proc proc /proc
root@NewNamespace:~# ps a
PID TTY STAT TIME COMMAND
1 pts/1 S 0:00 /bin/bash
12 pts/1 R+ 0:00 ps a
可以看到实际的 PID namespace
就只有两个进程在运行。
此时并没有进行 mount namespace
的隔离,所以该操作实际上已经影响了 root namespace
的文件系统。当退出新建的 PID namespace
以后,再执行 ps a
时,就会发现出错,再次执行 mount -t proc proc /proc
可以修复错误。后面还会介绍通过 mount namespace
来隔离文件系统,当我们基于 mount namespace
实现了容器 proc
文件系统隔离后,我们就能在 Docker 容器中使用 ps
等命令看到与 PID namespace
对应的进程列表。
4.unshare() 和 setns()
前一篇博客 就提到了 unshare()
和 setns()
这两个 API,在 PID namespace
中使用时,也有一些特别之处需要注意。
unshare()
允许用户在原有进程中建立命名空间进行隔离。但创建了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
。在 Docker 中,docker exec
会使用 setns()
函数加入已经存在的命名空间,但是最终还是会调用 clone()
函数,原因就在于此。