
【2】容器的诞生背景&发展历程
在本部分,我会讲解容器的诞生背景和发展历程,侧重于阐明当时的状态是怎样的,开发人员有哪些需求和痛点,为什么这个技术会出现等等问题, 并通过对这些问题的探索延申一些对我们自身发展相关的思考
容器出现之前
从本质上来讲,容器(这里特指以Docker为代表的技术)解决的是程序的部署运行方面的问题,在其出现之前,程序的部署往往有两类模式:
直接部署
最简单直接的部署方式,将程序直接部署到机器上运行,主要会使用systemd类的守护类程序把多个业务程序管理起来。这种方式可以很方便地查看每个业务程序的运行情况并进行手动、自动化的启停操作,但此方式只管理了业务程序本身,并没有管理业务程序的依赖。
所以每新加一台机器,就得预先安装所有待部署业务程序的依赖,在多台机器规模化的情况下,往往通过下发部署脚本来实现,然而业务程序的依赖往往是复杂且易变的,多个版本的业务程序的依赖很可能是不相同的,有时经常出现反复升级和降级依赖的情况,这使得单个业务程序依赖管理和安装非常繁琐复杂。
这仅仅是单个业务程序的依赖管理,我们还需要考虑多个业务程序同时在一台机器上混合部署的情况,在这种情况下,多个程序间的依赖还有可能产生冲突:比如程序A需要依赖X的1.0版本,程序B需要依赖X的2.0版本,而如果依赖X的1.0和2.0无法共存,那么这两个程序就无法部署到同一台机器上了,这种情况显然也是很棘手的。

在现在看来,上述这些问题都属于运维人员的工作范畴,所以早期的运维人员面对这类问题会异常头痛,作为开发人员,我们可能只会在做开发完后的测试运行的时候遇到这类的问题(发现多个程序的测试运行因为依赖冲突问题跑不起来),但同样也会让我们非常抓狂。
因此,我们可以看到,依赖本身批量化的安装是简单的,但在单机上存在多个版本、多个程序的case增加了依赖管理的复杂性,这给程序的部署管理效率和稳定性带来了非常大的阻力和挑战。
这样的棘手问题也对应了大量潜在的生产力红利的释放空间,于是就有无数的工程师开始尝试解决这一问题。
虚拟机隔离
解决这个问题有一个很直接的思路,既然程序的依赖管理很复杂,那是因为程序的运行总是天生地和依赖环境相耦合,那么我们尝试把程序依赖和程序本身完全一体化,作为一个独立单元管理不就好了?
最简单的实现方式就是每个版本的程序独占一台机器,这台机器上的依赖环境完全为这个版本的程序服务:要部署一个新版本程序,就必须先把机器重置然后安装此版本的程序所需要的依赖环境,这样程序自身版本前后的依赖冲突和多个程序间的依赖冲突问题不就都解决掉了?
当时的虚拟机技术已经发展较为成熟了,我们发现把程序放到虚拟机里就能满足上述这种模式,这样做的本质就是把依赖环境和程序绑定打包到了一个虚拟机镜像中,然后以虚拟机镜像作为程序管理部署的基本单位。

但虚拟机的缺点也很明显,比起直接在物理机上部署,虚拟机的部署显得异常臃肿和笨重:
- 镜像过于庞大,存储和传输成本高
- 启停速度过慢,程序的部署效率无法做到很高
- 虚拟化后的程序运行性能损耗和overhead大,机器性能被浪费
虽然以虚拟机为单元的程序部署管理解决了依赖的问题,但同时带来了一些新的问题,因此这样来看,在程序的部署管理上,还有更多的优化空间:如果能同时解决依赖管理问题和虚拟机部署带来的劣势,那么这才是一个终极的方案。
基石:容器基础技术的出现
承接上面虚拟机的缺陷,我们不难看出,后面的目标就是在保证依赖问题解决的基础上,将虚拟机部署的三个大的缺陷解决掉即可。基于这样的基本思路,容器的基础技术出现了,或者我们可以地称之为“轻量级虚拟机”,这些技术将成为后续广泛意义上容器(Docker等)的基础能力支撑。
解决镜像过大问题
虚拟机镜像之所以过大,是因为其基本上存储了操作系统运行所需要的所有文件,而对于同一系列的操作系统的不同版本、分支来说,往往存在大量相同的文件,比如glibc、动态链接库、内核、常用的二进制工具程序等等。
那么,如果一台物理机上部署了多个同一系列的虚拟机,那么每个虚拟机镜像中都必然有大部分相同的文件,这就给我们带来了优化的空间:如果每个镜像中相同的文件我们只存一份,是不是就能减小整体占用存储空间的大小?
那如果多个镜像同时修改了一个文件怎么办呢?重新拷贝一份修改过的文件并归属给发起修改的虚拟机即可。
这是一种非常朴素但直接的想法,其实就是Copy-on-Write的思想,我们在整个计算机科学体系内的很多地方都能看到这种优化思路的体现:比如虚拟内存、懒加载等等。你也可以简单的理解为我们只保存一份文件的指针或者链接,而不需要把文件的内容冗余保存多份。
事实上,在大部分情况下,物理机的操作系统和部署于其上的虚拟机操作系统也是存在大量重复文件的,因此这个思路不仅用于虚拟机间,也可以用于虚拟机和宿主机间。

这一技术实际上就是联合文件系统(UnionFS/UFS),在容器出现之前,这个技术就出现了(2004年),最开始并非服务于上述目的,而是希望在多个文件系统之上提供一个统一的文件系统视图。
更详细地,可以参考这篇文章:Docker基础技术:AUFS | 酷 壳 - CoolShell
解决启停速度问题
同样的,虚拟机之所以启停速度慢,也是因为把整个操作系统的启动都包含了进去,理论上,如果我们只关心程序的运行行为的话,为什么每次启停还要同时做一次操作系统的启停呢?
所以,只需要把操作系统的启停过程去掉就行,这就动摇了使用虚拟机技术的基础了,因为虚拟机的核心思想就是操作系统隔离,所以我们必须自己做一个“轻量化虚拟机”,把操作系统相关的启停开销去除掉。
当然,随着现在虚拟化技术的发展,也逐步发展出了“裁剪版”的虚拟机,即通过裁剪不必要的过程来优化加强虚拟机的启停效率
要达成这一目的,我们还是离不开操作系统底层能力的支持,事实上,linux就是在这样的背景下,发展了满足我们上述需求的技术:namespace隔离技术。
实现一个程序的隔离,在linux中主要是以下几方面:
- 文件系统
- 进程视图
- 进程通信
- 网络
- 用户和权限
如果每一个运行的程序进程在以上几方面都有属于自己的专属空间,那么实际上就实现了我们所说的“轻量化虚拟机”的隔离目标要求,这也就是所谓的LXC(Linux Containers)。如果把我们的程序放在LXC中运行,那么他的启停速度会比虚拟机加速很多,这就满足了我们对隔离后启停速度的要求。
这里其实还包含Cgroups机制,相比于namespace机制,Cgroup机制侧重于隔离每个LXC对cpu及mem等硬件资源的使用,保证了多个LXC中的程序不会出现资源争抢的情况,进而保证硬件资源被合理分配
LXC技术在2008年基本上发展成型,也早于容器代表性技术Docker的出现。
解决损耗问题
我们深究虚拟机带来性能损耗的底层原因,从软件角度来说,其实也是因为虚拟机本质是一个完整的操作系统环境所造成的。多个程序运行在同一个操作系统上,和多个程序运行在多个操作系统上相比,很多的操作系统层面的操作和行为就会有重复和冗余的出现,而这些重复和冗余也就意味着对各类硬件资源的使用损耗。
举一个例子,linux操作系统上一般都会有很多内核进程,来协助内核做很多系统管理工作,如果有多个内核,那么每个内核都会有自己的一套内核进程来管理自己范畴内的事情,从整体角度来看,这些重复的内核进程本身所占用的资源就是损耗。
所以,实际上我们还是要依赖“轻量化”的手段,来解决这个问题,上一部分提到的LXC相关的技术就从根本上解决掉了这一问题,因为本质上所有LXC都是共用内核的,损耗自然会得到降低。
这里的损耗都是从软件层面来讲的,随着虚拟化技术的发展,通用硬件层面(cpu、mem等)的损耗基本上被消除了,但特殊的设备(如gpu、各类io卡等)的硬件损耗仍然存在,不过随着整体技术的进步,最终可以期望所有的硬件层的损耗都会被基本消除掉
Docker的出现
随着前面所述的这些容器依赖的基础技术的出现,最开始的程序部署时依赖管理和隔离的痛点和需求,从整体条件上讲已经具备了被解决的可能。但在当时,这些技术还是零星散布在各个领域中,没有一套完全成型的体系化解决方案和产品的出现,进而导致了这类技术无法被大多数人所获知并使用,仅限于一些大公司内的高级技术团队凭借着强大的技术能力组合了其中一部分来使用(如当时Google发布的LMCTFY)。
但为什么最终是Docker变成了这个领域的代表呢?
其实回到我们最开始所提到的那个需求和痛点,如果能提供一套完整体系化的产品或方案来满足和解决它,那么就一定能被乐意采纳使用。当时尝试做到这个目标的项目和产品有很多,Docker是其中解决这个问题解决地最彻底出色的一个,并且提供了最出众的使用体验,以至于它最终成为了容器技术领域的标杆。
简单地讲,Docker就是将LXC技术、镜像和容器生命周期管理较好地结合在了一起(也就是上述填补虚拟机的三个问题所对应的功能):以LXC和UnionFS类技术作为基础,Docker设计和开发了容器镜像和运行的标准,使得程序和其依赖环境绑定作为独立单元的打包(Dockerfile)、版本管理(image)、分发(registry)、部署运行以及管理(runtime)等流程变得无比丝滑流畅,并提供了一套完整易用的工具,满足了当时大部分人的需求,达到了其宣传口号"Build once, Run anywhere"所说的效果。
Docker就是集装箱的意思,这个命名非常符合其功能对应的本质,即软件产物标准化交付,把软件运行相关所有的东西,包括程序本身和程序依赖,打包为一个标准集装箱(镜像)作为软件部署运行的交付单元,从根本上全面变革了软件部署和分发的模式。这也是一种典型的封装思想,Docker把软件部署依赖的复杂性全部封装到了镜像中,以致于外部的使用者不需要关注里面到底有些什么。
后来Docker更是迅速选择开源,通过免费带来的传播效应快速打出了名气,被广大开发者群体广泛使用,以至于后续成为了容器领域的技术标准。
作为现在Docker的用户,我们能够感受到Docker是将需求和痛点抓的最准的,当时大部分的项目都在聚焦于怎么使用LXC及底层相关技术来把隔离做得更彻底,性能做得更优越,而对于真正的痛点:解决程序部署时依赖管理和隔离这个事情却没有足够重视。现在我们每一次使用Docker,都能感受到它确实是把这个需求和痛点非常恰当地满足和解决了,所以它获得了今天在容器技术生态领域的地位。
我们能学到什么
那么,从整个容器的诞生背景和发展历程中,我们能汲取到哪些有助于我们发展的东西呢?在我看来,值得我们思考的有如下几点:
面向需求而不是面向技术
要抓到真正的需求,并把需求漂亮地解决掉,才能够做出真正有价值的产品和项目,这不仅限于业务产品,纯技术产品也是这样的。所谓的技术视野中很重要的一部分,也就是在技术领域中领先大多数人发现和洞察能够满足普适性需求的技术方向。
技术领域的组合创新
我们可以看出,容器技术的很多基础能力,散落在了众多不同的方面,后面成功的Docker项目,也并非是为了做出Docker而把这些底层基础能力都开发出来的。它的诞生更像是在合适的时机做了恰当的组合式创新,把零散的基础能力做了有目的性的整合,这也要求我们要对底层提供的能力足够熟悉才能在有需求的时候去使用。
渐进式、问题驱动
纵览容器技术从无到有的整个过程,我们可以看到一个相对长时间的积累和发展历程,其诞生和成熟并不是一步到位的,而是渐进式的。从最简单的无法满足需求的初始方案(直接部署),到一个大概可以满足的过渡方案(虚拟机部署),再到后续充分满足需求的成熟方案(容器部署),是一个问题驱动性的过程,不断发现问题、尝试优化、提出新方案,最终得以演进成为了成熟的技术。
当然随着更多新的需求出现,又有了更多基于容器之上新技术方案出现,譬如出于安全的诉求,系统内核必须隔离,此时就需要依赖容器的标准重新打造一个新的虚拟机方案出来,这不也是另一个子方向的进一步演进吗?
其实很多其他优秀的项目和产品,也是逐步这么发展来的,其关键就在于不断发现可以优化和解决的问题,并付诸行动渐进式地解决,而非一步就产出了一个完整的成果。
避开定势:换一个思路解决问题
我们看到其实在整个容器技术的发展过程中也存在两个绕不开的弯路:有些人一直秉持着直接部署的模式,编写了类似编程语言依赖管理的方案,通过计算和识别依赖关系来管理软件部署运行的依赖,但最终因为过于复杂和难以掌控,无法普及而失败。
此时虚拟机部署的方案就换了一个思路,不去解决依赖关系,而是直接隔离依赖关系,这种思路上的根本变革就立马用更低的成本解决了问题。而基于虚拟机思路下的人,仍有部分执着地陷入到了虚拟机的思路中,一直尝试优化虚拟机来解决虚拟机部署的缺陷问题,但虚拟机地诞生并非是服务于软件部署,这种自下而上、偏离初始功能目标的优化是相当难做的。
而此时有人就又换了一个思路,从系统底层能力的角度思考直接提供轻量化隔离的能力,于是自上而下的容器基础技术得以出现,最终结合Docker这类出色的项目得以被大家广泛使用。
这说明其实很多时候,寻找新的思路往往可能会比局限在旧的思维模式中更为可行,永远不要惧怕推倒已有的方案和思路,新的、更优秀的方案大多时候都不是已有方案的改进,而是根本性的变革。
