Hike News
Hike News

Docker的本质

Docker容器其实是一种沙盒技术,能够像一个集装箱一样,把应用“装”起来。

这样应用与应用之间,就因为有了边界而不会相互干扰

被装进集装箱的应用,也可以被方便地搬来搬去

容器的本质,是由Namespace、Cgroups和rootfs三种技术构建出来的进程的隔离环境。

Docke容器技术的核心功能,是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。

Namespace技术是用来修改进程视图的主要方法,Cgroups技术是用来制造约束的主要手段,rootfs技术是用来限制进程文件系统的主要方式。

Namespace:

正常情况下,每当我们在宿主机上运行一个程序的时候,操作系统都会给它分配一个进程编号,比如PID=100。这是进程的唯一标识,就像员工的工号一样。

而通过Docker把程序运行在容器中的时候,利用Namespace技术给进程施一个“障眼法”,让它看不到其他的进程,误以为只有它一个进程。

这种机制对被隔离的应用的进程空间做了限制,使其只能看到重新计算过的进程编号,比如PID=1,但事实上它在宿主机的操作系统里,还是原来的100号进程。

除了PID Namespace,Linux系统还提供了Mount、UTS、IPC、Network和User这些Namespace,用来对各种不同的进程上下文进行“障眼法”操作。

比如用Mount Namespace让被隔离的进程只能看到当前Namespace里的挂载点信息;Network Namespace让被隔离的进程只能看到当前Namespace的网络设备和配置。

所以,实际上Docker容器只是指定了这个进程所需要启用的一组Namespace参数,从而让进程只能看到当前Namespace所限定的资源、文件、设备、状态和配置等,对宿主机和其他不相关的程序完全看不到。

所以也可以说,容器其实就是一种特殊的进程而已。

虚拟机和Docker容器比较

img

在理解Docker的时候,通常会用这张图来比较虚拟机和Docker。

左边画出了虚拟机的工作原理:其中Hypervisor是虚拟机最主要的部分,它通过硬件虚拟化功能,模拟出运行一个操作系统需要的各种硬件,比如CPU、内存、I/O设备等等。然后它在这些虚拟的硬件上安装了一个新的操作系统,即Guest OS。

这样,用户的应用进程就可以运行在这个虚拟机的机器中,它能够看到的自然也只有Guest OS的文件和目录,以及这个机器里的虚拟设备。

右边用一个名为Docker Engine的软件替换了Hypervisor,把虚拟机的概念套在了容器上,实际上这个说法是不严谨的,跟真正存在的虚拟机不同,实际上并没有Docker Engine这一层。

Docker帮助用户启动的还是原来的应用进程,只不过在创建这些进程时给它们加上了各种各样的Namespace参数。

这样,这些进程就会觉得自己是各自PID Namespace里的第1号进程,只能看到各自Mount Namespace里挂载的目录和文件,只能访问各自Network Namespace里的网络设备,就好像运行在一个个独立的容器里。

img

Docker的架构其实这样画更合适

如果用虚拟化技术作为应用沙盒,就必须要由Hypervisor来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的Guest OS才能执行用户的应用进程。这就不可避免的带来的额外的资源消耗和占用。

根据实验,一个运行着CentOS的KVM虚拟机启动后,在不做优化的情况下,虚拟机自己就需要占用100~·200内存。此外,用户应用运行在虚拟机里面,对宿主机操作系统的调用就不可避免地进过虚拟化软件的拦截和处理,这本身又是一层性能损失。

使用Docker容器化后的用户应用,依然还是一个宿主机上的普通进程,这就意味着因为虚拟化而带来的性能损耗都是不存在的;

另一方面,使用Namespace作为隔离手段的容器并不需要单独的Guest OS,这使得容器额外的资源占用几乎可以忽略不计。

所以“敏捷”和“高性能”是容器相较于虚拟化最大的优势。

当然有利就有弊,Docker容器相较于虚拟化最大的弊端就是隔离的不彻底。

因为容器只是运行在宿主机上的一种特殊的进程,所以多个容器之间使用的还是同一个宿主机的操作系统内核。

另外,很多资源和对象不能被Namespace化,比如:时间。

Cgroups:

虽然容器内的进程在Namespace的作用下只能看到容器里的情况,但是在宿主机上,它作为第100号进程,与其它所有的进程之间依然是平等的竞争关系。

这就意味着,虽然这个进程表面上被隔离起来了,但它所能够使用到的资源(CPU、内存),却是可以随时被宿主机的其他进程(或者其他容器)占用。

当然这个进程也可能把所有的资源吃光,而Linux Cgroups就是Linux内核中用来为进程设置资源限制的一个重要功能。

跟Namespace的情况类似,Cgroups对资源的限制能力也有很多不完善的地方,比如/proc文件系统的问题。

/proc目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如CPU使用情况,内存占用率等。

当我们在容器内执行top命令的时候,会发现它显示的信息是宿主机的CPU和内存,而不是当前容器的数据。

rootfs:

在Linux操作系统里,有一个名为chroot的命令,作用是帮助我们“change root file”,即改变进程的根目录到指定的位置。

而对于被chroot的进程来说,它并不知道自己的根目录已经被修改了。Mount Namespace就是基于chroot的不断改良被发明出来的。

为了让容器的根目录更加真实,一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如Ubuntu 16.04的ISO。

这样,在容器启动之后,我们在容器里通过执行“ls /”查看根目录下的内容,就是Ubuntu 16.04的所有目录和文件。

这个挂载在容器根目录上,用来为容器进程提供隔离后执行环境的文件系统,就是容器镜像,专业名字rootfs(根文件系统)。

一个常见的rootfs,或者说容器镜像,会包含比如bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys test tmp usr var等的目录和文件。

进入容器后执行的/bin/bash,就是/bin目录下的可执行文件,与宿主机的/bin/bash完全不同。

rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。

所以说rootfs只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”,同一台机器上的所有容器,都是共享宿主机操作系统的内核。

这意味着,容器进程需要配置内核参数、加载额外的内核模块,以及跟内核进行直接交互的时候,需要注意这些操作和依赖的对象,都是宿主机操作系统的内核,对该机器上的所有容器生效。

不过也真是由于rootfs,容器才有了一个被反复宣传至今的重要特性:一致性。

由于rootfs里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。

有了容器镜像“打包操作系统”的能力,这个最基础的依赖环境也变成了应用沙盒的一部分,这就赋予了容器的所谓一致性。

无论在本地、云端,还是在任何机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。

这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

另外,Docker在镜像的设计中,引入了层(layer)的概念,也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量的rootfs。

这个设计用到了一种叫做联合文件系统(Union File System)的能力,也叫UnionFS,最主要的功能是将多个不同位置的目录联合挂载到同一个目录下。

img

如图所示,容器中的rootfs分为只读层、Init层、可读写层。

只读层:包含了操作系统的一部分。

Init层:夹在只读层和可读写层中间,是Docker项目单独生成的一个内部层,用来存在/etc/hosts、/etc/resolv.conf等信息。

可读写层:用来存放修改rootfs后产生的增量,无论增、删、改,都发生在这里。

使用docker commit和push指令保存并上传被修改过的容器的时候,只读层和Init层不会有任何变化,变化的只是可读写层。

Dockerfile

实际操作中,我们不会使用docker commit命令来把一个运行中的docker容器提交成为一个docker镜像。

使用 docker commit 意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为黑箱镜像,换句话说,就是除了制作镜像的人知道执行过什么命令、怎么生成的镜像,别人根本无从得知。

另外结合之前的分层,任何修改的结果仅仅是在当前层进行标记、添加、修改,而不会改动上一层。

这样每一次修改都会让镜像更加臃肿一次,所删除的上一层的东西并不会丢失,会一直如影随形的跟着这个镜像,即使根本无法访问到。

把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么无法重复的问题、镜像构建透明性的问题、体积的问题就都会解决,这个脚本就是 Dockerfile。

Dockerfile 是一个文本文件,其内包含了一条条的指令(Instruction),每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。

每一个 RUN 的行为,就和手动docker commit建立镜像的过程一样:新建立一层,在其上执行这些命令,执行结束后,commit 这一层的修改,构成新的镜像。

Union FS 是有最大层数限制的,比如 AUFS,曾经是最大不得超过 42 层,现在是不得超过 127 层。