first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 打通“容器技术”的任督二脉
你好我是张磊Kubernetes社区的一位资深成员和项目维护者。
2012年我还在浙大读书的时候就有幸组建了一个云计算与PaaS基础设施相关的科研团队就这样我从早期的Cloud Foundry社区开始正式与容器结缘。
这几年里我大多数时间都在Kubernetes项目里从事上游技术工作也得以作为一名从业者和社区成员的身份参与和亲历了容器技术从“初出茅庐”到“尘埃落定”的全过程。
而即使从2013年Docker项目发布开始算起这次变革也不过短短5年时间可在现如今的技术圈儿里不懂容器没听过Kubernetes你还真不好意思跟人打招呼。
容器技术这样一个新生事物,完全重塑了整个云计算市场的形态。它不仅催生出了一批年轻有为的容器技术人,更培育出了一个具有相当规模的开源基础设施技术市场。
在这个市场里不仅有Google、Microsoft等技术巨擘们厮杀至今更有无数的国内外创业公司前仆后继。而在国内甚至连以前对开源基础设施领域涉足不多的BAT、蚂蚁、滴滴这样的巨头们也都从AI、云计算、微服务、基础设施等维度多管齐下争相把容器和Kubernetes项目树立为战略重心之一。
就在这场因“容器”而起的技术变革中Kubernetes项目已然成为容器技术的事实标准重新定义了基础设施领域对应用编排与管理的种种可能。
2014年后我开始以远程的方式全职在Kubernetes和Kata Containers社区从事上游开发工作先后发起了容器镜像亲密性调度、基于等价类的调度优化等多个核心特性参与了容器运行时接口、安全容器沙盒等多个基础特性的设计和研发。还有幸作为主要的研发人员和维护者之一亲历了Serverless Container概念的诞生与崛起。
在2015年我发起和组织撰写了《Docker容器与容器云》一书希望帮助更多的人利用容器解决实际场景中的问题。时至今日这本书的第2版也已经出版快2年了受到了广大容器技术读者们的好评。
2018年我又赴西雅图在微软研究院MSR云计算与存储研究组专门从事基于Kubernetes的深度学习基础设施相关的研究工作。
我与容器打交道的这些年,一直在与关注容器生态的工程师们交流,并经常探讨容器在落地过程中遇到的问题。从这些交流中,我发现总有很多相似的问题被反复提及,比如:
为什么容器里只能跑“一个进程”?
为什么我原先一直在用的某个JVM参数在容器里就不好使了
为什么Kubernetes就不能固定IP地址容器网络连不通又该如何去Debug
Kubernetes中StatefulSet和Operator到底什么区别PV和PVC这些概念又该怎么用
这些问题乍一看与我们平常的认知非常矛盾,但它们的答案和原理却并不复杂。不过很遗憾,对于刚刚开始学习容器的技术人员来说,它们却很难用一两句话就能解释清楚。
究其原因在于,从过去以物理机和虚拟机为主体的开发运维环境,向以容器为核心的基础设施的转变过程,并不是一次温和的改革,而是涵盖了对网络、存储、调度、操作系统、分布式原理等各个方面的容器化理解和改造。
这就导致了很多初学者对于容器技术栈表现出来的这些难题要么知识储备不足要么杂乱无章、无法形成体系。这也是很多初次参与PaaS项目的从业者们共同面临的一个困境。
其实容器技术体系看似纷乱繁杂却存在着很多可以“牵一发而动全身”的主线。比如Linux的进程模型对于容器本身的重要意义或者“控制器”模式对整个Kubernetes项目提纲挈领的作用。
但是这些关于Linux内核、分布式系统、网络、存储等方方面面的积累并不会在Docker或者Kubernetes的文档中交代清楚。可偏偏就是它们才是真正掌握容器技术体系的精髓所在是每一位技术从业者需要悉心修炼的“内功”。
而这,也正是我开设这个专栏的初衷。
我希望借由这个专栏给你讲清楚容器背后的这些技术本质与设计思想并结合着对核心特性的剖析与实践加深你对容器技术的理解。为此我把专栏划分成了4大模块
“白话”容器技术基础: 我希望用饶有趣味的解说,给你梳理容器技术生态的发展脉络,用最通俗易懂的语言描述容器底层技术的实现方式,让你知其然,也知其所以然。
Kubernetes集群的搭建与实践 Kubernetes集群号称“非常复杂”但是如果明白了其中的架构和原理选择了正确的工具和方法它的搭建却也可以“一键安装”它的应用部署也可以浅显易懂。
容器编排与Kubernetes核心特性剖析 这是这个专栏最重要的内容。“编排”永远都是容器云项目的灵魂所在也是Kubernetes社区持久生命力的源泉。在这一模块我会从分布式系统设计的视角出发抽象和归纳出这些特性中体现出来的普遍方法然后带着这些指导思想去逐一阐述Kubernetes项目关于编排、调度和作业管理的各项核心特性。“不识庐山真面目只缘身在此山中”希望这样一个与众不同的角度能够给你以全新的启发。
Kubernetes开源社区与生态“开源生态”永远都是容器技术和Kubernetes项目成功的关键。在这个模块我会和你一起探讨容器社区在开源软件工程指导下的演进之路带你思考如何同团队一起平衡内外部需求让自己逐渐成为社区中不可或缺的一员。
我希望通过这些对容器与Kubernetes项目的逐层剖析能够让你面对容器化浪潮时不再踌躇无措有一种拨云见日的酣畅淋漓。
最后,我想再和你分享一个故事。
2015年我在InfoQ举办的第一届容器技术大会上结识了当时CoreOS的布道师Kelsey Hightower他热情地和大家一起安装和体验微信谈笑风生间还时不时地安利一番自家产品。
但两年后也就是2017年Kelsey已经是全世界容器圈儿的意见领袖是Google公司Kubernetes项目的首席布道师而他的座右铭也变为了“只布道不推销”。此时就算你漂洋过海想要亲自拜会Kelsey ,恐怕也得先预约下时间了。
诚然Kelsey 的“一夜成名”,与他的勤奋和天赋密不可分,但他对这次“容器”变革走向的准确把握却也是功不可没。这也正应了一句名言:一个人的命运啊,当然要靠自我奋斗,但是也要考虑到历史的行程。
眼下,你我可能已经错过了互联网技术大爆炸的时代,也没有在数字货币早期的狂热里分到一杯羹。可就在此时此刻,在沉寂了多年的云计算与基础设施领域,一次以“容器”为名的历史变革,正呼之欲出。这一次,我们又有什么理由作壁上观呢?
如果你也想登上“容器”这趟高速前进的列车我相信这个专栏可以帮助你打通学习容器技术的“任督二脉”。在专栏开始我首先为你准备了4篇预习文章详细地梳理了容器技术自兴起到现在的发展历程同时也回答了“Kubernetes为什么会赢”这个重要的问题算是我额外为你准备的一份开学礼物吧。
机会总是留给有准备的人,现在就让我们一起开启这次充满挑战的容器之旅!

View File

@@ -0,0 +1,118 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 预习篇 · 小鲸鱼大事记(一):初出茅庐
你好,我是张磊。我今天分享的主题是:小鲸鱼大事记之初出茅庐。
如果我问你现今最热门的服务器端技术是什么想必你不假思索就能回答上来当然是容器可是如果现在不是2018年而是2013年你的回答还能这么斩钉截铁么
现在就让我们把时间拨回到五年前去看看吧。
2013年的后端技术领域已经太久没有出现过令人兴奋的东西了。曾经被人们寄予厚望的云计算技术也已经从当初虚无缥缈的概念蜕变成了实实在在的虚拟机和账单。而相比于如日中天的AWS和盛极一时的OpenStack以Cloud Foundry为代表的开源PaaS项目却成为了当时云计算技术中的一股清流。
这时Cloud Foundry项目已经基本度过了最艰难的概念普及和用户教育阶段吸引了包括百度、京东、华为、IBM等一大批国内外技术厂商开启了以开源PaaS为核心构建平台层服务能力的变革。如果你有机会问问当时的云计算从业者们他们十有八九都会告诉你PaaS的时代就要来了
这个说法其实一点儿没错如果不是后来一个叫Docker的开源项目突然冒出来的话。
事实上当时还名叫dotCloud的Docker公司也是这股PaaS热潮中的一份子。只不过相比于Heroku、Pivotal、Red Hat等PaaS弄潮儿们dotCloud公司实在是太微不足道了而它的主打产品由于跟主流的Cloud Foundry社区脱节长期以来也无人问津。眼看就要被如火如荼的PaaS风潮抛弃dotCloud公司却做出了这样一个决定开源自己的容器项目Docker。
显然,这个决定在当时根本没人在乎。
“容器”这个概念从来就不是什么新鲜的东西也不是Docker公司发明的。即使在当时最热门的PaaS项目Cloud Foundry中容器也只是其最底层、最没人关注的那一部分。说到这里我正好以当时的事实标准Cloud Foundry为例来解说一下PaaS技术。
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的容器并没有太大不同所以在它发布后不久Cloud Foundry的首席产品经理James Bayer就在社区里做了一次详细对比告诉用户Docker实际上只是一个同样使用Cgroups和Namespace实现的“沙盒”而已没有什么特别的黑科技也不需要特别关注。
然而短短几个月Docker项目就迅速崛起了。它的崛起速度如此之快以至于Cloud Foundry以及所有的PaaS社区还没来得及成为它的竞争对手就直接被宣告出局了。那时候一位多年的PaaS从业者曾经如此感慨道这简直就是一场“降维打击”啊。
难道这一次连闯荡多年的“老江湖”James Bayer也看走眼了么
并没有。
事实上Docker项目确实与Cloud Foundry的容器在大部分功能和实现原理上都是一样的可偏偏就是这剩下的一小部分不一样的功能成了Docker项目接下来“呼风唤雨”的不二法宝。
这个功能就是Docker镜像。
恐怕连Docker项目的作者Solomon Hykes自己当时都没想到这个小小的创新在短短几年内就如此迅速地改变了整个云计算领域的发展历程。
我前面已经介绍过PaaS之所以能够帮助用户大规模部署应用到集群里是因为它提供了一套应用打包的功能。可偏偏就是这个打包功能却成了PaaS日后不断遭到用户诟病的一个“软肋”。
出现这个问题的根本原因是一旦用上了PaaS用户就必须为每种语言、每种框架甚至每个版本的应用维护一个打好的包。这个打包过程没有任何章法可循更麻烦的是明明在本地运行得好好的应用却需要做很多修改和配置工作才能在PaaS里运行起来。而这些修改和配置并没有什么经验可以借鉴基本上得靠不断试错直到你摸清楚了本地应用和远端PaaS匹配的“脾气”才能够搞定。
最后结局就是“cf push”确实是能一键部署了但是为了实现这个一键部署用户为每个应用打包的工作可谓一波三折费尽心机。
而Docker镜像解决的恰恰就是打包这个根本性的问题。 所谓Docker镜像其实就是一个压缩包。但是这个压缩包里的内容比PaaS的应用可执行文件+启停脚本的组合就要丰富多了。实际上大多数Docker镜像是直接由一个完整操作系统的所有文件和目录构成的所以这个压缩包里的内容跟你本地开发和测试环境用的操作系统是完全一样的。
这就有意思了假设你的应用在本地运行时能看见的环境是CentOS 7.2操作系统的所有文件和目录那么只要用CentOS 7.2的ISO做一个压缩包再把你的应用可执行文件也压缩进去那么无论在哪里解压这个压缩包都可以得到与你本地测试时一样的环境。当然你的应用也在里面
这就是Docker镜像最厉害的地方只要有这个压缩包在手你就可以使用某种技术创建一个“沙盒”在“沙盒”中解压这个压缩包然后就可以运行你的程序了。
更重要的是,这个压缩包包含了完整的操作系统文件和目录,也就是包含了这个应用运行所需要的所有依赖,所以你可以先用这个压缩包在本地进行开发和测试,完成之后,再把这个压缩包上传到云端运行。
在这个过程中,你完全不需要进行任何配置或者修改,因为这个压缩包赋予了你一种极其宝贵的能力:本地环境和云端环境的高度一致!
正是Docker镜像的精髓。
那么有了Docker镜像这个利器PaaS里最核心的打包系统一下子就没了用武之地最让用户抓狂的打包过程也随之消失了。相比之下在当今的互联网里Docker镜像需要的操作系统文件和目录可谓唾手可得。
所以,你只需要提供一个下载好的操作系统文件与目录,然后使用它制作一个压缩包即可,这个命令就是:
$ docker build "我的镜像"
一旦镜像制作完成用户就可以让Docker创建一个“沙盒”来解压这个镜像然后在“沙盒”中运行自己的应用这个命令就是
$ docker run "我的镜像"
当然docker run创建的“沙盒”也是使用Cgroups和Namespace机制创建出来的隔离环境。我会在后面的文章中详细介绍这个机制的实现原理。
所以Docker项目给PaaS世界带来的“降维打击”其实是提供了一种非常便利的打包机制。这种机制直接打包了应用运行所需要的整个操作系统从而保证了本地环境和云端环境的高度一致避免了用户通过“试错”来匹配两种不同运行环境之间差异的痛苦过程。
而对于开发者们来说在终于体验到了生产力解放所带来的痛快之后他们自然选择了用脚投票直接宣告了PaaS时代的结束。
不过Docker项目固然解决了应用打包的难题但正如前面所介绍的那样它并不能代替PaaS完成大规模部署应用的职责。
遗憾的是考虑到Docker公司是一个与自己有潜在竞争关系的商业实体再加上对Docker项目普及程度的错误判断Cloud Foundry项目并没有第一时间使用Docker作为自己的核心依赖去替换自己那套饱受诟病的打包流程。
反倒是一些机敏的创业公司纷纷在第一时间推出了Docker容器集群管理的开源项目比如Deis和Flynn它们一般称自己为CaaS即Container-as-a-Service用来跟“过时”的PaaS们划清界限。
而在2014年底的DockerCon上Docker公司雄心勃勃地对外发布了自家研发的“Docker原生”容器集群管理项目Swarm不仅将这波“CaaS”热推向了一个前所未有的高潮更是寄托了整个Docker公司重新定义PaaS的宏伟愿望。
在2014年的这段巅峰岁月里Docker公司离自己的理想真的只有一步之遥。
总结
2013~2014年以Cloud Foundry为代表的PaaS项目逐渐完成了教育用户和开拓市场的艰巨任务也正是在这个将概念逐渐落地的过程中应用“打包”困难这个问题成了整个后端技术圈子的一块心病。
Docker项目的出现则为这个根本性的问题提供了一个近乎完美的解决方案。这正是Docker项目刚刚开源不久就能够带领一家原本默默无闻的PaaS创业公司脱颖而出然后迅速占领了所有云计算领域头条的技术原因。
而在成为了基础设施领域近十年难得一见的技术明星之后dotCloud公司则在2013年底大胆改名为Docker公司。不过这个在当时就颇具争议的改名举动也成为了日后容器技术圈风云变幻的一个关键伏笔。
思考题
你是否曾经研发过类似PaaS的项目你碰到过应用打包的问题吗又是如何解决的呢
感谢收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,79 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 预习篇 · 小鲸鱼大事记(二):崭露头角
你好,我是张磊。我今天分享的主题是:小鲸鱼大事记之崭露头角。
在上一篇文章中我说到伴随着PaaS概念的逐步普及以Cloud Foundry为代表的经典PaaS项目开始进入基础设施领域的视野平台化和PaaS化成了这个生态中的一个最为重要的进化趋势。
就在对开源PaaS项目落地的不断尝试中这个领域的从业者们发现了PaaS中最为棘手也最亟待解决的一个问题究竟如何给应用打包
遗憾的是无论是Cloud Foundry、OpenShift还是Clodify面对这个问题都没能给出一个完美的答案反而在竞争中走向了碎片化的歧途。
而就在这时一个并不引人瞩目的PaaS创业公司dotCloud却选择了开源自家的一个容器项目Docker。更出人意料的是就是这样一个普通到不能再普通的技术却开启了一个名为“Docker”的全新时代。
你可能会有疑问Docker项目的崛起是不是偶然呢
事实上,这个以“鲸鱼”为注册商标的技术创业公司,最重要的战略之一就是:坚持把“开发者”群体放在至高无上的位置。
相比于其他正在企业级市场里厮杀得头破血流的经典PaaS项目们Docker项目的推广策略从一开始就呈现出一副“憨态可掬”的亲人姿态把每一位后端技术人员而不是他们的老板作为主要的传播对象。
简洁的UI有趣的demo“1分钟部署一个WordPress网站”“3分钟部署一个Nginx集群”这种同开发者之间与生俱来的亲近关系使Docker项目迅速成为了全世界Meetup上最受欢迎的一颗新星。
在过去的很长一段时间里相较于前端和互联网技术社区服务器端技术社区一直是一个相对沉闷而小众的圈子。在这里从事Linux内核开发的极客们自带“不合群”的“光环”后端开发者们啃着多年不变的TCP/IP发着牢骚运维更是天生注定的幕后英雄。
而Docker项目却给后端开发者提供了走向聚光灯的机会。就比如Cgroups和Namespace这种已经存在多年却很少被人们关心的特性在2014年和2015年竟然频繁入选各大技术会议的分享议题就因为听众们想要知道Docker这个东西到底是怎么一回事儿。
而Docker项目之所以能取得如此高的关注一方面正如前面我所说的那样它解决了应用打包和发布这一困扰运维人员多年的技术难题而另一方面就是因为它第一次把一个纯后端的技术概念通过非常友好的设计和封装交到了最广大的开发者群体手里。
在这种独特的氛围烘托下你不需要精通TCP/IP也无需深谙Linux内核原理哪怕只是一个前端或者网站的PHP工程师都会对如何把自己的代码打包成一个随处可以运行的Docker镜像充满好奇和兴趣。
这种受众群体的变革正是Docker这样一个后端开源项目取得巨大成功的关键。这也是经典PaaS项目想做却没有做好的一件事情PaaS的最终用户和受益者一定是为这个PaaS编写应用的开发者们而在Docker项目开源之前PaaS与开发者之间的关系却从未如此紧密过。
解决了应用打包这个根本性的问题同开发者与生俱来的的亲密关系再加上PaaS概念已经深入人心的完美契机成为Docker这个技术上看似平淡无奇的项目一举走红的重要原因。
一时之间“容器化”取代“PaaS化”成为了基础设施领域最炙手可热的关键词一个以“容器”为中心的、全新的云计算市场正呼之欲出。而作为这个生态的一手缔造者此时的dotCloud公司突然宣布将公司名称改为“Docker”。
这个举动在当时颇受质疑。在大家印象中Docker只是一个开源项目的名字。可是现在这个单词却成了Docker公司的注册商标任何人在商业活动中使用这个单词以及鲸鱼的Logo都会立刻受到法律警告。
那么Docker公司这个举动到底卖的什么药这个问题我不妨后面再做解读因为相较于这件“小事儿”Docker公司在2014年发布Swarm项目才是真正的“大事儿”。
那么Docker公司为什么一定要发布Swarm项目呢
通过我对Docker项目崛起背后原因的分析你应该能发现这样一个有意思的事实虽然通过“容器”这个概念完成了对经典PaaS项目的“降维打击”但是Docker项目和Docker公司兜兜转转了一年多却还是回到了PaaS项目原本深耕了多年的那个战场如何让开发者把应用部署在我的项目上。
没错Docker项目从发布之初就全面发力从技术、社区、商业、市场全方位争取到的开发者群体实际上是为此后吸引整个生态到自家“PaaS”上的一个铺垫。只不过这时“PaaS”的定义已经全然不是Cloud Foundry描述的那个样子而是变成了一套以Docker容器为技术核心以Docker镜像为打包标准的、全新的“容器化”思路。
正是Docker项目从一开始悉心运作“容器化”理念和经营整个Docker生态的主要目的。
而Swarm项目正是接下来承接Docker公司所有这些努力的关键所在。
总结
今天我着重介绍了Docker项目在短时间内迅速崛起的三个重要原因
Docker镜像通过技术手段解决了PaaS的根本性问题
Docker容器同开发者之间有着与生俱来的密切关系
PaaS概念已经深入人心的完美契机。
崭露头角的Docker公司也终于能够以一个更加强硬的姿态来面对这个曾经无比强势但现在却完全不知所措的云计算市场。而2014年底的DockerCon欧洲峰会则正式拉开了Docker公司扩张的序幕。
思考题
你是否认同dotCloud公司改名并开启扩张道路的战略选择
Docker公司凭借“开源”和“开发者社群”这两个关键词完成崛起的过程对你和你所在的团队有什么启发
感谢收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,106 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 预习篇 · 小鲸鱼大事记(三):群雄并起
你好,我是张磊。我今天分享的主题是:小鲸鱼大事记之群雄并起。
在上一篇文章中我剖析了Docker项目迅速走红背后的技术与非技术原因也介绍了Docker公司开启平台化战略的野心。可是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 APIMesos社区却拥有一个独特的竞争力超大规模集群的管理经验。
早在几年前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项目的横空出世一样再一次改变了整个容器市场的格局。
总结
我分享了Docker公司平台化战略的来龙去脉阐述了Docker Swarm项目发布的意义和它背后的设计思想介绍了Fig后来的Compose项目如何成为了继Docker之后最受瞩目的新星。
同时我也和你一起回顾了2014~2015年间如火如荼的容器化浪潮里群雄并起的繁荣姿态。在这次生态大爆发中Docker公司和Mesosphere公司依托自身优势率先占据了有利位置。
但是,更强大的挑战者们,即将在不久后纷至沓来。
思考题
你所在团队有没有在2014~2015年Docker热潮中推出过相关的容器产品或者项目现在结局如何呢
欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,149 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
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容器lmctfyLet 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等开源基础设施领域玩家们共同牵头发起了一个名为CNCFCloud Native Computing Foundation的基金会。这个基金会的目的其实很容易理解它希望以Kubernetes项目为基础建立一个由开源基础设施领域厂商主导的、按照独立基金会方式运营的平台级社区来对抗以Docker公司为核心的容器商业生态。
而为了打造出这样一条围绕Kubernetes项目的“护城河”CNCF社区就需要至少确保两件事情
Kubernetes项目必须能够在容器编排领域取得足够大的竞争优势
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和扩展接口的二次创新工作比如
目前热度极高的微服务治理项目Istio
被广泛采用的有状态应用部署框架Operator
还有像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宣布辞职曾经纷纷扰扰的容器技术圈子到此尘埃落定。
总结
容器技术圈子在短短几年里发生了很多变数但很多事情其实也都在情理之中。就像Docker这样一家创业公司在通过开源社区的运作取得了巨大的成功之后就不得不面对来自整个云计算产业的竞争和围剿。而这个产业的垄断特性对于Docker这样的技术型创业公司其实天生就不友好。
在这种局势下接受微软的天价收购在大多数人看来都是一个非常明智和实际的选择。可是Solomon Hykes却多少带有一些理想主义的影子既然不甘于“寄人篱下”那他就必须带领Docker公司去对抗来自整个云计算产业的压力。
只不过Docker公司最后选择的对抗方式是将开源项目与商业产品紧密绑定打造了一个极端封闭的技术生态。而这其实违背了Docker项目与开发者保持亲密关系的初衷。相比之下Kubernetes社区正是以一种更加温和的方式承接了Docker项目的未尽事业以开发者为核心构建一个相对民主和开放的容器生态。
这也是为何Kubernetes项目的成功其实是必然的。
现在我们很难想象如果Docker公司最初选择了跟Kubernetes社区合作如今的容器生态又将会是怎样的一番景象。不过我们可以肯定的是Docker公司在过去五年里的风云变幻以及Solomon Hykes本人的传奇经历都已经在云计算的长河中留下了浓墨重彩的一笔。
思考题
你如何评价Solomon Hykes在Docker公司发展历程中的所作所为你又是否看好Docker公司在今后的发展呢
欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,149 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 白话容器基础(一):从进程说开去
你好,我是张磊。今天我和你分享的主题是:白话容器基础之从进程说开去。
在前面的4篇预习文章中我梳理了“容器”这项技术的来龙去脉通过这些内容我希望你能理解如下几个事实
容器技术的兴起源于PaaS技术的普及
Docker公司发布的Docker项目具有里程碑式的意义
Docker项目通过“容器镜像”解决了应用打包这个根本性难题。
紧接着,我详细介绍了容器技术圈在过去五年里的“风云变幻”,而通过这部分内容,我希望你能理解这样一个道理:
容器本身没有价值,有价值的是“容器编排”。
也正因为如此容器技术生态才爆发了一场关于“容器编排”的“战争”。而这次战争最终以Kubernetes项目和CNCF社区的胜利而告终。所以这个专栏后面的内容我会以Docker和Kubernetes项目为核心为你详细介绍容器技术的各项实践与其中的原理。
不过在此之前,你还需要搞清楚一个更为基础的问题:
容器,到底是怎么一回事儿?
在第一篇预习文章《小鲸鱼大事记初出茅庐》中我已经提到过容器其实是一种沙盒技术。顾名思义沙盒就是能够像一个集装箱一样把你的应用“装”起来的技术。这样应用与应用之间就因为有了边界而不至于相互干扰而被装进集装箱的应用也可以被方便地搬来搬去这不就是PaaS最理想的状态嘛。
不过,这两个能力说起来简单,但要用技术手段去实现它们,可能大多数人就无从下手了。
所以,我就先来跟你说说这个“边界”的实现手段。
假如,现在你要写一个计算加法的小程序,这个程序需要的输入来自于一个文件,计算完成后的结果则输出到另一个文件中。
由于计算机只认识0和1所以无论用哪种语言编写这段代码最后都需要通过某种方式翻译成二进制文件才能在计算机操作系统中运行起来。
而为了能够让这些代码正常运行我们往往还要给它提供数据比如我们这个加法程序所需要的输入文件。这些数据加上代码本身的二进制文件放在磁盘上就是我们平常所说的一个“程序”也叫代码的可执行镜像executable image
然后,我们就可以在计算机上运行这个“程序”了。
首先操作系统从“程序”中发现输入数据保存在一个文件中所以这些数据就会被加载到内存中待命。同时操作系统又读取到了计算加法的指令这时它就需要指示CPU完成加法操作。而CPU与内存协作进行加法计算又会使用寄存器存放数值、内存堆栈保存执行的命令和变量。同时计算机里还有被打开的文件以及各种各样的I/O设备在不断地调用中修改自己的状态。
就这样,一旦“程序”被执行起来,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。像这样一个程序运行起来后的计算机执行环境的总和,就是我们今天的主角:进程。
所以,对于进程来说,它的静态表现就是程序,平常都安安静静地待在磁盘上;而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现。
而容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。
对于Docker等大多数Linux容器来说Cgroups技术是用来制造约束的主要手段而Namespace技术则是用来修改进程视图的主要方法。
你可能会觉得Cgroups和Namespace这两个概念很抽象别担心接下来我们一起动手实践一下你就很容易理解这两项技术了。
假设你已经有了一个Linux操作系统上的Docker项目在运行比如我的环境是Ubuntu 16.04和Docker CE 18.05。
接下来,让我们首先创建一个容器来试试。
$ docker run -it busybox /bin/sh
/ #
这个命令是Docker项目最重要的一个操作即大名鼎鼎的docker run。
而-it参数告诉了Docker项目在启动容器后需要给我们分配一个文本输入/输出环境也就是TTY跟容器的标准输入相关联这样我们就可以和这个Docker容器进行交互了。而/bin/sh就是我们要在Docker容器里运行的程序。
所以,上面这条指令翻译成人类的语言就是:请帮我启动一个容器,在容器里执行/bin/sh并且给我分配一个命令行终端跟这个容器交互。
这样我的Ubuntu 16.04机器就变成了一个宿主机,而一个运行着/bin/sh的容器就跑在了这个宿主机里面。
上面的例子和原理如果你已经玩过Docker一定不会感到陌生。此时如果我们在容器里执行一下ps指令就会发现一些更有趣的事情
/ # 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。
当然我们还可以多次执行上面的clone()调用这样就会创建多个PID Namespace而每个Namespace里的应用进程都会认为自己是当前容器里的第1号进程它们既看不到宿主机里真正的进程空间也看不到其他PID Namespace里的具体情况。
而除了我们刚刚用到的PID NamespaceLinux操作系统还提供了Mount、UTS、IPC、Network和User这些Namespace用来对各种不同的进程上下文进行“障眼法”操作。
比如Mount Namespace用于让被隔离进程只看到当前Namespace里的挂载点信息Network Namespace用于让被隔离进程看到当前Namespace里的网络设备和配置。
就是Linux容器最基本的实现原理了。
所以Docker容器这个听起来玄而又玄的概念实际上是在创建容器进程时指定了这个进程所需要启用的一组Namespace参数。这样容器就只能“看”到当前Namespace所限定的资源、文件、设备、状态或者配置。而对于宿主机以及其他不相关的程序它就完全看不到了。
所以说,容器,其实是一种特殊的进程而已。
总结
谈到为“进程划分一个独立空间”的思想,相信你一定会联想到虚拟机。而且,你应该还看过一张虚拟机和容器的对比图。
这幅图的左边画出了虚拟机的工作原理。其中名为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里的网络设备就仿佛运行在一个个“容器”里面与世隔绝。
不过,相信你此刻已经会心一笑:这些不过都是“障眼法”罢了。
思考题
鉴于我对容器本质的讲解,你觉得上面这张容器和虚拟机对比图右侧关于容器的部分,怎么画才更精确?
你是否知道最新的Docker项目默认会为容器启用哪些Namespace吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,206 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 白话容器基础(二):隔离与限制
你好,我是张磊。我今天和你分享的主题是:白话容器基础之隔离与限制。
在上一篇文章中我详细介绍了Linux容器中用来实现“隔离”的技术手段Namespace。而通过这些讲解你应该能够明白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的隔离机制相比于虚拟化技术也有很多不足之处其中最主要的问题就是隔离得不彻底。
首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。
尽管你可以在容器里通过Mount Namespace单独挂载其他不同版本的操作系统文件比如CentOS或者Ubuntu但这并不能改变共享宿主机内核的事实。这意味着如果你要在Windows宿主机上运行Linux容器或者在低版本的Linux宿主机上运行高版本的Linux容器都是行不通的。
而相比之下拥有硬件虚拟化技术和独立Guest OS的虚拟机就要方便得多了。最极端的例子是Microsoft的云计算平台Azure实际上就是运行在Windows服务器集群上的但这并不妨碍你在它上面创建各种Linux虚拟机出来。
其次在Linux内核中有很多资源和对象是不能被Namespace化的最典型的例子就是时间。
这就意味着如果你的容器中的程序使用settimeofday(2)系统调用修改了时间,整个宿主机的时间都会被随之修改,这显然不符合用户的预期。相比于在虚拟机里面可以随便折腾的自由度,在容器里部署应用的时候,“什么能做,什么不能做”,就是用户必须考虑的一个问题。
此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。
更为棘手的是尽管在实践中我们确实可以使用Seccomp等技术对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固但这种方法因为多了一层对系统调用的过滤必然会拖累容器的性能。何况默认情况下谁也不知道到底该开启哪些系统调用禁止哪些系统调用。
所以在生产环境中没有人敢把运行在物理机上的Linux容器直接暴露到公网上。当然我后续会讲到的基于虚拟化或者独立内核技术的容器实现则可以比较好地在隔离与性能之间做出平衡。
在介绍完容器的“隔离”技术之后,我们再来研究一下容器的“限制”问题。
也许你会好奇我们不是已经通过Linux Namespace创建了一个“容器”吗为什么还需要对容器做“限制”呢
我还是以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还没有任何限制-1CPU period则是默认的100 ms100000 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 ms20000 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带宽。
总结
在这篇文章中我首先介绍了容器使用Linux Namespace作为隔离手段的优势和劣势对比了Linux容器跟虚拟机技术的不同进一步明确了“容器只是一种特殊的进程”这个结论。
除了创建Namespace之外在后续关于容器网络的分享中我还会介绍一些其他Namespace的操作比如看不见摸不着的Linux Namespace在计算机中到底如何表示、一个进程如何“加入”到其他进程的Namespace当中等等。
紧接着我详细介绍了容器在做好了隔离工作之后又如何通过Linux Cgroups实现资源的限制并通过一系列简单的实验模拟了Docker项目创建容器限制的过程。
通过以上讲述你现在应该能够理解一个正在运行的Docker容器其实就是一个启用了多个Linux Namespace的应用进程而这个进程能够使用的资源量则受Cgroups配置的限制。
这也是容器技术中一个非常重要的概念,即:容器是一个“单进程”模型。
由于一个容器的本质就是一个进程用户的应用进程实际上就是容器里PID=1的进程也是其他后续创建的所有进程的父进程。这就意味着在一个容器中你没办法同时运行两个不同的应用除非你能事先找到一个公共的PID=1的程序来充当两个不同应用的父进程这也是为什么很多人都会用systemd或者supervisord这样的软件来代替应用本身作为容器的启动进程。
但是,在后面分享容器设计模式时,我还会推荐其他更好的解决办法。这是因为容器本身的设计,就是希望容器和应用能够同生命周期,这个概念对后续的容器编排非常重要。否则,一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。
另外跟Namespace的情况类似Cgroups对资源的限制能力也有很多不完善的地方被提及最多的自然是/proc文件系统的问题。
众所周知Linux下的/proc目录存储的是记录当前内核运行状态的一系列特殊文件用户可以通过访问这些文件查看系统以及当前正在运行的进程的信息比如CPU使用情况、内存占用率等这些文件也是top指令查看系统信息的主要数据来源。
但是你如果在容器里执行top指令就会发现它显示的信息居然是宿主机的CPU和内存数据而不是当前容器的数据。
造成这个问题的原因就是,/proc文件系统并不知道用户通过Cgroups给这个容器做了什么样的资源限制/proc文件系统不了解Cgroups限制的存在。
在生产环境中这个问题必须进行修正否则应用程序在容器里读取到的CPU核数、可用内存等信息都是宿主机上的数据这会给应用的运行带来非常大的困惑和风险。这也是在企业中容器化应用碰到的一个常见问题也是容器相较于虚拟机另一个不尽如人意的地方。
思考题
你是否知道如何修复容器中的top指令以及/proc文件系统中的信息呢提示lxcfs
在从虚拟机向容器环境迁移应用的过程中,你还遇到哪些容器与虚拟机的不一致问题?
感谢你的收听,欢迎给我留言一起讨论,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,423 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 白话容器基础(三):深入理解容器镜像
你好我是张磊。我在今天这篇文章的最后放置了一张Kubernetes的技能图谱希望对你有帮助。
在前两次的分享中我讲解了Linux容器最基础的两种技术Namespace和Cgroups。希望此时你已经彻底理解了“容器的本质是一种特殊的进程”这个最重要的概念。
而正如我前面所说的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 NamespaceCLONE_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)
可以看到,容器里的/tmp目录是以tmpfs方式单独挂载的。
更重要的是因为我们创建的新进程启用了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项目来说它最核心的原理实际上就是为待创建的用户进程
启用Linux Namespace配置
设置指定的Cgroups参数
切换进程的根目录Change Root
这样一个完整的容器就诞生了。不过Docker项目在最后一步的切换上会优先使用pivot_root系统调用如果系统不支持才会使用chroot。这两个系统调用虽然功能类似但是也有细微的区别这一部分小知识就交给你课后去探索了。
另外需要明确的是rootfs只是一个操作系统所包含的文件、配置和目录并不包括操作系统内核。在Linux操作系统中这两部分是分开存放的操作系统只有在开机启动时才会加载指定版本的内核镜像。
所以说rootfs只包括了操作系统的“躯壳”并没有包括操作系统的“灵魂”。
那么,对于容器来说,这个操作系统的“灵魂”又在哪里呢?
实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。
这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。
这也是容器相比于虚拟机的主要缺陷之一毕竟后者不仅有模拟出来的硬件机器充当沙盒而且每个沙盒里还运行着一个完整的Guest OS给应用随便折腾。
不过正是由于rootfs的存在容器才有了一个被反复宣传至今的重要特性一致性。
什么是容器的“一致性”呢?
我在专栏的第一篇文章《小鲸鱼大事记初出茅庐》中曾经提到过由于云端与本地服务器环境不同应用的打包过程一直是使用PaaS时最“痛苦”的一个步骤。
但有了容器之后更准确地说有了容器镜像即rootfs之后这个问题被非常优雅地解决了。
由于rootfs里打包的不只是应用而是整个操作系统的文件和目录也就意味着应用以及它运行所需要的所有依赖都被封装在了一起。
事实上对于大多数开发者而言他们对应用依赖的理解一直局限在编程语言层面。比如Golang的Godeps.json。但实际上一个一直以来很容易被忽视的事实是对一个应用来说操作系统本身才是它运行所需要的最完整的“依赖库”。
有了容器镜像“打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。
这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。
不过这时你可能已经发现了另一个非常棘手的问题难道我每开发一个应用或者升级一下现有的应用都要重复制作一次rootfs吗
比如我现在用Ubuntu操作系统的ISO做了一个rootfs然后又在里面安装了Java环境用来部署我的Java应用。那么我的另一个同事在发布他的Java应用时显然希望能够直接使用我安装过Java环境的rootfs而不是重复这个流程。
一种比较直观的解决办法是我在制作rootfs的时候每做一步“有意义”的操作就保存一个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从这些名字中你应该能看出这样两个事实
它是对Linux原生UnionFS的重写和改进
它的作者怨气好像很大。我猜是Linus TorvaldsLinux之父一直不让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由如下图所示的三部分组成
第一部分,只读层。
它是这个容器的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操作系统的一部分。
第二部分,可读写层。
它是这个容器的rootfs最上面的一层6e3be5d2ecccae7cc它的挂载方式为rw即read write。在没有写入文件之前这个目录是空的。而一旦在容器里做了写操作你修改产生的内容就会以增量的方式出现在这个层中。
可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?
为了实现这样的删除操作AuFS会在可读写层创建一个whiteout文件把只读层里的文件“遮挡”起来。
比如你要删除只读层里一个名叫foo的文件那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo的文件。这样当这两个层被联合挂载之后foo文件就会被.wh.foo文件“遮挡”起来“消失”了。这个功能就是“ro+wh”的挂载方式即只读+whiteout的含义。我喜欢把whiteout形象地翻译为“白障”。
所以最上面这个可读写层的作用就是专门用来存放你修改rootfs后产生的增量无论是增、删、改都发生在这里。而当我们使用完了这个被修改过的容器之后还可以使用docker commit和push指令保存这个被修改过的可读写层并上传到Docker Hub上供其他人使用而与此同时原先的只读层里的内容则不会有任何变化。这就是增量rootfs的好处。
第三部分Init层。
它是一个以“-init”结尾的层夹在只读层和读写层之间。Init层是Docker项目单独生成的一个内部层专门用来存放/etc/hosts、/etc/resolv.conf等信息。
需要这样一层的原因是这些文件本来属于只读的Ubuntu镜像的一部分但是用户往往需要在启动容器时写入一些指定的值比如hostname所以就需要在可读写层对它们进行修改。
可是这些修改往往只对当前的容器有效我们并不希望执行docker commit时把这些信息连同可读写层一起提交掉。
所以Docker做法是在修改了这些文件之后以一个单独的层挂载了出来。而用户执行docker commit只会提交可读写层所以是不包含这些内容的。
最终这7个层都被联合挂载到/var/lib/docker/aufs/mnt目录下表现为一个完整的Ubuntu操作系统供容器使用。
总结
在今天的分享中我着重介绍了Linux容器文件系统的实现方式。而这种机制正是我们经常提到的容器镜像也叫作rootfs。它只是一个操作系统的所有文件和目录并不包含内核最多也就几百兆。而相比之下传统虚拟机的镜像大多是一个磁盘的“快照”磁盘有多大镜像就至少有多大。
通过结合使用Mount Namespace和rootfs容器就能够为进程构建出一个完善的文件系统隔离环境。当然这个功能的实现还必须感谢chroot和pivot_root这两个系统调用切换进程根目录的能力。
而在rootfs的基础上Docker公司创新性地提出了使用多个增量rootfs联合挂载一个完整rootfs的方案这就是容器镜像中“层”的概念。
通过“分层镜像”的设计以Docker镜像为核心来自不同公司、不同团队的技术人员被紧密地联系在了一起。而且由于容器镜像的操作是增量式的这样每次镜像拉取、推送的内容比原本多个完整的操作系统的大小要小得多而共享层的存在可以使得所有这些容器镜像需要的总空间也比每个镜像的总和要小。这样就使得基于容器镜像的团队协作要比基于动则几个GB的虚拟机磁盘镜像的协作要敏捷得多。
更重要的是,一旦这个镜像被发布,那么你在全世界的任何一个地方下载这个镜像,得到的内容都完全一致,可以完全复现这个镜像制作者当初的完整环境。这,就是容器技术“强一致性”的重要体现。
而这种价值正是支撑Docker公司在2014~2016年间迅猛发展的核心动力。容器镜像的发明不仅打通了“开发-测试-部署”流程的每一个环节,更重要的是:
容器镜像将会成为未来软件的主流发布方式。
思考题
既然容器的rootfs比如Ubuntu镜像是以只读方式挂载的那么又如何在容器里修改Ubuntu镜像的内容呢提示Copy-on-Write
除了AuFS你知道Docker项目还支持哪些UnionFS实现吗你能说出不同宿主机环境下推荐使用哪种实现吗

View File

@@ -0,0 +1,451 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 白话容器基础重新认识Docker容器
你好我是张磊。今天我和你分享的主题是白话容器基础之重新认识Docker容器。
在前面的三次分享中我分别从Linux Namespace的隔离能力、Linux Cgroups的限制能力以及基于rootfs的文件系统三个角度为你剖析了一个Linux容器的核心实现原理。
备注之所以要强调Linux容器是因为比如Docker on Mac以及Windows DockerHyper-V实现实际上是基于虚拟化技术实现的跟我们这个专栏着重介绍的Linux容器完全不同。
而在今天的分享中我会通过一个实际案例对“白话容器基础”系列的所有内容做一次深入的总结和扩展。希望通过这次的讲解能够让你更透彻地理解Docker容器的本质。
在开始实践之前你需要准备一台Linux机器并安装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)
在这段代码中我使用Flask框架启动了一个Web服务器而它唯一的功能是如果当前环境中有“NAME”这个环境变量就把它打印在“Hello”之后否则就打印“Hello world”最后再打印出当前环境的hostname。
这个应用的依赖则被定义在了同目录下的requirements.txt文件里内容如下所示
$ cat requirements.txt
Flask
而将这样一个应用容器化的第一步,是制作容器镜像。
不过相较于我之前介绍的制作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镜像。并且这些原语都是按顺序处理的。
比如FROM原语指定了“python:2.7-slim”这个官方维护的基础镜像从而免去了安装Python等语言环境的操作。否则这一段我们就得这么写了
FROM ubuntu:latest
RUN apt-get update -yRUN apt-get install -y python-pip python-dev build-essential
...
其中RUN原语就是在容器里执行shell命令的意思。
而WORKDIR意思是在这一句之后Dockerfile后面的操作都以这一句指定的/app目录作为当前目录。
所以到了最后的CMD意思是Dockerfile指定python app.py为这个容器的进程。这里app.py的实际路径是/app/app.py。所以CMD ["python", "app.py"]等价于”docker run <image> python app.py“。
另外在使用Dockerfile时你可能还会看到一个叫作ENTRYPOINT的原语。实际上它和CMD都是Docker容器进程启动所必需的参数完整执行格式是“ENTRYPOINT CMD”。
但是默认情况下Docker会为你提供一个隐含的ENTRYPOINT/bin/sh -c。所以在不指定ENTRYPOINT时比如在我们这个例子里实际上运行在容器里的完整进程是/bin/sh -c "python app.py"即CMD的内容就是ENTRYPOINT的参数。
备注基于以上原因我们后面会统一称Docker容器的启动进程为ENTRYPOINT而不是CMD。
需要注意的是Dockerfile里的原语并不都是指对容器内部的操作。就比如ADD它指的是把当前目录即Dockerfile所在的目录里的文件复制到指定容器内的目录当中。
读懂这个Dockerfile之后我再把上述内容保存到当前目录里一个名叫“Dockerfile”的文件中
$ ls
Dockerfile app.py requirements.txt
接下来我就可以让Docker制作这个镜像了在当前目录执行
$ docker build -t helloworld .
其中,-t的作用是给这个镜像加一个Tag起一个好听的名字。docker build会自动加载当前目录下的Dockerfile文件然后按照顺序执行文件中的原语。而这个过程实际上可以等同于Docker使用基础镜像启动了一个容器然后在容器中依次执行Dockerfile中的原语。
需要注意的是Dockerfile中的每个原语执行后都会生成一个对应的镜像层。即使原语本身并没有明显地修改文件的操作比如ENV原语它对应的层也会存在。只不过在外界看来这个层是空的。
docker build操作完成后我可以通过docker images命令查看结果
$ docker image ls
REPOSITORY TAG IMAGE ID
helloworld latest 653287cdf998
通过这个镜像ID你就可以使用在《白话容器基础深入理解容器镜像》中讲过的方法查看这些新增的层在AuFS路径下对应的文件和目录了。
接下来我使用这个镜像通过docker run命令启动容器
$ docker run -p 4000:80 helloworld
在这一句命令中镜像名helloworld后面我什么都不用写因为在Dockerfile中已经指定了CMD。否则我就得把进程的启动命令加在后面
$ docker run -p 4000:80 helloworld python app.py
容器启动之后我可以使用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”才可以看到容器内应用的返回。
至此我已经使用容器完成了一个应用的开发与测试如果现在想要把这个容器的镜像上传到DockerHub上分享给更多的人我要怎么做呢
为了能够上传镜像我首先需要注册一个Docker Hub账号然后使用docker login命令登录。
接下来我要用docker tag命令给容器镜像起一个完整的名字
$ docker tag helloworld geektime/helloworld:v1
注意你自己做实验时请将”geektime”替换成你自己的Docker Hub账户名称比如zhangsan/helloworld:v1
其中geektime是我在Docker Hub上的用户名它的“学名”叫镜像仓库Repository“/”后面的helloworld是这个镜像的名字而“v1”则是我给这个镜像分配的版本号。
然后我执行docker push
$ docker push geektime/helloworld:v1
这样我就可以把这个镜像上传到Docker Hub上了。
此外我还可以使用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中。所以这里ifconfig返回的网卡信息跟我前面那个小程序返回的结果一模一样你也可以尝试一下。
而如果我指定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 Hub上了
$ docker push geektime/helloworld:v2
你可能还会有这样的问题我在企业内部能不能也搭建一个跟Docker Hub类似的镜像上传系统呢
当然可以这个统一存放镜像的系统就叫作Docker Registry。感兴趣的话你可以查看Docker的官方文档以及VMware的Harbor项目。
最后我再来讲解一下Docker项目另一个重要的内容Volume数据卷
前面我已经介绍过容器技术使用了rootfs机制和Mount Namespace构建出了一个同宿主机完全隔离开的文件系统环境。这时候我们就需要考虑这样两个问题
容器里进程新建的文件,怎么才能让宿主机获取到?
宿主机上的文件和目录,怎么才能让容器里的进程访问到?
这正是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的黑科技吗
实际上,并不需要这么麻烦。
在《白话容器基础深入理解容器镜像》的分享中我已经介绍过当容器进程被创建之后尽管开启了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机制。它的主要作用就是允许你将一个目录或者文件而不是整个设备挂载到一个指定的目录上。并且这时你在该挂载点上进行的任何操作只是发生在被挂载的目录或者文件上而原挂载点的内容则会被隐藏起来且不受影响。
其实如果你了解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的核心原理了。
总结
在今天的这次分享中我用了一个非常经典的Python应用作为案例讲解了Docke容器使用的主要场景。熟悉了这些操作你也就基本上摸清了Docker容器的核心功能。
更重要的是我着重介绍了如何使用Linux Namespace、Cgroups以及rootfs的知识对容器进行了一次庖丁解牛似的解读。
借助这种思考问题的方法最后的Docker容器我们实际上就可以用下面这个“全景图”描述出来
这个容器进程“python app.py”运行在由Linux Namespace和Cgroups构成的隔离环境里而它运行所需要的各种文件比如pythonapp.py以及整个操作系统文件则由多个联合挂载在一起的rootfs层提供。
这些rootfs层的最下层是来自Docker镜像的只读层。
在只读层之上是Docker自己添加的Init层用来存放被临时修改过的/etc/hosts等文件。
而rootfs的最上层是一个可读写层它以Copy-on-Write的方式存放任何对只读层的修改容器声明的Volume的挂载点也出现在这一层。
通过这样的剖析,对于曾经“神秘莫测”的容器技术,你是不是感觉清晰了很多呢?
思考题
你在查看Docker容器的Namespace时是否注意到有一个叫cgroup的Namespace它是Linux 4.6之后新增加的一个Namespace你知道它的作用吗
如果你执行docker run -v /home:/test的时候容器镜像里的/test目录下本来就有内容的话你会发现在宿主机的/home目录下也会出现这些内容。这是怎么回事为什么它们没有被绑定挂载隐藏起来呢提示Docker的“copyData”功能
请尝试给这个Python应用加上CPU和Memory限制然后启动它。根据我们前面介绍的Cgroups的知识请你查看一下这个容器的Cgroups文件系统的设置是不是跟我前面的讲解一致。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,268 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 从容器到容器云谈谈Kubernetes的本质
你好我是张磊。今天我和你分享的主题是从容器到容器云谈谈Kubernetes的本质。
在前面的四篇文章中我以Docker项目为例一步步剖析了Linux容器的具体实现方式。通过这些讲解你应该能够明白一个“容器”实际上是一个由Linux Namespace、Linux Cgroups和rootfs三种技术构建出来的进程的隔离环境。
从这个结构中我们不难看出一个正在运行的Linux容器其实可以被“一分为二”地看待
一组联合挂载在/var/lib/docker/aufs/mnt上的rootfs这一部分我们称为“容器镜像”Container Image是容器的静态视图
一个由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项目当仁不让地位居整个基础设施技术栈的最底层。
-
图片来源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的顶层设计说起。
首先Kubernetes项目要解决的问题是什么
编排?调度?容器云?还是集群管理?
实际上这个问题到目前为止都没有固定的答案。因为在不同的发展阶段Kubernetes需要着重解决的问题是不同的。
但是对于大多数用户来说他们希望Kubernetes项目带来的体验是确定的现在我有了应用的容器镜像请帮我在一个给定的集群上把这个应用运行起来。
更进一步地说我还希望Kubernetes能给我提供路由网关、水平扩展、监控、备份、灾难恢复等一系列运维能力。
等一下这些功能听起来好像有些耳熟这不就是经典PaaS比如Cloud Foundry项目的能力吗
而且有了Docker之后我根本不需要什么Kubernetes、PaaS只要使用Docker公司的Compose+Swarm项目就完全可以很方便地DIY出这些功能了
所以说如果Kubernetes项目只是停留在拉取用户镜像、运行容器以及提供常见的运维功能的话那么别说跟“原生”的Docker Swarm项目竞争了哪怕跟经典的PaaS项目相比也难有什么优势可言。
而实际上在定义核心功能的过程中Kubernetes项目正是依托着Borg项目的理论优势才在短短几个月内迅速站稳了脚跟进而确定了一个如下图所示的全局架构
我们可以看到Kubernetes项目的架构跟它的原型项目Borg非常类似都由Master和Node两种节点组成而这两种角色分别对应着控制节点和计算节点。
其中控制节点即Master节点由三个紧密协作的独立组件组合而成它们分别是负责API服务的kube-apiserver、负责调度的kube-scheduler以及负责容器编排的kube-controller-manager。整个集群的持久化数据则由kube-apiserver处理后保存在Etcd中。
而计算节点上最核心的部分则是一个叫作kubelet的组件。
在Kubernetes项目中kubelet主要负责同容器运行时比如Docker项目打交道。而这个交互所依赖的是一个称作CRIContainer Runtime Interface的远程调用接口这个接口定义了容器运行时的各项核心操作比如启动一个容器需要的所有参数。
这也是为何Kubernetes项目并不关心你部署的是什么容器运行时、使用的什么技术实现只要你的这个容器运行时能够运行标准的容器镜像它就可以通过实现CRI接入到Kubernetes项目当中。
而具体的容器运行时比如Docker项目则一般通过OCI这个容器运行时规范同底层的Linux操作系统进行交互把CRI请求翻译成对Linux操作系统的调用操作Linux Namespace和Cgroups等
此外kubelet还通过gRPC协议同一个叫作Device Plugin的插件进行交互。这个插件是Kubernetes项目用来管理GPU等宿主机物理设备的主要组件也是基于Kubernetes项目进行机器学习训练、高性能作业支持等工作必须关注的功能。
而kubelet的另一个重要功能则是调用网络插件和存储插件为容器配置网络和持久化存储。这两个插件与kubelet进行交互的接口分别是CNIContainer Networking Interface和CSIContainer 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镜像的角色。
那么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项目核心功能的“全景图”。
按照这幅图的线索我们从容器这个最基础的概念出发首先遇到了容器间“紧密协作”关系的难题于是就扩展到了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最核心的设计理念也是接下来我会重点剖析的关键技术点。
最后我来回答一个更直接的问题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的副本数是2replicas=2
然后执行:
$ kubectl create -f nginx-deployment.yaml
这样两个完全相同的Nginx容器副本就被启动了。
不过这么看来做同样一件事情Kubernetes用户要做的工作也不少嘛。
别急在后续的讲解中我会陆续介绍Kubernetes项目这种“声明式API”的种种好处以及基于它实现的强大的编排能力。
拭目以待吧。
总结
首先,我和你一起回顾了容器的核心知识,说明了容器其实可以分为两个部分:容器运行时和容器镜像。
然后我重点介绍了Kubernetes项目的架构详细讲解了它如何使用“声明式API”来描述容器化业务和容器间关系的设计思想。
实际上过去很多的集群管理项目比如Yarn、Mesos以及Swarm所擅长的都是把一个容器按照某种规则放置在某个最佳节点上运行起来。这种功能我们称为“调度”。
而Kubernetes项目所擅长的是按照用户的意愿和整个系统的规则完全自动化地处理好容器之间的各种关系。这种功能就是我们经常听到的一个概念编排。
所以说Kubernetes项目的本质是为用户提供一个具有普遍意义的容器编排工具。
不过更重要的是Kubernetes项目为用户提供的不仅限于一个工具。它真正的价值乃在于提供了一套基于容器构建分布式系统的基础依赖。关于这一点相信你会在今后的学习中体会越来越深。
思考题
这今天的分享中我介绍了Kubernetes项目的架构。你是否了解了Docker SwarmSwarmKit项目和Kubernetes在架构上和使用方法上的异同呢
在Kubernetes之前很多项目都没办法管理“有状态”的容器不能从一台宿主机“迁移”到另一台宿主机上的容器。你是否能列举出阻止这种“迁移”的原因都有哪些呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,329 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 Kubernetes一键部署利器kubeadm
你好我是张磊。今天我和你分享的主题是Kubernetes一键部署利器之kubeadm。
通过前面几篇文章的内容我其实阐述了这样一个思想要真正发挥容器技术的实力你就不能仅仅局限于对Linux容器本身的钻研和使用。
这些知识更适合作为你的技术储备,以便在需要的时候可以帮你更快地定位问题,并解决问题。
而更深入地学习容器技术的关键在于,如何使用这些技术来“容器化”你的应用。
比如我们的应用既可能是Java Web和MySQL这样的组合也可能是Cassandra这样的分布式系统。而要使用容器把后者运行起来你单单通过Docker把一个Cassandra镜像跑起来是没用的。
要把Cassandra应用容器化的关键在于如何处理好这些Cassandra容器之间的编排关系。比如哪些Cassandra容器是主哪些是从主从容器如何区分它们之间又如何进行自动发现和通信Cassandra容器的持久化数据又如何保持等等。
这也是为什么我们要反复强调Kubernetes项目的主要原因这个项目体现出来的容器化“表达能力”具有独有的先进性和完备性。这就使得它不仅能运行Java Web与MySQL这样的常规组合还能够处理Cassandra容器集群等复杂编排问题。所以对这种编排能力的剖析、解读和最佳实践将是本专栏最重要的一部分内容。
不过,万事开头难。
作为一个典型的分布式项目Kubernetes的部署一直以来都是挡在初学者前面的一只“拦路虎”。尤其是在Kubernetes项目发布初期它的部署完全要依靠一堆由社区维护的脚本。
其实Kubernetes作为一个Golang项目已经免去了很多类似于Python项目要安装语言级别依赖的麻烦。但是除了将各个组件编译成二进制文件外用户还要负责为这些二进制文件编写对应的配置文件、配置自启动脚本以及为kube-apiserver配置授权文件等等诸多运维工作。
目前各大云厂商最常用的部署的方法是使用SaltStack、Ansible等运维工具自动化地执行这些步骤。
但即使这样这个部署过程依然非常繁琐。因为SaltStack这类专业运维工具本身的学习成本就可能比Kubernetes项目还要高。
难道Kubernetes项目就没有简单的部署方法了吗
这个问题在Kubernetes社区里一直没有得到足够重视。直到2017年在志愿者的推动下社区才终于发起了一个独立的部署工具名叫kubeadm。
这个项目的目的就是要让用户能够通过这样两条指令完成一个Kubernetes集群的部署
# 创建一个Master节点
$ kubeadm init
# 将一个Node节点加入到当前集群中
$ kubeadm join <Master节点的IP和端口>
是不是非常方便呢?
不过你可能也会有所顾虑Kubernetes的功能那么多这样一键部署出来的集群能用于生产环境吗
为了回答这个问题在今天这篇文章我就先和你介绍一下kubeadm的工作原理吧。
kubeadm的工作原理
在上一篇文章《从容器到容器云谈谈Kubernetes的本质》中我已经详细介绍了Kubernetes的架构和它的组件。在部署时它的每一个组件都是一个需要被执行的、单独的二进制文件。所以不难想象SaltStack这样的运维工具或者由社区维护的脚本的功能就是要把这些二进制文件传输到指定的机器当中然后编写控制脚本来启停这些组件。
不过在理解了容器技术之后你可能已经萌生出了这样一个想法为什么不用容器部署Kubernetes呢
这样我只要给每个Kubernetes组件做一个容器镜像然后在每台宿主机上用docker run指令启动这些组件容器部署不就完成了吗
事实上在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的作者已经为各个发行版的Linux准备好了安装包所以你只需要执行
$ apt-get install kubeadm
就可以了。
接下来你就可以使用“kubeadm init”部署Master节点了。
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节点的服务器地址、监听端口、证书目录等信息。这样对应的客户端比如schedulerkubelet等可以直接加载相应的文件使用里面的信息与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文件怎么写、里面的字段如何解读我会在后续专门的文章中为你详细分析。在这里你只需要关注这样几个信息
这个Pod里只定义了一个容器它使用的镜像是k8s.gcr.io/kube-apiserver-amd64:v1.11.1 。这个镜像是Kubernetes官方维护的一个组件镜像。
这个容器的启动命令commands是kube-apiserver authorization-mode=Node,RBAC …这样一句非常长的命令。其实它就是容器里kube-apiserver这个二进制文件再加上指定的配置参数而已。
如果你要修改一个已有集群的kube-apiserver的配置需要修改这个YAML文件。
这些组件的参数也可以在部署时指定,我很快就会讲到。
在这一步完成后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和kubadm的节点都可以通过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就可以了。
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上这样一个新的节点就部署完成了。
接下来,你只要在其他节点上重复这个指令就可以了。
配置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在国内访问是有困难的指定自己的证书文件指定特殊的容器运行时等等。这些配置项就留给你在后续实践中探索了。
总结
在今天的这次分享中我重点介绍了kubeadm这个部署工具的工作原理和使用方法。紧接着我会在下一篇文章中使用它一步步地部署一个完整的Kubernetes集群。
从今天的分享中你可以看到kubeadm的设计非常简洁。并且它在实现每一步部署功能时都在最大程度地重用Kubernetes已有的功能这也就使得我们在使用kubeadm部署Kubernetes项目时非常有“原生”的感觉一点都不会感到突兀。
而kubeadm的源代码直接就在kubernetes/cmd/kubeadm目录下是Kubernetes项目的一部分。其中app/phases文件夹下的代码对应的就是我在这篇文章中详细介绍的每一个具体步骤。
看到这里你可能会猜想kubeadm的作者一定是Google公司的某个“大神”吧。
实际上kubeadm几乎完全是一位高中生的作品。他叫Lucas Käldström芬兰人今年只有18岁。kubeadm是他17岁时用业余时间完成的一个社区项目。
所以说开源社区的魅力也在于此一个成功的开源项目总能够吸引到全世界最厉害的贡献者参与其中。尽管参与者的总体水平参差不齐而且频繁的开源活动又显得杂乱无章难以管控但一个有足够热度的社区最终的收敛方向却一定是代码越来越完善、Bug越来越少、功能越来越强大。
最后我再来回答一下我在今天这次分享开始提到的问题kubeadm能够用于生产环境吗
到目前为止2018年9月这个问题的答案是不能。
因为kubeadm目前最欠缺的是一键部署一个高可用的Kubernetes集群Etcd、Master组件都应该是多节点集群而不是现在这样的单点。这当然也正是kubeadm接下来发展的主要方向。
另一方面Lucas也正在积极地把kubeadm phases开放给用户用户可以更加自由地定制kubeadm的每一个部署步骤。这些举措都可以让这个项目更加完善我对它的发展走向也充满了信心。
当然如果你有部署规模化生产环境的需求我推荐使用kops或者SaltStack这样更复杂的部署工具。但在本专栏接下来的讲解中我都会以kubeadm为依据进行讲述。
一方面作为Kubernetes项目的原生部署工具kubeadm对Kubernetes项目特性的使用和集成确实要比其他项目“技高一筹”非常值得我们学习和借鉴
另一方面kubeadm的部署方法不会涉及到太多的运维工作也不需要我们额外学习复杂的部署工具。而它部署的Kubernetes集群跟一个完全使用二进制文件搭建起来的集群几乎没有任何区别。
因此使用kubeadm去部署一个Kubernetes集群对于你理解Kubernetes组件的工作方式和架构最好不过了。
思考题
在Linux上为一个类似kube-apiserver的Web Server制作证书你知道可以用哪些工具实现吗
回忆一下我在前面文章中分享的Kubernetes架构你能够说出Kubernetes各个功能组件之间包含Etcd都有哪些建立连接或者调用的方式吗比如HTTP/HTTPS远程调用等等
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,403 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 从0到1搭建一个完整的Kubernetes集群
你好我是张磊。今天我和你分享的主题是从0到1搭建一个完整的Kubernetes集群。
不过,首先需要指出的是,本篇搭建指南是完全的手工操作,细节比较多,并且有些外部链接可能还会遇到特殊的“网络问题”。所以,对于只关心学习 Kubernetes 本身知识点、不太关注如何手工部署 Kubernetes 集群的同学,可以略过本节,直接使用 MiniKube 或者 Kind来在本地启动简单的 Kubernetes 集群进行后面的学习即可。如果是使用 MiniKube 的话,阿里云还维护了一个国内版的 MiniKube这对于在国内的同学来说会比较友好。
在上一篇文章中我介绍了kubeadm这个Kubernetes半官方管理工具的工作原理。既然kubeadm的初衷是让Kubernetes集群的部署不再让人头疼那么这篇文章我们就来使用它部署一个完整的Kubernetes集群吧。
备注这里所说的“完整”指的是这个集群具备Kubernetes项目在GitHub上已经发布的所有功能并能够模拟生产环境的所有使用需求。但并不代表这个集群是生产级别可用的类似于高可用、授权、多租户、灾难备份等生产级别集群的功能暂时不在本篇文章的讨论范围。-
目前kubeadm的高可用部署已经有了第一个发布。但是这个特性还没有GA生产可用所以包括了大量的手动工作跟我们所预期的一键部署还有一定距离。GA的日期预计是2018年底到2019年初。届时如果有机会我会再和你分享这部分内容。
这次部署我不会依赖于任何公有云或私有云的能力而是完全在Bare-metal环境中完成。这样的部署经验会更有普适性。而在后续的讲解中如非特殊强调我也都会以本次搭建的这个集群为基础。
准备工作
首先,准备机器。最直接的办法,自然是到公有云上申请几个虚拟机。当然,如果条件允许的话,拿几台本地的物理服务器来组集群是最好不过了。这些机器只要满足如下几个条件即可:
满足安装Docker项目所需的要求比如64位的Linux操作系统、3.10及以上的内核版本;
x86或者ARM架构均可
机器之间网络互通,这是将来容器之间网络互通的前提;
有外网访问权限,因为需要拉取镜像;
能够访问到gcr.io、quay.io这两个docker registry因为有小部分镜像需要在这里拉取
单机可用资源建议2核CPU、8 GB内存或以上再小的话问题也不大但是能调度的Pod数量就比较有限了
30 GB或以上的可用磁盘空间这主要是留给Docker镜像和日志文件用的。
在本次部署中,我准备的机器配置如下:
2核CPU、 7.5 GB内存
30 GB磁盘
Ubuntu 16.04
内网互通;
外网访问权限不受限制。
备注在开始部署前我推荐你先花几分钟时间回忆一下Kubernetes的架构。
然后,我再和你介绍一下今天实践的目标:
在所有节点上安装Docker和kubeadm
部署Kubernetes Master
部署容器网络插件;
部署Kubernetes Worker
部署Dashboard可视化插件
部署容器存储插件。
好了,现在,就来开始这次集群部署之旅吧!
安装kubeadm和Docker
我在上一篇文章《 Kubernetes一键部署利器kubeadm》中已经介绍过kubeadm的基础用法它的一键安装非常方便我们只需要添加kubeadm的源然后直接使用apt-get安装即可具体流程如下所示
备注为了方便讲解我后续都会直接在root用户下进行操作。
$ curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
$ cat <<EOF > /etc/apt/sources.list.d/kubernetes.list
deb http://apt.kubernetes.io/ kubernetes-xenial main
EOF
$ apt-get update
$ apt-get install -y docker.io kubeadm
提示:如果 apt.kubernetes.io 因为网络问题访问不到,可以换成中科大的 Ubuntu 镜像源deb http://mirrors.ustc.edu.cn/kubernetes/apt kubernetes-xenial main。
在上述安装kubeadm的过程中kubeadm和kubelet、kubectl、kubernetes-cni这几个二进制文件都会被自动安装好。
另外这里我直接使用Ubuntu的docker.io的安装源原因是Docker公司每次发布的最新的Docker CE社区版产品往往还没有经过Kubernetes项目的验证可能会有兼容性方面的问题。
部署Kubernetes的Master节点
在上一篇文章中我已经介绍过kubeadm可以一键部署Master节点。不过在本篇文章中既然要部署一个“完整”的Kubernetes集群那我们不妨稍微提高一下难度通过配置文件来开启一些实验性功能。
所以这里我编写了一个给kubeadm用的YAML文件名叫kubeadm.yaml
apiVersion: kubeadm.k8s.io/v1alpha1
kind: MasterConfiguration
controllerManagerExtraArgs:
horizontal-pod-autoscaler-use-rest-clients: "true"
horizontal-pod-autoscaler-sync-period: "10s"
node-monitor-grace-period: "10s"
apiServerExtraArgs:
runtime-config: "api/all=true"
kubernetesVersion: "stable-1.11"
这个配置中我给kube-controller-manager设置了
horizontal-pod-autoscaler-use-rest-clients: "true"
这意味着将来部署的kube-controller-manager能够使用自定义资源Custom Metrics进行自动水平扩展。这是我后面文章中会重点介绍的一个内容。
其中“stable-1.11”就是kubeadm帮我们部署的Kubernetes版本号Kubernetes release 1.11最新的稳定版在我的环境下它是v1.11.1。你也可以直接指定这个版本比如kubernetesVersion: “v1.11.1”。
然后,我们只需要执行一句指令:
$ kubeadm init --config kubeadm.yaml
就可以完成Kubernetes Master的部署了这个过程只需要几分钟。部署完成后kubeadm会生成一行指令
kubeadm join 10.168.0.2:6443 --token 00bwbx.uvnaa2ewjflwu1ry --discovery-token-ca-cert-hash sha256:00eb62a2a6020f94132e3fe1ab721349bbcd3e9b94da9654cfe15f2985ebd711
这个kubeadm join命令就是用来给这个Master节点添加更多工作节点Worker的命令。我们在后面部署Worker节点的时候马上会用到它所以找一个地方把这条命令记录下来。
此外kubeadm还会提示我们第一次使用Kubernetes集群所需要的配置命令
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
而需要这些配置命令的原因是Kubernetes集群默认需要加密方式访问。所以这几条命令就是将刚刚部署生成的Kubernetes集群的安全配置文件保存到当前用户的.kube目录下kubectl默认会使用这个目录下的授权信息访问Kubernetes集群。
如果不这么做的话我们每次都需要通过export KUBECONFIG环境变量告诉kubectl这个安全配置文件的位置。
现在我们就可以使用kubectl get命令来查看当前唯一一个节点的状态了
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
master NotReady master 1d v1.11.1
可以看到这个get指令输出的结果里Master节点的状态是NotReady这是为什么呢
在调试Kubernetes集群时最重要的手段就是用kubectl describe来查看这个节点Node对象的详细信息、状态和事件Event我们来试一下
$ kubectl describe node master
...
Conditions:
...
Ready False ... KubeletNotReady runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:docker: network plugin is not ready: cni config uninitialized
通过kubectl describe指令的输出我们可以看到NodeNotReady的原因在于我们尚未部署任何网络插件。
另外我们还可以通过kubectl检查这个节点上各个系统Pod的状态其中kube-system是Kubernetes项目预留的系统Pod的工作空间Namepsace注意它并不是Linux Namespace它只是Kubernetes划分不同工作空间的单位
$ kubectl get pods -n kube-system
NAME READY STATUS RESTARTS AGE
coredns-78fcdf6894-j9s52 0/1 Pending 0 1h
coredns-78fcdf6894-jm4wf 0/1 Pending 0 1h
etcd-master 1/1 Running 0 2s
kube-apiserver-master 1/1 Running 0 1s
kube-controller-manager-master 0/1 Pending 0 1s
kube-proxy-xbd47 1/1 NodeLost 0 1h
kube-scheduler-master 1/1 Running 0 1s
可以看到CoreDNS、kube-controller-manager等依赖于网络的Pod都处于Pending状态即调度失败。这当然是符合预期的因为这个Master节点的网络尚未就绪。
部署网络插件
在Kubernetes项目“一切皆容器”的设计理念指导下部署网络插件非常简单只需要执行一句kubectl apply指令以Weave为例
$ kubectl apply -f https://git.io/weave-kube-1.6
部署完成后我们可以通过kubectl get重新检查Pod的状态
$ kubectl get pods -n kube-system
NAME READY STATUS RESTARTS AGE
coredns-78fcdf6894-j9s52 1/1 Running 0 1d
coredns-78fcdf6894-jm4wf 1/1 Running 0 1d
etcd-master 1/1 Running 0 9s
kube-apiserver-master 1/1 Running 0 9s
kube-controller-manager-master 1/1 Running 0 9s
kube-proxy-xbd47 1/1 Running 0 1d
kube-scheduler-master 1/1 Running 0 9s
weave-net-cmk27 2/2 Running 0 19s
可以看到所有的系统Pod都成功启动了而刚刚部署的Weave网络插件则在kube-system下面新建了一个名叫weave-net-cmk27的Pod一般来说这些Pod就是容器网络插件在每个节点上的控制组件。
Kubernetes支持容器网络插件使用的是一个名叫CNI的通用接口它也是当前容器网络的事实标准市面上的所有容器网络开源项目都可以通过CNI接入Kubernetes比如Flannel、Calico、Canal、Romana等等它们的部署方式也都是类似的“一键部署”。关于这些开源项目的实现细节和差异我会在后续的网络部分详细介绍。
至此Kubernetes的Master节点就部署完成了。如果你只需要一个单节点的Kubernetes现在你就可以使用了。不过在默认情况下Kubernetes的Master节点是不能运行用户Pod的所以还需要额外做一个小操作。在本篇的最后部分我会介绍到它。
部署Kubernetes的Worker节点
Kubernetes的Worker节点跟Master节点几乎是相同的它们运行着的都是一个kubelet组件。唯一的区别在于在kubeadm init的过程中kubelet启动后Master节点上还会自动运行kube-apiserver、kube-scheduler、kube-controller-manger这三个系统Pod。
所以相比之下部署Worker节点反而是最简单的只需要两步即可完成。
第一步在所有Worker节点上执行“安装kubeadm和Docker”一节的所有步骤。
第二步执行部署Master节点时生成的kubeadm join指令
$ kubeadm join 10.168.0.2:6443 --token 00bwbx.uvnaa2ewjflwu1ry --discovery-token-ca-cert-hash sha256:00eb62a2a6020f94132e3fe1ab721349bbcd3e9b94da9654cfe15f2985ebd711
通过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节点上就会增加一个键值对格式的Taintfoo=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。
到了这一步一个基本完整的Kubernetes集群就部署完毕了。是不是很简单呢
有了kubeadm这样的原生管理工具Kubernetes的部署已经被大大简化。更重要的是像证书、授权、各个组件的配置等部署中最麻烦的操作kubeadm都已经帮你完成了。
接下来我们再在这个Kubernetes集群上安装一些其他的辅助插件比如Dashboard和存储插件。
部署Dashboard可视化插件
在Kubernetes社区中有一个很受欢迎的Dashboard项目它可以给用户提供一个可视化的Web界面来查看当前集群的各种信息。毫不意外它的部署也相当简单
$ kubectl apply -f
$ $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.0.0-rc6/aio/deploy/recommended.yaml
部署完成之后我们就可以查看Dashboard对应的Pod的状态了
$ kubectl get pods -n kube-system
kubernetes-dashboard-6948bdb78-f67xk 1/1 Running 0 1m
需要注意的是由于Dashboard是一个Web Server很多人经常会在自己的公有云上无意地暴露Dashboard的端口从而造成安全隐患。所以1.7版本之后的Dashboard项目部署完成后默认只能通过Proxy的方式在本地访问。具体的操作你可以查看Dashboard项目的官方文档。
而如果你想从集群外访问这个Dashboard的话就需要用到Ingress我会在后面的文章中专门介绍这部分内容。
部署容器存储插件
接下来让我们完成这个Kubernetes集群的最后一块拼图容器持久化存储。
我在前面介绍容器原理时已经提到过很多时候我们需要用数据卷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/common.yaml
$ 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 VolumePV和Persistent Volume ClaimPVC的方式在容器里挂载由Ceph提供的数据卷了。
而Rook项目则会负责这些数据卷的生命周期管理、灾难备份等运维工作。关于这些容器持久化存储的知识我会在后续章节中专门讲解。
这时候你可能会有个疑问为什么我要选择Rook项目呢
其实,是因为这个项目很有前途。
如果你去研究一下Rook项目的实现就会发现它巧妙地依赖了Kubernetes提供的编排能力合理的使用了很多诸如Operator、CRD等重要的扩展特性这些特性我都会在后面的文章中逐一讲解到。这使得Rook项目成为了目前社区中基于Kubernetes API构建的最完善也最成熟的容器存储插件。我相信这样的发展路线很快就会得到整个社区的推崇。
备注其实在很多时候大家说的所谓“云原生”就是“Kubernetes原生”的意思。而像Rook、Istio这样的项目正是贯彻这个思路的典范。在我们后面讲解了声明式API之后相信你对这些项目的设计思想会有更深刻的体会。
总结
在本篇文章中我们完全从0开始在Bare-metal环境下使用kubeadm工具部署了一个完整的Kubernetes集群这个集群有一个Master节点和多个Worker节点使用Weave作为容器网络插件使用Rook作为容器持久化存储插件使用Dashboard插件提供了可视化的Web界面。
这个集群,也将会是我进行后续讲解所依赖的集群环境,并且在后面的讲解中,我还会给它安装更多的插件,添加更多的新能力。
另外,这个集群的部署过程并不像传说中那么繁琐,这主要得益于:
kubeadm项目大大简化了部署Kubernetes的准备工作尤其是配置文件、证书、二进制文件的准备和制作以及集群版本管理等操作都被kubeadm接管了。
Kubernetes本身“一切皆容器”的设计思想加上良好的可扩展机制使得插件的部署非常简便。
上述思想也是开发和使用Kubernetes的重要指导思想基于Kubernetes开展工作时你一定要优先考虑这两个问题
我的工作是不是可以容器化?
我的工作是不是可以借助Kubernetes API和可扩展机制来完成
而一旦这项工作能够基于Kubernetes实现容器化就很有可能像上面的部署过程一样大幅简化原本复杂的运维工作。对于时间宝贵的技术人员来说这个变化的重要性是不言而喻的。
思考题
你是否使用其他工具部署过Kubernetes项目经历如何
你是否知道Kubernetes项目当前v1.11)能够有效管理的集群规模是多少个节点?你在生产环境中希望部署或者正在部署的集群规模又是多少个节点呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,330 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 牛刀小试:我的第一个容器化应用
你好,我是张磊。今天我和你分享的主题是:牛刀小试之我的第一个容器化应用。
在上一篇文章《从0到1搭建一个完整的Kubernetes集群》中我和你一起部署了一套完整的Kubernetes集群。这个集群虽然离生产环境的要求还有一定差距比如没有一键高可用部署但也可以当作是一个准生产级别的Kubernetes集群了。
而在这篇文章中我们就来扮演一个应用开发者的角色使用这个Kubernetes集群发布第一个容器化应用。
在开始实践之前我先给你讲解一下Kubernetes里面与开发者关系最密切的几个概念。
作为一个应用开发者,你首先要做的,是制作容器的镜像。这一部分内容,我已经在容器基础部分《白话容器基础(三):深入理解容器镜像》重点讲解过了。
而有了容器镜像之后你需要按照Kubernetes项目的规范和要求将你的镜像组织为它能够“认识”的方式然后提交上去。
那么什么才是Kubernetes项目能“认识”的方式呢
这就是使用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 ObjectAPI对象。当你为这个对象的各个字段填好值并提交给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对象的创建和更新操作具体做法如下所示
$ kubectl apply -f nginx-deployment.yaml
# 修改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
就可以了。
总结
在今天的分享中我通过一个小案例和你近距离体验了Kubernetes的使用方法。
可以看到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文件比如PodDeployment
最后在Kubernetes上部署这个YAML文件。
更重要的是在部署到Kubernetes之后接下来的所有操作要么通过kubectl来执行要么通过修改YAML文件来实现就尽量不要再碰Docker的命令行了。
思考题
在实际使用Kubernetes的过程中相比于编写一个单独的Pod的YAML文件我一定会推荐你使用一个replicas=1的Deployment。请问这两者有什么区别呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,313 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 为什么我们需要Pod
你好我是张磊。今天我和你分享的主题是为什么我们需要Pod。
在前面的文章中我详细介绍了在Kubernetes里部署一个应用的过程。在这些讲解中我提到了这样一个知识点Pod是Kubernetes项目中最小的API对象。如果换一个更专业的说法我们可以这样描述Pod是Kubernetes项目的原子调度单位。
不过我相信你在学习和使用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对应的imklogimuxsock和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容器关联在一起。这样的组织关系可以用下面这样一个示意图来表达
-
如上图所示这个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里的多个容器。
为了能够掌握这种思考方式,你就应该尽量尝试使用它来描述一些用单个容器难以解决的问题。
第一个最典型的例子是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的角色。
第二个例子,则是容器的日志收集。
比如,我现在有一个应用,需要不断地把日志文件输出到容器的/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社区曾经把“容器设计模式”这个理论整理成了一篇小论文你可以点击链接浏览。
总结
在本篇文章中我重点分享了Kubernetes项目中Pod的实现原理。
Pod是Kubernetes项目与其他单容器项目相比最大的不同也是一位容器技术初学者需要面对的第一个与常规认知不一致的知识点。
事实上,直到现在,仍有很多人把容器跟虚拟机相提并论,他们把容器当做性能更好的虚拟机,喜欢讨论如何把应用从虚拟机无缝地迁移到容器中。
但实际上,无论是从具体的实现原理,还是从使用方法、特性、功能等方面,容器与虚拟机几乎没有任何相似的地方;也不存在一种普遍的方法,能够把虚拟机里的应用无缝迁移到容器中。因为,容器的性能优势,必然伴随着相应缺陷,即:它不能像虚拟机那样,完全模拟本地物理机环境中的部署方法。
所以,这个“上云”工作的完成,最终还是要靠深入理解容器的本质,即:进程。
实际上一个运行在虚拟机里的应用哪怕再简单也是被管理在systemd或者supervisord之下的一组进程而不是一个进程。这跟本地物理机上应用的运行方式其实是一样的。这也是为什么从物理机到虚拟机之间的应用迁移往往并不困难。
可是对于容器来说,一个容器永远只能管理一个进程。更确切地说,一个容器,就是一个进程。这是容器技术的“天性”,不可能被修改。所以,将一个原本运行在虚拟机里的应用,“无缝迁移”到容器中的想法,实际上跟容器的本质是相悖的。
这也是当初Swarm项目无法成长起来的重要原因之一一旦到了真正的生产环境上Swarm这种单容器的工作方式就难以描述真实世界里复杂的应用架构了。
所以你现在可以这么理解Pod的本质
Pod实际上是在扮演传统基础设施里“虚拟机”的角色而容器则是这个虚拟机里运行的用户程序。
所以下一次当你需要把一个运行在虚拟机里的应用迁移到Docker容器中时一定要仔细分析到底有哪些进程组件运行在这个虚拟机里。
然后你就可以把整个虚拟机想象成为一个Pod把这些进程分别做成容器镜像把有顺序关系的容器定义为Init Container。这才是更加合理的、松耦合的容器编排诀窍也是从传统应用架构到“微服务架构”最自然的过渡方式。
注意Pod这个概念提供的是一种编排思想而不是具体的技术方案。所以如果愿意的话你完全可以使用虚拟机来作为Pod的实现然后把用户容器都运行在这个虚拟机里。比如Mirantis公司的virtlet项目就在干这个事情。甚至你可以去实现一个带有Init进程的容器项目来模拟传统应用的运行方式。这些工作在Kubernetes中都是非常轻松的也是我们后面讲解CRI时会提到的内容。
相反的如果强行把整个应用塞到一个容器里甚至不惜使用Docker In Docker这种在生产环境中后患无穷的解决方案恐怕最后往往会得不偿失。
思考题
除了Network Namespace外Pod里的容器还可以共享哪些Namespace呢你能说出共享这些Namesapce的具体应用场景吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,217 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 深入解析Pod对象基本概念
你好我是张磊。今天我和你分享的主题是深入解析Pod对象之基本概念。
在上一篇文章中我详细介绍了Pod这个Kubernetes项目中最重要的概念。而在今天这篇文章中我会和你分享Pod对象的更多细节。
现在你已经非常清楚Pod而不是容器才是Kubernetes项目中的最小编排单位。将这个设计落实到API对象上容器Container就成了Pod属性里的一个普通的字段。那么一个很自然的问题就是到底哪些属性属于Pod对象而又有哪些属性属于Container呢
要彻底理解这个问题你就一定要牢记我在上一篇文章中提到的一个结论Pod扮演的是传统部署环境里“虚拟机”的角色。这样的设计是为了使用户从传统环境虚拟机环境向Kubernetes容器环境的迁移更加平滑。
而如果你能把Pod看成传统环境里的“机器”、把容器看作是运行在这个“机器”里的“用户程序”那么很多关于Pod对象的设计就非常容易理解了。
比如凡是调度、网络、存储以及安全相关的属性基本上是Pod 级别的。
这些属性的共同特征是它们描述的是“机器”这个整体而不是里面运行的“程序”。比如配置这个“机器”的网卡Pod的网络定义配置这个“机器”的磁盘Pod的存储定义配置这个“机器”的防火墙Pod的安全定义。更不用说这台“机器”运行在哪个服务器之上Pod的调度
接下来我就先为你介绍Pod中几个重要字段的含义和用法。
NodeSelector是一个供用户将Pod与Node进行绑定的字段用法如下所示
apiVersion: v1
kind: Pod
...
spec:
nodeSelector:
disktype: ssd
这样的一个配置意味着这个Pod永远只能运行在携带了“disktype: ssd”标签Label的节点上否则它将调度失败。
NodeName一旦Pod的这个字段被赋值Kubernetes项目就会被认为这个Pod已经经过了调度调度的结果就是赋值的节点名字。所以这个字段一般由调度器负责设置但用户也可以设置它来“骗过”调度器当然这个做法一般是在测试或者调试的时候才会用到。
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模拟出的效果就跟虚拟机里程序间的关系非常类似了。
举个例子在下面这个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。
类似地凡是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的主要字段。不过在这里还有这么几个属性值得你额外关注。
首先是ImagePullPolicy字段。它定义了镜像拉取的策略。而它之所以是一个Container级别的属性是因为容器镜像本来就是Container定义中的一部分。
ImagePullPolicy的值默认是Always即每次创建Pod都重新拉取一次镜像。另外当容器的镜像是类似于nginx或者nginx:latest这样的名字时ImagePullPolicy也会被认为Always。
而如果它的值被定义为Never或者IfNotPresent则意味着Pod永远不会主动拉取这个镜像或者只在宿主机上不存在这个镜像时才拉取。
其次是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的当前状态它有如下几种可能的情况
Pending。这个状态意味着Pod的YAML文件已经提交给了KubernetesAPI对象已经被创建并保存在Etcd当中。但是这个Pod里有些容器因为某种原因而不能被顺利创建。比如调度不成功。
Running。这个状态下Pod已经调度成功跟一个具体的节点绑定。它包含的容器都已经创建成功并且至少有一个正在运行中。
Succeeded。这个状态意味着Pod里的所有容器都正常运行完毕并且已经退出了。这种情况在运行一次性任务时最为常见。
Failed。这个状态下Pod里至少有一个容器以不正常的状态非0的返回码退出。这个状态的出现意味着你得想办法Debug这个容器的应用比如查看Pod的Events和日志。
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”状态后你一定要能迅速做出反应根据它所代表的异常情况开始跟踪和定位而不是去手忙脚乱地查阅文档。
总结
在今天这篇文章中我详细讲解了Pod API对象介绍了Pod的核心使用方法并分析了Pod和Container在字段上的异同。希望这些讲解能够帮你更好地理解和记忆Pod YAML中的核心字段以及这些字段的准确含义。
实际上Pod API对象是整个Kubernetes体系中最核心的一个概念也是后面我讲解各种控制器时都要用到的。
在学习完这篇文章后,我希望你能仔细阅读$GOPATH/src/k8s.io/kubernetes/vendor/k8s.io/api/core/v1/types.go里type Pod struct 尤其是PodSpec部分的内容。争取做到下次看到一个Pod的YAML文件时不再需要查阅文档就能做到把常用字段及其作用信手拈来。
而在下一篇文章中我会通过大量的实践帮助你巩固和进阶关于Pod API对象核心字段的使用方法敬请期待吧。
思考题
你能否举出一些Pod即容器的状态是Running但是应用其实已经停止服务的例子相信Java Web开发者的亲身体会会比较多吧。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,555 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 深入解析Pod对象使用进阶
你好我是张磊。今天我和你分享的主题是深入解析Pod对象之使用进阶。
在上一篇文章中我深入解析了Pod的API对象讲解了Pod和Container的关系。
作为Kubernetes项目里最核心的编排对象Pod携带的信息非常丰富。其中资源定义比如CPU、内存等以及调度相关的字段我会在后面专门讲解调度器时再进行深入的分析。在本篇我们就先从一种特殊的Volume开始来帮助你更加深入地理解Pod对象各个重要字段的含义。
这种特殊的Volume叫作Projected Volume你可以把它翻译为“投射数据卷”。
备注Projected Volume是Kubernetes v1.11之后的新特性
这是什么意思呢?
在Kubernetes中有几种特殊的Volume它们存在的意义不是为了存放容器里的数据也不是用来进行容器和宿主机之间的数据交换。这些特殊Volume的作用是为容器提供预先定义好的数据。所以从容器的角度来看这些Volume里的信息就是仿佛是被Kubernetes“投射”Project进入容器当中的。这正是Projected Volume的含义。
到目前为止Kubernetes支持的Projected Volume一共有四种
Secret
ConfigMap
Downward API
ServiceAccountToken。
在今天这篇文章中我首先和你分享的是Secret。它的作用是帮你把Pod想要访问的加密数据存放到Etcd中。然后你就可以通过在Pod的容器里挂载Volume的方式访问到这些Secret里保存的信息了。
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
上面这个列表的内容随着Kubernetes项目的发展肯定还会不断增加。所以这里列出来的信息仅供参考你在使用Downward API时还是要记得去查阅一下官方文档。
不过需要注意的是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”。这时如果这个文件存在这条命令的返回值就是0Pod就会认为这个容器不仅已经启动而且是健康的。这个健康检查在容器启动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状态的对应关系总结了非常复杂的一大堆情况。实际上你根本不需要死记硬背这些对应关系只要记住如下两个基本的设计原理即可
只要Pod的restartPolicy指定的策略允许重启异常的容器比如Always那么这个Pod就会保持Running状态并进行容器重启。否则Pod就会进入Failed状态 。
对于包含多个容器的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 YAMLKubernetes就可以自动给对应的Pod对象加上其他必要的信息比如labelsannotationsvolumes等等。而这些信息可以是运维人员事先定义好的。
这么一来开发人员编写Pod YAML的门槛就被大大降低了。
所以这个叫作PodPresetPod预设置的功能 已经出现在了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要做的修改。而如果它们要做的修改有冲突的话这些冲突字段就不会被修改。
总结
在今天这篇文章中我和你详细介绍了Pod对象更高阶的使用方法希望通过对这些实例的讲解你可以更深入地理解Pod API对象的各个字段。
而在学习这些字段的同时你还应该认真体会一下Kubernetes“一切皆对象”的设计思想比如应用是Pod对象应用的配置是ConfigMap对象应用要访问的密码则是Secret对象。
所以也就自然而然地有了PodPreset这样专门用来对Pod进行批量化、自动化修改的工具对象。在后面的内容中我会为你讲解更多的这种对象还会和你介绍Kubernetes项目如何围绕着这些对象进行容器编排。
在本专栏中Pod对象相关的知识点非常重要它是接下来Kubernetes能够描述和编排各种复杂应用的基石所在希望你能够继续多实践、多体会。
思考题
在没有Kubernetes的时候你是通过什么方法进行应用的健康检查的Kubernetes的livenessProbe和readinessProbe提供的几种探测机制是否能满足你的需求
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,150 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 编排其实很简单:谈谈“控制器”模型
你好,我是张磊。今天我和你分享的主题是:编排其实很简单之谈谈“控制器”模型。
在上一篇文章中我和你详细介绍了Pod的用法讲解了Pod这个API对象的各个字段。而接下来我们就一起来看看“编排”这个Kubernetes项目最核心的功能吧。
实际上你可能已经有所感悟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为例我和你简单描述一下它对控制器模型的实现
Deployment控制器从Etcd中获取到所有携带了“app: nginx”标签的Pod然后统计它们的数量这就是实际状态
Deployment对象的Replicas字段的值就是期望状态
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项目中有一个专有的名字叫作PodTemplatePod模板
这个概念非常重要因为后面我要讲解到的大多数控制器都会使用PodTemplate来统一定义它所要管理的Pod。更有意思的是我们还会看到其他类型的对象模板比如Volume的模板。
至此我们就可以对Deployment以及其他类似的控制器做一个简单总结了
如上图所示类似Deployment这样的一个控制器实际上都是由上半部分的控制器定义包括期望状态加上下半部分的被控制对象的模板组成的。
这就是为什么在所有API对象的Metadata里都有一个字段叫作ownerReference用于保存当前这个API对象的拥有者Owner的信息。
那么对于我们这个nginx-deployment来说它创建出来的Pod的ownerReference就是nginx-deployment吗或者说nginx-deployment所直接控制的就是Pod对象么
这个问题的答案,我就留到下一篇文章时再做详细解释吧。
总结
在今天这篇文章中我以Deployment为例和你详细分享了Kubernetes项目如何通过一个称作“控制器模式”controller pattern的设计方法来统一地实现对各种不同的对象或者资源进行的编排操作。
在后面的讲解中我还会讲到很多不同类型的容器编排功能比如StatefulSet、DaemonSet等等它们无一例外地都有这样一个甚至多个控制器的存在并遵循控制循环control loop的流程完成各自的编排逻辑。
实际上跟Deployment相似这些控制循环最后的执行结果要么就是创建、更新一些Pod或者其他的API对象、资源要么就是删除一些已经存在的Pod或者其他的API对象、资源
但也正是在这个统一的编排框架下,不同的控制器可以在具体执行过程中,设计不同的业务逻辑,从而达到不同的编排效果。
这个实现思路正是Kubernetes项目进行容器编排的核心原理。在此后讲解Kubernetes编排功能的文章中我都会遵循这个逻辑展开并且带你逐步领悟控制器模式在不同的容器化作业中的实现方式。
思考题
你能否说出Kubernetes使用的这个“控制器模式”跟我们平常所说的“事件驱动”有什么区别和联系吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,397 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 经典PaaS的记忆作业副本与水平扩展
你好我是张磊。今天我和你分享的主题是经典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
从这个YAML文件中我们可以看到一个ReplicaSet对象其实就是由副本数目的定义和一个Pod模板组成的。不难发现它的定义其实是Deployment的一个子集。
更重要的是Deployment控制器实际操纵的正是这样的ReplicaSet对象而不是Pod对象。
还记不记得我在上一篇文章《编排其实很简单谈谈“控制器”模型》中曾经提出过这样一个问题对于一个Deployment所管理的Pod它的ownerReference是谁
所以这个问题的答案就是ReplicaSet。
明白了这个原理我再来和你一起分析一个如下所示的Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.7.9
ports:
- containerPort: 80
可以看到这就是一个我们常用的nginx-deployment它定义的Pod副本个数是3spec.replicas=3
那么在具体的实现上这个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
在返回结果中,我们可以看到四个状态字段,它们的含义如下所示。
DESIRED用户期望的Pod副本个数spec.replicas的值
CURRENT当前处于Running状态的Pod的个数
UP-TO-DATE当前处于最新版本的Pod的个数所谓最新版本指的是Pod的Spec部分与Deployment里Pod模板里定义的完全一致
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状态
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模板创建一个新的ReplicaSethash=1764197365这个新的ReplicaSet的初始Pod副本数是0。
然后在Age=24 s的位置Deployment Controller开始将这个新的ReplicaSet所控制的Pod副本数从0个变成1个“水平扩展”出一个副本。
紧接着在Age=22 s的位置Deployment Controller又将旧的ReplicaSethash=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
其中旧ReplicaSethash=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的数目以及每个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
通过这个返回结果我们可以看到新版本的ReplicaSethash=2156724341的“水平扩展”已经停止。而且此时它已经创建了两个Pod但是它们都没有进入READY状态。这当然是因为这两个Pod都拉取不到有效的镜像。
与此同时旧版本的ReplicaSethash=1764197365的“水平收缩”也自动停止了。此时已经有一个旧Pod被删除还剩下两个旧Pod。
那么问题来了, 我们如何让这个Deployment的3个Pod都回滚到以前的旧版本呢
我们只需要执行一条kubectl rollout undo命令就能把整个Deployment回滚到上一个版本
$ kubectl rollout undo deployment/nginx-deployment
deployment.extensions/nginx-deployment
很容易想到在具体操作上Deployment的控制器其实就是让这个旧ReplicaSethash=1764197365再次“扩展”成3个Pod而让新的ReplicaSethash=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 deployment/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你就再也不能做回滚操作了。
总结
在今天这篇文章中我为你详细讲解了Deployment这个Kubernetes项目中最基本的编排控制器的实现原理和使用方法。
通过这些讲解你应该了解到Deployment实际上是一个两层控制器。首先它通过ReplicaSet的个数来描述应用的版本然后它再通过ReplicaSet的属性比如replicas的值来保证Pod的副本数量。
备注Deployment控制ReplicaSet版本ReplicaSet控制Pod副本数。这个两层控制关系一定要牢记。
不过相信你也能够感受到Kubernetes项目对Deployment的设计实际上是代替我们完成了对“应用”的抽象使得我们可以使用这个Deployment对象来描述应用使用kubectl rollout命令控制应用的版本。
可是在实际使用场景中应用发布的流程往往千差万别也可能有很多的定制化需求。比如我的应用可能有会话黏连session sticky这就意味着“滚动更新”的时候哪个Pod能下线是不能随便选择的。
这种场景光靠Deployment自己就很难应对了。对于这种需求我在专栏后续文章中重点介绍的“自定义控制器”就可以帮我们实现一个功能更加强大的Deployment Controller。
当然Kubernetes项目本身也提供了另外一种抽象方式帮我们应对其他一些用Deployment无法处理的应用编排场景。这个设计就是对有状态应用的管理也是我在下一篇文章中要重点讲解的内容。
思考题
你听说过金丝雀发布Canary Deployment和蓝绿发布Blue-Green Deployment你能说出它们是什么意思吗
实际上有了Deployment的能力之后你可以非常轻松地用它来实现金丝雀发布、蓝绿发布以及A/B测试等很多应用发布模式。这些问题的答案都在这个GitHub库建议你在课后实践一下。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,277 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 深入理解StatefulSet拓扑状态
你好我是张磊。今天我和你分享的主题是深入理解StatefulSet之拓扑状态。
在上一篇文章中我在结尾处讨论到了Deployment实际上并不足以覆盖所有的应用编排问题。
造成这个问题的根本原因在于Deployment对应用做了一个简单化假设。
它认为一个应用的所有Pod是完全一样的。所以它们互相之间没有顺序也无所谓运行在哪台宿主机上。需要的时候Deployment就可以通过Pod模板创建新的Pod不需要的时候Deployment就可以“杀掉”任意一个Pod。
但是,在实际的场景中,并不是所有的应用都可以满足这样的要求。
尤其是分布式应用,它的多个实例之间,往往有依赖关系,比如:主从关系、主备关系。
还有就是数据存储类应用,它的多个实例,往往都会在本地磁盘上保存一份数据。而这些实例一旦被杀掉,即便重建出来,实例与数据之间的对应关系也已经丢失,从而导致应用失败。
所以这种实例之间有不对等关系以及实例对外部数据有依赖关系的应用就被称为“有状态应用”Stateful Application
容器技术诞生后大家很快发现它用来封装“无状态应用”Stateless Application尤其是Web服务非常好用。但是一旦你想要用容器运行“有状态应用”其困难程度就会直线上升。而且这个问题解决起来单纯依靠容器技术本身已经无能为力这也就导致了很长一段时间内“有状态应用”几乎成了容器技术圈子的“忌讳”大家一听到这个词就纷纷摇头。
不过Kubernetes项目还是成为了“第一个吃螃蟹的人”。
得益于“控制器模式”的设计思想Kubernetes项目很早就在Deployment的基础上扩展出了对“有状态应用”的初步支持。这个编排功能就是StatefulSet。
StatefulSet的设计其实非常容易理解。它把真实世界里的应用状态抽象为了两种情况
拓扑状态。这种情况意味着应用的多个实例之间不是完全对等的关系。这些应用实例必须按照某些顺序启动比如应用的主节点A要先于从节点B启动。而如果你把A和B两个Pod删除掉它们再次被创建出来时也必须严格按照这个顺序才行。并且新创建出来的Pod必须和原来Pod的网络标识一样这样原先的访问者才能使用同样的方法访问到这个新Pod。
存储状态。这种情况意味着应用的多个实例分别绑定了不同的存储数据。对于这些应用实例来说Pod A第一次读取到的数据和隔了十分钟之后再次读取到的数据应该是同一份哪怕在此期间Pod A被重新创建过。这种情况最典型的例子就是一个数据库应用的多个存储实例。
所以StatefulSet的核心功能就是通过某种方式记录这些状态然后在Pod被重新创建时能够为新Pod恢复这些状态。
在开始讲述StatefulSet的工作原理之前我就必须先为你讲解一个Kubernetes项目中非常实用的概念Headless Service。
我在和你一起讨论Kubernetes架构的时候就曾介绍过Service是Kubernetes项目中用来将一组Pod暴露给外界访问的一种机制。比如一个Deployment有3个Pod那么我就可以定义一个Service。然后用户只要能访问到这个Service它就能访问到某个具体的Pod。
那么这个Service又是如何被访问的呢
第一种方式是以Service的VIPVirtual IP虚拟IP方式。比如当我访问10.0.23.1这个Service的IP地址时10.0.23.1其实就是一个VIP它会把请求转发到该Service所代理的某一个Pod上。这里的具体原理我会在后续的Service章节中进行详细介绍。
第二种方式就是以Service的DNS方式。比如这时候只要我访问“my-svc.my-namespace.svc.cluster.local”这条DNS记录就可以访问到名叫my-svc的Service所代理的某一个Pod。
而在第二种Service DNS的方式下具体还可以分为两种处理方法
第一种处理方法是Normal Service。这种情况下你访问“my-svc.my-namespace.svc.cluster.local”解析到的正是my-svc这个Service的VIP后面的流程就跟VIP方式一致了。
而第二种处理方法正是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的名字进行了编号编号规则是<statefulset name>-<ordinal index>
而且这些编号都是从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:1.28.4 dns-test --restart=Never --rm /bin/sh
通过这条命令我们启动了一个一次性的Pod因为rm意味着Pod退出后就会被删除掉。然后在这个Pod的容器里面我们尝试用nslookup命令解析一下Pod对应的Headless Service
$ kubectl run -i --tty --image busybox:1.28.4 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地址。
总结
在今天这篇文章中我首先和你分享了StatefulSet的基本概念解释了什么是应用的“状态”。
紧接着 我为你分析了StatefulSet如何保证应用实例之间“拓扑状态”的稳定性。
如果用一句话来总结的话,你可以这么理解这个过程:
StatefulSet这个控制器的主要作用之一就是使用Pod模板创建Pod的时候对它们进行编号并且按照编号顺序逐一完成创建工作。而当StatefulSet的“控制循环”发现Pod的“实际状态”与“期望状态”不一致需要新建或者删除Pod进行“调谐”的时候它会严格按照这些Pod编号的顺序逐一完成这些操作。
所以StatefulSet其实可以认为是对Deployment的改良。
与此同时通过Headless Service的方式StatefulSet为每个Pod创建了一个固定并且稳定的DNS记录来作为它的访问入口。
实际上,在部署“有状态应用”的时候,应用的每个实例拥有唯一并且稳定的“网络标识”,是一个非常重要的假设。
在下一篇文章中我将会继续为你剖析StatefulSet如何处理存储状态。
思考题
你曾经运维过哪些有拓扑状态的应用呢比如主从、主主、主备、一主多从等结构你觉得这些应用实例之间的拓扑关系能否借助这种为Pod实例编号的方式表达出来呢如果不能你觉得Kubernetes还应该为你提供哪些支持来管理这个拓扑状态呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,276 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 深入理解StatefulSet存储状态
你好我是张磊。今天我和你分享的主题是深入理解StatefulSet之存储状态。
在上一篇文章中我和你分享了StatefulSet如何保证应用实例的拓扑状态在Pod删除和再创建的过程中保持稳定。
而在今天这篇文章中我将继续为你解读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 ClaimPVC和Persistent VolumePV的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 GiBaccessModes: 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又是从哪里来的呢
答案是它们来自于由运维人员维护的PVPersistent Volume对象。接下来我们一起看一个常见的PV对象的YAML文件
kind: PersistentVolume
apiVersion: v1
metadata:
name: pv-volume
labels:
type: local
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
rbd:
monitors:
# 使用 kubectl get pods -n rook-ceph 查看 rook-ceph-mon- 开头的 POD IP 即可得下面的列表
- '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
可以看到这个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都以“--<编号>”的方式命名并且处于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的工作原理呢现在我再为你详细梳理一下吧。
首先StatefulSet的控制器直接管理的是Pod。这是因为StatefulSet里的不同Pod实例不再像ReplicaSet中那样都是完全一样的而是有了细微区别的。比如每个Pod的hostname、名字等都是不同的、携带了编号的。而StatefulSet区分这些实例的方式就是通过在Pod的名字里加上事先约定好的编号。
其次Kubernetes通过Headless Service为这些有编号的Pod在DNS服务器中生成带有同样编号的DNS记录。只要StatefulSet能够保证这些Pod名字里的编号不变那么Service里类似于web-0.nginx.default.svc.cluster.local这样的DNS记录也就不会变而这条记录解析出来的Pod的IP地址则会随着后端Pod的删除和再创建而自动更新。这当然是Service机制本身的能力不需要StatefulSet操心。
最后StatefulSet还为每一个Pod分配并创建一个同样编号的PVC。这样Kubernetes就可以通过Persistent Volume机制为这个PVC绑定上对应的PV从而保证了每一个Pod都拥有一个独立的Volume。
在这种情况下即使Pod被删除它所对应的PVC和PV依然会保留下来。所以当这个Pod被重新创建出来之后Kubernetes会为它找到同样编号的PVC挂载这个PVC对应的Volume从而获取到以前保存在Volume里的数据。
这么一看原本非常复杂的StatefulSet是不是也很容易理解了呢
总结
在今天这篇文章中我为你详细介绍了StatefulSet处理存储状态的方法。然后以此为基础我为你梳理了StatefulSet控制器的工作原理。
从这些讲述中我们不难看出StatefulSet的设计思想StatefulSet其实就是一种特殊的Deployment而其独特之处在于它的每个Pod都被编号了。而且这个编号会体现在Pod的名字和hostname等标识信息上这不仅代表了Pod的创建顺序也是Pod的重要网络标识在整个集群里唯一的、可被访问的身份
有了这个编号后StatefulSet就使用Kubernetes里的两个标准功能Headless Service和PV/PVC实现了对Pod的拓扑状态和存储状态的维护。
实际上在下一篇文章的“有状态应用”实践环节以及后续的讲解中你就会逐渐意识到StatefulSet可以说是Kubernetes中作业编排的“集大成者”。
因为几乎每一种Kubernetes的编排功能都可以在编写StatefulSet的YAML文件时被用到。
思考题
在实际场景中,有一些分布式应用的集群是这么工作的:当一个新节点加入到集群时,或者老节点被迁移后重建时,这个节点可以从主节点或者其他从节点那里同步到自己所需要的数据。
在这种情况下你认为是否还有必要将这个节点Pod与它的PV进行一对一绑定呢提示这个问题的答案根据不同的项目是不同的。关键在于重建后的节点进行数据恢复和同步的时候是不是一定需要原先它写在本地磁盘里的数据
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,579 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 深入理解StatefulSet有状态应用实践
你好我是张磊。今天我和你分享的主题是深入理解StatefulSet之有状态应用实践。
在前面的两篇文章中我详细讲解了StatefulSet的工作原理以及处理拓扑状态和存储状态的方法。而在今天这篇文章中我将通过一个实际的例子再次为你深入解读一下部署一个StatefulSet的完整流程。
今天我选择的实例是部署一个MySQL集群这也是Kubernetes官方文档里的一个经典案例。但是很多工程师都曾向我吐槽说这个例子“完全看不懂”。
其实这样的吐槽也可以理解相比于Etcd、Cassandra等“原生”就考虑了分布式需求的项目MySQL以及很多其他的数据库项目在分布式集群的搭建上并不友好甚至有点“原始”。
所以这次我就直接选择了这个具有挑战性的例子和你分享如何使用StatefulSet将它的集群搭建过程“容器化”。
备注在开始实践之前请确保我们之前一起部署的那个Kubernetes集群还是可用的并且网络插件和存储插件都能正常运行。具体的做法请参考第11篇文章《从0到1搭建一个完整的Kubernetes集群》的内容。
首先,用自然语言来描述一下我们想要部署的“有状态应用”。
是一个“主从复制”Maser-Slave Replication的MySQL集群
有1个主节点Master
有多个从节点Slave
从节点需要能水平扩展;
所有的写操作,只能在主节点上执行;
读操作可以在所有节点上执行。
这就是一个非常典型的主从模式的MySQL集群了。我们可以把上面描述的“有状态应用”的需求通过一张图来表示。
-
在常规环境里部署这样一个主从模式的MySQL集群的主要难点在于如何让从节点能够拥有主节点的数据如何配置主MasterSlave节点的复制与同步。
所以在安装好MySQL的Master节点之后你需要做的第一步工作就是通过XtraBackup将Master节点的数据备份到指定目录。
备注XtraBackup是业界主要使用的开源MySQL备份和恢复工具。
这一步会自动在目标目录里生成一个备份信息文件名叫xtrabackup_binlog_info。这个文件一般会包含如下两个信息
$ cat xtrabackup_binlog_info
TheMaster-bin.000001 481
这两个信息会在接下来配置Slave节点的时候用到。
第二步配置Slave节点。Slave节点在第一次启动前需要先把Master节点的备份数据连同备份信息文件一起拷贝到自己的数据目录/var/lib/mysql下。然后我们执行这样一句SQL
TheSlave|mysql> CHANGE MASTER TO
MASTER_HOST='$masterip',
MASTER_USER='xxx',
MASTER_PASSWORD='xxx',
MASTER_LOG_FILE='TheMaster-bin.000001',
MASTER_LOG_POS=481;
其中MASTER_LOG_FILE和MASTER_LOG_POS就是该备份对应的二进制日志Binary Log文件的名称和开始的位置偏移量也正是xtrabackup_binlog_info文件里的那两部分内容TheMaster-bin.000001和481
第三步启动Slave节点。在这一步我们需要执行这样一句SQL
TheSlave|mysql> START SLAVE;
这样Slave节点就启动了。它会使用备份信息文件中的二进制日志文件和偏移量与主节点进行数据同步。
第四步在这个集群中添加更多的Slave节点。
需要注意的是新添加的Slave节点的备份数据来自于已经存在的Slave节点。
所以在这一步我们需要将Slave节点的数据备份在指定目录。而这个备份操作会自动生成另一种备份信息文件名叫xtrabackup_slave_info。同样地这个文件也包含了MASTER_LOG_FILE和MASTER_LOG_POS两个字段。
然后我们就可以执行跟前面一样的“CHANGE MASTER TO”和“START SLAVE” 指令来初始化并启动这个新的Slave节点了。
通过上面的叙述我们不难看到将部署MySQL集群的流程迁移到Kubernetes项目上需要能够“容器化”地解决下面的“三座大山”
Master节点和Slave节点需要有不同的配置文件不同的my.cnf
Master节点和Slave节点需要能够传输备份信息文件
在Slave节点第一次启动之前需要执行一些初始化SQL操作
而由于MySQL本身同时拥有拓扑状态主从节点的区别和存储状态MySQL保存在本地的数据我们自然要通过StatefulSet来解决这“三座大山”的问题。
其中“第一座大山Master节点和Slave节点需要有不同的配置文件”很容易处理我们只需要给主从节点分别准备两份不同的MySQL配置文件然后根据Pod的序号Index挂载进去即可。
正如我在前面文章中介绍过的这样的配置文件信息应该保存在ConfigMap里供Pod使用。它的定义如下所示
apiVersion: v1
kind: ConfigMap
metadata:
name: mysql
labels:
app: mysql
data:
master.cnf: |
# 主节点MySQL的配置文件
[mysqld]
log-bin
slave.cnf: |
# 从节点MySQL的配置文件
[mysqld]
super-read-only
在这里我们定义了master.cnf和slave.cnf两个MySQL的配置文件。
master.cnf开启了log-bin使用二进制日志文件的方式进行主从复制这是一个标准的设置。
slave.cnf的开启了super-read-only代表的是从节点会拒绝除了主节点的数据同步操作之外的所有写操作它对用户是只读的。
而上述ConfigMap定义里的data部分是Key-Value格式的。比如master.cnf就是这份配置数据的Key而“|”后面的内容就是这份配置数据的Value。这份数据将来挂载进Master节点对应的Pod后就会在Volume目录里生成一个叫作master.cnf的文件。
备注如果你对ConfigMap的用法感到陌生的话可以稍微复习一下第15篇文章《深入解析Pod对象使用进阶》中我讲解Secret对象部分的内容。因为ConfigMap跟Secret无论是使用方法还是实现原理几乎都是一样的。
接下来我们需要创建两个Service来供StatefulSet以及用户使用。这两个Service的定义如下所示
apiVersion: v1
kind: Service
metadata:
name: mysql
labels:
app: mysql
spec:
ports:
- name: mysql
port: 3306
clusterIP: None
selector:
app: mysql
---
apiVersion: v1
kind: Service
metadata:
name: mysql-read
labels:
app: mysql
spec:
ports:
- name: mysql
port: 3306
selector:
app: mysql
可以看到这两个Service都代理了所有携带app=mysql标签的Pod也就是所有的MySQL Pod。端口映射都是用Service的3306端口对应Pod的3306端口。
不同的是第一个名叫“mysql”的Service是一个Headless ServiceclusterIP= None。所以它的作用是通过为Pod分配DNS记录来固定它的拓扑状态比如“mysql-0.mysql”和“mysql-1.mysql”这样的DNS名字。其中编号为0的节点就是我们的主节点。
而第二个名叫“mysql-read”的Service则是一个常规的Service。
并且我们规定所有用户的读请求都必须访问第二个Service被自动分配的DNS记录“mysql-read”当然也可以访问这个Service的VIP。这样读请求就可以被转发到任意一个MySQL的主节点或者从节点上。
备注Kubernetes中的所有Service、Pod对象都会被自动分配同名的DNS记录。具体细节我会在后面Service部分做重点讲解。
而所有用户的写请求则必须直接以DNS记录的方式访问到MySQL的主节点也就是“mysql-0.mysql“这条DNS记录。
接下来我们再一起解决“第二座大山Master节点和Slave节点需要能够传输备份文件”的问题。
翻越这座大山的思路我比较推荐的做法是先搭建框架再完善细节。其中Pod部分如何定义是完善细节时的重点。
所以首先我们先为StatefulSet对象规划一个大致的框架如下图所示
在这一步我们可以先为StatefulSet定义一些通用的字段。
比如selector表示这个StatefulSet要管理的Pod必须携带app=mysql标签它声明要使用的Headless Service的名字是mysql。
这个StatefulSet的replicas值是3表示它定义的MySQL集群有三个节点一个Master节点两个Slave节点。
可以看到StatefulSet管理的“有状态应用”的多个实例也都是通过同一份Pod模板创建出来的使用的是同一个Docker镜像。这也就意味着如果你的应用要求不同节点的镜像不一样那就不能再使用StatefulSet了。对于这种情况应该考虑我后面会讲解到的Operator。
除了这些基本的字段外作为一个有存储状态的MySQL集群StatefulSet还需要管理存储状态。所以我们需要通过volumeClaimTemplatePVC模板来为每个Pod定义PVC。比如这个PVC模板的resources.requests.strorage指定了存储的大小为10 GiBReadWriteOnce指定了该存储的属性为可读写并且一个PV只允许挂载在一个宿主机上。将来这个PV对应的的Volume就会充当MySQL Pod的存储数据目录。
然后我们来重点设计一下这个StatefulSet的Pod模板也就是template字段。
由于StatefulSet管理的Pod都来自于同一个镜像这就要求我们在编写Pod时一定要保持清醒用“人格分裂”的方式进行思考
如果这个Pod是Master节点我们要怎么做
如果这个Pod是Slave节点我们又要怎么做。
想清楚这两个问题我们就可以按照Pod的启动过程来一步步定义它们了。
第一步从ConfigMap中获取MySQL的Pod对应的配置文件。
为此我们需要进行一个初始化操作根据节点的角色是Master还是Slave节点为Pod分配对应的配置文件。此外MySQL还要求集群里的每个节点都有一个唯一的ID文件名叫server-id.cnf。
而根据我们已经掌握的Pod知识这些初始化操作显然适合通过InitContainer来完成。所以我们首先定义了一个InitContainer如下所示
...
# template.spec
initContainers:
- name: init-mysql
image: mysql:5.7
command:
- bash
- "-c"
- |
set -ex
# 从Pod的序号生成server-id
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
echo [mysqld] > /mnt/conf.d/server-id.cnf
# 由于server-id=0有特殊含义我们给ID加一个100来避开它
echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf
# 如果Pod序号是0说明它是Master节点从ConfigMap里把Master的配置文件拷贝到/mnt/conf.d/目录;
# 否则拷贝Slave的配置文件
if [[ $ordinal -eq 0 ]]; then
cp /mnt/config-map/master.cnf /mnt/conf.d/
else
cp /mnt/config-map/slave.cnf /mnt/conf.d/
fi
volumeMounts:
- name: conf
mountPath: /mnt/conf.d
- name: config-map
mountPath: /mnt/config-map
在这个名叫init-mysql的InitContainer的配置中它从Pod的hostname里读取到了Pod的序号以此作为MySQL节点的server-id。
然后init-mysql通过这个序号判断当前Pod到底是Master节点序号为0还是Slave节点序号不为0从而把对应的配置文件从/mnt/config-map目录拷贝到/mnt/conf.d/目录下。
其中,文件拷贝的源目录/mnt/config-map正是ConfigMap在这个Pod的Volume如下所示
...
# template.spec
volumes:
- name: conf
emptyDir: {}
- name: config-map
configMap:
name: mysql
通过这个定义init-mysql在声明了挂载config-map这个Volume之后ConfigMap里保存的内容就会以文件的方式出现在它的/mnt/config-map目录当中。
而文件拷贝的目标目录,即容器里的/mnt/conf.d/目录对应的则是一个名叫conf的、emptyDir类型的Volume。基于Pod Volume共享的原理当InitContainer复制完配置文件退出后后面启动的MySQL容器只需要直接声明挂载这个名叫conf的Volume它所需要的.cnf配置文件已经出现在里面了。这跟我们之前介绍的Tomcat和WAR包的处理方法是完全一样的。
第二步在Slave Pod启动前从Master或者其他Slave Pod里拷贝数据库数据到自己的目录下。
为了实现这个操作我们就需要再定义第二个InitContainer如下所示
...
# template.spec.initContainers
- name: clone-mysql
image: gcr.io/google-samples/xtrabackup:1.0
command:
- bash
- "-c"
- |
set -ex
# 拷贝操作只需要在第一次启动时进行,所以如果数据已经存在,跳过
[[ -d /var/lib/mysql/mysql ]] && exit 0
# Master节点(序号为0)不需要做这个操作
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
[[ $ordinal -eq 0 ]] && exit 0
# 使用ncat指令远程地从前一个节点拷贝数据到本地
ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql
# 执行--prepare这样拷贝来的数据就可以用作恢复了
xtrabackup --prepare --target-dir=/var/lib/mysql
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
在这个名叫clone-mysql的InitContainer里我们使用的是xtrabackup镜像它里面安装了xtrabackup工具
而在它的启动命令里,我们首先做了一个判断。即:当初始化所需的数据(/var/lib/mysql/mysql 目录已经存在或者当前Pod是Master节点的时候不需要做拷贝操作。
接下来clone-mysql会使用Linux自带的ncat指令向DNS记录为“mysql-<当前序号减一>.mysql”的Pod也就是当前Pod的前一个Pod发起数据传输请求并且直接用xbstream指令将收到的备份数据保存在/var/lib/mysql目录下。
备注3307是一个特殊端口运行着一个专门负责备份MySQL数据的辅助进程。我们后面马上会讲到它。
当然这一步你可以随意选择用自己喜欢的方法来传输数据。比如用scp或者rsync都没问题。
你可能已经注意到,这个容器里的/var/lib/mysql目录实际上正是一个名为data的PVC我们在前面声明的持久化存储。
这就可以保证哪怕宿主机宕机了我们数据库的数据也不会丢失。更重要的是由于Pod Volume是被Pod里的容器共享的所以后面启动的MySQL容器就可以把这个Volume挂载到自己的/var/lib/mysql目录下直接使用里面的备份数据进行恢复操作。
不过clone-mysql容器还要对/var/lib/mysql目录执行一句xtrabackup prepare操作目的是让拷贝来的数据进入一致性状态这样这些数据才能被用作数据恢复。
至此我们就通过InitContainer完成了对“主、从节点间备份文件传输”操作的处理过程也就是翻越了“第二座大山”。
接下来我们可以开始定义MySQL容器,启动MySQL服务了。由于StatefulSet里的所有Pod都来自用同一个Pod模板所以我们还要“人格分裂”地去思考这个MySQL容器的启动命令在Master和Slave两种情况下有什么不同。
有了Docker镜像在Pod里声明一个Master角色的MySQL容器并不是什么困难的事情直接执行MySQL启动命令即可。
但是如果这个Pod是一个第一次启动的Slave节点在执行MySQL启动命令之前它就需要使用前面InitContainer拷贝来的备份数据进行初始化。
可是,别忘了,容器是一个单进程模型。
所以一个Slave角色的MySQL容器启动之前谁能负责给它执行初始化的SQL语句呢
这就是我们需要解决的“第三座大山”的问题如何在Slave节点的MySQL容器第一次启动之前执行初始化SQL。
你可能已经想到了我们可以为这个MySQL容器额外定义一个sidecar容器来完成这个操作它的定义如下所示
...
# template.spec.containers
- name: xtrabackup
image: gcr.io/google-samples/xtrabackup:1.0
ports:
- name: xtrabackup
containerPort: 3307
command:
- bash
- "-c"
- |
set -ex
cd /var/lib/mysql
# 从备份信息文件里读取MASTER_LOG_FILEM和MASTER_LOG_POS这两个字段的值用来拼装集群初始化SQL
if [[ -f xtrabackup_slave_info ]]; then
# 如果xtrabackup_slave_info文件存在说明这个备份数据来自于另一个Slave节点。这种情况下XtraBackup工具在备份的时候就已经在这个文件里自动生成了"CHANGE MASTER TO" SQL语句。所以我们只需要把这个文件重命名为change_master_to.sql.in后面直接使用即可
mv xtrabackup_slave_info change_master_to.sql.in
# 所以也就用不着xtrabackup_binlog_info了
rm -f xtrabackup_binlog_info
elif [[ -f xtrabackup_binlog_info ]]; then
# 如果只存在xtrabackup_binlog_inf文件那说明备份来自于Master节点我们就需要解析这个备份信息文件读取所需的两个字段的值
[[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1
rm xtrabackup_binlog_info
# 把两个字段的值拼装成SQL写入change_master_to.sql.in文件
echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in
fi
# 如果change_master_to.sql.in就意味着需要做集群初始化工作
if [[ -f change_master_to.sql.in ]]; then
# 但一定要先等MySQL容器启动之后才能进行下一步连接MySQL的操作
echo "Waiting for mysqld to be ready (accepting connections)"
until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done
echo "Initializing replication from clone position"
# 将文件change_master_to.sql.in改个名字防止这个Container重启的时候因为又找到了change_master_to.sql.in从而重复执行一遍这个初始化流程
mv change_master_to.sql.in change_master_to.sql.orig
# 使用change_master_to.sql.orig的内容也是就是前面拼装的SQL组成一个完整的初始化和启动Slave的SQL语句
mysql -h 127.0.0.1 <<EOF
$(<change_master_to.sql.orig),
MASTER_HOST='mysql-0.mysql',
MASTER_USER='root',
MASTER_PASSWORD='',
MASTER_CONNECT_RETRY=10;
START SLAVE;
EOF
fi
# 使用ncat监听3307端口它的作用是在收到传输请求的时候直接执行"xtrabackup --backup"命令备份MySQL的数据并发送给请求者
exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
"xtrabackup --backup --slave-info --stream=xbstream --host=127.0.0.1 --user=root"
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
可以看到在这个名叫xtrabackup的sidecar容器的启动命令里其实实现了两部分工作
第一部分工作当然是MySQL节点的初始化工作这个初始化需要使用的SQL是sidecar容器拼装出来保存在一个名为change_master_to.sql.in的文件里的具体过程如下所示
sidecar容器首先会判断当前Pod的/var/lib/mysql目录下是否有xtrabackup_slave_info这个备份信息文件
如果有则说明这个目录下的备份数据是由一个Slave节点生成的这种情况下XtraBackup工具在备份的时候就已经在这个文件里自动生成了CHANGE MASTER TO SQL语句所以我们只需要把这个文件重命名为change_master_to.sql.in后面直接使用即可
如果没有xtrabackup_slave_info文件但是存在xtrabackup_binlog_info文件那就说明备份数据来自于Master节点这种情况下sidecar容器就需要解析这个备份信息文件读取MASTER_LOG_FILE和MASTER_LOG_POS这两个字段的值用它们拼装出初始化SQL语句然后把这句SQL写入到change_master_to.sql.in文件中
接下来sidecar容器就可以执行初始化了从上面的叙述中可以看到只要这个change_master_to.sql.in文件存在那就说明接下来需要进行集群初始化操作
所以这时候sidecar容器只需要读取并执行change_master_to.sql.in里面的CHANGE MASTER TO指令再执行一句START SLAVE命令一个Slave节点就被成功启动了
需要注意的是Pod里的容器并没有先后顺序所以在执行初始化SQL之前必须先执行一句SQLselect 1来检查一下MySQL服务是否已经可用
当然上述这些初始化操作完成后我们还要删除掉前面用到的这些备份信息文件否则下次这个容器重启时就会发现这些文件存在所以又会重新执行一次数据恢复和集群初始化的操作这是不对的
同理change_master_to.sql.in在使用后也要被重命名以免容器重启时因为发现这个文件存在又执行一遍初始化
在完成MySQL节点的初始化后这个sidecar容器的第二个工作则是启动一个数据传输服务
具体做法是sidecar容器会使用ncat命令启动一个工作在3307端口上的网络发送服务一旦收到数据传输请求时sidecar容器就会调用xtrabackup backup指令备份当前MySQL的数据然后把这些备份数据返回给请求者这就是为什么我们在InitContainer里定义数据拷贝的时候访问的是上一个MySQL节点的3307端口
值得一提的是由于sidecar容器和MySQL容器同处于一个Pod里所以它是直接通过Localhost来访问和备份MySQL容器里的数据的非常方便
同样地我在这里举例用的只是一种备份方法而已你完全可以选择其他自己喜欢的方案比如你可以使用innobackupex命令做数据备份和准备它的使用方法几乎与本文的备份方法一样
至此我们也就翻越了第三座大山”,完成了Slave节点第一次启动前的初始化工作
扳倒了这三座大山我们终于可以定义Pod里的主角MySQL容器了有了前面这些定义和初始化工作MySQL容器本身的定义就非常简单了如下所示
...
# template.spec
containers:
- name: mysql
image: mysql:5.7
env:
- name: MYSQL_ALLOW_EMPTY_PASSWORD
value: "1"
ports:
- name: mysql
containerPort: 3306
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
resources:
requests:
cpu: 500m
memory: 1Gi
livenessProbe:
exec:
command: ["mysqladmin", "ping"]
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
exec:
# 通过TCP连接的方式进行健康检查
command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"]
initialDelaySeconds: 5
periodSeconds: 2
timeoutSeconds: 1
在这个容器的定义里我们使用了一个标准的MySQL 5.7 的官方镜像它的数据目录是/var/lib/mysql配置文件目录是/etc/mysql/conf.d
这时候你应该能够明白如果MySQL容器是Slave节点的话它的数据目录里的数据就来自于InitContainer从其他节点里拷贝而来的备份它的配置文件目录/etc/mysql/conf.d里的内容则来自于ConfigMap对应的Volume而它的初始化工作则是由同一个Pod里的sidecar容器完成的这些操作正是我刚刚为你讲述的大部分内容
另外我们为它定义了一个livenessProbe通过mysqladmin ping命令来检查它是否健康还定义了一个readinessProbe通过查询SQLselect 1来检查MySQL服务是否可用当然凡是readinessProbe检查失败的MySQL Pod都会从Service里被摘除掉
至此一个完整的主从复制模式的MySQL集群就定义完了
现在我们就可以使用kubectl命令尝试运行一下这个StatefulSet了
首先我们需要在Kubernetes集群里创建满足条件的PV如果你使用的是我们在第11篇文章从0到1搭建一个完整的Kubernetes集群里部署的Kubernetes集群的话你可以按照如下方式使用存储插件Rook
$ kubectl create -f rook-storage.yaml
$ cat rook-storage.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: rook-ceph-block
provisioner: ceph.rook.io/block
parameters:
pool: replicapool
clusterNamespace: rook-ceph
在这里我用到了StorageClass来完成这个操作它的作用是自动地为集群里存在的每一个PVC调用存储插件Rook创建对应的PV从而省去了我们手动创建PV的机械劳动我在后续讲解容器存储的时候会再详细介绍这个机制
备注在使用Rook的情况下mysql-statefulset.yaml里的volumeClaimTemplates字段需要加上声明storageClassName=rook-ceph-block才能使用到这个Rook提供的持久化存储。
然后我们就可以创建这个StatefulSet了如下所示
$ kubectl create -f mysql-statefulset.yaml
$ kubectl get pod -l app=mysql
NAME READY STATUS RESTARTS AGE
mysql-0 2/2 Running 0 2m
mysql-1 2/2 Running 0 1m
mysql-2 2/2 Running 0 1m
可以看到StatefulSet启动成功后会有三个Pod运行
接下来我们可以尝试向这个MySQL集群发起请求执行一些SQL操作来验证它是否正常
$ kubectl run mysql-client --image=mysql:5.7 -i --rm --restart=Never --\
mysql -h mysql-0.mysql <<EOF
CREATE DATABASE test;
CREATE TABLE test.messages (message VARCHAR(250));
INSERT INTO test.messages VALUES ('hello');
EOF
如上所示我们通过启动一个容器使用MySQL client执行了创建数据库和表以及插入数据的操作需要注意的是我们连接的MySQL的地址必须是mysql-0.mysqlMaster节点的DNS记录)。因为只有Master节点才能处理写操作
而通过连接mysql-read这个Service我们就可以用SQL进行读操作如下所示
$ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
mysql -h mysql-read -e "SELECT * FROM test.messages"
Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
+---------+
| message |
+---------+
| hello |
+---------+
pod "mysql-client" deleted
在有了StatefulSet以后你就可以像Deployment那样非常方便地扩展这个MySQL集群比如
$ kubectl scale statefulset mysql --replicas=5
这时候你就会发现新的Slave Pod mysql-3和mysql-4被自动创建了出来
而如果你像如下所示的这样直接连接mysql-3.mysql即mysql-3这个Pod的DNS名字来进行查询操作
$ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
mysql -h mysql-3.mysql -e "SELECT * FROM test.messages"
Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
+---------+
| message |
+---------+
| hello |
+---------+
pod "mysql-client" deleted
就会看到从StatefulSet为我们新创建的mysql-3上同样可以读取到之前插入的记录也就是说我们的数据备份和恢复都是有效的
总结
在今天这篇文章中我以MySQL集群为例和你详细分享了一个实际的StatefulSet的编写过程这个YAML文件的链接在这里希望你能多花一些时间认真消化
在这个过程中有以下几个关键点特别值得你注意和体会
人格分裂”:在解决需求的过程中一定要记得思考该Pod在扮演不同角色时的不同操作
阅后即焚”:很多有状态应用的节点只是在第一次启动的时候才需要做额外处理所以在编写YAML文件时你一定要考虑容器重启的情况不要让这一次的操作干扰到下一次的容器启动
容器之间平等无序”:除非是InitContainer否则一个Pod里的多个容器之间是完全平等的所以你精心设计的sidecar绝不能对容器的顺序做出假设否则就需要进行前置检查
最后相信你也已经能够理解StatefulSet其实是一种特殊的Deployment只不过这个Deployment的每个Pod实例的名字里都携带了一个唯一并且固定的编号这个编号的顺序固定了Pod的拓扑关系这个编号对应的DNS记录固定了Pod的访问方式这个编号对应的PV绑定了Pod与持久化存储的关系所以当Pod被删除重建时这些状态都会保持不变
而一旦你的应用没办法通过上述方式进行状态的管理那就代表了StatefulSet已经不能解决它的部署问题了这时候我后面讲到的Operator可能才是一个更好的选择
思考题
如果我们现在的需求是所有的读请求只由Slave节点处理所有的写请求只由Master节点处理那么你需要在今天这篇文章的基础上再做哪些改动呢
感谢你的收听欢迎你给我留言也欢迎分享给更多的朋友一起阅读

View File

@@ -0,0 +1,391 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 容器化守护进程的意义DaemonSet
你好我是张磊。今天我和你分享的主题是容器化守护进程的意义之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有如下三个特征
这个Pod运行在Kubernetes集群里的每一个节点Node
每个节点上只有一个这样的Pod实例
当有新的节点加入Kubernetes集群后该Pod会自动地在新节点上被创建出来而当旧节点被删除后它上面的Pod也相应地会被回收掉。
这个机制听起来很简单但Daemon Pod的意义确实是非常重要的。我随便给你列举几个例子
各种网络插件的Agent组件都必须运行在每一个节点上用来处理这个节点上的容器网络
各种存储插件的Agent组件也必须运行在每一个节点上用来在这个节点上挂载远程存储目录操作容器的Volume目录
各种监控组件和日志组件,也必须运行在每一个节点上,负责这个节点上的监控信息和日志搜集。
更重要的是跟其他编排对象不一样DaemonSet开始运行的时机很多时候比整个Kubernetes集群出现的时机都要早。
这个乍一听起来可能有点儿奇怪。但其实你来想一下如果这个DaemonSet正是一个网络插件的Agent组件呢
这个时候整个Kubernetes集群里还没有可用的容器网络所有Worker节点的状态都是NotReadyNetworkReady=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的含义是
requiredDuringSchedulingIgnoredDuringExecution它的意思是说这个nodeAffinity必须在每次调度的时候予以考虑。同时这也意味着你可以设置在某些情况下不考虑这个nodeAffinity
这个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。
这里你也可以再回顾一下第11篇文章《从0到1搭建一个完整的Kubernetes集群》中的相关内容。
至此通过上面这些内容你应该能够明白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对应的是dsDeployment对应的是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被创建了出来。
总结
在今天这篇文章中我首先简单介绍了StatefulSet的“滚动更新”然后重点讲解了本专栏的第三个重要编排对象DaemonSet。
相比于DeploymentDaemonSet只管理Pod对象然后通过nodeAffinity和Toleration这两个调度器的小功能保证了每个节点上有且只有一个Pod。这个控制器的实现原理简单易懂希望你能够快速掌握。
与此同时DaemonSet使用ControllerRevision来保存和管理自己对应的“版本”。这种“面向API对象”的设计思路大大简化了控制器本身的逻辑也正是Kubernetes项目“声明式API”的优势所在。
而且相信聪明的你此时已经想到了StatefulSet也是直接控制Pod对象的那么它是不是也在使用ControllerRevision进行版本管理呢
没错。在Kubernetes项目里ControllerRevision其实是一个通用的版本管理对象。这样Kubernetes项目就巧妙地避免了每种控制器都要维护一套冗余的代码和逻辑的问题。
思考题
我在文中提到在Kubernetes v1.11之前DaemonSet所管理的Pod的调度过程实际上都是由DaemonSet Controller自己而不是由调度器完成的。你能说出这其中有哪些原因吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,510 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 撬动离线业务Job与CronJob
你好我是张磊。今天我和你分享的主题是撬动离线业务之Job与CronJob。
在前面的几篇文章中我和你详细分享了Deployment、StatefulSet以及DaemonSet这三个编排概念。你有没有发现它们的共同之处呢
实际上它们主要编排的对象都是“在线业务”Long Running Task长作业。比如我在前面举例时常用的Nginx、Tomcat以及MySQL等等。这些应用一旦运行起来除非出错或者停止它的容器进程会一直保持在Running状态。
但是有一类作业显然不满足这样的条件这就是“离线业务”或者叫作Batch Job计算业务。这种业务在计算完成后就直接退出了而此时如果你依然用Deployment来管理这种业务的话就会发现Pod会在计算结束后退出然后被Deployment Controller不断地重启而像“滚动更新”这样的编排功能更无从谈起了。
所以早在Borg项目中Google就已经对作业进行了分类处理提出了LRSLong 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字段里定义了重试次数为4backoffLimit=4而这个字段的默认值是6。
需要注意的是Job Controller重新创建Pod的间隔是呈指数增加的即下一次重新创建Pod的动作会分别发生在10 s、20 s、40 s …后。
而如果你定义的restartPolicy=OnFailure那么离线作业失败后Job Controller就不会去尝试创建新的Pod。但是它会不断地尝试重启Pod里的容器。这也正好对应了restartPolicy的含义你也可以借此机会再回顾一下第15篇文章《深入解析Pod对象使用进阶》中的相关内容
如前所述当一个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对象的方法。
第一种用法,也是最简单粗暴的用法:外部管理器+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时我们只要注意如下两个方面即可
创建Job时替换掉$ITEM这样的变量
所有来自于同一个模板的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
第二种用法拥有固定任务数目的并行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处理完成了。所以说这种用法对应的就是“任务总数固定”的场景。
第三种用法也是很常用的一个用法指定并行度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与ReplicaSet的关系一样。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字段来定义具体的处理策略。比如
concurrencyPolicy=Allow这也是默认情况这意味着这些Job可以同时存在
concurrencyPolicy=Forbid这意味着不会创建新的Pod该创建周期被跳过
concurrencyPolicy=Replace这意味着新产生的Job会替换旧的、没有执行完的Job。
而如果某一次Job创建失败这次创建就会被标记为“miss”。当在指定的时间窗口内miss的数目达到100时那么CronJob会停止再创建这个Job。
这个时间窗口可以由spec.startingDeadlineSeconds字段指定。比如startingDeadlineSeconds=200意味着在过去200 s里如果miss的数目达到了100次那么这个Job就不会被创建执行了。
总结
在今天这篇文章中我主要和你分享了Job这个离线业务的编排方法讲解了completions和parallelism字段的含义以及Job Controller的执行原理。
紧接着我通过实例和你分享了Job对象三种常见的使用方法。但是根据我在社区和生产环境中的经验大多数情况下用户还是更倾向于自己控制Job对象。所以相比于这些固定的“模式”掌握Job的API对象和它各个字段的准确含义会更加重要。
最后我还介绍了一种Job的控制器叫作CronJob。这也印证了我在前面的分享中所说的用一个对象控制另一个对象是Kubernetes编排的精髓所在。
思考题
根据Job控制器的工作原理如果你定义的parallelism比completions还大的话比如
parallelism: 4
completions: 2
那么这个Job最开始创建的时候会同时启动几个Pod呢原因是什么
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,430 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 声明式API与Kubernetes编程范式
你好我是张磊。今天我和你分享的主题是声明式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命令你也可以借此机会再回顾一下第12篇文章《牛刀小试我的第一个容器化应用》中的相关内容
现在我就使用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最根本的组件是运行在每一个应用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
我们可以看到这个envoy-initializer使用的envoy-initializer:0.0.1镜像就是一个事先编写好的“自定义控制器”Custom Controller我将会在下一篇文章中讲解它的编写方法。而在这里我要先为你解释一下这个控制器的主要功能。
我曾在第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对象的“增、删、改、查”进行协作进而完成用户业务逻辑的编写过程。
这,也正是我要在后面文章中为你详细讲解的内容。
总结
在今天这篇文章中我为你重点讲解了Kubernetes声明式API的含义。并且通过对Istio项目的剖析我为你说明了它使用Kubernetes的Initializer特性完成Envoy容器“自动注入”的原理。
事实上从“使用Kubernetes部署代码”到“使用Kubernetes编写代码”的蜕变过程正是你从一个Kubernetes用户到Kubernetes玩家的晋级之路。
如何理解“Kubernetes编程范式”如何为Kubernetes添加自定义API对象编写自定义控制器正是这个晋级过程中的关键点也是我要在后面几篇文章中分享的核心内容。
此外基于今天这篇文章所讲述的Istio的工作原理尽管Istio项目一直宣称它可以运行在非Kubernetes环境中但我并不建议你花太多时间去做这个尝试。
毕竟无论是从技术实现还是在社区运作上Istio与Kubernetes项目之间都是紧密的、唇齿相依的关系。如果脱离了Kubernetes项目这个基础那么这条原本就不算平坦的“微服务”之路恐怕会更加困难重重。
思考题
你是否对Envoy项目做过了解你觉得为什么它能够击败Nginx以及HAProxy等竞品成为Service Mesh体系的核心
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,412 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 深入解析声明式APIAPI对象的奥秘
你好我是张磊。今天我和你分享的主题是深入解析声明式API之API对象的奥秘。
在上一篇文章中我为你详细讲解了Kubernetes声明式API的设计、特点以及使用方式。
而在今天这篇文章中我就来为你讲解一下Kubernetes声明式API的工作原理以及如何利用这套API机制在Kubernetes里添加自定义的API对象。
你可能一直就很好奇当我把一个YAML文件提交给Kubernetes之后它究竟是如何创建出一个API对象的呢
这得从声明式API的设计谈起了。
在Kubernetes项目中一个API对象在Etcd里的完整资源路径是由GroupAPI组、VersionAPI版本和ResourceAPI资源类型三个部分组成的。
通过这样的结构整个Kubernetes里的所有API对象实际上就可以用如下的树形结构表示出来
-
在这幅图中你可以很清楚地看到Kubernetes里API对象的组织方式其实是层层递进的。
比如现在我要声明要创建一个CronJob对象那么我的YAML文件的开始部分会这么写
apiVersion: batch/v2alpha1
kind: CronJob
...
在这个YAML文件中“CronJob”就是这个API对象的资源类型Resource“batch”就是它的组Groupv2alpha1就是它的版本Version
当我们提交了这个YAML文件之后Kubernetes就会把这个YAML文件里描述的内容转换成Kubernetes里的一个CronJob对象。
那么Kubernetes是如何对Resource、Group和Version进行解析从而在Kubernetes项目里找到CronJob对象的定义呢
首先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。
然后Kubernetes会进一步匹配到API对象的版本号。
对于CronJob这个API对象来说Kubernetes在batch这个Group下匹配到的版本号就是v2alpha1。
在Kubernetes中同一种API对象可以有多个版本这正是Kubernetes进行API版本化管理的重要手段。这样比如在CronJob的开发过程中对于会影响到用户的变更就可以通过升级新版本来处理从而保证了向后兼容。
最后Kubernetes会匹配API对象的资源类型。
在前面匹配到正确的版本之后Kubernetes就知道我要创建的原来是一个/apis/batch/v2alpha1下的CronJob对象。
这时候APIServer就可以继续创建这个CronJob对象了。为了方便理解我为你总结了一个如下所示流程图来阐述这个创建过程
-
首先当我们发起了创建CronJob的POST请求之后我们编写的YAML的信息就被提交给了APIServer。
而APIServer的第一个功能就是过滤这个请求并完成一些前置性的工作比如授权、超时处理、审计等。
然后请求会进入MUX和Routes流程。如果你编写过Web Server的话就会知道MUX和Routes是APIServer完成URL和Handler绑定的场所。而APIServer的Handler要做的事情就是按照我刚刚介绍的匹配过程找到对应的CronJob类型定义。
接着APIServer最重要的职责就来了根据这个CronJob类型定义使用用户提交的YAML文件里的字段创建一个CronJob对象。
而在这个过程中APIServer会进行一个Convert工作把用户提交的YAML文件转换成一个叫作Super Version的对象它正是该API资源类型所有版本的字段全集。这样用户提交的不同版本的YAML文件就都可以用这个Super Version对象来进行处理了。
接下来APIServer会先后进行Admission()和Validation()操作。比如我在上一篇文章中提到的Admission Controller和Initializer就都属于Admission的内容。
而Validation则负责验证这个对象里的各个字段是否合法。这个被验证过的API对象都保存在了APIServer里一个叫作Registry的数据结构中。也就是说只要一个API对象的定义能在Registry里查到它就是一个有效的Kubernetes API对象。
最后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资源类型是NetworkAPI组是samplecrd.k8s.ioAPI 版本是v1。
那么Kubernetes又该如何知道这个APIsamplecrd.k8s.io/v1/network的存在呢
其实上面的这个YAML文件就是一个具体的“自定义API资源”实例也叫CRCustom Resource。而为了能够让Kubernetes认识这个CR你就需要让Kubernetes明白这个CR的宏观定义是什么也就是CRDCustom 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”网关这些字段的含义。这就相当于我要告诉计算机“兔子有长耳朵和三瓣嘴”。
这时候呢,我就需要稍微做些代码工作了。
首先我要在GOPATH下创建一个结构如下的项目
备注在这里我并不要求你具有完备的Go语言知识体系但我会假设你已经了解了Golang的一些基本知识比如知道什么是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上你可以随时参考。
然后我在pkg/apis/samplecrd目录下创建了一个register.go文件用来放置后面要用到的全局变量。这个文件的内容如下所示
package samplecrd
const (
GroupName = "samplecrd.k8s.io"
Version = "v1"
)
接着我需要在pkg/apis/samplecrd目录下添加一个doc.go文件Golang的文档源文件。这个文件里的内容如下所示
// +k8s:deepcopy-gen=package
// +groupName=samplecrd.k8s.io
package v1
在这个文件中,你会看到+[=value]格式的注释这就是Kubernetes进行代码生成要用的Annotation风格的注释。
其中,+k8s:deepcopy-gen=package意思是请为整个v1包里的所有类型定义自动生成DeepCopy方法而+groupName=samplecrd.k8s.io则定义了这个包对应的API组的名字。
可以看到这些定义在doc.go文件的注释起到的是全局的代码生成控制的作用所以也被称为Global Tags。
接下来我需要添加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对象一样都包括了TypeMetaAPI元数据和ObjectMeta对象元数据字段。
而其中的Spec字段就是需要我们自己定义的部分。所以在networkspec里我定义了Cidr和Gateway两个字段。其中每个字段最后面的部分比如json:"cidr"指的就是这个字段被转换成JSON格式之后的名字也就是YAML文件里的字段名字。
如果你不熟悉这个用法的话可以查阅一下Golang的文档。
此外除了定义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的代码生成语法。
最后我需要再编写一个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对象的定义工作就全部完成了。可以看到它其实定义了两部分内容
第一部分是自定义资源类型的API描述包括Group、版本Version、资源类型Resource等。这相当于告诉了计算机兔子是哺乳动物。
第二部分是自定义资源类型的对象描述包括Spec、Status等。这相当于告诉了计算机兔子有长耳朵和三瓣嘴。
接下来我就要使用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对象了。我们不妨一起来试验下。
首先使用network.yaml文件在Kubernetes中创建Network对象的CRDCustom 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
然后我们就可以创建一个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的操作没有任何区别。
总结
在今天这篇文章中我为你详细解析了Kubernetes声明式API的工作原理讲解了如何遵循声明式API的设计为Kubernetes添加一个名叫Network的API资源类型。从而达到了通过标准的kubectl create和get操作来管理自定义API对象的目的。
不过创建出这样一个自定义API对象我们只是完成了Kubernetes声明式API的一半工作。
接下来的另一半工作是为这个API对象编写一个自定义控制器Custom Controller。这样 Kubernetes才能根据Network API对象的“增、删、改”操作在真实环境中做出相应的响应。比如“创建、删除、修改”真正的Neutron网络。
而这正是Network这个API对象所关注的“业务逻辑”。
这个业务逻辑的实现过程以及它所使用的Kubernetes API编程库的工作原理就是我要在下一篇文章中讲解的主要内容。
思考题
在了解了CRD的定义方法之后你是否已经在考虑使用CRD或者已经使用了CRD来描述现实中的某种实体了呢能否分享一下你的思路举个例子某技术团队使用CRD描述了“宿主机”然后用Kubernetes部署了Kubernetes
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,474 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 深入解析声明式API编写自定义控制器
你好我是张磊。今天我和你分享的主题是深入解析声明式API之编写自定义控制器。
在上一篇文章中我和你详细分享了Kubernetes中声明式API的实现原理并且通过一个添加Network对象的实例为你讲述了在Kubernetes里添加API资源的过程。
在今天的这篇文章中我就继续和你一起完成剩下一半的工作为Network这个自定义API对象编写一个自定义控制器Custom Controller
正如我在上一篇文章结尾处提到的“声明式API”并不像“命令式API”那样有着明显的执行逻辑。这就使得基于声明式API的业务功能实现往往需要通过控制器模式来“监视”API对象的变化比如创建或者删除Network然后以此来决定实际要执行的具体工作。
接下来我就和你一起通过编写代码来实现这个过程。这个项目和上一篇文章里的代码是同一个项目你可以从这个GitHub库里找到它们。我在代码里还加上了丰富的注释你可以随时参考。
总得来说编写自定义控制器代码的过程包括编写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函数主要通过三步完成了初始化并启动一个自定义控制器的工作。
第一步main函数根据我提供的Master配置APIServer的地址端口和kubeconfig的路径创建一个Kubernetes的clientkubeClient和Network对象的clientnetworkClient
但是如果我没有提供Master配置呢
这时main函数会直接使用一种名叫InClusterConfig的方式来创建这个client。这个方式会假设你的自定义控制器是以Pod的方式运行在Kubernetes集群里的。
而我在第15篇文章《深入解析Pod对象使用进阶》中曾经提到过Kubernetes 里所有的Pod都会以Volume的方式自动挂载Kubernetes的默认ServiceAccount。所以这个控制器就会直接使用默认ServiceAccount数据卷里的授权信息来访问APIServer。
第二步main函数为Network对象创建一个叫作InformerFactorynetworkInformerFactory的工厂并使用它生成一个Network对象的Informer传递给控制器。
第三步main函数启动上述的Informer然后执行controller.Run启动自定义控制器。
至此main函数就结束了。
看到这你可能会感到非常困惑编写自定义控制器的过程难道就这么简单吗这个Informer又是个什么东西呢
别着急。
接下来,我就为你详细解释一下这个自定义控制器的工作原理。
在Kubernetes项目中一个自定义控制器的工作原理可以用下面这样一幅流程图来表示在后面的叙述中我会用“示意图”来指代它
图1 自定义控制器的工作流程示意图
我们先从这幅示意图的最左边看起。
这个控制器要做的第一件事是从Kubernetes的APIServer里获取它所关心的对象也就是我定义的Network对象。
这个操作依靠的是一个叫作Informer可以翻译为通知器的代码库完成的。Informer与API对象是一一对应的所以我传递给自定义控制器的正是一个Network对象的InformerNetwork 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的第一个职责也是它最重要的职责。
而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函数里创建了两个clientkubeclientset和networkclientset然后在这段代码里使用这两个client和前面创建的Informer初始化了自定义控制器。
值得注意的是在这个自定义控制器里我还设置了一个工作队列work queue它正是处于示意图中间位置的WorkQueue。这个工作队列的作用是负责同步Informer和控制循环之间的数据。
实际上Kubernetes项目为我们提供了很多个工作队列的实现你可以根据需要选择合适的库直接使用。
然后我为networkInformer注册了三个HandlerAddFunc、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了一个成员也就是一个KeyNetwork对象的namespace/name)。
然后在syncHandler方法中我使用这个Key尝试从Informer维护的缓存中拿到了它所对应的Network对象
可以看到在这里我使用了networksLister来尝试获取这个Key对应的Network对象这个操作其实就是在访问本地缓存的索引实际上在Kubernetes的源码中你会经常看到控制器从各种Lister里获取对象比如podListernodeLister等等它们使用的都是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里获取”(ListNetwork对象时并不能找到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的clientkubeClient创建了一个工厂
然后我用跟Network类似的处理方法生成了一个Deployment Informer
接着我把Deployment Informer传递给了自定义控制器当然我也要调用Start方法来启动这个Deployment Informer
而有了这个Deployment Informer后这个控制器也就持有了所有Deployment对象的信息接下来它既可以通过deploymentInformer.Lister()来获取Etcd里的所有Deployment对象也可以为这个Deployment Informer注册具体的Handler来
更重要的是这就使得在这个自定义控制器里面我可以通过对自定义API对象和默认API对象进行协同从而实现更加复杂的编排功能
比如用户每创建一个新的Deployment这个自定义控制器就可以为它创建一个对应的Network供它使用
这些对Kubernetes API编程范式的更高级应用我就留给你在实际的场景中去探索和实践了
总结
在今天这篇文章中我为你剖析了Kubernetes API编程范式的具体原理并编写了一个自定义控制器
这其中有如下几个概念和机制是你一定要理解清楚的
所谓的Informer就是一个自带缓存和索引机制可以触发Handler的客户端库这个本地缓存在Kubernetes中一般被称为Store索引一般被称为Index
Informer使用了Reflector包它是一个可以通过ListAndWatch机制获取并监视API对象变化的客户端封装
Reflector和Informer之间用到了一个增量先进先出队列进行协同而Informer与你要编写的控制循环之间则使用了一个工作队列来进行协同
在实际应用中除了控制循环之外的所有代码实际上都是Kubernetes为你自动生成的pkg/client/{informers, listers, clientset}里的内容
而这些自动生成的代码就为我们提供了一个可靠而高效地获取API对象期望状态的编程库
所以接下来作为开发者你就只需要关注如何拿到实际状态”,然后如何拿它去跟期望状态做对比从而决定接下来要做的业务逻辑即可
以上内容就是Kubernetes API编程范式的核心思想
思考题
请思考一下为什么Informer和你编写的控制循环之间一定要使用一个工作队列来进行协作呢
感谢你的收听欢迎你给我留言也欢迎分享给更多的朋友一起阅读

View File

@@ -0,0 +1,387 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 基于角色的权限控制RBAC
你好我是张磊。今天我和你分享的主题是基于角色的权限控制之RBAC。
在前面的文章中我已经为你讲解了很多种Kubernetes内置的编排对象以及对应的控制器模式的实现原理。此外我还剖析了自定义API资源类型和控制器的编写方式。
这时候,你可能已经冒出了这样一个想法:控制器模式看起来好像也不难嘛,我能不能自己写一个编排对象呢?
答案当然是可以的。而且这才是Kubernetes项目最具吸引力的地方。
毕竟在互联网级别的大规模集群里Kubernetes内置的编排对象很难做到完全满足所有需求。所以很多实际的容器化工作都会要求你设计一个自己的编排对象实现自己的控制器模式。
而在Kubernetes项目里我们可以基于插件机制来完成这些工作而完全不需要修改任何一行代码。
不过你要通过一个外部插件在Kubernetes里新增和操作API对象那么就必须先了解一个非常重要的知识RBAC。
我们知道Kubernetes中所有的API对象都保存在Etcd里。可是对这些API对象的操作却一定都是通过访问kube-apiserver实现的。其中一个非常重要的原因就是你需要APIServer来帮助你做授权工作。
而在Kubernetes项目中负责完成授权Authorization工作的机制就是RBAC基于角色的访问控制Role-Based Access Control
如果你直接查看Kubernetes项目中关于RBAC的文档的话可能会感觉非常复杂。但实际上等到你用到这些RBAC的细节时再去查阅也不迟。
而在这里,我只希望你能明确三个最基本的概念。
Role角色它其实是一组规则定义了一组对Kubernetes API对象的操作权限。
Subject被作用者既可以是“人”也可以是“机器”也可以是你在Kubernetes里定义的“用户”。
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那就是使用的是默认Namespacedefault。
然后这个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对象。
那么对于非NamespacedNon-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"]
这些就是当前Kubernetesv1.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的名字是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没有声明serviceAccountNameKubernetes会自动在它的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。这个具体怎么做就当作思考题留给你了。
除了前面使用的“用户”UserKubernetes还拥有“用户组”Group的概念也就是一组“用户”的意思。如果你为Kubernetes配置了外部认证服务的话这个“用户组”的概念就会由外部认证服务提供。
而对于Kubernetes的内置“用户”ServiceAccount来说上述“用户组”的概念也同样适用。
实际上一个ServiceAccount在Kubernetes里对应的“用户”的名字是
system:serviceaccount:<Namespace名字>:<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-schedulerKubernetes的调度器组件运行所需要的必要权限。你可以通过如下指令查看这些权限的列表
$ 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来供用户直接使用
cluster-admin
admin
edit
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。
总结
在今天这篇文章中我主要为你讲解了基于角色的访问控制RBAC
其实你现在已经能够理解所谓角色Role其实就是一组权限规则列表。而我们分配这些权限的方式就是通过创建RoleBinding对象将被作用者subject和权限列表进行绑定。
另外与之对应的ClusterRole和ClusterRoleBinding则是Kubernetes集群级别的Role和RoleBinding它们的作用范围不受Namespace限制。
而尽管权限的被作用者可以有很多种比如User、Group等但在我们平常的使用中最普遍的用法还是ServiceAccount。所以Role + RoleBinding + ServiceAccount的权限分配方式是你要重点掌握的内容。我们在后面编写和安装各种插件的时候会经常用到这个组合。
思考题
请问如何为所有Namespace下的默认ServiceAccountdefault ServiceAccount绑定一个只读权限的Role呢请你提供ClusterRoleBinding或者RoleBinding的YAML文件。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,545 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 聪明的微创新Operator工作原理解读
你好我是张磊。今天我和你分享的主题是聪明的微创新之Operator工作原理解读。
在前面的几篇文章中我已经和你分享了Kubernetes项目中的大部分编排对象比如Deployment、StatefulSet、DaemonSet以及Job也介绍了“有状态应用”的管理方法还阐述了为Kubernetes添加自定义API对象和编写自定义控制器的原理和流程。
可能你已经感觉到在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定义了如下所示的权限
对Pod、Service、PVC、Deployment、Secret等API对象有所有权限
对CRD对象有所有权限
对属于etcd.database.coreos.com这个API Group的CRCustom 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 ResourceCR。而它的内容非常简单如下所示
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里的其他字段只关注SizeEtcd集群的大小字段即可。
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提一个patch来修复这个问题。提PR修TODO是给一个开源项目做有意义的贡献的一个重要方式。
由于Etcd Operator里没有工作队列那么在它的EventHandler部分就不会有什么入队操作而直接就是每种事件对应的具体的业务逻辑了。
不过Etcd Operator在业务逻辑的实现方式上与常规的自定义控制器略有不同。我把在这一部分的工作原理提炼成了一个详细的流程图如下所示
可以看到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-clusterEtcd Operator只会使用Pod的DNS记录而不是它的IP地址。
这当然是因为在Operator生成上述启动命令的时候Etcd的Pod还没有被创建出来它的IP地址自然也无从谈起。
这也就意味着每个Cluster对象都会事先创建一个与该EtcdCluster同名的Headless Service。这样Etcd Operator在接下来的所有创建Pod的步骤里就都可以使用Pod的DNS记录来代替它的IP地址了。
备注Headless Service的DNS记录格式是…svc.cluster.local。如果你记不太清楚了可以借此再回顾一下第18篇文章《深入理解StatefulSet拓扑状态》中的相关内容。
Cluster对象的第二个工作则是启动该集群所对应的控制循环。
这个控制循环每隔一定时间就会执行一次下面的Diff流程。
首先控制循环要获取到所有正在运行的、属于这个Cluster的Pod数量也就是该Etcd集群的“实际状态”。
而这个Etcd集群的“期望状态”正是用户在EtcdCluster对象里定义的size。
所以接下来,控制循环会对比这两个状态的差异。
如果实际的Pod数量不够那么控制循环就会执行一个添加成员节点的操作上述流程图中的addOneMember方法反之就执行删除成员节点的操作上述流程图中的removeOneMember方法
以addOneMember方法为例它执行的流程如下所示
生成一个新节点的Pod的名字比如example-etcd-cluster-v6v6s6stxd
调用Etcd Client执行前面提到过的etcdctl member add example-etcd-cluster-v6v6s6stxd命令
使用这个Pod名字和已经存在的所有节点列表组合成一个新的initial-cluster字段的值
使用这个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的话你可能会有两个问题。
第一个问题是在StatefulSet里它为Pod创建的名字是带编号的这样就把整个集群的拓扑状态固定了下来比如一个三节点的集群一定是由名叫web-0、web-1和web-2的三个Pod组成。可是在Etcd Operator里为什么我们使用随机名字就可以了呢
这是因为Etcd Operator在每次添加Etcd节点的时候都会先执行etcdctl member add 每次删除节点的时候则会执行etcdctl member remove 。这些操作其实就会更新Etcd内部维护的拓扑信息所以Etcd Operator无需在集群外部通过编号来固定这个拓扑关系。
第二个问题是为什么我没有在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的实现方式非常类似。所以这一部分就交给你课后去探索了。
总结
在今天这篇文章中我以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的框架代码。感兴趣的话你可以试用一下。
思考题
在Operator的实现过程中我们再一次用到了CRD。可是你一定要明白CRD并不是万能的它有很多场景不适用还有性能瓶颈。你能列举出一些不适用CRD的场景么你知道造成CRD性能瓶颈的原因主要在哪里么
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,373 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 PV、PVC、StorageClass这些到底在说啥
你好我是张磊。今天我和你分享的主题是PV、PVC、StorageClass这些到底在说啥
在前面的文章中我重点为你分析了Kubernetes的各种编排能力。
在这些讲解中,你应该已经发现,容器化一个应用比较麻烦的地方,莫过于对其“状态”的管理。而最常见的“状态”,又莫过于存储状态了。
所以从今天这篇文章开始我会通过4篇文章为你剖析Kubernetes项目处理容器持久化存储的核心原理从而帮助你更好地理解和使用这部分内容。
首先我们来回忆一下我在第19篇文章《深入理解StatefulSet存储状态》中和你分享StatefulSet如何管理存储状态的时候介绍过的Persistent VolumePV和Persistent Volume ClaimPVC这套持久化存储体系。
其中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 DiskGCE提供的远程磁盘服务那么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分别给这两个阶段提供了两种不同的参数列表
对于“第一阶段”AttachKubernetes提供的可用参数是nodeName即宿主机的名字。
而对于“第二阶段”MountKubernetes提供的可用参数是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进行绑定。
总结
在今天的分享中我为你详细解释了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命令的诞生时间还要早得多。
思考题
在了解了PV、PVC的设计和实现原理之后你是否依然觉得它有“过度设计”的嫌疑或者你是否有更加简单、足以解决你90%需求的Volume的用法
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,334 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 PV、PVC体系是不是多此一举从本地持久化卷谈起
你好我是张磊。今天我和你分享的主题是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的设计主要面临两个难点。
第一个难点在于如何把本地磁盘抽象成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一块盘”。
第二个难点在于调度器如何保证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
然后登录到node-1这台机器上查看一下它的 /mnt/disks/vol1目录下的内容你就可以看到刚刚创建的这个文件
# 在node-1上
$ ls /mnt/disks/vol1
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时需要按如下流程执行操作
删除使用这个PV的Pod
从宿主机移除本地磁盘比如umount它
删除PVC
删除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它的部署方法在对应的文档里有详细描述。这部分内容就留给你课后自行探索了。
总结
在今天这篇文章中我为你详细介绍了Kubernetes里Local Persistent Volume的实现方式。
可以看到正是通过PV和PVC以及StorageClass这套存储体系这个后来新添加的持久化存储方案对Kubernetes已有用户的影响几乎可以忽略不计。作为用户你的Pod的YAML和PVC的YAML并没有任何特殊的改变这个特性所有的实现只会影响到PV的处理也就是由运维人员负责的那部分工作。
而这,正是这套存储体系带来的“解耦”的好处。
其实Kubernetes很多看起来比较“繁琐”的设计比如“声明式API”以及我今天讲解的“PV、PVC体系”的主要目的都是希望为开发者提供更多的“可扩展性”给使用者带来更多的“稳定性”和“安全感”。这两个能力的高低是衡量开源基础设施项目水平的重要标准。
思考题
正是由于需要使用“延迟绑定”这个特性Local Persistent Volume目前还不能支持Dynamic Provisioning。你是否能说出为什么“延迟绑定”会跟Dynamic Provisioning有冲突呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,316 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 编写自己的存储插件FlexVolume与CSI
你好我是张磊。今天我和你分享的主题是编写自己的存储插件之FlexVolume与CSI。
在上一篇文章中我为你详细介绍了Kubernetes里的持久化存储体系讲解了PV和PVC的具体实现原理并提到了这样的设计实际上是出于对整个存储体系的可扩展性的考虑。
而在今天这篇文章中,我就和你分享一下如何借助这些机制,来开发自己的存储插件。
在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=$1。而你在PV的options字段里定义的NFS的服务器地址options.server和共享目录名字options.share则可以从第二个<json params>参数里解析出来。这里我们使用了jq命令来进行解析工作。
有了这三个参数之后这个脚本最关键的一步当然就是执行mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH} 。这样一个NFS的数据卷就被挂载到了MNTPATH也就是Volume所在的宿主机目录上一个持久化的Volume目录就处理完了。
需要注意的是当这个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 InterfaceCSI这样更完善、更编程友好的插件方式。
接下来我就来为你讲解一下开发存储插件的第二种方式CSI。我们先来看一下CSI插件体系的设计原理。
其实通过前面对FlexVolume的讲述你应该可以明白默认情况下Kubernetes里通过存储插件管理容器持久化存储的原理可以用如下所示的示意图来描述
-
可以看到在上述体系下无论是FlexVolume还是Kubernetes内置的其他存储插件它们实际上担任的角色仅仅是Volume管理中的“Attach阶段”和“Mount阶段”的具体执行者。而像Dynamic Provisioning这样的功能就不是存储插件的责任而是Kubernetes本身存储管理功能的一部分。
相比之下CSI插件体系的设计思想就是把这个Provision阶段以及Kubernetes里的一部分存储管理功能从主干代码里剥离出来做成了几个单独的组件。这些组件会通过Watch API监听Kubernetes里与存储相关的事件变化比如PVC的创建来执行具体的存储管理动作。
而这些管理动作比如“Attach阶段”和“Mount阶段”的具体操作实际上就是通过调用CSI插件来完成的。
这种设计思路,我可以用如下所示的一幅示意图来表示:-
-
可以看到这套存储插件体系多了三个独立的外部组件External ComponentsDriver Registrar、External Provisioner和External Attacher对应的正是从Kubernetes项目里面剥离出来的那部分存储管理功能。
需要注意的是External Components虽然是外部组件但依然由Kubernetes社区来开发和维护。
而图中最右侧的部分就是需要我们编写代码来实现的CSI插件。一个CSI插件只有一个二进制文件但它会以gRPC的方式对外提供三个服务gRPC Service分别叫作CSI Identity、CSI Controller和CSI Node。
我先来为你讲解一下这三个External Components。
其中Driver Registrar组件负责将插件注册到kubelet里面这可以类比为将可执行文件放在插件目录下。而在具体实现上Driver Registrar需要请求CSI插件的Identity服务来获取插件信息。
而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请你务必区分清楚。
最后一个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。
其中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) {}
}
而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进行协作。
而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两个接口共同实现的。我会在下一篇文章中为你详细介绍这个设计的目的和具体的实现方式。
总结
在本篇文章里我为你详细讲解了FlexVolume和CSI这两种自定义存储插件的工作原理。
可以看到相比于FlexVolumeCSI的设计思想把插件的职责从“两阶段处理”扩展成了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插件最基本的工作原理了。
在下一篇文章里我会和你一起实践一个CSI存储插件的完整实现过程。
思考题
假设现在你的宿主机是阿里云的一台虚拟机你要实现的容器持久化存储是基于阿里云提供的云盘。你能准确地描述出在Provision、Attach和Mount阶段CSI插件都需要做哪些操作吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,496 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 容器存储实践CSI插件编写指南
你好我是张磊。今天我和你分享的主题是容器存储实践之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
有了这个StorageClassExternal 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插件对应的StorageClassdo-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的内容我就不再赘述了。
总结
在今天这篇文章中我以一个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的创建和挂载流程就结束了。
思考题
请你根据编写FlexVolume和CSI插件的流程分析一下什么时候该使用FlexVolume什么时候应该使用CSI
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,212 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
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地址。
备注ARPAddress 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地址的对应表里查到对应的端口Portvethb4963f3然后把数据包发往这个端口。
而这个端口正是nginx-2容器“插”在docker0网桥上的另一块虚拟网卡当然它也是一个Veth Pair设备。这样数据包就进入到了nginx-2容器的Network Namespace里。
所以nginx-2容器看到的情况是它自己的eth0网卡上出现了流入的数据包。这样nginx-2的网络协议栈就会对请求进行处理最后将响应Pong返回到nginx-1。
以上就是同一个宿主机上的不同容器通过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的相关知识进行实践从而验证我和你分享的数据包传递流程。
熟悉了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网桥没有任何关联它们互相之间也没办法连通。所以连接在这些网桥上的容器自然也没办法进行通信了。
不过,万变不离其宗。
如果我们通过软件的方式,创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到这个网桥上,不就可以相互通信了吗?
说得没错。
这样一来,我们整个集群里的容器网络就会类似于下图所示的样子:
-
可以看到构建这种容器网络的核心在于我们需要在已有的宿主机网络上再通过软件构建一个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以这种技术就被称为Overlay Network覆盖网络
而这个Overlay Network本身可以由每台宿主机上的一个“特殊网桥”共同组成。比如当Node 1上的Container 1要访问Node 2上的Container 3的时候Node 1上的“特殊网桥”在收到数据包之后能够通过某种方式把数据包发送到正确的宿主机比如Node 2上。而Node 2上的“特殊网桥”在收到数据包后也能够通过某种方式把数据包转发给正确的容器比如Container 3。
甚至,每台宿主机上,都不需要有一个这种特殊的网桥,而仅仅通过某种方式配置宿主机的路由表,就能够把数据包转发到正确的宿主机上。这些内容,我在后面的文章中会为你一一讲述。
总结
在今天这篇文章中我主要为你介绍了在本地环境下单机容器网络的实现原理和docker0网桥的作用。
这里的关键在于容器要想跟外界进行通信它发出的IP包就必须从它的Network Namespace里出来来到宿主机上。
而解决这个问题的方法就是为容器创建一个一端在容器里充当默认网卡、另一端在宿主机上的Veth Pair设备。
上述单机容器网络的知识,是后面我们讲解多机容器网络的重要基础,请务必认真消化理解。
思考题
尽管容器的Host Network模式有一些缺点但是它性能好、配置简单并且易于调试所以很多团队会直接使用Host Network。那么如果要在生产环境中使用容器的Host Network模式你觉得需要做哪些额外的准备工作呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,282 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 深入解析容器跨主机网络
你好,我是张磊。今天我和你分享的主题是:深入解析容器跨主机网络。
在上一篇文章中我为你详细讲解了在单机环境下Linux容器网络的实现原理网桥模式。并且提到了在Docker的默认配置下不同宿主机上的容器通过IP地址进行互相访问是根本做不到的。
而正是为了解决这个容器“跨主通信”的问题,社区里才出现了那么多的容器网络方案。而且,相信你一直以来都有这样的疑问:这些网络方案的工作原理到底是什么?
要理解容器“跨主通信”的原理就一定要先从Flannel这个项目说起。
Flannel项目是CoreOS公司主推的容器网络方案。事实上Flannel项目本身只是一个框架真正为我们提供容器网络功能的是Flannel的后端实现。目前Flannel支持三种后端实现分别是
VXLAN
host-gw
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/24container-1的IP地址是100.96.1.2。Node 2的子网是100.96.2.0/24container-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模式的跨主通信的基本原理了。我把它总结成了一幅原理图如下所示。
可以看到Flannel UDP模式提供的其实是一个三层的Overlay网络它首先对发出端的IP包进行UDP封装然后在接收端进行解封装拿到原始的IP包进而把这个IP包转发给目标容器。这就好比Flannel在不同宿主机上的两个容器之间打通了一条“隧道”使得这两个容器可以直接使用IP地址进行通信而无需关心容器和宿主机的分布情况。
我前面曾经提到上述UDP模式有严重的性能问题所以已经被废弃了。通过我上面的讲述你有没有发现性能问题出现在了哪里呢
实际上相比于两台宿主机之间的直接通信基于Flannel UDP模式的容器通信多了一个额外的步骤即flanneld的处理过程。而这个过程由于使用到了flannel0这个TUN设备仅在发出IP包的过程中就需要经过三次用户态与内核态之间的数据拷贝如下所示
我们可以看到:
第一次用户态的容器进程发出的IP包经过docker0网桥进入内核态
第二次IP包根据路由表进入TUNflannel0设备从而回到用户态的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会在宿主机上设置一个特殊的网络设备作为“隧道”的两端。这个设备就叫作VTEPVXLAN Tunnel End Point虚拟隧道端点
而VTEP设备的作用其实跟前面的flanneld进程非常相似。只不过它进行封装和解封装的对象是二层数据帧Ethernet frame而且这个工作的执行流程全部是在内核里完成的因为VXLAN本身就是Linux内核中的一个模块
上述基于VTEP设备进行“隧道”通信的流程我也为你总结成了一幅图如下所示
可以看到图中每台宿主机上名叫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地址这正是ARPAddress 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内核就可以开始二层封包工作了。这个二层帧的格式如下所示
可以看到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内核里面“网桥”设备进行转发的依据来自于一个叫作FDBForwarding 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 2UDP包要发往的目的地就找到了。
所以接下来的流程,就是一个正常的、宿主机网络上的封包工作。
我们知道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维护。这时候我们封装出来的“外部数据帧”的格式如下所示
这样,封包工作就宣告完成了。
接下来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模式的具体工作原理了。
总结
在本篇文章中我为你详细讲解了Flannel UDP和VXLAN模式的工作原理。这两种模式其实都可以称作“隧道”机制也是很多其他容器网络插件的基础。比如Weave的两种模式以及Docker的Overlay模式。
此外从上面的讲解中我们可以看到VXLAN模式组建的覆盖网络其实就是一个由不同宿主机上的VTEP设备也就是flannel.1设备组成的虚拟二层网络。对于VTEP设备来说它发出的“内部数据帧”就仿佛是一直在这个虚拟的二层网络上流动。这也正是覆盖网络的含义。
备注如果你想要在我们前面部署的集群中实践Flannel的话可以在Master节点上执行如下命令来替换网络插件。-
第一步,执行$ rm -rf /etc/cni/net.d/*-
第二步,执行$ kubectl delete -f "https://cloud.weave.works/k8s/net?k8s-version=1.11"-
第三步,在/etc/kubernetes/manifests/kube-controller-manager.yaml里为容器启动命令添加如下两个参数-
--allocate-node-cidrs=true-
--cluster-cidr=10.244.0.0/16-
第四步, 重启所有kubelet-
第五步, 执行$ kubectl create -f https://raw.githubusercontent.com/coreos/flannel/bc79dd1505b0c8681ece4de4c0d86c5cd2643275/Documentation/kube-flannel.yml。
思考题
可以看到Flannel通过上述的“隧道”机制实现了容器之间三层网络IP地址的连通性。但是根据这个机制的工作原理你认为Flannel能负责保证二层网络MAC地址的连通性吗为什么呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,328 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 Kubernetes网络模型与CNI网络插件
你好我是张磊。今天我和你分享的主题是Kubernetes网络模型与CNI网络插件。
在上一篇文章中我以Flannel项目为例为你详细讲解了容器跨主机网络的两种实现方法UDP和VXLAN。
不难看到这些例子有一个共性那就是用户的容器都连接在docker0网桥上。而网络插件则在宿主机上创建了一个特殊的设备UDP模式创建的是TUN设备VXLAN模式创建的则是VTEP设备docker0与这个设备之间通过IP转发路由表进行协作。
然后,网络插件真正要做的事情,则是通过某种方法,把不同宿主机上的特殊设备连通,从而达到容器跨主机通信的目的。
实际上上面这个流程也正是Kubernetes对容器网络的主要处理方法。只不过Kubernetes是通过一个叫作CNI的接口维护了一个单独的网桥来代替docker0。这个网桥的名字就叫作CNI网桥它在宿主机上的设备名称默认是cni0。
以Flannel的VXLAN模式为例在Kubernetes环境里它的工作方式跟我们在上一篇文章中讲解的没有任何不同。只不过docker0网桥被替换成了CNI网桥而已如下所示
在这里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的基础可执行文件按照功能可以分为三类
第一类叫作Main插件它是用来创建具体网络设备的二进制文件。比如bridge网桥设备、ipvlan、loopbacklo设备、macvlan、ptpVeth Pair设备以及vlan。
我在前面提到过的Flannel、Weave等项目都属于“网桥”类型的CNI插件。所以在具体的实现中它们往往会调用bridge这个二进制文件。这个流程我马上就会详细介绍到。
第二类叫作IPAMIP Address Management插件它是负责分配IP地址的二进制文件。比如dhcp这个文件会向DHCP服务器发起请求host-local则会使用预先配置的IP地址段来进行分配。
第三类是由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主干代码里执行而是会在具体的CRIContainer 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操作需要的参数包括容器里网卡的名字eth0CNI_IFNAME、Pod的Network Namespace文件的路径CNI_NETNS、容器的IDCNI_CONTAINERID等。这些参数都属于上述环境变量里的内容。其中PodInfra容器的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网络”的操作流程以及网络方案本身的工作原理就都不太一样了。我将会在后续文章中继续为你分析这部分内容。
总结
在本篇文章中我为你详细讲解了Kubernetes中CNI网络的实现原理。根据这个原理你其实就很容易理解所谓的“Kubernetes网络模型”了
所有容器都可以直接使用IP地址与其他容器通信而无需使用NAT。
所有宿主机都可以直接使用IP地址与所有容器通信而无需使用NAT。反之亦然。
容器自己“看到”的自己的IP地址和别人宿主机或者容器看到的地址是完全一样的。
可以看到,这个网络模型,其实可以用一个字总结,那就是“通”。
容器与容器之间要“通”容器与宿主机之间也要“通”。并且Kubernetes要求这个“通”还必须是直接基于容器和宿主机的IP地址来进行的。
当然,考虑到不同用户之间的隔离性,在很多场合下,我们还要求容器之间的网络“不通”。这个问题,我会在后面的文章中会为你解决。
思考题
请你思考一下为什么Kubernetes项目不自己实现容器网络而是要通过 CNI 做一个如此简单的假设呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,276 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 解读Kubernetes三层网络方案
你好我是张磊。今天我和你分享的主题是解读Kubernetes三层网络方案。
在上一篇文章中我以网桥类型的Flannel插件为例为你讲解了Kubernetes里容器网络和CNI插件的主要工作原理。不过除了这种模式之外还有一种纯三层Pure Layer 3网络方案非常值得你注意。其中的典型例子莫过于Flannel的host-gw模式和Calico项目了。
我们先来看一下Flannel的host-gw模式。
它的工作原理非常简单我用一张图就可以和你说清楚。为了方便叙述接下来我会称这张图为“host-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.3via 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简称为ASAS 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项目的架构就非常容易理解了。它由三个部分组成
Calico的CNI插件。这是Calico与Kubernetes对接的部分。我已经在上一篇文章中和你详细分享了CNI插件的工作原理这里就不再赘述了。
Felix。它是一个DaemonSet负责在宿主机上插入路由规则写入Linux内核的FIB转发信息库以及维护Calico所需的网络设备等工作。
BIRD。它就是BGP的客户端专门负责在集群里分发路由规则信息。
除了对路由信息的维护方式之外Calico项目与Flannel的host-gw模式的另一个不同之处就是它不会在宿主机上创建任何网桥设备。这时候Calico的工作方式可以用一幅示意图来描述如下所示在接下来的讲述中我会统一用“BGP示意图”来指代它
其中的绿色实线标出的路径就是一个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示意图
在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-0tun0这两种设备的功能是完全不一样的。
Calico使用的这个tunl0设备是一个IP隧道IP tunnel设备。
在上面的例子中IP包进入IP隧道设备之后就会被Linux内核的IPIP驱动接管。IPIP驱动会将这个IP包直接封装在一个宿主机网络的IP包中如下所示
图5 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 2192.168.2.1了。以此类推我们可以继续在Router 2上添加“下一条”路由最终把IP包转发到Node 2上。
遗憾的是上述流程虽然简单明了但是在Kubernetes被广泛使用的公有云场景里却完全不可行。
这里的原因在于:公有云环境下,宿主机之间的网关,肯定不会允许用户进行干预和设置。
当然,在大多数公有云环境下,宿主机(公有云提供的虚拟机)本身往往就是二层连通的,所以这个需求也不强烈。
不过在私有部署的环境下宿主机属于不同子网VLAN反而是更加常见的部署状态。这时候想办法将宿主机网关也加入到BGP Mesh里从而避免使用IPIP就成了一个非常迫切的需求。
而在Calico项目中它已经为你提供了两种将宿主机网关设置成BGP Peer的解决方案。
第一种方案就是所有宿主机都跟宿主机网关建立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关系。
不过,相比之下,我更愿意推荐第二种方案。
这种方案是使用一个或多个独立组件负责搜集整个集群里的所有路由信息然后通过BGP协议同步给网关。而我们前面提到在大规模集群中Calico本身就推荐使用Route Reflector节点的方式进行组网。所以这里负责跟宿主机网关进行沟通的独立组件直接由Route Reflector兼任即可。
更重要的是这种情况下网关的BGP Peer个数是有限并且固定的。所以我们就可以直接把这些独立组件配置成路由器的BGP Peer而无需Dynamic Neighbors的支持。
当然这些独立组件的工作原理也很简单它们只需要WATCH Etcd里的宿主机和对应网段的变化信息然后把这些信息通过BGP协议分发给网关即可。
总结
在本篇文章中我为你详细讲述了Fannel host-gw模式和Calico这两种纯三层网络方案的工作原理。
需要注意的是,在大规模集群里,三层网络方案在宿主机上的路由规则可能会非常多,这会导致错误排查变得困难。此外,在系统故障的时候,路由规则出现重叠冲突的概率也会变大。
基于上述原因如果是在公有云上由于宿主机网络本身比较“直白”我一般会推荐更加简单的Flannel host-gw模式。
但不难看到在私有部署环境里Calico项目才能够覆盖更多的场景并为你提供更加可靠的组网方案和架构思路。
思考题
你能否能总结一下三层网络方案和“隧道模式”的异同,以及各自的优缺点?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,313 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 为什么说Kubernetes只有soft multi-tenancy
你好我是张磊。今天我和你分享的主题是为什么说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 AllPod可以接收来自任何发送方的请求或者向任何接收方发送请求。而如果你要对这个情况作出限制就必须通过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对象指定的隔离规则如下所示
该隔离规则只对default Namespace下的携带了role=db标签的Pod有效。限制的请求类型包括ingress流入和egress流出
Kubernetes会拒绝任何访问被隔离Pod的请求除非这个请求来自于以下“白名单”里的对象并且访问的是被隔离Pod的6379端口。这些“白名单”对象包括-
a. default Namespace里的携带了role=fronted标签的Pod-
b. 携带了project=myproject 标签的 Namespace 里的任何 Pod-
c. 任何源地址属于172.17.0.0/16网段且不属于172.17.1.0/24网段的请求。
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标签的Namespace里的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内核里挡在“网卡”和“用户态进程”之间的一道“防火墙”。它们的关系可以用如下的示意图来表示
-
可以看到这幅示意图中IP包“一进一出”的两条路径上有几个关键的“检查点”它们正是Netfilter设置“防火墙”的地方。在iptables中这些“检查点”被称为Chain。这是因为这些“检查点”对应的iptables规则是按照定义顺序依次进行匹配的。这些“检查点”的具体工作原理可以用如下所示的示意图进行描述
可以看到当一个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官方的原理图建议你点击图片以查看大图
可以看到我前面为你讲述的正是上图中绿色部分也就是网络层的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的基本方法了。当然对于不同的插件来说上述实现过程可能有不同的手段但根本原理是不变的。
总结
在本篇文章中我主要和你分享了Kubernetes对Pod进行“隔离”的手段NetworkPolicy。
可以看到NetworkPolicy实际上只是宿主机上的一系列iptables规则。这跟传统IaaS里面的安全组Security Group其实是非常类似的。
而基于上述讲述,你就会发现这样一个事实:
Kubernetes的网络模型以及大多数容器网络实现其实既不会保证容器之间二层网络的互通也不会实现容器之间的二层网络隔离。这跟IaaS项目管理虚拟机的方式是完全不同的。
所以说Kubernetes从底层的设计和实现上更倾向于假设你已经有了一套完整的物理基础设施。然后Kubernetes负责在此基础上提供一种“弱多租户”soft multi-tenancy的能力。
并且基于上述思路Kubernetes将来也不大可能把Namespace变成一个具有实质意义的隔离机制或者把它映射成为“子网”或者“租户”。毕竟你可以看到NetworkPolicy对象的描述能力要比基于Namespace的划分丰富得多。
这也是为什么到目前为止Kubernetes项目在云计算生态里的定位其实是基础设施与PaaS之间的中间层。这是非常符合“容器”这个本质上就是进程的抽象粒度的。
当然随着Kubernetes社区以及CNCF生态的不断发展Kubernetes项目也已经开始逐步下探“吃”掉了基础设施领域的很多“蛋糕”。这也正是容器生态继续发展的一个必然方向。
思考题
请你编写这样一个NetworkPolicy它使得指定的Namespace比如my-namespace里的所有Pod都不能接收任何Ingress请求。然后请你说说这样的NetworkPolicy有什么实际的作用
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,248 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 找到容器不容易Service、DNS与服务发现
你好我是张磊。今天我和你分享的主题是找到容器不容易之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/30.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
...
73kube-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上了。
而相比于iptablesIPVS在内核中的实现其实也是基于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记录就会变成…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层是一个原理。
总结
在这篇文章里我为你详细讲解了Service的工作原理。实际上Service机制以及Kubernetes里的DNS插件都是在帮助你解决同样一个问题如何找到我的某一个容器
这个问题在平台级项目中往往就被称作服务发现当我的一个服务Pod的IP地址是不固定的且没办法提前获知时我该如何通过一个固定的方式访问到这个Pod呢
而我在这里讲解的、ClusterIP模式的Service为你提供的就是一个Pod的稳定的IP地址即VIP。并且这里Pod和Service的关系是可以通过Label确定的。
而Headless Service为你提供的则是一个Pod的稳定的DNS名字并且这个名字是可以通过Pod名字和Service名字拼接出来的。
在实际的场景里,你应该根据自己的具体需求进行合理选择。
思考题
请问Kubernetes的Service的负载均衡策略在iptables和ipvs模式下都有哪几种具体工作模式是怎样的
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,248 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 从外界连通Service与Service调试“三板斧”
你好我是张磊。今天我和你分享的主题是从外界连通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对应的规则我在上一篇以及这一篇文章里已经全部介绍到了它们包括
KUBE-SERVICES或者KUBE-NODEPORTS规则对应的Service的入口链这个规则应该与VIP和Service端口一一对应
KUBE-SEP-(hash)规则对应的DNAT链这些规则应该与Endpoints一一对应
KUBE-SVC-(hash)规则对应的负载均衡链,这些规则的数目应该与 Endpoints 数目一致;
如果是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
总结
在本篇文章中我为你详细讲解了从外部访问Service的三种方式NodePort、LoadBalancer 和 External Name和具体的工作原理。然后我还为你讲述了当Service出现故障的时候如何根据它的工作原理按照一定的思路去定位问题的可行之道。
通过上述讲解不难看出所谓Service其实就是Kubernetes为Pod分配的、固定的、基于iptables或者IPVS的访问入口。而这些访问入口代理的Pod信息则来自于Etcd由kube-proxy通过控制循环来维护。
并且你可以看到Kubernetes里面的Service和DNS机制也都不具备强多租户能力。比如在多租户情况下每个租户应该拥有一套独立的Service规则Service只应该看到和代理同一个租户下的Pod。再比如DNS在多租户情况下每个租户应该拥有自己的kube-dnskube-dns只应该为同一个租户下的Service和Pod创建DNS Entry
当然在Kubernetes中kube-proxy和kube-dns其实也是普通的插件而已。你完全可以根据自己的需求实现符合自己预期的Service。
思考题
为什么Kubernetes要求externalIPs必须是至少能够路由到一个Kubernetes的节点
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。-

View File

@@ -0,0 +1,314 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 谈谈Service与Ingress
你好我是张磊。今天我和你分享的主题是谈谈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的Servicecoffee-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页面了。
总结
在这篇文章里我为你详细讲解了Ingress这个概念在Kubernetes里到底是怎么一回事儿。正如我在文章里所描述的Ingress实际上就是Kubernetes对“反向代理”的抽象。
目前Ingress只能工作在七层而Service只能工作在四层。所以当你想要在Kubernetes里为应用进行TLS配置等HTTP相关的操作时都必须通过Ingress来进行。
当然正如同很多负载均衡项目可以同时提供七层和四层代理一样将来Ingress的进化中也会加入四层代理的能力。这样一个比较完善的“反向代理”机制就比较成熟了。
而Kubernetes提出Ingress概念的原因其实也非常容易理解有了Ingress这个抽象用户就可以根据自己的需求来自由选择Ingress Controller。比如如果你的应用对代理服务的中断非常敏感那么你就应该考虑选择类似于Traefik这样支持“热加载”的Ingress Controller实现。
更重要的是一旦你对社区里现有的Ingress方案感到不满意或者你已经有了自己的负载均衡方案时你只需要做很少的编程工作就可以实现一个自己的Ingress Controller。
在实际的生产环境中Ingress带来的灵活度和自由度对于使用容器的用户来说其实是非常有意义的。要知道当年在Cloud Foundry项目里不知道有多少人为了给Gorouter组件配置一个TLS而伤透了脑筋。
思考题
如果我的需求是当访问www.mysite.com和 forums.mysite.com时分别访问到不同的Service比如site-svc和forums-svc。那么这个Ingress该如何定义呢请你描述出YAML文件中的rules字段。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,232 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
40 Kubernetes的资源模型与资源管理
你好我是张磊。今天我和你分享的主题是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就会因为OOMOut-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) 。这里要注意区分MiBmebibyte和MBmegabyte的区别。
备注1Mi=1024*10241M=1000*1000
此外不难看到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的值设置为(2501000)*1024。而当你没有设置requests.cpu的时候cpu.shares默认则是1024。这样Kubernetes就通过cpu.shares完成了对CPU时间的按比例分配。
而如果你指定了limits.cpu=500m之后则相当于将Cgroups的cpu.cfs_quota_us的值设置为(5001000)*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类别相关的主要内容
总结
在本篇文章中我先为你详细讲解了Kubernetes里对资源的定义方式和资源模型的设计然后我为你讲述了Kubernetes里对Pod进行Eviction的具体策略和实践方式
正是基于上述讲述在实际的使用中我强烈建议你将DaemonSet的Pod都设置为Guaranteed的QoS类型否则一旦DaemonSet的Pod被回收它又会立即在原宿主机上被重建出来这就使得前面资源回收的动作完全没有意义了
思考题
为什么宿主机进入MemoryPressure或者DiskPressure状态后新的Pod就不会被调度到这台宿主机上呢
感谢你的收听欢迎你给我留言也欢迎分享给更多的朋友一起阅读

View File

@@ -0,0 +1,104 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
41 十字路口上的Kubernetes默认调度器
你好我是张磊。今天我和你分享的主题是十字路口上的Kubernetes默认调度器。
在上一篇文章中我主要为你介绍了Kubernetes里关于资源模型和资源管理的设计方法。而在今天这篇文章中我就来为你介绍一下Kubernetes的默认调度器default scheduler
在Kubernetes项目中默认调度器的主要职责就是为一个新创建出来的Pod寻找一个最合适的节点Node
而这里“最合适”的含义,包括两层:
从集群所有的节点中根据调度算法挑选出所有可以运行该Pod的节点
从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。
所以在具体的调度流程中默认调度器会首先调用一组叫作Predicate的调度算法来检查每个Node。然后再调用一组叫作Priority的调度算法来给上一步得到的结果里的每个Node打分。最终的调度结果就是得分最高的那个Node。
而我在前面的文章中曾经介绍过调度器对一个Pod调度成功实际上就是将它的spec.nodeName字段填上调度结果的节点名字。
备注这里你可以再回顾下第14篇文章《深入解析Pod对象基本概念》中的相关内容。
在Kubernetes中上述调度机制的工作原理可以用如下所示的一幅示意图来表示。
可以看到Kubernetes的调度器的核心实际上就是两个相互独立的控制循环。
其中第一个控制循环我们可以称之为Informer Path。它的主要目的是启动一系列Informer用来监听WatchEtcd中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。
但是为了不在关键调度路径里远程访问APIServerKubernetes的默认调度器在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里面叫作Scheduler Framework。顾名思义这个设计的主要目的就是在调度器生命周期的各个关键点上为用户暴露出可以进行扩展和实现的接口从而实现由用户自定义调度器的能力。
上图中每一个绿色的箭头都是一个可以插入自定义逻辑的接口。比如上面的Queue部分就意味着你可以在这一部分提供一个自己的调度队列的实现从而控制每个Pod开始被调度出队的时机。
而Predicates部分则意味着你可以提供自己的过滤算法实现根据自己的需求来决定选择哪些机器。
需要注意的是上述这些可插拔式逻辑都是标准的Go语言插件机制Go plugin 机制),也就是说,你需要在编译的时候选择把哪些插件编译进去。
有了上述设计之后扩展和自定义Kubernetes的默认调度器就变成了一件非常容易实现的事情。这也意味着默认调度器在后面的发展过程中必然不会在现在的实现上再添加太多的功能反而还会对现在的实现进行精简最终成为Scheduler Framework的一个最小实现。而调度领域更多的创新和工程工作就可以交给整个社区来完成了。这个思路是完全符合我在前面提到的Kubernetes的“民主化”设计的。
不过这样的Scheduler Framework也有一个不小的问题那就是一旦这些插入点的接口设计不合理就会导致整个生态没办法很好地把这个插件机制使用起来。而与此同时这些接口本身的变更又是一个费时费力的过程一旦把控不好就很可能会把社区推向另一个极端Scheduler Framework没法实际落地大家只好都再次fork kube-scheduler。
总结
在本篇文章中我为你详细讲解了Kubernetes里默认调度器的设计与实现分析了它现在正在经历的重构以及未来的走向。
不难看到,在 Kubernetes 的整体架构中kube-scheduler 的责任虽然重大,但其实它却是在社区里最少受到关注的组件之一。这里的原因也很简单,调度这个事情,在不同的公司和团队里的实际需求一定是大相径庭的,上游社区不可能提供一个大而全的方案出来。所以,将默认调度器进一步做轻做薄,并且插件化,才是 kube-scheduler 正确的演进方向。
思考题
请问Kubernetes默认调度器与Mesos的“两级”调度器有什么异同呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,211 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
42 Kubernetes默认调度器调度策略解析
你好我是张磊。今天我和你分享的主题是Kubernetes默认调度器调度策略解析。
在上一篇文章中我主要为你讲解了Kubernetes默认调度器的设计原理和架构。在今天这篇文章中我们就专注在调度过程中Predicates和Priorities这两个调度策略主要发生作用的阶段。
首先我们一起看看Predicates。
Predicates在调度过程中的作用可以理解为Filter它按照调度策略从当前集群的所有节点中“过滤”出一系列符合条件的节点。这些节点都是可以运行待调度Pod的宿主机。
而在Kubernetes中默认的调度策略有如下四种。
第一种类型叫作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。
第二种类型是与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正是调度器里完成这个决策的位置。
第三种类型,是宿主机相关的过滤规则。
这一组规则,主要考察待调度 Pod 是否满足 Node 本身的某些条件。
比如PodToleratesNodeTaints负责检查的就是我们前面经常用到的Node 的“污点”机制。只有当 Pod 的 Toleration 字段与 Node 的 Taint 字段能够匹配的时候,这个 Pod 才能被调度到该节点上。
备注这里你也可以再回顾下第21篇文章《容器化守护进程的意义DaemonSet》中的相关内容。
而NodeMemoryPressurePredicate检查的是当前节点的内存是不是已经不够充足如果是的话那么待调度 Pod 就不能被调度到该节点上。
第四种类型,是 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 的时候反复获取和计算整个集群的信息。
总结
在本篇文章中,我为你讲述了 Kubernetes 默认调度器里的主要调度算法。
需要注意的是除了本篇讲述的这些规则Kubernetes 调度器里其实还有一些默认不会开启的策略。你可以通过为kube-scheduler 指定一个配置文件或者创建一个 ConfigMap ,来配置哪些规则需要开启、哪些规则需要关闭。并且,你可以通过为 Priorities 设置权重,来控制调度器的调度行为。
思考题
请问,如何能够让 Kubernetes 的调度器尽可能地将 Pod 分布在不同机器上,避免“堆叠”呢?请简单描述下你的算法。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,144 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
43 Kubernetes默认调度器的优先级与抢占机制
你好我是张磊。今天我和你分享的主题是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的整数最大值不超过100000000010亿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里面。
然后,这次失败事件就会触发调度器为抢占者寻找牺牲者的流程。
第一步,调度器会检查这次失败事件的原因,来确认抢占是不是可以帮助抢占者找到一个新节点。这是因为有很多 Predicates的失败是不能通过抢占来解决的。比如PodFitsHost算法负责的是检查Pod 的 nodeSelector与 Node 的名字是否匹配),这种情况下,除非 Node 的名字发生变化,否则你即使删除再多的 Pod抢占者也不可能调度成功。
第二步,如果确定抢占可以发生,那么调度器就会把自己缓存的所有节点信息复制一份,然后使用这个副本来模拟抢占过程。
这里的抢占过程很容易理解。调度器会检查缓存副本里的每一个节点然后从该节点上最低优先级的Pod开始逐一“删除”这些Pod。而每删除一个低优先级Pod调度器都会检查一下抢占者是否能够运行在该 Node 上。一旦可以运行,调度器就记录下这个 Node 的名字和被删除 Pod 的列表,这就是一次抢占过程的结果了。
当遍历完所有的节点之后,调度器会在上述模拟产生的所有抢占结果里做一个选择,找出最佳结果。而这一步的判断原则,就是尽量减少抢占对整个系统的影响。比如,需要抢占的 Pod 越少越好,需要抢占的 Pod 的优先级越低越好,等等。
在得到了最佳的抢占结果之后,这个结果里的 Node就是即将被抢占的 Node被删除的 Pod 列表,就是牺牲者。所以接下来,调度器就可以真正开始抢占的操作了,这个过程,可以分为三步。
第一步,调度器会检查牺牲者列表,清理这些 Pod 所携带的nominatedNodeName字段。
第二步调度器会把抢占者的nominatedNodeName设置为被抢占的Node 的名字。
第三步,调度器会开启一个 Goroutine同步地删除牺牲者。
而第二步对抢占者 Pod 的更新操作,就会触发到我前面提到的“重新做人”的流程,从而让抢占者在下一个调度周期重新进入调度流程。
所以接下来,调度器就会通过正常的调度流程把抢占者调度成功。这也是为什么,我前面会说调度器并不保证抢占的结果:在这个正常的调度流程里,是一切皆有可能的。
不过,对于任意一个待调度 Pod来说因为有上述抢占者的存在它的调度过程其实是有一些特殊情况需要特殊处理的。
具体来说,在为某一对 Pod 和 Node 执行 Predicates 算法的时候,如果待检查的 Node 是一个即将被抢占的节点调度队列里有nominatedNodeName字段值是该 Node 名字的 Pod 存在(可以称之为:“潜在的抢占者”)。那么,调度器就会对这个 Node ,将同样的 Predicates 算法运行两遍。
第一遍, 调度器会假设上述“潜在的抢占者”已经运行在这个节点上,然后执行 Predicates 算法;
第二遍, 调度器会正常执行Predicates算法不考虑任何“潜在的抢占者”。
而只有这两遍 Predicates 算法都能通过时,这个 Pod 和 Node 才会被认为是可以绑定bind的。
不难想到这里需要执行第一遍Predicates算法的原因是由于InterPodAntiAffinity 规则的存在。
由于InterPodAntiAffinity规则关心待考察节点上所有 Pod之间的互斥关系所以我们在执行调度算法时必须考虑如果抢占者已经存在于待考察 Node 上时,待调度 Pod 还能不能调度成功。
当然,这也就意味着,我们在这一步只需要考虑那些优先级等于或者大于待调度 Pod 的抢占者。毕竟对于其他较低优先级 Pod 来说,待调度 Pod 总是可以通过抢占运行在待考察 Node 上。
而我们需要执行第二遍Predicates 算法的原因,则是因为“潜在的抢占者”最后不一定会运行在待考察的 Node 上。关于这一点我在前面已经讲解过了Kubernetes调度器并不保证抢占者一定会运行在当初选定的被抢占的 Node 上。
以上,就是 Kubernetes 默认调度器里优先级和抢占机制的实现原理了。
总结
在本篇文章中,我为你详细讲述了 Kubernetes 里关于 Pod 的优先级和抢占机制的设计与实现。
这个特性在v1.11之后已经是Beta了意味着比较稳定了。所以我建议你在Kubernetes集群中开启这两个特性以便实现更高的资源使用率。
思考题
当整个集群发生可能会影响调度结果的变化(比如,添加或者更新 Node添加和更新 PV、Service等调度器会执行一个被称为MoveAllToActiveQueue的操作把所调度失败的 Pod 从 unscheduelableQ 移动到activeQ 里面。请问这是为什么?
一个相似的问题是,当一个已经调度成功的 Pod 被更新时调度器则会将unschedulableQ 里所有跟这个 Pod 有 Affinity/Anti-affinity 关系的 Pod移动到 activeQ 里面。请问这又是为什么呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,156 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
44 Kubernetes GPU管理与Device Plugin机制
你好我是张磊。今天我和你分享的主题是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 设备为例,上面的需求就意味着当用户的容器被创建之后,这个容器里必须出现如下两部分设备和目录:
GPU 设备,比如 /dev/nvidia0
GPU 驱动目录,比如/usr/local/nvidia/*。
其中GPU 设备路径,正是该容器启动时的 Devices 参数;而驱动目录,则是该容器启动时的 Volume 参数。所以,在 Kubernetes 的GPU 支持的实现里kubelet 实际上就是将上述两部分内容,设置在了创建该容器的 CRI Container Runtime Interface参数里面。这样等到该容器启动之后对应的容器里就会出现 GPU 设备和驱动的路径了。
不过Kubernetes 在 Pod 的 API 对象里,并没有为 GPU 专门设置一个资源类型字段,而是使用了一种叫作 Extended ResourceER的特殊字段来负责传递 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 机制,我可以用如下所示的一幅示意图来和你解释清楚。
我们先从这幅示意图的右侧开始看起。
首先,对于每一种硬件设备,都需要有它所对应的 Device Plugin 进行管理,这些 Device Plugin都通过gRPC 的方式,同 kubelet 连接起来。以 NVIDIA GPU 为例它对应的插件叫作NVIDIA GPU device plugin。
这个 Device Plugin 会通过一个叫作 ListAndWatch的 API定期向 kubelet 汇报该 Node 上 GPU 的列表。比如在我们的例子里一共有三个GPUGPU0、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社区里已经实现了很多硬件插件比如FPGA、SRIOV、RDMA等等。感兴趣的话你可以点击这些链接来查看这些 Device Plugin 的实现。
总结
在本篇文章中,我为你详细讲述了 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。这实属不得已而为之。
思考题
请你结合自己的需求谈一谈,你希望如何对当前的 Device Plugin进行改进呢或者说你觉得当前的设计已经完全够用了吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,103 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
45 幕后英雄SIG-Node与CRI
你好我是张磊。今天我和你分享的主题是幕后英雄之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 的工作核心就是一个控制循环SyncLoop图中的大圆圈。而驱动这个控制循环运行的事件包括四种
Pod 更新事件;
Pod 生命周期变化;
kubelet 本身设置的执行周期;
定时的清理事件。
所以跟其他控制器类似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而是通过一组叫作 CRIContainer 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 本身的架构,就可以用如下所示的一幅示意图来描述。
-
可以看到,当 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 请求“翻译”成对后端容器项目的请求或者操作。
总结
在本篇文章中,我首先为你介绍了 SIG-Node 的职责,以及 kubelet 这个组件的工作原理。
接下来,我为你重点讲解了 kubelet 究竟是如何将 Kubernetes 对应用的定义,一步步转换成最终对 Docker 或者其他容器项目的API 请求的。
不难看到在这个过程中kubelet 的 SyncLoop 和 CRI 的设计是其中最重要的两个关键点。也正是基于以上设计SyncLoop 本身就要求这个控制循环是绝对不可以被阻塞的。所以,凡是在 kubelet 里有可能会耗费大量时间的操作,比如准备 Pod 的 Volume、拉取镜像等SyncLoop 都会开启单独的 Goroutine 来进行操作。
思考题
请问,在你的项目中,你是如何部署 kubelet 这个组件的?为什么要这么做呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,100 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
46 解读 CRI 与 容器运行时
你好,我是张磊。今天我和你分享的主题是:解读 CRI 与 容器运行时。
在上一篇文章中,我为你详细讲解了 kubelet 的工作原理和 CRI 的来龙去脉。在今天这篇文章中,我们就来进一步地、更深入地了解一下 CRI 的设计与工作原理。
首先,我们先来简要回顾一下有了 CRI 之后Kubernetes 的架构图,如下所示。
-
在上一篇文章中我也提到了CRI 机制能够发挥作用的核心,就在于每一种容器项目现在都可以自己实现一个 CRI shim自行对 CRI 请求进行处理。这样Kubernetes 就有了一个统一的容器抽象层,使得下层容器运行时可以自由地对接进入 Kubernetes 当中。
所以说,这里的 CRI shim就是容器项目的维护者们自由发挥的“场地”了。而除了 dockershim之外其他容器运行时的 CRI shim都是需要额外部署在宿主机上的。
举个例子。CNCF 里的 containerd 项目,就可以提供一个典型的 CRI shim 的能力将Kubernetes 发出的 CRI 请求,转换成对 containerd 的调用,然后创建出 runC 容器。而 runC项目才是负责执行我们前面讲解过的设置容器 Namespace、Cgroups和chroot 等基础操作的组件。所以,这几层的组合关系,可以用如下所示的示意图来描述。
-
而作为一个 CRI shimcontainerd 对 CRI 的具体实现,又是怎样的呢?
我们先来看一下 CRI 这个接口的定义。下面这幅示意图,就展示了 CRI 里主要的待实现接口。
-
具体地说,我们可以把 CRI 分为两组:
第一组,是 RuntimeService。它提供的接口主要是跟容器相关的操作。比如创建和启动容器、删除容器、执行 exec 命令等等。
而第二组,则是 ImageService。它提供的接口主要是容器镜像相关的操作比如拉取镜像、删除镜像等等。
关于容器镜像的操作比较简单所以我们就暂且略过。接下来我主要为你讲解一下RuntimeService部分。
在这一部分CRI 设计的一个重要原则,就是确保这个接口本身,只关注容器,不关注 Pod。这样做的原因也很容易理解。
第一Pod 是 Kubernetes 的编排概念,而不是容器运行时的概念。所以,我们就不能假设所有下层容器项目,都能够暴露出可以直接映射为 Pod 的 API。
第二,如果 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模型。这里的原理可以用如下所示的示意图来表示清楚。
-
比如,当我们执行 kubectl run 创建了一个名叫 foo 的、包括了 A、B 两个容器的 Pod 之后。这个Pod 的信息最后来到 kubeletkubelet 就会按照图中所示的顺序来调用 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里就是直接启动AB两个 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 机制。这一部分原理,可以用如下所示的示意图来为你描述。
-
可以看到,当我们对一个容器执行 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 的设计以及具体的工作原理了。
总结
在本篇文章中,我为你详细解读了 CRI 的设计和具体工作原理并为你梳理了实现CRI 接口的核心流程。
从这些讲解中不难看出CRI 这个接口的设计,实际上还是比较宽松的。这就意味着,作为容器项目的维护者,我在实现 CRI 的具体接口时,往往拥有着很高的自由度,这个自由度不仅包括了容器的生命周期管理,也包括了如何将 Pod 映射成为我自己的实现,还包括了如何调用 CNI 插件来为 Pod 设置网络的过程。
所以说,当你对容器这一层有特殊的需求时,我一定优先建议你考虑实现一个自己的 CRI shim ,而不是修改 kubelet 甚至容器项目的代码。这样通过插件的方式定制 Kubernetes 的做法,也是整个 Kubernetes 社区最鼓励和推崇的一个最佳实践。这也正是为什么像 Kata Containers、gVisor 甚至虚拟机这样的“非典型”容器,都可以无缝接入到 Kubernetes 项目里的重要原因。
思考题
请你思考一下我前面讲解过的Device Plugin 为容器分配的 GPU 信息,是通过 CRI 的哪个接口传递给 dockershim最后交给 Docker API 的呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,105 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
47 绝不仅仅是安全Kata Containers 与 gVisor
你好我是张磊。今天我和你分享的主题是绝不仅仅是安全之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 具体的设计原理。
首先,我们来看 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的实现方式同一个正常的虚拟机其实也非常类似。这里的原理可以用如下所示的一幅示意图来表示。
可以看到,当 Kata Containers 运行起来之后,虚拟机里的用户进程(容器),实际上只能看到虚拟机里的、被裁减过的 Guest Kernel以及通过 Hypervisor 虚拟出来的硬件设备。
而为了能够对这个虚拟机的 I/O 性能进行优化Kata Containers 也会通过 vhost 技术比如vhost-user来实现 Guest 与 Host 之间的高效的网络通信,并且使用 PCI Passthrough PCI 穿透)技术来让 Guest 里的进程直接访问到宿主机上的物理设备。这些架构设计与实现,其实跟常规虚拟机的优化手段是基本一致的。
相比之下gVisor 的设计其实要更加“激进”一些。它的原理,可以用如下所示的示意图来表示清楚。
gVisor工作的核心在于它为应用进程、也就是用户容器启动了一个名叫 Sentry 的进程。 而Sentry 进程的主要职责就是提供一个传统的操作系统内核的能力运行用户程序执行系统调用。所以说Sentry 并不是使用 Go 语言重新实现了一个完整的 Linux 内核,而只是一个对应用进程“冒充”内核的系统组件。
在这种设计思想下我们就不难理解Sentry 其实需要自己实现一个完整的 Linux 内核网络栈,以便处理应用进程的通信请求。然后,把封装好的二层帧直接发送给 Kubernetes 设置的 Pod 的Network Namespace 即可。
此外Sentry 对于Volume 的操作,则需要通过 9p 协议交给一个叫做 Gofer 的代理进程来完成。Gofer 会代替应用进程直接操作宿主机上的文件并依靠seccomp机制将自己的能力限制在最小集从而防止恶意应用进程通过 Gofer 来从容器中“逃逸”出去。
而在具体的实现上gVisor 的 Sentry 进程,其实还分为两种不同的实现方式。这里的工作原理,可以用下面的示意图来描述清楚。
第一种实现方式是使用Ptrace机制来拦截用户应用的系统调用System Call然后把这些系统调用交给 Sentry 来进行处理。
这个过程,对于应用进程来说,是完全透明的。而 Sentry 接下来,则会扮演操作系统的角色,在用户态执行用户程序,然后仅在需要的时候,才向宿主机发起 Sentry 自己所需要执行的系统调用。这,就是 gVisor 对用户应用进程进行强隔离的主要手段。不过, Ptrace 进行系统调用拦截的性能实在是太差,仅能供 Demo 时使用。
而第二种实现方式,则更加具有普适性。它的工作原理如下图所示。
在这种实现里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 运行起来。
总结
在本篇文章中,我为你详细地介绍了拥有独立内核的安全容器项目,对比了 KataContainers 和 gVisor 的设计与实现细节。
在性能上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 的官方对比资料。
思考题
安全容器的意义,绝不仅仅止于安全。你可以想象一下这样一个场景:比如,你的宿主机的 Linux 内核版本是3.6,但是应用却必须要求 Linux 内核版本是4.0。这时候,你就可以把这个应用运行在一个 KataContainers 里。那么请问,你觉得使用 gVisor 是否也能提供这种能力呢?原因是什么呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,134 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
48 Prometheus、Metrics Server与Kubernetes监控体系
你好我是张磊。今天我和你分享的主题是Prometheus、Metrics Server与Kubernetes监控体系。
通过前面的文章我已经和你分享过了Kubernetes 的核心架构编排概念以及具体的设计与实现。接下来我会用3篇文章为你介绍 Kubernetes 监控相关的一些核心技术。
首先需要明确指出的是Kubernetes 项目的监控体系曾经非常繁杂在社区中也有很多方案。但这套体系发展到今天已经完全演变成了以Prometheus 项目为核心的一套统一的方案。
在这里,可能有一些同学对 Prometheus 项目还太不熟悉。所以,我先来简单为你介绍一下这个项目。
实际上Prometheus 项目是当年 CNCF 基金会起家时的“第二把交椅”。而这个项目发展到今天,已经全面接管了 Kubernetes 项目的整套监控体系。
比较有意思的是Prometheus项目与 Kubernetes 项目一样,也来自于 Google 的 Borg 体系它的原型系统叫作BorgMon是一个几乎与 Borg 同时诞生的内部监控系统。而 Prometheus 项目的发起原因也跟 Kubernetes 很类似,都是希望通过对用户更友好的方式,将 Google 内部系统的设计理念,传递给用户和开发者。
作为一个监控系统Prometheus 项目的作用和工作方式,其实可以用如下所示的一张官方示意图来解释。
-
可以看到Prometheus 项目工作的核心,是使用 Pull (抓取)的方式去搜集被监控对象的 Metrics 数据(监控指标数据),然后,再把这些数据保存在一个 TSDB (时间序列数据库,比如 OpenTSDB、InfluxDB 等)当中,以便后续可以按照时间进行检索。
有了这套核心监控机制, Prometheus 剩下的组件就是用来配合这套机制的运行。比如 Pushgateway可以允许被监控对象以Push 的方式向 Prometheus 推送 Metrics 数据。而Alertmanager则可以根据 Metrics 信息灵活地设置报警。当然, Prometheus 最受用户欢迎的功能,还是通过 Grafana 对外暴露出的、可以灵活配置的监控数据可视化界面。
有了 Prometheus 之后我们就可以按照Metrics 数据的来源,来对 Kubernetes 的监控体系做一个汇总了。
第一种 Metrics是宿主机的监控数据。这部分数据的提供需要借助一个由 Prometheus 维护的Node Exporter 工具。一般来说Node Exporter 会以 DaemonSet 的方式运行在宿主机上。其实,所谓的 Exporter就是代替被监控对象来对 Prometheus 暴露出可以被“抓取”的 Metrics 信息的一个辅助进程。
而 Node Exporter 可以暴露给 Prometheus 采集的Metrics 数据, 也不单单是节点的负载Load、CPU 、内存、磁盘以及网络这样的常规信息,它的 Metrics 指标可以说是“包罗万象”,你可以查看这个列表来感受一下。
第二种 Metrics是来自于 Kubernetes 的 API Server、kubelet 等组件的/metrics API。除了常规的 CPU、内存的信息外这部分信息还主要包括了各个组件的核心监控指标。比如对于 API Server 来说,它就会在/metrics API 里,暴露出各个 Controller 的工作队列Work Queue的长度、请求的 QPS 和延迟数据等等。这些信息,是检查 Kubernetes 本身工作情况的主要依据。
第三种 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 (即:/stats/summary采集而来的。Summary API 返回的信息,既包括了 cAdVisor的监控数据也包括了 kubelet 本身汇总的信息。
需要指出的是, Metrics Server 并不是 kube-apiserver 的一部分,而是通过 Aggregator 这种插件机制,在独立部署的情况下同 kube-apiserver 一起统一对外服务的。
这里Aggregator APIServer 的工作原理,可以用如下所示的一幅示意图来表示清楚:
备注图片出处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=
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=
proxy-client-key-file=
而这些配置的作用,主要就是为 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 (自定义监控指标)的具体技术点。
总结
在本篇文章中,我主要为你介绍了 Kubernetes 当前监控体系的设计,介绍了 Prometheus 项目在这套体系中的地位,讲解了以 Prometheus 为核心的监控系统的架构设计。
然后,我为你详细地解读了 Kubernetes 核心监控数据的来源Metrics Server的具体工作原理以及 Aggregator APIServer 的设计思路。
通过以上讲述,我希望你能够对 Kubernetes 的监控体系形成一个整体的认知,体会到 Kubernetes 社区在监控这个事情上,全面以 Prometheus 项目为核心进行建设的大方向。
最后,在具体的监控指标规划上,我建议你遵循业界通用的 USE 原则和 RED 原则。
其中USE 原则指的是,按照如下三个维度来规划资源监控指标:
利用率Utilization资源被有效利用起来提供服务的平均时间占比
饱和度Saturation资源拥挤的程度比如工作队列的长度
错误率Errors错误的数量。
而 RED 原则指的是,按照如下三个维度来规划服务监控指标:
每秒请求数量Rate
每秒错误数量Errors
服务响应时间Duration
不难发现, USE 原则主要关注的是“资源”,比如节点和容器的资源使用情况,而 RED 原则主要关注的是“服务”,比如 kube-apiserver 或者某个应用的工作情况。这两种指标,在我今天为你讲解的 Kubernetes + Prometheus 组成的监控体系中,都是可以完全覆盖到的。
思考题
在监控体系中,对于数据的采集,其实既有 Prometheus 这种 Pull 模式,也有 Push 模式。请问,你如何看待这两种模式的异同和优缺点呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,211 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
49 Custom Metrics_ 让Auto Scaling不再“食之无味”
你好我是张磊。今天我和你分享的主题是Custom Metrics让Auto Scaling不再“食之无味”。
在上一篇文章中,我为你详细讲述了 Kubernetes 里的核心监控体系的架构。不难看到Prometheus 项目在其中占据了最为核心的位置。
实际上借助上述监控体系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 来指定被监控应用的。
总结
在本篇文章中,我为你详细讲解了 Kubernetes 里对自定义监控指标,即 Custom Metrics 的设计与实现机制。这套机制的可扩展性非常强也终于使得Auto Scaling 在 Kubernetes 里面不再是一个“食之无味”的鸡肋功能了。
另外可以看到Kubernetes 的 Aggregator APIServer是一个非常行之有效的 API 扩展机制。而且Kubernetes 社区已经为你提供了一套叫作 KubeBuilder 的工具库,帮助你生成一个 API Server 的完整代码框架,你只需要在里面添加自定义 API以及对应的业务逻辑即可。
思考题
在你的业务场景中,你希望使用什么样的指标作为 Custom Metrics ,以便对 Pod 进行 Auto Scaling 呢?怎么获取到这个指标呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,237 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
50 让日志无处可逃:容器日志收集与管理
你好,我是张磊。今天我和你分享的主题是:让日志无处可逃之容器日志收集与管理。
在前面的文章中,我为你详细讲解了 Kubernetes 的核心监控体系和自定义监控体系的设计与实现思路。而在本篇文章里我就来为你详细介绍一下Kubernetes 里关于容器日志的处理方式。
首先需要明确的是Kubernetes 里面对容器日志的处理方式都叫作cluster-level-logging这个日志处理系统与容器、Pod以及Node的生命周期都是完全无关的。这种设计当然是为了保证无论是容器挂了、Pod 被删除,甚至节点宕机的时候,应用的日志依然可以被正常获取到。
而对于一个容器来说,当应用把日志输出到 stdout 和 stderr 之后,容器项目在默认情况下就会把这些日志输出到宿主机上的一个 JSON 文件里。这样,你通过 kubectl logs 命令就可以看到这些容器的日志了。
上述机制,就是我们今天要讲解的容器日志收集的基础假设。而如果你的应用是把文件输出到其他地方,比如直接输出到了容器里的某个文件里,或者输出到了远程存储里,那就属于特殊情况了。当然,我在文章里也会对这些特殊情况的处理方法进行讲述。
而 Kubernetes 本身实际上是不会为你做容器日志收集工作的所以为了实现上述cluster-level-logging你需要在部署集群的时候提前对具体的日志方案进行规划。而 Kubernetes 项目本身,主要为你推荐了三种日志方案。
第一种,在 Node 上部署 logging agent将日志文件转发到后端存储里保存起来。这个方案的架构图如下所示。
不难看到,这里的核心就在于 logging agent ,它一般都会以 DaemonSet 的方式运行在节点上,然后将宿主机上的容器日志目录挂载进去,最后由 logging-agent 把日志转发出去。
举个例子,我们可以通过 Fluentd 项目作为宿主机上的 logging-agent然后把日志转发到远端的 ElasticSearch 里保存起来供将来进行检索。具体的操作过程,你可以通过阅读这篇文档来了解。另外,在很多 Kubernetes 的部署里,会自动为你启用 logrotate在日志文件超过10MB 的时候自动对日志文件进行 rotate 操作。
可以看到,在 Node 上部署 logging agent最大的优点在于一个节点只需要部署一个 agent并且不会对应用和 Pod 有任何侵入性。所以,这个方案,在社区里是最常用的一种。
但是也不难看到,这种方案的不足之处就在于,它要求应用输出的日志,都必须是直接输出到容器的 stdout 和 stderr 里。
所以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 文件。这对磁盘是很大的浪费。所以说,除非万不得已或者应用容器完全不可能被修改,否则我还是建议你直接使用方案一,或者直接使用下面的第三种方案。
第三种方案,就是通过一个 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 项目对容器应用日志进行管理最常用的三种手段了。
总结
在本篇文章中我为你详细讲解了Kubernetes 项目对容器应用日志的收集方式。综合对比以上三种方案,我比较建议你将应用日志输出到 stdout 和 stderr然后通过在宿主机上部署 logging-agent 的方式来集中处理日志。
这种方案不仅管理简单kubectl logs 也可以用,而且可靠性高,并且宿主机本身,很可能就自带了 rsyslogd 等非常成熟的日志收集组件来供你使用。
除此之外,还有一种方式就是在编写应用的时候,就直接指定好日志的存储后端,如下所示:
在这种方案下Kubernetes 就完全不必操心容器日志的收集了,这对于本身已经有完善的日志处理系统的公司来说,是一个非常好的选择。
最后需要指出的是,无论是哪种方案,你都必须要及时将这些日志文件从宿主机上清理掉,或者给日志目录专门挂载一些容量巨大的远程盘。否则,一旦主磁盘分区被打满,整个系统就可能会陷入崩溃状态,这是非常麻烦的。
思考题
请问,当日志量很大的时候,直接将日志输出到容器 stdout 和 stderr上有没有什么隐患呢有没有解决办法呢
你还有哪些容器收集的方案,是否可以分享一下?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,121 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
51 谈谈Kubernetes开源社区和未来走向
你好我是张磊。今天我和你分享的主题是谈谈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 的 TOCTechnical 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 Chaoreviewer 就是 lavalamp。这就意味着任何人提交的Pull RequestPR代码修改请求只要修改了这个目录下的文件那么就必须要经过 lavalamp 的 Code Review然后再经过caesarxuchao的 Approve 才可以被合并。当然在这个文件里caesarxuchao 的权力是最大的,它可以既做 Code Review也做最后的 Approve。但 lavalamp 是不能进行 Approve 的。
当然,无论是 Code Review 通过,还是 Approve这些维护者只需要在 PR下面Comment /lgtm 和 /approveKubernetes 项目的机器人k8s-ci-robot就会自动给该 PR 加上 lgtm 和 approve标签然后进入 Kubernetes 项目 CI 系统的合并队列,最后被合并。此外,如果你要对这个项目加标签,或者把它 Assign 给其他人,也都可以通过 Comment 的方式来进行。
可以看到在上述整个过程中代码维护者不需要对Kubernetes 项目拥有写权限,就可以完成代码审核、合并等所有流程。这当然得益于 Kubernetes 社区完善的机器人机制,这也是 GitHub 最吸引人的特性之一。
顺便说一句很多人问我GitHub 比 GitLab 或者其他代码托管平台强在哪里?实际上, GitHub 庞大的API 和插件生态,才是这个产品最具吸引力的地方。
当然,当你想要将你的想法以代码的形式提交给 Kubernetes项目时除非你的改动是 bugfix 或者很简单的改动,否则,你直接提交一个 PR 上去,是大概率不会被 Approve 的。这里的流程,一定要按照我下面的讲解来进行:
在 Kubernetes 主库里创建 Issue详细地描述你希望解决的问题、方案以及开发计划。而如果社区里已经有相关的Issue存在那你就必须要在这里把它们引用过来。而如果社区里已经存在相同的 Issue 了,你就需要确认一下,是不是应该直接转到原有 issue 上进行讨论。
给 Issue 加上与它相关的 SIG 的标签。比如,你可以直接 Comment /sig node那么这个 Issue 就会被加上 sig-node 的标签,这样 SIG-Node的成员就会特别留意这个 Issue。
收集社区对这个 Issue 的信息,回复 Comment与 SIG 成员达成一致。必要的时候,你还需要参加 SIG 的周会,更好地阐述你的想法和计划。
在与 SIG 的大多数成员达成一致后,你就可以开始进行详细的设计了。
如果设计比较复杂的话,你还需要在 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。你可以查看一下这个列表与上述“大佬”名单有什么不同。
上述 Design Proposal被合并后你就可以开始按照设计文档的内容编写代码了。这个流程才是正常大家所熟知的编写代码、提交 PR、通过 CI 测试、进行Code Review然后等待合并的流程。
如果你的 feature 是需要要在 Kubernetes 的正式 Release 里发布上线的那么你还需要在Kubernetes Enhancements这个库里面提交一个 KEP即Kubernetes Enhancement Proposal。这个 KEP 的主要内容,是详细地描述你的编码计划、测试计划、发布计划,以及向后兼容计划等软件工程相关的信息,供全社区进行监督和指导。
以上内容,就是 Kubernetes 社区运作的主要方式了。
总结
在本篇文章里,我为你详细讲述了 CNCF 和 Kubernetes 社区的关系,以及 Kubernetes 社区的运作方式,希望能够帮助你更好地理解这个社区的特点和它的先进之处。
除此之外,你可能还听说过 Kubernetes 社区里有一个叫作Kubernetes Steering Committee的组织。这个组织其实也是属于Kubernetes Community 库的一部分。这个组织成员的主要职能,是对 Kubernetes 项目治理的流程进行约束和规范,但通常并不会直接干涉 Kubernetes 具体的设计和代码实现。
其实到目前为止Kubernetes 社区最大的一个优点,就是把“搞政治”的人和“搞技术”的人分得比较清楚。相信你也不难理解,这两种角色在一个活跃的开源社区里其实都是需要的,但是,如果这两部分人发生了大量的重合,那对于一个开源社区来说,恐怕就是个灾难了。
思考题
你能说出 Kubernetes 社区同 OpenStack 社区相比的不同点吗?你觉得这两个社区各有哪些优缺点呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,199 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
52 答疑:在问题中解决问题,在思考中产生思考
在本篇文章中,我将会对本专栏部分文章最后的思考题,进行一次集中的汇总和答疑。希望能够帮助你更好地理解和掌握 Kubernetes 项目。
问题1你是否知道如何修复容器中的top指令以及/proc文件系统中的信息呢提示lxcfs
其实,这个问题的答案在提示里其实已经给出了,即 lxcfs 方案。通过lxcfs你可以把宿主机的 /var/lib/lxcfs/proc 文件系统挂载到Docker容器的/proc目录下。使得容器中进程读取相应文件内容时实际上会从容器对应的Cgroups中读取正确的资源限制。 从而得到正确的top 命令的返回值。
问题选自第6篇文章《白话容器基础隔离与限制》。
问题2既然容器的rootfs比如Ubuntu镜像是以只读方式挂载的那么又如何在容器里修改Ubuntu镜像的内容呢提示Copy-on-Write
这个问题的答案也同样出现在了提示里。
简单地说修改一个镜像里的文件的时候联合文件系统首先会从上到下在各个层中查找有没有目标文件。如果找到就把这个文件复制到可读写层进行修改。这个修改的结果会屏蔽掉下层的文件这种方式就被称为copy-on-write。
问题选自第7篇文章《白话容器基础深入理解容器镜像》。
问题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容器》](https://time.geekbang.org/column/article/18119)。
问题4你能否说出Kubernetes使用的这个“控制器模式”跟我们平常所说的“事件驱动”有什么区别和联系吗
这里“控制器模式”和“事件驱动”最关键的区别在于:
对于控制器来说,被监听对象的变化是一个持续的信号,比如变成 ADD 状态。只要这个状态没变化,那么此后无论任何时候控制器再去查询对象的状态,都应该是 ADD。
而对于事件驱动来说,它只会在 ADD 事件发生的时候发出一个事件。如果控制器错过了这个事件,那么它就有可能再也没办法知道 ADD 这个事件的发生了。
问题选自第16篇文章《编排其实很简单谈谈“控制器”模型》。
问题5在实际场景中有一些分布式应用的集群是这么工作的当一个新节点加入到集群时或者老节点被迁移后重建时这个节点可以从主节点或者其他从节点那里同步到自己所需要的数据。
在这种情况下你认为是否还有必要将这个节点Pod与它的PV进行一对一绑定呢提示这个问题的答案根据不同的项目是不同的。关键在于重建后的节点进行数据恢复和同步的时候是不是一定需要原先它写在本地磁盘里的数据
这个问题的答案是不需要。
像这种不依赖于 PV 保持存储状态或者不依赖于 DNS 名字保持拓扑状态的”非典型“应用的管理,都应该使用 Operator 来实现。
问题选自第19篇文章《深入理解StatefulSet存储状态》。
问题6我在文中提到在Kubernetes v1.11之前DaemonSet所管理的Pod的调度过程实际上都是由DaemonSet Controller自己而不是由调度器完成的。你能说出这其中有哪些原因吗
这里的原因在于,默认调度器之前的功能不是很完善,比如,缺乏优先级和抢占机制。所以,它没办法保证 DaemonSet ,尤其是部署时候的系统级的、高优先级的 DaemonSet 一定会调度成功。这种情况下,就会影响到集群的部署了。
问题选自第21篇文章《容器化守护进程的意义DaemonSet》。
问题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正是由于需要使用“延迟绑定”这个特性Local Persistent Volume目前还不能支持Dynamic Provisioning。你是否能说出为什么“延迟绑定”会跟Dynamic Provisioning有冲突呢
延迟绑定将 Volume Bind 的时机,推迟到了第一个使用该 Volume 的 Pod 到达调度器的时候。可是对于Dynamic Provisioning 来说,它是要在管理 Volume的控制循环里就为 PVC 创建 PV 然后绑定起来的这个时间点跟Pod 被调度的时间点是不相关的。
问题选自第29篇文章《 PV、PVC体系是不是多此一举从本地持久化卷谈起》。
问题9请你根据编写FlexVolume和CSI插件的流程分析一下什么时候该使用FlexVolume什么时候应该使用CSI
在文章中我其实已经提到CSI 与 FlexVolume 的最大区别,在于 CSI 可以实现 Provision 阶段。所以说,对于不需要 Provision的情况 比如你的远程存储服务总是事先准备好或者准备起来非常简单的情况下就可以考虑使用FlexVolume。但在生产环境下我都会优先推荐 CSI的方案。
问题选自第31篇文章《容器存储实践CSI插件编写指南》。
问题10Flannel通过“隧道”机制实现了容器之间三层网络IP地址的连通性。但是根据这个机制的工作原理你认为Flannel能保证容器二层网络MAC地址的连通性吗为什么呢
不能保证,因为“隧道”机制只能保证被封装的 IP 包可以到达目的地。而只要网络插件能满足 Kubernetes 网络的三个假设Kubernetes 并不关心你的网络插件的实现方式是把容器二层连通的,还是三层连通的。
问题选自第33篇文章《深入解析容器跨主机网络》。
问题11你能否能总结一下三层网络方案和“隧道模式”的异同以及各自的优缺点
在第35篇文章的正文里我已经为你讲解过隧道模式最大的特点在于需要通过某种方式比如 UDP 或者 VXLAN 来对原始的容器间通信的网络包进行封装,然后伪装成宿主机间的网络通信来完成容器跨主通信。这个过程中就不可避免地需要封包和解封包。这两个操作的性能损耗都是非常明显的。而三层网络方案则避免了这个过程,所以性能会得到很大的提升。
不过隧道模式的优点在于它依赖的底层原理非常直白内核里的实现也非常成熟和稳定。而三层网络方案相对来说维护成本会比较高容易碰到路由规则分发和设置出现问题的情况并且当容器数量很多时宿主机上的路由规则会非常复杂难以Debug。
所以最终选择选择哪种方案,还是要看自己的具体需求。
问题选自第35篇文章《解读Kubernetes三层网络方案》。
问题12为什么宿主机进入MemoryPressure或者DiskPressure状态后新的Pod就不会被调度到这台宿主机上呢
在 Kubernetes 里,实际上有一种叫作 Taint Nodes by Condition 的机制,即当
Node 本身进入异常状态的时候,比如 Condition 变成了DiskPressure。那么 Kubernetes 会通过 Controller自动给Node加上对应的 Taint从而阻止新的 Pod 调度到这台宿主机上。
问题选自第40篇文章《Kubernetes的资源模型与资源管理》。
问题13Kubernetes默认调度器与Mesos的“两级”调度器有什么异同呢
Mesos 的两级调度器的设计是Mesos 自己充当0层调度器Layer 0负责统一管理整个集群的资源情况把可用资源以 Resource Offer 的方式暴露出去;而上层的大数据框架(比如 Spark则充当1层调度器Layer 1它会负责根据Layer 0发来的Resource Offer来决定把任务调度到某个具体的节点上。这样做的好处是
第一,上层大数据框架本身往往自己已经实现了调度逻辑,这样它就可以很方便地接入到 Mesos 里面;
第二这样的设计使得Mesos 本身能够统一地对上层所有框架进行资源分配,资源利用率和调度效率就可以得到很好的保证了。
相比之下Kubernetes 的默认调度器实际上无论从功能还是性能上都要简单得多。这也是为什么把 Spark 这样本身就具有调度能力的框架接入到 Kubernetes 里还是比较困难的。
问题选自第41篇文章《十字路口上的Kubernetes默认调度器》。
问题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请你思考一下我前面讲解过的Device Plugin 为容器分配的 GPU 信息,是通过 CRI 的哪个接口传递给 dockershim最后交给 Docker API 的呢?
既然 GPU 是Devices 信息那当然是通过CRI 的CreateContainerRequest接口。这个接口的参数ContainerConfig里就有容器 Devices 的描述。
问题选自第46篇文章《解读 CRI 与 容器运行时》。
问题16安全容器的意义绝不仅仅止于安全。你可以想象一下这样一个场景比如你的宿主机的 Linux 内核版本是3.6,但是应用却必须要求 Linux 内核版本是4.0。这时候,你就可以把这个应用运行在一个 KataContainers 里。那么请问,你觉得使用 gVisor 是否也能提供这种能力呢?原因是什么呢?
答案是不能。gVisor 的实现里并没有一个真正的Linux Guest Kernel 在运行。所以它不能像 KataContainers 或者虚拟机那样,实现容器和宿主机不同 Kernel 甚至不同操作系统的需求。
但还是要强调一下以gVisor 为代表的用户态 Kernel 方案是安全容器的未来,只是现在还不够完善。
问题选自第47篇文章《绝不仅仅是安全Kata Containers 与 gVisor》。
问题17将日志直接输出到 stdout 和 stderr有没有什么其他的隐患或者问题呢如何进行处理呢
这样做有一个问题,就是日志都需要经过 Docker Daemon 的处理才会写到宿主机磁盘上,所以宿主机没办法以容器为单位进行日志文件的 Rotate。这时候还是要考虑通过宿主机的 Agent 来对容器日志进行处理和收集的方案。
问题选自第50篇文章《让日志无处可逃容器日志收集与管理》。
问题18你能说出 Kubernetes 社区同 OpenStack 社区相比的不同点吗?你觉得各有哪些优缺点呢?
OpenStack 社区非常强调民主化,治理方式相对松散,这导致它在治理上没能把主线和旁线分开,政治和技术也没有隔离。这使得后期大量的低价值或者周边型的项目不断冲进 OpenStack社区大大降低了社区的含金量并且分散了大量的社区精力在这些价值相对不高的项目上从而拖慢并干扰了比如 Cinder、Neutron 等核心项目的演进步伐和方向,最终使得整个社区在容器的热潮下难以掉头,不可避免地走向了下滑的态势。
相比之下CNCF 基金会成功地帮助 Kubernetes 社区分流了低价值以及周边型项目的干扰,并且完全承接了 Marketing 的角色,使得 Kubernetes 社区在后面大量玩家涌入的时候,依然能够专注在主线的演进上。
Kubernetes社区和OpenStack社区的这个区别是非常关键的。
问题选自第51篇文章《谈谈Kubernetes开源社区和未来走向》。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,74 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送 2019 年,容器技术生态会发生些什么?
你好,我是张磊。
虽然“深入剖析Kubernetes”专栏已经更新结束了但我仍在挂念着每一个订阅专栏的“你”也希望能多和你分享一些我的观点和看法希望对你有所帮助。今天我和你分享的主题是2019年容器技术生态会发生些什么。
Kubernetes 项目被采纳度将持续增长
==========================
作为“云原生”Cloud Native理念落地的核心Kubernetes 项目已经成为了构建容器化平台体系的默认选择。但是不同于一个只能生产资源的集群管理工具Kubernetes 项目最大的价值,乃在于它从一开始就提倡的声明式 API 和以此为基础“控制器”模式。
在这个体系的指导下, Kubernetes 项目保证了在自身突飞猛进的发展过程中 API 层的相对稳定和一定的向后兼容能力,这是作为一个平台级项目被用户广泛接受和认可的重要前提。
更重要的是Kubernetes 项目为使用者提供了宝贵的 API 可扩展能力和良好的 API 编程范式催生出了一个完全基于Kubernetes API 构建出来的上层应用服务生态。可以说,正是这个生态的逐步完善与日趋成熟,才确立了 Kubernetes 项目如今在云平台领域牢不可破的领导地位,也间接宣告了其他竞品方案的边缘化。
与此同时,上述事实标准的确立,也使得“正确和合理地使用了 Kubernetes 的能力”,在某种意义上成为了评判上层应用服务框架(比如 PaaS 和 Serverless )的一个重要依据:这不仅包括了对框架本身复杂性和易用性的考量,也包括了对框架可扩展性和演进趋势的预期与判断。
不过,相比于国外公有云上以 Kubernetes 为基础的容器化作业的高占比,国内公有云市场对容器的采纳程度目前仍然处于比较初步的水平,直接贩卖虚拟机及其关联 IaaS 层能力依然是国内绝大多数公有云提供商的主要业务形态。
所以不同于国外市场容器技术增长逐步趋于稳定、Kubernetes 公有云服务已经开始支撑头部互联网客户的情况Kubernetes 以及容器技术在国内云计算市场里依然具有巨大的增长空间和强劲的发展势头。
不难预测Kubernetes 项目在国内公有云上的逐渐铺开,会逐渐成为接下来几年国内公有云市场上的一个重要趋势。而无论是国内外,大量 Kubernetes 项目相关岗位的涌现,正是验证这个趋势与变化的一个最直接的征兆。
“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 能为用户带来价值的最有力保障。
看得见、摸得着、能落地的“云原生”
=====================
自从 CNCF 社区迅速崛起以来,“云原生”三个字就成了各大云厂商竞相角逐的一个关键词。不过,相比于 Kubernetes 项目和容器技术实实在在的发展和落地过程云原生Cloud Native的概念却长期以来“曲高和寡”让人很难说出个所以然来。
其实,“云原生”的本质,不是简单对 Kubernetes 生态体系的一个指代。“云原生” 刻画出的,是一个使用户能低心智负担的、敏捷的,以可扩展、可复制的方式,最大化利用“云”的能力、发挥“云”的价值的一条最佳路径。
而这其中,“不可变基础设施” 是“云原生”的实践基础(这也是容器技术的核心价值);而 Kubernetes、Prometheus、Envoy 等 CNCF 核心项目,则可以认为是这个路径落地的最佳实践。这套理论体系的发展过程,与 CNCF 基金会创立的初衷和云原生生态的发展历程是完全一致的。
也正是伴随着这样的发展过程云原生对于它的使用者的意义在2019年之后已经变得非常清晰是否采用云原生技术体系实际上已经成为了一个关系到是不是要最大化“云”的价值、是不是要在“云”上赢取最广泛用户群体的一个关键取舍。这涉及到的是关系到整个组织的发展、招聘、产品形态等一系列核心问题而绝非一个单纯的技术决定。
明白了这一层道理在2019年我们已经不难看到国内最顶尖的技术公司们都已经开始在云原生技术框架下发起了实实在在的技术体系升级与落地的“战役”。显然大家都已经注意到相比于纠结于“云原生到底是什么”这样意识形态话题抓紧时间和机遇将 Kubernetes 及其周边核心技术生态在组织中生长起来,并借此机会完成自身基础技术体系的转型与升级,才是这些体量庞大的技术巨人赶上这次云计算浪潮的不二法宝。
在这个背景下,所谓“云原生”体系在这些公司的落地,只是这个激动人心的技术革命背后的一个附加值而已。
而在“云原生”这个关键词的含义不断清晰的过程中,我们一定要再次强调:云原生不等于 CNCF更不等于 Kubernetes。云原生固然源自于 Kubernetes 技术生态和理念,但也必然是一个超越 CNCF 和 Kubernetes 存在的一个全集。它被创立的目的和始终在坚持探索的方向,是使用户能够最大化利用“云”的能力、发挥“云”的价值,而不是在此过程中构建一个又一个不可复制、不可扩展的“巨型烟囱”。
所以说,云原生这个词语的准确定义,是围绕着 Kubernetes 技术生态为核心的,但也一定是一个伴随着 CNCF 社区和 Kubernetes 项目不断演进而日趋完善的一个动态过程。而更为重要的是,在这次以“云”为关键词的技术革命当中,我们每一个人都有可能成为“云原生”的一个重要的定义者。

View File

@@ -0,0 +1,171 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送 基于 Kubernetes 的云原生应用管理,到底应该怎么做?
你好,我是张磊。
虽然《深入剖析 Kubernetes》专栏已经完结了一段时间了但是在留言中很多同学依然在不时地推敲与消化专栏里的知识和案例。对此我非常开心同时也看到大家在实践 Kubernetes的过程中存在的很多问题。所以在接下来的一段时间里我会以 Kubernetes 最为重要的一个主线能力作为专题,对专栏内容从广度和深度两个方向上进行一系列延伸与拓展。希望这些内容,能够帮助你在探索这个全世界最受欢迎的开源生态的过程中,更加深刻地理解到 Kubernetes 项目的内涵与本质。
随着 Kubernetes 项目的日趋成熟与稳定,越来越多的人都在问我这样一个问题:现在的 Kubernetes 项目里,最有价值的部分到底是哪些呢?
为了回答这个问题我们不妨一起回到第13篇文章《为什么我们需要Pod》中来看一下几个非常典型的用户提问。
用户一关于升级War和Tomcat那块也是先修改yaml然后Kubenertes执行升级命令pod会重新启动生产也是按照这种方式吗所以这种情况下如果只是升级个War包或者加一个新的War包Tomcat也要重新启动这就不是完全松耦合了
用户二WAR包的例子并没有解决频发打包的问题吧? WAR包变动后, geektime/sample:v2包仍然需要重新打包。这和东西一股脑装在tomcat中后, 重新打tomcat 并没有差太多吧?
用户三关于部署war包和tomcat在升级war的时候先修改yaml然后Kubernetes会重启整个pod然后按照定义好的容器启动顺序流程走下去正常生产是按照这种方式进行升级的吗
在《为什么我们需要Pod》这篇文章中为了讲解 Pod 里容器间关系容器设计模式的典型场景我举了一个“WAR 包与 Web 服务器解耦”的例子。在这个例子中,我既没有让你通过 Volume 的方式将 WAR 包挂载到 Tomcat 容器中,也没有建议你把 WAR 包和 Tomcat 打包在一个镜像里,而是用了一个 InitContainer 将 WAR 包“注入”给了Tomcat 容器。
不过,不同用户面对的场景不同,对问题的思考角度也不一样。所以在这一节里,大家提出了很多不同维度的问题。这些问题总结起来,其实无外乎有两个疑惑:
如果 WAR 包更新了,那不是也得重新制作 WAR 包容器的镜像么?这和重新打 Tomcat 镜像有很大区别吗?
当用户通过 YAML 文件将 WAR 包镜像更新后,整个 Pod 不会重建么Tomcat 需要重启么?
这里的两个问题,实际上都聚焦在了这样一个对于 Kubernetes 项目至关重要的核心问题之上:基于 Kubernetes 的应用管理,到底应该怎么做?
比如,对于第一个问题,在不同规模、不同架构的组织中,可能有着不同的看法。一般来说,如果组织的规模不大、发布和迭代次数不多的话,将 WAR 包(应用代码)的发布流程和 Tomcat Web 服务器的发布流程解耦实际上很难有较强的体感。在这些团队中Tomcat 本身很可能就是开发人员自己负责管理的,甚至被认为是应用的一部分,无需进行很明确的分离。
而对于更多的组织来说Tomcat 作为全公司通用的 Web 服务器,往往有一个专门的小团队兼职甚至全职负责维护。这不仅包括了版本管理、统一升级和安全补丁等工作,还会包括全公司通用的性能优化甚至定制化内容。
在这种场景下WAR 包的发布流水线(制作 WAR包镜像的流水线和 Tomcat 的发布流水线(制作 Tomcat 镜像的流水线)其实是通过两个完全独立的团队在负责维护,彼此之间可能都不知晓。
这时候,在 Pod 的定义中直接将两个容器解耦,相比于每次发布前都必须先将两个镜像“融合”成一个镜像然后再发布,就要自动化得多了。这个原因是显而易见的:开发人员不需要额外维护一个“重新打包”应用的脚本、甚至手动地去做这个步骤了。
这正是上述设计模式带来的第一个好处:自动化。
当然,正如另外一些用户指出的那样,这个“解耦”的工作,貌似也可以通过把 WAR 包以 Volume 的方式挂载进 Tomcat 容器来完成,对吧?
然而,相比于 Volume 挂载的方式,通过在 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 里应用的版本是多少Tomcat 的版本又是多少?
相信你一眼就能看出来:应用版本是 v2Tomcat 的版本是 7.0.42-v2。
没错!所以我们说,一个良好编写的 Pod的 YAML 文件应该是“自描述”的,它直接描述了这个应用本身的所有信息。
但是,如果我们改用 Volume 挂载的方式来解耦WAR 包和 Tomcat 服务器,这个 Pod 的 YAML 文件会变成什么样子呢?如下所示:
apiVersion: v1
kind: Pod
metadata:
name: javaweb-2
spec:
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
flexVolume:
driver: "alicloud/disk"
fsType: "ext4"
options:
volumeId: "d-bp1j17ifxfasvts3tf40"
在上面这个例子中我们就通过了一个名叫“app-volume”的数据卷Volume来为我们的 Tomcat 容器提供 WAR 包文件。需要注意的是,这个 Volume 必须是持久化类型的数据卷(比如本例中的阿里云盘),绝不可以是 emptyDir 或者 hostPath 这种临时的宿主机目录,否则一旦 Pod 重调度你的 WAR 包就找不回来了。
然而,如果这时候我再问你:这个 Pod 里应用的版本是多少Tomcat 的版本又是多少?
这时候,你可能要傻眼了:在这个 Pod YAML 文件里,根本看不到应用的版本啊,它是通过 Volume 挂载而来的!
也就是说,这个 YAML文件再也没有“自描述”的能力了。
更为棘手的是,在这样的一个系统中,你肯定是不可能手工地往这个云盘里拷贝 WAR 包的。所以上面这个Pod 要想真正工作起来,你还必须在外部再维护一个系统,专门负责在云盘里拷贝指定版本的 WAR 包,或者直接在制作这个云盘的过程中把指定 WAR 包打进去。然而,无论怎么做,这个工作都是非常不舒服并且自动化程度极低的,我强烈不推荐。
要想 “Volume 挂载”的方式真正能工作,可行方法只有一种:那就是写一个专门的 Kubernetes Volume 插件比如Flexvolume或者CSI插件 。这个插件的特殊之处,在于它在执行完 “Mount 阶段”后,会自动执行一条从远端下载指定 WAR 包文件的命令,从而将 WAR 包正确放置在这个 Volume 里。这个 WAR 包文件的名字和路径,可以通过 Volume 的自定义参数传递,比如:
...
volumes:
- name: app-volume
flexVolume:
driver: "geektime/war-vol"
fsType: "ext4"
options:
downloadURL: "https://github.com/geektime/sample/releases/download/v2/sample.war"
在这个例子中, 我就定义了 app-volume 的类型是 geektime/war-vol在挂载的时候它会自动从 downloadURL 指定的地址下载指定的 WAR 包,问题解决。
可以看到,这个 YAML 文件也是“自描述”的:因为你可以通过 downloadURL 等参数知道这个应用到底是什么版本。看到这里,你是不是已经感受到 “Volume 挂载的方式” 实际上一点都不简单呢?
在明白了我们在 Pod 定义中解耦 WAR 包容器和 Tomcat 容器能够得到的两个好处之后,第一个问题也就回答得差不多了。这个问题的本质,其实是一个关于“ Kubernetes 应用究竟应该如何描述”的问题。
而这里的原则,最重要的就是“自描述”。
我们之前已经反复讲解过Kubernetes 项目最强大的能力就是“声明式”的应用定义方式。这个“声明式”背后的设计思想是在YAML 文件Kubernetes API 对象)中描述应用的“终态”。然后 Kubernetes 负责通过“控制器模式”不断地将整个系统的实际状态向这个“终态”逼近并且达成一致。
而“声明式”最大的好处是什么呢?
“声明式”带来最大的好处,其实正是“自动化”。作为一个 Kubernetes 用户,你只需要在 YAML 里描述清楚这个应用长什么样子,那么剩下的所有事情,就都可以放心地交给 Kubernetes 自动完成了:它会通过控制器模式确保这个系统里的应用状态,最终并且始终跟你在 YAML 文件里的描述完全一致。
这种“把简单交给用户,把复杂留给自己”的精神,正是一个“声明式”项目的精髓所在了。
这也就意味着,如果你的 YAML 文件不是“自描述”的,那么 Kubernetes 就不能“完全”理解你的应用的“终态”到底是什么样子的,它也就没办法把所有的“复杂”都留给自己。这不,你就得自己去写一个额外 Volume 插件去了。
回到之前用户提到的第二个问题:当通过 YAML 文件将 WAR 包镜像更新后,整个 Pod 不会重建么Tomcat 需要重启么?
实际上,当一个 Pod 里的容器镜像被更新后kubelet 本身就能够判断究竟是哪个容器需要更新而不会“无脑”地重建整个Pod。当然你的 Tomcat 需要配置好 reloadable=“true”这样就不需要重启 Tomcat 服务器了,这是一个非常常见的做法。
但是,这里还有一个细节需要注意。即使 kubelet 本身能够“智能”地单独重建被更新的容器,但如果你的 Pod 是用 Deployment 管理的话它会按照自己的发布策略RolloutStrategy 来通过重建的方式更新 Pod。
这时候,如果这个 Pod 被重新调度到其他机器上,那么 kubelet “单独重建被更新的容器”的能力就没办法发挥作用了。所以说,要让这个案例中的“解耦”能力发挥到最佳程度,你还需要一个“原地升级”的功能,即:允许 Kubernetes 在原地进行 Pod 的更新,避免重调度带来的各种麻烦。
原地升级能力,在 Kubernetes 的默认控制器中都是不支持的。但,这是社区开源控制器项目 https://github.com/openkruise/kruise 的重要功能之一,如果你感兴趣的话可以研究一下。
总结
说到这里,再让我们回头看一下文章最开始大家提出的共性问题:现在的 Kubernetes 项目里,最有价值的部分到底是哪些?这个项目的本质到底在哪部分呢?
实际上,通过深入地讲解 “Tomcat 与 WAR 包解耦”这个案例,你可以看到 Kubernetes 的“声明式 API”“容器设计模式”“控制器原理”以及kubelet 的工作机制等很多核心知识点,实际上是可以通过一条主线贯穿起来的。这条主线,从“应用如何描述”开始,到“容器如何运行”结束。
这条主线,正是 Kubernetes 项目中最具价值的那个部分云原生应用管理Cloud Native Application Management。它是一条连接 Kubernetes 项目绝大多数核心特性的关键线索,也是 Kubernetes 项目乃至整个云原生社区这五年来飞速发展背后唯一不变的精髓。

View File

@@ -0,0 +1,31 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 Kubernetes赢开发者赢天下
你好,我是张磊。
在本专栏一开始我用了大量的笔墨和篇幅和你探讨了这样一个话题Kubernetes 为什么会赢?
而在当时的讨论中我为你下了这样一个结论Kubernetes 项目之所以能赢,最重要的原因在于它争取到了云计算生态里的绝大多数开发者。不过,相信在那个时候,你可能会对这个结论有所疑惑:大家不都说 Kubernetes 是一个运维工具么?怎么就和开发者搭上了关系呢?
事实上Kubernetes 项目发展到今天,已经成为了云计算领域中平台层当仁不让的事实标准。但这样的生态地位,并不是一个运维工具或者 Devops 项目所能达成的。这里的原因也很容易理解Kubernetes 项目的成功,是成千上万云计算平台上的开发者用脚投票的结果。而在学习完本专栏之后,相信你也应该能够明白,云计算平台上的开发者们所关心的,并不是调度,也不是资源管理,更不是网络或者存储,他们关心的只有一件事,那就是 Kubernetes 的 API。
这也是为什么,在 Kubernetes 这个项目里,只要是跟 API 相关的事情,那就都是大事儿;只要是想要在这个社区构建影响力的人或者组织,就一定会在 API 层面展开角逐。这一层 “API 为王”的思路,早已经深入到了 Kubernetes 里每一个 API 对象的每一个字段的设计过程当中。
所以说Kubernetes 项目的本质其实只有一个,那就是“控制器模式”。这个思想,不仅仅是 Kubernetes 项目里每一个组件的“设计模板”也是Kubernetes 项目能够将开发者们紧紧团结到自己身边的重要原因。作为一个云计算平台的用户,能够用一个 YAML 文件表达我开发的应用的最终运行状态,并且自动地对我的应用进行运维和管理。这种信赖关系,就是连接 Kubernetes 项目和开发者们最重要的纽带。更重要的是,当这个 API 趋向于足够稳定和完善的时候,越来越多的开发者会自动汇集到这个 API 上来,依托它所提供的能力构建出一个全新的生态。
事实上,在云计算发展的历史上,像这样一个围绕一个 API 创建出一个“新世界”的例子,已经出现过了一次,这正是 AWS 和它庞大的开发者生态的故事。而这一次 Kubernetes 项目的巨大成功,其实就是 AWS 故事的另一个版本而已。只不过,相比于 AWS 作为基础设施层提供运维和资源抽象标准的故事Kubernetes 生态终于把触角触碰到了应用开发者的边界,使得应用的开发者可以有能力去关心自己开发的应用的运行状态和运维方法,实现了经典 PaaS 项目很多年前就已经提出、但却始终没能达成的美好愿景。
这也是为什么我在本专栏里一再强调Kubernetes 项目里最重要的,是它的“容器设计模式”,是它的 API 对象,是它的 API 编程范式。这些,都是未来云计算时代的每一个开发者需要融会贯通、融化到自己开发基因里的关键所在。也只有这样,作为一个开发者,你才能够开发和构建出符合未来云计算形态的应用。而更重要的是,也只有这样,你才能够借助云计算的力量,让自己的应用真正产生价值。
而通过本专栏的讲解,我希望你能够真正理解 Kubernetes API 背后的设计思想,能够领悟 Kubernetes 项目为了赢得开发者信赖的“煞费苦心”。更重要的是,当你带着这种“觉悟”再去理解和学习 Kubernetes 调度、网络、存储、资源管理、容器运行时的设计和实现方法时,才会真正触碰到这些机制隐藏在文档和代码背后的灵魂所在。
所以说,当你不太理解为什么要学习 Kubernetes 项目的时候,或者,你在学习 Kubernetes 项目感到困难的时候,不妨想象一下 Kubernetes 就是未来的 Linux 操作系统。在这个云计算以前所未有的速度迅速普及的世界里Kubernetes 项目很快就会像操作系统一样,成为每一个技术从业者必备的基础知识。而现在,你不仅牢牢把握住了这个项目的精髓,也就是声明式 API 和控制器模式;掌握了这个 API 独有的编程范式,即 Controller 和 Operator还以此为基础详细地了解了这个项目每一个核心模块和功能的设计与实现方法。那么对于这个未来云计算时代的操作系统你还有什么好担心的呢
所以说,《深入剖析 Kubernetes 》专栏的结束,其实是你技术生涯全新的开始。我相信你一定能够带着这个“赢开发者赢天下”的启发,在云计算的海洋里继续乘风破浪、一往无前!