4.2 基本技术
一个应用程序的运行环境的总和(内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的集合)被称为一个进程。容器技术的核心就是通过约束和修改进程的动态表现,从而为其创造出一个逻辑的“边界”。容器其实是一种沙盒技术,沙盒能够像集装箱一样把应用“装”起来,这样应用与应用之间就因为有了边界而不会相互干扰。被装进“集装箱”的应用,也可以被方便地搬来搬去。容器技术本质上为应用解决了两个核心问题:应用的资源隔离限制和应用的可移植性(即在新的环境中可以直接运行)。容器将替代进程,成为今后主流的应用运行形态。
4.2.1 namespace
容器基于Linux的namespace技术,为每个应用进程都创建了一个完全隔离的环境,让每个应用进程都觉得自己拥有整个系统。
namespace是Linux用来隔离系统资源的方式,它使得PID、IPC、network等系统资源不再是全局性的,而是属于特定 namespace 的,其中的进程好像拥有独立的“全局”系统资源。每个namespace里面的资源对其他namespace都是透明的、互不干扰的,改变一个namespace中的系统资源只会影响当前namespace中的进程,对其他namespace中的进程没有影响。
在原先的 Linux 中,许多资源都是全局管理的。例如,系统中的所有进程按照惯例都是通过PID标识的,这意味着内核必须管理一个全局的PID列表。而且,所有调用者通过uname系统调用返回的系统相关信息(包括系统名称和有关内核的一些信息)都是相同的。用户ID的管理方式与其类似,即各个用户都是通过一个全局唯一的UID标识的。namespace提供了一种不同的解决方案,前面所讲的所有全局资源都通过namespace封装、抽象出来。本质上,namespace建立了系统的不同视图,此前的每一项全局资源都必须被包装到namespace数据结构中。Linux系统对形式简单的命名空间的支持已经有很长一段时间了,主要是指chroot系统调用。该方法可以将进程限制到文件系统的某一部分,因而是一种简单的 namespace 机制,但真正的命名空间能够控制的功能远远超过文件系统视图。
创建namespace有以下三种方法。
在用fork或clone系统调用创建新进程时,可通过特定的选项控制,是与父进程共享命名空间,还是建立新的命名空间。
setns系统调用让进程加入已经存在的namespace中,Docker exec就采取了该方法。
unshare系统调用让进程离开当前的namespace,加入新的namespace中。
当一个进程通过上述方法从父进程命名空间中分离后,从该进程的角度来看,改变全局属性不会传播到父进程命名空间,而父进程的修改也不会传播到子进程。但是对于文件系统来说,情况就比较复杂了,其中的共享机制非常强大,带来了大量的可能性。
1.PID namespace
如果在调用clone时设定了CLONE_NEWPID,就会创建一个新的PID namespace,形成的新进程将成为该namespace里的第一个进程。PID namespace为进程提供了一个独立的PID环境,PID namespace内的PID将从1开始,在namespace内调用fork、vfork或clone都将产生一个独立的PID。新创建的进程将会“看到”一个全新的进程空间,在这个进程空间里它的PID是1,就像一个独立系统里的 init 进程一样。之所以说“看到”,是因为该进程在宿主机真实的进程空间里的PID是其真实的数值。该namespace内的其他进程都将以该进程为父进程,当该进程结束时,其中所有的进程都会结束。
PID namespace 是有层次的,新创建的 namespace 将会是创建该 namespace 的进程所属的namespace的子namespace。子namespace中的进程对父namespace是可见的,一个进程将拥有不止一个PID,其所在的namespace及所有直系祖先namespace中都将有一个PID。系统启动时,内核将创建一个默认的PID namespace,该namespace是所有以后创建的namespace的祖先,因此系统的所有进程在该namespace内都是可见的。
2.IPC namespace
如果在调用clone时设定了CLONE_NEWIPC,就会创建一个新的IPC namespace,形成的进程将成为该namespace里的第一个进程。一个IPC namespace由一组System V IPC object标识符构成,这些标识符由与IPC相关的系统调用创建。在一个IPC namespace中创建的IPC object对该 namespace 内的所有进程可见,但是对其他 namespace 中的进程不可见,这就使得不同namespace之间的进程不能直接通信,就像在不同的系统里一样。当一个IPC namespace被销毁时,该namespace内的所有IPC object都会被自动销毁。
PID namespace 和 IPC namespace 可以组合使用,只需在调用 clone 系统时同时指定CLONE_NEWPID和CLONE_NEWIPC,这样新创建的namespace就既是一个独立的PID命名空间,又是一个独立的IPC命名空间。不同namespace中的进程彼此不可见,也不能互相通信,这样就实现了进程间的隔离。
3.mount namespace
如果在调用clone时设定了CLONE_NEWNS,就会创建一个新的mount namespace。每个进程都存在于一个mount namespace中,mount namespace为进程提供了一个文件层次视图,用于让被隔离的进程只看到当前namespace里的挂载点信息。只有在“挂载”这个操作发生之后,进程的视图才会被改变,而在此之前新创建的容器会直接继承宿主机的各个挂载点。
如果不设定这个flag,子进程和父进程将共享一个mount namespace,其后子进程调用mount或umount将会对该namespace内的所有进程可见。如果子进程在一个独立的mount namespace中,就可以调用mount或umount建立一个新的文件层次视图,mount、unmount只对该namespace内的进程可见。该flag配合chroot、pivot_root系统调用,可以为进程创建一个独立的目录空间,chroot实现目录独享,mount namespace实现挂载点独享。
4.network namespace
如果在调用 clone 时设定了 CLONE_NEWNET,就会创建一个新的 network namespace。network namespace为进程提供了一个完全独立的网络协议栈视图,其包括网络设备接口、IPv4和IPv6协议栈、IP地址路由表、防火墙规则、Socket等。一个network namespace提供了一个独立的网络环境,就跟一个独立的系统一样。一个物理设备只能存在于一个network namespace中,但它可以从一个 namespace 移动到另一个 namespace 中。虚拟网络设备(Virtual Network Device)提供了一种类似于管道的抽象,可以在不同的 namespace 之间建立隧道。利用虚拟网络设备,我们可以建立某个 namepace 与其他 namespace 中物理设备的桥接。当一个 network namespace 被销毁时,物理设备会被自动移回初始的 network namespace,即系统最开始的namespace中。
5.UTS namespace
如果在调用clone时设定了CLONE_NEWUTS,就会创建一个新的UTS namespace,即系统内核参数 namespace。一个 UTS namespace 就是一组被 uname 返回的标识符。新的 UTS namespace中的标识符通过复制调用进程所属的namespace的标识符来初始化,clone出来的进程可以通过相关系统调用改变这些标识符,比如调用sethostname来改变该namespace的主机名。这一改变对该namespace内的所有进程可见。CLONENEWUTS和CLONE_NEWNET一起使用,可以虚拟出一个有独立主机名和网络空间的环境,就跟网络上一台独立的主机一样。
总结来说,Linux中的每个进程都包含以上多种namespace,可以通过“ls-alt/proc/PID/ns”命令来查看。以上所有clone flag都可以一起使用,为进程提供一个独立的运行环境。LXC正是通过在调用clone时设定了这些flag,为进程创建了一个有独立PID、IPC、mount、network、UTS空间的容器。
一个容器就是一个虚拟的运行环境,它对容器里的进程是透明的,进程会以为自己是直接在一个系统上运行的。实际上,容器在创建容器进程时,指定了这个进程所需启用的一组namespace参数,这样容器进程就只能“看到”当前namespace所限定的资源、文件、设备、状态或配置,而对于宿主机及其他不相关的应用,它就完全看不到了。这时,容器进程就会觉得自己是各自PID namespace里的第1号进程,只能看到各自mount namespace里挂载的目录和文件,只能访问各自network namespace里的网络设备,就好像运行在一个“容器”里面。
Linux namespace机制本身就是为实现容器虚拟化而开发的,它实际上修改了应用进程看待整个系统资源的“视角”,即它的“视线”被namespace做了限制,只能看到某些指定的内容。但对于宿主机来说,这些被隔离的进程与其他进程并没有太大的区别,所以 namespace 提供了一套轻量级、高效率的系统资源隔离方案,其远比传统的虚拟化技术开销小。不过,它也不是完美的,它为内核的开发带来了更大的复杂性,在隔离性和容错性上与传统的虚拟化技术相比也有差距。
4.2.2 cgroup
虽然容器通过 namespace 实现了隔离,但是它在宿主机上还是被看作一个普通的进程与其他所有进程之间保持着平等关系。这就意味着,虽然容器进程表面上被隔离起来,但是它所能够使用的资源(比如CPU、内存等)却是可以随时被宿主机上的其他进程(或其他容器)占用的,这显然不是一个“沙盒”应该表现出来的合理行为。而这个缺陷可以通过 Linux 内核中用来为进程设置资源限制的cgroup来弥补。
cgroup是Linux内核中的一项功能,它可以对进程进行分组,并在分组的基础上限制进程组能够使用的资源上限(如 CPU 时间、系统内存、网络带宽等)。通过 cgroup,系统管理员在分配、排序、拒绝、管理和监控系统资源等方面,可以对硬件资源进行精细化控制。cgroup的作用和namespace不一样,namespace是为了隔离进程之间的资源,而cgroup是为了对一组进程进行统一的资源监控和限制。
cgroup 技术将系统中的所有进程组织成进程树——进程树中包含系统的所有进程,树的每个节点都是一个进程组。cgroup 中的资源被称为 subsystem,进程树可以与一个或者多个subsystem关联。系统中可以有很多棵进程树,每棵树都与不同的subsystem关联。一个进程可以属于多棵树,即一个进程可以属于多个进程组,只是这些进程组与不同的 subsystem 关联。进程树的作用是将进程分组,而 subsystem 的作用是监控、调度或限制每个进程组的资源。目前Linux支持12种subsystem(比如限制CPU的使用时间、内存,以及统计CPU的使用情况等),也就是Linux中最多可以建立12棵进程树,每棵树都关联一个subsystem,当然也可以只建立一棵树,然后让这棵树关联所有的subsystem。
在实际操作中,cgroup就是一个subsystem目录(如/sys/fs/cgroup/cpu)和一组资源限制文件的组合。
4.2.3 rootfs
为了实现应用运行环境的一致性,容器使用了 rootfs 技术,这使得容器镜像中打包的内容不只有应用本身,还包括整个操作系统的文件和目录,即应用及其所需的依赖都被封装在一起,实现了应用环境的强一致性。
从文件隔离的角度来讲,我们希望新建的容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。在Linux中有一个chroot命令,它的作用就是将进程的根目录变更到指定的位置(change root file system)。因为容器就是一个进程,所以可以通过chroot为容器进程提供一个新的根目录及新的文件系统。为了能够让容器的根目录看起来更像是一个真实的操作系统的根目录,一般会在容器启动时在其根目录下挂载一个完整的操作系统的文件系统,比如Ubuntu 16.04的ISO。这样在容器启动之后,在容器内执行“ls/”命令就可以查看整个根目录下的内容,也就是Ubuntu系统的所有目录和文件。
这个被挂载在容器根目录下,用来为容器进程提供隔离后运行环境的文件系统,就是容器镜像,被称为rootfs(根文件系统)。rootfs只是一个操作系统的文件系统,其中包括文件、配置和目录等,但并不包括操作系统内核。Linux操作系统只有在开机启动时,才会加载指定版本的内核镜像到内存中。同一台宿主机上的所有容器,都共享宿主机操作系统的内核。这就意味着,如果容器中的应用程序需要配置内核参数、加载额外的内核模块,以及与内核进行直接的交互等,那么这些都是对宿主机操作系统内核的操作,其对于该宿主机上的所有容器来说是全局操作。
正是有了容器镜像“打包操作系统”的能力,应用的依赖环境终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端还是在任何一台宿主机上,只需要解压缩打包好的容器镜像,应用运行所需的完整环境就可以重现。这种深入到操作系统级别的运行环境的一致性,解决了过去因本地开发环境和远端运行环境不同所带来的各种应用问题。
Docker镜像的制作并没有沿用以前制作rootfs的标准流程,而是在镜像的设计过程中引入了层(layer)的概念。用户制作镜像的每一步操作都会生成一个层,整个文件系统的增量机制是基于UnionFS的。UnionFS是Linux内核中的一项技术,它将多个处于不同位置的目录联合挂载到同一个目录下。而Docker就是利用这种联合挂载的能力,将容器镜像里的多层内容呈现为统一的rootfs的。在Docker中使用的UnionFS是通过aufs来实现的,虽然aufs还未进入Linux内核主干,但是它在Ubuntu、Debain等发行版本中均有使用。
以Docker为例,其镜像主要分为三层,具体如下。
只读层:容器的rootfs最下面的五层,以增量的方式分别包含整个文件系统。
可读/写层:容器的rootfs最上面的一层,在没有写入文件之前,这个层是空的。一旦在容器里进行了写操作,由此产生的内容就会以增量的方式出现在这一层中。可读/写层就是专门用来存放修改 rootfs 后产生的增量内容的——无论是增加、删除还是修改产生的增量内容。当使用完这个被修改过的容器之后,还可以使用“docker commit”和“push”命令保存这个被修改过的可读/写层,而只读层里的内容不会有任何变化,这就是增量rootfs的好处。
init层:这是 Docker/Kubernetes 单独生成的一个内部层,专门用来存放/etc/hosts、/etc/resolv.conf等配置信息。这些文件本来属于只读层,但是在启动容器时每次都会自动写入一些指定的参数,比如hostname,所以理论上需要在可读/写层对它们进行修改。但这些修改往往只对当前的容器有效,并不希望执行“docker commit”命令时,需将这些信息连同可读/写层一起提交,所以设置了额外的 init 层,init 层的内容在执行“docker commit”命令时会被忽略。
由于容器镜像的操作是增量式的,因此每次镜像拉取、推送内容所需的空间,比原本多次推送完整操作系统所需的空间要小得多。而只读层的存在,可以使得所有这些容器镜像需要的总空间比单个镜像占用的空间总和要小。这也使得基于容器镜像的协作,要比基于动辄几 GB的虚拟机磁盘镜像的协作敏捷得多。
更重要的是,一旦发布了镜像,则在任何环境中使用这个镜像启动的容器都完全一致,可以完全复现当初制作镜像时的完整环境,这也是容器技术“强一致性”的重要体现。基于 aufs的容器镜像的出现,不仅打通了“开发—测试—部署”流程的每一个环节,而且更重要的是,容器镜像将会成为未来软件发布的主流方式。