Docker本质上是运行在宿主机上的进程,它通过namespace实现了资源隔离,并通过cgroups实现了资源限制,同时通过写时复制(copy-on-write)实现了高效的文件操作。
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的API包括clone()、setns()、unshare()以及/proc下的部分文件。
对于clone()系统调用,其调用方式如下:
int clone(int (*child_func)(void *), void *child_stack, int flags, void *args);
clone()实际上是Linux系统调用fork()的一种更通用的实现方式,它可以通过flags来控制使用多少功能。
clone()标志位参数中与namespace相关的四个参数分别是:
child_func
:用于传入子进程运行的程序主函数;child_stack
:用于传入子进程使用的栈空间;flags
:表示使用哪些CLONE_*标志位,主要包括如上表所示的系统调用参数;args
:表示可用于传入的用户参数。对于setns()系统调用,其调用方式如下:
int setns(int fd, int nstype);
通过setns()系统调用,进程可以从原来的namespace加入某个已存在的namespace中。
setns()系统调用中的参数如下:
fd
:表示要加入namespace的文件描述符。namespace实际上也是一个文件,有对应的文件描述符。它是一个指向/proc/[pid]/ns目录的文件描述符。nstype
:表示是否检查fd指向的namespace类型是否符合实际要求。该参数为0表示不检查。通常,为了不影响进程的调用者,也为了使新加入的pid namespace生效,会在setns()函数执行后使用clone()创建子进程继续执行命令,让原先的进程结束运行。
例如,新创建namespace,并在该namespace中调用/bin/bash并接受参数,以运行shell。用法如下所示:
fd = open(argv[1], O_RDONLY); // 获取namespace文件描述符
setns(fd, 0); // 加入新的namespace
execvp(argv[2], &argv[2]); // 执行程序
假设编译后的程序名称为setnsdemo,于是可以执行:
$ ./setnsdemo /proc/27514/ns/uts /bin/bash # uts是对应进程号为27514的进程对应uts的namespace号
至此,就可以在新加入的namespace中执行shell命令了。
对于unshare()系统调用,其调用方式:
int unshare(int flags);
与clone()不同的是,unshare()运行在原来的进程上,不需要启动一个新进程。调用unshare()的主要作用是不启动一个新进程就可以起到隔离的效果,相当于跳出原先的namespace进行操作。
从3.8版本的内核开始,用户就可以在/proc/[pid]/ns
文件夹下看到指向不同namespace号的文件,如下图所示:
注意:如果两个进程指向的namespace编号相同,就说明它们在同一个namespace之下,否则便在不同namespace中。
那么为什么要在/proc/[pid]/ns中设置这些link链接呢?
在Docker中,通过文件描述符定位和加入一个存在的namespace是最基本的方式。
UTS(Unix Time-sharing System)namespace提供了主机名和域名的隔离,这样,每个Docker容器就可以拥有独立的主机名和域名了,在网络上可以被当作一个独立的节点,而非宿主机上的一个进程,其标志位为CLONE_NEWUTS
。
在Docker中,每个镜像基本都以自身所提供的服务名称来命名镜像的hostname,且不会对宿主机产生任何影响,其原理就是使用了UTS namespace。
使用对比:
没有使用UTS的情况
运行结果如下:
使用UTS的情况
运行结果如下:
可见,当使用UTS隔离之后,整个子进程的主机名和域名发生了改变。
进程间通信(Inter-Process Communication, IPC)涉及的IPC资源包括常见的信号量、消息队列和共享内存,其标志位为CLONE_NEWIPC
。
对于IPC资源申请,申请IPC资源就申请了一个全局唯一的32位ID,因此,IPC namespace中实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。
在同一个IPC namespace下的进程彼此可见,不同IPC namespace下的进程则互相不可见。
对于IPC namespace隔离的测试,可以参考:http://crosbymichael.com/creating-containers-part-1.html
PID namespace隔离对进程PID重新标号,即两个不同namespace下的进程可以有相同的PID。每个PID namespace都有自己的计数程序,标志位为CLONE_NEWPID
。
内核为所有的PID namespace维护了一个树状结构,最顶层的是系统初始时创建的,被称为root namespace。它创建的新的PID namespace被称为child namespace(树的子节点),而原先的PID namespace就是创建的新的PID namespace的parent namespace(树的父节点)。通过这种方式,不同的PID namespace就会形成一个层级体系。所属的父节点可以看到子节点中的进程,并可以通过信号等方式对子节点中的进程产生影响,但是子节点却不能看到父节点PID namespace中的任何内容。
关于PID namespace的相关结论:
一种在外部监控Docker中运行程序的方法:监控Docker daemon所在的PID namespace下的所有进程及其子进程,再进行筛选即可。
对于PID namespace中的init进程,其主要用于维护所有后续启动进程的运行状态。当系统中存在树状嵌套结构的PID namespace时,若某个子进程成为孤儿进程,收养该子进程的责任就交给了该子进程所属的PID namespace中的init进程。通观而言,PID namespace维护这样一个树状结构有利于系统的资源监控与回收。因此,如果确实需要在一个Docker容器中运行多个进程,最先启动的命令进程应该是具有资源监控与回收等管理能力的,如/bin/bash。另外,对于PID namespace中的init进程,其同时具有信号屏蔽的特权。也就是说,与init在同一个PID namespace下的进程(即使有超级权限)发送给它的所有信号都会被屏蔽,以防止init进程被误杀。但是,当父节点PID namespace中的进程发送相同的信号给子节点PID namespace中的init进程时,如果该信号是SIGKILL(销毁进程)或SIGSTOP(暂停进程),子节点的init进程会强制执行,其余的信号则会被忽略。同时,一旦init进程被销毁,同一PID namespace中的其他进程也随之接收到SIGKILL信号而被销毁。
对于PID namespace的ps命令,如果只想看到PID namespace本身应该看到的进程,需要重新挂载/proc,命令如下:
zjl@ubuntu:~$ mount -t proc proc /proc
zjl@ubuntu:~$ ps a
mount namespace通过隔离文件系统挂载点对文件系统的隔离提供支持,它是第一个Linux namespace,因此标志位比较特殊,为CLONE_NEWNS
。
可以通过/proc/[pid]/mount
查看到所有挂载在当前namespace中的文件系统,还可以通过/proc/[pid]/mountstats
看到mount namespace中文件设备的统计信息,包括挂载文件的名字、文件系统类型、挂在位置等。
具体如下图所示:
$ vim /proc/2430/mounts
$ vim /proc/2430/mountstats
进程在创建mount namespace时,会把当前的文件结构复制给新的mount namespace。新的mount namespace中的所有mount操作都只影响自身的文件系统,对外界不会产生任何影响。
mount namespace机制:挂载传播(mount propagation)。
挂载传播定义了挂载对象(mount object)之间的关系,这样的关系包括共享关系和从属关系,系统用这些关系决定任何挂载对象中的挂载事件如何传播到其他挂载对象。
一个挂载状态可能为以下一种:
示意图如下:
如图,可以得知:
在默认情况下,所有挂载状态都是私有挂载。
设置为共享挂载的命令如下:
$ mount --make-shared object>
从共享挂载状态的挂载对象克隆的挂载对象,其状态也是共享的,它们互相传播挂载事件。
设置为从属挂载的命令如下:
$ mount --make-slave <shared-mount-object>
来源于从属挂载对象克隆的挂载对象也是从属挂载,它也从属于原来的从属挂载的主挂载对象。
将一个从属挂载对象设置为共享/从属挂载的命令如下:
$ mount --make-shared <slave-mount-object>
对于CLONE_NEWNS
,当CLONE_NEWNS
生效之后,子进程进行的挂载与卸载操作都将只作用于该mount namespace。
network namespace主要提供了关于网络资源的隔离,包括网络设备、IPv4和IPv6协议栈、IP路由表、防火墙、/proc/net
目录、/sys/class/net
目录以及套接字(socket)等。
对于网络设备,其最多存在于一个network namespace中,可以通过创建veth pair在不同的network namespace间创建通道,以达到通信的目的。对于veth pair,其表示虚拟网络映射对,它有两端,类似管道,如果数据从一端传入,另一端也能接收到。一般情况下,物理网络设备都分配在最初的root namespace中,但是,如果有多块物理网卡,也可以把其中一块或多块分配给新创建的network namespace。
对于network namespace,我们可以认为其是将网络独立出来,模拟一个独立网络实体与外部用户实体进行通信。对于该过程,容器的经典做法就是:创建一个veth pair,一端放置于新的network namespace中,通常命名为eth0,另一端放置在原来的network namespace中连接物理网络设备,然后,再通过把多个设备接入网桥或进行路由转发,以实现网络通信的目的。
另外,在建立起veth pair之前,新的network namespace和旧的network namespace之间通过管道(pipe)来进行通信。具体示意图如下图所示:
与其他namespace类似,对network namespace的使用其实就是在创建的时候添加CLONE_NEWNET标志位。
user namespace主要隔离了安全相关的标识符(identifier)和属性(attributes),包括用户ID、用户组ID、root目录、密钥以及特殊权限。也就是说,一个普通用户的进程通过clone()创建的新进程在新user namespace中可以拥有不同的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,但是它创建的容器进程却属于拥有所有权限的超级用户。相应的,其在clone()中的标志位为CLONE_NEWUSER
。
本节从namespace使用的API开始,结合Docker逐步对6个namespace进行了讲解。
对于cgroups,它可以用于限制被namespace隔离起来的资源,还可以为资源设置权重、计算使用量、操控任务启动和停止等。
cgroups是Linux内核提供的一种机制,这种机制可以根据需求把一系列系统任务及其子任务整合(或分隔)到按资源划分等级的不同组内,从而为系统资源管理提供一个统一的框架。也就是说,cgroups可以限制、记录任务组所使用的物理资源(包括CPU、memory、IO等),为容器实现虚拟化提供了基本保证,是构建Docker等一系列虚拟化管理工具的基石。
cgroups具有如下四个特点:
从本质上,cgroups是内核附加在程序上的一系列钩子(hook),通过程序运行时对资源的调度触发相应的钩子以达到资源追踪和限制的目的。
从单个任务的资源控制到操作系统层面的虚拟化,cgroups提供了如下四大功能:
在cgroups中,主要有如下几个术语:
对于cgroups的组织结构,主要有以下几个基本规则:
规则1:同一个层级可以附加一个或多个子系统。
规则2:一个子系统可以附加到多个层级,当且仅当目标层级只有唯一一个子系统时。
规则3:系统每次新建一个层级时,该系统上的所有任务默认加入这个新建层级的初始化cgroup,这个cgroup又称root cgroup。对于创建的每个层级,任务只能存在于其中一个cgroup中,即一个任务不能存在于同一个层级的不同cgroup中,但一个任务可以存在于不同层级中的多个cgroup中。如果操作时把一个任务添加到同一个层级的另一个cgroup中,则会将它从第一个cgroup中移除。
规则4:任务在fork/clone自身时创建的子任务默认与原任务在同一个cgroup中,但是子任务允许被移动到不同的cgroup中。
在cgroups中,子系统实际上就是cgroups的资源控制系统,每种子系统独立地控制一种资源,目前Docker使用如下9种子系统,具体如下:
cgroups的实现本质上是给任务挂上钩子,当任务运行的过程中涉及某种资源时,就会触发钩子上所附带的子系统进行检测,然后根据资源类别的不同使用对应的技术进行资源限制和优先级分配。
对于不同的系统资源,cgroups提供了统一的接口对资源进行控制和统计,但限制的具体方式不尽相同。
实现上,cgroup与任务之间是多对多的关系,因此它们并不直接关联,而是通过一个中间结构把双向的关联信息记录起来。每个任务结构体task_struct
都包含了一个指针,可以查询到对应cgroup的情况,同时也可以查询到各个子系统的状态,这些子系统状态中也包含了找到任务的指针,不同类型的子系统按需定义本身的控制信息结构体,最终在自定义的结构体中把子系统状态指针包含进去,然后内核通过container_of
等宏定义来获取对应的结构体,关联到任务,以此达到资源限制的目的。
在实际的使用过程中,需要通过挂载cgroup文件系统来新建一个层级结构,挂载时需要指定要绑定的子系统,缺省情况下默认绑定系统所有子系统。在将cgroup文件系统挂载以后,就可以像操作文件一样对cgroups的hierarchy层级进行浏览和操作管理(包括权限管理、子文件管理等等)。除了cgroup文件系统以外,内核没有为cgroups的访问和操作添加任何系统调用。当一个顶层的cgroup文件系统被卸载时,如果其中创建后代cgroup目录,那么就算上层的cgroup被卸载了,层级也是激活状态,其后代cgoup中的配置依旧有效。只有递归式的卸载层级中的所有cgoup,那个层级才会被真正删除。层级激活后,/proc
目录下的每个task PID文件夹下都会新添加一个名为cgroup的文件,列出task所在的层级,对其进行控制的子系统及对应cgroup文件系统的路径。同时,一个cgroup创建完成,不管绑定了何种子系统,其目录下都会生成以下几个文件,用来描述cgroup的相应信息。同样,把相应信息写入这些配置文件就可以生效,内容如下:
tasks
:这个文件中罗列了所有在该cgroup中task的PID。该文件并不保证task的PID有序,把一个task的PID写到这个文件中就意味着把这个task加入这个cgroup中。cgroup.procs
:这个文件罗列所有在该cgroup中的线程组ID。该文件并不保证线程组ID有序和无重复。写一个线程组ID到这个文件就意味着把这个组中所有的线程加到这个cgroup中。notify_on_release
:填0或1,表示是否在cgroup中最后一个task退出时通知运行release agent,默认情况下是0,表示不运行。release_agent
:指定release agent执行脚本的文件路径(该文件在最顶层cgroup目录中存在),在这个脚本通常用于自动化umount无用的cgroup。可参考:http://www.infoq.com/cn/articles/docker-kernel-knowledge-cgroups-resource-isolation
本节浅入深出地讲解了cgroups,从cgroups是什么,到cgroups该怎么用,最后对大量地cgroup子系统配置参数进行了梳理。