容器的崛起
对于K8s启用docker,作为普通开发者的体感是,k8s不就是docker的集群操作吗?k8s弃用docker就像鱼反对水一样不可思议,那么这两个技术究竟是什么关系,Kubernetes 是如何一步步与 Docker 解耦的,请看下文。
模块导学:从微服务到云原生
什么是不可变基础设施
向应用代码隐藏分布式架构复杂度、让分布式架构得以成为一种能够普遍推广的普适架构风格的必要前提。
云原生的定义
官方定义太抽象了,我就不复制粘贴了,我选择从我们都比较熟悉的,至少是能看得见、摸得着的容器化技术开始讲起。
虚拟化的目标与类型
容器是云计算、微服务等诸多软件业界核心技术的共同基石。容器的首要目标是让软件分发部署的过程,从传统的发布安装包、靠人工部署,转变为直接发布已经部署好的、包含整套运行环境的虚拟化镜像。
操作系统层虚拟化
操作系统层虚拟化也就是容器化,仅仅是虚拟化的一个子集,它只能提供操作系统内核以上的部分 ABI 兼容性与完整的环境兼容性。
引言
作为整个模块的开篇,我们这节课的学习目的是要明确软件运行的“兼容性”指的是什么,以及要能理解我们经常能听到的“虚拟化”概念指的是什么。只有理清了这些概念、统一了语境,在后续的课程学习中,我们关于容器、编排、云原生等的讨论,才不会产生太多的歧义。
Docker与K8s的相爱相杀
接下来的两节课,我会以容器化技术的发展为线索,带你从隔离与封装两个角度,去学习和了解容器技术。
今天,我们就先来学习下 Linux 系统中隔离技术前提准备,以此为下节课理解“以容器封装应用”的思想打好前置基础。
封装系统:LXC
当文件系统、访问、资源都可以被隔离后,容器就已经具备它降生所需要的全部前置支撑条件了,并且 Linux 的开发者们也已经明确地看到了这一点。
2008 年 Linux Kernel 2.6.24 内核在刚刚开始提供 cgroups 的同一时间,就马上发布了名为Linux 容器(LinuX Containers,LXC)的系统级虚拟化功能。
如此一来,LXC 就带着令人瞩目的光环登场,它的出现促使“容器”从一个阳春白雪的、只流传于开发人员口中的技术词汇,逐渐向整个软件业的公共概念、共同语言发展,就如同今天的“服务器”“客户端”和“互联网”一样。
LXC 眼中的容器的定义与 OpenVZ 和 Linux-VServer 并没有什么差别,它们都是一种封装系统的轻量级虚拟机,而 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?(Why would I use Docker over plain LXC?)
- 跨机器的绿色部署
- 以应用为中心的封装
- 自动构建:Docker 提供了开发人员从在容器中构建产品的全部支持,开发人员无需关注目标机器的具体配置,就可以使用任意的构建工具链,在容器中自动构建出最终产品。
- 多版本支持:Docker 支持像 Git 一样管理容器的连续版本,进行检查版本间差异、提交或者回滚等操作。从历史记录中,你可以查看到该容器是如何一步一步构建成的,并且只增量上传或下载新版本中变更的部分。
- 组件重用:Docker 允许将任何现有容器作为基础镜像来使用,以此构建出更加专业的镜像。
- 共享:Docker 拥有公共的镜像仓库,成千上万的 Docker 用户在上面上传了自己的镜像,同时也使用他人上传的镜像。
- 工具生态:Docker 开放了一套可自动化和自行扩展的接口,在此之上用户可以实现很多工具来扩展其功能,比如容器编排、管理界面、持续集成,等等。
其实,促使 Docker 一问世就惊艳世间的,并不是什么黑科技式的秘密武器,而是它符合历史潮流的创意与设计理念,还有充分开放的生态运营。由此可见,在正确的时候,正确的人手上有一个优秀的点子,确实有机会引爆一个时代。
== 和chatgpt套壳一样,巨大的需求,最低的门槛,资源丰富的公共仓库 ==
2014 年,Docker 开源了自己用 Golang 开发的libcontainer,这是一个越过 LXC 直接操作 namespaces 和 cgroups 的核心模块,有了 libcontainer 以后,Docker 就能直接与系统内核打交道,不必依赖 LXC 来提供容器化隔离能力了。(runC的前身,后期会讲到这个)(docker开始构建自己的技术壁垒)
到了 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 消亡和存续的伏笔(到这节课的末尾你就能理解这句矛盾的话了)。
封装集群:
Kubernetes如果说以 Docker 为代表的容器引擎,是把软件的发布流程从分发二进制安装包,转变为了直接分发虚拟化后的整个运行环境,让应用得以实现跨机器的绿色部署;那以 Kubernetes 为代表的容器编排框架,就是把大型软件系统运行所依赖的集群环境也进行了虚拟化,让集群得以实现跨数据中心的绿色部署,并能够根据实际情况自动扩缩。
尽管早在 2013 年,Pivotal(持有着 Spring Framework 和 Cloud Foundry 的公司)就提出了“云原生”的概念,但是要实现服务化、具备韧性(Resilience)、弹性(Elasticity)、可观测性(Observability)的软件系统依旧十分困难,在当时基本只能依靠架构师和程序员高超的个人能力,云计算本身还帮不上什么忙。
而在云的时代,不能充分利用云的强大能力,这让云计算厂商无比遗憾,也无比焦虑。
所以可以说,直到 Kubernetes 横空出世,大家才终于等到了破局的希望,认准了这就是云原生时代的操作系统,是让复杂软件在云计算下获得韧性、弹性、可观测性的最佳路径,也是为厂商们推动云计算时代加速到来的关键引擎之一。
Docker 靠的是优秀的理念,它是以一个“好点子”引爆了一个时代。我相信就算没有 Docker,也会有 Cocker 或者 Eocker 的出现,但由成立仅三年的 DotCloud 公司(三年后又倒闭)做成了这样的产品,确实有一定的偶然性。
而 Kubernetes 的成功,不仅有 Google 深厚的技术功底作支撑、有领先时代的设计理念,更加关键的是 Kubernetes 的出现,符合所有云计算大厂的切身利益,有着业界巨头不遗余力地广泛支持,所以它的成功便是一种必然。
Kubernetes 与 Docker 两者的关系十分微妙,因此我们把握住两者关系的变化过程,是理解 Kubernetes 架构演变与 CRI、OCI 规范的良好线索。
Kubernetes 是如何一步步与 Docker 解耦的?
在一般使用者的体感中,k8s,就是作为管理和编排众多docker容器的工具,后来听说k8s要弃用docker,感觉就像鱼反对水一样不可思议,但是k8s随着版本更迭慢慢解耦docker,可以看做是云原生发展的历史,对理解现在k8s架构有重大意义。于我而言,在这里多费笔墨的原因是,这个过程和企业级架构中用于替换或者升级某一个应用或者组件也如出一辙,在替换过程中的抽象和重构,亦是大型应用的常态。
在 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 Containers、Kata Containers等其他 OCI 运行时,也完全没有问题。(前面提过的运行时交互的部分抽象为了containerd 项目,负责管理容器执行、分发、监控、网络、构建、日志等功能的核心模块,其内部会为每个容器运行时创建一个 containerd-shim 适配进程)
不过到这里,开源版的 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 中移除,那么你会如何看待容器化日后的发展呢?