47 _ 容器的崛起(下):系统、应用、集群的封装 你好,我是周志明。在理解了从隔离角度出发的容器化技术的发展之后,这节课我们接着从封装的角度来学习容器应用的发展。

封装系统:LXC

当文件系统、访问、资源都可以被隔离后,容器就已经具备它降生所需要的全部前置支撑条件了,并且Linux的开发者们也已经明确地看到了这一点。

因此,为了降低普通用户综合使用namespaces、cgroups这些低级特性的门槛,2008年Linux Kernel 2.6.24内核在刚刚开始提供cgroups的同一时间,就马上发布了名为Linux容器(LinuX Containers,LXC)的系统级虚拟化功能。

当然在这之前,在Linux上并不是没有系统级虚拟化的解决方案,比如传统的OpenVZLinux-VServer都能够实现容器隔离,并且只会有很低的性能损失(按OpenVZ提供的数据,只会有1~3%的损失),但它们都是非官方的技术,使用它们最大的阻碍是系统级虚拟化必须要有内核的支持。为此,它们就只能通过非官方内核补丁的方式来修改标准内核,才能获得那些原本在内核中不存在的能力。

如此一来,LXC就带着令人瞩目的光环登场,它的出现促使“容器”从一个阳春白雪的、只流传于开发人员口中的技术词汇,逐渐向整个软件业的公共概念、共同语言发展,就如同今天的“服务器”“客户端”和“互联网”一样。

不过,相信你现在肯定会好奇:为什么如今一提到容器,大家首先联想到的是Docker而不是LXC?为什么去问10个开发人员,至少有9个听过Docker,但如果问LXC,可能只有1个人听说过?

那么,我们首先可以知道的是,LXC的出现肯定是受到了OpenVZ和Linux-VServer的启发,摸着巨人的肩膀过河当然没有什么不对。但可惜的是,LXC在设定自己的发展目标时,也被前辈们的影响所局限了。

其实,LXC眼中的容器的定义与OpenVZ和Linux-VServer并没有什么差别,它们都是一种封装系统的轻量级虚拟机,而Docker眼中的容器的定义则是一种封装应用的技术手段。这两种封装理念在技术层面并没有什么本质区别,但在应用效果上差异可就相当大了。

我举个具体的例子,如果你要建设一个LAMP(Linux、Apache、MySQL、PHP)应用,按照LXC的思路,你应该先编写或者寻找到LAMP的template(可以暂且不准确地类比为LXC版本的Dockerfile吧),以此构造出一个安装了LAMP的虚拟系统。

如果按部署虚拟机的角度来看,这还算挺方便的,作为那个时代(距今也就十年)的系统管理员,所有软件、补丁、配置都是要自己搞定的,部署一台新虚拟机要花费一两天时间都很正常,而有了LXC的template,一下子帮你把LAMP都安装好了,还想要啥自行车?

但是,作为一名现代的系统管理员,这里的问题就相当大了:如果我想把LAMP改为LNMP(Linux、Nginx、MySQL、PHP)该怎么办?如果我想把LAMP里的MySQL 5调整为MySQL 8该怎么办?这些都得通过找到或者自己编写新的template来解决。

或者好吧,那这台机的软件、版本都配置对了,下一台机我要构建LYME或者MEAN,又该怎么办呢?以封装系统为出发点,如果仍然是按照先装系统再装软件的思路,就永远无法做到一两分钟甚至十几秒钟就构造出一个合乎要求的软件运行环境,这也就决定了LXC不可能形成今天的容器生态。

所以,接下来舞台的聚光灯终于落到了Docker身上。

封装应用:Docker

在2013年宣布开源的Docker,毫无疑问是容器发展历史上里程碑式的发明,然而Docker的成功似乎没有太多技术驱动的成分。至少对于开源早期的Docker而言,确实没有什么能构成壁垒的技术。

事实上,它的容器化能力直接来源于LXC,它的镜像分层组合的文件系统直接来源于AUFS,在Docker开源后不久,就有人仅用了一百多行的Shell脚本,便实现了Docker的核心功能(名为Bocker,提供了docker bulid/pull/images/ps/run/exec/logs/commit/rm/rmi等功能)。

那你可能就要问了:为何历史选择了Docker,而不是LXC或者其他容器技术呢?对于这个问题,我想引用下(转述非直译,有所精简)DotCloud公司(当年创造Docker的公司,已于2016年倒闭)创始人所罗门 · 海克斯(Solomon Hykes)在Stackoverflow上的一段问答

为什么要用Docker而不是LXC?(Why would I use Docker over plain LXC?)-

Docker除了包装来自Linux内核的特性之外,它的价值还在于:-

跨机器的绿色部署:Docker定义了一种将应用及其所有的环境依赖都打包到一起的格式,仿佛它原本就是绿色软件一样。而LXC并没有提供这样的能力,使用LXC部署的新机器很多细节都要依赖人的介入,虚拟机的环境基本上肯定会跟你原本部署程序的机器有所差别。-

以应用为中心的封装:Docker封装应用而非封装机器的理念贯穿了它的设计、API、界面、文档等多个方面。相比之下,LXC将容器视为对系统的封装,这局限了容器的发展。-

自动构建:Docker提供了开发人员从在容器中构建产品的全部支持,开发人员无需关注目标机器的具体配置,就可以使用任意的构建工具链,在容器中自动构建出最终产品。-

多版本支持:Docker支持像Git一样管理容器的连续版本,进行检查版本间差异、提交或者回滚等操作。从历史记录中,你可以查看到该容器是如何一步一步构建成的,并且只增量上传或下载新版本中变更的部分。-

组件重用:Docker允许将任何现有容器作为基础镜像来使用,以此构建出更加专业的镜像。-

共享:Docker拥有公共的镜像仓库,成千上万的Docker用户在上面上传了自己的镜像,同时也使用他人上传的镜像。-

工具生态:Docker开放了一套可自动化和自行扩展的接口,在此之上用户可以实现很多工具来扩展其功能,比如容器编排、管理界面、持续集成,等等。- —— Solomon Hykes,Stackoverflow,2013

这段回答也被收录到了Docker官网的FAQ上,从Docker开源到今天从没有改变过。

其实,促使Docker一问世就惊艳世间的,并不是什么黑科技式的秘密武器,而是它符合历史潮流的创意与设计理念,还有充分开放的生态运营。由此可见,在正确的时候,正确的人手上有一个优秀的点子,确实有机会引爆一个时代。

这里我还想让你看一张图片,它是Docker开源一年后(截至2014年12月)获得的成绩。

我们可以发现,从开源到现在,只过了短短数年时间,Docker就已经成为了软件开发、测试、分发、部署等各个环节都难以或缺的基础支撑,而它自身的架构也发生了相当大的改变:Docker被分解为了几个子系统,包括Docker Client、Docker Daemon、Docker Registry、Docker Container等等,以及Graph、Driver、libcontainer等各司其职的模块。

所以此时,我们再说一百多行脚本就能实现Docker的核心功能,再说Docker没有太高的技术含量,就不太合适了。

2014年,Docker开源了自己用Golang开发的libcontainer,这是一个越过LXC直接操作namespaces和cgroups的核心模块,有了libcontainer以后,Docker就能直接与系统内核打交道,不必依赖LXC来提供容器化隔离能力了。

到了2015年,在Docker的主导和倡议下,多家公司联合制定了“开放容器交互标准”(Open Container Initiative,OCI),这是一个关于容器格式和运行时的规范文件,其中包含了运行时标准(runtime-spec )、容器镜像标准(image-spec)和镜像分发标准(distribution-spec,分发标准还未正式发布)。

  • 运行时标准定义了应该如何运行一个容器、如何管理容器的状态和生命周期、如何使用操作系统的底层特性(namespaces、cgroup、pivot_root等);
  • 容器镜像标准规定了容器镜像的格式、配置、元数据的格式,你可以理解为对镜像的静态描述;
  • 镜像分发标准则规定了镜像推送和拉取的网络交互过程。

由此,为了符合OCI标准,Docker推动自身的架构继续向前演进。

首先,它是将libcontainer独立出来,封装重构成runC项目,并捐献给了Linux基金会管理。runC是OCI Runtime的首个参考实现,它提出了“让标准容器无所不在”(Make Standard Containers Available Everywhere)的口号。

而为了能够兼容所有符合标准的OCI Runtime实现,Docker进一步重构了Docker Daemon子系统,把其中与运行时交互的部分抽象为了containerd项目

这是一个负责管理容器执行、分发、监控、网络、构建、日志等功能的核心模块,其内部会为每个容器运行时创建一个containerd-shim适配进程,默认与runC搭配工作,但也可以切换到其他OCI Runtime实现上(然而实际并没做到,最后containerd仍是紧密绑定于runC)。

后来到了2016年,Docker把containerd捐献给了CNCF管理。

可以说,runC与containerd两个项目的捐赠托管,既带有Docker对开源信念的追求,也带有Docker在众多云计算大厂夹击下自救的无奈,这两个项目也将会成为未来Docker消亡和存续的伏笔(到这节课的末尾你就能理解这句矛盾的话了)。

以上我列举的这些Docker推动的开源与标准化工作,既是对Docker为开源乃至整个软件业做出贡献的赞赏,也是为后面给你介绍容器编排时,讲解当前容器引擎的混乱关系做的前置铺垫。

我们当然很清楚的一个事实就是,Docker目前无疑在容器领域具有统治地位,但其统治的稳固程度不仅没到高枕无忧,说是危机四伏都不为过。

我之所以这么说的原因,是因为现在已经能隐隐看出足以威胁动摇Docker地位的潜在可能性,而引出这个风险的,就是Docker虽然赢得了容器战争的胜利,但Docker Swarm却输掉了容器编排战争。

实际上,从结果回望当初,Docker能赢得容器战争是存在了一些偶然性的,而能确定的是Docker Swarm输掉编排战争是必然的。为什么这么说呢?下面我就来揭晓答案。

封装集群:Kubernetes

如果说以Docker为代表的容器引擎,是把软件的发布流程从分发二进制安装包,转变为了直接分发虚拟化后的整个运行环境,让应用得以实现跨机器的绿色部署;那以Kubernetes为代表的容器编排框架,就是把大型软件系统运行所依赖的集群环境也进行了虚拟化,让集群得以实现跨数据中心的绿色部署,并能够根据实际情况自动扩缩。

我们从上节课的容器崛起之路,讲到现在Docker和Kubernetes这个阶段,已经不再是介绍历史了,从这里开始发生的变化,都是近几年软件业界中的热点事件,也是“容器的崛起”这个小章节我们要讨论的主要话题。不过现在,我暂时不打算介绍Kubernetes的技术细节,在“容器间网络”“容器持久化存储”及“资源调度”这几个章节中,我还会进行更详细的解析。

在今天这节课里,我们就先从宏观层面去理解Kubernetes的诞生与演变的驱动力,这对正确理解未来云原生的发展方向是至关重要的。

从Docker到Kubernetes

众所周知,Kubernetes可谓是出身名门,它的前身是Google内部已经运行多年的集群管理系统Borg,在2014年6月使用Golang完全重写后开源。自它诞生之日起,只要能与云计算稍微扯上关系的业界巨头,都对Kubernetes争相追捧,IBM、RedHat、Microsoft、VMware和华为都是它最早期的代码贡献者。

此时,距离云计算从实验室到工业化应用已经有十个年头,不过大量应用使用云计算的方式,还是停滞在了传统的IDC(Internet Data Center)时代,它们仅仅是用云端的虚拟机代替了传统的物理机而已。

尽管早在2013年,Pivotal(持有着Spring Framework和Cloud Foundry的公司)就提出了“云原生”的概念,但是要实现服务化、具备韧性(Resilience)、弹性(Elasticity)、可观测性(Observability)的软件系统依旧十分困难,在当时基本只能依靠架构师和程序员高超的个人能力,云计算本身还帮不上什么忙。

而在云的时代,不能充分利用云的强大能力,这让云计算厂商无比遗憾,也无比焦虑。

所以可以说,直到Kubernetes横空出世,大家才终于等到了破局的希望,认准了这就是云原生时代的操作系统,是让复杂软件在云计算下获得韧性、弹性、可观测性的最佳路径,也是为厂商们推动云计算时代加速到来的关键引擎之一。

2015年7月,Kubernetes发布了第一个正式版本1.0版,更重要的事件是Google宣布与Linux基金会共同筹建云原生基金会(Cloud Native Computing Foundation,CNCF),并且把Kubernetes托管到CNCF,成为其第一个项目。随后,Kubernetes就以摧枯拉朽之势消灭了容器编排领域的其他竞争对手,哪怕Docker Swarm有着Docker在容器引擎方面的先天优势,DotCloud后来甚至把Swarm直接内置入Docker之中,都不能稍稍阻挡Kubernetes前进的步伐。

但是我们也要清楚,Kubernetes的成功与Docker的成功并不一样

Docker靠的是优秀的理念,它是以一个“好点子”引爆了一个时代。我相信就算没有Docker,也会有Cocker或者Eocker的出现,但由成立仅三年的DotCloud公司(三年后又倒闭)做成了这样的产品,确实有一定的偶然性。

而Kubernetes的成功,不仅有Google深厚的技术功底作支撑、有领先时代的设计理念,更加关键的是Kubernetes的出现,符合所有云计算大厂的切身利益,有着业界巨头不遗余力地广泛支持,所以它的成功便是一种必然。

Kubernetes与Docker两者的关系十分微妙,因此我们把握住两者关系的变化过程,是理解Kubernetes架构演变与CRI、OCI规范的良好线索。

Kubernetes是如何一步步与Docker解耦的?

在Kubernetes开源的早期,它是完全依赖且绑定Docker的,并没有过多地考虑日后有使用其他容器引擎的可能性。直到Kubernetes 1.5之前,Kubernetes管理容器的方式都是通过内部的DockerManager,向Docker Engine以HTTP方式发送指令,通过Docker来操作镜像的增删改查的,如上图最右边线路的箭头所示(图中的kubelet是集群节点中的代理程序,负责与管理集群的Master通信,其他节点的含义在下面介绍时都会有解释)。

现在,我们可以把这个阶段的Kubernetes与容器引擎的调用关系捋直,并结合前面提到的Docker捐献containerd与runC后重构的调用,一起来梳理下这个完整的调用链条: Kubernetes Master → kubelet → DockerManager → Docker Engine → containerd → runC

然后到了2016年,Kubernetes 1.5版本开始引入“容器运行时接口”(Container Runtime Interface,CRI),这是一个定义容器运行时应该如何接入到kubelet的规范标准,从此Kubernetes内部的DockerManager,就被更为通用的KubeGenericRuntimeManager所替代了(实际上在1.6.6之前都仍然可以看到DockerManager),kubelet与KubeGenericRuntimeManager之间通过gRPC协议通信。

不过,由于CRI是在Docker之后才发布的规范,Docker是肯定不支持CRI的,所以Kubernetes又提供了DockerShim服务作为Docker与CRI的适配层,由它与Docker Engine以HTTP形式通信,从而实现了原来DockerManager的全部功能。

此时,Docker对Kubernetes来说就只是一项默认依赖,而非之前的不可或缺了,现在它们的调用链为: Kubernetes Master → kubelet → KubeGenericRuntimeManager → DockerShim → Docker Engine → containerd → runC

接着再到2017年,由Google、RedHat、Intel、SUSE、IBM联合发起的CRI-O(Container Runtime Interface Orchestrator)项目发布了首个正式版本。

一方面,我们从名字上就可以看出来,它肯定是完全遵循CRI规范来实现的;另一方面,它可以支持所有符合OCI运行时标准的容器引擎,默认仍然是与runC搭配工作的,如果要换成Clear ContainersKata Containers等其他OCI运行时,也完全没有问题。

不过到这里,开源版的Kubernetes虽然完全支持用户去自由选择(根据用户宿主机的环境选择)是使用CRI-O、cri-containerd,还是DockerShim来作为CRI实现,但在RedHat自己扩展定制的Kubernetes企业版,即OpenShift 4中,调用链已经没有了Docker Engine的身影: Kubernetes Master → kubelet → KubeGenericRuntimeManager → CRI-O→ runC

当然,因为此时Docker在容器引擎中的市场份额仍然占有绝对优势,对于普通用户来说,如果没有明确的收益,也并没有什么动力要把Docker换成别的引擎。所以CRI-O即使摆出了直接挖掉Docker根基的凶悍姿势,实际上也并没有给Docker带来太多即时可见的影响。不过,我们能够想像此时Docker心中肯定充斥了难以言喻的危机感。

时间继续来到了2018年,由Docker捐献给CNCF的containerd,在CNCF的精心孵化下发布了1.1版,1.1版与1.0版的最大区别是此时它已经完美地支持了CRI标准,这意味着原本用作CRI适配器的cri-containerd从此不再被需要。

此时,我们再观察Kubernetes到容器运行时的调用链,就会发现调用步骤会比通过DockerShim、Docker Engine与containerd交互的步骤要减少两步,这又意味着用户只要愿意抛弃掉Docker情怀的话,在容器编排上就可以至少省略一次HTTP调用,获得性能上的收益。而且根据Kubernetes官方给出的测试数据,这些免费的收益还相当地可观。

如此,Kubernetes从1.10版本宣布开始支持containerd 1.1,在调用链中就已经能够完全抹去Docker Engine的存在了: Kubernetes Master → kubelet → KubeGenericRuntimeManager → containerd → runC

而到了今天,要使用哪一种容器运行时,就取决于你安装Kubernetes时宿主机上的容器运行时环境,但对于云计算厂商来说,比如国内的阿里云ACK腾讯云TKE等直接提供的Kubernetes容器环境,采用的容器运行时普遍都已经是containerd了,毕竟运行性能对它们来说就是核心生产力和竞争力。

小结

学完这节课,我们可以试着来做一个判断:在未来,随着Kubernetes的持续发展壮大,Docker Engine经历从不可或缺、默认依赖、可选择、直到淘汰,会是大概率的事件。从表面上看,这件事情是Google、RedHat等云计算大厂联手所为,可实际淘汰它的还是技术发展的潮流趋势。这就如同Docker诞生时依赖LXC,到最后用libcontainer取代掉LXC一样。

同时,我们也该看到事情的另一面:现在连LXC都还没有挂掉,反倒还发展出了更加专注于跟OpenVZ等系统级虚拟化竞争的LXD,就可以相信Docker本身也是很难彻底消亡的,已经养成习惯的CLI界面,已经形成成熟生态的镜像仓库等,都应该会长期存在,只是在容器编排领域,未来的Docker很可能只会以runC和containerd的形式存续下去,毕竟它们最初都源于Docker的血脉。

一课一思

在2021年1月初,Kubernetes宣布将会在v1.23版本中,把Dockershim从 Kubelet中移除,那么你会如何看待容器化日后的发展呢?

欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e5%91%a8%e5%bf%97%e6%98%8e%e7%9a%84%e6%9e%b6%e6%9e%84%e8%af%be/47%20_%20%e5%ae%b9%e5%99%a8%e7%9a%84%e5%b4%9b%e8%b5%b7%ef%bc%88%e4%b8%8b%ef%bc%89%ef%bc%9a%e7%b3%bb%e7%bb%9f%e3%80%81%e5%ba%94%e7%94%a8%e3%80%81%e9%9b%86%e7%be%a4%e7%9a%84%e5%b0%81%e8%a3%85.md