当谈论Docker时,常常会聊到Docker的实现方式.Docker容器本质上是宿主机上的进程.Docker
通过namespace
实现了资源隔离,通过cgroups实现了资源现实,通过写时复制机制(copy-on-write
)实现了高效的文件操作.但当进一步什么namespace喝cgroups等技术细节,大部分开发者都会感到茫然无措.接下来我们进入namespace和cgroup的技术细节中.
Docker大热之后,热衷技术的开发者就会思考,想要实现一个资源隔离的容器,应该从哪些方面下手? 也许第一反应就是chroot
命令,这条命令给用户最直观的感受就是在使用后根目录 /的挂载点切换了,即文件系统被隔离
.接着,为了在分布式的环境下进行通信和定位,容器必然要有独立的IP、端口、路由等,自然就联想到了网络的隔离.同时,容器还需要一个独立的主机名以便在网络中标识自己.
有了网络,如何进程间通信呢.开发者可能也想到了权限的问题
,对用户和用户组的隔离就实现了用户权限的隔离.最后,运行在容器中的应用需要有进程号,自然也需要与宿主机中的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及以后的版本
namespace的API包括clone()、setns()以及unshare(),还有/proc下的部分文件.为了确定隔离到底是哪6项namespace,在使用API时,通常需要指定以下6个参数重的一个或者通过|(位或)操作来实现.
在使用clone()来创建一个独立的namespace的进程,最常见的做法也是Docker使用namespace最基本的方法.clone实际上是Linux系统调用fork()的一种更通用的实现方式
,它可以通过flags来控制使用多少功能.
int clone ( int (* child_func)(void *) ,void * child_stack ,int flags , void * args)
child_func 传入子进程运行的程序主函数
child_stack传入子进程使用的栈空间
flags 表示使用哪些CLONE_*标志位,可以参考以上标志位列表
args则可用于传入用户参数
从3.8版本的内核开始,用户就可以在/proc/[pid]/ns文件下看到指向不同namespace号的 文件,
如果两个进程只想的namespace编号相同,就说明他们在同一个namespace下.
/proc/[pid]/ns里设置这些link的另外一个作用是,一旦上述link文件被打开,只要打开的文件描述符存在,那么就算该namespace下所有的进程都已经结束,这个namespace也会一直存在
,后续进程也可以在加入进来.在Docker中,通过文件描述符定位和加入一个存在的namespace是最基本的方式.另外,把/proc/[pid]/ns目录文件使用--bind方式挂在起来可以起到同样的作用
上文提到,在进程都结束的情况下,可以通过挂在的形式把namespace保留下来,保留namespace的目的就是为以后有进程加入做准备,Docker中,使用docker exec 命令在已经运行着的容器中执行一个新的命令,就需要用到该方->通过setns()的调用,进程从原先的namespace加入某个已经存在的namespace,为了不影响进程的调用者,也为了使新加入的pid namespace生效,会在setns()函数执行后使用clone()创建子进程继续执行命令.
int setns(int fd, int mstype);
参数fd表示要加入namespace的文件描述符.上文提到,它是一个指向/proc/[pid]/ns目录的文件描述符,可以通过直接打开该目录下的连接或者打开一个挂载了该目录下链接的文件得到
参数nstype让调用者可以检查fd指向的namespace类型是否符合实际要求,参数为0表示不检查.
unshare()它与clone()很像,不同的是unshare()运行在原先的进程上,不需要启动一个新进程
调用unshare()的主要作用就是,不启动新进程就可以起到隔离的效果,相当于跳出原先的namespace进程操作,这样就可以在原进程进行一些需要隔离的操作.
Docker目前病没有使用这个系统调用,这里不组展开.
系统调用函数fork()并不属于namespace的API,这部分内容属于延伸阅读,如果读者已经对fork()有足够的了解可以忽略.
当程序调用fork()函数时,系统会创建新的县城,为其分配资源,然后把原来进程的所有值都复制到新进程中,只有少量数值与原来进程值不同,相当于复制了其本身.
fork()的神奇之处就在于它仅仅被调用一次,却能够返回两次(父进程与子进程各返回一次),通过返回值的不同就可以区分父进程与子进程,返回值如下:
- 在父进程中,fork()返回新创建子进程的PID;
- 在子进程中,fork()返回0;
- 如果出现错误,fork()返回负值;
使用fork()后,父进程有义务监控子进程的运行状态,并在子进程退出后自己才能正常退出,否则子进程就会成为“孤儿”进程.
UTS( UNIX Time-sharing System) namespace提供了主机名和域名的隔离,这样每个Docker容器就可以拥有独立的主机名和域名了,在网络上可以被视为一个独立的节点,而非宿主机上的一个进程.Docker中每个镜像基本都以自身所提供的服务名称来命名镜像的hostname,且不会对宿主机产生影响,其原理就是UTS namespaces
Linux 中提供sethostname() 系统调用函数,可以修改hostname,结合clone()系统调用函数
在这里读者会有一个疑问, 如果调用sethostname不就把宿主机主机名改变了么?
其实,调用clone()中flags 加入clone_NEWUTS 则可以改变当前进程的主机名,不会对宿主机产生影响.
进程间(Inter-Process Communication)通信涉及的IPC资源包括常见的信号量、消息队列和共享内存.申请IPC资源就申请了一个全局唯一的32位ID,所以IPC namespace中实际上包含了系统的IPC标识符以及实现POSIX消息队列的文件系统.在同一个IPC namespace 下的进程彼此可见,不同IPC namespace下的进程则相互不可见.
IPC namespace在实现上与UTS namespace相似,只是标识符有变化,需要加上CLONE_NEWIPC参数.
目前使用IPC namespace机制的系统不多,其中比较有名的是PostgreSQL,Docker当前也是用了IPC namespace实现了容器与宿主机、容器与容器之间的IPC隔离.
PID namespace隔离非常实用,它对进程PID重新标号,即两个不同namespace下的进程可以有相同的PID,每个PID namespace都有自己的计数程序.内核为所有的PID namespace维护了一个树状结构,最顶层的是系统初始时创建的,被称为root namespace .它创建的新PID namespace被称child namespace(树的子节点),而原先的IPD 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中可以看到所有的进程,并且递归包含所有子节点中的进程.
PID namespace在实现上与UTS namespace相似,只是标识符有变化,需要加上CLONE_NEWPID参数.但是只这样后 执行ps aux/top 之类的命令,发现还是可以看到所有父进程的PID,那是因为还没有对文件系统挂载点进行隔离,ps/top之类的命令调用的是真实系统下的/proc文件内容,看到的自然是所有进程.
到这里,读者可能已经联想到一种在外部监控Docker中运行程序的方法了,就是监控Docker daemon所在的PID namespace下的所有进程及其子进程,再进程筛选.
在传统的Unix系统中,PID为1的进程为init,地位非常特殊.它作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为父进程错误称为了“孤儿”进程,init就会负责收养这个子进程并最终回收资源,结束进程.所以要在实现的容器中,启动的第一个进程也需要实现类似init的功能,维护所有后续启动进程的运行状态.
当系统中存在树状嵌套结构的PID namespace时,若某个子进程称为孤儿进程,收养该子进程的责任就交给了孩子进程所属的PID namespace中的init进程.
至此,可能读者明白了设计的良苦用心,PID namespace维护这样一个树状结构,有利于系统的资源监控与回收,因此,如果确实需要在一个Docker容器中运行多个进程最先启动的进程应该是具有资源监控和回收能力的,比如bash。
内核还为PID namespace中的init进程赋予了其他特权—信号屏蔽,如果init中没有编写处理某个信号的代码逻辑,那么与init在同一个PID namespace下的进程发送给它的信号都会被屏蔽。这个功能主要作用防止init进程被误杀。
那么父节点PID namespace中的进程发送同样的信号给子节点中的init进程,这会被忽略么?
父节点中的进程发送的信号如果不是SIGKILL(销毁进程),**SIGSTOP(暂停进程)**也会被忽略。但如果发送这两个信号,子节点的init会强制执行(无法通过代码捕捉进行特殊处理),也就是说父节点中的进程有权终止子节点进程。
一旦init进程被销毁,同一PIDnamespace中的其他进程也随之收到SIGKILL/SIGSTOP信号而被销毁,理论上该namespace也就不存在了。但是如果/proc/[pid]/ns/pid处于被挂载或者打开状态,namespace就会被保留下来,然而保留下来的namespace无法通过setns()或者fork()创建进程,所以实际上并没有什么作用
当一个容器内存在多个进程时,容器内的init进程可以对信号进行捕获,当SIGTERM或SIGINT等信号到来时,对其子进程做信息保存、资源回收等处理工作。在Docker daemon的源码中也可以看到类似的处理方式,当结束信号来临时,结束容器进程并回收资源。
前文提到,如果在新的PID namespace中使用ps命令查看,看到的还是所有进程,因为与PID直接相关的/proc文件系统没有挂载到一个与原/proc不同的位置。如果只看到PID namespace本身应该看到的进程,需要重新挂载/proc。
mount namespace通过隔离文件系统挂载点对隔离文件系统提供支持,它是历史上第一个Linux namespace,所以标示位比较特殊,就是CLONE_NEWNS。隔离后,不同mount name。space中的文件结构发生变化也互不影响,可以通过/proc/[pid]/mountstats看到mount namespace中文件设备的统计信息,包括挂载文件的名字、文件系统类型、挂载位置等
进程在创建mount namespace时,会把当前的文件结构复制给新的namespace,新的namespace中的所有mount操作都只影响自身的文件系统,对外界不会产生任何影响。这种做法非常严格的实现了隔离,但对某些情况可能并不适用。比如父节点namespace中的进程挂载了一张CD-ROM,这时子节点namespace复制的目录结构是无法自动挂载上这张CD-ROM,因为这种操作会影响到父节点的文件系统。
2006年引入的挂载传播解决了这个问题,挂载传播定义了挂载对象(mount object)之间的关系,这样的关系包括共享关系和从属关系,系统用这些关系决定任何挂载对象中的挂载事件如何传播到其他挂载对象。
当我们了解完各类namespace,兴致勃勃的构建出一个容器,并在容器中启动一个Tomcat,却出现了“80”端口已被占用“的错误,原来主机上已经运行了一个Tomcat进程,这时就需要借助network namespace技术进行网络隔离。
network namespace主要提供了关于网络资源的隔离,包括网络设备、IPv4和IPv6协议栈、IP路由表、防火墙、/proc/net目录、/sys/class/net目录、套接字等。一个物理的网络设备最多存在于一个network namespace中,可以通过创建veth pair(虚拟网络设备对:有两端蕾丝与管道,如果数据从一端传入另一端也能接收到,反之依然)在不同的network namespace间创建通道,以达到通信目的
一般情况下,物理网络设备都分配在最初的root namespace(表示系统默认的namespace)中。但是如果有多块物理网卡,也可以把其中一块或多块分配给新创建的network namespace。需要注意的是,当新创建的network namespace被释放时(所有内部的进程都终止并且namespace文件没有被挂在或打开),在这个namespace中的物理网卡会返回到root namespace,而非创建该进程的父进程所在的network namespace
当说到network namespace时,指的未必是真正的网络隔离,而是把网络独立出来,给外部用户一种透明的感觉,仿佛在与一个独立网络实体进行通信。为了达到目的,容器的经典做法就是创建一个veth pair,一端放置在新的namespace中,通常命名为eth0,一端放在原先的namespace中连接物理物理网络设备,再通过把多个设备接入网桥或者进行路由转发,来实现通信的目的。
也许会好奇,在建立起veth pair之前,新旧namespace该如何通信呢?答案是管道,以Docker Daemon启动容器的过程为例,假设容器内初始化的进程称为init。Docker daemon在宿主机上负责创建这个veth pair,把一端绑定到docker0 网桥上,另一端介入新建的network namespace进程中。这个过程执行期间,Docker daemon和init就通过pipe进行通信。具体来说,就是在Docker daemon完成veth pair 的创建之前,init在管道的另一端循环等待,直到管道另一端传来Docker daemon关于veth设备的信息,并关闭管道。init才结束等待的过程,并把它的eth0启动起来,整个结构如下:
与其他namespace类似,对network namespace的使用其实就是在创建的时候添加CLONE_NEWNET标示位。
user namespace 主要隔离了安全相关的标识符(identifier)和属性(attribute),包括用户ID、用户组ID、root目录、key(密钥)以及特殊权限。通俗的讲,一个普通用户的进程通过clone()创建的新进程在新的user namespace中可以拥有不同的用户和用户组,这意味着一个进程在容器外属于一个没有特权的普通用户,但是它创建的容器内却属于超级用户,这个技术给了容器很大的权限自由。
user namespace是最后一个支持的,直到Linux3.8版本的时候还不算成熟,很多发行版担心安全问题,在编译内核的时候并未开启USER_NS,Docker在1.10版本中对user namespace进行了支持,只要用户在启动Docker daemon的时候指定了–userns-remap,那么用户运行容器时,容器内部的root用户并不等于宿主机的root用户,而是映射到宿主机上的普通用户。
讲完user namespace,再来谈谈Docker,Docker不仅使用了user namespace,还使用了在user namespace中涉及的Capabilities机制。从内核2.2版本开始,Linux把原来和超级用户的高级权限划分为不用的单元,称为Capabilities。
这样管理员就可以独立对特定的Capability进行使用或禁止。Docker同时使用user namespace和Capability,在很大程度上加强了容器的安全性
讲到安全,namespace的6项隔离看似全面,实际上依旧没有完全隔离Linux的资源,比如SELinux,cgroups以及/sys、/proc/sys、/dev/sd*等目录下的资源,对于安全,后面会进行探讨。