《深入剖析Kubernetes》学习笔记


对应的代码: https://github.com/kibaamor/deep-study-k8s

课前必读

开篇词 | 打通”容器技术”的任督二脉

Kubernetes技能图谱

01 | 预习篇 · 小鲸鱼大事记(一):初出茅庐

PaaS 项目被大家接纳的一个主要原因,就是它提供了一种名叫”应用托管”的能力。 在当时,虚拟机和云计算已经是比较普遍的技术和服务了,那时主流用户的普遍用法,就是租一批 AWS 或者 OpenStack 的虚拟机,然后像以前管理物理服务器那样,用脚本或者手工的方式在这些机器上部署应用。

当然,这个部署过程难免会碰到云端虚拟机和本地环境不一致的问题,所以当时的云计算服务,比的就是谁能更好地模拟本地服务器环境,能带来更好的”上云”体验。而 PaaS 开源项目的出现,就是当时解决这个问题的一个最佳方案。

举个例子,虚拟机创建好之后,运维人员只需要在这些机器上部署一个 Cloud Foundry 项目,然后开发者只要执行一条命令就能把本地的应用部署到云上,这条命令就是:

cf push "我的应用"

事实上, 像 Cloud Foundry 这样的 PaaS 项目,最核心的组件就是一套应用的打包和分发机制。 Cloud Foundry 为每种主流编程语言都定义了一种打包格式,而”cf push”的作用,基本上等同于用户把应用的可执行文件和启动脚本打进一个压缩包内,上传到云上 Cloud Foundry 的存储中。接着,Cloud Foundry 会通过调度器选择一个可以运行这个应用的虚拟机,然后通知这个机器上的 Agent 把应用压缩包下载下来启动。

这时候关键来了,由于需要在一个虚拟机上启动很多个来自不同用户的应用,Cloud Foundry 会调用操作系统的 Cgroups 和 Namespace 机制为每一个应用单独创建一个称作”沙盒”的隔离环境,然后在”沙盒”中启动这些应用进程。这样,就实现了把多个用户的应用互不干涉地在虚拟机里批量地、自动地运行起来的目的。

这,正是 PaaS 项目最核心的能力。 而这些 Cloud Foundry 用来运行应用的隔离环境,或者说”沙盒”,就是所谓的”容器”。

事实上,Docker 项目确实与 Cloud Foundry 的容器在大部分功能和实现原理上都是一样的,可偏偏就是这剩下的一小部分不一样的功能,成了 Docker 项目接下来”呼风唤雨”的不二法宝。

这个功能,就是 Docker 镜像

我前面已经介绍过,PaaS 之所以能够帮助用户大规模部署应用到集群里,是因为它提供了一套应用打包的功能。可偏偏就是这个打包功能,却成了 PaaS 日后不断遭到用户诟病的一个”软肋”。

出现这个问题的根本原因是,一旦用上了 PaaS,用户就必须为每种语言、每种框架,甚至每个版本的应用维护一个打好的包。这个打包过程,没有任何章法可循,更麻烦的是,明明在本地运行得好好的应用,却需要做很多修改和配置工作才能在 PaaS 里运行起来。而这些修改和配置,并没有什么经验可以借鉴,基本上得靠不断试错,直到你摸清楚了本地应用和远端 PaaS 匹配的”脾气”才能够搞定。

最后结局就是,”cf push”确实是能一键部署了,但是为了实现这个一键部署,用户为每个应用打包的工作可谓一波三折,费尽心机。

Docker 镜像解决的,恰恰就是打包这个根本性的问题。 所谓 Docker 镜像,其实就是一个压缩包。但是这个压缩包里的内容,比 PaaS 的应用可执行文件 + 启停脚本的组合就要丰富多了。实际上,大多数 Docker 镜像是直接由一个完整操作系统的所有文件和目录构成的,所以这个压缩包里的内容跟你本地开发和测试环境用的操作系统是完全一样的。

这就是 Docker 镜像最厉害的地方:只要有这个压缩包在手,你就可以使用某种技术创建一个”沙盒”,在”沙盒”中解压这个压缩包,然后就可以运行你的程序了。

更重要的是,这个压缩包包含了完整的操作系统文件和目录,也就是包含了这个应用运行所需要的所有依赖,所以你可以先用这个压缩包在本地进行开发和测试,完成之后,再把这个压缩包上传到云端运行。

在这个过程中,你完全不需要进行任何配置或者修改,因为这个压缩包赋予了你一种极其宝贵的能力:本地环境和云端环境的高度一致!

这,正是 Docker 镜像的精髓。

那么,有了 Docker 镜像这个利器,PaaS 里最核心的打包系统一下子就没了用武之地,最让用户抓狂的打包过程也随之消失了。相比之下,在当今的互联网里,Docker 镜像需要的操作系统文件和目录,可谓唾手可得。

Docker 项目给 PaaS 世界带来的”降维打击”,其实是提供了一种非常便利的打包机制。这种机制直接打包了应用运行所需要的整个操作系统,从而保证了本地环境和云端环境的高度一致,避免了用户通过”试错”来匹配两种不同运行环境之间差异的痛苦过程。

02 | 预习篇 · 小鲸鱼大事记(二):崭露头角

在过去的很长一段时间里,相较于前端和互联网技术社区,服务器端技术社区一直是一个相对沉闷而小众的圈子。在这里,从事 Linux 内核开发的极客们自带”不合群”的”光环”,后端开发者们啃着多年不变的 TCP/IP 发着牢骚,运维更是天生注定的幕后英雄。

而 Docker 项目,却给后端开发者提供了走向聚光灯的机会。就比如 Cgroups 和 Namespace 这种已经存在多年却很少被人们关心的特性,在 2014 年和 2015 年竟然频繁入选各大技术会议的分享议题,就因为听众们想要知道 Docker 这个东西到底是怎么一回事儿。

而 Docker 项目之所以能取得如此高的关注,一方面正如前面我所说的那样,它解决了应用打包和发布这一困扰运维人员多年的技术难题;而另一方面,就是因为它第一次把一个纯后端的技术概念,通过非常友好的设计和封装,交到了最广大的开发者群体手里。

在这种独特的氛围烘托下,你不需要精通 TCP/IP,也无需深谙 Linux 内核原理,哪怕只是一个前端或者网站的 PHP 工程师,都会对如何把自己的代码打包成一个随处可以运行的 Docker 镜像充满好奇和兴趣。

这种受众群体的变革,正是 Docker 这样一个后端开源项目取得巨大成功的关键。这也是经典 PaaS 项目想做却没有做好的一件事情:PaaS 的最终用户和受益者,一定是为这个 PaaS 编写应用的开发者们,而在 Docker 项目开源之前,PaaS 与开发者之间的关系却从未如此紧密过。

解决了应用打包这个根本性的问题,同开发者与生俱来的的亲密关系,再加上 PaaS 概念已经深入人心的完美契机,成为 Docker 这个技术上看似平淡无奇的项目一举走红的重要原因。

那么,Docker 公司为什么一定要发布 Swarm 项目呢?

通过我对 Docker 项目崛起背后原因的分析,你应该能发现这样一个有意思的事实:虽然通过”容器”这个概念完成了对经典 PaaS 项目的”降维打击”,但是 Docker 项目和 Docker 公司,兜兜转转了一年多,却还是回到了 PaaS 项目原本深耕了多年的那个战场:如何让开发者把应用部署在我的项目上

没错,Docker 项目从发布之初就全面发力,从技术、社区、商业、市场全方位争取到的开发者群体,实际上是为此后吸引整个生态到自家”PaaS”上的一个铺垫。只不过这时,”PaaS”的定义已经全然不是 Cloud Foundry 描述的那个样子,而是变成了一套以 Docker 容器为技术核心,以 Docker 镜像为打包标准的、全新的”容器化”思路。

这,正是 Docker 项目从一开始悉心运作”容器化”理念和经营整个 Docker 生态的主要目的。

而 Swarm 项目,正是接下来承接 Docker 公司所有这些努力的关键所在。

03 | 预习篇 · 小鲸鱼大事记(三):群雄并起

Docker 公司为什么在 Docker 项目已经取得巨大成功之后,却执意要重新走回那条已经让无数先驱们尘沙折戟的 PaaS 之路呢?

实际上,Docker 项目一日千里的发展势头,一直伴随着公司管理层和股东们的阵阵担忧。他们心里明白,虽然 Docker 项目备受追捧,但用户们最终要部署的,还是他们的网站、服务、数据库,甚至是云计算业务。

这就意味着,只有那些能够为用户提供平台层能力的工具,才会真正成为开发者们关心和愿意付费的产品。而 Docker 项目这样一个只能用来创建和启停容器的小工具,最终只能充当这些平台项目的”幕后英雄”。

而谈到 Docker 项目的定位问题,就不得不说说 Docker 公司的老朋友和老对手 CoreOS 了。

CoreOS 是一个基础设施领域创业公司。 它的核心产品是一个定制化的操作系统,用户可以按照分布式集群的方式,管理所有安装了这个操作系统的节点。从而,用户在集群里部署和管理应用就像使用单机一样方便了。

Docker 项目发布后,CoreOS 公司很快就认识到可以把”容器”的概念无缝集成到自己的这套方案中,从而为用户提供更高层次的 PaaS 能力。所以,CoreOS 很早就成了 Docker 项目的贡献者,并在短时间内成为了 Docker 项目中第二重要的力量。

然而,这段短暂的蜜月期到 2014 年底就草草结束了。CoreOS 公司以强烈的措辞宣布与 Docker 公司停止合作,并直接推出了自己研制的 Rocket(后来叫 rkt)容器。

这次决裂的根本原因,正是源于 Docker 公司对 Docker 项目定位的不满足。Docker 公司解决这种不满足的方法就是,让 Docker 项目提供更多的平台层能力,即向 PaaS 项目进化。而这,显然与 CoreOS 公司的核心产品和战略发生了严重冲突。

也就是说,Docker 公司在 2014 年就已经定好了平台化的发展方向,并且绝对不会跟 CoreOS 在平台层面开展任何合作。这样看来,Docker 公司在 2014 年 12 月的 DockerCon 上发布 Swarm 的举动,也就一点都不突然了。

相较于 CoreOS 是依托于一系列开源项目(比如 Container Linux 操作系统、Fleet 作业调度工具、systemd 进程管理和 rkt 容器),一层层搭建起来的平台产品,Swarm 项目则是以一个完整的整体来对外提供集群管理功能。而 Swarm 的最大亮点,则是它完全使用 Docker 项目原本的容器管理 API 来完成集群管理,比如:

单机 Docker 项目:

docker run "我的容器"

多机 Docker 项目:

docker run -H "我的 Swarm 集群 API 地址" "我的容器"

所以在部署了 Swarm 的多机环境下,用户只需要使用原先的 Docker 指令创建一个容器,这个请求就会被 Swarm 拦截下来处理,然后通过具体的调度算法找到一个合适的 Docker Daemon 运行起来。

这个操作方式简洁明了,对于已经了解过 Docker 命令行的开发者们也很容易掌握。所以,这样一个”原生”的 Docker 容器集群管理项目一经发布,就受到了已有 Docker 用户群的热捧。而相比之下,CoreOS 的解决方案就显得非常另类,更不用说用户还要去接受完全让人摸不着头脑、新造的容器项目 rkt 了。

当然,Swarm 项目只是 Docker 公司重新定义”PaaS”的关键一环而已。在 2014 年到 2015 年这段时间里,Docker 项目的迅速走红催生出了一个非常繁荣的”Docker 生态”。在这个生态里,围绕着 Docker 在各个层次进行集成和创新的项目层出不穷。

而此时已经大红大紫到”不差钱”的 Docker 公司,开始及时地借助这波浪潮通过并购来完善自己的平台层能力 。其中一个最成功的案例,莫过于对 Fig 项目的收购。

要知道,Fig 项目基本上只是靠两个人全职开发和维护的,可它却是当时 GitHub 上热度堪比 Docker 项目的明星。

Fig 项目之所以受欢迎,在于它在开发者面前第一次提出了”容器编排”(Container Orchestration)的概念。

其实,”编排”(Orchestration)在云计算行业里不算是新词汇,它主要是指用户如何通过某些工具或者配置来完成一组虚拟机以及关联资源的定义、配置、创建、删除等工作,然后由云计算平台按照这些指定的逻辑来完成的过程。

而容器时代,”编排”显然就是对 Docker 容器的一系列定义、配置和创建动作的管理。而 Fig 的工作实际上非常简单:假如现在用户需要部署的是应用容器 A、数据库容器 B、负载均衡容器 C,那么 Fig 就允许用户把 A、B、C 三个容器定义在一个配置文件中,并且可以指定它们之间的关联关系,比如容器 A 需要访问数据库容器 B。

接下来,你只需要执行一条非常简单的指令:

fig up

Fig 就会把这些容器的定义和配置交给 Docker API 按照访问逻辑依次创建,你的一系列容器就都启动了;而容器 A 与 B 之间的关联关系,也会交给 Docker 的 Link 功能通过写入 hosts 文件的方式进行配置。更重要的是,你还可以在 Fig 的配置文件里定义各种容器的副本个数等编排参数,再加上 Swarm 的集群管理能力,一个活脱脱的 PaaS 呼之欲出。

Fig 项目被收购后改名为 Compose,它成了 Docker 公司到目前为止第二大受欢迎的项目,一直到今天也依然被很多人使用。

当时的这个容器生态里,还有很多令人眼前一亮的开源项目或公司。比如,专门负责处理容器网络的 SocketPlane 项目(后来被 Docker 公司收购),专门负责处理容器存储的 Flocker 项目(后来被 EMC 公司收购),专门给 Docker 集群做图形化管理界面和对外提供云服务的 Tutum 项目(后来被 Docker 公司收购)等等。

一时之间,整个后端和云计算领域的聪明才俊都汇集在了这个”小鲸鱼”的周围,为 Docker 生态的蓬勃发展献上了自己的智慧。

而除了这个异常繁荣的、围绕着 Docker 项目和公司的生态之外,还有一个势力在当时也是风头无两,这就是老牌集群管理项目 Mesos 和它背后的创业公司 Mesosphere。

Mesos 作为 Berkeley 主导的大数据套件之一,是大数据火热时最受欢迎的资源管理项目,也是跟 Yarn 项目杀得难舍难分的实力派选手。

不过,大数据所关注的计算密集型离线业务,其实并不像常规的 Web 服务那样适合用容器进行托管和扩容,也没有对应用打包的强烈需求,所以 Hadoop、Spark 等项目到现在也没在容器技术上投下更大的赌注;但是对于 Mesos 来说,天生的两层调度机制让它非常容易从大数据领域抽身,转而去支持受众更加广泛的 PaaS 业务。

在这种思路的指导下,Mesosphere 公司发布了一个名为 Marathon 的项目,而这个项目很快就成为了 Docker Swarm 的一个有力竞争对手。

虽然不能提供像 Swarm 那样的原生 Docker API,Mesos 社区却拥有一个独特的竞争力:超大规模集群的管理经验。

早在几年前,Mesos 就已经通过了万台节点的验证,2014 年之后又被广泛使用在 eBay 等大型互联网公司的生产环境中。而这次通过 Marathon 实现了诸如应用托管和负载均衡的 PaaS 功能之后,Mesos+Marathon 的组合实际上进化成了一个高度成熟的 PaaS 项目,同时还能很好地支持大数据业务。

所以,在这波容器化浪潮中,Mesosphere 公司不失时机地提出了一个名叫”DC/OS”(数据中心操作系统)的口号和产品,旨在使用户能够像管理一台机器那样管理一个万级别的物理机集群,并且使用 Docker 容器在这个集群里自由地部署应用。而这,对很多大型企业来说具有着非同寻常的吸引力。

这时,如果你再去审视当时的容器技术生态,就不难发现 CoreOS 公司竟然显得有些尴尬了。它的 rkt 容器完全打不开局面,Fleet 集群管理项目更是少有人问津,CoreOS 完全被 Docker 公司压制了。

而处境同样不容乐观的似乎还有 RedHat,作为 Docker 项目早期的重要贡献者,RedHat 也是因为对 Docker 公司平台化战略不满而愤愤退出。但此时,它竟只剩下 OpenShift 这个跟 Cloud Foundry 同时代的经典 PaaS 一张牌可以打,跟 Docker Swarm 和转型后的 Mesos 完全不在同一个”竞技水平”之上。

那么,事实果真如此吗?

2014 年注定是一个神奇的年份。就在这一年的 6 月,基础设施领域的翘楚 Google 公司突然发力,正式宣告了一个名叫 Kubernetes 项目的诞生。而这个项目,不仅挽救了当时的 CoreOS 和 RedHat,还如同当年 Docker 项目的横空出世一样,再一次改变了整个容器市场的格局。

04 | 预习篇 · 小鲸鱼大事记(四):尘埃落定

在上一次的分享中我提到,伴随着 Docker 公司一手打造出来的容器技术生态在云计算市场中站稳了脚跟,围绕着 Docker 项目进行的各个层次的集成与创新产品,也如雨后春笋般出现在这个新兴市场当中。而 Docker 公司,不失时机地发布了 Docker Compose、Swarm 和 Machine”三件套”,在重新定义 PaaS 的方向上走出了最关键的一步。

这段时间,也正是 Docker 生态创业公司们的春天,大量围绕着 Docker 项目的网络、存储、监控、CI/CD,甚至 UI 项目纷纷出台,也涌现出了很多 Rancher、Tutum 这样在开源与商业上均取得了巨大成功的创业公司。

在 2014~2015 年间,整个容器社区可谓热闹非凡。

这令人兴奋的繁荣背后,却浮现出了更多的担忧。这其中最主要的负面情绪,是对 Docker 公司商业化战略的种种顾虑。

事实上,很多从业者也都看得明白,Docker 项目此时已经成为 Docker 公司一个商业产品。而开源,只是 Docker 公司吸引开发者群体的一个重要手段。不过这么多年来,开源社区的商业化其实都是类似的思路,无非是高不高调、心不心急的问题罢了。

而真正令大多数人不满意的是,Docker 公司在 Docker 开源项目的发展上,始终保持着绝对的权威和发言权,并在多个场合用实际行动挑战到了其他玩家(比如,CoreOS、RedHat,甚至谷歌和微软)的切身利益。

那么,这个时候,大家的不满也就不再是在 GitHub 上发发牢骚这么简单了。

相信很多容器领域的老玩家们都听说过,Docker 项目刚刚兴起时,Google 也开源了一个在内部使用多年、经历过生产环境验证的 Linux 容器:lmctfy(Let Me Container That For You)。

然而,面对 Docker 项目的强势崛起,这个对用户没那么友好的 Google 容器项目根本没有招架之力。所以,知难而退的 Google 公司,向 Docker 公司表示了合作的愿望:关停这个项目,和 Docker 公司共同推进一个中立的容器运行时(container runtime)库作为 Docker 项目的核心依赖。

不过,Docker 公司并没有认同这个明显会削弱自己地位的提议,还在不久后,自己发布了一个容器运行时库 Libcontainer。这次匆忙的、由一家主导的、并带有战略性考量的重构,成了 Libcontainer 被社区长期诟病代码可读性差、可维护性不强的一个重要原因。

至此,Docker 公司在容器运行时层面上的强硬态度,以及 Docker 项目在高速迭代中表现出来的不稳定和频繁变更的问题,开始让社区叫苦不迭。

这种情绪在 2015 年达到了一个小高潮,容器领域的其他几位玩家开始商议”切割”Docker 项目的话语权。而”切割”的手段也非常经典,那就是成立一个中立的基金会。

于是,2015 年 6 月 22 日,由 Docker 公司牵头,CoreOS、Google、RedHat 等公司共同宣布,Docker 公司将 Libcontainer 捐出,并改名为 RunC 项目,交由一个完全中立的基金会管理,然后以 RunC 为依据,大家共同制定一套容器和镜像的标准和规范。

这套标准和规范,就是 OCI( Open Container Initiative )。 OCI 的提出,意在将容器运行时和镜像的实现从 Docker 项目中完全剥离出来。 这样做,一方面可以改善 Docker 公司在容器技术上一家独大的现状,另一方面也为其他玩家不依赖于 Docker 项目构建各自的平台层能力提供了可能。

不过,不难看出,OCI 的成立更多的是这些容器玩家出于自身利益进行干涉的一个妥协结果。所以,尽管 Docker 是 OCI 的发起者和创始成员,它却很少在 OCI 的技术推进和标准制定等事务上扮演关键角色,也没有动力去积极地推进这些所谓的标准。

这,也正是迄今为止 OCI 组织效率持续低下的根本原因。

眼看着 OCI 并没能改变 Docker 公司在容器领域一家独大的现状,Google 和 RedHat 等公司于是把与第二把武器摆上了台面。

Docker 之所以不担心 OCI 的威胁,原因就在于它的 Docker 项目是容器生态的事实标准,而它所维护的 Docker 社区也足够庞大。可是,一旦这场斗争被转移到容器之上的平台层,或者说 PaaS 层,Docker 公司的竞争优势便立刻捉襟见肘了。

在这个领域里,像 Google 和 RedHat 这样的成熟公司,都拥有着深厚的技术积累;而像 CoreOS 这样的创业公司,也拥有像 Etcd 这样被广泛使用的开源基础设施项目。

可是 Docker 公司呢?它却只有一个 Swarm。

所以这次,Google、RedHat 等开源基础设施领域玩家们,共同牵头发起了一个名为 CNCF(Cloud Native Computing Foundation)的基金会。这个基金会的目的其实很容易理解:它希望,以 Kubernetes 项目为基础,建立一个由开源基础设施领域厂商主导的、按照独立基金会方式运营的平台级社区,来对抗以 Docker 公司为核心的容器商业生态。

而为了打造出这样一个围绕 Kubernetes 项目的”护城河”,CNCF 社区就需要至少确保两件事情:

  1. Kubernetes 项目必须能够在容器编排领域取得足够大的竞争优势;
  2. CNCF 社区必须以 Kubernetes 项目为核心,覆盖足够多的场景。

我们先来看看 CNCF 社区如何解决 Kubernetes 项目在编排领域的竞争力的问题。

在容器编排领域,Kubernetes 项目需要面对来自 Docker 公司和 Mesos 社区两个方向的压力。不难看出,Swarm 和 Mesos 实际上分别从两个不同的方向讲出了自己最擅长的故事:Swarm 擅长的是跟 Docker 生态的无缝集成,而 Mesos 擅长的则是大规模集群的调度与管理。

这两个方向,也是大多数人做容器集群管理项目时最容易想到的两个出发点。也正因为如此,Kubernetes 项目如果继续在这两个方向上做文章恐怕就不太明智了。

所以这一次,Kubernetes 选择的应对方式是:Borg。

如果你看过 Kubernetes 项目早期的 GitHub Issue 和 Feature 的话,就会发现它们大多来自于 Borg 和 Omega 系统的内部特性,这些特性落到 Kubernetes 项目上,就是 Pod、Sidecar 等功能和设计模式。

这就解释了,为什么 Kubernetes 发布后,很多人”抱怨”其设计思想过于”超前”的原因:Kubernetes 项目的基础特性,并不是几个工程师突然”拍脑袋”想出来的东西,而是 Google 公司在容器化基础设施领域多年来实践经验的沉淀与升华。这,正是 Kubernetes 项目能够从一开始就避免同 Swarm 和 Mesos 社区同质化的重要手段。

于是,CNCF 接下来的任务就是,如何把这些先进的思想通过技术手段在开源社区落地,并培育出一个认同这些理念的生态?这时,RedHat 就发挥了重要作用。

当时,Kubernetes 团队规模很小,能够投入的工程能力也十分紧张,而这恰恰是 RedHat 的长处。更难得的是,RedHat 是世界上为数不多的、能真正理解开源社区运作和项目研发真谛的合作伙伴。

所以,RedHat 与 Google 联盟的成立,不仅保证了 RedHat 在 Kubernetes 项目上的影响力,也正式开启了容器编排领域”三国鼎立”的局面。

这时,我们再重新审视容器生态的格局,就不难发现 Kubernetes 项目、Docker 公司和 Mesos 社区这三大玩家的关系已经发生了微妙的变化。

其中,Mesos 社区与容器技术的关系,更像是”借势”,而不是这个领域真正的参与者和领导者。这个事实,加上它所属的 Apache 社区固有的封闭性,导致了 Mesos 社区虽然技术最为成熟,却在容器编排领域鲜有创新。

这也是为何,Google 公司很快就把注意力转向了动作更加激进的 Docker 公司。

有意思的是,Docker 公司对 Mesos 社区也是类似的看法。所以从一开始,Docker 公司就把应对 Kubernetes 项目的竞争摆在了首要位置:一方面,不断强调”Docker Native”的”重要性”,另一方面,与 Kubernetes 项目在多个场合进行了直接的碰撞。

不过,这次竞争的发展态势,很快就超过了 Docker 公司的预期。

Kubernetes 项目并没有跟 Swarm 项目展开同质化的竞争,所以”Docker Native”的说辞并没有太大的杀伤力。相反地,Kubernetes 项目让人耳目一新的设计理念和号召力,很快就构建出了一个与众不同的容器编排与管理的生态。

就这样,Kubernetes 项目在 GitHub 上的各项指标开始一骑绝尘,将 Swarm 项目远远地甩在了身后。

有了这个基础,CNCF 社区就可以放心地解决第二个问题了。

在已经囊括了容器监控事实标准的 Prometheus 项目之后,CNCF 社区迅速在成员项目中添加了 Fluentd、OpenTracing、CNI 等一系列容器生态的知名工具和项目。

而在看到了 CNCF 社区对用户表现出来的巨大吸引力之后,大量的公司和创业团队也开始专门针对 CNCF 社区而非 Docker 公司制定推广策略。

面对这样的竞争态势,Docker 公司决定更进一步。在 2016 年,Docker 公司宣布了一个震惊所有人的计划:放弃现有的 Swarm 项目,将容器编排和集群管理功能全部内置到 Docker 项目当中。

显然,Docker 公司意识到了 Swarm 项目目前唯一的竞争优势,就是跟 Docker 项目的无缝集成。那么,如何让这种优势最大化呢?那就是把 Swarm 内置到 Docker 项目当中。

实际上,从工程角度来看,这种做法的风险很大。内置容器编排、集群管理和负载均衡能力,固然可以使得 Docker 项目的边界直接扩大到一个完整的 PaaS 项目的范畴,但这种变更带来的技术复杂度和维护难度,长远来看对 Docker 项目是不利的。

不过,在当时的大环境下,Docker 公司的选择恐怕也带有一丝孤注一掷的意味。

Kubernetes 的应对策略则是反其道而行之,开始在整个社区推进”民主化”架构 ,即:从 API 到容器运行时的每一层,Kubernetes 项目都为开发者暴露出了可以扩展的插件机制,鼓励用户通过代码的方式介入到 Kubernetes 项目的每一个阶段。

Kubernetes 项目的这个变革的效果立竿见影,很快在整个容器社区中催生出了大量的、基于 Kubernetes API 和扩展接口的二次创新工作,比如:

  1. 目前热度极高的微服务治理项目 Istio;
  2. 被广泛采用的有状态应用部署框架 Operator;
  3. 还有像 Rook 这样的开源创业项目,它通过 Kubernetes 的可扩展接口,把 Ceph 这样的重量级产品封装成了简单易用的容器存储插件。

就这样,在这种鼓励二次创新的整体氛围当中,Kubernetes 社区在 2016 年之后得到了空前的发展。更重要的是,不同于之前局限于”打包、发布”这样的 PaaS 化路线,这一次容器社区的繁荣,是一次完全以 Kubernetes 项目为核心的”百家争鸣”。

面对 Kubernetes 社区的崛起和壮大,Docker 公司也不得不面对自己豪赌失败的现实。但在早前拒绝了微软的天价收购之后,Docker 公司实际上已经没有什么回旋余地,只能选择逐步放弃开源社区而专注于自己的商业化转型。

所以,从 2017 年开始,Docker 公司先是将 Docker 项目的容器运行时部分 Containerd 捐赠给 CNCF 社区,标志着 Docker 项目已经全面升级成为一个 PaaS 平台;紧接着,Docker 公司宣布将 Docker 项目改名为 Moby,然后交给社区自行维护,而 Docker 公司的商业产品将占有 Docker 这个注册商标。

Docker 公司这些举措背后的含义非常明确:它将全面放弃在开源社区同 Kubernetes 生态的竞争,转而专注于自己的商业业务,并且通过将 Docker 项目改名为 Moby 的举动,将原本属于 Docker 社区的用户转化成了自己的客户。

2017 年 10 月,Docker 公司出人意料地宣布,将在自己的主打产品 Docker 企业版中内置 Kubernetes 项目,这标志着持续了近两年之久的”编排之争”至此落下帷幕。

2018 年 1 月 30 日,RedHat 宣布斥资 2.5 亿美元收购 CoreOS。

2018 年 3 月 28 日,这一切纷争的始作俑者,Docker 公司的 CTO Solomon Hykes 宣布辞职,曾经纷纷扰扰的容器技术圈子,到此尘埃落定。

容器技术概念入门篇

05 | 白话容器基础(一):从进程说开去

在前面的 4 篇预习文章中,我梳理了”容器”这项技术的来龙去脉,通过这些内容,我希望你能理解如下几个事实:

  • 容器技术的兴起源于 PaaS 技术的普及;
  • Docker 公司发布的 Docker 项目具有里程碑式的意义;
  • Docker 项目通过”容器镜像”,解决了应用打包这个根本性难题。

紧接着,我详细介绍了容器技术圈在过去五年里的”风云变幻”,而通过这部分内容,我希望你能理解这样一个道理:

  • 容器本身没有价值,有价值的是”容器编排”。

也正因为如此,容器技术生态才爆发了一场关于”容器编排”的”战争”。而这次战争,最终以 Kubernetes 项目和 CNCF 社区的胜利而告终。所以,这个专栏后面的内容,我会以 Docker 和 Kubernetes 项目为核心,为你详细介绍容器技术的各项实践与其中的原理。

不过在此之前,你还需要搞清楚一个更为基础的问题:

容器,到底是怎么一回事儿?

容器其实是一种沙盒技术。顾名思义,沙盒就是能够像一个集装箱一样,把你的应用”装”起来的技术。这样,应用与应用之间,就因为有了边界而不至于相互干扰;而被装进集装箱的应用,也可以被方便地搬来搬去,这不就是 PaaS 最理想的状态嘛。

所以,我就先来跟你说说这个”边界”的实现手段。

一旦”程序”被执行起来,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。 像这样一个程序运起来后的计算机执行环境的总和,就是我们今天的主角:进程。

所以,对于进程来说,它的静态表现就是程序,平常都安安静静地待在磁盘上;而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现。

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

对于 Docker 等大多数 Linux 容器来说,Cgroups 技术 是用来制造约束的主要手段,而 Namespace 技术 则是用来修改进程视图的主要方法。

让我们首先创建一个容器来试试。

$ docker run -it busybox /bin/sh

/ # ps
PID  USER   TIME COMMAND
  1 root   0:00 /bin/sh
  10 root   0:00 ps

可以看到,我们在 Docker 里最开始执行的 /bin/sh,就是这个容器内部的第 1 号进程(PID=1),而这个容器里一共只有两个进程在运行。这就意味着,前面执行的 /bin/sh,以及我们刚刚执行的 ps,已经被 Docker 隔离在了一个跟宿主机完全不同的世界当中。

本来,每当我们在宿主机上运行了一个 /bin/sh 程序,操作系统都会给它分配一个进程编号,比如 PID=100。这个编号是进程的唯一标识,就像员工的工牌一样。所以 PID=100,可以粗略地理解为这个 /bin/sh 是我们公司里的第 100 号员工,而第 1 号员工就自然是比尔 · 盖茨这样统领全局的人物。

而现在,我们要通过 Docker 把这个 /bin/sh 程序运行在一个容器当中。这时候,Docker 就会在这个第 100 号员工入职时给他施一个”障眼法”,让他永远看不到前面的其他 99 个员工,更看不到比尔 · 盖茨。这样,他就会错误地以为自己就是公司里的第 1 号员工。

这种机制,其实就是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,比如 PID=1。可实际上,他们在宿主机的操作系统里,还是原来的第 100 号进程。

这种技术,就是 Linux 里面的 Namespace 机制 。而 Namespace 的使用方式也非常有意思:它其实只是 Linux 创建新进程的一个可选参数。我们知道,在 Linux 系统中创建线程的系统调用是 clone(),比如:

int pid = clone(main_function, stack_size, SIGCHLD, NULL);

这个系统调用就会为我们创建一个新的进程,并且返回它的进程号 pid。

而当我们用 clone() 系统调用创建一个新进程时,就可以在参数中指定 CLONE_NEWPID 参数,比如:

int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

这时,新创建的这个进程将会”看到”一个全新的进程空间,在这个进程空间里,它的 PID 是 1。之所以说”看到”,是因为这只是一个”障眼法”,在宿主机真实的进程空间里,这个进程的 PID 还是真实的数值,比如 100。

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

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

这,就是 Linux 容器最基本的实现原理了。

所以,Docker 容器这个听起来玄而又玄的概念,实际上是在创建容器进程时,指定了这个进程所需要启用的一组 Namespace 参数。这样,容器就只能”看”到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。

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

5.1 总结

谈到为”进程划分一个独立空间”的思想,相信你一定会联想到虚拟机。而且,你应该还看过一张虚拟机和容器的对比图。

虚拟机和容器的对比图

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

这样,用户的应用进程就可以运行在这个虚拟的机器中,它能看到的自然也只有 Guest OS 的文件和目录,以及这个机器里的虚拟设备。这就是为什么虚拟机也能起到将不同的应用进程相互隔离的作用。

而这幅图的右边,则用一个名为 Docker Engine 的软件替换了 Hypervisor。这也是为什么,很多人会把 Docker 项目称为”轻量级”虚拟化技术的原因,实际上就是把虚拟机的概念套在了容器上。

可是这样的说法,却并不严谨。

在理解了 Namespace 的工作方式之后,你就会明白,跟真实存在的虚拟机不同,在使用 Docker 的时候,并没有一个真正的”Docker 容器”运行在宿主机里面。Docker 项目帮助用户启动的,还是原来的应用进程,只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数。

这时,这些进程就会觉得自己是各自 PID Namespace 里的第 1 号进程,只能看到各自 Mount Namespace 里挂载的目录和文件,只能访问到各自 Network Namespace 里的网络设备,就仿佛运行在一个个”容器”里面,与世隔绝。

不过,相信你此刻已经会心一笑:这些不过都是”障眼法”罢了。

06 | 白话容器基础(二):隔离与限制

Namespace 技术实际上修改了应用进程看待整个计算机”视图”,即它的”视线”被操作系统做了限制,只能”看到”某些指定的内容。 但对于宿主机来说,这些被”隔离”了的进程跟其他进程并没有太大区别。

在之前虚拟机与容器技术的对比图里,不应该把 Docker Engine 或者任何容器管理工具放在跟 Hypervisor 相同的位置,因为它们并不像 Hypervisor 那样对应用进程的隔离环境负责,也不会创建任何实体的”容器”,真正对隔离环境负责的是宿主机操作系统本身:

正确的虚拟机和容器的对比图

所以,在这个对比图里,我们应该把 Docker 画在跟应用同级别并且靠边的位置。这意味着,用户运行在容器里的应用进程,跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置过的 Namespace 参数。而 Docker 项目在这里扮演的角色,更多的是旁路式的辅助和管理工作。

我在后续分享 CRI 和容器运行时的时候还会专门介绍到,其实像 Docker 这样的角色甚至可以去掉。

这样的架构也解释了为什么 Docker 项目比虚拟机更受欢迎的原因。

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

根据实验,一个运行着 CentOS 的 KVM 虚拟机启动后,在不做优化的情况下,虚拟机自己就需要占用 100~200 MB 内存。此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源、网络和磁盘 I/O 的损耗非常大。

而相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用 Namespace 作为隔离手段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。

所以说, “敏捷”和”高性能”是容器相较于虚拟机最大的优势,也是它能够在 PaaS 这种更细粒度的资源管理平台上大行其道的重要原因。

不过,有利就有弊,基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是: 隔离得不彻底

  1. 首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。

    尽管你可以在容器里通过 Mount Namespace 单独挂载其他不同版本的操作系统文件,比如 CentOS 或者 Ubuntu,但这并不能改变共享宿主机内核的事实。这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。

    而相比之下,拥有硬件虚拟化技术和独立 Guest OS 的虚拟机就要方便得多了。最极端的例子是,Microsoft 的云计算平台 Azure,实际上就是运行在 Windows 服务器集群上的,但这并不妨碍你在它上面创建各种 Linux 虚拟机出来。

  2. 其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。

    这就意味着,如果你的容器中的程序使用 settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改,这显然不符合用户的预期。相比于在虚拟机里面可以随便折腾的自由度,在容器里部署应用的时候,”什么能做,什么不能做”,就是用户必须考虑的一个问题。

此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大的,应用”越狱”的难度自然也比虚拟机低得多。

更为棘手的是,尽管在实践中我们确实可以使用 Seccomp 等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这种方法因为多了一层对系统调用的过滤,一定会拖累容器的性能。何况,默认情况下,谁也不知道到底该开启哪些系统调用,禁止哪些系统调用。

所以,在生产环境中,没有人敢把运行在物理机上的 Linux 容器直接暴露到公网上。当然,我后续会讲到的基于虚拟化或者独立内核技术的容器实现,则可以比较好地在隔离与性能之间做出平衡。

在介绍完容器的”隔离”技术之后,我们再来研究一下容器的”限制”问题。

我还是以 PID Namespace 为例,来给你解释这个问题。

虽然容器内的第 1 号进程在”障眼法”的干扰下只能看到容器里的情况,但是宿主机上,它作为第 100 号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第 100 号进程表面上被隔离了起来,但是它所能够使用到的资源(比如 CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个 100 号进程自己也可能把所有资源吃光。这些情况,显然都不是一个”沙盒”应该表现出来的合理行为。

Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。

有意思的是,Google 的工程师在 2006 年发起这项特性的时候,曾将它命名为”进程容器”(process container)。实际上,在 Google 内部,”容器”这个术语长期以来都被用于形容被 Cgroups 限制过的进程组。后来 Google 的工程师们说,他们的 KVM 虚拟机也运行在 Borg 所管理的”容器”里,其实也是运行在 Cgroups”容器”当中。这和我们今天说的 Docker 容器差别很大。

Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。

此外,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。在今天的分享中,我只和你重点探讨它与容器关系最紧密的”限制”能力,并通过一组实践来带你认识一下 Cgroups。

在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。在 Ubuntu 16.04 机器里,我可以用 mount 指令把它们展示出来,这条命令是:

$ mount -t cgroup
cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
...

它的输出结果,是一系列文件系统目录。如果你在自己的机器上没有看到这些目录,那你就需要自己去挂载 Cgroups,具体做法可以自行 Google。

可以看到,在 /sys/fs/cgroup 下面有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。比如,对 CPU 子系统来说,我们就可以看到如下几个配置文件,这个指令是:

$ ls /sys/fs/cgroup/cpu
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us  cpu.shares notify_on_release
cgroup.procs      cpu.cfs_quota_us  cpu.rt_runtime_us cpu.stat  tasks

如果熟悉 Linux CPU 管理的话,你就会在它的输出里注意到 cfs_period 和 cfs_quota 这样的关键词。这两个参数需要组合使用,可以用来限制进程在长度为 cfs_period 的一段时间内,只能被分配到总量为 cfs_quota 的 CPU 时间。

而这样的配置文件又如何使用呢?

你需要在对应的子系统下面创建一个目录,比如,我们现在进入 /sys/fs/cgroup/cpu 目录下:

root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container
root@ubuntu:/sys/fs/cgroup/cpu$ ls container/
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us  cpu.shares notify_on_release
cgroup.procs      cpu.cfs_quota_us  cpu.rt_runtime_us cpu.stat  tasks

这个目录就称为一个”控制组”。你会发现,操作系统会在你新创建的 container 目录下,自动生成该子系统对应的资源限制文件。

现在,我们在后台执行这样一条脚本:

$ while : ; do : ; done &
[1] 226

显然,它执行了一个死循环,可以把计算机的 CPU 吃到 100%,根据它的输出,我们可以看到这个脚本在后台运行的进程号(PID)是 226。

这样,我们可以用 top 指令来确认一下 CPU 有没有被打满:

$ top
%Cpu0 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

在输出里可以看到,CPU 的使用率已经 100% 了(%Cpu0 :100.0 us)。

而此时,我们可以通过查看 container 目录下的文件,看到 container 控制组里的 CPU quota 还没有任何限制(即:-1),CPU period 则是默认的 100 ms(100000 us):

$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
-1
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us
100000

接下来,我们可以通过修改这些文件的内容来设置限制。

比如,向 container 组里的 cfs_quota 文件写入 20 ms(20000 us):

echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

结合前面的介绍,你应该能明白这个操作的含义,它意味着在每 100 ms 的时间里,被该控制组限制的进程只能使用 20 ms 的 CPU 时间,也就是说这个进程只能使用到 20% 的 CPU 带宽。

接下来,我们把被限制的进程的 PID 写入 container 组里的 tasks 文件,上面的设置就会对该进程生效了:

echo 226 > /sys/fs/cgroup/cpu/container/tasks

我们可以用 top 指令查看一下:

$ top
%Cpu0 : 20.3 us, 0.0 sy, 0.0 ni, 79.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

可以看到,计算机的 CPU 使用率立刻降到了 20%(%Cpu0 : 20.3 us)。

除 CPU 子系统外,Cgroups 的每一项子系统都有其独有的资源限制能力,比如:

  • blkio,为​​​块​​​设​​​备​​​设​​​定​​​I/O 限​​​制,一般用于磁盘等设备;
  • cpuset,为进程分配单独的 CPU 核和对应的内存节点;
  • memory,为进程设定内存使用的限制。

Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。 而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。

而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指定了,比如这样一条命令:

docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

在启动这个容器后,我们可以通过查看 Cgroups 文件系统下,CPU 子系统中,”docker”这个控制组里的资源限制文件的内容来确认:

$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us
100000
$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us
20000

这就意味着这个 Docker 容器,只能使用到 20% 的 CPU 带宽。

6.1 总结

通过以上讲述,你现在应该能够理解,一个正在运行的 Docker 容器,其实就是一个启用了多个 Linux Namespace 的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。

这也是容器技术中一个非常重要的概念,即:容器是一个”单进程”模型。

由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里 PID=1 的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程,这也是为什么很多人都会用 systemd 或者 supervisord 这样的软件来代替应用本身作为容器的启动进程。

但是,在后面分享容器设计模式时,我还会推荐其他更好的解决办法。这是因为容器本身的设计,就是希望容器和应用能够 同生命周期 ,这个概念对后续的容器编排非常重要。否则,一旦出现类似于”容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。

另外,跟 Namespace 的情况类似,Cgroups 对资源的限制能力也有很多不完善的地方,被提及最多的自然是 /proc 文件系统的问题。

众所周知,Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等,这些文件也是 top 指令查看系统信息的主要数据来源。

但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。

造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:/proc 文件系统不了解 Cgroups 限制的存在。

在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取到的 CPU 核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险。这也是在企业中,容器化应用碰到的一个常见问题,也是容器相较于虚拟机另一个不尽如人意的地方。

07 | 白话容器基础(三):深入理解容器镜像

正如我前面所说的,Namespace 的作用是”隔离”,它让应用进程只能看到该 Namespace 内的”世界”;而 Cgroups 的作用是”限制”,它给这个”世界”围上了一圈看不见的墙。这么一折腾,进程就真的被”装”在了一个与世隔绝的房间里,而这些房间就是 PaaS 项目赖以生存的应用”沙盒”。

但是,容器里的进程看到的文件系统又是什么样子的呢?

可能你立刻就能想到,这一定是一个关于 Mount Namespace 的问题:容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如 /tmp)下进行操作,而完全不会受宿主机以及其他容器的影响。

那么,真实情况是这样吗?

“左耳朵耗子”叔在多年前写的一篇 关于 Docker 基础知识 的博客里,曾经介绍过一段小程序。这段小程序的作用是,在创建子进程时开启指定的 Namespace。

下面,我们不妨使用它来验证一下刚刚提到的问题。

#define _GNU_SOURCE
#include <sys/mount.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
  "/bin/bash",
  NULL
};

int container_main(void* arg)
{
  printf("Container - inside the container!\n");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

int main()
{
  printf("Parent - start a container!\n");
  int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
  waitpid(container_pid, NULL, 0);
  printf("Parent - container stopped!\n");
  return 0;
}

这段代码的功能非常简单:在 main 函数里,我们通过 clone() 系统调用创建了一个新的子进程 container_main,并且声明要为它启用 Mount Namespace(即:CLONE_NEWNS 标志)。

而这个子进程执行的,是一个”/bin/bash”程序,也就是一个 shell。所以这个 shell 就运行在了 Mount Namespace 的隔离环境中。

我们来一起编译一下这个程序:

$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!

这样,我们就进入了这个”容器”当中。可是,如果在”容器”里执行一下 ls 指令的话,我们就会发现一个有趣的现象: /tmp 目录下的内容跟宿主机的内容是一样的。

$ ls /tmp
# 你会看到好多宿主机的文件

也就是说:

即使开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。

仔细思考一下,你会发现这其实并不难理解:Mount Namespace 修改的,是容器进程对文件系统”挂载点”的认知。 但是,这也就意味着,只有在”挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

这时,你可能已经想到了一个解决办法:创建新进程时,除了声明要启用 Mount Namespace 之外,我们还可以告诉容器进程,有哪些目录需要重新挂载,就比如这个 /tmp 目录。于是,我们在容器进程执行前可以添加一步重新挂载 /tmp 目录的操作:

int container_main(void* arg)
{
  printf("Container - inside the container!\n");
  // 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
  // mount("", "/", NULL, MS_PRIVATE, "");
  mount("none", "/tmp", "tmpfs", 0, "");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

可以看到,在修改后的代码里,我在容器进程启动之前,加上了一句 mount("none", "/tmp", "tmpfs", 0, "") 语句。就这样,我告诉了容器以 tmpfs(内存盘)格式,重新挂载了 /tmp 目录。

这段修改后的代码,编译执行后的结果又如何呢?我们可以试验一下:

$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!
$ ls /tmp

可以看到,这次 /tmp 变成了一个空目录,这意味着重新挂载生效了。我们可以用 mount -l 检查一下:

$ mount -l | grep tmpfs
none on /tmp type tmpfs (rw,relatime)

更重要的是,因为我们创建的新进程启用了 Mount Namespace,所以这次重新挂载的操作,只在容器进程的 Mount Namespace 中有效。如果在宿主机上用 mount -l 来检查一下这个挂载,你会发现它是不存在的:

# 在宿主机上
$ mount -l | grep tmpfs

这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。

可是,作为一个普通用户,我们希望的是一个更友好的情况:每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能做到这一点呢?

不难想到,我们可以在容器进程启动之前重新挂载它的整个根目录”/“。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。

在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成这个工作。顾名思义,它的作用就是帮你”change root file system”,即改变进程的根目录到你指定的位置。它的用法也非常简单。

假设,我们现在有一个 $HOME/test 目录,想要把它作为一个 /bin/bash 进程的根目录。

首先,创建一个 test 目录和几个 lib 文件夹:

mkdir -p $HOME/test
mkdir -p $HOME/test/{bin,lib64,lib}
cd $T

然后,把 bash 命令拷贝到 test 目录对应的 bin 路径下:

cp -v /bin/{bash,ls} $HOME/test/bin

接下来,把 bash 命令需要的所有 so 文件,也拷贝到 test 目录对应的 lib 路径下。找到 so 文件可以用 ldd 命令:

T=$HOME/test
list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done

最后,执行 chroot 命令,告诉操作系统,我们将使用 $HOME/test 目录作为 /bin/bash 进程的根目录:

chroot $HOME/test /bin/bash

这时,你如果执行 “ls /“,就会看到,它返回的都是 $HOME/test 目录下面的内容,而不是宿主机的内容。

更重要的是,对于被 chroot 的进程来说,它并不会感受到自己的根目录已经被”修改”成 $HOME/test 了。

这种视图被修改的原理,是不是跟我之前介绍的 Linux Namespace 很类似呢?

没错!

实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。

当然,为了能够让容器的这个根目录看起来更”真实”,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO。这样,在容器启动之后,我们在容器里通过执行 “ls /“ 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文件。

而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的”容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。

所以,一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc 等等:

$ ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var

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

现在,你应该可以理解,对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:

  1. 启用 Linux Namespace 配置;
  2. 设置指定的 Cgroups 参数;
  3. 切换进程的根目录(Change Root)。

这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。这两个系统调用虽然功能类似,但是也有细微的区别,这一部分小知识就交给你课后去探索了。

另外, 需要明确的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

所以说,rootfs 只包括了操作系统的”躯壳”,并没有包括操作系统的”灵魂”。

那么,对于容器来说,这个操作系统的”灵魂”又在哪里呢?

实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。

这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个”全局变量”,牵一发而动全身。

这也是容器相比于虚拟机的主要缺陷之一:毕竟后者不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的 Guest OS 给应用随便折腾。

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

什么是容器的”一致性”呢?

由于云端与本地服务器环境不同,应用的打包过程,一直是使用 PaaS 时最”痛苦”的一个步骤。

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

事实上,对于大多数开发者而言,他们对应用依赖的理解,一直局限在编程语言层面。比如 Golang 的 Godeps.json。但实际上,一个一直以来很容易被忽视的事实是,对一个应用来说,操作系统本身才是它运行所需要的最完整的”依赖库”。

有了容器镜像”打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。

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

不过,这时你可能已经发现了另一个非常棘手的问题:难道我每开发一个应用,或者升级一下现有的应用,都要重复制作一次 rootfs 吗?

一种比较直观的解决办法是,我在制作 rootfs 的时候,每做一步”有意义”的操作,就保存一个 rootfs 出来,这样其他同事就可以按需求去用他需要的 rootfs 了。

那么,既然这些修改都基于一个旧的 rootfs,我们能不能以增量的方式去做这些修改呢?这样做的好处是,所有人都只需要维护相对于 base rootfs 修改的增量内容,而不是每次修改都制造一个”fork”。

答案当然是肯定的。

这也正是为何,Docker 公司在实现 Docker 镜像时并没有沿用以前制作 rootfs 的标准流程,而是做了一个小小的创新:

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

当然,这个想法不是凭空臆造出来的,而是用到了一种叫作联合文件系统(Union File System)的能力。

Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。比如,我现在有两个目录 A 和 B,它们分别有两个文件:

$ tree
.
├── A
│  ├── a
│  └── x
└── B
  ├── b
  └── x

然后,我使用联合挂载的方式,将这两个目录挂载到一个公共的目录 C 上:

mkdir C
mount -t aufs -o dirs=./A:./B none ./C

这时,我再查看目录 C 的内容,就能看到目录 A 和 B 下的文件被合并到了一起:

$ tree ./C
./C
├── a
├── b
└── x

可以看到,在这个合并后的目录 C 里,有 a、b、x 三个文件,并且 x 文件只有一份。这,就是”合并”的含义。此外,如果你在目录 C 里对 a、b、x 文件做修改,这些修改也会在对应的目录 A、B 中生效。

那么,在 Docker 项目中,又是如何使用这种 Union File System 的呢?

我的环境是 Ubuntu 16.04 和 Docker CE 18.05,这对组合默认使用的是 AuFS 这个联合文件系统的实现。你可以通过 docker info 命令,查看到这个信息。

AuFS 的全称是 Another UnionFS,后改名为 Alternative UnionFS,再后来干脆改名叫作 Advance UnionFS,从这些名字中你应该能看出这样两个事实:

  1. 它是对 Linux 原生 UnionFS 的重写和改进;
  2. 它的作者怨气好像很大。我猜是 Linus Torvalds(Linux 之父)一直不让 AuFS 进入 Linux 内核主干的缘故,所以我们只能在 Ubuntu 和 Debian 这些发行版上使用它。

对于 AuFS 来说,它最关键的目录结构在 /var/lib/docker 路径下的 diff 目录:

/var/lib/docker/aufs/diff/<layer_id>

而这个目录的作用,我们不妨通过一个具体例子来看一下。

现在,我们启动一个容器,比如:

docker run -d ubuntu:latest sleep 3600

这时候,Docker 就会从 Docker Hub 上拉取一个 Ubuntu 镜像到本地。

这个所谓的”镜像”,实际上就是一个 Ubuntu 操作系统的 rootfs,它的内容是 Ubuntu 操作系统的所有文件和目录。不过,与之前我们讲述的 rootfs 稍微不同的是,Docker 镜像使用的 rootfs,往往由多个”层”组成:

$ docker image inspect ubuntu:latest
...
     "RootFS": {
      "Type": "layers",
      "Layers": [
        "sha256:f49017d4d5ce9c0f544c...",
        "sha256:8f2b771487e9d6354080...",
        "sha256:ccd4d61916aaa2159429...",
        "sha256:c01d74f99de40e097c73...",
        "sha256:268a067217b5fe78e000..."
      ]
    }

可以看到,这个 Ubuntu 镜像,实际上由五个层组成。这五个层就是五个增量 rootfs,每一层都是 Ubuntu 操作系统文件与目录的一部分;而在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上(等价于前面例子里的”/C”目录)。

这个挂载点就是 /var/lib/docker/aufs/mnt/,比如:

/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e

不出意外的,这个目录里面正是一个完整的 Ubuntu 操作系统:

$ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

那么,前面提到的五个镜像层,又是如何被联合挂载成这样一个完整的 Ubuntu 文件系统的呢?

这个信息记录在 AuFS 的系统目录 /sys/fs/aufs 下面。

首先,通过查看 AuFS 的挂载信息,我们可以找到这个目录对应的 AuFS 的内部 ID(也叫:si):

$ cat /proc/mounts| grep aufs
none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0

即,si=972c6d361e6b32ba。

然后使用这个 ID,你就可以在 /sys/fs/aufs 下查看被联合挂载在一起的各个层的信息:

$ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]*
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh
/var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh
/var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh
/var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh
/var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh
/var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh

从这些信息里,我们可以看到,镜像的层都放置在 /var/lib/docker/aufs/diff 目录下,然后被联合挂载在 /var/lib/docker/aufs/mnt 里面。

而且,从这个结构可以看出来,这个容器的 rootfs 由如下图所示的三部分组成:

aufs举例

  1. 第一部分,只读层。

    它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout,至于什么是 whiteout,我下面马上会讲到)。

    这时,我们可以分别查看一下这些层的内容:

    $ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0...
    etc sbin usr var
    $ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2...
    run
    $ ls /var/lib/docker/aufs/diff/a524a729adadedb900...
    bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

    可以看到,这些层,都以增量的方式分别包含了 Ubuntu 操作系统的一部分。

  2. 第二部分,可读写层。

    它是这个容器的 rootfs 最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即 read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。

    可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?

    为了实现这样的删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件”遮挡”起来。

    比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件”遮挡”起来,”消失”了。这个功能,就是”ro+wh”的挂载方式,即只读 +whiteout 的含义。我喜欢把 whiteout 形象地翻译为:”白障”。

    所以,最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量 rootfs 的好处。

  3. 第三部分,Init 层。

    它是一个以”-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。

    需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。

    可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。

    所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。

    最终,这 7 个层都被联合挂载到 /var/lib/docker/aufs/mnt 目录下,表现为一个完整的 Ubuntu 操作系统供容器使用。

08 | 白话容器基础(四):重新认识Docker容器

我会通过一个实际案例,对”白话容器基础”系列的所有内容做一次深入的总结和扩展。希望通过这次的讲解,能够让你更透彻地理解 Docker 容器的本质。

我要用 Docker 部署一个用 Python 编写的 Web 应用。这个应用的代码部分(app.py)非常简单:

from flask import Flask
import socket
import os

app = Flask(__name__)

@app.route('/')
def hello():
    html = "<h3>Hello {name}!</h3>" \
           "<b>Hostname:</b> {hostname}<br/>"
    return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname())

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=80)

而将这样一个应用容器化的第一步,是制作容器镜像。

不过,相较于我之前介绍的制作 rootfs 的过程,Docker 为你提供了一种更便捷的方式,叫作 Dockerfile,如下所示。

# 使用官方提供的 Python 开发镜像作为基础镜像
FROM python:2.7-slim

# 将工作目录切换为 /app
WORKDIR /app

# 将当前目录下的所有内容复制到 /app 下
ADD . /app

# 使用 pip 命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# 允许外界访问容器的 80 端口
EXPOSE 80

# 设置环境变量
ENV NAME World

# 设置容器进程为:python app.py,即:这个 Python 应用的启动命令
CMD ["python", "app.py"]

通过这个文件的内容,你可以看到 Dockerfile 的设计思想,是使用一些标准的原语(即大写高亮的词语),描述我们所要构建的 Docker 镜像。并且这些原语,都是按顺序处理的。

在使用 Dockerfile 时,你可能还会看到一个叫作 ENTRYPOINT 的原语。实际上,它和 CMD 都是 Docker 容器进程启动所必需的参数,完整执行格式是:”ENTRYPOINT CMD”。

但是,默认情况下,Docker 会为你提供一个隐含的 ENTRYPOINT,即:/bin/sh -c。所以,在不指定 ENTRYPOINT 时,比如在我们这个例子里,实际上运行在容器里的完整进程是:/bin/sh -c “python app.py”,即 CMD 的内容就是 ENTRYPOINT 的参数。

备注:基于以上原因, 我们后面会统一称 Docker 容器的启动进程为 ENTRYPOINT,而不是 CMD。

制作镜像:

docker build -t helloworld .

需要注意的是,Dockerfile 中的每个原语执行后,都会生成一个对应的镜像层。 即使原语本身并没有明显地修改文件的操作(比如,ENV 原语),它对应的层也会存在。只不过在外界看来,这个层是空的。

docker build 操作完成后,我可以通过 docker images 命令查看结果:

$ docker image ls
REPOSITORY            TAG                 IMAGE ID
helloworld         latest              653287cdf998

接下来,我使用这个镜像,通过 docker run 命令启动容器:

docker run -p 4000:80 helloworld

容器启动之后,我可以使用 docker ps 命令看到:

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED
4ddf4638572d        helloworld       "python app.py"     10 seconds ago

同时,我已经通过 -p 4000:80 告诉了 Docker,请把容器内的 80 端口映射在宿主机的 4000 端口上。

这样做的目的是,只要访问宿主机的 4000 端口,我就可以看到容器里应用返回的结果:

$ curl http://localhost:4000
<h3>Hello World!</h3><b>Hostname:</b> 4ddf4638572d<br/>

否则,我就得先用 docker inspect 命令查看容器的 IP 地址,然后访问 http://< 容器 IP 地址 >:80 才可以看到容器内应用的返回。

此外,我还可以使用 docker commit 指令,把一个正在运行的容器,直接提交为一个镜像。一般来说,需要这么操作原因是:这个容器运行起来后,我又在里面做了一些操作,并且要把操作结果保存到镜像里,比如:

$ docker exec -it 4ddf4638572d /bin/sh
# 在容器内部新建了一个文件
root@4ddf4638572d:/app# touch test.txt
root@4ddf4638572d:/app# exit

# 将这个新建的文件提交到镜像中保存
$ docker commit 4ddf4638572d geektime/helloworld:v2

这里,我使用了 docker exec 命令进入到了容器当中。在了解了 Linux Namespace 的隔离机制后,你应该会很自然地想到一个问题:docker exec 是怎么做到进入容器里的呢?

实际上,Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。

比如,通过如下指令,你可以看到当前正在运行的 Docker 容器的进程号(PID)是 25686:

$ docker inspect --format '{{ .State.Pid }}' 4ddf4638572d
25686

这时,你可以通过查看宿主机的 proc 文件,看到这个 25686 进程的所有 Namespace 对应的文件:

$ ls -l  /proc/25686/ns
total 0
lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]

可以看到,一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。

有了这样一个可以”hold 住”所有 Linux Namespace 的文件,我们就可以对 Namespace 做一些很有意义事情了,比如:加入到一个已经存在的 Namespace 当中。

这也就意味着:一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到”进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。

而这个操作所依赖的,乃是一个名叫 setns() 的 Linux 系统调用。它的调用方法,我可以用如下一段小程序为你说明:

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)

int main(int argc, char *argv[]) {
    int fd;

    fd = open(argv[1], O_RDONLY);
    if (setns(fd, 0) == -1) {
        errExit("setns");
    }
    execvp(argv[2], &argv[2]);
    errExit("execvp");
}

这段代码功能非常简单:它一共接收两个参数,第一个参数是 argv[1],即当前进程要加入的 Namespace 文件的路径,比如 /proc/25686/ns/net;而第二个参数,则是你要在这个 Namespace 里运行的进程,比如 /bin/bash。

这段代码的的核心操作,则是通过 open() 系统调用打开了指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后,当前进程就加入了这个文件对应的 Linux Namespace 当中了。

现在,你可以编译执行一下这个程序,加入到容器进程(PID=25686)的 Network Namespace 中:

$ gcc -o set_ns set_ns.c
$ ./set_ns /proc/25686/ns/net /bin/bash
$ ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:ac:11:00:02
          inet addr:172.17.0.2  Bcast:0.0.0.0  Mask:255.255.0.0
          inet6 addr: fe80::42:acff:fe11:2/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:12 errors:0 dropped:0 overruns:0 frame:0
          TX packets:10 errors:0 dropped:0 overruns:0 carrier:0
     collisions:0 txqueuelen:0
          RX bytes:976 (976.0 B)  TX bytes:796 (796.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
    collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

正如上所示,当我们执行 ifconfig 命令查看网络设备时,我会发现能看到的网卡”变少”了:只有两个。而我的宿主机则至少有四个网卡。这是怎么回事呢?

实际上,在 setns() 之后我看到的这两个网卡,正是我在前面启动的 Docker 容器里的网卡。也就是说,我新创建的这个 /bin/bash 进程,由于加入了该容器进程(PID=25686)的 Network Namepace,它看到的网络设备与这个容器里是一样的,即:/bin/bash 进程的网络设备视图,也被修改了。

而一旦一个进程加入到了另一个 Namespace 当中,在宿主机的 Namespace 文件上,也会有所体现。

在宿主机上,你可以用 ps 指令找到这个 set_ns 程序执行的 /bin/bash 进程,其真实的 PID 是 28499:

# 在宿主机上
ps aux | grep /bin/bash
root     28499  0.0  0.0 19944  3612 pts/0    S    14:15   0:00 /bin/bash

这时,如果按照前面介绍过的方法,查看一下这个 PID=28499 的进程的 Namespace,你就会发现这样一个事实:

$ ls -l /proc/28499/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:18 /proc/28499/ns/net -> net:[4026532281]

$ ls -l  /proc/25686/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -> net:[4026532281]

在 /proc/[PID]/ns/net 目录下,这个 PID=28499 进程,与我们前面的 Docker 容器进程(PID=25686)指向的 Network Namespace 文件完全一样。这说明这两个进程,共享了这个名叫 net:[4026532281] 的 Network Namespace。

此外,Docker 还专门提供了一个参数,可以让你启动一个容器并”加入”到另一个容器的 Network Namespace 里,这个参数就是 -net,比如:

docker run -it --net container:4ddf4638572d busybox ifconfig

这样,我们新启动的这个容器,就会直接加入到 ID=4ddf4638572d 的容器,也就是我们前面的创建的 Python 应用容器(PID=25686)的 Network Namespace 中。

而如果我指定-net=host,就意味着这个容器不会为进程启用 Network Namespace。这就意味着,这个容器拆除了 Network Namespace 的”隔离墙”,所以,它会和宿主机上的其他普通进程一样,直接共享宿主机的网络栈。这就为容器直接操作和使用宿主机网络提供了一个渠道。

转了一个大圈子,我其实是为你详细解读了 docker exec 这个操作背后,Linux Namespace 更具体的工作原理。

这种通过操作系统进程相关的知识,逐步剖析 Docker 容器的方法,是理解容器的一个关键思路,希望你一定要掌握。

现在,我们再一起回到前面提交镜像的操作 docker commit 上来吧。

docker commit,实际上就是在容器运行起来后,把最上层的”可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。

而由于使用了联合文件系统,你在容器里对镜像 rootfs 所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。这就是所谓的:Copy-on-Write。

而正如前所说,Init 层的存在,就是为了避免你执行 docker commit 时,把 Docker 自己对 /etc/hosts 等文件做的修改,也一起提交掉。

最后,我再来讲解一下 Docker 项目另一个重要的内容:Volume(数据卷)。

Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。

在 Docker 项目里,它支持两种 Volume 声明方式,可以把宿主机目录挂载进容器的 /test 目录当中:

docker run -v /test ...
docker run -v /home:/test ...

而这两种声明方式的本质,实际上是相同的:都是把一个宿主机的目录挂载进了容器的 /test 目录。

只不过,在第一种情况下,由于你并没有显示声明宿主机目录,那么 Docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。而在第二种情况下,Docker 就直接把宿主机的 /home 目录挂载到容器的 /test 目录上。

那么,Docker 又是如何做到把一个宿主机上的目录或者文件,挂载到容器里面去呢?

在《白话容器基础(三):深入理解容器镜像》的分享中,我已经介绍过,当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。

而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在 /var/lib/docker/aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/aufs/mnt/ 目录中,这样容器所需的 rootfs 就准备好了。

所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。

更重要的是,由于执行这个挂载操作时,”容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破

注意:这里提到的 “ 容器进程 “,是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。

而这里要使用到的挂载技术,就是 Linux 的绑定挂载(bind mount)机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

绑定挂载mount —bind介绍

其实,如果你了解 Linux 内核的话,就会明白,绑定挂载实际上是一个 inode 替换的过程。在 Linux 操作系统中,inode 可以理解为存放文件内容的”对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的”指针”。

绑定挂载举例

正如上图所示,mount —bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。

所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。

这样,进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录(比如,/home,或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像的内容。

那么,这个 /test 目录里的内容,既然挂载在容器 rootfs 的可读写层,它会不会被 docker commit 提交掉呢?

也不会。

这个原因其实我们前面已经提到过。容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test), 始终是空的

不过,由于 Docker 一开始还是要创建 /test 这个目录作为挂载点,所以执行了 docker commit 之后,你会发现新产生的镜像里,会多出来一个空的 /test 目录。毕竟,新建目录操作,又不是挂载操作,Mount Namespace 对它可起不到”障眼法”的作用。

结合以上的讲解,我们现在来亲自验证一下:

首先,启动一个 helloworld 容器,给它声明一个 Volume,挂载在容器里的 /test 目录上:

$ docker run -d -v /test helloworld
cf53b766fa6f

容器启动之后,我们来查看一下这个 Volume 的 ID:

$ docker volume ls
DRIVER              VOLUME NAME
local               cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d

然后,使用这个 ID,可以找到它在 Docker 工作目录下的 volumes 路径:

ls /var/lib/docker/volumes/cb1c2f7221fa/_data/

这个 _data 文件夹,就是这个容器的 Volume 在宿主机上对应的临时目录了。

接下来,我们在容器的 Volume 里,添加一个文件 text.txt:

$ docker exec -it cf53b766fa6f /bin/sh
cd test/
touch text.txt

这时,我们再回到宿主机,就会发现 text.txt 已经出现在了宿主机上对应的临时目录里:

$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
text.txt

可是,如果你在宿主机上查看该容器的可读写层,虽然可以看到这个 /test 目录,但其内容是空的(关于如何找到这个 AuFS 文件系统的路径,请参考我上一次分享的内容):

ls /var/lib/docker/aufs/mnt/6780d0778b8a/test

可以确认,容器 Volume 里的信息,并不会被 docker commit 提交掉;但这个挂载点目录 /test 本身,则会出现在新的镜像当中。

以上内容,就是 Docker Volume 的核心原理了。

8.1 总结

在今天的这次分享中,我用了一个非常经典的 Python 应用作为案例,讲解了 Docker 容器使用的主要场景。熟悉了这些操作,你也就基本上摸清了 Docker 容器的核心功能。

更重要的是,我着重介绍了如何使用 Linux Namespace、Cgroups,以及 rootfs 的知识,对容器进行了一次庖丁解牛似的解读。

借助这种思考问题的方法,最后的 Docker 容器,我们实际上就可以用下面这个”全景图”描述出来:

Docker中Python应用全景图

这个容器进程”python app.py”,运行在由 Linux Namespace 和 Cgroups 构成的隔离环境里;而它运行所需要的各种文件,比如 python,app.py,以及整个操作系统文件,则由多个联合挂载在一起的 rootfs 层提供。

这些 rootfs 层的最下层,是来自 Docker 镜像的只读层。

在只读层之上,是 Docker 自己添加的 Init 层,用来存放被临时修改过的 /etc/hosts 等文件。

而 rootfs 的最上层是一个可读写层,它以 Copy-on-Write 的方式存放任何对只读层的修改,容器声明的 Volume 的挂载点,也出现在这一层。

09 | 从容器到容器云:谈谈Kubernetes的本质

在前面的四篇文章中,我以 Docker 项目为例,一步步剖析了 Linux 容器的具体实现方式。通过这些讲解你应该能够明白:一个”容器”,实际上是一个由 Linux Namespace、Linux Cgroups 和 rootfs 三种技术构建出来的进程的隔离环境。

从这个结构中我们不难看出,一个正在运行的 Linux 容器,其实可以被”一分为二”地看待:

  1. 一组联合挂载在 /var/lib/docker/aufs/mnt 上的 rootfs,这一部分我们称为”容器镜像”(Container Image),是容器的静态视图;
  2. 一个由 Namespace+Cgroups 构成的隔离环境,这一部分我们称为”容器运行时”(Container Runtime),是容器的动态视图。

更进一步地说,作为一名开发者,我并不关心容器运行时的差异。因为,在整个”开发 - 测试 - 发布”的流程中,真正承载着容器信息进行传递的,是容器镜像,而不是容器运行时。

这个重要假设,正是容器技术圈在 Docker 项目成功后不久,就迅速走向了”容器编排”这个”上层建筑”的主要原因:作为一家云服务商或者基础设施提供商,我只要能够将用户提交的 Docker 镜像以容器的方式运行起来,就能成为这个非常热闹的容器生态图上的一个承载点,从而将整个容器技术栈上的价值,沉淀在我的这个节点上。

更重要的是,只要从我这个承载点向 Docker 镜像制作者和使用者方向回溯,整条路径上的各个服务节点,比如 CI/CD、监控、安全、网络、存储等等,都有我可以发挥和盈利的余地。这个逻辑,正是所有云计算提供商如此热衷于容器技术的重要原因:通过容器镜像,它们可以和潜在用户(即,开发者)直接关联起来。

从一个开发者和单一的容器镜像,到无数开发者和庞大的容器集群,容器技术实现了从”容器”到”容器云”的飞跃,标志着它真正得到了市场和生态的认可。

这样,容器就从一个开发者手里的小工具,一跃成为了云计算领域的绝对主角;而能够定义容器组织和管理规范的”容器编排”技术,则当仁不让地坐上了容器技术领域的”头把交椅”。

这其中,最具代表性的容器编排工具,当属 Docker 公司的 Compose+Swarm 组合,以及 Google 与 RedHat 公司共同主导的 Kubernetes 项目。

我在前面介绍容器技术发展历史的四篇预习文章中,已经对这两个开源项目做了详细地剖析和评述。所以,在今天的这次分享中,我会专注于本专栏的主角 Kubernetes 项目,谈一谈它的设计与架构。

跟很多基础设施领域先有工程实践、后有方法论的发展路线不同,Kubernetes 项目的理论基础则要比工程实践走得靠前得多,这当然要归功于 Google 公司在 2015 年 4 月发布的 Borg 论文了。

Borg 系统,一直以来都被誉为 Google 公司内部最强大的”秘密武器”。虽然略显夸张,但这个说法倒不算是吹牛。

因为,相比于 Spanner、BigTable 等相对上层的项目,Borg 要承担的责任,是承载 Google 公司整个基础设施的核心依赖。在 Google 公司已经公开发表的基础设施体系论文中,Borg 项目当仁不让地位居整个基础设施技术栈的最底层。

Google基础设施栈

图片来源:Malte Schwarzkopf. “Operating system support for warehouse-scale computing”. PhD thesis. University of Cambridge Computer Laboratory (to appear), 2015, Chapter 2.

上面这幅图,来自于 Google Omega 论文的第一作者的博士毕业论文。它描绘了当时 Google 已经公开发表的整个基础设施栈。在这个图里,你既可以找到 MapReduce、BigTable 等知名项目,也能看到 Borg 和它的继任者 Omega 位于整个技术栈的最底层。

正是由于这样的定位,Borg 可以说是 Google 最不可能开源的一个项目。而幸运地是,得益于 Docker 项目和容器技术的风靡,它却终于得以以另一种方式与开源社区见面,这个方式就是 Kubernetes 项目。

所以,相比于”小打小闹”的 Docker 公司、”旧瓶装新酒”的 Mesos 社区, Kubernetes 项目从一开始就比较幸运地站上了一个他人难以企及的高度 :在它的成长阶段,这个项目每一个核心特性的提出,几乎都脱胎于 Borg/Omega 系统的设计与经验。更重要的是,这些特性在开源社区落地的过程中,又在整个社区的合力之下得到了极大的改进,修复了很多当年遗留在 Borg 体系中的缺陷和问题。

所以,尽管在发布之初被批评是”曲高和寡”,但是在逐渐觉察到 Docker 技术栈的”稚嫩”和 Mesos 社区的”老迈”之后,这个社区很快就明白了:Kubernetes 项目在 Borg 体系的指导下,体现出了一种独有的”先进性”与”完备性”,而这些特质才是一个基础设施领域开源项目赖以生存的核心价值。

为了更好地理解这两种特质,我们不妨从 Kubernetes 的顶层设计说起。

  1. 首先,Kubernetes 项目要解决的问题是什么?

    编排?调度?容器云?还是集群管理?

    实际上,这个问题到目前为止都没有固定的答案。因为在不同的发展阶段,Kubernetes 需要着重解决的问题是不同的。

    但是,对于大多数用户来说,他们希望 Kubernetes 项目带来的体验是确定的:现在我有了应用的容器镜像,请帮我在一个给定的集群上把这个应用运行起来。

    更进一步地说,我还希望 Kubernetes 能给我提供路由网关、水平扩展、监控、备份、灾难恢复等一系列运维能力。

    等一下,这些功能听起来好像有些耳熟?这不就是经典 PaaS(比如,Cloud Foundry)项目的能力吗?

    而且,有了 Docker 之后,我根本不需要什么 Kubernetes、PaaS,只要使用 Docker 公司的 Compose+Swarm 项目,就完全可以很方便地 DIY 出这些功能了!

    所以说,如果 Kubernetes 项目只是停留在拉取用户镜像、运行容器,以及提供常见的运维功能的话,那么别说跟”原生”的 Docker Swarm 项目竞争了,哪怕跟经典的 PaaS 项目相比也难有什么优势可言。

    而实际上,在定义核心功能的过程中,Kubernetes 项目正是依托着 Borg 项目的理论优势,才在短短几个月内迅速站稳了脚跟,进而确定了一个如下图所示的全局架构:

    Kubernetes项目的架构

    我们可以看到,Kubernetes 项目的架构,跟它的原型项目 Borg 非常类似,都由 Master 和 Node 两种节点组成,而这两种角色分别对应着控制节点和计算节点。

    其中,控制节点,即 Master 节点,由三个紧密协作的独立组件组合而成,它们分别是负责 API 服务的 kube-apiserver、负责调度的 kube-scheduler,以及负责容器编排的 kube-controller-manager。整个集群的持久化数据,则由 kube-apiserver 处理后保存在 Etcd 中。

    而计算节点上最核心的部分,则是一个叫作 kubelet 的组件。

    在 Kubernetes 项目中,kubelet 主要负责同容器运行时(比如 Docker 项目)打交道 。而这个交互所依赖的,是一个称作 CRI(Container Runtime Interface)的远程调用接口,这个接口定义了容器运行时的各项核心操作,比如:启动一个容器需要的所有参数。

    这也是为何,Kubernetes 项目并不关心你部署的是什么容器运行时、使用的什么技术实现,只要你的这个容器运行时能够运行标准的容器镜像,它就可以通过实现 CRI 接入到 Kubernetes 项目当中。

    而具体的容器运行时,比如 Docker 项目,则一般通过 OCI 这个容器运行时规范同底层的 Linux 操作系统进行交互,即:把 CRI 请求翻译成对 Linux 操作系统的调用(操作 Linux Namespace 和 Cgroups 等)。

    此外,kubelet 还通过 gRPC 协议同一个叫作 Device Plugin 的插件进行交互 。这个插件,是 Kubernetes 项目用来管理 GPU 等宿主机物理设备的主要组件,也是基于 Kubernetes 项目进行机器学习训练、高性能作业支持等工作必须关注的功能。

    kubelet 的另一个重要功能,则是调用网络插件和存储插件为容器配置网络和持久化存储 。这两个插件与 kubelet 进行交互的接口,分别是 CNI(Container Networking Interface)和 CSI(Container Storage Interface)。

    实际上,kubelet 这个奇怪的名字,来自于 Borg 项目里的同源组件 Borglet。不过,如果你浏览过 Borg 论文的话,就会发现,这个命名方式可能是 kubelet 组件与 Borglet 组件的唯一相似之处。因为 Borg 项目,并不支持我们这里所讲的容器技术,而只是简单地使用了 Linux Cgroups 对进程进行限制。

    这就意味着,像 Docker 这样的”容器镜像”在 Borg 中是不存在的,Borglet 组件也自然不需要像 kubelet 这样考虑如何同 Docker 进行交互、如何对容器镜像进行管理的问题,也不需要支持 CRI、CNI、CSI 等诸多容器技术接口。

    可以说,kubelet 完全就是为了实现 Kubernetes 项目对容器的管理能力而重新实现的一个组件,与 Borg 之间并没有直接的传承关系。

    备注:虽然不使用 Docker,但 Google 内部确实在使用一个包管理工具,名叫 Midas Package Manager (MPM),其实它可以部分取代 Docker 镜像的角色。

  2. 那么,Borg 对于 Kubernetes 项目的指导作用又体现在哪里呢?

    答案是,Master 节点。

    虽然在 Master 节点的实现细节上 Borg 项目与 Kubernetes 项目不尽相同,但它们的出发点却高度一致,即:如何编排、管理、调度用户提交的作业?

    所以,Borg 项目完全可以把 Docker 镜像看做是一种新的应用打包方式。这样,Borg 团队过去在大规模作业管理与编排上的经验就可以直接”套”在 Kubernetes 项目上了。

    这些经验最主要的表现就是, 从一开始,Kubernetes 项目就没有像同时期的各种”容器云”项目那样,把 Docker 作为整个架构的核心,而仅仅把它作为最底层的一个容器运行时实现

    而 Kubernetes 项目要着重解决的问题,则来自于 Borg 的研究人员在论文中提到的一个非常重要的观点:

    运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些关系的处理,才是作业编排和管理系统最困难的地方。

    事实也正是如此。

    其实,这种任务与任务之间的关系,在我们平常的各种技术场景中随处可见。比如,一个 Web 应用与数据库之间的访问关系,一个负载均衡器和它的后端服务之间的代理关系,一个门户应用与授权组件之间的调用关系。

    更进一步地说,同属于一个服务单位的不同功能之间,也完全可能存在这样的关系。比如,一个 Web 应用与日志搜集组件之间的文件交换关系。

    而在容器技术普及之前,传统虚拟机环境对这种关系的处理方法都是比较”粗粒度”的。你会经常发现很多功能并不相关的应用被一股脑儿地部署在同一台虚拟机中,只是因为它们之间偶尔会互相发起几个 HTTP 请求。

    更常见的情况则是,一个应用被部署在虚拟机里之后,你还得手动维护很多跟它协作的守护进程(Daemon),用来处理它的日志搜集、灾难恢复、数据备份等辅助工作。

    但容器技术出现以后,你就不难发现,在”功能单位”的划分上,容器有着独一无二的”细粒度”优势:毕竟容器的本质,只是一个进程而已。

    也就是说,只要你愿意,那些原先拥挤在同一个虚拟机里的各个应用、组件、守护进程,都可以被分别做成镜像,然后运行在一个个专属的容器中。它们之间互不干涉,拥有各自的资源配额,可以被调度在整个集群里的任何一台机器上。而这,正是一个 PaaS 系统最理想的工作状态,也是所谓”微服务”思想得以落地的先决条件。

    当然,如果只做到”封装微服务、调度单容器”这一层次,Docker Swarm 项目就已经绰绰有余了。如果再加上 Compose 项目,你甚至还具备了处理一些简单依赖关系的能力,比如:一个”Web 容器”和它要访问的数据库”DB 容器”。

    在 Compose 项目中,你可以为这样的两个容器定义一个”link”,而 Docker 项目则会负责维护这个”link”关系,其具体做法是:Docker 会在 Web 容器中,将 DB 容器的 IP 地址、端口等信息以环境变量的方式注入进去,供应用进程使用,比如:

    DB_NAME=/web/db
    DB_PORT=tcp://172.17.0.5:5432
    DB_PORT_5432_TCP=tcp://172.17.0.5:5432
    DB_PORT_5432_TCP_PROTO=tcp
    DB_PORT_5432_TCP_PORT=5432
    DB_PORT_5432_TCP_ADDR=172.17.0.5

    而当 DB 容器发生变化时(比如,镜像更新,被迁移到其他宿主机上等等),这些环境变量的值会由 Docker 项目自动更新。 这就是平台项目自动地处理容器间关系的典型例子。

    可是,如果我们现在的需求是,要求这个项目能够处理前面提到的所有类型的关系,甚至还要能够支持未来可能出现的更多种类的关系呢?

    这时,”link”这种单独针对一种案例设计的解决方案就太过简单了。如果你做过架构方面的工作,就会深有感触:一旦要追求项目的普适性,那就一定要从顶层开始做好设计。

    所以, Kubernetes 项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。

    比如,Kubernetes 项目对容器间的”访问”进行了分类,首先总结出了一类非常常见的”紧密交互”的关系,即:这些应用之间需要非常频繁的交互和访问;又或者,它们会直接通过本地文件进行信息交换。

    在常规环境下,这些应用往往会被直接部署在同一台机器上,通过 Localhost 通信,通过本地磁盘目录交换文件。而在 Kubernetes 项目中,这些容器则会被划分为一个”Pod”, Pod 里的容器共享同一个 Network Namespace、同一组数据卷 ,从而达到高效率交换信息的目的。

    Pod 是 Kubernetes 项目中最基础的一个对象,源自于 Google Borg 论文中一个名叫 Alloc 的设计。在后续的章节中,我们会对 Pod 做更进一步地阐述。

    而对于另外一种更为常见的需求,比如 Web 应用与数据库之间的访问关系,Kubernetes 项目则提供了一种叫作”Service”的服务。像这样的两个应用,往往故意不部署在同一台机器上,这样即使 Web 应用所在的机器宕机了,数据库也完全不受影响。可是,我们知道,对于一个容器来说,它的 IP 地址等信息不是固定的,那么 Web 应用又怎么找到数据库容器的 Pod 呢?

    所以,Kubernetes 项目的做法是给 Pod 绑定一个 Service 服务,而 Service 服务声明的 IP 地址等信息是”终生不变”的。这个Service 服务的主要作用,就是作为 Pod 的代理入口(Portal),从而代替 Pod 对外暴露一个固定的网络地址。

    这样,对于 Web 应用的 Pod 来说,它需要关心的就是数据库 Pod 的 Service 信息。不难想象,Service 后端真正代理的 Pod 的 IP 地址、端口等信息的自动更新、维护,则是 Kubernetes 项目的职责。

    像这样,围绕着容器和 Pod 不断向真实的技术场景扩展,我们就能够摸索出一幅如下所示的 Kubernetes 项目核心功能的”全景图”。

    Kubernetes项目核心功能的全景图

    按照这幅图的线索,我们从容器这个最基础的概念出发,首先遇到了容器间”紧密协作”关系的难题,于是就扩展到了 Pod;有了 Pod 之后,我们希望能一次启动多个应用的实例,这样就需要 Deployment 这个 Pod 的多实例管理器;而有了这样一组相同的 Pod 后,我们又需要通过一个固定的 IP 地址和端口以负载均衡的方式访问它,于是就有了 Service。

    可是,如果现在两个不同 Pod 之间不仅有”访问关系”,还要求在发起时加上授权信息。最典型的例子就是 Web 应用对数据库访问时需要 Credential(数据库的用户名和密码)信息。那么,在 Kubernetes 中这样的关系又如何处理呢?

    Kubernetes 项目提供了一种叫作 Secret 的对象,它其实是一个保存在 Etcd 里的键值对数据。这样,你把 Credential 信息以 Secret 的方式存在 Etcd 里,Kubernetes 就会在你指定的 Pod(比如,Web 应用的 Pod)启动时,自动把 Secret 里的数据以 Volume 的方式挂载到容器里。这样,这个 Web 应用就可以访问数据库了。

    除了应用与应用之间的关系外,应用运行的形态是影响”如何容器化这个应用”的第二个重要因素。

    为此,Kubernetes 定义了新的、基于 Pod 改进后的对象。比如 Job,用来描述一次性运行的 Pod(比如,大数据任务);再比如 DaemonSet,用来描述每个宿主机上必须且只能运行一个副本的守护进程服务;又比如 CronJob,则用于描述定时任务等等。

    如此种种,正是 Kubernetes 项目定义容器间关系和形态的主要方法。

    可以看到,Kubernetes 项目并没有像其他项目那样,为每一个管理功能创建一个指令,然后在项目中实现其中的逻辑。这种做法,的确可以解决当前的问题,但是在更多的问题来临之后,往往会力不从心。

    相比之下,在 Kubernetes 项目中,我们所推崇的使用方法是:

    • 首先,通过一个”编排对象”,比如 Pod、Job、CronJob 等,来描述你试图管理的应用;
    • 然后,再为它定义一些”服务对象”,比如 Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等。这些对象,会负责具体的平台级功能。

      这种使用方法,就是所谓的”声明式 API”。这种 API 对应的”编排对象”和”服务对象”,都是 Kubernetes 项目中的 API 对象(API Object)。

      这就是 Kubernetes 最核心的设计理念,也是接下来我会重点剖析的关键技术点。

  3. 最后,我来回答一个更直接的问题:Kubernetes 项目如何启动一个容器化任务呢?

    比如,我现在已经制作好了一个 Nginx 容器镜像,希望让平台帮我启动这个镜像。并且,我要求平台帮我运行两个完全相同的 Nginx 副本,以负载均衡的方式共同对外提供服务。

    • 如果是自己 DIY 的话,可能需要启动两台虚拟机,分别安装两个 Nginx,然后使用 keepalived 为这两个虚拟机做一个虚拟 IP。
    • 而如果使用 Kubernetes 项目呢?你需要做的则是编写如下这样一个 YAML 文件(比如名叫 nginx-deployment.yaml):

      apiVersion: apps/v1
      kind: Deployment
      metadata:
      name: nginx-deployment
      labels:
          app: nginx
      spec:
      replicas: 2
      selector:
          matchLabels:
          app: nginx
      template:
          metadata:
          labels:
              app: nginx
          spec:
          containers:
          - name: nginx
              image: nginx:1.7.9
              ports:
              - containerPort: 80

      在上面这个 YAML 文件中,我们定义了一个 Deployment 对象,它的主体部分(spec.template 部分)是一个使用 Nginx 镜像的 Pod,而这个 Pod 的副本数是 2(replicas=2)。

      然后执行:

      kubectl create -f nginx-deployment.yaml

      这样,两个完全相同的 Nginx 容器副本就被启动了。

      不过,这么看来,做同样一件事情,Kubernetes 用户要做的工作也不少嘛。

      别急,在后续的讲解中,我会陆续介绍 Kubernetes 项目这种”声明式 API”的种种好处,以及基于它实现的强大的编排能力。

9.1 总结

实际上,过去很多的集群管理项目(比如 Yarn、Mesos,以及 Swarm)所擅长的,都是把一个容器,按照某种规则,放置在某个最佳节点上运行起来。这种功能,我们称为”调度”。

而 Kubernetes 项目所擅长的,是按照用户的意愿和整个系统的规则,完全自动化地处理好容器之间的各种关系。 这种功能,就是我们经常听到的一个概念:编排。

所以说,Kubernetes 项目的本质,是为用户提供一个具有普遍意义的容器编排工具。

不过,更重要的是,Kubernetes 项目为用户提供的不仅限于一个工具。它真正的价值,乃在于提供了一套基于容器构建分布式系统的基础依赖。关于这一点,相信你会在今后的学习中,体会的越来越深。

Kubernetes集群搭建与实践

10 | Kubernetes一键部署利器:kubeadm

通过前面几篇文章的内容,我其实阐述了这样一个思想: 要真正发挥容器技术的实力,你就不能仅仅局限于对 Linux 容器本身的钻研和使用。

  1. kubeadm 的工作原理

    在上一篇文章《从容器到容器云:谈谈 Kubernetes 的本质》中,我已经详细介绍了 Kubernetes 的架构和它的组件。在部署时,它的每一个组件都是一个需要被执行的、单独的二进制文件。所以不难想象,SaltStack 这样的运维工具或者由社区维护的脚本的功能,就是要把这些二进制文件传输到指定的机器当中,然后编写控制脚本来启停这些组件。

    不过,在理解了容器技术之后,你可能已经萌生出了这样一个想法, 为什么不用容器部署 Kubernetes 呢?

    事实上,在 Kubernetes 早期的部署脚本里,确实有一个脚本就是用 Docker 部署 Kubernetes 项目的,这个脚本相比于 SaltStack 等的部署方式,也的确简单了不少。

    但是, 这样做会带来一个很麻烦的问题,即:如何容器化 kubelet。

    我在上一篇文章中,已经提到 kubelet 是 Kubernetes 项目用来操作 Docker 等容器运行时的核心组件。可是,除了跟容器运行时打交道外,kubelet 在配置容器网络、管理容器数据卷时,都需要直接操作宿主机。

    而如果现在 kubelet 本身就运行在一个容器里,那么直接操作宿主机就会变得很麻烦。对于网络配置来说还好,kubelet 容器可以通过不开启 Network Namespace(即 Docker 的 host network 模式)的方式,直接共享宿主机的网络栈。可是,要让 kubelet 隔着容器的 Mount Namespace 和文件系统,操作宿主机的文件系统,就有点儿困难了。

    比如,如果用户想要使用 NFS 做容器的持久化数据卷,那么 kubelet 就需要在容器进行绑定挂载前,在宿主机的指定目录上,先挂载 NFS 的远程目录。

    可是,这时候问题来了。由于现在 kubelet 是运行在容器里的,这就意味着它要做的这个”mount -F nfs”命令,被隔离在了一个单独的 Mount Namespace 中。即,kubelet 做的挂载操作,不能被”传播”到宿主机上。

    对于这个问题,有人说,可以使用 setns() 系统调用,在宿主机的 Mount Namespace 中执行这些挂载操作;也有人说,应该让 Docker 支持一个-mnt=host 的参数。

    但是,到目前为止,在容器里运行 kubelet,依然没有很好的解决办法,我也不推荐你用容器去部署 Kubernetes 项目。

    正因为如此,kubeadm 选择了一种妥协方案:

    把 kubelet 直接运行在宿主机上,然后使用容器部署其他的 Kubernetes 组件。

    所以,你使用 kubeadm 的第一步,是在机器上手动安装 kubeadm、kubelet 和 kubectl 这三个二进制文件。

    接下来,你就可以使用”kubeadm init”部署 Master 节点了。

  2. kubeadm init 的工作流程

    当你执行 kubeadm init 指令后, kubeadm 首先要做的,是一系列的检查工作,以确定这台机器可以用来部署 Kubernetes。 这一步检查,我们称为”Preflight Checks”,它可以为你省掉很多后续的麻烦。

    其实,Preflight Checks 包括了很多方面,比如:

    • Linux 内核的版本必须是否是 3.10 以上?
    • Linux Cgroups 模块是否可用?
    • 机器的 hostname 是否标准?在 Kubernetes 项目里,机器的名字以及一切存储在 Etcd 中的 API 对象,都必须使用标准的 DNS 命名(RFC 1123)。
    • 用户安装的 kubeadm 和 kubelet 的版本是否匹配?
    • 机器上是不是已经安装了 Kubernetes 的二进制文件?
    • Kubernetes 的工作端口 10250/10251/10252 端口是不是已经被占用?
    • ip、mount 等 Linux 指令是否存在?
    • Docker 是否已经安装?
    • ……

      在通过了 Preflight Checks 之后,kubeadm 要为你做的,是生成 Kubernetes 对外提供服务所需的各种证书和对应的目录。

      Kubernetes 对外提供服务时,除非专门开启”不安全模式”,否则都要通过 HTTPS 才能访问 kube-apiserver。这就需要为 Kubernetes 集群配置好证书文件。

      kubeadm 为 Kubernetes 项目生成的证书文件都放在 Master 节点的 /etc/kubernetes/pki 目录下。在这个目录下,最主要的证书文件是 ca.crt 和对应的私钥 ca.key。

      此外,用户使用 kubectl 获取容器日志等 streaming 操作时,需要通过 kube-apiserver 向 kubelet 发起请求,这个连接也必须是安全的。kubeadm 为这一步生成的是 apiserver-kubelet-client.crt 文件,对应的私钥是 apiserver-kubelet-client.key。

      除此之外,Kubernetes 集群中还有 Aggregate APIServer 等特性,也需要用到专门的证书,这里我就不再一一列举了。需要指出的是,你可以选择不让 kubeadm 为你生成这些证书,而是拷贝现有的证书到如下证书的目录里:

      /etc/kubernetes/pki/ca.{crt,key}

      这时,kubeadm 就会跳过证书生成的步骤,把它完全交给用户处理。

      证书生成后,kubeadm 接下来会为其他组件生成访问 kube-apiserver 所需的配置文件。这些文件的路径是:/etc/kubernetes/xxx.conf:

      ls /etc/kubernetes/
      admin.conf  controller-manager.conf  kubelet.conf  scheduler.conf

      这些文件里面记录的是,当前这个 Master 节点的服务器地址、监听端口、证书目录等信息。这样,对应的客户端(比如 scheduler,kubelet 等),可以直接加载相应的文件,使用里面的信息与 kube-apiserver 建立安全连接。

      接下来,kubeadm 会为 Master 组件生成 Pod 配置文件。我已经在上一篇文章中和你介绍过 Kubernetes 有三个 Master 组件 kube-apiserver、kube-controller-manager、kube-scheduler,而它们都会被使用 Pod 的方式部署起来。

      你可能会有些疑问:这时,Kubernetes 集群尚不存在,难道 kubeadm 会直接执行 docker run 来启动这些容器吗?

      当然不是。

      在 Kubernetes 中,有一种特殊的容器启动方法叫做”Static Pod”。它允许你把要部署的 Pod 的 YAML 文件放在一个指定的目录里。这样,当这台机器上的 kubelet 启动时,它会自动检查这个目录,加载所有的 Pod YAML 文件,然后在这台机器上启动它们。

      从这一点也可以看出,kubelet 在 Kubernetes 项目中的地位非常高,在设计上它就是一个完全独立的组件,而其他 Master 组件,则更像是辅助性的系统容器。

      在 kubeadm 中,Master 组件的 YAML 文件会被生成在 /etc/kubernetes/manifests 路径下。比如,kube-apiserver.yaml:

      apiVersion: v1
      kind: Pod
      metadata:
      annotations:
          scheduler.alpha.kubernetes.io/critical-pod: ""
      creationTimestamp: null
      labels:
          component: kube-apiserver
          tier: control-plane
      name: kube-apiserver
      namespace: kube-system
      spec:
      containers:
      - command:
          - kube-apiserver
          - --authorization-mode=Node,RBAC
          - --runtime-config=api/all=true
          - --advertise-address=10.168.0.2
          ...
          - --tls-cert-file=/etc/kubernetes/pki/apiserver.crt
          - --tls-private-key-file=/etc/kubernetes/pki/apiserver.key
          image: k8s.gcr.io/kube-apiserver-amd64:v1.11.1
          imagePullPolicy: IfNotPresent
          livenessProbe:
          ...
          name: kube-apiserver
          resources:
          requests:
              cpu: 250m
          volumeMounts:
          - mountPath: /usr/share/ca-certificates
          name: usr-share-ca-certificates
          readOnly: true
          ...
      hostNetwork: true
      priorityClassName: system-cluster-critical
      volumes:
      - hostPath:
          path: /etc/ca-certificates
          type: DirectoryOrCreate
          name: etc-ca-certificates
      ...

      关于一个 Pod 的 YAML 文件怎么写、里面的字段如何解读,我会在后续专门的文章中为你详细分析。在这里,你只需要关注这样几个信息:

    1. 这个 Pod 里只定义了一个容器,它使用的镜像是:k8s.gcr.io/kube-apiserver-amd64:v1.11.1 。这个镜像是 Kubernetes 官方维护的一个组件镜像。
    2. 这个容器的启动命令(commands)是 kube-apiserver —authorization-mode=Node,RBAC …,这样一句非常长的命令。其实,它就是容器里 kube-apiserver 这个二进制文件再加上指定的配置参数而已。
    3. 如果你要修改一个已有集群的 kube-apiserver 的配置,需要修改这个 YAML 文件。
    4. 这些组件的参数也可以在部署时指定,我很快就会讲解到。

      在这一步完成后,kubeadm 还会再生成一个 Etcd 的 Pod YAML 文件,用来通过同样的 Static Pod 的方式启动 Etcd。所以,最后 Master 组件的 Pod YAML 文件如下所示:

      $ ls /etc/kubernetes/manifests/
      etcd.yaml  kube-apiserver.yaml  kube-controller-manager.yaml  kube-scheduler.yaml

      而一旦这些 YAML 文件出现在被 kubelet 监视的 /etc/kubernetes/manifests 目录下,kubelet 就会自动创建这些 YAML 文件中定义的 Pod,即 Master 组件的容器。

      Master 容器启动后,kubeadm 会通过检查 localhost:6443/healthz 这个 Master 组件的健康检查 URL,等待 Master 组件完全运行起来。

      然后,kubeadm 就会为集群生成一个 bootstrap token。 在后面,只要持有这个 token,任何一个安装了 kubelet 和 kubeadm 的节点,都可以通过 kubeadm join 加入到这个集群当中。

      这个 token 的值和使用方法会,会在 kubeadm init 结束后被打印出来。

      在 token 生成之后,kubeadm 会将 ca.crt 等 Master 节点的重要信息,通过 ConfigMap 的方式保存在 Etcd 当中,供后续部署 Node 节点使用。 这个 ConfigMap 的名字是 cluster-info。

      kubeadm init 的最后一步,就是安装默认插件。 Kubernetes 默认 kube-proxy 和 DNS 这两个插件是必须安装的。它们分别用来提供整个集群的服务发现和 DNS 功能。其实,这两个插件也只是两个容器镜像而已,所以 kubeadm 只要用 Kubernetes 客户端创建两个 Pod 就可以了。

  3. kubeadm join 的工作流程

    这个流程其实非常简单,kubeadm init 生成 bootstrap token 之后,你就可以在任意一台安装了 kubelet 和 kubeadm 的机器上执行 kubeadm join 了。

    可是,为什么执行 kubeadm join 需要这样一个 token 呢?

    因为,任何一台机器想要成为 Kubernetes 集群中的一个节点,就必须在集群的 kube-apiserver 上注册。可是,要想跟 apiserver 打交道,这台机器就必须要获取到相应的证书文件(CA 文件)。可是,为了能够一键安装,我们就不能让用户去 Master 节点上手动拷贝这些文件。

    所以,kubeadm 至少需要发起一次”不安全模式”的访问到 kube-apiserver,从而拿到保存在 ConfigMap 中的 cluster-info(它保存了 APIServer 的授权信息)。而 bootstrap token,扮演的就是这个过程中的安全验证的角色。

    只要有了 cluster-info 里的 kube-apiserver 的地址、端口、证书,kubelet 就可以以”安全模式”连接到 apiserver 上,这样一个新的节点就部署完成了。

    接下来,你只要在其他节点上重复这个指令就可以了。

  4. 配置 kubeadm 的部署参数

    我在前面讲解了 kubeadm 部署 Kubernetes 集群最关键的两个步骤,kubeadm init 和 kubeadm join。相信你一定会有这样的疑问:kubeadm 确实简单易用,可是我又该如何定制我的集群组件参数呢?

    比如,我要指定 kube-apiserver 的启动参数,该怎么办?

    在这里,我强烈推荐你在使用 kubeadm init 部署 Master 节点时,使用下面这条指令:

    kubeadm init --config kubeadm.yaml

    这时,你就可以给 kubeadm 提供一个 YAML 文件(比如,kubeadm.yaml),它的内容如下所示(我仅列举了主要部分):

    apiVersion: kubeadm.k8s.io/v1alpha2
    kind: MasterConfiguration
    kubernetesVersion: v1.11.0
    api:
    advertiseAddress: 192.168.0.102
    bindPort: 6443
    ...
    etcd:
    local:
        dataDir: /var/lib/etcd
        image: ""
    imageRepository: k8s.gcr.io
    kubeProxy:
    config:
        bindAddress: 0.0.0.0
        ...
    kubeletConfiguration:
    baseConfig:
        address: 0.0.0.0
        ...
    networking:
    dnsDomain: cluster.local
    podSubnet: ""
    serviceSubnet: 10.96.0.0/12
    nodeRegistration:
    criSocket: /var/run/dockershim.sock
    ...

    通过制定这样一个部署参数配置文件,你就可以很方便地在这个文件里填写各种自定义的部署参数了。比如,我现在要指定 kube-apiserver 的参数,那么我只要在这个文件里加上这样一段信息:

    ...
    apiServerExtraArgs:
    advertise-address: 192.168.0.103
    anonymous-auth: false
    enable-admission-plugins: AlwaysPullImages,DefaultStorageClass
    audit-log-path: /home/johndoe/audit.log

    然后,kubeadm 就会使用上面这些信息替换 /etc/kubernetes/manifests/kube-apiserver.yaml 里的 command 字段里的参数了。

    而这个 YAML 文件提供的可配置项远不止这些。比如,你还可以修改 kubelet 和 kube-proxy 的配置,修改 Kubernetes 使用的基础镜像的 URL(默认的k8s.gcr.io/xxx镜像 URL 在国内访问是有困难的),指定自己的证书文件,指定特殊的容器运行时等等。

10.1 总结

kubeadm 的源代码,直接就在 kubernetes/cmd/kubeadm 目录下,是 Kubernetes 项目的一部分。其中,app/phases 文件夹下的代码,对应的就是我在这篇文章中详细介绍的每一个具体步骤。

实际上,kubeadm 几乎完全是一位高中生的作品。他叫 Lucas Käldström,芬兰人,今年只有 18 岁。kubeadm,是他 17 岁时用业余时间完成的一个社区项目。

11 | 从0到1:搭建一个完整的Kubernetes集群

  1. 安装 kubeadm 和 Docker

  2. 部署 Kubernetes 的 Master 节点

  3. 部署网络插件

  4. 部署 Kubernetes 的 Worker 节点

  5. 通过 Taint/Toleration 调整 Master 执行 Pod 的策略

    默认情况下 Master 节点是不允许运行用户 Pod 的。而 Kubernetes 做到这一点,依靠的是 Kubernetes 的 Taint/Toleration 机制。

    它的原理非常简单:一旦某个节点被加上了一个 Taint,即被”打上了污点”,那么所有 Pod 就都不能在这个节点上运行,因为 Kubernetes 的 Pod 都有”洁癖”。

    除非,有个别的 Pod 声明自己能”容忍”这个”污点”,即声明了 Toleration,它才可以在这个节点上运行。

    其中,为节点打上”污点”(Taint)的命令是:

    kubectl taint nodes node1 foo=bar:NoSchedule

    这时,该 node1 节点上就会增加一个键值对格式的 Taint,即:foo=bar:NoSchedule。其中值里面的 NoSchedule,意味着这个 Taint 只会在调度新 Pod 时产生作用,而不会影响已经在 node1 上运行的 Pod,哪怕它们没有 Toleration。

    那么 Pod 又如何声明 Toleration 呢?

    我们只要在 Pod 的.yaml 文件中的 spec 部分,加入 tolerations 字段即可:

    apiVersion: v1
    kind: Pod
    ...
    spec:
    tolerations:
    - key: "foo"
        operator: "Equal"
        value: "bar"
        effect: "NoSchedule"

    这个 Toleration 的含义是,这个 Pod 能”容忍”所有键值对为 foo=bar 的 Taint( operator: “Equal”,”等于”操作)。

    现在回到我们已经搭建的集群上来。这时,如果你通过 kubectl describe 检查一下 Master 节点的 Taint 字段,就会有所发现了:

    $ kubectl describe node master
    Name:               master
    Roles:              master
    Taints:             node-role.kubernetes.io/master:NoSchedule

    可以看到,Master 节点默认被加上了node-role.kubernetes.io/master:NoSchedule这样一个”污点”,其中”键”是node-role.kubernetes.io/master,而没有提供”值”。

    此时,你就需要像下面这样用”Exists”操作符(operator: “Exists”,”存在”即可)来说明,该 Pod 能够容忍所有以 foo 为键的 Taint,才能让这个 Pod 运行在该 Master 节点上:

    apiVersion: v1
    kind: Pod
    ...
    spec:
    tolerations:
    - key: "foo"
        operator: "Exists"
        effect: "NoSchedule"

    当然,如果你就是想要一个单节点的 Kubernetes,删除这个 Taint 才是正确的选择:

    kubectl taint nodes --all node-role.kubernetes.io/master-

    如上所示,我们在”node-role.kubernetes.io/master”这个键后面加上了一个短横线”-“,这个格式就意味着移除所有以”node-role.kubernetes.io/master”为键的 Taint。

  6. 部署 Dashboard 可视化插件

  7. 部署容器存储插件

    我在前面介绍容器原理时已经提到过,很多时候我们需要用数据卷(Volume)把外面宿主机上的目录或者文件挂载进容器的 Mount Namespace 中,从而达到容器和宿主机共享这些目录或者文件的目的。容器里的应用,也就可以在这些数据卷中新建和写入文件。

    可是,如果你在某一台机器上启动的一个容器,显然无法看到其他机器上的容器在它们的数据卷里写入的文件。这是容器最典型的特征之一:无状态。

    而容器的持久化存储,就是用来保存容器存储状态的重要手段:存储插件会在容器里挂载一个基于网络或者其他机制的远程数据卷,使得在容器里创建的文件,实际上是保存在远程存储服务器上,或者以分布式的方式保存在多个节点上,而与当前宿主机没有任何绑定关系。这样,无论你在其他哪个宿主机上启动新的容器,都可以请求挂载指定的持久化存储卷,从而访问到数据卷里保存的内容。这就是”持久化”的含义。

    由于 Kubernetes 本身的松耦合设计,绝大多数存储项目,比如 Ceph、GlusterFS、NFS 等,都可以为 Kubernetes 提供持久化存储能力。在这次的部署实战中,我会选择部署一个很重要的 Kubernetes 存储插件项目:Rook。

    Rook 项目是一个基于 Ceph 的 Kubernetes 存储插件(它后期也在加入对更多存储实现的支持)。不过,不同于对 Ceph 的简单封装,Rook 在自己的实现中加入了水平扩展、迁移、灾难备份、监控等大量的企业级功能,使得这个项目变成了一个完整的、生产级别可用的容器存储插件。

    得益于容器化技术,用两条指令,Rook 就可以把复杂的 Ceph 存储后端部署起来:

    kubectl apply -f https://raw.githubusercontent.com/rook/rook/master/cluster/examples/kubernetes/ceph/operator.yaml
    kubectl apply -f https://raw.githubusercontent.com/rook/rook/master/cluster/examples/kubernetes/ceph/cluster.yaml

    在部署完成后,你就可以看到 Rook 项目会将自己的 Pod 放置在由它自己管理的两个 Namespace 当中:

    $ kubectl get pods -n rook-ceph-system
    NAME                                  READY     STATUS    RESTARTS   AGE
    rook-ceph-agent-7cv62                 1/1       Running   0          15s
    rook-ceph-operator-78d498c68c-7fj72   1/1       Running   0          44s
    rook-discover-2ctcv                   1/1       Running   0          15s
    
    $ kubectl get pods -n rook-ceph
    NAME                   READY     STATUS    RESTARTS   AGE
    rook-ceph-mon0-kxnzh   1/1       Running   0          13s
    rook-ceph-mon1-7dn2t   1/1       Running   0          2s

    这样,一个基于 Rook 持久化存储集群就以容器的方式运行起来了,而接下来在 Kubernetes 项目上创建的所有 Pod 就能够通过 Persistent Volume(PV)和 Persistent Volume Claim(PVC)的方式,在容器里挂载由 Ceph 提供的数据卷了。

    而 Rook 项目,则会负责这些数据卷的生命周期管理、灾难备份等运维工作。关于这些容器持久化存储的知识,我会在后续章节中专门讲解。

    这时候,你可能会有个疑问:为什么我要选择 Rook 项目呢?

    其实,是因为这个项目很有前途。

    如果你去研究一下 Rook 项目的实现,就会发现它巧妙地依赖了 Kubernetes 提供的编排能力,合理的使用了很多诸如 Operator、CRD 等重要的扩展特性(这些特性我都会在后面的文章中逐一讲解到)。这使得 Rook 项目,成为了目前社区中基于 Kubernetes API 构建的最完善也最成熟的容器存储插件。我相信,这样的发展路线,很快就会得到整个社区的推崇。

    备注:其实,在很多时候,大家说的所谓”云原生”,就是”Kubernetes 原生”的意思。而像 Rook、Istio 这样的项目,正是贯彻这个思路的典范。在我们后面讲解了声明式 API 之后,相信你对这些项目的设计思想会有更深刻的体会。

12 | 牛刀小试:我的第一个容器化应用

使用 Kubernetes 的必备技能:编写配置文件。

备注:配置文件可以是 YAML 或者 JSON 格式的。为方便阅读与理解,在后面的讲解中,我会统一使用 YAML 文件来指代它们。

Kubernetes 跟 Docker 等很多项目最大的不同,就在于它不推荐你使用命令行的方式直接运行容器(虽然 Kubernetes 项目也支持这种方式,比如:kubectl run),而是希望你用 YAML 文件的方式,即:把容器的定义、参数、配置,统统记录在一个 YAML 文件中,然后用这样一句指令把它运行起来:

kubectl create -f 我的配置文件

这么做最直接的好处是,你会有一个文件能记录下 Kubernetes 到底”run”了什么。比如下面这个例子:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

像这样的一个 YAML 文件,对应到 Kubernetes 中,就是一个 API Object(API 对象)。当你为这个对象的各个字段填好值并提交给 Kubernetes 之后,Kubernetes 就会负责创建出这些对象所定义的容器或者其他类型的 API 资源。

可以看到,这个 YAML 文件中的 Kind 字段,指定了这个 API 对象的类型(Type),是一个 Deployment。

所谓 Deployment,是一个定义多副本应用(即多个副本 Pod)的对象,我在前面的文章中(也是第 9 篇文章 《从容器到容器云:谈谈 Kubernetes 的本质》 )曾经简单提到过它的用法。此外,Deployment 还负责在 Pod 定义发生变化时,对每个副本进行滚动更新(Rolling Update)。

在上面这个 YAML 文件中,我给它定义的 Pod 副本个数 (spec.replicas) 是:2。

而这些 Pod 具体的又长什么样子呢?

为此,我定义了一个 Pod 模版(spec.template),这个模版描述了我想要创建的 Pod 的细节。在上面的例子里,这个 Pod 里只有一个容器,这个容器的镜像(spec.containers.image)是 nginx:1.7.9,这个容器监听端口(containerPort)是 80。

关于 Pod 的设计和用法我已经在第 9 篇文章 《从容器到容器云:谈谈 Kubernetes 的本质》 中简单的介绍过。而在这里,你需要记住这样一句话:

Pod 就是 Kubernetes 世界里的”应用”;而一个应用,可以由多个容器组成。

需要注意的是,像这样使用一种 API 对象(Deployment)管理另一种 API 对象(Pod)的方法,在 Kubernetes 中,叫作”控制器”模式(controller pattern)。 在我们的例子中,Deployment 扮演的正是 Pod 的控制器的角色。关于 Pod 和控制器模式的更多细节,我会在后续编排部分做进一步讲解。

你可能还注意到,这样的每一个 API 对象都有一个叫作 Metadata 的字段,这个字段就是 API 对象的”标识”,即元数据,它也是我们从 Kubernetes 里找到这个对象的主要依据。这其中最主要使用到的字段是 Labels。

顾名思义,Labels 就是一组 key-value 格式的标签。而像 Deployment 这样的控制器对象,就可以通过这个 Labels 字段从 Kubernetes 中过滤出它所关心的被控制对象。

比如,在上面这个 YAML 文件中,Deployment 会把所有正在运行的、携带”app: nginx”标签的 Pod 识别为被管理的对象,并确保这些 Pod 的总数严格等于两个。

而这个过滤规则的定义,是在 Deployment 的”spec.selector.matchLabels”字段。我们一般称之为:Label Selector。

另外,在 Metadata 中,还有一个与 Labels 格式、层级完全相同的字段叫 Annotations,它专门用来携带 key-value 格式的内部信息。所谓内部信息,指的是对这些信息感兴趣的,是 Kubernetes 组件本身,而不是用户。所以大多数 Annotations,都是在 Kubernetes 运行过程中,被自动加在这个 API 对象上。

一个 Kubernetes 的 API 对象的定义,大多可以分为 Metadata 和 Spec 两个部分。前者存放的是这个对象的元数据,对所有 API 对象来说,这一部分的字段和格式基本上是一样的;而后者存放的,则是属于这个对象独有的定义,用来描述它所要表达的功能。

在了解了上述 Kubernetes 配置文件的基本知识之后,我们现在就可以把这个 YAML 文件”运行”起来。正如前所述,你可以使用 kubectl create 指令完成这个操作:

kubectl create -f nginx-deployment.yaml

然后,通过 kubectl get 命令检查这个 YAML 运行起来的状态是不是与我们预期的一致:

$ kubectl get pods -l app=nginx
NAME                                READY     STATUS    RESTARTS   AGE
nginx-deployment-67594d6bf6-9gdvr   1/1       Running   0          10m
nginx-deployment-67594d6bf6-v6j7w   1/1       Running   0          10m

kubectl get 指令的作用,就是从 Kubernetes 里面获取(GET)指定的 API 对象。可以看到,在这里我还加上了一个 -l 参数,即获取所有匹配 app: nginx 标签的 Pod。需要注意的是, 在命令行中,所有 key-value 格式的参数,都使用”=”而非”:”表示。

从这条指令返回的结果中,我们可以看到现在有两个 Pod 处于 Running 状态,也就意味着我们这个 Deployment 所管理的 Pod 都处于预期的状态。

此外, 你还可以使用 kubectl describe 命令,查看一个 API 对象的细节,比如:

$ kubectl describe pod nginx-deployment-67594d6bf6-9gdvr
Name:               nginx-deployment-67594d6bf6-9gdvr
Namespace:          default
Priority:           0
PriorityClassName:  <none>
Node:               node-1/10.168.0.3
Start Time:         Thu, 16 Aug 2018 08:48:42 +0000
Labels:             app=nginx
                    pod-template-hash=2315082692
Annotations:        <none>
Status:             Running
IP:                 10.32.0.23
Controlled By:      ReplicaSet/nginx-deployment-67594d6bf6
...
Events:

  Type     Reason                  Age                From               Message

  ----     ------                  ----               ----               -------

  Normal   Scheduled               1m                 default-scheduler  Successfully assigned default/nginx-deployment-67594d6bf6-9gdvr to node-1
  Normal   Pulling                 25s                kubelet, node-1    pulling image "nginx:1.7.9"
  Normal   Pulled                  17s                kubelet, node-1    Successfully pulled image "nginx:1.7.9"
  Normal   Created                 17s                kubelet, node-1    Created container
  Normal   Started                 17s                kubelet, node-1    Started container

在 kubectl describe 命令返回的结果中,你可以清楚地看到这个 Pod 的详细信息,比如它的 IP 地址等等。其中,有一个部分值得你特别关注,它就是 Events(事件)

在 Kubernetes 执行的过程中,对 API 对象的所有重要操作,都会被记录在这个对象的 Events 里,并且显示在 kubectl describe 指令返回的结果中。

比如,对于这个 Pod,我们可以看到它被创建之后,被调度器调度(Successfully assigned)到了 node-1,拉取了指定的镜像(pulling image),然后启动了 Pod 里定义的容器(Started container)。

所以,这个部分正是我们将来进行 Debug 的重要依据。 如果有异常发生,你一定要第一时间查看这些 Events ,往往可以看到非常详细的错误信息。

接下来,如果我们要对这个 Nginx 服务进行升级,把它的镜像版本从 1.7.9 升级为 1.8,要怎么做呢?

很简单,我们只要修改这个 YAML 文件即可。

...
    spec:
      containers:
      - name: nginx
        image: nginx:1.8 # 这里被从 1.7.9 修改为 1.8
        ports:
      - containerPort: 80

可是,这个修改目前只发生在本地,如何让这个更新在 Kubernetes 里也生效呢?

我们可以使用 kubectl replace 指令来完成这个更新:

kubectl replace -f nginx-deployment.yaml

不过,在本专栏里,我推荐你使用 kubectl apply 命令,来统一进行 Kubernetes 对象的创建和更新操作,具体做法如下所示:

# 修改 nginx-deployment.yaml 的内容
$ kubectl apply -f nginx-deployment.yaml

这样的操作方法,是 Kubernetes”声明式 API”所推荐的使用方法。也就是说,作为用户,你不必关心当前的操作是创建,还是更新,你执行的命令始终是 kubectl apply,而 Kubernetes 则会根据 YAML 文件的内容变化,自动进行具体的处理。

而这个流程的好处是,它有助于帮助开发和运维人员,围绕着可以版本化管理的 YAML 文件,而不是”行踪不定”的命令行进行协作,从而大大降低开发人员和运维人员之间的沟通成本。

举个例子,一位开发人员开发好一个应用,制作好了容器镜像。那么他就可以在应用的发布目录里附带上一个 Deployment 的 YAML 文件。

而运维人员,拿到这个应用的发布目录后,就可以直接用这个 YAML 文件执行 kubectl apply 操作把它运行起来。

这时候,如果开发人员修改了应用,生成了新的发布内容,那么这个 YAML 文件,也就需要被修改,并且成为这次变更的一部分。

而接下来,运维人员可以使用 git diff 命令查看到这个 YAML 文件本身的变化,然后继续用 kubectl apply 命令更新这个应用。

所以说,如果通过容器镜像,我们能够保证应用本身在开发与部署环境里的一致性的话,那么现在,Kubernetes 项目通过这些 YAML 文件,就保证了应用的”部署参数”在开发与部署环境中的一致性。

而当应用本身发生变化时,开发人员和运维人员可以依靠容器镜像来进行同步;当应用部署参数发生变化时,这些 YAML 文件就是他们相互沟通和信任的媒介。

以上,就是 Kubernetes 发布应用的最基本操作了。

接下来,我们再在这个 Deployment 中尝试声明一个 Volume。

在 Kubernetes 中,Volume 是属于 Pod 对象的一部分。所以,我们就需要修改这个 YAML 文件里的 template.spec 字段,如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.8
        ports:
        - containerPort: 80
        volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: nginx-vol
      volumes:
      - name: nginx-vol
        emptyDir: {}

可以看到,我们在 Deployment 的 Pod 模板部分添加了一个 volumes 字段,定义了这个 Pod 声明的所有 Volume。它的名字叫作 nginx-vol,类型是 emptyDir。

那什么是 emptyDir 类型呢?

它其实就等同于我们之前讲过的 Docker 的隐式 Volume 参数,即:不显式声明宿主机目录的 Volume。所以,Kubernetes 也会在宿主机上创建一个临时目录,这个目录将来就会被绑定挂载到容器所声明的 Volume 目录上。

备注:不难看到,Kubernetes 的 emptyDir 类型,只是把 Kubernetes 创建的临时目录作为 Volume 的宿主机目录,交给了 Docker。这么做的原因,是 Kubernetes 不想依赖 Docker 自己创建的那个 _data 目录。

而 Pod 中的容器,使用的是 volumeMounts 字段来声明自己要挂载哪个 Volume,并通过 mountPath 字段来定义容器内的 Volume 目录,比如:/usr/share/nginx/html。

当然,Kubernetes 也提供了显式的 Volume 定义,它叫做 hostPath。比如下面的这个 YAML 文件:

...
   volumes:
     - name: nginx-vol
       hostPath:
         path: /var/data

这样,容器 Volume 挂载的宿主机目录,就变成了 /var/data。

在上述修改完成后,我们还是使用 kubectl apply 指令,更新这个 Deployment:

kubectl apply -f nginx-deployment.yaml

接下来,你可以通过 kubectl get 指令,查看两个 Pod 被逐一更新的过程:

$ kubectl get pods
NAME                                READY     STATUS              RESTARTS   AGE
nginx-deployment-5c678cfb6d-v5dlh   0/1       ContainerCreating   0          4s
nginx-deployment-67594d6bf6-9gdvr   1/1       Running             0          10m
nginx-deployment-67594d6bf6-v6j7w   1/1       Running             0          10m

$ kubectl get pods
NAME                                READY     STATUS    RESTARTS   AGE
nginx-deployment-5c678cfb6d-lg9lw   1/1       Running   0          8s
nginx-deployment-5c678cfb6d-v5dlh   1/1       Running   0          19s

从返回结果中,我们可以看到,新旧两个 Pod,被交替创建、删除,最后剩下的就是新版本的 Pod。这个滚动更新的过程,我也会在后续进行详细的讲解。

然后,你可以使用 kubectl describe 查看一下最新的 Pod,就会发现 Volume 的信息已经出现在了 Container 描述部分:

...
Containers:
  nginx:
    Container ID:   docker://07b4f89248791c2aa47787e3da3cc94b48576cd173018356a6ec8db2b6041343
    Image:          nginx:1.8
    ...
    Environment:    <none>
    Mounts:
      /usr/share/nginx/html from nginx-vol (rw)
...
Volumes:
  nginx-vol:
    Type:    EmptyDir (a temporary directory that shares a pod's lifetime)

备注:作为一个完整的容器化平台项目,Kubernetes 为我们提供的 Volume 类型远远不止这些,在容器存储章节里,我将会为你详细介绍这部分内容。

最后,你还可以使用 kubectl exec 指令,进入到这个 Pod 当中(即容器的 Namespace 中)查看这个 Volume 目录:

$ kubectl exec -it nginx-deployment-5c678cfb6d-lg9lw -- /bin/bash
# ls /usr/share/nginx/html

此外,你想要从 Kubernetes 集群中删除这个 Nginx Deployment 的话,直接执行:

kubectl delete -f nginx-deployment.yaml

12.1 总结

可以看到,Kubernetes 推荐的使用方式,是用一个 YAML 文件来描述你所要部署的 API 对象。然后,统一使用 kubectl apply 命令完成对这个对象的创建和更新操作。

而 Kubernetes 里”最小”的 API 对象是 Pod。Pod 可以等价为一个应用,所以,Pod 可以由多个紧密协作的容器组成。

在 Kubernetes 中,我们经常会看到它通过一种 API 对象来管理另一种 API 对象,比如 Deployment 和 Pod 之间的关系;而由于 Pod 是”最小”的对象,所以它往往都是被其他对象控制的。这种组合方式,正是 Kubernetes 进行容器编排的重要模式。

而像这样的 Kubernetes API 对象,往往由 Metadata 和 Spec 两部分组成,其中 Metadata 里的 Labels 字段是 Kubernetes 过滤对象的主要手段。

在这些字段里面,容器想要使用的数据卷,也就是 Volume,正是 Pod 的 Spec 字段的一部分。而 Pod 里的每个容器,则需要显式的声明自己要挂载哪个 Volume。

上面这些基于 YAML 文件的容器管理方式,跟 Docker、Mesos 的使用习惯都是不一样的,而从 docker run 这样的命令行操作,向 kubectl apply YAML 文件这样的声明式 API 的转变,是每一个容器技术学习者,必须要跨过的第一道门槛。

所以,如果你想要快速熟悉 Kubernetes,请按照下面的流程进行练习:

  • 首先,在本地通过 Docker 测试代码,制作镜像;
  • 然后,选择合适的 Kubernetes API 对象,编写对应 YAML 文件(比如,Pod,Deployment);
  • 最后,在 Kubernetes 上部署这个 YAML 文件。

更重要的是,在部署到 Kubernetes 之后,接下来的所有操作,要么通过 kubectl 来执行,要么通过修改 YAML 文件来实现,就尽量不要再碰 Docker 的命令行了

容器编排与Kubernetes作业管理

13 | 为什么我们需要Pod?

Pod,是 Kubernetes 项目中最小的 API 对象。如果换一个更专业的说法,我们可以这样描述:Pod,是 Kubernetes 项目的原子调度单位。

但是, 为什么我们会需要 Pod?

是啊,我们在前面已经花了很多精力去解读 Linux 容器的原理、分析了 Docker 容器的本质,终于,”Namespace 做隔离,Cgroups 做限制,rootfs 做文件系统”这样的”三句箴言”可以朗朗上口了, 为什么 Kubernetes 项目又突然搞出一个 Pod 来呢?

容器的本质是进程。容器,就是未来云计算系统中的进程;容器镜像就是这个系统里的”.exe”安装包。那么 Kubernetes 呢?Kubernetes 就是操作系统!

在 linux 中执行命令

$ pstree -g
systemd(1)-+-accounts-daemon(1984)-+-{gdbus}(1984)
           | `-{gmain}(1984)
           |-acpid(2044)
          ...
           |-lxcfs(1936)-+-{lxcfs}(1936)
           | `-{lxcfs}(1936)
           |-mdadm(2135)
           |-ntpd(2358)
           |-polkitd(2128)-+-{gdbus}(2128)
           | `-{gmain}(2128)
           |-rsyslogd(1632)-+-{in:imklog}(1632)
           |  |-{in:imuxsock) S 1(1632)
           | `-{rs:main Q:Reg}(1632)
           |-snapd(1942)-+-{snapd}(1942)
           |  |-{snapd}(1942)
           |  |-{snapd}(1942)
           |  |-{snapd}(1942)
           |  |-{snapd}(1942)

不难发现,在一个真正的操作系统里,进程并不是”孤苦伶仃”地独自运行的,而是以进程组的方式,”有原则地”组织在一起。比如,这里有一个叫作 rsyslogd 的程序,它负责的是 Linux 操作系统里的日志处理。可以看到,rsyslogd 的主程序 main,和它要用到的内核日志模块 imklog 等,同属于 1632 进程组。这些进程相互协作,共同完成 rsyslogd 程序的职责。

注意:我在本篇中提到的”进程”,比如,rsyslogd 对应的 imklog,imuxsock 和 main,严格意义上来说,其实是 Linux 操作系统语境下的”线程”。这些线程,或者说,轻量级进程之间,可以共享文件、信号、数据内存、甚至部分代码,从而紧密协作共同完成一个程序的职责。所以同理,我提到的”进程组”,对应的也是 Linux 操作系统语境下的”线程组”。这种命名关系与实际情况的不一致,是 Linux 发展历史中的一个遗留问题。

而 Kubernetes 项目所做的,其实就是将”进程组”的概念映射到了容器技术中,并使其成为了这个云计算”操作系统”里的”一等公民”。

Kubernetes 项目之所以要这么做的原因,我在前面介绍 Kubernetes 和 Borg 的关系时曾经提到过:在 Borg 项目的开发和实践过程中,Google 公司的工程师们发现,他们部署的应用,往往都存在着类似于”进程和进程组”的关系。更具体地说,就是这些应用之间有着密切的协作关系,使得它们必须部署在同一台机器上。

而如果事先没有”组”的概念,像这样的运维关系就会非常难以处理。

我还是以前面的 rsyslogd 为例子。已知 rsyslogd 由三个进程组成:一个 imklog 模块,一个 imuxsock 模块,一个 rsyslogd 自己的 main 函数主进程。这三个进程一定要运行在同一台机器上,否则,它们之间基于 Socket 的通信和文件交换,都会出现问题。

现在,我要把 rsyslogd 这个应用给容器化,由于受限于容器的”单进程模型”,这三个模块必须被分别制作成三个不同的容器。而在这三个容器运行的时候,它们设置的内存配额都是 1 GB。

再次强调一下:容器的”单进程模型”,并不是指容器里只能运行”一个”进程,而是指容器没有管理多个进程的能力。这是因为容器里 PID=1 的进程就是应用本身,其他的进程都是这个 PID=1 进程的子进程。可是,用户编写的应用,并不能够像正常操作系统里的 init 进程或者 systemd 那样拥有进程管理的功能。比如,你的应用是一个 Java Web 程序(PID=1),然后你执行 docker exec 在后台启动了一个 Nginx 进程(PID=3)。可是,当这个 Nginx 进程异常退出的时候,你该怎么知道呢?这个进程退出后的垃圾收集工作,又应该由谁去做呢?

假设我们的 Kubernetes 集群上有两个节点:node-1 上有 3 GB 可用内存,node-2 有 2.5 GB 可用内存。

这时,假设我要用 Docker Swarm 来运行这个 rsyslogd 程序。为了能够让这三个容器都运行在同一台机器上,我就必须在另外两个容器上设置一个 affinity=main(与 main 容器有亲密性)的约束,即:它们俩必须和 main 容器运行在同一台机器上。

然后,我顺序执行:”docker run main””docker run imklog”和”docker run imuxsock”,创建这三个容器。

这样,这三个容器都会进入 Swarm 的待调度队列。然后,main 容器和 imklog 容器都先后出队并被调度到了 node-2 上(这个情况是完全有可能的)。

可是,当 imuxsock 容器出队开始被调度时,Swarm 就有点懵了:node-2 上的可用资源只有 0.5 GB 了,并不足以运行 imuxsock 容器;可是,根据 affinity=main 的约束,imuxsock 容器又只能运行在 node-2 上。

这就是一个典型的成组调度(gang scheduling)没有被妥善处理的例子。

在工业界和学术界,关于这个问题的讨论可谓旷日持久,也产生了很多可供选择的解决方案。

比如,Mesos 中就有一个资源囤积(resource hoarding)的机制,会在所有设置了 Affinity 约束的任务都达到时,才开始对它们统一进行调度。而在 Google Omega 论文中,则提出了使用乐观调度处理冲突的方法,即:先不管这些冲突,而是通过精心设计的回滚机制在出现了冲突之后解决问题。

可是这些方法都谈不上完美。资源囤积带来了不可避免的调度效率损失和死锁的可能性;而乐观调度的复杂程度,则不是常规技术团队所能驾驭的。

但是,到了 Kubernetes 项目里,这样的问题就迎刃而解了:Pod 是 Kubernetes 里的原子调度单位。这就意味着,Kubernetes 项目的调度器,是统一按照 Pod 而非容器的资源需求进行计算的。

所以,像 imklog、imuxsock 和 main 函数主进程这样的三个容器,正是一个典型的由三个容器组成的 Pod。Kubernetes 项目在调度时,自然就会去选择可用内存等于 3 GB 的 node-1 节点进行绑定,而根本不会考虑 node-2。

像这样容器间的紧密协作,我们可以称为”超亲密关系”。这些具有”超亲密关系”容器的典型特征包括但不限于:互相之间会发生直接的文件交换、使用 localhost 或者 Socket 文件进行本地通信、会发生非常频繁的远程调用、需要共享某些 Linux Namespace(比如,一个容器要加入另一个容器的 Network Namespace)等等。

这也就意味着,并不是所有有”关系”的容器都属于同一个 Pod。比如,PHP 应用容器和 MySQL 虽然会发生访问关系,但并没有必要、也不应该部署在同一台机器上,它们更适合做成两个 Pod。

不过,相信此时你可能会有 第二个疑问

对于初学者来说,一般都是先学会了用 Docker 这种单容器的工具,才会开始接触 Pod。

而如果 Pod 的设计只是出于调度上的考虑,那么 Kubernetes 项目似乎完全没有必要非得把 Pod 作为”一等公民”吧?这不是故意增加用户的学习门槛吗?

没错,如果只是处理”超亲密关系”这样的调度问题,有 Borg 和 Omega 论文珠玉在前,Kubernetes 项目肯定可以在调度器层面给它解决掉。

不过,Pod 在 Kubernetes 项目里还有更重要的意义,那就是:容器设计模式

为了理解这一层含义,我就必须先给你介绍一下Pod 的实现原理。

首先,关于 Pod 最重要的一个事实是:它只是一个逻辑概念。

也就是说,Kubernetes 真正处理的,还是宿主机操作系统上 Linux 容器的 Namespace 和 Cgroups,而并不存在一个所谓的 Pod 的边界或者隔离环境。

那么,Pod 又是怎么被”创建”出来的呢?

答案是:Pod,其实是一组共享了某些资源的容器。

具体的说: Pod 里的所有容器,共享的是同一个 Network Namespace,并且可以声明共享同一个 Volume

那这么来看的话,一个有 A、B 两个容器的 Pod,不就是等同于一个容器(容器 A)共享另外一个容器(容器 B)的网络和 Volume 的玩儿法么?

这好像通过 docker run —net —volumes-from 这样的命令就能实现嘛,比如:

docker run --net=B --volumes-from=B --name=A image-A ...

但是,你有没有考虑过,如果真这样做的话,容器 B 就必须比容器 A 先启动,这样一个 Pod 里的多个容器就不是对等关系,而是拓扑关系了。

所以,在 Kubernetes 项目里,Pod 的实现需要使用一个中间容器,这个容器叫作 Infra 容器。在这个 Pod 中,Infra 容器永远都是第一个被创建的容器,而其他用户定义的容器,则通过 Join Network Namespace 的方式,与 Infra 容器关联在一起。这样的组织关系,可以用下面这样一个示意图来表达:

Infra容器

更多关于 Infra 容器的资料: https://stackoverflow.com/questions/46630377/what-is-pod-infra-container-image-meant-for

如上图所示,这个 Pod 里有两个用户容器 A 和 B,还有一个 Infra 容器。很容易理解,在 Kubernetes 项目里,Infra 容器一定要占用极少的资源,所以它使用的是一个非常特殊的镜像,叫作:k8s.gcr.io/pause。这个镜像是一个用汇编语言编写的、永远处于”暂停”状态的容器,解压后的大小也只有 100~200 KB 左右。

而在 Infra 容器”Hold 住”Network Namespace 后,用户容器就可以加入到 Infra 容器的 Network Namespace 当中了。所以,如果你查看这些容器在宿主机上的 Namespace 文件(这个 Namespace 文件的路径,我已经在前面的内容中介绍过),它们指向的值一定是完全一样的。

这也就意味着,对于 Pod 里的容器 A 和容器 B 来说:

  • 它们可以直接使用 localhost 进行通信;
  • 它们看到的网络设备跟 Infra 容器看到的完全一样;
  • 一个 Pod 只有一个 IP 地址,也就是这个 Pod 的 Network Namespace 对应的 IP 地址;
  • 当然,其他的所有网络资源,都是一个 Pod 一份,并且被该 Pod 中的所有容器共享;
  • Pod 的生命周期只跟 Infra 容器一致,而与容器 A 和 B 无关。

而对于同一个 Pod 里面的所有用户容器来说,它们的进出流量,也可以认为都是通过 Infra 容器完成的。这一点很重要,因为 将来如果你要为 Kubernetes 开发一个网络插件时,应该重点考虑的是如何配置这个 Pod 的 Network Namespace,而不是每一个用户容器如何使用你的网络配置,这是没有意义的。

这就意味着,如果你的网络插件需要在容器里安装某些包或者配置才能完成的话,是不可取的:Infra 容器镜像的 rootfs 里几乎什么都没有,没有你随意发挥的空间。当然,这同时也意味着你的网络插件完全不必关心用户容器的启动与否,而只需要关注如何配置 Pod,也就是 Infra 容器的 Network Namespace 即可。

有了这个设计之后,共享 Volume 就简单多了:Kubernetes 项目只要把所有 Volume 的定义都设计在 Pod 层级即可。

这样,一个 Volume 对应的宿主机目录对于 Pod 来说就只有一个,Pod 里的容器只要声明挂载这个 Volume,就一定可以共享这个 Volume 对应的宿主机目录。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: two-containers
spec:
  restartPolicy: Never
  volumes:
  - name: shared-data
    hostPath:
      path: /data
  containers:
  - name: nginx-container
    image: nginx
    volumeMounts:
    - name: shared-data
      mountPath: /usr/share/nginx/html
  - name: debian-container
    image: debian
    volumeMounts:
    - name: shared-data
      mountPath: /pod-data
    command: ["/bin/sh"]
    args: ["-c", "echo Hello from the debian container > /pod-data/index.html"]

在这个例子中,debian-container 和 nginx-container 都声明挂载了 shared-data 这个 Volume。而 shared-data 是 hostPath 类型。所以,它对应在宿主机上的目录就是:/data。而这个目录,其实就被同时绑定挂载进了上述两个容器当中。

这就是为什么,nginx-container 可以从它的 /usr/share/nginx/html 目录中,读取到 debian-container 生成的 index.html 文件的原因。

明白了 Pod 的实现原理后,我们再来讨论”容器设计模式” ,就容易多了。

Pod 这种”超亲密关系”容器的设计思想,实际上就是希望,当用户想在一个容器里跑多个功能并不相关的应用时,应该优先考虑它们是不是更应该被描述成一个 Pod 里的多个容器。

为了能够掌握这种思考方式,你就应该尽量尝试使用它来描述一些用单个容器难以解决的问题。

  1. 第一个最典型的例子是:WAR 包与 Web 服务器

    我们现在有一个 Java Web 应用的 WAR 包,它需要被放在 Tomcat 的 webapps 目录下运行起来。

    假如,你现在只能用 Docker 来做这件事情,那该如何处理这个组合关系呢?

    • 一种方法是,把 WAR 包直接放在 Tomcat 镜像的 webapps 目录下,做成一个新的镜像运行起来。可是,这时候,如果你要更新 WAR 包的内容,或者要升级 Tomcat 镜像,就要重新制作一个新的发布镜像,非常麻烦。
    • 另一种方法是,你压根儿不管 WAR 包,永远只发布一个 Tomcat 容器。不过,这个容器的 webapps 目录,就必须声明一个 hostPath 类型的 Volume,从而把宿主机上的 WAR 包挂载进 Tomcat 容器当中运行起来。不过,这样你就必须要解决一个问题,即:如何让每一台宿主机,都预先准备好这个存储有 WAR 包的目录呢?这样来看,你只能独立维护一套分布式存储系统了。

      实际上,有了 Pod 之后,这样的问题就很容易解决了。我们可以把 WAR 包和 Tomcat 分别做成镜像,然后把它们作为一个 Pod 里的两个容器”组合”在一起。这个 Pod 的配置文件如下所示:

      apiVersion: v1
      kind: Pod
      metadata:
      name: javaweb-2
      spec:
      initContainers:
      - image: geektime/sample:v2
          name: war
          command: ["cp", "/sample.war", "/app"]
          volumeMounts:
          - mountPath: /app
          name: app-volume
      containers:
      - image: geektime/tomcat:7.0
          name: tomcat
          command: ["sh","-c","/root/apache-tomcat-7.0.42-v2/bin/start.sh"]
          volumeMounts:
          - mountPath: /root/apache-tomcat-7.0.42-v2/webapps
          name: app-volume
          ports:
          - containerPort: 8080
          hostPort: 8001
      volumes:
      - name: app-volume
          emptyDir: {}

      在这个 Pod 中,我们定义了两个容器,第一个容器使用的镜像是 geektime/sample:v2,这个镜像里只有一个 WAR 包(sample.war)放在根目录下。而第二个容器则使用的是一个标准的 Tomcat 镜像。

      不过,你可能已经注意到,WAR 包容器的类型不再是一个普通容器,而是一个 Init Container 类型的容器。

      在 Pod 中,所有 Init Container 定义的容器,都会比 spec.containers 定义的用户容器先启动。并且,Init Container 容器会按顺序逐一启动,而直到它们都启动并且退出了,用户容器才会启动。

      所以,这个 Init Container 类型的 WAR 包容器启动后,我执行了一句”cp /sample.war /app”,把应用的 WAR 包拷贝到 /app 目录下,然后退出。

      而后这个 /app 目录,就挂载了一个名叫 app-volume 的 Volume。

      接下来就很关键了。Tomcat 容器,同样声明了挂载 app-volume 到自己的 webapps 目录下。

      所以,等 Tomcat 容器启动时,它的 webapps 目录下就一定会存在 sample.war 文件:这个文件正是 WAR 包容器启动时拷贝到这个 Volume 里面的,而这个 Volume 是被这两个容器共享的。

      像这样,我们就用一种”组合”方式,解决了 WAR 包与 Tomcat 容器之间耦合关系的问题。

      实际上,这个所谓的”组合”操作,正是容器设计模式里最常用的一种模式,它的名字叫:sidecar。

      顾名思义,sidecar 指的就是我们可以在一个 Pod 中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。

      比如,在我们的这个应用 Pod 中,Tomcat 容器是我们要使用的主容器,而 WAR 包容器的存在,只是为了给它提供一个 WAR 包而已。所以,我们用 Init Container 的方式优先运行 WAR 包容器,扮演了一个 sidecar 的角色。

  2. 第二个例子,则是容器的日志收集

    比如,我现在有一个应用,需要不断地把日志文件输出到容器的 /var/log 目录中。

    这时,我就可以把一个 Pod 里的 Volume 挂载到应用容器的 /var/log 目录上。

    然后,我在这个 Pod 里同时运行一个 sidecar 容器,它也声明挂载同一个 Volume 到自己的 /var/log 目录上。

    这样,接下来 sidecar 容器就只需要做一件事儿,那就是不断地从自己的 /var/log 目录里读取日志文件,转发到 MongoDB 或者 Elasticsearch 中存储起来。这样,一个最基本的日志收集工作就完成了。

    跟第一个例子一样,这个例子中的 sidecar 的主要工作也是使用共享的 Volume 来完成对文件的操作。

    但不要忘记,Pod 的另一个重要特性是,它的所有容器都共享同一个 Network Namespace。这就使得很多与 Pod 网络相关的配置和管理,也都可以交给 sidecar 完成,而完全无须干涉用户容器。这里最典型的例子莫过于 Istio 这个微服务治理项目了。

    Istio 项目使用 sidecar 容器完成微服务治理的原理,我在后面很快会讲解到。

    备注:Kubernetes 社区曾经把”容器设计模式”这个理论,整理成了 一篇小论文 ,你可以点击链接浏览。

13.1 总结

Pod 是 Kubernetes 项目与其他单容器项目相比最大的不同,也是一位容器技术初学者需要面对的第一个与常规认知不一致的知识点。

事实上,直到现在,仍有很多人把容器跟虚拟机相提并论,他们把容器当做性能更好的虚拟机,喜欢讨论如何把应用从虚拟机无缝地迁移到容器中。

但实际上,无论是从具体的实现原理,还是从使用方法、特性、功能等方面,容器与虚拟机几乎没有任何相似的地方;也不存在一种普遍的方法,能够把虚拟机里的应用无缝迁移到容器中。因为,容器的性能优势,必然伴随着相应缺陷,即:它不能像虚拟机那样,完全模拟本地物理机环境中的部署方法。

所以,这个”上云”工作的完成,最终还是要靠深入理解容器的本质,即:进程。

实际上,一个运行在虚拟机里的应用,哪怕再简单,也是被管理在 systemd 或者 supervisord 之下的 一组进程,而不是一个进程 。这跟本地物理机上应用的运行方式其实是一样的。这也是为什么,从物理机到虚拟机之间的应用迁移,往往并不困难。

可是对于容器来说,一个容器永远只能管理一个进程。更确切地说,一个容器,就是一个进程。这是容器技术的”天性”,不可能被修改。所以,将一个原本运行在虚拟机里的应用,”无缝迁移”到容器中的想法,实际上跟容器的本质是相悖的。

这也是当初 Swarm 项目无法成长起来的重要原因之一:一旦到了真正的生产环境上,Swarm 这种单容器的工作方式,就难以描述真实世界里复杂的应用架构了。

所以,你现在可以这么理解 Pod 的本质:

Pod,实际上是在扮演传统基础设施里”虚拟机”的角色;而容器,则是这个虚拟机里运行的用户程序。

所以下一次,当你需要把一个运行在虚拟机里的应用迁移到 Docker 容器中时,一定要仔细分析到底有哪些进程(组件)运行在这个虚拟机里。

然后,你就可以把整个虚拟机想象成为一个 Pod,把这些进程分别做成容器镜像,把有顺序关系的容器,定义为 Init Container。这才是更加合理的、

注意:Pod 这个概念,提供的是一种编排思想,而不是具体的技术方案。所以,如果愿意的话,你完全可以使用虚拟机来作为 Pod 的实现,然后把用户容器都运行在这个虚拟机里。比如,Mirantis 公司的 virtlet 项目 就在干这个事情。甚至,你可以去实现一个带有 Init 进程的容器项目,来模拟传统应用的运行方式。这些工作,在 Kubernetes 中都是非常轻松的,也是我们后面讲解 CRI 时会提到的内容。

相反的,如果强行把整个应用塞到一个容器里,甚至不惜使用 Docker In Docker 这种在生产环境中后患无穷的解决方案,恐怕最后往往会得不偿失。

14 | 深入解析Pod对象(一):基本概念

现在,你已经非常清楚:Pod,而不是容器,才是 Kubernetes 项目中的最小编排单位。将这个设计落实到 API 对象上,容器(Container)就成了 Pod 属性里的一个普通的字段。那么,一个很自然的问题就是:到底哪些属性属于 Pod 对象,而又有哪些属性属于 Container 呢?

要彻底理解这个问题,你就一定要牢记我在上一篇文章中提到的一个结论:Pod 扮演的是传统部署环境里虚拟机”的角色。这样的设计,是为了使用户从传统环境(虚拟机环境)向 Kubernetes(容器环境)的迁移,更加平滑。

而如果你能把 Pod 看成传统环境里的机器”、把容器看作是运行在这个机器”里的用户程序”,那么很多关于 Pod 对象的设计就非常容易理解了。

比如, 凡是调度、网络、存储,以及安全相关的属性,基本上是 Pod 级别的。

这些属性的共同特征是,它们描述的是机器”这个整体,而不是里面运行的程序”。比如,配置这个机器”的网卡(即:Pod 的网络定义),配置这个机器”的磁盘(即:Pod 的存储定义),配置这个机器”的防火墙(即:Pod 的安全定义)。更不用说,这台机器”运行在哪个服务器之上(即:Pod 的调度)。

接下来,我就先为你介绍 Pod 中几个重要字段的含义和用法。

  1. NodeSelector:是一个供用户将 Pod 与 Node 进行绑定的字段 ,用法如下所示:

    apiVersion: v1
    kind: Pod
    ...
    spec:
    nodeSelector:
        disktype: ssd

    这样的一个配置,意味着这个 Pod 永远只能运行在携带了disktype: ssd”标签(Label)的节点上;否则,它将调度失败。

  2. NodeName :一旦 Pod 的这个字段被赋值,Kubernetes 项目就会被认为这个 Pod 已经经过了调度,调度的结果就是赋值的节点名字。所以,这个字段一般由调度器负责设置,但用户也可以设置它来骗过”调度器,当然这个做法一般是在测试或者调试的时候才会用到。

  3. HostAliases:定义了 Pod 的 hosts 文件(比如 /etc/hosts)里的内容 ,用法如下:

    apiVersion: v1
    kind: Pod
    ...
    spec:
    hostAliases:
    - ip: "10.1.2.3"
        hostnames:
        - "foo.remote"
        - "bar.remote"
    ...

    在这个 Pod 的 YAML 文件中,我设置了一组 IP 和 hostname 的数据。这样,这个 Pod 启动后,/etc/hosts 文件的内容将如下所示:

    cat /etc/hosts
    # Kubernetes-managed hosts file.
    127.0.0.1 localhost
    ...
    10.244.135.10 hostaliases-pod
    10.1.2.3 foo.remote
    10.1.2.3 bar.remote

    其中,最下面两行记录,就是我通过 HostAliases 字段为 Pod 设置的。需要指出的是,在 Kubernetes 项目中,如果要设置 hosts 文件里的内容,一定要通过这种方法。否则,如果直接修改了 hosts 文件的话,在 Pod 被删除重建之后,kubelet 会自动覆盖掉被修改的内容。

除了上述跟机器”相关的配置外,你可能也会发现, 凡是跟容器的 Linux Namespace 相关的属性,也一定是 Pod 级别的 。这个原因也很容易理解:Pod 的设计,就是要让它里面的容器尽可能多地共享 Linux Namespace,仅保留必要的隔离和限制能力。这样,Pod 模拟出的效果,就跟虚拟机里程序间的关系非常类似了。

  1. 举个例子,在下面这个 Pod 的 YAML 文件中,我定义了 shareProcessNamespace=true:

    apiVersion: v1
    kind: Pod
    metadata:
    name: nginx
    spec:
    shareProcessNamespace: true
    containers:
    - name: nginx
        image: nginx
    - name: shell
        image: busybox
        stdin: true
        tty: true

    这就意味着这个 Pod 里的容器要共享 PID Namespace。

    而在这个 YAML 文件中,我还定义了两个容器:一个是 nginx 容器,一个是开启了 tty 和 stdin 的 shell 容器。

    我在前面介绍容器基础时,曾经讲解过什么是 tty 和 stdin。而在 Pod 的 YAML 文件里声明开启它们俩,其实等同于设置了 docker run 里的 -it(-i 即 stdin,-t 即 tty)参数。

    如果你还是不太理解它们俩的作用的话,可以直接认为 tty 就是 Linux 给用户提供的一个常驻小程序,用于接收用户的标准输入,返回操作系统的标准输出。当然,为了能够在 tty 中输入信息,你还需要同时开启 stdin(标准输入流)。

    于是,这个 Pod 被创建后,你就可以使用 shell 容器的 tty 跟这个容器进行交互了。我们一起实践一下:

    kubectl create -f nginx.yaml

    接下来,我们使用 kubectl attach 命令,连接到 shell 容器的 tty 上:

    kubectl attach -it nginx -c shell

    这样,我们就可以在 shell 容器里执行 ps 指令,查看所有正在运行的进程:

    $ kubectl attach -it nginx -c shell
    / # ps ax
    PID   USER     TIME  COMMAND
        1 root      0:00 /pause
        8 root      0:00 nginx: master process nginx -g daemon off;
    14 101       0:00 nginx: worker process
    15 root      0:00 sh
    21 root      0:00 ps ax

    可以看到,在这个容器里,我们不仅可以看到它本身的 ps ax 指令,还可以看到 nginx 容器的进程,以及 Infra 容器的 /pause 进程。这就意味着,整个 Pod 里的每个容器的进程,对于所有容器来说都是可见的:它们共享了同一个 PID Namespace。

  2. 类似地, 凡是 Pod 中的容器要共享宿主机的 Namespace,也一定是 Pod 级别的定义 ,比如:

    apiVersion: v1
    kind: Pod
    metadata:
    name: nginx
    spec:
    hostNetwork: true
    hostIPC: true
    hostPID: true
    containers:
    - name: nginx
        image: nginx
    - name: shell
        image: busybox
        stdin: true
        tty: true

    在这个 Pod 中,我定义了共享宿主机的 Network、IPC 和 PID Namespace。这就意味着,这个 Pod 里的所有容器,会直接使用宿主机的网络、直接与宿主机进行 IPC 通信、看到宿主机里正在运行的所有进程。

当然,除了这些属性,Pod 里最重要的字段当属Containers”了。而在上一篇文章中,我还介绍过Init Containers”。其实,这两个字段都属于 Pod 对容器的定义,内容也完全相同,只是 Init Containers 的生命周期,会先于所有的 Containers,并且严格按照定义的顺序执行。

Kubernetes 项目中对 Container 的定义,和 Docker 相比并没有什么太大区别。我在前面的容器技术概念入门系列文章中,和你分享的 Image(镜像)、Command(启动命令)、workingDir(容器的工作目录)、Ports(容器要开发的端口),以及 volumeMounts(容器要挂载的 Volume)都是构成 Kubernetes 项目中 Container 的主要字段。不过在这里,还有这么几个属性值得你额外关注。

  1. 首先,是 ImagePullPolicy 字段 。它定义了镜像拉取的策略。而它之所以是一个 Container 级别的属性,是因为容器镜像本来就是 Container 定义中的一部分。

    ImagePullPolicy 的值默认是 Always,即每次创建 Pod 都重新拉取一次镜像。另外,当容器的镜像是类似于 nginx 或者 nginx:latest 这样的名字时,ImagePullPolicy 也会被认为 Always。

    而如果它的值被定义为 Never 或者 IfNotPresent,则意味着 Pod 永远不会主动拉取这个镜像,或者只在宿主机上不存在这个镜像时才拉取。

  2. 其次,是 Lifecycle 字段 。它定义的是 Container Lifecycle Hooks。顾名思义,Container Lifecycle Hooks 的作用,是在容器状态发生变化时触发一系列钩子”。我们来看这样一个例子:

    apiVersion: v1
    kind: Pod
    metadata:
    name: lifecycle-demo
    spec:
    containers:
    - name: lifecycle-demo-container
        image: nginx
        lifecycle:
        postStart:
            exec:
            command: ["/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"]
        preStop:
            exec:
            command: ["/usr/sbin/nginx","-s","quit"]

    这是一个来自 Kubernetes 官方文档的 Pod 的 YAML 文件。它其实非常简单,只是定义了一个 nginx 镜像的容器。不过,在这个 YAML 文件的容器(Containers)部分,你会看到这个容器分别设置了一个 postStart 和 preStop 参数。这是什么意思呢?

    先说 postStart 吧。它指的是,在容器启动后,立刻执行一个指定的操作。需要明确的是,postStart 定义的操作,虽然是在 Docker 容器 ENTRYPOINT 执行之后,但它并不严格保证顺序。也就是说,在 postStart 启动时,ENTRYPOINT 有可能还没有结束。

    当然,如果 postStart 执行超时或者错误,Kubernetes 会在该 Pod 的 Events 中报出该容器启动失败的错误信息,导致 Pod 也处于失败的状态。

    而类似地,preStop 发生的时机,则是容器被杀死之前(比如,收到了 SIGKILL 信号)。而需要明确的是,preStop 操作的执行,是同步的。所以,它会阻塞当前的容器杀死流程,直到这个 Hook 定义操作完成之后,才允许容器被杀死,这跟 postStart 不一样。

    所以,在这个例子中,我们在容器成功启动之后,在 /usr/share/message 里写入了一句欢迎信息”(即 postStart 定义的操作)。而在这个容器被删除之前,我们则先调用了 nginx 的退出指令(即 preStop 定义的操作),从而实现了容器的优雅退出”。

在熟悉了 Pod 以及它的 Container 部分的主要字段之后,我再和你分享一下 这样一个的 Pod 对象在 Kubernetes 中的生命周期

Pod 生命周期的变化,主要体现在 Pod API 对象的Status 部分,这是它除了 Metadata 和 Spec 之外的第三个重要字段。其中,pod.status.phase,就是 Pod 的当前状态,它有如下几种可能的情况:

  1. Pending。这个状态意味着,Pod 的 YAML 文件已经提交给了 Kubernetes,API 对象已经被创建并保存在 Etcd 当中。但是,这个 Pod 里有些容器因为某种原因而不能被顺利创建。比如,调度不成功。
  2. Running。这个状态下,Pod 已经调度成功,跟一个具体的节点绑定。它包含的容器都已经创建成功,并且至少有一个正在运行中。
  3. Succeeded。这个状态意味着,Pod 里的所有容器都正常运行完毕,并且已经退出了。这种情况在运行一次性任务时最为常见。
  4. Failed。这个状态下,Pod 里至少有一个容器以不正常的状态(非 0 的返回码)退出。这个状态的出现,意味着你得想办法 Debug 这个容器的应用,比如查看 Pod 的 Events 和日志。
  5. Unknown。这是一个异常状态,意味着 Pod 的状态不能持续地被 kubelet 汇报给 kube-apiserver,这很有可能是主从节点(Master 和 Kubelet)间的通信出现了问题。

更进一步地,Pod 对象的 Status 字段,还可以再细分出一组 Conditions。这些细分状态的值包括:PodScheduled、Ready、Initialized,以及 Unschedulable。它们主要用于描述造成当前 Status 的具体原因是什么。

比如,Pod 当前的 Status 是 Pending,对应的 Condition 是 Unschedulable,这就意味着它的调度出现了问题。

而其中,Ready 这个细分状态非常值得我们关注:它意味着 Pod 不仅已经正常启动(Running 状态),而且已经可以对外提供服务了。这两者之间(Running 和 Ready)是有区别的,你不妨仔细思考一下。

Pod 的这些状态信息,是我们判断应用运行情况的重要标准,尤其是 Pod 进入了非Running”状态后,你一定要能迅速做出反应,根据它所代表的异常情况开始跟踪和定位,而不是去手忙脚乱地查阅文档。

14.1 总结

在学习完这篇文章后,我希望你能仔细阅读 $GOPATH/src/k8s.io/kubernetes/vendor/k8s.io/api/core/v1/types.go 里,type Pod struct ,尤其是 PodSpec 部分的内容。争取做到下次看到一个 Pod 的 YAML 文件时,不再需要查阅文档,就能做到把常用字段及其作用信手拈来。

15 | 深入解析Pod对象(二):使用进阶

在 Kubernetes 中,有几种特殊的 Volume,它们存在的意义不是为了存放容器里的数据,也不是用来进行容器和宿主机之间的数据交换。这些特殊 Volume 的作用,是为容器提供预先定义好的数据。所以,从容器的角度来看,这些 Volume 里的信息就是仿佛是 被 Kubernetes”投射”(Project)进入容器当中的 。这种特殊的 Volume,叫作 Projected Volume,你可以把它翻译为”投射数据卷”。

备注:Projected Volume 是 Kubernetes v1.11 之后的新特性

到目前为止,Kubernetes 支持的 Projected Volume 一共有四种:

  1. Secret;
  2. ConfigMap;
  3. Downward API;
  4. ServiceAccountToken。

Secret 最典型的使用场景,莫过于存放数据库的 Credential 信息,比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: test-projected-volume
spec:
  containers:
  - name: test-secret-volume
    image: busybox
    args:
    - sleep
    - "86400"
    volumeMounts:
    - name: mysql-cred
      mountPath: "/projected-volume"
      readOnly: true
  volumes:
  - name: mysql-cred
    projected:
      sources:
      - secret:
          name: user
      - secret:
          name: pass

在这个 Pod 中,我定义了一个简单的容器。它声明挂载的 Volume,并不是常见的 emptyDir 或者 hostPath 类型,而是 projected 类型。而这个 Volume 的数据来源(sources),则是名为 user 和 pass 的 Secret 对象,分别对应的是数据库的用户名和密码。

这里用到的数据库的用户名、密码,正是以 Secret 对象的方式交给 Kubernetes 保存的。完成这个操作的指令,如下所示:

$ cat ./username.txt
admin
$ cat ./password.txt
c1oudc0w!

$ kubectl create secret generic user --from-file=./username.txt
$ kubectl create secret generic pass --from-file=./password.txt

其中,username.txt 和 password.txt 文件里,存放的就是用户名和密码;而 user 和 pass,则是我为 Secret 对象指定的名字。而我想要查看这些 Secret 对象的话,只要执行一条 kubectl get 命令就可以了:

$ kubectl get secrets
NAME           TYPE                                DATA      AGE
user          Opaque                                1         51s
pass          Opaque                                1         51s

当然,除了使用 kubectl create secret 指令外,我也可以直接通过编写 YAML 文件的方式来创建这个 Secret 对象,比如:

apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  user: YWRtaW4=
  pass: MWYyZDFlMmU2N2Rm

可以看到,通过编写 YAML 文件创建出来的 Secret 对象只有一个。但它的 data 字段,却以 Key-Value 的格式保存了两份 Secret 数据。其中,”user”就是第一份数据的 Key,”pass”是第二份数据的 Key。

需要注意的是,Secret 对象要求这些数据必须是经过 Base64 转码的,以免出现明文密码的安全隐患。这个转码操作也很简单,比如:

$ echo -n 'admin' | base64
YWRtaW4=
$ echo -n '1f2d1e2e67df' | base64
MWYyZDFlMmU2N2Rm

这里需要注意的是,像这样创建的 Secret 对象,它里面的内容仅仅是经过了转码,而并没有被加密。在真正的生产环境中,你需要在 Kubernetes 中开启 Secret 的加密插件,增强数据的安全性。关于开启 Secret 加密插件的内容,我会在后续专门讲解 Secret 的时候,再做进一步说明。

接下来,我们尝试一下创建这个 Pod:

kubectl create -f test-projected-volume.yaml

当 Pod 变成 Running 状态之后,我们再验证一下这些 Secret 对象是不是已经在容器里了:

$ kubectl exec -it test-projected-volume -- /bin/sh
$ ls /projected-volume/
user
pass
$ cat /projected-volume/user
root
$ cat /projected-volume/pass
1f2d1e2e67df

从返回结果中,我们可以看到,保存在 Etcd 里的用户名和密码信息,已经以文件的形式出现在了容器的 Volume 目录里。而这个文件的名字,就是 kubectl create secret 指定的 Key,或者说是 Secret 对象的 data 字段指定的 Key。

更重要的是,像这样通过挂载方式进入到容器里的 Secret,一旦其对应的 Etcd 里的数据被更新,这些 Volume 里的文件内容,同样也会被更新。其实, 这是 kubelet 组件在定时维护这些 Volume

需要注意的是,这个更新可能会有一定的延时。所以 在编写应用程序时,在发起数据库连接的代码处写好重试和超时的逻辑,绝对是个好习惯。

与 Secret 类似的是 ConfigMap ,它与 Secret 的区别在于,ConfigMap 保存的是不需要加密的、应用所需的配置信息。而 ConfigMap 的用法几乎与 Secret 完全相同:你可以使用 kubectl create configmap 从文件或者目录创建 ConfigMap,也可以直接编写 ConfigMap 对象的 YAML 文件。

比如,一个 Java 应用所需的配置文件(.properties 文件),就可以通过下面这样的方式保存在 ConfigMap 里:

# .properties 文件的内容
$ cat example/ui.properties
color.good=purple
color.bad=yellow
allow.textmode=true
how.nice.to.look=fairlyNice

# 从.properties 文件创建 ConfigMap
$ kubectl create configmap ui-config --from-file=example/ui.properties

# 查看这个 ConfigMap 里保存的信息 (data)
$ kubectl get configmaps ui-config -o yaml
apiVersion: v1
data:
  ui.properties: |
    color.good=purple
    color.bad=yellow
    allow.textmode=true
    how.nice.to.look=fairlyNice
kind: ConfigMap
metadata:
  name: ui-config
  ...

备注:kubectl get -o yaml 这样的参数,会将指定的 Pod API 对象以 YAML 的方式展示出来。

接下来是 Downward API,它的作用是:让 Pod 里的容器能够直接获取到这个 Pod API 对象本身的信息。

apiVersion: v1
kind: Pod
metadata:
  name: test-downwardapi-volume
  labels:
    zone: us-est-coast
    cluster: test-cluster1
    rack: rack-22
spec:
  containers:
    - name: client-container
      image: k8s.gcr.io/busybox
      command: ["sh", "-c"]
      args:
      - while true; do
          if [[ -e /etc/podinfo/labels ]]; then
            echo -en '\n\n'; cat /etc/podinfo/labels; fi;
          sleep 5;
        done;
      volumeMounts:
        - name: podinfo
          mountPath: /etc/podinfo
          readOnly: false
  volumes:
    - name: podinfo
      projected:
        sources:
        - downwardAPI:
            items:
              - path: "labels"
                fieldRef:
                  fieldPath: metadata.labels

在这个 Pod 的 YAML 文件中,我定义了一个简单的容器,声明了一个 projected 类型的 Volume。只不过这次 Volume 的数据来源,变成了 Downward API。而这个 Downward API Volume,则声明了要暴露 Pod 的 metadata.labels 信息给容器。

通过这样的声明方式,当前 Pod 的 Labels 字段的值,就会被 Kubernetes 自动挂载成为容器里的 /etc/podinfo/labels 文件。

而这个容器的启动命令,则是不断打印出 /etc/podinfo/labels 里的内容。所以,当我创建了这个 Pod 之后,就可以通过 kubectl logs 指令,查看到这些 Labels 字段被打印出来,如下所示:

$ kubectl create -f dapi-volume.yaml
$ kubectl logs test-downwardapi-volume
cluster="test-cluster1"
rack="rack-22"
zone="us-est-coast"

目前,Downward API 支持的字段已经非常丰富了,比如:

  1. 使用 fieldRef 可以声明使用:

    • spec.nodeName - 宿主机名字
    • status.hostIP - 宿主机 IP
    • metadata.name - Pod 的名字
    • metadata.namespace - Pod 的 Namespace
    • status.podIP - Pod 的 IP
    • spec.serviceAccountName - Pod 的 Service Account 的名字
    • metadata.uid - Pod 的 UID
    • metadata.labels['<KEY>'] - 指定 <KEY> 的 Label 值
    • metadata.annotations['<KEY>'] - 指定 <KEY> 的 Annotation 值
    • metadata.labels - Pod 的所有 Label
    • metadata.annotations - Pod 的所有 Annotation
  2. 使用 resourceFieldRef 可以声明使用:

    • 容器的 CPU limit
    • 容器的 CPU request
    • 容器的 memory limit
    • 容器的 memory request

不过,需要注意的是,Downward API 能够获取到的信息, 一定是 Pod 里的容器进程启动之前就能够确定下来的信息 。而如果你想要获取 Pod 容器运行后才会出现的信息,比如,容器进程的 PID,那就肯定不能使用 Downward API 了,而应该考虑在 Pod 里定义一个 sidecar 容器。

其实,Secret、ConfigMap,以及 Downward API 这三种 Projected Volume 定义的信息,大多还可以通过环境变量的方式出现在容器里。但是,通过环境变量获取这些信息的方式,不具备自动更新的能力。所以,一般情况下,我都建议你使用 Volume 文件的方式获取这些信息。

在明白了 Secret 之后,我再为你讲解 Pod 中一个与它密切相关的概念:Service Account。

相信你一定有过这样的想法:我现在有了一个 Pod,我能不能在这个 Pod 里安装一个 Kubernetes 的 Client,这样就可以从容器里直接访问并且操作这个 Kubernetes 的 API 了呢?

这当然是可以的。

不过,你首先要解决 API Server 的授权问题。

Service Account 对象的作用,就是 Kubernetes 系统内置的一种”服务账户”,它是 Kubernetes 进行权限分配的对象。比如,Service Account A,可以只被允许对 Kubernetes API 进行 GET 操作,而 Service Account B,则可以有 Kubernetes API 的所有操作的权限。

像这样的 Service Account 的授权信息和文件,实际上保存在它所绑定的一个特殊的 Secret 对象里的。这个特殊的 Secret 对象,就叫作ServiceAccountToken。任何运行在 Kubernetes 集群上的应用,都必须使用这个 ServiceAccountToken 里保存的授权信息,也就是 Token,才可以合法地访问 API Server。

所以说,Kubernetes 项目的 Projected Volume 其实只有三种,因为第四种 ServiceAccountToken,只是一种特殊的 Secret 而已。

另外,为了方便使用,Kubernetes 已经为你提供了一个的默认”服务账户”(default Service Account)。并且,任何一个运行在 Kubernetes 里的 Pod,都可以直接使用这个默认的 Service Account,而无需显示地声明挂载它。

这是如何做到的呢?

当然还是靠 Projected Volume 机制。

如果你查看一下任意一个运行在 Kubernetes 集群里的 Pod,就会发现,每一个 Pod,都已经自动声明一个类型是 Secret、名为 default-token-xxxx 的 Volume,然后 自动挂载在每个容器的一个固定目录上。比如:

$ kubectl describe pod nginx-deployment-5c678cfb6d-lg9lw
Containers:
...
  Mounts:
    /var/run/secrets/kubernetes.io/serviceaccount from default-token-s8rbq (ro)
Volumes:
  default-token-s8rbq:
  Type:       Secret (a volume populated by a Secret)
  SecretName:  default-token-s8rbq
  Optional:    false

这个 Secret 类型的 Volume,正是默认 Service Account 对应的 ServiceAccountToken。所以说,Kubernetes 其实在每个 Pod 创建的时候,自动在它的 spec.volumes 部分添加上了默认 ServiceAccountToken 的定义,然后自动给每个容器加上了对应的 volumeMounts 字段。这个过程对于用户来说是完全透明的。

这样,一旦 Pod 创建完成,容器里的应用就可以直接从这个默认 ServiceAccountToken 的挂载目录里访问到授权信息和文件。这个容器内的路径在 Kubernetes 里是固定的,即:/var/run/secrets/kubernetes.io/serviceaccount ,而这个 Secret 类型的 Volume 里面的内容如下所示:

$ ls /var/run/secrets/kubernetes.io/serviceaccount
ca.crt namespace  token

所以,你的应用程序只要直接加载这些授权文件,就可以访问并操作 Kubernetes API 了。而且,如果你使用的是 Kubernetes 官方的 Client 包(k8s.io/client-go)的话,它还可以自动加载这个目录下的文件,你不需要做任何配置或者编码操作。

这种把 Kubernetes 客户端以容器的方式运行在集群里,然后使用 default Service Account 自动授权的方式,被称作”InClusterConfig”,也是我最推荐的进行 Kubernetes API 编程的授权方式。

当然,考虑到自动挂载默认 ServiceAccountToken 的潜在风险,Kubernetes 允许你设置默认不为 Pod 里的容器自动挂载这个 Volume。

除了这个默认的 Service Account 外,我们很多时候还需要创建一些我们自己定义的 Service Account,来对应不同的权限设置。这样,我们的 Pod 里的容器就可以通过挂载这些 Service Account 对应的 ServiceAccountToken,来使用这些自定义的授权信息。在后面讲解为 Kubernetes 开发插件的时候,我们将会实践到这个操作。

接下来,我们再来看 Pod 的另一个重要的配置: 容器健康检查和恢复机制

在 Kubernetes 中,你可以为 Pod 里的容器定义一个健康检查”探针”(Probe)。这样,kubelet 就会根据这个 Probe 的返回值决定这个容器的状态,而不是直接以容器进行是否运行(来自 Docker 返回的信息)作为依据。这种机制,是生产环境中保证应用健康存活的重要手段。

我们一起来看一个 Kubernetes 文档中的例子。

apiVersion: v1
kind: Pod
metadata:
  labels:
    test: liveness
  name: test-liveness-exec
spec:
  containers:
  - name: liveness
    image: busybox
    args:
    - /bin/sh
    - -c
    - touch /tmp/healthy; sleep 30; rm -rf /tmp/healthy; sleep 600
    livenessProbe:
      exec:
        command:
        - cat
        - /tmp/healthy
      initialDelaySeconds: 5
      periodSeconds: 5

在这个 Pod 中,我们定义了一个有趣的容器。它在启动之后做的第一件事,就是在 /tmp 目录下创建了一个 healthy 文件,以此作为自己已经正常运行的标志。而 30 s 过后,它会把这个文件删除掉。

与此同时,我们定义了一个这样的 livenessProbe(健康检查)。它的类型是 exec,这意味着,它会在容器启动后,在容器里面执行一句我们指定的命令,比如:”cat /tmp/healthy”。这时,如果这个文件存在,这条命令的返回值就是 0,Pod 就会认为这个容器不仅已经启动,而且是健康的。这个健康检查,在容器启动 5 s 后开始执行(initialDelaySeconds: 5),每 5 s 执行一次(periodSeconds: 5)。

现在,让我们来具体实践一下这个过程。

首先,创建这个 Pod:

kubectl create -f test-liveness-exec.yaml

然后,查看这个 Pod 的状态:

$ kubectl get pod
NAME                READY     STATUS    RESTARTS   AGE
test-liveness-exec   1/1       Running   0          10s

可以看到,由于已经通过了健康检查,这个 Pod 就进入了 Running 状态。

而 30 s 之后,我们再查看一下 Pod 的 Events:

kubectl describe pod test-liveness-exec

你会发现,这个 Pod 在 Events 报告了一个异常:

FirstSeen LastSeen    Count   From            SubobjectPath           Type        Reason      Message
--------- --------    -----   ----            -------------           --------    ------      -------
2s        2s      1   {kubelet worker0}   spec.containers{liveness}   Warning     Unhealthy   Liveness probe failed: cat: can't open '/tmp/healthy': No such file or directory

显然,这个健康检查探查到 /tmp/healthy 已经不存在了,所以它报告容器是不健康的。那么接下来会发生什么呢?

我们不妨再次查看一下这个 Pod 的状态:

$ kubectl get pod test-liveness-exec
NAME           READY     STATUS    RESTARTS   AGE
liveness-exec   1/1       Running   1          1m

这时我们发现,Pod 并没有进入 Failed 状态,而是保持了 Running 状态。这是为什么呢?

其实,如果你注意到 RESTARTS 字段从 0 到 1 的变化,就明白原因了:这个异常的容器已经被 Kubernetes 重启了。在这个过程中,Pod 保持 Running 状态不变。

需要注意的是:Kubernetes 中并没有 Docker 的 Stop 语义。所以虽然是 Restart(重启),但实际却是重新创建了容器。

这个功能就是 Kubernetes 里的 Pod 恢复机制 ,也叫 restartPolicy。它是 Pod 的 Spec 部分的一个标准字段(pod.spec.restartPolicy),默认值是 Always,即:任何时候这个容器发生了异常,它一定会被重新创建。

但一定要强调的是,Pod 的恢复过程,永远都是发生在当前节点上,而不会跑到别的节点上去。事实上,一旦一个 Pod 与一个节点(Node)绑定,除非这个绑定发生了变化(pod.spec.node 字段被修改),否则它永远都不会离开这个节点。这也就意味着,如果这个宿主机宕机了,这个 Pod 也不会主动迁移到其他节点上去。

而如果你想让 Pod 出现在其他的可用节点上,就必须使用 Deployment 这样的”控制器”来管理 Pod,哪怕你只需要一个 Pod 副本。这就是我在第 12 篇文章 《牛刀小试:我的第一个容器化应用》 最后给你留的思考题的答案,即 一个单 Pod 的 Deployment 与一个 Pod 最主要的区别

而作为用户,你还可以通过设置 restartPolicy,改变 Pod 的恢复策略。除了 Always,它还有 OnFailure 和 Never 两种情况:

  • Always:在任何情况下,只要容器不在运行状态,就自动重启容器;
  • OnFailure: 只在容器 异常时才自动重启容器;
  • Never: 从来不重启容器。

在实际使用时,我们需要根据应用运行的特性,合理设置这三种恢复策略。

比如,一个 Pod,它只计算 1+1=2,计算完成输出结果后退出,变成 Succeeded 状态。这时,你如果再用 restartPolicy=Always 强制重启这个 Pod 的容器,就没有任何意义了。

而如果你要关心这个容器退出后的上下文环境,比如容器退出后的日志、文件和目录,就需要将 restartPolicy 设置为 Never。因为一旦容器被自动重新创建,这些内容就有可能丢失掉了(被垃圾回收了)。

值得一提的是,Kubernetes 的官方文档,把 restartPolicy 和 Pod 里容器的状态,以及 Pod 状态的对应关系,总结了非常复杂的一大堆情况。实际上,你根本不需要死记硬背这些对应关系,只要记住如下两个基本的设计原理即可:

  1. 只要 Pod 的 restartPolicy 指定的策略允许重启异常的容器(比如:Always),那么这个 Pod 就会保持 Running 状态,并进行容器重启。否则,Pod 就会进入 Failed 状态 。
  2. 于包含多个容器的 Pod,只有它里面所有的容器都进入异常状态后,Pod 才会进入 Failed 状态。在此之前,Pod 都是 Running 状态。此时,Pod 的 READY 字段会显示正常容器的个数,比如:

    $ kubectl get pod test-liveness-exec
    NAME           READY     STATUS    RESTARTS   AGE
    liveness-exec   0/1       Running   1          1m

    所以,假如一个 Pod 里只有一个容器,然后这个容器异常退出了。那么,只有当 restartPolicy=Never 时,这个 Pod 才会进入 Failed 状态。而其他情况下,由于 Kubernetes 都可以重启这个容器,所以 Pod 的状态保持 Running 不变。

而如果这个 Pod 有多个容器,仅有一个容器异常退出,它就始终保持 Running 状态,哪怕即使 restartPolicy=Never。只有当所有容器也异常退出之后,这个 Pod 才会进入 Failed 状态。

其他情况,都可以以此类推出来。

现在,我们一起回到前面提到的 livenessProbe 上来。

除了在容器中执行命令外,livenessProbe 也可以定义为发起 HTTP 或者 TCP 请求的方式,定义格式如下:

...
livenessProbe:
     httpGet:
       path: /healthz
       port: 8080
       httpHeaders:
       - name: X-Custom-Header
         value: Awesome
       initialDelaySeconds: 3
       periodSeconds: 3
...
livenessProbe:
  tcpSocket:
    port: 8080
  initialDelaySeconds: 15
  periodSeconds: 20

所以,你的 Pod 其实可以暴露一个健康检查 URL(比如 /healthz),或者直接让健康检查去检测应用的监听端口。这两种配置方法,在 Web 服务类的应用中非常常用。

在 Kubernetes 的 Pod 中,还有一个叫 readinessProbe 的字段。虽然它的用法与 livenessProbe 类似,但作用却大不一样。readinessProbe 检查结果的成功与否,决定的这个 Pod 是不是能被通过 Service 的方式访问到,而并不影响 Pod 的生命周期。这部分内容,我会留在讲解 Service 时再重点介绍。

在讲解了这么多字段之后,想必你对 Pod 对象的语义和描述能力,已经有了一个初步的感觉。

这时,你有没有产生这样一个想法:Pod 的字段这么多,我又不可能全记住,Kubernetes 能不能自动给 Pod 填充某些字段呢?

这个需求实际上非常实用。比如,开发人员只需要提交一个基本的、非常简单的 Pod YAML,Kubernetes 就可以自动给对应的 Pod 对象加上其他必要的信息,比如 labels,annotations,volumes 等等。而这些信息,可以是运维人员事先定义好的。

这么一来,开发人员编写 Pod YAML 的门槛,就被大大降低了。

所以,这个叫作 PodPreset(Pod 预设置)的功能 已经出现在了 v1.11 版本的 Kubernetes 中。

举个例子,现在开发人员编写了如下一个 pod.yaml 文件:

apiVersion: v1
kind: Pod
metadata:
  name: website
  labels:
    app: website
    role: frontend
spec:
  containers:
    - name: website
      image: nginx
      ports:
        - containerPort: 80

作为 Kubernetes 的初学者,你肯定眼前一亮:这不就是我最擅长编写的、最简单的 Pod 嘛。没错,这个 YAML 文件里的字段,想必你现在闭着眼睛也能写出来。

可是,如果运维人员看到了这个 Pod,他一定会连连摇头:这种 Pod 在生产环境里根本不能用啊!

所以,这个时候,运维人员就可以定义一个 PodPreset 对象。在这个对象中,凡是他想在开发人员编写的 Pod 里追加的字段,都可以预先定义好。比如这个 preset.yaml:

apiVersion: settings.k8s.io/v1alpha1
kind: PodPreset
metadata:
  name: allow-database
spec:
  selector:
    matchLabels:
      role: frontend
  env:
    - name: DB_PORT
      value: "6379"
  volumeMounts:
    - mountPath: /cache
      name: cache-volume
  volumes:
    - name: cache-volume
      emptyDir: {}

在这个 PodPreset 的定义中,首先是一个 selector。这就意味着后面这些追加的定义,只会作用于 selector 所定义的、带有”role: frontend”标签的 Pod 对象,这就可以防止”误伤”。

然后,我们定义了一组 Pod 的 Spec 里的标准字段,以及对应的值。比如,env 里定义了 DB_PORT 这个环境变量,volumeMounts 定义了容器 Volume 的挂载目录,volumes 定义了一个 emptyDir 的 Volume。

接下来,我们假定运维人员先创建了这个 PodPreset,然后开发人员才创建 Pod:

kubectl create -f preset.yaml
kubectl create -f pod.yaml

这时,Pod 运行起来之后,我们查看一下这个 Pod 的 API 对象:

$ kubectl get pod website -o yaml
apiVersion: v1
kind: Pod
metadata:
  name: website
  labels:
    app: website
    role: frontend
  annotations:
    podpreset.admission.kubernetes.io/podpreset-allow-database: "resource version"
spec:
  containers:
    - name: website
      image: nginx
      volumeMounts:
        - mountPath: /cache
          name: cache-volume
      ports:
        - containerPort: 80
      env:
        - name: DB_PORT
          value: "6379"
  volumes:
    - name: cache-volume
      emptyDir: {}

这个时候,我们就可以清楚地看到,这个 Pod 里多了新添加的 labels、env、volumes 和 volumeMount 的定义,它们的配置跟 PodPreset 的内容一样。此外,这个 Pod 还被自动加上了一个 annotation 表示这个 Pod 对象被 PodPreset 改动过。

需要说明的是, PodPreset 里定义的内容,只会在 Pod API 对象被创建之前追加在这个对象本身上,而不会影响任何 Pod 的控制器的定义。

比如,我们现在提交的是一个 nginx-deployment,那么这个 Deployment 对象本身是永远不会被 PodPreset 改变的,被修改的只是这个 Deployment 创建出来的所有 Pod。这一点请务必区分清楚。

这里有一个问题:如果你定义了同时作用于一个 Pod 对象的多个 PodPreset,会发生什么呢?

实际上,Kubernetes 项目会帮你合并(Merge)这两个 PodPreset 要做的修改。而如果它们要做的修改有冲突的话,这些冲突字段就不会被修改。

15.1 总结

在学习这些字段的同时,你还应该认真体会一下 Kubernetes”一切皆对象”的设计思想:比如应用是 Pod 对象,应用的配置是 ConfigMap 对象,应用要访问的密码则是 Secret 对象。

所以,也就自然而然地有了 PodPreset 这样专门用来对 Pod 进行批量化、自动化修改的工具对象。在后面的内容中,我会为你讲解更多的这种对象,还会和你介绍 Kubernetes 项目如何围绕着这些对象进行容器编排。

在本专栏中,Pod 对象相关的知识点非常重要,它是接下来 Kubernetes 能够描述和编排各种复杂应用的基石所在,希望你能够继续多实践、多体会。

16 | 编排其实很简单:谈谈”控制器”模型

你可能已经有所感悟:Pod 这个看似复杂的 API 对象,实际上就是对容器的进一步抽象和封装而已。

说得更形象些,”容器”镜像虽然好用,但是容器这样一个”沙盒”的概念,对于描述应用来说,还是太过简单了。这就好比,集装箱固然好用,但是如果它四面都光秃秃的,吊车还怎么把这个集装箱吊起来并摆放好呢?

所以,Pod 对象,其实就是容器的升级版。它对容器进行了组合,添加了更多的属性和字段。这就好比给集装箱四面安装了吊环,使得 Kubernetes 这架”吊车”,可以更轻松地操作它。

而 Kubernetes 操作这些”集装箱”的逻辑,都由控制器(Controller)完成。在前面的第 12 篇文章 《牛刀小试:我的第一个容器化应用》 中,我们曾经使用过 Deployment 这个最基本的控制器对象。

现在,我们一起来回顾一下这个名叫 nginx-deployment 的例子:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

这个 Deployment 定义的编排动作非常简单,即:确保携带了 app=nginx 标签的 Pod 的个数,永远等于 spec.replicas 指定的个数,即 2 个。

这就意味着,如果在这个集群中,携带 app=nginx 标签的 Pod 的个数大于 2 的时候,就会有旧的 Pod 被删除;反之,就会有新的 Pod 被创建。

这时,你也许就会好奇:究竟是 Kubernetes 项目中的哪个组件,在执行这些操作呢?

我在前面介绍 Kubernetes 架构的时候,曾经提到过一个叫作 kube-controller-manager 的组件。

实际上,这个组件,就是一系列控制器的集合。我们可以查看一下 Kubernetes 项目的 pkg/controller 目录:

$ cd kubernetes/pkg/controller/
$ ls -d */
deployment/             job/                    podautoscaler/
cloud/                  disruption/             namespace/
replicaset/             serviceaccount/         volume/
cronjob/                garbagecollector/       nodelifecycle/          replication/            statefulset/            daemon/
...

这个目录下面的每一个控制器,都以独有的方式负责某种编排功能。而我们的 Deployment,正是这些控制器中的一种。

实际上,这些控制器之所以被统一放在 pkg/controller 目录下,就是因为它们都遵循 Kubernetes 项目中的一个通用编排模式,即:控制循环(control loop)。

比如,现在有一种待编排的对象 X,它有一个对应的控制器。那么,我就可以用一段 Go 语言风格的伪代码,为你描述这个 控制循环

for {
  实际状态 := 获取集群中对象 X 的实际状态(Actual State)
  期望状态 := 获取集群中对象 X 的期望状态(Desired State)
  if 实际状态 == 期望状态{
    什么都不做
  } else {
    执行编排动作,将实际状态调整为期望状态
  }
}

在具体实现中,实际状态往往来自于 Kubernetes 集群本身。

比如,kubelet 通过心跳汇报的容器状态和节点状态,或者监控系统中保存的应用监控数据,或者控制器主动收集的它自己感兴趣的信息,这些都是常见的实际状态的来源。

而期望状态,一般来自于用户提交的 YAML 文件。

比如,Deployment 对象中 Replicas 字段的值。很明显,这些信息往往都保存在 Etcd 中。

接下来,以 Deployment 为例,我和你简单描述一下它对控制器模型的实现:

  1. Deployment 控制器从 Etcd 中获取到所有携带了”app: nginx”标签的 Pod,然后统计它们的数量,这就是实际状态;
  2. Deployment 对象的 Replicas 字段的值就是期望状态;
  3. Deployment 控制器将两个状态做比较,然后根据比较结果,确定是创建 Pod,还是删除已有的 Pod(具体如何操作 Pod 对象,我会在下一篇文章详细介绍)。

可以看到,一个 Kubernetes 对象的主要编排逻辑,实际上是在第三步的”对比”阶段完成的。

这个操作,通常被叫作调谐(Reconcile)。这个调谐的过程,则被称作”Reconcile Loop”(调谐循环)或者”Sync Loop”(同步循环)。

所以,如果你以后在文档或者社区中碰到这些词,都不要担心,它们其实指的都是同一个东西:控制循环。

而调谐的最终结果,往往都是对被控制对象的某种写操作。

比如,增加 Pod,删除已有的 Pod,或者更新 Pod 的某个字段。 这也是 Kubernetes 项目”面向 API 对象编程”的一个直观体现

其实,像 Deployment 这种控制器的设计原理,就是我们前面提到过的,”用一种对象管理另一种对象”的”艺术”。

其中,这个控制器对象本身,负责定义被管理对象的期望状态。比如,Deployment 里的 replicas=2 这个字段。

而被控制对象的定义,则来自于一个”模板”。比如,Deployment 里的 template 字段。

可以看到,Deployment 这个 template 字段里的内容,跟一个标准的 Pod 对象的 API 定义,丝毫不差。而所有被这个 Deployment 管理的 Pod 实例,其实都是根据这个 template 字段的内容创建出来的。

像 Deployment 定义的 template 字段,在 Kubernetes 项目中有一个专有的名字,叫作 PodTemplate(Pod 模板)。

这个概念非常重要,因为后面我要讲解到的大多数控制器,都会使用 PodTemplate 来统一定义它所要管理的 Pod。更有意思的是,我们还会看到其他类型的对象模板,比如 Volume 的模板。

至此,我们就可以对 Deployment 以及其他类似的控制器,做一个简单总结了:

Deployment控制器分析

如上图所示, 类似 Deployment 这样的一个控制器,实际上都是由上半部分的控制器定义(包括期望状态),加上下半部分的被控制对象的模板组成的。

这就是为什么,在所有 API 对象的 Metadata 里,都有一个字段叫作 ownerReference,用于保存当前这个 API 对象的拥有者(Owner)的信息。

那么,对于我们这个 nginx-deployment 来说,它创建出来的 Pod 的 ownerReference 就是 nginx-deployment 吗?或者说,nginx-deployment 所直接控制的,就是 Pod 对象么?

17 | 经典PaaS的记忆:作业副本与水平扩展

在上一篇文章中,我为你详细介绍了 Kubernetes 项目中第一个重要的设计思想:控制器模式。

而在今天这篇文章中,我就来为你详细讲解一下,Kubernetes 里第一个控制器模式的完整实现:Deployment。

Deployment 看似简单,但实际上,它实现了 Kubernetes 项目中一个非常重要的功能: Pod 的”水平扩展 / 收缩”(horizontal scaling out/in) 。这个功能,是从 PaaS 时代开始,一个平台级项目就必须具备的编排能力。

举个例子,如果你更新了 Deployment 的 Pod 模板(比如,修改了容器的镜像),那么 Deployment 就需要遵循一种叫作”滚动更新”(rolling update)的方式,来升级现有的容器。

而这个能力的实现,依赖的是 Kubernetes 项目中的一个非常重要的概念(API 对象):ReplicaSet。

ReplicaSet 的结构非常简单,我们可以通过这个 YAML 文件查看一下:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: nginx-set
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9

可以看到,这就是一个我们常用的 nginx-deployment,它定义的 Pod 副本个数是 3(spec.replicas=3)。

那么,在具体的实现上,这个 Deployment,与 ReplicaSet,以及 Pod 的关系是怎样的呢?

我们可以用一张图把它描述出来:

Deployment与ReplicaSet以及Pod的关系

通过这张图,我们就很清楚的看到,一个定义了 replicas=3 的 Deployment,与它的 ReplicaSet,以及 Pod 的关系,实际上是一种”层层控制”的关系。

其中,ReplicaSet 负责通过”控制器模式”,保证系统中 Pod 的个数永远等于指定的个数(比如,3 个)。这也正是 Deployment 只允许容器的 restartPolicy=Always 的主要原因:只有在容器能保证自己始终是 Running 状态的前提下,ReplicaSet 调整 Pod 的个数才有意义。

而在此基础上,Deployment 同样通过”控制器模式”,来操作 ReplicaSet 的个数和属性,进而实现”水平扩展 / 收缩”和”滚动更新”这两个编排动作。

其中,”水平扩展 / 收缩”非常容易实现,Deployment Controller 只需要修改它所控制的 ReplicaSet 的 Pod 副本个数就可以了。

比如,把这个值从 3 改成 4,那么 Deployment 所对应的 ReplicaSet,就会根据修改后的值自动创建一个新的 Pod。这就是”水平扩展”了;”水平收缩”则反之。

而用户想要执行这个操作的指令也非常简单,就是 kubectl scale,比如:

$ kubectl scale deployment nginx-deployment --replicas=4
deployment.apps/nginx-deployment scaled

那么, “滚动更新”又是什么意思,是如何实现的呢?

接下来,我还以这个 Deployment 为例,来为你讲解”滚动更新”的过程。

首先,我们来创建这个 nginx-deployment:

kubectl create -f nginx-deployment.yaml --record

注意,在这里,我额外加了一个-record 参数。它的作用,是记录下你每次操作所执行的命令,以方便后面查看。

然后,我们来检查一下 nginx-deployment 创建后的状态信息:

$ kubectl get deployments
NAME               DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   3         0         0            0           1s

在返回结果中,我们可以看到四个状态字段,它们的含义如下所示。

  1. DESIRED:用户期望的 Pod 副本个数(spec.replicas 的值);
  2. CURRENT:当前处于 Running 状态的 Pod 的个数;
  3. UP-TO-DATE:当前处于最新版本的 Pod 的个数,所谓最新版本指的是 Pod 的 Spec 部分与 Deployment 里 Pod 模板里定义的完全一致;
  4. AVAILABLE:当前已经可用的 Pod 的个数,即:既是 Running 状态,又是最新版本,并且已经处于 Ready(健康检查正确)状态的 Pod 的个数。

可以看到,只有这个 AVAILABLE 字段,描述的才是用户所期望的最终状态。

而 Kubernetes 项目还为我们提供了一条指令,让我们可以实时查看 Deployment 对象的状态变化。这个指令就是 kubectl rollout status:

$ kubectl rollout status deployment/nginx-deployment
Waiting for rollout to finish: 2 out of 3 new replicas have been updated...
deployment.apps/nginx-deployment successfully rolled out

在这个返回结果中,”2 out of 3 new replicas have been updated”意味着已经有 2 个 Pod 进入了 UP-TO-DATE 状态。

继续等待一会儿,我们就能看到这个 Deployment 的 3 个 Pod,就进入到了 AVAILABLE 状态:

$ kubectl get deployments
NAME               DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   3         3         3            3           20s

此时,你可以尝试查看一下这个 Deployment 所控制的 ReplicaSet:

$ kubectl get rs
NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-3167673210   3         3         3       20s

如上所示,在用户提交了一个 Deployment 对象后,Deployment Controller 就会立即创建一个 Pod 副本个数为 3 的 ReplicaSet。这个 ReplicaSet 的名字,则是由 Deployment 的名字和一个随机字符串共同组成。

这个随机字符串叫作 pod-template-hash,在我们这个例子里就是:3167673210。ReplicaSet 会把这个随机字符串加在它所控制的所有 Pod 的标签里,从而保证这些 Pod 不会与集群里的其他 Pod 混淆。

而 ReplicaSet 的 DESIRED、CURRENT 和 READY 字段的含义,和 Deployment 中是一致的。所以, 相比之下,Deployment 只是在 ReplicaSet 的基础上,添加了 UP-TO-DATE 这个跟版本有关的状态字段。

这个时候,如果我们修改了 Deployment 的 Pod 模板,”滚动更新”就会被自动触发。

修改 Deployment 有很多方法。比如,我可以直接使用 kubectl edit 指令编辑 Etcd 里的 API 对象。

$ kubectl edit deployment/nginx-deployment
...
    spec:
      containers:
      - name: nginx
        image: nginx:1.9.1 # 1.7.9 -> 1.9.1
        ports:
        - containerPort: 80
...
deployment.extensions/nginx-deployment edited

这个 kubectl edit 指令,会帮你直接打开 nginx-deployment 的 API 对象。然后,你就可以修改这里的 Pod 模板部分了。比如,在这里,我将 nginx 镜像的版本升级到了 1.9.1。

备注:kubectl edit 并不神秘,它不过是把 API 对象的内容下载到了本地文件,让你修改完成后再提交上去。

kubectl edit 指令编辑完成后,保存退出,Kubernetes 就会立刻触发”滚动更新”的过程。你还可以通过 kubectl rollout status 指令查看 nginx-deployment 的状态变化:

$ kubectl rollout status deployment/nginx-deployment
Waiting for rollout to finish: 2 out of 3 new replicas have been updated...
deployment.extensions/nginx-deployment successfully rolled out

这时,你可以通过查看 Deployment 的 Events,看到这个”滚动更新”的流程:

$ kubectl describe deployment nginx-deployment
...
Events:
  Type    Reason             Age   From                   Message
  ----    ------             ----  ----                   -------
...
  Normal  ScalingReplicaSet  24s   deployment-controller  Scaled up replica set nginx-deployment-1764197365 to 1
  Normal  ScalingReplicaSet  22s   deployment-controller  Scaled down replica set nginx-deployment-3167673210 to 2
  Normal  ScalingReplicaSet  22s   deployment-controller  Scaled up replica set nginx-deployment-1764197365 to 2
  Normal  ScalingReplicaSet  19s   deployment-controller  Scaled down replica set nginx-deployment-3167673210 to 1
  Normal  ScalingReplicaSet  19s   deployment-controller  Scaled up replica set nginx-deployment-1764197365 to 3
  Normal  ScalingReplicaSet  14s   deployment-controller  Scaled down replica set nginx-deployment-3167673210 to 0

可以看到,首先,当你修改了 Deployment 里的 Pod 定义之后,Deployment Controller 会使用这个修改后的 Pod 模板,创建一个新的 ReplicaSet(hash=1764197365),这个新的 ReplicaSet 的初始 Pod 副本数是:0。

然后,在 Age=24 s 的位置,Deployment Controller 开始将这个新的 ReplicaSet 所控制的 Pod 副本数从 0 个变成 1 个,即:”水平扩展”出一个副本。

紧接着,在 Age=22 s 的位置,Deployment Controller 又将旧的 ReplicaSet(hash=3167673210)所控制的旧 Pod 副本数减少一个,即:”水平收缩”成两个副本。

如此交替进行,新 ReplicaSet 管理的 Pod 副本数,从 0 个变成 1 个,再变成 2 个,最后变成 3 个。而旧的 ReplicaSet 管理的 Pod 副本数则从 3 个变成 2 个,再变成 1 个,最后变成 0 个。这样,就完成了这一组 Pod 的版本升级过程。

像这样, 将一个集群中正在运行的多个 Pod 版本,交替地逐一升级的过程,就是”滚动更新”。

在这个”滚动更新”过程完成之后,你可以查看一下新、旧两个 ReplicaSet 的最终状态:

$ kubectl get rs
NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-1764197365   3         3         3       6s
nginx-deployment-3167673210   0         0         0       30s

其中,旧 ReplicaSet(hash=3167673210)已经被”水平收缩”成了 0 个副本。

这种”滚动更新”的好处是显而易见的。

比如,在升级刚开始的时候,集群里只有 1 个新版本的 Pod。如果这时,新版本 Pod 有问题启动不起来,那么”滚动更新”就会停止,从而允许开发和运维人员介入。而在这个过程中,由于应用本身还有两个旧版本的 Pod 在线,所以服务并不会受到太大的影响。

当然,这也就要求你一定要使用 Pod 的 Health Check 机制检查应用的运行状态,而不是简单地依赖于容器的 Running 状态。要不然的话,虽然容器已经变成 Running 了,但服务很有可能尚未启动,”滚动更新”的效果也就达不到了。

而为了进一步保证服务的连续性,Deployment Controller 还会确保,在任何时间窗口内,只有指定比例的 Pod 处于离线状态。同时,它也会确保,在任何时间窗口内,只有指定比例的新 Pod 被创建出来。这两个比例的值都是可以配置的,默认都是 DESIRED 值的 25%。

所以,在上面这个 Deployment 的例子中,它有 3 个 Pod 副本,那么控制器在”滚动更新”的过程中永远都会确保至少有 2 个 Pod 处于可用状态,至多只有 4 个 Pod 同时存在于集群中。这个策略,是 Deployment 对象的一个字段,名叫 RollingUpdateStrategy,如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
...
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1

在上面这个 RollingUpdateStrategy 的配置中,maxSurge 指定的是除了 DESIRED 数量之外,在一次”滚动”中,Deployment 控制器还可以创建多少个新 Pod;而 maxUnavailable 指的是,在一次”滚动”中,Deployment 控制器可以删除多少个旧 Pod。

同时,这两个配置还可以用前面我们介绍的百分比形式来表示,比如:maxUnavailable=50%,指的是我们最多可以一次删除”50%*DESIRED 数量”个 Pod。

结合以上讲述,现在我们可以扩展一下 Deployment、ReplicaSet 和 Pod 的关系图了。

Deployment和ReplicaSet以及Pod关系图的扩展

如上所示,Deployment 的控制器,实际上控制的是 ReplicaSet 的数目,以及每个 ReplicaSet 的属性。

而一个应用的版本,对应的正是一个 ReplicaSet;这个版本应用的 Pod 数量,则由 ReplicaSet 通过它自己的控制器(ReplicaSet Controller)来保证。

通过这样的多个 ReplicaSet 对象,Kubernetes 项目就实现了对多个”应用版本”的描述。

而明白了”应用版本和 ReplicaSet 一一对应”的设计思想之后,我就可以为你讲解一下 Deployment 对应用进行版本控制的具体原理 了。

这一次,我会使用一个叫 kubectl set image 的指令,直接修改 nginx-deployment 所使用的镜像。这个命令的好处就是,你可以不用像 kubectl edit 那样需要打开编辑器。

不过这一次,我把这个镜像名字修改成为了一个错误的名字,比如:nginx:1.91。这样,这个 Deployment 就会出现一个升级失败的版本。

我们一起来实践一下:

$ kubectl set image deployment/nginx-deployment nginx=nginx:1.91
deployment.extensions/nginx-deployment image updated

由于这个 nginx:1.91 镜像在 Docker Hub 中并不存在,所以这个 Deployment 的”滚动更新”被触发后,会立刻报错并停止。

这时,我们来检查一下 ReplicaSet 的状态,如下所示:

$ kubectl get rs
NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-1764197365   2         2         2       24s
nginx-deployment-3167673210   0         0         0       35s
nginx-deployment-2156724341   2         2         0       7s

通过这个返回结果,我们可以看到,新版本的 ReplicaSet(hash=2156724341)的”水平扩展”已经停止。而且此时,它已经创建了两个 Pod,但是它们都没有进入 READY 状态。这当然是因为这两个 Pod 都拉取不到有效的镜像。

与此同时,旧版本的 ReplicaSet(hash=1764197365)的”水平收缩”,也自动停止了。此时,已经有一个旧 Pod 被删除,还剩下两个旧 Pod。

那么问题来了, 我们如何让这个 Deployment 的 3 个 Pod,都回滚到以前的旧版本呢?

我们只需要执行一条 kubectl rollout undo 命令,就能把整个 Deployment 回滚到上一个版本:

$ kubectl rollout undo deployment/nginx-deployment
deployment.extensions/nginx-deployment

很容易想到,在具体操作上,Deployment 的控制器,其实就是让这个旧 ReplicaSet(hash=1764197365)再次”扩展”成 3 个 Pod,而让新的 ReplicaSet(hash=2156724341)重新”收缩”到 0 个 Pod。

更进一步地,如果我想回滚到更早之前的版本,要怎么办呢?

首先,我需要使用 kubectl rollout history 命令,查看每次 Deployment 变更对应的版本。 而由于我们在创建这个 Deployment 的时候,指定了-record 参数,所以我们创建这些版本时执行的 kubectl 命令,都会被记录下来。这个操作的输出如下所示:

$ kubectl rollout history deployment/nginx-deployment
deployments "nginx-deployment"
REVISION    CHANGE-CAUSE
1           kubectl create -f nginx-deployment.yaml --record
2           kubectl edit deployment/nginx-deployment
3           kubectl set image deployment/nginx-deployment nginx=nginx:1.91

可以看到,我们前面执行的创建和更新操作,分别对应了版本 1 和版本 2,而那次失败的更新操作,则对应的是版本 3。

当然,你还可以通过这个 kubectl rollout history 指令,看到每个版本对应的 Deployment 的 API 对象的细节,具体命令如下所示:

kubectl rollout history deployment/nginx-deployment --revision=2

然后,我们就可以在 kubectl rollout undo 命令行最后,加上要回滚到的指定版本的版本号,就可以回滚到指定版本了。 这个指令的用法如下:

$ kubectl rollout undo deployment/nginx-deployment --to-revision=2
deployment.extensions/nginx-deployment

这样,Deployment Controller 还会按照”滚动更新”的方式,完成对 Deployment 的降级操作。

不过,你可能已经想到了一个问题:我们对 Deployment 进行的每一次更新操作,都会生成一个新的 ReplicaSet 对象,是不是有些多余,甚至浪费资源呢?

没错。

所以,Kubernetes 项目还提供了一个指令,使得我们对 Deployment 的多次更新操作,最后 只生成一个 ReplicaSet。

具体的做法是,在更新 Deployment 前,你要先执行一条 kubectl rollout pause 指令。它的用法如下所示:

$ kubectl rollout pause deployment/nginx-deployment
deployment.extensions/nginx-deployment paused

这个 kubectl rollout pause 的作用,是让这个 Deployment 进入了一个”暂停”状态。

所以接下来,你就可以随意使用 kubectl edit 或者 kubectl set image 指令,修改这个 Deployment 的内容了。

由于此时 Deployment 正处于”暂停”状态,所以我们对 Deployment 的所有修改,都不会触发新的”滚动更新”,也不会创建新的 ReplicaSet。

而等到我们对 Deployment 修改操作都完成之后,只需要再执行一条 kubectl rollout resume 指令,就可以把这个 Deployment”恢复”回来,如下所示:

$ kubectl rollout resume deploy/nginx-deployment
deployment.extensions/nginx-deployment resumed

而在这个 kubectl rollout resume 指令执行之前,在 kubectl rollout pause 指令之后的这段时间里,我们对 Deployment 进行的所有修改,最后只会触发一次”滚动更新”。

当然,我们可以通过检查 ReplicaSet 状态的变化,来验证一下 kubectl rollout pause 和 kubectl rollout resume 指令的执行效果,如下所示:

$ kubectl get rs
NAME               DESIRED   CURRENT   READY     AGE
nginx-1764197365   0         0         0         2m
nginx-3196763511   3         3         3         28s

通过返回结果,我们可以看到,只有一个 hash=3196763511 的 ReplicaSet 被创建了出来。

不过,即使你像上面这样小心翼翼地控制了 ReplicaSet 的生成数量,随着应用版本的不断增加,Kubernetes 中还是会为同一个 Deployment 保存很多很多不同的 ReplicaSet。

那么,我们又该如何控制这些”历史”ReplicaSet 的数量呢?

很简单,Deployment 对象有一个字段,叫作 spec.revisionHistoryLimit,就是 Kubernetes 为 Deployment 保留的”历史版本”个数。所以,如果把它设置为 0,你就再也不能做回滚操作了。

17.1 总结

Deployment 实际上是一个 两层控制器 。首先,它通过 ReplicaSet 的个数 来描述应用的版本;然后,它再通过 ReplicaSet 的属性 (比如 replicas 的值),来保证 Pod 的副本数量。

备注:Deployment 控制 ReplicaSet(版本),ReplicaSet 控制 Pod(副本数)。这个两层控制关系一定要牢记。

不过,相信你也能够感受到,Kubernetes 项目对 Deployment 的设计,实际上是代替我们完成了对”应用”的抽象,使得我们可以使用这个 Deployment 对象来描述应用,使用 kubectl rollout 命令控制应用的版本。

可是,在实际使用场景中,应用发布的流程往往千差万别,也可能有很多的定制化需求。比如,我的应用可能有会话黏连(session sticky),这就意味着”滚动更新”的时候,哪个 Pod 能下线,是不能随便选择的。

这种场景,光靠 Deployment 自己就很难应对了。对于这种需求,我在专栏后续文章中重点介绍的”自定义控制器”,就可以帮我们实现一个功能更加强大的 Deployment Controller。

当然,Kubernetes 项目本身,也提供了另外一种抽象方式,帮我们应对其他一些用 Deployment 无法处理的应用编排场景。这个设计,就是对有状态应用的管理,也是我在下一篇文章中要重点讲解的内容。

18 | 深入理解StatefulSet(一):拓扑状态

在上一篇文章中,我在结尾处讨论到了 Deployment 实际上并不足以覆盖所有的应用编排问题。

造成这个问题的根本原因,在于 Deployment 对应用做了一个简单化假设。

它认为,一个应用的所有 Pod,是完全一样的。所以,它们互相之间没有顺序,也无所谓运行在哪台宿主机上。需要的时候,Deployment 就可以通过 Pod 模板创建新的 Pod;不需要的时候,Deployment 就可以”杀掉”任意一个 Pod。

但是,在实际的场景中,并不是所有的应用都可以满足这样的要求。

尤其是分布式应用,它的多个实例之间,往往有依赖关系,比如:主从关系、主备关系。

还有就是数据存储类应用,它的多个实例,往往都会在本地磁盘上保存一份数据。而这些实例一旦被杀掉,即便重建出来,实例与数据之间的对应关系也已经丢失,从而导致应用失败。

所以,这种实例之间有不对等关系,以及实例对外部数据有依赖关系的应用,就被称为”有状态应用”(Stateful Application)。

得益于”控制器模式”的设计思想,Kubernetes 项目很早就在 Deployment 的基础上,扩展出了对”有状态应用”的初步支持。这个编排功能,就是:StatefulSet。

StatefulSet 的设计其实非常容易理解。它把真实世界里的应用状态,抽象为了两种情况:

  1. 拓扑状态。 这种情况意味着,应用的多个实例之间不是完全对等的关系。这些应用实例,必须按照某些顺序启动,比如应用的主节点 A 要先于从节点 B 启动。而如果你把 A 和 B 两个 Pod 删除掉,它们再次被创建出来时也必须严格按照这个顺序才行。并且,新创建出来的 Pod,必须和原来 Pod 的网络标识一样,这样原先的访问者才能使用同样的方法,访问到这个新 Pod。

  2. 存储状态。 这种情况意味着,应用的多个实例分别绑定了不同的存储数据。对于这些应用实例来说,Pod A 第一次读取到的数据,和隔了十分钟之后再次读取到的数据,应该是同一份,哪怕在此期间 Pod A 被重新创建过。这种情况最典型的例子,就是一个数据库应用的多个存储实例。

所以, StatefulSet 的核心功能,就是通过某种方式记录这些状态,然后在 Pod 被重新创建时,能够为新 Pod 恢复这些状态。

在开始讲述 StatefulSet 的工作原理之前,我就必须 先为你讲解一个 Kubernetes 项目中非常实用的概念:Headless Service。

我在和你一起讨论 Kubernetes 架构的时候就曾介绍过,Service 是 Kubernetes 项目中用来将一组 Pod 暴露给外界访问的一种机制。比如,一个 Deployment 有 3 个 Pod,那么我就可以定义一个 Service。然后,用户只要能访问到这个 Service,它就能访问到某个具体的 Pod。

那么,这个 Service 又是如何被访问的呢?

  1. 第一种方式,是以 Service 的 VIP(Virtual IP,即:虚拟 IP)方式。 比如:当我访问 10.0.23.1 这个 Service 的 IP 地址时,10.0.23.1 其实就是一个 VIP,它会把请求转发到该 Service 所代理的某一个 Pod 上。这里的具体原理,我会在后续的 Service 章节中进行详细介绍。

  2. 第二种方式,就是以 Service 的 DNS 方式。 比如:这时候,只要我访问”my-svc.my-namespace.svc.cluster.local”这条 DNS 记录,就可以访问到名叫 my-svc 的 Service 所代理的某一个 Pod。

而在第二种 Service DNS 的方式下,具体还可以分为两种处理方法:

  1. 第一种处理方法,是 Normal Service。这种情况下,你访问”my-svc.my-namespace.svc.cluster.local”解析到的,正是 my-svc 这个 Service 的 VIP,后面的流程就跟 VIP 方式一致了。

  2. 而第二种处理方法,正是 Headless Service。这种情况下,你访问”my-svc.my-namespace.svc.cluster.local”解析到的,直接就是 my-svc 代理的某一个 Pod 的 IP 地址。 可以看到,这里的区别在于,Headless Service 不需要分配一个 VIP,而是可以直接以 DNS 记录的方式解析出被代理 Pod 的 IP 地址。

那么,这样的设计又有什么作用呢?

想要回答这个问题,我们需要从 Headless Service 的定义方式看起。

下面是一个标准的 Headless Service 对应的 YAML 文件:

apiVersion: v1
kind: Service
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  ports:
  - port: 80
    name: web
  clusterIP: None
  selector:
    app: nginx

可以看到,所谓的 Headless Service,其实仍是一个标准 Service 的 YAML 文件。只不过,它的 clusterIP 字段的值是:None,即:这个 Service,没有一个 VIP 作为”头”。这也就是 Headless 的含义。所以,这个 Service 被创建后并不会被分配一个 VIP,而是会以 DNS 记录的方式暴露出它所代理的 Pod。

而它所代理的 Pod,依然是采用我在前面第 12 篇文章 《牛刀小试:我的第一个容器化应用》 中提到的 Label Selector 机制选择出来的,即:所有携带了 app=nginx 标签的 Pod,都会被这个 Service 代理起来。

然后关键来了。

当你按照这样的方式创建了一个 Headless Service 之后,它所代理的所有 Pod 的 IP 地址,都会被绑定一个这样格式的 DNS 记录,如下所示:

<pod-name>.<svc-name>.<namespace>.svc.cluster.local

这个 DNS 记录,正是 Kubernetes 项目为 Pod 分配的唯一的”可解析身份”(Resolvable Identity)。

有了这个”可解析身份”,只要你知道了一个 Pod 的名字,以及它对应的 Service 的名字,你就可以非常确定地通过这条 DNS 记录访问到 Pod 的 IP 地址。

那么, StatefulSet 又是如何使用这个 DNS 记录来维持 Pod 的拓扑状态的呢?

为了回答这个问题,现在我们就来编写一个 StatefulSet 的 YAML 文件,如下所示:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  serviceName: "nginx"
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.9.1
        ports:
        - containerPort: 80
          name: web

这个 YAML 文件,和我们在前面文章中用到的 nginx-deployment 的唯一区别,就是多了一个 serviceName=nginx 字段。

这个字段的作用,就是告诉 StatefulSet 控制器,在执行控制循环(Control Loop)的时候,请使用 nginx 这个 Headless Service 来保证 Pod 的”可解析身份”。

所以,当你通过 kubectl create 创建了上面这个 Service 和 StatefulSet 之后,就会看到如下两个对象:

$ kubectl create -f svc.yaml
$ kubectl get service nginx
NAME      TYPE         CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
nginx     ClusterIP    None         <none>        80/TCP    10s

$ kubectl create -f statefulset.yaml
$ kubectl get statefulset web
NAME      DESIRED   CURRENT   AGE
web       2         1         19s

这时候,如果你手比较快的话,还可以通过 kubectl 的 -w 参数,即:Watch 功能,实时查看 StatefulSet 创建两个有状态实例的过程:

备注:如果手不够快的话,Pod 很快就创建完了。不过,你依然可以通过这个 StatefulSet 的 Events 看到这些信息。

$ kubectl get pods -w -l app=nginx
NAME      READY     STATUS    RESTARTS   AGE
web-0     0/1       Pending   0          0s
web-0     0/1       Pending   0         0s
web-0     0/1       ContainerCreating   0         0s
web-0     1/1       Running   0         19s
web-1     0/1       Pending   0         0s
web-1     0/1       Pending   0         0s
web-1     0/1       ContainerCreating   0         0s
web-1     1/1       Running   0         20s

通过上面这个 Pod 的创建过程,我们不难看到,StatefulSet 给它所管理的所有 Pod 的名字,进行了编号,编号规则是:-。

而且这些编号都是从 0 开始累加,与 StatefulSet 的每个 Pod 实例一一对应,绝不重复。

更重要的是,这些 Pod 的创建,也是严格按照编号顺序进行的。比如,在 web-0 进入到 Running 状态、并且细分状态(Conditions)成为 Ready 之前,web-1 会一直处于 Pending 状态。

备注:Ready 状态再一次提醒了我们,为 Pod 设置 livenessProbe 和 readinessProbe 的重要性。

当这两个 Pod 都进入了 Running 状态之后,你就可以查看到它们各自唯一的”网络身份”了。

我们使用 kubectl exec 命令进入到容器中查看它们的 hostname:

$ kubectl exec web-0 -- sh -c 'hostname'
web-0
$ kubectl exec web-1 -- sh -c 'hostname'
web-1

可以看到,这两个 Pod 的 hostname 与 Pod 名字是一致的,都被分配了对应的编号。接下来,我们再试着以 DNS 的方式,访问一下这个 Headless Service:

kubectl run -i --tty --image busybox dns-test --restart=Never --rm /bin/sh

通过这条命令,我们启动了一个一次性的 Pod,因为-rm 意味着 Pod 退出后就会被删除掉。然后,在这个 Pod 的容器里面,我们尝试用 nslookup 命令,解析一下 Pod 对应的 Headless Service:

$ kubectl run -i --tty --image busybox dns-test --restart=Never --rm /bin/sh
$ nslookup web-0.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-0.nginx
Address 1: 10.244.1.7

$ nslookup web-1.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-1.nginx
Address 1: 10.244.2.7

从 nslookup 命令的输出结果中,我们可以看到,在访问 web-0.nginx 的时候,最后解析到的,正是 web-0 这个 Pod 的 IP 地址;而当访问 web-1.nginx 的时候,解析到的则是 web-1 的 IP 地址。

这时候,如果你在另外一个 Terminal 里把这两个”有状态应用”的 Pod 删掉:

$ kubectl delete pod -l app=nginx
pod "web-0" deleted
pod "web-1" deleted

然后,再在当前 Terminal 里 Watch 一下这两个 Pod 的状态变化,就会发现一个有趣的现象:

$ kubectl get pod -w -l app=nginx
NAME      READY     STATUS              RESTARTS   AGE
web-0     0/1       ContainerCreating   0          0s
NAME      READY     STATUS    RESTARTS   AGE
web-0     1/1       Running   0          2s
web-1     0/1       Pending   0         0s
web-1     0/1       ContainerCreating   0         0s
web-1     1/1       Running   0         32s

可以看到,当我们把这两个 Pod 删除之后,Kubernetes 会按照原先编号的顺序,创建出了两个新的 Pod。并且,Kubernetes 依然为它们分配了与原来相同的”网络身份”:web-0.nginx 和 web-1.nginx。

通过这种严格的对应规则, StatefulSet 就保证了 Pod 网络标识的稳定性。

比如,如果 web-0 是一个需要先启动的主节点,web-1 是一个后启动的从节点,那么只要这个 StatefulSet 不被删除,你访问 web-0.nginx 时始终都会落在主节点上,访问 web-1.nginx 时,则始终都会落在从节点上,这个关系绝对不会发生任何变化。

所以,如果我们再用 nslookup 命令,查看一下这个新 Pod 对应的 Headless Service 的话:

$ kubectl run -i --tty --image busybox dns-test --restart=Never --rm /bin/sh
$ nslookup web-0.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-0.nginx
Address 1: 10.244.1.8

$ nslookup web-1.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-1.nginx
Address 1: 10.244.2.8

我们可以看到,在这个 StatefulSet 中,这两个新 Pod 的”网络标识”(比如:web-0.nginx 和 web-1.nginx),再次解析到了正确的 IP 地址(比如:web-0 Pod 的 IP 地址 10.244.1.8)。

通过这种方法,Kubernetes 就成功地将 Pod 的拓扑状态(比如:哪个节点先启动,哪个节点后启动),按照 Pod 的”名字 + 编号”的方式固定了下来。 此外,Kubernetes 还为每一个 Pod 提供了一个固定并且唯一的访问入口,即:这个 Pod 对应的 DNS 记录。

这些状态,在 StatefulSet 的整个生命周期里都会保持不变,绝不会因为对应 Pod 的删除或者重新创建而失效。

不过,相信你也已经注意到了,尽管 web-0.nginx 这条记录本身不会变,但它解析到的 Pod 的 IP 地址,并不是固定的。这就意味着,对于”有状态应用”实例的访问,你必须使用 DNS 记录或者 hostname 的方式,而绝不应该直接访问这些 Pod 的 IP 地址。

18.1 总结

StatefulSet 这个控制器的主要作用之一,就是使用 Pod 模板创建 Pod 的时候,对它们进行编号,并且按照编号顺序逐一完成创建工作。而当 StatefulSet 的”控制循环”发现 Pod 的”实际状态”与”期望状态”不一致,需要新建或者删除 Pod 进行”调谐”的时候,它会严格按照这些 Pod 编号的顺序,逐一完成这些操作。

所以,StatefulSet 其实可以认为是对 Deployment 的改良。

与此同时,通过 Headless Service 的方式,StatefulSet 为每个 Pod 创建了一个固定并且稳定的 DNS 记录,来作为它的访问入口。

实际上,在部署”有状态应用”的时候,应用的每个实例拥有唯一并且稳定的”网络标识”,是一个非常重要的假设。

18.2 留言

今天按文章中的内容来, 确实也遇到了nslookup 反馈失败的状况.

** server can't find web-0.nginx: NXDOMAIN
*** Can't find web-0.nginx: No answer

但是直接 ping web-0.nginx 是可以获取真实ip地址的.
需要用 busybox:1.28.4 的镜像. 这个是最新版busybox的问题。

19 | 深入理解StatefulSet(二):存储状态

在今天这篇文章中,我将继续为你解读 StatefulSet 对存储状态的管理机制。这个机制,主要使用的是 一个叫作 Persistent Volume Claim 的功能。

在前面介绍 Pod 的时候,我曾提到过,要在一个 Pod 里声明 Volume,只要在 Pod 里加上 spec.volumes 字段即可。然后,你就可以在这个字段里定义一个具体类型的 Volume 了,比如:hostPath。

可是,你有没有想过这样一个场景: 如果你并不知道有哪些 Volume 类型可以用,要怎么办呢?

更具体地说,作为一个应用开发者,我可能对持久化存储项目(比如 Ceph、GlusterFS 等)一窍不通,也不知道公司的 Kubernetes 集群里到底是怎么搭建出来的,我也自然不会编写它们对应的 Volume 定义文件。

所谓”术业有专攻”,这些关于 Volume 的管理和远程持久化存储的知识,不仅超越了开发者的知识储备,还会有暴露公司基础设施秘密的风险。

比如,下面这个例子,就是一个声明了 Ceph RBD 类型 Volume 的 Pod:

apiVersion: v1
kind: Pod
metadata:
  name: rbd
spec:
  containers:
    - image: kubernetes/pause
      name: rbd-rw
      volumeMounts:
      - name: rbdpd
        mountPath: /mnt/rbd
  volumes:
    - name: rbdpd
      rbd:
        monitors:
        - '10.16.154.78:6789'
        - '10.16.154.82:6789'
        - '10.16.154.83:6789'
        pool: kube
        image: foo
        fsType: ext4
        readOnly: true
        user: admin
        keyring: /etc/ceph/keyring
        imageformat: "2"
        imagefeatures: "layering"

其一,如果不懂得 Ceph RBD 的使用方法,那么这个 Pod 里 Volumes 字段,你十有八九也完全看不懂。其二,这个 Ceph RBD 对应的存储服务器的地址、用户名、授权文件的位置,也都被轻易地暴露给了全公司的所有开发人员,这是一个典型的信息被”过度暴露”的例子。

这也是为什么,在后来的演化中, Kubernetes 项目引入了一组叫作 Persistent Volume Claim(PVC)和 Persistent Volume(PV)的 API 对象,大大降低了用户声明和使用持久化 Volume 的门槛。

举个例子,有了 PVC 之后,一个开发人员想要使用一个 Volume,只需要简单的两步即可。

第一步:定义一个 PVC,声明想要的 Volume 的属性:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pv-claim
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

可以看到,在这个 PVC 对象里,不需要任何关于 Volume 细节的字段,只有描述性的属性和定义。比如,storage: 1Gi,表示我想要的 Volume 大小至少是 1 GiB;accessModes: ReadWriteOnce,表示这个 Volume 的挂载方式是可读写,并且只能被挂载在一个节点上而非被多个节点共享。

备注:关于哪种类型的 Volume 支持哪种类型的 AccessMode,你可以查看 Kubernetes 项目官方文档中的 详细列表

第二步:在应用的 Pod 中,声明使用这个 PVC:

apiVersion: v1
kind: Pod
metadata:
  name: pv-pod
spec:
  containers:
    - name: pv-container
      image: nginx
      ports:
        - containerPort: 80
          name: "http-server"
      volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: pv-storage
  volumes:
    - name: pv-storage
      persistentVolumeClaim:
        claimName: pv-claim

可以看到,在这个 Pod 的 Volumes 定义中,我们只需要声明它的类型是 persistentVolumeClaim,然后指定 PVC 的名字,而完全不必关心 Volume 本身的定义。

这时候,只要我们创建这个 PVC 对象,Kubernetes 就会自动为它绑定一个符合条件的 Volume。可是,这些符合条件的 Volume 又是从哪里来的呢?

答案是,它们来自于由运维人员维护的 PV(Persistent Volume)对象。接下来,我们一起看一个常见的 PV 对象的 YAML 文件:

kind: PersistentVolume
apiVersion: v1
metadata:
  name: pv-volume
  labels:
    type: local
spec:
  capacity:
    storage: 10Gi
  rbd:
    monitors:
    - '10.16.154.78:6789'
    - '10.16.154.82:6789'
    - '10.16.154.83:6789'
    pool: kube
    image: foo
    fsType: ext4
    readOnly: true
    user: admin
    keyring: /etc/ceph/keyring
    imageformat: "2"
    imagefeatures: "layering"

可以看到,这个 PV 对象的 spec.rbd 字段,正是我们前面介绍过的 Ceph RBD Volume 的详细定义。而且,它还声明了这个 PV 的容量是 10 GiB。这样,Kubernetes 就会为我们刚刚创建的 PVC 对象绑定这个 PV。

所以,Kubernetes 中 PVC 和 PV 的设计, 实际上类似于”接口”和”实现”的思想 。开发者只要知道并会使用”接口”,即:PVC;而运维人员则负责给”接口”绑定具体的实现,即:PV。

这种解耦,就避免了因为向开发者暴露过多的存储系统细节而带来的隐患。此外,这种职责的分离,往往也意味着出现事故时可以更容易定位问题和明确责任,从而避免”扯皮”现象的出现。

而 PVC、PV 的设计,也使得 StatefulSet 对存储状态的管理成为了可能。 我们还是以上一篇文章中用到的 StatefulSet 为例(你也可以借此再回顾一下 《深入理解 StatefulSet(一):拓扑状态》 中的相关内容):

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  serviceName: "nginx"
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.9.1
        ports:
        - containerPort: 80
          name: web
        volumeMounts:
        - name: www
          mountPath: /usr/share/nginx/html
  volumeClaimTemplates:
  - metadata:
      name: www
    spec:
      accessModes:
      - ReadWriteOnce
      resources:
        requests:
          storage: 1Gi

这次,我们为这个 StatefulSet 额外添加了一个 volumeClaimTemplates 字段。从名字就可以看出来,它跟 Deployment 里 Pod 模板(PodTemplate)的作用类似。也就是说,凡是被这个 StatefulSet 管理的 Pod,都会声明一个对应的 PVC;而这个 PVC 的定义,就来自于 volumeClaimTemplates 这个模板字段。更重要的是, 这个 PVC 的名字,会被分配一个与这个 Pod 完全一致的编号。

这个自动创建的 PVC,与 PV 绑定成功后,就会进入 Bound 状态,这就意味着这个 Pod 可以挂载并使用这个 PV 了。

如果你还是不太理解 PVC 的话,可以先记住这样一个结论: PVC 其实就是一种特殊的 Volume 。只不过一个 PVC 具体是什么类型的 Volume,要在跟某个 PV 绑定之后才知道。关于 PV、PVC 更详细的知识,我会在容器存储部分做进一步解读。

当然,PVC 与 PV 的绑定得以实现的前提是,运维人员已经在系统里创建好了符合条件的 PV(比如,我们在前面用到的 pv-volume);或者,你的 Kubernetes 集群运行在公有云上,这样 Kubernetes 就会通过 Dynamic Provisioning 的方式,自动为你创建与 PVC 匹配的 PV。

所以,我们在使用 kubectl create 创建了 StatefulSet 之后,就会看到 Kubernetes 集群里出现了两个 PVC:

$ kubectl create -f statefulset.yaml
$ kubectl get pvc -l app=nginx
NAME        STATUS    VOLUME                                     CAPACITY   ACCESSMODES   AGE
www-web-0   Bound     pvc-15c268c7-b507-11e6-932f-42010a800002   1Gi        RWO           48s
www-web-1   Bound     pvc-15c79307-b507-11e6-932f-42010a800002   1Gi        RWO           48s

可以看到,这些 PVC,都以 <PVC 名字 >-<StatefulSet 名字 >-< 编号 > 的方式命名,并且处于 Bound 状态。

我们前面已经讲到过,这个 StatefulSet 创建出来的所有 Pod,都会声明使用编号的 PVC。比如,在名叫 web-0 的 Pod 的 volumes 字段,它会声明使用名叫 www-web-0 的 PVC,从而挂载到这个 PVC 所绑定的 PV。

所以,我们就可以使用如下所示的指令,在 Pod 的 Volume 目录里写入一个文件,来验证一下上述 Volume 的分配情况:

for i in 0 1; do kubectl exec web-$i -- sh -c 'echo hello $(hostname) > /usr/share/nginx/html/index.html'; done

如上所示,通过 kubectl exec 指令,我们在每个 Pod 的 Volume 目录里,写入了一个 index.html 文件。这个文件的内容,正是 Pod 的 hostname。比如,我们在 web-0 的 index.html 里写入的内容就是 “hello web-0”。

此时,如果你在这个 Pod 容器里访问 http://localhost ,你实际访问到的就是 Pod 里 Nginx 服务器进程,而它会为你返回 /usr/share/nginx/html/index.html 里的内容。这个操作的执行方法如下所示:

$ for i in 0 1; do kubectl exec -it web-$i -- curl localhost; done
hello web-0
hello web-1

现在,关键来了。

如果你使用 kubectl delete 命令删除这两个 Pod,这些 Volume 里的文件会不会丢失呢?

$ kubectl delete pod -l app=nginx
pod "web-0" deleted
pod "web-1" deleted

可以看到,正如我们前面介绍过的,在被删除之后,这两个 Pod 会被按照编号的顺序被重新创建出来。而这时候,如果你在新创建的容器里通过访问 http://localhost 的方式去访问 web-0 里的 Nginx 服务:

# 在被重新创建出来的 Pod 容器里访问 http://localhost
$ kubectl exec -it web-0 -- curl localhost
hello web-0

就会发现,这个请求依然会返回:hello web-0。也就是说,原先与名叫 web-0 的 Pod 绑定的 PV,在这个 Pod 被重新创建之后,依然同新的名叫 web-0 的 Pod 绑定在了一起。对于 Pod web-1 来说,也是完全一样的情况。

这是怎么做到的呢?

其实,我和你分析一下 StatefulSet 控制器恢复这个 Pod 的过程,你就可以很容易理解了。

首先,当你把一个 Pod,比如 web-0,删除之后,这个 Pod 对应的 PVC 和 PV,并不会被删除,而这个 Volume 里已经写入的数据,也依然会保存在远程存储服务里(比如,我们在这个例子里用到的 Ceph 服务器)。

此时,StatefulSet 控制器发现,一个名叫 web-0 的 Pod 消失了。所以,控制器就会重新创建一个新的、名字还是叫作 web-0 的 Pod 来,”纠正”这个不一致的情况。

需要注意的是,在这个新的 Pod 对象的定义里,它声明使用的 PVC 的名字,还是叫作:www-web-0。这个 PVC 的定义,还是来自于 PVC 模板(volumeClaimTemplates),这是 StatefulSet 创建 Pod 的标准流程。

所以,在这个新的 web-0 Pod 被创建出来之后,Kubernetes 为它查找名叫 www-web-0 的 PVC 时,就会直接找到旧 Pod 遗留下来的同名的 PVC,进而找到跟这个 PVC 绑定在一起的 PV。

这样,新的 Pod 就可以挂载到旧 Pod 对应的那个 Volume,并且获取到保存在 Volume 里的数据。

通过这种方式,Kubernetes 的 StatefulSet 就实现了对应用存储状态的管理。

看到这里,你是不是已经大致理解了 StatefulSet 的工作原理呢?现在,我再为你详细梳理一下吧。

  1. 首先,StatefulSet 的控制器直接管理的是 Pod。 这是因为,StatefulSet 里的不同 Pod 实例,不再像 ReplicaSet 中那样都是完全一样的,而是有了细微区别的。比如,每个 Pod 的 hostname、名字等都是不同的、携带了编号的。而 StatefulSet 区分这些实例的方式,就是通过在 Pod 的名字里加上事先约定好的编号。

  2. 其次,Kubernetes 通过 Headless Service,为这些有编号的 Pod,在 DNS 服务器中生成带有同样编号的 DNS 记录。 只要 StatefulSet 能够保证这些 Pod 名字里的编号不变,那么 Service 里类似于 web-0.nginx.default.svc.cluster.local 这样的 DNS 记录也就不会变,而这条记录解析出来的 Pod 的 IP 地址,则会随着后端 Pod 的删除和再创建而自动更新。这当然是 Service 机制本身的能力,不需要 StatefulSet 操心。

  3. 最后,StatefulSet 还为每一个 Pod 分配并创建一个同样编号的 PVC。 这样,Kubernetes 就可以通过 Persistent Volume 机制为这个 PVC 绑定上对应的 PV,从而保证了每一个 Pod 都拥有一个独立的 Volume。

    在这种情况下,即使 Pod 被删除,它所对应的 PVC 和 PV 依然会保留下来。所以当这个 Pod 被重新创建出来之后,Kubernetes 会为它找到同样编号的 PVC,挂载这个 PVC 对应的 Volume,从而获取到以前保存在 Volume 里的数据。

19.1 总结

StatefulSet 的设计思想: StatefulSet 其实就是一种特殊的 Deployment,而其独特之处在于,它的每个 Pod 都被编号了。而且,这个编号会体现在 Pod 的名字和 hostname 等标识信息上,这不仅代表了 Pod 的创建顺序,也是 Pod 的重要网络标识(即:在整个集群里唯一的、可被的访问身份)。

有了这个编号后,StatefulSet 就使用 Kubernetes 里的两个标准功能:Headless Service 和 PV/PVC,实现了对 Pod 的拓扑状态和存储状态的维护。

实际上,在下一篇文章的”有状态应用”实践环节,以及后续的讲解中,你就会逐渐意识到,StatefulSet 可以说是 Kubernetes 中作业编排的”集大成者”。

因为,几乎每一种 Kubernetes 的编排功能,都可以在编写 StatefulSet 的 YAML 文件时被用到。

20 | 深入理解StatefulSet(三):有状态应用实践

在今天这篇文章中,我将通过一个实际的例子,再次为你深入解读一下部署一个 StatefulSet 的完整流程。

今天我选择的实例是部署一个 MySQL 集群,这也是 Kubernetes 官方文档里的一个经典案例。

https://github.com/kibaamor/deep-study-k8s/tree/master/mysql-cluster

21 | 容器化守护进程的意义:DaemonSet

在上一篇文章中,我和你详细分享了使用 StatefulSet 编排”有状态应用”的过程。从中不难看出,StatefulSet 其实就是对现有典型运维业务的容器化抽象。也就是说,你一定有方法在不使用 Kubernetes、甚至不使用容器的情况下,自己 DIY 一个类似的方案出来。但是,一旦涉及到升级、版本管理等更工程化的能力,Kubernetes 的好处,才会更加凸现。

比如,如何对 StatefulSet 进行”滚动更新”(rolling update)?

很简单。你只要修改 StatefulSet 的 Pod 模板,就会自动触发”滚动更新”:

$ kubectl patch statefulset mysql --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value":"mysql:5.7.23"}]'
statefulset.apps/mysql patched

在这里,我使用了 kubectl patch 命令。它的意思是,以”补丁”的方式(JSON 格式的)修改一个 API 对象的指定字段,也就是我在后面指定的”spec/template/spec/containers/0/image”。

这样,StatefulSet Controller 就会按照与 Pod 编号相反的顺序,从最后一个 Pod 开始,逐一更新这个 StatefulSet 管理的每个 Pod。而如果更新发生了错误,这次”滚动更新”就会停止。此外,StatefulSet 的”滚动更新”还允许我们进行更精细的控制,比如金丝雀发布(Canary Deploy)或者灰度发布, 这意味着应用的多个实例中被指定的一部分不会被更新到最新的版本。

这个字段,正是 StatefulSet 的 spec.updateStrategy.rollingUpdate 的 partition 字段。

比如,现在我将前面这个 StatefulSet 的 partition 字段设置为 2:

$ kubectl patch statefulset mysql -p '{"spec":{"updateStrategy":{"type":"RollingUpdate","rollingUpdate":{"partition":2}}}}'
statefulset.apps/mysql patched

其中,kubectl patch 命令后面的参数(JSON 格式的),就是 partition 字段在 API 对象里的路径。所以,上述操作等同于直接使用 kubectl edit 命令,打开这个对象,把 partition 字段修改为 2。

这样,我就指定了当 Pod 模板发生变化的时候,比如 MySQL 镜像更新到 5.7.23,那么只有序号大于或者等于 2 的 Pod 会被更新到这个版本。并且,如果你删除或者重启了序号小于 2 的 Pod,等它再次启动后,也会保持原先的 5.7.2 版本,绝不会被升级到 5.7.23 版本。

StatefulSet 可以说是 Kubernetes 项目中最为复杂的编排对象,希望你课后能认真消化,动手实践一下这个例子。

而在今天这篇文章中,我会为你重点讲解一个相对轻松的知识点:DaemonSet。

顾名思义,DaemonSet 的主要作用,是让你在 Kubernetes 集群里,运行一个 Daemon Pod。 所以,这个 Pod 有如下三个特征:

  1. 这个 Pod 运行在 Kubernetes 集群里的每一个节点(Node)上;
  2. 每个节点上只有一个这样的 Pod 实例;
  3. 当有新的节点加入 Kubernetes 集群后,该 Pod 会自动地在新节点上被创建出来;而当旧节点被删除后,它上面的 Pod 也相应地会被回收掉。

这个机制听起来很简单,但 Daemon Pod 的意义确实是非常重要的。我随便给你列举几个例子:

  1. 各种网络插件的 Agent 组件,都必须运行在每一个节点上,用来处理这个节点上的容器网络;
  2. 各种存储插件的 Agent 组件,也必须运行在每一个节点上,用来在这个节点上挂载远程存储目录,操作容器的 Volume 目录;
  3. 各种监控组件和日志组件,也必须运行在每一个节点上,负责这个节点上的监控信息和日志搜集。

更重要的是,跟其他编排对象不一样,DaemonSet 开始运行的时机,很多时候比整个 Kubernetes 集群出现的时机都要早。

这个乍一听起来可能有点儿奇怪。但其实你来想一下:如果这个 DaemonSet 正是一个网络插件的 Agent 组件呢?

这个时候,整个 Kubernetes 集群里还没有可用的容器网络,所有 Worker 节点的状态都是 NotReady(NetworkReady=false)。这种情况下,普通的 Pod 肯定不能运行在这个集群上。所以,这也就意味着 DaemonSet 的设计,必须要有某种”过人之处”才行。

为了弄清楚 DaemonSet 的工作原理,我们还是按照老规矩,先从它的 API 对象的定义说起。

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluentd-elasticsearch
  namespace: kube-system
  labels:
    k8s-app: fluentd-logging
spec:
  selector:
    matchLabels:
      name: fluentd-elasticsearch
  template:
    metadata:
      labels:
        name: fluentd-elasticsearch
    spec:
      tolerations:
      - key: node-role.kubernetes.io/master
        effect: NoSchedule
      containers:
      - name: fluentd-elasticsearch
        image: k8s.gcr.io/fluentd-elasticsearch:1.20
        resources:
          limits:
            memory: 200Mi
          requests:
            cpu: 100m
            memory: 200Mi
        volumeMounts:
        - name: varlog
          mountPath: /var/log
        - name: varlibdockercontainers
          mountPath: /var/lib/docker/containers
          readOnly: true
      terminationGracePeriodSeconds: 30
      volumes:
      - name: varlog
        hostPath:
          path: /var/log
      - name: varlibdockercontainers
        hostPath:
          path: /var/lib/docker/containers

这个 DaemonSet,管理的是一个 fluentd-elasticsearch 镜像的 Pod。这个镜像的功能非常实用:通过 fluentd 将 Docker 容器里的日志转发到 ElasticSearch 中。

可以看到,DaemonSet 跟 Deployment 其实非常相似,只不过是没有 replicas 字段;它也使用 selector 选择管理所有携带了 name=fluentd-elasticsearch 标签的 Pod。

而这些 Pod 的模板,也是用 template 字段定义的。在这个字段中,我们定义了一个使用 fluentd-elasticsearch:1.20 镜像的容器,而且这个容器挂载了两个 hostPath 类型的 Volume,分别对应宿主机的 /var/log 目录和 /var/lib/docker/containers 目录。

显然,fluentd 启动之后,它会从这两个目录里搜集日志信息,并转发给 ElasticSearch 保存。这样,我们通过 ElasticSearch 就可以很方便地检索这些日志了。

需要注意的是,Docker 容器里应用的日志,默认会保存在宿主机的 /var/lib/docker/containers/{{. 容器 ID}}/{{. 容器 ID}}-json.log 文件里,所以这个目录正是 fluentd 的搜集目标。

那么, DaemonSet 又是如何保证每个 Node 上有且只有一个被管理的 Pod 呢?

显然,这是一个典型的”控制器模型”能够处理的问题。

DaemonSet Controller,首先从 Etcd 里获取所有的 Node 列表,然后遍历所有的 Node。这时,它就可以很容易地去检查,当前这个 Node 上是不是有一个携带了 name=fluentd-elasticsearch 标签的 Pod 在运行。

而检查的结果,可能有这么三种情况:

没有这种 Pod,那么就意味着要在这个 Node 上创建这样一个 Pod;

有这种 Pod,但是数量大于 1,那就说明要把多余的 Pod 从这个 Node 上删除掉;

正好只有一个这种 Pod,那说明这个节点是正常的。

其中,删除节点(Node)上多余的 Pod 非常简单,直接调用 Kubernetes API 就可以了。

但是, 如何在指定的 Node 上创建新 Pod 呢?

如果你已经熟悉了 Pod API 对象的话,那一定可以立刻说出答案:用 nodeSelector,选择 Node 的名字即可。

nodeSelector:
    name: <Node 名字 >

没错。

不过,在 Kubernetes 项目里,nodeSelector 其实已经是一个将要被废弃的字段了。因为,现在有了一个新的、功能更完善的字段可以代替它,即:nodeAffinity。我来举个例子:

apiVersion: v1
kind: Pod
metadata:
  name: with-node-affinity
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: metadata.name
            operator: In
            values:
            - node-geektime

在这个 Pod 里,我声明了一个 spec.affinity 字段,然后定义了一个 nodeAffinity。其中,spec.affinity 字段,是 Pod 里跟调度相关的一个字段。关于它的完整内容,我会在讲解调度策略的时候再详细阐述。

而在这里,我定义的 nodeAffinity 的含义是:

  1. requiredDuringSchedulingIgnoredDuringExecution:它的意思是说,这个 nodeAffinity 必须在每次调度的时候予以考虑。同时,这也意味着你可以设置在某些情况下不考虑这个 nodeAffinity;
  2. 这个 Pod,将来只允许运行在”metadata.name”是”node-geektime”的节点上。

在这里,你应该注意到 nodeAffinity 的定义,可以支持更加丰富的语法,比如 operator: In(即:部分匹配;如果你定义 operator: Equal,就是完全匹配),这也正是 nodeAffinity 会取代 nodeSelector 的原因之一。

备注:其实在大多数时候,这些 Operator 语义没啥用处。所以说,在学习开源项目的时候,一定要学会抓住”主线”。不要顾此失彼。

所以, 我们的 DaemonSet Controller 会在创建 Pod 的时候,自动在这个 Pod 的 API 对象里,加上这样一个 nodeAffinity 定义。 其中,需要绑定的节点名字,正是当前正在遍历的这个 Node。

当然,DaemonSet 并不需要修改用户提交的 YAML 文件里的 Pod 模板,而是在向 Kubernetes 发起请求之前,直接修改根据模板生成的 Pod 对象。这个思路,也正是我在前面讲解 Pod 对象时介绍过的。

此外,DaemonSet 还会给这个 Pod 自动加上另外一个与调度相关的字段,叫作 tolerations。这个字段意味着这个 Pod,会”容忍”(Toleration)某些 Node 的”污点”(Taint)。

而 DaemonSet 自动加上的 tolerations 字段,格式如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: with-toleration
spec:
  tolerations:
  - key: node.kubernetes.io/unschedulable
    operator: Exists
    effect: NoSchedule

这个 Toleration 的含义是:”容忍”所有被标记为 unschedulable”污点”的 Node;”容忍”的效果是允许调度。

备注:关于如何给一个 Node 标记上”污点”,以及这里具体的语法定义,我会在后面介绍调度器的时候做详细介绍。这里,你可以简单地把”污点”理解为一种特殊的 Label。

而在正常情况下,被标记了 unschedulable”污点”的 Node,是不会有任何 Pod 被调度上去的(effect: NoSchedule)。可是,DaemonSet 自动地给被管理的 Pod 加上了这个特殊的 Toleration,就使得这些 Pod 可以忽略这个限制,继而保证每个节点上都会被调度一个 Pod。当然,如果这个节点有故障的话,这个 Pod 可能会启动失败,而 DaemonSet 则会始终尝试下去,直到 Pod 启动成功。

这时,你应该可以猜到,我在前面介绍到的 DaemonSet 的”过人之处”,其实就是依靠 Toleration 实现的。

假如当前 DaemonSet 管理的,是一个网络插件的 Agent Pod,那么你就必须在这个 DaemonSet 的 YAML 文件里,给它的 Pod 模板加上一个能够”容忍”node.kubernetes.io/network-unavailable”污点”的 Toleration。正如下面这个例子所示:

...
template:
    metadata:
      labels:
        name: network-plugin-agent
    spec:
      tolerations:
      - key: node.kubernetes.io/network-unavailable
        operator: Exists
        effect: NoSchedule

在 Kubernetes 项目中,当一个节点的网络插件尚未安装时,这个节点就会被自动加上名为node.kubernetes.io/network-unavailable的”污点”。

而通过这样一个 Toleration,调度器在调度这个 Pod 的时候,就会忽略当前节点上的”污点”,从而成功地将网络插件的 Agent 组件调度到这台机器上启动起来。

这种机制,正是我们在部署 Kubernetes 集群的时候,能够先部署 Kubernetes 本身、再部署网络插件的根本原因:因为当时我们所创建的 Weave 的 YAML,实际上就是一个 DaemonSet。

至此,通过上面这些内容,你应该能够明白, DaemonSet 其实是一个非常简单的控制器 。在它的控制循环中,只需要遍历所有节点,然后根据节点上是否有被管理 Pod 的情况,来决定是否要创建或者删除一个 Pod。

只不过,在创建每个 Pod 的时候,DaemonSet 会自动给这个 Pod 加上一个 nodeAffinity,从而保证这个 Pod 只会在指定节点上启动。同时,它还会自动给这个 Pod 加上一个 Toleration,从而忽略节点的 unschedulable”污点”。

当然, 你也可以在 Pod 模板里加上更多种类的 Toleration,从而利用 DaemonSet 实现自己的目的。 比如,在这个 fluentd-elasticsearch DaemonSet 里,我就给它加上了这样的 Toleration:

tolerations:
- key: node-role.kubernetes.io/master
  effect: NoSchedule

这是因为在默认情况下,Kubernetes 集群不允许用户在 Master 节点部署 Pod。因为,Master 节点默认携带了一个叫作node-role.kubernetes.io/master的”污点”。所以,为了能在 Master 节点上部署 DaemonSet 的 Pod,我就必须让这个 Pod”容忍”这个”污点”。

在理解了 DaemonSet 的工作原理之后,接下来我就通过一个具体的实践来帮你更深入地掌握 DaemonSet 的使用方法。

备注:需要注意的是,在 Kubernetes v1.11 之前,由于调度器尚不完善,DaemonSet 是由 DaemonSet Controller 自行调度的,即它会直接设置 Pod 的 spec.nodename 字段,这样就可以跳过调度器了。但是,这样的做法很快就会被废除,所以在这里我也不推荐你再花时间学习这个流程了。

首先,创建这个 DaemonSet 对象:

kubectl create -f fluentd-elasticsearch.yaml

需要注意的是,在 DaemonSet 上,我们一般都应该加上 resources 字段,来限制它的 CPU 和内存使用,防止它占用过多的宿主机资源。

而创建成功后,你就能看到,如果有 N 个节点,就会有 N 个 fluentd-elasticsearch Pod 在运行。比如在我们的例子里,会有两个 Pod,如下所示:

$ kubectl get pod -n kube-system -l name=fluentd-elasticsearch
NAME                          READY     STATUS    RESTARTS   AGE
fluentd-elasticsearch-dqfv9   1/1       Running   0          53m
fluentd-elasticsearch-pf9z5   1/1       Running   0          53m

而如果你此时通过 kubectl get 查看一下 Kubernetes 集群里的 DaemonSet 对象:

$ kubectl get ds -n kube-system fluentd-elasticsearch
NAME                    DESIRED   CURRENT   READY     UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
fluentd-elasticsearch   2         2         2         2            2           <none>          1h

备注:Kubernetes 里比较长的 API 对象都有短名字,比如 DaemonSet 对应的是 ds,Deployment 对应的是 deploy。

就会发现 DaemonSet 和 Deployment 一样,也有 DESIRED、CURRENT 等多个状态字段。这也就意味着,DaemonSet 可以像 Deployment 那样,进行版本管理。这个版本,可以使用 kubectl rollout history 看到:

$ kubectl rollout history daemonset fluentd-elasticsearch -n kube-system
daemonsets "fluentd-elasticsearch"
REVISION  CHANGE-CAUSE
1         <none>

接下来,我们来把这个 DaemonSet 的容器镜像版本到 v2.2.0:

kubectl set image ds/fluentd-elasticsearch fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --record -n=kube-system

这个 kubectl set image 命令里,第一个 fluentd-elasticsearch 是 DaemonSet 的名字,第二个 fluentd-elasticsearch 是容器的名字。

这时候,我们可以使用 kubectl rollout status 命令看到这个”滚动更新”的过程,如下所示:

$ kubectl rollout status ds/fluentd-elasticsearch -n kube-system
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 0 out of 2 new pods have been updated...
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 0 out of 2 new pods have been updated...
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 1 of 2 updated pods are available...
daemon set "fluentd-elasticsearch" successfully rolled out

注意,由于这一次我在升级命令后面加上了-record 参数,所以这次升级使用到的指令就会自动出现在 DaemonSet 的 rollout history 里面,如下所示:

$ kubectl rollout history daemonset fluentd-elasticsearch -n kube-system
daemonsets "fluentd-elasticsearch"
REVISION  CHANGE-CAUSE
1         <none>
2         kubectl set image ds/fluentd-elasticsearch fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --namespace=kube-system --record=true

有了版本号,你也就可以像 Deployment 一样,将 DaemonSet 回滚到某个指定的历史版本了。

而我在前面的文章中讲解 Deployment 对象的时候,曾经提到过,Deployment 管理这些版本,靠的是”一个版本对应一个 ReplicaSet 对象”。可是,DaemonSet 控制器操作的直接就是 Pod,不可能有 ReplicaSet 这样的对象参与其中。 那么,它的这些版本又是如何维护的呢?

所谓,一切皆对象!

在 Kubernetes 项目中,任何你觉得需要记录下来的状态,都可以被用 API 对象的方式实现。当然,”版本”也不例外。

Kubernetes v1.7 之后添加了一个 API 对象,名叫 ControllerRevision ,专门用来记录某种 Controller 对象的版本。比如,你可以通过如下命令查看 fluentd-elasticsearch 对应的 ControllerRevision:

$ kubectl get controllerrevision -n kube-system -l name=fluentd-elasticsearch
NAME                               CONTROLLER                             REVISION   AGE
fluentd-elasticsearch-64dc6799c9   daemonset.apps/fluentd-elasticsearch   2          1h

而如果你使用 kubectl describe 查看这个 ControllerRevision 对象:

$ kubectl describe controllerrevision fluentd-elasticsearch-64dc6799c9 -n kube-system
Name:         fluentd-elasticsearch-64dc6799c9
Namespace:    kube-system
Labels:       controller-revision-hash=2087235575
              name=fluentd-elasticsearch
Annotations:  deprecated.daemonset.template.generation=2
              kubernetes.io/change-cause=kubectl set image ds/fluentd-elasticsearch fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --record=true --namespace=kube-system
API Version:  apps/v1
Data:
  Spec:
    Template:
      $ Patch:  replace
      Metadata:
        Creation Timestamp:  <nil>
        Labels:
          Name:  fluentd-elasticsearch
      Spec:
        Containers:
          Image:              k8s.gcr.io/fluentd-elasticsearch:v2.2.0
          Image Pull Policy:  IfNotPresent
          Name:               fluentd-elasticsearch
...
Revision:                  2
Events:                    <none>

就会看到,这个 ControllerRevision 对象,实际上是在 Data 字段保存了该版本对应的完整的 DaemonSet 的 API 对象。并且,在 Annotation 字段保存了创建这个对象所使用的 kubectl 命令。

接下来,我们可以尝试将这个 DaemonSet 回滚到 Revision=1 时的状态:

$ kubectl rollout undo daemonset fluentd-elasticsearch --to-revision=1 -n kube-system
daemonset.extensions/fluentd-elasticsearch rolled back

这个 kubectl rollout undo 操作,实际上相当于读取到了 Revision=1 的 ControllerRevision 对象保存的 Data 字段。而这个 Data 字段里保存的信息,就是 Revision=1 时这个 DaemonSet 的完整 API 对象。

所以,现在 DaemonSet Controller 就可以使用这个历史 API 对象,对现有的 DaemonSet 做一次 PATCH 操作(等价于执行一次 kubectl apply -f “旧的 DaemonSet 对象”),从而把这个 DaemonSet”更新”到一个旧版本。

这也是为什么,在执行完这次回滚完成后,你会发现,DaemonSet 的 Revision 并不会从 Revision=2 退回到 1,而是会增加成 Revision=3。这是因为,一个新的 ControllerRevision 被创建了出来。

21.1 总结

相比于 Deployment,DaemonSet 只管理 Pod 对象,然后通过 nodeAffinity 和 Toleration 这两个调度器的小功能,保证了每个节点上有且只有一个 Pod。这个控制器的实现原理简单易懂,希望你能够快速掌握。

与此同时,DaemonSet 使用 ControllerRevision,来保存和管理自己对应的”版本”。这种”面向 API 对象”的设计思路,大大简化了控制器本身的逻辑,也正是 Kubernetes 项目”声明式 API”的优势所在。

而且,相信聪明的你此时已经想到了,StatefulSet 也是直接控制 Pod 对象的,那么它是不是也在使用 ControllerRevision 进行版本管理呢?

没错。在 Kubernetes 项目里,ControllerRevision 其实是一个通用的版本管理对象。这样,Kubernetes 项目就巧妙地避免了每种控制器都要维护一套冗余的代码和逻辑的问题。

22 | 撬动离线业务:Job与CronJob

在前面的几篇文章中,我和你详细分享了 Deployment、StatefulSet,以及 DaemonSet 这三个编排概念。你有没有发现它们的共同之处呢?

实际上,它们主要编排的对象,都是”在线业务”,即:Long Running Task(长作业)。比如,我在前面举例时常用的 Nginx、Tomcat,以及 MySQL 等等。这些应用一旦运行起来,除非出错或者停止,它的容器进程会一直保持在 Running 状态。

但是,有一类作业显然不满足这样的条件,这就是”离线业务”,或者叫作 Batch Job(计算业务)。这种业务在计算完成后就直接退出了,而此时如果你依然用 Deployment 来管理这种业务的话,就会发现 Pod 会在计算结束后退出,然后被 Deployment Controller 不断地重启;而像”滚动更新”这样的编排功能,更无从谈起了。

所以,早在 Borg 项目中,Google 就已经对作业进行了分类处理,提出了 LRS(Long Running Service)和 Batch Jobs 两种作业形态,对它们进行”分别管理”和”混合调度”。

不过,在 2015 年 Borg 论文刚刚发布的时候,Kubernetes 项目并不支持对 Batch Job 的管理。直到 v1.4 版本之后,社区才逐步设计出了一个用来描述离线业务的 API 对象,它的名字就是:Job。

Job API 对象的定义非常简单,我来举个例子 ,如下所示:

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
    spec:
      containers:
      - name: pi
        image: resouer/ubuntu-bc
        command: ["sh", "-c", "echo 'scale=10000; 4*a(1)' | bc -l "]
      restartPolicy: Never
  backoffLimit: 4

此时,相信你对 Kubernetes 的 API 对象已经不再陌生了。在这个 Job 的 YAML 文件里,你肯定一眼就会看到一位”老熟人”:Pod 模板,即 spec.template 字段。

在这个 Pod 模板中,我定义了一个 Ubuntu 镜像的容器(准确地说,是一个安装了 bc 命令的 Ubuntu 镜像),它运行的程序是:

echo "scale=10000; 4*a(1)" | bc -l

其中,bc 命令是 Linux 里的”计算器”;-l 表示,我现在要使用标准数学库;而 a(1),则是调用数学库中的 arctangent 函数,计算 atan(1)。这是什么意思呢?

中学知识告诉我们:tan(π/4) = 1。所以,4*atan(1)正好就是π,也就是 3.1415926…。

所以,这其实就是一个计算π值的容器。而通过 scale=10000,我指定了输出的小数点后的位数是 10000。在我的计算机上,这个计算大概用时 1 分 54 秒。

但是,跟其他控制器不同的是,Job 对象并不要求你定义一个 spec.selector 来描述要控制哪些 Pod。具体原因,我马上会讲解到。

现在,我们就可以创建这个 Job 了:

kubectl create -f job.yaml

在成功创建后,我们来查看一下这个 Job 对象,如下所示:

$ kubectl describe jobs/pi
Name:             pi
Namespace:        default
Selector:         controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
Labels:           controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
                  job-name=pi
Annotations:      <none>
Parallelism:      1
Completions:      1
..
Pods Statuses:    0 Running / 1 Succeeded / 0 Failed
Pod Template:
  Labels:       controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
                job-name=pi
  Containers:
   ...
  Volumes:              <none>
Events:
  FirstSeen    LastSeen    Count    From            SubobjectPath    Type        Reason            Message
  ---------    --------    -----    ----            -------------    --------    ------            -------
  1m           1m          1        {job-controller }                Normal      SuccessfulCreate  Created pod: pi-rq5rl

可以看到,这个 Job 对象在创建后,它的 Pod 模板,被自动加上了一个 controller-uid= < 一个随机字符串 > 这样的 Label。而这个 Job 对象本身,则被自动加上了这个 Label 对应的 Selector,从而 保证了 Job 与它所管理的 Pod 之间的匹配关系。

而 Job Controller 之所以要使用这种携带了 UID 的 Label,就是为了避免不同 Job 对象所管理的 Pod 发生重合。需要注意的是, 这种自动生成的 Label 对用户来说并不友好,所以不太适合推广到 Deployment 等长作业编排对象上。

接下来,我们可以看到这个 Job 创建的 Pod 进入了 Running 状态,这意味着它正在计算 Pi 的值。

$ kubectl get pods
NAME                                READY     STATUS    RESTARTS   AGE
pi-rq5rl                            1/1       Running   0          10s

而几分钟后计算结束,这个 Pod 就会进入 Completed 状态:

$ kubectl get pods
NAME                                READY     STATUS      RESTARTS   AGE
pi-rq5rl                            0/1       Completed   0          4m

这也是我们需要在 Pod 模板中定义 restartPolicy=Never 的原因:离线计算的 Pod 永远都不应该被重启,否则它们会再重新计算一遍。

事实上,restartPolicy 在 Job 对象里只允许被设置为 Never 和 OnFailure;而在 Deployment 对象里,restartPolicy 则只允许被设置为 Always。

此时,我们通过 kubectl logs 查看一下这个 Pod 的日志,就可以看到计算得到的 Pi 值已经被打印了出来:

$ kubectl logs pi-rq5rl
3.141592653589793238462643383279...

这时候,你一定会想到这样一个问题, 如果这个离线作业失败了要怎么办?

比如,我们在这个例子中 定义了 restartPolicy=Never,那么离线作业失败后 Job Controller 就会不断地尝试创建一个新 Pod ,如下所示:

$ kubectl get pods
NAME                                READY     STATUS              RESTARTS   AGE
pi-55h89                            0/1       ContainerCreating   0          2s
pi-tqbcz                            0/1       Error               0          5s

可以看到,这时候会不断地有新 Pod 被创建出来。

当然,这个尝试肯定不能无限进行下去。所以,我们就在 Job 对象的 spec.backoffLimit 字段里定义了重试次数为 4(即,backoffLimit=4),而这个字段的默认值是 6。

需要注意的是,Job Controller 重新创建 Pod 的间隔是呈指数增加的,即下一次重新创建 Pod 的动作会分别发生在 10 s、20 s、40 s …后。

而如果你 定义的 restartPolicy=OnFailure,那么离线作业失败后,Job Controller 就不会去尝试创建新的 Pod。但是,它会不断地尝试重启 Pod 里的容器。 这也正好对应了 restartPolicy 的含义。

如前所述,当一个 Job 的 Pod 运行结束后,它会进入 Completed 状态。但是,如果这个 Pod 因为某种原因一直不肯结束呢?

在 Job 的 API 对象里,有一个 spec.activeDeadlineSeconds 字段可以设置最长运行时间,比如:

spec:
 backoffLimit: 5
 activeDeadlineSeconds: 100

一旦运行超过了 100 s,这个 Job 的所有 Pod 都会被终止。并且,你可以在 Pod 的状态里看到终止的原因是 reason: DeadlineExceeded。

以上,就是一个 Job API 对象最主要的概念和用法了。不过,离线业务之所以被称为 Batch Job,当然是因为它们可以以”Batch”,也就是并行的方式去运行。

接下来,我就来为你讲解一下 Job Controller 对并行作业的控制方法。

在 Job 对象中,负责并行控制的参数有两个:

spec.parallelism,它定义的是一个 Job 在任意时间最多可以启动多少个 Pod 同时运行;

spec.completions,它定义的是 Job 至少要完成的 Pod 数目,即 Job 的最小完成数。

这两个参数听起来有点儿抽象,所以我准备了一个例子来帮助你理解。

现在,我在之前计算 Pi 值的 Job 里,添加这两个参数:

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  parallelism: 2
  completions: 4
  template:
    spec:
      containers:
      - name: pi
        image: resouer/ubuntu-bc
        command: ["sh", "-c", "echo 'scale=5000; 4*a(1)' | bc -l "]
      restartPolicy: Never
  backoffLimit: 4

这样,我们就指定了这个 Job 最大的并行数是 2,而最小的完成数是 4。

接下来,我们来创建这个 Job 对象:

kubectl create -f job.yaml

可以看到,这个 Job 其实也维护了两个状态字段,即 DESIRED 和 SUCCESSFUL,如下所示:

$ kubectl get job
NAME      DESIRED   SUCCESSFUL   AGE
pi        4         0            3s

其中,DESIRED 的值,正是 completions 定义的最小完成数。

然后,我们可以看到,这个 Job 首先创建了两个并行运行的 Pod 来计算 Pi:

$ kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
pi-5mt88   1/1       Running   0          6s
pi-gmcq5   1/1       Running   0          6s

而在 40 s 后,这两个 Pod 相继完成计算。

这时我们可以看到,每当有一个 Pod 完成计算进入 Completed 状态时,就会有一个新的 Pod 被自动创建出来,并且快速地从 Pending 状态进入到 ContainerCreating 状态:

$ kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
pi-gmcq5   0/1       Completed   0         40s
pi-84ww8   0/1       Pending   0         0s
pi-5mt88   0/1       Completed   0         41s
pi-62rbt   0/1       Pending   0         0s

$ kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
pi-gmcq5   0/1       Completed   0         40s
pi-84ww8   0/1       ContainerCreating   0         0s
pi-5mt88   0/1       Completed   0         41s
pi-62rbt   0/1       ContainerCreating   0         0s

紧接着,Job Controller 第二次创建出来的两个并行的 Pod 也进入了 Running 状态:

$ kubectl get pods
NAME       READY     STATUS      RESTARTS   AGE
pi-5mt88   0/1       Completed   0          54s
pi-62rbt   1/1       Running     0          13s
pi-84ww8   1/1       Running     0          14s
pi-gmcq5   0/1       Completed   0          54s

最终,后面创建的这两个 Pod 也完成了计算,进入了 Completed 状态。

这时,由于所有的 Pod 均已经成功退出,这个 Job 也就执行完了,所以你会看到它的 SUCCESSFUL 字段的值变成了 4:

$ kubectl get pods
NAME       READY     STATUS      RESTARTS   AGE
pi-5mt88   0/1       Completed   0          5m
pi-62rbt   0/1       Completed   0          4m
pi-84ww8   0/1       Completed   0          4m
pi-gmcq5   0/1       Completed   0          5m

$ kubectl get job
NAME      DESIRED   SUCCESSFUL   AGE
pi        4         4            5m

通过上述 Job 的 DESIRED 和 SUCCESSFUL 字段的关系,我们就可以很容易地理解 Job Controller 的工作原理 了。

首先,Job Controller 控制的对象,直接就是 Pod。

其次,Job Controller 在控制循环中进行的调谐(Reconcile)操作,是根据实际在 Running 状态 Pod 的数目、已经成功退出的 Pod 的数目,以及 parallelism、completions 参数的值共同计算出在这个周期里,应该创建或者删除的 Pod 数目,然后调用 Kubernetes API 来执行这个操作。

以创建 Pod 为例。在上面计算 Pi 值的这个例子中,当 Job 一开始创建出来时,实际处于 Running 状态的 Pod 数目 =0,已经成功退出的 Pod 数目 =0,而用户定义的 completions,也就是最终用户需要的 Pod 数目 =4。

所以,在这个时刻,需要创建的 Pod 数目 = 最终需要的 Pod 数目 - 实际在 Running 状态 Pod 数目 - 已经成功退出的 Pod 数目 = 4 - 0 - 0= 4。也就是说,Job Controller 需要创建 4 个 Pod 来纠正这个不一致状态。

可是,我们又定义了这个 Job 的 parallelism=2。也就是说,我们规定了每次并发创建的 Pod 个数不能超过 2 个。所以,Job Controller 会对前面的计算结果做一个修正,修正后的期望创建的 Pod 数目应该是:2 个。

这时候,Job Controller 就会并发地向 kube-apiserver 发起两个创建 Pod 的请求。

类似地,如果在这次调谐周期里,Job Controller 发现实际在 Running 状态的 Pod 数目,比 parallelism 还大,那么它就会删除一些 Pod,使两者相等。

综上所述,Job Controller 实际上控制了,作业执行的 并行度 ,以及总共需要完成的 任务数 这两个重要参数。而在实际使用时,你需要根据作业的特性,来决定并行度(parallelism)和任务数(completions)的合理取值。

接下来, 我再和你分享三种常用的、使用 Job 对象的方法。

  1. 第一种用法,也是最简单粗暴的用法:外部管理器 +Job 模板。

    这种模式的特定用法是:把 Job 的 YAML 文件定义为一个”模板”,然后用一个外部工具控制这些”模板”来生成 Job。这时,Job 的定义方式如下所示:

    apiVersion: batch/v1
    kind: Job
    metadata:
    name: process-item-$ITEM
    labels:
        jobgroup: jobexample
    spec:
    template:
        metadata:
        name: jobexample
        labels:
            jobgroup: jobexample
        spec:
        containers:
        - name: c
            image: busybox
            command: ["sh", "-c", "echo Processing item $ITEM && sleep 5"]
        restartPolicy: Never

    可以看到,我们在这个 Job 的 YAML 里,定义了 $ITEM 这样的”变量”。

    所以,在控制这种 Job 时,我们只要注意如下两个方面即可:

    1. 创建 Job 时,替换掉 $ITEM 这样的变量;
    2. 所有来自于同一个模板的 Job,都有一个 jobgroup: jobexample 标签,也就是说这一组 Job 使用这样一个相同的标识。

      而做到第一点非常简单。比如,你可以通过这样一句 shell 把 $ITEM 替换掉:

      $ mkdir ./jobs
      $ for i in apple banana cherry
      do
      cat job-tmpl.yaml | sed "s/\$ITEM/$i/" > ./jobs/job-$i.yaml
      done

      这样,一组来自于同一个模板的不同 Job 的 yaml 就生成了。接下来,你就可以通过一句 kubectl create 指令创建这些 Job 了:

      $ kubectl create -f ./jobs
      $ kubectl get pods -l jobgroup=jobexample
      NAME                        READY     STATUS      RESTARTS   AGE
      process-item-apple-kixwv    0/1       Completed   0          4m
      process-item-banana-wrsf7   0/1       Completed   0          4m
      process-item-cherry-dnfu9   0/1       Completed   0          4m

      这个模式看起来虽然很”傻”,但却是 Kubernetes 社区里使用 Job 的一个很普遍的模式。

      原因很简单:大多数用户在需要管理 Batch Job 的时候,都已经有了一套自己的方案,需要做的往往就是集成工作。这时候,Kubernetes 项目对这些方案来说最有价值的,就是 Job 这个 API 对象。所以,你只需要编写一个外部工具(等同于我们这里的 for 循环)来管理这些 Job 即可。

      这种模式最典型的应用,就是 TensorFlow 社区的 KubeFlow 项目。

      很容易理解,在这种模式下使用 Job 对象,completions 和 parallelism 这两个字段都应该使用默认值 1,而不应该由我们自行设置。而作业 Pod 的并行控制,应该完全交由外部工具来进行管理(比如,KubeFlow)。

  2. 第二种用法:拥有固定任务数目的并行 Job。

    这种模式下,我只关心最后是否有指定数目(spec.completions)个任务成功退出。至于执行时的并行度是多少,我并不关心。

    比如,我们这个计算 Pi 值的例子,就是这样一个典型的、拥有固定任务数目(completions=4)的应用场景。 它的 parallelism 值是 2;或者,你可以干脆不指定 parallelism,直接使用默认的并行度(即:1)。

    此外,你还可以使用一个工作队列(Work Queue)进行任务分发。这时,Job 的 YAML 文件定义如下所示:

    apiVersion: batch/v1
    kind: Job
    metadata:
    name: job-wq-1
    spec:
    completions: 8
    parallelism: 2
    template:
        metadata:
        name: job-wq-1
        spec:
        containers:
        - name: c
            image: myrepo/job-wq-1
            env:
            - name: BROKER_URL
            value: amqp://guest:guest@rabbitmq-service:5672
            - name: QUEUE
            value: job1
        restartPolicy: OnFailure

    我们可以看到,它的 completions 的值是:8,这意味着我们总共要处理的任务数目是 8 个。也就是说,总共会有 8 个任务会被逐一放入工作队列里(你可以运行一个外部小程序作为生产者,来提交任务)。

    在这个实例中,我选择充当工作队列的是一个运行在 Kubernetes 里的 RabbitMQ。所以,我们需要在 Pod 模板里定义 BROKER_URL,来作为消费者。

    所以,一旦你用 kubectl create 创建了这个 Job,它就会以并发度为 2 的方式,每两个 Pod 一组,创建出 8 个 Pod。每个 Pod 都会去连接 BROKER_URL,从 RabbitMQ 里读取任务,然后各自进行处理。这个 Pod 里的执行逻辑,我们可以用这样一段伪代码来表示:

    /* job-wq-1 的伪代码 */
    queue := newQueue($BROKER_URL, $QUEUE)
    task := queue.Pop()
    process(task)
    exit

    可以看到,每个 Pod 只需要将任务信息读取出来,处理完成,然后退出即可。而作为用户,我只关心最终一共有 8 个计算任务启动并且退出,只要这个目标达到,我就认为整个 Job 处理完成了。所以说,这种用法,对应的就是”任务总数固定”的场景。

  3. 第三种用法,也是很常用的一个用法:指定并行度(parallelism),但不设置固定的 completions 的值。

    此时,你就必须自己想办法,来决定什么时候启动新 Pod,什么时候 Job 才算执行完成。在这种情况下,任务的总数是未知的,所以你不仅需要一个工作队列来负责任务分发,还需要能够判断工作队列已经为空(即:所有的工作已经结束了)。

    这时候,Job 的定义基本上没变化,只不过是不再需要定义 completions 的值了而已:

    apiVersion: batch/v1
    kind: Job
    metadata:
    name: job-wq-2
    spec:
    parallelism: 2
    template:
        metadata:
        name: job-wq-2
        spec:
        containers:
        - name: c
            image: gcr.io/myproject/job-wq-2
            env:
            - name: BROKER_URL
            value: amqp://guest:guest@rabbitmq-service:5672
            - name: QUEUE
            value: job2
        restartPolicy: OnFailure

    而对应的 Pod 的逻辑会稍微复杂一些,我可以用这样一段伪代码来描述:

    /* job-wq-2 的伪代码 */
    for !queue.IsEmpty($BROKER_URL, $QUEUE) {
    task := queue.Pop()
    process(task)
    }
    print("Queue empty, exiting")
    exit

    由于任务数目的总数不固定,所以每一个 Pod 必须能够知道,自己什么时候可以退出。比如,在这个例子中,我简单地以”队列为空”,作为任务全部完成的标志。所以说,这种用法,对应的是”任务总数不固定”的场景。

    不过,在实际的应用中,你需要处理的条件往往会非常复杂。比如,任务完成后的输出、每个任务 Pod 之间是不是有资源的竞争和协同等等。

    所以,在今天这篇文章中,我就不再展开 Job 的用法了。因为,在实际场景里,要么干脆就用第一种用法来自己管理作业;要么,这些任务 Pod 之间的关系就不那么”单纯”,甚至还是”有状态应用”(比如,任务的输入 / 输出是在持久化数据卷里)。在这种情况下,我在后面要重点讲解的 Operator,加上 Job 对象一起,可能才能更好的满足实际离线任务的编排需求。

最后,我再来和你分享一个非常有用的 Job 对象,叫作:CronJob。

顾名思义,CronJob 描述的,正是定时任务。它的 API 对象,如下所示:

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: hello
spec:
  schedule: "*/1 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: hello
            image: busybox
            args:
            - /bin/sh
            - -c
            - date; echo Hello from the Kubernetes cluster
          restartPolicy: OnFailure

在这个 YAML 文件中,最重要的关键词就是jobTemplate。看到它,你一定恍然大悟,原来 CronJob 是一个 Job 对象的控制器(Controller)!

没错,CronJob 与 Job 的关系,正如同 Deployment 与 Pod 的关系一样。CronJob 是一个专门用来管理 Job 对象的控制器。只不过,它创建和删除 Job 的依据,是 schedule 字段定义的、一个标准的 Unix Cron 格式的表达式。

比如,*/1 * * * *

这个 Cron 表达式里 */1 中的 * 表示从 0 开始,/ 表示”每”,1 表示偏移量。所以,它的意思就是:从 0 开始,每 1 个时间单位执行一次。

Cron 表达式中的五个部分分别代表:分钟、小时、日、月、星期。

所以,上面这句 Cron 表达式的意思是:从当前开始,每分钟执行一次。

而这里要执行的内容,就是 jobTemplate 定义的 Job 了。

所以,这个 CronJob 对象在创建 1 分钟后,就会有一个 Job 产生了,如下所示:

$ kubectl create -f ./cronjob.yaml
cronjob "hello" created

# 一分钟后
$ kubectl get jobs
NAME               DESIRED   SUCCESSFUL   AGE
hello-4111706356   1         1         2s

此时,CronJob 对象会记录下这次 Job 执行的时间:

$ kubectl get cronjob hello
NAME      SCHEDULE      SUSPEND   ACTIVE    LAST-SCHEDULE
hello     */1 * * * *   False     0         Thu, 6 Sep 2018 14:34:00 -070

需要注意的是,由于定时任务的特殊性,很可能某个 Job 还没有执行完,另外一个新 Job 就产生了。这时候,你可以通过 spec.concurrencyPolicy 字段来定义具体的处理策略。比如:

  1. concurrencyPolicy=Allow,这也是默认情况,这意味着这些 Job 可以同时存在;
  2. concurrencyPolicy=Forbid,这意味着不会创建新的 Pod,该创建周期被跳过;
  3. concurrencyPolicy=Replace,这意味着新产生的 Job 会替换旧的、没有执行完的 Job。

而如果某一次 Job 创建失败,这次创建就会被标记为”miss”。当在指定的时间窗口内,miss 的数目达到 100 时,那么 CronJob 会停止再创建这个 Job。

这个时间窗口,可以由 spec.startingDeadlineSeconds 字段指定。比如 startingDeadlineSeconds=200,意味着在过去 200 s 里,如果 miss 的数目达到了 100 次,那么这个 Job 就不会被创建执行了。

23 | 声明式API与Kubernetes编程范式

在前面的几篇文章中,我和你分享了很多 Kubernetes 的 API 对象。这些 API 对象,有的是用来描述应用,有的则是为应用提供各种各样的服务。但是,无一例外地,为了使用这些 API 对象提供的能力,你都需要编写一个对应的 YAML 文件交给 Kubernetes。

这个 YAML 文件,正是 Kubernetes 声明式 API 所必须具备的一个要素。不过,是不是只要用 YAML 文件代替了命令行操作,就是声明式 API 了呢?

举个例子。我们知道,Docker Swarm 的编排操作都是基于命令行的,比如:

docker service create --name nginx --replicas 2  nginx
docker service update --image nginx:1.7.9 nginx

像这样的两条命令,就是用 Docker Swarm 启动了两个 Nginx 容器实例。其中,第一条 create 命令创建了这两个容器,而第二条 update 命令则把它们”滚动更新”为了一个新的镜像。

对于这种使用方式,我们称为命令式命令行操作。

那么,像上面这样的创建和更新两个 Nginx 容器的操作,在 Kubernetes 里又该怎么做呢?

这个流程,相信你已经非常熟悉了:我们需要在本地编写一个 Deployment 的 YAML 文件:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 80

然后,我们还需要使用 kubectl create 命令在 Kubernetes 里创建这个 Deployment 对象:

kubectl create -f nginx.yaml

这样,两个 Nginx 的 Pod 就会运行起来了。

而如果要更新这两个 Pod 使用的 Nginx 镜像,该怎么办呢?

我们前面曾经使用过 kubectl set image 和 kubectl edit 命令,来直接修改 Kubernetes 里的 API 对象。不过,相信很多人都有这样的想法,我能不能通过修改本地 YAML 文件来完成这个操作呢?这样我的改动就会体现在这个本地 YAML 文件里了。

当然可以。

比如,我们可以修改这个 YAML 文件里的 Pod 模板部分,把 Nginx 容器的镜像改成 1.7.9,如下所示:

...
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9

而接下来,我们就可以执行一句 kubectl replace 操作,来完成这个 Deployment 的更新:

kubectl replace -f nginx.yaml

可是,上面这种基于 YAML 文件的操作方式,是”声明式 API”吗?

并不是。

对于上面这种先 kubectl create,再 replace 的操作,我们称为 命令式配置文件操作

也就是说,它的处理方式,其实跟前面 Docker Swarm 的两句命令,没什么本质上的区别。只不过,它是把 Docker 命令行里的参数,写在了配置文件里而已。

那么,到底什么才是”声明式 API”呢?

答案是,kubectl apply 命令。

在前面的文章中,我曾经提到过这个 kubectl apply 命令,并推荐你使用它来代替 kubectl create 命令。

现在,我就使用 kubectl apply 命令来创建这个 Deployment:

kubectl apply -f nginx.yaml

这样,Nginx 的 Deployment 就被创建了出来,这看起来跟 kubectl create 的效果一样。

然后,我再修改一下 nginx.yaml 里定义的镜像:

...
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9

这时候,关键来了。

在修改完这个 YAML 文件之后,我不再使用 kubectl replace 命令进行更新,而是继续执行一条 kubectl apply 命令,即:

kubectl apply -f nginx.yaml

这时,Kubernetes 就会立即触发这个 Deployment 的”滚动更新”。

可是,它跟 kubectl replace 命令有什么本质区别吗?

实际上,你可以简单地理解为,kubectl replace 的执行过程,是使用新的 YAML 文件中的 API 对象, 替换原有的 API 对象 ;而 kubectl apply,则是执行了一个 对原有 API 对象的 PATCH 操作

类似地,kubectl set image 和 kubectl edit 也是对已有 API 对象的修改。

更进一步地,这意味着 kube-apiserver 在响应命令式请求(比如,kubectl replace)的时候,一次只能处理一个写请求,否则会有产生冲突的可能。而对于声明式请求(比如,kubectl apply), 一次能处理多个写操作,并且具备 Merge 能力。

这种区别,可能乍一听起来没那么重要。而且,正是由于要照顾到这样的 API 设计,做同样一件事情,Kubernetes 需要的步骤往往要比其他项目多不少。

但是,如果你仔细思考一下 Kubernetes 项目的工作流程,就不难体会到这种声明式 API 的独到之处。

接下来,我就以 Istio 项目为例,来为你讲解一下声明式 API 在实际使用时的重要意义。

在 2017 年 5 月,Google、IBM 和 Lyft 公司,共同宣布了 Istio 开源项目的诞生。很快,这个项目就在技术圈儿里,掀起了一阵名叫”微服务”的热潮,把 Service Mesh 这个新的编排概念推到了风口浪尖。

而 Istio 项目,实际上就是一个基于 Kubernetes 项目的微服务治理框架。它的架构非常清晰,如下所示:

Istio项目架构图

在上面这个架构图中,我们不难看到 Istio 项目架构的核心所在。 Istio 最根本的组件,是运行在每一个应用 Pod 里的 Envoy 容器。

这个 Envoy 项目是 Lyft 公司推出的一个高性能 C++ 网络代理,也是 Lyft 公司对 Istio 项目的唯一贡献。

而 Istio 项目,则把这个代理服务以 sidecar 容器的方式,运行在了每一个被治理的应用 Pod 中。我们知道,Pod 里的所有容器都共享同一个 Network Namespace。所以,Envoy 容器就能够通过配置 Pod 里的 iptables 规则,把整个 Pod 的进出流量接管下来。

这时候,Istio 的控制层(Control Plane)里的 Pilot 组件,就能够通过调用每个 Envoy 容器的 API,对这个 Envoy 代理进行配置,从而实现微服务治理。

我们一起来看一个例子。

假设这个 Istio 架构图左边的 Pod 是已经在运行的应用,而右边的 Pod 则是我们刚刚上线的应用的新版本。这时候,Pilot 通过调节这两 Pod 里的 Envoy 容器的配置,从而将 90% 的流量分配给旧版本的应用,将 10% 的流量分配给新版本应用,并且,还可以在后续的过程中随时调整。这样,一个典型的”灰度发布”的场景就完成了。比如,Istio 可以调节这个流量从 90%-10%,改到 80%-20%,再到 50%-50%,最后到 0%-100%,就完成了这个灰度发布的过程。

更重要的是,在整个微服务治理的过程中,无论是对 Envoy 容器的部署,还是像上面这样对 Envoy 代理的配置,用户和应用都是完全”无感”的。

这时候,你可能会有所疑惑:Istio 项目明明需要在每个 Pod 里安装一个 Envoy 容器,又怎么能做到”无感”的呢?

实际上, Istio 项目使用的,是 Kubernetes 中的一个非常重要的功能,叫作 Dynamic Admission Control。

在 Kubernetes 项目中,当一个 Pod 或者任何一个 API 对象被提交给 APIServer 之后,总有一些”初始化”性质的工作需要在它们被 Kubernetes 项目正式处理之前进行。比如,自动为所有 Pod 加上某些标签(Labels)。

而这个”初始化”操作的实现,借助的是一个叫作 Admission 的功能。它其实是 Kubernetes 项目里一组被称为 Admission Controller 的代码,可以选择性地被编译进 APIServer 中,在 API 对象创建之后会被立刻调用到。

但这就意味着,如果你现在想要添加一些自己的规则到 Admission Controller,就会比较困难。因为,这要求重新编译并重启 APIServer。显然,这种使用方法对 Istio 来说,影响太大了。

所以,Kubernetes 项目为我们额外提供了一种”热插拔”式的 Admission 机制,它就是 Dynamic Admission Control,也叫作:Initializer。

现在,我给你举个例子。比如,我有如下所示的一个应用 Pod:

apiVersion: v1
kind: Pod
metadata:
  name: myapp-pod
  labels:
    app: myapp
spec:
  containers:
  - name: myapp-container
    image: busybox
    command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600']

可以看到,这个 Pod 里面只有一个用户容器,叫作:myapp-container。

接下来,Istio 项目要做的,就是在这个 Pod YAML 被提交给 Kubernetes 之后,在它对应的 API 对象里自动加上 Envoy 容器的配置,使这个对象变成如下所示的样子:

apiVersion: v1
kind: Pod
metadata:
  name: myapp-pod
  labels:
    app: myapp
spec:
  containers:
  - name: myapp-container
    image: busybox
    command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600']
  - name: envoy
    image: lyft/envoy:845747b88f102c0fd262ab234308e9e22f693a1
    command: ["/usr/local/bin/envoy"]
    ...

可以看到,被 Istio 处理后的这个 Pod 里,除了用户自己定义的 myapp-container 容器之外,多出了一个叫作 envoy 的容器,它就是 Istio 要使用的 Envoy 代理。

那么,Istio 又是如何在用户完全不知情的前提下完成这个操作的呢?

Istio 要做的,就是编写一个用来为 Pod”自动注入”Envoy 容器的 Initializer。

首先,Istio 会将这个 Envoy 容器本身的定义,以 ConfigMap 的方式保存在 Kubernetes 当中。 这个 ConfigMap(名叫:envoy-initializer)的定义如下所示:

apiVersion: v1
kind: ConfigMap
metadata:
  name: envoy-initializer
data:
  config: |
    containers:
      - name: envoy
        image: lyft/envoy:845747db88f102c0fd262ab234308e9e22f693a1
        command: ["/usr/local/bin/envoy"]
        args:
          - "--concurrency 4"
          - "--config-path /etc/envoy/envoy.json"
          - "--mode serve"
        ports:
          - containerPort: 80
            protocol: TCP
        resources:
          limits:
            cpu: "1000m"
            memory: "512Mi"
          requests:
            cpu: "100m"
            memory: "64Mi"
        volumeMounts:
          - name: envoy-conf
            mountPath: /etc/envoy
    volumes:
      - name: envoy-conf
        configMap:
          name: envoy

相信你已经注意到了,这个 ConfigMap 的 data 部分,正是一个 Pod 对象的一部分定义。其中,我们可以看到 Envoy 容器对应的 containers 字段,以及一个用来声明 Envoy 配置文件的 volumes 字段。

不难想到,Initializer 要做的工作,就是把这部分 Envoy 相关的字段,自动添加到用户提交的 Pod 的 API 对象里。可是,用户提交的 Pod 里本来就有 containers 字段和 volumes 字段,所以 Kubernetes 在处理这样的更新请求时,就必须使用类似于 git merge 这样的操作,才能将这两部分内容合并在一起。

所以说,在 Initializer 更新用户的 Pod 对象的时候,必须使用 PATCH API 来完成。而这种 PATCH API,正是声明式 API 最主要的能力。

接下来,Istio 将一个编写好的 Initializer,作为一个 Pod 部署在 Kubernetes 中。这个 Pod 的定义非常简单,如下所示:

apiVersion: v1
kind: Pod
metadata:
  labels:
    app: envoy-initializer
  name: envoy-initializer
spec:
  containers:
    - name: envoy-initializer
      image: envoy-initializer:0.0.1
      imagePullPolicy: Always

我曾在第 16 篇文章 《编排其实很简单:谈谈”控制器”模型》 中和你分享过,一个 Kubernetes 的控制器,实际上就是一个”死循环”:它不断地获取”实际状态”,然后与”期望状态”作对比,并以此为依据决定下一步的操作。

而 Initializer 的控制器,不断获取到的”实际状态”,就是用户新创建的 Pod。而它的”期望状态”,则是:这个 Pod 里被添加了 Envoy 容器的定义。

我还是用一段 Go 语言风格的伪代码,来为你描述这个控制逻辑,如下所示:

for {
  // 获取新创建的 Pod
  pod := client.GetLatestPod()
  // Diff 一下,检查是否已经初始化过
  if !isInitialized(pod) {
    // 没有?那就来初始化一下
    doSomething(pod)
  }
}
  • 如果这个 Pod 里面已经添加过 Envoy 容器,那么就”放过”这个 Pod,进入下一个检查周期。
  • 而如果还没有添加过 Envoy 容器的话,它就要进行 Initialize 操作了,即:修改该 Pod 的 API 对象(doSomething 函数)。

这时候,你应该立刻能想到,Istio 要往这个 Pod 里合并的字段,正是我们之前保存在 envoy-initializer 这个 ConfigMap 里的数据(即:它的 data 字段的值)。

所以,在 Initializer 控制器的工作逻辑里,它首先会从 APIServer 中拿到这个 ConfigMap:

func doSomething(pod) {
  cm := client.Get(ConfigMap, "envoy-initializer")
}

然后,把这个 ConfigMap 里存储的 containers 和 volumes 字段,直接添加进一个空的 Pod 对象里:

func doSomething(pod) {
  cm := client.Get(ConfigMap, "envoy-initializer")

  newPod := Pod{}
  newPod.Spec.Containers = cm.Containers
  newPod.Spec.Volumes = cm.Volumes
}

现在,关键来了。

Kubernetes 的 API 库,为我们提供了一个方法,使得我们可以直接使用新旧两个 Pod 对象,生成一个 TwoWayMergePatch:

func doSomething(pod) {
  cm := client.Get(ConfigMap, "envoy-initializer")

  newPod := Pod{}
  newPod.Spec.Containers = cm.Containers
  newPod.Spec.Volumes = cm.Volumes

  // 生成 patch 数据
  patchBytes := strategicpatch.CreateTwoWayMergePatch(pod, newPod)

  // 发起 PATCH 请求,修改这个 pod 对象
  client.Patch(pod.Name, patchBytes)
}

有了这个 TwoWayMergePatch 之后,Initializer 的代码就可以使用这个 patch 的数据,调用 Kubernetes 的 Client,发起一个 PATCH 请求。

这样,一个用户提交的 Pod 对象里,就会被自动加上 Envoy 容器相关的字段。

当然,Kubernetes 还允许你通过配置,来指定要对什么样的资源进行这个 Initialize 操作,比如下面这个例子:

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: InitializerConfiguration
metadata:
  name: envoy-config
initializers:
  // 这个名字必须至少包括两个 "."
  - name: envoy.initializer.kubernetes.io
    rules:
      - apiGroups:
          - "" // 前面说过, "" 就是 core API Group 的意思
        apiVersions:
          - v1
        resources:
          - pods

这个配置,就意味着 Kubernetes 要对所有的 Pod 进行这个 Initialize 操作,并且,我们指定了负责这个操作的 Initializer,名叫:envoy-initializer。

而一旦这个 InitializerConfiguration 被创建,Kubernetes 就会把这个 Initializer 的名字,加在所有新创建的 Pod 的 Metadata 上,格式如下所示:

apiVersion: v1
kind: Pod
metadata:
  initializers:
    pending:
      - name: envoy.initializer.kubernetes.io
  name: myapp-pod
  labels:
    app: myapp
...

可以看到,每一个新创建的 Pod,都会自动携带了 metadata.initializers.pending 的 Metadata 信息。

这个 Metadata,正是接下来 Initializer 的控制器判断这个 Pod 有没有执行过自己所负责的初始化操作的重要依据(也就是前面伪代码中 isInitialized() 方法的含义)。

这也就意味着,当你在 Initializer 里完成了要做的操作后,一定要记得将这个 metadata.initializers.pending 标志清除掉。这一点,你在编写 Initializer 代码的时候一定要非常注意。

此外,除了上面的配置方法,你还可以在具体的 Pod 的 Annotation 里添加一个如下所示的字段,从而声明要使用某个 Initializer:

apiVersion: v1
kind: Pod
metadata
  annotations:
    "initializer.kubernetes.io/envoy": "true"
    ...

在这个 Pod 里,我们添加了一个 Annotation,写明: initializer.kubernetes.io/envoy=true。这样,就会使用到我们前面所定义的 envoy-initializer 了。

以上,就是关于 Initializer 最基本的工作原理和使用方法了。相信你此时已经明白, Istio 项目的核心,就是由无数个运行在应用 Pod 中的 Envoy 容器组成的服务代理网格。 这也正是 Service Mesh 的含义。

备注:如果你对这个 Demo 感兴趣,可以在 这个 GitHub 链接 里找到它的所有源码和文档。这个 Demo,是我 fork 自 Kelsey Hightower 的一个同名的 Demo。

而这个机制得以实现的原理,正是借助了 Kubernetes 能够对 API 对象进行在线更新的能力,这也正是 Kubernetes”声明式 API”的独特之处:

  • 首先,所谓”声明式”,指的就是我只需要提交一个定义好的 API 对象来”声明”,我所期望的状态是什么样子。
  • 其次,”声明式 API”允许有多个 API 写端,以 PATCH 的方式对 API 对象进行修改,而无需关心本地原始 YAML 文件的内容。
  • 最后,也是最重要的,有了上述两个能力,Kubernetes 项目才可以基于对 API 对象的增、删、改、查,在完全无需外界干预的情况下,完成对”实际状态”和”期望状态”的调谐(Reconcile)过程。

所以说, 声明式 API,才是 Kubernetes 项目编排能力”赖以生存”的核心所在,希望你能够认真理解。

此外,不难看到,无论是对 sidecar 容器的巧妙设计,还是对 Initializer 的合理利用,Istio 项目的设计与实现,其实都依托于 Kubernetes 的声明式 API 和它所提供的各种编排能力。可以说,Istio 是在 Kubernetes 项目使用上的一位”集大成者”。

要知道,一个 Istio 项目部署完成后,会在 Kubernetes 里创建大约 43 个 API 对象。

所以,Kubernetes 社区也看得很明白:Istio 项目有多火热,就说明 Kubernetes 这套”声明式 API”有多成功。这,既是 Google Cloud 喜闻乐见的事情,也是 Istio 项目一推出就被 Google 公司和整个技术圈儿热捧的重要原因。

而在使用 Initializer 的流程中,最核心的步骤,莫过于 Initializer”自定义控制器”的编写过程。它遵循的,正是标准的”Kubernetes 编程范式”,即:

如何使用控制器模式,同 Kubernetes 里 API 对象的”增、删、改、查”进行协作,进而完成用户业务逻辑的编写过程。

这,也正是我要在后面文章中为你详细讲解的内容。

23.1 总结

在今天这篇文章中,我为你重点讲解了 Kubernetes 声明式 API 的含义。并且,通过对 Istio 项目的剖析,我为你说明了它使用 Kubernetes 的 Initializer 特性,完成 Envoy 容器”自动注入”的原理。

事实上,从”使用 Kubernetes 部署代码”,到”使用 Kubernetes 编写代码”的蜕变过程,正是你从一个 Kubernetes 用户,到 Kubernetes 玩家的晋级之路。

而,如何理解”Kubernetes 编程范式”,如何为 Kubernetes 添加自定义 API 对象,编写自定义控制器,正是这个晋级过程中的关键点,也是我要在后面几篇文章中分享的核心内容。

此外,基于今天这篇文章所讲述的 Istio 的工作原理,尽管 Istio 项目一直宣称它可以运行在非 Kubernetes 环境中,但我并不建议你花太多时间去做这个尝试。

毕竟,无论是从技术实现还是在社区运作上,Istio 与 Kubernetes 项目之间都是紧密的、唇齿相依的关系。如果脱离了 Kubernetes 项目这个基础,那么这条原本就不算平坦的”微服务”之路,恐怕会更加困难重重。

24 | 深入解析声明式API(一):API对象的奥秘

在今天这篇文章中,我就来为你讲解一下 Kubernetes 声明式 API 的工作原理,以及如何利用这套 API 机制,在 Kubernetes 里添加自定义的 API 对象。

你可能一直就很好奇:当我把一个 YAML 文件提交给 Kubernetes 之后,它究竟是如何创建出一个 API 对象的呢?

这得从声明式 API 的设计谈起了。

在 Kubernetes 项目中,一个 API 对象在 Etcd 里的完整资源路径,是由:Group(API 组)、Version(API 版本)和 Resource(API 资源类型)三个部分组成的。

通过这样的结构,整个 Kubernetes 里的所有 API 对象,实际上就可以用如下的树形结构表示出来:

kubernetes里API对象的组织方式

在这幅图中,你可以很清楚地看到 Kubernetes 里 API 对象的组织方式,其实是层层递进的。

比如,现在我要声明要创建一个 CronJob 对象,那么我的 YAML 文件的开始部分会这么写:

apiVersion: batch/v2alpha1
kind: CronJob
...

在这个 YAML 文件中,”CronJob”就是这个 API 对象的资源类型(Resource),”batch”就是它的组(Group),v2alpha1 就是它的版本(Version)。

当我们提交了这个 YAML 文件之后,Kubernetes 就会把这个 YAML 文件里描述的内容,转换成 Kubernetes 里的一个 CronJob 对象。

那么, Kubernetes 是如何对 Resource、Group 和 Version 进行解析,从而在 Kubernetes 项目里找到 CronJob 对象的定义呢?

  1. 首先,Kubernetes 会匹配 API 对象的组。

    需要明确的是, 对于 Kubernetes 里的核心 API 对象,比如:Pod、Node 等,是不需要 Group 的(即:它们 Group 是””)。 所以,对于这些 API 对象来说,Kubernetes 会直接在 /api 这个层级进行下一步的匹配过程。

    而对于 CronJob 等非核心 API 对象来说,Kubernetes 就必须在 /apis 这个层级里查找它对应的 Group,进而根据”batch”这个 Group 的名字,找到 /apis/batch。

    不难发现,这些 API Group 的分类是以对象功能为依据的,比如 Job 和 CronJob 就都属于”batch” (离线业务)这个 Group。

  2. 然后,Kubernetes 会进一步匹配到 API 对象的版本号。

    对于 CronJob 这个 API 对象来说,Kubernetes 在 batch 这个 Group 下,匹配到的版本号就是 v2alpha1。

    在 Kubernetes 中,同一种 API 对象可以有多个版本,这正是 Kubernetes 进行 API 版本化管理的重要手段。这样,比如在 CronJob 的开发过程中,对于会影响到用户的变更就可以通过升级新版本来处理,从而保证了向后兼容。

  3. 最后,Kubernetes 会匹配 API 对象的资源类型。

    在前面匹配到正确的版本之后,Kubernetes 就知道,我要创建的原来是一个 /apis/batch/v2alpha1 下的 CronJob 对象。

这时候,APIServer 就可以继续创建这个 CronJob 对象了。为了方便理解,我为你总结了一个如下所示流程图来阐述这个创建过程:

APIServer创建CronJob对象流程图

  1. 首先,当我们发起了创建 CronJob 的 POST 请求之后,我们编写的 YAML 的信息就被提交给了 APIServer。

    而 APIServer 的第一个功能,就是过滤这个请求,并完成一些前置性的工作,比如授权、超时处理、审计等。

  2. 然后,请求会进入 MUX 和 Routes 流程。如果你编写过 Web Server 的话就会知道,MUX 和 Routes 是 APIServer 完成 URL 和 Handler 绑定的场所。而 APIServer 的 Handler 要做的事情,就是按照我刚刚介绍的匹配过程,找到对应的 CronJob 类型定义。

  3. 接着,APIServer 最重要的职责就来了:根据这个 CronJob 类型定义,使用用户提交的 YAML 文件里的字段,创建一个 CronJob 对象。

    而在这个过程中,APIServer 会进行一个 Convert 工作,即:把用户提交的 YAML 文件,转换成一个叫作 Super Version 的对象,它正是该 API 资源类型所有版本的字段全集。这样用户提交的不同版本的 YAML 文件,就都可以用这个 Super Version 对象来进行处理了。

  4. 接下来,APIServer 会先后进行 Admission() 和 Validation() 操作。比如,我在上一篇文章中提到的 Admission Controller 和 Initializer,就都属于 Admission 的内容。

    而 Validation,则负责验证这个对象里的各个字段是否合法。这个被验证过的 API 对象,都保存在了 APIServer 里一个叫作 Registry 的数据结构中。也就是说,只要一个 API 对象的定义能在 Registry 里查到,它就是一个有效的 Kubernetes API 对象。

  5. 最后,APIServer 会把验证过的 API 对象转换成用户最初提交的版本,进行序列化操作,并调用 Etcd 的 API 把它保存起来。

由此可见,声明式 API 对于 Kubernetes 来说非常重要。所以, APIServer 这样一个在其他项目里”平淡无奇”的组件,却成了 Kubernetes 项目的重中之重。 它不仅是 Google Borg 设计思想的集中体现,也是 Kubernetes 项目里唯一一个被 Google 公司和 RedHat 公司双重控制、其他势力根本无法参与其中的组件。

此外,由于同时要兼顾性能、API 完备性、版本化、向后兼容等很多工程化指标,所以 Kubernetes 团队在 APIServer 项目里大量使用了 Go 语言的代码生成功能,来自动化诸如 Convert、DeepCopy 等与 API 资源相关的操作。这部分自动生成的代码,曾一度占到 Kubernetes 项目总代码的 20%~30%。

这也是为何,在过去很长一段时间里,在这样一个极其”复杂”的 APIServer 中,添加一个 Kubernetes 风格的 API 资源类型,是一个非常困难的工作。

不过,在 Kubernetes v1.7 之后,这个工作就变得轻松得多了。这,当然得益于一个全新的 API 插件机制:CRD。

CRD 的全称是 Custom Resource Definition。顾名思义,它指的就是,允许用户在 Kubernetes 中添加一个跟 Pod、Node 类似的、新的 API 资源类型,即:自定义 API 资源。

举个例子, 我现在要为 Kubernetes 添加一个名叫 Network 的 API 资源类型。

它的作用是,一旦用户创建一个 Network 对象,那么 Kubernetes 就应该使用这个对象定义的网络参数,调用真实的网络插件,比如 Neutron 项目,为用户创建一个真正的”网络”。这样,将来用户创建的 Pod,就可以声明使用这个”网络”了。

这个 Network 对象的 YAML 文件,名叫 example-network.yaml,它的内容如下所示:

apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
  name: example-network
spec:
  cidr: "192.168.0.0/16"
  gateway: "192.168.0.1"

可以看到,我想要描述”网络”的 API 资源类型是 Network;API 组是samplecrd.k8s.io;API 版本是 v1。

那么, Kubernetes 又该如何知道这个 API(samplecrd.k8s.io/v1/network)的存在呢?

其实,上面的这个 YAML 文件,就是一个具体的”自定义 API 资源”实例,也叫 CR(Custom Resource)。而为了能够让 Kubernetes 认识这个 CR,你就需要让 Kubernetes 明白这个 CR 的宏观定义是什么,也就是 CRD(Custom Resource Definition)。

这就好比,你想让计算机认识各种兔子的照片,就得先让计算机明白,兔子的普遍定义是什么。比如,兔子”是哺乳动物””有长耳朵,三瓣嘴”。

所以,接下来,我就先需编写一个 CRD 的 YAML 文件,它的名字叫作 network.yaml,内容如下所示:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: networks.samplecrd.k8s.io
spec:
  group: samplecrd.k8s.io
  version: v1
  names:
    kind: Network
    plural: networks
  scope: Namespaced

可以看到,在这个 CRD 中,我指定了”group: samplecrd.k8s.io””version: v1”这样的 API 信息,也指定了这个 CR 的资源类型叫作 Network,复数(plural)是 networks。

然后,我还声明了它的 scope 是 Namespaced,即:我们定义的这个 Network 是一个属于 Namespace 的对象,类似于 Pod。

这就是一个 Network API 资源类型的 API 部分的宏观定义了。这就等同于告诉了计算机:”兔子是哺乳动物”。所以这时候,Kubernetes 就能够认识和处理所有声明了 API 类型是”samplecrd.k8s.io/v1/network”的 YAML 文件了。

接下来,我还需要让 Kubernetes”认识”这种 YAML 文件里描述的”网络”部分,比如”cidr”(网段),”gateway”(网关)这些字段的含义。这就相当于我要告诉计算机:”兔子有长耳朵和三瓣嘴”。

这时候呢,我就需要稍微做些代码工作了。

  1. 首先,我要在 GOPATH 下,创建一个结构如下的项目:

    $ tree $GOPATH/src/github.com/<your-name>/k8s-controller-custom-resource
    .
    ├── controller.go
    ├── crd
    │   └── network.yaml
    ├── example
    │   └── example-network.yaml
    ├── main.go
    └── pkg
        └── apis
            └── samplecrd
                ├── register.go
                └── v1
                    ├── doc.go
                    ├── register.go
                    └── types.go

    其中,pkg/apis/samplecrd 就是 API 组的名字,v1 是版本,而 v1 下面的 types.go 文件里,则定义了 Network 对象的完整描述。我已经把这个项目 上传到了 GitHub 上 ,你可以随时参考。

    注: k8s 官方的 example 项目地址是: https://github.com/kubernetes/sample-controller

  2. 然后,我在 pkg/apis/samplecrd 目录下创建了一个 register.go 文件,用来放置后面要用到的全局变量。 这个文件的内容如下所示:

    package samplecrd
    
    const (
    GroupName = "samplecrd.k8s.io"
    Version   = "v1"
    )
  3. 接着,我需要在 pkg/apis/samplecrd 目录下添加一个 doc.go 文件(Golang 的文档源文件)。 这个文件里的内容如下所示:

    // +k8s:deepcopy-gen=package
    
    // +groupName=samplecrd.k8s.io
    package v1

    在这个文件中,你会看到 +<tag_name>[=value] 格式的注释,这就是 Kubernetes 进行代码生成要用的 Annotation 风格的注释。

    其中,+k8s:deepcopy-gen=package 意思是,请为整个 v1 包里的所有类型定义自动生成 DeepCopy 方法;而+groupName=samplecrd.k8s.io,则定义了这个包对应的 API 组的名字。

    可以看到,这些定义在 doc.go 文件的注释,起到的是全局的代码生成控制的作用,所以也被称为 Global Tags。

  4. 接下来,我需要添加 types.go 文件。 顾名思义,它的作用就是定义一个 Network 类型到底有哪些字段(比如,spec 字段里的内容)。这个文件的主要内容如下所示:

    package v1
    ...
    // +genclient
    // +genclient:noStatus
    // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
    
    // Network describes a Network resource
    type Network struct {
    // TypeMeta is the metadata for the resource, like kind and apiversion
    metav1.TypeMeta `json:",inline"`
    // ObjectMeta contains the metadata for the particular object, including
    // things like...
    //  - name
    //  - namespace
    //  - self link
    //  - labels
    //  - ... etc ...
    metav1.ObjectMeta `json:"metadata,omitempty"`
    
    Spec networkspec `json:"spec"`
    }
    // networkspec is the spec for a Network resource
    type networkspec struct {
    Cidr    string `json:"cidr"`
    Gateway string `json:"gateway"`
    }
    
    // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
    
    // NetworkList is a list of Network resources
    type NetworkList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata"`
    
    Items []Network `json:"items"`
    }

    在上面这部分代码里,你可以看到 Network 类型定义方法跟标准的 Kubernetes 对象一样,都包括了 TypeMeta(API 元数据)和 ObjectMeta(对象元数据)字段。

    而其中的 Spec 字段,就是需要我们自己定义的部分。所以,在 networkspec 里,我定义了 Cidr 和 Gateway 两个字段。其中,每个字段最后面的部分比如json:”cidr”,指的就是这个字段被转换成 JSON 格式之后的名字,也就是 YAML 文件里的字段名字。

    此外,除了定义 Network 类型,你还需要定义一个 NetworkList 类型,用来描述 一组 Network 对象 应该包括哪些字段。之所以需要这样一个类型,是因为在 Kubernetes 中,获取所有 X 对象的 List() 方法,返回值都是List 类型,而不是 X 类型的数组。这是不一样的。

    同样地,在 Network 和 NetworkList 类型上,也有代码生成注释。

    其中,+genclient 的意思是:请为下面这个 API 资源类型生成对应的 Client 代码(这个 Client,我马上会讲到)。而 +genclient:noStatus 的意思是:这个 API 资源类型定义里,没有 Status 字段。否则,生成的 Client 就会自动带上 UpdateStatus 方法。

    如果你的类型定义包括了 Status 字段的话,就不需要这句 +genclient:noStatus 注释了。比如下面这个例子:

    // +genclient
    
    // Network is a specification for a Network resource
    type Network struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    
    Spec   NetworkSpec   `json:"spec"`
    Status NetworkStatus `json:"status"`
    }

    需要注意的是,+genclient 只需要写在 Network 类型上,而不用写在 NetworkList 上。因为 NetworkList 只是一个返回值类型,Network 才是”主类型”。

    而由于我在 Global Tags 里已经定义了为所有类型生成 DeepCopy 方法,所以这里就不需要再显式地加上 +k8s:deepcopy-gen=true 了。当然,这也就意味着你可以用 +k8s:deepcopy-gen=false 来阻止为某些类型生成 DeepCopy。

    你可能已经注意到,在这两个类型上面还有一句+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object的注释。它的意思是,请在生成 DeepCopy 的时候,实现 Kubernetes 提供的 runtime.Object 接口。否则,在某些版本的 Kubernetes 里,你的这个类型定义会出现编译错误。这是一个固定的操作,记住即可。

    不过,你或许会有这样的顾虑:这些代码生成注释这么灵活,我该怎么掌握呢?

    其实,上面我所讲述的内容,已经足以应对 99% 的场景了。当然,如果你对代码生成感兴趣的话,我推荐你阅读 这篇博客 ,它详细地介绍了 Kubernetes 的代码生成语法。

  5. *最后,我需要再编写的一个 pkg/apis/samplecrd/v1/register.go 文件。

    在前面对 APIServer 工作原理的讲解中,我已经提到,”registry”的作用就是注册一个类型(Type)给 APIServer。其中,Network 资源类型在服务器端的注册的工作,APIServer 会自动帮我们完成。但与之对应的,我们还需要让客户端也能”知道”Network 资源类型的定义。这就需要我们在项目里添加一个 register.go 文件。它最主要的功能,就是定义了如下所示的 addKnownTypes() 方法:

    package v1
    ...
    // addKnownTypes adds our types to the API scheme by registering
    // Network and NetworkList
    func addKnownTypes(scheme *runtime.Scheme) error {
     scheme.AddKnownTypes(
      SchemeGroupVersion,
      &Network{},
      &NetworkList{},
     )
    
     // register the type in the scheme
     metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
     return nil
    }

    有了这个方法,Kubernetes 就能够在后面生成客户端的时候,”知道”Network 以及 NetworkList 类型的定义了。

    像上面这种 register.go 文件里的内容其实是非常固定的,你以后可以直接使用我提供的这部分代码做模板,然后把其中的资源类型、GroupName 和 Version 替换成你自己的定义即可。

    这样,Network 对象的定义工作就全部完成了。可以看到,它其实定义了两部分内容:

    1. 这样,Network 对象的定义工作就全部完成了。可以看到,它其实定义了两部分内容:
    2. 第二部分是,自定义资源类型的对象描述,包括:Spec、Status 等。这相当于告诉了计算机:兔子有长耳朵和三瓣嘴。
  6. 接下来,我就要使用 Kubernetes 提供的代码生成工具,为上面定义的 Network 资源类型自动生成 clientset、informer 和 lister。 其中,clientset 就是操作 Network 对象所需要使用的客户端,而 informer 和 lister 这两个包的主要功能,我会在下一篇文章中重点讲解。

    这个代码生成工具名叫k8s.io/code-generator,使用方法如下所示:

    # 代码生成的工作目录,也就是我们的项目路径
    $ ROOT_PACKAGE="github.com/resouer/k8s-controller-custom-resource"
    # API Group
    $ CUSTOM_RESOURCE_NAME="samplecrd"
    # API Version
    $ CUSTOM_RESOURCE_VERSION="v1"
    
    # 安装 k8s.io/code-generator
    $ go get -u k8s.io/code-generator/...
    $ cd $GOPATH/src/k8s.io/code-generator
    
    # 执行代码自动生成,其中 pkg/client 是生成目标目录,pkg/apis 是类型定义目录
    $ ./generate-groups.sh all "$ROOT_PACKAGE/pkg/client" "$ROOT_PACKAGE/pkg/apis" "$CUSTOM_RESOURCE_NAME:$CUSTOM_RESOURCE_VERSION"

    代码生成工作完成之后,我们再查看一下这个项目的目录结构:

    $ tree
    .
    ├── controller.go
    ├── crd
    │   └── network.yaml
    ├── example
    │   └── example-network.yaml
    ├── main.go
    └── pkg
        ├── apis
        │   └── samplecrd
        │       ├── constants.go
        │       └── v1
        │           ├── doc.go
        │           ├── register.go
        │           ├── types.go
        │           └── zz_generated.deepcopy.go
        └── client
            ├── clientset
            ├── informers
            └── listers

    其中,pkg/apis/samplecrd/v1 下面的 zz_generated.deepcopy.go 文件,就是自动生成的 DeepCopy 代码文件。

    而整个 client 目录,以及下面的三个包(clientset、informers、 listers),都是 Kubernetes 为 Network 类型生成的客户端库,这些库会在后面编写自定义控制器的时候用到。

可以看到,到目前为止的这些工作,其实并不要求你写多少代码,主要考验的是”复制、粘贴、替换”这样的”基本功”。

而有了这些内容,现在你就可以在 Kubernetes 集群里创建一个 Network 类型的 API 对象了。我们不妨一起来实验一下。

  1. 首先,使用 network.yaml 文件,在 Kubernetes 中创建 Network 对象的 CRD(Custom Resource Definition):

    $ kubectl apply -f crd/network.yaml
    customresourcedefinition.apiextensions.k8s.io/networks.samplecrd.k8s.io created

    这个操作,就告诉了 Kubernetes,我现在要添加一个自定义的 API 对象。而这个对象的 API 信息,正是 network.yaml 里定义的内容。我们可以通过 kubectl get 命令,查看这个 CRD:

    $ kubectl get crd
    NAME                        CREATED AT
    networks.samplecrd.k8s.io   2018-09-15T10:57:12Z
  2. 然后,我们就可以创建一个 Network 对象了,这里用到的是 example-network.yaml:

    $ kubectl apply -f example/example-network.yaml
    network.samplecrd.k8s.io/example-network created

    通过这个操作,你就在 Kubernetes 集群里创建了一个 Network 对象。它的 API 资源路径是samplecrd.k8s.io/v1/networks。

    这时候,你就可以通过 kubectl get 命令,查看到新创建的 Network 对象:

    $ kubectl get network
    NAME              AGE
    example-network   8s

    你还可以通过 kubectl describe 命令,看到这个 Network 对象的细节:

    $ kubectl describe network example-network
    Name:         example-network
    Namespace:    default
    Labels:       <none>
    ...API Version:  samplecrd.k8s.io/v1
    Kind:         Network
    Metadata:
    ...
    Generation:          1
    Resource Version:    468239
    ...
    Spec:
    Cidr:     192.168.0.0/16
    Gateway:  192.168.0.1

    当然 ,你也可以编写更多的 YAML 文件来创建更多的 Network 对象,这和创建 Pod、Deployment 的操作,没有任何区别。

24.1 总结

在今天这篇文章中,我为你详细解析了 Kubernetes 声明式 API 的工作原理,讲解了如何遵循声明式 API 的设计,为 Kubernetes 添加一个名叫 Network 的 API 资源类型。从而达到了通过标准的 kubectl create 和 get 操作,来管理自定义 API 对象的目的。

不过,创建出这样一个自定义 API 对象,我们只是完成了 Kubernetes 声明式 API 的一半工作。

接下来的另一半工作是:为这个 API 对象编写一个自定义控制器(Custom Controller)。这样, Kubernetes 才能根据 Network API 对象的”增、删、改”操作,在真实环境中做出相应的响应。比如,”创建、删除、修改”真正的 Neutron 网络。

而这,正是 Network 这个 API 对象所关注的”业务逻辑”。

这个业务逻辑的实现过程,以及它所使用的 Kubernetes API 编程库的工作原理,就是我要在下一篇文章中讲解的主要内容。

25 | 深入解析声明式API(二):编写自定义控制器

在上一篇文章中,我和你详细分享了 Kubernetes 中声明式 API 的实现原理,并且通过一个添加 Network 对象的实例,为你讲述了在 Kubernetes 里添加 API 资源的过程。

在今天的这篇文章中,我就继续和你一起完成剩下一半的工作,即:为 Network 这个自定义 API 对象编写一个自定义控制器(Custom Controller)。

正如我在上一篇文章结尾处提到的,”声明式 API”并不像”命令式 API”那样有着明显的执行逻辑。这就使得 基于声明式 API 的业务功能实现,往往需要通过控制器模式来”监视”API 对象的变化(比如,创建或者删除 Network),然后以此来决定实际要执行的具体工作。

总得来说,编写自定义控制器代码的过程包括:编写 main 函数、编写自定义控制器的定义,以及编写控制器里的业务逻辑三个部分。

首先,我们来编写这个自定义控制器的 main 函数。

main 函数的主要工作就是,定义并初始化一个自定义控制器(Custom Controller),然后启动它。这部分代码的主要内容如下所示:

func main() {
...

cfg, err := clientcmd.BuildConfigFromFlags(masterURL, kubeconfig)
...
kubeClient, err := kubernetes.NewForConfig(cfg)
...
networkClient, err := clientset.NewForConfig(cfg)
...

networkInformerFactory := informers.NewSharedInformerFactory(networkClient, ...)

controller := NewController(kubeClient, networkClient,
networkInformerFactory.Samplecrd().V1().Networks())

go networkInformerFactory.Start(stopCh)

if err = controller.Run(2, stopCh); err != nil {
    glog.Fatalf("Error running controller: %s", err.Error())
}
}

可以看到,这个 main 函数主要通过三步完成了初始化并启动一个自定义控制器的工作。

  1. 第一步:main 函数根据我提供的 Master 配置(APIServer 的地址端口和 kubeconfig 的路径),创建一个 Kubernetes 的 client(kubeClient)和 Network 对象的 client(networkClient)。

    但是,如果我没有提供 Master 配置呢?

    这时,main 函数会直接使用一种名叫InClusterConfig的方式来创建这个 client。这个方式,会假设你的自定义控制器是以 Pod 的方式运行在 Kubernetes 集群里的。

    而我在第 15 篇文章 《深入解析 Pod 对象(二):使用进阶》 中曾经提到过,Kubernetes 里所有的 Pod 都会以 Volume 的方式自动挂载 Kubernetes 的默认 ServiceAccount。所以,这个控制器就会直接使用默认 ServiceAccount 数据卷里的授权信息,来访问 APIServer。

  2. 第二步:main 函数为 Network 对象创建一个叫作 InformerFactory(即:networkInformerFactory)的工厂,并使用它生成一个 Network 对象的 Informer,传递给控制器。

  3. 第三步:main 函数启动上述的 Informer,然后执行 controller.Run,启动自定义控制器。

至此,main 函数就结束了。

看到这,你可能会感到非常困惑:编写自定义控制器的过程难道就这么简单吗?这个 Informer 又是个什么东西呢?

接下来,我就为你 详细解释一下这个自定义控制器的工作原理。

在 Kubernetes 项目中,一个自定义控制器的工作原理,可以用下面这样一幅流程图来表示(在后面的叙述中,我会用”示意图”来指代它):

自定义控制器的工作流程示意图

我们先从这幅示意图的最左边看起。

  1. 这个控制器要做的第一件事,是从 Kubernetes 的 APIServer 里获取它所关心的对象,也就是我定义的 Network 对象。

    这个操作,依靠的是一个叫作 Informer(可以翻译为:通知器)的代码库完成的。Informer 与 API 对象是一一对应的,所以我传递给自定义控制器的,正是一个 Network 对象的 Informer(Network Informer)。

    不知你是否已经注意到,我在创建这个 Informer 工厂的时候,需要给它传递一个 networkClient。

    事实上,Network Informer 正是使用这个 networkClient,跟 APIServer 建立了连接。不过,真正负责维护这个连接的,则是 Informer 所使用的 Reflector 包。

    更具体地说,Reflector 使用的是一种叫作 ListAndWatch 的方法,来”获取”并”监听”这些 Network 对象实例的变化。

    在 ListAndWatch 机制下,一旦 APIServer 端有新的 Network 实例被创建、删除或者更新,Reflector 都会收到”事件通知”。这时,该事件及它对应的 API 对象这个组合,就被称为增量(Delta),它会被放进一个 Delta FIFO Queue(即:增量先进先出队列)中。

    而另一方面,Informe 会不断地从这个 Delta FIFO Queue 里读取(Pop)增量。每拿到一个增量,Informer 就会判断这个增量里的事件类型,然后创建或者更新本地对象的缓存。这个缓存,在 Kubernetes 里一般被叫作 Store。

    比如,如果事件类型是 Added(添加对象),那么 Informer 就会通过一个叫作 Indexer 的库把这个增量里的 API 对象保存在本地缓存中,并为它创建索引。相反地,如果增量的事件类型是 Deleted(删除对象),那么 Informer 就会从本地缓存中删除这个对象。

    这个 同步本地缓存的工作,是 Informer 的第一个职责,也是它最重要的职责。

  2. Informer 的第二个职责,则是根据这些事件的类型,触发事先注册好的 ResourceEventHandler。 这些 Handler,需要在创建控制器的时候注册给它对应的 Informer。

接下来,我们就来 编写这个控制器的定义 ,它的主要内容如下所示:

func NewController(
  kubeclientset kubernetes.Interface,
  networkclientset clientset.Interface,
  networkInformer informers.NetworkInformer) *Controller {
  ...
  controller := &Controller{
    kubeclientset:    kubeclientset,
    networkclientset: networkclientset,
    networksLister:   networkInformer.Lister(),
    networksSynced:   networkInformer.Informer().HasSynced,
    workqueue:        workqueue.NewNamedRateLimitingQueue(...,  "Networks"),
    ...
  }
    networkInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
    AddFunc: controller.enqueueNetwork,
    UpdateFunc: func(old, new interface{}) {
      oldNetwork := old.(*samplecrdv1.Network)
      newNetwork := new.(*samplecrdv1.Network)
      if oldNetwork.ResourceVersion == newNetwork.ResourceVersion {
        return
      }
      controller.enqueueNetwork(new)
    },
    DeleteFunc: controller.enqueueNetworkForDelete,
 return controller
}

我前面在 main 函数里创建了两个 client(kubeclientset 和 networkclientset),然后在这段代码里,使用这两个 client 和前面创建的 Informer,初始化了自定义控制器。

值得注意的是,在这个自定义控制器里,我还设置了一个工作队列(work queue),它正是处于示意图中间位置的 WorkQueue。这个工作队列的作用是,负责同步 Informer 和控制循环之间的数据。

实际上,Kubernetes 项目为我们提供了很多个工作队列的实现,你可以根据需要选择合适的库直接使用。

然后,我为 networkInformer 注册了三个 Handler(AddFunc、UpdateFunc 和 DeleteFunc),分别对应 API 对象的”添加””更新”和”删除”事件。而具体的处理操作,都是将该事件对应的 API 对象加入到工作队列中。

需要注意的是,实际入队的并不是 API 对象本身,而是它们的 Key,即:该 API 对象的 <namespace>/<name>

而我们后面即将编写的控制循环,则会不断地从这个工作队列里拿到这些 Key,然后开始执行真正的控制逻辑。

综合上面的讲述,你现在应该就能明白, 所谓 Informer,其实就是一个带有本地缓存和索引机制的、可以注册 EventHandler 的 client。 它是自定义控制器跟 APIServer 进行数据同步的重要组件。

更具体地说,Informer 通过一种叫作 ListAndWatch 的方法,把 APIServer 中的 API 对象缓存在了本地,并负责更新和维护这个缓存。

其中,ListAndWatch 方法的含义是:首先,通过 APIServer 的 LIST API”获取”所有最新版本的 API 对象;然后,再通过 WATCH API 来”监听”所有这些 API 对象的变化。

而通过监听到的事件变化,Informer 就可以实时地更新本地缓存,并且调用这些事件对应的 EventHandler 了。

此外,在这个过程中,每经过 resyncPeriod 指定的时间,Informer 维护的本地缓存,都会使用最近一次 LIST 返回的结果强制更新一次,从而保证缓存的有效性。在 Kubernetes 中,这个缓存强制更新的操作就叫作:resync。

需要注意的是,这个定时 resync 操作,也会触发 Informer 注册的”更新”事件。但此时,这个”更新”事件对应的 Network 对象实际上并没有发生变化,即:新、旧两个 Network 对象的 ResourceVersion 是一样的。在这种情况下,Informer 就不需要对这个更新事件再做进一步的处理了。

这也是为什么我在上面的 UpdateFunc 方法里,先判断了一下新、旧两个 Network 对象的版本(ResourceVersion)是否发生了变化,然后才开始进行的入队操作。

以上,就是 Kubernetes 中的 Informer 库的工作原理了。

接下来,我们就来到了示意图中最后面的控制循环(Control Loop)部分,也正是我在 main 函数最后调用 controller.Run() 启动的”控制循环”。它的主要内容如下所示:

func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
 ...
  if ok := cache.WaitForCacheSync(stopCh, c.networksSynced); !ok {
    return fmt.Errorf("failed to wait for caches to sync")
  }

  ...
  for i := 0; i < threadiness; i++ {
    go wait.Until(c.runWorker, time.Second, stopCh)
  }

  ...
  return nil
}

可以看到,启动控制循环的逻辑非常简单:

  • 首先,等待 Informer 完成一次本地缓存的数据同步操作;
  • 然后,直接通过 goroutine 启动一个(或者并发启动多个)”无限循环”的任务。

而这个”无限循环”任务的每一个循环周期,执行的正是我们真正关心的业务逻辑。

所以接下来,我们就来 编写这个自定义控制器的业务逻辑 ,它的主要内容如下所示:

func (c *Controller) runWorker() {
  for c.processNextWorkItem() {
  }
}

func (c *Controller) processNextWorkItem() bool {
  obj, shutdown := c.workqueue.Get()

  ...

  err := func(obj interface{}) error {
    ...
    if err := c.syncHandler(key); err != nil {
     return fmt.Errorf("error syncing '%s': %s", key, err.Error())
    }

    c.workqueue.Forget(obj)
    ...
    return nil
  }(obj)

  ...

  return true
}

func (c *Controller) syncHandler(key string) error {

  namespace, name, err := cache.SplitMetaNamespaceKey(key)
  ...

  network, err := c.networksLister.Networks(namespace).Get(name)
  if err != nil {
    if errors.IsNotFound(err) {
      glog.Warningf("Network does not exist in local cache: %s/%s, will delete it from Neutron ...",
      namespace, name)

      glog.Warningf("Network: %s/%s does not exist in local cache, will delete it from Neutron ...",
    namespace, name)

     // FIX ME: call Neutron API to delete this network by name.
     //
     // neutron.Delete(namespace, name)

     return nil
  }
    ...

    return err
  }

  glog.Infof("[Neutron] Try to process network: %#v ...", network)

  // FIX ME: Do diff().
  //
  // actualNetwork, exists := neutron.Get(namespace, name)
  //
  // if !exists {
  //   neutron.Create(namespace, name)
  // } else if !reflect.DeepEqual(actualNetwork, network) {
  //   neutron.Update(namespace, name)
  // }

  return nil
}

可以看到,在这个执行周期里(processNextWorkItem),我们 首先 从工作队列里出队(workqueue.Get)了一个成员,也就是一个 Key(Network 对象的:namespace/name)。

然后 ,在 syncHandler 方法中,我使用这个 Key,尝试从 Informer 维护的缓存中拿到了它所对应的 Network 对象。

可以看到,在这里,我使用了 networksLister 来尝试获取这个 Key 对应的 Network 对象。这个操作,其实就是在访问本地缓存的索引。实际上,在 Kubernetes 的源码中,你会经常看到控制器从各种 Lister 里获取对象,比如:podLister、nodeLister 等等,它们使用的都是 Informer 和缓存机制。

而如果控制循环从缓存中拿不到这个对象(即:networkLister 返回了 IsNotFound 错误),那就意味着这个 Network 对象的 Key 是通过前面的”删除”事件添加进工作队列的。所以,尽管队列里有这个 Key,但是对应的 Network 对象已经被删除了。

这时候,我就需要调用 Neutron 的 API,把这个 Key 对应的 Neutron 网络从真实的集群里删除掉。

而如果能够获取到对应的 Network 对象,我就可以执行控制器模式里的对比”期望状态”和”实际状态”的逻辑了。

其中,自定义控制器”千辛万苦”拿到的这个 Network 对象,正是 APIServer 里保存的”期望状态”,即:用户通过 YAML 文件提交到 APIServer 里的信息。当然,在我们的例子里,它已经被 Informer 缓存在了本地。

那么,”实际状态”又从哪里来呢?

当然是来自于实际的集群了。

所以,我们的控制循环需要通过 Neutron API 来查询实际的网络情况。

比如,我可以先通过 Neutron 来查询这个 Network 对象对应的真实网络是否存在。

  • 如果不存在,这就是一个典型的”期望状态”与”实际状态”不一致的情形。这时,我就需要使用这个 Network 对象里的信息(比如:CIDR 和 Gateway),调用 Neutron API 来创建真实的网络。
  • 如果存在,那么,我就要读取这个真实网络的信息,判断它是否跟 Network 对象里的信息一致,从而决定我是否要通过 Neutron 来更新这个已经存在的真实网络。

这样,我就通过对比”期望状态”和”实际状态”的差异,完成了一次调协(Reconcile)的过程。

至此,一个完整的自定义 API 对象和它所对应的自定义控制器,就编写完毕了。

备注:与 Neutron 相关的业务代码并不是本篇文章的重点,所以我仅仅通过注释里的伪代码为你表述了这部分内容。如果你对这些代码感兴趣的话,可以自行完成。最简单的情况,你可以自己编写一个 Neutron Mock,然后输出对应的操作日志。

接下来, 我们就一起来把这个项目运行起来,查看一下它的工作情况。

你可以自己编译这个项目,也可以直接使用我编译好的二进制文件(samplecrd-controller)。编译并启动这个项目的具体流程如下所示:

# Clone repo
$ git clone https://github.com/resouer/k8s-controller-custom-resource$ cd k8s-controller-custom-resource

### Skip this part if you don't want to build
# Install dependency
$ go get github.com/tools/godep
$ godep restore
# Build
$ go build -o samplecrd-controller .

$ ./samplecrd-controller -kubeconfig=$HOME/.kube/config -alsologtostderr=true
I0915 12:50:29.051349   27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615   27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630   27159 controller.go:116] Waiting for informer caches to sync
E0915 12:50:29.066745   27159 reflector.go:134] github.com/resouer/k8s-controller-custom-resource/pkg/client/informers/externalversions/factory.go:117: Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)
...

你可以看到,自定义控制器被启动后,一开始会报错。

这是因为,此时 Network 对象的 CRD 还没有被创建出来,所以 Informer 去 APIServer 里”获取”(List)Network 对象时,并不能找到 Network 这个 API 资源类型的定义,即:

Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)

所以,接下来我就需要创建 Network 对象的 CRD,这个操作在上一篇文章里已经介绍过了。

在另一个 shell 窗口里执行:

kubectl apply -f crd/network.yaml

这时候,你就会看到控制器的日志恢复了正常,控制循环启动成功:

...
I0915 12:50:29.051630   27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854   25245 controller.go:121] Starting workers
I0915 12:52:54.346914   25245 controller.go:127] Started workers

接下来,我就可以进行 Network 对象的增删改查操作了。

首先,创建一个 Network 对象:

$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
  name: example-network
spec:
  cidr: "192.168.0.0/16"
  gateway: "192.168.0.1"

$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network created

这时候,查看一下控制器的输出:

...
I0915 12:50:29.051349   27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615   27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630   27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854   25245 controller.go:121] Starting workers
I0915 12:52:54.346914   25245 controller.go:127] Started workers
I0915 12:53:18.064409   25245 controller.go:229] [Neutron] Try to process network: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ... ResourceVersion:"479015", ... Spec:v1.NetworkSpec{Cidr:"192.168.0.0/16", Gateway:"192.168.0.1"}} ...
I0915 12:53:18.064650   25245 controller.go:183] Successfully synced 'default/example-network'
...

可以看到,我们上面创建 example-network 的操作,触发了 EventHandler 的”添加”事件,从而被放进了工作队列。

紧接着,控制循环就从队列里拿到了这个对象,并且打印出了正在”处理”这个 Network 对象的日志。

可以看到,这个 Network 的 ResourceVersion,也就是 API 对象的版本号,是 479015,而它的 Spec 字段的内容,跟我提交的 YAML 文件一摸一样,比如,它的 CIDR 网段是:192.168.0.0/16。

这时候,我来修改一下这个 YAML 文件的内容,如下所示:

$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
  name: example-network
spec:
  cidr: "192.168.1.0/16"
  gateway: "192.168.1.1"

可以看到,我把这个 YAML 文件里的 CIDR 和 Gateway 字段的修改成了 192.168.1.0/16 网段。

然后,我们执行了 kubectl apply 命令来提交这次更新,如下所示:

$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network configured

这时候,我们就可以观察一下控制器的输出:

...
I0915 12:53:51.126029   25245 controller.go:229] [Neutron] Try to process network: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ...  ResourceVersion:"479062", ... Spec:v1.NetworkSpec{Cidr:"192.168.1.0/16", Gateway:"192.168.1.1"}} ...
I0915 12:53:51.126348   25245 controller.go:183] Successfully synced 'default/example-network'

可以看到,这一次,Informer 注册的”更新”事件被触发,更新后的 Network 对象的 Key 被添加到了工作队列之中。

所以,接下来控制循环从工作队列里拿到的 Network 对象,与前一个对象是不同的:它的 ResourceVersion 的值变成了 479062;而 Spec 里的字段,则变成了 192.168.1.0/16 网段。

最后,我再把这个对象删除掉:

kubectl delete -f example/example-network.yaml

这一次,在控制器的输出里,我们就可以看到,Informer 注册的”删除”事件被触发,并且控制循环”调用”Neutron API”删除”了真实环境里的网络。这个输出如下所示:

W0915 12:54:09.738464   25245 controller.go:212] Network: default/example-network does not exist in local cache, will delete it from Neutron ...
I0915 12:54:09.738832   25245 controller.go:215] [Neutron] Deleting network: default/example-network ...
I0915 12:54:09.738854   25245 controller.go:183] Successfully synced 'default/example-network'

以上,就是编写和使用自定义控制器的全部流程了。

实际上, 这套流程不仅可以用在自定义 API 资源上,也完全可以用在 Kubernetes 原生的默认 API 对象上。

比如,我们在 main 函数里,除了创建一个 Network Informer 外,还可以初始化一个 Kubernetes 默认 API 对象的 Informer 工厂,比如 Deployment 对象的 Informer。这个具体做法如下所示:

func main() {
  ...

  kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, time.Second*30)

  controller := NewController(kubeClient, exampleClient,
  kubeInformerFactory.Apps().V1().Deployments(),
  networkInformerFactory.Samplecrd().V1().Networks())

  go kubeInformerFactory.Start(stopCh)
  ...
}

在这段代码中,我们 首先 使用 Kubernetes 的 client(kubeClient)创建了一个工厂;

然后 ,我用跟 Network 类似的处理方法,生成了一个 Deployment Informer;

接着 ,我把 Deployment Informer 传递给了自定义控制器;当然,我也要调用 Start 方法来启动这个 Deployment Informer。

而有了这个 Deployment Informer 后,这个控制器也就持有了所有 Deployment 对象的信息。接下来,它既可以通过 deploymentInformer.Lister() 来获取 Etcd 里的所有 Deployment 对象,也可以为这个 Deployment Informer 注册具体的 Handler 来。

更重要的是, 这就使得在这个自定义控制器里面,我可以通过对自定义 API 对象和默认 API 对象进行协同,从而实现更加复杂的编排功能。

比如:用户每创建一个新的 Deployment,这个自定义控制器,就可以为它创建一个对应的 Network 供它使用。

25.1 总结

这其中,有如下几个概念和机制,是你一定要理解清楚的:

所谓的 Informer,就是一个自带缓存和索引机制,可以触发 Handler 的客户端库。这个本地缓存在 Kubernetes 中一般被称为 Store,索引一般被称为 Index。

Informer 使用了 Reflector 包,它是一个可以通过 ListAndWatch 机制获取并监视 API 对象变化的客户端封装。

Reflector 和 Informer 之间,用到了一个”增量先进先出队列”进行协同。而 Informer 与你要编写的控制循环之间,则使用了一个工作队列来进行协同。

在实际应用中,除了控制循环之外的所有代码,实际上都是 Kubernetes 为你自动生成的,即:pkg/client/{informers, listers, clientset}里的内容。

而这些自动生成的代码,就为我们提供了一个可靠而高效地获取 API 对象”期望状态”的编程库。

所以,接下来,作为开发者,你就只需要关注如何拿到”实际状态”,然后如何拿它去跟”期望状态”做对比,从而决定接下来要做的业务逻辑即可。

以上内容,就是 Kubernetes API 编程范式的核心思想。

25.2 思考题

请思考一下,为什么 Informer 和你编写的控制循环之间,一定要使用一个工作队列来进行协作呢?

答: Informer 和控制循环分开是为了解耦,防止控制循环执行过慢把Informer 拖死。

26 | 基于角色的权限控制:RBAC

你要通过一个外部插件,在 Kubernetes 里新增和操作 API 对象,那么就必须先了解一个非常重要的知识:RBAC。

我们知道,Kubernetes 中所有的 API 对象,都保存在 Etcd 里。可是,对这些 API 对象的操作,却一定都是通过访问 kube-apiserver 实现的。其中一个非常重要的原因,就是你需要 APIServer 来帮助你做授权工作。

在 Kubernetes 项目中,负责完成授权(Authorization)工作的机制,就是 RBAC :基于角色的访问控制(Role-Based Access Control)。

如果你直接查看 Kubernetes 项目中关于 RBAC 的文档的话,可能会感觉非常复杂。但实际上,等到你用到这些 RBAC 的细节时,再去查阅也不迟。

而在这里,我只希望你能明确三个最基本的概念。

  1. Role:角色,它其实是一组规则,定义了一组对 Kubernetes API 对象的操作权限。
  2. Subject:被作用者,既可以是”人”,也可以是”机器”,也可以使你在 Kubernetes 里定义的”用户”。
  3. RoleBinding:定义了”被作用者”和”角色”的绑定关系。

而这三个概念,其实就是整个 RBAC 体系的核心所在。

我先来讲解一下 Role。

实际上,Role 本身就是一个 Kubernetes 的 API 对象,定义如下所示:

kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: mynamespace
  name: example-role
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "watch", "list"]

首先,这个 Role 对象指定了它能产生作用的 Namepace 是:mynamespace。

Namespace 是 Kubernetes 项目里的一个逻辑管理单位。不同 Namespace 的 API 对象,在通过 kubectl 命令进行操作的时候,是互相隔离开的。

比如,kubectl get pods -n mynamespace。

当然,这仅限于逻辑上的”隔离”,Namespace 并不会提供任何实际的隔离或者多租户能力。而在前面文章中用到的大多数例子里,我都没有指定 Namespace,那就是使用的是默认 Namespace:default。

然后,这个 Role 对象的 rules 字段,就是它所定义的权限规则。在上面的例子里,这条规则的含义就是:允许”被作用者”,对 mynamespace 下面的 Pod 对象,进行 GET、WATCH 和 LIST 操作。

那么, 这个具体的”被作用者”又是如何指定的呢?这就需要通过 RoleBinding 来实现了。

当然,RoleBinding 本身也是一个 Kubernetes 的 API 对象。它的定义如下所示:

kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: example-rolebinding
  namespace: mynamespace
subjects:
- kind: User
  name: example-user
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: example-role
  apiGroup: rbac.authorization.k8s.io

可以看到,这个 RoleBinding 对象里定义了一个 subjects 字段,即”被作用者”。它的类型是 User,即 Kubernetes 里的用户。这个用户的名字是 example-user。

可是,在 Kubernetes 中,其实并没有一个叫作”User”的 API 对象。而且,我们在前面和部署使用 Kubernetes 的流程里,既不需要 User,也没有创建过 User。

这个 User 到底是从哪里来的呢?

实际上,Kubernetes 里的”User”,也就是”用户”,只是一个授权系统里的逻辑概念。它需要通过外部认证服务,比如 Keystone,来提供。或者,你也可以直接给 APIServer 指定一个用户名、密码文件。那么 Kubernetes 的授权系统,就能够从这个文件里找到对应的”用户”了。当然,在大多数私有的使用环境中,我们只要使用 Kubernetes 提供的内置”用户”,就足够了。这部分知识,我后面马上会讲到。

接下来,我们会看到一个 roleRef 字段。正是通过这个字段, RoleBinding 对象就可以直接通过名字,来引用我们前面定义的 Role 对象(example-role),从而定义了”被作用者(Subject)”和”角色(Role)”之间的绑定关系。

需要再次提醒的是,Role 和 RoleBinding 对象都是 Namespaced 对象(Namespaced Object),它们对权限的限制规则仅在它们自己的 Namespace 内有效,roleRef 也只能引用当前 Namespace 里的 Role 对象。

那么, 对于非 Namespaced(Non-namespaced)对象(比如:Node),或者,某一个 Role 想要作用于所有的 Namespace 的时候,我们又该如何去做授权呢?

这时候,我们就必须要使用 ClusterRole 和 ClusterRoleBinding 这两个组合了。这两个 API 对象的用法跟 Role 和 RoleBinding 完全一样。只不过,它们的定义里,没有了 Namespace 字段,如下所示:

kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: example-clusterrole
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "watch", "list"]
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: example-clusterrolebinding
subjects:
- kind: User
  name: example-user
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: example-clusterrole
  apiGroup: rbac.authorization.k8s.io

上面的例子里的 ClusterRole 和 ClusterRoleBinding 的组合,意味着名叫 example-user 的用户,拥有对所有 Namespace 里的 Pod 进行 GET、WATCH 和 LIST 操作的权限。

更进一步地,在 Role 或者 ClusterRole 里面,如果要赋予用户 example-user 所有权限,那你就可以给它指定一个 verbs 字段的全集,如下所示:

verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

这些就是当前 Kubernetes(v1.11)里能够对 API 对象进行的所有操作了。

类似的,Role 对象的 rules 字段也可以进一步细化。比如,你可以只针对某一个具体的对象进行权限设置,如下所示:

rules:
- apiGroups: [""]
  resources: ["configmaps"]
  resourceNames: ["my-config"]
  verbs: ["get"]

这个例子就表示,这条规则的”被作用者”,只对名叫”my-config”的 ConfigMap 对象,有进行 GET 操作的权限。

而正如我前面介绍过的,在大多数时候,我们其实都不太使用”用户”这个功能,而是直接使用 Kubernetes 里的”内置用户”。

这个由 Kubernetes 负责管理的”内置用户”,正是我们前面曾经提到过的:ServiceAccount。

接下来,我通过一个具体的实例来为你讲解一下为 ServiceAccount 分配权限的过程。

首先,我们要定义一个 ServiceAccount。 它的 API 对象非常简单,如下所示:

apiVersion: v1
kind: ServiceAccount
metadata:
  namespace: mynamespace
  name: example-sa

可以看到,一个最简单的 ServiceAccount 对象只需要 Name 和 Namespace 这两个最基本的字段。

然后,我们通过编写 RoleBinding 的 YAML 文件,来为这个 ServiceAccount 分配权限:

kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: example-rolebinding
  namespace: mynamespace
subjects:
- kind: ServiceAccount
  name: example-sa
  namespace: mynamespace
roleRef:
  kind: Role
  name: example-role
  apiGroup: rbac.authorization.k8s.io

可以看到,在这个 RoleBinding 对象里,subjects 字段的类型(kind),不再是一个 User,而是一个名叫 example-sa 的 ServiceAccount。而 roleRef 引用的 Role 对象,依然名叫 example-role,也就是我在这篇文章一开始定义的 Role 对象。

接着,我们用 kubectl 命令创建这三个对象:

kubectl create -f svc-account.yaml
kubectl create -f role-binding.yaml
kubectl create -f role.yaml

然后,我们来查看一下这个 ServiceAccount 的详细信息:

$ kubectl get sa -n mynamespace -o yaml
- apiVersion: v1
  kind: ServiceAccount
  metadata:
    creationTimestamp: 2018-09-08T12:59:17Z
    name: example-sa
    namespace: mynamespace
    resourceVersion: "409327"
    ...
  secrets:
  - name: example-sa-token-vmfg6

可以看到,Kubernetes 会为一个 ServiceAccount 自动创建并分配一个 Secret 对象,即:上述 ServiceAcount 定义里最下面的 secrets 字段。

这个 Secret,就是这个 ServiceAccount 对应的、用来跟 APIServer 进行交互的授权文件,我们一般称它为:Token。Token 文件的内容一般是证书或者密码,它以一个 Secret 对象的方式保存在 Etcd 当中。

这时候,用户的 Pod,就可以声明使用这个 ServiceAccount 了,比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  namespace: mynamespace
  name: sa-token-test
spec:
  containers:
  - name: nginx
    image: nginx:1.7.9
  serviceAccountName: example-sa

等这个 Pod 运行起来之后,我们就可以看到,该 ServiceAccount 的 token,也就是一个 Secret 对象,被 Kubernetes 自动挂载到了容器的 /var/run/secrets/kubernetes.io/serviceaccount 目录下,如下所示:

$ kubectl describe pod sa-token-test -n mynamespace
Name:               sa-token-test
Namespace:          mynamespace
...
Containers:
  nginx:
    ...
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from example-sa-token-vmfg6 (ro)

这时候,我们可以通过 kubectl exec 查看到这个目录里的文件:

$ kubectl exec -it sa-token-test -n mynamespace -- /bin/bash
root@sa-token-test:/# ls /var/run/secrets/kubernetes.io/serviceaccount
ca.crt namespace  token

如上所示,容器里的应用,就可以使用这个 ca.crt 来访问 APIServer 了。更重要的是,此时它只能够做 GET、WATCH 和 LIST 操作。因为 example-sa 这个 ServiceAccount 的权限,已经被我们绑定了 Role 做了限制。

此外,我在第 15 篇文章 《深入解析 Pod 对象(二):使用进阶》 中曾经提到过,如果一个 Pod 没有声明 serviceAccountName,Kubernetes 会自动在它的 Namespace 下创建一个名叫 default 的默认 ServiceAccount,然后分配给这个 Pod。

但在这种情况下,这个默认 ServiceAccount 并没有关联任何 Role。也就是说,此时它有访问 APIServer 的绝大多数权限。当然,这个访问所需要的 Token,还是默认 ServiceAccount 对应的 Secret 对象为它提供的,如下所示。

$kubectl describe sa default
Name:                default
Namespace:           default
Labels:              <none>
Annotations:         <none>
Image pull secrets:  <none>
Mountable secrets:   default-token-s8rbq
Tokens:              default-token-s8rbq
Events:              <none>

$ kubectl get secret
NAME                  TYPE                                  DATA      AGE
kubernetes.io/service-account-token   3         82d

$ kubectl describe secret default-token-s8rbq
Name:         default-token-s8rbq
Namespace:    default
Labels:       <none>
Annotations:  kubernetes.io/service-account.name=default
              kubernetes.io/service-account.uid=ffcb12b2-917f-11e8-abde-42010aa80002

Type:  kubernetes.io/service-account-token

Data
====
ca.crt:     1025 bytes
namespace:  7 bytes
token:      <TOKEN 数据 >

可以看到,Kubernetes 会自动为默认 ServiceAccount 创建并绑定一个特殊的 Secret:它的类型是kubernetes.io/service-account-token;它的 Annotation 字段,声明了kubernetes.io/service-account.name=default,即这个 Secret 会跟同一 Namespace 下名叫 default 的 ServiceAccount 进行绑定。

所以,在生产环境中,我强烈建议你为所有 Namespace 下的默认 ServiceAccount,绑定一个只读权限的 Role。这个具体怎么做,就当做思考题留给你了。

除了前面使用的”用户”(User),Kubernetes 还拥有”用户组”(Group)的概念,也就是一组”用户”的意思。如果你为 Kubernetes 配置了外部认证服务的话,这个”用户组”的概念就会由外部认证服务提供。

而对于 Kubernetes 的内置用户”ServiceAccount 来说,上述”用户组”的概念也同样适用。

实际上,一个 ServiceAccount,在 Kubernetes 里对应的”用户”的名字是:

system:serviceaccount:<ServiceAccount 名字 >

而它对应的内置”用户组”的名字,就是:

system:serviceaccounts:<Namespace 名字 >

这两个对应关系,请你一定要牢记。

比如,现在我们可以在 RoleBinding 里定义如下的 subjects:

subjects:
- kind: Group
  name: system:serviceaccounts:mynamespace
  apiGroup: rbac.authorization.k8s.io

这就意味着这个 Role 的权限规则,作用于 mynamespace 里的所有 ServiceAccount。这就用到了”用户组”的概念。

而下面这个例子:

subjects:
- kind: Group
  name: system:serviceaccounts
  apiGroup: rbac.authorization.k8s.io

就意味着这个 Role 的权限规则,作用于整个系统里的所有 ServiceAccount。

最后,值得一提的是, 在 Kubernetes 中已经内置了很多个为系统保留的 ClusterRole,它们的名字都以 system: 开头 。你可以通过 kubectl get clusterroles 查看到它们。

一般来说,这些系统 ClusterRole,是绑定给 Kubernetes 系统组件对应的 ServiceAccount 使用的。

比如,其中一个名叫 system:kube-scheduler 的 ClusterRole,定义的权限规则是 kube-scheduler(Kubernetes 的调度器组件)运行所需要的必要权限。你可以通过如下指令查看这些权限的列表:

$ kubectl describe clusterrole system:kube-scheduler
Name:         system:kube-scheduler
...
PolicyRule:
  Resources                    Non-Resource URLs Resource Names    Verbs
  ---------                    -----------------  --------------    -----
...
  services                     []                 []                [get list watch]
  replicasets.apps             []                 []                [get list watch]
  statefulsets.apps            []                 []                [get list watch]
  replicasets.extensions       []                 []                [get list watch]
  poddisruptionbudgets.policy  []                 []                [get list watch]
  pods/status                  []                 []                [patch update]

这个 system:kube-scheduler 的 ClusterRole,就会被绑定给 kube-system Namesapce 下名叫 kube-scheduler 的 ServiceAccount,它正是 Kubernetes 调度器的 Pod 声明使用的 ServiceAccount。

除此之外,Kubernetes 还提供了四个预先定义好的 ClusterRole 来供用户直接使用:

  1. cluster-admin;
  2. admin;
  3. edit;
  4. view。

通过它们的名字,你应该能大致猜出它们都定义了哪些权限。比如,这个名叫 view 的 ClusterRole,就规定了被作用者只有 Kubernetes API 的只读权限。

而我还要提醒你的是,上面这个 cluster-admin 角色,对应的是整个 Kubernetes 项目中的最高权限(verbs=*),如下所示:

$ kubectl describe clusterrole cluster-admin -n kube-system
Name:         cluster-admin
Labels:       kubernetes.io/bootstrapping=rbac-defaults
Annotations:  rbac.authorization.kubernetes.io/autoupdate=true
PolicyRule:
  Resources  Non-Resource URLs Resource Names  Verbs
  ---------  -----------------  --------------  -----
  *.*        []                 []              [*]
             [*]                []              [*]

所以,请你务必要谨慎而小心地使用 cluster-admin。

26.1 总结

其实,你现在已经能够理解,所谓角色(Role),其实就是一组权限规则列表。而我们分配这些权限的方式,就是通过创建 RoleBinding 对象,将被作用者(subject)和权限列表进行绑定。

另外,与之对应的 ClusterRole 和 ClusterRoleBinding,则是 Kubernetes 集群级别的 Role 和 RoleBinding,它们的作用范围不受 Namespace 限制。

而尽管权限的被作用者可以有很多种(比如,User、Group 等),但在我们平常的使用中,最普遍的用法还是 ServiceAccount。所以,Role + RoleBinding + ServiceAccount 的权限分配方式是你要重点掌握的内容。我们在后面编写和安装各种插件的时候,会经常用到这个组合。

27 | 聪明的微创新:Operator工作原理解读

可能你已经感觉到,在 Kubernetes 中,管理”有状态应用”是一个比较复杂的过程,尤其是编写 Pod 模板的时候,总有一种”在 YAML 文件里编程序”的感觉,让人很不舒服。

而在 Kubernetes 生态中,还有一个相对更加灵活和编程友好的管理”有状态应用”的解决方案,它就是:Operator。

接下来,我就以 Etcd Operator 为例,来为你讲解一下 Operator 的工作原理和编写方法。

Etcd Operator 的使用方法非常简单,只需要两步即可完成:

第一步,将这个 Operator 的代码 Clone 到本地:

git clone https://github.com/coreos/etcd-operator

第二步,将这个 Etcd Operator 部署在 Kubernetes 集群里。

不过,在部署 Etcd Operator 的 Pod 之前,你需要先执行这样一个脚本:

example/rbac/create_role.sh

不用我多说你也能够明白:这个脚本的作用,就是为 Etcd Operator 创建 RBAC 规则。这是因为,Etcd Operator 需要访问 Kubernetes 的 APIServer 来创建对象。

更具体地说,上述脚本为 Etcd Operator 定义了如下所示的权限:

  1. 对 Pod、Service、PVC、Deployment、Secret 等 API 对象,有所有权限;
  2. 对 CRD 对象,有所有权限;
  3. 对属于 etcd.database.coreos.com 这个 API Group 的 CR(Custom Resource)对象,有所有权限。

而 Etcd Operator 本身,其实就是一个 Deployment,它的 YAML 文件如下所示:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: etcd-operator
spec:
  replicas: 1
  template:
    metadata:
      labels:
        name: etcd-operator
    spec:
      containers:
      - name: etcd-operator
        image: quay.io/coreos/etcd-operator:v0.9.2
        command:
        - etcd-operator
        env:
        - name: MY_POD_NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        - name: MY_POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name

所以,我们就可以使用上述的 YAML 文件来创建 Etcd Operator,如下所示:

kubectl create -f example/deployment.yaml

而一旦 Etcd Operator 的 Pod 进入了 Running 状态,你就会发现,有一个 CRD 被自动创建了出来,如下所示:

$ kubectl get pods
NAME                              READY     STATUS      RESTARTS   AGE
etcd-operator-649dbdb5cb-bzfzp    1/1       Running     0          20s

$ kubectl get crd
NAME                                    CREATED AT
etcdclusters.etcd.database.coreos.com   2018-09-18T11:42:55Z

这个 CRD 名叫etcdclusters.etcd.database.coreos.com 。你可以通过 kubectl describe 命令看到它的细节,如下所示:

$ kubectl describe crd  etcdclusters.etcd.database.coreos.com
...
Group:   etcd.database.coreos.com
  Names:
    Kind:       EtcdCluster
    List Kind:  EtcdClusterList
    Plural:     etcdclusters
    Short Names:
      etcd
    Singular:  etcdcluster
  Scope:       Namespaced
  Version:     v1beta2
...

可以看到,这个 CRD 相当于告诉了 Kubernetes:接下来,如果有 API 组(Group)是etcd.database.coreos.com、API 资源类型(Kind)是”EtcdCluster”的 YAML 文件被提交上来,你可一定要认识啊。

所以说,通过上述两步操作,你实际上是在 Kubernetes 里添加了一个名叫 EtcdCluster 的自定义资源类型。而 Etcd Operator 本身,就是这个自定义资源类型对应的自定义控制器。

而当 Etcd Operator 部署好之后,接下来在这个 Kubernetes 里创建一个 Etcd 集群的工作就非常简单了。你只需要编写一个 EtcdCluster 的 YAML 文件,然后把它提交给 Kubernetes 即可,如下所示:

kubectl apply -f example/example-etcd-cluster.yaml

这个 example-etcd-cluster.yaml 文件里描述的,是一个 3 个节点的 Etcd 集群。我们可以看到它被提交给 Kubernetes 之后,就会有三个 Etcd 的 Pod 运行起来,如下所示:

$ kubectl get pods
NAME                            READY     STATUS    RESTARTS   AGE
example-etcd-cluster-dp8nqtjznc   1/1       Running     0          1m
example-etcd-cluster-mbzlg6sd56   1/1       Running     0          2m
example-etcd-cluster-v6v6s6stxd   1/1       Running     0          2m

那么, 究竟发生了什么,让创建一个 Etcd 集群的工作如此简单呢?

我们当然还是得从这个 example-etcd-cluster.yaml 文件开始说起。

不难想到,这个文件里定义的,正是 EtcdCluster 这个 CRD 的一个具体实例,也就是一个 Custom Resource(CR)。而它的内容非常简单,如下所示:

apiVersion: "etcd.database.coreos.com/v1beta2"
kind: "EtcdCluster"
metadata:
  name: "example-etcd-cluster"
spec:
  size: 3
  version: "3.2.13"

可以看到,EtcdCluster 的 spec 字段非常简单。其中,size=3 指定了它所描述的 Etcd 集群的节点个数。而 version=”3.2.13”,则指定了 Etcd 的版本,仅此而已。

而真正把这样一个 Etcd 集群创建出来的逻辑,就是 Etcd Operator 要实现的主要工作了。

看到这里,相信你应该已经对 Operator 有了一个初步的认知:

Operator 的工作原理,实际上是利用了 Kubernetes 的自定义 API 资源(CRD),来描述我们想要部署的”有状态应用”;然后在自定义控制器里,根据自定义 API 对象的变化,来完成具体的部署和运维工作。

所以,编写一个 Etcd Operator,与我们前面编写一个自定义控制器的过程,没什么不同。

不过,考虑到你可能还不太清楚 Etcd 集群的组建方式 ,我在这里先简单介绍一下这部分知识。

Etcd Operator 部署 Etcd 集群,采用的是静态集群(Static)的方式。

静态集群的好处是,它不必依赖于一个额外的服务发现机制来组建集群,非常适合本地容器化部署。而它的难点,则在于你必须在部署的时候,就规划好这个集群的拓扑结构,并且能够知道这些节点固定的 IP 地址。比如下面这个例子:

$ etcd --name infra0 --initial-advertise-peer-urls http://10.0.1.10:2380 \
  --listen-peer-urls http://10.0.1.10:2380 \
...
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  --initial-cluster-state new

$ etcd --name infra1 --initial-advertise-peer-urls http://10.0.1.11:2380 \
  --listen-peer-urls http://10.0.1.11:2380 \
...
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  --initial-cluster-state new

$ etcd --name infra2 --initial-advertise-peer-urls http://10.0.1.12:2380 \
  --listen-peer-urls http://10.0.1.12:2380 \
...
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  --initial-cluster-state new

在这个例子中,我启动了三个 Etcd 进程,组成了一个三节点的 Etcd 集群。

其中,这些节点启动参数里的-initial-cluster 参数,非常值得你关注。它的含义,正是 当前节点启动时集群的拓扑结构。说得更详细一点,就是当前这个节点启动时,需要跟哪些节点通信来组成集群。

举个例子,我们可以看一下上述 infra2 节点的-initial-cluster 的值,如下所示:

...
--initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \

可以看到,-initial-cluster 参数是由 < 节点名字 >=< 节点地址 > 格式组成的一个数组。而上面这个配置的意思就是,当 infra2 节点启动之后,这个 Etcd 集群里就会有 infra0、infra1 和 infra2 三个节点。

同时,这些 Etcd 节点,需要通过 2380 端口进行通信以便组成集群,这也正是上述配置中-listen-peer-urls 字段的含义。

此外,一个 Etcd 集群还需要用-initial-cluster-token 字段,来声明一个该集群独一无二的 Token 名字。

像上述这样为每一个 Ectd 节点配置好它对应的启动参数之后把它们启动起来,一个 Etcd 集群就可以自动组建起来了。

而我们要编写的 Etcd Operator,就是要把上述过程自动化。 这其实等同于:用代码来生成每个 Etcd 节点 Pod 的启动命令,然后把它们启动起来。

接下来,我们一起来实践一下这个流程。

当然,在编写自定义控制器之前,我们首先需要完成 EtcdCluster 这个 CRD 的定义,它对应的 types.go 文件的主要内容,如下所示:

// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

type EtcdCluster struct {
  metav1.TypeMeta   `json:",inline"`
  metav1.ObjectMeta `json:"metadata,omitempty"`
  Spec              ClusterSpec   `json:"spec"`
  Status            ClusterStatus `json:"status"`
}

type ClusterSpec struct {
 // Size is the expected size of the etcd cluster.
 // The etcd-operator will eventually make the size of the running
 // cluster equal to the expected size.
 // The vaild range of the size is from 1 to 7.
 Size int `json:"size"`
 ...
}

可以看到,EtcdCluster 是一个有 Status 字段的 CRD。在这里,我们可以不必关心 ClusterSpec 里的其他字段,只关注 Size(即:Etcd 集群的大小)字段即可。

Size 字段的存在,就意味着将来如果我们想要调整集群大小的话,应该直接修改 YAML 文件里 size 的值,并执行 kubectl apply -f。

这样,Operator 就会帮我们完成 Etcd 节点的增删操作。这种”scale”能力,也是 Etcd Operator 自动化运维 Etcd 集群需要实现的主要功能。

而为了能够支持这个功能,我们就不再像前面那样在-initial-cluster 参数里把拓扑结构固定死。

所以,Etcd Operator 的实现,虽然选择的也是静态集群,但这个集群具体的组建过程,是逐个节点动态添加的方式,即:

首先,Etcd Operator 会创建一个”种子节点”;
然后,Etcd Operator 会不断创建新的 Etcd 节点,然后将它们逐一加入到这个集群当中,直到集群的节点数等于 size。

这就意味着,在生成不同角色的 Etcd Pod 时,Operator 需要能够区分种子节点与普通节点。

而这两种节点的不同之处,就在于一个名叫-initial-cluster-state 的启动参数:

  • 当这个参数值设为 new 时,就代表了该节点是种子节点。而我们前面提到过,种子节点还必须通过-initial-cluster-token 声明一个独一无二的 Token。
  • 而如果这个参数值设为 existing,那就是说明这个节点是一个普通节点,Etcd Operator 需要把它加入到已有集群里。

那么接下来的问题就是,每个 Etcd 节点的-initial-cluster 字段的值又是怎么生成的呢?

由于这个方案要求种子节点先启动,所以对于种子节点 infra0 来说,它启动后的集群只有它自己,即:-initial-cluster=infra0=http://10.0.1.10:2380

而对于接下来要加入的节点,比如 infra1 来说,它启动后的集群就有两个节点了,所以它的 -initial-cluster 参数的值应该是:infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380

其他节点,都以此类推。

现在,你就应该能在脑海中构思出上述三节点 Etcd 集群的部署过程了。

首先,只要用户提交 YAML 文件时声明创建一个 EtcdCluster 对象(一个 Etcd 集群),那么 Etcd Operator 都应该先创建一个单节点的种子集群(Seed Member),并启动这个种子节点。

以 infra0 节点为例,它的 IP 地址是 10.0.1.10,那么 Etcd Operator 生成的种子节点的启动命令,如下所示:

$ etcd
  --data-dir=/var/etcd/data
  --name=infra0
  --initial-advertise-peer-urls=http://10.0.1.10:2380
  --listen-peer-urls=http://0.0.0.0:2380
  --listen-client-urls=http://0.0.0.0:2379
  --advertise-client-urls=http://10.0.1.10:2379
  --initial-cluster=infra0=http://10.0.1.10:2380
  --initial-cluster-state=new
  --initial-cluster-token=4b5215fa-5401-4a95-a8c6-892317c9bef8

可以看到,这个种子节点的 initial-cluster-state 是 new,并且指定了唯一的 initial-cluster-token 参数。

我们可以把这个创建种子节点(集群)的阶段称为: Bootstrap

接下来, 对于其他每一个节点,Operator 只需要执行如下两个操作即可 ,以 infra1 为例。

第一步:通过 Etcd 命令行添加一个新成员:

etcdctl member add infra1 http://10.0.1.11:2380

第二步:为这个成员节点生成对应的启动参数,并启动它:

$ etcd
    --data-dir=/var/etcd/data
    --name=infra1
    --initial-advertise-peer-urls=http://10.0.1.11:2380
    --listen-peer-urls=http://0.0.0.0:2380
    --listen-client-urls=http://0.0.0.0:2379
    --advertise-client-urls=http://10.0.1.11:2379
    --initial-cluster=infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380
    --initial-cluster-state=existing

可以看到,对于这个 infra1 成员节点来说,它的 initial-cluster-state 是 existing,也就是要加入已有集群。而它的 initial-cluster 的值,则变成了 infra0 和 infra1 两个节点的 IP 地址。

所以,以此类推,不断地将 infra2 等后续成员添加到集群中,直到整个集群的节点数目等于用户指定的 size 之后,部署就完成了。

在熟悉了这个部署思路之后,我再为你讲解 Etcd Operator 的工作原理 ,就非常简单了。

跟所有的自定义控制器一样,Etcd Operator 的启动流程也是围绕着 Informer 展开的,如下所示:

func (c *Controller) Start() error {
 for {
  err := c.initResource()
  ...
  time.Sleep(initRetryWaitTime)
 }
 c.run()
}

func (c *Controller) run() {
 ...

 _, informer := cache.NewIndexerInformer(source, &api.EtcdCluster{}, 0, cache.ResourceEventHandlerFuncs{
  AddFunc:    c.onAddEtcdClus,
  UpdateFunc: c.onUpdateEtcdClus,
  DeleteFunc: c.onDeleteEtcdClus,
 }, cache.Indexers{})

 ctx := context.TODO()
 // TODO: use workqueue to avoid blocking
 informer.Run(ctx.Done())
}

可以看到, Etcd Operator 启动要做的第一件事 ( c.initResource),是创建 EtcdCluster 对象所需要的 CRD,即:前面提到的etcdclusters.etcd.database.coreos.com。这样 Kubernetes 就能够”认识”EtcdCluster 这个自定义 API 资源了。

接下来,Etcd Operator 会定义一个 EtcdCluster 对象的 Informer。

不过,需要注意的是,由于 Etcd Operator 的完成时间相对较早,所以它里面有些代码的编写方式会跟我们之前讲解的最新的编写方式不太一样。在具体实践的时候,你还是应该以我讲解的模板为主。

比如,在上面的代码最后,你会看到有这样一句注释:

// TODO: use workqueue to avoid blocking
...

也就是说,Etcd Operator 并没有用工作队列来协调 Informer 和控制循环。这其实正是我在第 25 篇文章 《深入解析声明式 API(二):编写自定义控制器》 中,给你留的关于工作队列的思考题的答案。

具体来讲,我们在控制循环里执行的业务逻辑,往往是比较耗时间的。比如,创建一个真实的 Etcd 集群。而 Informer 的 WATCH 机制对 API 对象变化的响应,则非常迅速。所以,控制器里的业务逻辑就很可能会拖慢 Informer 的执行周期,甚至可能 Block 它。而要协调这样两个快、慢任务的一个典型解决方法,就是引入一个工作队列。

由于 Etcd Operator 里没有工作队列,那么在它的 EventHandler 部分,就不会有什么入队操作,而直接就是每种事件对应的具体的业务逻辑了。

不过,Etcd Operator 在业务逻辑的实现方式上,与常规的自定义控制器略有不同。我把在这一部分的工作原理,提炼成了一个详细的流程图,如下所示:

EtcdOperator工作原理

可以看到,Etcd Operator 的特殊之处在于,它为每一个 EtcdCluster 对象,都启动了一个控制循环,”并发”地响应这些对象的变化。显然,这种做法不仅可以简化 Etcd Operator 的代码实现,还有助于提高它的响应速度。

以文章一开始的 example-etcd-cluster 的 YAML 文件为例。

当这个 YAML 文件第一次被提交到 Kubernetes 之后,Etcd Operator 的 Informer,就会立刻”感知”到一个新的 EtcdCluster 对象被创建了出来。所以,EventHandler 里的”添加”事件会被触发。

而这个 Handler 要做的操作也很简单,即:在 Etcd Operator 内部创建一个对应的 Cluster 对象(cluster.New),比如流程图里的 Cluster1。

这个 Cluster 对象,就是一个 Etcd 集群在 Operator 内部的描述,所以它与真实的 Etcd 集群的生命周期是一致的。

而一个 Cluster 对象需要具体负责的,其实有两个工作。

其中,第一个工作只在该 Cluster 对象第一次被创建的时候才会执行。这个工作,就是我们前面提到过的 Bootstrap,即:创建一个单节点的种子集群。

由于种子集群只有一个节点,所以这一步直接就会生成一个 Etcd 的 Pod 对象。这个 Pod 里有一个 InitContainer,负责检查 Pod 的 DNS 记录是否正常。如果检查通过,用户容器也就是 Etcd 容器就会启动起来。

而这个 Etcd 容器最重要的部分,当然就是它的启动命令了。

以我们在文章一开始部署的集群为例,它的种子节点的容器启动命令如下所示:

/usr/local/bin/etcd
  --data-dir=/var/etcd/data
  --name=example-etcd-cluster-mbzlg6sd56
  --initial-advertise-peer-urls=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2380
  --listen-peer-urls=http://0.0.0.0:2380
  --listen-client-urls=http://0.0.0.0:2379
  --advertise-client-urls=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2379
  --initial-cluster=example-etcd-cluster-mbzlg6sd56=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2380
  --initial-cluster-state=new
  --initial-cluster-token=4b5215fa-5401-4a95-a8c6-892317c9bef8

上述启动命令里的各个参数的含义,我已经在前面介绍过。

可以看到,在这些启动参数(比如:initial-cluster)里,Etcd Operator 只会使用 Pod 的 DNS 记录,而不是它的 IP 地址。

这当然是因为,在 Operator 生成上述启动命令的时候,Etcd 的 Pod 还没有被创建出来,它的 IP 地址自然也无从谈起。

这也就意味着,每个 Cluster 对象,都会事先创建一个与该 EtcdCluster 同名的 Headless Service。这样,Etcd Operator 在接下来的所有创建 Pod 的步骤里,就都可以使用 Pod 的 DNS 记录来代替它的 IP 地址了。

Cluster 对象的第二个工作,则是启动该集群所对应的控制循环。

这个控制循环每隔一定时间,就会执行一次下面的 Diff 流程。

首先,控制循环要获取到所有正在运行的、属于这个 Cluster 的 Pod 数量,也就是该 Etcd 集群的”实际状态”。

而这个 Etcd 集群的”期望状态”,正是用户在 EtcdCluster 对象里定义的 size。

所以接下来,控制循环会对比这两个状态的差异。

如果实际的 Pod 数量不够,那么控制循环就会执行一个添加成员节点的操作(即:上述流程图中的 addOneMember 方法);反之,就执行删除成员节点的操作(即:上述流程图中的 removeOneMember 方法)。

以 addOneMember 方法为例,它执行的流程如下所示:

  1. 生成一个新节点的 Pod 的名字,比如:example-etcd-cluster-v6v6s6stxd;
  2. 调用 Etcd Client,执行前面提到过的 etcdctl member add example-etcd-cluster-v6v6s6stxd 命令;
  3. 使用这个 Pod 名字,和已经存在的所有节点列表,组合成一个新的 initial-cluster 字段的值;
  4. 使用这个 initial-cluster 的值,生成这个 Pod 里 Etcd 容器的启动命令。如下所示:
/usr/local/bin/etcd
  --data-dir=/var/etcd/data
  --name=example-etcd-cluster-v6v6s6stxd
  --initial-advertise-peer-urls=http://example-etcd-cluster-v6v6s6stxd.example-etcd-cluster.default.svc:2380
  --listen-peer-urls=http://0.0.0.0:2380
  --listen-client-urls=http://0.0.0.0:2379
  --advertise-client-urls=http://example-etcd-cluster-v6v6s6stxd.example-etcd-cluster.default.svc:2379
  --initial-cluster=example-etcd-cluster-mbzlg6sd56=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2380,example-etcd-cluster-v6v6s6stxd=http://example-etcd-cluster-v6v6s6stxd.example-etcd-cluster.default.svc:2380
  --initial-cluster-state=existing

这样,当这个容器启动之后,一个新的 Etcd 成员节点就被加入到了集群当中。控制循环会重复这个过程,直到正在运行的 Pod 数量与 EtcdCluster 指定的 size 一致。

在有了这样一个与 EtcdCluster 对象一一对应的控制循环之后,你后续对这个 EtcdCluster 的任何修改,比如:修改 size 或者 Etcd 的 version,它们对应的更新事件都会由这个 Cluster 对象的控制循环进行处理。

以上,就是一个 Etcd Operator 的工作原理了。

如果对比一下 Etcd Operator 与我在第 20 篇文章 《深入理解 StatefulSet(三):有状态应用实践》 中讲解过的 MySQL StatefulSet 的话,你可能会有两个问题。

  1. 第一个问题是,在 StatefulSet 里,它为 Pod 创建的名字是带编号的,这样就把整个集群的拓扑状态固定了下来(比如:一个三节点的集群一定是由名叫 web-0、web-1 和 web-2 的三个 Pod 组成)。 可是,在 Etcd Operator 里,为什么我们使用随机名字就可以了呢?

    这是因为,Etcd Operator 在每次添加 Etcd 节点的时候,都会先执行 etcdctl member add <Pod 名字 > ;每次删除节点的时候,则会执行 etcdctl member remove <Pod 名字 > 。这些操作,其实就会更新 Etcd 内部维护的拓扑信息,所以 Etcd Operator 无需在集群外部通过编号来固定这个拓扑关系。

  2. 第二个问题是,为什么我没有在 EtcdCluster 对象里声明 Persistent Volume?

    难道,我们不担心节点宕机之后 Etcd 的数据会丢失吗?

    我们知道,Etcd 是一个基于 Raft 协议实现的高可用 Key-Value 存储。根据 Raft 协议的设计原则,当 Etcd 集群里只有半数以下(在我们的例子里,小于等于一个)的节点失效时,当前集群依然可以正常工作。此时,Etcd Operator 只需要通过控制循环创建出新的 Pod,然后将它们加入到现有集群里,就完成了”期望状态”与”实际状态”的调谐工作。这个集群,是一直可用的 。

    备注:关于 Etcd 的工作原理和 Raft 协议的设计思想,你可以阅读 这篇文章 来进行学习。

但是,当这个 Etcd 集群里有半数以上(在我们的例子里,大于等于两个)的节点失效的时候,这个集群就会丧失数据写入的能力,从而进入”不可用”状态。此时,即使 Etcd Operator 创建出新的 Pod 出来,Etcd 集群本身也无法自动恢复起来。

这个时候,我们就必须使用 Etcd 本身的备份数据来对集群进行恢复操作。

在有了 Operator 机制之后,上述 Etcd 的备份操作,是由一个单独的 Etcd Backup Operator 负责完成的。

创建和使用这个 Operator 的流程,如下所示:

# 首先,创建 etcd-backup-operator
$ kubectl create -f example/etcd-backup-operator/deployment.yaml

# 确认 etcd-backup-operator 已经在正常运行
$ kubectl get pod
NAME                                    READY     STATUS    RESTARTS   AGE
etcd-backup-operator-1102130733-hhgt7   1/1       Running   0          3s

# 可以看到,Backup Operator 会创建一个叫 etcdbackups 的 CRD
$ kubectl get crd
NAME                                    KIND
etcdbackups.etcd.database.coreos.com    CustomResourceDefinition.v1beta1.apiextensions.k8s.io

# 我们这里要使用 AWS S3 来存储备份,需要将 S3 的授权信息配置在文件里
$ cat $AWS_DIR/credentials
[default]
aws_access_key_id = XXX
aws_secret_access_key = XXX

$ cat $AWS_DIR/config
[default]
region = <region>

# 然后,将上述授权信息制作成一个 Secret
$ kubectl create secret generic aws --from-file=$AWS_DIR/credentials --from-file=$AWS_DIR/config

# 使用上述 S3 的访问信息,创建一个 EtcdBackup 对象
$ sed -e 's|<full-s3-path>|mybucket/etcd.backup|g' \
    -e 's|<aws-secret>|aws|g' \
    -e 's|<etcd-cluster-endpoints>|"http://example-etcd-cluster-client:2379"|g' \
    example/etcd-backup-operator/backup_cr.yaml \
    | kubectl create -f -

需要注意的是,每当你创建一个 EtcdBackup 对象( backup_cr.yaml ),就相当于为它所指定的 Etcd 集群做了一次备份。EtcdBackup 对象的 etcdEndpoints 字段,会指定它要备份的 Etcd 集群的访问地址。

所以,在实际的环境里,我建议你把最后这个备份操作,编写成一个 Kubernetes 的 CronJob 以便定时运行。

而当 Etcd 集群发生了故障之后,你就可以通过创建一个 EtcdRestore 对象来完成恢复操作。当然,这就意味着你也需要事先启动 Etcd Restore Operator。

这个流程的完整过程,如下所示:

# 创建 etcd-restore-operator
$ kubectl create -f example/etcd-restore-operator/deployment.yaml

# 确认它已经正常运行
$ kubectl get pods
NAME                                     READY     STATUS    RESTARTS   AGE
etcd-restore-operator-4203122180-npn3g   1/1       Running   0          7s

# 创建一个 EtcdRestore 对象,来帮助 Etcd Operator 恢复数据,记得替换模板里的 S3 的访问信息
$ sed -e 's|<full-s3-path>|mybucket/etcd.backup|g' \
    -e 's|<aws-secret>|aws|g' \
    example/etcd-restore-operator/restore_cr.yaml \
    | kubectl create -f -

上面例子里的 EtcdRestore 对象( restore_cr.yaml ),会指定它要恢复的 Etcd 集群的名字和备份数据所在的 S3 存储的访问信息。

而当一个 EtcdRestore 对象成功创建后,Etcd Restore Operator 就会通过上述信息,恢复出一个全新的 Etcd 集群。然后,Etcd Operator 会把这个新集群直接接管过来,从而重新进入可用的状态。

EtcdBackup 和 EtcdRestore 这两个 Operator 的工作原理,与 Etcd Operator 的实现方式非常类似。所以,这一部分就交给你课后去探索了。

27.1 总结

在今天这篇文章中,我以 Etcd Operator 为例,详细介绍了一个 Operator 的工作原理和编写过程。

可以看到,Etcd 集群本身就拥有良好的分布式设计和一定的高可用能力。在这种情况下,StatefulSet”为 Pod 编号”和”将 Pod 同 PV 绑定”这两个主要的特性,就不太有用武之地了。

而相比之下,Etcd Operator 把一个 Etcd 集群,抽象成了一个具有一定”自治能力”的整体。而当这个”自治能力”本身不足以解决问题的时候,我们可以通过两个专门负责备份和恢复的 Operator 进行修正。这种实现方式,不仅更加贴近 Etcd 的设计思想,也更加编程友好。

不过,如果我现在要部署的应用,既需要用 StatefulSet 的方式维持拓扑状态和存储状态,又有大量的编程工作要做,那我到底该如何选择呢?

其实,Operator 和 StatefulSet 并不是竞争关系。你完全可以编写一个 Operator,然后在 Operator 的控制循环里创建和控制 StatefulSet 而不是 Pod。比如,业界知名的 Prometheus 项目的 Operator ,正是这么实现的。

此外,CoreOS 公司在被 RedHat 公司收购之后,已经把 Operator 的编写过程封装成了一个叫作 Operator SDK 的工具(整个项目叫作 Operator Framework),它可以帮助你生成 Operator 的框架代码。感兴趣的话,你可以试用一下。

Kubernetes容器持久化存储

28 | PV、PVC、StorageClass,这些到底在说啥?

在第 19 篇文章 《深入理解 StatefulSet(二):存储状态》 中,和你分享 StatefulSet 如何管理存储状态的时候,介绍过的 Persistent Volume(PV)和 Persistent Volume Claim(PVC) 这套持久化存储体系。

其中, PV 描述的,是持久化存储数据卷 。这个 API 对象主要定义的是一个持久化存储在宿主机上的目录,比如一个 NFS 的挂载目录。

通常情况下,PV 对象是由运维人员事先创建在 Kubernetes 集群里待用的。比如,运维人员可以定义这样一个 NFS 类型的 PV,如下所示:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs
spec:
  storageClassName: manual
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteMany
  nfs:
    server: 10.244.1.4
    path: "/"

PVC 描述的,则是 Pod 所希望使用的持久化存储的属性 。比如,Volume 存储的大小、可读写权限等等。

PVC 对象通常由开发人员创建;或者以 PVC 模板的方式成为 StatefulSet 的一部分,然后由 StatefulSet 控制器负责创建带编号的 PVC。

比如,开发人员可以声明一个 1 GiB 大小的 PVC,如下所示:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: manual
  resources:
    requests:
      storage: 1Gi

而用户创建的 PVC 要真正被容器使用起来,就必须先和某个符合条件的 PV 进行绑定。这里要检查的条件,包括两部分:

  • 第一个条件,当然是 PV 和 PVC 的 spec 字段。比如,PV 的存储(storage)大小,就必须满足 PVC 的要求。
  • 而第二个条件,则是 PV 和 PVC 的 storageClassName 字段必须一样。这个机制我会在本篇文章的最后一部分专门介绍。

在成功地将 PVC 和 PV 进行绑定之后,Pod 就能够像使用 hostPath 等常规类型的 Volume 一样,在自己的 YAML 文件里声明使用这个 PVC 了,如下所示:

apiVersion: v1
kind: Pod
metadata:
  labels:
    role: web-frontend
spec:
  containers:
  - name: web
    image: nginx
    ports:
      - name: web
        containerPort: 80
    volumeMounts:
        - name: nfs
          mountPath: "/usr/share/nginx/html"
  volumes:
  - name: nfs
    persistentVolumeClaim:
      claimName: nfs

可以看到,Pod 需要做的,就是在 volumes 字段里声明自己要使用的 PVC 名字。接下来,等这个 Pod 创建之后,kubelet 就会把这个 PVC 所对应的 PV,也就是一个 NFS 类型的 Volume,挂载在这个 Pod 容器内的目录上。

不难看出, PVC 和 PV 的设计,其实跟”面向对象”的思想完全一致。

PVC 可以理解为持久化存储的”接口”,它提供了对某种持久化存储的描述,但不提供具体的实现;而这个持久化存储的实现部分则由 PV 负责完成。

这样做的好处是,作为应用开发者,我们只需要跟 PVC 这个”接口”打交道,而不必关心具体的实现是 NFS 还是 Ceph。毕竟这些存储相关的知识太专业了,应该交给专业的人去做。

而在上面的讲述中,其实还 有一个比较棘手的情况。

比如,你在创建 Pod 的时候,系统里并没有合适的 PV 跟它定义的 PVC 绑定,也就是说此时容器想要使用的 Volume 不存在。这时候,Pod 的启动就会报错。

但是,过了一会儿,运维人员也发现了这个情况,所以他赶紧创建了一个对应的 PV。这时候,我们当然希望 Kubernetes 能够再次完成 PVC 和 PV 的绑定操作,从而启动 Pod。

所以在 Kubernetes 中,实际上存在着一个专门处理持久化存储的控制器,叫作 Volume Controller。这个 Volume Controller 维护着多个控制循环,其中有一个循环,扮演的就是撮合 PV 和 PVC 的”红娘”的角色。它的名字叫作 PersistentVolumeController。

PersistentVolumeController 会不断地查看当前每一个 PVC,是不是已经处于 Bound(已绑定)状态。如果不是,那它就会遍历所有的、可用的 PV,并尝试将其与这个”单身”的 PVC 进行绑定。这样,Kubernetes 就可以保证用户提交的每一个 PVC,只要有合适的 PV 出现,它就能够很快进入绑定状态,从而结束”单身”之旅。

而所谓将一个 PV 与 PVC 进行”绑定”,其实就是将这个 PV 对象的名字,填在了 PVC 对象的 spec.volumeName 字段上。所以,接下来 Kubernetes 只要获取到这个 PVC 对象,就一定能够找到它所绑定的 PV。

那么, 这个 PV 对象,又是如何变成容器里的一个持久化存储的呢?

我在前面讲解容器基础的时候,已经为你详细剖析了容器 Volume 的挂载机制。用一句话总结, 所谓容器的 Volume,其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起。 (你可以借此机会,再回顾一下专栏的第 8 篇文章 《白话容器基础(四):重新认识 Docker 容器》 中的相关内容)

而所谓的”持久化 Volume”,指的就是这个宿主机上的目录,具备”持久性”。 即:这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定。这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个 Volume,访问到这些内容。

显然,我们前面使用的 hostPath 和 emptyDir 类型的 Volume 并不具备这个特征:它们既有可能被 kubelet 清理掉,也不能被”迁移”到其他节点上。

所以,大多数情况下,持久化 Volume 的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等。

而 Kubernetes 需要做的工作,就是使用这些存储服务,来为容器准备一个持久化的宿主机目录,以供将来进行绑定挂载时使用。而所谓”持久化”,指的是容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了”持久性”。

这个准备”持久化”宿主机目录的过程,我们可以形象地称为”两阶段处理”。

接下来,我通过一个具体的例子为你说明。

当一个 Pod 调度到一个节点上之后,kubelet 就要负责为这个 Pod 创建它的 Volume 目录。默认情况下,kubelet 为 Volume 创建的目录是如下所示的一个宿主机上的路径:

/var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >

接下来,kubelet 要做的操作就取决于你的 Volume 类型了。

如果你的 Volume 类型是远程块存储,比如 Google Cloud 的 Persistent Disk(GCE 提供的远程磁盘服务),那么 kubelet 就需要先调用 Goolge Cloud 的 API,将它所提供的 Persistent Disk 挂载到 Pod 所在的宿主机上。

备注:你如果不太了解块存储的话,可以直接把它理解为:一块 磁盘

这相当于执行:

gcloud compute instances attach-disk < 虚拟机名字 > --disk < 远程磁盘名字 >

这一步为虚拟机挂载远程磁盘的操作,对应的正是”两阶段处理”的第一阶段。在 Kubernetes 中,我们把这个阶段称为 Attach。

Attach 阶段完成后,为了能够使用这个远程磁盘,kubelet 还要进行第二个操作,即:格式化这个磁盘设备,然后将它挂载到宿主机指定的挂载点上。不难理解,这个挂载点,正是我在前面反复提到的 Volume 的宿主机目录。所以,这一步相当于执行:

# 通过 lsblk 命令获取磁盘设备 ID
$ sudo lsblk
# 格式化成 ext4 格式
$ sudo mkfs.ext4 -m 0 -F -E lazy_itable_init=0,lazy_journal_init=0,discard /dev/< 磁盘设备 ID>
# 挂载到挂载点
$ sudo mkdir -p /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >

这个 将磁盘设备格式化并挂载到 Volume 宿主机目录的操作,对应的正是”两阶段处理”的第二个阶段,我们一般称为:Mount。

Mount 阶段完成后,这个 Volume 的宿主机目录就是一个”持久化”的目录了,容器在它里面写入的内容,会保存在 Google Cloud 的远程磁盘中。

而如果你的 Volume 类型是远程文件存储(比如 NFS)的话,kubelet 的处理过程就会更简单一些。

因为在这种情况下,kubelet 可以跳过”第一阶段”(Attach)的操作,这是因为一般来说,远程文件存储并没有一个”存储设备”需要挂载在宿主机上。

所以,kubelet 会直接从”第二阶段”(Mount)开始准备宿主机上的 Volume 目录。

在这一步,kubelet 需要作为 client,将远端 NFS 服务器的目录(比如:”/“目录),挂载到 Volume 的宿主机目录上,即相当于执行如下所示的命令:

mount -t nfs <NFS 服务器地址 >:/ /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >

通过这个挂载操作,Volume 的宿主机目录就成为了一个远程 NFS 目录的挂载点,后面你在这个目录里写入的所有文件,都会被保存在远程 NFS 服务器上。所以,我们也就完成了对这个 Volume 宿主机目录的”持久化”。

到这里,你可能会有疑问,Kubernetes 又是如何定义和区分这两个阶段的呢?

其实很简单,在具体的 Volume 插件的实现接口上,Kubernetes 分别给这两个阶段提供了两种不同的参数列表:

  • 对于”第一阶段”(Attach),Kubernetes 提供的可用参数是 nodeName,即宿主机的名字。
  • 而对于”第二阶段”(Mount),Kubernetes 提供的可用参数是 dir,即 Volume 的宿主机目录。

所以,作为一个存储插件,你只需要根据自己的需求进行选择和实现即可。在后面关于编写存储插件的文章中,我会对这个过程做深入讲解。

而经过了”两阶段处理”,我们就得到了一个”持久化”的 Volume 宿主机目录。所以,接下来,kubelet 只要把这个 Volume 目录通过 CRI 里的 Mounts 参数,传递给 Docker,然后就可以为 Pod 里的容器挂载这个”持久化”的 Volume 了。其实,这一步相当于执行了如下所示的命令:

docker run -v /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >:/< 容器内的目标目录 > 我的镜像 ...

以上,就是 Kubernetes 处理 PV 的具体原理了。

备注:对应地,在删除一个 PV 的时候,Kubernetes 也需要 Unmount 和 Dettach 两个阶段来处理。这个过程我就不再详细介绍了,执行”反向操作”即可。

实际上,你可能已经发现,这个 PV 的处理流程似乎跟 Pod 以及容器的启动流程没有太多的耦合,只要 kubelet 在向 Docker 发起 CRI 请求之前,确保”持久化”的宿主机目录已经处理完毕即可。

所以,在 Kubernetes 中,上述 关于 PV 的”两阶段处理”流程,是靠独立于 kubelet 主控制循环(Kubelet Sync Loop)之外的两个控制循环来实现的。

其中,”第一阶段”的 Attach(以及 Dettach)操作,是由 Volume Controller 负责维护的,这个控制循环的名字叫作: AttachDetachController 。而它的作用,就是不断地检查每一个 Pod 对应的 PV,和这个 Pod 所在宿主机之间挂载情况。从而决定,是否需要对这个 PV 进行 Attach(或者 Dettach)操作。

需要注意,作为一个 Kubernetes 内置的控制器,Volume Controller 自然是 kube-controller-manager 的一部分。所以,AttachDetachController 也一定是运行在 Master 节点上的。当然,Attach 操作只需要调用公有云或者具体存储项目的 API,并不需要在具体的宿主机上执行操作,所以这个设计没有任何问题。

而”第二阶段”的 Mount(以及 Unmount)操作,必须发生在 Pod 对应的宿主机上,所以它必须是 kubelet 组件的一部分。这个控制循环的名字,叫作: VolumeManagerReconciler ,它运行起来之后,是一个独立于 kubelet 主循环的 Goroutine。

通过这样将 Volume 的处理同 kubelet 的主循环解耦,Kubernetes 就避免了这些耗时的远程挂载操作拖慢 kubelet 的主控制循环,进而导致 Pod 的创建效率大幅下降的问题。实际上, kubelet 的一个主要设计原则,就是它的主控制循环绝对不可以被 block。 这个思想,我在后续的讲述容器运行时的时候还会提到。

在了解了 Kubernetes 的 Volume 处理机制之后,我再来为你介绍这个体系里最后一个重要概念:StorageClass。

我在前面介绍 PV 和 PVC 的时候,曾经提到过,PV 这个对象的创建,是由运维人员完成的。但是,在大规模的生产环境里,这其实是一个非常麻烦的工作。

这是因为,一个大规模的 Kubernetes 集群里很可能有成千上万个 PVC,这就意味着运维人员必须得事先创建出成千上万个 PV。更麻烦的是,随着新的 PVC 不断被提交,运维人员就不得不继续添加新的、能满足条件的 PV,否则新的 Pod 就会因为 PVC 绑定不到 PV 而失败。在实际操作中,这几乎没办法靠人工做到。

所以,Kubernetes 为我们提供了一套可以自动创建 PV 的机制,即:Dynamic Provisioning。

相比之下,前面人工管理 PV 的方式就叫作 Static Provisioning。

Dynamic Provisioning 机制工作的核心,在于一个名叫 StorageClass 的 API 对象。

而 StorageClass 对象的作用,其实就是创建 PV 的模板。

具体地说,StorageClass 对象会定义如下两个部分内容:

  • 第一,PV 的属性。比如,存储类型、Volume 的大小等等。
  • 第二,创建这种 PV 需要用到的存储插件。比如,Ceph 等等。

有了这样两个信息之后,Kubernetes 就能够根据用户提交的 PVC,找到一个对应的 StorageClass 了。然后,Kubernetes 就会调用该 StorageClass 声明的存储插件,创建出需要的 PV。

举个例子,假如我们的 Volume 的类型是 GCE 的 Persistent Disk 的话,运维人员就需要定义一个如下所示的 StorageClass:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: block-service
provisioner: kubernetes.io/gce-pd
parameters:
  type: pd-ssd

在这个 YAML 文件里,我们定义了一个名叫 block-service 的 StorageClass。

这个 StorageClass 的 provisioner 字段的值是:kubernetes.io/gce-pd,这正是 Kubernetes 内置的 GCE PD 存储插件的名字。

而这个 StorageClass 的 parameters 字段,就是 PV 的参数。比如:上面例子里的 type=pd-ssd,指的是这个 PV 的类型是”SSD 格式的 GCE 远程磁盘”。

需要注意的是,由于需要使用 GCE Persistent Disk,上面这个例子只有在 GCE 提供的 Kubernetes 服务里才能实践。如果你想使用我们之前部署在本地的 Kubernetes 集群以及 Rook 存储服务的话,你的 StorageClass 需要使用如下所示的 YAML 文件来定义:

apiVersion: ceph.rook.io/v1beta1
kind: Pool
metadata:
  name: replicapool
  namespace: rook-ceph
spec:
  replicated:
    size: 3
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: block-service
provisioner: ceph.rook.io/block
parameters:
  pool: replicapool
  #The value of "clusterNamespace" MUST be the same as the one in which your rook cluster exist
  clusterNamespace: rook-ceph

在这个 YAML 文件中,我们定义的还是一个名叫 block-service 的 StorageClass,只不过它声明使的存储插件是由 Rook 项目。

有了 StorageClass 的 YAML 文件之后,运维人员就可以在 Kubernetes 里创建这个 StorageClass 了:

kubectl create -f sc.yaml

这时候,作为应用开发者,我们只需要在 PVC 里指定要使用的 StorageClass 名字即可,如下所示:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: claim1
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: block-service
  resources:
    requests:
      storage: 30Gi

可以看到,我们在这个 PVC 里添加了一个叫作 storageClassName 的字段,用于指定该 PVC 所要使用的 StorageClass 的名字是:block-service。

以 Google Cloud 为例。

当我们通过 kubectl create 创建上述 PVC 对象之后,Kubernetes 就会调用 Google Cloud 的 API,创建出一块 SSD 格式的 Persistent Disk。然后,再使用这个 Persistent Disk 的信息,自动创建出一个对应的 PV 对象。

我们可以一起来实践一下这个过程(如果使用 Rook 的话下面的流程也是一样的,只不过 Rook 创建出的是 Ceph 类型的 PV):

kubectl create -f pvc.yaml

可以看到,我们创建的 PVC 会绑定一个 Kubernetes 自动创建的 PV,如下所示:

$ kubectl describe pvc claim1
Name:           claim1
Namespace:      default
StorageClass:   block-service
Status:         Bound
Volume:         pvc-e5578707-c626-11e6-baf6-08002729a32b
Labels:         <none>
Capacity:       30Gi
Access Modes:   RWO
No Events.

而且,通过查看这个自动创建的 PV 的属性,你就可以看到它跟我们在 PVC 里声明的存储的属性是一致的,如下所示:

$ kubectl describe pv pvc-e5578707-c626-11e6-baf6-08002729a32b
Name:            pvc-e5578707-c626-11e6-baf6-08002729a32b
Labels:          <none>
StorageClass:    block-service
Status:          Bound
Claim:           default/claim1
Reclaim Policy:  Delete
Access Modes:    RWO
Capacity:        30Gi
...
No events.

此外,你还可以看到,这个自动创建出来的 PV 的 StorageClass 字段的值,也是 block-service。 这是因为,Kubernetes 只会将 StorageClass 相同的 PVC 和 PV 绑定起来。

有了 Dynamic Provisioning 机制,运维人员只需要在 Kubernetes 集群里创建出数量有限的 StorageClass 对象就可以了。这就好比,运维人员在 Kubernetes 集群里创建出了各种各样的 PV 模板。这时候,当开发人员提交了包含 StorageClass 字段的 PVC 之后,Kubernetes 就会根据这个 StorageClass 创建出对应的 PV。

Kubernetes 的官方文档 里已经列出了默认支持 Dynamic Provisioning 的内置存储插件。而对于不在文档里的插件,比如 NFS,或者其他非内置存储插件,你其实可以通过 kubernetes-incubator/external-storage 这个库来自己编写一个外部插件完成这个工作。像我们之前部署的 Rook,已经内置了 external-storage 的实现,所以 Rook 是完全支持 Dynamic Provisioning 特性的。

需要注意的是, StorageClass 并不是专门为了 Dynamic Provisioning 而设计的。

比如,在本篇一开始的例子里,我在 PV 和 PVC 里都声明了 storageClassName=manual。而我的集群里,实际上并没有一个名叫 manual 的 StorageClass 对象。这完全没有问题,这个时候 Kubernetes 进行的是 Static Provisioning,但在做绑定决策的时候,它依然会考虑 PV 和 PVC 的 StorageClass 定义。

而这么做的好处也很明显:这个 PVC 和 PV 的绑定关系,就完全在我自己的掌控之中。

这里,你可能会有疑问,我在之前讲解 StatefulSet 存储状态的例子时,好像并没有声明 StorageClass 啊?

实际上,如果你的集群已经开启了名叫 DefaultStorageClass 的 Admission Plugin,它就会为 PVC 和 PV 自动添加一个默认的 StorageClass; 否则,PVC 的 storageClassName 的值就是””,这也意味着它只能够跟 storageClassName 也是””的 PV 进行绑定。

28.1 总结

在今天的分享中,我为你详细解释了 PVC 和 PV 的设计与实现原理,并为你阐述了 StorageClass 到底是干什么用的。这些概念之间的关系,可以用如下所示的一幅示意图描述:

PVC和PV以及StorageClass之间的关系

从图中我们可以看到,在这个体系中:

  • PVC 描述的,是 Pod 想要使用的持久化存储的属性,比如存储的大小、读写权限等。
  • PV 描述的,则是一个具体的 Volume 的属性,比如 Volume 的类型、挂载目录、远程存储服务器地址等。
  • 而 StorageClass 的作用,则是充当 PV 的模板。并且,只有同属于一个 StorageClass 的 PV 和 PVC,才可以绑定在一起。

当然,StorageClass 的另一个重要作用,是指定 PV 的 Provisioner(存储插件)。这时候,如果你的存储插件支持 Dynamic Provisioning 的话,Kubernetes 就可以自动为你创建 PV 了。

基于上述讲述,为了统一概念和方便叙述,在本专栏中,我以后凡是提到”Volume”,指的就是一个远程存储服务挂载在宿主机上的持久化目录;而”PV”,指的是这个 Volume 在 Kubernetes 里的 API 对象。

需要注意的是,这套容器持久化存储体系,完全是 Kubernetes 项目自己负责管理的,并不依赖于 docker volume 命令和 Docker 的存储插件。当然,这套体系本身就比 docker volume 命令的诞生时间还要早得多。

29 | PV、PVC体系是不是多此一举?从本地持久化卷谈起

在上一篇文章中,我为你详细讲解了 PV、PVC 持久化存储体系在 Kubernetes 项目中的设计和实现原理。而在文章最后的思考题中,我为你留下了这样一个讨论话题:像 PV、PVC 这样的用法,是不是有”过度设计”的嫌疑?

比如,我们公司的运维人员可以像往常一样维护一套 NFS 或者 Ceph 服务器,根本不必学习 Kubernetes。而开发人员,则完全可以靠”复制粘贴”的方式,在 Pod 的 YAML 文件里填上 Volumes 字段,而不需要去使用 PV 和 PVC。

实际上,如果只是为了职责划分,PV、PVC 体系确实不见得比直接在 Pod 里声明 Volumes 字段有什么优势。

不过,你有没有想过这样一个问题,如果 Kubernetes 内置的 20 种持久化数据卷实现 ,都没办法满足你的容器存储需求时,该怎么办?

这个情况乍一听起来有点不可思议。但实际上,凡是鼓捣过开源项目的读者应该都有所体会,”不能用””不好用””需要定制开发”,这才是落地开源基础设施项目的三大常态。

而在持久化存储领域,用户呼声最高的定制化需求,莫过于支持”本地”持久化存储了。

也就是说,用户希望 Kubernetes 能够直接使用宿主机上的本地磁盘目录,而不依赖于远程存储服务,来提供”持久化”的容器 Volume。

这样做的好处很明显,由于这个 Volume 直接使用的是本地磁盘,尤其是 SSD 盘,它的读写性能相比于大多数远程存储来说,要好得多。这个需求对本地物理服务器部署的私有 Kubernetes 集群来说,非常常见。

所以,Kubernetes 在 v1.10 之后,就逐渐依靠 PV、PVC 体系实现了这个特性。这个特性的名字叫作:Local Persistent Volume。

不过,首先需要明确的是, Local Persistent Volume 并不适用于所有应用 。事实上,它的适用范围非常固定,比如:高优先级的系统应用,需要在多个不同节点上存储数据,并且对 I/O 较为敏感。典型的应用包括:分布式数据存储比如 MongoDB、Cassandra 等,分布式文件系统比如 GlusterFS、Ceph 等,以及需要在本地磁盘上进行大量数据缓存的分布式应用。

其次,相比于正常的 PV,一旦这些节点宕机且不能恢复时,Local Persistent Volume 的数据就可能丢失。这就要求 使用 Local Persistent Volume 的应用必须具备数据备份和恢复的能力 ,允许你把这些数据定时备份在其他位置。

接下来,我就为你深入讲解一下这个特性。

不难想象,Local Persistent Volume 的设计,主要面临两个难点。

  1. 第一个难点在于:如何把本地磁盘抽象成 PV。

    可能你会说,Local Persistent Volume,不就等同于 hostPath 加 NodeAffinity 吗?

    比如,一个 Pod 可以声明使用类型为 Local 的 PV,而这个 PV 其实就是一个 hostPath 类型的 Volume。如果这个 hostPath 对应的目录,已经在节点 A 上被事先创建好了。那么,我只需要再给这个 Pod 加上一个 nodeAffinity=nodeA,不就可以使用这个 Volume 了吗?

    事实上,你 绝不应该把一个宿主机上的目录当作 PV 使用 。这是因为,这种本地目录的存储行为完全不可控,它所在的磁盘随时都可能被应用写满,甚至造成整个宿主机宕机。而且,不同的本地目录之间也缺乏哪怕最基础的 I/O 隔离机制。

    所以,一个 Local Persistent Volume 对应的存储介质,一定是一块额外挂载在宿主机的磁盘或者块设备(”额外”的意思是,它不应该是宿主机根目录所使用的主硬盘)。这个原则,我们可以称为”一个 PV 一块盘”。

  2. 第二个难点在于:调度器如何保证 Pod 始终能被正确地调度到它所请求的 Local Persistent Volume 所在的节点上呢?

    造成这个问题的原因在于,对于常规的 PV 来说,Kubernetes 都是先调度 Pod 到某个节点上,然后,再通过”两阶段处理”来”持久化”这台机器上的 Volume 目录,进而完成 Volume 目录与容器的绑定挂载。

    可是,对于 Local PV 来说,节点上可供使用的磁盘(或者块设备),必须是运维人员提前准备好的。它们在不同节点上的挂载情况可以完全不同,甚至有的节点可以没这种磁盘。

    所以,这时候,调度器就必须能够知道所有节点与 Local Persistent Volume 对应的磁盘的关联关系,然后根据这个信息来调度 Pod。

    这个原则,我们可以称为” 在调度的时候考虑 Volume 分布 “。在 Kubernetes 的调度器里,有一个叫作 VolumeBindingChecker 的过滤条件专门负责这个事情。在 Kubernetes v1.11 中,这个过滤条件已经默认开启了。

基于上述讲述,在开始使用 Local Persistent Volume 之前,你首先需要在集群里配置好磁盘或者块设备。在公有云上,这个操作等同于给虚拟机额外挂载一个磁盘,比如 GCE 的 Local SSD 类型的磁盘就是一个典型例子。

而在我们部署的私有环境中,你有两种办法来完成这个步骤。

  • 第一种,当然就是给你的宿主机挂载并格式化一个可用的本地磁盘,这也是最常规的操作;
  • 第二种,对于实验环境,你其实可以在宿主机上挂载几个 RAM Disk(内存盘)来模拟本地磁盘。

接下来,我会使用第二种方法,在我们之前部署的 Kubernetes 集群上进行实践。

首先 ,在名叫 node-1 的宿主机上创建一个挂载点,比如 /mnt/disks;然后,用几个 RAM Disk 来模拟本地磁盘,如下所示:

# 在 node-1 上执行
$ mkdir /mnt/disks
$ for vol in vol1 vol2 vol3; do
    mkdir /mnt/disks/$vol
    mount -t tmpfs $vol /mnt/disks/$vol
done

需要注意的是,如果你希望其他节点也能支持 Local Persistent Volume 的话,那就需要为它们也执行上述操作,并且确保这些磁盘的名字(vol1、vol2 等)都不重复。

接下来,我们就可以为这些本地磁盘定义对应的 PV 了 ,如下所示:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: example-pv
spec:
  capacity:
    storage: 5Gi
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: local-storage
  local:
    path: /mnt/disks/vol1
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - node-1

可以看到,这个 PV 的定义里:local 字段,指定了它是一个 Local Persistent Volume;而 path 字段,指定的正是这个 PV 对应的本地磁盘的路径,即:/mnt/disks/vol1。

当然了,这也就意味着如果 Pod 要想使用这个 PV,那它就必须运行在 node-1 上。所以,在这个 PV 的定义里,需要有一个 nodeAffinity 字段指定 node-1 这个节点的名字。这样,调度器在调度 Pod 的时候,就能够知道一个 PV 与节点的对应关系,从而做出正确的选择。 这正是 Kubernetes 实现”在调度的时候就考虑 Volume 分布”的主要方法。

接下来 ,我们就可以使用 kubect create 来创建这个 PV,如下所示:

$ kubectl create -f local-pv.yaml
persistentvolume/example-pv created

$ kubectl get pv
NAME         CAPACITY   ACCESS MODES   RECLAIM POLICY  STATUS      CLAIM             STORAGECLASS    REASON    AGE
example-pv   5Gi        RWO            Delete           Available                     local-storage             16s

可以看到,这个 PV 创建后,进入了 Available(可用)状态。

而正如我在上一篇文章里所建议的那样,使用 PV 和 PVC 的最佳实践,是你要创建一个 StorageClass 来描述这个 PV,如下所示:

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer

这个 StorageClass 的名字,叫作 local-storage。需要注意的是,在它的 provisioner 字段,我们指定的是 no-provisioner。这是因为 Local Persistent Volume 目前尚不支持 Dynamic Provisioning,所以它没办法在用户创建 PVC 的时候,就自动创建出对应的 PV。也就是说,我们前面创建 PV 的操作,是不可以省略的。

与此同时,这个 StorageClass 还定义了一个 volumeBindingMode=WaitForFirstConsumer 的属性。它是 Local Persistent Volume 里一个非常重要的特性,即: 延迟绑定

我们知道,当你提交了 PV 和 PVC 的 YAML 文件之后,Kubernetes 就会根据它们俩的属性,以及它们指定的 StorageClass 来进行绑定。只有绑定成功后,Pod 才能通过声明这个 PVC 来使用对应的 PV。

可是,如果你使用的是 Local Persistent Volume 的话,就会发现,这个流程根本行不通。

比如,现在你有一个 Pod,它声明使用的 PVC 叫作 pvc-1。并且,我们规定,这个 Pod 只能运行在 node-2 上。

而在 Kubernetes 集群中,有两个属性(比如:大小、读写权限)相同的 Local 类型的 PV。

其中,第一个 PV 的名字叫作 pv-1,它对应的磁盘所在的节点是 node-1。而第二个 PV 的名字叫作 pv-2,它对应的磁盘所在的节点是 node-2。

假设现在,Kubernetes 的 Volume 控制循环里,首先检查到了 pvc-1 和 pv-1 的属性是匹配的,于是就将它们俩绑定在一起。

然后,你用 kubectl create 创建了这个 Pod。

这时候,问题就出现了。

调度器看到,这个 Pod 所声明的 pvc-1 已经绑定了 pv-1,而 pv-1 所在的节点是 node-1,根据”调度器必须在调度的时候考虑 Volume 分布”的原则,这个 Pod 自然会被调度到 node-1 上。

可是,我们前面已经规定过,这个 Pod 根本不允许运行在 node-1 上。所以。最后的结果就是,这个 Pod 的调度必然会失败。

这就是为什么,在使用 Local Persistent Volume 的时候,我们必须想办法推迟这个”绑定”操作。

那么,具体推迟到什么时候呢?

答案是:推迟到调度的时候。

所以说,StorageClass 里的 volumeBindingMode=WaitForFirstConsumer 的含义,就是告诉 Kubernetes 里的 Volume 控制循环(”红娘”):虽然你已经发现这个 StorageClass 关联的 PVC 与 PV 可以绑定在一起,但请不要现在就执行绑定操作(即:设置 PVC 的 VolumeName 字段)。

而要等到第一个声明使用该 PVC 的 Pod 出现在调度器之后,调度器再综合考虑所有的调度规则,当然也包括每个 PV 所在的节点位置,来统一决定,这个 Pod 声明的 PVC,到底应该跟哪个 PV 进行绑定。

这样,在上面的例子里,由于这个 Pod 不允许运行在 pv-1 所在的节点 node-1,所以它的 PVC 最后会跟 pv-2 绑定,并且 Pod 也会被调度到 node-2 上。

所以,通过这个延迟绑定机制,原本实时发生的 PVC 和 PV 的绑定过程,就被延迟到了 Pod 第一次调度的时候在调度器中进行,从而保证了这个 绑定结果不会影响 Pod 的正常调度。

当然,在具体实现中,调度器实际上维护了一个与 Volume Controller 类似的控制循环,专门负责为那些声明了”延迟绑定”的 PV 和 PVC 进行绑定工作。

通过这样的设计,这个额外的绑定操作,并不会拖慢调度器的性能。而当一个 Pod 的 PVC 尚未完成绑定时,调度器也不会等待,而是会直接把这个 Pod 重新放回到待调度队列,等到下一个调度周期再做处理。

在明白了这个机制之后,我们就可以创建 StorageClass 了,如下所示:

$ kubectl create -f local-sc.yaml
storageclass.storage.k8s.io/local-storage created

接下来,我们只需要定义一个非常普通的 PVC,就可以让 Pod 使用到上面定义好的 Local Persistent Volume 了 ,如下所示:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: example-local-claim
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: local-storage

可以看到,这个 PVC 没有任何特别的地方。唯一需要注意的是,它声明的 storageClassName 是 local-storage。所以,将来 Kubernetes 的 Volume Controller 看到这个 PVC 的时候,不会为它进行绑定操作。

现在,我们来创建这个 PVC:

$ kubectl create -f local-pvc.yaml
persistentvolumeclaim/example-local-claim created

$ kubectl get pvc
NAME                  STATUS    VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS    AGE
example-local-claim   Pending                                       local-storage   7s

可以看到,尽管这个时候,Kubernetes 里已经存在了一个可以与 PVC 匹配的 PV,但这个 PVC 依然处于 Pending 状态,也就是等待绑定的状态。

然后,我们编写一个 Pod 来声明使用这个 PVC ,如下所示:

kind: Pod
apiVersion: v1
metadata:
  name: example-pv-pod
spec:
  volumes:
    - name: example-pv-storage
      persistentVolumeClaim:
       claimName: example-local-claim
  containers:
    - name: example-pv-container
      image: nginx
      ports:
        - containerPort: 80
          name: "http-server"
      volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: example-pv-storage

这个 Pod 没有任何特别的地方,你只需要注意,它的 volumes 字段声明要使用前面定义的、名叫 example-local-claim 的 PVC 即可。

而我们一旦使用 kubectl create 创建这个 Pod,就会发现,我们前面定义的 PVC,会立刻变成 Bound 状态,与前面定义的 PV 绑定在了一起,如下所示:

$ kubectl create -f local-pod.yaml
pod/example-pv-pod created

$ kubectl get pvc
NAME                  STATUS    VOLUME       CAPACITY   ACCESS MODES   STORAGECLASS    AGE
example-local-claim   Bound     example-pv   5Gi        RWO            local-storage   6h

也就是说,在我们创建的 Pod 进入调度器之后,”绑定”操作才开始进行。

这时候,我们可以尝试在这个 Pod 的 Volume 目录里,创建一个测试文件,比如:

$ kubectl exec -it example-pv-pod -- /bin/sh
# cd /usr/share/nginx/html
# touch test.txt

而如果你重新创建这个 Pod 的话,就会发现,我们之前创建的测试文件,依然被保存在这个持久化 Volume 当中:

$ kubectl delete -f local-pod.yaml

$ kubectl create -f local-pod.yaml

$ kubectl exec -it example-pv-pod -- /bin/sh
# ls /usr/share/nginx/html
# touch test.txt

这就说明,像 Kubernetes 这样构建出来的、基于本地存储的 Volume,完全可以提供容器持久化存储的功能。所以,像 StatefulSet 这样的有状态编排工具,也完全可以通过声明 Local 类型的 PV 和 PVC,来管理应用的存储状态。

需要注意的是,我们上面手动创建 PV 的方式,即 Static 的 PV 管理方式,在删除 PV 时需要按如下流程执行操作:

  1. 删除使用这个 PV 的 Pod;
  2. 从宿主机移除本地磁盘(比如,umount 它);
  3. 删除 PVC;
  4. 删除 PV。

如果不按照这个流程的话,这个 PV 的删除就会失败。

当然,由于上面这些创建 PV 和删除 PV 的操作比较繁琐,Kubernetes 其实提供了一个 Static Provisioner 来帮助你管理这些 PV。

比如,我们现在的所有磁盘,都挂载在宿主机的 /mnt/disks 目录下。

那么,当 Static Provisioner 启动后,它就会通过 DaemonSet,自动检查每个宿主机的 /mnt/disks 目录。然后,调用 Kubernetes API,为这些目录下面的每一个挂载,创建一个对应的 PV 对象出来。这些自动创建的 PV,如下所示:

$ kubectl get pv
NAME                CAPACITY    ACCESSMODES   RECLAIMPOLICY   STATUS      CLAIM     STORAGECLASS    REASON    AGE
local-pv-ce05be60   1024220Ki   RWO           Delete          Available             local-storage             26s

$ kubectl describe pv local-pv-ce05be60
Name:  local-pv-ce05be60
...
StorageClass: local-storage
Status:  Available
Claim:
Reclaim Policy: Delete
Access Modes: RWO
Capacity: 1024220Ki
NodeAffinity:
  Required Terms:
      Term 0:  kubernetes.io/hostname in [node-1]
Message:
Source:
    Type: LocalVolume (a persistent volume backed by local storage on a node)
    Path: /mnt/disks/vol1

这个 PV 里的各种定义,比如 StorageClass 的名字、本地磁盘挂载点的位置,都可以通过 provisioner 的 配置文件指定 。当然,provisioner 也会负责前面提到的 PV 的删除工作。

而这个 provisioner 本身,其实也是一个我们前面提到过的 External Provisioner ,它的部署方法,在 对应的文档 里有详细描述。这部分内容,就留给你课后自行探索了。

30 | 编写自己的存储插件:FlexVolume与CSI

在 Kubernetes 中,存储插件的开发有两种方式:FlexVolume 和 CSI。

接下来,我就先为你剖析一下 Flexvolume 的原理和使用方法。

举个例子,现在我们要编写的是一个使用 NFS 实现的 FlexVolume 插件。

对于一个 FlexVolume 类型的 PV 来说,它的 YAML 文件如下所示:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-flex-nfs
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteMany
  flexVolume:
    driver: "k8s/nfs"
    fsType: "nfs"
    options:
      server: "10.10.0.25" # 改成你自己的 NFS 服务器地址
      share: "export"

可以看到,这个 PV 定义的 Volume 类型是 flexVolume。并且,我们 指定了这个 Volume 的 driver 叫作 k8s/nfs 。这个名字很重要,我后面马上会为你解释它的含义。

而 Volume 的 options 字段,则是一个自定义字段。也就是说,它的类型,其实是 map[string]string。所以,你可以在这一部分自由地加上你想要定义的参数。

在我们这个例子里,options 字段指定了 NFS 服务器的地址(server: “10.10.0.25”),以及 NFS 共享目录的名字(share: “export”)。当然,你这里定义的所有参数,后面都会被 FlexVolume 拿到。

备注:你可以使用 这个 Docker 镜像 轻松地部署一个试验用的 NFS 服务器。

像这样的一个 PV 被创建后,一旦和某个 PVC 绑定起来,这个 FlexVolume 类型的 Volume 就会进入到我们前面讲解过的 Volume 处理流程。

你应该还记得,这个流程的名字叫作”两阶段处理”,即”Attach 阶段”和”Mount 阶段”。它们的主要作用,是在 Pod 所绑定的宿主机上,完成这个 Volume 目录的持久化过程,比如为虚拟机挂载磁盘(Attach),或者挂载一个 NFS 的共享目录(Mount)。

备注:你可以再回顾一下第 28 篇文章 《PV、PVC、StorageClass,这些到底在说啥?》 中的相关内容。

而在具体的控制循环中,这两个操作实际上调用的,正是 Kubernetes 的 pkg/volume 目录下的存储插件(Volume Plugin)。在我们这个例子里,就是 pkg/volume/flexvolume 这个目录里的代码。

当然了,这个目录其实只是 FlexVolume 插件的入口。以”Mount 阶段”为例,在 FlexVolume 目录里,它的处理过程非常简单,如下所示:

// SetUpAt creates new directory.
func (f *flexVolumeMounter) SetUpAt(dir string, fsGroup *int64) error {
  ...
  call := f.plugin.NewDriverCall(mountCmd)

  // Interface parameters
  call.Append(dir)

  extraOptions := make(map[string]string)

  // pod metadata
  extraOptions[optionKeyPodName] = f.podName
  extraOptions[optionKeyPodNamespace] = f.podNamespace

  ...

  call.AppendSpec(f.spec, f.plugin.host, extraOptions)

  _, err = call.Run()

  ...

  return nil
}

上面这个名叫 SetUpAt() 的方法,正是 FlexVolume 插件对”Mount 阶段”的实现位置。而 SetUpAt() 实际上只做了一件事,那就是封装出了一行命令(即:NewDriverCall),由 kubelet 在”Mount 阶段”去执行。

在我们这个例子中, kubelet 要通过插件在宿主机上执行的命令,如下所示:

/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs mount <mount dir> <json param>

其中,/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs 就是插件的可执行文件的路径。这个名叫 nfs 的文件,正是你要编写的插件的实现。它可以是一个二进制文件,也可以是一个脚本。总之,只要能在宿主机上被执行起来即可。

而且这个路径里的 k8s~nfs 部分,正是这个插件在 Kubernetes 里的名字。它是从 driver=”k8s/nfs”字段解析出来的。

这个 driver 字段的格式是:vendor/driver。比如,一家存储插件的提供商(vendor)的名字叫作 k8s,提供的存储驱动(driver)是 nfs,那么 Kubernetes 就会使用 k8s~nfs 来作为插件名。

所以说, 当你编写完了 FlexVolume 的实现之后,一定要把它的可执行文件放在每个节点的插件目录下。

而紧跟在可执行文件后面的”mount”参数,定义的就是当前的操作。在 FlexVolume 里,这些操作参数的名字是固定的,比如 init、mount、unmount、attach,以及 dettach 等等,分别对应不同的 Volume 处理操作。

而跟在 mount 参数后面的两个字段:<mount dir><json params> ,则是 FlexVolume 必须提供给这条命令的两个执行参数。

其中第一个执行参数 <mount dir> ,正是 kubelet 调用 SetUpAt() 方法传递来的 dir 的值。它代表的是当前正在处理的 Volume 在宿主机上的目录。在我们的例子里,这个路径如下所示:

/var/lib/kubelet/pods/<Pod ID>/volumes/k8s~nfs/test

其中,test 正是我们前面定义的 PV 的名字;而 k8s~nfs,则是插件的名字。可以看到,插件的名字正是从你声明的 driver=”k8s/nfs”字段里解析出来的。

而第二个执行参数 <json params> ,则是一个 JSON Map 格式的参数列表。我们在前面 PV 里定义的 options 字段的值,都会被追加在这个参数里。此外,在 SetUpAt() 方法里可以看到,这个参数列表里还包括了 Pod 的名字、Namespace 等元数据(Metadata)。

在明白了存储插件的调用方式和参数列表之后,这个插件的可执行文件的实现部分就非常容易理解了。

在这个例子中,我直接编写了一个简单的 shell 脚本来作为插件的实现,它对”Mount 阶段”的处理过程,如下所示:

domount() {
 MNTPATH=$1

 NFS_SERVER=$(echo $2 | jq -r '.server')
 SHARE=$(echo $2 | jq -r '.share')

 ...

 mkdir -p ${MNTPATH} &> /dev/null

 mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH} &> /dev/null
 if [ $? -ne 0 ]; then
  err "{ \"status\": \"Failure\", \"message\": \"Failed to mount ${NFS_SERVER}:${SHARE} at ${MNTPATH}\"}"
  exit 1
 fi
 log '{"status": "Success"}'
 exit 0
}

可以看到,当 kubelet 在宿主机上执行 nfs mount <mount dir> <json params> 的时候,这个名叫 nfs 的脚本,就可以直接从 <mount dir> 参数里拿到 Volume 在宿主机上的目录,即:

MNTPATH=$<json params>

有了这三个参数之后,这个脚本最关键的一步,当然就是执行:

mount -t nfs {NFS_SERVER}:/${SHARE} ${MNTPATH}

需要注意的是,当这个 mount -t nfs 操作完成后,你必须把一个 JOSN 格式的字符串,比如:{“status”: “Success”},返回给调用者,也就是 kubelet。这是 kubelet 判断这次调用是否成功的唯一依据。

综上所述,在”Mount 阶段”,kubelet 的 VolumeManagerReconcile 控制循环里的一次”调谐”操作的执行流程,如下所示:

kubelet -->
    pkg/volume/flexvolume.SetUpAt() -->
        /usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs mount <mount dir> <json param>

备注:这个 NFS 的 FlexVolume 的完整实现,在 这个 GitHub 库 里。而你如果想用 Go 语言编写 FlexVolume 的话,我也有一个 很好的例子 供你参考。

当然,在前面文章中我也提到过,像 NFS 这样的文件系统存储,并不需要在宿主机上挂载磁盘或者块设备。所以,我们也就不需要实现 attach 和 dettach 操作了。

不过, 像这样的 FlexVolume 实现方式,虽然简单,但局限性却很大。

比如,跟 Kubernetes 内置的 NFS 插件类似,这个 NFS FlexVolume 插件,也不能支持 Dynamic Provisioning(即:为每个 PVC 自动创建 PV 和对应的 Volume)。除非你再为它编写一个专门的 External Provisioner。

再比如,我的插件在执行 mount 操作的时候,可能会生成一些挂载信息。这些信息,在后面执行 unmount 操作的时候会被用到。可是,在上述 FlexVolume 的实现里,你没办法把这些信息保存在一个变量里,等到 unmount 的时候直接使用。

这个原因也很容易理解: FlexVolume 每一次对插件可执行文件的调用,都是一次完全独立的操作。 所以,我们只能把这些信息写在一个宿主机上的临时文件里,等到 unmount 的时候再去读取。

这也是为什么,我们需要有 Container Storage Interface(CSI)这样更完善、更编程友好的插件方式。

接下来,我就来为你讲解一下开发存储插件的第二种方式 CSI。我们先来看一下 CSI 插件体系的设计原理

其实,通过前面对 FlexVolume 的讲述,你应该可以明白,默认情况下,Kubernetes 里通过存储插件管理容器持久化存储的原理,可以用如下所示的示意图来描述:

存储插件管理容器持久化存储的原理

可以看到,在上述体系下,无论是 FlexVolume,还是 Kubernetes 内置的其他存储插件,它们实际上担任的角色,仅仅是 Volume 管理中的”Attach 阶段”和”Mount 阶段”的具体执行者。而像 Dynamic Provisioning 这样的功能,就不是存储插件的责任,而是 Kubernetes 本身存储管理功能的一部分。

相比之下, CSI 插件体系的设计思想,就是把这个 Provision 阶段,以及 Kubernetes 里的一部分存储管理功能,从主干代码里剥离出来,做成了几个单独的组件。 这些组件会通过 Watch API 监听 Kubernetes 里与存储相关的事件变化,比如 PVC 的创建,来执行具体的存储管理动作。

而这些管理动作,比如”Attach 阶段”和”Mount 阶段”的具体操作,实际上就是通过调用 CSI 插件来完成的。

这种设计思路,我可以用如下所示的一幅示意图来表示:

CSI插件体系的设计思想

可以看到,这套存储插件体系多了三个独立的外部组件(External Components),即:Driver Registrar、External Provisioner 和 External Attacher,对应的正是从 Kubernetes 项目里面剥离出来的那部分存储管理功能。

需要注意的是,External Components 虽然是外部组件,但依然由 Kubernetes 社区来开发和维护。

而图中最右侧的部分,就是需要我们编写代码来实现的 CSI 插件。一个 CSI 插件只有一个二进制文件,但它会以 gRPC 的方式对外提供三个服务(gRPC Service),分别叫作:CSI Identity、CSI Controller 和 CSI Node。

我先来为你讲解一下这三个 External Components。

  1. 其中, Driver Registrar 组件,负责将插件注册到 kubelet 里面 (这可以类比为,将可执行文件放在插件目录下)。而在具体实现上,Driver Registrar 需要请求 CSI 插件的 Identity 服务来获取插件信息。

  2. External Provisioner 组件,负责的正是 Provision 阶段 。在具体实现上,External Provisioner 监听(Watch)了 APIServer 里的 PVC 对象。当一个 PVC 被创建时,它就会调用 CSI Controller 的 CreateVolume 方法,为你创建对应 PV。

    此外,如果你使用的存储是公有云提供的磁盘(或者块设备)的话,这一步就需要调用公有云(或者块设备服务)的 API 来创建这个 PV 所描述的磁盘(或者块设备)了。

    不过,由于 CSI 插件是独立于 Kubernetes 之外的,所以在 CSI 的 API 里不会直接使用 Kubernetes 定义的 PV 类型,而是会自己定义一个单独的 Volume 类型。

    为了方便叙述,在本专栏里,我会把 Kubernetes 里的持久化卷类型叫作 PV,把 CSI 里的持久化卷类型叫作 CSI Volume,请你务必区分清楚。

  3. 最后一个 External Attacher 组件,负责的正是”Attach 阶段” 。在具体实现上,它监听了 APIServer 里 VolumeAttachment 对象的变化。VolumeAttachment 对象是 Kubernetes 确认一个 Volume 可以进入”Attach 阶段”的重要标志,我会在下一篇文章里为你详细讲解。

    一旦出现了 VolumeAttachment 对象,External Attacher 就会调用 CSI Controller 服务的 ControllerPublish 方法,完成它所对应的 Volume 的 Attach 阶段。

    而 Volume 的”Mount 阶段”,并不属于 External Components 的职责。当 kubelet 的 VolumeManagerReconciler 控制循环检查到它需要执行 Mount 操作的时候,会通过 pkg/volume/csi 包,直接调用 CSI Node 服务完成 Volume 的”Mount 阶段”。

在实际使用 CSI 插件的时候,我们会将这三个 External Components 作为 sidecar 容器和 CSI 插件放置在同一个 Pod 中。由于 External Components 对 CSI 插件的调用非常频繁,所以这种 sidecar 的部署方式非常高效。

接下来, 我再为你讲解一下 CSI 插件的里三个服务 :CSI Identity、CSI Controller 和 CSI Node。

  1. 其中, CSI 插件的 CSI Identity 服务,负责对外暴露这个插件本身的信息 ,如下所示:

    service Identity {
    // return the version and name of the plugin
    rpc GetPluginInfo(GetPluginInfoRequest)
        returns (GetPluginInfoResponse) {}
    // reports whether the plugin has the ability of serving the Controller interface
    rpc GetPluginCapabilities(GetPluginCapabilitiesRequest)
        returns (GetPluginCapabilitiesResponse) {}
    // called by the CO just to check whether the plugin is running or not
    rpc Probe (ProbeRequest)
        returns (ProbeResponse) {}
    }
  2. CSI Controller 服务,定义的则是对 CSI Volume(对应 Kubernetes 里的 PV)的管理接口 ,比如:创建和删除 CSI Volume、对 CSI Volume 进行 Attach/Dettach(在 CSI 里,这个操作被叫作 Publish/Unpublish),以及对 CSI Volume 进行 Snapshot 等,它们的接口定义如下所示:

    service Controller {
    // provisions a volume
    rpc CreateVolume (CreateVolumeRequest)
        returns (CreateVolumeResponse) {}
    
    // deletes a previously provisioned volume
    rpc DeleteVolume (DeleteVolumeRequest)
        returns (DeleteVolumeResponse) {}
    
    // make a volume available on some required node
    rpc ControllerPublishVolume (ControllerPublishVolumeRequest)
        returns (ControllerPublishVolumeResponse) {}
    
    // make a volume un-available on some required node
    rpc ControllerUnpublishVolume (ControllerUnpublishVolumeRequest)
        returns (ControllerUnpublishVolumeResponse) {}
    
    ...
    
    // make a snapshot
    rpc CreateSnapshot (CreateSnapshotRequest)
        returns (CreateSnapshotResponse) {}
    
    // Delete a given snapshot
    rpc DeleteSnapshot (DeleteSnapshotRequest)
        returns (DeleteSnapshotResponse) {}
    
    ...
    }

    不难发现,CSI Controller 服务里定义的这些操作有个共同特点,那就是它们都无需在宿主机上进行,而是属于 Kubernetes 里 Volume Controller 的逻辑,也就是属于 Master 节点的一部分。

    需要注意的是,正如我在前面提到的那样,CSI Controller 服务的实际调用者,并不是 Kubernetes(即:通过 pkg/volume/csi 发起 CSI 请求),而是 External Provisioner 和 External Attacher。这两个 External Components,分别通过监听 PVC 和 VolumeAttachement 对象,来跟 Kubernetes 进行协作。

  3. 而 CSI Volume 需要在宿主机上执行的操作,都定义在了 CSI Node 服务里面,如下所示:

    service Node {
    // temporarily mount the volume to a staging path
    rpc NodeStageVolume (NodeStageVolumeRequest)
        returns (NodeStageVolumeResponse) {}
    
    // unmount the volume from staging path
    rpc NodeUnstageVolume (NodeUnstageVolumeRequest)
        returns (NodeUnstageVolumeResponse) {}
    
    // mount the volume from staging to target path
    rpc NodePublishVolume (NodePublishVolumeRequest)
        returns (NodePublishVolumeResponse) {}
    
    // unmount the volume from staging path
    rpc NodeUnpublishVolume (NodeUnpublishVolumeRequest)
        returns (NodeUnpublishVolumeResponse) {}
    
    // stats for the volume
    rpc NodeGetVolumeStats (NodeGetVolumeStatsRequest)
        returns (NodeGetVolumeStatsResponse) {}
    
    ...
    
    // Similar to NodeGetId
    rpc NodeGetInfo (NodeGetInfoRequest)
        returns (NodeGetInfoResponse) {}
    }

    需要注意的是,”Mount 阶段”在 CSI Node 里的接口,是由 NodeStageVolume 和 NodePublishVolume 两个接口共同实现的。我会在下一篇文章中,为你详细介绍这个设计的目的和具体的实现方式。

30.1 总结

相比于 FlexVolume,CSI 的设计思想,把插件的职责从”两阶段处理”,扩展成了 Provision、Attach 和 Mount 三个阶段。其中,Provision 等价于”创建磁盘”,Attach 等价于”挂载磁盘到虚拟机”,Mount 等价于”将该磁盘格式化后,挂载在 Volume 的宿主机目录上”。

在有了 CSI 插件之后,Kubernetes 本身依然按照我在第 28 篇文章 《PV、PVC、StorageClass,这些到底在说啥?》 中所讲述的方式工作,唯一区别在于:

  • 当 AttachDetachController 需要进行”Attach”操作时(”Attach 阶段”),它实际上会执行到 pkg/volume/csi 目录中,创建一个 VolumeAttachment 对象,从而触发 External Attacher 调用 CSI Controller 服务的 ControllerPublishVolume 方法。
  • 当 VolumeManagerReconciler 需要进行”Mount”操作时(”Mount 阶段”),它实际上也会执行到 pkg/volume/csi 目录中,直接向 CSI Node 服务发起调用 NodePublishVolume 方法的请求。

以上,就是 CSI 插件最基本的工作原理了。

31 | 容器存储实践:CSI插件编写指南

在上一篇文章中,我已经为你详细讲解了 CSI 插件机制的设计原理。今天我将继续和你一起实践一个 CSI 插件的编写过程。

为了能够覆盖到 CSI 插件的所有功能,我这一次选择了 DigitalOcean 的块存储(Block Storage)服务,来作为实践对象。

DigitalOcean 是业界知名的”最简”公有云服务,即:它只提供虚拟机、存储、网络等为数不多的几个基础功能,其他功能一概不管。而这,恰恰就使得 DigitalOcean 成了我们在公有云上实践 Kubernetes 的最佳选择。

我们这次编写的 CSI 插件的功能,就是:让我们运行在 DigitalOcean 上的 Kubernetes 集群能够使用它的块存储服务,作为容器的持久化存储。

备注:在 DigitalOcean 上部署一个 Kubernetes 集群的过程,也很简单。你只需要先在 DigitalOcean 上创建几个虚拟机,然后按照我们在第 11 篇文章 《从 0 到 1:搭建一个完整的 Kubernetes 集群》 中从 0 到 1 的步骤直接部署即可。

而有了 CSI 插件之后,持久化存储的用法就非常简单了,你只需要创建一个如下所示的 StorageClass 对象即可:

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: do-block-storage
  namespace: kube-system
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: com.digitalocean.csi.dobs

有了这个 StorageClass,External Provisoner 就会为集群中新出现的 PVC 自动创建出 PV,然后调用 CSI 插件创建出这个 PV 对应的 Volume,这正是 CSI 体系中 Dynamic Provisioning 的实现方式。

备注:storageclass.kubernetes.io/is-default-class: “true”的意思,是使用这个 StorageClass 作为默认的持久化存储提供者。

不难看到,这个 StorageClass 里唯一引人注意的,是 provisioner=com.digitalocean.csi.dobs 这个字段。显然,这个字段告诉了 Kubernetes,请使用名叫 com.digitalocean.csi.dobs 的 CSI 插件来为我处理这个 StorageClass 相关的所有操作。

那么,Kubernetes 又是如何知道一个 CSI 插件的名字的呢?

这就需要从 CSI 插件的第一个服务 CSI Identity 说起了。

其实,一个 CSI 插件的代码结构非常简单,如下所示:

tree $GOPATH/src/github.com/digitalocean/csi-digitalocean/driver
$GOPATH/src/github.com/digitalocean/csi-digitalocean/driver
├── controller.go
├── driver.go
├── identity.go
├── mounter.go
└── node.go

其中,CSI Identity 服务的实现,就定义在了 driver 目录下的 identity.go 文件里。

当然,为了能够让 Kubernetes 访问到 CSI Identity 服务,我们需要先在 driver.go 文件里,定义一个标准的 gRPC Server,如下所示:

// Run starts the CSI plugin by communication over the given endpoint
func (d *Driver) Run() error {
 ...

 listener, err := net.Listen(u.Scheme, addr)
 ...

 d.srv = grpc.NewServer(grpc.UnaryInterceptor(errHandler))
 csi.RegisterIdentityServer(d.srv, d)
 csi.RegisterControllerServer(d.srv, d)
 csi.RegisterNodeServer(d.srv, d)

 d.ready = true // we're now ready to go!
 ...
 return d.srv.Serve(listener)
}

可以看到,只要把编写好的 gRPC Server 注册给 CSI,它就可以响应来自 External Components 的 CSI 请求了。

CSI Identity 服务中,最重要的接口是 GetPluginInfo ,它返回的就是这个插件的名字和版本号,如下所示:

备注:CSI 各个服务的接口我在上一篇文章中已经介绍过,你也可以在这里找到 它的 protoc 文件

func (d *Driver) GetPluginInfo(ctx context.Context, req *csi.GetPluginInfoRequest) (*csi.GetPluginInfoResponse, error) {
 resp := &csi.GetPluginInfoResponse{
  Name:          driverName,
  VendorVersion: version,
 }
 ...
}

其中,driverName 的值,正是”com.digitalocean.csi.dobs”。所以说,Kubernetes 正是通过 GetPluginInfo 的返回值,来找到你在 StorageClass 里声明要使用的 CSI 插件的。

备注:CSI 要求插件的名字遵守 “反向 DNS”格式

另外一个 GetPluginCapabilities 接口也很重要 。这个接口返回的是这个 CSI 插件的”能力”。

比如,当你编写的 CSI 插件不准备实现”Provision 阶段”和”Attach 阶段”(比如,一个最简单的 NFS 存储插件就不需要这两个阶段)时,你就可以通过这个接口返回:本插件不提供 CSI Controller 服务,即:没有 csi.PluginCapability_Service_CONTROLLER_SERVICE 这个”能力”。这样,Kubernetes 就知道这个信息了。

最后, CSI Identity 服务还提供了一个 Probe 接口 。Kubernetes 会调用它来检查这个 CSI 插件是否正常工作。

一般情况下,我建议你在编写插件时给它设置一个 Ready 标志,当插件的 gRPC Server 停止的时候,把这个 Ready 标志设置为 false。或者,你可以在这里访问一下插件的端口,类似于健康检查的做法。

备注:关于健康检查的问题,你可以再回顾一下第 15 篇文章 《深入解析 Pod 对象(二):使用进阶》 中的相关内容。

然后, 我们要开始编写 CSI 插件的第二个服务,即 CSI Controller 服务了 。它的代码实现,在 controller.go 文件里。

在上一篇文章中我已经为你讲解过,这个服务主要实现的就是 Volume 管理流程中的”Provision 阶段”和”Attach 阶段”。

“Provision 阶段”对应的接口,是 CreateVolume 和 DeleteVolume ,它们的调用者是 External Provisoner。以 CreateVolume 为例,它的主要逻辑如下所示:

func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
 ...

 volumeReq := &godo.VolumeCreateRequest{
  Region:        d.region,
  Name:          volumeName,
  Description:   createdByDO,
  SizeGigaBytes: size / GB,
 }

 ...

 vol, _, err := d.doClient.Storage.CreateVolume(ctx, volumeReq)

 ...

 resp := &csi.CreateVolumeResponse{
  Volume: &csi.Volume{
   Id:            vol.ID,
   CapacityBytes: size,
   AccessibleTopology: []*csi.Topology{
    {
     Segments: map[string]string{
      "region": d.region,
     },
    },
   },
  },
 }

 return resp, nil
}

可以看到,对于 DigitalOcean 这样的公有云来说,CreateVolume 需要做的操作,就是调用 DigitalOcean 块存储服务的 API,创建出一个存储卷(d.doClient.Storage.CreateVolume)。如果你使用的是其他类型的块存储(比如 Cinder、Ceph RBD 等),对应的操作也是类似地调用创建存储卷的 API。

“Attach 阶段”对应的接口是 ControllerPublishVolume 和 ControllerUnpublishVolume ,它们的调用者是 External Attacher。以 ControllerPublishVolume 为例,它的逻辑如下所示:

func (d *Driver) ControllerPublishVolume(ctx context.Context, req *csi.ControllerPublishVolumeRequest) (*csi.ControllerPublishVolumeResponse, error) {
 ...

  dropletID, err := strconv.Atoi(req.NodeId)

  // check if volume exist before trying to attach it
  _, resp, err := d.doClient.Storage.GetVolume(ctx, req.VolumeId)

 ...

  // check if droplet exist before trying to attach the volume to the droplet
  _, resp, err = d.doClient.Droplets.Get(ctx, dropletID)

 ...

  action, resp, err := d.doClient.StorageActions.Attach(ctx, req.VolumeId, dropletID)

 ...

 if action != nil {
  ll.Info("waiting until volume is attached")
 if err := d.waitAction(ctx, req.VolumeId, action.ID); err != nil {
  return nil, err
  }
  }

  ll.Info("volume is attached")
 return &csi.ControllerPublishVolumeResponse{}, nil
}

可以看到,对于 DigitalOcean 来说,ControllerPublishVolume 在”Attach 阶段”需要做的工作,是调用 DigitalOcean 的 API,将我们前面创建的存储卷,挂载到指定的虚拟机上(d.doClient.StorageActions.Attach)。

其中,存储卷由请求中的 VolumeId 来指定。而虚拟机,也就是将要运行 Pod 的宿主机,则由请求中的 NodeId 来指定。这些参数,都是 External Attacher 在发起请求时需要设置的。

我在上一篇文章中已经为你介绍过,External Attacher 的工作原理,是监听(Watch)了一种名叫 VolumeAttachment 的 API 对象。这种 API 对象的主要字段如下所示:

// VolumeAttachmentSpec is the specification of a VolumeAttachment request.
type VolumeAttachmentSpec struct {
 // Attacher indicates the name of the volume driver that MUST handle this
 // request. This is the name returned by GetPluginName().
 Attacher string

 // Source represents the volume that should be attached.
 Source VolumeAttachmentSource

 // The node that the volume should be attached to.
 NodeName string
}

而这个对象的生命周期,正是由 AttachDetachController 负责管理的(这里,你可以再回顾一下第 28 篇文章 《PV、PVC、StorageClass,这些到底在说啥?》 中的相关内容)。

这个控制循环的职责,是不断检查 Pod 所对应的 PV,在它所绑定的宿主机上的挂载情况,从而决定是否需要对这个 PV 进行 Attach(或者 Dettach)操作。

而这个 Attach 操作,在 CSI 体系里,就是创建出上面这样一个 VolumeAttachment 对象。可以看到,Attach 操作所需的 PV 的名字(Source)、宿主机的名字(NodeName)、存储插件的名字(Attacher),都是这个 VolumeAttachment 对象的一部分。

而当 External Attacher 监听到这样的一个对象出现之后,就可以立即使用 VolumeAttachment 里的这些字段,封装成一个 gRPC 请求调用 CSI Controller 的 ControllerPublishVolume 方法。

最后,我们就可以编写 CSI Node 服务了。

CSI Node 服务对应的,是 Volume 管理流程里的”Mount 阶段”。它的代码实现,在 node.go 文件里。

我在上一篇文章里曾经提到过,kubelet 的 VolumeManagerReconciler 控制循环会直接调用 CSI Node 服务来完成 Volume 的”Mount 阶段”。

不过,在具体的实现中,这个”Mount 阶段”的处理其实被细分成了 NodeStageVolume 和 NodePublishVolume 这两个接口。

这里的原因其实也很容易理解:我在前面第 28 篇文章 《PV、PVC、StorageClass,这些到底在说啥?》 中曾经介绍过,对于磁盘以及块设备来说,它们被 Attach 到宿主机上之后,就成为了宿主机上的一个待用存储设备。而到了”Mount 阶段”,我们首先需要格式化这个设备,然后才能把它挂载到 Volume 对应的宿主机目录上。

在 kubelet 的 VolumeManagerReconciler 控制循环中,这两步操作分别叫作 MountDevice 和 SetUp

其中,MountDevice 操作,就是直接调用了 CSI Node 服务里的 NodeStageVolume 接口。顾名思义,这个接口的作用,就是格式化 Volume 在宿主机上对应的存储设备,然后挂载到一个临时目录(Staging 目录)上。

对于 DigitalOcean 来说,它对 NodeStageVolume 接口的实现如下所示:

func (d *Driver) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRequest) (*csi.NodeStageVolumeResponse, error) {
 ...

 vol, resp, err := d.doClient.Storage.GetVolume(ctx, req.VolumeId)

 ...

 source := getDiskSource(vol.Name)
 target := req.StagingTargetPath

 ...

 if !formatted {
  ll.Info("formatting the volume for staging")
  if err := d.mounter.Format(source, fsType); err != nil {
   return nil, status.Error(codes.Internal, err.Error())
  }
 } else {
  ll.Info("source device is already formatted")
 }

...

 if !mounted {
  if err := d.mounter.Mount(source, target, fsType, options...); err != nil {
   return nil, status.Error(codes.Internal, err.Error())
  }
 } else {
  ll.Info("source device is already mounted to the target path")
 }

 ...
 return &csi.NodeStageVolumeResponse{}, nil
}

可以看到,在 NodeStageVolume 的实现里,我们首先通过 DigitalOcean 的 API 获取到了这个 Volume 对应的设备路径(getDiskSource);然后,我们把这个设备格式化成指定的格式( d.mounter.Format);最后,我们把格式化后的设备挂载到了一个临时的 Staging 目录(StagingTargetPath)下。

而 SetUp 操作则会调用 CSI Node 服务的 NodePublishVolume 接口。有了上述对设备的预处理工作后,它的实现就非常简单了,如下所示:

func (d *Driver) NodePublishVolume(ctx context.Context, req *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
 ...
 source := req.StagingTargetPath
 target := req.TargetPath

 mnt := req.VolumeCapability.GetMount()
 options := mnt.MountFlag
    ...

 if !mounted {
  ll.Info("mounting the volume")
  if err := d.mounter.Mount(source, target, fsType, options...); err != nil {
   return nil, status.Error(codes.Internal, err.Error())
  }
 } else {
  ll.Info("volume is already mounted")
 }

 return &csi.NodePublishVolumeResponse{}, nil
}

可以看到,在这一步实现中,我们只需要做一步操作,即:将 Staging 目录,绑定挂载到 Volume 对应的宿主机目录上。

由于 Staging 目录,正是 Volume 对应的设备被格式化后挂载在宿主机上的位置,所以当它和 Volume 的宿主机目录绑定挂载之后,这个 Volume 宿主机目录的”持久化”处理也就完成了。

当然,我在前面也曾经提到过,对于文件系统类型的存储服务来说,比如 NFS 和 GlusterFS 等,它们并没有一个对应的磁盘”设备”存在于宿主机上,所以 kubelet 在 VolumeManagerReconciler 控制循环中,会跳过 MountDevice 操作而直接执行 SetUp 操作。所以对于它们来说,也就不需要实现 NodeStageVolume 接口了。

在编写完了 CSI 插件之后,我们就可以把这个插件和 External Components 一起部署起来。

首先,我们需要创建一个 DigitalOcean client 授权需要使用的 Secret 对象,如下所示:

apiVersion: v1
kind: Secret
metadata:
  name: digitalocean
  namespace: kube-system
stringData:
  access-token: "a05dd2f26b9b9ac2asdas__REPLACE_ME____123cb5d1ec17513e06da"

接下来,我们通过一句指令就可以将 CSI 插件部署起来:

kubectl apply -f https://raw.githubusercontent.com/digitalocean/csi-digitalocean/master/deploy/kubernetes/releases/csi-digitalocean-v0.2.0.yaml

这个 CSI 插件的 YAML 文件的主要内容如下所示(其中,非重要的内容已经被略去):

kind: DaemonSet
apiVersion: apps/v1beta2
metadata:
  name: csi-do-node
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: csi-do-node
  template:
    metadata:
      labels:
        app: csi-do-node
        role: csi-do
    spec:
      serviceAccount: csi-do-node-sa
      hostNetwork: true
      containers:
        - name: driver-registrar
          image: quay.io/k8scsi/driver-registrar:v0.3.0
          ...
        - name: csi-do-plugin
          image: digitalocean/do-csi-plugin:v0.2.0
          args :
            - "--endpoint=$(CSI_ENDPOINT)"
            - "--token=$(DIGITALOCEAN_ACCESS_TOKEN)"
            - "--url=$(DIGITALOCEAN_API_URL)"
          env:
            - name: CSI_ENDPOINT
              value: unix:///csi/csi.sock
            - name: DIGITALOCEAN_API_URL
              value: https://api.digitalocean.com/
            - name: DIGITALOCEAN_ACCESS_TOKEN
              valueFrom:
                secretKeyRef:
                  name: digitalocean
                  key: access-token
          imagePullPolicy: "Always"
          securityContext:
            privileged: true
            capabilities:
              add: ["SYS_ADMIN"]
            allowPrivilegeEscalation: true
          volumeMounts:
            - name: plugin-dir
              mountPath: /csi
            - name: pods-mount-dir
              mountPath: /var/lib/kubelet
              mountPropagation: "Bidirectional"
            - name: device-dir
              mountPath: /dev
      volumes:
        - name: plugin-dir
          hostPath:
            path: /var/lib/kubelet/plugins/com.digitalocean.csi.dobs
            type: DirectoryOrCreate
        - name: pods-mount-dir
          hostPath:
            path: /var/lib/kubelet
            type: Directory
        - name: device-dir
          hostPath:
            path: /dev
---
kind: StatefulSet
apiVersion: apps/v1beta1
metadata:
  name: csi-do-controller
  namespace: kube-system
spec:
  serviceName: "csi-do"
  replicas: 1
  template:
    metadata:
      labels:
        app: csi-do-controller
        role: csi-do
    spec:
      serviceAccount: csi-do-controller-sa
      containers:
        - name: csi-provisioner
          image: quay.io/k8scsi/csi-provisioner:v0.3.0
          ...
        - name: csi-attacher
          image: quay.io/k8scsi/csi-attacher:v0.3.0
          ...
        - name: csi-do-plugin
          image: digitalocean/do-csi-plugin:v0.2.0
          args :
            - "--endpoint=$(CSI_ENDPOINT)"
            - "--token=$(DIGITALOCEAN_ACCESS_TOKEN)"
            - "--url=$(DIGITALOCEAN_API_URL)"
          env:
            - name: CSI_ENDPOINT
              value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock
            - name: DIGITALOCEAN_API_URL
              value: https://api.digitalocean.com/
            - name: DIGITALOCEAN_ACCESS_TOKEN
              valueFrom:
                secretKeyRef:
                  name: digitalocean
                  key: access-token
          imagePullPolicy: "Always"
          volumeMounts:
            - name: socket-dir
              mountPath: /var/lib/csi/sockets/pluginproxy/
      volumes:
        - name: socket-dir
          emptyDir: {}

可以看到,我们编写的 CSI 插件只有一个二进制文件,它的镜像是 digitalocean/do-csi-plugin:v0.2.0。

而我们 部署 CSI 插件的常用原则是:

第一,通过 DaemonSet 在每个节点上都启动一个 CSI 插件,来为 kubelet 提供 CSI Node 服务 。这是因为,CSI Node 服务需要被 kubelet 直接调用,所以它要和 kubelet”一对一”地部署起来。

此外,在上述 DaemonSet 的定义里面,除了 CSI 插件,我们还以 sidecar 的方式运行着 driver-registrar 这个外部组件。它的作用,是向 kubelet 注册这个 CSI 插件。这个注册过程使用的插件信息,则通过访问同一个 Pod 里的 CSI 插件容器的 Identity 服务获取到。

需要注意的是,由于 CSI 插件运行在一个容器里,那么 CSI Node 服务在”Mount 阶段”执行的挂载操作,实际上是发生在这个容器的 Mount Namespace 里的。可是,我们真正希望执行挂载操作的对象,都是宿主机 /var/lib/kubelet 目录下的文件和目录。

所以,在定义 DaemonSet Pod 的时候,我们需要把宿主机的 /var/lib/kubelet 以 Volume 的方式挂载进 CSI 插件容器的同名目录下,然后设置这个 Volume 的 mountPropagation=Bidirectional,即开启双向挂载传播,从而将容器在这个目录下进行的挂载操作”传播”给宿主机,反之亦然。

第二,通过 StatefulSet 在任意一个节点上再启动一个 CSI 插件,为 External Components 提供 CSI Controller 服务 。所以,作为 CSI Controller 服务的调用者,External Provisioner 和 External Attacher 这两个外部组件,就需要以 sidecar 的方式和这次部署的 CSI 插件定义在同一个 Pod 里。

你可能会好奇,为什么我们会用 StatefulSet 而不是 Deployment 来运行这个 CSI 插件呢。

这是因为,由于 StatefulSet 需要确保应用拓扑状态的稳定性,所以它对 Pod 的更新,是严格保证顺序的,即:只有在前一个 Pod 停止并删除之后,它才会创建并启动下一个 Pod。

而像我们上面这样将 StatefulSet 的 replicas 设置为 1 的话,StatefulSet 就会确保 Pod 被删除重建的时候,永远有且只有一个 CSI 插件的 Pod 运行在集群中。这对 CSI 插件的正确性来说,至关重要。

而在今天这篇文章一开始,我们就已经定义了这个 CSI 插件对应的 StorageClass(即:do-block-storage),所以你接下来只需要定义一个声明使用这个 StorageClass 的 PVC 即可,如下所示:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: csi-pvc
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: do-block-storage

当你把上述 PVC 提交给 Kubernetes 之后,你就可以在 Pod 里声明使用这个 csi-pvc 来作为持久化存储了。这一部分使用 PV 和 PVC 的内容,我就不再赘述了。

31.1 总结

在今天这篇文章中,我以一个 DigitalOcean 的 CSI 插件为例,和你分享了编写 CSI 插件的具体流程。

基于这些讲述,你现在应该已经对 Kubernetes 持久化存储体系有了一个更加全面和深入的认识。

举个例子,对于一个部署了 CSI 存储插件的 Kubernetes 集群来说:

当用户创建了一个 PVC 之后,你前面部署的 StatefulSet 里的 External Provisioner 容器,就会监听到这个 PVC 的诞生,然后调用同一个 Pod 里的 CSI 插件的 CSI Controller 服务的 CreateVolume 方法,为你创建出对应的 PV。

这时候,运行在 Kubernetes Master 节点上的 Volume Controller,就会通过 PersistentVolumeController 控制循环,发现这对新创建出来的 PV 和 PVC,并且看到它们声明的是同一个 StorageClass。所以,它会把这一对 PV 和 PVC 绑定起来,使 PVC 进入 Bound 状态。

然后,用户创建了一个声明使用上述 PVC 的 Pod,并且这个 Pod 被调度器调度到了宿主机 A 上。这时候,Volume Controller 的 AttachDetachController 控制循环就会发现,上述 PVC 对应的 Volume,需要被 Attach 到宿主机 A 上。所以,AttachDetachController 会创建一个 VolumeAttachment 对象,这个对象携带了宿主机 A 和待处理的 Volume 的名字。

这样,StatefulSet 里的 External Attacher 容器,就会监听到这个 VolumeAttachment 对象的诞生。于是,它就会使用这个对象里的宿主机和 Volume 名字,调用同一个 Pod 里的 CSI 插件的 CSI Controller 服务的 ControllerPublishVolume 方法,完成”Attach 阶段”。

上述过程完成后,运行在宿主机 A 上的 kubelet,就会通过 VolumeManagerReconciler 控制循环,发现当前宿主机上有一个 Volume 对应的存储设备(比如磁盘)已经被 Attach 到了某个设备目录下。于是 kubelet 就会调用同一台宿主机上的 CSI 插件的 CSI Node 服务的 NodeStageVolume 和 NodePublishVolume 方法,完成这个 Volume 的”Mount 阶段”。

至此,一个完整的持久化 Volume 的创建和挂载流程就结束了。

Kubernetes容器网络

32 | 浅谈容器网络

在前面讲解容器基础时,我曾经提到过一个 Linux 容器能看见的”网络栈”,实际上是被隔离在它自己的 Network Namespace 当中的。

而所谓”网络栈”,就包括了:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和 iptables 规则。对于一个进程来说,这些要素,其实就构成了它发起和响应网络请求的基本环境。

需要指出的是,作为一个容器,它可以声明直接使用宿主机的网络栈(-net=host),即:不开启 Network Namespace,比如:

docker run -d -net=host --name nginx-host nginx

在这种情况下,这个容器启动后,直接监听的就是宿主机的 80 端口。

像这样直接使用宿主机网络栈的方式,虽然可以为容器提供良好的网络性能,但也会不可避免地引入共享网络资源的问题,比如端口冲突。所以, 在大多数情况下,我们都希望容器进程能使用自己 Network Namespace 里的网络栈,即:拥有属于自己的 IP 地址和端口。

这时候,一个显而易见的问题就是: 这个被隔离的容器进程,该如何跟其他 Network Namespace 里的容器进程进行交互呢?

为了理解这个问题,你其实可以把每一个容器看做一台主机,它们都有一套独立的”网络栈”。

如果你想要实现两台主机之间的通信,最直接的办法,就是把它们用一根网线连接起来;而如果你想要实现多台主机之间的通信,那就需要用网线,把它们连接在一台交换机上。

在 Linux 中,能够起到虚拟交换机作用的网络设备,是网桥(Bridge)。它是一个工作在数据链路层(Data Link)的设备,主要功能是根据 MAC 地址学习来将数据包转发到网桥的不同端口(Port)上。

当然,至于为什么这些主机之间需要 MAC 地址才能进行通信,这就是网络分层模型的基础知识了。不熟悉这块内容的读者,可以通过 这篇文章 来学习一下。

而为了实现上述目的,Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡是连接在 docker0 网桥上的容器,就可以通过它来进行通信。

可是,我们又该如何把这些容器”连接”到 docker0 网桥上呢?

这时候,我们就需要使用一种名叫 Veth Pair 的虚拟设备了。

Veth Pair 设备的特点是:它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出现的。并且,从其中一个”网卡”发出的数据包,可以直接出现在与它对应的另一张”网卡”上,哪怕这两个”网卡”在不同的 Network Namespace 里。

这就使得 Veth Pair 常常被用作连接不同 Network Namespace 的”网线”。

比如,现在我们启动了一个叫作 nginx-1 的容器:

docker run -d --name nginx-1 nginx

然后进入到这个容器中查看一下它的网络设备:

# 在宿主机上
$ docker exec -it nginx-1 /bin/bash
# 在容器里
root@2b3c181aecf1:/# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.2  netmask 255.255.0.0  broadcast 0.0.0.0
        inet6 fe80::42:acff:fe11:2  prefixlen 64  scopeid 0x20<link>
        ether 02:42:ac:11:00:02  txqueuelen 0  (Ethernet)
        RX packets 364  bytes 8137175 (7.7 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 281  bytes 21161 (20.6 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

$ route
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
default         172.17.0.1      0.0.0.0         UG    0      0        0 eth0
172.17.0.0      0.0.0.0         255.255.0.0     U     0      0        0 eth0

可以看到,这个容器里有一张叫作 eth0 的网卡,它正是一个 Veth Pair 设备在容器里的这一端。

通过 route 命令查看 nginx-1 容器的路由表,我们可以看到,这个 eth0 网卡是这个容器里的默认路由设备;所有对 172.17.0.0/16 网段的请求,也会被交给 eth0 来处理(第二条 172.17.0.0 路由规则)。

而这个 Veth Pair 设备的另一端,则在宿主机上。你可以通过查看宿主机的网络设备看到它,如下所示:

# 在宿主机上
$ ifconfig
...
docker0   Link encap:Ethernet  HWaddr 02:42:d8:e4:df:c1
          inet addr:172.17.0.1  Bcast:0.0.0.0  Mask:255.255.0.0
          inet6 addr: fe80::42:d8ff:fee4:dfc1/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:309 errors:0 dropped:0 overruns:0 frame:0
          TX packets:372 errors:0 dropped:0 overruns:0 carrier:0
 collisions:0 txqueuelen:0
          RX bytes:18944 (18.9 KB)  TX bytes:8137789 (8.1 MB)
veth9c02e56 Link encap:Ethernet  HWaddr 52:81:0b:24:3d:da
          inet6 addr: fe80::5081:bff:fe24:3dda/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:288 errors:0 dropped:0 overruns:0 frame:0
          TX packets:371 errors:0 dropped:0 overruns:0 carrier:0
 collisions:0 txqueuelen:0
          RX bytes:21608 (21.6 KB)  TX bytes:8137719 (8.1 MB)

$ brctl show
bridge name bridge id  STP enabled interfaces
docker0  8000.0242d8e4dfc1 no  veth9c02e56

通过 ifconfig 命令的输出,你可以看到,nginx-1 容器对应的 Veth Pair 设备,在宿主机上是一张虚拟网卡。它的名字叫作 veth9c02e56。并且,通过 brctl show 的输出,你可以看到这张网卡被”插”在了 docker0 上。

这时候,如果我们再在这台宿主机上启动另一个 Docker 容器,比如 nginx-2:

$ docker run -d --name nginx-2 nginx
$ brctl show
bridge name     bridge id               STP enabled     interfaces
docker0         8000.0242d8e4dfc1       no              veth9c02e56
                                                        vethb4963f3

你就会发现一个新的、名叫 vethb4963f3 的虚拟网卡,也被”插”在了 docker0 网桥上。

这时候,如果你在 nginx-1 容器里 ping 一下 nginx-2 容器的 IP 地址(172.17.0.3),就会发现同一宿主机上的两个容器默认就是相互连通的。

这其中的原理其实非常简单,我来解释一下。

当你在 nginx-1 容器里访问 nginx-2 容器的 IP 地址(比如 ping 172.17.0.3)的时候,这个目的 IP 地址会匹配到 nginx-1 容器里的第二条路由规则。可以看到,这条路由规则的网关(Gateway)是 0.0.0.0,这就意味着这是一条直连规则,即:凡是匹配到这条规则的 IP 包,应该经过本机的 eth0 网卡,通过二层网络直接发往目的主机。

而要通过二层网络到达 nginx-2 容器,就需要有 172.17.0.3 这个 IP 地址对应的 MAC 地址。所以 nginx-1 容器的网络协议栈,就需要通过 eth0 网卡发送一个 ARP 广播,来通过 IP 地址查找对应的 MAC 地址。

备注:ARP(Address Resolution Protocol),是通过三层的 IP 地址找到对应的二层 MAC 地址的协议。

我们前面提到过,这个 eth0 网卡,是一个 Veth Pair,它的一端在这个 nginx-1 容器的 Network Namespace 里,而另一端则位于宿主机上(Host Namespace),并且被”插”在了宿主机的 docker0 网桥上。

一旦一张虚拟网卡被”插”在网桥上,它就会变成该网桥的”从设备”。从设备会被”剥夺”调用网络协议栈处理数据包的资格,从而”降级”成为网桥上的一个端口。而这个端口唯一的作用,就是接收流入的数据包,然后把这些数据包的”生杀大权”(比如转发或者丢弃),全部交给对应的网桥。

所以,在收到这些 ARP 请求之后,docker0 网桥就会扮演二层交换机的角色,把 ARP 广播转发到其他被”插”在 docker0 上的虚拟网卡上。这样,同样连接在 docker0 上的 nginx-2 容器的网络协议栈就会收到这个 ARP 请求,从而将 172.17.0.3 所对应的 MAC 地址回复给 nginx-1 容器。

有了这个目的 MAC 地址,nginx-1 容器的 eth0 网卡就可以将数据包发出去。

而根据 Veth Pair 设备的原理,这个数据包会立刻出现在宿主机上的 veth9c02e56 虚拟网卡上。不过,此时这个 veth9c02e56 网卡的网络协议栈的资格已经被”剥夺”,所以这个数据包就直接流入到了 docker0 网桥里。

docker0 处理转发的过程,则继续扮演二层交换机的角色。此时,docker0 网桥根据数据包的目的 MAC 地址(也就是 nginx-2 容器的 MAC 地址),在它的 CAM 表(即交换机通过 MAC 地址学习维护的端口和 MAC 地址的对应表)里查到对应的端口(Port)为:vethb4963f3,然后把数据包发往这个端口。

而这个端口,正是 nginx-2 容器”插”在 docker0 网桥上的另一块虚拟网卡,当然,它也是一个 Veth Pair 设备。这样,数据包就进入到了 nginx-2 容器的 Network Namespace 里。

所以,nginx-2 容器看到的情况是,它自己的 eth0 网卡上出现了流入的数据包。这样,nginx-2 的网络协议栈就会对请求进行处理,最后将响应(Pong)返回到 nginx-1。

以上,就是同一个宿主机上的不同容器通过 docker0 网桥进行通信的流程了。我把这个流程总结成了一幅示意图,如下所示:

同一个宿主机上的不同容器通过docker0网桥进行通信的流程

需要注意的是,在实际的数据传递时,上述数据的传递过程在网络协议栈的不同层次,都有 Linux 内核 Netfilter 参与其中。所以,如果感兴趣的话,你可以通过打开 iptables 的 TRACE 功能查看到数据包的传输过程,具体方法如下所示:

# 在宿主机上执行
$ iptables -t raw -A OUTPUT -p icmp -j TRACE
$ iptables -t raw -A PREROUTING -p icmp -j TRACE

通过上述设置,你就可以在 /var/log/syslog 里看到数据包传输的日志了。这一部分内容,你可以在课后结合 iptables 的相关知识 进行实践,从而验证我和你分享的数据包传递流程。

实际测试发现需要使用命令 xtables-monitor --trace 命令才能看到。来源: https://serverfault.com/questions/1109845/i-cannot-get-iptables-to-show-trace-logs .

熟悉了 docker0 网桥的工作方式,你就可以理解, 在默认情况下,被限制在 Network Namespace 里的容器进程,实际上是通过 Veth Pair 设备 + 宿主机网桥的方式,实现了跟同其他容器的数据交换。

与之类似地,当你在一台宿主机上,访问该宿主机上的容器的 IP 地址时,这个请求的数据包,也是先根据路由规则到达 docker0 网桥,然后被转发到对应的 Veth Pair 设备,最后出现在容器里。这个过程的示意图,如下所示:

宿主机访问该宿主机上的容器

同样地,当一个容器试图连接到另外一个宿主机时,比如:ping 10.168.0.3,它发出的请求数据包,首先经过 docker0 网桥出现在宿主机上。然后根据宿主机的路由表里的直连路由规则(10.168.0.0/24 via eth0)),对 10.168.0.3 的访问请求就会交给宿主机的 eth0 处理。

所以接下来,这个数据包就会经宿主机的 eth0 网卡转发到宿主机网络上,最终到达 10.168.0.3 对应的宿主机上。当然,这个过程的实现要求这两台宿主机本身是连通的。这个过程的示意图,如下所示:

容器连接另外一个宿主机

所以说,当你遇到容器连不通”外网”的时候,你都应该先试试 docker0 网桥能不能 ping 通,然后查看一下跟 docker0 和 Veth Pair 设备相关的 iptables 规则是不是有异常,往往就能够找到问题的答案了。

不过,在最后一个”Docker 容器连接其他宿主机”的例子里,你可能已经联想到了这样一个问题:如果在另外一台宿主机(比如:10.168.0.3)上,也有一个 Docker 容器。那么,我们的 nginx-1 容器又该如何访问它呢?

这个问题,其实就是容器的”跨主通信”问题。

在 Docker 的默认配置下,一台宿主机上的 docker0 网桥,和其他宿主机上的 docker0 网桥,没有任何关联,它们互相之间也没办法连通。所以,连接在这些网桥上的容器,自然也没办法进行通信了。

不过,万变不离其宗。

如果我们通过软件的方式,创建一个整个集群”公用”的网桥,然后把集群里的所有容器都连接到这个网桥上,不就可以相互通信了吗?

说得没错。

这样一来,我们整个集群里的容器网络就会类似于下图所示的样子:

OverlayNetwork原理

可以看到,构建这种容器网络的核心在于:我们需要在已有的宿主机网络上,再通过软件构建一个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以,这种技术就被称为:Overlay Network(覆盖网络)。

而这个 Overlay Network 本身,可以由每台宿主机上的一个”特殊网桥”共同组成。比如,当 Node 1 上的 Container 1 要访问 Node 2 上的 Container 3 的时候,Node 1 上的”特殊网桥”在收到数据包之后,能够通过某种方式,把数据包发送到正确的宿主机,比如 Node 2 上。而 Node 2 上的”特殊网桥”在收到数据包后,也能够通过某种方式,把数据包转发给正确的容器,比如 Container 3。

甚至,每台宿主机上,都不需要有一个这种特殊的网桥,而仅仅通过某种方式配置宿主机的路由表,就能够把数据包转发到正确的宿主机上。这些内容,我在后面的文章中会为你一一讲述。

33 | 深入解析容器跨主机网络

要理解容器”跨主通信”的原理,就一定要先从 Flannel 这个项目说起。

Flannel 项目是 CoreOS 公司主推的容器网络方案。事实上,Flannel 项目本身只是一个框架,真正为我们提供容器网络功能的,是 Flannel 的后端实现。目前,Flannel 支持三种后端实现,分别是:

  1. VXLAN;
  2. host-gw;
  3. UDP。

这三种不同的后端实现,正代表了三种容器跨主网络的主流实现方法。其中,host-gw 模式,我会在下一篇文章中再做详细介绍。

而 UDP 模式,是 Flannel 项目最早支持的一种方式,却也是性能最差的一种方式。所以,这个模式目前已经被弃用。不过,Flannel 之所以最先选择 UDP 模式,就是因为这种模式是最直接、也是最容易理解的容器跨主网络实现。

所以,在今天这篇文章中, 我会先从 UDP 模式开始,来为你讲解容器”跨主网络”的实现原理。

在这个例子中,我有两台宿主机。

  • 宿主机 Node 1 上有一个容器 container-1,它的 IP 地址是 100.96.1.2,对应的 docker0 网桥的地址是:100.96.1.1/24。
  • 宿主机 Node 2 上有一个容器 container-2,它的 IP 地址是 100.96.2.3,对应的 docker0 网桥的地址是:100.96.2.1/24。

我们现在的任务,就是让 container-1 访问 container-2。

这种情况下,container-1 容器里的进程发起的 IP 包,其源地址就是 100.96.1.2,目的地址就是 100.96.2.3。由于目的地址 100.96.2.3 并不在 Node 1 的 docker0 网桥的网段里,所以这个 IP 包会被交给默认路由规则,通过容器的网关进入 docker0 网桥(如果是同一台宿主机上的容器间通信,走的是直连规则),从而出现在宿主机上。

这时候,这个 IP 包的下一个目的地,就取决于宿主机上的路由规则了。此时,Flannel 已经在宿主机上创建出了一系列的路由规则,以 Node 1 为例,如下所示:

# 在 Node 1 上
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0  proto kernel  scope link  src 100.96.1.0
100.96.1.0/24 dev docker0  proto kernel  scope link  src 100.96.1.1
10.168.0.0/24 dev eth0  proto kernel  scope link  src 10.168.0.2

可以看到,由于我们的 IP 包的目的地址是 100.96.2.3,它匹配不到本机 docker0 网桥对应的 100.96.1.0/24 网段,只能匹配到第二条、也就是 100.96.0.0/16 对应的这条路由规则,从而进入到一个叫作 flannel0 的设备中。

而这个 flannel0 设备的类型就比较有意思了:它是一个 TUN 设备(Tunnel 设备)。

在 Linux 中,TUN 设备是一种工作在三层(Network Layer)的虚拟网络设备。TUN 设备的功能非常简单,即: 在操作系统内核和用户应用程序之间传递 IP 包。

以 flannel0 设备为例:

像上面提到的情况,当操作系统将一个 IP 包发送给 flannel0 设备之后,flannel0 就会把这个 IP 包,交给创建这个设备的应用程序,也就是 Flannel 进程。这是一个从内核态(Linux 操作系统)向用户态(Flannel 进程)的流动方向。

反之,如果 Flannel 进程向 flannel0 设备发送了一个 IP 包,那么这个 IP 包就会出现在宿主机网络栈中,然后根据宿主机的路由表进行下一步处理。这是一个从用户态向内核态的流动方向。

所以,当 IP 包从容器经过 docker0 出现在宿主机,然后又根据路由表进入 flannel0 设备后,宿主机上的 flanneld 进程(Flannel 项目在每个宿主机上的主进程),就会收到这个 IP 包。然后,flanneld 看到了这个 IP 包的目的地址,是 100.96.2.3,就把它发送给了 Node 2 宿主机。

等一下, flanneld 又是如何知道这个 IP 地址对应的容器,是运行在 Node 2 上的呢?

这里,就用到了 Flannel 项目里一个非常重要的概念:子网(Subnet)。

事实上,在由 Flannel 管理的容器网络里,一台宿主机上的所有容器,都属于该宿主机被分配的一个”子网”。在我们的例子中,Node 1 的子网是 100.96.1.0/24,container-1 的 IP 地址是 100.96.1.2。Node 2 的子网是 100.96.2.0/24,container-2 的 IP 地址是 100.96.2.3。

而这些子网与宿主机的对应关系,正是保存在 Etcd 当中,如下所示:

$ etcdctl ls /coreos.com/network/subnets
/coreos.com/network/subnets/100.96.1.0-24
/coreos.com/network/subnets/100.96.2.0-24
/coreos.com/network/subnets/100.96.3.0-24

所以,flanneld 进程在处理由 flannel0 传入的 IP 包时,就可以根据目的 IP 的地址(比如 100.96.2.3),匹配到对应的子网(比如 100.96.2.0/24),从 Etcd 中找到这个子网对应的宿主机的 IP 地址是 10.168.0.3,如下所示:

$ etcdctl get /coreos.com/network/subnets/100.96.2.0-24
{"PublicIP":"10.168.0.3"}

而对于 flanneld 来说,只要 Node 1 和 Node 2 是互通的,那么 flanneld 作为 Node 1 上的一个普通进程,就一定可以通过上述 IP 地址(10.168.0.3)访问到 Node 2,这没有任何问题。

所以说,flanneld 在收到 container-1 发给 container-2 的 IP 包之后,就会把这个 IP 包直接封装在一个 UDP 包里,然后发送给 Node 2。不难理解,这个 UDP 包的源地址,就是 flanneld 所在的 Node 1 的地址,而目的地址,则是 container-2 所在的宿主机 Node 2 的地址。

当然,这个请求得以完成的原因是,每台宿主机上的 flanneld,都监听着一个 8285 端口,所以 flanneld 只要把 UDP 包发往 Node 2 的 8285 端口即可。

通过这样一个普通的、宿主机之间的 UDP 通信,一个 UDP 包就从 Node 1 到达了 Node 2。而 Node 2 上监听 8285 端口的进程也是 flanneld,所以这时候,flanneld 就可以从这个 UDP 包里解析出封装在里面的、container-1 发来的原 IP 包。

而接下来 flanneld 的工作就非常简单了:flanneld 会直接把这个 IP 包发送给它所管理的 TUN 设备,即 flannel0 设备。

根据我前面讲解的 TUN 设备的原理,这正是一个从用户态向内核态的流动方向(Flannel 进程向 TUN 设备发送数据包),所以 Linux 内核网络栈就会负责处理这个 IP 包,具体的处理方法,就是通过本机的路由表来寻找这个 IP 包的下一步流向。

而 Node 2 上的路由表,跟 Node 1 非常类似,如下所示:

# 在 Node 2 上
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0  proto kernel  scope link  src 100.96.2.0
100.96.2.0/24 dev docker0  proto kernel  scope link  src 100.96.2.1
10.168.0.0/24 dev eth0  proto kernel  scope link  src 10.168.0.3

由于这个 IP 包的目的地址是 100.96.2.3,它跟第三条、也就是 100.96.2.0/24 网段对应的路由规则匹配更加精确。所以,Linux 内核就会按照这条路由规则,把这个 IP 包转发给 docker0 网桥。

接下来的流程,就如同我在上一篇文章 《浅谈容器网络》 中和你分享的那样,docker0 网桥会扮演二层交换机的角色,将数据包发送给正确的端口,进而通过 Veth Pair 设备进入到 container-2 的 Network Namespace 里。

而 container-2 返回给 container-1 的数据包,则会经过与上述过程完全相反的路径回到 container-1 中。

需要注意的是,上述流程要正确工作还有一个重要的前提,那就是 docker0 网桥的地址范围必须是 Flannel 为宿主机分配的子网。这个很容易实现,以 Node 1 为例,你只需要给它上面的 Docker Daemon 启动时配置如下所示的 bip 参数即可:

FLANNEL_SUBNET=100.96.1.1/24
dockerd --bip=$FLANNEL_SUBNET ...

以上,就是基于 Flannel UDP 模式的跨主通信的基本原理了。我把它总结成了一幅原理图,如下所示。

基于FlannelUDP模式的跨主通信的基本原理

可以看到,Flannel UDP 模式提供的其实是一个三层的 Overlay 网络,即:它首先对发出端的 IP 包进行 UDP 封装,然后在接收端进行解封装拿到原始的 IP 包,进而把这个 IP 包转发给目标容器。这就好比,Flannel 在不同宿主机上的两个容器之间打通了一条”隧道”,使得这两个容器可以直接使用 IP 地址进行通信,而无需关心容器和宿主机的分布情况。

我前面曾经提到,上述 UDP 模式有严重的性能问题,所以已经被废弃了。通过我上面的讲述,你有没有发现性能问题出现在了哪里呢?

实际上,相比于两台宿主机之间的直接通信,基于 Flannel UDP 模式的容器通信多了一个额外的步骤,即 flanneld 的处理过程。而这个过程,由于使用到了 flannel0 这个 TUN 设备,仅在发出 IP 包的过程中,就需要经过三次用户态与内核态之间的数据拷贝,如下所示:

FlannelUDP模式中的三次数据拷贝

我们可以看到:

  • 第一次:用户态的容器进程发出的 IP 包经过 docker0 网桥进入内核态;
  • 第二次:IP 包根据路由表进入 TUN(flannel0)设备,从而回到用户态的 flanneld 进程;
  • 第三次:flanneld 进行 UDP 封包之后重新进入内核态,将 UDP 包通过宿主机的 eth0 发出去。

此外,我们还可以看到,Flannel 进行 UDP 封装(Encapsulation)和解封装(Decapsulation)的过程,也都是在用户态完成的。在 Linux 操作系统中,上述这些上下文切换和用户态操作的代价其实是比较高的,这也正是造成 Flannel UDP 模式性能不好的主要原因。

所以说, 我们在进行系统级编程的时候,有一个非常重要的优化原则,就是要减少用户态到内核态的切换次数,并且把核心的处理逻辑都放在内核态进行。 这也是为什么,Flannel 后来支持的 VXLAN 模式,逐渐成为了主流的容器网络方案 的原因。

VXLAN,即 Virtual Extensible LAN(虚拟可扩展局域网),是 Linux 内核本身就支持的一种网络虚似化技术。所以说,VXLAN 可以完全在内核态实现上述封装和解封装的工作,从而通过与前面相似的”隧道”机制,构建出覆盖网络(Overlay Network)。

VXLAN 的覆盖网络的设计思想是:在现有的三层网络之上,”覆盖”一层虚拟的、由内核 VXLAN 模块负责维护的二层网络,使得连接在这个 VXLAN 二层网络上的”主机”(虚拟机或者容器都可以)之间,可以像在同一个局域网(LAN)里那样自由通信。当然,实际上,这些”主机”可能分布在不同的宿主机上,甚至是分布在不同的物理机房里。

而为了能够在二层网络上打通”隧道”,VXLAN 会在宿主机上设置一个特殊的网络设备作为”隧道”的两端。这个设备就叫作 VTEP,即:VXLAN Tunnel End Point(虚拟隧道端点)。

而 VTEP 设备的作用,其实跟前面的 flanneld 进程非常相似。只不过,它进行封装和解封装的对象,是二层数据帧(Ethernet frame);而且这个工作的执行流程,全部是在内核里完成的(因为 VXLAN 本身就是 Linux 内核中的一个模块)。

上述基于 VTEP 设备进行”隧道”通信的流程,我也为你总结成了一幅图,如下所示:

基于FlannelVXLAN模式的跨主通信的基本原理

可以看到,图中每台宿主机上名叫 flannel.1 的设备,就是 VXLAN 所需的 VTEP 设备,它既有 IP 地址,也有 MAC 地址。

现在,我们的 container-1 的 IP 地址是 10.1.15.2,要访问的 container-2 的 IP 地址是 10.1.16.3。

那么,与前面 UDP 模式的流程类似,当 container-1 发出请求之后,这个目的地址是 10.1.16.3 的 IP 包,会先出现在 docker0 网桥,然后被路由到本机 flannel.1 设备进行处理。也就是说,来到了”隧道”的入口。为了方便叙述,我接下来会把这个 IP 包称为”原始 IP 包”。

为了能够将”原始 IP 包”封装并且发送到正确的宿主机,VXLAN 就需要找到这条”隧道”的出口,即:目的宿主机的 VTEP 设备。

而这个设备的信息,正是每台宿主机上的 flanneld 进程负责维护的。

比如,当 Node 2 启动并加入 Flannel 网络之后,在 Node 1(以及所有其他节点)上,flanneld 就会添加一条如下所示的路由规则:

$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
...
10.1.16.0       10.1.16.0       255.255.255.0   UG    0      0        0 flannel.1

这条规则的意思是:凡是发往 10.1.16.0/24 网段的 IP 包,都需要经过 flannel.1 设备发出,并且,它最后被发往的网关地址是:10.1.16.0。

从图 3 的 Flannel VXLAN 模式的流程图中我们可以看到,10.1.16.0 正是 Node 2 上的 VTEP 设备(也就是 flannel.1 设备)的 IP 地址。

为了方便叙述,接下来我会把 Node 1 和 Node 2 上的 flannel.1 设备分别称为”源 VTEP 设备”和”目的 VTEP 设备”。

而这些 VTEP 设备之间,就需要想办法组成一个虚拟的二层网络,即:通过二层数据帧进行通信。

所以在我们的例子中,”源 VTEP 设备”收到”原始 IP 包”后,就要想办法把”原始 IP 包”加上一个目的 MAC 地址,封装成一个二层数据帧,然后发送给”目的 VTEP 设备”(当然,这么做还是因为这个 IP 包的目的地址不是本机)。

这里需要解决的问题就是: “目的 VTEP 设备”的 MAC 地址是什么?

此时,根据前面的路由记录,我们已经知道了”目的 VTEP 设备”的 IP 地址。而要根据三层 IP 地址查询对应的二层 MAC 地址,这正是 ARP(Address Resolution Protocol )表的功能。

而这里要用到的 ARP 记录,也是 flanneld 进程在 Node 2 节点启动时,自动添加在 Node 1 上的。我们可以通过 ip 命令看到它,如下所示:

# 在 Node 1 上
$ ip neigh show dev flannel.1
10.1.16.0 lladdr 5e:f8:4f:00:e3:37 PERMANENT

这条记录的意思非常明确,即:IP 地址 10.1.16.0,对应的 MAC 地址是 5e:f8:4f:00:e3:37。

可以看到,最新版本的 Flannel 并不依赖 L3 MISS 事件和 ARP 学习,而会在每台节点启动时把它的 VTEP 设备对应的 ARP 记录,直接下放到其他每台宿主机上。

有了这个”目的 VTEP 设备”的 MAC 地址,Linux 内核就可以开始二层封包工作了。这个二层帧的格式,如下所示:

FlannelVXLAN模式的内部帧

可以看到,Linux 内核会把”目的 VTEP 设备”的 MAC 地址,填写在图中的 Inner Ethernet Header 字段,得到一个二层数据帧。

需要注意的是,上述封包过程只是加一个二层头,不会改变”原始 IP 包”的内容。所以图中的 Inner IP Header 字段,依然是 container-2 的 IP 地址,即 10.1.16.3。

但是,上面提到的这些 VTEP 设备的 MAC 地址,对于宿主机网络来说并没有什么实际意义。所以上面封装出来的这个数据帧,并不能在我们的宿主机二层网络里传输。为了方便叙述,我们把它称为”内部数据帧”(Inner Ethernet Frame)。

所以接下来,Linux 内核还需要再把”内部数据帧”进一步封装成为宿主机网络里的一个普通的数据帧,好让它”载着””内部数据帧”,通过宿主机的 eth0 网卡进行传输。

我们把这次要封装出来的、宿主机对应的数据帧称为”外部数据帧”(Outer Ethernet Frame)。

为了实现这个”搭便车”的机制,Linux 内核会在”内部数据帧”前面,加上一个特殊的 VXLAN 头,用来表示这个”乘客”实际上是一个 VXLAN 要使用的数据帧。

而这个 VXLAN 头里有一个重要的标志叫作VNI,它是 VTEP 设备识别某个数据帧是不是应该归自己处理的重要标识。而在 Flannel 中,VNI 的默认值是 1,这也是为何,宿主机上的 VTEP 设备都叫作 flannel.1 的原因,这里的”1”,其实就是 VNI 的值。

然后,Linux 内核会把这个数据帧封装进一个 UDP 包里发出去。

所以,跟 UDP 模式类似,在宿主机看来,它会以为自己的 flannel.1 设备只是在向另外一台宿主机的 flannel.1 设备,发起了一次普通的 UDP 链接。它哪里会知道,这个 UDP 包里面,其实是一个完整的二层数据帧。这是不是跟特洛伊木马的故事非常像呢?

不过,不要忘了,一个 flannel.1 设备只知道另一端的 flannel.1 设备的 MAC 地址,却不知道对应的宿主机地址是什么。

也就是说,这个 UDP 包该发给哪台宿主机呢?

在这种场景下,flannel.1 设备实际上要扮演一个”网桥”的角色,在二层网络进行 UDP 包的转发。而在 Linux 内核里面,”网桥”设备进行转发的依据,来自于一个叫作 FDB(Forwarding Database)的转发数据库。

不难想到,这个 flannel.1”网桥”对应的 FDB 信息,也是 flanneld 进程负责维护的。它的内容可以通过 bridge fdb 命令查看到,如下所示:

# 在 Node 1 上,使用"目的 VTEP 设备"的 MAC 地址进行查询
$ bridge fdb show flannel.1 | grep 5e:f8:4f:00:e3:37
5e:f8:4f:00:e3:37 dev flannel.1 dst 10.168.0.3 self permanent

可以看到,在上面这条 FDB 记录里,指定了这样一条规则,即:

发往我们前面提到的”目的 VTEP 设备”(MAC 地址是 5e:f8:4f:00:e3:37)的二层数据帧,应该通过 flannel.1 设备,发往 IP 地址为 10.168.0.3 的主机。显然,这台主机正是 Node 2,UDP 包要发往的目的地就找到了。

所以 接下来的流程,就是一个正常的、宿主机网络上的封包工作。

我们知道,UDP 包是一个四层数据包,所以 Linux 内核会在它前面加上一个 IP 头,即原理图中的 Outer IP Header,组成一个 IP 包。并且,在这个 IP 头里,会填上前面通过 FDB 查询出来的目的主机的 IP 地址,即 Node 2 的 IP 地址 10.168.0.3。

然后,Linux 内核再在这个 IP 包前面加上二层数据帧头,即原理图中的 Outer Ethernet Header,并把 Node 2 的 MAC 地址填进去。这个 MAC 地址本身,是 Node 1 的 ARP 表要学习的内容,无需 Flannel 维护。这时候,我们封装出来的”外部数据帧”的格式,如下所示:

FlannelVXLAN模式的外部帧

这样,封包工作就宣告完成了。

接下来,Node 1 上的 flannel.1 设备就可以把这个数据帧从 Node 1 的 eth0 网卡发出去。显然,这个帧会经过宿主机网络来到 Node 2 的 eth0 网卡。

这时候,Node 2 的内核网络栈会发现这个数据帧里有 VXLAN Header,并且 VNI=1。所以 Linux 内核会对它进行拆包,拿到里面的内部数据帧,然后根据 VNI 的值,把它交给 Node 2 上的 flannel.1 设备。

而 flannel.1 设备则会进一步拆包,取出”原始 IP 包”。接下来就回到了我在上一篇文章中分享的单机容器网络的处理流程。最终,IP 包就进入到了 container-2 容器的 Network Namespace 里。

以上,就是 Flannel VXLAN 模式的具体工作原理了。

33.1 总结

在本篇文章中,我为你详细讲解了 Flannel UDP 和 VXLAN 模式的工作原理。这两种模式其实都可以称作”隧道”机制,也是很多其他容器网络插件的基础。比如 Weave 的两种模式,以及 Docker 的 Overlay 模式。

此外,从上面的讲解中我们可以看到,VXLAN 模式组建的覆盖网络,其实就是一个由不同宿主机上的 VTEP 设备,也就是 flannel.1 设备组成的虚拟二层网络。对于 VTEP 设备来说,它发出的”内部数据帧”就仿佛是一直在这个虚拟的二层网络上流动。这,也正是覆盖网络的含义。

备注:如果你想要在我们前面部署的集群中实践 Flannel 的话,可以在 Master 节点上执行如下命令来替换网络插件。

  1. 第一步,执行 rm -rf /etc/cni/net.d/*
  2. 第二步,执行 kubectl delete -f "https://cloud.weave.works/k8s/net?k8s-version=1.11"
  3. 第三步,在 /etc/kubernetes/manifests/kube-controller-manager.yaml 里,为容器启动命令添加如下两个参数:

    --allocate-node-cidrs=true
    --cluster-cidr=10.244.0.0/16
  4. 第四步, 重启所有 kubelet;

  5. 第五步, 执行 kubectl create -f https://raw.githubusercontent.com/coreos/flannel/bc79dd1505b0c8681ece4de4c0d86c5cd2643275/Documentation/kube-flannel.yml

34 | Kubernetes网络模型与CNI网络插件

在上一篇文章中,我以 Flannel 项目为例,为你详细讲解了容器跨主机网络的两种实现方法:UDP 和 VXLAN。

不难看到,这些例子有一个共性,那就是用户的容器都连接在 docker0 网桥上。而网络插件则在宿主机上创建了一个特殊的设备(UDP 模式创建的是 TUN 设备,VXLAN 模式创建的则是 VTEP 设备),docker0 与这个设备之间,通过 IP 转发(路由表)进行协作。

然后,网络插件真正要做的事情,则是通过某种方法,把不同宿主机上的特殊设备连通,从而达到容器跨主机通信的目的。

实际上,上面这个流程,也正是 Kubernetes 对容器网络的主要处理方法。只不过,Kubernetes 是通过一个叫作 CNI 的接口,维护了一个单独的网桥来代替 docker0。这个网桥的名字就叫作:CNI 网桥,它在宿主机上的设备名称默认是:cni0。

以 Flannel 的 VXLAN 模式为例,在 Kubernetes 环境里,它的工作方式跟我们在上一篇文章中讲解的没有任何不同。只不过,docker0 网桥被替换成了 CNI 网桥而已,如下所示:

Kubernetes中基于FlannelVXLAN模式的跨主通信的基本原理

在这里,Kubernetes 为 Flannel 分配的子网范围是 10.244.0.0/16。这个参数可以在部署的时候指定,比如:

kubeadm init --pod-network-cidr=10.244.0.0/16

也可以在部署完成后,通过修改 kube-controller-manager 的配置文件来指定。

这时候,假设 Infra-container-1 要访问 Infra-container-2(也就是 Pod-1 要访问 Pod-2),这个 IP 包的源地址就是 10.244.0.2,目的 IP 地址是 10.244.1.3。而此时,Infra-container-1 里的 eth0 设备,同样是以 Veth Pair 的方式连接在 Node 1 的 cni0 网桥上。所以这个 IP 包就会经过 cni0 网桥出现在宿主机上。

此时,Node 1 上的路由表,如下所示:

# 在 Node 1 上
$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
...
10.244.0.0      0.0.0.0         255.255.255.0   U     0      0        0 cni0
10.244.1.0      10.244.1.0      255.255.255.0   UG    0      0        0 flannel.1
172.17.0.0      0.0.0.0         255.255.0.0     U     0      0        0 docker0

因为我们的 IP 包的目的 IP 地址是 10.244.1.3,所以它只能匹配到第二条规则,也就是 10.244.1.0 对应的这条路由规则。

可以看到,这条规则指定了本机的 flannel.1 设备进行处理。并且,flannel.1 在处理完后,要将 IP 包转发到的网关(Gateway),正是”隧道”另一端的 VTEP 设备,也就是 Node 2 的 flannel.1 设备。所以,接下来的流程,就跟上一篇文章中介绍过的 Flannel VXLAN 模式完全一样了。

需要注意的是,CNI 网桥只是接管所有 CNI 插件负责的、即 Kubernetes 创建的容器(Pod)。而此时,如果你用 docker run 单独启动一个容器,那么 Docker 项目还是会把这个容器连接到 docker0 网桥上。所以这个容器的 IP 地址,一定是属于 docker0 网桥的 172.17.0.0/16 网段。

Kubernetes 之所以要设置这样一个与 docker0 网桥功能几乎一样的 CNI 网桥,主要原因包括两个方面:

  • 一方面,Kubernetes 项目并没有使用 Docker 的网络模型(CNM),所以它并不希望、也不具备配置 docker0 网桥的能力;
  • 另一方面,这还与 Kubernetes 如何配置 Pod,也就是 Infra 容器的 Network Namespace 密切相关。

我们知道,Kubernetes 创建一个 Pod 的第一步,就是创建并启动一个 Infra 容器,用来”hold”住这个 Pod 的 Network Namespace(这里,你可以再回顾一下专栏第 13 篇文章 《为什么我们需要 Pod?》 中的相关内容)。

所以,CNI 的设计思想,就是: Kubernetes 在启动 Infra 容器之后,就可以直接调用 CNI 网络插件,为这个 Infra 容器的 Network Namespace,配置符合预期的网络栈。

备注:在前面第 32 篇文章 《浅谈容器网络》 中,我讲解单机容器网络时,已经和你分享过,一个 Network Namespace 的网络栈包括:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和 iptables 规则。

那么,这个网络栈的配置工作又是如何完成的呢?

为了回答这个问题,我们就需要从 CNI 插件的部署和实现方式谈起了。

我们在部署 Kubernetes 的时候,有一个步骤是安装 kubernetes-cni 包,它的目的就是在宿主机上安装 CNI 插件所需的基础可执行文件。

在安装完成后,你可以在宿主机的 /opt/cni/bin 目录下看到它们,如下所示:

$ ls -al /opt/cni/bin/
total 73088
-rwxr-xr-x 1 root root  3890407 Aug 17  2017 bridge
-rwxr-xr-x 1 root root  9921982 Aug 17  2017 dhcp
-rwxr-xr-x 1 root root  2814104 Aug 17  2017 flannel
-rwxr-xr-x 1 root root  2991965 Aug 17  2017 host-local
-rwxr-xr-x 1 root root  3475802 Aug 17  2017 ipvlan
-rwxr-xr-x 1 root root  3026388 Aug 17  2017 loopback
-rwxr-xr-x 1 root root  3520724 Aug 17  2017 macvlan
-rwxr-xr-x 1 root root  3470464 Aug 17  2017 portmap
-rwxr-xr-x 1 root root  3877986 Aug 17  2017 ptp
-rwxr-xr-x 1 root root  2605279 Aug 17  2017 sample
-rwxr-xr-x 1 root root  2808402 Aug 17  2017 tuning
-rwxr-xr-x 1 root root  3475750 Aug 17  2017 vlan

这些 CNI 的基础可执行文件,按照功能可以分为三类:

  1. 第一类,叫作 Main 插件,它是用来创建具体网络设备的二进制文件。 比如,bridge(网桥设备)、ipvlan、loopback(lo 设备)、macvlan、ptp(Veth Pair 设备),以及 vlan。

    我在前面提到过的 Flannel、Weave 等项目,都属于”网桥”类型的 CNI 插件。所以在具体的实现中,它们往往会调用 bridge 这个二进制文件。这个流程,我马上就会详细介绍到。

  2. 第二类,叫作 IPAM(IP Address Management)插件,它是负责分配 IP 地址的二进制文件。 比如,dhcp,这个文件会向 DHCP 服务器发起请求;host-local,则会使用预先配置的 IP 地址段来进行分配。

  3. 第三类,是由 CNI 社区维护的内置 CNI 插件。 比如:flannel,就是专门为 Flannel 项目提供的 CNI 插件;tuning,是一个通过 sysctl 调整网络设备参数的二进制文件;portmap,是一个通过 iptables 配置端口映射的二进制文件;bandwidth,是一个使用 Token Bucket Filter (TBF) 来进行限流的二进制文件。

从这些二进制文件中,我们可以看到,如果要实现一个给 Kubernetes 用的容器网络方案,其实需要做两部分工作,以 Flannel 项目为例:

首先,实现这个网络方案本身。 这一部分需要编写的,其实就是 flanneld 进程里的主要逻辑。比如,创建和配置 flannel.1 设备、配置宿主机路由、配置 ARP 和 FDB 表里的信息等等。

然后,实现该网络方案对应的 CNI 插件。 这一部分主要需要做的,就是配置 Infra 容器里面的网络栈,并把它连接在 CNI 网桥上。

由于 Flannel 项目对应的 CNI 插件已经被内置了,所以它无需再单独安装。而对于 Weave、Calico 等其他项目来说,我们就必须在安装插件的时候,把对应的 CNI 插件的可执行文件放在 /opt/cni/bin/ 目录下。

实际上,对于 Weave、Calico 这样的网络方案来说,它们的 DaemonSet 只需要挂载宿主机的 /opt/cni/bin/,就可以实现插件可执行文件的安装了。你可以想一下具体应该怎么做,就当当作一个课后小问题留给你去实践了。

接下来,你就需要在宿主机上安装 flanneld(网络方案本身)。而在这个过程中,flanneld 启动后会在每台宿主机上生成它对应的 CNI 配置文件 (它其实是一个 ConfigMap),从而告诉 Kubernetes,这个集群要使用 Flannel 作为容器网络方案。

这个 CNI 配置文件的内容如下所示:

$ cat /etc/cni/net.d/10-flannel.conflist
{
  "name": "cbr0",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    }
  ]
}

需要注意的是,在 Kubernetes 中,处理容器网络相关的逻辑并不会在 kubelet 主干代码里执行,而是会在具体的 CRI(Container Runtime Interface,容器运行时接口)实现里完成。 对于 Docker 项目来说,它的 CRI 实现叫作 dockershim ,你可以在 kubelet 的代码里找到它。

所以,接下来 dockershim 会加载上述的 CNI 配置文件。

需要注意,Kubernetes 目前不支持多个 CNI 插件混用。如果你在 CNI 配置目录(/etc/cni/net.d)里放置了多个 CNI 配置文件的话,dockershim 只会加载按字母顺序排序的第一个插件。

但另一方面,CNI 允许你在一个 CNI 配置文件里,通过 plugins 字段,定义多个插件进行协作。

比如,在我们上面这个例子里,Flannel 项目就指定了 flannel 和 portmap 这两个插件。

这时候,dockershim 会把这个 CNI 配置文件加载起来,并且把列表里的第一个插件、也就是 flannel 插件,设置为默认插件。 而在后面的执行过程中,flannel 和 portmap 插件会按照定义顺序被调用,从而依次完成”配置容器网络”和”配置端口映射”这两步操作。

接下来,我就来为你讲解一下这样一个 CNI 插件的工作原理。

当 kubelet 组件需要创建 Pod 的时候,它第一个创建的一定是 Infra 容器。所以在这一步,dockershim 就会先调用 Docker API 创建并启动 Infra 容器,紧接着执行一个叫作 SetUpPod 的方法。这个方法的作用就是:为 CNI 插件准备参数,然后调用 CNI 插件为 Infra 容器配置网络。

这里要调用的 CNI 插件,就是 /opt/cni/bin/flannel;而调用它所需要的参数,分为两部分。

第一部分,是由 dockershim 设置的一组 CNI 环境变量。

其中,最重要的环境变量参数叫作:CNI_COMMAND。它的取值只有两种:ADD 和 DEL。

这个 ADD 和 DEL 操作,就是 CNI 插件唯一需要实现的两个方法。

其中 ADD 操作的含义是:把容器添加到 CNI 网络里;DEL 操作的含义则是:把容器从 CNI 网络里移除掉。

而对于网桥类型的 CNI 插件来说,这两个操作意味着把容器以 Veth Pair 的方式”插”到 CNI 网桥上,或者从网桥上”拔”掉。

接下来,我以 ADD 操作为重点进行讲解。

CNI 的 ADD 操作需要的参数包括:容器里网卡的名字 eth0(CNI_IFNAME)、Pod 的 Network Namespace 文件的路径(CNI_NETNS)、容器的 ID(CNI_CONTAINERID)等。这些参数都属于上述环境变量里的内容。其中,Pod(Infra 容器)的 Network Namespace 文件的路径,我在前面讲解容器基础的时候提到过,即:/proc/< 容器进程的 PID>/ns/net

备注:这里你也可以再回顾下专栏第 8 篇文章 《白话容器基础(四):重新认识 Docker 容器》 中的相关内容。

除此之外,在 CNI 环境变量里,还有一个叫作 CNI_ARGS 的参数。通过这个参数,CRI 实现(比如 dockershim)就可以以 Key-Value 的格式,传递自定义信息给网络插件。这是用户将来自定义 CNI 协议的一个重要方法。

第二部分,则是 dockershim 从 CNI 配置文件里加载到的、默认插件的配置信息。

这个配置信息在 CNI 中被叫作 Network Configuration,它的完整定义你可以参考 这个文档 。dockershim 会把 Network Configuration 以 JSON 数据的格式,通过标准输入(stdin)的方式传递给 Flannel CNI 插件。

而有了这两部分参数,Flannel CNI 插件实现 ADD 操作的过程就非常简单了。

不过,需要注意的是,Flannel 的 CNI 配置文件( /etc/cni/net.d/10-flannel.conflist)里有这么一个字段,叫作 delegate:

...
     "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }

Delegate 字段的意思是,这个 CNI 插件并不会自己做事儿,而是会调用 Delegate 指定的某种 CNI 内置插件来完成。对于 Flannel 来说,它调用的 Delegate 插件,就是前面介绍到的 CNI bridge 插件。

所以说,dockershim 对 Flannel CNI 插件的调用,其实就是走了个过场。Flannel CNI 插件唯一需要做的,就是对 dockershim 传来的 Network Configuration 进行补充。比如,将 Delegate 的 Type 字段设置为 bridge,将 Delegate 的 IPAM 字段设置为 host-local 等。

经过 Flannel CNI 插件补充后的、完整的 Delegate 字段如下所示:

{
    "hairpinMode":true,
    "ipMasq":false,
    "ipam":{
        "routes":[
            {
                "dst":"10.244.0.0/16"
            }
        ],
        "subnet":"10.244.1.0/24",
        "type":"host-local"
    },
    "isDefaultGateway":true,
    "isGateway":true,
    "mtu":1410,
    "name":"cbr0",
    "type":"bridge"
}

其中,ipam 字段里的信息,比如 10.244.1.0/24,读取自 Flannel 在宿主机上生成的 Flannel 配置文件,即:宿主机上的 /run/flannel/subnet.env 文件。

接下来,Flannel CNI 插件就会调用 CNI bridge 插件,也就是执行:/opt/cni/bin/bridge 二进制文件。

这一次,调用 CNI bridge 插件需要的两部分参数的第一部分、也就是 CNI 环境变量,并没有变化。所以,它里面的 CNI_COMMAND 参数的值还是”ADD”。

而第二部分 Network Configration,正是上面补充好的 Delegate 字段。Flannel CNI 插件会把 Delegate 字段的内容以标准输入(stdin)的方式传递给 CNI bridge 插件。

此外,Flannel CNI 插件还会把 Delegate 字段以 JSON 文件的方式,保存在 /var/lib/cni/flannel 目录下。这是为了给后面删除容器调用 DEL 操作时使用的。

有了这两部分参数,接下来 CNI bridge 插件就可以”代表”Flannel,进行”将容器加入到 CNI 网络里”这一步操作了。而这一部分内容,与容器 Network Namespace 密切相关,所以我要为你详细讲解一下。

首先,CNI bridge 插件会在宿主机上检查 CNI 网桥是否存在。如果没有的话,那就创建它。这相当于在宿主机上执行:

# 在宿主机上
$ ip link add cni0 type bridge
$ ip link set cni0 up

接下来,CNI bridge 插件会通过 Infra 容器的 Network Namespace 文件,进入到这个 Network Namespace 里面,然后创建一对 Veth Pair 设备。

紧接着,它会把这个 Veth Pair 的其中一端,”移动”到宿主机上。这相当于在容器里执行如下所示的命令:

# 在容器里

# 创建一对 Veth Pair 设备。其中一个叫作 eth0,另一个叫作 vethb4963f3
$ ip link add eth0 type veth peer name vethb4963f3

# 启动 eth0 设备
$ ip link set eth0 up

# 将 Veth Pair 设备的另一端(也就是 vethb4963f3 设备)放到宿主机(也就是 Host Namespace)里
$ ip link set vethb4963f3 netns $HOST_NS

# 通过 Host Namespace,启动宿主机上的 vethb4963f3 设备
$ ip netns exec $HOST_NS ip link set vethb4963f3 up

这样,vethb4963f3 就出现在了宿主机上,而且这个 Veth Pair 设备的另一端,就是容器里面的 eth0。

当然,你可能已经想到,上述创建 Veth Pair 设备的操作,其实也可以先在宿主机上执行,然后再把该设备的一端放到容器的 Network Namespace 里,这个原理是一样的。

不过,CNI 插件之所以要”反着”来,是因为 CNI 里对 Namespace 操作函数的设计就是如此,如下所示:

err := containerNS.Do(func(hostNS ns.NetNS) error {
  ...
  return nil
})

这个设计其实很容易理解。在编程时,容器的 Namespace 是可以直接通过 Namespace 文件拿到的;而 Host Namespace,则是一个隐含在上下文的参数。所以,像上面这样,先通过容器 Namespace 进入容器里面,然后再反向操作 Host Namespace,对于编程来说要更加方便。

接下来,CNI bridge 插件就可以把 vethb4963f3 设备连接在 CNI 网桥上。这相当于在宿主机上执行:

# 在宿主机上
$ ip link set vethb4963f3 master cni0

在将 vethb4963f3 设备连接在 CNI 网桥之后,CNI bridge 插件还会为它设置 Hairpin Mode(发夹模式) 。这是因为,在默认情况下,网桥设备是不允许一个数据包从一个端口进来后,再从这个端口发出去的。但是,它允许你为这个端口开启 Hairpin Mode,从而取消这个限制。

这个特性,主要用在容器需要通过 NAT (即:端口映射)的方式,”自己访问自己”的场景下。

举个例子,比如我们执行 docker run -p 8080:80,就是在宿主机上通过 iptables 设置了一条 DNAT (目的地址转换)转发规则。这条规则的作用是,当宿主机上的进程访问 < 宿主机的 IP 地址 >:8080 时,iptables 会把该请求直接转发到 < 容器的 IP 地址 >:80 上。也就是说,这个请求最终会经过 docker0 网桥进入容器里面。

但如果你是在容器里面访问宿主机的 8080 端口,那么这个容器里发出的 IP 包会经过 vethb4963f3 设备(端口)和 docker0 网桥,来到宿主机上。此时,根据上述 DNAT 规则,这个 IP 包又需要回到 docker0 网桥,并且还是通过 vethb4963f3 端口进入到容器里。所以,这种情况下,我们就需要开启 vethb4963f3 端口的 Hairpin Mode 了。

所以说,Flannel 插件要在 CNI 配置文件里声明 hairpinMode=true。这样,将来这个集群里的 Pod 才可以通过它自己的 Service 访问到自己。

接下来,CNI bridge 插件会调用 CNI ipam 插件,从 ipam.subnet 字段规定的网段里为容器分配一个可用的 IP 地址。然后,CNI bridge 插件就会把这个 IP 地址添加在容器的 eth0 网卡上,同时为容器设置默认路由。这相当于在容器里执行:

# 在容器里
$ ip addr add 10.244.0.2/24 dev eth0
$ ip route add default via 10.244.0.1 dev eth0

最后,CNI bridge 插件会为 CNI 网桥添加 IP 地址。这相当于在宿主机上执行:

# 在宿主机上
$ ip addr add 10.244.0.1/24 dev cni0

在执行完上述操作之后,CNI 插件会把容器的 IP 地址等信息返回给 dockershim,然后被 kubelet 添加到 Pod 的 Status 字段。

至此,CNI 插件的 ADD 方法就宣告结束了。接下来的流程,就跟我们上一篇文章中容器跨主机通信的过程完全一致了。

需要注意的是,对于非网桥类型的 CNI 插件,上述”将容器添加到 CNI 网络”的操作流程,以及网络方案本身的工作原理,就都不太一样了。我将会在后续文章中,继续为你分析这部分内容。

34.1 总结

在本篇文章中,我为你详细讲解了 Kubernetes 中 CNI 网络的实现原理。根据这个原理,你其实就很容易理解所谓的”Kubernetes 网络模型”了:

  1. 所有容器都可以直接使用 IP 地址与其他容器通信,而无需使用 NAT。
  2. 所有宿主机都可以直接使用 IP 地址与所有容器通信,而无需使用 NAT。反之亦然。
  3. 容器自己”看到”的自己的 IP 地址,和别人(宿主机或者容器)看到的地址是完全一样的。

可以看到,这个网络模型,其实可以用一个字总结,那就是”通”。

容器与容器之间要”通”,容器与宿主机之间也要”通”。并且,Kubernetes 要求这个”通”,还必须是直接基于容器和宿主机的 IP 地址来进行的。

35 | 解读Kubernetes三层网络方案

在上一篇文章中,我以网桥类型的 Flannel 插件为例,为你讲解了 Kubernetes 里容器网络和 CNI 插件的主要工作原理。不过,除了这种模式之外,还有一种纯三层(Pure Layer 3)网络方案非常值得你注意。其中的典型例子,莫过于 Flannel 的 host-gw 模式和 Calico 项目了。

我们先来看一下 Flannel 的 host-gw 模式。

它的工作原理非常简单,我用一张图就可以和你说清楚。为了方便叙述,接下来我会称这张图为”host-gw 示意图”。

Flannelhost-gw示意图

假设现在,Node 1 上的 Infra-container-1,要访问 Node 2 上的 Infra-container-2。

当你设置 Flannel 使用 host-gw 模式之后,flanneld 会在宿主机上创建这样一条规则,以 Node 1 为例:

$ ip route
...
10.244.1.0/24 via 10.168.0.3 dev eth0

这条路由规则的含义是:

目的 IP 地址属于 10.244.1.0/24 网段的 IP 包,应该经过本机的 eth0 设备发出去(即:dev eth0);并且,它下一跳地址(next-hop)是 10.168.0.3(即:via 10.168.0.3)。

所谓下一跳地址就是:如果 IP 包从主机 A 发到主机 B,需要经过路由设备 X 的中转。那么 X 的 IP 地址就应该配置为主机 A 的下一跳地址。

而从 host-gw 示意图中我们可以看到,这个下一跳地址对应的,正是我们的目的宿主机 Node 2。

一旦配置了下一跳地址,那么接下来,当 IP 包从网络层进入链路层封装成帧的时候,eth0 设备就会使用下一跳地址对应的 MAC 地址,作为该数据帧的目的 MAC 地址。显然,这个 MAC 地址,正是 Node 2 的 MAC 地址。

这样,这个数据帧就会从 Node 1 通过宿主机的二层网络顺利到达 Node 2 上。

而 Node 2 的内核网络栈从二层数据帧里拿到 IP 包后,会”看到”这个 IP 包的目的 IP 地址是 10.244.1.3,即 Infra-container-2 的 IP 地址。这时候,根据 Node 2 上的路由表,该目的地址会匹配到第二条路由规则(也就是 10.244.1.0 对应的路由规则),从而进入 cni0 网桥,进而进入到 Infra-container-2 当中。

可以看到, host-gw 模式的工作原理,其实就是将每个 Flannel 子网(Flannel Subnet,比如:10.244.1.0/24)的”下一跳”,设置成了该子网对应的宿主机的 IP 地址。

也就是说,这台”主机”(Host)会充当这条容器通信路径里的”网关”(Gateway)。这也正是”host-gw”的含义。

当然,Flannel 子网和主机的信息,都是保存在 Etcd 当中的。flanneld 只需要 WACTH 这些数据的变化,然后实时更新路由表即可。

注意:在 Kubernetes v1.7 之后,类似 Flannel、Calico 的 CNI 网络插件都是可以直接连接 Kubernetes 的 APIServer 来访问 Etcd 的,无需额外部署 Etcd 给它们使用。

而在这种模式下,容器通信的过程就免除了额外的封包和解包带来的性能损耗。根据实际的测试,host-gw 的性能损失大约在 10% 左右,而其他所有基于 VXLAN”隧道”机制的网络方案,性能损失都在 20%~30% 左右。

当然,通过上面的叙述,你也应该看到,host-gw 模式能够正常工作的核心,就在于 IP 包在封装成帧发送出去的时候,会使用路由表里的”下一跳”来设置目的 MAC 地址。这样,它就会经过二层网络到达目的宿主机。

所以说,Flannel host-gw 模式必须要求集群宿主机之间是二层连通的。

需要注意的是,宿主机之间二层不连通的情况也是广泛存在的。比如,宿主机分布在了不同的子网(VLAN)里。但是,在一个 Kubernetes 集群里,宿主机之间必须可以通过 IP 地址进行通信,也就是说至少是三层可达的。否则的话,你的集群将不满足上一篇文章中提到的宿主机之间 IP 互通的假设(Kubernetes 网络模型)。当然,”三层可达”也可以通过为几个子网设置三层转发来实现。

而在容器生态中,要说到像 Flannel host-gw 这样的三层网络方案,我们就不得不提到这个领域里的”龙头老大”Calico 项目了。

实际上,Calico 项目提供的网络解决方案,与 Flannel 的 host-gw 模式,几乎是完全一样的。也就是说,Calico 也会在每台宿主机上,添加一个格式如下所示的路由规则:

< 目的容器 IP 地址段 > via < 网关的 IP 地址 > dev eth0

其中,网关的 IP 地址,正是目的容器所在宿主机的 IP 地址。

而正如前所述,这个三层网络方案得以正常工作的核心,是为每个容器的 IP 地址,找到它所对应的、”下一跳”的 网关

不过, 不同于 Flannel 通过 Etcd 和宿主机上的 flanneld 来维护路由信息的做法,Calico 项目使用了一个”重型武器”来自动地在整个集群中分发路由信息。

这个”重型武器”,就是 BGP。

BGP 的全称是 Border Gateway Protocol,即:边界网关协议。 它是一个 Linux 内核原生就支持的、专门用在大规模数据中心里维护不同的”自治系统”之间路由信息的、无中心的路由协议。

这个概念可能听起来有点儿”吓人”,但实际上,我可以用一个非常简单的例子来为你讲清楚。

自治系统

在这个图中,我们有两个自治系统(Autonomous System,简称为 AS):AS 1 和 AS 2。而所谓的一个自治系统,指的是一个组织管辖下的所有 IP 网络和路由器的全体。你可以把它想象成一个小公司里的所有主机和路由器。在正常情况下,自治系统之间不会有任何”来往”。

但是,如果这样两个自治系统里的主机,要通过 IP 地址直接进行通信,我们就必须使用路由器把这两个自治系统连接起来。

比如,AS 1 里面的主机 10.10.0.2,要访问 AS 2 里面的主机 172.17.0.3 的话。它发出的 IP 包,就会先到达自治系统 AS 1 上的路由器 Router 1。

而在此时,Router 1 的路由表里,有这样一条规则,即:目的地址是 172.17.0.2 包,应该经过 Router 1 的 C 接口,发往网关 Router 2(即:自治系统 AS 2 上的路由器)。

所以 IP 包就会到达 Router 2 上,然后经过 Router 2 的路由表,从 B 接口出来到达目的主机 172.17.0.3。

但是反过来,如果主机 172.17.0.3 要访问 10.10.0.2,那么这个 IP 包,在到达 Router 2 之后,就不知道该去哪儿了。因为在 Router 2 的路由表里,并没有关于 AS 1 自治系统的任何路由规则。

所以这时候,网络管理员就应该给 Router 2 也添加一条路由规则,比如:目标地址是 10.10.0.2 的 IP 包,应该经过 Router 2 的 C 接口,发往网关 Router 1。

像上面这样负责把自治系统连接在一起的路由器,我们就把它形象地称为: 边界网关 。它跟普通路由器的不同之处在于,它的路由表里拥有其他自治系统里的主机路由信息。

上面的这部分原理,相信你理解起来应该很容易。毕竟,路由器这个设备本身的主要作用,就是连通不同的网络。

但是,你可以想象一下,假设我们现在的网络拓扑结构非常复杂,每个自治系统都有成千上万个主机、无数个路由器,甚至是由多个公司、多个网络提供商、多个自治系统组成的复合自治系统呢?

这时候,如果还要依靠人工来对边界网关的路由表进行配置和维护,那是绝对不现实的。

而这种情况下,BGP 大显身手的时刻就到了。

在使用了 BGP 之后,你可以认为,在每个边界网关上都会运行着一个小程序,它们会将各自的路由表信息,通过 TCP 传输给其他的边界网关。而其他边界网关上的这个小程序,则会对收到的这些数据进行分析,然后将需要的信息添加到自己的路由表里。

这样,图 2 中 Router 2 的路由表里,就会自动出现 10.10.0.2 和 10.10.0.3 对应的路由规则了。

所以说, 所谓 BGP,就是在大规模网络中实现节点路由信息共享的一种协议。

而 BGP 的这个能力,正好可以取代 Flannel 维护主机上路由表的功能。而且,BGP 这种原生就是为大规模网络环境而实现的协议,其可靠性和可扩展性,远非 Flannel 自己的方案可比。

需要注意的是,BGP 协议实际上是最复杂的一种路由协议。我在这里的讲述和所举的例子,仅是为了能够帮助你建立对 BGP 的感性认识,并不代表 BGP 真正的实现方式。

接下来,我们还是回到 Calico 项目上来。

在了解了 BGP 之后,Calico 项目的架构就非常容易理解了。它由三个部分组成:

  1. Calico 的 CNI 插件。这是 Calico 与 Kubernetes 对接的部分。我已经在上一篇文章中,和你详细分享了 CNI 插件的工作原理,这里就不再赘述了。
  2. Felix。它是一个 DaemonSet,负责在宿主机上插入路由规则(即:写入 Linux 内核的 FIB 转发信息库),以及维护 Calico 所需的网络设备等工作。
  3. BIRD。它就是 BGP 的客户端,专门负责在集群里分发路由规则信息。

除了对路由信息的维护方式之外,Calico 项目与 Flannel 的 host-gw 模式的另一个不同之处,就是它不会在宿主机上创建任何网桥设备。 这时候,Calico 的工作方式,可以用一幅示意图来描述,如下所示(在接下来的讲述中,我会统一用”BGP 示意图”来指代它):

Calico工作原理

其中的绿色实线标出的路径,就是一个 IP 包从 Node 1 上的 Container 1,到达 Node 2 上的 Container 4 的完整路径。

可以看到,Calico 的 CNI 插件会为每个容器设置一个 Veth Pair 设备,然后把其中的一端放置在宿主机上(它的名字以 cali 前缀开头)。

此外,由于 Calico 没有使用 CNI 的网桥模式,Calico 的 CNI 插件还需要在宿主机上为每个容器的 Veth Pair 设备配置一条路由规则,用于接收传入的 IP 包。比如,宿主机 Node 2 上的 Container 4 对应的路由规则,如下所示:

10.233.2.3 dev cali5863f3 scope link

即:发往 10.233.2.3 的 IP 包,应该进入 cali5863f3 设备。

基于上述原因,Calico 项目在宿主机上设置的路由规则,肯定要比 Flannel 项目多得多。不过,Flannel host-gw 模式使用 CNI 网桥的主要原因,其实是为了跟 VXLAN 模式保持一致。否则的话,Flannel 就需要维护两套 CNI 插件了。

有了这样的 Veth Pair 设备之后,容器发出的 IP 包就会经过 Veth Pair 设备出现在宿主机上。然后,宿主机网络栈就会根据路由规则的下一跳 IP 地址,把它们转发给正确的网关。接下来的流程就跟 Flannel host-gw 模式完全一致了。

其中,这里最核心的”下一跳”路由规则,就是由 Calico 的 Felix 进程负责维护的。这些路由规则信息,则是通过 BGP Client 也就是 BIRD 组件,使用 BGP 协议传输而来的。

而这些通过 BGP 协议传输的消息,你可以简单地理解为如下格式:

[BGP 消息]
我是宿主机 192.168.1.3
10.233.2.0/24 网段的容器都在我这里
这些容器的下一跳地址是我

不难发现,Calico 项目实际上将集群里的所有节点,都当作是边界路由器来处理,它们一起组成了一个全连通的网络,互相之间通过 BGP 协议交换路由规则。这些节点,我们称为 BGP Peer。

需要注意的是, Calico 维护的网络在默认配置下,是一个被称为”Node-to-Node Mesh”的模式 。这时候,每台宿主机上的 BGP Client 都需要跟其他所有节点的 BGP Client 进行通信以便交换路由信息。但是,随着节点数量 N 的增加,这些连接的数量就会以 N²的规模快速增长,从而给集群本身的网络带来巨大的压力。

所以,Node-to-Node Mesh 模式一般推荐用在少于 100 个节点的集群里。而在更大规模的集群中,你需要用到的是一个叫作 Route Reflector 的模式。

在这种模式下,Calico 会指定一个或者几个专门的节点,来负责跟所有节点建立 BGP 连接从而学习到全局的路由规则。而其他节点,只需要跟这几个专门的节点交换路由信息,就可以获得整个集群的路由规则信息了。

这些专门的节点,就是所谓的 Route Reflector 节点,它们实际上扮演了”中间代理”的角色,从而把 BGP 连接的规模控制在 N 的数量级上。

此外,我在前面提到过,Flannel host-gw 模式最主要的限制,就是要求集群宿主机之间是二层连通的。而这个限制对于 Calico 来说,也同样存在。

举个例子,假如我们有两台处于不同子网的宿主机 Node 1 和 Node 2,对应的 IP 地址分别是 192.168.1.2 和 192.168.2.2。需要注意的是,这两台机器通过路由器实现了三层转发,所以这两个 IP 地址之间是可以相互通信的。

而我们现在的需求,还是 Container 1 要访问 Container 4。

按照我们前面的讲述,Calico 会尝试在 Node 1 上添加如下所示的一条路由规则:

10.233.2.0/16 via 192.168.2.2 eth0

但是,这时候问题就来了。

上面这条规则里的下一跳地址是 192.168.2.2,可是它对应的 Node 2 跟 Node 1 却根本不在一个子网里,没办法通过二层网络把 IP 包发送到下一跳地址。

在这种情况下,你就需要为 Calico 打开 IPIP 模式。

我把这个模式下容器通信的原理,总结成了一副示意图,如下所示(接下来我会称之为:IPIP 示意图):

CalicoIPIP模式工作原理

在 Calico 的 IPIP 模式下,Felix 进程在 Node 1 上添加的路由规则,会稍微不同,如下所示:

10.233.2.0/24 via 192.168.2.2 tunl0

可以看到,尽管这条规则的下一跳地址仍然是 Node 2 的 IP 地址,但这一次,要负责将 IP 包发出去的设备,变成了 tunl0。注意,是 T-U-N-L-0,而不是 Flannel UDP 模式使用的 T-U-N-0(tun0),这两种设备的功能是完全不一样的。

Calico 使用的这个 tunl0 设备,是一个 IP 隧道(IP tunnel)设备。

在上面的例子中,IP 包进入 IP 隧道设备之后,就会被 Linux 内核的 IPIP 驱动接管。IPIP 驱动会将这个 IP 包直接封装在一个宿主机网络的 IP 包中,如下所示:

IPIP封包方式

其中,经过封装后的新的 IP 包的目的地址(图 5 中的 Outer IP Header 部分),正是原 IP 包的下一跳地址,即 Node 2 的 IP 地址:192.168.2.2。

而原 IP 包本身,则会被直接封装成新 IP 包的 Payload。

这样,原先从容器到 Node 2 的 IP 包,就被伪装成了一个从 Node 1 到 Node 2 的 IP 包。

由于宿主机之间已经使用路由器配置了三层转发,也就是设置了宿主机之间的”下一跳”。所以这个 IP 包在离开 Node 1 之后,就可以经过路由器,最终”跳”到 Node 2 上。

这时,Node 2 的网络内核栈会使用 IPIP 驱动进行解包,从而拿到原始的 IP 包。然后,原始 IP 包就会经过路由规则和 Veth Pair 设备到达目的容器内部。

以上,就是 Calico 项目主要的工作原理了。

不难看到,当 Calico 使用 IPIP 模式的时候,集群的网络性能会因为额外的封包和解包工作而下降。在实际测试中,Calico IPIP 模式与 Flannel VXLAN 模式的性能大致相当。所以,在实际使用时,如非硬性需求,我建议你将所有宿主机节点放在一个子网里,避免使用 IPIP。

不过,通过上面对 Calico 工作原理的讲述,你应该能发现这样一个事实:

如果 Calico 项目能够让宿主机之间的路由设备(也就是网关),也通过 BGP 协议”学习”到 Calico 网络里的路由规则,那么从容器发出的 IP 包,不就可以通过这些设备路由到目的宿主机了么?

比如,只要在上面”IPIP 示意图”中的 Node 1 上,添加如下所示的一条路由规则:

10.233.2.0/24 via 192.168.1.1 eth0

然后,在 Router 1 上(192.168.1.1),添加如下所示的一条路由规则:

10.233.2.0/24 via 192.168.2.1 eth0

那么 Container 1 发出的 IP 包,就可以通过两次”下一跳”,到达 Router 2(192.168.2.1)了。以此类推,我们可以继续在 Router 2 上添加”下一条”路由,最终把 IP 包转发到 Node 2 上。

遗憾的是,上述流程虽然简单明了,但是在 Kubernetes 被广泛使用的公有云场景里,却完全不可行。

这里的原因在于:公有云环境下,宿主机之间的网关,肯定不会允许用户进行干预和设置。

当然,在大多数公有云环境下,宿主机(公有云提供的虚拟机)本身往往就是二层连通的,所以这个需求也不强烈。

不过,在私有部署的环境下,宿主机属于不同子网(VLAN)反而是更加常见的部署状态。这时候,想办法将宿主机网关也加入到 BGP Mesh 里从而避免使用 IPIP,就成了一个非常迫切的需求。

而在 Calico 项目中,它已经为你提供了两种将宿主机网关设置成 BGP Peer 的解决方案。

  1. 第一种方案 ,就是所有宿主机都跟宿主机网关建立 BGP Peer 关系。

    这种方案下,Node 1 和 Node 2 就需要主动跟宿主机网关 Router 1 和 Router 2 建立 BGP 连接。从而将类似于 10.233.2.0/24 这样的路由信息同步到网关上去。

    需要注意的是,这种方式下,Calico 要求宿主机网关必须支持一种叫作 Dynamic Neighbors 的 BGP 配置方式。这是因为,在常规的路由器 BGP 配置里,运维人员必须明确给出所有 BGP Peer 的 IP 地址。考虑到 Kubernetes 集群可能会有成百上千个宿主机,而且还会动态地添加和删除节点,这时候再手动管理路由器的 BGP 配置就非常麻烦了。而 Dynamic Neighbors 则允许你给路由器配置一个网段,然后路由器就会自动跟该网段里的主机建立起 BGP Peer 关系。

  2. 不过,相比之下,我更愿意推荐 第二种方案

    这种方案,是使用一个或多个独立组件负责搜集整个集群里的所有路由信息,然后通过 BGP 协议同步给网关。而我们前面提到,在大规模集群中,Calico 本身就推荐使用 Route Reflector 节点的方式进行组网。所以,这里负责跟宿主机网关进行沟通的独立组件,直接由 Route Reflector 兼任即可。

    更重要的是,这种情况下网关的 BGP Peer 个数是有限并且固定的。所以我们就可以直接把这些独立组件配置成路由器的 BGP Peer,而无需 Dynamic Neighbors 的支持。

    当然,这些独立组件的工作原理也很简单:它们只需要 WATCH Etcd 里的宿主机和对应网段的变化信息,然后把这些信息通过 BGP 协议分发给网关即可。

35.1 总结

需要注意的是,在大规模集群里,三层网络方案在宿主机上的路由规则可能会非常多,这会导致错误排查变得困难。此外,在系统故障的时候,路由规则出现重叠冲突的概率也会变大。

基于上述原因,如果是在公有云上,由于宿主机网络本身比较”直白”,我一般会推荐更加简单的 Flannel host-gw 模式。

但不难看到,在私有部署环境里,Calico 项目才能够覆盖更多的场景,并为你提供更加可靠的组网方案和架构思路。

36 | 为什么说Kubernetes只有soft multi-tenancy?

在前面的文章中,我为你详细讲解了 Kubernetes 生态里,主流容器网络方案的工作原理。

不难发现,Kubernetes 的网络模型,以及前面这些网络方案的实现,都只关注容器之间网络的”连通”,却并不关心容器之间网络的”隔离”。这跟传统的 IaaS 层的网络方案,区别非常明显。

你肯定会问了,Kubernetes 的网络方案对”隔离”到底是如何考虑的呢?难道 Kubernetes 就不管网络”多租户”的需求吗?

接下来,在今天这篇文章中,我就来回答你的这些问题。

在 Kubernetes 里,网络隔离能力的定义,是依靠一种专门的 API 对象来描述的,即:NetworkPolicy。

一个完整的 NetworkPolicy 对象的示例 ,如下所示:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: default
spec:
  podSelector:
    matchLabels:
      role: db
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - ipBlock:
        cidr: 172.17.0.0/16
        except:
        - 172.17.1.0/24
    - namespaceSelector:
        matchLabels:
          project: myproject
    - podSelector:
        matchLabels:
          role: frontend
    ports:
    - protocol: TCP
      port: 6379
  egress:
  - to:
    - ipBlock:
        cidr: 10.0.0.0/24
    ports:
    - protocol: TCP
      port: 5978

我在和你分享前面的内容时已经说过(这里你可以再回顾下第 34 篇文章 《Kubernetes 网络模型与 CNI 网络插件》中的相关内容)Kubernetes 里的 Pod 默认都是”允许所有”(Accept All)的 ,即:Pod 可以接收来自任何发送方的请求;或者,向任何接收方发送请求。而如果你要对这个情况作出限制,就必须通过 NetworkPolicy 对象来指定。

而在上面这个例子里,你首先会看到 podSelector 字段。它的作用,就是定义这个 NetworkPolicy 的限制范围,比如:当前 Namespace 里携带了 role=db 标签的 Pod。

而如果你把 podSelector 字段留空:

spec:
 podSelector: {}

那么这个 NetworkPolicy 就会作用于当前 Namespace 下的所有 Pod。

而一旦 Pod 被 NetworkPolicy 选中, 那么这个 Pod 就会进入”拒绝所有”(Deny All)的状态 ,即:这个 Pod 既不允许被外界访问,也不允许对外界发起访问。

而 NetworkPolicy 定义的规则,其实就是”白名单”。

例如,在我们上面这个例子里,我在 policyTypes 字段,定义了这个 NetworkPolicy 的类型是 ingress 和 egress,即:它既会影响流入(ingress)请求,也会影响流出(egress)请求。

然后,在 ingress 字段里,我定义了 from 和 ports,即:允许流入的”白名单”和端口。其中,这个允许流入的”白名单”里,我指定了 三种并列的情况 ,分别是:ipBlock、namespaceSelector 和 podSelector。

而在 egress 字段里,我则定义了 to 和 ports,即:允许流出的”白名单”和端口。这里允许流出的”白名单”的定义方法与 ingress 类似。只不过,这一次 ipblock 字段指定的,是目的地址的网段。

综上所述,这个 NetworkPolicy 对象,指定的隔离规则如下所示:

  1. 该隔离规则只对 default Namespace 下的,携带了 role=db 标签的 Pod 有效。限制的请求类型包括 ingress(流入)和 egress(流出)。
  2. Kubernetes 会拒绝任何访问被隔离 Pod 的请求,除非这个请求来自于以下”白名单”里的对象,并且访问的是被隔离 Pod 的 6379 端口。这些”白名单”对象包括:

    • default Namespace 里的,携带了 role=fronted 标签的 Pod;
    • 任何 Namespace 里的、携带了 project=myproject 标签的 Pod;
    • 任何源地址属于 172.17.0.0/16 网段,且不属于 172.17.1.0/24 网段的请求。
  3. Kubernetes 会拒绝被隔离 Pod 对外发起任何请求,除非请求的目的地址属于 10.0.0.0/24 网段,并且访问的是该网段地址的 5978 端口。

需要注意的是,定义一个 NetworkPolicy 对象的过程,容易犯错的是”白名单”部分(from 和 to 字段)。

举个例子:

...
ingress:
- from:
  - namespaceSelector:
      matchLabels:
        user: alice
  - podSelector:
      matchLabels:
        role: client
...

像上面这样定义的 namespaceSelector 和 podSelector,是”或”(OR)的关系。所以说,这个 from 字段定义了两种情况,无论是 Namespace 满足条件,还是 Pod 满足条件,这个 NetworkPolicy 都会生效。

而下面这个例子,虽然看起来类似,但是它定义的规则却完全不同:

...
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          user: alice
      podSelector:
        matchLabels:
          role: client
  ...

注意看,这样定义的 namespaceSelector 和 podSelector,其实是”与”(AND)的关系。所以说,这个 from 字段只定义了一种情况,只有 Namespace 和 Pod 同时满足条件,这个 NetworkPolicy 才会生效。

这两种定义方式的区别,请你一定要分清楚。

此外,如果要使上面定义的 NetworkPolicy 在 Kubernetes 集群里真正产生作用,你的 CNI 网络插件就必须是支持 Kubernetes 的 NetworkPolicy 的。

在具体实现上,凡是支持 NetworkPolicy 的 CNI 网络插件,都维护着一个 NetworkPolicy Controller,通过控制循环的方式对 NetworkPolicy 对象的增删改查做出响应,然后在宿主机上完成 iptables 规则的配置工作。

在 Kubernetes 生态里,目前已经实现了 NetworkPolicy 的网络插件包括 Calico、Weave 和 kube-router 等多个项目,但是并不包括 Flannel 项目。

所以说,如果想要在使用 Flannel 的同时还使用 NetworkPolicy 的话,你就需要再额外安装一个网络插件,比如 Calico 项目,来负责执行 NetworkPolicy。

安装 Flannel + Calico 的流程非常简单,你直接参考这个文档 一键安装 即可。

那么,这些网络插件,又是如何根据 NetworkPolicy 对 Pod 进行隔离的呢?

接下来,我就以三层网络插件为例(比如 Calico 和 kube-router),来为你分析一下这部分的原理。

为了方便讲解,这一次我编写了一个比较简单的 NetworkPolicy 对象,如下所示:

apiVersion: extensions/v1beta1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: default
spec:
  podSelector:
    matchLabels:
      role: db
  ingress:
   - from:
     - namespaceSelector:
         matchLabels:
           project: myproject
     - podSelector:
         matchLabels:
           role: frontend
     ports:
       - protocol: tcp
         port: 6379

可以看到,我们指定的 ingress”白名单”,是任何 Namespace 里,携带 project=myproject 标签的 Pod;以及 default Namespace 里,携带了 role=frontend 标签的 Pod。允许被访问的端口是:6379。

而被隔离的对象,是所有携带了 role=db 标签的 Pod。

那么这个时候,Kubernetes 的网络插件就会使用这个 NetworkPolicy 的定义,在宿主机上生成 iptables 规则。这个过程,我可以通过如下所示的一段 Go 语言风格的伪代码来为你描述:

for dstIP := range 所有被 networkpolicy.spec.podSelector 选中的 Pod 的 IP 地址
  for srcIP := range 所有被 ingress.from.podSelector 选中的 Pod 的 IP 地址
    for port, protocol := range ingress.ports {
      iptables -A KUBE-NWPLCY-CHAIN -s $srcIP -d $dstIP -p $protocol -m $protocol --dport $port -j ACCEPT
    }
  }
}

可以看到,这是一条最基本的、通过匹配条件决定下一步动作的 iptables 规则。

这条规则的名字是 KUBE-NWPLCY-CHAIN,含义是:当 IP 包的源地址是 srcIP、目的地址是 dstIP、协议是 protocol、目的端口是 port 的时候,就允许它通过(ACCEPT)。

而正如这段伪代码所示,匹配这条规则所需的这四个参数,都是从 NetworkPolicy 对象里读取出来的。

可以看到,Kubernetes 网络插件对 Pod 进行隔离,其实是靠在宿主机上生成 NetworkPolicy 对应的 iptable 规则来实现的。

此外,在设置好上述”隔离”规则之后,网络插件还需要想办法,将所有对被隔离 Pod 的访问请求,都转发到上述 KUBE-NWPLCY-CHAIN 规则上去进行匹配。并且,如果匹配不通过,这个请求应该被”拒绝”。

在 CNI 网络插件中,上述需求可以通过设置两组 iptables 规则来实现。

第一组规则,负责”拦截”对被隔离 Pod 的访问请求 。生成这一组规则的伪代码,如下所示:

for pod := range 该 Node 上的所有 Pod {
    if pod 是 networkpolicy.spec.podSelector 选中的 {
        iptables -A FORWARD -d $podIP -m physdev --physdev-is-bridged -j KUBE-POD-SPECIFIC-FW-CHAIN
        iptables -A FORWARD -d $podIP -j KUBE-POD-SPECIFIC-FW-CHAIN
        ...
    }
}

可以看到,这里的的 iptables 规则使用到了内置链:FORWARD。它是什么意思呢?

说到这里,我就得为你稍微普及一下 iptables 的知识了。

实际上,iptables 只是一个操作 Linux 内核 Netfilter 子系统的”界面”。顾名思义,Netfilter 子系统的作用,就是 Linux 内核里挡在”网卡”和”用户态进程”之间的一道”防火墙”。它们的关系,可以用如下的示意图来表示:

iptables与kernelspace和userspace的关系

可以看到,这幅示意图中,IP 包”一进一出”的两条路径上,有几个关键的”检查点”,它们正是 Netfilter 设置”防火墙”的地方。在 iptables 中,这些”检查点”被称为:链(Chain) 。这是因为这些”检查点”对应的 iptables 规则,是按照定义顺序依次进行匹配的。这些”检查点”的具体工作原理,可以用如下所示的示意图进行描述:

iptables中的chains

可以看到,当一个 IP 包通过网卡进入主机之后,它就进入了 Netfilter 定义的流入路径(Input Path)里。

在这个路径中,IP 包要经过路由表路由来决定下一步的去向。而在这次路由之前,Netfilter 设置了一个名叫 PREROUTING 的”检查点”。在 Linux 内核的实现里,所谓”检查点”实际上就是内核网络协议栈代码里的 Hook(比如,在执行路由判断的代码之前,内核会先调用 PREROUTING 的 Hook)。

而在经过路由之后,IP 包的去向就分为了两种:

  • 第一种,继续在本机处理;
  • 第二种,被转发到其他目的地。

我们先说一下 IP 包的第一种去向 。这时候,IP 包将继续向上层协议栈流动。在它进入传输层之前,Netfilter 会设置一个名叫 INPUT 的”检查点”。到这里,IP 包流入路径(Input Path)结束。

接下来,这个 IP 包通过传输层进入用户空间,交给用户进程处理。而处理完成后,用户进程会通过本机发出返回的 IP 包。这时候,这个 IP 包就进入了流出路径(Output Path)。

此时,IP 包首先还是会经过主机的路由表进行路由。路由结束后,Netfilter 就会设置一个名叫 OUTPUT 的”检查点”。然后,在 OUTPUT 之后,再设置一个名叫 POSTROUTING”检查点”。

你可能会觉得奇怪,为什么在流出路径结束后,Netfilter 会连着设置两个”检查点”呢?

这就要说到在流入路径里, 路由判断后的第二种去向了。

在这种情况下,这个 IP 包不会进入传输层,而是会继续在网络层流动,从而进入到转发路径(Forward Path)。在转发路径中,Netfilter 会设置一个名叫 FORWARD 的”检查点”。

而在 FORWARD”检查点”完成后,IP 包就会来到流出路径。而转发的 IP 包由于目的地已经确定,它就不会再经过路由,也自然不会经过 OUTPUT,而是会直接来到 POSTROUTING”检查点”。

所以说,POSTROUTING 的作用,其实就是上述两条路径,最终汇聚在一起的”最终检查点”。

需要注意的是,在有网桥参与的情况下,上述 Netfilter 设置”检查点”的流程,实际上也会出现在链路层(二层),并且会跟我在上面讲述的网络层(三层)的流程有交互。

这些链路层的”检查点”对应的操作界面叫作 ebtables。所以,准确地说,数据包在 Linux Netfilter 子系统里完整的流动过程,其实应该如下所示(这是一幅来自 Netfilter 官方的原理图,建议你点击图片以查看大图):

Netfilter官方的原理图

可以看到,我前面为你讲述的,正是上图中绿色部分,也就是网络层的 iptables 链的工作流程。

另外,你应该还能看到,每一个白色的”检查点”上,还有一个绿色的”标签”,比如:raw、nat、filter 等等。

在 iptables 里,这些标签叫作:表。比如,同样是 OUTPUT 这个”检查点”,filter Output 和 nat Output 在 iptables 里的语法和参数,就完全不一样,实现的功能也完全不同。

所以说,iptables 表的作用,就是在某个具体的”检查点”(比如 Output)上,按顺序执行几个不同的检查动作(比如,先执行 nat,再执行 filter)。

在理解了 iptables 的工作原理之后,我们再回到 NetworkPolicy 上来。这时候,前面由网络插件设置的、负责”拦截”进入 Pod 的请求的三条 iptables 规则,就很容易读懂了:

iptables -A FORWARD -d $podIP -m physdev --physdev-is-bridged -j KUBE-POD-SPECIFIC-FW-CHAIN
iptables -A FORWARD -d $podIP -j KUBE-POD-SPECIFIC-FW-CHAIN
...

其中, 第一条 FORWARD 链”拦截”的是一种特殊情况 :它对应的是同一台宿主机上容器之间经过 CNI 网桥进行通信的流入数据包。其中,-physdev-is-bridged 的意思就是,这个 FORWARD 链匹配的是,通过本机上的网桥设备,发往目的地址是 podIP 的 IP 包。

当然,如果是像 Calico 这样的非网桥模式的 CNI 插件,就不存在这个情况了。

kube-router 其实是一个简化版的 Calico,它也使用 BGP 来维护路由信息,但是使用 CNI bridge 插件负责跟 Kubernetes 进行交互。

第二条 FORWARD 链”拦截”的则是最普遍的情况,即:容器跨主通信 。这时候,流入容器的数据包都是经过路由转发(FORWARD 检查点)来的。

不难看到,这些规则最后都跳转(即:-j)到了名叫 KUBE-POD-SPECIFIC-FW-CHAIN 的规则上。它正是网络插件为 NetworkPolicy 设置的第二组规则。

而这个 KUBE-POD-SPECIFIC-FW-CHAIN 的作用,就是做出”允许”或者”拒绝”的判断。这部分功能的实现,可以简单描述为下面这样的 iptables 规则:

iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j KUBE-NWPLCY-CHAIN
iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j REJECT --reject-with icmp-port-unreachable

可以看到,首先在第一条规则里,我们会把 IP 包转交给前面定义的 KUBE-NWPLCY-CHAIN 规则去进行匹配。按照我们之前的讲述,如果匹配成功,那么 IP 包就会被”允许通过”。

而如果匹配失败,IP 包就会来到第二条规则上。可以看到,它是一条 REJECT 规则。通过这条规则,不满足 NetworkPolicy 定义的请求就会被拒绝掉,从而实现了对该容器的”隔离”。

以上,就是 CNI 网络插件实现 NetworkPolicy 的基本方法了。当然,对于不同的插件来说,上述实现过程可能有不同的手段,但根本原理是不变的。

36.1 总结

在本篇文章中,我主要和你分享了 Kubernetes 对 Pod 进行”隔离”的手段,即:NetworkPolicy。

可以看到,NetworkPolicy 实际上只是宿主机上的一系列 iptables 规则。这跟传统 IaaS 里面的安全组(Security Group)其实是非常类似的。

而基于上述讲述,你就会发现这样一个事实:

Kubernetes 的网络模型以及大多数容器网络实现,其实既不会保证容器之间二层网络的互通,也不会实现容器之间的二层网络隔离。这跟 IaaS 项目管理虚拟机的方式,是完全不同的。

所以说,Kubernetes 从底层的设计和实现上,更倾向于假设你已经有了一套完整的物理基础设施。然后,Kubernetes 负责在此基础上提供一种”弱多租户”(soft multi-tenancy)的能力。

并且,基于上述思路,Kubernetes 将来也不大可能把 Namespace 变成一个具有实质意义的隔离机制,或者把它映射成为”子网”或者”租户”。毕竟你可以看到,NetworkPolicy 对象的描述能力,要比基于 Namespace 的划分丰富得多。

这也是为什么,到目前为止,Kubernetes 项目在云计算生态里的定位,其实是基础设施与 PaaS 之间的中间层。这是非常符合”容器”这个本质上就是进程的抽象粒度的。

37 | 找到容器不容易:Service、DNS与服务发现

在前面的文章中,我们已经多次使用到了 Service 这个 Kubernetes 里重要的服务对象。而 Kubernetes 之所以需要 Service,一方面是因为 Pod 的 IP 不是固定的,另一方面则是因为一组 Pod 实例之间总会有负载均衡的需求。

一个最典型的 Service 定义,如下所示:

apiVersion: v1
kind: Service
metadata:
  name: hostnames
spec:
  selector:
    app: hostnames
  ports:
  - name: default
    protocol: TCP
    port: 80
    targetPort: 9376

这个 Service 的例子,相信你不会陌生。其中,我使用了 selector 字段来声明这个 Service 只代理携带了 app=hostnames 标签的 Pod。并且,这个 Service 的 80 端口,代理的是 Pod 的 9376 端口。

然后,我们的应用的 Deployment,如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hostnames
spec:
  selector:
    matchLabels:
      app: hostnames
  replicas: 3
  template:
    metadata:
      labels:
        app: hostnames
    spec:
      containers:
      - name: hostnames
        image: k8s.gcr.io/serve_hostname
        ports:
        - containerPort: 9376
          protocol: TCP

这个应用的作用,就是每次访问 9376 端口时,返回它自己的 hostname。

而被 selector 选中的 Pod,就称为 Service 的 Endpoints,你可以使用 kubectl get ep 命令看到它们,如下所示:

$ kubectl get endpoints hostnames
NAME        ENDPOINTS
hostnames   10.244.0.5:9376,10.244.0.6:9376,10.244.0.7:9376

需要注意的是,只有处于 Running 状态,且 readinessProbe 检查通过的 Pod,才会出现在 Service 的 Endpoints 列表里。并且,当某一个 Pod 出现问题时,Kubernetes 会自动把它从 Service 里摘除掉。

而此时,通过该 Service 的 VIP 地址 10.0.1.175,你就可以访问到它所代理的 Pod 了:

$ kubectl get svc hostnames
NAME        TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
hostnames   ClusterIP   10.0.1.175   <none>        80/TCP    5s

$ curl 10.0.1.175:80
hostnames-0uton

$ curl 10.0.1.175:80
hostnames-yp2kp

$ curl 10.0.1.175:80
hostnames-bvc05

这个 VIP 地址是 Kubernetes 自动为 Service 分配的。而像上面这样,通过三次连续不断地访问 Service 的 VIP 地址和代理端口 80,它就为我们依次返回了三个 Pod 的 hostname。这也正印证了 Service 提供的是 Round Robin 方式的负载均衡。对于这种方式,我们称为:ClusterIP 模式的 Service。

你可能一直比较好奇,Kubernetes 里的 Service 究竟是如何工作的呢?

实际上, Service 是由 kube-proxy 组件,加上 iptables 来共同实现的。

举个例子,对于我们前面创建的名叫 hostnames 的 Service 来说,一旦它被提交给 Kubernetes,那么 kube-proxy 就可以通过 Service 的 Informer 感知到这样一个 Service 对象的添加。而作为对这个事件的响应,它就会在宿主机上创建这样一条 iptables 规则(你可以通过 iptables-save 看到它),如下所示:

-A KUBE-SERVICES -d 10.0.1.175/32 -p tcp -m comment --comment "default/hostnames: cluster IP" -m tcp --dport 80 -j KUBE-SVC-NWV5X2332I4OT4T3

可以看到,这条 iptables 规则的含义是:凡是目的地址是 10.0.1.175、目的端口是 80 的 IP 包,都应该跳转到另外一条名叫 KUBE-SVC-NWV5X2332I4OT4T3 的 iptables 链进行处理。

而我们前面已经看到,10.0.1.175 正是这个 Service 的 VIP。所以这一条规则,就为这个 Service 设置了一个固定的入口地址。并且,由于 10.0.1.175 只是一条 iptables 规则上的配置,并没有真正的网络设备,所以你 ping 这个地址,是不会有任何响应的。

那么,我们即将跳转到的 KUBE-SVC-NWV5X2332I4OT4T3 规则,又有什么作用呢?

实际上,它是一组规则的集合,如下所示:

-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-WNBA2IHDGP2BOBGZ
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-X3P2623AGDH6CDF3
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -j KUBE-SEP-57KPRZ3JQVENLNBR

可以看到,这一组规则,实际上是一组随机模式(-mode random)的 iptables 链。

而随机转发的目的地,分别是 KUBE-SEP-WNBA2IHDGP2BOBGZ、KUBE-SEP-X3P2623AGDH6CDF3 和 KUBE-SEP-57KPRZ3JQVENLNBR。

而这三条链指向的最终目的地,其实就是这个 Service 代理的三个 Pod。所以这一组规则,就是 Service 实现负载均衡的位置。

需要注意的是,iptables 规则的匹配是从上到下逐条进行的,所以为了保证上述三条规则每条被选中的概率都相同,我们应该将它们的 probability 字段的值分别设置为 1/3(0.333…)、1/2 和 1。

这么设置的原理很简单:第一条规则被选中的概率就是 1/3;而如果第一条规则没有被选中,那么这时候就只剩下两条规则了,所以第二条规则的 probability 就必须设置为 1/2;类似地,最后一条就必须设置为 1。

你可以想一下,如果把这三条规则的 probability 字段的值都设置成 1/3,最终每条规则被选中的概率会变成多少。

通过查看上述三条链的明细,我们就很容易理解 Service 进行转发的具体原理了,如下所示:

-A KUBE-SEP-57KPRZ3JQVENLNBR -s 10.244.3.6/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-57KPRZ3JQVENLNBR -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.3.6:9376

-A KUBE-SEP-WNBA2IHDGP2BOBGZ -s 10.244.1.7/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-WNBA2IHDGP2BOBGZ -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.1.7:9376

-A KUBE-SEP-X3P2623AGDH6CDF3 -s 10.244.2.3/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-X3P2623AGDH6CDF3 -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.2.3:9376

可以看到,这三条链,其实是三条 DNAT 规则。但在 DNAT 规则之前,iptables 对流入的 IP 包还设置了一个”标志”(-set-xmark)。这个”标志”的作用,我会在下一篇文章再为你讲解。

而 DNAT 规则的作用,就是在 PREROUTING 检查点之前,也就是在路由之前,将流入 IP 包的目的地址和端口,改成-to-destination 所指定的新的目的地址和端口。可以看到,这个目的地址和端口,正是被代理 Pod 的 IP 地址和端口。

这样,访问 Service VIP 的 IP 包经过上述 iptables 处理之后,就已经变成了访问具体某一个后端 Pod 的 IP 包了。不难理解,这些 Endpoints 对应的 iptables 规则,正是 kube-proxy 通过监听 Pod 的变化事件,在宿主机上生成并维护的。

以上,就是 Service 最基本的工作原理。

此外,你可能已经听说过,Kubernetes 的 kube-proxy 还支持一种叫作 IPVS 的模式。这又是怎么一回事儿呢?

其实,通过上面的讲解,你可以看到,kube-proxy 通过 iptables 处理 Service 的过程,其实需要在宿主机上设置相当多的 iptables 规则。而且,kube-proxy 还需要在控制循环里不断地刷新这些规则来确保它们始终是正确的。

不难想到,当你的宿主机上有大量 Pod 的时候,成百上千条 iptables 规则不断地被刷新,会大量占用该宿主机的 CPU 资源,甚至会让宿主机”卡”在这个过程中。所以说, 一直以来,基于 iptables 的 Service 实现,都是制约 Kubernetes 项目承载更多量级的 Pod 的主要障碍。

而 IPVS 模式的 Service,就是解决这个问题的一个行之有效的方法。

IPVS 模式的工作原理,其实跟 iptables 模式类似。当我们创建了前面的 Service 之后,kube-proxy 首先会在宿主机上创建一个虚拟网卡(叫作:kube-ipvs0),并为它分配 Service VIP 作为 IP 地址,如下所示:

# ip addr
  ...
  73:kube-ipvs0:<BROADCAST,NOARP>  mtu 1500 qdisc noop state DOWN qlen 1000
  link/ether  1a:ce:f5:5f:c1:4d brd ff:ff:ff:ff:ff:ff
  inet 10.0.1.175/32  scope global kube-ipvs0
  valid_lft forever  preferred_lft forever

而接下来,kube-proxy 就会通过 Linux 的 IPVS 模块,为这个 IP 地址设置三个 IPVS 虚拟主机,并设置这三个虚拟主机之间使用轮询模式 (rr) 来作为负载均衡策略。我们可以通过 ipvsadm 查看到这个设置,如下所示:

# ipvsadm -ln
 IP Virtual Server version 1.2.1 (size=4096)
  Prot LocalAddress:Port Scheduler Flags
    ->  RemoteAddress:Port           Forward  Weight ActiveConn InActConn
  TCP  10.102.128.4:80 rr
    ->  10.244.3.6:9376    Masq    1       0          0
    ->  10.244.1.7:9376    Masq    1       0          0
    ->  10.244.2.3:9376    Masq    1       0          0

可以看到,这三个 IPVS 虚拟主机的 IP 地址和端口,对应的正是三个被代理的 Pod。

这时候,任何发往 10.102.128.4:80 的请求,就都会被 IPVS 模块转发到某一个后端 Pod 上了。

而相比于 iptables,IPVS 在内核中的实现其实也是基于 Netfilter 的 NAT 模式,所以在转发这一层上,理论上 IPVS 并没有显著的性能提升。但是,IPVS 并不需要在宿主机上为每个 Pod 设置 iptables 规则,而是把对这些”规则”的处理放到了内核态,从而极大地降低了维护这些规则的代价。这也正印证了我在前面提到过的,”将重要操作放入内核态”是提高性能的重要手段。

备注:这里你可以再回顾下第 33 篇文章 《深入解析容器跨主机网络》 中的相关内容。

不过需要注意的是,IPVS 模块只负责上述的负载均衡和代理功能。而一个完整的 Service 流程正常工作所需要的包过滤、SNAT 等操作,还是要靠 iptables 来实现。只不过,这些辅助性的 iptables 规则数量有限,也不会随着 Pod 数量的增加而增加。

所以,在大规模集群里,我非常建议你为 kube-proxy 设置 -proxy-mode=ipvs 来开启这个功能。它为 Kubernetes 集群规模带来的提升,还是非常巨大的。

此外,我在前面的文章中还介绍过 Service 与 DNS 的关系。

在 Kubernetes 中,Service 和 Pod 都会被分配对应的 DNS A 记录(从域名解析 IP 的记录)。

对于 ClusterIP 模式的 Service 来说(比如我们上面的例子),它的 A 记录的格式是:..svc.cluster.local。当你访问这条 A 记录的时候,它解析到的就是该 Service 的 VIP 地址。

而对于指定了 clusterIP=None 的 Headless Service 来说,它的 A 记录的格式也是:..svc.cluster.local。但是,当你访问这条 A 记录的时候,它返回的是所有被代理的 Pod 的 IP 地址的集合。当然,如果你的客户端没办法解析这个集合的话,它可能会只会拿到第一个 Pod 的 IP 地址。

此外,对于 ClusterIP 模式的 Service 来说,它代理的 Pod 被自动分配的 A 记录的格式是:..pod.cluster.local。这条记录指向 Pod 的 IP 地址。

而对 Headless Service 来说,它代理的 Pod 被自动分配的 A 记录的格式是:…svc.cluster.local。这条记录也指向 Pod 的 IP 地址。

但如果你为 Pod 指定了 Headless Service,并且 Pod 本身声明了 hostname 和 subdomain 字段,那么这时候 Pod 的 A 记录就会变成:<pod 的 hostname>...svc.cluster.local ,比如:

apiVersion: v1
kind: Service
metadata:
  name: default-subdomain
spec:
  selector:
    name: busybox
  clusterIP: None
  ports:
  - name: foo
    port: 1234
    targetPort: 1234
---
apiVersion: v1
kind: Pod
metadata:
  name: busybox1
  labels:
    name: busybox
spec:
  hostname: busybox-1
  subdomain: default-subdomain
  containers:
  - image: busybox
    command:
      - sleep
      - "3600"
    name: busybox

在上面这个 Service 和 Pod 被创建之后,你就可以通过 busybox-1.default-subdomain.default.svc.cluster.local 解析到这个 Pod 的 IP 地址了。

需要注意的是,在 Kubernetes 里,/etc/hosts 文件是单独挂载的,这也是为什么 kubelet 能够对 hostname 进行修改并且 Pod 重建后依然有效的原因。这跟 Docker 的 Init 层是一个原理。

37.1 总结

在这篇文章里,我为你详细讲解了 Service 的工作原理。实际上,Service 机制,以及 Kubernetes 里的 DNS 插件,都是在帮助你解决同样一个问题,即:如何找到我的某一个容器?

这个问题在平台级项目中,往往就被称作服务发现,即:当我的一个服务(Pod)的 IP 地址是不固定的且没办法提前获知时,我该如何通过一个固定的方式访问到这个 Pod 呢?

而我在这里讲解的、ClusterIP 模式的 Service 为你提供的,就是一个 Pod 的稳定的 IP 地址,即 VIP。并且,这里 Pod 和 Service 的关系是可以通过 Label 确定的。

而 Headless Service 为你提供的,则是一个 Pod 的稳定的 DNS 名字,并且,这个名字是可以通过 Pod 名字和 Service 名字拼接出来的。

38 | 从外界连通Service与Service调试”三板斧”

在上一篇文章中,我为你介绍了 Service 机制的工作原理。通过这些讲解,你应该能够明白这样一个事实:Service 的访问信息在 Kubernetes 集群之外,其实是无效的。

这其实也容易理解:所谓 Service 的访问入口,其实就是每台宿主机上由 kube-proxy 生成的 iptables 规则,以及 kube-dns 生成的 DNS 记录。而一旦离开了这个集群,这些信息对用户来说,也就自然没有作用了。

所以,在使用 Kubernetes 的 Service 时,一个必须要面对和解决的问题就是: 如何从外部(Kubernetes 集群之外),访问到 Kubernetes 里创建的 Service?

这里 最常用的一种方式就是:NodePort 。我来为你举个例子。

apiVersion: v1
kind: Service
metadata:
  name: my-nginx
  labels:
    run: my-nginx
spec:
  type: NodePort
  ports:
  - nodePort: 8080
    targetPort: 80
    protocol: TCP
    name: http
  - nodePort: 443
    protocol: TCP
    name: https
  selector:
    run: my-nginx

在这个 Service 的定义里,我们声明它的类型是,type=NodePort。然后,我在 ports 字段里声明了 Service 的 8080 端口代理 Pod 的 80 端口,Service 的 443 端口代理 Pod 的 443 端口。

当然,如果你不显式地声明 nodePort 字段,Kubernetes 就会为你分配随机的可用端口来设置代理。这个端口的范围默认是 30000-32767,你可以通过 kube-apiserver 的-service-node-port-range 参数来修改它。

那么这时候,要访问这个 Service,你只需要访问 < 任何一台宿主机的 IP 地址 >:8080 就可以访问到某一个被代理的 Pod 的 80 端口了。

而在理解了我在上一篇文章中讲解的 Service 的工作原理之后,NodePort 模式也就非常容易理解了。显然,kube-proxy 要做的,就是在每台宿主机上生成这样一条 iptables 规则:

-A KUBE-NODEPORTS -p tcp -m comment --comment "default/my-nginx: nodePort" -m tcp --dport 8080 -j KUBE-SVC-67RL4FN6JRUPOJYM

而我在上一篇文章中已经讲到,KUBE-SVC-67RL4FN6JRUPOJYM 其实就是一组随机模式的 iptables 规则。所以接下来的流程,就跟 ClusterIP 模式完全一样了。

需要注意的是,在 NodePort 方式下,Kubernetes 会在 IP 包离开宿主机发往目的 Pod 时,对这个 IP 包做一次 SNAT 操作,如下所示:

-A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -m mark --mark 0x4000/0x4000 -j MASQUERADE

可以看到,这条规则设置在 POSTROUTING 检查点,也就是说,它给即将离开这台主机的 IP 包,进行了一次 SNAT 操作,将这个 IP 包的源地址替换成了这台宿主机上的 CNI 网桥地址,或者宿主机本身的 IP 地址(如果 CNI 网桥不存在的话)。

当然,这个 SNAT 操作只需要对 Service 转发出来的 IP 包进行(否则普通的 IP 包就被影响了)。而 iptables 做这个判断的依据,就是查看该 IP 包是否有一个”0x4000”的”标志”。你应该还记得,这个标志正是在 IP 包被执行 DNAT 操作之前被打上去的。

可是, 为什么一定要对流出的包做 SNAT操作呢?

这里的原理其实很简单,如下所示:

          client
            \ ^
             \ \
              v \
  node 1 <--- node 2
   | ^   SNAT
   | |   --->
   v |
endpoint

当一个外部的 client 通过 node 2 的地址访问一个 Service 的时候,node 2 上的负载均衡规则,就可能把这个 IP 包转发给一个在 node 1 上的 Pod。这里没有任何问题。

而当 node 1 上的这个 Pod 处理完请求之后,它就会按照这个 IP 包的源地址发出回复。

可是,如果没有做 SNAT 操作的话,这时候,被转发来的 IP 包的源地址就是 client 的 IP 地址。 所以此时,Pod 就会直接将回复发给client 。对于 client 来说,它的请求明明发给了 node 2,收到的回复却来自 node 1,这个 client 很可能会报错。

所以,在上图中,当 IP 包离开 node 2 之后,它的源 IP 地址就会被 SNAT 改成 node 2 的 CNI 网桥地址或者 node 2 自己的地址。这样,Pod 在处理完成之后就会先回复给 node 2(而不是 client),然后再由 node 2 发送给 client。

当然,这也就意味着这个 Pod 只知道该 IP 包来自于 node 2,而不是外部的 client。对于 Pod 需要明确知道所有请求来源的场景来说,这是不可以的。

所以这时候,你就可以将 Service 的 spec.externalTrafficPolicy 字段设置为 local,这就保证了所有 Pod 通过 Service 收到请求之后,一定可以看到真正的、外部 client 的源地址。

而这个机制的实现原理也非常简单: 这时候,一台宿主机上的 iptables 规则,会设置为只将 IP 包转发给运行在这台宿主机上的 Pod 。所以这时候,Pod 就可以直接使用源地址将回复包发出,不需要事先进行 SNAT 了。这个流程,如下所示:

      client
      ^ /   \
     / /     \
    / v       X
  node 1     node 2
   ^ |
   | |
   | v
endpoint

当然,这也就意味着如果在一台宿主机上,没有任何一个被代理的 Pod 存在,比如上图中的 node 2,那么你使用 node 2 的 IP 地址访问这个 Service,就是无效的。此时,你的请求会直接被 DROP 掉。

从外部访问 Service 的第二种方式,适用于公有云上的 Kubernetes 服务。这时候,你可以指定一个 LoadBalancer 类型的 Service ,如下所示:

---
kind: Service
apiVersion: v1
metadata:
  name: example-service
spec:
  ports:
  - port: 8765
    targetPort: 9376
  selector:
    app: example
  type: LoadBalancer

在公有云提供的 Kubernetes 服务里,都使用了一个叫作 CloudProvider 的转接层,来跟公有云本身的 API 进行对接。所以,在上述 LoadBalancer 类型的 Service 被提交后,Kubernetes 就会调用 CloudProvider 在公有云上为你创建一个负载均衡服务,并且把被代理的 Pod 的 IP 地址配置给负载均衡服务做后端。

而第三种方式,是 Kubernetes 在 1.7 之后支持的一个新特性,叫作 ExternalName。举个例子:

kind: Service
apiVersion: v1
metadata:
  name: my-service
spec:
  type: ExternalName
  externalName: my.database.example.com

在上述 Service 的 YAML 文件中,我指定了一个 externalName=my.database.example.com 的字段。而且你应该会注意到,这个 YAML 文件里不需要指定 selector。

这时候,当你通过 Service 的 DNS 名字访问它的时候,比如访问:my-service.default.svc.cluster.local。那么,Kubernetes 为你返回的就是my.database.example.com。所以说,ExternalName 类型的 Service,其实是在 kube-dns 里为你添加了一条 CNAME 记录。这时,访问 my-service.default.svc.cluster.local 就和访问 my.database.example.com 这个域名是一个效果了。

此外,Kubernetes 的 Service 还允许你为 Service 分配公有 IP 地址,比如下面这个例子:

kind: Service
apiVersion: v1
metadata:
  name: my-service
spec:
  selector:
    app: MyApp
  ports:
  - name: http
    protocol: TCP
    port: 80
    targetPort: 9376
  externalIPs:
  - 80.11.12.10

在上述 Service 中,我为它指定的 externalIPs=80.11.12.10,那么此时,你就可以通过访问 80.11.12.10:80 访问到被代理的 Pod 了。不过,在这里 Kubernetes 要求 externalIPs 必须是至少能够路由到一个 Kubernetes 的节点。你可以想一想这是为什么。

实际上, 在理解了 Kubernetes Service 机制的工作原理之后,很多与 Service 相关的问题,其实都可以通过分析 Service 在宿主机上对应的 iptables 规则(或者 IPVS 配置)得到解决。

比如,当你的 Service 没办法通过 DNS 访问到的时候。你就需要区分到底是 Service 本身的配置问题,还是集群的 DNS 出了问题。一个行之有效的方法,就是检查 Kubernetes 自己的 Master 节点的 Service DNS 是否正常:

# 在一个 Pod 里执行
$ nslookup kubernetes.default
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      kubernetes.default
Address 1: 10.0.0.1 kubernetes.default.svc.cluster.local

如果上面访问 kubernetes.default 返回的值都有问题,那你就需要检查 kube-dns 的运行状态和日志了。否则的话,你应该去检查自己的 Service 定义是不是有问题。

而如果你的 Service 没办法通过 ClusterIP 访问到的时候,你首先应该检查的是这个 Service 是否有 Endpoints:

$ kubectl get endpoints hostnames
NAME        ENDPOINTS
hostnames   10.244.0.5:9376,10.244.0.6:9376,10.244.0.7:9376

需要注意的是,如果你的 Pod 的 readniessProbe 没通过,它也不会出现在 Endpoints 列表里。

而如果 Endpoints 正常,那么你就需要确认 kube-proxy 是否在正确运行。在我们通过 kubeadm 部署的集群里,你应该看到 kube-proxy 输出的日志如下所示:

I1027 22:14:53.995134    5063 server.go:200] Running in resource-only container "/kube-proxy"
I1027 22:14:53.998163    5063 server.go:247] Using iptables Proxier.
I1027 22:14:53.999055    5063 server.go:255] Tearing down userspace rules. Errors here are acceptable.
I1027 22:14:54.038140    5063 proxier.go:352] Setting endpoints for "kube-system/kube-dns:dns-tcp" to [10.244.1.3:53]
I1027 22:14:54.038164    5063 proxier.go:352] Setting endpoints for "kube-system/kube-dns:dns" to [10.244.1.3:53]
I1027 22:14:54.038209    5063 proxier.go:352] Setting endpoints for "default/kubernetes:https" to [10.240.0.2:443]
I1027 22:14:54.038238    5063 proxier.go:429] Not syncing iptables until Services and Endpoints have been received from master
I1027 22:14:54.040048    5063 proxier.go:294] Adding new service "default/kubernetes:https" at 10.0.0.1:443/TCP
I1027 22:14:54.040154    5063 proxier.go:294] Adding new service "kube-system/kube-dns:dns" at 10.0.0.10:53/UDP
I1027 22:14:54.040223    5063 proxier.go:294] Adding new service "kube-system/kube-dns:dns-tcp" at 10.0.0.10:53/TCP

如果 kube-proxy 一切正常,你就应该仔细查看宿主机上的 iptables 了。而 一个 iptables 模式的 Service 对应的规则,我在上一篇以及这一篇文章里已经全部介绍到了,它们包括:

  1. KUBE-SERVICES 或者 KUBE-NODEPORTS 规则对应的 Service 的入口链,这个规则应该与 VIP 和 Service 端口一一对应;
  2. KUBE-SEP-(hash) 规则对应的 DNAT 链,这些规则应该与 Endpoints 一一对应;
  3. KUBE-SVC-(hash) 规则对应的负载均衡链,这些规则的数目应该与 Endpoints 数目一致;
  4. 如果是 NodePort 模式的话,还有 POSTROUTING 处的 SNAT 链。

通过查看这些链的数量、转发目的地址、端口、过滤条件等信息,你就能很容易发现一些异常的蛛丝马迹。

当然, 还有一种典型问题,就是 Pod 没办法通过 Service 访问到自己 。这往往就是因为 kubelet 的 hairpin-mode 没有被正确设置。关于 Hairpin 的原理我在前面已经介绍过,这里就不再赘述了。你只需要确保将 kubelet 的 hairpin-mode 设置为 hairpin-veth 或者 promiscuous-bridge 即可。

这里,你可以再回顾下第 34 篇文章 《Kubernetes 网络模型与 CNI 网络插件》 中的相关内容。

其中,在 hairpin-veth 模式下,你应该能看到 CNI 网桥对应的各个 VETH 设备,都将 Hairpin 模式设置为了 1,如下所示:

$ for d in /sys/devices/virtual/net/cni0/brif/veth*/hairpin_mode; do echo "$d = $(cat $d)"; done
/sys/devices/virtual/net/cni0/brif/veth4bfbfe74/hairpin_mode = 1
/sys/devices/virtual/net/cni0/brif/vethfc2a18c5/hairpin_mode = 1

而如果是 promiscuous-bridge 模式的话,你应该看到 CNI 网桥的混杂模式(PROMISC)被开启,如下所示:

$ ifconfig cni0 |grep PROMISC
UP BROADCAST RUNNING PROMISC MULTICAST  MTU:1460  Metric:1

38.1 总结

通过上述讲解不难看出,所谓 Service,其实就是 Kubernetes 为 Pod 分配的、固定的、基于 iptables(或者 IPVS)的访问入口。而这些访问入口代理的 Pod 信息,则来自于 Etcd,由 kube-proxy 通过控制循环来维护。

并且,你可以看到,Kubernetes 里面的 Service 和 DNS 机制,也都不具备强多租户能力。比如,在多租户情况下,每个租户应该拥有一套独立的 Service 规则(Service 只应该看到和代理同一个租户下的 Pod)。再比如 DNS,在多租户情况下,每个租户应该拥有自己的 kube-dns(kube-dns 只应该为同一个租户下的 Service 和 Pod 创建 DNS Entry)。

当然,在 Kubernetes 中,kube-proxy 和 kube-dns 其实也是普通的插件而已。你完全可以根据自己的需求,实现符合自己预期的 Service。

39 | 谈谈Service与Ingress

在上一篇文章中,我为你详细讲解了将 Service 暴露给外界的三种方法。其中有一个叫作 LoadBalancer 类型的 Service,它会为你在 Cloud Provider(比如:Google Cloud 或者 OpenStack)里创建一个与该 Service 对应的负载均衡服务。

但是,相信你也应该能感受到,由于每个 Service 都要有一个负载均衡服务,所以这个做法实际上既浪费成本又高。作为用户,我其实更希望看到 Kubernetes 为我内置一个全局的负载均衡器。然后,通过我访问的 URL,把请求转发给不同的后端 Service。

这种全局的、为了代理不同后端 Service 而设置的负载均衡服务,就是 Kubernetes 里的 Ingress 服务。

所以,Ingress 的功能其实很容易理解: 所谓 Ingress,就是 Service 的”Service”。

举个例子,假如我现在有这样一个站点:https://cafe.example.com 。其中, https://cafe.example.com/coffee ,对应的是”咖啡点餐系统”。而, https://cafe.example.com/tea ,对应的则是”茶水点餐系统”。这两个系统,分别由名叫 coffee 和 tea 这样两个 Deployment 来提供服务。

那么现在, 我如何能使用 Kubernetes 的 Ingress 来创建一个统一的负载均衡器,从而实现当用户访问不同的域名时,能够访问到不同的 Deployment 呢?

上述功能,在 Kubernetes 里就需要通过 Ingress 对象来描述,如下所示:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: cafe-ingress
spec:
  tls:
  - hosts:
    - cafe.example.com
    secretName: cafe-secret
  rules:
  - host: cafe.example.com
    http:
      paths:
      - path: /tea
        backend:
          serviceName: tea-svc
          servicePort: 80
      - path: /coffee
        backend:
          serviceName: coffee-svc
          servicePort: 80

在上面这个名叫 cafe-ingress.yaml 文件中,最值得我们关注的,是 rules 字段。在 Kubernetes 里,这个字段叫作: IngressRule

IngressRule 的 Key,就叫做:host。它必须是一个标准的域名格式(Fully Qualified Domain Name)的字符串,而不能是 IP 地址。

备注:Fully Qualified Domain Name 的具体格式,可以参考 RFC 3986 标准。

而 host 字段定义的值,就是这个 Ingress 的入口。这也就意味着,当用户访问 cafe.example.com 的时候,实际上访问到的是这个 Ingress 对象。这样,Kubernetes 就能使用 IngressRule 来对你的请求进行下一步转发。

而接下来 IngressRule 规则的定义,则依赖于 path 字段。你可以简单地理解为,这里的每一个 path 都对应一个后端 Service。所以在我们的例子里,我定义了两个 path,它们分别对应 coffee 和 tea 这两个 Deployment 的 Service(即:coffee-svc 和 tea-svc)。

通过上面的讲解,不难看到,所谓 Ingress 对象,其实就是 Kubernetes 项目对”反向代理”的一种抽象。

一个 Ingress 对象的主要内容,实际上就是一个”反向代理”服务(比如:Nginx)的配置文件的描述。而这个代理服务对应的转发规则,就是 IngressRule。

这就是为什么在每条 IngressRule 里,需要有一个 host 字段来作为这条 IngressRule 的入口,然后还需要有一系列 path 字段来声明具体的转发策略。这其实跟 Nginx、HAproxy 等项目的配置文件的写法是一致的。

而有了 Ingress 这样一个统一的抽象,Kubernetes 的用户就无需关心 Ingress 的具体细节了。

在实际的使用中,你只需要从社区里选择一个具体的 Ingress Controller,把它部署在 Kubernetes 集群里即可。

然后,这个 Ingress Controller 会根据你定义的 Ingress 对象,提供对应的代理能力。目前,业界常用的各种反向代理项目,比如 Nginx、HAProxy、Envoy、Traefik 等,都已经为 Kubernetes 专门维护了对应的 Ingress Controller。

接下来, 我就以最常用的 Nginx Ingress Controller 为例,在我们前面用 kubeadm 部署的 Bare-metal 环境中,和你实践一下 Ingress 机制的使用过程。

部署 Nginx Ingress Controller 的方法非常简单,如下所示:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/mandatory.yaml

其中,在 mandatory.yaml 这个文件里,正是 Nginx 官方为你维护的 Ingress Controller 的定义。我们来看一下它的内容:

kind: ConfigMap
apiVersion: v1
metadata:
  name: nginx-configuration
  namespace: ingress-nginx
  labels:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: nginx-ingress-controller
  namespace: ingress-nginx
  labels:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: ingress-nginx
      app.kubernetes.io/part-of: ingress-nginx
  template:
    metadata:
      labels:
        app.kubernetes.io/name: ingress-nginx
        app.kubernetes.io/part-of: ingress-nginx
      annotations:
        ...
    spec:
      serviceAccountName: nginx-ingress-serviceaccount
      containers:
        - name: nginx-ingress-controller
          image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.20.0
          args:
            - /nginx-ingress-controller
            - --configmap=$(POD_NAMESPACE)/nginx-configuration
            - --publish-service=$(POD_NAMESPACE)/ingress-nginx
            - --annotations-prefix=nginx.ingress.kubernetes.io
          securityContext:
            capabilities:
              drop:
                - ALL
              add:
                - NET_BIND_SERVICE
            # www-data -> 33
            runAsUser: 33
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: POD_NAMESPACE
            - name: http
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
          ports:
            - name: http
              containerPort: 80
            - name: https
              containerPort: 443

可以看到,在上述 YAML 文件中,我们定义了一个使用 nginx-ingress-controller 镜像的 Pod。需要注意的是,这个 Pod 的启动命令需要使用该 Pod 所在的 Namespace 作为参数。而这个信息,当然是通过 Downward API 拿到的,即:Pod 的 env 字段里的定义(env.valueFrom.fieldRef.fieldPath)。

而这个 Pod 本身,就是一个监听 Ingress 对象以及它所代理的后端 Service 变化的控制器。

当一个新的 Ingress 对象由用户创建后,nginx-ingress-controller 就会根据 Ingress 对象里定义的内容,生成一份对应的 Nginx 配置文件(/etc/nginx/nginx.conf),并使用这个配置文件启动一个 Nginx 服务。

而一旦 Ingress 对象被更新,nginx-ingress-controller 就会更新这个配置文件。需要注意的是,如果这里只是被代理的 Service 对象被更新,nginx-ingress-controller 所管理的 Nginx 服务是不需要重新加载(reload)的。这当然是因为 nginx-ingress-controller 通过 Nginx Lua 方案实现了 Nginx Upstream 的动态配置。

此外,nginx-ingress-controller 还允许你通过 Kubernetes 的 ConfigMap 对象来对上述 Nginx 配置文件进行定制。这个 ConfigMap 的名字,需要以参数的方式传递给 nginx-ingress-controller。而你在这个 ConfigMap 里添加的字段,将会被合并到最后生成的 Nginx 配置文件当中。

可以看到,一个 Nginx Ingress Controller 为你提供的服务,其实是一个可以根据 Ingress 对象和被代理后端 Service 的变化,来自动进行更新的 Nginx 负载均衡器。

当然,为了让用户能够用到这个 Nginx,我们就需要创建一个 Service 来把 Nginx Ingress Controller 管理的 Nginx 服务暴露出去,如下所示:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/provider/baremetal/service-nodeport.yaml

由于我们使用的是 Bare-metal 环境,所以 service-nodeport.yaml 文件里的内容,就是一个 NodePort 类型的 Service,如下所示:

apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx
  namespace: ingress-nginx
  labels:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
spec:
  type: NodePort
  ports:
    - name: http
      port: 80
      targetPort: 80
      protocol: TCP
    - name: https
      port: 443
      targetPort: 443
      protocol: TCP
  selector:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx

可以看到,这个 Service 的唯一工作,就是将所有携带 ingress-nginx 标签的 Pod 的 80 和 433 端口暴露出去。

而如果你是公有云上的环境,你需要创建的就是 LoadBalancer 类型的 Service 了。

上述操作完成后,你一定要记录下这个 Service 的访问入口,即:宿主机的地址和 NodePort 的端口,如下所示:

$ kubectl get svc -n ingress-nginx
NAME            TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGE
ingress-nginx   NodePort   10.105.72.96   <none>        80:30044/TCP,443:31453/TCP   3h

为了后面方便使用,我会把上述访问入口设置为环境变量:

IC_IP=10.168.0.2 # 任意一台宿主机的地址
IC_HTTPS_PORT=31453 # NodePort 端口

在 Ingress Controller 和它所需要的 Service 部署完成后,我们就可以使用它了。

备注:这个”咖啡厅”Ingress 的所有示例文件,都在 这里

首先,我们要在集群里部署我们的应用 Pod 和它们对应的 Service,如下所示:

kubectl create -f cafe.yaml

然后,我们需要创建 Ingress 所需的 SSL 证书(tls.crt)和密钥(tls.key),这些信息都是通过 Secret 对象定义好的,如下所示:

kubectl create -f cafe-secret.yaml

这一步完成后,我们就可以创建在本篇文章一开始定义的 Ingress 对象了,如下所示:

kubectl create -f cafe-ingress.yaml

这时候,我们就可以查看一下这个 Ingress 对象的信息,如下所示:

$ kubectl get ingress
NAME           HOSTS              ADDRESS   PORTS     AGE
cafe-ingress   cafe.example.com             80, 443   2h

$ kubectl describe ingress cafe-ingress
Name:             cafe-ingress
Namespace:        default
Address:
Default backend:  default-http-backend:80 (<none>)
TLS:
  cafe-secret terminates cafe.example.com
Rules:
  Host              Path  Backends
  ----              ----  --------
  cafe.example.com
                    /tea      tea-svc:80 (<none>)
                    /coffee   coffee-svc:80 (<none>)
Annotations:
Events:
  Type    Reason  Age   From                      Message
  ----    ------  ----  ----                      -------
  Normal  CREATE  4m    nginx-ingress-controller  Ingress default/cafe-ingress

可以看到,这个 Ingress 对象最核心的部分,正是 Rules 字段。其中,我们定义的 Host 是cafe.example.com,它有两条转发规则(Path),分别转发给 tea-svc 和 coffee-svc。

当然,在 Ingress 的 YAML 文件里,你还可以定义多个 Host,比如restaurant.example.com、movie.example.com等等,来为更多的域名提供负载均衡服务。

接下来,我们就可以通过访问这个 Ingress 的地址和端口,访问到我们前面部署的应用了,比如,当我们访问 https://cafe.example.com:443/coffee 时,应该是 coffee 这个 Deployment 负责响应我的请求。我们可以来尝试一下:

$ curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/coffee --insecureServer address: 10.244.1.56:80
Server name: coffee-7dbb5795f6-vglbv
Date: 03/Nov/2018:03:55:32 +0000
URI: /coffee
Request ID: e487e672673195c573147134167cf898

我们可以看到,访问这个 URL 得到的返回信息是:Server name: coffee-7dbb5795f6-vglbv。这正是 coffee 这个 Deployment 的名字。

而当我访问 https://cafe.example.com:433/tea 的时候,则应该是 tea 这个 Deployment 负责响应我的请求(Server name: tea-7d57856c44-lwbnp),如下所示:

$ curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/tea --insecure
Server address: 10.244.1.58:80
Server name: tea-7d57856c44-lwbnp
Date: 03/Nov/2018:03:55:52 +0000
URI: /tea
Request ID: 32191f7ea07cb6bb44a1f43b8299415c

可以看到,Nginx Ingress Controller 为我们创建的 Nginx 负载均衡器,已经成功地将请求转发给了对应的后端 Service。

以上,就是 Kubernetes 里 Ingress 的设计思想和使用方法了。

不过,你可能会有一个疑问, 如果我的请求没有匹配到任何一条 IngressRule,那么会发生什么呢?

首先,既然 Nginx Ingress Controller 是用 Nginx 实现的,那么它当然会为你返回一个 Nginx 的 404 页面。

不过,Ingress Controller 也允许你通过 Pod 启动命令里的-default-backend-service 参数,设置一条默认规则,比如:-default-backend-service=nginx-default-backend。

这样,任何匹配失败的请求,就都会被转发到这个名叫 nginx-default-backend 的 Service。所以,你就可以通过部署一个专门的 Pod,来为用户返回自定义的 404 页面了。

39.1 总结

在这篇文章里,我为你详细讲解了 Ingress 这个概念在 Kubernetes 里到底是怎么一回事儿。正如我在文章里所描述的,Ingress 实际上就是 Kubernetes 对”反向代理”的抽象。

目前,Ingress 只能工作在七层,而 Service 只能工作在四层。所以当你想要在 Kubernetes 里为应用进行 TLS 配置等 HTTP 相关的操作时,都必须通过 Ingress 来进行。

当然,正如同很多负载均衡项目可以同时提供七层和四层代理一样,将来 Ingress 的进化中,也会加入四层代理的能力。这样,一个比较完善的”反向代理”机制就比较成熟了。

而 Kubernetes 提出 Ingress 概念的原因其实也非常容易理解,有了 Ingress 这个抽象,用户就可以根据自己的需求来自由选择 Ingress Controller。比如,如果你的应用对代理服务的中断非常敏感,那么你就应该考虑选择类似于 Traefik 这样支持”热加载”的 Ingress Controller 实现。

更重要的是,一旦你对社区里现有的 Ingress 方案感到不满意,或者你已经有了自己的负载均衡方案时,你只需要做很少的编程工作,就可以实现一个自己的 Ingress Controller。

Kubernetes作业调度与资源管理

40 | Kubernetes的资源模型与资源管理

作为一个容器集群编排与管理项目,Kubernetes 为用户提供的基础设施能力,不仅包括了我在前面为你讲述的应用定义和描述的部分,还包括了对应用的资源管理和调度的处理。那么,从今天这篇文章开始,我就来为你详细讲解一下后面这部分内容。

而作为 Kubernetes 的资源管理与调度部分的基础,我们要从它的资源模型开始说起。

我在前面的文章中已经提到过,在 Kubernetes 里,Pod 是最小的原子调度单位。这也就意味着,所有跟调度和资源管理相关的属性都应该是属于 Pod 对象的字段。而这其中最重要的部分,就是 Pod 的 CPU 和内存配置,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: frontend
spec:
  containers:
  - name: db
    image: mysql
    env:
    - name: MYSQL_ROOT_PASSWORD
      value: "password"
    resources:
      requests:
        memory: "64Mi"
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"
  - name: wp
    image: wordpress
    resources:
      requests:
        memory: "64Mi"
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"

备注:关于哪些属性属于 Pod 对象,而哪些属性属于 Container,你可以在回顾一下第 14 篇文章 《深入解析 Pod 对象(一):基本概念》 中的相关内容。

在 Kubernetes 中,像 CPU 这样的资源被称作”可压缩资源”(compressible resources)。它的典型特点是,当可压缩资源不足时,Pod 只会”饥饿”,但不会退出。

而像内存这样的资源,则被称作”不可压缩资源(incompressible resources)。当不可压缩资源不足时,Pod 就会因为 OOM(Out-Of-Memory)被内核杀掉。

而由于 Pod 可以由多个 Container 组成,所以 CPU 和内存资源的限额,是要配置在每个 Container 的定义上的。这样,Pod 整体的资源配置,就由这些 Container 的配置值累加得到。

其中,Kubernetes 里为 CPU 设置的单位是”CPU 的个数”。比如,cpu=1 指的就是,这个 Pod 的 CPU 限额是 1 个 CPU。当然,具体”1 个 CPU”在宿主机上如何解释,是 1 个 CPU 核心,还是 1 个 vCPU,还是 1 个 CPU 的超线程(Hyperthread),完全取决于宿主机的 CPU 实现方式。Kubernetes 只负责保证 Pod 能够使用到”1 个 CPU”的计算能力。

此外,Kubernetes 允许你将 CPU 限额设置为分数,比如在我们的例子里,CPU limits 的值就是 500m。所谓 500m,指的就是 500 millicpu,也就是 0.5 个 CPU 的意思。这样,这个 Pod 就会被分配到 1 个 CPU 一半的计算能力。

当然, 你也可以直接把这个配置写成 cpu=0.5。但在实际使用时,我还是推荐你使用 500m 的写法,毕竟这才是 Kubernetes 内部通用的 CPU 表示方式。

而对于内存资源来说,它的单位自然就是 bytes。Kubernetes 支持你使用 Ei、Pi、Ti、Gi、Mi、Ki(或者 E、P、T、G、M、K)的方式来作为 bytes 的值。比如,在我们的例子里,Memory requests 的值就是 64MiB (2 的 26 次方 bytes) 。这里要注意区分 MiB(mebibyte)和 MB(megabyte)的区别。

备注:1Mi=10241024;1M=10001000

此外,不难看到, Kubernetes 里 Pod 的 CPU 和内存资源,实际上还要分为 limits 和 requests 两种情况 ,如下所示:

spec.containers[].resources.limits.cpu
spec.containers[].resources.limits.memory
spec.containers[].resources.requests.cpu
spec.containers[].resources.requests.memory

这两者的区别其实非常简单:在调度的时候,kube-scheduler 只会按照 requests 的值进行计算。而在真正设置 Cgroups 限制的时候,kubelet 则会按照 limits 的值来进行设置。

更确切地说,当你指定了 requests.cpu=250m 之后,相当于将 Cgroups 的 cpu.shares 的值设置为 (250/1000)*1024。而当你没有设置 requests.cpu 的时候,cpu.shares 默认则是 1024。这样,Kubernetes 就通过 cpu.shares 完成了对 CPU 时间的按比例分配。

而如果你指定了 limits.cpu=500m 之后,则相当于将 Cgroups 的 cpu.cfs_quota_us 的值设置为 (500/1000)*100ms,而 cpu.cfs_period_us 的值始终是 100ms。这样,Kubernetes 就为你设置了这个容器只能用到 CPU 的 50%。

而对于内存来说,当你指定了 limits.memory=128Mi 之后,相当于将 Cgroups 的 memory.limit_in_bytes 设置为 128 * 1024 * 1024 。而需要注意的是,在调度的时候,调度器只会使用 requests.memory=64Mi 来进行判断。

Kubernetes 这种对 CPU 和内存资源限额的设计,实际上参考了 Borg 论文中对”动态资源边界”的定义 ,既:容器化作业在提交时所设置的资源边界,并不一定是调度系统所必须严格遵守的,这是因为在实际场景中,大多数作业使用到的资源其实远小于它所请求的资源限额。

基于这种假设,Borg 在作业被提交后,会主动减小它的资源限额配置,以便容纳更多的作业、提升资源利用率。而当作业资源使用量增加到一定阈值时,Borg 会通过”快速恢复”过程,还原作业原始的资源限额,防止出现异常情况。

而 Kubernetes 的 requests+limits 的做法,其实就是上述思路的一个简化版:用户在提交 Pod 时,可以声明一个相对较小的 requests 值供调度器使用,而 Kubernetes 真正设置给容器 Cgroups 的,则是相对较大的 limits 值。不难看到,这跟 Borg 的思路相通的。

在理解了 Kubernetes 资源模型的设计之后,我再来和你谈谈 Kubernetes 里的 QoS 模型。 在 Kubernetes 中,不同的 requests 和 limits 的设置方式,其实会将这个 Pod 划分到不同的 QoS 级别当中。

当 Pod 里的每一个 Container 都同时设置了 requests 和 limits,并且 requests 和 limits 值相等的时候,这个 Pod 就属于 Guaranteed 类别 ,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: qos-demo
  namespace: qos-example
spec:
  containers:
  - name: qos-demo-ctr
    image: nginx
    resources:
      limits:
        memory: "200Mi"
        cpu: "700m"
      requests:
        memory: "200Mi"
        cpu: "700m"

当这个 Pod 创建之后,它的 qosClass 字段就会被 Kubernetes 自动设置为 Guaranteed。 需要注意的是,当 Pod 仅设置了 limits 没有设置 requests 的时候,Kubernetes 会自动为它设置与 limits 相同的 requests 值,所以,这也属于 Guaranteed 情况。

而当 Pod 不满足 Guaranteed 的条件,但至少有一个 Container 设置了 requests。那么这个 Pod 就会被划分到 Burstable 类别。 比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: qos-demo-2
  namespace: qos-example
spec:
  containers:
  - name: qos-demo-2-ctr
    image: nginx
    resources:
      limits
        memory: "200Mi"
      requests:
        memory: "100Mi"

而如果一个 Pod 既没有设置 requests,也没有设置 limits,那么它的 QoS 类别就是 BestEffort。 比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: qos-demo-3
  namespace: qos-example
spec:
  containers:
  - name: qos-demo-3-ctr
    image: nginx

那么,Kubernetes 为 Pod 设置这样三种 QoS 类别,具体有什么作用呢?

实际上, QoS 划分的主要应用场景,是当宿主机资源紧张的时候,kubelet 对 Pod 进行 Eviction(即资源回收)时需要用到的。

具体地说,当 Kubernetes 所管理的宿主机上不可压缩资源短缺时,就有可能触发 Eviction。比如,可用内存(memory.available)、可用的宿主机磁盘空间(nodefs.available),以及容器运行时镜像存储空间(imagefs.available)等等。

目前,Kubernetes 为你设置的 Eviction 的默认阈值如下所示:

memory.available<100Mi
nodefs.available<10%
nodefs.inodesFree<5%
imagefs.available<15%

当然,上述各个触发条件在 kubelet 里都是可配置的。比如下面这个例子:

kubelet --eviction-hard=imagefs.available<10%,memory.available<500Mi,nodefs.available<5%,nodefs.inodesFree<5% --eviction-soft=imagefs.available<30%,nodefs.available<10% --eviction-soft-grace-period=imagefs.available=2m,nodefs.available=2m --eviction-max-pod-grace-period=600

在这个配置中,你可以看到 Eviction 在 Kubernetes 里其实分为 Soft 和 Hard 两种模式。

其中,Soft Eviction 允许你为 Eviction 过程设置一段”优雅时间”,比如上面例子里的 imagefs.available=2m,就意味着当 imagefs 不足的阈值达到 2 分钟之后,kubelet 才会开始 Eviction 的过程。

而 Hard Eviction 模式下,Eviction 过程就会在阈值达到之后立刻开始。

Kubernetes 计算 Eviction 阈值的数据来源,主要依赖于从 Cgroups 读取到的值,以及使用 cAdvisor 监控到的数据。

当宿主机的 Eviction 阈值达到后,就会进入 MemoryPressure 或者 DiskPressure 状态,从而避免新的 Pod 被调度到这台宿主机上。

而当 Eviction 发生的时候,kubelet 具体会挑选哪些 Pod 进行删除操作,就需要参考这些 Pod 的 QoS 类别了。

  • 首当其冲的,自然是 BestEffort 类别的 Pod。
  • 其次,是属于 Burstable 类别、并且发生”饥饿”的资源使用量已经超出了 requests 的 Pod。
  • 最后,才是 Guaranteed 类别。并且,Kubernetes 会保证只有当 Guaranteed 类别的 Pod 的资源使用量超过了其 limits 的限制,或者宿主机本身正处于 Memory Pressure 状态时,Guaranteed 的 Pod 才可能被选中进行 Eviction 操作。

当然,对于同 QoS 类别的 Pod 来说,Kubernetes 还会根据 Pod 的优先级来进行进一步地排序和选择。

在理解了 Kubernetes 里的 QoS 类别的设计之后,我再来为你讲解一下 Kubernetes 里一个非常有用的特性:cpuset 的设置。

我们知道,在使用容器的时候,你可以通过设置 cpuset 把容器绑定到某个 CPU 的核上,而不是像 cpushare 那样共享 CPU 的计算能力。

这种情况下,由于操作系统在 CPU 之间进行上下文切换的次数大大减少,容器里应用的性能会得到大幅提升。事实上, cpuset 方式,是生产环境里部署在线应用类型的 Pod 时,非常常用的一种方式。

可是,这样的需求在 Kubernetes 里又该如何实现呢?

其实非常简单。

  • 首先,你的 Pod 必须是 Guaranteed 的 QoS 类型;
  • 然后,你只需要将 Pod 的 CPU 资源的 requests 和 limits 设置为同一个相等的整数值即可。

比如下面这个例子:

spec:
  containers:
  - name: nginx
    image: nginx
    resources:
      limits:
        memory: "200Mi"
        cpu: "2"
      requests:
        memory: "200Mi"
        cpu: "2"

这时候,该 Pod 就会被绑定在 2 个独占的 CPU 核上。当然,具体是哪两个 CPU 核,是由 kubelet 为你分配的。

以上,就是 Kubernetes 的资源模型和 QoS 类别相关的主要内容。

40.1 总结

在实际的使用中,我强烈建议你将 DaemonSet 的 Pod 都设置为 Guaranteed 的 QoS 类型。否则,一旦 DaemonSet 的 Pod 被回收,它又会立即在原宿主机上被重建出来,这就使得前面资源回收的动作,完全没有意义了。

41 | 十字路口上的Kubernetes默认调度器

在 Kubernetes 项目中,默认调度器的主要职责,就是为一个新创建出来的 Pod,寻找一个最合适的节点(Node)。

而这里”最合适”的含义,包括两层:

  1. 从集群所有的节点中,根据调度算法挑选出所有可以运行该 Pod 的节点;
  2. 从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。

所以在具体的调度流程中,默认调度器会首先调用一组叫作 Predicate 的调度算法,来检查每个 Node。然后,再调用一组叫作 Priority 的调度算法,来给上一步得到的结果里的每个 Node 打分。最终的调度结果,就是得分最高的那个 Node。

而我在前面的文章中曾经介绍过,调度器对一个 Pod 调度成功,实际上就是将它的 spec.nodeName 字段填上调度结果的节点名字。

备注:这里你可以再回顾下第 14 篇文章 《深入解析 Pod 对象(一):基本概念》 中的相关内容。

在 Kubernetes 中,上述调度机制的工作原理,可以用如下所示的一幅示意图来表示。

kubernetes中默认调度器的调度机制

可以看到,Kubernetes 的调度器的核心,实际上就是两个相互独立的控制循环。

其中, 第一个控制循环,我们可以称之为 Informer Path 。它的主要目的,是启动一系列 Informer,用来监听(Watch)Etcd 中 Pod、Node、Service 等与调度相关的 API 对象的变化。比如,当一个待调度 Pod(即:它的 nodeName 字段是空的)被创建出来之后,调度器就会通过 Pod Informer 的 Handler,将这个待调度 Pod 添加进调度队列。

在默认情况下,Kubernetes 的调度队列是一个 PriorityQueue(优先级队列),并且当某些集群信息发生变化的时候,调度器还会对调度队列里的内容进行一些特殊操作。这里的设计,主要是出于调度优先级和抢占的考虑,我会在后面的文章中再详细介绍这部分内容。

此外,Kubernetes 的默认调度器还要负责对调度器缓存(即:scheduler cache)进行更新。事实上,Kubernetes 调度部分进行性能优化的一个最根本原则,就是尽最大可能将集群信息 Cache 化,以便从根本上提高 Predicate 和 Priority 调度算法的执行效率。

第二个控制循环,是调度器负责 Pod 调度的主循环,我们可以称之为 Scheduling Path。

Scheduling Path 的主要逻辑,就是不断地从调度队列里出队一个 Pod。然后,调用 Predicates 算法进行”过滤”。这一步”过滤”得到的一组 Node,就是所有可以运行这个 Pod 的宿主机列表。当然,Predicates 算法需要的 Node 信息,都是从 Scheduler Cache 里直接拿到的,这是调度器保证算法执行效率的主要手段之一。

接下来,调度器就会再调用 Priorities 算法为上述列表里的 Node 打分,分数从 0 到 10。得分最高的 Node,就会作为这次调度的结果。

调度算法执行完成后,调度器就需要将 Pod 对象的 nodeName 字段的值,修改为上述 Node 的名字。 这个步骤在 Kubernetes 里面被称作 Bind。

但是,为了不在关键调度路径里远程访问 APIServer,Kubernetes 的默认调度器在 Bind 阶段,只会更新 Scheduler Cache 里的 Pod 和 Node 的信息。 这种基于”乐观”假设的 API 对象更新方式,在 Kubernetes 里被称作 Assume。

Assume 之后,调度器才会创建一个 Goroutine 来异步地向 APIServer 发起更新 Pod 的请求,来真正完成 Bind 操作。如果这次异步的 Bind 过程失败了,其实也没有太大关系,等 Scheduler Cache 同步之后一切就会恢复正常。

当然,正是由于上述 Kubernetes 调度器的”乐观”绑定的设计,当一个新的 Pod 完成调度需要在某个节点上运行起来之前,该节点上的 kubelet 还会通过一个叫作 Admit 的操作来再次验证该 Pod 是否确实能够运行在该节点上。这一步 Admit 操作,实际上就是把一组叫作 GeneralPredicates 的、最基本的调度算法,比如:”资源是否可用””端口是否冲突”等再执行一遍,作为 kubelet 端的二次确认。

备注:关于 Kubernetes 默认调度器的调度算法,我会在下一篇文章里为你讲解。

除了上述的”Cache 化”和”乐观绑定”,Kubernetes 默认调度器还有一个重要的设计,那就是”无锁化”。

在 Scheduling Path 上,调度器会启动多个 Goroutine 以节点为粒度并发执行 Predicates 算法,从而提高这一阶段的执行效率。而与之类似的,Priorities 算法也会以 MapReduce 的方式并行计算然后再进行汇总。而在这些所有需要并发的路径上,调度器会避免设置任何全局的竞争资源,从而免去了使用锁进行同步带来的巨大的性能损耗。

所以,在这种思想的指导下,如果你再去查看一下前面的调度器原理图,你就会发现,Kubernetes 调度器只有对调度队列和 Scheduler Cache 进行操作时,才需要加锁。而这两部分操作,都不在 Scheduling Path 的算法执行路径上。

当然,Kubernetes 调度器的上述设计思想,也是在集群规模不断增长的演进过程中逐步实现的。尤其是 “Cache 化”,这个变化其实是最近几年 Kubernetes 调度器性能得以提升的一个关键演化。

不过,随着 Kubernetes 项目发展到今天,它的默认调度器也已经来到了一个关键的十字路口。事实上,Kubernetes 现今发展的主旋律,是整个开源项目的”民主化”。也就是说,Kubernetes 下一步发展的方向,是组件的轻量化、接口化和插件化。所以,我们才有了 CRI、CNI、CSI、CRD、Aggregated APIServer、Initializer、Device Plugin 等各个层级的可扩展能力。可是,默认调度器,却成了 Kubernetes 项目里最后一个没有对外暴露出良好定义过的、可扩展接口的组件。

当然,这是有一定的历史原因的。在过去几年,Kubernetes 发展的重点,都是以功能性需求的实现和完善为核心。在这个过程中,它的很多决策,还是以优先服务公有云的需求为主,而性能和规模则居于相对次要的位置。

而现在,随着 Kubernetes 项目逐步趋于稳定,越来越多的用户开始把 Kubernetes 用在规模更大、业务更加复杂的私有集群当中。很多以前的 Mesos 用户,也开始尝试使用 Kubernetes 来替代其原有架构。 在这些场景下,对默认调度器进行扩展和重新实现,就成了社区对 Kubernetes 项目最主要的一个诉求。

所以,Kubernetes 的默认调度器,是目前这个项目里为数不多的、正在经历大量重构的核心组件之一。这些正在进行的重构的目的,一方面是将默认调度器里大量的”技术债”清理干净;另一方面,就是为默认调度器的可扩展性设计进行铺垫。

而 Kubernetes 默认调度器的可扩展性设计,可以用如下所示的一幅示意图来描述:

Kubernetes默认调度器的可扩展性设计

可以看到,默认调度器的可扩展机制,在 Kubernetes 里面叫作 Scheduler Framework。顾名思义,这个设计的主要目的,就是在调度器生命周期的各个关键点上,为用户暴露出可以进行扩展和实现的接口,从而实现由用户自定义调度器的能力。

上图中,每一个绿色的箭头都是一个可以插入自定义逻辑的接口。比如,上面的 Queue 部分,就意味着你可以在这一部分提供一个自己的调度队列的实现,从而控制每个 Pod 开始被调度(出队)的时机。

而 Predicates 部分,则意味着你可以提供自己的过滤算法实现,根据自己的需求,来决定选择哪些机器。

需要注意的是,上述这些可插拔式逻辑,都是标准的 Go 语言插件机制(Go plugin 机制) ,也就是说,你需要在编译的时候选择把哪些插件编译进去。

有了上述设计之后,扩展和自定义 Kubernetes 的默认调度器就变成了一件非常容易实现的事情。这也意味着默认调度器在后面的发展过程中,必然不会在现在的实现上再添加太多的功能,反而还会对现在的实现进行精简,最终成为 Scheduler Framework 的一个最小实现。而调度领域更多的创新和工程工作,就可以交给整个社区来完成了。这个思路,是完全符合我在前面提到的 Kubernetes 的”民主化”设计的。

不过,这样的 Scheduler Framework 也有一个不小的问题,那就是一旦这些插入点的接口设计不合理,就会导致整个生态没办法很好地把这个插件机制使用起来。而与此同时,这些接口本身的变更又是一个费时费力的过程,一旦把控不好,就很可能会把社区推向另一个极端,即:Scheduler Framework 没法实际落地,大家只好都再次 fork kube-scheduler。

42 | Kubernetes默认调度器调度策略解析

在上一篇文章中,我主要为你讲解了 Kubernetes 默认调度器的设计原理和架构。在今天这篇文章中,我们就专注在调度过程中 Predicates 和 Priorities 这两个调度策略主要发生作用的阶段。

首先,我们一起看看 Predicates。

Predicates 在调度过程中的作用,可以理解为 Filter ,即:它按照调度策略,从当前集群的所有节点中,”过滤”出一系列符合条件的节点。这些节点,都是可以运行待调度 Pod 的宿主机。

而在 Kubernetes 中,默认的调度策略有如下三种。

  1. 第一种类型,叫作 GeneralPredicates。

    顾名思义,这一组过滤规则,负责的是最基础的调度策略。比如,PodFitsResources 计算的就是宿主机的 CPU 和内存资源等是否够用。

    当然,我在前面已经提到过,PodFitsResources 检查的只是 Pod 的 requests 字段。需要注意的是,Kubernetes 的调度器并没有为 GPU 等硬件资源定义具体的资源类型,而是统一用一种名叫 Extended Resource 的、Key-Value 格式的扩展字段来描述的。比如下面这个例子:

    apiVersion: v1
    kind: Pod
    metadata:
    name: extended-resource-demo
    spec:
    containers:
    - name: extended-resource-demo-ctr
        image: nginx
        resources:
        requests:
            alpha.kubernetes.io/nvidia-gpu: 2
        limits:
            alpha.kubernetes.io/nvidia-gpu: 2

    可以看到,我们这个 Pod 通过alpha.kubernetes.io/nvidia-gpu=2这样的定义方式,声明使用了两个 NVIDIA 类型的 GPU。

    而在 PodFitsResources 里面,调度器其实并不知道这个字段 Key 的含义是 GPU,而是直接使用后面的 Value 进行计算。当然,在 Node 的 Capacity 字段里,你也得相应地加上这台宿主机上 GPU 的总数,比如:alpha.kubernetes.io/nvidia-gpu=4。这些流程,我在后面讲解 Device Plugin 的时候会详细介绍到。

    而 PodFitsHost 检查的是,宿主机的名字是否跟 Pod 的 spec.nodeName 一致。

    PodFitsHostPorts 检查的是,Pod 申请的宿主机端口(spec.nodePort)是不是跟已经被使用的端口有冲突。

    PodMatchNodeSelector 检查的是,Pod 的 nodeSelector 或者 nodeAffinity 指定的节点,是否与待考察节点匹配,等等。

    可以看到,像上面这样一组 GeneralPredicates,正是 Kubernetes 考察一个 Pod 能不能运行在一个 Node 上最基本的过滤条件。所以,GeneralPredicates 也会被其他组件(比如 kubelet)直接调用。

    我在上一篇文章中已经提到过,kubelet 在启动 Pod 前,会执行一个 Admit 操作来进行二次确认。这里二次确认的规则,就是执行一遍 GeneralPredicates。

  2. 第二种类型,是与 Volume 相关的过滤规则。

    这一组过滤规则,负责的是跟容器持久化 Volume 相关的调度策略。

    其中,NoDiskConflict 检查的条件,是多个 Pod 声明挂载的持久化 Volume 是否有冲突。比如,AWS EBS 类型的 Volume,是不允许被两个 Pod 同时使用的。所以,当一个名叫 A 的 EBS Volume 已经被挂载在了某个节点上时,另一个同样声明使用这个 A Volume 的 Pod,就不能被调度到这个节点上了。

    而 MaxPDVolumeCountPredicate 检查的条件,则是一个节点上某种类型的持久化 Volume 是不是已经超过了一定数目,如果是的话,那么声明使用该类型持久化 Volume 的 Pod 就不能再调度到这个节点了。

    而 VolumeZonePredicate,则是检查持久化 Volume 的 Zone(高可用域)标签,是否与待考察节点的 Zone 标签相匹配。

    此外,这里还有一个叫作 VolumeBindingPredicate 的规则。它负责检查的,是该 Pod 对应的 PV 的 nodeAffinity 字段,是否跟某个节点的标签相匹配。

    在前面的第 29 篇文章 《PV、PVC 体系是不是多此一举?从本地持久化卷谈起》 中,我曾经为你讲解过,Local Persistent Volume(本地持久化卷),必须使用 nodeAffinity 来跟某个具体的节点绑定。这其实也就意味着,在 Predicates 阶段,Kubernetes 就必须能够根据 Pod 的 Volume 属性来进行调度。

    此外,如果该 Pod 的 PVC 还没有跟具体的 PV 绑定的话,调度器还要负责检查所有待绑定 PV,当有可用的 PV 存在并且该 PV 的 nodeAffinity 与待考察节点一致时,这条规则才会返回”成功”。比如下面这个例子:

    apiVersion: v1
    kind: PersistentVolume
    metadata:
    name: example-local-pv
    spec:
    capacity:
        storage: 500Gi
    accessModes:
    - ReadWriteOnce
    persistentVolumeReclaimPolicy: Retain
    storageClassName: local-storage
    local:
        path: /mnt/disks/vol1
    nodeAffinity:
        required:
        nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
            operator: In
            values:
            - my-node

    可以看到,这个 PV 对应的持久化目录,只会出现在名叫 my-node 的宿主机上。所以,任何一个通过 PVC 使用这个 PV 的 Pod,都必须被调度到 my-node 上才可以正常工作。VolumeBindingPredicate,正是调度器里完成这个决策的位置。

  3. 第三种类型,是宿主机相关的过滤规则。

    这一组规则,主要考察待调度 Pod 是否满足 Node 本身的某些条件。

    比如,PodToleratesNodeTaints,负责检查的就是我们前面经常用到的 Node 的”污点”机制。只有当 Pod 的 Toleration 字段与 Node 的 Taint 字段能够匹配的时候,这个 Pod 才能被调度到该节点上。

    备注:这里,你也可以再回顾下第 21 篇文章 《容器化守护进程的意义:DaemonSet》 中的相关内容。

    而 NodeMemoryPressurePredicate,检查的是当前节点的内存是不是已经不够充足,如果是的话,那么待调度 Pod 就不能被调度到该节点上。

  4. 第四种类型,是 Pod 相关的过滤规则。

    这一组规则,跟 GeneralPredicates 大多数是重合的。而比较特殊的,是 PodAffinityPredicate。这个规则的作用,是检查待调度 Pod 与 Node 上的已有 Pod 之间的亲密(affinity)和反亲密(anti-affinity)关系。比如下面这个例子:

    apiVersion: v1
    kind: Pod
    metadata:
    name: with-pod-antiaffinity
    spec:
    affinity:
        podAntiAffinity:
        requiredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
            podAffinityTerm:
            labelSelector:
                matchExpressions:
                - key: security
                operator: In
                values:
                - S2
            topologyKey: kubernetes.io/hostname
    containers:
    - name: with-pod-affinity
        image: docker.io/ocpqe/hello-pod

    这个例子里的 podAntiAffinity 规则,就指定了这个 Pod 不希望跟任何携带了 security=S2 标签的 Pod 存在于同一个 Node 上。需要注意的是,PodAffinityPredicate 是有作用域的,比如上面这条规则,就仅对携带了 Key 是kubernetes.io/hostname标签的 Node 有效。这正是 topologyKey 这个关键词的作用。

    而与 podAntiAffinity 相反的,就是 podAffinity,比如下面这个例子:

    apiVersion: v1
    kind: Pod
    metadata:
    name: with-pod-affinity
    spec:
    affinity:
        podAffinity:
        requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchExpressions:
            - key: security
                operator: In
                values:
                - S1
            topologyKey: failure-domain.beta.kubernetes.io/zone
    containers:
    - name: with-pod-affinity
        image: docker.io/ocpqe/hello-pod

    这个例子里的 Pod,就只会被调度到已经有携带了 security=S1 标签的 Pod 运行的 Node 上。而这条规则的作用域,则是所有携带 Key 是failure-domain.beta.kubernetes.io/zone标签的 Node。

    此外,上面这两个例子里的 requiredDuringSchedulingIgnoredDuringExecution 字段的含义是:这条规则必须在 Pod 调度时进行检查(requiredDuringScheduling);但是如果是已经在运行的 Pod 发生变化,比如 Label 被修改,造成了该 Pod 不再适合运行在这个 Node 上的时候,Kubernetes 不会进行主动修正(IgnoredDuringExecution)。

上面这四种类型的 Predicates,就构成了调度器确定一个 Node 可以运行待调度 Pod 的基本策略。

在具体执行的时候, 当开始调度一个 Pod 时,Kubernetes 调度器会同时启动 16 个 Goroutine,来并发地为集群里的所有 Node 计算 Predicates,最后返回可以运行这个 Pod 的宿主机列表。

需要注意的是,在为每个 Node 执行 Predicates 时,调度器会按照固定的顺序来进行检查。这个顺序,是按照 Predicates 本身的含义来确定的。比如,宿主机相关的 Predicates 会被放在相对靠前的位置进行检查。要不然的话,在一台资源已经严重不足的宿主机上,上来就开始计算 PodAffinityPredicate,是没有实际意义的。

接下来,我们再来看一下 Priorities。

在 Predicates 阶段完成了节点的”过滤”之后,Priorities 阶段的工作就是为这些节点打分。这里打分的范围是 0-10 分,得分最高的节点就是最后被 Pod 绑定的最佳节点。

Priorities 里最常用到的一个打分规则,是 LeastRequestedPriority。它的计算方法,可以简单地总结为如下所示的公式:

score = (cpu((capacity-sum(requested))10/capacity) + memory((capacity-sum(requested))10/capacity))/2

可以看到,这个算法实际上就是在选择空闲资源(CPU 和 Memory)最多的宿主机。

而与 LeastRequestedPriority 一起发挥作用的,还有 BalancedResourceAllocation。它的计算公式如下所示:

score = 10 - variance(cpuFraction,memoryFraction,volumeFraction)*10

其中,每种资源的 Fraction 的定义是 :Pod 请求的资源 / 节点上的可用资源。而 variance 算法的作用,则是计算每两种资源 Fraction 之间的”距离”。而最后选择的,则是资源 Fraction 差距最小的节点。

所以说,BalancedResourceAllocation 选择的,其实是调度完成后,所有节点里各种资源分配最均衡的那个节点,从而避免一个节点上 CPU 被大量分配、而 Memory 大量剩余的情况。

此外,还有 NodeAffinityPriority、TaintTolerationPriority 和 InterPodAffinityPriority 这三种 Priority。顾名思义,它们与前面的 PodMatchNodeSelector、PodToleratesNodeTaints 和 PodAffinityPredicate 这三个 Predicate 的含义和计算方法是类似的。但是作为 Priority,一个 Node 满足上述规则的字段数目越多,它的得分就会越高。

在默认 Priorities 里,还有一个叫作 ImageLocalityPriority 的策略。它是在 Kubernetes v1.12 里新开启的调度规则,即:如果待调度 Pod 需要使用的镜像很大,并且已经存在于某些 Node 上,那么这些 Node 的得分就会比较高。

当然,为了避免这个算法引发调度堆叠,调度器在计算得分的时候还会根据镜像的分布进行优化,即:如果大镜像分布的节点数目很少,那么这些节点的权重就会被调低,从而”对冲”掉引起调度堆叠的风险。

以上,就是 Kubernetes 调度器的 Predicates 和 Priorities 里默认调度规则的主要工作原理了。

在实际的执行过程中,调度器里关于集群和 Pod 的信息都已经缓存化,所以这些算法的执行过程还是比较快的。

此外,对于比较复杂的调度算法来说,比如 PodAffinityPredicate,它们在计算的时候不只关注待调度 Pod 和待考察 Node,还需要关注整个集群的信息,比如,遍历所有节点,读取它们的 Labels。这时候,Kubernetes 调度器会在为每个待调度 Pod 执行该调度算法之前,先将算法需要的集群信息初步计算一遍,然后缓存起来。这样,在真正执行该算法的时候,调度器只需要读取缓存信息进行计算即可,从而避免了为每个 Node 计算 Predicates 的时候反复获取和计算整个集群的信息。

42.1 总结

需要注意的是,除了本篇讲述的这些规则,Kubernetes 调度器里其实还有一些默认不会开启的策略。你可以通过为 kube-scheduler 指定一个配置文件或者创建一个 ConfigMap ,来配置哪些规则需要开启、哪些规则需要关闭。并且,你可以通过为 Priorities 设置权重,来控制调度器的调度行为。

43 | Kubernetes默认调度器的优先级与抢占机制

在上一篇文章中,我为你详细讲解了 Kubernetes 默认调度器的主要调度算法的工作原理。在本篇文章中,我再来为你讲解一下 Kubernetes 调度器里的另一个重要机制,即:优先级(Priority )和抢占(Preemption)机制。

首先需要明确的是,优先级和抢占机制,解决的是 Pod 调度失败时该怎么办的问题。

正常情况下,当一个 Pod 调度失败后,它就会被暂时”搁置”起来,直到 Pod 被更新,或者集群状态发生变化,调度器才会对这个 Pod 进行重新调度。

但在有时候,我们希望的是这样一个场景。当一个高优先级的 Pod 调度失败后,该 Pod 并不会被”搁置”,而是会”挤走”某个 Node 上的一些低优先级的 Pod 。这样就可以保证这个高优先级 Pod 的调度成功。这个特性,其实也是一直以来就存在于 Borg 以及 Mesos 等项目里的一个基本功能。

而在 Kubernetes 里,优先级和抢占机制是在 1.10 版本后才逐步可用的。要使用这个机制,你首先需要在 Kubernetes 里提交一个 PriorityClass 的定义,如下所示:

apiVersion: scheduling.k8s.io/v1beta1
kind: PriorityClass
metadata:
  name: high-priority
value: 1000000
globalDefault: false
description: "This priority class should be used for high priority service pods only."

上面这个 YAML 文件,定义的是一个名叫 high-priority 的 PriorityClass,其中 value 的值是 1000000 (一百万)。

Kubernetes 规定,优先级是一个 32 bit 的整数,最大值不超过 1000000000(10 亿,1 billion),并且值越大代表优先级越高。 而超出 10 亿的值,其实是被 Kubernetes 保留下来分配给系统 Pod 使用的。显然,这样做的目的,就是保证系统 Pod 不会被用户抢占掉。

而一旦上述 YAML 文件里的 globalDefault 被设置为 true 的话,那就意味着这个 PriorityClass 的值会成为系统的默认值。而如果这个值是 false,就表示我们只希望声明使用该 PriorityClass 的 Pod 拥有值为 1000000 的优先级,而对于没有声明 PriorityClass 的 Pod 来说,它们的优先级就是 0。

在创建了 PriorityClass 对象之后,Pod 就可以声明使用它了,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    env: test
spec:
  containers:
  - name: nginx
    image: nginx
    imagePullPolicy: IfNotPresent
  priorityClassName: high-priority

可以看到,这个 Pod 通过 priorityClassName 字段,声明了要使用名叫 high-priority 的 PriorityClass。当这个 Pod 被提交给 Kubernetes 之后,Kubernetes 的 PriorityAdmissionController 就会自动将这个 Pod 的 spec.priority 字段设置为 1000000。

而我在前面的文章中曾为你介绍过,调度器里维护着一个调度队列。所以,当 Pod 拥有了优先级之后,高优先级的 Pod 就可能会比低优先级的 Pod 提前出队,从而尽早完成调度过程。 这个过程,就是”优先级”这个概念在 Kubernetes 里的主要体现。

备注:这里,你可以再回顾一下第 41 篇文章 《十字路口上的 Kubernetes 默认调度器》 中的相关内容。

而当一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发。这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被删除后,待调度的高优先级 Pod 就可以被调度到这个节点上。 这个过程,就是”抢占”这个概念在 Kubernetes 里的主要体现。

为了方便叙述,我接下来会把待调度的高优先级 Pod 称为”抢占者”(Preemptor)。

当上述抢占过程发生时,抢占者并不会立刻被调度到被抢占的 Node 上。事实上,调度器只会将抢占者的 spec.nominatedNodeName 字段,设置为被抢占的 Node 的名字。然后,抢占者会重新进入下一个调度周期,然后在新的调度周期里来决定是不是要运行在被抢占的节点上。这当然也就意味着,即使在下一个调度周期,调度器也不会保证抢占者一定会运行在被抢占的节点上。

这样设计的一个重要原因是,调度器只会通过标准的 DELETE API 来删除被抢占的 Pod,所以,这些 Pod 必然是有一定的”优雅退出”时间(默认是 30s)的。而在这段时间里,其他的节点也是有可能变成可调度的,或者直接有新的节点被添加到这个集群中来。所以,鉴于优雅退出期间,集群的可调度性可能会发生的变化, 把抢占者交给下一个调度周期再处理,是一个非常合理的选择。

而在抢占者等待被调度的过程中,如果有其他更高优先级的 Pod 也要抢占同一个节点,那么调度器就会清空原抢占者的 spec.nominatedNodeName 字段,从而允许更高优先级的抢占者执行抢占,并且,这也就是得原抢占者本身,也有机会去重新抢占其他节点。这些,都是设置 nominatedNodeName 字段的主要目的。

那么, Kubernetes 调度器里的抢占机制,又是如何设计的呢?

接下来,我就为你详细讲述一下这其中的原理。

我在前面已经提到过,抢占发生的原因,一定是一个高优先级的 Pod 调度失败。这一次,我们还是称这个 Pod 为”抢占者”,称被抢占的 Pod 为”牺牲者”(victims)。

而 Kubernetes 调度器实现抢占算法的一个最重要的设计,就是在调度队列的实现里,使用了两个不同的队列。

第一个队列,叫作 activeQ。 凡是在 activeQ 里的 Pod,都是下一个调度周期需要调度的对象。所以,当你在 Kubernetes 集群里新创建一个 Pod 的时候,调度器会将这个 Pod 入队到 activeQ 里面。而我在前面提到过的、调度器不断从队列里出队(Pop)一个 Pod 进行调度,实际上都是从 activeQ 里出队的。

第二个队列,叫作 unschedulableQ, 专门用来存放调度失败的 Pod。

而这里的一个关键点就在于,当一个 unschedulableQ 里的 Pod 被更新之后,调度器会自动把这个 Pod 移动到 activeQ 里,从而给这些调度失败的 Pod “重新做人”的机会。

现在,回到我们的抢占者调度失败这个时间点上来。

调度失败之后,抢占者就会被放进 unschedulableQ 里面。

然后,这次失败事件就会 触发调度器为抢占者寻找牺牲者的流程。

  1. 第一步 ,调度器会检查这次失败事件的原因,来确认抢占是不是可以帮助抢占者找到一个新节点。这是因为有很多 Predicates 的失败是不能通过抢占来解决的。比如,PodFitsHost 算法(负责的是,检查 Pod 的 nodeSelector 与 Node 的名字是否匹配),这种情况下,除非 Node 的名字发生变化,否则你即使删除再多的 Pod,抢占者也不可能调度成功。

  2. 第二步 ,如果确定抢占可以发生,那么调度器就会把自己缓存的所有节点信息复制一份,然后使用这个副本来模拟抢占过程。

    这里的抢占过程很容易理解。调度器会检查缓存副本里的每一个节点,然后从该节点上最低优先级的 Pod 开始,逐一”删除”这些 Pod。而每删除一个低优先级 Pod,调度器都会检查一下抢占者是否能够运行在该 Node 上。一旦可以运行,调度器就记录下这个 Node 的名字和被删除 Pod 的列表,这就是一次抢占过程的结果了。

    当遍历完所有的节点之后,调度器会在上述模拟产生的所有抢占结果里做一个选择,找出最佳结果。而这一步的 判断原则,就是尽量减少抢占对整个系统的影响 。比如,需要抢占的 Pod 越少越好,需要抢占的 Pod 的优先级越低越好,等等。

在得到了最佳的抢占结果之后,这个结果里的 Node,就是即将被抢占的 Node;被删除的 Pod 列表,就是牺牲者。所以接下来, 调度器就可以真正开始抢占的操作了 ,这个过程,可以分为三步。

  1. 第一步 ,调度器会检查牺牲者列表,清理这些 Pod 所携带的 nominatedNodeName 字段。

  2. 第二步 ,调度器会把抢占者的 nominatedNodeName,设置为被抢占的 Node 的名字。

  3. 第三步 ,调度器会开启一个 Goroutine,同步地删除牺牲者。

而第二步对抢占者 Pod 的更新操作,就会触发到我前面提到的”重新做人”的流程,从而让抢占者在下一个调度周期重新进入调度流程。

所以 接下来,调度器就会通过正常的调度流程把抢占者调度成功 。这也是为什么,我前面会说调度器并不保证抢占的结果:在这个正常的调度流程里,是一切皆有可能的。

不过,对于任意一个待调度 Pod 来说,因为有上述抢占者的存在,它的调度过程,其实是有一些特殊情况需要特殊处理的。

具体来说,在为某一对 Pod 和 Node 执行 Predicates 算法的时候,如果待检查的 Node 是一个即将被抢占的节点,即:调度队列里有 nominatedNodeName 字段值是该 Node 名字的 Pod 存在(可以称之为:”潜在的抢占者”)。那么, 调度器就会对这个 Node ,将同样的 Predicates 算法运行两遍。

  1. 第一遍 , 调度器会假设上述”潜在的抢占者”已经运行在这个节点上,然后执行 Predicates 算法;

  2. 第二遍 , 调度器会正常执行 Predicates 算法,即:不考虑任何”潜在的抢占者”。

而只有这两遍 Predicates 算法都能通过时,这个 Pod 和 Node 才会被认为是可以绑定(bind)的。

不难想到,这里需要执行第一遍 Predicates 算法的原因,是由于 InterPodAntiAffinity 规则的存在。

由于 InterPodAntiAffinity 规则关心待考察节点上所有 Pod 之间的互斥关系,所以我们在执行调度算法时必须考虑,如果抢占者已经存在于待考察 Node 上时,待调度 Pod 还能不能调度成功。

当然,这也就意味着,我们在这一步只需要考虑那些优先级等于或者大于待调度 Pod 的抢占者。毕竟对于其他较低优先级 Pod 来说,待调度 Pod 总是可以通过抢占运行在待考察 Node 上。

而我们需要执行第二遍 Predicates 算法的原因,则是因为”潜在的抢占者”最后不一定会运行在待考察的 Node 上。关于这一点,我在前面已经讲解过了:Kubernetes 调度器并不保证抢占者一定会运行在当初选定的被抢占的 Node 上。

以上,就是 Kubernetes 默认调度器里优先级和抢占机制的实现原理了。

43.1 思考题

当整个集群发生可能会影响调度结果的变化(比如,添加或者更新 Node,添加和更新 PV、Service 等)时,调度器会执行一个被称为 MoveAllToActiveQueue 的操作,把所调度失败的 Pod 从 unscheduelableQ 移动到 activeQ 里面。请问这是为什么?

一个相似的问题是,当一个已经调度成功的 Pod 被更新时,调度器则会将 unschedulableQ 里所有跟这个 Pod 有 Affinity/Anti-affinity 关系的 Pod,移动到 activeQ 里面。请问这又是为什么呢?

44 | Kubernetes GPU管理与Device Plugin机制

2016 年,随着 AlphaGo 的走红和 TensorFlow 项目的异军突起,一场名为 AI 的技术革命迅速从学术界蔓延到了工业界,所谓的 AI 元年,就此拉开帷幕。

当然,机器学习或者说人工智能,并不是什么新鲜的概念。而这次热潮的背后,云计算服务的普及与成熟,以及算力的巨大提升,其实正是将人工智能从象牙塔带到工业界的一个重要推手。

而与之相对应的,从 2016 年开始,Kubernetes 社区就不断收到来自不同渠道的大量诉求,希望能够在 Kubernetes 集群上运行 TensorFlow 等机器学习框架所创建的训练(Training)和服务(Serving)任务。而这些诉求中,除了前面我为你讲解过的 Job、Operator 等离线作业管理需要用到的编排概念之外,还有一个亟待实现的功能,就是对 GPU 等硬件加速设备管理的支持。

不过, 正如同 TensorFlow 之于 Google 的战略意义一样, GPU 支持对于 Kubernetes 项目来说,其实也有着超过技术本身的考虑 。所以,尽管在硬件加速器这个领域里,Kubernetes 上游有着不少来自 NVIDIA 和 Intel 等芯片厂商的工程师,但这个特性本身,却从一开始就是以 Google Cloud 的需求为主导来推进的。

而对于云的用户来说,在 GPU 的支持上,他们最基本的诉求其实非常简单:我只要在 Pod 的 YAML 里面,声明某容器需要的 GPU 个数,那么 Kubernetes 为我创建的容器里就应该出现对应的 GPU 设备,以及它对应的驱动目录。

以 NVIDIA 的 GPU 设备为例,上面的需求就意味着当用户的容器被创建之后,这个容器里必须出现如下两部分设备和目录:

  1. GPU 设备,比如 /dev/nvidia0;
  2. GPU 驱动目录,比如 /usr/local/nvidia/*。

其中,GPU 设备路径,正是该容器启动时的 Devices 参数;而驱动目录,则是该容器启动时的 Volume 参数。所以,在 Kubernetes 的 GPU 支持的实现里,kubelet 实际上就是将上述两部分内容,设置在了创建该容器的 CRI (Container Runtime Interface)参数里面。这样,等到该容器启动之后,对应的容器里就会出现 GPU 设备和驱动的路径了。

不过,Kubernetes 在 Pod 的 API 对象里,并没有为 GPU 专门设置一个资源类型字段,而是使用了一种叫作 Extended Resource(ER)的特殊字段来负责传递 GPU 的信息。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: cuda-vector-add
spec:
  restartPolicy: OnFailure
  containers:
    - name: cuda-vector-add
      image: "k8s.gcr.io/cuda-vector-add:v0.1"
      resources:
        limits:
          nvidia.com/gpu: 1

可以看到,在上述 Pod 的 limits 字段里,这个资源的名称是nvidia.com/gpu,它的值是 1。也就是说,这个 Pod 声明了自己要使用一个 NVIDIA 类型的 GPU。

而在 kube-scheduler 里面,它其实并不关心这个字段的具体含义,只会在计算的时候,一律将调度器里保存的该类型资源的可用量,直接减去 Pod 声明的数值即可。所以说,Extended Resource,其实是 Kubernetes 为用户设置的一种对自定义资源的支持。

当然,为了能够让调度器知道这个自定义类型的资源在每台宿主机上的可用量,宿主机节点本身,就必须能够向 API Server 汇报该类型资源的可用数量。在 Kubernetes 里,各种类型的资源可用量,其实是 Node 对象 Status 字段的内容,比如下面这个例子:

apiVersion: v1
kind: Node
metadata:
  name: node-1
...
Status:
  Capacity:
   cpu:  2
   memory:  2049008Ki

而为了能够在上述 Status 字段里添加自定义资源的数据,你就必须使用 PATCH API 来对该 Node 对象进行更新,加上你的自定义资源的数量。这个 PATCH 操作,可以简单地使用 curl 命令来发起,如下所示:

# 启动 Kubernetes 的客户端 proxy,这样你就可以直接使用 curl 来跟 Kubernetes  的 API Server 进行交互了
$ kubectl proxy

# 执行 PACTH 操作
$ curl --header "Content-Type: application/json-patch+json" \
    --request PATCH \
    --data '[{"op": "add", "path": "/status/capacity/nvidia.com/gpu", "value": "1"}]' \
    http://localhost:8001/api/v1/nodes/<your-node-name>/status

PATCH 操作完成后,你就可以看到 Node 的 Status 变成了如下所示的内容:

apiVersion: v1
kind: Node
...
Status:
  Capacity:
   cpu:  2
   memory:  2049008Ki
   nvidia.com/gpu: 1

这样在调度器里,它就能够在缓存里记录下 node-1 上的nvidia.com/gpu类型的资源的数量是 1。

当然,在 Kubernetes 的 GPU 支持方案里,你并不需要真正去做上述关于 Extended Resource 的这些操作。在 Kubernetes 中,对所有硬件加速设备进行管理的功能,都是由一种叫作 Device Plugin 的插件来负责的。这其中,当然也就包括了对该硬件的 Extended Resource 进行汇报的逻辑。

Kubernetes 的 Device Plugin 机制,我可以用如下所示的一幅示意图来和你解释清楚。

Kubernetes的DevicePlugin机制

我们先从这幅示意图的右侧开始看起。

首先,对于每一种硬件设备,都需要有它所对应的 Device Plugin 进行管理,这些 Device Plugin,都通过 gRPC 的方式,同 kubelet 连接起来。以 NVIDIA GPU 为例,它对应的插件叫作 NVIDIA GPU device plugin

这个 Device Plugin 会通过一个叫作 ListAndWatch 的 API,定期向 kubelet 汇报该 Node 上 GPU 的列表。比如,在我们的例子里,一共有三个 GPU(GPU0、GPU1 和 GPU2)。这样,kubelet 在拿到这个列表之后,就可以直接在它向 APIServer 发送的心跳里,以 Extended Resource 的方式,加上这些 GPU 的数量,比如nvidia.com/gpu=3。所以说,用户在这里是不需要关心 GPU 信息向上的汇报流程的。

需要注意的是,ListAndWatch 向上汇报的信息,只有本机上 GPU 的 ID 列表,而不会有任何关于 GPU 设备本身的信息。而且 kubelet 在向 API Server 汇报的时候,只会汇报该 GPU 对应的 Extended Resource 的数量。当然,kubelet 本身,会将这个 GPU 的 ID 列表保存在自己的内存里,并通过 ListAndWatch API 定时更新。

而当一个 Pod 想要使用一个 GPU 的时候,它只需要像我在本文一开始给出的例子一样,在 Pod 的 limits 字段声明nvidia.com/gpu: 1。那么接下来,Kubernetes 的调度器就会从它的缓存里,寻找 GPU 数量满足条件的 Node,然后将缓存里的 GPU 数量减 1,完成 Pod 与 Node 的绑定。

这个调度成功后的 Pod 信息,自然就会被对应的 kubelet 拿来进行容器操作。而当 kubelet 发现这个 Pod 的容器请求一个 GPU 的时候,kubelet 就会从自己持有的 GPU 列表里,为这个容器分配一个 GPU。此时,kubelet 就会向本机的 Device Plugin 发起一个 Allocate() 请求。这个请求携带的参数,正是即将分配给该容器的设备 ID 列表。

当 Device Plugin 收到 Allocate 请求之后,它就会根据 kubelet 传递过来的设备 ID,从 Device Plugin 里找到这些设备对应的设备路径和驱动目录。当然,这些信息,正是 Device Plugin 周期性的从本机查询到的。比如,在 NVIDIA Device Plugin 的实现里,它会定期访问 nvidia-docker 插件,从而获取到本机的 GPU 信息。

而被分配 GPU 对应的设备路径和驱动目录信息被返回给 kubelet 之后,kubelet 就完成了为一个容器分配 GPU 的操作。接下来,kubelet 会把这些信息追加在创建该容器所对应的 CRI 请求当中。这样,当这个 CRI 请求发给 Docker 之后,Docker 为你创建出来的容器里,就会出现这个 GPU 设备,并把它所需要的驱动目录挂载进去。

至此,Kubernetes 为一个 Pod 分配一个 GPU 的流程就完成了。

对于其他类型硬件来说,要想在 Kubernetes 所管理的容器里使用这些硬件的话,也需要遵循上述 Device Plugin 的流程来实现如下所示的 Allocate 和 ListAndWatch API:

service DevicePlugin {
    // ListAndWatch returns a stream of List of Devices
    // Whenever a Device state change or a Device disappears, ListAndWatch
    // returns the new list
    rpc ListAndWatch(Empty) returns (stream ListAndWatchResponse) {}
    // Allocate is called during container creation so that the Device
    // Plugin can run device specific operations and instruct Kubelet
    // of the steps to make the Device available in the container
    rpc Allocate(AllocateRequest) returns (AllocateResponse) {}
}

目前,Kubernetes 社区里已经实现了很多硬件插件,比如 FPGASRIOVRDMA 等等。感兴趣的话,你可以点击这些链接来查看这些 Device Plugin 的实现。

44.1 总结

在本篇文章中,我为你详细讲述了 Kubernetes 对 GPU 的管理方式,以及它所需要使用的 Device Plugin 机制。

需要指出的是,Device Plugin 的设计,长期以来都是以 Google Cloud 的用户需求为主导的,所以,它的整套工作机制和流程上,实际上跟学术界和工业界的真实场景还有着不小的差异。

这里最大的问题在于,GPU 等硬件设备的调度工作,实际上是由 kubelet 完成的。即,kubelet 会负责从它所持有的硬件设备列表中,为容器挑选一个硬件设备,然后调用 Device Plugin 的 Allocate API 来完成这个分配操作。可以看到,在整条链路中,调度器扮演的角色,仅仅是为 Pod 寻找到可用的、支持这种硬件设备的节点而已。

这就使得,Kubernetes 里对硬件设备的管理,只能处理”设备个数”这唯一一种情况。一旦你的设备是异构的、不能简单地用”数目”去描述具体使用需求的时候,比如,”我的 Pod 想要运行在计算能力最强的那个 GPU 上”,Device Plugin 就完全不能处理了。

更不用说,在很多场景下,我们其实希望在调度器进行调度的时候,就可以根据整个集群里的某种硬件设备的全局分布,做出一个最佳的调度选择。

此外,上述 Device Plugin 的设计,也使得 Kubernetes 里,缺乏一种能够对 Device 进行描述的 API 对象。这就使得如果你的硬件设备本身的属性比较复杂,并且 Pod 也关心这些硬件的属性的话,那么 Device Plugin 也是完全没有办法支持的。

更为棘手的是,在 Device Plugin 的设计和实现中,Google 的工程师们一直不太愿意为 Allocate 和 ListAndWatch API 添加可扩展性的参数。这就使得,当你确实需要处理一些比较复杂的硬件设备使用需求时,是没有办法通过扩展 Device Plugin 的 API 来实现的。

针对这些问题,RedHat 在社区里曾经大力推进过 ResourceClass 的设计,试图将硬件设备的管理功能上浮到 API 层和调度层。但是,由于各方势力的反对,这个提议最后不了了之了。

所以说,目前 Kubernetes 本身的 Device Plugin 的设计,实际上能覆盖的场景是非常单一的,属于”可用”但是”不好用”的状态。并且, Device Plugin 的 API 本身的可扩展性也不是很好。这也就解释了为什么像 NVIDIA 这样的硬件厂商,实际上并没有完全基于上游的 Kubernetes 代码来实现自己的 GPU 解决方案,而是做了一定的改动,也就是 fork。这,实属不得已而为之。

Kubernetes容器运行时

45 | 幕后英雄:SIG-Node与CRI

在前面的文章中,我为你详细讲解了关于 Kubernetes 调度和资源管理相关的内容。实际上,在调度这一步完成后,Kubernetes 就需要负责将这个调度成功的 Pod,在宿主机上创建出来,并把它所定义的各个容器启动起来。这些,都是 kubelet 这个核心组件的主要功能。

在接下来三篇文章中,我就深入到 kubelet 里面,为你详细剖析一下 Kubernetes 对容器运行时的管理能力。

在 Kubernetes 社区里,与 kubelet 以及容器运行时管理相关的内容,都属于 SIG-Node 的范畴。如果你经常参与社区的话,你可能会觉得,相比于其他每天都热闹非凡的 SIG 小组,SIG-Node 是 Kubernetes 里相对沉寂也不太发声的一个小组,小组里的成员也很少在外面公开宣讲。

不过,正如我前面所介绍的,SIG-Node 以及 kubelet,其实是 Kubernetes 整套体系里非常核心的一个部分。 毕竟,它们才是 Kubernetes 这样一个容器编排与管理系统,跟容器打交道的主要”场所”。

而 kubelet 这个组件本身,也是 Kubernetes 里面第二个不可被替代的组件(第一个不可被替代的组件当然是 kube-apiserver)。也就是说, 无论如何,我都不太建议你对 kubelet 的代码进行大量的改动。保持 kubelet 跟上游基本一致的重要性,就跟保持 kube-apiserver 跟上游一致是一个道理。

当然, kubelet 本身,也是按照”控制器”模式来工作的。它实际的工作原理,可以用如下所示的一幅示意图来表示清楚。

kubelet的工作原理

可以看到,kubelet 的工作核心,就是一个控制循环,即:SyncLoop(图中的大圆圈)。而驱动这个控制循环运行的事件,包括四种:

  1. Pod 更新事件;
  2. Pod 生命周期变化;
  3. kubelet 本身设置的执行周期;
  4. 定时的清理事件。

所以,跟其他控制器类似,kubelet 启动的时候,要做的第一件事情,就是设置 Listers,也就是注册它所关心的各种事件的 Informer。这些 Informer,就是 SyncLoop 需要处理的数据的来源。

此外,kubelet 还负责维护着很多很多其他的子控制循环(也就是图中的小圆圈)。这些控制循环的名字,一般被称作某某 Manager,比如 Volume Manager、Image Manager、Node Status Manager 等等。

不难想到,这些控制循环的责任,就是通过控制器模式,完成 kubelet 的某项具体职责。比如 Node Status Manager,就负责响应 Node 的状态变化,然后将 Node 的状态收集起来,并通过 Heartbeat 的方式上报给 APIServer。再比如 CPU Manager,就负责维护该 Node 的 CPU 核的信息,以便在 Pod 通过 cpuset 的方式请求 CPU 核的时候,能够正确地管理 CPU 核的使用量和可用量。

那么这个 SyncLoop,又是如何根据 Pod 对象的变化,来进行容器操作的呢?

实际上,kubelet 也是通过 Watch 机制,监听了与自己相关的 Pod 对象的变化。当然,这个 Watch 的过滤条件是该 Pod 的 nodeName 字段与自己相同。kubelet 会把这些 Pod 的信息缓存在自己的内存里。

而当一个 Pod 完成调度、与一个 Node 绑定起来之后, 这个 Pod 的变化就会触发 kubelet 在控制循环里注册的 Handler,也就是上图中的 HandlePods 部分。此时,通过检查该 Pod 在 kubelet 内存里的状态,kubelet 就能够判断出这是一个新调度过来的 Pod,从而触发 Handler 里 ADD 事件对应的处理逻辑。

在具体的处理过程当中,kubelet 会启动一个名叫 Pod Update Worker 的、单独的 Goroutine 来完成对 Pod 的处理工作。

比如,如果是 ADD 事件的话,kubelet 就会为这个新的 Pod 生成对应的 Pod Status,检查 Pod 所声明使用的 Volume 是不是已经准备好。然后,调用下层的容器运行时(比如 Docker),开始创建这个 Pod 所定义的容器。

而如果是 UPDATE 事件的话,kubelet 就会根据 Pod 对象具体的变更情况,调用下层容器运行时进行容器的重建工作。

在这里需要注意的是, kubelet 调用下层容器运行时的执行过程,并不会直接调用 Docker 的 API,而是通过一组叫作 CRI(Container Runtime Interface,容器运行时接口)的 gRPC 接口来间接执行的。

Kubernetes 项目之所以要在 kubelet 中引入这样一层单独的抽象,当然是为了对 Kubernetes 屏蔽下层容器运行时的差异。实际上,对于 1.6 版本之前的 Kubernetes 来说,它就是直接调用 Docker 的 API 来创建和管理容器的。

但是,正如我在本专栏开始介绍容器背景的时候提到过的,Docker 项目风靡全球后不久,CoreOS 公司就推出了 rkt 项目来与 Docker 正面竞争。在这种背景下,Kubernetes 项目的默认容器运行时,自然也就成了两家公司角逐的重要战场。

毋庸置疑,Docker 项目必然是 Kubernetes 项目最依赖的容器运行时。但凭借与 Google 公司非同一般的关系,CoreOS 公司还是在 2016 年成功地将对 rkt 容器的支持,直接添加进了 kubelet 的主干代码里。

不过,这个”赶鸭子上架”的举动,并没有为 rkt 项目带来更多的用户,反而给 kubelet 的维护人员,带来了巨大的负担。

不难想象,在这种情况下, kubelet 任何一次重要功能的更新,都不得不考虑 Docker 和 rkt 这两种容器运行时的处理场景,然后分别更新 Docker 和 rkt 两部分代码。

更让人为难的是,由于 rkt 项目实在太小众,kubelet 团队所有与 rkt 相关的代码修改,都必须依赖于 CoreOS 的员工才能做到。这不仅拖慢了 kubelet 的开发周期,也给项目的稳定性带来了巨大的隐患。

与此同时,在 2016 年,Kata Containers 项目的前身 runV 项目也开始逐渐成熟,这种基于虚拟化技术的强隔离容器,与 Kubernetes 和 Linux 容器项目之间具有良好的互补关系。所以, 在 Kubernetes 上游,对虚拟化容器的支持很快就被提上了日程。

不过,虽然虚拟化容器运行时有各种优点,但它与 Linux 容器截然不同的实现方式,使得它跟 Kubernetes 的集成工作,比 rkt 要复杂得多。如果此时,再把对 runV 支持的代码也一起添加到 kubelet 当中,那么接下来 kubelet 的维护工作就可以说完全没办法正常进行了。

所以,在 2016 年,SIG-Node 决定开始动手解决上述问题。而解决办法也很容易想到,那就是把 kubelet 对容器的操作,统一地抽象成一个接口。这样,kubelet 就只需要跟这个接口打交道了。而作为具体的容器项目,比如 Docker、 rkt、runV,它们就只需要自己提供一个该接口的实现,然后对 kubelet 暴露出 gRPC 服务即可。

这一层统一的容器操作接口,就是 CRI 了。我会在下一篇文章中,为你详细讲解 CRI 的设计与具体的实现原理。

而在有了 CRI 之后,Kubernetes 以及 kubelet 本身的架构,就可以用如下所示的一幅示意图来描述。

HowCRIWorks

可以看到,当 Kubernetes 通过编排能力创建了一个 Pod 之后,调度器会为这个 Pod 选择一个具体的节点来运行。这时候,kubelet 当然就会通过前面讲解过的 SyncLoop 来判断需要执行的具体操作,比如创建一个 Pod。那么此时,kubelet 实际上就会调用一个叫作 GenericRuntime 的通用组件来发起创建 Pod 的 CRI 请求。

那么, 这个 CRI 请求,又该由谁来响应呢?

如果你使用的容器项目是 Docker 的话,那么负责响应这个请求的就是一个叫作 dockershim 的组件。它会把 CRI 请求里的内容拿出来,然后组装成 Docker API 请求发给 Docker Daemon。

需要注意的是,在 Kubernetes 目前的实现里,dockershim 依然是 kubelet 代码的一部分。当然,在将来,dockershim 肯定会被从 kubelet 里移出来,甚至直接被废弃掉。

而更普遍的场景,就是你需要在每台宿主机上单独安装一个负责响应 CRI 的组件,这个组件,一般被称作 CRI shim。顾名思义,CRI shim 的工作,就是扮演 kubelet 与容器项目之间的”垫片”(shim)。所以它的作用非常单一,那就是实现 CRI 规定的每个接口,然后把具体的 CRI 请求”翻译”成对后端容器项目的请求或者操作。

45.1 总结

在本篇文章中,我首先为你介绍了 SIG-Node 的职责,以及 kubelet 这个组件的工作原理。

接下来,我为你重点讲解了 kubelet 究竟是如何将 Kubernetes 对应用的定义,一步步转换成最终对 Docker 或者其他容器项目的 API 请求的。

不难看到,在这个过程中,kubelet 的 SyncLoop 和 CRI 的设计,是其中最重要的两个关键点。也正是基于以上设计,SyncLoop 本身就要求这个控制循环是绝对不可以被阻塞的。所以,凡是在 kubelet 里有可能会耗费大量时间的操作,比如准备 Pod 的 Volume、拉取镜像等,SyncLoop 都会开启单独的 Goroutine 来进行操作。

46 | 解读 CRI 与 容器运行时

首先,我们先来简要回顾一下有了 CRI 之后,Kubernetes 的架构图,如下所示。

HowCRIWorks

在上一篇文章中我也提到了,CRI 机制能够发挥作用的核心,就在于每一种容器项目现在都可以自己实现一个 CRI shim,自行对 CRI 请求进行处理。这样,Kubernetes 就有了一个统一的容器抽象层,使得下层容器运行时可以自由地对接进入 Kubernetes 当中。

所以说,这里的 CRI shim,就是容器项目的维护者们自由发挥的”场地”了。而除了 dockershim 之外,其他容器运行时的 CRI shim,都是需要额外部署在宿主机上的。

举个例子。CNCF 里的 containerd 项目,就可以提供一个典型的 CRI shim 的能力,即:将 Kubernetes 发出的 CRI 请求,转换成对 containerd 的调用,然后创建出 runC 容器。而 runC 项目,才是负责执行我们前面讲解过的设置容器 Namespace、Cgroups 和 chroot 等基础操作的组件。所以,这几层的组合关系,可以用如下所示的示意图来描述。

containerd项目

而作为一个 CRI shim,containerd 对 CRI 的具体实现,又是怎样的呢?

我们先来看一下 CRI 这个接口的定义。下面这幅示意图,就展示了 CRI 里主要的待实现接口。

CRI的主要接口

具体地说, 我们可以把 CRI 分为两组:

  1. 第一组,是 RuntimeService。它提供的接口,主要是跟容器相关的操作。比如,创建和启动容器、删除容器、执行 exec 命令等等。
  2. 而第二组,则是 ImageService。它提供的接口,主要是容器镜像相关的操作,比如拉取镜像、删除镜像等等。

关于容器镜像的操作比较简单,所以我们就暂且略过。接下来,我主要为你讲解一下 RuntimeService 部分。

在这一部分,CRI 设计的一个重要原则,就是确保这个接口本身,只关注容器,不关注 Pod。 这样做的原因,也很容易理解。

  1. 第一 ,Pod 是 Kubernetes 的编排概念,而不是容器运行时的概念。所以,我们就不能假设所有下层容器项目,都能够暴露出可以直接映射为 Pod 的 API。
  2. 第二 ,如果 CRI 里引入了关于 Pod 的概念,那么接下来只要 Pod API 对象的字段发生变化,那么 CRI 就很有可能需要变更。而在 Kubernetes 开发的前期,Pod 对象的变化还是比较频繁的,但对于 CRI 这样的标准接口来说,这个变更频率就有点麻烦了。

所以,在 CRI 的设计里,并没有一个直接创建 Pod 或者启动 Pod 的接口。

不过,相信你也已经注意到了,CRI 里还是有一组叫作 RunPodSandbox 的接口的。

这个 PodSandbox,对应的并不是 Kubernetes 里的 Pod API 对象,而只是抽取了 Pod 里的一部分与容器运行时相关的字段,比如 HostName、DnsConfig、CgroupParent 等。所以说,PodSandbox 这个接口描述的,其实是 Kubernetes 将 Pod 这个概念映射到容器运行时层面所需要的字段,或者说是一个 Pod 对象子集。

而作为具体的容器项目,你就需要自己决定如何使用这些字段来实现一个 Kubernetes 期望的 Pod 模型。这里的原理,可以用如下所示的示意图来表示清楚。

CRI中的RunPodSandbox接口

比如,当我们执行 kubectl run 创建了一个名叫 foo 的、包括了 A、B 两个容器的 Pod 之后。这个 Pod 的信息最后来到 kubelet,kubelet 就会按照图中所示的顺序来调用 CRI 接口。

在具体的 CRI shim 中,这些接口的实现是可以完全不同的。比如,如果是 Docker 项目,dockershim 就会创建出一个名叫 foo 的 Infra 容器(pause 容器),用来”hold”住整个 Pod 的 Network Namespace。

而如果是基于虚拟化技术的容器,比如 Kata Containers 项目,它的 CRI 实现就会直接创建出一个轻量级虚拟机来充当 Pod。

此外,需要注意的是,在 RunPodSandbox 这个接口的实现中,你还需要调用 networkPlugin.SetUpPod(…) 来为这个 Sandbox 设置网络。这个 SetUpPod(…) 方法,实际上就在执行 CNI 插件里的 add(…) 方法,也就是我在前面为你讲解过的 CNI 插件为 Pod 创建网络,并且把 Infra 容器加入到网络中的操作。

备注:这里,你可以再回顾下第 34 篇文章 《Kubernetes 网络模型与 CNI 网络插件》 中的相关内容。

接下来,kubelet 继续调用 CreateContainer 和 StartContainer 接口来创建和启动容器 A、B。对应到 dockershim 里,就是直接启动 A,B 两个 Docker 容器。所以最后,宿主机上会出现三个 Docker 容器组成这一个 Pod。

而如果是 Kata Containers 的话,CreateContainer 和 StartContainer 接口的实现,就只会在前面创建的轻量级虚拟机里创建两个 A、B 容器对应的 Mount Namespace。所以,最后在宿主机上,只会有一个叫作 foo 的轻量级虚拟机在运行。关于像 Kata Containers 或者 gVisor 这种所谓的安全容器项目,我会在下一篇文章中为你详细介绍。

除了上述对容器生命周期的实现之外,CRI shim 还有一个重要的工作,就是如何实现 exec、logs 等接口。这些接口跟前面的操作有一个很大的不同,就是这些 gRPC 接口调用期间,kubelet 需要跟容器项目维护一个长连接来传输数据。这种 API,我们就称之为 Streaming API。

CRI shim 里对 Streaming API 的实现,依赖于一套独立的 Streaming Server 机制。这一部分原理,可以用如下所示的示意图来为你描述。

CRIshim里对StreamingAPI的实现原理

可以看到,当我们对一个容器执行 kubectl exec 命令的时候,这个请求首先交给 API Server,然后 API Server 就会调用 kubelet 的 Exec API。

这时,kubelet 就会调用 CRI 的 Exec 接口,而负责响应这个接口的,自然就是具体的 CRI shim。

但在这一步,CRI shim 并不会直接去调用后端的容器项目(比如 Docker )来进行处理,而只会返回一个 URL 给 kubelet。这个 URL,就是该 CRI shim 对应的 Streaming Server 的地址和端口。

而 kubelet 在拿到这个 URL 之后,就会把它以 Redirect 的方式返回给 API Server。所以这时候,API Server 就会通过重定向来向 Streaming Server 发起真正的 /exec 请求,与它建立长连接。

当然,这个 Streaming Server 本身,是需要通过使用 SIG-Node 为你维护的 Streaming API 库来实现的。并且,Streaming Server 会在 CRI shim 启动时就一起启动。此外,Stream Server 这一部分具体怎么实现,完全可以由 CRI shim 的维护者自行决定。比如,对于 Docker 项目来说,dockershim 就是直接调用 Docker 的 Exec API 来作为实现的。

以上,就是 CRI 的设计以及具体的工作原理了。

46.1 总结

从这些讲解中不难看出,CRI 这个接口的设计,实际上还是比较宽松的。这就意味着,作为容器项目的维护者,我在实现 CRI 的具体接口时,往往拥有着很高的自由度,这个自由度不仅包括了容器的生命周期管理,也包括了如何将 Pod 映射成为我自己的实现,还包括了如何调用 CNI 插件来为 Pod 设置网络的过程。

所以说,当你对容器这一层有特殊的需求时,我一定优先建议你考虑实现一个自己的 CRI shim ,而不是修改 kubelet 甚至容器项目的代码。这样通过插件的方式定制 Kubernetes 的做法,也是整个 Kubernetes 社区最鼓励和推崇的一个最佳实践。这也正是为什么像 Kata Containers、gVisor 甚至虚拟机这样的”非典型”容器,都可以无缝接入到 Kubernetes 项目里的重要原因。

47 | 绝不仅仅是安全:Kata Containers 与 gVisor

在上一篇文章中,我为你详细地讲解了 kubelet 和 CRI 的设计和具体的工作原理。而在讲解 CRI 的诞生背景时,我也提到过,这其中的一个重要推动力,就是基于虚拟化或者独立内核的安全容器项目的逐渐成熟。

使用虚拟化技术来做一个像 Docker 一样的容器项目,并不是一个新鲜的主意。早在 Docker 项目发布之后,Google 公司就开源了一个实验性的项目,叫作 novm。这,可以算是试图使用常规的虚拟化技术来运行 Docker 镜像的第一次尝试。不过,novm 在开源后不久,就被放弃了,这对于 Google 公司来说或许不算是什么新鲜事,但是 novm 的昙花一现,还是激发出了很多内核开发者的灵感。

所以在 2015 年,几乎在同一个星期,Intel OTC (Open Source Technology Center) 和国内的 HyperHQ 团队同时开源了两个基于虚拟化技术的容器实现,分别叫做 Intel Clear Container 和 runV 项目。

而在 2017 年,借着 Kubernetes 的东风,这两个相似的容器运行时项目在中立基金会的撮合下最终合并,就成了现在大家耳熟能详的 Kata Containers 项目。 由于 Kata Containers 的本质就是一个精简后的轻量级虚拟机,所以它的特点,就是”像虚拟机一样安全,像容器一样敏捷”。

而在 2018 年,Google 公司则发布了一个名叫 gVisor 的项目。gVisor 项目给容器进程配置一个用 Go 语言实现的、运行在用户态的、极小的”独立内核”。这个内核对容器进程暴露 Linux 内核 ABI,扮演着”Guest Kernel”的角色,从而达到了将容器和宿主机隔离开的目的。

不难看到,无论是 Kata Containers,还是 gVisor,它们实现安全容器的方法其实是殊途同归的。这两种容器实现的本质,都是给进程分配了一个独立的操作系统内核,从而避免了让容器共享宿主机的内核。这样,容器进程能够看到的攻击面,就从整个宿主机内核变成了一个极小的、独立的、以容器为单位的内核,从而有效解决了容器进程发生”逃逸”或者夺取整个宿主机的控制权的问题。这个原理,可以用如下所示的示意图来表示清楚。

安全容器的原理

而它们的区别在于,Kata Containers 使用的是传统的虚拟化技术,通过虚拟硬件模拟出了一台”小虚拟机”,然后在这个小虚拟机里安装了一个裁剪后的 Linux 内核来实现强隔离。

而 gVisor 的做法则更加激进,Google 的工程师直接用 Go 语言”模拟”出了一个运行在用户态的操作系统内核,然后通过这个模拟的内核来代替容器进程向宿主机发起有限的、可控的系统调用。

接下来,我就来为你详细解读一下 KataContainers 和 gVisor 具体的设计原理。

  1. 首先,我们来看 KataContainers。 它的工作原理可以用如下所示的示意图来描述。

    KataContainers的工作原理

    我们前面说过,Kata Containers 的本质,就是一个轻量化虚拟机。所以当你启动一个 Kata Containers 之后,你其实就会看到一个正常的虚拟机在运行。这也就意味着,一个标准的虚拟机管理程序(Virtual Machine Manager, VMM)是运行 Kata Containers 必备的一个组件。在我们上面图中,使用的 VMM 就是 Qemu。

    而使用了虚拟机作为进程的隔离环境之后,Kata Containers 原生就带有了 Pod 的概念。即:这个 Kata Containers 启动的虚拟机,就是一个 Pod;而用户定义的容器,就是运行在这个轻量级虚拟机里的进程。在具体实现上,Kata Containers 的虚拟机里会有一个特殊的 Init 进程负责管理虚拟机里面的用户容器,并且只为这些容器开启 Mount Namespace。所以,这些用户容器之间,原生就是共享 Network 以及其他 Namespace 的。

    此外,为了跟上层编排框架比如 Kubernetes 进行对接,Kata Containers 项目会启动一系列跟用户容器对应的 shim 进程,来负责操作这些用户容器的生命周期。当然,这些操作,实际上还是要靠虚拟机里的 Init 进程来帮你做到。

    而在具体的架构上,Kata Containers 的实现方式同一个正常的虚拟机其实也非常类似。这里的原理,可以用如下所示的一幅示意图来表示。

    KataContainers的实现

    可以看到,当 Kata Containers 运行起来之后,虚拟机里的用户进程(容器),实际上只能看到虚拟机里的、被裁减过的 Guest Kernel,以及通过 Hypervisor 虚拟出来的硬件设备。

    而为了能够对这个虚拟机的 I/O 性能进行优化,Kata Containers 也会通过 vhost 技术(比如:vhost-user)来实现 Guest 与 Host 之间的高效的网络通信,并且使用 PCI Passthrough (PCI 穿透)技术来让 Guest 里的进程直接访问到宿主机上的物理设备。这些架构设计与实现,其实跟常规虚拟机的优化手段是基本一致的。

  2. 相比之下,gVisor 的设计其实要更加”激进”一些。它的原理,可以用如下所示的示意图来表示清楚。

    gVisor的工作原理

    gVisor 工作的核心,在于它为应用进程、也就是用户容器,启动了一个名叫 Sentry 的进程。 而 Sentry 进程的主要职责,就是提供一个传统的操作系统内核的能力,即:运行用户程序,执行系统调用。所以说,Sentry 并不是使用 Go 语言重新实现了一个完整的 Linux 内核,而只是一个对应用进程”冒充”内核的系统组件。

    在这种设计思想下,我们就不难理解,Sentry 其实需要自己实现一个完整的 Linux 内核网络栈,以便处理应用进程的通信请求。然后,把封装好的二层帧直接发送给 Kubernetes 设置的 Pod 的 Network Namespace 即可。

    此外,Sentry 对于 Volume 的操作,则需要通过 9p 协议交给一个叫做 Gofer 的代理进程来完成。Gofer 会代替应用进程直接操作宿主机上的文件,并依靠 seccomp 机制将自己的能力限制在最小集,从而防止恶意应用进程通过 Gofer 来从容器中”逃逸”出去。

    而在具体的实现上,gVisor 的 Sentry 进程,其实还分为两种不同的实现方式。这里的工作原理,可以用下面的示意图来描述清楚。

    gVisor的ptrace实现

    第一种实现方式 ,是使用 Ptrace 机制来拦截用户应用的系统调用(System Call),然后把这些系统调用交给 Sentry 来进行处理。

    这个过程,对于应用进程来说,是完全透明的。而 Sentry 接下来,则会扮演操作系统的角色,在用户态执行用户程序,然后仅在需要的时候,才向宿主机发起 Sentry 自己所需要执行的系统调用。这,就是 gVisor 对用户应用进程进行强隔离的主要手段。不过, Ptrace 进行系统调用拦截的性能实在是太差,仅能供 Demo 时使用。

    而第二种实现方式,则更加具有普适性。它的工作原理如下图所示。

    gVisor的kvm实现

    在这种实现里,Sentry 会使用 KVM 来进行系统调用的拦截,这个性能比 Ptrace 就要好很多了。

    当然,为了能够做到这一点,Sentry 进程就必须扮演一个 Guest Kernel 的角色,负责执行用户程序,发起系统调用。而这些系统调用被 KVM 拦截下来,还是继续交给 Sentry 进行处理。只不过在这时候,Sentry 就切换成了一个普通的宿主机进程的角色,来向宿主机发起它所需要的系统调用。

    可以看到, 在这种实现里,Sentry 并不会真的像虚拟机那样去虚拟出硬件设备、安装 Guest 操作系统。它只是借助 KVM 进行系统调用的拦截,以及处理地址空间切换等细节。

    值得一提的是,在 Google 内部,他们也是使用的第二种基于 Hypervisor 的 gVisor 实现。只不过 Google 内部有自己研发的 Hypervisor,所以要比 KVM 实现的性能还要好。

通过以上的讲述,相信你对 Kata Containers 和 gVisor 的实现原理,已经有一个感性的认识了。需要指出的是,到目前为止,gVisor 的实现依然不是非常完善,有很多 Linux 系统调用它还不支持;有很多应用,在 gVisor 里还没办法运行起来。 此外,gVisor 也暂时没有实现一个 Pod 多个容器的支持。当然,在后面的发展中,这些工程问题一定会逐渐解决掉的。

另外,你可能还听说过 AWS 在 2018 年末发布的一个叫做 Firecracker 的安全容器项目。这个项目的核心,其实是一个用 Rust 语言重新编写的 VMM(即:虚拟机管理器)。这就意味着, Firecracker 和 Kata Containers 的本质原理,其实是一样的。只不过, Kata Containers 默认使用的 VMM 是 Qemu,而 Firecracker,则使用自己编写的 VMM。所以,理论上,Kata Containers 也可以使用 Firecracker 运行起来。

47.1 总结

在性能上,KataContainers 和 KVM 实现的 gVisor 基本不分伯仲,在启动速度和占用资源上,基于用户态内核的 gVisor 还略胜一筹。但是,对于系统调用密集的应用,比如重 I/O 或者重网络的应用,gVisor 就会因为需要频繁拦截系统调用而出现性能急剧下降的情况。此外,gVisor 由于要自己使用 Sentry 去模拟一个 Linux 内核,所以它能支持的系统调用是有限的,只是 Linux 系统调用的一个子集。

不过,gVisor 虽然现在没有任何优势,但是这种通过在用户态运行一个操作系统内核,来为应用进程提供强隔离的思路,的确是未来安全容器进一步演化的一个非常有前途的方向。

值得一提的是,Kata Containers 团队在 gVisor 之前,就已经 Demo 了一个名叫 Linuxd 的项目。这个项目,使用了 User Mode Linux (UML) 技术,在用户态运行起了一个真正的 Linux Kernel 来为应用进程提供强隔离,从而避免了重新实现 Linux Kernel 带来的各种麻烦。

有兴趣的话,你可以 在这里查看 这个演讲。我相信,这个方向,应该才是安全容器进化的未来。这比 Unikernels 这种根本不适合实际场景中使用的思路,要靠谱得多。

本篇图片出处均来自于 Kata Containers 的官方对比资料

Kubernetes容器监控与日志

48 | Prometheus、Metrics Server与Kubernetes监控体系

首先需要明确指出的是,Kubernetes 项目的监控体系曾经非常繁杂,在社区中也有很多方案。但这套体系发展到今天,已经完全演变成了以 Prometheus 项目为核心的一套统一的方案。

在这里,可能有一些同学对 Prometheus 项目还太不熟悉。所以,我先来简单为你介绍一下这个项目。

实际上,Prometheus 项目是当年 CNCF 基金会起家时的”第二把交椅”。而这个项目发展到今天,已经全面接管了 Kubernetes 项目的整套监控体系。

比较有意思的是,Prometheus 项目与 Kubernetes 项目一样,也来自于 Google 的 Borg 体系,它的原型系统,叫作 BorgMon,是一个几乎与 Borg 同时诞生的内部监控系统。而 Prometheus 项目的发起原因也跟 Kubernetes 很类似,都是希望通过对用户更友好的方式,将 Google 内部系统的设计理念,传递给用户和开发者。

作为一个监控系统,Prometheus 项目的作用和工作方式,其实可以用如下所示的一张官方示意图来解释。

Prometheus项目

可以看到,Prometheus 项目工作的核心,是使用 Pull (抓取)的方式去搜集被监控对象的 Metrics 数据(监控指标数据),然后,再把这些数据保存在一个 TSDB (时间序列数据库,比如 OpenTSDB、InfluxDB 等)当中,以便后续可以按照时间进行检索。

有了这套核心监控机制, Prometheus 剩下的组件就是用来配合这套机制的运行。比如 Pushgateway,可以允许被监控对象以 Push 的方式向 Prometheus 推送 Metrics 数据。而 Alertmanager,则可以根据 Metrics 信息灵活地设置报警。当然, Prometheus 最受用户欢迎的功能,还是通过 Grafana 对外暴露出的、可以灵活配置的监控数据可视化界面。

有了 Prometheus 之后,我们就可以按照 Metrics 数据的来源,来对 Kubernetes 的监控体系做一个汇总了。

  1. 第一种 Metrics,是宿主机的监控数据。 这部分数据的提供,需要借助一个由 Prometheus 维护的 Node Exporter 工具。一般来说,Node Exporter 会以 DaemonSet 的方式运行在宿主机上。其实,所谓的 Exporter,就是代替被监控对象来对 Prometheus 暴露出可以被”抓取”的 Metrics 信息的一个辅助进程。

    而 Node Exporter 可以暴露给 Prometheus 采集的 Metrics 数据, 也不单单是节点的负载(Load)、CPU 、内存、磁盘以及网络这样的常规信息,它的 Metrics 指标可以说是”包罗万象”,你可以查看 这个列表 来感受一下。

  2. 第二种 Metrics,是来自于 Kubernetes 的 API Server、kubelet 等组件的 /metrics API。 除了常规的 CPU、内存的信息外,这部分信息还主要包括了各个组件的核心监控指标。比如,对于 API Server 来说,它就会在 /metrics API 里,暴露出各个 Controller 的工作队列(Work Queue)的长度、请求的 QPS 和延迟数据等等。这些信息,是检查 Kubernetes 本身工作情况的主要依据。

  3. 第三种 Metrics,是 Kubernetes 相关的监控数据。 这部分数据,一般叫作 Kubernetes 核心监控数据(core metrics)。这其中包括了 Pod、Node、容器、Service 等主要 Kubernetes 核心概念的 Metrics。

其中,容器相关的 Metrics 主要来自于 kubelet 内置的 cAdvisor 服务。在 kubelet 启动后,cAdvisor 服务也随之启动,而它能够提供的信息,可以细化到每一个容器的 CPU 、文件系统、内存、网络等资源的使用情况。

需要注意的是,这里提到的 Kubernetes 核心监控数据,其实使用的是 Kubernetes 的一个非常重要的扩展能力,叫作 Metrics Server。

Metrics Server 在 Kubernetes 社区的定位,其实是用来取代 Heapster 这个项目的。在 Kubernetes 项目发展的初期,Heapster 是用户获取 Kubernetes 监控数据(比如 Pod 和 Node 的资源使用情况) 的主要渠道。而后面提出来的 Metrics Server,则把这些信息,通过标准的 Kubernetes API 暴露了出来。这样,Metrics 信息就跟 Heapster 完成了解耦,允许 Heapster 项目慢慢退出舞台。

而有了 Metrics Server 之后,用户就可以通过标准的 Kubernetes API 来访问到这些监控数据了。比如,下面这个 URL:

http://127.0.0.1:8001/apis/metrics.k8s.io/v1beta1/namespaces/<namespace-name>/pods/<pod-name>

当你访问这个 Metrics API 时,它就会为你返回一个 Pod 的监控数据,而这些数据,其实是从 kubelet 的 Summary API (即 <kubelet_ip>:<kubelet_port>/stats/summary )采集而来的。Summary API 返回的信息,既包括了 cAdVisor 的监控数据,也包括了 kubelet 本身汇总的信息。

需要指出的是, Metrics Server 并不是 kube-apiserver 的一部分,而是通过 Aggregator 这种插件机制,在独立部署的情况下同 kube-apiserver 一起统一对外服务的。

这里,Aggregator APIServer 的工作原理,可以用如下所示的一幅示意图来表示清楚:

AggregatorAPIServer的工作原理

备注:图片出处 https://blog.jetstack.io/blog/resource-and-custom-metrics-hpa-v2/

可以看到,当 Kubernetes 的 API Server 开启了 Aggregator 模式之后,你再访问 apis/metrics.k8s.io/v1beta1 的时候,实际上访问到的是一个叫作 kube-aggregator 的代理。而 kube-apiserver,正是这个代理的一个后端;而 Metrics Server,则是另一个后端。

而且,在这个机制下,你还可以添加更多的后端给这个 kube-aggregator。所以 kube-aggregator 其实就是一个根据 URL 选择具体的 API 后端的代理服务器。 通过这种方式,我们就可以很方便地扩展 Kubernetes 的 API 了。

而 Aggregator 模式的开启也非常简单:

  • 如果你是使用 kubeadm 或者官方的 kube-up.sh 脚本部署 Kubernetes 集群的话,Aggregator 模式就是默认开启的;

  • 如果是手动 DIY 搭建的话,你就需要在 kube-apiserver 的启动参数里加上如下所示的配置:

    --requestheader-client-ca-file=<path to aggregator CA cert>
    --requestheader-allowed-names=front-proxy-client
    --requestheader-extra-headers-prefix=X-Remote-Extra-
    --requestheader-group-headers=X-Remote-Group
    --requestheader-username-headers=X-Remote-User
    --proxy-client-cert-file=<path to aggregator proxy cert>
    --proxy-client-key-file=<path to aggregator proxy key>

    而这些配置的作用,主要就是为 Aggregator 这一层设置对应的 Key 和 Cert 文件。而这些文件的生成,就需要你自己手动完成了,具体流程请参考这篇官方文档。

Aggregator 功能开启之后,你只需要将 Metrics Server 的 YAML 文件部署起来,如下所示:

git clone https://github.com/kubernetes-incubator/metrics-server
cd metrics-server
kubectl create -f deploy/1.8+/

接下来,你就会看到 metrics.k8s.io 这个 API 出现在了你的 Kubernetes API 列表当中。

在理解了 Prometheus 关心的三种监控数据源,以及 Kubernetes 的核心 Metrics 之后,作为用户,你其实要做的就是将 Prometheus Operator 在 Kubernetes 集群里部署起来。然后,按照本篇文章一开始介绍的架构,把上述 Metrics 源配置起来,让 Prometheus 自己去进行采集即可。

在后续的文章中,我会为你进一步剖析 Kubernetes 监控体系以及自定义 Metrics (自定义监控指标)的具体技术点。

48.1 总结

在具体的监控指标规划上,我建议你 遵循业界通用的 USE 原则和 RED 原则。

其中,USE 原则指的是,按照如下三个维度来规划资源监控指标:

  1. 利用率(Utilization),资源被有效利用起来提供服务的平均时间占比;
  2. 饱和度(Saturation),资源拥挤的程度,比如工作队列的长度;
  3. 错误率(Errors),错误的数量。

而 RED 原则指的是,按照如下三个维度来规划服务监控指标:

  1. 每秒请求数量(Rate);
  2. 每秒错误数量(Errors);
  3. 服务响应时间(Duration)。

不难发现, USE 原则主要关注的是”资源”,比如节点和容器的资源使用情况,而 RED 原则主要关注的是”服务”,比如 kube-apiserver 或者某个应用的工作情况。这两种指标,在我今天为你讲解的 Kubernetes + Prometheus 组成的监控体系中,都是可以完全覆盖到的。

49 | Custom Metrics: 让Auto Scaling不再”食之无味”

实际上,借助上述监控体系,Kubernetes 就可以为你提供一种非常有用的能力,那就是 Custom Metrics,自定义监控指标。

在过去的很多 PaaS 项目中,其实都有一种叫作 Auto Scaling,即自动水平扩展的功能。只不过,这个功能往往只能依据某种指定的资源类型执行水平扩展,比如 CPU 或者 Memory 的使用值。

而在真实的场景中,用户需要进行 Auto Scaling 的依据往往是自定义的监控指标。比如,某个应用的等待队列的长度,或者某种应用相关资源的使用情况。这些复杂多变的需求,在传统 PaaS 项目和其他容器编排项目里,几乎是不可能轻松支持的。

而凭借强大的 API 扩展机制,Custom Metrics 已经成为了 Kubernetes 的一项标准能力。并且,Kubernetes 的自动扩展器组件 Horizontal Pod Autoscaler (HPA), 也可以直接使用 Custom Metrics 来执行用户指定的扩展策略,这里的整个过程都是非常灵活和可定制的。

不难想到,Kubernetes 里的 Custom Metrics 机制,也是借助 Aggregator APIServer 扩展机制来实现的。这里的具体原理是,当你把 Custom Metrics APIServer 启动之后,Kubernetes 里就会出现一个叫作custom.metrics.k8s.io的 API。而当你访问这个 URL 时,Aggregator 就会把你的请求转发给 Custom Metrics APIServer 。

而 Custom Metrics APIServer 的实现,其实就是一个 Prometheus 项目的 Adaptor。

比如,现在我们要实现一个根据指定 Pod 收到的 HTTP 请求数量来进行 Auto Scaling 的 Custom Metrics,这个 Metrics 就可以通过访问如下所示的自定义监控 URL 获取到:

https://<apiserver_ip>/apis/custom-metrics.metrics.k8s.io/v1beta1/namespaces/default/pods/sample-metrics-app/http_requests

这里的工作原理是,当你访问这个 URL 的时候,Custom Metrics APIServer 就会去 Prometheus 里查询名叫 sample-metrics-app 这个 Pod 的 http_requests 指标的值,然后按照固定的格式返回给访问者。

当然,http_requests 指标的值,就需要由 Prometheus 按照我在上一篇文章中讲到的核心监控体系,从目标 Pod 上采集来。

这里具体的做法有很多种,最普遍的做法,就是让 Pod 里的应用本身暴露出一个 /metrics API,然后在这个 API 里返回自己收到的 HTTP 的请求的数量。所以说,接下来 HPA 只需要定时访问前面提到的自定义监控 URL,然后根据这些值计算是否要执行 Scaling 即可。

接下来,我通过一个具体的实例,来为你讲解一下 Custom Metrics 具体的使用方式。这个实例的 GitHub 库 在这里 ,你可以点击链接查看。在这个例子中,我依然会假设你的集群是 kubeadm 部署出来的,所以 Aggregator 功能已经默认开启了。

备注:我们这里使用的实例,fork 自 Lucas 在上高中时做的一系列 Kubernetes 指南。

首先 ,我们当然是先部署 Prometheus 项目。这一步,我当然会使用 Prometheus Operator 来完成,如下所示:

$ kubectl apply -f demos/monitoring/prometheus-operator.yaml
clusterrole "prometheus-operator" created
serviceaccount "prometheus-operator" created
clusterrolebinding "prometheus-operator" created
deployment "prometheus-operator" created

$ kubectl apply -f demos/monitoring/sample-prometheus-instance.yaml
clusterrole "prometheus" created
serviceaccount "prometheus" created
clusterrolebinding "prometheus" created
prometheus "sample-metrics-prom" created
service "sample-metrics-prom" created

第二步 ,我们需要把 Custom Metrics APIServer 部署起来,如下所示:

$ kubectl apply -f demos/monitoring/custom-metrics.yaml
namespace "custom-metrics" created
serviceaccount "custom-metrics-apiserver" created
clusterrolebinding "custom-metrics:system:auth-delegator" created
rolebinding "custom-metrics-auth-reader" created
clusterrole "custom-metrics-read" created
clusterrolebinding "custom-metrics-read" created
deployment "custom-metrics-apiserver" created
service "api" created
apiservice "v1beta1.custom-metrics.metrics.k8s.io" created
clusterrole "custom-metrics-server-resources" created
clusterrolebinding "hpa-controller-custom-metrics" created

第三步 ,我们需要为 Custom Metrics APIServer 创建对应的 ClusterRoleBinding,以便能够使用 curl 来直接访问 Custom Metrics 的 API:

$ kubectl create clusterrolebinding allowall-cm --clusterrole custom-metrics-server-resources --user system:anonymous
clusterrolebinding "allowall-cm" created

第四步 ,我们就可以把待监控的应用和 HPA 部署起来了,如下所示:

$ kubectl apply -f demos/monitoring/sample-metrics-app.yaml
deployment "sample-metrics-app" created
service "sample-metrics-app" created
servicemonitor "sample-metrics-app" created
horizontalpodautoscaler "sample-metrics-app-hpa" created
ingress "sample-metrics-app" created

这里,我们需要关注一下 HPA 的配置,如下所示:

kind: HorizontalPodAutoscaler
apiVersion: autoscaling/v2beta1
metadata:
  name: sample-metrics-app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: sample-metrics-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Object
    object:
      target:
        kind: Service
        name: sample-metrics-app
      metricName: http_requests
      targetValue: 100

可以看到, HPA 的配置,就是你设置 Auto Scaling 规则的地方。

比如,scaleTargetRef 字段,就指定了被监控的对象是名叫 sample-metrics-app 的 Deployment,也就是我们上面部署的被监控应用。并且,它最小的实例数目是 2,最大是 10。

在 metrics 字段,我们指定了这个 HPA 进行 Scale 的依据,是名叫 http_requests 的 Metrics。而获取这个 Metrics 的途径,则是访问名叫 sample-metrics-app 的 Service。

有了这些字段里的定义, HPA 就可以向如下所示的 URL 发起请求来获取 Custom Metrics 的值了:

https://<apiserver_ip>/apis/custom-metrics.metrics.k8s.io/v1beta1/namespaces/default/services/sample-metrics-app/http_requests

需要注意的是,上述这个 URL 对应的被监控对象,是我们的应用对应的 Service。这跟本文一开始举例用到的 Pod 对应的 Custom Metrics URL 是不一样的。当然, 对于一个多实例应用来说,通过 Service 来采集 Pod 的 Custom Metrics 其实才是合理的做法。

这时候,我们可以通过一个名叫 hey 的测试工具来为我们的应用增加一些访问压力,具体做法如下所示:

# Install hey
docker run -it -v /usr/local/bin:/go/bin golang:1.8 go get github.com/rakyll/hey

export APP_ENDPOINT=$(kubectl get svc sample-metrics-app -o template --template {{.spec.clusterIP}}); echo ${APP_ENDPOINT}
hey -n 50000 -c 1000 http://${APP_ENDPOINT}

与此同时,如果你去访问应用 Service 的 Custom Metircs URL,就会看到这个 URL 已经可以为你返回应用收到的 HTTP 请求数量了,如下所示:

$ curl -sSLk https://<apiserver_ip>/apis/custom-metrics.metrics.k8s.io/v1beta1/namespaces/default/services/sample-metrics-app/http_requests
{
  "kind": "MetricValueList",
  "apiVersion": "custom-metrics.metrics.k8s.io/v1beta1",
  "metadata": {
    "selfLink": "/apis/custom-metrics.metrics.k8s.io/v1beta1/namespaces/default/services/sample-metrics-app/http_requests"
  },
  "items": [
    {
      "describedObject": {
        "kind": "Service",
        "name": "sample-metrics-app",
        "apiVersion": "/__internal"
      },
      "metricName": "http_requests",
      "timestamp": "2018-11-30T20:56:34Z",
      "value": "501484m"
    }
  ]
}

这里需要注意的,是 Custom Metrics API 为你返回的 Value 的格式。

在为被监控应用编写 /metrics API 的返回值时,我们其实比较容易计算的,是该 Pod 收到的 HTTP request 的总数。所以,我们这个应用的代码其实是如下所示的样子:

if (request.url == "/metrics") {
    response.end("# HELP http_requests_total The amount of requests served by the server in total\n# TYPE http_requests_total counter\nhttp_requests_total " + totalrequests + "\n");
    return;
}

可以看到,我们的应用在 /metrics 对应的 HTTP response 里返回的,其实是 http_requests_total 的值。这,也就是 Prometheus 收集到的值。

而 Custom Metrics APIServer 在收到对 http_requests 指标的访问请求之后,它会从 Prometheus 里查询 http_requests_total 的值,然后把它折算成一个以时间为单位的请求率,最后把这个结果作为 http_requests 指标对应的值返回回去。

所以说,我们在对前面的 Custom Metircs URL 进行访问时,会看到值是 501484m,这里的格式,其实就是 milli-requests,相当于是在过去两分钟内,每秒有 501 个请求。这样,应用的开发者就无需关心如何计算每秒的请求个数了。而这样的”请求率”的格式,是可以直接被 HPA 拿来使用的。

这时候,如果你同时查看 Pod 的个数的话,就会看到 HPA 开始增加 Pod 的数目了。

不过,在这里你可能会有一个疑问,Prometheus 项目,又是如何知道采集哪些 Pod 的 /metrics API 作为监控指标的来源呢。

实际上,如果仔细观察一下我们前面创建应用的输出,你会看到有一个类型是 ServiceMonitor 的对象也被创建了出来。它的 YAML 文件如下所示:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: sample-metrics-app
  labels:
    service-monitor: sample-metrics-app
spec:
  selector:
    matchLabels:
      app: sample-metrics-app
  endpoints:
  - port: web

这个 ServiceMonitor 对象,正是 Prometheus Operator 项目用来指定被监控 Pod 的一个配置文件。可以看到,我其实是通过 Label Selector 为 Prometheus 来指定被监控应用的。

49.1 总结

在本篇文章中,我为你详细讲解了 Kubernetes 里对自定义监控指标,即 Custom Metrics 的设计与实现机制。这套机制的可扩展性非常强,也终于使得 Auto Scaling 在 Kubernetes 里面不再是一个”食之无味”的鸡肋功能了。

另外可以看到,Kubernetes 的 Aggregator APIServer,是一个非常行之有效的 API 扩展机制。而且,Kubernetes 社区已经为你提供了一套叫作 KubeBuilder 的工具库,帮助你生成一个 API Server 的完整代码框架,你只需要在里面添加自定义 API,以及对应的业务逻辑即可。

50 | 让日志无处可逃:容器日志收集与管理

首先需要明确的是,Kubernetes 里面对容器日志的处理方式,都叫作 cluster-level-logging,即:这个日志处理系统,与容器、Pod 以及 Node 的生命周期都是完全无关的。这种设计当然是为了保证,无论是容器挂了、Pod 被删除,甚至节点宕机的时候,应用的日志依然可以被正常获取到。

而对于一个容器来说,当应用把日志输出到 stdout 和 stderr 之后,容器项目在默认情况下就会把这些日志输出到宿主机上的一个 JSON 文件里。这样,你通过 kubectl logs 命令就可以看到这些容器的日志了。

上述机制,就是我们今天要讲解的容器日志收集的基础假设。而如果你的应用是把文件输出到其他地方,比如直接输出到了容器里的某个文件里,或者输出到了远程存储里,那就属于特殊情况了。当然,我在文章里也会对这些特殊情况的处理方法进行讲述。

而 Kubernetes 本身,实际上是不会为你做容器日志收集工作的,所以为了实现上述 cluster-level-logging,你需要在部署集群的时候,提前对具体的日志方案进行规划。而 Kubernetes 项目本身,主要为你推荐了三种日志方案。

  1. 第一种,在 Node 上部署 logging agent,将日志文件转发到后端存储里保存起来。 这个方案的架构图如下所示

    第一种日志方案

    不难看到,这里的核心就在于 logging agent ,它一般都会以 DaemonSet 的方式运行在节点上,然后将宿主机上的容器日志目录挂载进去,最后由 logging-agent 把日志转发出去。

    举个例子,我们可以通过 Fluentd 项目作为宿主机上的 logging-agent,然后把日志转发到远端的 ElasticSearch 里保存起来供将来进行检索。具体的操作过程,你可以通过阅读 这篇文档 来了解。另外,在很多 Kubernetes 的部署里,会自动为你启用 logrotate ,在日志文件超过 10MB 的时候自动对日志文件进行 rotate 操作。

    可以看到,在 Node 上部署 logging agent 最大的优点,在于一个节点只需要部署一个 agent,并且不会对应用和 Pod 有任何侵入性。所以,这个方案,在社区里是最常用的一种。

    但是也不难看到,这种方案的不足之处就在于,它要求应用输出的日志,都必须是直接输出到容器的 stdout 和 stderr 里。

  2. 所以, Kubernetes 容器日志方案的第二种,就是对这种特殊情况的一个处理,即:当容器的日志只能输出到某些文件里的时候,我们可以通过一个 sidecar 容器把这些日志文件重新输出到 sidecar 的 stdout 和 stderr 上,这样就能够继续使用第一种方案了。 这个方案的具体工作原理,如下所示。

    第二种日志方案

    比如,现在我的应用 Pod 只有一个容器,它会把日志输出到容器里的 /var/log/1.log 和 2.log 这两个文件里。这个 Pod 的 YAML 文件如下所示:

    apiVersion: v1
    kind: Pod
    metadata:
    name: counter
    spec:
    containers:
    - name: count
        image: busybox
        args:
        - /bin/sh
        - -c
        - >
        i=0;
        while true;
        do
            echo "$i: $(date)" >> /var/log/1.log;
            echo "$(date) INFO $i" >> /var/log/2.log;
            i=$((i+1));
            sleep 1;
        done
        volumeMounts:
        - name: varlog
        mountPath: /var/log
    volumes:
    - name: varlog
        emptyDir: {}

    在这种情况下,你用 kubectl logs 命令是看不到应用的任何日志的。而且我们前面讲解的、最常用的方案一,也是没办法使用的。

    那么这个时候,我们就可以为这个 Pod 添加两个 sidecar 容器,分别将上述两个日志文件里的内容重新以 stdout 和 stderr 的方式输出出来,这个 YAML 文件的写法如下所示:

    apiVersion: v1
    kind: Pod
    metadata:
    name: counter
    spec:
    containers:
    - name: count
        image: busybox
        args:
        - /bin/sh
        - -c
        - >
        i=0;
        while true;
        do
            echo "$i: $(date)" >> /var/log/1.log;
            echo "$(date) INFO $i" >> /var/log/2.log;
            i=$((i+1));
            sleep 1;
        done
        volumeMounts:
        - name: varlog
        mountPath: /var/log
    - name: count-log-1
        image: busybox
        args: [/bin/sh, -c, 'tail -n+1 -f /var/log/1.log']
        volumeMounts:
        - name: varlog
        mountPath: /var/log
    - name: count-log-2
        image: busybox
        args: [/bin/sh, -c, 'tail -n+1 -f /var/log/2.log']
        volumeMounts:
        - name: varlog
        mountPath: /var/log
    volumes:
    - name: varlog
        emptyDir: {}

    这时候,你就可以通过 kubectl logs 命令查看这两个 sidecar 容器的日志,间接看到应用的日志内容了,如下所示:

    $ kubectl logs counter count-log-1
    0: Mon Jan 1 00:00:00 UTC 2001
    1: Mon Jan 1 00:00:01 UTC 2001
    2: Mon Jan 1 00:00:02 UTC 2001
    ...
    $ kubectl logs counter count-log-2
    Mon Jan 1 00:00:00 UTC 2001 INFO 0
    Mon Jan 1 00:00:01 UTC 2001 INFO 1
    Mon Jan 1 00:00:02 UTC 2001 INFO 2
    ...

    由于 sidecar 跟主容器之间是共享 Volume 的,所以这里的 sidecar 方案的额外性能损耗并不高,也就是多占用一点 CPU 和内存罢了。

    但需要注意的是,这时候,宿主机上实际上会存在两份相同的日志文件:一份是应用自己写入的;另一份则是 sidecar 的 stdout 和 stderr 对应的 JSON 文件。这对磁盘是很大的浪费。所以说,除非万不得已或者应用容器完全不可能被修改,否则我还是建议你直接使用方案一,或者直接使用下面的第三种方案。

  3. 第三种方案,就是通过一个 sidecar 容器,直接把应用的日志文件发送到远程存储里面去。 也就是相当于把方案一里的 logging agent,放在了应用 Pod 里。这种方案的架构如下所示:

    第三种日志方案

    在这种方案里,你的应用还可以直接把日志输出到固定的文件里而不是 stdout,你的 logging-agent 还可以使用 fluentd,后端存储还可以是 ElasticSearch。只不过, fluentd 的输入源,变成了应用的日志文件。一般来说,我们会把 fluentd 的输入源配置保存在一个 ConfigMap 里,如下所示:

    apiVersion: v1
    kind: ConfigMap
    metadata:
    name: fluentd-config
    data:
    fluentd.conf: |
        <source>
        type tail
        format none
        path /var/log/1.log
        pos_file /var/log/1.log.pos
        tag count.format1
        </source>
    
        <source>
        type tail
        format none
        path /var/log/2.log
        pos_file /var/log/2.log.pos
        tag count.format2
        </source>
    
        <match **>
        type google_cloud
        </match>

    然后,我们在应用 Pod 的定义里,就可以声明一个 Fluentd 容器作为 sidecar,专门负责将应用生成的 1.log 和 2.log 转发到 ElasticSearch 当中。这个配置,如下所示:

    apiVersion: v1
    kind: Pod
    metadata:
    name: counter
    spec:
    containers:
    - name: count
        image: busybox
        args:
        - /bin/sh
        - -c
        - >
        i=0;
        while true;
        do
            echo "$i: $(date)" >> /var/log/1.log;
            echo "$(date) INFO $i" >> /var/log/2.log;
            i=$((i+1));
            sleep 1;
        done
        volumeMounts:
        - name: varlog
        mountPath: /var/log
    - name: count-agent
        image: k8s.gcr.io/fluentd-gcp:1.30
        env:
        - name: FLUENTD_ARGS
        value: -c /etc/fluentd-config/fluentd.conf
        volumeMounts:
        - name: varlog
        mountPath: /var/log
        - name: config-volume
        mountPath: /etc/fluentd-config
    volumes:
    - name: varlog
        emptyDir: {}
    - name: config-volume
        configMap:
        name: fluentd-config

    可以看到,这个 Fluentd 容器使用的输入源,就是通过引用我们前面编写的 ConfigMap 来指定的。这里我用到了 Projected Volume 来把 ConfigMap 挂载到 Pod 里。如果你对这个用法不熟悉的话,可以再回顾下第 15 篇文章 《 深入解析 Pod 对象(二):使用进阶》 中的相关内容。

    需要注意的是,这种方案虽然部署简单,并且对宿主机非常友好,但是这个 sidecar 容器很可能会消耗较多的资源,甚至拖垮应用容器。并且,由于日志还是没有输出到 stdout 上,所以你通过 kubectl logs 是看不到任何日志输出的。

以上,就是 Kubernetes 项目对容器应用日志进行管理最常用的三种手段了。

50.1 总结

在本篇文章中,我为你详细讲解了 Kubernetes 项目对容器应用日志的收集方式。综合对比以上三种方案,我比较建议你将应用日志输出到 stdout 和 stderr,然后通过在宿主机上部署 logging-agent 的方式来集中处理日志。

这种方案不仅管理简单,kubectl logs 也可以用,而且可靠性高,并且宿主机本身,很可能就自带了 rsyslogd 等非常成熟的日志收集组件来供你使用。

除此之外,还有一种方式就是在编写应用的时候,就直接指定好日志的存储后端,如下所示:

第四种日志方案

在这种方案下,Kubernetes 就完全不必操心容器日志的收集了,这对于本身已经有完善的日志处理系统的公司来说,是一个非常好的选择。

最后需要指出的是,无论是哪种方案,你都必须要及时将这些日志文件从宿主机上清理掉,或者给日志目录专门挂载一些容量巨大的远程盘。否则,一旦主磁盘分区被打满,整个系统就可能会陷入奔溃状态,这是非常麻烦的。

再谈开源与社区

51 | 谈谈Kubernetes开源社区和未来走向

在前面的文章中,我已经为你详细讲解了容器与 Kubernetes 项目的所有核心技术点。在今天这最后一篇文章里,我就跟你谈一谈 Kubernetes 开源社区以及 CNCF 相关的一些话题。

我们知道 Kubernetes 这个项目是托管在 CNCF 基金会下面的。但是,我在专栏最前面讲解容器与 Kubernetes 的发展历史的时候就已经提到过,CNCF 跟 Kubernetes 的关系,并不是传统意义上的基金会与托管项目的关系,CNCF 实际上扮演的,是 Kubernetes 项目的 Marketing 的角色。

这就好比,本来 Kubernetes 项目应该是由 Google 公司一家维护、运营和推广的。但是为了表示中立,并且吸引更多的贡献者加入,Kubernetes 项目从一开始就选择了由基金会托管的模式。而这里的关键在于,这个基金会本身,就是 Kubernetes 背后的”大佬们”一手创建出来的,然后以中立的方式,对 Kubernetes 项目进行运营和 Marketing。

通过这种方式,Kubernetes 项目既避免了因为 Google 公司在开源社区里的”不良作风”和非中立角色被竞争对手口诛笔伐,又可以站在开源基金会的制高点上团结社区里所有跟容器相关的力量。而随后 CNCF 基金会的迅速发展和壮大,也印证了这个思路其实是非常正确和有先见之明的。

不过,在 Kubernetes 和 Prometheus 这两个 CNCF 的一号和二号项目相继毕业之后,现在 CNCF 社区的更多职能,就是扮演一个传统的开源基金会的角色,吸纳会员,帮助项目孵化和运转。

只不过,由于 Kubernetes 项目的巨大成功,CNCF 在云计算领域已经取得了极高的声誉和认可度,也填补了以往 Linux 基金会在这一领域的空白。所以说,你可以认为现在的 CNCF,就是云计算领域里的 Apache ,而它的作用跟当年大数据领域里 Apache 基金会的作用是一样的。

不过,需要指出的是, 对于开源项目和开源社区的运作来说,第三方基金会从来就不是一个必要条件。 事实上,这个世界上绝大多数成功的开源项目和社区,都来自于一个聪明的想法或者一帮杰出的黑客。在这些项目的发展过程中,一个独立的、第三方基金会的作用,更多是在该项目发展到一定程度后主动进行商业运作的一部分。开源项目与基金会间的这一层关系,希望你不要本末倒置了。

另外,需要指出的是,CNCF 基金会仅仅负责成员项目的 Marketing, 而绝不会、也没有能力直接影响具体项目的发展历程。无论是任何一家成员公司或者是 CNCF 的 TOC(Technical Oversight Committee,技术监督委员会),都没有对 Kubernetes 项目”指手画脚”的权利,除非这位 TOC 本人就是 Kubernetes 项目里的关键人物。

所以说,真正能够影响 Kubernetes 项目发展的,当然还是 Kubernetes 社区本身。可能你会好奇, Kubernetes 社区本身的运作方式,又是怎样的呢?

通常情况下,一个基金会下面托管的项目,都需要遵循基金会本身的管理机制,比如统一的 CI 系统、Code Review 流程、管理方式等等。

但是,在我们这个社区的实际情况,是先有的 Kubernetes,然后才有的 CNCF,并且 CNCF 基金会还是 Kubernetes “一手带大”的。所以,在项目治理这个事情上,Kubernetes 项目早就自成体系,并且发展的非常完善了。而基金会里的其他项目一般各自为阵,CNCF 不会对项目本身的治理方法提出过多的要求。

而说到 Kubernetes 项目的治理方式,其实还是比较贴近 Google 风格的,即:重视代码,重视社区的民主性。

首先,Kubernetes 项目是一个没有”Maintainer”的项目。这一点非常有意思,Kubernetes 项目里曾经短时间内存在过 Maintainer 这个角色,但是很快就被废弃了。取而代之的,则是 approver+reviewer 机制。这里具体的原理,是在 Kubernetes 的每一个目录下,你都可以添加一个 OWNERS 文件,然后在文件里写入这样的字段:

approvers:
- caesarxuchao
reviewers:
- lavalamp
labels:
- sig/api-machinery
- area/apiserver

比如,上面这个例子里,approver 的 GitHub ID 就是 caesarxuchao (Xu Chao),reviewer 就是 lavalamp。这就意味着,任何人提交的 Pull Request(PR,代码修改请求),只要修改了这个目录下的文件,那么就必须要经过 lavalamp 的 Code Review,然后再经过 caesarxuchao 的 Approve 才可以被合并。当然,在这个文件里,caesarxuchao 的权力是最大的,它可以既做 Code Review,也做最后的 Approve。但, lavalamp 是不能进行 Approve 的。

当然,无论是 Code Review 通过,还是 Approve,这些维护者只需要在 PR 下面 Comment /lgtm 和 /approve,Kubernetes 项目的机器人(k8s-ci-robot)就会自动给该 PR 加上 lgtm 和 approve 标签,然后进入 Kubernetes 项目 CI 系统的合并队列,最后被合并。此外,如果你要对这个项目加标签,或者把它 Assign 给其他人,也都可以通过 Comment 的方式来进行。

可以看到,在上述整个过程中,代码维护者不需要对 Kubernetes 项目拥有写权限,就可以完成代码审核、合并等所有流程。这当然得益于 Kubernetes 社区完善的机器人机制,这也是 GitHub 最吸引人的特性之一。

顺便说一句,很多人问我, GitHub 比 GitLab 或者其他代码托管平台强在哪里? 实际上, GitHub 庞大的 API 和插件生态,才是这个产品最具吸引力的地方。

当然,当你想要将你的想法以代码的形式提交给 Kubernetes 项目时,除非你的改动是 bugfix 或者很简单的改动,否则,你直接提交一个 PR 上去,是大概率不会被 Approve 的。 这里的流程 ,一定要按照我下面的讲解来进行:

  1. 在 Kubernetes 主库里创建 Issue,详细地描述你希望解决的问题、方案,以及开发计划。而如果社区里已经有相关的 Issue 存在,那你就必须要在这里把它们引用过来。而如果社区里已经存在相同的 Issue 了,你就需要确认一下,是不是应该直接转到原有 issue 上进行讨论。

  2. 给 Issue 加上与它相关的 SIG 的标签。比如,你可以直接 Comment /sig node,那么这个 Issue 就会被加上 sig-node 的标签,这样 SIG-Node 的成员就会特别留意这个 Issue。

  3. 收集社区对这个 Issue 的信息,回复 Comment,与 SIG 成员达成一致。必要的时候,你还需要参加 SIG 的周会,更好地阐述你的想法和计划。

  4. 在与 SIG 的大多数成员达成一致后,你就可以开始进行详细的设计了。

  5. 如果设计比较复杂的话,你还需要在 Kubernetes 的 设计提议目录 (在 Kubernetes Community 库里)下提交一个 PR,把你的设计文档加进去。这时候,所有关心这个设计的社区成员,都会来对你的设计进行讨论。不过最后,在整个 Kubernetes 社区只有很少一部分成员才有权限来 Review 和 Approve 你的设计文档。他们当然也被定义在了这个目录下面的 OWNERS 文件里,如下所示:

    reviewers:
    - brendandburns
    - dchen1107
    - jbeda
    - lavalamp
    - smarterclayton
    - thockin
    - wojtek-t
    - bgrant0607
    approvers:
    - brendandburns
    - dchen1107
    - jbeda
    - lavalamp
    - smarterclayton
    - thockin
    - wojtek-t
    - bgrant0607
    labels:
    - kind/design

    这几位成员,就可以称为社区里的”大佬”了。不过我在这里要提醒你的是,”大佬”并不一定代表水平高,所以你还是要擦亮眼睛。此外,Kubernetes 项目的几位创始成员,被称作 Elders(元老),分别是 jbeda、bgrant0607、brendandburns、dchen1107 和 thockin。你可以查看一下这个列表与上述”大佬”名单有什么不同。

  6. 上述 Design Proposal 被合并后,你就可以开始按照设计文档的内容编写代码了。这个流程,才是正常大家所熟知的编写代码、提交 PR、通过 CI 测试、进行 Code Review,然后等待合并的流程。

  7. 如果你的 feature 是需要要在 Kubernetes 的正式 Release 里发布上线的,那么你还需要在 Kubernetes Enhancements 这个库里面提交一个 KEP(即 Kubernetes Enhancement Proposal)。这个 KEP 的主要内容,是详细地描述你的编码计划、测试计划、发布计划,以及向后兼容计划等软件工程相关的信息,供全社区进行监督和指导。

以上内容,就是 Kubernetes 社区运作的主要方式了。

51.1 总结

在本篇文章里,我为你详细讲述了 CNCF 和 Kubernetes 社区的关系,以及 Kubernetes 社区的运作方式,希望能够帮助你更好地理解这个社区的特点和它的先进之处。

除此之外,你可能还听说过 Kubernetes 社区里有一个叫作 Kubernetes Steering Committee 的组织。这个组织,其实也是属于 Kubernetes Community 库 的一部分。这个组织成员的主要职能,是对 Kubernetes 项目治理的流程进行约束和规范,但通常并不会直接干涉 Kubernetes 具体的设计和代码实现。

其实,到目前为止,Kubernetes 社区最大的一个优点,就是把”搞政治”的人和”搞技术”的人分得比较清楚。相信你也不难理解,这两种角色在一个活跃的开源社区里其实都是需要的,但是,如果这两部分人发生了大量的重合,那对于一个开源社区来说,恐怕就是个灾难了。

答疑文章

52 | 答疑:在问题中解决问题,在思考中产生思考

  1. 问题 1:你是否知道如何修复容器中的 top 指令以及 /proc 文件系统中的信息呢?(提示:lxcfs)

    其实,这个问题的答案在提示里其实已经给出了,即 lxcfs 方案。通过 lxcfs,你可以把宿主机的 /var/lib/lxcfs/proc 文件系统挂载到 Docker 容器的 /proc 目录下。使得容器中进程读取相应文件内容时,实际上会从容器对应的 Cgroups 中读取正确的资源限制。 从而得到正确的 top 命令的返回值。

    问题选自第 6 篇文章 《白话容器基础(二):隔离与限制》

  2. 问题 2:既然容器的 rootfs(比如,Ubuntu 镜像),是以只读方式挂载的,那么又如何在容器里修改 Ubuntu 镜像的内容呢?(提示:Copy-on-Write)

    这个问题的答案也同样出现在了提示里。

    简单地说,修改一个镜像里的文件的时候,联合文件系统首先会从上到下在各个层中查找有没有目标文件。如果找到,就把这个文件复制到可读写层进行修改。这个修改的结果会屏蔽掉下层的文件,这种方式就被称为 copy-on-write。

    问题选自第 7 篇文章 《白话容器基础(三):深入理解容器镜像》

  3. 问题 3:你在查看 Docker 容器的 Namespace 时,是否注意到有一个叫 cgroup 的 Namespace?它是 Linux 4.6 之后新增加的一个 Namespace,你知道它的作用吗?

    Linux 内核从 4.6 开始,支持了一个新的 Namespace 叫作:Cgroup Namespace。 我们知道,正常情况下,在一个容器里查看 /proc/$PID/cgroup,是会看到整个宿主机的 cgroup 信息的。而有了 Cgroup Namespace 后,每个容器里的进程都会有自己 Cgroup Namespace,从而获得一个属于自己的 Cgroups 文件目录视图。也就是说,Cgroups 文件系统也可以被 Namespace 隔离起来了。

    问题选自第 8 篇文章 《 白话容器基础(四):重新认识 Docker 容器》

  4. 问题 4:你能否说出,Kubernetes 使用的这个”控制器模式”,跟我们平常所说的”事件驱动”,有什么区别和联系吗?

    这里”控制器模式”和”事件驱动”最关键的区别在于:

    • 对于控制器来说,被监听对象的变化是一个持续的信号,比如变成 ADD 状态。只要这个状态没变化,那么此后无论任何时候控制器再去查询对象的状态,都应该是 ADD。
    • 而对于事件驱动来说,它只会在 ADD 事件发生的时候发出一个事件。如果控制器错过了这个事件,那么它就有可能再也没办法知道 ADD 这个事件的发生了。

      问题选自第 16 篇文章 《编排其实很简单:谈谈”控制器”模型》

  5. 问题 5:在实际场景中,有一些分布式应用的集群是这么工作的:当一个新节点加入到集群时,或者老节点被迁移后重建时,这个节点可以从主节点或者其他从节点那里同步到自己所需要的数据。

    在这种情况下,你认为是否还有必要将这个节点 Pod 与它的 PV 进行一对一绑定呢?(提示:这个问题的答案根据不同的项目是不同的。关键在于,重建后的节点进行数据恢复和同步的时候,是不是一定需要原先它写在本地磁盘里的数据)

    这个问题的答案是不需要。

    像这种不依赖于 PV 保持存储状态或者不依赖于 DNS 名字保持拓扑状态的”非典型”应用的管理,都应该使用 Operator 来实现。

    问题选自第 19 篇文章 《深入理解 StatefulSet(二):存储状态》

  6. 问题 6:我在文中提到,在 Kubernetes v1.11 之前,DaemonSet 所管理的 Pod 的调度过程,实际上都是由 DaemonSet Controller 自己而不是由调度器完成的。你能说出这其中有哪些原因吗?

    这里的原因在于,默认调度器之前的功能不是很完善,比如,缺乏优先级和抢占机制。所以,它没办法保证 DaemonSet ,尤其是部署时候的系统级的、高优先级的 DaemonSet 一定会调度成功。这种情况下,就会影响到集群的部署了。

    问题选自第 21 篇文章 《容器化守护进程的意义:DaemonSet》

  7. 问题 7:在 Operator 的实现过程中,我们再一次用到了 CRD。可是,你一定要明白,CRD 并不是万能的,它有很多场景不适用,还有性能瓶颈。你能列举出一些不适用 CRD 的场景么?你知道造成 CRD 性能瓶颈的原因主要在哪里么?

    CRD 目前不支持 protobuf,当 API Object 数量 >1K,或者单个对象 >1KB,或者高频请求时,CRD 的响应都会有问题。 所以,CRD 千万不能也不应该被当作数据库使用。

    其实像 Kubernetes ,或者说 Etcd 本身,最佳的使用场景就是作为配置管理的依赖。此外,如果业务需求不能用 CRD 进行建模的时候,比如,需要等待 API 最终返回,或者需要检查 API 的返回值,也是不能用 CRD 的。同时,当你需要完整的 APIServer 而不是只关心 API 对象的时候,请使用 API Aggregator。

    问题选自第 27 篇文章 《聪明的微创新:Operator 工作原理解读》

  8. 问题 8:正是由于需要使用”延迟绑定”这个特性,Local Persistent Volume 目前还不能支持 Dynamic Provisioning。你是否能说出,为什么”延迟绑定”会跟 Dynamic Provisioning 有冲突呢?

    延迟绑定将 Volume Bind 的时机,推迟到了第一个使用该 Volume 的 Pod 到达调度器的时候。可是对于 Dynamic Provisioning 来说,它是要在管理 Volume 的控制循环里就为 PVC 创建 PV 然后绑定起来的,这个时间点跟 Pod 被调度的时间点是不相关的。

    问题选自第 29 篇文章 《 PV、PVC 体系是不是多此一举?从本地持久化卷谈起》

  9. 问题 9:请你根据编写 FlexVolume 和 CSI 插件的流程,分析一下什么时候该使用 FlexVolume,什么时候应该使用 CSI?

    在文章中我其实已经提到,CSI 与 FlexVolume 的最大区别,在于 CSI 可以实现 Provision 阶段。所以说,对于不需要 Provision 的情况 ,比如你的远程存储服务总是事先准备好或者准备起来非常简单的情况下,就可以考虑使用 FlexVolume。但在生产环境下,我都会优先推荐 CSI 的方案。

    问题选自第 31 篇文章 《容器存储实践:CSI 插件编写指南》

  10. 问题 10:Flannel 通过”隧道”机制,实现了容器之间三层网络(IP 地址)的连通性。但是,根据这个机制的工作原理,你认为 Flannel 能保证容器二层网络(MAC 地址)的连通性吗?为什么呢?

    不能保证,因为”隧道”机制只能保证被封装的 IP 包可以到达目的地。而只要网络插件能满足 Kubernetes 网络的三个假设,Kubernetes 并不关心你的网络插件的实现方式是把容器二层连通的,还是三层连通的。

    问题选自第 33 篇文章 《深入解析容器跨主机网络》

  11. 问题 11:你能否能总结一下三层网络方案和”隧道模式”的异同,以及各自的优缺点?

    在第 35 篇文章的正文里,我已经为你讲解过,隧道模式最大的特点,在于需要通过某种方式比如 UDP 或者 VXLAN 来对原始的容器间通信的网络包进行封装,然后伪装成宿主机间的网络通信来完成容器跨主通信。这个过程中就不可避免的需要封包和解封包。这两个操作的性能损耗都是非常明显的。而三层网络方案则避免了这个过程,所以性能会得到很大的提升。

    不过,隧道模式的优点在于,它依赖的底层原理非常直白,内核里的实现也非常成熟和稳定。而三层网络方案,相对来说维护成本会比较高,容易碰到路由规则分发和设置出现问题的情况,并且当容器数量很多时,宿主机上的路由规则会非常复杂,难以 Debug。

    所以最终选择选择哪种方案,还是要看自己的具体需求。

    问题选自第 35 篇文章 《解读 Kubernetes 三层网络方案》

  12. 问题 12:为什么宿主机进入 MemoryPressure 或者 DiskPressure 状态后,新的 Pod 就不会被调度到这台宿主机上呢?

    在 Kubernetes 里,实际上有一种叫作 Taint Nodes by Condition 的机制,即当

    Node 本身进入异常状态的时候,比如 Condition 变成了 DiskPressure。那么, Kubernetes 会通过 Controller 自动给 Node 加上对应的 Taint,从而阻止新的 Pod 调度到这台宿主机上。

    问题选自第 40 篇文章《Kubernetes 的资源模型与资源管理》。

  13. 问题 13:Kubernetes 默认调度器与 Mesos 的”两级”调度器,有什么异同呢?

    Mesos 的两级调度器的设计,是 Mesos 自己充当 0 层调度器(Layer 0),负责统一管理整个集群的资源情况,把可用资源以 Resource Offer 的方式暴露出去;而上层的大数据框架(比如 Spark)则充当 1 层调度器(Layer 1),它会负责根据 Layer 0 发来的 Resource Offer 来决定把任务调度到某个具体的节点上。这样做的好处是:

    • 第一,上层大数据框架本身往往自己已经实现了调度逻辑,这样它就可以很方便地接入到 Mesos 里面;
    • 第二,这样的设计,使得 Mesos 本身能够统一地对上层所有框架进行资源分配,资源利用率和调度效率就可以得到很好的保证了。

    相比之下,Kubernetes 的默认调度器实际上无论从功能还是性能上都要简单得多。这也是为什么把 Spark 这样本身就具有调度能力的框架接入到 Kubernetes 里还是比较困难的。

    问题选自第 41 篇文章 《十字路口上的 Kubernetes 默认调度器》

  14. 问题 14:当整个集群发生可能会影响调度结果的变化(比如,添加或者更新 Node,添加和更新 PV、Service 等)时,调度器会执行一个被称为 MoveAllToActiveQueue 的操作,把所调度失败的 Pod 从 unscheduelableQ 移动到 activeQ 里面。请问这是为什么?

    一个相似的问题是,当一个已经调度成功的 Pod 被更新时,调度器则会将 unschedulableQ 里所有跟这个 Pod 有 Affinity/Anti-affinity 关系的 Pod,移动到 activeQ 里面。请问这又是为什么呢?

    其实,这两个问题的答案是一样的。

    在正常情况下,默认调度器在调度失败后,就会把该 Pod 放到 unschedulableQ 里。unschedulableQ 里的 Pod 是不会出现在下个调度周期里的。但是,当集群本身发生变化时,这个 Pod 就有可能再次变成可调度的了,所以这时候调度器要把它们移动到 activeQ 里面,这样它们就获得了下一次调度的机会。

    类似地,当原本已经调度成功的 Pod 被更新后,也有可能触发 unschedulableQ 里与它有 Affinity 或者 Anti-Affinity 关系的 Pod 变成可调度的,所以它也需要获得”重新做人”的机会。

    问题选自第 43 篇文章 《Kubernetes 默认调度器的优先级与抢占机制》

  15. 问题 15:请你思考一下,我前面讲解过的 Device Plugin 为容器分配的 GPU 信息,是通过 CRI 的哪个接口传递给 dockershim,最后交给 Docker API 的呢?

    既然 GPU 是 Devices 信息,那当然是通过 CRI 的 CreateContainerRequest 接口。这个接口的参数 ContainerConfig 里就有容器 Devices 的描述。

    问题选自第 46 篇文章 《解读 CRI 与 容器运行时》

  16. 问题 16:安全容器的意义,绝不仅仅止于安全。你可以想象一下这样一个场景:比如,你的宿主机的 Linux 内核版本是 3.6,但是应用却必须要求 Linux 内核版本是 4.0。这时候,你就可以把这个应用运行在一个 KataContainers 里。那么请问,你觉得使用 gVisor 是否也能提供这种能力呢?原因是什么呢?

    答案是不能。gVisor 的实现里并没有一个真正的 Linux Guest Kernel 在运行。所以它不能像 KataContainers 或者虚拟机那样,实现容器和宿主机不同 Kernel 甚至不同操作系统的需求。

    但还是要强调一下,以 gVisor 为代表的用户态 Kernel 方案是安全容器的未来,只是现在还不够完善。

    问题选自第 47 篇文章 《绝不仅仅是安全:Kata Containers 与 gVisor》

  17. 问题 17:将日志直接输出到 stdout 和 stderr,有没有什么其他的隐患或者问题呢?如何进行处理呢?

    这样做有一个问题,就是日志都需要经过 Docker Daemon 的处理才会写到宿主机磁盘上,所以宿主机没办法以容器为单位进行日志文件的 Rotate。这时候,还是要考虑通过宿主机的 Agent 来对容器日志进行处理和收集的方案。

    问题选自第 50 篇文章 《让日志无处可逃:容器日志收集与管理》

  18. 问题 18:你能说出 Kubernetes 社区同 OpenStack 社区相比的不同点吗?你觉得各有哪些优缺点呢?

    OpenStack 社区非常强调民主化,治理方式相对松散,这导致它在治理上没能把主线和旁线分开,政治和技术也没有隔离。这使得后期大量的低价值或者周边型的项目不断冲进 OpenStack 社区,大大降低了社区的含金量,并且分散了大量的社区精力在这些价值相对不高的项目上,从而拖慢并干扰了比如 Cinder、Neutron 等核心项目的演进步伐和方向,最终使得整个社区在容器的热潮下难以掉头,不可避免地走向了下滑的态势。

    相比之下,CNCF 基金会成功地帮助 Kubernetes 社区分流了低价值以及周边型项目的干扰,并且完全承接了 Marketing 的角色,使得 Kubernetes 社区在后面大量玩家涌入的时候,依然能够专注在主线的演进上。

    Kubernetes 社区和 OpenStack 社区的这个区别,是非常关键的。

    问题选自第 51 篇文章 《谈谈 Kubernetes 开源社区和未来走向》

特别放送

特别放送 | 2019 年,容器技术生态会发生些什么?

  1. Kubernetes 项目被采纳度将持续增长

    作为”云原生”(Cloud Native)理念落地的核心,Kubernetes 项目已经成为了构建容器化平台体系的默认选择。但是,不同于一个只能生产资源的集群管理工具, Kubernetes 项目最大的价值,乃在于它从一开始就提倡的声明式 API 和以此为基础”控制器”模式。

    在这个体系的指导下, Kubernetes 项目保证了在自身突飞猛进的发展过程中 API 层的相对稳定和一定的向后兼容能力,这是作为一个平台级项目被用户广泛接受和认可的重要前提。

    更重要的是,Kubernetes 项目为使用者提供了宝贵的 API 可扩展能力和良好的 API 编程范式,催生出了一个完全基于 Kubernetes API 构建出来的上层应用服务生态。可以说,正是这个生态的逐步完善与日趋成熟,才确立了 Kubernetes 项目如今在云平台领域牢不可破的领导地位,也间接宣告了其他竞品方案的边缘化。

    与此同时,上述事实标准的确立,也使得”正确和合理的使用了 Kubernetes 的能力”,在某种意义上成为了评判上层应用服务框架(比如 PaaS 和 Serverless )的一个重要依据:这不仅包括了对框架本身复杂性和易用性的考量,也包括了对框架可扩展性和演进趋势的预期与判断。

    不过,相比于国外公有云上以 Kubernetes 为基础的容器化作业的高占比,国内公有云市场对容器的采纳程度目前仍然处于比较初步的水平,直接贩卖虚拟机及其关联 IaaS 层能力依然是国内绝大多数公有云提供商的主要业务形态。

    所以,不同于国外市场容器技术增长逐步趋于稳定、Kubernetes 公有云服务已经开始支撑头部互联网客户的情况, Kubernetes 以及容器技术在国内云计算市场里依然具有巨大的增长空间和强劲的发展势头。

    不难预测,Kubernetes 项目在国内公有云上的逐渐铺开,会逐渐成为接下来几年国内公有云市场上的一个重要趋势。而无论是国内外,大量 Kubernetes 项目相关岗位的涌现,正是验证这个趋势与变化的一个最直接的征兆。

  2. “Serverless 化”与”多样性”将成为上层应用服务生态的两大关键词

    当云上的平台层被 Kubernetes 项目逐步统一之后,过去长期纠结在应用编排、调度与资源管理上裹足不前的 PaaS 项目得到了生产力的全面释放,进而在云平台层之上催生出了一个日趋多样化的应用服务生态。

    事实上,这个生态的本质与 2014 年之前的 PaaS 生态没有太大不同。只不过,当原本 PaaS 项目的平台层功能(编排、调度、资源管理等)被剥离了出来之后,PaaS 终于可以专注于应用服务和发布流程管理这两个最核心的功能,开始向更轻、更薄、更以应用为中心的方向进行演进。而在这个过程中, Serverless 自然开始成为了主流话题。

    这里需要指出的是,Serverless 从 2014 年 AWS 发布 Lambda 时专门用来指代函数计算(或者说 FaaS)发展到今天,已经被扩展成了包括大多数 PaaS 功能在内的一个泛指术语,即:Serverless = FaaS + BaaS。

    而究其本质, “高可扩展性”、”工作流驱动”和”按使用计费” ,可以认为是 Serverless 最主要的三个特征。这也是为什么我们会说今天大家所谈论的 Serverless,其实是经典 PaaS 演进到今天的一种”极端”形态。

    伴随着 Serverless 概念本身的”横向发展”,我们不难预料到,2019 年之后云端的应用服务生态,一定会趋于多样化,进而覆盖到更多场景下的应用服务管理需求。 并且,无论是 Function、传统应用、容器、存储服务、网络服务,都会开始尝试以不同的方式和形态嵌入到”高可扩展性”、”工作流驱动”和”按使用计费”这三个特征当中。

    当然,这种变化趋势的原因也不言而喻:Serverless 三个特征背后所体现的, 乃是云端应用开发过程向”用户友好”和”低心智负担”方向演进的最直接途径。而这种”简单、经济、可信赖”的朴实诉求,正是云计算诞生的最初期许和永恒的发展方向。

    而在这种上层应用服务能力向 Serverless 迁移的演进过程中,不断被优化的 Auto-scaling 能力和细粒度的资源隔离技术,将会成为确保 Serverless 能为用户带来价值的最有力保障。

  3. 看得见、摸得着、能落地的”云原生”

    自从 CNCF 社区迅速崛起以来,”云原生”三个字就成了各大云厂商竞相角逐的一个关键词。不过,相比于 Kubernetes 项目和容器技术实实在在的发展和落地过程,云原生(Cloud Native)的概念却长期以来”曲高和寡”,让人很难说出个所以然来。

    其实,”云原生”的本质,不是简单对 Kubernetes 生态体系的一个指代。 “云原生” 刻画出的,是一个使用户能低心智负担的、敏捷的,以可扩展、可复制的方式,最大化利用”云”的能力、发挥”云”的价值的一条最佳路径。

    而这其中, “不可变基础设施” 是”云原生”的实践基础(这也是容器技术的核心价值);而 Kubernetes、Prometheus、Envoy 等 CNCF 核心项目,则可以认为是这个路径落地的最佳实践。 这套理论体系的发展过程,与 CNCF 基金会创立的初衷和云原生生态的发展历程是完全一致的。

    也正是伴随着这样的发展过程,云原生对于它的使用者的意义,在 2019 年之后已经变得非常清晰: 是否采用云原生技术体系,实际上已经成为了一个关系到是不是要最大化”云”的价值、是不是要在”云”上赢取最广泛用户群体的一个关键取舍。 这涉及到的,是关系到整个组织的发展、招聘、产品形态等一系列核心问题,而绝非一个单纯的技术决定。

    明白了这一层道理,在 2019 年,我们已经不难看到,国内最顶尖的技术公司们,都已经开始在云原生技术框架下发起了实实在在的技术体系升级与落地的”战役”。显然,大家都已经注意到, 相比于纠结于”云原生到底是什么”这样意识形态话题,抓紧时间和机遇将 Kubernetes 及其周边核心技术生态在组织中生长起来,并借此机会完成自身基础技术体系的转型与升级,才是这些体量庞大的技术巨人赶上这次云计算浪潮的不二法宝。

    在这个背景下,所谓”云原生”体系在这些公司的落地,只是这个激动人心的技术革命背后的一个附加值而已。

    而在”云原生”这个关键词的含义不断清晰的过程中,我们一定要再次强调: 云原生不等于 CNCF,更不等于 Kubernetes。 云原生固然源自于 Kubernetes 技术生态和理念, 但也必然是一个超越 CNCF 和 Kubernetes 存在的一个全集。 它被创立的目的和始终在坚持探索的方向, 是使用户能够最大化利用”云”的能力、发挥”云”的价值,而不是在此过程中构建一个又一个不可复制、不可扩展的”巨型烟囱”。

    所以说,云原生这个词语的准确定义,是围绕着 Kubernetes 技术生态为核心的,但也一定是一个伴随着 CNCF 社区和 Kubernetes 项目不断演进而日趋完善的一个动态过程。而更为重要的是, 在这次以”云”为关键词的技术革命当中,我们每一个人都有可能成为”云原生”的一个重要的定义者。


文章作者: Kiba Amor
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 Kiba Amor !
  目录