通过容器(上)这篇文章的学习,我们清晰的了解到Docker是如何把我们开发的应用程序代码,打包成镜像,上传到镜像仓库,并在其他目标机器上运行起来,那么不知道你是否有想过,当容器运行起来后,如果从容器的运行环境内部看,它能看到什么?
我们的应用在目标机器上运行起来就是一个进程,而程序和进程的概念其实不是计算机科班出身,很难理解,我们就先来说说这两个概念,为后续更加深入的讨论打好基础。
用自己熟悉的编程语言编写如下的C语言代码:
#include <stdio.h>
main() {
printf("hello world\n");
}
由于计算机只能识别0和1,因此我们需要首先将这个文件保存到本地磁盘,比如说helloword.c,然后我们用c语言的编译器将上边的高级语言翻译成机器能够读懂的二进制文件,在翻译的过程中,我们的代码逻辑会被翻译成计算机的指令,比如加法,加法等,而需要输出的数据字符串,也会被一并编译到最终的可执行文件中。
最后变成形成的这个文件,我们就叫程序,或者也叫可执行程序,比如在Windows上, 我们将代码编译后,会形成可执行文件helloword.exe,这个以exe为扩展名的文件,就叫可执行文件。而在类Unix系统上,比如说Mac上,我们用gcc将这个文件编译后,会生成helloworld这个可执行文件,无论是helloworld.exe还是helloword,我们都叫程序。
有了程序,我们就可以讲这个程序在操作系统上运行起来,比如在Mac下,我就可以使用./helloword来将刚才我们编译好的程序加载起来,操作系统会生成进程并且从磁盘上读取这个可执行文件,然后从第一个指令开始执行,在代码的执行过程中,可能需要驱动磁盘来读取更多的指令和数据。一旦程序在计算机上被运行起来,就从磁盘的二进制文件,变成了计算机内存中的数据,寄存器中的指令,以及各种被打开文件的句柄,而我们把程序运行起来计算机的各个部件的状态之和,称之为进程。
介绍问程序和进程之后,我们来从容器内部的视角来看一下,它能看到那些信息。对于在容器中运行的应用,它其实能够看到的就是我们在进项中打包的文件和文件夹,以及在打包系统启动时候通过参数挂载的目录结构,因此如果我们在启动的时候没有挂载任何卷目录,那么容器在任何一个环境,看到的执行环境和目录文件结构是完全相同的,即便是我们在测试环境和生产环境运行的是不同版本的Linux操作系统,因为应用是不被允许修改操作系统文件数据的,这就给开发人员带来极大的便利性,因为终于不用说那句“在我电脑上好好的,怎么部署上去就不行了”。
我们来举个例子说明一下,假设我们将编写的代码基于基础镜像Red Hat企业版进行了打包,然后我们无论是在Fedora还是CoreOS运行这个镜像,应用程序一直会觉得自己在Red Hat的环境中运行,因为当容器运行起来的时候,看到的是Red Hat的操作系统文件夹目录,而和宿主机上的操作系统不太相关(这么说不严谨,其实也相关,就是操作系统内核的版本要匹配)。
这种神奇的功能是如何实现的呢?接下来我们来深入分析一下容器镜像的分层概念。
【容器的分层机制】
虚拟机镜像本质就是一个巨大无比的文件,应用需要运行的操作系统内核和文件系统都被打包在一起,而容器镜像对虚拟机的这种模式进行了优化,打包后的应用被分成了多个层,层可以在多个镜像之间共享,这就意味着我们在启动某个镜像的时候,可能并不需要把整个镜像完整下载下来,大概率是你本地已经有了这个镜像的某些层,节省带宽的同时,可以加速应用的启动速度。
通过对镜像进行分层,镜像分享就变得更加的高效,特别是从使用者的角度,每台安装docker的机器,就对某个编号的层只保存一次,如下图所示:
从上图可以看到,容器A和B共享了最上边的层,因此容器A和容器B就可以读取到这一层上相同的文件和数据,更进一步,容器A和B以及容器C,他们都可以访问下边的三方库文件所在的层,这种共享的机制对容器大规模部署和共享都有极大的促进作用。
不知道你是否意识到这里有个问题,如果按照上图这样来在多个容器间共享,那么容器之间的隔离性如何保障呢?是不是容器B对最上边的那层的某个文件进行了修改,容器A也能看到这个修改?
如果真如上边所说,肯定不行,容器的文件系统通过COW(copy on write)机制来保证隔离性,也就是确保一个容器对共享层文件的修改,不会让另外一个容器看到。具体来说,容器镜像由多个层组成,但是这些层中,有很多都是只读层,以及在只读层之上的读写层。
当应用程序A需要修改只读层的某个文件的时候,整个文件会被拷贝到读写层,然后在读写层对这个相同文件名的文件进行修改和保存,由于每个容器运行起来之后,都有自己专属的读写层,因此通过这种方式,容器A对某个文件的修改,对容器B不具有可见性,从而实现了隔离。
另外,当我们删除只读层的某个文件,我们其实只是在读写层将这个文件进行了标记,不让容器看到这个文件而已,文件本身没有发生任何变化,因此我们在容器中删除文件,其实并没有办法让镜像的尺寸变小。
注意:基于上边的介绍,看起来在容器中修改只读层文件的权限和所属信息只会造成文件被拷贝到读写层而已,其实并不然,如果你在容器的只读层进行大量文件的权限修改,容器镜像的尺寸会增长的非常可观,具体原因我们后续介绍。
好了,关于镜像分层的相关内容就这么多了,我们接下来看看Docker的这种打包操作系统文件系统的机制有什么缺陷。
【理解Docker镜像打包机制的缺陷】
理论上来说,通过Docker的镜像打包机制构建的应用,可以运行在任何Linux操作系统上,但是这里有个坑,主要是因为镜像是没有自己的操作系统内核,只有操作系统文件夹结构和文件,或者说就是徒有操作系统的外表而已。
如果镜像需要某个特殊版本操作系统内核功能的支持才能运行,那么理论上能够在任意操作系统上运行这句话就不严谨了。如果运行的操作系统内核因为版本过低,没有镜像需要的功能模块,那么就无法在这台机器上运行应用程序。如下图所示:
容器B在运行的时候,需要特定操作系统版本提供的功能模块,而这个模块在工作节点1上有,在工作节点2上没有,当我们将容器的实例调度到节点2上的时候,这个应用就无法运行起来,由于缺少操作系统相关内核模块。
其实不光是内核和内核模块会造成这种问题,如果我们的应用程序针对特定的硬件平台,那么硬件平台的架构也会对应用的部署造成约束,比如说我们的应用是基于X86CPU架构来构建,那么我们无法将这个应用部署到ARMS机器上,如果我们非要在ARMS机器上运行这个应用,只能通过在ARMS安装虚拟机来模拟X86环境。
好了,以上就是关于镜像打包机制缺陷的详细介绍。笔者反复强调过,Docker从来都不是Kubernetes平台上的默认容器引擎,Kubernetes从一开始就有更加宏伟的目标:从宏观的角度,以统一的方式来定义不同的对象之间的关系,并为复杂多变的场景预留空间。这就不难理解Kubernetes项目并没有把Docker作为整个架构的核心,而顶多就是底层运行容器的一种方式而已,而Kubernetes的核心就是在这些运行时的上边,如何处理编排,调度,网络,存储,安全,监控等功能。
接下里我们来介绍一下除了Docker,在Kubernetes平台上,还有哪些可选的容器实现方式。虽然说Docker让容器这个“老”技术换发青春,但是由于Docker所依赖的技术和Docker没有什么关系,特别是容器所依赖的隔离技术,其实是操作系统内核提供,Docker只是让这些容器隔离技术用起来更加简单而已。
随着Docker变为主流,Open Container Initiative(OCI)启动了标准化工作,试图创建公开的容器格式和运行时行业标准,Docker公司也是这个标准化组织的一员,但是可以猜测到,基本属于出工不出力的态度,因此OCI这个标准化根本没办法顺利往前推进,虽然这个组织制定了OCI Image Format Specification,来规范容器镜像的格式,以及OCI Runtime Specification,定义了标准的容器创建,配置和运行的接口,但是很不幸的是,这些标准你没有听说过,我在写这篇文章之前也没有听说过,从这个角度,你就知道Docker公司参与的这个标准组织几乎没有啥影响力。
随着Kubernetes的崛起,由于谷歌和红帽公司的高瞻远瞩,特别是谷歌公司多年在内部践行Borg系统的实战经验,从一开始就没有把整座大厦建立在Docker的容器平台上,虽然从Kubernetes刚开始的时候,Docker是整个平台进行容器化战略的主航道,原因并不是基于架构考虑,而是因为那个时候Docker是被使用最广泛的容器平台。
但是随着Kubernetes逐渐坐稳容器化PASS平台的头把交易,Kubernetes随即就标准化了容器运行时,这就是笔者在前边提到的CRI(Common Runtime interface),也叫通用运行时接口。Docker实现了这个接口,另外还有很多其他的比如CRI-O的实现,作为Docker的另外一个选项,可以用来在Kubernetes上部署容器化的应用。
除了这个CRI-O,还有诸如rkt,runC和kata contrainer等的OCI兼容的容器实现,大家如果感兴趣,可以自行学习。
好了,到这里为止,我们做了足够的铺垫,为了让大家顺利将自己的第一个应用部署到容器平台。接下来,我们在下一篇,详细介绍如何把一个Spring Cloud的应用进行打包, 并部署到容器平台Docker中。