当谈论docker时,常常会聊到docker的实现方式。很多开发者都知道,docker容器本质上是宿主机的进程,Docker通过namespace实现了资源隔离,通过cgroups实现了资源限制,通过写时复制机制(copy-on-write)实现了高效的文件操作。当进一步深入namespace和cgroups等技术细节时,大部分开发者都会感到茫然无措。尤其是接下来解释libcontainer的工作原理时,我们会接触大量容器核心知识。所以在这里,希望先带领大家走进linux内核,了解namespa和cgroups的技术细节。

namespace资源隔离

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及以后版本。

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中的任何内容,由此产生如下结论。

  1. 每个PID namespace中的第一个进程“PID 1”,都会像全通Linux中的init进程一样拥有特权,其特殊作用。
  2. 一个namespace中的进程,不可能通过kill或ptrace影响父节点或者兄弟节点中的进程,因为其他几点的PID在这个namespace没有任何意义。
  3. 如果你在新的PID namespace中重新挂载/proc文件系统,会发现其下只显示同属一个PID namespace中的其他进程。
  4. 在root namespace中看到所有的进程,并且递归包含所有子节点中的进程。到这里,读者可能已经联想到了一种在Docker外部监控运行程序的方法了,就是监控Docker daemon所在的PID namespace下的所有进程及子进程,在进行筛选即可。
  • PID namespace中的init进程
    在传统的Unix系统中,PID为1的进程时init,地位非常特殊。它作为所有进程的父进程,维护一张进程表,不断检查进程状态,一旦某个子进程因为父进程错误成为了“孤儿”进程,init就会负责收养这个子进程并最终回收资源,结束进程。所以在要实现的容器中,启动的第一个进程也需要实现类似init的功能,维护所有后续启动进程的状态。
    当系统中存在的树状嵌套结构的PID namespace时,若某个子进程成为了孤儿进程,收养孩子进程的责任就交给了孩子进程所属的PID namespace中的init进程。
    至此,读者可以明白内核设计的良苦用心。PID namespace维护这样一个树状结构,有利于系统的资源的控制与回收。因此,如果确实需要在一个Docker容器中运行多个进程,最先启动的命令进程应该是具有资源监控与回收等管理能力的,如bash。
  • 信号与init进程
    内核还为PID namespace中的init进程赋予了其他特权—信号屏蔽。如果init中没有编写处理某个信号的代码逻辑,那么与init在同一个PID namespace下的进程(即使有超级权限)发送非他的信号都会屏蔽。这个功能主要作用就是防止init进程被误杀。
    那么,父节点PID namespace中的进程发送同样的信号给子节点中的init的进程,这会被忽略吗?父节点中的进程发送的信号,如果不是SIGKILL(销毁进程)或SIGSTOPO(暂停进程)也会诶忽略。但如果发送SIGKILL或SIGSTOP,子节点的init会强制执行(无法通过代码捕捉进行特殊处理),也就是说父节点中的进程有权终止子进程。
    一旦init进程被销毁,同一PID namespace中的其他进程也所致接收到SIGKIKLL信号而被销毁。理论上,该PID namespace也不复存在了。但如果/proc/[pid]/ns/pid处于被挂载或打开的状态,namespace就会被保留下来。然而,被保留下来的namespace无法通过setns()或者fork()创建进程,所以实际上并没有什么作用。
    当一个容器内存在多个进程时,容器内的init进程可以对信号进行捕获,当SIGTERM或SIGINT等信号到来时,对其子进程做信息保存、资源回收等处理工作。在Docker daemon的源码中也可以看到类似的处理方式,当结束信号来临时,结束容器进程并回收相应资源。
  • 挂载proc文件系统
    前文提到,如果在新的PID namespace中使用使用ps命令查看,看到的还是所有的进程,因为与PID直接相关的/proc文件系统(procfs)没有挂载到一个与原/proc不同的位置。如果只想看到PID namespace本身应该看到的进程,需要重新挂载/proc,命令如下。
$ mount -t proc proc /proc
$ ps a
  • 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()函数,原因就在于此。
  • mount namespace
    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的,因为这种操作会影响到父节点的文件系统。

一个挂载状态可能为以下一种:

  1. 共享挂载
  2. 从属挂载
  3. 共享/从属挂载
  4. 私有挂载
  5. 不可绑定挂载

传播事件的挂载对象称为共享挂载;接收传播事件的挂载对象称为从属挂载;同时兼有前述两者特征的挂载对象为共享/从属挂载;既不传播也不接受事件的挂载对象称为私有挂载;另一种特殊的挂载对象称为不可绑定挂载,它们与私有挂载相似,但不允许执行绑定挂载,即创建mount namespace时这块文件对象不可被复制。

1.docker run

[root@localhost ~]# docker run -it --name my-busybox2 docker.io/busybox  /bin/sh
/ #

另启一个窗口在宿主机上

[root@localhost proc]# docker ps 
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
cb465fa71b06        docker.io/busybox   "/bin/sh"           37 seconds ago      Up 36 seconds                           my-busybox2
9b8c4b4cce0d        docker.io/busybox   "/bin/sh"           28 hours ago        Up 6 minutes                            my-busybox1
[root@localhost proc]# docker top my-busybox2
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
root                2117                2101                0                   06:09               pts/4               00:00:00            /bin/sh
[root@localhost proc]# ##进入2117容器进程,pid的namespace的id为4026532746

 

docker 切换namespace_子进程

 

2.docker exec

[root@localhost ~]# docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
cb465fa71b06        docker.io/busybox   "/bin/sh"           10 minutes ago      Up 10 minutes                           my-busybox2
9b8c4b4cce0d        docker.io/busybox   "/bin/sh"           28 hours ago        Up 16 minutes                           my-busybox1
[root@localhost ~]# docker exec -it my-busybox2 /bin/sh
/ #

主机上查看一下,docker-run 的进程2117和docker exec进程2354 在同一个namespace (pid对应的namespace的id 是一样)

docker 切换namespace_父节点_02

 

 所以在两个进程中看到的内容是一样的

docker 切换namespace_docker 切换namespace_03

 

docker 切换namespace_父节点_04

 

 在两个窗口中执行top

docker 切换namespace_父节点_05

 

 在docker exec中运行ping命令

docker 切换namespace_父节点_06

 

 在宿主机上查看namespace,发现ping和docker run 及docker exec及top都在同一个namespace中

[root@localhost ns]# docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
cb465fa71b06        docker.io/busybox   "/bin/sh"           27 minutes ago      Up 27 minutes                           my-busybox2
9b8c4b4cce0d        docker.io/busybox   "/bin/sh"           29 hours ago        Up 33 minutes                           my-busybox1
[root@localhost ns]# docker top my-busybox2
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
root                2117 (docker exec)               2101                0                   06:09               pts/4               00:00:00            /bin/sh
root                2354(docker run)                2340                0                   06:20               pts/5               00:00:00            /bin/sh
root                2519(docker run 中运行的top)       2354                0                   06:33               pts/5               00:00:00            top
root                2536 (docker exec中运行的ping命令)  2117                0                   06:35               pts/4               00:00:00            ping www.baidu.com
[root@localhost ns]# cd ../../2536/ns
[root@localhost ns]# ll
total 0
lrwxrwxrwx 1 root root 0 Aug 16 06:37 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug 16 06:37 ipc -> ipc:[4026532745]
lrwxrwxrwx 1 root root 0 Aug 16 06:37 mnt -> mnt:[4026532743]
lrwxrwxrwx 1 root root 0 Aug 16 06:37 net -> net:[4026532748]
lrwxrwxrwx 1 root root 0 Aug 16 06:37 pid -> pid:[4026532746]
lrwxrwxrwx 1 root root 0 Aug 16 06:37 pid_for_children -> pid:[4026532746]
lrwxrwxrwx 1 root root 0 Aug 16 06:37 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug 16 06:37 uts -> uts:[4026532744]
[root@localhost ns]# pwd
/proc/2536/ns
[root@localhost ns]#

 3.单进程模式控制