first commit
This commit is contained in:
120
专栏/周志明的架构课/00_导读_什么是“TheFenixProject”?.md
Normal file
120
专栏/周志明的架构课/00_导读_什么是“TheFenixProject”?.md
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 _导读 _ 什么是“The Fenix Project”?
|
||||
你好,我是周志明。
|
||||
|
||||
在开篇词中,我在介绍“探索与实践”这个模块的时候,提到会带你开发不同架构的Fenix’s Bookstore。你是不是还不清楚这个项目是啥,以及为什么要做这么一个项目。所以,这一讲我要再和你说一说这门课的来源,这样你就会更清楚为什么会这么设计了。
|
||||
|
||||
因为我一直看重“布道与分享”对梳理、扎实知识体系的重大作用,所以便萌生了把自己这十几年软件开发工作中用到的架构知识,进行梳理,并以文字的形式分享出来的想法。于是,2019年底,我就开始了这项浩大的工程,在GitHub写了一部叫做《软件架构探索:The Fenix Project》的开源文档。
|
||||
|
||||
后来我又和极客时间的编辑讨论,为了让更多的开发者能从中收益,让他们可以相对轻松地跟着我一起进行这次的软件架构探索之旅,所以就再一次整理成了“图文+音频”的形式。
|
||||
|
||||
后面,我也会基于这个开源文档再出版一本纸质图书。如果你在这门课更新的过程中,分享了优质的留言并被课程编辑展示了出来,我也会送你一本有我亲笔签名的书。
|
||||
|
||||
其实,“软件架构探索”的意思是清晰的,但乍一听到“The Fenix Project”,是不是还很难判断这门课到底要做什么呢。确实如此,所以接下来,我们就先从“Phoenix”这个词说起吧。
|
||||
|
||||
软件架构探索
|
||||
|
||||
“Phoenix”的字面意思,就是“凤凰”,或者是“不死鸟”,这个词我们东方人不太常用,但它在西方的软件工程读物,尤其是关于Agile、DevOps话题的作品中经常会出现。
|
||||
|
||||
比如说,软件工程小说《The Phoenix Project》,就讲述了徘徊在死亡边缘的Phoenix项目,在精益方法下浴火重生的故事;马丁 · 福勒(Martin Fowler)对《Continuous Delivery》(持续交付)的诠释里,也多次提到过“Phoenix Server”(取其能够“涅槃重生”之意)与“Snowflake Server”(取其“世界上没有相同的两片雪花”之意)的优劣比对。
|
||||
|
||||
|
||||
|
||||
The Phoenix Project
|
||||
|
||||
也许是东西方文化差异的原因,尽管我们东方人会说“失败是成功之母”,但骨子里还是更注重一次就能把事做对、做好,尽量别出乱子;而西方人则要“更看得开”一些,把出错看作是正常、甚至是必须的发展过程,只要出了问题能够兜底,能重回正轨就好。
|
||||
|
||||
其实在软件工程的世界里,任何产品的研发,只要时间尺度足够长,人就总会疏忽犯错,代码就总会带有缺陷,电脑就总会宕机崩溃,网络就总会堵塞中断……
|
||||
|
||||
所以如果一项工程需要大量的人员共同去研发,并保证它们分布在网络中的大量服务器节点能够同时运行,那么随着项目规模的增大、运作时间变长,它必然会受到墨菲定律的无情打击。
|
||||
|
||||
|
||||
Murphy’s Law: Anything that can go wrong will go wrong.-
|
||||
墨菲定律:凡事只要有可能出错,那就一定会出错。
|
||||
|
||||
|
||||
这样问题就来了:为了得到高质量的软件产品,我们是应该把精力更多地集中在提升每一个人员、过程、产出物的能力和质量上,还是放在整体流程和架构上?
|
||||
|
||||
这里我先给一个“和稀泥”式的回答:这两者都重要。前者重术,后者重道;前者更多与编码能力相关,后者更多与软件架构相关;前者主要由开发者个体水平决定,后者主要由技术决策者水平决定。
|
||||
|
||||
但是,我也必须要强调这个问题的另外一面:这两者的理解路径和抽象程度是不一样的。
|
||||
|
||||
如何学习一项具体的语言、框架、工具,比如Java、Spring、Vue.js,等等,都是相对具象的,不论其蕴含的内容多少、复杂程度的高低,它们至少是能看得见、摸得着的。
|
||||
|
||||
而如何学习某一种风格的架构方法,比如单体、微服务、服务网格、无服务、云原生,等等,则是相对抽象的,谈论它们可能要面临着“一百个人眼中有一百个哈姆雷特”的困境。
|
||||
|
||||
所以,探讨这方面的话题,要想言之有物,就不能只是单纯的经验陈述了。
|
||||
|
||||
那么我就想,回到这些架构根本的出发点和问题上,带你一起真正去使用这些不同风格的架构方法,来实现某些需求、解决某些问题,然后在实践中观察它们的异同优劣,这会是一种很好的,也许是最好的学习方式。
|
||||
|
||||
可靠的系统
|
||||
|
||||
我们接着前面提出的“人与系统”的探讨,再来思考一个问题:构建一个大规模但依然可靠的软件系统,是否可行?
|
||||
|
||||
看到这个问题,你的第一感觉可能会认为有点荒谬:废话。如果这个事情从理论上来说就根本不可能的话,那我们这些做软件开发的还在瞎忙活些什么呢?
|
||||
|
||||
但你再仔细想想,根据“墨菲定律”和在“大规模”这个前提下,在做软件开发时,你一定会遇到各种不靠谱的人员、代码、硬件、网络等因素。那你从中就能得出一个听起来很符合逻辑直觉的推论:如果一项工作,要经过多个“不靠谱”的过程相互协作来完成,其中的误差应该会不断地累积叠加,导致最终结果必然不能收敛稳定才对。
|
||||
|
||||
这个问题,也并不是杞人忧天、庸人自扰式的瞎操心,计算机之父冯 · 诺依曼(John von Neumann)在1940年代末期,就曾经花了大约两年的时间,来研究这个问题,并且得出了一门理论《自复制自动机》(Theory of Self-Reproducing Automata),这个理论以机器应该如何从基本的部件中,构造出与自身相同的另一台机器引出。
|
||||
|
||||
他的目的并不是想单纯地模拟或者理解生物体的自我复制,也并不是简单地想制造自我复制的计算机,而就是想回答一个理论问题:如何用一些不可靠的部件来构造出一个可靠的系统。
|
||||
|
||||
|
||||
|
||||
当时自复制机的艺术表示
|
||||
|
||||
所以说,自复制机恰好就是一个最好的、用不可靠部件构造的可靠系统的例子。这里的“不可靠部件”,你可以理解为构成生命的大量细胞、甚至是分子。由于热力学扰动、生物复制差错等因素的干扰,这些分子本身并不可靠。
|
||||
|
||||
但是,生命系统之所以可靠的本质,恰恰是因为它可以使用不可靠的部件来完成遗传迭代。这其中的关键点,便是承认细胞、分子等这些零部件可能会出错,某个具体的零部件可能会崩溃消亡,但在存续生命的微生态系统中,一定会有其后代的出现,重新代替该零部件的作用,以维持系统的整体稳定。
|
||||
|
||||
因而,在这个微生态里,每一个部件都可以看作是一只不死鸟(Phoenix),它会老迈,而之后又能涅槃重生。
|
||||
|
||||
虽然几乎是在计算机诞生的同时,计算机科学家就开始研究如何构造可靠的软件系统,并且得到了“像Phoenix一样迭代的生态才是可靠的”明确的结论,但是软件架构却不是一蹴而就地直接照这个结论去设计。原因也很简单,因为软件架构有一个逐渐演进的过程。
|
||||
|
||||
架构的演进
|
||||
|
||||
软件架构风格从大型机(Mainframe),发展到了多层单体架构(Monolithic),到分布式(Distributed),到微服务(Microservices),到服务网格(Service Mesh),到无服务(Serverless)……你能发现,在技术架构上确实呈现出“从大到小”的发展趋势。
|
||||
|
||||
这几年微服务兴起后,出现了各类文章去总结、去赞美它的各种好处,比如简化了部署、逻辑拆分更清晰、便于技术异构、易于伸缩拓展应对更高的性能,等等。没错,这些都是微服务架构非常重要的优点,也是企业去搭建微服务的动力。
|
||||
|
||||
可是,如果不拘泥于特定系统或特定的某个问题,我们从更宏观的角度来看,前面所列举的这些好处,都只能算是“锦上添花”、是让系统“活得更好”的动因,肯定比不上系统如何“确保生存”的需求来得更关键、本质。
|
||||
|
||||
在我看来,架构演变最重要的驱动力,或者说产生这种“从大到小”趋势的最根本的驱动力,始终都是为了方便某个服务能够顺利地“死去”与“重生”而设计的。个体服务的生死更迭,是关系到整个系统能否可靠续存的关键因素。
|
||||
|
||||
我举个例子。假设某个企业中应用的是单体架构的Java系统,它的更新、升级都必须要有固定的停机计划,必须在特定的时间窗口内才能按时开始,而且必须按时结束。如果出现了非计划之中的宕机,那就是生产事故。
|
||||
|
||||
但是,软件的缺陷不会遵循领导定下的停机计划来“安排时间出错”,所以为了应对缺陷与变化,做到不停机地检修,Java曾经搞出了OSGi和JVMTI Instrumentation等这样复杂的HotSwap方案,以实现给奔跑中的汽车更换轮胎这种匪夷所思却又无可奈何的需求。
|
||||
|
||||
而在微服务架构的视角下,所谓的系统检修,只不过是一次在线服务更新而已,先停掉1/3的机器,升级新的软件版本,再有条不紊地导流、测试、做金丝雀发布,一切都是显得如此理所当然;而在无服务架构的视角下,我们甚至都不可能去关心服务所运行的基础设施,甚至连机器是哪台都不用知道,停机升级什么的就根本无从谈起了。
|
||||
|
||||
流水不腐,有老朽、有消亡、有重生、有更迭,才是正常生态的运作合理规律。
|
||||
|
||||
那么你来设想一下,如果你的系统中,每个部件都符合“Phoenix”的特性,哪怕其中的某些部件采用了极不靠谱的程序代码,哪怕存在严重的内存泄漏问题,最多只能服务三分钟就一定会崩溃。而即便这样,只要在整体架构设计中,有恰当的、自动化的错误熔断、服务淘汰和重建的机制,那在系统外部来观察,它在整体上仍然有可能表现出稳定和健壮的服务能力。
|
||||
|
||||
铺垫到这里,我就可以给你解释清楚,到底什么是“The Fenix Project”了。
|
||||
|
||||
为什么叫做“The Fenix Project”?
|
||||
|
||||
你应该也知道,在企业软件开发的历史中,当发布一项新技术的时候,常常会有伴以该技术开发的“宠物店(PetStore)”作为演示的传统(如J2EE PetStore、.NET PetShop、Spring PetClinic等)。
|
||||
|
||||
所以,在课程里,我在带你做不同架构风格的演示时,也希望能遵循这个传统。不过无奈的是,我从来没养过宠物,于是就改行开了书店(Fenix’s Bookstore),里面出售了几本我写过的书,算是夹带了一点私货,这样也避免了在使用素材时可能产生的版权问题。
|
||||
|
||||
另外,尽管我相信没有人会误解,但我还是想多强调一句,Oracle、Microsoft、Pivotal等公司设计宠物店的目的,绝不是为了日后能在网上贩卖小猫小狗,他们只是在纯粹地演示技术。
|
||||
|
||||
所以说,你也不要以“实现这种学生毕业设计复杂度的需求,却引入如此规模的架构或框架,纯属大炮打苍蝇,肯定是过度设计”的眼光,来看待这个“Fenix’s Bookstore”项目。
|
||||
|
||||
相反,如果可能的话,我会在有新的技术、框架发布出来的时候,持续更新,以恰当的形式添加到项目的不同版本中,让它的技术栈越来越复杂。我希望把这些新的、不断发展的知识,融合进已有的知识框架之中,让自己学习、理解、思考,然后将这些技术连同自己的观点看法,分享给你。
|
||||
|
||||
说到这儿,我和“Fenix”这个名字还有一段奇妙的缘分。在二十多年前,我就开始用“IcyFenix”这个网名了。这个名字来源于暴雪公司的即时战略游戏《星际争霸》,里面有一个Protoss(普罗托斯)英雄叫Fenix(菲尼克斯)。就像这个名字所预示的那样,Fenix曾经是Zealot(狂热者),牺牲后以Dragoon(龙骑兵)的形式重生,带领Protoss与刀锋女王Kerrigan(凯瑞甘)继续抗争。
|
||||
|
||||
所以,既然我们要开始一段关于“Phoenix”的代码与故事,那便叫它“The Fenix Project”,如何?
|
||||
|
||||
好了,现在你对这门课程的设计和讲解思路就已经非常了解了,那么,你是否也制定了一些学习计划?不妨分享出来,让我们一起开启这趟软件架构的学习之旅。
|
||||
|
||||
|
||||
|
||||
|
||||
71
专栏/周志明的架构课/00开篇词_如何构建一个可靠的分布式系统?.md
Normal file
71
专栏/周志明的架构课/00开篇词_如何构建一个可靠的分布式系统?.md
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 _ 如何构建一个可靠的分布式系统?
|
||||
你好,我是周志明,一名纯粹的软件开发者。之所以说“纯粹”,是因为我想强调,研究技术、编写程序不仅是我养家糊口的技能,也是我最大的兴趣爱好。
|
||||
|
||||
在十几年的职业生涯里,我有过很多种不同的标签。比如说,企业管理者、实验室研究员、技术布道师、计算机作家,等等。它们都有一个共同点,就是强调要把自己的想法、理念分享出来,影响他人。
|
||||
|
||||
所以,你很可能是从《深入理解Java虚拟机》这一类技术书籍知道我的名字的。确实,我之前出版过7本计算机技术类的书籍,还写过两部开源文档。特别开心的是,这些原创技术书的口碑和销量都得到了大家的认可,其中有四本书在豆瓣上的评分还超过了9分。现在,分享与布道这件事儿也成了我在技术与代码之外的另一个兴趣,当然它也给我带来了很多机会和荣誉。
|
||||
|
||||
不过除了分享和布道,其实最贴合我的标签,还是程序员。对我来说,这也是我最看重的身份。十几年间,我从一个面向业务逻辑与局部功能编码的程序员,逐步成长为了一名对系统全局负责的技术架构师。现在,我主要是在做一些大型企业级软件的研发工作,也参与和主导过多个国家级的软件项目。
|
||||
|
||||
那么,作为一名架构师,在软件研发的过程中,最难的事儿,其实并不是如何解决具体某个缺陷、如何提升某段代码的性能,而是如何才能让一系列来自不同开发者、不同厂商、不同版本、不同语言、质量也良莠不齐的软件模块,在不同的物理硬件和拓扑结构随时变动的网络环境中,依然能保证可靠的运行质量。
|
||||
|
||||
显然,这并不是一个研发过程的管理问题。一套“靠谱”的软件系统,尤其是大型的、分布式的软件系统,很难指望只依靠团队成员的个人能力水平,或者依靠质量管理流程来达成。
|
||||
|
||||
在我看来,这是一个系统性的、架构层面的问题,最终还是要在技术和架构中去解决。而这也正是我要在这门课中跟你一起探讨的主题:如何构建一个可靠的分布式系统。
|
||||
|
||||
我是怎么规划课程的?
|
||||
|
||||
那么,为了能够讨论清楚这个话题,我把课程划分成了以下5个模块。
|
||||
|
||||
演进中的架构:我会借着讨论历史之名,从全局性的视角,帮你梳理微服务发展历程中出现的大量技术名词、概念,让你了解这些技术的时代背景和探索过程,帮你在后续的课程讲解中,更容易去深入理解软件架构设计的本质。
|
||||
|
||||
架构师的视角:我不会局限在某种架构的通用技巧,而是会带你系统性地了解在做架构设计的时候,架构师都应该思考哪些问题、可以选择哪些主流的解决方案和行业标准做法,以及这些主流方案都有什么优缺点、会给架构设计带来什么影响,等等。这样一来,我们才可以把“架构设计”这样比较抽象的工作具体化、具象化。
|
||||
|
||||
分布式的基石:我会聚焦在分布式架构,和你探讨分布式带来的问题与应对策略。我会带你剖析分布式架构中出现的一系列问题,比如服务的注册发现、跟踪治理、负载均衡、故障隔离、认证授权、伸缩扩展、传输通讯、事务处理等,有哪些解决思路、方法和常见工具。
|
||||
|
||||
不可变基础设施:我会按照云原生时代“基础设施即代码”的新思路,带你深入理解基础设施不变性的目的、原理与实现途径,和你一起去体会用代码和用基础设施,来解决分布式问题的差异,让你能够理解不可变基础设施的内涵,便于在实际工作中做运维、程序升级和部署等工作。
|
||||
|
||||
探索与实践:我会带你一起开发不同架构的Fenix’s Bookstore(“导读”这一讲会具体介绍这个项目),并看看在不同环境下都应该怎么部署。这个模块的定位是“实战”,为了保证学习效果,我特意没有安排音频,所以建议你一定要自己动手去实操。
|
||||
|
||||
因为我相信,如果你是一名驾驶初学者,最合理的学习路径应该是先把汽车发动,然后慢慢行驶起来,而不是马上从“引擎动力原理”“变速箱构造”入手,去设法深刻地了解一台汽车。学习计算机技术也是同样的道理。所以在“探索与实践”模块,我会先带你从运行程序开始,看看效果,然后再搭建好开发、调试环境。
|
||||
|
||||
说到这里,我一定要和你说说怎么学习这门课,才能保证最好的效果。
|
||||
|
||||
你要怎么学习这门课?
|
||||
|
||||
如果你已经是一名系统架构师或者高级开发工程师了,那这门课程就非常适合你。通过跟随学习,你会知道,在软件设计、架构工作中,都需要考虑哪些因素、需要解决哪些问题、有哪些行业标准的解决方案。而如果你是个刚入行不久的程序员,那你可以把这门课程作为一个概念名词的速查手册。
|
||||
|
||||
很多内容对你来说可能是全新的,甚至会颠覆你过去的一些认知。而这门课程的好处就是,在不同的技术水平阶段,你都会找到不同的使用方法。具体怎么做呢?
|
||||
|
||||
|
||||
第一步,先完整地跟着课程的节奏学习一遍。你可以先去串一下各种技术名词和架构理论概念,拓展一下视野,去看看大型的架构项目是怎么搭建的,涨涨见识,不一定要求自己深入地理解和记住每一讲的内容。
|
||||
第二步,根据自己当前的情况,按图索骥寻找对应的章节深入学习并实践。
|
||||
第三步,当你有了一定的实践经验之后,再来重新学习对应的章节,看看自己曾经的理解是否有遗漏或者有偏差,或者看看我的内容是否还有不完善的地方,真正将知识变成自己的认知。
|
||||
|
||||
|
||||
写在最后
|
||||
|
||||
最后,我想说的是,我在极客时间上开设这门课程,既是为了分享与技术布道,也是为了借这个机会,系统性地整理自己的知识,查缺补漏,将它们都融入既有的知识框架之中。
|
||||
|
||||
我一直认为,技术人员的成长是有“捷径”的,做技术不仅要去看、去读、去想、去用,更要去写、去说。
|
||||
|
||||
把自己“认为掌握了的”知识给叙述出来,能够写得条理清晰,讲得理直气壮;能够让别人听得明白,释去心中疑惑;能够把自己的观点交给别人审视,乃至质疑。在这个过程之中,就会挖掘出很多潜藏在“已知”背后的“未知”。
|
||||
|
||||
那么既然如此,我也非常希望,你能够在学习课程的过程当中,也记录下自己的所学所得、所思所想,然后分享给我和其他同学。在分享的过程中,相信我们一定都会有所收获。
|
||||
|
||||
所以在这里,我也想发起一个活动:在课程更新的过程中,分享出你的学习心得、实践感悟等等,或者也可以分享出你自己在架构设计中的实践经历、遇到的坑以及避坑的经验。在期中和期末时,课程编辑会甄选出优秀的留言分享内容,专门做一个展示模块,最后还会送出这门课程的纸质版图书。
|
||||
|
||||
另外,我还会从中挑选一些比较有代表性的留言做针对性点评,期待在留言区看到你的身影呀!欢迎你的踊跃参与,这也是给自己增添一份学习动力~
|
||||
|
||||
OK,最后的最后,我还想正式认识一下你。你可以在留言区里做个自我介绍,和我聊聊,你目前对于软件架构设计的最大难点在哪?或者,你也可以聊聊你对软件架构都有哪些独特的思考和体验,欢迎在留言区和我交流讨论。
|
||||
|
||||
好了,让我们正式开始学习之旅吧!
|
||||
|
||||
|
||||
|
||||
|
||||
109
专栏/周志明的架构课/01_原始分布式时代:Unix设计哲学下的服务探索.md
Normal file
109
专栏/周志明的架构课/01_原始分布式时代:Unix设计哲学下的服务探索.md
Normal file
@@ -0,0 +1,109 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 _ 原始分布式时代:Unix设计哲学下的服务探索
|
||||
你好,我是周志明。欢迎你来到“软件架构课”,从今天开始,我们就进入课程的第一个模块“演进中的架构”。
|
||||
|
||||
架构并不是被“发明”出来的,而是持续进化的结果。所以在这一模块中,我会借着讨论历史之名,从全局性的视角,来带你一起梳理下微服务的发展历程中,出现的大量技术名词、概念。
|
||||
|
||||
我会和你一起去分析,它们都是什么、取代了什么,以及为什么能够在技术发展的斗争中取得成功,为什么会成为软件架构不可或缺的支撑;又或者它们为什么会失败,为什么会逐渐被我们遗忘。
|
||||
|
||||
了解了这些技术的时代背景和探索过程,在后续的课程中,我再去讲解它们的原理、它们是如何解决问题的时候,你就能与它们当初的设计思想产生共鸣,能更容易深入理解其本质了。
|
||||
|
||||
今天这一讲,让我们先把时间拨回到半个世纪之前,一起来探讨下计算机最开始进入公众视野的时候,在Unix设计哲学的指导下,分布式架构的第一次服务化探索的得与失。
|
||||
|
||||
|
||||
Unix的分布式设计哲学-
|
||||
Simplicity of both the interface and the implementation are more important than any other attributes of the system — including correctness, consistency, and completeness.-
|
||||
保持接口与实现的简单性,比系统的任何其他属性,包括准确性、一致性和完整性,都来得更加重要。-
|
||||
—— Richard P. Gabriel,The Rise of ‘Worse is Better,1991
|
||||
|
||||
|
||||
分布式架构的目标是使用多个独立的分布式服务,来共同构建一个更大型的系统。不过,可能跟绝大多数人心中的认知有点儿差异,分布式系统的设想和它实际的尝试,反而要比你今天所了解的大型单体系统出现的时间更早。
|
||||
|
||||
在20世纪70年代末到80年代初,计算机科学刚经历了从以大型机为主,到向以微型机为主的蜕变,计算机也逐渐从一种存在于研究机构、实验室当中的科研设备,转变为了存在于商业企业中的生产设备,甚至是面向家庭、个人用户的娱乐设备。
|
||||
|
||||
这个时候的微型计算机系统,通常具有16位寻址能力、不足5MHz(兆赫)时钟频率的处理器和128KB左右的内存地址空间。比如说,著名的英特尔处理器的鼻祖,Intel 8086处理器就是在1978年研制成功,流行于80年代中期的,甚至一直到90年代初期还在生产销售。
|
||||
|
||||
不过,因为当时的计算机硬件的运算处理能力还相当薄弱,已经直接妨碍了单台计算机上信息系统软件能够达到的最大规模。所以,为了突破硬件算力的限制,各个高校、研究机构、软硬件厂商,都开始分头探索,想看看到底能不能使用多台计算机共同协作,来支撑同一套软件系统的运行。
|
||||
|
||||
这个阶段其实是对分布式架构最原始的探索与研究。你可能会觉得奇怪,计算机科学这个技术发展一日千里的领域,半个世纪之前的研究对今天还能有什么指导意义?那个时候探索的分布式如果是可行的,又怎么会拖到今时今日,软件系统才逐步进入微服务时代?
|
||||
|
||||
然而并非如此,从结果来看,历史局限决定了它不可能一蹴而就地解决分布式的难题,但仅从过程来看,这个阶段的探索可以称得上是硕果累累、成绩斐然。因为在这个时期提出的很多技术、概念,对Unix系统后续的发展,甚至是对今天计算机科学的很多领域,都产生了巨大而深远的影响,直接带动了后续的软件架构演化进程。
|
||||
|
||||
我们看一些比较熟悉的例子吧。
|
||||
|
||||
比如,惠普公司(及后来被惠普收购的Apollo),在80年代初期提出的网络运算架构(Network Computing Architecture,NCA),就可以说是未来远程服务调用的雏形。
|
||||
|
||||
再比如,卡内基 · 梅隆大学提出的AFS文件系统(Andrew File System),可以看作是分布式文件系统的最早实现(顺便一提,Andrew的意思是纪念Andrew Carnegie和Andrew Mellon)。
|
||||
|
||||
再比如,麻省理工学院提出的Kerberos协议,是服务认证和访问控制(ACL)的基础性协议,是分布式服务安全性的重要支撑,目前包括Windows和macOS在内的众多操作系统的登录、认证功能,等等,都会利用到这个协议。
|
||||
|
||||
而为了避免Unix系统的版本战争在分布式领域中重演,负责制定Unix系统技术标准的开放软件基金会(Open Software Foundation,OSF,也就是后来的“国际开放标准组织”)就邀请了各个主要的研究厂商一起参与,共同制订了“分布式运算环境”(Distributed Computing Environment,DCE)的分布式技术体系。
|
||||
|
||||
DCE包括了一整套完整的分布式服务组件的规范与实现。
|
||||
|
||||
比如,源自NCA的远程服务调用规范(Remote Procedure Call,RPC,在当时被称为是DCE/RPC),跟后来不局限于Unix系统的、基于通用TCP/IP协议的远程服务标准ONC RPC,一起被认为是现代RPC的共同鼻祖(这是Sun公司向互联网工程任务组提交的);源自AFS的分布式文件系统(Distributed File System,DFS)规范,在当时被称为DCE/DFS;源自Kerberos的服务认证规范;还有时间服务、命名与目录服务,就连现在程序中很常用的通用唯一识别符UUID,也是在DCE中发明出来的。
|
||||
|
||||
因为OSF本身的背景(它是一个由Unix开发者组成的Unix标准化组织),所以在当时研究这些分布式技术,通常都会有一个预设的重要原则,也就是在实现分布式环境中的服务调用、资源访问、数据存储等操作的时候,要尽可能地透明化、简单化,让开发人员不用去过于关注他们访问的方法,或者是要知道其他资源是位于本地还是远程。
|
||||
|
||||
这样的主旨呢,确实非常符合Unix设计哲学(有过几个版本的不同说法,这里我指的是Common Lisp作者Richard P. Gabriel提出的简单优先“Worse is Better”原则),但这个目标其实是过于理想化了,它存在一些在当时根本不可能完美解决的技术困难。
|
||||
|
||||
“调用远程方法”与“调用本地方法”尽管只是两字之差,但要是想能同时兼顾到简单、透明、性能、正确、鲁棒(Robust)、一致的目标的话,两者的复杂度就完全不能相提并论了。
|
||||
|
||||
我们先不说,远程方法是不可能做到像本地方法一样,能用内联等传统编译原理中的优化算法,来提升程序运行速度的,光是“远程”二字带来的网络环境下的新问题。
|
||||
|
||||
比如说,远程的服务在哪里(服务发现)、有多少个(负载均衡)、网络出现分区、超时或者服务出错了怎么办(熔断、隔离、降级)、方法的参数与返回结果如何表示(序列化协议)、如何传输(传输协议)、服务权限如何管理(认证、授权)、如何保证通信安全(网络安全层)、如何令调用不同机器的服务能返回相同的结果(分布式数据一致性)等一系列问题,就需要设计者耗费大量的心思。
|
||||
|
||||
那么,面对重重的困难与压力,DCE不仅从零开始、从无到有地回答了其中大部分问题,构建出了大量的分布式基础组件与协议,而且它还真的尽力去做到了相对意义的“透明”。
|
||||
|
||||
比如说,你在DFS上访问文件,如果不考虑性能上的差异的话,就很难感受到,它与本地磁盘文件系统有什么不同。可是,一旦考虑性能上的差异,分布式和本地的鸿沟是无比深刻的,这是数量级上的差距,是不可调和的。
|
||||
|
||||
尤其是在那个年代,在机器硬件的限制下,开发者为了让程序在运行效率上可以接受,就只有在方法本身的运行时间很长,可以相对忽略远程调用成本时的情况下,才去考虑使用分布式。如果方法本身的运行时长不够,就要人为地用各种奇技淫巧来刻意构造出这样的场景,比如可能会将几个原本毫无关系的方法打包到一个方法内,一块进行远程调用。
|
||||
|
||||
一方面,刻意构造长时间运行的方法这本身就与使用分布式来突破硬件算力、提升性能的初衷相互矛盾,需要我们小心平衡;另一方面,此时的开发人员,实际上仍然必须无时无刻地都要意识到,自己是在编写分布式的程序,不能随随便便地踏过本地与远程的界限,让软件系统的设计向性能做出妥协,让DCE“尽量简单透明”的努力几乎全部付诸东流。
|
||||
|
||||
因为本地与远程,无论是从编码、部署,还是从运行效率的角度上看,都有着天壤之别,所以在设计一个能运作良好的分布式应用的时候,就变得需要极高的编程技巧和各方面的知识来作为支撑,这个时候,反而是人员本身对软件规模的约束,超过机器算力上的约束了。
|
||||
|
||||
对DCE的研究呢,算得上是计算机科学中第一次有组织领导、有标准可循、有巨大投入的分布式计算的尝试。但无论是DCE,还是稍后出现的CORBA(Common ObjectRequest Broker Architecture,公共对象请求代理体系结构),我们从结果来看,都不能说它们取得了成功。
|
||||
|
||||
因为把一个系统直接拆分到不同的机器之中,这样做带来的服务的发现、跟踪、通讯、容错、隔离、配置、传输、数据一致性和编码复杂度等方面的问题,所付出的代价远远超过了分布式所取得的收益。
|
||||
|
||||
而亲身经历过那个年代的计算机科学家、IBM院士凯尔 · 布朗(Kyle Brown),在事后曾经评价道,“这次尝试最大的收获就是对RPC、DFS等概念的开创,以及得到了一个价值千金的教训:某个功能能够进行分布式,并不意味着它就应该进行分布式,强行追求透明的分布式操作,只会自寻苦果”。
|
||||
|
||||
|
||||
原始分布式时代的教训-
|
||||
Just because something can be distributed doesn’t mean it should be distributed. Trying to make a distributed call act like a local call always ends in tears.-
|
||||
某个功能能够进行分布式,并不意味着它就应该进行分布式,强行追求透明的分布式操作,只会自寻苦果。-
|
||||
—— Kyle Brown,IBM Fellow,Beyond buzzwords: A brief history of microservices patterns,2016
|
||||
|
||||
|
||||
其实,从设计角度来看,以上的结论是有违Unix哲学的,但这也是在当时的现实情况下,不得不做出的让步。在当时计算机科学面前,有两条通往更大规模软件系统的道路,一条路是尽快提升单机的处理能力,以避免分布式的种种问题;另一条路是找到更完美的解决方案,来应对如何构筑分布式系统的问题。
|
||||
|
||||
在20世纪80年代,正是摩尔定律开始稳定发挥作用的黄金时期,微型计算机的性能以每两年就增长一倍的惊人速度在提升,硬件算力束缚软件规模的链条,很快就松动了,我们用单台或者几台计算机,就可以作为服务器来支撑大型信息系统的运作了,信息系统进入了单体时代,而且在未来很长的一段时间内,单体系统都将是软件架构的主流。
|
||||
|
||||
不过尽管如此,对于另外一条路径,也就是对分布式计算、远程服务调用的探索,开发者们也从没有中断过。关于远程服务调用这个关键问题的历史、发展与现状,我还会在服务设计风格的“远程服务调用”部分(第7~10讲),以现代RPC和RESTful为主角,来进行更详细的讲述。而对于在原始分布式时代中遭遇到的其他问题,我也还会在软件架构演进的后面几个时代里,反复提起它们。
|
||||
|
||||
小结
|
||||
|
||||
今天,我给你介绍了计算机科学对分布式和服务化的第一次探索,着重分析了这次探索的主旨思想,也就是追求简单、符合Unix哲学的分布式系统,以及它当时所面临的困难,比如在捉襟见肘的运算能力、网络带宽下,设计不得不做出的妥协。
|
||||
|
||||
在这个过程中,我们接触到了DCE、CORBA等早期的分布式基础架构。其中许多的技术,比如远程服务调用、分布式文件系统、Kerberos认证协议等。如果你对这些技术觉得还有点陌生、或者还有很多疑惑,没有关系,我还会在后面的课程中为你着重介绍。
|
||||
|
||||
原始分布式时代提出的构建“符合Unix的设计哲学的”,以及“如同本地调用一般简单透明的”分布式系统的这个目标,是软件开发者对分布式系统最初的美好愿景。不过迫于现实,它会在一定时期内被妥协、被舍弃,分布式将会经过一段越来越复杂的发展进程。
|
||||
|
||||
但是,到了三十多年以后的今天,随着微服务的逐渐成熟完善,成为大型软件的主流架构风格以后,这个美好的愿景终将还是会重新被开发者拾起。
|
||||
|
||||
一课一思
|
||||
|
||||
Richard P. Gabriel提出的Unix设计哲学中写到:“保持接口与实现的简单性,比系统的任何其他属性,包括准确性、一致性和完整性,都来得更加重要。”
|
||||
|
||||
现在你来思考一下:今天以微服务为代表的分布式系统,是如何看待“简单”的?欢迎在留言区分享你的见解,我也将会在第5讲“后微服务时代”中,带你一起重新审视这个问题。
|
||||
|
||||
好,这节课就到这里。如果你身边也有想要或者必须要了解架构的演进的朋友,欢迎你把这一讲的内容分享给她/他。
|
||||
|
||||
|
||||
|
||||
|
||||
127
专栏/周志明的架构课/02_单体系统时代:应用最广泛的架构风格.md
Normal file
127
专栏/周志明的架构课/02_单体系统时代:应用最广泛的架构风格.md
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 _ 单体系统时代:应用最广泛的架构风格
|
||||
你好,我是周志明。今天,我们一起来探索单体系统时代。
|
||||
|
||||
这一讲,我会带你去了解以单体架构构建的软件系统,都有哪些优势和缺点,还有哪些容易让人产生错误理解的误区。在探索的过程中,你可以同时思考一下,为什么单体架构能够在相当长的时间里成为软件架构的主流风格,然后再对比下我在这一讲最后给出答案。
|
||||
|
||||
大型单体系统
|
||||
|
||||
单体架构是绝大部分软件开发者都学习和实践过的一种软件架构,很多介绍微服务的图书和技术资料中,也常常会把这种架构形式的应用称作“巨石系统”(Monolithic Application)。
|
||||
|
||||
在整个软件架构演进的历史进程里,单体架构是出现时间最早、应用范围最广、使用人数最多、统治历史最长的一种架构风格。但“单体”这个名称,却是从微服务开始流行之后,才“事后追认”所形成的概念。在这之前,并没有多少人会把“单体”看成一种架构。
|
||||
|
||||
如果你去查找软件架构的开发资料,可以轻轻松松找到很多以微服务为主题的图书和文章,但却很难能找到专门教我们怎么开发单体系统的任何形式的材料。
|
||||
|
||||
这一方面体现了单体架构本身的简单性;另一方面也体现出,在相当长的时间里,我们都已经习惯了,软件架构就应该是单体这种样子的。
|
||||
|
||||
那在剖析单体架构之前呢,我们有必要先搞清楚一个思维误区,那就是单体架构是落后的系统架构风格,最终会被微服务所取代。
|
||||
|
||||
因为在许多微服务的研究资料里,单体系统往往是以“反派角色”的身份登场的,比如著名的微服务入门书《微服务架构设计模式》,第一章的名字就是“逃离单体的地狱”。而这些材料所讲的单体系统,其实都有一个没有明说的隐含定语:“大型的单体系统”。
|
||||
|
||||
对于小型系统,也就是用单台机器就足以支撑其良好运行的系统来说,这样的单体不仅易于开发、易于测试、易于部署,而且因为各个功能、模块、方法的调用过程,都是在进程内调用的,不会发生进程间通讯,所以程序的运行效率也要比分布式系统更高,完全不应该被贴上“反派角色”的标签。要我说的话,反倒是那些爱赶技术潮流,却不顾需求现状的微服务吹捧者更像是个反派。
|
||||
|
||||
|
||||
进程间通讯:Inter-Process Communication,IPC。RPC属于IPC的一种特例,但请注意,这里两个“PC”不是同个单词的缩写,关于IPC与RPC的知识,在“远程服务调用”这个小章节中我会详细讲解。
|
||||
|
||||
|
||||
所以,当我们在讨论单体系统的缺陷的时候,必须基于软件的性能需求超过了单机,软件的开发人员规模明显超过了“2 Pizza Teams”范畴的前提下,这样才有讨论的价值。那么,在咱们课程后续讨论中,我所说的单体,都应该是特指的“大型的单体系统”。
|
||||
|
||||
也正因如此,在这一讲的开篇中“单体是出现最早的架构风格”,跟我在上一讲介绍“原始分布式时代”时,在开篇中提到的“使用多个独立的分布式服务共同构建一个更大型系统的设想与实际尝试,反而要比今天你所了解的大型单体系统出现的时间更早”,这两句话实际上并没有矛盾的地方。
|
||||
|
||||
可拆分的单体系统
|
||||
|
||||
好了,回到主题,接下来我就带你来详细、深入地了解一下单体系统,看看“巨石系统”为何仍然是可以拆分的。
|
||||
|
||||
尽管“Monolithic”这个词语本身的意思“巨石”,确实是带有一些“不可拆分”的隐含意味,但我们也不能简单粗暴地把单体系统在维基百科上的定义“All in One Piece”,翻译成“铁板一块”,它其实更接近于自给自足(Self-Contained)的含义。
|
||||
|
||||
|
||||
单体系统-
|
||||
Monolith means composed all in one piece. The Monolithic application describes a single-tiered software application in which different components combined into a single program from a single platform.-
|
||||
—— Monolithic Application,Wikipedia
|
||||
|
||||
|
||||
当然了,这种“铁板一块”的译法也不全是段子。我相信肯定有一部分人说起单体架构、巨石系统的缺点,脑海中闪过的第一印象就是“不可拆分”,难以扩展,所以它才不能支撑起越来越大的软件规模。这种想法我觉得其实是有失偏颇的,至少不完整。
|
||||
|
||||
我为什么会这么判断呢?
|
||||
|
||||
因为从纵向角度来看,在现代信息系统中,我从来没有见到过实际的生产环境里,有哪个大型的系统是完全不分层的。
|
||||
|
||||
分层架构(Layered Architecture)已经是现在几乎所有的信息系统建设中,都普遍认可、普遍采用的软件设计方法了。无论是单体还是微服务,或者是其他架构风格,都会对代码进行纵向拆分,收到的外部请求会在各层之间,以不同形式的数据结构进行流转传递,在触及到最末端的数据库后依次返回响应。
|
||||
|
||||
那么,对于单体架构来说,在这个意义上的“可拆分”,单体其实完全不会展露出丝毫的弱势,反而还可能因为更容易开发、部署、测试而更加便捷。比如说,当前市面上所有主流的IDE,如Intellij IDEA、Eclipse等,都对单体架构最为友好。IDE提供的代码分析、重构能力,以及对编译结果的自动化部署和调试能力,都是主要面向单体架构而设计的。
|
||||
|
||||
|
||||
|
||||
(来自O’Reilly的开放文档《Software Architecture Patterns》)
|
||||
|
||||
而在横向角度的“可拆分”上,单体架构也可以支持按照技术、功能、职责等角度,把软件拆分为各种模块,以便重用和团队管理。
|
||||
|
||||
实际上,单体系统并不意味着就只能有一个整体的程序封装形式,如果有需要,它完全可以由多个JAR、WAR、DLL、Assembly或者其他模块格式来构成。
|
||||
|
||||
即使是从横向扩展(Scale Horizontally)的角度来衡量,如果我们要在负载均衡器之后,同时部署若干个单体系统的副本,以达到分摊流量压力的效果,那么基于单体架构,也是轻而易举就可以实现的。
|
||||
|
||||
非独立的单体
|
||||
|
||||
不过,在“拆分”这方面,单体系统的真正缺陷实际上并不在于要如何拆分,而在于拆分之后,它会存在隔离与自治能力上的欠缺。
|
||||
|
||||
在单体架构中,所有的代码都运行在同一个进程空间之内,所有模块、方法的调用也都不需要考虑网络分区、对象复制这些麻烦事儿,也不担心因为数据交换而造成性能的损失。
|
||||
|
||||
可是,在获得了进程内调用的简单、高效这些好处的同时,也就意味着,如果在单体架构中,有任何一部分的代码出现了缺陷,过度消耗进程空间内的公共资源,那所造成的影响就是全局性的、难以隔离的。
|
||||
|
||||
我们要怎么理解这个问题呢?
|
||||
|
||||
首先,一旦架构中出现了内存泄漏、线程爆炸、阻塞、死循环等问题,就都将会影响到整个程序的运行,而不仅仅是某一个功能、模块本身的正常运作;而如果消耗的是某些更高层次的公共资源,比如端口占用过多或者数据库连接池泄漏,还将会波及到整台机器,甚至是集群中其他单体副本的正常工作。
|
||||
|
||||
此外,同样是因为所有代码都共享着同一个进程空间,如果代码无法隔离,那也就意味着,我们无法做到单独停止、更新、升级某一部分代码,因为不可能有“停掉半个进程,重启1/4个进程”这样不合逻辑的操作。所以,从动态可维护性的角度来说,单体系统也是有所不足的,对于程序升级、修改缺陷这样的工作,我们往往需要制定专门的停机更新计划,而且做灰度发布也相对会更加复杂。
|
||||
|
||||
补充:这里我说的“代码无法隔离,无法做到单独停止、更新……”,其实严谨来说还是有办法的,比如可以使用OSGi这种运行时模块化框架,只是会很别扭、很复杂。
|
||||
|
||||
这里就涉及到一个需要权衡的问题:如果说共享同一进程获得简单、高效这些优势的代价,是损失了各个功能模块的自治、隔离能力,那这两者孰轻孰重呢?这个问题很有代表性,我们还可以换个角度思考一下,它的潜台词其实是在比较微服务、单体架构哪种更好用、优秀?
|
||||
|
||||
在我看来,“好用和优秀”不一定是绝对的。我们看一个例子吧。
|
||||
|
||||
比如说,沃尔玛将超市分为仓储部、采购部、安保部、库存管理部、巡检部、质量管理部、市场营销部,等等,来划清职责,明确边界,让管理能力可以支持企业的成长规模;但如果你家楼下开的小卖部,爸、妈加儿子,再算上看家的中华田园犬小黄,一共也就只有四名员工,也去追求“先进管理”,来划分仓储部、采购部、库存管理部……的话,那纯粹是给自己找麻烦。
|
||||
|
||||
在单体架构下,哪怕是信息系统中两个毫无关联的子系统,我们也都必须部署到一起。当系统规模小的时候,这是个优势;但当系统规模扩大、程序需要修改的时候,相应的部署成本、技术升级时的迁移成本,都会变得非常高。
|
||||
|
||||
就拿沃尔玛例子来说,也就是当公司规模比较小的时候,让安保部和质检部两个不相干的部门在同一栋大楼中办公,算是节约资源。但当公司的人数增加了,办公室已经变得拥挤不堪的时候,我们也最多只能在楼顶加盖新楼层(相当于增强硬件性能),而不能让安保、质检分开地方办公,这才是缺陷所在。
|
||||
|
||||
另外,由于隔离能力的缺失,除了会带来难以阻断错误传播、不便于动态更新程序的问题,还会给带来难以技术异构等困难。
|
||||
|
||||
|
||||
技术异构:后面在介绍微服务时,我会提到马丁 · 福勒(Martin Fowler)提出的9个特征,技术异构就是其中之一。它的意思是说允许系统的每个模块,自由选择不一样的程序语言、不一样的编程框架等技术栈去实现。单体系统的技术栈异构不是一定做不到,比如JNI就可以让Java混用C/C++,但是这也是很麻烦的事,是迫不得已下的选择。
|
||||
|
||||
|
||||
不过,在我看来,我们提到的这些问题,还不是我们今天以微服务去代替单体系统的根本原因。我认为最根本的原因是:单体系统并不兼容“Phoenix”的特性。
|
||||
|
||||
单体这种架构风格,潜在的观念是希望系统的每一个部件,甚至每一处代码都尽量可靠,不出、少出错误,致力于构筑一个7×24小时不间断的可靠系统。
|
||||
|
||||
这种观念在小规模软件上能运作良好,但当系统越来越大的时候,交付一个可靠的单体系统就会变得越来越有挑战性。就像我在导读《什么是“The Fenix Project”?》中所说的,正是随着软件架构的不断演进,我们构建可靠系统的观念,开始从“追求尽量不出错”,转变为了正视“出错是必然”。实际上,这才是微服务架构能够挑战,并且能逐步开始代替运作了几十年的单体架构的根本驱动力。
|
||||
|
||||
不过,即使是为了允许程序出错,为了获得隔离、自治的能力,为了可以技术异构等目标,也并不意味着一定要依靠微服务架构。在新旧世纪之交,人们曾经探索过几种服务的拆分方法,把一个大的单体系统拆分为若干个更小的、不运行在同一个进程的独立服务,这些服务拆分的方法,后来导致了面向服务架构(Service-Oriented Architecture)的一段兴盛期,我们把它称作是“SOA时代”。
|
||||
|
||||
下一讲呢,我就带你来一起探索这个架构时代的特点与得失,你可以期待一下。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我们一起学习了单体架构。单体作为迄今为止使用人数最多的一种软件架构风格,自身必定是有可取之处的,比如说,易于分层、易于开发、易于部署测试、进程内的高效交互,等等,这些都是单体架构的优点。
|
||||
|
||||
可是,今天以微服务为代表的分布式架构的呼声如此之高,也同样说明,单体至少在当今的某些领域中存在一些关键性的问题,而我在前面提到的自治、隔离,谈到的单体不兼容“Phoenix”的特性,这些也正是单体系统的缺陷所在,也是我在下一讲中,要讲的SOA、微服务等分布式架构的核心目标所在。
|
||||
|
||||
一课一思
|
||||
|
||||
上一讲,我在给你介绍原始分布式时代的时候,提到过软件系统发展的道路有两条,一条是“分布式”路径,而单体架构是“不分布式”这条路径所演化的结果。经过这节课的讨论,我们已经知道了单体最终并不会被微服务所取代,未来它仍然会长期存在。
|
||||
|
||||
那么,在了解了它的演化历史的基础上,请你思考一下:未来的单体系统将会朝着怎样的方向发展呢?欢迎在留言区分享你的见解。
|
||||
|
||||
在下一讲中,我会给你介绍另一条“分布式”路径下的架构风格:SOA架构。不过到了这一模块的最后一讲时,我还将重新回到原点,讨论这两条架构演化路径的未来。到时候,你可以看看,你思考的答案是否跟我观察到的一致。
|
||||
|
||||
好,这节课就到这里,如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
||||
140
专栏/周志明的架构课/03_SOA时代:成功理论与失败实践.md
Normal file
140
专栏/周志明的架构课/03_SOA时代:成功理论与失败实践.md
Normal file
@@ -0,0 +1,140 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 _ SOA时代:成功理论与失败实践
|
||||
你好,我是周志明。
|
||||
|
||||
SOA架构是第一次被广泛使用过的、通过分布式服务来构建信息系统的工程实践。它有完善的理论和工具,可以说,它解决了分布式系统中,几乎所有主要的技术问题。
|
||||
|
||||
但遗憾的是,虽然SOA架构曾经被视为更大规模的软件发展的方向,但它最终还是没能成为一种普适的软件架构。
|
||||
|
||||
所以今天,我们就来探索一下SOA架构,一起来找找,它没能成为普适的软件架构的原因。通过这一讲,你能从中体会到SOA的设计思想与原则,理解它为什么不能成功。
|
||||
|
||||
三种代表性的服务拆分架构模式
|
||||
|
||||
在上一讲,我曾经提到过,为了对大型的单体系统进行拆分,让每一个子系统都能独立地部署、运行、更新,开发者们尝试了很多种方案。
|
||||
|
||||
所以,在介绍SOA架构模式之前,我还要先带你学习三种比较有代表性的服务拆分的架构模式。这些架构是SOA演化过程的中间产物,你也可以理解为,它们是SOA架构出现的必要前提。
|
||||
|
||||
烟囱式架构(Information Silo Architecture)
|
||||
|
||||
第一种架构模式是烟囱式架构。
|
||||
|
||||
信息烟囱也被叫做信息孤岛(Information Island),使用这种架构的系统呢,也被称为孤岛式信息系统或者烟囱式信息系统。这种信息系统,完全不会跟其他相关的信息系统之间进行互操作,或者是进行协调工作。
|
||||
|
||||
那你就会发现,这样的系统其实并没有什么“架构设计”可言。你还记不记得,我在上一讲中举的那个“企业与部门”的例子?如果两个部门真的完全不会发生任何交互,那我们就并没有什么理由,一定要强迫他们必须在一栋楼里办公。
|
||||
|
||||
所以,两个不发生交互的信息系统,让它们使用独立的数据库、服务器,就可以完成拆分了。
|
||||
|
||||
而唯一的问题,也是这个架构模式的致命问题,那就是:企业中真的存在完全不发生交互的部门吗?
|
||||
|
||||
|
||||
|
||||
对于两个信息系统来说,哪怕真的毫无业务往来关系,但系统的人员、组织、权限等主数据,会是完全独立、没有任何重叠的吗?这样“独立拆分”“老死不相往来”的系统,显然不可能是企业所希望见到的。
|
||||
|
||||
微内核架构(Microkernel Architecture)
|
||||
|
||||
第二种是微内核架构,它也被称为插件式架构(Plug-in Architecture)。
|
||||
|
||||
既然在烟囱式架构中,我们说两个没有业务往来关系的系统,也可能需要共享人员、组织、权限等一些公共的主数据,那就不妨把这些主数据,连同其他可能被各个子系统使用到的公共服务、数据、资源,都集中到一块,成为一个被所有业务系统共同依赖的核心系统(Kernel,也称为Core System)。
|
||||
|
||||
这样的话,具体的业务系统就能以插件模块(Plug-in Modules)的形式存在了,就可以为整个系统提供可扩展的、灵活的、天然隔离的功能特性。
|
||||
|
||||
|
||||
|
||||
(来自O’Reilly的开放文档《Software Architecture Patterns》)
|
||||
|
||||
以更高层次的抽象程度来看,任何计算机系统都是由各种架构的软件互相配合来实现各种功能的,这一讲我介绍的各种架构模式,一般都可以看作是整个系统的一种插件。对于产品型应用程序来说,如果我们想将新特性或者功能及时加入系统,微内核架构会是一个不错的选择。
|
||||
|
||||
微内核架构也可以嵌入到其它架构模式之中,通过插件的方式,来提供逐步演化的功能和增量开发。所以,如果你准备实现一个能够支持二次开发的软件系统,微内核就是一种良好的架构模式。
|
||||
|
||||
不过,微内核架构也有它的局限和使用前提,它会假设系统中各个插件模块之间是互不认识的(不可预知系统会安装哪些模块),这些插件会访问内核中一些公共的资源,但不会发生直接交互。
|
||||
|
||||
可是,无论是在企业信息系统还是在互联网,在许多场景中这一假设都不成立。比如说,你要建设一个购物网站,支付子系统和用户子系统是独立的,但当交易发生时,支付子系统可能需要从用户子系统中得到是否是VIP、银行账号等信息,而用户子系统也可能要从支付子系统中获取交易金额等数据,来维护用户积分。
|
||||
|
||||
所以,我们必须找到一个办法,它既能拆分出独立的系统,也能让拆分后的子系统之间可以顺畅地互相调用通讯。
|
||||
|
||||
事件驱动架构(Event-Driven Architecture)
|
||||
|
||||
那么,为了能让子系统之间互相通讯,事件驱动架构就应运而生了。
|
||||
|
||||
这种架构模式的运作方案是,在子系统之间建立一套事件队列管道(Event Queues),来自系统外部的消息将以事件的形式发送到管道中,各个子系统可以从管道里获取自己感兴趣、可以处理的事件消息,也可以为事件新增或者是修改其中的附加信息,甚至还可以自己发布一些新的事件到管道队列中去。
|
||||
|
||||
这样一来,每一个消息的处理者都是独立的、高度解耦的,但它又能与其他处理者(如果存在该消息处理者的话)通过事件管道来进行互动。
|
||||
|
||||
|
||||
|
||||
(来自O’Reilly的开放文档《Software Architecture Patterns》)
|
||||
|
||||
那么,当系统演化至事件驱动架构的时候,我在原始分布式时代这一讲的结尾中,提到的第二条通往大规模软件的路径,也就是仍然在并行发展的远程服务调用,就迎来了SOAP协议的诞生(我在后面第7~10讲分享远程服务调用的时候,还会给你详细介绍它,你到时可以再次印证一下这一讲的内容)。
|
||||
|
||||
此时“面向服务的架构”(Service Oriented Architecture,SOA),就已经有了登上软件架构舞台所需要的全部前置条件了。
|
||||
|
||||
SOA架构时代的探索
|
||||
|
||||
SOA的概念最早是由Gartner公司在1994年提出的。当时的SOA还不具备发展的条件,直到2006年情况才有所变化,IBM、Oracle、SAP等公司,共同成立了OSOA联盟(Open Service Oriented Architecture),来联合制定和推进SOA相关行业标准。
|
||||
|
||||
到2007年,在结构化资讯标准促进组织(Organization for the Advancement of Structured Information Standards,OASIS)的倡议与支持下,OSOA就由一个软件厂商组成的松散联盟,转变为了一个制定行业标准的国际组织。它联合OASIS共同新成立了Open CSA组织(Open Composite Services Architecture),也就是SOA的“官方管理机构”。
|
||||
|
||||
当软件架构发展至SOA时代的时候,其中的许多概念、思想都已经能在今天的微服务中,找到对应的身影了。比如说,服务之间的松散耦合、注册、发现、治理、隔离、编排等等,都是微服务架构中耳熟能详的概念了,也大多是在分布式服务刚被提出的时候,就已经可以预见到的困难。
|
||||
|
||||
所以,SOA就针对这些问题,乃至于针对“软件开发”这件事儿本身,进行了更具体、更系统的探索。
|
||||
|
||||
更具体
|
||||
|
||||
“更具体”体现在,尽管SOA本身还是属于一种抽象概念,而不是特指某一种具体的技术,但它比单体架构和烟囱式架构、微内核架构、事件驱动架构,都要更具可操作性,细节也充实了很多。所以,我们已经不能简单地把SOA看作是一种架构风格了,而是可以称之为一套软件架构的基础平台了。
|
||||
|
||||
那,我们怎么理解“基础平台”这个概念呢?在我看来,主要是下面几个方面:
|
||||
|
||||
|
||||
SOA拥有领导制定技术标准的组织Open CSA;
|
||||
SOA具有清晰的软件设计的指导原则,比如服务的封装性、自治、松耦合、可重用、可组合、无状态,等等;
|
||||
SOA架构明确了采用SOAP作为远程调用的协议,依靠SOAP协议族(WSDL、UDDI和一大票WS-*协议)来完成服务的发布、发现和治理;
|
||||
SOA架构会利用一个被称为是企业服务总线(Enterprise Service Bus,ESB)的消息管道,来实现各个子系统之间的通讯交互,这就让各个服务间在ESB的调度下,不需要相互依赖就可以实现相互通讯,既带来了服务松耦合的好处,也为以后可以进一步实现业务流程编排(Business Process Management,BPM)提供了基础;
|
||||
SOA架构使用了服务数据对象(Service Data Object,SDO)来访问和表示数据,使用服务组件架构(Service Component Architecture,SCA)来定义服务封装的形式和服务运行的容器;
|
||||
……
|
||||
|
||||
|
||||
在这一整套成体系、可以互相精密协作的技术组件的支持下,我们从技术可行性的角度来评判的话,SOA实际上就可以算是成功地解决了分布式环境下,出现的诸如服务注册、发现、隔离、治理等主要技术问题了。
|
||||
|
||||
更系统
|
||||
|
||||
这里我说的“更系统”,指的是SOA的宏大理想。因为SOA最根本的目标,就是希望能够总结出一套自上而下的软件研发方法论,让企业只需要跟着它的思路,就能够一揽子解决掉软件开发过程中的全套问题。比如,如何挖掘需求、如何将需求分解为业务能力、如何编排已有服务、如何开发测试部署新的功能,等等。
|
||||
|
||||
如果这个目标真的能够达成,那么软件开发就有可能从此迈进工业化大生产的阶段。你可以试想一下,如果有一天,你在写符合客户需求的软件时,就像写八股文一样有迹可循、有法可依,那对你来说或许很无趣,但这肯定可以大幅提升整个社会实施信息化的效率。
|
||||
|
||||
SOA在21世纪最初的十年里,曾经盛行一时,有IBM等一众巨头为其摇旗呐喊,吸引了不少软件开发商,尤其是企业级软件开发商的跟随,但最终却还是偃旗息鼓,沉寂了下去。
|
||||
|
||||
原因也很简单,开发信息系统毕竟不是写八股文,SOA架构过于严谨精密的流程与理论,导致了软件开发的全过程,都需要有懂得复杂概念的专业人员才能够驾驭。从SOA诞生的那一天起,就已经注定了它只能是少数系统的阳春白雪式的精致奢侈品:它可以实现多个异构大型系统之间的复杂集成交互,却很难作为一种具有广泛普适性的软件架构风格来推广。
|
||||
|
||||
我在后面第7~10讲介绍远程服务调用时,我还会为你介绍Web Service的兴起与衰落。Web Service之所以被逐渐边缘化,最本质的原因就是过于严格的规范定义,给架构带来了过度的复杂性。
|
||||
|
||||
而构建在Web Service基础之上的ESB、BPM、SCA、SDO等诸多的上层建筑,就进一步加剧了这种复杂性。
|
||||
|
||||
SOA最终没有获得成功的致命伤,其实跟当年的EJB(Enterprise JavaBean,企业级JavaBean)的失败如出一辙。
|
||||
|
||||
尽管在当时,EJB有Sun Microsystems(被甲骨文收购)和IBM等一众巨头在背后力挺,希望能把它发展成一套面向信息系统的编程范式,但它仍然被以Spring、Hibernate为代表的“草根框架”给打败了。可见,任何事物一旦脱离了人民群众,最终都会淹没在群众的海洋之中,就连信息技术也不曾例外过。
|
||||
|
||||
最后,当你读到这一段的时候,你不妨再重新思考下我们这一讲的开头提到的,“如何使用多个独立的分布式服务共同构建一个更大型系统”这个问题,再回顾下“原始分布式时代”这一讲中,Unix DCE提出的分布式服务的主旨:“让开发人员不必关心服务是远程还是本地,都能够透明地调用服务或者访问资源”。
|
||||
|
||||
经过了三十年的技术发展,信息系统经历了巨石、烟囱、微内核、事件驱动、SOA等架构模式,应用受架构复杂度的牵绊却是越来越大,距离“透明”二字已经越来越远了。这是否算不自觉间忘记了当年的初心呢?
|
||||
|
||||
接下来我们要探索的微服务时代,似乎正是带着这样自省式的问句而开启的。
|
||||
|
||||
小结
|
||||
|
||||
这一讲,我带你学习了解了SOA架构,重点了解了从原始分布式架构、单体架构演进到SOA架构这段过程中的一些中间产物,如烟囱式架构、微内核架构、事件驱动架构等。
|
||||
|
||||
另外,我之所以带你解构SOA架构,就是要帮助你弄清楚它成功的部分,比如它是如何提出了哪些技术、解决问题的方法论是什么,它是如何看待分布式、乃至是如何看待软件开发的;你也要弄清楚它失败的部分,要清楚为什么SOA在众多软件业巨头的推动下,仍然没能成为软件开发者所普遍接受的普适的软件开发方法。这是你了解和掌握推动架构时代演进原因的重要方式。
|
||||
|
||||
一课一思
|
||||
|
||||
你是否使用过SOA的方法论来开发软件系统呢?无论有还是没有,作为一个软件开发者,你是否愿意软件开发向着工业化方向发展,让软件类似工业产品制造那样,可以在规范、理论、工具、技术的支持下,以流水线的方式生产出来?
|
||||
|
||||
欢迎在留言区分享你的见解。如果你身边的朋友,也对SOA架构的成功与失败感兴趣,希望你能把今天的内容分享给TA。
|
||||
|
||||
|
||||
|
||||
|
||||
150
专栏/周志明的架构课/04_微服务时代:SOA的革命者.md
Normal file
150
专栏/周志明的架构课/04_微服务时代:SOA的革命者.md
Normal file
@@ -0,0 +1,150 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 _ 微服务时代:SOA的革命者
|
||||
你好,我是周志明。这一讲,我继续来带你探索软件架构中的微服务时代。
|
||||
|
||||
其实“微服务”这个词儿,Peter Rodgers博士在2005年的云计算博览会(Web Services Edge 2005)上,就已经提出和使用了。当时的说法是“Micro-Web-Service”,指的是一种专注于单一职责的、与语言无关的、细粒度的Web服务(Granular Web Services)。
|
||||
|
||||
“微服务”这个词,并不是Peter Rodgers直接凭空创造出来的概念。最开始的微服务,可以说是在SOA发展的同时被催生出来的产物,就像是EJB在推广的过程中,催生出了Spring和Hibernate框架那样。这一阶段的微服务,是作为SOA的一种轻量化的补救方案而被提出来的。
|
||||
|
||||
到今天为止,在英文版的维基百科上,人们仍然是把微服务定义成了SOA的一个变种。所以,微服务在诞生和最初的发展阶段,跟SOA、Web Service这些概念有所牵扯,也是完全可以理解的。
|
||||
|
||||
|
||||
What is microservices-
|
||||
Microservices is a software development technique — a variant of the service-oriented architecture (SOA) structural style.-
|
||||
—— Wikipedia,Microservices
|
||||
|
||||
|
||||
但我们现在再来看,维基百科对微服务的定义,其实已经有些过时了。至于为什么这样说,就是我在这一讲中要和你解释的了。
|
||||
|
||||
在微服务的概念被提出后将近10年的时间里面,它都没有受到太多人的追捧。毕竟,如果只是对现有的SOA架构的修修补补,确实难以唤起广大技术人员的更多激情。
|
||||
|
||||
不过,也是在这10年的时间里,微服务本身其实一直在思考、蜕变。
|
||||
|
||||
2012年,在波兰克拉科夫举行的“33rd Degree Conference”大会上,Thoughtworks首席咨询师James Lewis做了题为《Microservices - Java, the Unix Way》的主题演讲。其中,他提到了单一服务职责、康威定律、自动扩展、领域驱动设计等原则,却只字未提SOA,反而号召大家,应该重拾Unix的设计哲学(As Well Behaved Unix Services)。这一点跟我在上一讲中所说的“初心与自省”,可以说是一个意思。
|
||||
|
||||
微服务已经迫不及待地要脱离SOA的附庸,想要成为一种独立的架构风格,也许,它还将会是SOA的革命者,找到一条能被广大开发者普遍接受且愿意接受的、实现服务化系统的目标。
|
||||
|
||||
微服务真正崛起是在2014年。相信我们大多数程序员,也是从Martin Fowler和James Lewis合写的文章“Microservices: a definition of this new architectural term”里面,第一次了解到微服务的。这篇文章虽然不是最早提出“微服务”这个概念的,但却是真正丰富的、广为人知的和可操作的微服务指南。也就是说,这篇文章才是微服务的真正起源。
|
||||
|
||||
这篇文章定义了现代微服务的概念:微服务是一种通过多个小型服务的组合,来构建单个应用的架构风格,这些服务会围绕业务能力而非特定的技术标准来构建。各个服务可以采用不同的编程语言、不同的数据存储技术、运行在不同的进程之中。服务会采取轻量级的通讯机制和自动化的部署机制,来实现通讯与运维。
|
||||
|
||||
此外,在这篇论文中,作者还列举出了微服务的九个核心的业务与技术特征。接下来,我就一一解读为你解读下,希望你可以从中领悟到,微服务在团队、开发、运维等一系列研发过程中的核心思想。
|
||||
|
||||
第一,围绕业务能力构建(Organized around Business Capabilities)
|
||||
|
||||
这个核心技术特征,实际上再次强调了康威定律的重要性。它的意思是,有怎样的结构、规模和能力的团队,就会产生出对应结构、规模、能力的产品。这个结论不是某个团队、某个公司遇到的巧合,而是必然的演化结果。
|
||||
|
||||
如果本应该归属同一个产品内的功能,被划分在了不同的团队当中,那就必然会产生大量的跨团队沟通协作,而跨越团队边界,无论是在管理、沟通,还是在工作安排上,都会产生更高的成本。高效的团队,自然会针对这个情况进行改进,而当团队和产品磨合调节稳定了之后,就会拥有一致的结构。
|
||||
|
||||
第二,分散治理(Decentralized Governance)
|
||||
|
||||
这个技术特征,表达的是“谁家孩子谁来管”。微服务对应的开发团队,有着直接对服务运行质量负责的责任,也应该有着不受外界干预,掌控服务各个方面的权力,可以选择与其他服务异构的技术来实现自己的服务。
|
||||
|
||||
这一点在真正实践的时候,其实多少都会留点儿宽松的处理余地。因为大多数的公司都不会在某一个服务用Java,另一个用Python,下一个用Golang,而是通常都会统一主流语言,甚至会有统一的技术栈或专有的技术平台。
|
||||
|
||||
微服务不提倡也并不反对这种“统一”,它只负责提供和维护基础技术栈的团队,有被各方依赖的觉悟,要有“经常被凌晨3点的闹钟吵醒”的心理准备就好。
|
||||
|
||||
微服务更加强调的是,在确实有必要进行技术异构的时候,一个开发团队应该能有选择“不统一”的权利。比如说,我们不应该强迫用Node.js去开发报表页面;要做人工智能计算的时候,也可以选择用Python,等等。
|
||||
|
||||
第三,通过服务来实现独立自治的组件(Componentization via Services)
|
||||
|
||||
这里,Martin Fowler与James Lewis之所以强调要通过“服务”(Service)而不是“类库”(Library)来构建组件,是因为类库是在编译期静态链接到程序中的,会通过本地调用来提供功能,而服务是进程外组件,它是通过远程调用来提供功能的。在第2讲中,我们已经分析过,尽管远程服务有更高昂的调用成本,但这是为组件带来隔离与自治能力的必要代价。
|
||||
|
||||
第四,产品化思维(Products not Projects)
|
||||
|
||||
产品化思维的意思就是,我们要避免把软件研发看作是要去完成某种功能,而要把它当做是一种持续改进、提升的过程。比如,我们不应该把运维看作就是运维团队的事,把开发看作就是开发团队的事。
|
||||
|
||||
开发团队应该为软件产品的整个生命周期负责。开发者不仅应该知道软件是如何开发的,还应该知道它会如何运作、用户如何反馈,乃至售后支持工作是怎样进行的。这里服务的用户,不一定是最终用户,也可能是消费这个服务的另外一个服务。
|
||||
|
||||
以前在单体的架构模式下,程序的规模决定了我们无法让全部的开发人员,都关注到一个完整的产品,在组织中会有开发、运维、支持等细致分工的成员,他们只关注于自己的一块工作。但在微服务下,我们可以让团队中的每一位成员,都具有产品化思维。因为在“2 Pizza Teams”的团队规模下,每一个人都了解全过程是完全有可能实现的。
|
||||
|
||||
第五,数据去中心化(Decentralized Data Management)
|
||||
|
||||
微服务这种架构模式也明确地提倡,数据应该按领域来分散管理、更新、维护和存储。
|
||||
|
||||
在单体服务中,通常一个系统的各个功能模块会使用同一个数据库,虽然这种中心化的存储确实天生就更容易避免一致性的问题,但是,同一个数据实体在不同服务的视角里,它的抽象形态往往也是不同的。
|
||||
|
||||
比如,Bookstore应用中的书本,在销售领域中关注的是价格,在仓储领域中关注的是库存数量,在商品展示领域中关注的是书籍的介绍信息。如果是作为中心化的存储,那么这里所有的领域,都必须修改和映射到同一个实体之中,就会导致不同的服务之间,可能会互相产生影响,从而丧失了各自的独立性。
|
||||
|
||||
另外,尽管在分布式中,我们要想处理好一致性的问题也很困难,很多时候都没法使用传统的事务处理来保证不出现一致性问题。但是两害相权取其轻,一致性问题这些必要的代价是值得付出的。
|
||||
|
||||
第六,轻量级通讯机制(Smart Endpoints and Dumb Pipes)
|
||||
|
||||
这个弱管道(Dumb Pipes)机制,可以说几乎算是在直接指名道姓地反对ESB、BPM和SOAP等复杂的通讯机制。
|
||||
|
||||
ESB可以处理消息的编码加工、业务规则转换等;BPM可以集中编排企业的业务服务;SOAP有几十个WS-*协议族在处理事务、一致性、认证授权等一系列工作。这些构筑在通讯管道上的功能,也许在某个系统中的确有一部分服务是需要的,但对于另外更多的服务来说是强加进来的负担。
|
||||
|
||||
如果服务需要上面的某一种功能或能力,那就应该在服务自己的Endpoint(端点)上解决,而不是在通讯管道上一揽子处理。
|
||||
|
||||
微服务提倡的是类似于经典Unix过滤器那样,简单直接的通讯方式。比如说,RESTful风格的通讯,在微服务中就是比较适合的。
|
||||
|
||||
第七,容错性设计(Design for Failure)
|
||||
|
||||
容错性设计,是指软件架构不再虚幻地追求服务永远稳定,而是接受服务总会出错的现实。
|
||||
|
||||
这个技术特征要求,在微服务的设计中,有自动的机制能够对其依赖的服务进行快速故障检测,在持续出错的时候进行隔离,在服务恢复的时候重新联通。所以“断路器”这类设施,对实际生产环境的微服务来说,并不是可选的外围组件,而是一个必须的支撑点。如果没有容错性的设计,系统很容易就会因为一两个服务的崩溃带来的雪崩效应而被淹没。
|
||||
|
||||
我想说的是,可靠系统完全可以由会出错的服务来组成,这是微服务最大的价值所在,也是咱们这门课的开篇导读标题中“The Fenix Project”的含义。
|
||||
|
||||
第八,演进式设计(Evolutionary Design)
|
||||
|
||||
容错性设计承认服务会出错,而演进式设计则是承认服务会被报废淘汰。
|
||||
|
||||
一个良好设计的服务,应该是能够报废的,而不是期望得到长久的发展。如果一个系统中出现不可更改、无可替代的服务,这并不能说明这个服务有多么重要,反而是系统设计上脆弱的表现。微服务带来的独立、自治,也是在反对这种脆弱性。
|
||||
|
||||
第九,基础设施自动化(Infrastructure Automation)
|
||||
|
||||
基础设施自动化,如CI/CD的长足发展,大大降低了构建、发布、运维工作的复杂性。
|
||||
|
||||
由于微服务架构下,运维的服务数量比起单体架构来说,要有数量级的增长,所以使用微服务的团队,会更加依赖于基础设施的自动化。毕竟,人工是无法运维成百上千,乃至成千上万级别的服务的。
|
||||
|
||||
好,到这里,通过我的解读,你是不是已经大概理解了微服务核心的业务和技术特征了?以上9个特征,是一个合理的微服务系统展示出来的内、外在表现,它能够指导你该如何应用微服务架构,却不必作为一种强加于系统中的束缚来看待。
|
||||
|
||||
“Microservices: a definition of this new architectural term”一文中,对微服务特征的描写已经非常具体了,除定义了微服务是什么,还专门申明了微服务不是什么:微服务不是SOA的衍生品,应该明确地与SOA划清界线,不再贴上任何SOA的标签。
|
||||
|
||||
这样一来,微服务才算是一种真正丰满、独立、具体的架构风格,为它在未来的几年时间里,如同明星一般闪耀崛起于技术舞台奠定了坚实的基础。
|
||||
|
||||
|
||||
Microservices and SOA-
|
||||
This common manifestation of SOA has led some microservice advocates to reject the SOA label entirely, although others consider microservices to be one form of SOA , perhaps service orientation done right. Either way, the fact that SOA means such different things means it’s valuable to have a term that more crisply defines this architectural style.-
|
||||
由于与SOA具有一致的表现形式,这让微服务的支持者更加迫切地拒绝再被打上SOA的标签。一些人坚持认为微服务就是SOA的一种变体,尽管仅从面向服务这个角度来考虑,这个观点可以说也是正确的。但无论如何,从整体上看SOA与微服务都是两种不同的东西。也因此,使用一个别的名称,来简明地定义这种架构风格就显得非常有必要了。-
|
||||
—— Martin Fowler / James Lewis,Microservices
|
||||
|
||||
|
||||
从上面我对微服务的定义和特征的解读当中,你还可以明显地感觉到,微服务追求的是更加自由的架构风格,它摒弃了SOA中几乎所有可以抛弃的约束和规定,提倡以“实践标准”代替“规范标准”。
|
||||
|
||||
可是,如果没有了统一的规范和约束,以前SOA解决的那些分布式服务的问题,不又都重新出现了吗?
|
||||
|
||||
没错,的确如此。服务的注册发现、跟踪治理、负载均衡、故障隔离、认证授权、伸缩扩展、传输通讯、事务处理等问题,在微服务中,都不再会有统一的解决方案。
|
||||
|
||||
即使我们只讨论Java范围内会使用到的微服务,那么光一个服务间通讯的问题,可以列入候选清单的解决方案就有很多很多。比如,RMI(Sun/Oracle)、Thrift(Facebook)、Dubbo(阿里巴巴)、gRPC(Google)、Motan2(新浪)、Finagle(Twitter)、brpc(百度)、Arvo(Hadoop)、JSON-RPC、REST,等等。
|
||||
|
||||
再来举个例子,光一个服务发现问题,我们可以选择的解决方案就有:Eureka(Netflix)、Consul(HashiCorp)、Nacos(阿里巴巴)、ZooKeeper(Apache)、etcd(CoreOS)、CoreDNS(CNCF),等等。
|
||||
|
||||
其他领域的情况也很类似。总之,完全就是“八仙过海,各显神通”的局面。
|
||||
|
||||
所以说,微服务所带来的自由是一把双刃开锋的宝剑。当软件架构者拿起这把宝剑的时候,它的一刃指向的是SOA定下的复杂技术标准,而在将选择的权力夺回的同一时刻,另外一刃也正朝向着自己映出冷冷的寒光。
|
||||
|
||||
小结
|
||||
|
||||
在微服务时代中,软件研发本身的复杂度应该说是有所降低,一个简单服务,并不见得就会同时面临分布式中所有的问题,也就没有必要背上SOA那百宝袋般沉重的技术包袱。微服务架构下,我们需要解决什么问题,就引入什么工具;团队熟悉什么技术,就使用什么框架。
|
||||
|
||||
此外,像Spring Cloud这样的胶水式的全家桶工具集,通过一致的接口、声明和配置,进一步屏蔽了源自于具体工具、框架的复杂性,降低了在不同工具、框架之间切换的成本。所以,作为一个普通的服务开发者,作为一个“螺丝钉”式的程序员,微服务架构对我们来说是很友善的。
|
||||
|
||||
可是,微服务对架构者来说却是满满的恶意,因为它对架构能力的要求可以说是史无前例。要知道,技术架构者的第一职责就是做决策权衡,有利有弊才需要决策,有取有舍才需要权衡。如果架构者本身的知识面不足以覆盖所需要决策的内容,不清楚其中的利弊,也就不可避免地会陷入选择困难症的困境之中。
|
||||
|
||||
总而言之,微服务时代充满着自由的气息,也充斥着迷茫的选择。软件架构不会止步于自由,微服务仍然不可能是架构探索的终点。如果有下一个时代,我希望信息系统能同时拥有微服务的自由权利,围绕业务能力构建自己的服务而不受技术规范管束,但同时又不必承担自行解决分布式问题的代价。管他什么利弊权衡!小孩子才做选择题,成年人全部都要!
|
||||
|
||||
一课一思
|
||||
|
||||
思考一下,你所负责的产品是不是基于微服务的?如果是,它符合微服务的9个特征吗?如果不是,你的产品适合微服务架构吗?你所在的企业、团队适合引入微服务吗?
|
||||
|
||||
欢迎在留言区分享你的答案。如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
||||
110
专栏/周志明的架构课/05_后微服务时代:跨越软件与硬件之间的界限.md
Normal file
110
专栏/周志明的架构课/05_后微服务时代:跨越软件与硬件之间的界限.md
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 _ 后微服务时代:跨越软件与硬件之间的界限
|
||||
你好,我是周志明。今天,我们一起来探索后微服务时代。
|
||||
|
||||
在开始探讨这一讲的主题之前呢,我想先跟你讨论一个问题。我们都知道,在微服务架构中,会面临一些必须解决的问题,比如注册发现、跟踪治理、负载均衡、传输通讯等。但这些问题,其实在SOA时代甚至可以说自原始分布式时代,就一直存在了。既然只要是分布式系统,就没办法完全避免这些问题,那我们就回过头来想一下:这些问题一定要由分布式系统自己来解决吗?
|
||||
|
||||
既然这样,那我们就先不去纠结到底是用微服务还是什么别的架构,直接看看面对这些问题,现在最常见的解决方法是怎样的:
|
||||
|
||||
|
||||
如果某个系统需要伸缩扩容,我们通常会购买新的服务器,多部署几套副本实例来分担压力;
|
||||
如果某个系统需要解决负载均衡的问题,我们通常会布置负载均衡器,并选择恰当的均衡算法来分流;
|
||||
如果需要解决安全传输的问题,我们通常会布置TLS传输链路,配置好CA证书,以保证通讯不被窃听篡改;
|
||||
如果需要解决服务发现的问题,我们通常会设置DNS服务器,让服务访问依赖稳定的记录名而不是易变的IP地址,等等。
|
||||
|
||||
|
||||
所以你会发现,计算机科学经过了这么多年的发展,这些问题已经大多都有了专职化的基础设施来帮助解决了。
|
||||
|
||||
那么,在微服务时代,我们之所以不得不在应用服务层面,而不是基础设施层面去解决这些分布式问题,完全是因为由硬件构成的基础设施,跟不上由软件构成的应用服务的灵活性。这其实是一种无奈之举。
|
||||
|
||||
软件可以做到只使用键盘就能拆分出不同的服务,只通过拷贝、启动就能够伸缩扩容服务。那么,硬件难道也可以通过敲键盘就变出相应的应用服务器、负载均衡器、DNS服务器、网络链路等等的这些设施吗?嗯?好像也可以啊!
|
||||
|
||||
到这里,你是不是已经知道了,注册发现、跟踪治理等等问题的解决,依靠的就是虚拟化技术和容器化技术。我们也就明白了,微服务时代所取得的成就,本身就离不开以Docker为代表的早期容器化技术的巨大贡献。
|
||||
|
||||
不知道你注意到没有,在这之前,我从来没有提起过“容器”二字。其实,这并不是我想刻意冷落它,而是因为早期的容器只是被简单地视为一种可快速启动的服务运行环境,使用它的目的是方便程序的分发部署。所以,早期阶段针对单个服务的容器,并没有真正参与到分布式问题的解决之中。
|
||||
|
||||
尽管2014年,微服务真正崛起的时候,Docker Swarm(2013年)和Apache Mesos(2012年)就已经存在了,更早之前也出现过软件定义网络(Software-Defined Networking,SDN)、软件定义存储(Software-Defined Storage,SDS)等技术,但是,被业界广泛认可、普遍采用的通过虚拟化的基础设施,去解决分布式架构问题的方案,应该要从2017年Kubernetes赢得容器战争的胜利开始算起。
|
||||
|
||||
2017年,可以说是容器生态发展历史中具有里程碑意义的一年。
|
||||
|
||||
在这一年,长期作为Docker竞争对手的RKT容器一派的领导者CoreOS,宣布放弃了自己的容器管理系统Fleet,未来将会把所有容器管理功能,转移到Kubernetes之上去实现。
|
||||
|
||||
在这一年,容器管理领域的独角兽Rancher Labs,宣布放弃其内置了数年的容器管理系统Cattle,提出了“All-in-Kubernetes”战略,从2.0版本开始,把1.x版本能够支持多种容器管理工具的Rancher,“升级”为只支持Kubernetes一种的容器管理系统。
|
||||
|
||||
在这一年,Kubernetes的主要竞争者Apache Mesos,在9月正式宣布了“Kubernetes on Mesos”集成计划,开始由竞争关系,转为了对Kubernetes提供支持,使其能够与Mesos的其他一级框架(如HDFS、Spark和Chros等)进行集群资源动态共享、分配与隔离。
|
||||
|
||||
在这一年,Kubernetes的最大竞争者,Docker Swarm的母公司Docker,终于在10月被迫宣布Docker要同时支持Swarm与Kubernetes两套容器管理系统,也就是承认了Kubernetes的统治地位。
|
||||
|
||||
至此,这场已经持续了三、四年时间,以Docker Swarm、Apache Mesos与Kubernetes为主要竞争者的“容器战争”,终于有了明确结果。可以说,Kubernetes最后从众多的容器管理系统中脱颖而出、“登基加冕”,就代表了容器发展中一个时代的结束。而且我可以说,它带来的容器间网络、服务、负载均衡、配置等虚拟化基础设施,也将会是开启下一个软件架构发展新纪元的钥匙。
|
||||
|
||||
我为什么会这么肯定呢?
|
||||
|
||||
针对同一个分布式服务的问题,对比下Spring Cloud中提供的应用层面的解决方案,以及Kubernetes中提供的基础设施层面的解决方案,你就可以明白其中缘由了。
|
||||
|
||||
|
||||
|
||||
虽然Spring Cloud和Kubernetes的出发点不同,解决问题的方法和效果也不一样,但不容忽视的是,Kubernetes的确提供了一条全新的、前途更加广阔的解题思路。
|
||||
|
||||
我说的“前途广阔”,不仅仅是一句恭维赞赏的客气话。当虚拟化的基础设施,开始从单个服务的容器发展到由多个容器构成的服务集群,以及集群所需的所有通讯、存储设施的时候,软件与硬件的界限就开始模糊了。
|
||||
|
||||
一旦硬件能够跟得上软件的灵活性,那么这些与业务无关的技术问题,便很可能从软件的层面剥离出来,在硬件的基础设施之内就被悄悄解决掉,让软件可以只专注于业务,真正“围绕业务能力构建”团队与产品。那么原来只能从软件层面解决的分布式架构问题,于是有了另外一种解法:应用代码与基础设施软硬一体,合力应对。
|
||||
|
||||
这样一来,在DCE中未能实现的“透明的分布式应用”就成为了可能,Martin Fowler设想的“凤凰服务器”就成为了可能,Chad Fowler提出的“不可变基础设施”也会成为可能。
|
||||
|
||||
没错,我们借此就来到了现在媒体文章中常说的“云原生”时代。这样理解下来,“云原生”这个概念,是不是没那么抽象了。
|
||||
|
||||
云原生时代追求的目标,跟此前微服务时代中追求的目标相比,并没有什么本质的改变,它们都是通过一系列小型服务去构建大型系统。在服务架构演进的历史进程中,我更愿意把“云原生时代”称为“后微服务时代”。
|
||||
|
||||
不过还有一点值得注意的是,前面我说,Kubernetes成为了容器战争的胜利者,标志着后微服务时代的开端,但Kubernetes其实并没有完美地解决全部的分布式问题。
|
||||
|
||||
这里所说的“不完美”的意思是,仅从功能灵活强大这点来看,Kubernetes反而还不如之前的Spring Cloud方案。这是因为有一些问题处于应用系统与基础设施的边缘,我们很难能完全在基础设施的层面中,去精细化地解决掉它们。
|
||||
|
||||
给你举个例子,微服务A调用了微服务B中发布的两个服务,我们称之为B1和B2,假设B1表现正常,但B2出现了持续的500错,那在达到一定的阈值之后,我们就应该对B2进行熔断,以避免产生雪崩效应。如果我们仅在基础设施的层面来做处理,这就会遇到一个两难问题,也就是切断A到B的网络通路,会影响到B1的正常调用,而不切断的话则会持续受到B2的错误影响。
|
||||
|
||||
|
||||
|
||||
这种问题在通过Spring Cloud这类应用代码实现的微服务中,其实并不难处理,反正是使用代码(或者配置)来解决问题,只要合乎逻辑,我们想做什么功能都是可以的,只是会受限于开发人员的想象力与技术能力。但基础设施是针对整个容器来做整体管理的,它的粒度就相对粗犷。
|
||||
|
||||
实际上,类似的情况不仅仅会在断路器上出现,服务的监控、认证、授权、安全、负载均衡等功能,都有细化管理的需求。比如,服务调用时的负载均衡,往往需要根据流量特征,调整负载均衡的层次、算法等,而DNS尽管能实现一定程度的负载均衡,但它通常并不能满足这些额外的需求。
|
||||
|
||||
所以,为了解决这一类问题,微服务基础设施很快就进行了第二次进化,引入在今天被我们叫做是“服务网格”(Service Mesh)的“边车代理模式”(Sidecar Proxy)。
|
||||
|
||||
所谓的“边车”,是指一种带挎斗的三轮摩托,我小时候还算常见,现在基本就只在抗日神剧中才会看到了。
|
||||
|
||||
|
||||
|
||||
具体到咱们现在的语境里,“边车”的意思是,微服务基础设施会由系统自动地在服务的资源容器(指Kubernetes的Pod)中注入一个通讯代理服务器(相当于那个挎斗),用类似网络安全里中间人攻击的方式进行流量劫持,在应用毫无感知的情况下,悄悄接管掉应用的所有对外通讯。
|
||||
|
||||
这个代理除了会实现正常的服务调用以外(称为数据平面通讯),同时还接受来自控制器的指令(称为控制平面通讯),根据控制平面中的配置,分析数据平面通讯的内容,以实现熔断、认证、度量、监控、负载均衡等各种附加功能。
|
||||
|
||||
这样,就实现了既不需要在应用层面附带额外的代码,也提供了几乎不亚于应用代码的精细管理能力的目的。
|
||||
|
||||
|
||||
|
||||
(来自Istio的配置文档,图中的Mixer在Istio 1.5之后已经取消,这里仅作示意)
|
||||
|
||||
虽然,我们很难从概念上,来判定一个与应用系统运行于同一资源容器之内的代理服务,到底应该算是软件,还是算作基础设施,但只要它对应用是透明的,不需要改动任何软件代码就可以实现服务治理,这就足够了。
|
||||
|
||||
小结
|
||||
|
||||
今天,我带着你一起游览了后微服务时代,一起了解了容器化技术兴起对软件架构、软件开发的改变,并一起探讨了微服务如何通过虚拟化基础设施,来解决分布式问题的办法,即今天服务网格中的“边车代理模式”。
|
||||
|
||||
服务网格在2018年才火了起来,到今天它仍然是一个新潮的概念,Istio和Envoy的发展时间还很短,仍然没有完全成熟,甚至连Kubernetes也还算是个新生事物(以它开源的日期来计算)。
|
||||
|
||||
但我相信,未来几年,Kubernetes将会成为服务器端标准的运行环境,如同在此之前的Linux一样;服务网格将会成为微服务之间通讯交互的主流模式,它会把“选择什么通讯协议”“如何做认证授权”之类的技术问题隔离于应用软件之外,取代今天的Spring Cloud全家桶中的大部分组件的功能。这是最理想的Smart Endpoints解决方案,微服务只需要考虑业务本身的逻辑就行了。
|
||||
|
||||
上帝的归上帝,凯撒的归凯撒,业务与技术完全分离,远程与本地完全透明,我想也许这就是分布式架构最好的时代吧。
|
||||
|
||||
一课一思
|
||||
|
||||
分布式架构发展到服务网格后,真的是到达“最好的时代”了吗?软件架构的发展不太可能真的就此止步,你认为今天的云原生还有哪些主要矛盾,下一次软件架构的进化将会主要解决什么问题?
|
||||
|
||||
欢迎你在留言区分享你的看法。如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
||||
74
专栏/周志明的架构课/06_无服务时代:“不分布式”云端系统的起点.md
Normal file
74
专栏/周志明的架构课/06_无服务时代:“不分布式”云端系统的起点.md
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 _ 无服务时代:“不分布式”云端系统的起点
|
||||
你好,我是周志明。今天是探索“演进中的架构”的最后一讲,我们来聊聊最近一两年才开始兴起的“无服务架构”。
|
||||
|
||||
我们都知道,分布式架构出现的最初目的,是要解决单台机器的性能成为整个软件系统的瓶颈的问题。后来随着技术的演进,容错能力、技术异构、职责划分等其他因素,也都成了分布式架构要考虑的问题。但不可否认的是,获得更好的性能,仍然在架构设计中占有非常大的比重。
|
||||
|
||||
在前面几讲我们也说,分布式架构也会引入一些新问题(比如服务的安全、容错,分布式事务的一致性),因此对软件开发这件事儿来说,不去做分布式无疑是最简单的。如果单台服务器的性能可以是无限的,那架构演进的结果,肯定会跟今天不一样。不管是分布式和容器化,还是微服务,恐怕都未必会出现了,最起码不会是今天的模样。
|
||||
|
||||
当然了,绝对意义上的无限性能肯定是不存在的,但相对意义上的无限性能其实已经实现了,云计算的成功落地就可以说明这一点。对基于云计算的软件系统来说,无论用户有多少、逻辑如何复杂,AWS、阿里云等云服务提供商都能在算力上满足系统对性能的需求,只要你能为这种无限的性能支付得起对应的代价。
|
||||
|
||||
在工业界,2012年,iron.io公司率先提出了“无服务”(Serverless,应该翻译为“无服务器”才合适,但现在用“无服务”已形成习惯了)的概念;2014年开始,AWS发布了命名为Lambda的商业化无服务应用,并在后续的几年里逐步得到了开发者的认可,发展成目前世界上最大的无服务的运行平台;到了2019年,中国的阿里云、腾讯云等厂商,也发布了无服务的产品。“无服务”成了近期技术圈里的“新网红”之一。
|
||||
|
||||
我们再看看学术界对无服务的态度。在2009年云计算刚提出的时候,UC Berkeley大学就发表了一篇论文“Above the Clouds: A Berkeley View of Cloud Computing”,文中预言的云计算的价值、演进和普及,在过去的十年(2009~2019年)里一一得到了验证。十年之后的2019年,UC Berkeley的第二篇命名风格相同的论文“Cloud Programming Simplified: A Berkeley View on Serverless Computing”,再次预言“无服务将会成为日后云计算的主流方式”。
|
||||
|
||||
由此可见,主流学术界也同样认可无服务是未来的一个发展方向。
|
||||
|
||||
虽然工业界和学术界在“无服务”这件事儿上都取得了些成果,但是到今天“无服务”也还没有一个特别权威的定义。不过这也不是什么问题,毕竟它没有我们前面讲到的微服务、SOA等各种架构那么复杂,它最大的卖点就是简单,只涉及了后端设施(Backend)和函数(Function)两块内容。
|
||||
|
||||
|
||||
后端设施是指数据库、消息队列、日志、存储等这一类用于支撑业务逻辑运行,但本身无业务含义的技术组件。这些后端设施都运行在云中,也就是无服务中的“后端即服务”(Backend as a Service,BaaS)。
|
||||
函数指的就是业务逻辑代码。这里函数的概念与粒度,都已经和程序编码角度的函数非常接近了,区别就在于,无服务中的函数运行在云端,不必考虑算力问题和容量规划(从技术角度可以不考虑,但从计费的角度来看,你还是要掂量一下自己的钱包够不够用),也就是无服务中的“函数即服务”(Function as a Service,FaaS)。
|
||||
|
||||
|
||||
无服务的愿景是让开发者只需要纯粹地关注业务:一是,不用考虑技术组件,因为后端的技术组件是现成的,可以直接取用,没有采购、版权和选型的烦恼;二是,不需要考虑如何部署,因为部署过程完全是托管到云端的,由云端自动完成;三是,不需要考虑算力,因为有整个数据中心的支撑,算力可以认为是无限的;四是,也不需要操心运维,维护系统持续地平稳运行是云服务商的责任,而不再是开发者的责任。
|
||||
|
||||
你看,这是不是就像从汇编语言发展到高级语言后,开发者不用再去关注寄存器、信号、中断等与机器底层相关的细节?没错儿,UC Berkeley的论文“Cloud Programming Simplified: A Berkeley View on Serverless Computing”中,就是这样描述无服务给生产力带来的极大解放的。
|
||||
|
||||
不过,无服务架构的远期前景也许很美好,但我自己对无服务中短期内的发展,并没有那么乐观。为什么这么说呢?
|
||||
|
||||
与单体架构、微服务架构不同,无服务架构天生的一些特点,比如冷启动、 无状态、运行时间有限制等等,决定了它不是一种具有普适性的架构模式。除非是有重大变革,否则它也很难具备普适性。
|
||||
|
||||
一方面,对一些适合的应用来说,使用无服务架构确实能够降低开发和运维环节的成本,比如不需要交互的离线大规模计算,又比如多数Web资讯类网站、小程序、公共API服务、移动应用服务端等,都跟无服务架构擅长的短链接、无状态、适合事件驱动的交互形式很契合。
|
||||
|
||||
但另一方面,对于那些信息管理系统、网络游戏等应用来说,又或者说对所有具有业务逻辑复杂、依赖服务端状态、响应速度要求较高、需要长连接等特征的应用来说,无服务架构至少在目前来看并不是最合适的。
|
||||
|
||||
这是因为,无服务天生“无限算力”的假设,就决定了它必须要按使用量(函数运算的时间和内存)来计费,以控制消耗算力的规模,所以函数不会一直以活动状态常驻服务器,只有请求到了才会开始运行。这导致了函数不便于依赖服务端状态,也导致了函数会有冷启动时间,响应的性能不可能会太好(目前,无服务的云函数冷启动过程大概是在百毫秒级别,对于Java这类启动性能差的应用,甚至能到秒级)。
|
||||
|
||||
但无论如何,云计算毕竟是大势所趋,今天信息系统建设的概念和观念,在较长尺度的“明天”都是会转变成适应云端的。我并不怀疑Serverless+API的这种设计方式,随着云计算的持续发展,将会成为一种主流的软件架构形式,无服务到时候也应该会有更广阔的应用空间。
|
||||
|
||||
如果说微服务架构是分布式系统这条路当前所能做到的极致,那无服务架构,也许就是“不分布式”的云端系统这条路的起点。
|
||||
|
||||
虽然在顺序上,我把“无服务”安排到了“微服务”和“云原生”时代之后,但它们并没有继承替代关系。我之所以要强调这一点,是为了避免你可能会从两者的名称和安排顺序的角度,产生“无服务比微服务更加先进”的错误想法。我相信,软件开发的未来,不会只存在某一种“最先进的”架构风格,而是会有多种具有针对性的架构风格并存。这才是软件产业更有生命力的形态。
|
||||
|
||||
我同样也相信,软件开发的未来,多种架构风格将会融合互补,“分布式”与“不分布式”的边界将会逐渐模糊,两条路线将会在云端的数据中心交汇。
|
||||
|
||||
今天,我们已经能初步看见一些使用无服务的云函数去实现微服务架构的苗头了,所以把无服务作为技术层面的架构,把微服务视为应用层面的架构,这样的组合使用也是完全合理可行的。比如,根据腾讯公开的资料,企业微信、QQ小程序、腾讯新闻等产品,就是使用自己的无服务框架构成的微服务系统。以后,无论是通过物理机、虚拟机、容器,或者是无服务云函数,都会是微服务实现方案的一个候选项。
|
||||
|
||||
小结
|
||||
|
||||
今天是架构演进历史的最后一讲,如第1讲的开篇所说,我们谈历史重点不在考古,而是要借历史之名,来理解每种架构出现的意义以及被淘汰的原因。这样,我们才能更好地解决今天遇到的各种实际的问题,看清楚未来架构演进的发展道路。
|
||||
|
||||
对于架构演进的未来,2014年的时候,Martin Fowler和James Lewis在《Microservices》的结束语中分享的观点是,他们对于微服务日后能否被大范围地推广,最多只能持谨慎的乐观态度。无服务方兴未艾的今天,与那时微服务的情况十分相近,我对无服务日后的推广也是持有谨慎的乐观态度。软件开发的最大挑战就在于,只能在不完备的信息下决定当前要处理的问题。
|
||||
|
||||
时至今日,我们依然很难预想在架构演进之路的前方,微服务和无服务之后,还会出现什么形式的架构风格,这也正契合了图灵的那句名言:尽管目光所及之处,只是不远的前方,即使如此,依然可以看到那里有许多值得去完成的工作在等待我们。
|
||||
|
||||
|
||||
We can only see a short distance ahead, but we can see plenty there that needs to be done.-
|
||||
尽管目光所及之处,只是不远的前方,即使如此,依然可以看到那里有许多值得去完成的工作在等待我们。-
|
||||
—— Alan Turing, Computing Machinery and Intelligence, 1950
|
||||
|
||||
|
||||
一课一思
|
||||
|
||||
在这一讲之前,你是否了解、接触过无服务架构?无服务目前在中国处于起步的发展阶段,阿里云、腾讯云的无服务计算框架,都给了普通用户相当大的免费额度,你愿意去试一下吗?
|
||||
|
||||
欢迎你在留言区分享你的看法。如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
||||
161
专栏/周志明的架构课/07_远程服务调用(上):从本地方法到远程方法的桥梁.md
Normal file
161
专栏/周志明的架构课/07_远程服务调用(上):从本地方法到远程方法的桥梁.md
Normal file
@@ -0,0 +1,161 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 _ 远程服务调用(上):从本地方法到远程方法的桥梁
|
||||
你好,我是周志明。从今天这一讲开始,我们就进入了课程的第二个模块:架构师的视角。
|
||||
|
||||
“架构师”这个词,其实指向非常宽泛,你可以说做企业战略设计的是架构师,也可以说做业务流程分析的是架构师。而在这门课程中,我所针对的架构师视角,特指软件系统中技术模型的系统设计者。在这个模块当中,我会带你系统性地了解,在做架构设计的时候,架构师都应该思考哪些问题、可以选择哪些主流的解决方案和行业标准做法,以及这些主流方案都有什么优缺点、会给架构设计带来什么影响,等等。
|
||||
|
||||
理解了架构师的这些职责,你对“架构设计”这种听起来就很抽象的工作,是不是有个更具体的认识了?
|
||||
|
||||
从今天开始,我会花两讲的时间,和你一起学习“远程服务调用(Remote Procedure Call,RPC)”这个话题。我会尽可能地从根源到现状、从表现到本质,为你解释清楚RPC的一些常见的问题。
|
||||
|
||||
那今天,我们就先从“什么是RPC”开始,一起去学习“远程服务”这个构建分布式系统的最基本的前置条件,看看它是如何出现、如何发展的,以及当前业界的主流实现手段。
|
||||
|
||||
其实,RPC这个词儿在计算机科学中已经有超过40年的历史了,肯定不是一个新概念。但是直到今天,我们还是会在知乎等网站上,看到很多人提问“什么是RPC?”“如何评价某某RPC技术?”“RPC好还是REST好?”,仍然“每天”都有新的不同形状的RPC轮子被发明出来,仍然有层出不穷的文章,去比对Google gRPC、Facebook Thrift等各个厂家的RPC技术的优劣。
|
||||
|
||||
像计算机科学这种知识快速更迭的领域,一项40岁高龄的技术能有如此的关注度,可以说是相当稀罕的现象了。那为什么会出现这种现象呢?
|
||||
|
||||
我分析了其中的原因:一方面,可能是微服务风潮带来的热度;另一方面,也不得不承认,作为开发者,我们很多人对RPC本身可以解决什么问题、如何解决这些问题、为什么要这样解决,都或多或少存在些认知模糊的情况。
|
||||
|
||||
那接下来,我就给你详细解读一下,关于RPC的各种分歧和普遍的错误认知。
|
||||
|
||||
进程间通讯
|
||||
|
||||
尽管今天的大多数RPC技术已经不再追求“与本地方法调用一致”这个目标了,但不可否认的是,RPC出现的最初目的,就是为了让计算机能够跟调用本地方法一样,去调用远程方法。所以,我们先来看一下在本地方法调用的时候,都会发生些什么。
|
||||
|
||||
我们先通过下面这段Java风格的伪代码,来定义几个概念:
|
||||
|
||||
// 调用者(Caller) : main()
|
||||
// 被调用者(Callee) : println()
|
||||
// 调用点(Call Site) : 发生方法调用的指令流位置
|
||||
// 调用参数(Parameter) : 由Caller传递给Callee的数据,即“hello world”
|
||||
// 返回值(Retval) : 由Callee传递给Caller的数据,如果方法正常完成,返回值是void,否则是对应的异常
|
||||
public static void main(String[] args) {
|
||||
System.out.println(“hello world”);
|
||||
}
|
||||
|
||||
|
||||
通过这段伪代码,你可以发现,在完全不考虑编译器优化的前提下,程序运行至调用println()这一行的时候,计算机(物理机或者虚拟机)会做以下这些事情:
|
||||
|
||||
|
||||
传递方法参数:将字符串hello world的引用压栈。
|
||||
确定方法版本:根据println()方法的签名,确定它的执行版本其实并不是一个简单的过程,不管是编译时的静态解析也好,还是运行时的动态分派也好,程序都必须根据某些语言规范中明确定义的原则,找到明确的被调用者Callee。这里的“明确”是指唯一的一个Callee,或者有严格优先级的多个Callee,比如不同的重载版本。我曾在《深入理解Java虚拟机》中用一整章介绍过这个过程。如果你感兴趣的话,可以去深入了解一下。
|
||||
执行被调方法:从栈中获得Parameter,以此为输入,执行Callee内部的逻辑。
|
||||
返回执行结果:将Callee的执行结果压栈,并将指令流恢复到Call Site处,继续向下执行。
|
||||
|
||||
|
||||
接下来,我们就需要考虑一下,当println()方法不在当前进程的内存地址空间中,会出现什么问题。不难想到,此时至少面临两个直接的障碍:
|
||||
|
||||
|
||||
第一个障碍,前面的第一步和第四步所做的传递参数、传回结果都依赖于栈内存的帮助,如果Caller与Callee分属不同的进程,就不会拥有相同的栈内存,那么在Caller进程的内存中将参数压栈,对于Callee进程的执行毫无意义。
|
||||
第二个障碍,第二步的方法版本选择依赖于语言规则的定义,而如果Caller与Callee不是同一种语言实现的程序,方法版本选择就将是一项模糊的不可知行为。
|
||||
|
||||
|
||||
所以为了简化,我们暂时忽略第二个障碍,假设Caller与Callee是使用同一种语言实现的,先来解决两个进程之间如何交换数据的问题,这件事情在计算机科学中被称为“进程间通讯”(Inter-Process Communication,IPC)。那么我们可以考虑的解决办法就有以下几种:
|
||||
|
||||
第一,管道(Pipe)或具名管道(Named Pipe)
|
||||
|
||||
管道其实类似于两个进程间的桥梁,用于进程间传递少量的字符流或字节流。普通管道可用于有亲缘关系进程间的通信(由一个进程启动的另外一个进程);而具名管道摆脱了普通管道没有名字的限制,除了具有普通管道所具有的功能以外,它还允许无亲缘关系进程间的通信。
|
||||
|
||||
管道典型的应用就是命令行中的“ | ”操作符,比如说,命令“ps -ef | grep java” ,就是管道操作符“ | ”将ps命令的标准输出通过管道,连接到grep命令的标准输入上。
|
||||
|
||||
第二,信号(Signal)
|
||||
|
||||
信号是用来通知目标进程有某种事件发生的。除了用于进程间通信外,信号还可以被进程发送给进程自身。信号的典型应用是kill命令,比如“kill -9 pid”,意思就是由Shell进程向指定PID的进程发送SIGKILL信号。
|
||||
|
||||
第三,信号量(Semaphore)
|
||||
|
||||
信号量是用于两个进程之间同步协作的手段,相当于操作系统提供的一个特殊变量。我们可以在信号量上,进行wait()和notify()操作。
|
||||
|
||||
第四,消息队列(Message Queue)
|
||||
|
||||
前面所说的这三种方式,只适合传递少量信息,而POSIX标准中,有定义“消息队列”用于进程间通讯的方法。也就是说,进程可以向队列中添加消息,而被赋予读权限的进程则可以从队列中消费消息。消息队列就克服了信号承载信息量少、管道只能用于无格式字节流,以及缓冲区大小受限等缺点 ,但实时性相对受限。
|
||||
|
||||
第五,共享内存(Shared Memory)
|
||||
|
||||
允许多个进程可以访问同一块内存空间,这是效率最高的进程间通讯形式。进程的内存地址空间是独立隔离的,但操作系统提供了让进程主动创建、映射、分离、控制某一块内存的接口。由于内存是多进程共享的,所以往往会与其它通信机制,如信号量等结合使用,来达到进程间的同步及互斥。
|
||||
|
||||
第六,本地套接字接口(IPC Socket)
|
||||
|
||||
消息队列和共享内存这两种方式,只适合单机多进程间的通讯。而套接字接口,是更为普适的进程间通信机制,可用于不同机器之间的进程通信。
|
||||
|
||||
套接字(Socket)起初是由Unix系统的BSD分支开发出来的,但现在已经移植到所有的Unix和Linux系统上了。基于效率考虑,当仅限于本机进程间通讯的时候,套接字接口是被优化过的,不会经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等操作,只是简单地将应用层数据从一个进程拷贝到另一个进程,这种进程间通讯方式有个专有的名称:Unix Domain Socket,又叫做IPC Socket。
|
||||
|
||||
通信的成本
|
||||
|
||||
我之所以花这么多篇幅来介绍IPC的手段,是因为计算机科学家们最初的想法,就是将RPC作为IPC的一种特例来看待(其实现在分类上这么说也仍然合适,只是在具体操作手段上不会这么做了)。
|
||||
|
||||
这里,我们需要特别关注的是最后一种基于套接字接口的通讯方式(IPC Socket)。因为它不仅适用于本地相同机器的不同进程间通讯,而且因为Socket是网络栈的统一接口,它也理所当然地能支持基于网络的跨机器、跨进程的通讯。比如Linux系统的图形化界面中,X Window服务器和GUI程序之间的交互,就是由这套机制来实现的。
|
||||
|
||||
此外,这样做还有一个看起来无比诱人的好处。因为IPC Socket是操作系统提供的标准接口,所以它完全有可能把远程方法调用的通讯细节,隐藏在操作系统底层,从应用层面上来看,可以做到远程调用与本地方法调用几乎完全一致。
|
||||
|
||||
事实上,在原始分布式时代的初期确实是奔着这个目标去做的,但这种透明的调用形式反而让程序员们误以为通信是无成本的,从而被滥用,以至于显著降低了分布式系统的性能。
|
||||
|
||||
1987年,当“透明的RPC调用”一度成为主流范式的时候,安德鲁 · 塔能鲍姆(Andrew Tanenbaum)教授曾发表了一篇论文“A Critique of the Remote Procedure Call Paradigm”,对这种透明的RPC范式提出了一系列质问:
|
||||
|
||||
|
||||
两个进程通讯,谁作为服务端,谁作为客户端?
|
||||
怎样进行异常处理?异常该如何让调用者获知?
|
||||
服务端出现多线程竞争之后怎么办?
|
||||
如何提高网络利用的效率,比如连接是否可被多个请求复用以减少开销?是否支持多播?
|
||||
参数、返回值如何表示?应该有怎样的字节序?
|
||||
如何保证网络的可靠性,比如调用期间某个链接忽然断开了怎么办?
|
||||
服务端发送请求后,收不到回复该怎么办?
|
||||
……
|
||||
|
||||
|
||||
论文的中心观点是:把本地调用与远程调用当作一样的来处理,是犯了方向性的错误,把系统间的调用做成透明的,反而会增加程序员工作的复杂度。
|
||||
|
||||
此后几年,关于RPC应该如何发展、如何实现的论文层出不穷,有支持的也有反对,有冷静分析的也有狂热唾骂的,但历史逐渐证明了Andrew Tanenbaum的预言是正确的。
|
||||
|
||||
最终,1994年至1997年间,由ACM和Sun的院士Peter Deutsch、套接字接口发明者Bill Joy、Java之父James Gosling等众多在Sun Microsystems工作的大佬们,共同总结了通过网络进行分布式运算的八宗罪(8 Fallacies of Distributed Computing):
|
||||
|
||||
|
||||
网络是可靠的(The network is reliable)
|
||||
延迟是不存在的(Latency is zero )
|
||||
带宽是无限的(Bandwidth is infinite)
|
||||
网络是安全的(The network is secure)
|
||||
拓扑结构是一成不变的(Topology doesn’t change)
|
||||
总会有一个管理员(There is one administrator)
|
||||
不考虑传输成本(Transport cost is zero)
|
||||
网络是同质化的(The network is homogeneous)
|
||||
|
||||
|
||||
这八宗罪,被认为是程序员在网络编程中经常忽略的八大问题,潜台词就是如果远程服务调用要弄透明化的话,就必须为这些罪过买单。这算是给RPC能否等同于IPC来实现,暂时定下了一个具有公信力的结论。
|
||||
|
||||
到这时为止,RPC应该是一种高层次的,或者说语言层次的特征,而不是像IPC那样,是低层次的,或者说系统层次的特征,就成为了工业界、学术界的主流观点。
|
||||
|
||||
在1980年代初期,传奇的施乐Palo Alto研究中心,发布了基于Cedar语言的RPC框架Lupine,并实现了世界上第一个基于RPC的商业应用Courier。这里施乐PARC定义的“远程服务调用”的概念,就是符合上面针对RPC的结论的。所以,尽管此前已经有用其他名词指代RPC的操作,我们也一般认为RPC的概念,最早是由施乐公司所提出的。
|
||||
|
||||
|
||||
首次提出远程服务调用的定义-
|
||||
Remote procedure call is the synchronous language-level transfer of control between programs in address spaces whose primary communication is a narrow channel.-
|
||||
—— Bruce Jay Nelson,Remote Procedure Call,Xerox PARC,1981
|
||||
|
||||
|
||||
|
||||
|
||||
到这里,我们就可以得出RPC的定义了:RPC是一种语言级别的通讯协议,它允许运行于一台计算机上的程序以某种管道作为通讯媒介(即某种传输协议的网络),去调用另外一个地址空间(通常为网络上的另外一台计算机)。
|
||||
|
||||
小结
|
||||
|
||||
这一讲,我们讨论了RPC的起源、概念,以及它发展上的一些分歧。以此为基础,我们才能更好地理解后面几讲要学习的内容,包括RPC本身要解决的三大问题、RPC框架的现状与发展,以及它与REST的区别。
|
||||
|
||||
RPC以模拟进程间方法调用为起点,许多思想和概念都借鉴的是IPC,因此这一讲我也介绍了IPC中的一些关键概念和实现方法。但是,RPC原本想照着IPC的发展思路,却在实现层面上遇到了很大的困难。RPC作为一种跨网络的通讯手段,能否无视通讯的成本去迁就编程和设计的原则,这一点从几十年前的DCE开始,直到今天学术界、工业界都还有争议。
|
||||
|
||||
在下一讲,我会和你一起学习在RPC的定义提出之后,工业界中出现过的、著名的RPC协议,以及当今常用的各种RPC框架,学习它们的共性,也就是它们都必须解决哪几个问题,各自以什么为关注点,以及为何不会出现“完美的”RPC框架。
|
||||
|
||||
一课一思
|
||||
|
||||
“远程方法不应该无视通讯成本”这个观点,从性能的角度来看是有益的,但从简单的角度看则是有害的。在现代的软件系统开发中,你用过什么RPC框架吗?它们有没有把“像本地方法一样调用远程方法”作为卖点?
|
||||
|
||||
欢迎在留言区分享你的答案。如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
199
专栏/周志明的架构课/08_远程服务调用(下):如何选择适合自己的RPC框架?.md
Normal file
199
专栏/周志明的架构课/08_远程服务调用(下):如何选择适合自己的RPC框架?.md
Normal file
@@ -0,0 +1,199 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 _ 远程服务调用(下):如何选择适合自己的RPC框架?
|
||||
你好,我是周志明。
|
||||
|
||||
上一讲,我们主要是从学术的角度出发,一起学习了RPC概念的形成过程。今天这一讲,我会带你从技术的角度出发,去看看工业界在RPC这个领域曾经出现过的各种协议,以及时至今日还在层出不穷的各种框架。你会从中了解到RPC要解决什么问题,以及如何选择适合自己的RPC框架。
|
||||
|
||||
RPC框架要解决的三个基本问题
|
||||
|
||||
在第1讲“原始分布式时代”中,我曾提到过,在80年代中后期,惠普和Apollo提出了网络运算架构(Network Computing Architecture,NCA)的设想,并随后在DCE项目中,发展成了在Unix系统下的远程服务调用框架DCE/RPC。
|
||||
|
||||
这是历史上第一次对分布式有组织的探索尝试。因为DCE本身是基于Unix操作系统的,所以DCE/RPC也仅面向于Unix系统程序之间的通用。
|
||||
|
||||
|
||||
补充:这句话其实不全对,微软COM/DCOM的前身MS RPC,就是DCE的一种变体版本,而它就可以在Windows系统中使用。
|
||||
|
||||
|
||||
在1988年,Sun Microsystems起草并向互联网工程任务组(Internet Engineering Task Force,IETF)提交了RFC 1050规范,此规范中设计了一套面向广域网或混合网络环境的、基于TCP/IP网络的、支持C语言的RPC协议,后来也被称为是ONC RPC(Open Network Computing RPC/Sun RPC)。
|
||||
|
||||
这两个RPC协议,就可以算是如今各种RPC协议的鼻祖了。从它们开始,一直到接下来的这几十年,所有流行过的RPC协议,都不外乎通过各种手段来解决三个基本问题:
|
||||
|
||||
|
||||
如何表示数据?
|
||||
如何传递数据?
|
||||
如何表示方法?
|
||||
|
||||
|
||||
接下来,我们分别看看是如何解决的吧。
|
||||
|
||||
如何表示数据?
|
||||
|
||||
这里的数据包括了传递给方法的参数,以及方法的返回值。无论是将参数传递给另外一个进程,还是从另外一个进程中取回执行结果,都会涉及应该如何表示的问题。
|
||||
|
||||
针对进程内的方法调用,我们使用程序语言内置的和程序员自定义的数据类型,就很容易解决数据表示的问题了;而远程方法调用,则可能面临交互双方分属不同程序语言的情况。
|
||||
|
||||
所以,即使是只支持同一种语言的RPC协议,在不同硬件指令集、不同操作系统下,也完全可能有不一样的表现细节,比如数据宽度、字节序的差异等。
|
||||
|
||||
行之有效的做法,是将交互双方涉及的数据,转换为某种事先约定好的中立数据流格式来传输,将数据流转换回不同语言中对应的数据类型来使用。这个过程说起来比较拗口,但相信你一定很熟悉它,这其实就是序列化与反序列化。
|
||||
|
||||
每种RPC协议都应该有对应的序列化协议,比如:
|
||||
|
||||
|
||||
ONC RPC的External Data Representation (XDR)
|
||||
CORBA的Common Data Representation(CDR)
|
||||
Java RMI的Java Object Serialization Stream Protocol
|
||||
gRPC的Protocol Buffers
|
||||
Web Service的XML Serialization
|
||||
众多轻量级RPC支持的JSON Serialization
|
||||
……
|
||||
|
||||
|
||||
如何传递数据?
|
||||
|
||||
准确地说,如何传递数据是指如何通过网络,在两个服务Endpoint之间相互操作、交换数据。这里“传递数据”通常指的是应用层协议,实际传输一般是基于标准的TCP、UDP等传输层协议来完成的。
|
||||
|
||||
两个服务交互不是只扔个序列化数据流来表示参数和结果就行了,诸如异常、超时、安全、认证、授权、事务等信息,都可能存在双方交换信息的需求。
|
||||
|
||||
在计算机科学中,专门有一个“Wire Protocol”,用来表示两个Endpoint之间交换这类数据的行为。常见的Wire Protocol有以下几种:
|
||||
|
||||
|
||||
Java RMI的Java Remote Message Protocol(JRMP,也支持RMI-IIOP)
|
||||
CORBA的Internet Inter ORB Protocol(IIOP,是GIOP协议在IP协议上的实现版本)
|
||||
DDS的Real Time Publish Subscribe Protocol(RTPS)
|
||||
Web Service的Simple Object Access Protocol(SOAP)
|
||||
如果要求足够简单,双方都是HTTP Endpoint,直接使用HTTP也可以(如JSON-RPC)
|
||||
……
|
||||
|
||||
|
||||
如何表示方法?
|
||||
|
||||
“如何表示方法”,这在本地方法调用中其实也不成问题,因为编译器或者解释器会根据语言规范,把调用的方法转换为进程地址空间中方法入口位置的指针。
|
||||
|
||||
不过一旦考虑到不同语言,这件事儿又麻烦起来了,因为每门语言的方法签名都可能有所差别,所以,针对“如何表示一个方法”和“如何找到这些方法”这两个问题,我们还是得有个统一的标准。
|
||||
|
||||
这个标准做起来其实可以很简单:只要给程序中的每个方法,都规定一个通用的又绝对不会重复的编号;在调用的时候,直接传这个编号就可以找到对应的方法。这种听起来无比寒碜的办法,还真的就是DCE/RPC最初准备的解决方案。
|
||||
|
||||
虽然最后,DCE还是弄出了一套跟语言无关的接口描述语言(Interface Description Language,IDL),成为了此后许多RPC参考或依赖的基础(如CORBA的OMG IDL),但那个唯一的“绝不重复”的编码方案UUID,却意外地流行了起来,已经被广泛应用到了程序开发的方方面面。
|
||||
|
||||
这类用于表示方法的协议还有:
|
||||
|
||||
|
||||
Android的Android Interface Definition Language(AIDL)
|
||||
CORBA的OMG Interface Definition Language(OMG IDL)
|
||||
Web Service的Web Service Description Language(WSDL)
|
||||
JSON-RPC的JSON Web Service Protocol(JSON-WSP)
|
||||
……
|
||||
|
||||
|
||||
你看,如何表示数据、如何传递数据、如何表示方法这三个RPC中的基本问题,都可以在本地方法调用中找到对应的操作。RPC的思想始于本地方法调用,尽管它早就不再追求要跟本地方法调用的实现完全一样了,但RPC的发展仍然带有本地方法调用的深刻烙印。因此,我们在理解PRC的本质时,比较轻松的方式是,以它和本地调用的联系来对比着理解。
|
||||
|
||||
好,理解了RPC要解决的三个基本问题以后,我们接着来看一下,现代的RPC框架都为我们提供了哪些可选的解决方案,以及为什么今天会有这么多的RPC框架在并行发展。
|
||||
|
||||
统一的RPC
|
||||
|
||||
DCE/RPC与ONC RPC都有很浓厚的Unix痕迹,所以它们其实并没有真正地在Unix系统以外大规模流行过,而且它们还有一个“大问题”:只支持传递值而不支持传递对象(ONC RPC的XDR的序列化器能用于序列化结构体,但结构体毕竟不是对象)。这两个RPC协议都是面向C语言设计的,根本就没有对象的概念。
|
||||
|
||||
而90年代,正好又是面向对象编程(Object-Oriented Programming,OOP)风头正盛的年代,所以在1991年,对象管理组织(Object Management Group,OMG)便发布了跨进程、面向异构语言的、支持面向对象的服务调用协议:CORBA 1.0(Common Object Request Broker Architecture)。
|
||||
|
||||
CORBA 1.0和1.1版本只提供了对C和C++的支持,而到了末代的CORBA 3.0版本,不仅支持了C、C++、Java、Object Pascal、Python、Ruby等多种主流编程语言,还支持了Smalltalk、Lisp、Ada、COBOL等已经“半截入土”的非主流语言,阵营不可谓不强大。
|
||||
|
||||
可以这么说,CORBA是一套由国际标准组织牵头、由多个软件提供商共同参与的分布式规范。在当时,只有微软私有的DCOM的影响力可以稍微跟CORBA抗衡一下。但是,与DCE一样,DCOM也受限于操作系统(不过比DCE厉害的是,DCOM能跨语言哟)。所以,能够同时支持跨系统、跨语言的CORBA,其实原本是最有机会统一RPC这个细分领域的竞争者。
|
||||
|
||||
但很无奈的是,CORBA并没有抓住这个机会。一方面,CORBA本身的设计实在是太过于啰嗦和繁琐了,甚至有些规定简直到了荒谬的程度。比如说,我们要写一个对象请求代理(ORB,这是CORBA中的关键概念)大概要200行代码,其中大概有170行是纯粹无用的废话(这句带有鞭尸性质的得罪人的评价不是我说的,是CORBA的首席科学家Michi Henning在文章《The Rise and Fall of CORBA》中自己说的)。
|
||||
|
||||
另一方面,为CORBA制定规范的专家逐渐脱离实际了,所以做出的CORBA规范非常晦涩难懂,导致各家语言的厂商都有自己的解读,结果弄出来的CORBA实现互不兼容,实在是对CORBA号称支持众多异构语言的莫大讽刺。这也间接造就了后来W3C Web Service的出现。
|
||||
|
||||
所以,Web Service一出现,CORBA就在这场竞争中,犹如十八路诸侯讨董卓,互乱阵脚、一触即溃,局面可以说是惨败无比。最终下场就是,CORBA和DCOM一起被扫进了计算机历史的博物馆中,而Web Service获得了一统RPC的大好机会。
|
||||
|
||||
1998年,XML 1.0发布,并成为了万维网联盟(World Wide Web Consortium,W3C)的推荐标准。1999年末,以XML为基础的SOAP 1.0(Simple Object Access Protocol)规范的发布,代表着一种被称为“Web Service”的全新RPC协议的诞生。
|
||||
|
||||
Web Service是由微软和DevelopMentor公司共同起草的远程服务协议,随后被提交给W3C,并通过投票成为了国际标准。所以,Web Service也被称为是W3C Web Service。
|
||||
|
||||
Web Service采用了XML作为远程过程调用的序列化、接口描述、服务发现等所有编码的载体,当时XML是计算机工业最新的银弹,只要是定义为XML的东西,几乎就都被认为是好的,风头一时无两,连微软自己都主动宣布放弃DCOM,迅速转投Web Service的怀抱。
|
||||
|
||||
交给W3C管理后,Web Service再没有天生属于哪家公司的烙印,商业运作非常成功,很受市场欢迎,大量的厂商都想分一杯羹。但从技术角度来看,它设计得也并不优秀,甚至同样可以说是有显著缺陷。
|
||||
|
||||
对于开发者而言,Web Service的一大缺点,就是过于严格的数据和接口定义所带来的性能问题。
|
||||
|
||||
虽然Web Service吸取了CORBA的教训,不再需要程序员手工去编写对象的描述和服务代理了,但是XML作为一门描述性语言,本身的信息密度就很低(都不用与二进制协议比,与今天的JSON或YAML比一下就知道了)。同时,Web Service是一个跨语言的RPC协议,这使得一个简单的字段,为了在不同语言中不会产生歧义,要以XML描述去清楚的话,往往比原本存储这个字段值的空间多出十几倍、几十倍乃至上百倍。
|
||||
|
||||
这个特点就导致了,要想使用Web Service,就必须要有专门的客户端去调用和解析SOAP内容,也需要专门的服务去部署(如Java中的Apache Axis/CXF);更关键的是,这导致了每一次数据交互都包含大量的冗余信息,性能非常差。
|
||||
|
||||
如果只是需要客户端、传输性能差也就算了,又不是不能用。既然选择了XML来获得自描述能力,也就代表着没打算把性能放到第一位。但是,Web Service还有另外一点原罪:贪婪。
|
||||
|
||||
“贪婪”是指,它希望在一套协议上,一揽子解决分布式计算中可能遇到的所有问题。这导致Web Service生出了一整个家族的协议出来。
|
||||
|
||||
Web Service协议家族中,除它本身包括了的SOAP、WSDL、UDDI协议之外,还有一堆以WS-*命名的子功能协议,来解决事务、一致性、事件、通知、业务描述、安全、防重放等问题。这些几乎数不清个数的家族协议,对开发者来说学习负担极其沉重。结果就是,得罪惨了开发者,谁爱用谁用去。
|
||||
|
||||
当程序员们对Web Service的热情迅速燃起,又逐渐冷却之后,也不禁开始反思:那些面向透明的、简单的RPC协议,如DCE/RPC、DCOM、Java RMI,要么依赖于操作系统,要么依赖于特定语言,总有一些先天约束;那些面向通用的、普适的RPC协议,如CORBA,就无法逃过使用复杂性的困扰;而那些意图通过技术手段来屏蔽复杂性的RPC协议,如Web Service,又不免受到性能问题的束缚。
|
||||
|
||||
简单、普适和高性能,似乎真的难以同时满足。
|
||||
|
||||
分裂的RPC
|
||||
|
||||
由于一直没有一个能同时满足以上简单、普适和高性能的“完美RPC协议”,因此远程服务器调用这个小小的领域就逐渐进入了群雄混战、百家争鸣的“战国时代”,距离“统一”越来越远,并一直延续至今。
|
||||
|
||||
我们看看相继出现过的RPC协议/框架,就能明白了:RMI(Sun/Oracle)、Thrift(Facebook/Apache)、Dubbo(阿里巴巴/Apache)、gRPC(Google)、Motan2(新浪)、Finagle(Twitter)、brpc(百度)、.NET Remoting(微软)、Arvo(Hadoop)、JSON-RPC 2.0(公开规范,JSON-RPC工作组)……
|
||||
|
||||
这些RPC的功能、特点都不太一样,有的是某种语言私有,有的能支持跨越多门语言,有的运行在HTTP协议之上,有的能直接运行于TCP/UDP之上,但没有哪一款是“最完美的RPC”。据此,我们也可以发现一个规律,任何一款具有生命力的RPC框架,都不再去追求大而全的“完美”,而是会找到一个独特的点作为主要的发展方向。
|
||||
|
||||
我们看几个典型的发展方向:
|
||||
|
||||
|
||||
朝着面向对象发展。这条线的缘由在于,在分布式系统中,开发者们不再满足于RPC带来的面向过程的编码方式,而是希望能够进行跨进程的面向对象编程。因此,这条线还有一个别名叫作分布式对象(Distributed Object),它的代表有RMI、.NET Remoting。当然了,之前的CORBA和DCOM也可以归入这一类。
|
||||
朝着性能发展,代表为gRPC和Thrift。决定RPC性能主要就两个因素:序列化效率和信息密度。序列化效率很好理解,序列化输出结果的容量越小,速度越快,效率自然越高;信息密度则取决于协议中,有效荷载(Payload)所占总传输数据的比例大小,使用传输协议的层次越高,信息密度就越低,SOAP使用XML拙劣的性能表现就是前车之鉴。gRPC和Thrift都有自己优秀的专有序列化器,而在传输协议方面,gRPC是基于HTTP/2的,支持多路复用和Header压缩,Thrift则直接基于传输层的TCP协议来实现,省去了额外的应用层协议的开销。
|
||||
朝着简化发展,代表为JSON-RPC。要是说选出功能最强、速度最快的RPC可能会有争议,但要选出哪个功能弱的、速度慢的,JSON-RPC肯定会是候选人之一。它牺牲了功能和效率,换来的是协议的简单。也就是说,JSON-RPC的接口与格式的通用性很好,尤其适合用在Web浏览器这类一般不会有额外协议、客户端支持的应用场合。
|
||||
……
|
||||
|
||||
|
||||
经历了RPC框架的“战国时代”,开发者们终于认可了,不同的RPC框架所提供的不同特性或多或少是互相矛盾的,很难有某一种框架说“我全部都要”。
|
||||
|
||||
要把面向对象那套全搬过来,就注定不会太简单(比如建Stub、Skeleton就很烦了,即使由IDL生成也很麻烦);功能多起来,协议就要弄得复杂,效率一般就会受影响;要简单易用,那很多事情就必须遵循约定而不是配置才行;要重视效率,那就需要采用二进制的序列化器和较底层的传输协议,支持的语言范围容易受限。
|
||||
|
||||
也正是因为每一种RPC框架都有不完美的地方,才会有新的RPC轮子不断出现。
|
||||
|
||||
而到了最近几年,RPC框架有明显朝着更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化方向发展的趋势,不再选择自己去解决表示数据、传递数据和表示方法这三个问题,而是将全部或者一部分问题设计为扩展点,实现核心能力的可配置,再辅以外围功能,如负载均衡、服务注册、可观察性等方面的支持。这一类框架的代表,有Facebook的Thrift和阿里的Dubbo(现在两者都是Apache的)。
|
||||
|
||||
尤其是断更多年后重启的Dubbo表现得更为明显,它默认有自己的传输协议(Dubbo协议),同时也支持其他协议,它默认采用Hessian 2作为序列化器,如果你有JSON的需求,可以替换为Fastjson;如果你对性能有更高的需求,可以替换为Kryo、FST、Protocol Buffers等;如果你不想依赖其他包,直接使用JDK自带的序列化器也可以。这种设计,就在一定程度上缓解了RPC框架必须取舍,难以完美的缺憾。
|
||||
|
||||
小结
|
||||
|
||||
今天,我们一起学习了RPC协议在工业界的发展,包括它要解决的三个基本问题,以及层出不穷的RPC协议/框架。
|
||||
|
||||
表示数据、传递数据和表示方法,是RPC必须解决的三大基本问题。要解决这些问题,可以有很多方案,这也是RPC协议/框架出现群雄混战局面的一个原因。
|
||||
|
||||
出现这种分裂局面的另一个原因,是简单的框架很难能达到功能强大的要求。
|
||||
|
||||
功能强大的框架往往要在传输中加入额外的负载和控制措施,导致传输性能降低,而如果既想要高性能,又想要强功能,这就必然要依赖大量的技巧去实现,进而也就导致了框架会变得过于复杂,这就决定了不可能有一个“完美”的框架同时满足简单、普适和高性能这三个要求。
|
||||
|
||||
认识到这一点后,一个RPC框架要想取得成功,就要选择一个发展方向,能够非常好地满足某一方面的需求。因此,我们也就有了朝着面向对象发展、朝着性能发展和朝着简化发展这三条线。
|
||||
|
||||
以上就是这一讲我要和你分享的RPC在工业界的发展成果了。这也是,你在日后工作中选择RPC实现方案的一个参考。
|
||||
|
||||
最后,我再和你分享一点我的心得。我在讲到DCOM、CORBA、Web Service的失败的时候,虽然说我的口吻多少有一些戏谑,但我们得明确一点:这些框架即使没有成功,但作为早期的探索先驱,并没有什么应该被讽刺的地方。而且其后续的发展,都称得上是知耻后勇,反而值得我们的掌声赞赏。
|
||||
|
||||
比如,说到CORBA的消亡,OMG痛定思痛之后,提出了基于RTPS协议栈的“数据分发服务”商业标准(Data Distribution Service,DDS,“商业”就是要付费使用的意思)。这个标准现在主要用在物联网领域,能够做到微秒级延时,还能支持大规模并发通讯。
|
||||
|
||||
再比如,说到DCOM的失败和Web Service的衰落,微软在它们的基础上,推出了.NET WCF(Windows Communication Foundation,Windows通信基础)。
|
||||
|
||||
.NET WCF的优势主要有两点:一是,把REST、TCP、SOAP等不同形式的调用,自动封装为了完全一致的、如同本地方法调用一般的程序接口;二是,依靠自家的“地表最强IDE”Visual Studio,把工作量减少到只需要指定一个远程服务地址,就可以获取服务描述、绑定各种特性(如安全传输)、自动生成客户端调用代码,甚至还能选择同步还是异步之类细节的程度。
|
||||
|
||||
虽然.NET WCF只支持.NET平台,而且也是采用XML语言描述,但使用体验真的是非常畅快,足够挽回Web Service得罪开发者丢掉的全部印象分。
|
||||
|
||||
一课一思
|
||||
|
||||
我们通过两讲学习了RPC在学术界和工业界的发展后,再回过头来思考一个问题:开发一个分布式系统,是不是就一定要用RPC呢?
|
||||
|
||||
我提供给你一个分析思路吧。RPC的三大问题源自对本地方法调用的类比模拟,如果我们把思维从“方法调用”的约束中挣脱,那参数与结果如何表示、方法如何表示、数据如何传递这些问题,都会海阔天空,拥有焕然一新的视角。但是我们写程序,真的可能不面向方法来编程吗?
|
||||
|
||||
这就是我在下一讲准备跟你探讨的话题了。现在你可以先自己思考一下,欢迎在留言区分享你的看法。另外,如果觉得有收获,也非常欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
226
专栏/周志明的架构课/09_RESTful服务(上):从面向过程编程到面向资源编程.md
Normal file
226
专栏/周志明的架构课/09_RESTful服务(上):从面向过程编程到面向资源编程.md
Normal file
@@ -0,0 +1,226 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 _ RESTful服务(上):从面向过程编程到面向资源编程
|
||||
你好,我是周志明。前面两节课,我们学习了远程方法调用RPC,今天我们接着学习另一种主流的远程服务访问风格:RESTful服务。
|
||||
|
||||
REST与RPC的对比
|
||||
|
||||
很多人都会拿REST来跟RPC对比优劣,其实,无论是思想上、概念上,还是使用范围上,REST与RPC都不完全一样,它们在本质上并不是同一个类型的东西,充其量只算是有一些相似,在应用中会有一部分功能重合的地方。
|
||||
|
||||
REST与RPC在思想上存在差异的核心,是抽象的目标不一样,也就是面向资源的编程思想与面向过程的编程思想之间的区别。
|
||||
|
||||
面向过程编程和面向对象编程,想必你应该都听说过,但什么是面向资源编程呢?这个问题等我一会儿介绍完REST的特征之后,再回头细说。
|
||||
|
||||
那么,二者在概念上的不同,是指REST并不是一种远程服务调用协议,甚至我们可以把定语也去掉,它就不是一种协议。
|
||||
|
||||
因为协议都带有一定的规范性和强制性,最起码也该有个规约文档,比如JSON-RPC,它哪怕再简单,也要有个《JSON-RPC Specification》来规定协议的格式细节、异常、响应码等信息。但是REST并没有定义这些内容,虽然它有一些指导原则,但实际上并不受任何强制的约束。
|
||||
|
||||
经常会有人批评说,某个系统接口“设计得不够RESTful”,其实这句话本身就有些争议。因为REST只能说是一种风格,而不是规范、协议,并且能完全达到REST所有指导原则的系统,也是很少见的。这个问题我们会在下一讲中详细讨论。
|
||||
|
||||
至于使用范围上,REST与RPC作为主流的两种远程调用方式,在使用上确实有重合之处,但重合的区域有多大就见仁见智了。
|
||||
|
||||
上一节课,我提到了当前的RPC协议框架各有侧重点,并且列举了RPC的一些典型发展方向,比如分布式对象、提升调用效率、简化调用复杂性等等。
|
||||
|
||||
其中的分布式对象这一条线的应用,对于REST就可以说是毫无关系;而能够重视远程服务调用效率的应用场景,就基本上已经排除了REST应用得最多的供浏览器端消费的远程服务。因为以浏览器作为前端,对于传输协议、序列化器这两点都没有什么选择权,哪怕想要更高效率也有心无力。
|
||||
|
||||
而在移动端、桌面端或者分布式服务端的节点之间通讯这一块,REST虽然照样有宽阔的用武之地,只要支持HTTP就可以用于任何语言之间的交互,不过使用REST的前提是网络没有成为性能上的瓶颈。但是在需要追求传输效率的场景里,REST提升传输效率的潜力有限,死磕REST又想要好的网络性能,一般不会有好的效果。
|
||||
|
||||
另外,对于追求简化调用的场景,我在前面提到的浏览器端就属于这一类的典型,在众多RPC里,也就JSON-RPC有机会与REST竞争,其他RPC协议与框架,哪怕是能够支持HTTP协议,哪怕提供了JavaScript版本的客户端(如gRPC-Web),也只是具备前端使用的理论可行性,很少能看到有实际项目把它们真的用到浏览器上的。
|
||||
|
||||
可是,尽管有着种种不同,REST跟RPC还是产生了很频繁的比较与争论,这两种分别面向资源和面向过程的远程调用方式,就像当年面向对象与面向过程的编程思想一样,非得分出个高低不可。
|
||||
|
||||
理解REST
|
||||
|
||||
REST概念的提出来自于罗伊·菲尔丁(Roy Fielding)在2000年发表的博士论文:《Architectural Styles and the Design of Network-based Software Architectures》(架构风格与网络的软件架构设计)。这篇文章的确是REST的源头,但我们也不能忽略Fielding的身份和他之前的工作背景,这对理解REST的设计思想也是非常重要的。
|
||||
|
||||
首先,Fielding是一名很优秀的软件工程师,他是Apache服务器的核心开发者,后来成为了著名的Apache软件基金会的联合创始人;同时,Fielding也是HTTP 1.0协议(1996年发布)的专家组成员,后来还成为了HTTP 1.1协议(1999年发布)的负责人。
|
||||
|
||||
HTTP 1.1协议设计得非常成功,以至于在发布后长达十年的时间里,都没有多少人认为有修订的必要。而用来指导设计HTTP 1.1协议的理论和思想,最初是以备忘录的形式,在专家组成员之间交流,这个备忘录其实就是REST的雏形。
|
||||
|
||||
那么从时间上看,当起草完HTTP 1.1协议之后,Fielding就回到了加州大学欧文分校,继续攻读博士学位。然后到了第二年,也就是2000年,Fielding更为系统、严谨地阐述了这套理论框架,并且以这套理论框架为基础,导出了一种新的编程风格,他把这种风格命名为了我们今天所熟知的REST,即“表征状态转移”(Representational State Transfer)的缩写。
|
||||
|
||||
不过,哪怕是对编程和网络都很熟悉的同学,单从“表征状态转移”这个标题上看,也不太可能直接弄明白,什么叫“表征”、啥东西的“状态”、从哪“转移”到哪。虽然在论文当中,Fielding有论述过这些概念,但他写得确实非常晦涩(不想读英文的话,你可以参考一下中文翻译版本)。
|
||||
|
||||
这里呢,我推荐你一种比较容易理解REST思想的方式,就是你先去理解什么是HTTP,再配合一些实际例子来进行类比,你就会发现“REST”实际上是“HTT”(Hyper Text Transfer,超文本传输)的进一步抽象,它们就像是接口与实现类之间的关系。
|
||||
|
||||
HTTP中使用的“超文本”一词,是美国社会学家泰德·H·尼尔森(Theodor Holm Nelson)在1967年于《Brief Words on the Hypertext》一文里提出的,这里引用的是他本人在1992年修正后的定义:
|
||||
|
||||
|
||||
Hypertext-
|
||||
By now the word “hypertext” has become generally accepted for branching and responding text, but the corresponding word “hypermedia”, meaning complexes of branching and responding graphics, movies and sound – as well as text – is much less used. Instead they use the strange term “interactive multimedia”: this is four syllables longer, and does not express the idea of extending hypertext.-
|
||||
—— Theodor Holm Nelson Literary Machines, 1992
|
||||
|
||||
|
||||
可以看到,“超文本(或超媒体)”指的是一种“能够对操作进行判断和响应的文本(或声音、图像等)”,这个概念在上个世纪60年代提出的时候,应该还属于科幻的范畴,但是到了今天,我们已经完全接受了它,互联网中的一段文字可以点击、可以触发脚本执行、可以调用服务端,都已经非常平常,毫不稀奇了。
|
||||
|
||||
所以接下来,我们就尝试着从理解“超文本”的含义开始,根据一个具体的阅读文章的例子,来理解一下什么是“表征”,以及REST中的其他关键概念。
|
||||
|
||||
|
||||
资源(Resource)
|
||||
|
||||
|
||||
假设,你现在正在阅读一篇名为《REST设计风格》的文章,这篇文章的内容本身(可以将其视作是某种信息、数据),我们称之为“资源”。无论你是在网上看的网页,还是打印出来看的文字稿,或者是在电脑屏幕上阅读、手机上浏览,尽管它呈现出来的样子都不一样,但其中的信息是不变的,你阅读的仍是同一个“资源”。
|
||||
|
||||
|
||||
表征(Representation)
|
||||
|
||||
|
||||
当你通过电脑浏览器阅读这篇文章的时候,浏览器会向服务端发出请求“我需要这个资源的HTML格式”,那么服务端向浏览器返回的这个HTML,就被称之为“表征”,你通过其他方式拿到了文章的PDF、Markdown、RSS等其他形式的版本,它们也同样是一个资源的多种表征。可见,“表征”这个概念是指信息与用户交互时的表示形式,这跟应用分层中我们常说的“表示层”(Presentation Layer)的语义其实是一致的。
|
||||
|
||||
|
||||
状态(State)
|
||||
|
||||
|
||||
当你读完了这篇文章,想再接着看看下一篇文章的内容时,你向服务器发出请求“给我下一篇文章”。但是“下一篇”是个相对概念,必须依赖“当前你正在阅读的文章是哪一篇”,这样服务器才能正确回应,那么这类在特定语境中才能产生的上下文信息就被称为“状态”。
|
||||
|
||||
这里我们要注意,有状态(Stateful)还是无状态(Stateless),都是只相对于服务端来说的,服务器要完成“取下一篇”的请求,要么是自己记住用户的状态(这个用户现在阅读的是哪一篇文章,这是有状态),要么是客户端来记住状态,在请求的时候明确告诉服务器(我正在阅读某某文章,现在要读下一篇,这是无状态)。
|
||||
|
||||
|
||||
转移(Transfer)
|
||||
|
||||
|
||||
要知道,无论状态是由服务端还是客户端来提供的,“取下一篇文章”这个行为逻辑必然只能由服务端来提供。服务器通过某种方式,把“用户当前阅读的文章”转变成“下一篇文章”,这就被称为“表征状态转移”。
|
||||
|
||||
好,通过这个“阅读文章”的例子,对资源等概念进行通俗的解释,现在你应该就能理解REST所说的“表征状态转移”的含义了。
|
||||
|
||||
那么,借着这个例子的上下文,我再给你介绍几个现在不涉及,但在后面解读REST的6大核心特征时要用到的概念名词:
|
||||
|
||||
第一个,统一接口(Uniform Interface)。
|
||||
|
||||
在了解这个概念之前,我们先来思考一个问题,前面所说的“服务器通过某种方式”,让表征状态发生转移,具体指的是什么方式呢?
|
||||
|
||||
如果你现在正在使用Web端来学习这一讲的内容,你可以看到页面的左半部分有下一讲(或者是下面几讲)的URI超链接地址,这是服务端在渲染这讲内容时就预置好的,点击它让页面跳转到下一讲,就是所谓“某种方式”的其中一种方式(不过若下一讲还未更新出来时,你只能看到之前的课程内容,道理其实也差不多)。
|
||||
|
||||
现在,我们其实并不会对点击超链接网页出现跳转而感到奇怪,但你再细想一下,URI的含义是统一资源标识符,是一个名词,那它如何能表达出“转移”这个动作的含义呢?
|
||||
|
||||
答案是HTTP协议中已经提前约定好了一套“统一接口”,它包括:GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS七种基本操作,任何一个支持HTTP协议的服务器都会遵守这套规定,对特定的URI采取这些操作,服务器就会触发相应的表征状态转移。
|
||||
|
||||
第二个,超文本驱动(Hypertext Driven)。
|
||||
|
||||
尽管表征状态转移是由浏览器主动向服务器发出请求所引发的,该请求导致了“在浏览器屏幕上显示出了下一篇文章的内容”这个结果的出现。但是你我都清楚,这不可能真的是浏览器的主动意图,浏览器是根据用户输入的URI地址来找到服务器给予的首页超文本内容,通过超文本内部的链接,导航到了这篇文章,阅读结束时,也是通过超文本内部的链接再导航到下一篇。
|
||||
|
||||
浏览器作为所有网站的通用的客户端,任何网站的导航(状态转移)行为都不可能是预置于浏览器代码之中,而是由服务器发出的请求响应信息(超文本)来驱动的。这点与其他带有客户端的软件有十分本质的区别,在那些软件中,业务逻辑往往是预置于程序代码之中的,有专门的页面控制器(无论在服务端还是在客户端中)来驱动页面的状态转移。
|
||||
|
||||
第三个,自描述消息(Self-Descriptive Messages)。
|
||||
|
||||
前面我们知道了,资源的表征可能存在多种不同形态,因此在传输给浏览器的消息中应当有明确的信息,来告知客户端该消息的类型以及该如何处理这条消息。一种被广泛采用的自描述方法,是在名为“Content-Type”的HTTP Header中标识出互联网媒体类型(MIME type),比如“Content-Type : application/json; charset=utf-8”,就说明了该资源会以JSON的格式返回,请使用UTF-8字符集进行处理。
|
||||
|
||||
好,除了以上列出的这些看名字不容易弄懂的概念外,在理解REST的时候,你还要注意一个常见的误区。Fielding在提出REST时,所谈论的范围是“架构风格与网络的软件架构设计”(Architectural Styles and Design of Network-based Software Architectures),而不是现在被人们所狭义理解的一种“远程服务设计风格”。
|
||||
|
||||
这两者的范围差别,就好比这门课程所谈论的话题“软件架构”与这个小章节所谈论的话题“访问远程服务”的关系那样,前者是后者的一个很大的超集。尽管考虑到这节课的主题和多数人的关注点,我们确实是会以“远程服务设计风格”作为讨论的重点,但至少我们要知道它们在范围上的差别。
|
||||
|
||||
RESTful风格的系统特征
|
||||
|
||||
OK,理解了前面解读的这些概念以后,现在我们就可以开始讨论面向资源的编程思想,以及Fielding所提出的具体的软件架构设计特征了。Fielding认为,一套理想的、完全满足REST的系统应该满足以下六个特征。
|
||||
|
||||
服务端与客户端分离(Client-Server)
|
||||
|
||||
现在,有越来越多的开发者认可,分离开用户界面和数据存储所关注的逻辑,有助于提高用户界面跨平台的可移植性。
|
||||
|
||||
以前完全基于服务端控制和渲染(如JSF这类)框架的实际用户,现在已经很少见了。另外,在服务端进行界面控制(Controller),通过服务端或者客户端的模版渲染引擎,来进行界面渲染的框架(如Struts、SpringMVC这类)也受到了颇大的冲击。而推动这个局面发展的主要原因,实际上跟REST的关系并不大,随着前端技术(从ES规范,到语言实现,到前端框架等)近年来的高速发展,前端表达能力的大幅度加强才是真正的幕后推手。
|
||||
|
||||
此外,由于前端的日渐强势,现在还流行起由前端代码反过来驱动服务端进行渲染的SSR(Server-Side Rendering)技术,在Serverless、SEO等场景中已经占领了一块领地。
|
||||
|
||||
无状态(Stateless)
|
||||
|
||||
这是REST的一条关键原则,部分开发者在做服务接口规划时,觉得RESTful风格的API怎么设计都别扭,一个很可能的原因就是服务端持有着比较重的状态。
|
||||
|
||||
REST希望服务器能不负责维护状态,每一次从客户端发送的请求中,应该包括所有必要的上下文信息,会话信息也由客户端保存维护,服务器端依据客户端传递的状态信息来进行业务处理,并且驱动整个应用的状态变迁。
|
||||
|
||||
至于客户端承担状态维护职责后的认证、授权等各方面的可信问题,都会有针对性的解决方案(这部分内容,我会在后面讲解安全架构时展开介绍)。
|
||||
|
||||
但必须承认的现状是,目前大多数的系统是达不到这个要求的,越复杂、越大型的系统越是如此。服务端无状态可以在分布式环境中获得很高价值的好处,但大型系统的上下文状态数量,完全可能膨胀到,客户端在每次发送请求时,根本无法全部囊括系统里所有必要的上下文信息。在服务端的内存、会话、数据库或者缓存等地方,持有一定的状态是一种现实情况,而且会是长期存在、被广泛使用的主流方案。
|
||||
|
||||
可缓存(Cacheability)
|
||||
|
||||
前面我们提到的无状态服务,虽然提升了系统的可见性、可靠性和可伸缩性,但也降低了系统的网络性。这句话通俗的解释就是,某个功能使用有状态的架构只需要一次请求就能完成,而无状态的服务则可能会需要多个请求,或者在请求中带有冗余的信息。
|
||||
|
||||
所以,为了缓解这个矛盾,REST希望软件系统能够像万维网一样,客户端和中间的通讯传递者(代理)可以将部分服务端的应答缓存起来。当然,应答中必须明确或者间接地表明本身是否可以进行缓存,以避免客户端在将来进行请求的时候得到过时的数据。
|
||||
|
||||
运作良好的缓存机制可以减少客户端、服务器之间的交互,甚至有些场景中可以完全避免交互,这就进一步提高了性能。
|
||||
|
||||
分层系统(Layered System)
|
||||
|
||||
这里所指的并不是表示层、服务层、持久层这种意义上的分层,而是指客户端一般不需要知道是否直接连接到了最终的服务器,或者是连接到路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制,提高系统的可扩展性,这样也便于缓存、伸缩和安全策略的部署。
|
||||
|
||||
统一接口(Uniform Interface)
|
||||
|
||||
REST希望开发者面向资源编程,希望软件系统设计的重点放在抽象系统该有哪些资源上,而不是抽象系统该有哪些行为(服务)上。
|
||||
|
||||
这个特征,你可以类比计算机中对文件管理的操作。我们知道,管理文件可能会进行创建、修改、删除、移动等操作,这些操作数量是可数的,而且对所有文件都是固定的、统一的。如果面向资源来设计系统,同样会具有类似的操作特征,由于REST并没有设计新的协议,所以这些操作都借用了HTTP协议中固有的操作命令来完成。
|
||||
|
||||
统一接口也是REST最容易陷入争论的地方,基于网络的软件系统,到底是面向资源更好,还是面向服务更合适,这件事情在很长的时间里恐怕都不会有个定论,也许永远都没有。但是,有一个已经基本清晰的结论是:面向资源编程的抽象程度通常更高。
|
||||
|
||||
抽象程度高有好处但也有坏处。坏处是往往距离人类的思维方式更远,而好处是往往通用程度会更好。
|
||||
|
||||
不过这样来诠释REST,大概本身就挺抽象的,你可能不太好理解,我还是举个例子来说明。
|
||||
|
||||
几乎每个系统都有登录和注销功能,如果你理解成登录对应于login()、注销对应于logout()这样两个独立服务,这是“符合人类思维”的;如果你理解成登录是PUT Session,注销是DELETE Session,这样你只需要设计一种“Session资源”即可满足需求,甚至以后对Session的其他需求,如查询登录用户的信息,就是GET Session而已,其他操作如修改用户信息等等,都可以被这同一套设计囊括在内,这便是“抽象程度更高”带来的好处。
|
||||
|
||||
而如果你想要在架构设计中合理恰当地利用统一接口,Fielding给出了三个建议:第一,系统要能做到每次请求中都包含资源的ID,所有操作均通过资源ID来进行;第二,每个资源都应该是自描述的消息;第三,通过超文本来驱动应用状态的转移。
|
||||
|
||||
按需代码(Code-On-Demand)
|
||||
|
||||
按需代码被Fielding列为了一条可选原则,原因其实并非是它特别难以达到,更多是出于必要性和性价比的实际考虑。按需代码是指任何按照客户端(如浏览器)的请求,将可执行的软件程序从服务器发送到客户端的技术。它赋予了客户端无需事先知道,所有来自服务端的信息应该如何处理、如何运行的宽容度。
|
||||
|
||||
举个具体例子,以前的Java Applet技术、今天的WebAssembly等都属于典型的按需代码,蕴含着具体执行逻辑的代码存放在了服务端,只有当客户端请求了某个Java Applet之后,代码才会被传输并在客户端机器中运行,结束后通常也会随即在客户端中被销毁掉。
|
||||
|
||||
到这里,REST中的主要概念与思想原则就介绍完了,那么现在,我们再回过头来讨论一下这节课开篇中提出的REST与RPC在思想上的差异。
|
||||
|
||||
REST与RPC在思想上的差异
|
||||
|
||||
我在前面提到,REST的基本思想是面向资源来抽象问题,它与此前流行的面向过程的编程思想,在抽象主体上有本质的差别。
|
||||
|
||||
在REST提出以前,人们设计分布式系统服务的唯一方案就只有RPC,RPC是将本地的方法调用思路迁移到远程方法调用上,开发者是围绕着“远程方法”去设计两个系统间的交互的,比如CORBA、RMI、DCOM,等等。
|
||||
|
||||
这样做的坏处,不仅是“如何在异构系统间表示一个方法”“如何获得接口能够提供的方法清单”,都成了需要专门协议去解决的问题(RPC的三大基本问题之一),更在于服务的每个方法都是不同的,服务使用者必须逐个学习才能正确地使用它们。Google在《Google API Design Guide》中曾经写下这样一段话:
|
||||
|
||||
|
||||
Traditionally, people design RPC APIs in terms of API interfaces and methods, such as CORBA and Windows COM. As time goes by, more and more interfaces and methods are introduced. The end result can be an overwhelming number of interfaces and methods, each of them different from the others. Developers have to learn each one carefully in order to use it correctly, which can be both time consuming and error prone.-
|
||||
以前,人们面向方法去设计RPC API,比如CORBA和DCOM,随着时间推移,接口与方法越来越多却又各不相同,开发人员必须了解每一个方法才能正确使用它们,这样既耗时又容易出错。-
|
||||
—— Google API Design Guide, 2017
|
||||
|
||||
|
||||
而REST提出以资源为主体进行服务设计的风格,就为它带来了不少好处。我举几个典型例子。
|
||||
|
||||
第一,降低了服务接口的学习成本。
|
||||
|
||||
统一接口是REST的重要标志,它把对资源的标准操作都映射到了标准的HTTP方法上去,这些方法对每个资源的语义都是一致的,我们不需要刻意学习,更不会有什么Interface Description Language之类的协议存在。
|
||||
|
||||
第二,资源天然具有集合与层次结构。
|
||||
|
||||
以方法为中心抽象的接口,由于方法是动词,逻辑上决定了每个接口都是互相独立的;但以资源为中心抽象的接口,由于资源是名词,天然就可以产生集合与层次结构。
|
||||
|
||||
我举个例子。你可以想像一个商城用户中心的接口设计:用户资源会拥有多个不同的下级的资源,比如若干条短消息资源、一份用户资料资源、一部购物车资源,而购物车中又会有自己的下级资源,比如多本书籍资源。
|
||||
|
||||
这样,你就很容易在程序接口中构造出这些资源的集合关系与层次关系,而且能符合人们长期在单机或网络环境中管理数据的直觉。我相信,你并不需要专门去阅读接口说明书,也能轻易推断出获取用户icyfenix的购物车中,第2本书的REST接口应该表示为:
|
||||
|
||||
GET /users/icyfenix/cart/2
|
||||
|
||||
|
||||
第三,REST绑定于HTTP协议。
|
||||
|
||||
面向资源编程并不是必须构筑在HTTP之上,但对于REST来说,这是优点,也是缺点。
|
||||
|
||||
因为HTTP本来就是面向资源而设计的网络协议,纯粹只用HTTP(而不是SOAP over HTTP那样在再构筑协议)带来的好处,是不需要再去考虑RPC中的Wire Protocol问题了,REST可以复用HTTP协议中已经定义的语义和相关基础支持来解决。HTTP协议已经有效运作了30年,与其相关的技术基础设施已是千锤百炼,无比成熟。而它的坏处自然就是,当你想去考虑那些HTTP不提供的特性时,就束手无策了。
|
||||
|
||||
小结
|
||||
|
||||
在这节课中,虽然我列举了一些面向资源的优点,但我并非要证明它比面向过程、面向对象更优秀。是否选用REST的API设计风格,需要权衡的是你的需求场景、你团队的设计,以及开发人员是否能够适应面向资源的思想来设计软件、来编写代码。
|
||||
|
||||
在互联网中,面向资源来进行网络传输,是这三十年来HTTP协议精心培养出来的用户习惯,如果开发者能够适应REST不太符合人类思维习惯的抽象方式,那REST通常能够更好地匹配在HTTP基础上构建的互联网,在效率与扩展性方面也会有可观的收益。
|
||||
|
||||
一课一思
|
||||
|
||||
与远端服务通讯,除了以资源为中心的REST和以方法为中心的RPC外,还有基于长连接、消息管道等其他方式,想一想你还知道哪些通讯手段,它们能解决什么问题?有什么应用场景?
|
||||
|
||||
欢迎给我留言,分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
320
专栏/周志明的架构课/10_RESTful服务(下):如何评价服务是否RESTful?.md
Normal file
320
专栏/周志明的架构课/10_RESTful服务(下):如何评价服务是否RESTful?.md
Normal file
@@ -0,0 +1,320 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 _ RESTful服务(下):如何评价服务是否RESTful?
|
||||
你好,我是周志明。
|
||||
|
||||
上一节课,我们一起学习了REST的思想、概念和指导原则等,今天我们把重心放在REST的实践上,把目光聚焦到具体如何设计REST服务接口上。这样我们也就能回答上节课提出的问题“如何评价服务是否RESTful”了。
|
||||
|
||||
Richardson成熟度模型
|
||||
|
||||
“RESTful Web APIs”和“RESTful Web Services”的作者伦纳德 · 理查德森(Leonard Richardson),曾提出过一个衡量“服务有多么REST”的Richardson成熟度模型(Richardson Maturity Model,RMM)。这个模型的一个用处是,方便那些原本不使用REST的服务,能够逐步导入REST。
|
||||
|
||||
Richardson将服务接口按照“REST的程度”,从低到高分为0至3共4级:
|
||||
|
||||
|
||||
The Swamp of Plain Old XML:完全不REST。另外,关于POX这个说法,SOAP表示感觉有被冒犯到。
|
||||
Resources:开始引入资源的概念。
|
||||
HTTP Verbs:引入统一接口,映射到HTTP协议的方法上。
|
||||
Hypermedia Controls:在咱们课程里面的说法是“超文本驱动”,在Fielding论文里的说法是Hypertext as the Engine of Application State(HATEOAS),都说的是同一件事情。
|
||||
|
||||
|
||||
|
||||
|
||||
REST成熟度模型
|
||||
|
||||
接下来,我们通过马丁 · 福勒(Martin Fowler)的关于RMM的文章中的实际例子(原文是XML写的,我简化了一下),来看看四种不同程度的REST反应到实际API是怎样的。
|
||||
|
||||
假设,你是一名软件工程师,接到需求(也被我尽量简化了)的用户故事是这样的:现在要开发一个医生预约系统,病人通过这个系统,可以知道自己熟悉的医生在指定日期是否有空闲时间,以方便预约就诊。
|
||||
|
||||
第0级成熟度:The Swamp of Plain Old XML
|
||||
|
||||
医院开放了一个/appointmentService的Web API,传入日期、医生姓名作为参数,就可以得到该时间段、该医生的空闲时间。
|
||||
|
||||
这个API的一次HTTP调用如下所示:
|
||||
|
||||
POST /appointmentService?action=query HTTP/1.1
|
||||
|
||||
{date: "2020-03-04", doctor: "mjones"}
|
||||
|
||||
|
||||
在接收到请求之后,服务器会传回一个包含所需信息的结果:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
[
|
||||
{start:"14:00", end: "14:50", doctor: "mjones"},
|
||||
{start:"16:00", end: "16:50", doctor: "mjones"}
|
||||
]
|
||||
|
||||
|
||||
得到了医生空闲的结果后,我觉得14:00的时间比较合适,于是预约确认,并提交了我的基本信息:
|
||||
|
||||
POST /appointmentService?action=comfirm HTTP/1.1
|
||||
|
||||
{
|
||||
appointment: {date: "2020-03-04", start:"14:00", doctor: "mjones"},
|
||||
patient: {name: xx, age: 30, ……}
|
||||
}
|
||||
|
||||
|
||||
如果预约成功,那我能够收到一个预约成功的响应:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
{
|
||||
code: 0,
|
||||
message: "Successful confirmation of appointment"
|
||||
}
|
||||
|
||||
|
||||
如果发生了问题,比如有人在我前面抢先预约了,那么我会在响应中收到某种错误信息:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
{
|
||||
code: 1
|
||||
message: "doctor not available"
|
||||
}
|
||||
|
||||
|
||||
到此,整个预约服务就完成了,可以说是直接明了。
|
||||
|
||||
在这个方案里,我们采用的是非常直观的基于RPC风格的服务设计,看似是很轻松地解决了所有问题,但真的是这样吗?
|
||||
|
||||
第1级成熟度:Resources
|
||||
|
||||
实际上你可以发现,第0级是RPC的风格,所以如果需求永远不会变化,也不会增加,那它完全可以良好地工作下去。但是,如果你不想为预约医生之外的其他操作、为获取空闲时间之外的其他信息去编写额外的方法,或者改动现有方法的接口,那就应该考虑一下如何使用REST来抽象资源。
|
||||
|
||||
通往REST的第一步是引入资源的概念,在API中最基本的体现,就是它会围绕着资源而不是过程来设计服务。说得直白一点,你可以理解为服务的Endpoint应该是一个名词而不是动词。此外,每次请求中都应包含资源ID,所有操作均通过资源ID来进行。
|
||||
|
||||
POST /doctors/mjones HTTP/1.1
|
||||
|
||||
{date: "2020-03-04"}
|
||||
|
||||
|
||||
然后,服务器传回一个包含了ID的信息。注意,ID是资源的唯一编号,有ID即代表“医生的档期”被视为一种资源:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
[
|
||||
{id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
|
||||
{id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
|
||||
]
|
||||
|
||||
|
||||
我还是觉得14:00的时间比较合适,于是又预约确认,并提交了我的基本信息:
|
||||
|
||||
POST /schedules/1234 HTTP/1.1
|
||||
|
||||
{name: xx, age: 30, ……}
|
||||
|
||||
|
||||
后面预约成功或者失败的响应消息在这个级别里面与之前一致,就不重复了。
|
||||
|
||||
比起第0级,第1级的服务抽象程度有所提高,但至少还有三个问题并没有解决:
|
||||
|
||||
|
||||
一是,只处理了查询和预约,如果我临时想换个时间要调整预约,或者我的病忽然好了想删除预约,这都需要提供新的服务接口。
|
||||
二是,处理结果响应时,只能靠着结果中的code、message这些字段做分支判断,每一套服务都要设计可能发生错误的code。而这很难考虑全面,而且也不利于对某些通用的错误做统一处理。
|
||||
三是,并没有考虑认证授权等安全方面的内容。比如,要求只有登录过的用户才允许查询医生的档期;再比如,某些医生可能只对VIP开放,需要特定级别的病人才能预约等等。
|
||||
|
||||
|
||||
这三个问题,其实都可以通过引入统一接口(Uniform Interface)来解决。接下来,我们就来到了第2级。
|
||||
|
||||
第2级成熟度:HTTP Verbs
|
||||
|
||||
前面说到,第1级中遗留的这三个问题,都可以靠引入统一接口来解决,而HTTP协议的标准方法便是最常接触到的统一接口。
|
||||
|
||||
HTTP协议的标准方法是经过精心设计的,它几乎涵盖了资源可能遇到的所有操作场景(这其实更取决于架构师的抽象能力)。
|
||||
|
||||
那么,REST的做法是:
|
||||
|
||||
|
||||
针对预约变更的问题,把不同业务需求抽象为对资源的增加、修改、删除等操作来解决;
|
||||
针对响应代码的问题,使用HTTP协议的Status Code,可以涵盖大多数资源操作可能出现的异常(而且也是可以自定义扩展的);
|
||||
针对安全性的问题,依靠HTTP Header中携带的额外认证、授权信息来解决(这个在实战中并没有体现,你可以去看看后面第23~28讲中关于安全架构的相关内容)。
|
||||
|
||||
|
||||
按这个思路,我们在获取医生档期时,应该使用具有查询语义的GET操作来完成:
|
||||
|
||||
GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1
|
||||
|
||||
|
||||
然后,服务器会传回一个包含了所需信息的结果:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
[
|
||||
{id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
|
||||
{id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
|
||||
]
|
||||
|
||||
|
||||
我还是觉得14:00的时间比较合适,于是就预约确认,并提交了我的基本信息用来创建预约。这是符合POST的语义的:
|
||||
|
||||
POST /schedules/1234 HTTP/1.1
|
||||
|
||||
{name: xx, age: 30, ……}
|
||||
|
||||
|
||||
如果预约成功,那我能够收到一个预约成功的响应:
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
|
||||
Successful confirmation of appointment
|
||||
|
||||
|
||||
否则,我会在响应中收到某种错误信息:
|
||||
|
||||
HTTP/1.1 409 Conflict
|
||||
|
||||
doctor not available
|
||||
|
||||
|
||||
目前绝大多数的系统能够达到的REST级别,也就是第2级了。不过这种方案还不够完美,最主要的一个问题是:我们如何知道预约mjones医生的档期,需要访问“/schedules/1234”这个服务Endpoint?
|
||||
|
||||
第3级成熟度:Hypermedia Controls
|
||||
|
||||
或许你第一眼看到这个问题会说,这当然是程序写的啊,我为什么会问这么奇怪的问题。但问题是,REST并不认同这种已烙在程序员脑海中许久的想法。
|
||||
|
||||
RMM中的第3级成熟度Hypermedia Controls、Fielding论文中的HATEOAS和现在提得比较多的超文本驱动,其实都是希望能达到这样一种效果:除了第一个请求是由你在浏览器地址栏输入的信息所驱动的之外,其他的请求都应该能够自己描述清楚后续可能发生的状态转移,由超文本自身来驱动。
|
||||
|
||||
所以,当你输入了查询命令后:
|
||||
|
||||
GET /doctors/mjones/schedule?date=2020-03-04&statu s=open HTTP/1.1
|
||||
|
||||
|
||||
服务器传回的响应信息应该包括如何预约档期、如何了解医生信息等可能的后续操作:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
{
|
||||
schedules:[
|
||||
{
|
||||
id: 1234, start:"14:00", end: "14:50", doctor: "mjones",
|
||||
links: [
|
||||
{rel: "comfirm schedule", href: "/schedules/1234"}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5678, start:"16:00", end: "16:50", doctor: "mjones",
|
||||
links: [
|
||||
{rel: "comfirm schedule", href: "/schedules/5678"}
|
||||
]
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{rel: "doctor info", href: "/doctors/mjones/info"}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
如果做到了第3级REST,那么服务端的API和客户端就可以做到完全解耦了。这样一来,你再想要调整服务数量,或者同一个服务做API升级,将会变得非常简单。
|
||||
|
||||
至此,我们已经学完了REST的相关知识,了解了REST的一些优点,然而凡事总有两面,下面我们来看一看REST经常收到非议的方面。
|
||||
|
||||
REST的不足与争议
|
||||
|
||||
第一个有争议的问题是:面向资源的编程思想只适合做CRUD,只有面向过程、面向对象编程才能处理真正复杂的业务逻辑。
|
||||
|
||||
这是我们在实践REST时遇到的最多的一个问题。有这个争议的原因也很简单,HTTP的4个最基础的命令POST、GET、PUT和DELETE,很容易让人联想到CRUD操作,因此在脑海中就自然产生了直接的对应。
|
||||
|
||||
REST涵盖的范围当然远不止于此。不过要说POST、GET、PUT和DELETE对应于CRUD,其实也没什么不对,只是我们必须泛化地去理解这个CRUD:它们涵盖了信息在客户端与服务端之间流动的几种主要方式(比如POST、GET、PUT等标准方法),所有基于网络的操作逻辑,都可以通过解决“信息在服务端与客户端之间如何流动”这个问题来理解,有的场景里比较直观,而另一些场景中可能比较抽象。
|
||||
|
||||
针对那些比较抽象的场景,如果确实不好把HTTP方法映射为资源的所需操作,REST也并不会刻板地要求一定要做映射。这时,用户可以使用自定义方法,按Google推荐的REST API风格来拓展HTTP标准方法。
|
||||
|
||||
自定义方法应该放在资源路径末尾,嵌入冒号加自定义动词的后缀。比如,我将删除操作映射到标准DELETE方法上,此外还要提供一个恢复删除的API,那它可能会被设计为:
|
||||
|
||||
POST /user/user_id/cart/book_id:undelete
|
||||
|
||||
|
||||
要实现恢复删除,一个完全可行的设计是:设计一个回收站的资源,在那里保留还能被恢复的商品,我们把恢复删除看作是对这个资源的某个状态值的修改,映射到PUT或者PATCH方法上。
|
||||
|
||||
最后,我要再重复一遍,面向资源的编程思想与另外两种主流编程(面向过程和面向对象编程)思想,只是抽象问题时所处的立场不同,只有选择问题,没有高下之分:
|
||||
|
||||
|
||||
面向过程编程时,为什么要以算法和处理过程为中心,输入数据,输出结果?当然是为了符合计算机世界中主流的交互方式。
|
||||
面向对象编程时,为什么要将数据和行为统一起来、封装成对象?当然是为了符合现实世界的主流交互方式。
|
||||
面向资源编程时,为什么要将数据(资源)作为抽象的主体,把行为看作是统一的接口?当然是为了符合网络世界的主流的交互方式。
|
||||
|
||||
|
||||
第二个有争议的问题是:REST与HTTP完全绑定,不适用于要求高性能传输的场景中。
|
||||
|
||||
其实,我在很大程度上赞同这个观点,但我并不认为这是REST的缺陷,因为锤子不能当扳手用,并不是锤子的质量有问题。
|
||||
|
||||
面向资源编程与协议无关,但是REST(特指Fielding论文中所定义的REST,而不是泛指面向资源的思想)的确依赖着HTTP协议的标准方法、状态码和协议头等各个方面。
|
||||
|
||||
我们也知道,HTTP是应用层协议,而不是传输层协议,如果我们只是把HTTP用作传输是不恰当的(SOAP:再次感觉有被冒犯到)。因此,对于需要直接控制传输(如二进制细节/编码形式/报文格式/连接方式等)细节的场景,REST确实不合适。这些场景往往存在于服务集群的内部节点之间,这也是我在上一讲提到的,虽然REST和RPC的应用场景的确有所重合,但重合的范围有多大就是见仁见智的事情了。
|
||||
|
||||
第三个有争议的问题是:REST不利于事务支持。
|
||||
|
||||
其实,这个问题首先要看我们怎么去理解“事务(Transaction)”这个概念了。
|
||||
|
||||
|
||||
如果“事务”指的是数据库那种狭义的刚性ACID事务,那分布式系统本身跟它之间就是有矛盾的(CAP不可兼得)。这是分布式的问题,而不是REST的问题。
|
||||
如果“事务”是指通过服务协议或架构,在分布式服务中,获得对多个数据同时提交的统一协调能力(2PC/3PC),比如WS-AtomicTransaction和WS-Coordination这样的功能性协议,那REST确实不支持。假如你已经理解了这样做的代价,仍决定要这样做的话,Web Service是比较好的选择。
|
||||
如果“事务”是指希望保证数据的最终一致性,说明你已经放弃刚性事务了。这才是分布式系统中的主流,使用REST肯定不会有什么阻碍,更谈不上“不利于”事务支持(当然,对于最终一致性的问题,REST本身并没有提供什么帮助,而是完全取决于你系统的事务设计。我们在讲解事务处理的课程章节中,会再详细讨论)。
|
||||
|
||||
|
||||
第四个有争议的问题是:REST没有传输可靠性支持。
|
||||
|
||||
是的,REST并没有提供对传输可靠性的支持。在HTTP中,你发送出去一个请求,通常会收到一个与之相对的响应,比如HTTP/1.1 200 OK或者HTTP/1.1 404 Not Found等。但是,如果你没有收到任何响应,那就无法确定消息到底是没有发送出去,还是没有从服务端返回回来。这其中的关键差别,是服务端到底是否被触发了某些处理?
|
||||
|
||||
应对传输可靠性最简单粗暴的做法,就是把消息再重发一遍。这种简单处理能够成立的前提,是服务具有幂等性(Idempotency),也就是说服务被重复执行多次的效果与执行一次是相等的。
|
||||
|
||||
HTTP协议要求GET、PUT和DELETE操作应该具有幂等性,我们把REST服务映射到这些方法时,也应该保证幂等性。
|
||||
|
||||
对于POST方法,曾经有过一些专门的提案(比如POE、POST Once Exactly),但并未得到IETF的通过。对于POST的重复提交,浏览器会出现相应警告,比如Chrome中会有“确认重新提交表单”的提示。而服务端就应该做预校验,如果发现可能重复,就返回HTTP/1.1 425 Too Early。
|
||||
|
||||
另外,Web Service中有WS-ReliableMessaging功能协议,用来支持消息可靠投递。类似的,REST因为没有采用额外的Wire Protocol,所以除了缺少对事务、可靠传输的支持外,一定还可以在WS-*协议中找到很多REST不支持的特性。
|
||||
|
||||
第五个有争议的问题是:REST缺乏对资源进行“部分”和“批量”的处理能力。
|
||||
|
||||
这个观点我是认同的,而且我认为这很可能是未来面向资源的思想和API设计风格的发展方向。
|
||||
|
||||
REST开创了面向资源的服务风格,却肯定不完美。以HTTP协议为基础,虽然给REST带来了极大的便捷(不需要额外协议,不需要重复解决一堆基础网络问题,等等),但也成了束缚REST的无形牢笼。
|
||||
|
||||
关于HTTP协议对REST的束缚,我会通过具体的例子和你解释。
|
||||
|
||||
第一种束缚,就是缺少对资源的“部分”操作的支持。有些时候,我们只是想获得某个用户的姓名,RPC风格中可以设计一个“getUsernameById”的服务,返回一个字符串。尽管这种服务的通用性实在称不上“设计”二字,但确实可以工作。而要是采用REST风格的话,你需要向服务端请求整个用户对象,然后丢弃掉返回结果中的其他属性,这就是一种请求冗余(Overfetching)。
|
||||
|
||||
REST的应对手段是,通过位于中间节点或客户端缓存来缓解。但这治标不治本,因为这个问题的根源在于,HTTP协议对请求资源完全没有结构化的描述能力(但有的是非结构化的部分内容获取能力,也就是今天多用于端点续传的Range Header),所以返回资源的哪些内容、以什么数据类型返回等等,都不可能得到协议层面的支持。如果要实现这种能力,你就只能自己在GET方法的Endpoint上设计各种参数。
|
||||
|
||||
而与此相对的缺陷,也是HTTP协议对REST的第二种束缚,是对资源的“批量”操作的支持。有时候,我们不得不为此而专门设计一些抽象的资源才能应对。
|
||||
|
||||
比如,我们要把某个用户的昵称增加一个“VIP”前缀,那提交一个PUT请求修改这个用户的昵称就可以了。但如果我们要给1000个用户的昵称加“VIP”前缀时,就不得不先创建一个(比如名为“VIP-Modify-Task”)任务资源,把1000个用户的ID交给这个任务,最后驱动任务进入执行状态(如果真去调用1000次PUT,等浏览器回应我们HTTP/1.1 429 Too Many Requests的时候,老板就要发飙了)。
|
||||
|
||||
又比如,我们在网店买东西的时候,下单、冻结库存、支付、加积分、扣减库存这一系列步骤会涉及多个资源的变化,这时候我们就得创建一种“事务”的抽象资源,或者用某种具体的资源(比如“结算单”),贯穿网购这个过程的始终,每次操作其他资源时都带着事务或者结算单的ID。对于HTTP协议来说,由于它的无状态性,相对来说不适用于(并非不能够)处理这类业务场景。
|
||||
|
||||
要解决批量操作这类问题,目前一种从理论上看还比较优秀的解决方案是GraphQL(但实际使用人数并不多)。GraphQL是由Facebook提出并开源的一种面向资源API的数据查询语言。它和SQL一样,挂了个“查询语言”的名字,但其实CRUD都能做。
|
||||
|
||||
相对于依赖HTTP无协议的REST来说,GraphQL是另一种“有协议”地、更彻底地面向资源的服务方式。但是凡事都有两面,离开了HTTP,GraphQL又面临着几乎所有RPC框架都会遇到的如何推广交互接口的问题。
|
||||
|
||||
小结
|
||||
|
||||
介绍REST服务的两节课里面,我们学习了REST的思想内涵,讲解了RESTful系统的6个核心特征,以及如何衡量RESTful程度的RMM成熟度,同时也讨论了REST的争议与不足。
|
||||
|
||||
在软件行业发展的初期,程序编写都是以算法为核心的,程序员会把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据。这种直接站在计算机的角度去抽象问题和解决问题的思维方式,就是面向过程的编程思想。
|
||||
|
||||
与此类似,后来出现的面向对象的编程思想,则是站在现实世界的角度去抽象和解决问题。它把数据和行为都看作是对象的一部分,以方便程序员用符合现实世界的思维方式,来编写和组织程序。
|
||||
|
||||
我们今天再去看这两种编程思想,虽然它们出现的时间有先后,但在人类使用计算机语言来处理数据的工作中,无论用哪种思维来抽象问题都是合乎逻辑的。
|
||||
|
||||
经过了20世纪90年代末到21世纪初期面向对象编程的火热,如今,站在网络角度考虑如何对内封装逻辑、对外重用服务的新思想,也就是面向资源的编程思想,又成为了新的受追捧的对象。
|
||||
|
||||
面向资源编程这种思想,是把问题空间中的数据对象作为抽象的主体,把解决问题时从输入数据到输出结果的处理过程,看作是一个(组)数据资源的状态不断发生变换而导致的结果。这符合目前网络主流的交互方式,也因此REST常常被看作是为基于网络的分布式系统量身定做的交互方式。
|
||||
|
||||
一课一思
|
||||
|
||||
从第7到10讲,我们通过四节课学习了RPC和REST两种远程服务的设计风格。你更倾向于哪一种呢?你觉得未来这两种风格会如何发展呢?
|
||||
|
||||
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
159
专栏/周志明的架构课/11_本地事务如何实现原子性和持久性?.md
Normal file
159
专栏/周志明的架构课/11_本地事务如何实现原子性和持久性?.md
Normal file
@@ -0,0 +1,159 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 _ 本地事务如何实现原子性和持久性?
|
||||
你好,我是周志明。
|
||||
|
||||
在接下来的五节课里,我们将会一起讨论软件开发中另一个常见的话题:事务处理。
|
||||
|
||||
事务处理几乎是每一个信息系统中都会涉及到的问题,它存在的意义就是保证系统中的数据是正确的,不同数据间不会产生矛盾,也就是保证数据状态的一致性(Consistency)。
|
||||
|
||||
关于一致性,我这里先做个说明。“一致性”在数据科学中有严肃定义,并且有多种细分类型的概念。这里我们重点关注的是数据库状态的一致性,它跟课程后面第三个模块“分布式的基石”当中,即将要讨论的分布式共识算法时所说的一致性,是不一样的,具体的差别我们会在第三个模块中探讨。
|
||||
|
||||
说回数据库状态的一致性,理论上,要达成这个目标需要三方面的共同努力:
|
||||
|
||||
|
||||
原子性(Atomic):在同一项业务处理过程中,事务保证了多个对数据的修改,要么同时成功,要么一起被撤销。
|
||||
隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
|
||||
持久性(Durability):事务应当保证所有被成功提交的数据修改都能够正确地被持久化,不丢失数据。
|
||||
|
||||
|
||||
以上就是事务的“ACID”的概念提法。我自己对这种已经形成习惯的“ACID”的提法是不太认同的,因为这四种特性并不正交,A、I、D是手段,C是目的,完全是为了拼凑个单词缩写才弄到一块去,误导的弊端已经超过了易于传播的好处。所以明确了这一点,也就明确了我们今天的讨论,就是要聚焦在事务处理的A、I、D上。
|
||||
|
||||
那接下来,我们先来看看事务处理的场景。
|
||||
|
||||
事务场景
|
||||
|
||||
事务的概念最初是源于数据库,但今天的信息系统中,所有需要保证数据正确性(一致性)的场景下,包括但不限于数据库、缓存、事务内存、消息、队列、对象文件存储等等,都有可能会涉及到事务处理。
|
||||
|
||||
当一个服务只操作一个数据源的时候,通过A、I、D来获得一致性是相对容易的,但当一个服务涉及到多个不同的数据源,甚至多个不同服务同时涉及到多个不同的数据源时,这件事情就变得很困难,有时需要付出很大、甚至是不切实际的代价,因此业界探索过许多其他方案,在确保可操作的前提下获得尽可能高的一致性保障。由此,事务处理才从一个具体操作上的“编程问题”上升成一个需要仔细权衡的“架构问题”。
|
||||
|
||||
人们在探索这些事务方案的过程中,产生了许多新的思路和概念,有一些概念看上去并不那么直观,因此,在接下来的这几节课中,我会带着你,一起探索同一个事例在不同的事务方案中的不同处理,以此来贯穿、理顺这些概念。
|
||||
|
||||
场景事例
|
||||
|
||||
我先来给你介绍下具体的事例。
|
||||
|
||||
Fenix’s Bookstore是一个在线书店。一份商品成功售出,需要确保以下三件事情被正确地处理:
|
||||
|
||||
|
||||
用户的账号扣减相应的商品款项;
|
||||
商品仓库中扣减库存,将商品标识为待配送状态;
|
||||
商家的账号增加相应的商品款项。
|
||||
|
||||
|
||||
接下来,我将逐一介绍在“单个服务使用单个数据源”“单个服务使用多个数据源”“多个服务使用单个数据源”以及“多个服务使用多个数据源”的不同场景下,我们可以采用哪些手段来保证以上场景实例的正确性。
|
||||
|
||||
今天这一讲,我们先来看“单个服务使用单个数据源”,也就是本地事务场景。
|
||||
|
||||
本地事务
|
||||
|
||||
本地事务(Local Transactions)其实应该翻译成“局部事务”,才好与第13讲中要讲解的“全局事务”对应起来。不过,现在“本地事务”的译法似乎已经成为主流,我们就不去纠结名称了。
|
||||
|
||||
本地事务是指仅操作特定单一事务资源的、不需要“全局事务管理器”进行协调的事务。如果这个定义你现在不能理解的话,不妨暂且先放下,等学完“全局事务”这个小章节后再回过头来想想。
|
||||
|
||||
本地事务是最基础的一种事务处理方案,通常只适用于单个服务使用单个数据源的场景,它是直接依赖于数据源(通常是数据库系统)本身的事务能力来工作的。在程序代码层面,我们最多只能对事务接口做一层标准化的包装(如JDBC接口),并不能深入参与到事务的运作过程当中。
|
||||
|
||||
事务的开启、终止、提交、回滚、嵌套、设置隔离级别、乃至与应用代码贴近的传播方式,全部都要依赖底层数据库的支持,这一点与后面的14、15两讲中要介绍的XA、TCC、SAGA等主要靠应用程序代码来实现的事务,有着十分明显的区别(到时你可以跟今天所讲的内容相互对照下)。
|
||||
|
||||
我举个具体的例子,假设你的代码调用了JDBC中的Transaction::rollback()方法,方法的成功执行并不代表事务就已经被成功回滚,如果数据表采用引擎的是MyISAM,那rollback()方法便是一项没有意义的空操作。因此,我们要想深入地讨论本地事务,便不得不越过应用代码的层次,去了解一些数据库本身的事务实现原理,弄明白传统数据库管理系统是如何实现ACID的。
|
||||
|
||||
ARIES理论
|
||||
|
||||
如今研究事务的实现原理,必定会追溯到ARIES理论(Algorithms for Recovery and Isolation Exploiting Semantics,基于语义的恢复与隔离算法)。起这拗口的名字应该多少也有些拼凑“ARIES”这个单词的目的(跟ACID一样的恶趣味)。
|
||||
|
||||
虽然,我们不能说所有的数据库都实现了ARIES理论,但现代的主流关系型数据库(Oracle、Microsoft SQLServer、MySQL-InnoDB、IBM DB2、PostgreSQL,等等)在事务实现上都深受该理论的影响。
|
||||
|
||||
上世纪90年代,IBM Almaden研究院总结了研发原型数据库系统“IBM System R”的经验,发表了ARIES理论中最主要的三篇论文,这里先给你介绍两篇。《ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging》着重解决了事务的ACID三个属性中,原子性(A)和持久性(D)在算法层面上应当如何实现;而另一篇《ARIES/KVL: A Key-Value Locking Method for Concurrency Control of Multiaction Transactions Operating on B-Tree Indexes》则是现代数据库隔离性(I)奠基式的文章。
|
||||
|
||||
我们先从原子性和持久性说起。至于隔离性,在下一节课中我们再接着展开介绍。
|
||||
|
||||
实现原子性和持久性
|
||||
|
||||
原子性和持久性在事务里是密切相关的两个属性,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。
|
||||
|
||||
显而易见,数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性,只存储在内存中的数据,一旦遇到程序忽然崩溃、数据库崩溃、操作系统崩溃,机器突然断电宕机(后面我们都统称为崩溃,Crash)等情况就会丢失。实现原子性和持久性所面临的困难是,“写入磁盘”这个操作不会是原子的,不仅有“写入”与“未写入”,还客观地存在着“正在写”的中间状态。
|
||||
|
||||
按照上面我们列出的示例场景,从Fenix’s Bookstore购买一本书需要修改三个数据:在用户账户中减去货款、在商家账户中增加货款、在商品仓库中标记一本书为配送状态,由于写入存在中间状态,可能发生以下情形:
|
||||
|
||||
|
||||
未提交事务:程序还没修改完三个数据,数据库已经将其中一个或两个数据的变动写入了磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性。
|
||||
已提交事务:程序已经修改完三个数据,数据库还未将全部三个数据的变动都写入到磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。
|
||||
|
||||
|
||||
这种数据恢复操作被称为崩溃恢复(Crash Recovery,也有称作Failure Recovery或Transaction Recovery)。为了能够顺利地完成崩溃恢复,在磁盘中写数据就不能像程序修改内存中变量值那样,直接改变某表某行某列的某个值,必须将修改数据这个操作所需的全部信息(比如修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值等等),以日志的形式(日志特指仅进行顺序追加的文件写入方式,这是最高效的写入方式)先记录到磁盘中。
|
||||
|
||||
只有在日志记录全部都安全落盘,见到代表事务成功提交的“Commit Record”后,数据库才会根据日志上的信息对真正的数据进行修改,修改完成后,在日志中加入一条“End Record”表示事务已完成持久化,这种事务实现方法被称为“Commit Logging”。
|
||||
|
||||
|
||||
额外知识:Shadow Paging-
|
||||
|
||||
通过日志实现事务的原子性和持久性是当今的主流方案,但并非唯一的选择。除日志外,还有另外一种称为“Shadow Paging”(有中文资料翻译为“影子分页”)的事务实现机制,常用的轻量级数据库SQLite Version 3采用的就是Shadow Paging。-
|
||||
|
||||
Shadow Paging的大体思路是对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数据。在事务过程中,被修改的数据会同时存在两份,一份修改前的数据,一份是修改后的数据,这也是“影子”(Shadow)这个名字的由来。-
|
||||
|
||||
当事务成功提交,所有数据的修改都成功持久化之后,最后一步要修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,所以Shadow Paging也可以保证原子性和持久性。-
|
||||
|
||||
Shadow Paging相对简单,但涉及到隔离性与锁时,Shadow Paging实现的事务并发能力相对有限,因此在高性能的数据库中应用不多。
|
||||
|
||||
|
||||
Commit Logging保障数据持久性、原子性的原理并不难想明白。
|
||||
|
||||
首先,日志一旦成功写入Commit Record,那整个事务就是成功的,即使修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性。
|
||||
|
||||
其次,如果日志没有写入成功就发生崩溃,系统重启后会看到一部分没有Commit Record的日志,那将这部分日志标记为回滚状态即可,整个事务就像完全没有发生过一样,这保证了原子性。
|
||||
|
||||
Commit Logging实现事务简单清晰,也有一些数据库就是采用Commit Logging机制来实现事务的(较具代表性的是阿里的OceanBase)。但是,Commit Logging存在一个巨大的缺陷:所有对数据的真实修改都必须发生在事务提交、日志写入了Commit Record之后,即使事务提交前磁盘I/O有足够空闲、即使某个事务修改的数据量非常庞大,占用大量的内存缓冲,无论何种理由,都决不允许在事务提交之前就开始修改磁盘上的数据,这一点对提升数据库的性能是很不利的。
|
||||
|
||||
为了解决这个缺陷,前面提到的ARIES理论终于可以登场了。ARIES提出了“Write-Ahead Logging”的日志改进方案,其名字里所谓的“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。
|
||||
|
||||
Write-Ahead Logging先将何时写入变动数据,按照事务提交时点为界,分为了FORCE和STEAL两类:
|
||||
|
||||
|
||||
FORCE:当事务提交后,要求变动数据必须同时完成写入则称为FORCE,如果不强制变动数据必须同时完成写入则称为NO-FORCE。现实中绝大多数数据库采用的都是NO-FORCE策略,只要有了日志,变动数据随时可以持久化,从优化磁盘I/O性能考虑,没有必要强制数据写入立即进行。
|
||||
STEAL:在事务提交前,允许变动数据提前写入则称为STEAL,不允许则称为NO-STEAL。从优化磁盘I/O性能考虑,允许数据提前写入,有利于利用空闲I/O资源,也有利于节省数据库缓存区的内存。
|
||||
|
||||
|
||||
Commit Logging允许NO-FORCE,但不允许STEAL。因为假如事务提交前就有部分变动数据写入磁盘,那一旦事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。
|
||||
|
||||
Write-Ahead Logging允许NO-FORCE,也允许STEAL,它给出的解决办法是增加了另一种称为Undo Log的日志。当变动数据写入磁盘前,必须先记录Undo Log,写明修改哪个位置的数据、从什么值改成什么值,以便在事务回滚或者崩溃恢复时,根据Undo Log对提前写入的数据变动进行擦除。
|
||||
|
||||
Undo Log现在一般被翻译为“回滚日志”,此前记录的用于崩溃恢复时重演数据变动的日志,就相应被命名为Redo Log,一般翻译为“重做日志”。
|
||||
|
||||
由于Undo Log的加入,Write-Ahead Logging在崩溃恢复时,会以此经历以下三个阶段:
|
||||
|
||||
|
||||
分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,找出所有没有End Record的事务,组成待恢复的事务集合(一般包括Transaction Table和Dirty Page Table)。
|
||||
重做阶段(Redo):该阶段依据分析阶段中,产生的待恢复的事务集合来重演历史(Repeat History),找出所有包含Commit Record的日志,将它们写入磁盘,写入完成后增加一条End Record,然后移除出待恢复事务集合。
|
||||
回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务(被称为Loser),根据Undo Log中的信息回滚这些事务。
|
||||
|
||||
|
||||
重做阶段和回滚阶段的操作都应该设计为幂等的。而为了追求高性能,以上三个阶段都无可避免地会涉及到非常繁琐的概念和细节(如Redo Log、Undo Log的具体数据结构等),这里我们就不展开讲了,如果想要继续学习,前面讲到的那两篇论文就是学习的最佳途径。
|
||||
|
||||
Write-Ahead Logging是ARIES理论的一部分,整套ARIES拥有严谨、高性能等很多的优点,但这些也是以复杂性为代价的。
|
||||
|
||||
数据库按照“是否允许FORCE和STEAL”可以产生四种组合,从优化磁盘I/O的角度看,NO-FORCE加STEAL组合的性能无疑是最高的;从算法实现与日志的角度看,NO-FORCE加STEAL组合的复杂度无疑是最高的。
|
||||
|
||||
这四种组合与Undo Log、Redo Log之间的具体关系如下图所示:
|
||||
|
||||
|
||||
|
||||
小结
|
||||
|
||||
今天这节课,我们学习了经典ARIES理论下实现本地事务中原子性与持久性的方法。通过写入日志来保证原子性和持久性是业界的主流做法,这个做法最困难的一点,就是如何处理日志“写入中”的中间状态,才能既保证严谨,也能够高效。
|
||||
|
||||
ARIES理论提出了Write-Ahead Logging式的日志写入方法,通过分析、重做、回滚三个阶段实现了STEAL、NO-FORCE,从而实现了既高效又严谨的日志记录与故障恢复。
|
||||
|
||||
一课一思
|
||||
|
||||
为什么Logging会成为实现原子性、持久性的主流做法?Shadow Paging等其他方法的不足之处是什么?
|
||||
|
||||
欢迎在留言区分享你的见解。如果你身边的朋友,也对实现本地事务中原子性与持久性的方法感兴趣,欢迎你把今天的内容分享给TA,我们一起交流探讨。
|
||||
|
||||
好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
151
专栏/周志明的架构课/12_本地事务如何实现隔离性?.md
Normal file
151
专栏/周志明的架构课/12_本地事务如何实现隔离性?.md
Normal file
@@ -0,0 +1,151 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 _ 本地事务如何实现隔离性?
|
||||
你好。我是周志明。
|
||||
|
||||
今天我们接着上一节课的话题,继续来探讨数据库如何实现隔离性。
|
||||
|
||||
隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上,我们就能感觉到隔离性肯定与并发密切相关。如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。
|
||||
|
||||
但在现实情况中不可能没有并发,要在并发下实现串行的数据访问,该怎样做?几乎所有程序员都会回答到:加锁同步呀!现代数据库都提供了以下三种锁:
|
||||
|
||||
|
||||
写锁(Write Lock,也叫做排他锁eXclusive Lock,简写为X-Lock):只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。
|
||||
读锁(Read Lock,也叫做共享锁Shared Lock,简写为S-Lock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有一个事务加了读锁,那可以直接将其升级为写锁,然后写入数据。
|
||||
范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被读取,也不能被写入。如下语句是典型的加范围锁的例子:
|
||||
|
||||
|
||||
SELECT * FROM books WHERE price < 100 FOR UPDATE;
|
||||
|
||||
|
||||
请注意“范围不能写入”与“一批数据不能写入”的差别,也就是我们不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,这是一组排他锁的集合无法做到的。
|
||||
|
||||
本地事务的四种隔离级别
|
||||
|
||||
了解了这三种锁的概念之后,如果我们要继续探讨数据库是如何实现隔离性的,那就得先理解事务的隔离级别。接下来,我就按照隔离强度从高到低来给你一一介绍。
|
||||
|
||||
可串行化
|
||||
|
||||
串行化访问提供了强度最高的隔离性,ANSI/ISO SQL-92中定义的最高等级的隔离级别便是可串行化(Serializable)。
|
||||
|
||||
可串行化比较符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可(这种可串行化的实现方案称为Two-Phase Lock)。
|
||||
|
||||
但数据库不考虑性能肯定是不行的,并发控制理论(Concurrency Control)决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户调节隔离级别的选项,这样做的根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。
|
||||
|
||||
可重复读
|
||||
|
||||
可串行化的下一个隔离级别是可重复读(Repeatable Read)。可重复读的意思就是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但不再加范围锁。
|
||||
|
||||
可重复读比可串行化弱化的地方在于幻读问题(Phantom Reads),它是指在事务执行的过程中,两个完全相同的范围查询得到了不同的结果集。比如我现在准备统计一下Fenix’s Bookstore中售价小于100元的书有多少本,就可以执行以下第一条SQL语句:
|
||||
|
||||
SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:1,事务: T1 */
|
||||
INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90) /* 时间顺序:2,事务: T2 */
|
||||
SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:3,事务: T1 */
|
||||
|
||||
|
||||
那么,根据前面对范围锁、读锁和写锁的定义,我们可以知道,假如这条SQL语句在同一个事务中重复执行了两次,并且这两次执行之间,恰好有另外一个事务在数据库中插入了一本小于100元的书籍(这是当前隔离级别允许的操作),那这两次相同的查询就会得到不一样的结果。原因就是,可重复读没有范围锁来禁止在该范围内插入新的数据。
|
||||
|
||||
这就是一个事务遭到其他事务影响,隔离性被破坏的表现。
|
||||
|
||||
这里我要提醒你注意一个地方,我这里的介绍实际上是以ARIES理论作为讨论目标的,而具体的数据库并不一定要完全遵照着这个理论去实现。
|
||||
|
||||
我给你举个例子。MySQL/InnoDB的默认隔离级别是可重复读,但它在只读事务中就可以完全避免幻读问题。
|
||||
|
||||
比如在前面这个例子中,事务T1只有查询语句,它是一个只读事务,所以这个例子里出现的幻读问题在MySQL中并不会出现。但在读写事务中,MySQL仍然会出现幻读问题,比如例子中的事务T1,如果在其他事务插入新书后,不是重新查询一次数量,而是要把所有小于100元的书全部改名,那就依然会受到新插入书籍的影响。
|
||||
|
||||
读已提交
|
||||
|
||||
可重复读的下一个隔离级别是读已提交(Read Committed)。读已提交对事务涉及到的数据加的写锁,会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。
|
||||
|
||||
读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Reads),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。
|
||||
|
||||
比如说,现在我要获取Fenix’s Bookstore中《深入理解Java虚拟机》这本书的售价,同样让程序执行了两条SQL语句。而在这两条语句执行之间,恰好有另外一个事务修改了这本书的价格,从90元调整到了110元,如下所示:
|
||||
|
||||
SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
|
||||
UPDATE books SET price = 110 WHERE ID = 1; COMMIT; /* 时间顺序:2,事务: T2 */
|
||||
SELECT * FROM books WHERE id = 1; COMMIT; /* 时间顺序:3,事务: T1 */
|
||||
|
||||
|
||||
所以到这里,你其实也会发现,如果隔离级别是读已提交,那么这两次重复执行的查询结果也会不一样。原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化。而此时,事务T2中的更新语句可以马上提供成功,这也是一个事务遭到其他事务影响,隔离性被破坏的表现。
|
||||
|
||||
不过,假如隔离级别是可重复读的话,由于数据已被事务T1施加了读锁,并且读取后不会马上释放,所以事务T2无法获取到写锁,更新就会被阻塞,直至事务T1被提交或回滚后才能提交。
|
||||
|
||||
读未提交
|
||||
|
||||
读已提交的下一个级别是读未提交(Read Uncommitted)。读未提交对事务涉及到的数据只加写锁,这会一直持续到事务结束,但完全不加读锁。
|
||||
|
||||
读未提交比读已提交弱化的地方在于脏读问题(Dirty Reads),它是指在事务执行的过程中,一个事务读取到了另一个事务未提交的数据。
|
||||
|
||||
比如说,我觉得《深入理解Java虚拟机》从90元涨价到110元是损害消费者利益的行为,又执行了一条更新语句,把价格改回了90元。而在我提交事务之前,同事过来告诉我,这并不是随便涨价的,而是印刷成本上升导致的,按90元卖要亏本,于是我随即回滚了事务。那么在这个场景下,程序执行的SQL语句是这样的:
|
||||
|
||||
SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
|
||||
/* 注意没有COMMIT */
|
||||
UPDATE books SET price = 90 WHERE ID = 1; /* 时间顺序:2,事务: T2 */
|
||||
/* 这条SELECT模拟购书的操作的逻辑 */
|
||||
SELECT * FROM books WHERE id = 1; /* 时间顺序:3,事务: T1 */
|
||||
ROLLBACK; /* 时间顺序:4,事务: T2 */
|
||||
|
||||
|
||||
不过,在我修改完价格之后,事务T1已经按90元的价格卖出了几本。出现这个问题的原因就在于,读未提交在数据上完全不加读锁,这反而令它能读到其他事务加了写锁的数据,也就是我前面所说的,事务T1中两条查询语句得到的结果并不相同。
|
||||
|
||||
这里,你可能会有点疑问,“为什么完全不加读锁,反而令它能读到其他事务加了写锁的数据”,这句话中的“反而”代表的是什么意思呢?不理解也没关系,我们再来重新读一遍写锁的定义:写锁禁止其他事务施加读锁,而不是禁止事务读取数据。
|
||||
|
||||
所以说,如果事务T1读取数据时,根本就不用去加读锁的话,就会导致事务T2未提交的数据也能马上就被事务T1所读到。这同样是一个事务遭到其他事务影响,隔离性被破坏的表现。
|
||||
|
||||
那么,这里我们假设隔离级别是读已提交的话,由于事务T2持有数据的写锁,所以事务T1的第二次查询就无法获得读锁。而读已提交级别是要求先加读锁后读数据的,所以T1中的查询就会被阻塞,直到事务T2被提交或者回滚后才能得到结果。
|
||||
|
||||
理论上还有更低的隔离级别,就是“完全不隔离”,即读、写锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write,即一个事务没提交之前的修改可以被另外一个事务的修改覆盖掉),脏写已经不单纯是隔离性上的问题了,它会导致事务的原子性都无法实现,所以一般隔离级别不会包括它,会把读未提交看作是最低级的隔离级别。
|
||||
|
||||
这四种隔离级别属于数据库的基础知识,多数大学的计算机课程应该都会讲到,但不少教材、资料都把它们当作数据库的某种固有设定来进行讲解,导致很多人只能对这些现象死记硬背。其实,不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。
|
||||
|
||||
除了锁之外,以上对四种隔离级别的介绍还有一个共同特点,就是一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种“一个事务读+另一个事务写”的隔离问题,有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。
|
||||
|
||||
接下来我们就一起讨论下MVCC。
|
||||
|
||||
MVCC的基础原理
|
||||
|
||||
MVCC是一种读取优化策略,它的“无锁”是特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。
|
||||
|
||||
这句话里的“版本”是个关键词,你不妨将其理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION和DELETE_VERSION,这两个字段记录的值都是事务ID(事务ID是一个全局严格递增的数值),然后:
|
||||
|
||||
|
||||
数据被插入时:CREATE_VERSION记录插入数据的事务ID,DELETE_VERSION为空。
|
||||
数据被删除时:DELETE_VERSION记录删除数据的事务ID,CREATE_VERSION为空。
|
||||
数据被修改时:将修改视为“删除旧数据,插入新数据”,即先将原有数据复制一份,原有数据的DELETE_VERSION记录修改数据的事务ID,CREATE_VERSION为空。复制出来的新数据的CREATE_VERSION记录修改数据的事务ID,DELETE_VERSION为空。
|
||||
|
||||
|
||||
此时,当有另外一个事务要读取这些发生了变化的数据时,会根据隔离级别来决定到底应该读取哪个版本的数据:
|
||||
|
||||
|
||||
隔离级别是可重复读:总是读取CREATE_VERSION小于或等于当前事务ID的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务ID最大)的。
|
||||
隔离级别是读已提交:总是取最新的版本即可,即最近被Commit的那个版本的数据记录。
|
||||
|
||||
|
||||
另外,两个隔离级别都没有必要用到MVCC,读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以查看到,根本无需版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而MVCC是做读取时无锁优化的,自然就不会放到一起用。
|
||||
|
||||
MVCC是只针对“读+写”场景的优化,如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,加锁几乎是唯一可行的解决方案。
|
||||
|
||||
稍微有点讨论余地的是“乐观加锁”(Optimistic Locking)或“悲观加锁”(Pessimistic Locking),对此我们还可以根据实际情况去商量一下。
|
||||
|
||||
前面我介绍的加锁都属于悲观加锁策略,也就是数据库认为如果不先做加锁再访问数据,就肯定会出现问题。与之相对的,乐观加锁策略认为,事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就不应该一开始就加锁,而是应当出现竞争时再找补救措施。这种思路被称为“乐观并发控制”(Optimistic Concurrency Control,OCC),这一点我就不再展开了。不过提醒一句,不要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看竞争的剧烈程度,如果竞争剧烈的话,乐观锁反而会更慢。
|
||||
|
||||
小结
|
||||
|
||||
今天的内容再加上上一讲,这两节课我们总结了本地事务中原子性、持久性和隔离性的实现模式。如果你是后端程序员,只要你实际开发过用于生产的软件系统,几乎一定会使用过本地事务。
|
||||
|
||||
但在Spring等框架的声明式事务的简化下,对多数程序员来说,事务可能仅仅是一个注解、一种概念,却未必真正理解它们的原理和运作。希望通过这两节课的学习,你能对这些常用却不常为人所注意到的知识点有更进一步的理解。
|
||||
|
||||
一课一思
|
||||
|
||||
现在大多数系统都把本地事务控制在底层,在系统特定分层中开启和结束,对普通开发人员尽量透明。你在开发时会考虑事务吗?你认为以上“透明式”的事务管理是否合适?普通开发人员是否应该意识到事务的存在?
|
||||
|
||||
欢迎在留言区分享你的见解。如果你身边的朋友,也对实现本地事务中隔离性的方法感兴趣,欢迎你把今天的内容分享给TA,我们一起交流探讨。
|
||||
|
||||
好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
185
专栏/周志明的架构课/13_全局事务和共享事务是如何实现的?.md
Normal file
185
专栏/周志明的架构课/13_全局事务和共享事务是如何实现的?.md
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 _ 全局事务和共享事务是如何实现的?
|
||||
你好,我是周志明。今天,我们一起来学习全局事务(Global Transactions)和共享事务(Share Transactions)的原理与实现。
|
||||
|
||||
其实,相对于我们前两节课学习的本地事务,全局事务和共享事务的使用频率已经很低了。但这两种事务类型,是分布式事务(下一讲要学习)的中间形式,起到的是承上启下的作用。
|
||||
|
||||
所以,我们还是有必要去理解它们的实现方式,这样才能更透彻地理解事务处理这个话题。
|
||||
|
||||
接下来,我们就从全局事务学起吧。
|
||||
|
||||
全局事务
|
||||
|
||||
与本地事务相对的是全局事务,一些资料中也会称之为外部事务(External Transactions)。在今天这一讲,我会给全局事务做个限定:一种适用于单个服务使用多个数据源场景的事务解决方案。
|
||||
|
||||
需要注意的是,理论上,真正的全局事务是没有“单个服务”这个约束的,它本来就是DTP(Distributed Transaction Processing)模型中的概念。那我为什么要在这一讲给它做个限定呢?
|
||||
|
||||
这是因为,我们今天要学习的内容,也就是一种在分布式环境中仍追求强一致性的事务处理方案,在多节点互相调用彼此服务的场景(比如现在的微服务)中是非常不合适的。从目前的情况来看,这种方案几乎只实际应用在了单服务多数据源的场景中。
|
||||
|
||||
为了避免与我们下一讲要学习的放弃了ACID的弱一致性事务处理方式混淆,所以我在这一讲缩减了全局事务所指的范围;对于涉及多服务多数据源的事务,我将其称为“分布式事务”。
|
||||
|
||||
XA协议
|
||||
|
||||
为了解决分布式事务的一致性问题,1991年的时候X/Open组织(后来并入了The Open Group)提出了一套叫做X/Open XA(XA是eXtended Architecture的缩写)的事务处理框架。这个框架的核心内容是,定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通讯接口。
|
||||
|
||||
XA接口是双向的,是一个事务管理器和多个资源管理器之间通信的桥梁,通过协调多个数据源的动作保持一致,来实现全局事务的统一提交或者统一回滚。现在,我们在Java代码中还偶尔能看见的XADataSource、XAResource等名字,其实都是源于XA接口。
|
||||
|
||||
这里你要注意的是,XA并不是Java规范(因为当时还没有Java),而是一套通用的技术规范。Java后来专门定义了一套全局事务处理标准,也就是我们熟知的JTA(JSR 907 Java Transaction API)接口。它有两个最主要的接口:
|
||||
|
||||
|
||||
事务管理器的接口:javax.transaction.TransactionManager,这套接口是给Java EE服务器提供容器事务(由容器自动负责事务管理)使用的。另外它还提供了另外一套javax.transaction.UserTransaction接口,用于给程序员通过程序代码手动开启、提交和回滚事务。
|
||||
满足XA规范的资源定义接口:javax.transaction.xa.XAResource。任何资源(JDBC、JMS等)如果需要支持JTA,只要实现XAResource接口中的方法就可以了。
|
||||
|
||||
|
||||
JTA原本是Java EE中的技术,一般情况下应该由JBoss、WebSphere、WebLogic这些Java EE容器来提供支持,但现在Bittronix、Atomikos和JBossTM(以前叫Arjuna)都以JAR包的形式实现了JTA的接口,也就是JOTM(Java Open Transaction Manager)。有了JOTM的支持,我们就可以在Tomcat、Jetty这样的Java SE环境下使用JTA了。
|
||||
|
||||
我们在第11讲讲解本地事务的时候,设计了一个Fenix’s Bookstore在线书店场景。一份商品成功售出,需要确保以下三件事情被正确地处理:
|
||||
|
||||
|
||||
用户的账号扣减相应的商品款项;
|
||||
商品仓库中扣减库存,将商品标识为待配送状态;
|
||||
商家的账号增加相应的商品款项。
|
||||
|
||||
|
||||
现在,我们对这个示例场景做另外一种假设:如果书店的用户、商家、仓库分别处于不同的数据库中,其他条件不变,那会发生什么变化呢?
|
||||
|
||||
如果我们以声明式事务来编码的话,那与本地事务看起来可能没什么区别,都是标个@Transactional注解而已,但如果是以编程式事务来实现的话,在写法上就有差异了。我们具体看看:
|
||||
|
||||
public void buyBook(PaymentBill bill) {
|
||||
userTransaction.begin();
|
||||
warehouseTransaction.begin();
|
||||
businessTransaction.begin();
|
||||
try {
|
||||
userAccountService.pay(bill.getMoney());
|
||||
warehouseService.deliver(bill.getItems());
|
||||
businessAccountService.receipt(bill.getMoney());
|
||||
userTransaction.commit();
|
||||
warehouseTransaction.commit();
|
||||
businessTransaction.commit();
|
||||
} catch(Exception e) {
|
||||
userTransaction.rollback();
|
||||
warehouseTransaction.rollback();
|
||||
businessTransaction.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
两段式提交
|
||||
|
||||
代码上能看出程序的目的是要做三次事务提交,但实际代码并不能这样写。为什么呢?
|
||||
|
||||
我们可以试想一下:如果程序运行到businessTransaction.commit()中出现错误,会跳转到catch块中继续执行,这时候userTransaction和warehouseTransaction已经提交了,再去调用rollback()方法已经无济于事。因为这会导致一部分数据被提交,另一部分被回滚,无法保证整个事务的一致性。
|
||||
|
||||
为了解决这个问题,XA将事务提交拆分成了两阶段过程,也就是准备阶段和提交阶段。
|
||||
|
||||
准备阶段,又叫做投票阶段。在这一阶段,协调者询问事务的所有参与者是否准备好提交,如果已经准备好提交回复Prepared,否则回复Non-Prepared。
|
||||
|
||||
这里的“准备”操作,其实和我们通常理解的“准备”不太一样:对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条Commit Record。这意味着在做完数据持久化后并不会立即释放隔离性,也就是仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。
|
||||
|
||||
提交阶段,又叫做执行阶段,协调者如果在准备阶段收到所有事务参与者回复的Prepared消息,就会首先在本地持久化事务状态为Commit,然后向所有参与者发送Commit指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了Non-Prepared消息,或任意一个参与者超时未回复,协调者都会将自己的事务状态持久化为“Abort”之后,向所有参与者发送Abort指令,参与者立即执行回滚操作。
|
||||
|
||||
对于数据库来说,提交阶段的提交操作是相对轻量的,仅仅是持久化一条Commit Record而已,通常能够快速完成。回滚阶段则相对耗时,收到Abort指令时,需要根据回滚日志清理已提交的数据,这可能是相对重负载操作。
|
||||
|
||||
“准备”和“提交”这两个过程,被称为“两段式提交”(2 Phase Commit,2PC)协议。那么,使用了两阶段提交协议,就一定可以成功保证一致性吗?也不是的,它还需要两个前提条件。
|
||||
|
||||
第一,必须假设网络在提交阶段这个短时间内是可靠的,即提交阶段不会丢失消息。同时也假设网络通讯在全过程都不会出现误差,即可以丢失后消息,但不会传递错误的消息,XA的设计目标并不是解决诸如拜占庭将军一类的问题。
|
||||
|
||||
两段式提交中投票阶段失败了可以补救(回滚),而提交阶段失败了无法补救(不再改变提交或回滚的结果,只能等崩溃的节点重新恢复),因而提交阶段的耗时应尽可能短,这也是为了尽量控制网络风险的考虑。
|
||||
|
||||
第二,必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志中找出已准备妥当但并未提交的事务数据,再向协调者查询该事务的状态,确定下一步应该进行提交还是回滚操作。
|
||||
|
||||
到这里,我还要给你澄清一个概念。我们前面提到的协调者和参与者,通常都是由数据库自己来扮演的,不需要应用程序介入,应用程序相对于数据库来说只扮演客户端的角色。
|
||||
|
||||
|
||||
|
||||
两段式提交的原理很简单,也不难实现,但有三个非常明显的缺点。
|
||||
|
||||
单点问题:协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦协调者宕机,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送Commit或者Rollback的指令,那所有参与者都必须一直等待。
|
||||
|
||||
性能问题:两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用、三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入Commit Record),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止。这就决定了两段式提交的性能通常都比较差。
|
||||
|
||||
一致性风险:当网络稳定性和宕机恢复能力的假设不成立时,两段式提交可能会出现一致性问题。
|
||||
|
||||
宕机恢复能力这一点无需多说。1985年Fischer、Lynch、Paterson用定理(被称为FLP不可能原理,在分布式中与CAP定理齐名)证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。
|
||||
|
||||
我们重点看看网络稳定性带来的一致性风险。尽管提交阶段时间很短,但仍是明确存在的危险期。如果协调者在发出准备指令后,根据各个参与者发回的信息确定事务状态是可以提交的,协调者就会先持久化事务状态,并提交自己的事务。如果这时候网络忽然断开了,无法再通过网络向所有参与者发出Commit指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交也没办法回滚,导致数据不一致。
|
||||
|
||||
三段式提交
|
||||
|
||||
为了解决两段式提交的单点问题、性能问题和数据一致性问题,“三段式提交”(3 Phase Commit,3PC)协议出现了。但是三段式提交,也并没有解决一致性问题。
|
||||
|
||||
这是为什么呢?别着急,接下来我就具体和你分析下其中的缘由,以及了解三段式提交是否真正解决了单点问题和性能问题。
|
||||
|
||||
三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为CanCommit、PreCommit,把提交阶段改为DoCommit阶段。其中,新增的CanCommit是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。
|
||||
|
||||
将准备阶段一分为二的理由是,这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,这时候涉及的数据资源都会被锁住。如果此时某一个参与者无法完成提交,相当于所有的参与者都做了一轮无用功。
|
||||
|
||||
所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,也意味着因某个参与者提交时发生崩溃而导致全部回滚的风险相对变小了。
|
||||
|
||||
因此,在事务需要回滚的场景中,三段式的性能通常要比两段式好很多,但在事务能够正常提交的场景中,两段式和三段式提交的性能都很差,三段式因为多了一次询问,性能还要更差一些。
|
||||
|
||||
同样地,也是因为询问阶段使得事务失败回滚的概率变小了,所以在三段式提交中,如果协调者在PreCommit阶段开始之后发生了宕机,参与者没有能等到DoCommit的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待。你看,这就相当于避免了协调者的单点问题。
|
||||
|
||||
三段式提交的操作时序如下图所示。
|
||||
|
||||
|
||||
|
||||
可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是对一致性风险问题并未有任何改进,甚至是增加了面临的一致性风险。为什么这么说呢?
|
||||
|
||||
我们看一个例子。比如,进入PreCommit阶段之后,协调者发出的指令不是Ack而是Abort,而此时因为网络问题,有部分参与者直至超时都没能收到协调者的Abort指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题。
|
||||
|
||||
共享事务
|
||||
|
||||
与全局事务的单个服务使用多个数据源正好相反,共享事务是指多个服务共用同一个数据源。
|
||||
|
||||
这里,我要再强调一次“数据源”与“数据库”的区别:数据源是指提供数据的逻辑设备,不必与物理设备一一对应。
|
||||
|
||||
在部署应用集群时最常采用的模式是,将同一套程序部署到多个中间件服务器上,构成多个副本实例来分担流量压力。它们虽然连接了同一个数据库,但每个节点配有自己的专属数据源,通常是中间件以JNDI的形式开放给程序代码使用。
|
||||
|
||||
这种情况下,所有副本实例的数据访问都是完全独立的,并没有任何交集,每个节点使用的仍是最简单的本地事务。但是有些场景下,多个服务之间是有业务交集的,它们可能会共用一个数据源,共享事务也有可能成为专门针对这种业务场景的一种解决方案。
|
||||
|
||||
举个例子。在Fenix’s Bookstore的场景事例中,假设用户账户、商家账户和商品仓库都存储在同一个数据库里面,但用户、商户和仓库每个领域部署了独立的微服务。此时,一次购书的业务操作将贯穿三个微服务,而且都要在数据库中修改数据。
|
||||
|
||||
如果我们直接将不同数据源视为不同的数据库,那我们完全可以用全局事务或者下一讲要学习的分布式事务来实现。不过,针对每个数据源连接的都是同一个物理数据库的特例,共享事务可能是另一条可以提高性能、降低复杂度的途径,当然这也很有可能是一个伪需求。
|
||||
|
||||
一种理论可行的方案是,直接让各个服务共享数据库连接。同一个应用进程中的不同持久化工具(JDBC、ORM、JMS等)共享数据库连接并不困难,一些中间件服务器(比如WebSphere),就内置了“可共享连接”功能来专门支持共享数据库的连接。
|
||||
|
||||
但这种“共享”的前提是,数据源的使用者都在同一个进程内。由于数据库连接的基础是网络连接,它是与IP地址和端口号绑定的,字面意义上的“不同服务节点共享数据库连接”很难做到。所以,为了实现共享事务,就必须新增一个中间角色,也就是交易服务器。无论是用户服务、商家服务还是仓库服务,它们都要通过同一台交易服务器来与数据库打交道。
|
||||
|
||||
如果将交易服务器的对外接口实现为满足JDBC规范,那它完全可以看作一个独立于各个服务的远程数据库连接池,或者直接作为数据库代理来看待。此时,三个服务所发出的交易请求就有可能做到,由交易服务器上的同一个数据库连接,通过本地事务的方式完成。
|
||||
|
||||
比如,交易服务器根据不同服务节点传来的同一个事务ID,使用同一个数据库连接来处理跨越多个服务的交易事务。
|
||||
|
||||
|
||||
|
||||
之所以强调理论可行,是因为这个方案,其实是与实际生产系统中的压力方向相悖的。一个服务集群里,数据库才是压力最大、最不容易伸缩拓展的重灾区。
|
||||
|
||||
所以,现实中只有类似ProxySQL和MaxScale这样用于对多个数据库实例做负载均衡的数据库代理,而几乎没有反过来代理一个数据库为多个应用提供事务协调的交易服务代理。
|
||||
|
||||
这也是为什么说它更有可能是个伪需求的原因。如果你有充足理由让多个微服务去共享数据库,那就必须找到更加站得住脚的理由,来向团队解释拆分微服务的目的是什么。
|
||||
|
||||
让多个微服务去共享一个数据库这个方案,其实还有另一种应用形式:使用消息队列服务器来代替交易服务器,用户、商家、仓库的服务操作业务时,通过消息将所有对数据库的改动传送到消息队列服务器,然后通过消息的消费者来统一处理,实现由本地事务保障的持久化操作。这就是“单个数据库的消息驱动更新”(Message-Driven Update of a Single Database)。
|
||||
|
||||
“共享事务”这种叫法,以及我们刚刚讲到的通过交易服务器或者通过消息驱动来更新单个数据库这两种处理方式,在实际应用中并不常见,也几乎没有相应的成功案例,能够查到的资料几乎都来源于十多年前Spring的核心开发者Dave Syer的文章“Distributed Transactions in Spring, with and without XA”。
|
||||
|
||||
正如我在这一讲的开头所说,我把共享事务和本地事务、全局事务、分布式事务并列成为四大事务类型,更多的考虑到事务演进过程的完备性,也是为了方便你理解这三种事务类型。同时,拆分微服务后仍然共享数据库的案例,我们经常会在实践中看到,但我个人仍旧不赞同将共享事务看作是一种常规的解决方案。
|
||||
|
||||
小结
|
||||
|
||||
这节课我们学习了全局事务和共享事务的实现方式。目前,共享事务确实已经很少见了,但是全局事务中的两段式提交和三段式提交模式仍然会在一些多数据源的场景中用到,Java的JTA事务也仍然有一定规模的用户群体。
|
||||
|
||||
两段式提交和三段式提交仍然追求ACID的强一致性,这个目标不仅给它带来了很高的复杂度,而且吞吐量和使用效果上也不佳。因此,现在系统设计的主流,已经变成了不追求ACID而是强调BASE的弱一致性事务,这就是我们要在下一讲学习的分布式事务了。
|
||||
|
||||
一课一思
|
||||
|
||||
你开发过的系统使用过全局事务和共享事务吗?你当时是如何实现这些事务的呢?
|
||||
|
||||
欢迎在留言区分享你的答案。如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
||||
163
专栏/周志明的架构课/14_分布式事务之可靠消息队列.md
Normal file
163
专栏/周志明的架构课/14_分布式事务之可靠消息队列.md
Normal file
@@ -0,0 +1,163 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 _ 分布式事务之可靠消息队列
|
||||
你好,我是周志明。
|
||||
|
||||
前面几节课,我们谈论了事务处理中的本地事务(单个服务、单个数据源)、全局事务(单个服务、多个数据源)和共享事务(多个服务、单个数据源),这一讲我们将聚焦于事务处理中最复杂的分布式事务(多个服务、多个数据源)。
|
||||
|
||||
在开始展开介绍之前,我想先给你强调一下,这里所说的分布式事务(Distributed Transactions),跟DTP模型中所指的“分布式事务”的含义是不一样的:DTP模型所指的“分布式”是相对于数据源而言的,并不涉及服务,这部分内容我们在上节课已经讨论过了;而这里的“分布式”是相对于服务而言的,它特指的是多个服务同时访问多个数据源的事务处理机制,严谨地说,它更应该被称为“在分布式服务环境下的事务处理机制”。
|
||||
|
||||
其实在上一讲我们就提到过,为了解决分布式事务的一致性问题,1991年X/Open组织提出了一套XA的事务处理架构。在2000年以前,人们还寄希望于这套事务处理架构能良好地应用在分布式环境中。不过很遗憾,这个美好的愿望今天已经被CAP理论彻底地击碎了。
|
||||
|
||||
那么,为什么会出现这种局面呢?就让我们从CAP与ACID的矛盾开始说起吧。
|
||||
|
||||
CAP与ACID之间的矛盾
|
||||
|
||||
CAP理论又叫Brewer理论,这是加州大学伯克利分校的埃里克 · 布鲁尔(Eric Brewer)教授,在2000年7月“ACM分布式计算原理研讨会(PODC)”上提出的一个猜想。
|
||||
|
||||
|
||||
|
||||
CAP理论原稿(那时候还只是猜想)
|
||||
|
||||
然后到了2002年,麻省理工学院的赛斯 · 吉尔伯特(Seth Gilbert)和南希 · 林奇(Nancy Lynch)就以严谨的数学推理证明了这个CAP猜想。在这之后,CAP理论就正式成为了分布式计算领域公认的著名定理。
|
||||
|
||||
这个定理里,描述了一个分布式的系统中,当涉及到共享数据问题时,以下三个特性最多只能满足其中两个:
|
||||
|
||||
|
||||
一致性(Consistency):代表在任何时刻、任何分布式节点中,我们所看到的数据都是没有矛盾的。这与第11讲所提到的ACID中的C是相同的单词,但它们又有不同的定义(分别指Replication的一致性和数据库状态的一致性)。在分布式事务中,ACID的C要以满足CAP中的C为前提。
|
||||
可用性(Availability):代表系统不间断地提供服务的能力。
|
||||
分区容忍性(Partition Tolerance):代表分布式环境中,当部分节点因网络原因而彼此失联(即与其他节点形成“网络分区”)时,系统仍能正确地提供服务的能力。
|
||||
|
||||
|
||||
当然,单纯只看这个概念的话,CAP是比较抽象的,我还是以第11讲开头所列的事例场景来说明一下,这三种特性对分布式系统来说都意味着什么。
|
||||
|
||||
|
||||
事例场景:Fenix’s Bookstore是一个在线书店。一份商品成功售出,需要确保以下三件事情被正确地处理:
|
||||
|
||||
|
||||
用户的账号扣减相应的商品款项;
|
||||
商品仓库中扣减库存,将商品标识为待配送状态;
|
||||
商家的账号增加相应的商品款项。
|
||||
|
||||
|
||||
|
||||
假设,Fenix’s Bookstore的服务拓扑如下图所示,一个来自最终用户的交易请求,将交由账号、商家和仓库服务集群中的某一个节点来完成响应:
|
||||
|
||||
|
||||
|
||||
Fenix’s Bookstore的服务拓扑
|
||||
|
||||
你可以看到,在这套系统中,每一个单独的服务节点都有着自己的数据库。
|
||||
|
||||
假设某次交易请求分别由“账号节点1”“商家节点2”“仓库节点N”来进行响应,当用户购买一件价值100元的商品后,账号节点1首先应该给用户账号扣减100元货款。
|
||||
|
||||
账号节点1在自己的数据库扣减100元是很容易的,但它还要把这次交易变动告知账号节点2到N,以及确保能正确变更商家和仓库集群其他账号节点中的关联数据。那么此时,我们可能会面临以下几种情况:
|
||||
|
||||
|
||||
如果该变动信息没有及时同步给其他账号节点,那么当用户购买其他商品时,会被分配给另一个节点处理,因为没有及时同步,此时系统会看到用户账户上有不正确的余额,从而错误地发生了原本无法进行的交易。此为一致性问题。
|
||||
如果因为要把该变动信息同步给其他账号节点,就必须暂停对该用户的交易服务,直到数据同步一致后再重新恢复,那么当用户在下一次购买商品时,可能会因为系统暂时无法提供服务而被拒绝交易。此为可用性问题。
|
||||
如果由于账号服务集群中某一部分节点,因出现网络问题,无法正常与另一部分节点交换账号变动信息,那么此时的服务集群中,无论哪一部分节点对外提供的服务,都可能是不正确的,我们需要考虑能否接受由于部分节点之间的连接中断,而影响整个集群的正确性的情况。此为分区容忍性问题。
|
||||
|
||||
|
||||
以上还只是涉及到了账号服务集群自身的CAP问题,而对于整个Bookstore站点来说,它更是面临着来自于账号、商家和仓库服务集群带来的CAP问题。
|
||||
|
||||
比如,用户账号扣款后,由于没有及时通知仓库服务,导致另一次交易中看到仓库中有不正确的库存数据而发生了超售。再比如,因为仓库中某个商品的交易正在进行当中,为了同步用户、商家和仓库此时的交易变动,而暂时锁定了该商品的交易服务,导致了可用性问题,等等。
|
||||
|
||||
不过既然CAP理论已经有了数学证明,也成为了业界公认的计算定理,我们就不去讨论为何CAP特性会存在不可兼得的问题了,直接来分析下在实际的应用场景中,我们要如何权衡取舍CAP,然后看看这些不同取舍都会带来哪些问题。
|
||||
|
||||
|
||||
如果放弃分区容错性(CA without P)
|
||||
|
||||
|
||||
这意味着,我们将假设节点之间的通讯永远是可靠的。可是永远可靠的通讯在分布式系统中必定是不成立的,这不是你想不想的问题,而是网络分区现象始终会存在。
|
||||
|
||||
在现实场景中,主流的RDBMS(关系数据库管理系统)集群通常就是采用放弃分区容错性的工作模式。以Oracle的RAC集群为例,它的每一个节点都有自己的SGA(系统全局区)、重做日志、回滚日志等,但各个节点是共享磁盘中的同一份数据文件和控制文件的,也就是说,RAC集群是通过共享磁盘的方式来避免网络分区的出现。
|
||||
|
||||
|
||||
如果放弃可用性(CP without A)
|
||||
|
||||
|
||||
这意味着,我们将假设一旦发生分区,节点之间的信息同步时间可以无限制地延长,那么这个问题就相当于退化到了上一讲所讨论的全局事务的场景之中,即一个系统可以使用多个数据源。我们可以通过2PC/3PC等手段,同时获得分区容错性和一致性。
|
||||
|
||||
在现实中,除了DTP模型的分布式数据库事务外,著名的HBase也是属于CP系统。以它的集群为例,假如某个RegionServer宕机了,这个RegionServer持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个时间通常会是很长的。
|
||||
|
||||
|
||||
如果放弃一致性(AP without C)
|
||||
|
||||
|
||||
这意味着,我们将假设一旦发生分区,节点之间所提供的数据可能不一致。
|
||||
|
||||
AP系统目前是分布式系统设计的主流选择,大多数的NoSQL库和支持分布式的缓存都是AP系统。因为P是分布式网络的天然属性,你不想要也无法丢弃;而A通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就没有存在的价值了(除非银行这些涉及到金钱交易的服务,宁可中断也不能出错)。
|
||||
|
||||
以Redis集群为例,如果某个Redis节点出现网络分区,那也不妨碍每个节点仍然会以自己本地的数据对外提供服务。但这时有可能出现这种情况,即请求分配到不同节点时,返回给客户端的是不同的数据。
|
||||
|
||||
那么看到这里,你是否感受到了一丝无奈?这个小章节所讨论的话题“事务”,原本的目的就是要获得“一致性”。而在分布式环境中,“一致性”却不得不成为了通常被牺牲、被放弃的那一项属性。
|
||||
|
||||
但无论如何,我们建设信息系统,终究还是要保证操作结果(在最终被交付的时候)是正确的。为此,人们又重新给一致性下了定义,把前面我们在CAP、ACID中讨论的一致性称为“强一致性**”(Strong Consistency),有时也称为“线性一致性”(Linearizability),而把牺牲了C的AP系统,又要尽可能获得正确的结果的行为,称为追求“弱一致性**”。
|
||||
|
||||
不过,如果单纯只说“弱一致性”,那其实就是“不保证一致性”的意思……人类语言这东西真是博大精深。
|
||||
|
||||
所以,在弱一致性中,人们又总结出了一种特例,叫做“[最终一致性](https://en.wikipedia.org/wiki/Eventual_consistency)”(Eventual Consistency)。它是指,如果数据在一段时间内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果,有时候面向最终一致性的算法,也被称为“乐观复制算法”。
|
||||
|
||||
那么,在“分布式事务”中,我们的设计目标同样也不得不从获得强一致性,降低为获得“最终一致性”,在这个意义上,其实“事务”一词的含义也已经被拓宽了。
|
||||
|
||||
除了本地事务、全局事务和分布式事务以外,还有一种对于不同事务的叫法,那就是针对追求ACID的事务,我们称之为“刚性事务”。而在接下来和下一讲中,我将要介绍的几种分布式事务的常见做法,会统称为“柔性事务”。
|
||||
|
||||
这一讲我们先来讨论下,可靠消息队列这种分布式事务的实现方式。
|
||||
|
||||
可靠事件队列
|
||||
|
||||
前面提到的最终一致性的概念,是由eBay的系统架构师丹 · 普利切特(Dan Pritchett)在2008年发表于ACM的论文“Base: An Acid Alternative”中提出的。
|
||||
|
||||
这篇文章中,总结了一种独立于ACID获得的强一致性之外的途径,即通过BASE来达成一致性目的,最终一致性就是其中的“E”。
|
||||
|
||||
BASE这个提法,比ACID凑缩写的痕迹更重,不过因为有ACID vs BASE(酸 vs 碱)这个朗朗上口的梗,这篇文章传播得足够快。在这里我就不多谈BASE中的概念了,但这篇论文本身作为最终一致性的概念起源,并系统性地总结了一种在分布式事务的技术手段,还是非常有价值的。
|
||||
|
||||
下面,我们继续以Fenix’s Bookstore的事例场景,来解释下丹 · 普利切特提出的“可靠事件队列”的具体做法,下图为操作时序:
|
||||
|
||||
|
||||
|
||||
可靠事件队列时序图
|
||||
|
||||
我们按照顺序,一步步来解读一下。
|
||||
|
||||
第一步,最终用户向Fenix’s Bookstore发送交易请求:购买一本价值100元的《深入理解Java虚拟机》。
|
||||
|
||||
第二步,Fenix’s Bookstore应该对用户账户扣款、商家账户收款、库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率的大小来安排它们的操作顺序(这个一般体现在程序代码中,有一些大型系统也可能动态排序)。比如,最有可能出错的地方,是用户购买了,但是系统不同意扣款,或者是账户余额不足;其次是商品库存不足;最后是商家收款,一般收款不会遇到什么意外。那么这个顺序就应该是最容易出错的最先进行,即:账户扣款 → 仓库出库 → 商家收款。
|
||||
|
||||
第三步,账户服务进行扣款业务,如果扣款成功,就在自己的数据库建立一张消息表,里面存入一条消息:“事务ID:UUID;扣款:100元(状态:已完成);仓库出库《深入理解Java虚拟机》:1本(状态:进行中);某商家收款:100元(状态:进行中)”。注意,这个步骤中“扣款业务”和“写入消息”是依靠同一个本地事务写入自身数据库的。
|
||||
|
||||
第四步,系统建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到库存和商家服务节点中去。
|
||||
|
||||
这时候可能会产生以下几种情况:
|
||||
|
||||
|
||||
商家和仓库服务成功完成了收款和出库工作,向用户账户服务器返回执行结果,用户账户服务把消息状态从“进行中”更新为“已完成”。整个事务宣告顺利结束,达到最终一致性的状态。
|
||||
商家或仓库服务有某些或全部因网络原因,未能收到来自用户账户服务的消息。此时,由于用户账户服务器中存储的消息状态,一直处于“进行中”,所以消息服务器将在每次轮询的时候,持续地向对应的服务重复发送消息。这个步骤的可重复性,就决定了所有被消息服务器发送的消息都必须具备幂等性。通常我们的设计是让消息带上一个唯一的事务ID,以保证一个事务中的出库、收款动作只会被处理一次。
|
||||
商家或仓库服务有某个或全部无法完成工作。比如仓库发现《深入理解Java虚拟机》没有库存了,此时,仍然是持续自动重发消息,直至操作成功(比如补充了库存),或者被人工介入为止。
|
||||
商家和仓库服务成功完成了收款和出库工作,但回复的应答消息因网络原因丢失。此时,用户账户服务仍会重新发出下一条消息,但因消息幂等,所以不会导致重复出库和收款,只会导致商家、仓库服务器重新发送一条应答消息。此过程会一直重复,直至双方网络恢复。
|
||||
也有一些支持分布式事务的消息框架,如RocketMQ,原生就支持分布式事务操作,这时候前面提到的情况2、4也可以交给消息框架来保障。
|
||||
|
||||
|
||||
前面这种靠着持续重试来保证可靠性的操作,在计算机中就非常常见,它有个专门的名字,叫做“最大努力交付”(Best-Effort Delivery),比如TCP协议中的可靠性保障,就属于最大努力交付。
|
||||
|
||||
而“可靠事件队列”有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),意思就是系统会把最有可能出错的业务,以本地事务的方式完成后,通过不断重试的方式(不限于消息系统)来促使同个事务的其他关联业务完成。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我第一次引入了CAP定理,希望你能通过事务处理的上下文场景去理解它。这套理论不仅是在事务处理中,而且在一致性、共识,乃至整个分布式所有涉及到数据的知识点中,都有重要的应用,后面讲到分布式共识算法、微服务中多种基础设施等内容的时候,我们还会多次涉及到它。
|
||||
|
||||
除了可靠事件队列之外,下一讲我还会给你介绍TCC和SAGA这两种主流的实现方式,它们都有各自的优缺点和应用场景。分布式系统中不存在放之四海皆准的万能事务解决方案,针对具体场景,选择合适的解决方案,达到一致性与可用性之间的最佳平衡,是我们作为一名设计者必须具备的技能。
|
||||
|
||||
一课一思
|
||||
|
||||
请你思考一下,为什么XA事务很少在分布式环境下直接应用,会有什么代价?而这节课介绍的“可靠事件队列”的事务实现方式又会有什么代价?欢迎给我留言,分享你的思考和见解。
|
||||
|
||||
如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
166
专栏/周志明的架构课/15_分布式事务之TCC与SAGA.md
Normal file
166
专栏/周志明的架构课/15_分布式事务之TCC与SAGA.md
Normal file
@@ -0,0 +1,166 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 _ 分布式事务之TCC与SAGA
|
||||
你好,我是周志明。
|
||||
|
||||
今天,我们接着上一节课的话题,继续讨论另外两种主流的分布式事务实现方式:TCC和SAGA。
|
||||
|
||||
TCC事务的实现过程
|
||||
|
||||
TCC(Try-Confirm-Cancel)是除可靠消息队列以外的另一种常见的分布式事务机制,它是由数据库专家帕特 · 赫兰德(Pat Helland)在2007年撰写的论文《Life beyond Distributed Transactions: An Apostate’s Opinion》中提出的。
|
||||
|
||||
在上一讲,我给你介绍了可靠消息队列的实现原理,虽然它也能保证最终的结果是相对可靠的,过程也足够简单(相对于TCC来说),但现在你已经知道,可靠消息队列的整个实现过程完全没有任何隔离性可言。
|
||||
|
||||
虽然在有些业务中,有没有隔离性不是很重要,比如说搜索系统。但在有些业务中,一旦缺乏了隔离性,就会带来许多麻烦。比如说前几讲,我一直引用的Fenix’s Bookstore在线书店的场景事例中,如果缺乏了隔离性,就会带来一个显而易见的问题:超售。
|
||||
|
||||
|
||||
事例场景:Fenix’s Bookstore是一个在线书店。一份商品成功售出,需要确保以下三件事情被正确地处理:
|
||||
|
||||
|
||||
用户的账号扣减相应的商品款项;
|
||||
商品仓库中扣减库存,将商品标识为待配送状态;
|
||||
商家的账号增加相应的商品款项。
|
||||
|
||||
|
||||
|
||||
也就是说,在书店的业务场景下,很有可能会出现这样的情况:两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和,却超过了库存。
|
||||
|
||||
如果这件事情是发生在刚性事务且隔离级别足够的情况下,其实是可以完全避免的。比如,我前面提到的“超售”场景,就需要“可重复读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败。但用可靠消息队列就无法保证这一点了。我在第12讲中已经给你介绍过数据库本地事务的相关知识,你可以再去回顾复习下。
|
||||
|
||||
所以,如果业务需要隔离,我们通常就应该重点考虑TCC方案,它天生适合用于需要强隔离性的分布式事务中。
|
||||
|
||||
在具体实现上,TCC的操作其实有点儿麻烦和复杂,它是一种业务侵入性较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。另外,你看名字也能看出来,TCC的实现过程分为了三个阶段:
|
||||
|
||||
|
||||
Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好事务需要用到的所有业务资源(保障隔离性)。
|
||||
Confirm:确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。注意,Confirm阶段可能会重复执行,因此需要满足幂等性。
|
||||
Cancel:取消执行阶段,释放Try阶段预留的业务资源。注意,Cancel阶段也可能会重复执行,因此也需要满足幂等性。
|
||||
|
||||
|
||||
那么,根据Fenix’s Bookstore在线书店的场景事例,TCC的执行过程应该是这样的:
|
||||
|
||||
|
||||
|
||||
第一步,最终用户向Fenix’s Bookstore发送交易请求:购买一本价值100元的《深入理解Java虚拟机》。
|
||||
|
||||
第二步,创建事务,生成事务ID,记录在活动日志中,进入Try阶段:
|
||||
|
||||
|
||||
用户服务:检查业务可行性,可行的话,把该用户的100元设置为“冻结”状态,通知下一步进入Confirm阶段;不可行的话,通知下一步进入Cancel阶段。
|
||||
仓库服务:检查业务可行性,可行的话,将该仓库的1本《深入理解Java虚拟机》设置为“冻结”状态,通知下一步进入Confirm阶段;不可行的话,通知下一步进入Cancel阶段。
|
||||
商家服务:检查业务可行性,不需要冻结资源。
|
||||
|
||||
|
||||
第三步,如果第二步中所有业务都反馈业务可行,就将活动日志中的状态记录为Confirm,进入Confirm阶段:
|
||||
|
||||
|
||||
用户服务:完成业务操作(扣减被冻结的100元)。
|
||||
仓库服务:完成业务操作(标记那1本冻结的书为出库状态,扣减相应库存)。
|
||||
商家服务:完成业务操作(收款100元)。
|
||||
|
||||
|
||||
第四步,如果第三步的操作全部完成了,事务就会宣告正常结束。而如果第三步中的任何一方出现了异常,不论是业务异常还是网络异常,都将会根据活动日志中的记录,来重复执行该服务的Confirm操作,即进行“最大努力交付”。
|
||||
|
||||
第五步,如果是在第二步,有任意一方反馈业务不可行,或是任意一方出现了超时,就将活动日志的状态记录为Cancel,进入Cancel阶段:
|
||||
|
||||
|
||||
用户服务:取消业务操作(释放被冻结的100元)。
|
||||
仓库服务:取消业务操作(释放被冻结的1本书)。
|
||||
商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。
|
||||
|
||||
|
||||
第六步,如果第五步全部完成了,事务就会宣告以失败回滚结束。而如果第五步中的任何一方出现了异常,不论是业务异常还是网络异常,也都将会根据活动日志中的记录,来重复执行该服务的Cancel操作,即进行“最大努力交付”。
|
||||
|
||||
那么,你从上述的操作执行过程中可以发现,TCC其实有点类似于2PC的准备阶段和提交阶段,但TCC是位于用户代码层面,而不是在基础设施层面,这就为它的实现带来了较高的灵活性,我们可以根据需要设计资源锁定的粒度。
|
||||
|
||||
另外,TCC在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。
|
||||
|
||||
但是,由于TCC的业务侵入性比较高,需要开发编码配合,在一定程度上增加了不少工作量,也就给我们带来了一些使用上的弊端,那就是我们需要投入更高的开发成本和更换事务实现方案的替换成本。
|
||||
|
||||
所以,通常我们并不会完全靠裸编码来实现TCC,而是会基于某些分布式事务中间件(如阿里开源的Seata)来完成,以尽量减轻一些编码工作量。
|
||||
|
||||
好,现在你就已经知道了,TCC事务具有较强的隔离性,能够有效避免“超售”的问题,而且它的性能可以说是包括可靠消息队列在内的几种柔性事务模式中最高的。但是,TCC仍然不能满足所有的业务场景。
|
||||
|
||||
我在前面也提到了,TCC最主要的限制是它的业务侵入性很强,但并不是指由此给开发编码带来的工作量,而是指它所要求的技术可控性上的约束。
|
||||
|
||||
比如说,我们把这个书店的场景事例修改一下:由于中国网络支付日益盛行,在书店系统中,现在用户和商家可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能进行消费,而是允许在购物时,直接通过U盾或扫码支付,在银行账户中划转货款。
|
||||
|
||||
这个需求完全符合我们现在支付的习惯,但这也给系统的事务设计增加了额外的限制:如果用户、商家的账户余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲地自行定义了,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以,在TCC的执行过程中,第一步Try阶段往往就已经无法施行了。
|
||||
|
||||
那么,我们就只能考虑采用另外一种柔性事务方案:SAGA事务。
|
||||
|
||||
SAGA事务基于数据补偿代替回滚的解决思路
|
||||
|
||||
SAGA事务模式的历史十分悠久,比分布式事务的概念提出还要更早。SAGA的意思是“长篇故事、长篇记叙、一长串事件”,它起源于1987年普林斯顿大学的赫克托 · 加西亚 · 莫利纳(Hector Garcia Molina)和肯尼斯 · 麦克米伦(Kenneth Salem)在ACM发表的一篇论文《SAGAS》(这就是论文的全名)。
|
||||
|
||||
文中提出了一种如何提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务的集合。原本提出SAGA的目的,是为了避免大事务长时间锁定数据库的资源,后来才逐渐发展成将一个分布式环境中的大事务,分解为一系列本地事务的设计模式。
|
||||
|
||||
SAGA由两部分操作组成。
|
||||
|
||||
一部分是把大事务拆分成若干个小事务,将整个分布式事务T分解为n个子事务,我们命名为T1,T2,…,Ti,…,Tn。每个子事务都应该、或者能被看作是原子行为。如果分布式事务T能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务Ti等价。
|
||||
|
||||
另一部分是为每一个子事务设计对应的补偿动作,我们命名为C1,C2,…,Ci,…,Cn。Ti与Ci必须满足以下条件:
|
||||
|
||||
|
||||
Ti与Ci都具备幂等性;
|
||||
Ti与Ci满足交换律(Commutative),即不管是先执行Ti还是先执行Ci,效果都是一样的;
|
||||
Ci必须能成功提交,即不考虑Ci本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。
|
||||
|
||||
|
||||
如果T1到Tn均成功提交,那么事务就可以顺利完成。否则,我们就要采取以下两种恢复策略之一:
|
||||
|
||||
|
||||
正向恢复(Forward Recovery):如果Ti事务提交失败,则一直对Ti进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
|
||||
反向恢复(Backward Recovery):如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功为止(最大努力交付)。这里要求Ci必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。
|
||||
|
||||
|
||||
所以你能发现,与TCC相比,SAGA不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。
|
||||
|
||||
我给你举个例子。我在前面提到的账户余额直接在银行维护的场景,从银行划转货款到Fenix’s Bookstore系统中,这步是经由用户支付操作(扫码或U盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉之前的用户转账操作,但是作为补偿措施,我们让Fenix’s Bookstore系统将货款转回到用户账上,却是完全可行的。
|
||||
|
||||
SAGA必须保证所有子事务都能够提交或者补偿,但SAGA系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。
|
||||
|
||||
另外你还要注意,尽管补偿操作通常比冻结/撤销更容易实现,但要保证正向、反向恢复过程能严谨地进行,也需要你花费不少的工夫。比如,你可能需要通过服务编排、可靠事件队列等方式来完成。所以,SAGA事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。我前面提到的Seata就同样支持SAGA事务模式。
|
||||
|
||||
还有,SAGA基于数据补偿来代替回滚的思路,也可以应用在其他事务方案上。举个例子,阿里的GTS(Global Transaction Service,Seata由GTS开源而来)所提出的“AT事务模式”就是这样的一种应用。
|
||||
|
||||
另一种应用模式:AT事务
|
||||
|
||||
从整体上看,AT事务是参照了XA两段提交协议来实现的,但针对XA 2PC的缺陷,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出Commit命令而导致的[木桶效应](https://en.wikipedia.org/wiki/Liebig’s_law_of_the_minimum)(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT事务也设计了针对性的解决方案。
|
||||
|
||||
它大致的做法是在业务数据提交时,自动拦截所有SQL,分别保存SQL对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。
|
||||
|
||||
如果分布式事务成功提交了,那么我们后续只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向SQL”。
|
||||
|
||||
所以,基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。AT事务这种异步提交的模式,相比2PC极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功。
|
||||
|
||||
比如,当在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),而这个时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向SQL来实现补偿,只能由人工介入处理了。
|
||||
|
||||
一般来说,对于脏写我们是一定要避免的,所有传统关系数据库在最低的隔离级别上,都仍然要加锁以避免脏写。因为脏写情况一旦发生,人工其实也很难进行有效处理。
|
||||
|
||||
所以,GTS增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,而在没有获得全局锁之前就必须一直等待。
|
||||
|
||||
这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。
|
||||
|
||||
另外,在读隔离方面,AT事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能会产生脏读(Dirty Read)。读隔离也可以采用全局锁的方案来解决,但直接阻塞读取的话,我们要付出的代价就非常大了,一般并不会这样做。
|
||||
|
||||
所以到这里,你其实能发现,分布式事务中并没有能一揽子包治百病的解决办法,你只有因地制宜地选用合适的事务处理方案,才是唯一有效的做法。
|
||||
|
||||
小结
|
||||
|
||||
通过上一讲和今天这节课的学习,我们已经知道,CAP定理决定了C与A不可兼得,传统的ACID强一致性在分布式环境中,要想能保证一致性(C),就不得不牺牲可用性(A)。那么这个时候,随着分布式系统中节点数量的增加,整个系统发生服务中断的概率和时间都会随之增长。
|
||||
|
||||
所以,我们只能退而求其次,把“最终一致性”作为分布式架构下事务处理的目标。在这两节课中,我给你介绍的可靠事件队列、TCC和SAGA,都是实现最终一致性的三种主流模式。
|
||||
|
||||
一课一思
|
||||
|
||||
请你思考并对比可靠事件队列、TCC和SAGA三种事务实现的优缺点,然后来总结一下它们各自适用的场景。
|
||||
|
||||
欢迎在留言区分享你的思考和见解。 如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
131
专栏/周志明的架构课/16_域名解析系统,优化HTTP性能的第一步.md
Normal file
131
专栏/周志明的架构课/16_域名解析系统,优化HTTP性能的第一步.md
Normal file
@@ -0,0 +1,131 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 _ 域名解析系统,优化HTTP性能的第一步
|
||||
你好,我是周志明。从今天这节课开始,我们一起来学习下,如何引导流量分配到最合适的系统部件中进行响应。
|
||||
|
||||
那么在正式开始学习之前,我们先来了解下所谓的透明多级分流系统的定义。
|
||||
|
||||
理解透明多级分流系统的设计原则
|
||||
|
||||
我们都知道,用户在使用信息系统的过程中,请求首先是从浏览器出发,在DNS的指引下找到系统的入口,然后经过了网关、负载均衡器、缓存、服务集群等一系列设施,最后接触到了系统末端存储于数据库服务器中的信息,然后再逐级返回到用户的浏览器之中。
|
||||
|
||||
这个过程需要经过许许多多的技术部件。那么作为系统的设计者,我们应该意识到不同的设施、部件在系统中,都具有各自不同的价值:
|
||||
|
||||
|
||||
有一些部件位于客户端或网络的边缘,能够迅速响应用户的请求,避免给后方的I/O与CPU带来压力,典型的如本地缓存、内容分发网络、反向代理等。
|
||||
有一些部件的处理能力能够线性拓展,易于伸缩,可以通过使用较小的代价堆叠机器,来获得与用户数量相匹配的并发性能,并且应尽量作为业务逻辑的主要载体,典型的如集群中能够自动扩缩的服务节点。
|
||||
有一些部件的稳定服务,对系统运行具有全局性的影响,要时刻保持着容错备份,维护着高可用性,典型的如服务注册中心、配置中心。
|
||||
有一些设施是天生的单点部件,只能依靠升级机器本身的网络、存储和运算性能来提升处理能力,比如位于系统入口的路由、网关或者负载均衡器(它们都可以做集群,但一次网络请求中无可避免至少有一个是单点的部件)、位于请求调用链末端的传统关系数据库等,都是典型的容易形成单点部件。
|
||||
|
||||
|
||||
所以,在对系统进行流量规划时,我们需要充分理解这些部件的价值差异。这里,我认为有两个简单、普适的原则,能指导我们进行设计。
|
||||
|
||||
第一个原则是尽可能减少单点部件,如果某些单点是无可避免的,则应尽最大限度减少到达单点部件的流量。
|
||||
|
||||
用户的请求在系统中往往会有多个部件都能够处理响应,比如要获取一张存储在数据库的用户头像图片,浏览器缓存、内容分发网络、反向代理、Web服务器、文件服务器、数据库等,都可能会提供这张图片。
|
||||
|
||||
所以,恰如其分地引导请求分流至最合适的组件中,避免绝大多数流量汇集到单点部件(如数据库),同时依然能够、或者在绝大多数时候能够保证处理结果的准确性,在单点系统出现故障时,仍能自动而迅速地实施补救措施,这便是系统架构中多级分流的意义。
|
||||
|
||||
那么,缓存、节流、主备、负载均衡等措施,就都是为了达成该目标所采用的工具与手段,而高可用架构、高并发架构,则是通过该原则所获得的价值。
|
||||
|
||||
许多介绍架构设计的资料呢,都会以“高可用、高并发架构”为主题,主要聚焦于流量到达服务端后,如何构建强大的服务端集群来应对。
|
||||
|
||||
而在这个小章节中,我们是以“透明多级分流系统”为主题,聚焦于流量从客户端发出,到达服务端处理节点前的过程,并会去了解在这个过程中对流量削峰填谷的基础设施与通用组件。
|
||||
|
||||
第二个原则是奥卡姆剃刀原则,它更为关键。
|
||||
|
||||
|
||||
奥卡姆剃刀原则-
|
||||
Entities should not be multiplied without necessity.-
|
||||
如无必要,勿增实体。-
|
||||
—— Occam’s Razor,William of Ockham
|
||||
|
||||
|
||||
作为一个架构设计者,你应该对前面提到的多级分流的手段,有一个全面的理解与充分的准备。同时你也要清晰地意识到,这些设施并不是越多越好,在实际构建系统的时候,你要在有明确需求、真正有必要的时候,再去考虑部署它们。
|
||||
|
||||
因为并不是每一个系统都要追求高并发、高可用的,从系统的用户量、峰值流量和团队本身的技术与运维能力出发,来考虑如何布置这些设施,才是最合理的做法。
|
||||
|
||||
在能满足需求的前提下,最简单的系统就是最好的系统。
|
||||
|
||||
所以,在这个章节的第一节课当中,我们就先来学习一个相对简单,但又是全世界最大规模的查询系统,即DNS域名解析查询系统,我们一起来看看它是如何实现多级分流的。
|
||||
|
||||
DNS的工作原理
|
||||
|
||||
我们都知道,DNS的作用是把便于人类理解的域名地址,转换为便于计算机处理的IP地址。
|
||||
|
||||
说到DNS,我想到了一件事,你可能会觉得有点儿好笑:我在刚接触计算机网络有一小段时间以后,一直都把DNS想像成是一个部署在世界上某个神秘机房里的大型电话本式的翻译服务。直到后来,当我第一次了解到DNS的工作原理,也知道了世界根域名服务器的ZONE文件只有2MB大小,甚至可以打印出来物理备份的时候,我就对DNS系统的设计惊叹得不得了。
|
||||
|
||||
域名解析对于大多数信息系统,尤其是基于互联网的系统来说是必不可少的组件,可是现在想想,它在我们的开发工作里其实根本没有特别高的存在感,通常它都是不会受到重点关注的设施。这就导致了很多程序员还不太了解DNS本身的工作过程,以及它对系统流量能够施加的影响;而且,DNS本身就堪称是示范性的透明多级分流系统,非常符合我们这个章节的主题,也很值得我们去借鉴。
|
||||
|
||||
无论是使用浏览器,还是在程序代码中访问某个网址域名,如果没有缓存的话,都会先经过DNS服务器的解析翻译,找到域名对应的IP地址,才能开始通讯。
|
||||
|
||||
后面我就以www.icyfenix.com.cn为例吧。这项操作是操作系统自动完成的,一般不需要用户程序的介入。
|
||||
|
||||
不过,DNS服务器并不是一次性地把www.icyfenix.com.cn直接解析成IP地址的,这个解析需要经历一个递归的过程。
|
||||
|
||||
首先,DNS会把域名还原为“www.icyfenix.com.cn.”。注意这里最后多了一个点“.”,它是“.root”的意思。早期的域名都必须得带这个点,DNS才能正确解析,不过现在几乎所有的操作系统、DNS服务器都可以自动补上结尾的点号了。
|
||||
|
||||
然后,DNS就开始进行解析了。它的解析步骤是这样的:
|
||||
|
||||
第一步,客户端先检查本地的DNS缓存,查看是否存在并且是存活着的该域名的地址记录。
|
||||
|
||||
DNS是以存活时间(Time to Live,TTL)来衡量缓存的有效情况的,因此如果某个域名改变了IP地址,它也无法去通知缓存了该地址的机器来更新或失效掉缓存,只能依靠TTL超期后重新获取来保证一致性。后续每一级DNS查询的过程,都会有类似的缓存查询操作,所以我就不重复说了。
|
||||
|
||||
第二步,客户端将地址发送给本机操作系统中配置的本地DNS(Local DNS)。这个本地DNS服务器可以由用户手工设置,也可以在DHCP分配时或者在拨号时,从PPP服务器中自动获取。
|
||||
|
||||
第三步,本地DNS收到查询请求后,会按照“是否有www.icyfenix.com.cn的权威服务器”→“是否有icyfenix.com.cn的权威服务器”→“是否有com.cn的权威服务器”→“是否有cn的权威服务器”的顺序,依次查询自己的地址记录。如果都没有查询到,本地DNS就会一直找到最后点号代表的根域名服务器为止。
|
||||
|
||||
这个步骤里涉及了两个重要名词,你需要好好掌握:
|
||||
|
||||
|
||||
权威域名服务器(Authoritative DNS):是指负责翻译特定域名的DNS服务器,“权威”的意思就是说,服务器决定了这个域名应该翻译出怎样的结果。DNS翻译域名的时候,不需要像查电话本一样刻板地一对一翻译,它可以根据来访机器、网络链路、服务内容等各种信息,玩出很多花样。权威DNS的灵活应用,在后面的内容分发网络、服务发现等课程内容中都还会涉及到。
|
||||
根域名服务器(Root DNS):是指固定的、无需查询的顶级域名(Top-Level Domain)服务器,可以默认为它们已内置在操作系统代码之中。全世界一共有13组根域名服务器(注意并不是13台,每一组根域名都通过任播的方式建立了一大群镜像,根据维基百科的数据,迄今已经超过1000台根域名服务器的镜像了),之所以有13这个数字的限制是因为,DNS主要是采用UDP传输协议(在需要稳定性保证的时候也可以采用TCP)来进行数据交换的,未分片的UDP数据包在IPv4下最大有效值为512字节,最多可以存放13组地址记录。
|
||||
|
||||
|
||||
第四步,现在假设本地DNS是全新的,上面不存在任何域名的权威服务器记录,所以当DNS查询请求按步骤3的顺序,一直查到根域名服务器之后,它将会得到“cn的权威服务器”的地址记录,然后通过“cn的权威服务器”,得到“com.cn的权威服务器”的地址记录,以此类推,最后找到能够解释www.icyfenix.com.cn的权威服务器地址。
|
||||
|
||||
第五步,通过“www.icyfenix.com.cn的权威服务器”,查询www.icyfenix.com.cn的地址记录。这里的地址记录并不一定就是指IP地址,在RFC规范中,有定义的地址记录类型已经多达几十种,比如IPv4下的IP地址为A记录,IPv6下的AAAA记录、主机别名CNAME记录,等等。
|
||||
|
||||
我给你举一个例子。假设一个域名下配置了多条不同的A记录,此时权威服务器就可以根据自己的策略来进行选择,典型的应用是智能线路:根据访问者所处的不同地区(如华北、华南、东北)、不同服务商(如电信、联通、移动)等因素,来确定返回最合适的A记录,将访问者路由到最合适的数据中心,达到智能加速的目的。
|
||||
|
||||
可见,DNS系统多级分流的设计,就让DNS系统能够经受住全球网络流量不间断的冲击,但它也并不是没有缺点。
|
||||
|
||||
典型的问题就是响应速度会受影响。在极端情况(如各级服务器均无缓存)下,域名解析可能会导致每个域名都必须递归多次才能查询到结果,显著影响传输的响应速度。
|
||||
|
||||
如下图所示,你可以看到其高达310毫秒的DNS查询速度:
|
||||
|
||||
|
||||
|
||||
首次DNS请求耗时
|
||||
|
||||
所以,为了避免产生这种问题,专门就有一种前端优化手段,叫做DNS预取(DNS Prefetching):如果网站后续要使用来自于其他域的资源,那就在网页加载时便生成一个link请求,促使浏览器提前对该域名进行预解释,如下所示:
|
||||
|
||||
<link rel="dns-prefetch" href="//domain.not-icyfenx.cn">
|
||||
|
||||
|
||||
而另一种可能更严重的缺陷,就是DNS的分级查询意味着每一级都有可能受到中间人攻击的威胁,产生被劫持的风险。
|
||||
|
||||
我们应该都知道,要攻陷位于递归链条顶层的(如根域名服务器,cn权威服务器)服务器和链路是非常困难的,它们都有很专业的安全防护措施。但很多位于递归链底层的、或者来自本地运营商的Local DNS服务器,安全防护就相对松懈,甚至不少地区的运行商自己就会主动进行劫持,专门返回一个错的IP,通过在这个IP上代理用户请求,以便给特定类型的资源(主要是HTML)注入广告,进行牟利。
|
||||
|
||||
所以针对这种情况,最近几年出现了一种新的DNS工作模式:HTTPDNS(也称为DNS over HTTPS,DoH)。它把原本的DNS解析服务开放为一个基于HTTPS协议的查询服务,替代基于UDP传输协议的DNS域名解析,通过程序代替操作系统直接从权威DNS,或者可靠Local DNS获取解析数据,从而绕过传统Local DNS。
|
||||
|
||||
这种做法的好处是完全免去了“中间商赚差价”的环节,不再惧怕底层的域名劫持,能有效避免Local DNS不可靠导致的域名生效缓慢、来源IP不准确、产生的智能线路切换错误等问题。
|
||||
|
||||
小结
|
||||
|
||||
这节课作为“透明多级分流系统”的第一讲,我给你介绍了这个名字的意义与来由。在开发过程中没有太多存在感的DNS系统,其实就很符合透明和多级分流的特点。所以我也以此为例,给你简要介绍了它的工作原理。
|
||||
|
||||
根据请求从浏览器发出到最终查询或修改数据库的信息,除了DNS以外,还会有客户端浏览器、网络传输链路、内容分发网络、负载均衡器和缓存中间件这些位于服务器、数据库之外的组件,可以帮助分担流量,便于我们构建出更加高并发、高可用的系统。在后面的几节课中,我们就会逐一来探讨它们的工作原理。
|
||||
|
||||
一课一思
|
||||
|
||||
思考一下,你开发的系统中,有使用过哪些分流手段?欢迎给我留言,分享你的做法。
|
||||
|
||||
如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
215
专栏/周志明的架构课/17_客户端缓存是如何帮助服务器分担流量的?.md
Normal file
215
专栏/周志明的架构课/17_客户端缓存是如何帮助服务器分担流量的?.md
Normal file
@@ -0,0 +1,215 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 _ 客户端缓存是如何帮助服务器分担流量的?
|
||||
你好,我是周志明。这节课,我们继续来讨论透明多级分流系统中,最靠近用户一侧的分流部件:浏览器的客户端缓存。
|
||||
|
||||
当万维网刚刚出现的时候,浏览器的缓存机制差不多就已经存在了。在HTTP协议设计之初,人们便确定了服务端与客户端之间“无状态”(Stateless)的交互原则,即要求客户端的每次请求是独立的,每次请求无法感知、也不能依赖另一个请求的存在,这既简化了HTTP服务器的设计,也为它的水平扩展能力留下了广阔的空间。
|
||||
|
||||
但无状态并不是只有好的一面。因为客户端的每次请求都是独立的,服务端不会保存之前请求的状态和资源,所以也不可避免地导致它会携带重复的数据,造成网络性能的降低。
|
||||
|
||||
那么,HTTP协议针对这个问题的解决方案,就是客户端缓存。从HTTP/1.0到1.1、再到2.0版本的演进中,逐步形成了现在被称为“状态缓存”、“强制缓存”(或简称为“强缓存”)和“协商缓存”这三种HTTP缓存机制。
|
||||
|
||||
这其中的状态缓存,是指不经过服务器,客户端直接根据缓存信息来判断目标网站的状态。以前只有301/Moved Permanently(永久重定向)这一种;后来在RFC6797中增加了HSTS(HTTP Strict Transport Security)机制,用来避免依赖301/302跳转HTTPS时,可能产生的降级中间人劫持问题(在第28、29讲中,我还会展开讲解这个问题),这也属于另一种状态缓存。
|
||||
|
||||
因为状态缓存涉及的内容只有这么一点,所以后面,我们就只聚焦在强制缓存与协商缓存这两种机制的探讨上。
|
||||
|
||||
下面我们就先来看看强制缓存的实现机制吧。
|
||||
|
||||
实现强制缓存机制的两类Headers
|
||||
|
||||
就像“强制缓存”这个名字一样,它对一致性问题的处理策略十分直接:假设在某个时间点内,比如服务器收到响应后的10分钟内,资源的内容和状态一定不会被改变,因此客户端可以不需要经过任何请求,在该时间点到来之前一直持有和使用该资源的本地缓存副本。
|
||||
|
||||
根据约定,在浏览器的地址输入、页面链接跳转、新开窗口、前进和后退中,强制缓存都可以生效,但在用户主动刷新页面时应当自动失效。
|
||||
|
||||
在HTTP协议中,设置了两类可以实现强制缓存的Headers(标头):Expires和Cache-Control。
|
||||
|
||||
第一类:Expires
|
||||
|
||||
Expires是HTTP/1.0协议中开始提供的Header,后面跟随了一个截止时间参数。当服务器返回某个资源时,如果带有该Header的话,就意味着服务器承诺在截止时间之前,资源不会发生变动,浏览器可直接缓存该数据,不再重新发请求。我们直接来看一个Expires头的示例程序:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Expires: Wed, 8 Apr 2020 07:28:00 GMT
|
||||
|
||||
|
||||
那么,你能看到,Expires设计得非常直观易懂,但它考虑得其实并不周全。我给你简单举几个例子。
|
||||
|
||||
|
||||
受限于客户端的本地时间
|
||||
|
||||
|
||||
比如,在收到响应后,客户端修改了本地时间,将时间点前后调整了几分钟,这就可能会造成缓存提前失效或超期持有。
|
||||
|
||||
|
||||
无法处理涉及到用户身份的私有资源
|
||||
|
||||
|
||||
比如,合理的做法是,某些资源被登录用户缓存在了自己的浏览器上。但如果被代理服务器或者内容分发网络(CDN)缓存起来,就可能会被其他未认证的用户获取。
|
||||
|
||||
|
||||
无法描述“不缓存”的语义
|
||||
|
||||
|
||||
比如,一般浏览器为了提高性能,往往会自动在当次会话中缓存某些MINE类型的资源,这会造成设计者不希望缓存的资源无法被及时更新。而在HTTP/1.0的设计中,Expires并没有考虑这方面的需求,导致无法强制浏览器不允许缓存某个资源。
|
||||
|
||||
所以,以前为了实现这类功能,我们通常不得不使用脚本,或者手工在资源后面增加时间戳(如“xx.js?t=1586359920”“xx.jpg?t=1586359350”)来保证每次资源都会重新获取。
|
||||
|
||||
不过,关于“不缓存”的语义,在HTTP/1.0中其实是预留了“Pragma: no-cache”来表达的,但在HTTP/1.0中,并没有确切地描述Pragma参数的具体行为,随后它就被HTTP/1.1中出现过的Cache-Control给替代了。
|
||||
|
||||
现在,尽管主流的浏览器也通常都会支持Pragma,但它的行为仍然是不确定的,实际上并没有什么使用价值。而Cache-Control的出现,进一步压缩了Pragma的生存空间。所以接下来,我们就一起来看看,它是如何支持强制缓存机制的实现的。
|
||||
|
||||
第二类:Cache-Control
|
||||
|
||||
Cache-Control是HTTP/1.1协议中定义的强制缓存Header,它的语义比起Expires来说就丰富了很多。而如果Cache-Control和Expires同时存在,并且语义存在冲突(比如Expires与max-age / s-maxage冲突)的话,IETF规定必须以Cache-Control为准。
|
||||
|
||||
同样这里,我们也看看Cache-Control的使用示例:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: max-age=600
|
||||
|
||||
|
||||
那么,你能看到,这里的示例中使用的参数是max-age。实际上,在客户端的请求Header或服务器的响应Header中,Cache-Control都可以存在,它定义了一系列的参数,并且允许自行扩展(即不在标准RFC协议中,由浏览器自行支持的参数)。Cache-Control标准的参数主要包括6种,下面我就带你一一了解下。
|
||||
|
||||
|
||||
max-age和s-maxage
|
||||
|
||||
|
||||
在前面的示例中,你会发现max-age后面跟随了一个数字,它是以秒为单位的,表明相对于请求时间(在Date Header中会注明请求时间)多少秒以内,缓存是有效的,资源不需要重新从服务器中获取。这个相对时间,就避免了Expires中,采用的绝对时间可能受客户端时钟影响的问题。
|
||||
|
||||
另一个类似的参数是s-maxage,其中的“s”是“Share”的缩写,意味着“共享缓存”的有效时间,即允许被CDN、代理等持有的缓存有效时间,这个参数主要是用来提示CDN这类服务器如何对缓存进行失效。
|
||||
|
||||
|
||||
public和private
|
||||
|
||||
|
||||
这一类参数表明了是否涉及到用户身份的私有资源。如果是public,就意味着资源可以被代理、CDN等缓存;如果是private,就意味着只能由用户的客户端进行私有缓存。
|
||||
|
||||
|
||||
no-cache和no-store
|
||||
|
||||
|
||||
no-cache表明该资源不应该被缓存,哪怕是同一个会话中对同一个URL地址的请求,也必须从服务端获取,从而令强制缓存完全失效(但此时的协商缓存机制依然是生效的);no-store不强制会话中是否重复获取相同的URL资源,但它禁止浏览器、CDN等以任何形式保存该资源。
|
||||
|
||||
|
||||
no-transform
|
||||
|
||||
|
||||
no-transform禁止资源以任何形式被修改。比如,某些CDN、透明代理支持自动GZip压缩图片或文本,以提升网络性能,而no-transform就禁止了这样的行为,它要求Content-Encoding、Content-Range、Content-Type均不允许进行任何形式的修改。
|
||||
|
||||
|
||||
min-fresh和only-if-cached
|
||||
|
||||
|
||||
这两个参数是仅用于客户端的请求Header。min-fresh后续跟随了一个以秒为单位的数字,用于建议服务器能返回一个不少于该时间的缓存资源(即包含max-age且不少于min-fresh的数字);only-if-cached表示服务器希望客户端不要发送请求,只使用缓存来进行响应,若缓存不能命中,就直接返回503/Service Unavailable错误。
|
||||
|
||||
|
||||
must-revalidate和proxy-revalidate
|
||||
|
||||
|
||||
must-revalidate表示在资源过期后,一定要从服务器中进行获取,即超过了max-age的时间后,就等同于no-cache的行为;proxy-revalidate用于提示代理、CDN等设备资源过期后的缓存行为,除对象不同外,语义与must-revalidate完全一致。
|
||||
|
||||
好了,现在你应该就已经理解了强制缓存的实现机制了。但是,强制缓存是基于时效性的,无论是人还是服务器,在大多数情况下,其实都没有什么把握去承诺某项资源多久不会发生变化。
|
||||
|
||||
所以,接下来我就要给你介绍另一种基于变化检测的缓存机制,也就是协商缓存。它在处理一致性问题上,比强制缓存会有更好的表现。不过它需要一次变化检测的交互开销,在性能上就会略差一些。
|
||||
|
||||
协商缓存的两种变动检查机制
|
||||
|
||||
那么,在开始了解协商缓存的实现机制之前,你要先注意一个地方,就是在HTTP中,协商缓存与强制缓存并没有互斥性,这两套机制是并行工作的。
|
||||
|
||||
比如说,当强制缓存存在时,客户端可以直接从强制缓存中返回资源,无需进行变动检查;而当强制缓存超过时效,或者被禁止(no-cache / must-revalidate),协商缓存也仍然可以正常工作。
|
||||
|
||||
协商缓存有两种变动检查机制,一种是根据资源的修改时间进行检查,另一种是根据资源唯一标识是否发生变化来进行检查。它们都是靠一组成对出现的请求、响应Header来实现的。
|
||||
|
||||
根据资源的修改时间进行检查
|
||||
|
||||
我们先来看看根据资源的修改时间进行检查的协商缓存机制。它的语义中包含了两种标准参数:Last-Modified和If-Modified-Since。
|
||||
|
||||
Last-Modified是服务器的响应Header,用来告诉客户端这个资源的最后修改时间。
|
||||
|
||||
而对于带有这个Header的资源,当客户端需要再次请求时,会通过If-Modified-Since,把之前收到的资源最后修改时间发送回服务端。
|
||||
|
||||
如果此时,服务端发现资源在该时间后没有被修改过,就只要返回一个304/Not Modified的响应即可,无需附带消息体,从而达到了节省流量的目的:
|
||||
|
||||
HTTP/1.1 304 Not Modified
|
||||
Cache-Control: public, max-age=600
|
||||
Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT
|
||||
|
||||
|
||||
而如果此时,服务端发现资源在该时间之后有变动,就会返回200/OK的完整响应,在消息体中包含最新的资源。
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: public, max-age=600
|
||||
Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT
|
||||
|
||||
Content
|
||||
|
||||
|
||||
根据资源唯一标识是否发生变化来进行检查
|
||||
|
||||
好,我们再来看看“根据资源唯一标识是否发生变化来进行检查”的协商缓存机制。它的语义中也包含了两种标准参数:Etag和If-None-Match。
|
||||
|
||||
Etag是服务器的响应Header,用于告诉客户端这个资源的唯一标识。HTTP服务器可以根据自己的意愿,来选择如何生成这个标识,比如Apache服务器的Etag值,就默认是对文件的索引节点(INode)、大小和最后修改时间进行哈希计算后而得到的。
|
||||
|
||||
然后,对于带有这个Header的资源,当客户端需要再次请求时,就会通过If-None-Match,把之前收到的资源唯一标识发送回服务端。
|
||||
|
||||
如果此时,服务端计算后发现资源的唯一标识与上传回来的一致,就说明资源没有被修改过,同样也只需要返回一个304/Not Modified的响应即可,无需附带消息体,达到节省流量的目的:
|
||||
|
||||
HTTP/1.1 304 Not Modified
|
||||
Cache-Control: public, max-age=600
|
||||
ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"
|
||||
|
||||
|
||||
而如果此时,服务端发现资源的唯一标识有变动,也一样会返回200/OK的完整响应,在消息体中包含最新的资源。
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: public, max-age=600
|
||||
ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"
|
||||
|
||||
Content
|
||||
|
||||
|
||||
另外,我还想强调的是,Etag是HTTP中一致性最强的缓存机制。
|
||||
|
||||
为什么会这么说呢?我直接给你举个例子。
|
||||
|
||||
前面我提到的Last-Modified参数,它标注的最后修改只能精确到秒级,而如果某些文件在一秒钟以内被修改多次的话,它就不能准确标注文件的修改时间了;又或者,如果某些文件会被定期生成,可能内容上并没有任何变化,但Last-Modified却改变了,导致文件无法有效使用缓存。而这些情况,Last-Modified都有可能产生资源一致性的问题,只能使用Etag解决。
|
||||
|
||||
但是,Etag又是HTTP中性能最差的缓存机制。这个“最差”体现在每次请求时,服务端都必须对资源进行哈希计算,这比起简单获取一下修改时间,开销要大了很多。
|
||||
|
||||
所以,Etag和Last-Modified是允许一起使用的,服务器会优先验证Etag,在Etag一致的情况下,再去对比Last-Modified,这是为了防止有一些HTTP服务器没有把文件修改日期纳入哈希范围内。
|
||||
|
||||
HTTP的内容协商机制
|
||||
|
||||
那到这里为止,HTTP的协商缓存机制,就已经能很好地处理通过URL获取单个资源的场景了。不过你可能要问了:为什么要强调“单个资源”呢?
|
||||
|
||||
我们知道,在HTTP协议的设计中,一个URL地址是有可能提供多份不同版本的资源的,比如说,一段文字的不同语言版本,一个文件的不同编码格式版本,一份数据的不同压缩方式版本,等等。因此针对请求的缓存机制,也必须能够提供对应的支持。
|
||||
|
||||
所以,针对这种情况,HTTP协议设计了以Accept*(Accept、Accept-Language、Accept-Charset、Accept-Encoding)开头的一套请求Header,以及对应的以Content-*(Content-Language、Content-Type、Content-Encoding)开头的响应Header。这些Headers被称为HTTP的内容协商机制。
|
||||
|
||||
那么,与之对应的,对于一个URL能够获取多个资源的场景中,缓存同样也需要有明确的标识来获知,它要根据什么内容来对同一个URL返回给用户正确的资源。这个就是Vary Header的作用,Vary后面应该跟随一组其他Header的名字,比如说:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept, User-Agent
|
||||
|
||||
|
||||
这里你要知道,这个响应的含义是应该根据MINE类型和浏览器类型来缓存资源,另外服务端在获取资源时,也需要根据请求Header中对应的字段,来筛选出适合的资源版本。
|
||||
|
||||
根据约定,协商缓存不仅可以在浏览器的地址输入、页面链接跳转、新开窗口、前进、后退中生效,而且在用户主动刷新页面(F5)时也同样是生效的。只有用户强制刷新(Ctrl+F5)或者明确禁用缓存(比如在DevTools中设定)时才会失效,此时客户端向服务端发出的请求会自动带有“Cache-Control: no-cache”。
|
||||
|
||||
小结
|
||||
|
||||
HTTP协议以“无状态”作为基本的交互原则,那么由此而来的资源重复访问问题,就需要通过网络链路中的缓存来解决了。现在你也已经知道,客户端缓存具体包括了“状态缓存”、“强制缓存”和“协商缓存”三类。
|
||||
|
||||
这节课,我们详细分析了强制缓存和协商缓存的工作原理。利用好客户端的缓存,能够节省大量网络流量,这是为后端系统分流,以实现更高并发的第一步。
|
||||
|
||||
一课一思
|
||||
|
||||
除了在HTTP协议中规定的缓存机制,你还使用过其他客户端的缓存方式吗?比如说,浏览器WebAPI中的Cache Storage和Application Cache,或者你是否通过LocalStorage、SessionStorage、IndexedDB、WebSQL等等去缓存信息?
|
||||
|
||||
欢迎在留言区分享你的做法和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
277
专栏/周志明的架构课/18_传输链路,优化HTTP传输速度的小技巧.md
Normal file
277
专栏/周志明的架构课/18_传输链路,优化HTTP传输速度的小技巧.md
Normal file
@@ -0,0 +1,277 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 _ 传输链路,优化HTTP传输速度的小技巧
|
||||
你好,我是周志明。
|
||||
|
||||
在经过了客户端缓存的节流和DNS服务的解析指引以后,程序发出的请求流量就正式离开了客户端,踏上以服务器为目的地的旅途了。而这个过程就是我们今天这节课要讨论的主角:传输链路。
|
||||
|
||||
以优化链路传输为目的的前端设计原则未来或许不再适用
|
||||
|
||||
可能不少人的第一直觉都会认为,传输链路是完全不受开发者控制的因素,觉得网络路由跳点的数量、运营商铺设线路的质量,已经决定了线路带宽的大小、速率的高低。不过事实并非如此,程序发出的请求能否与应用层、传输层协议提倡的方式相匹配,对传输的效率也会有非常大的影响。
|
||||
|
||||
最容易体现出这点的,就是那些前端网页的优化技巧。我们只要简单搜索一下,就能找到很多以优化链路传输为目的的前端设计原则,比如经典的雅虎YSlow-23条规则中,就涵盖了很多与传输相关的内容。
|
||||
|
||||
下面我来给你简单举几个例子。
|
||||
|
||||
|
||||
Minimize HTTP Requests
|
||||
|
||||
|
||||
即减少请求数量:对于客户端发出的请求,服务器每次都需要建立通信链路进行数据传输,这些开销很昂贵,所以减少请求的数量,就可以有效地提高访问性能。如果你是做前端开发的,那你可能就听说过下面这几种减少请求数量的手段:
|
||||
|
||||
|
||||
a. 雪碧图(CSS Sprites)-
|
||||
b. CSS、JS文件合并/内联(Concatenation / Inline)-
|
||||
c. 分段文档(Multipart Document)-
|
||||
d. 媒体(图片、音频)内联(Data Base64 URI)-
|
||||
e. 合并Ajax请求(Batch Ajax Request)-
|
||||
f. ……
|
||||
|
||||
|
||||
|
||||
Split Components Across Domains
|
||||
|
||||
|
||||
即扩大并发请求数:现代浏览器(Chrome、Firefox)一般可以为每个域名支持6个(IE为8-13个)并发请求。如果你希望更快地加载大量图片或其他资源,就需要进行域名分片(Domain Sharding),将图片同步到不同主机或者同一个主机的不同域名上。
|
||||
|
||||
|
||||
GZip Components
|
||||
|
||||
|
||||
即启用压缩传输:启用压缩能够大幅度减少需要在网络上传输内容的大小,节省网络流量。
|
||||
|
||||
|
||||
Avoid Redirects
|
||||
|
||||
|
||||
即避免页面重定向:当页面发生了重定向,就会延迟整个文档的传输。在HTML文档到达之前,页面中不会呈现任何东西,会降低用户的体验。
|
||||
|
||||
|
||||
Put Stylesheets at the Top,Put Scripts at the Bottom
|
||||
|
||||
|
||||
即按重要性调节资源优先级:将重要的、马上就要使用的、对客户端展示影响大的资源,放在HTML的头部,以便优先下载。
|
||||
|
||||
|
||||
……
|
||||
|
||||
|
||||
这些原则在今天暂且仍算得上是有一定价值,但如果在若干年后的未来再回头看它们,大概率其中的多数原则已经成了奇技淫巧,有些甚至成了反模式。
|
||||
|
||||
我为什么这么说呢?这是因为HTTP协议还在持续发展,从上世纪90年代的HTTP/1.0和HTTP/1.1,到2015年发布的HTTP/2,再到2019年的HTTP/3,由于HTTP协议本身的变化,造成了“适合HTTP传输的请求”的特征也在不断变化。
|
||||
|
||||
那么接下来,我们就来看看这里所说的特征变化指的都是什么。
|
||||
|
||||
连接数优化
|
||||
|
||||
我们知道,HTTP(特指HTTP/3以前)是以TCP为传输层的应用层协议,但HTTP over TCP这种搭配,只能说是TCP目前在互联网中的统治性地位所造就的结果,而不能说它们两者配合工作就是合适的。
|
||||
|
||||
为啥呢?一方面,你可以回想一下,平常你在上网的时候,平均每个页面停留的时间都有多长?然后你也可以大概估算一下每个页面中包含的资源(HTML、JS、CSS、图片等)数量,由此你其实就可以大致总结出HTTP传输对象的主要特征了,那就是数量多、时间短、资源小、切换快。
|
||||
|
||||
另一方面,TCP协议要求必须在三次握手完成之后才能开始数据传输,这是一个可能高达“百毫秒”为计时尺度的事件;另外,TCP还有慢启动的特性,这就会导致通信双方在刚刚建立连接时的传输速度是最低的,后面再逐步加速直至稳定。
|
||||
|
||||
由于TCP协议本身是面向长时间、大数据传输来设计的,所以只有在一段较长的时间尺度内,TCP协议才能展现出稳定性和可靠性的优势,不会因为建立连接的成本太高,成为了使用瓶颈。
|
||||
|
||||
所以我才说,HTTP over TCP这种搭配在目标特征上确实是有矛盾的,以至于HTTP/1.x时代,大量短而小的TCP连接导致了网络性能的瓶颈。
|
||||
|
||||
开发Tricks的使用困境
|
||||
|
||||
那么,为了缓解HTTP与TCP之间的矛盾,聪明的程序员们一面致力于减少发出的请求数量,另一面也在致力于增加客户端到服务端的连接数量。这就是我在前面提到的Yslow规则中,“Minimize HTTP Requests”与“Split Components Across Domains”两条优化措施的根本依据所在。
|
||||
|
||||
由此可见,通过前端开发者的各种Tricks,确实能够减少TCP连接数量的消耗,这是有数据统计作为支撑的。
|
||||
|
||||
HTTP Archive就对最近5年来数百万个URL地址进行了采样,并得出了一个结论:页面平均请求没有改变的情况下(桌面端下降3.8%,移动端上升1.4%),TCP连接正在持续且幅度较大地下降(桌面端下降36.4%,移动端下降28.6%)。我们一起来具体看看这个变化:
|
||||
|
||||
|
||||
|
||||
HTTP平均请求数量,70余个,没有明显变化
|
||||
|
||||
|
||||
|
||||
TCP连接数量,约15个,有明显下降趋势
|
||||
|
||||
但是,这些开发Tricks除了可以节省TCP连接以外,其实也给我们带来了不少的副作用,比如说:
|
||||
|
||||
|
||||
如果你用CSS Sprites合并了多张图片,就意味着在任何场景下,哪怕你只用到了其中一张小图,也必须要完整地加载整个大图片;或者哪怕只有一张小图需要修改,也都会导致整个大图的缓存失效。类似的,样式、脚本等其他文件的合并,也会造成同样的问题。
|
||||
如果你使用了媒体内嵌,除了要承受Base64编码导致传输容量膨胀1/3的代价以外(Base64以8 bit表示6 bit数据),也会无法有效利用缓存。
|
||||
如果你合并了异步请求,就会导致所有请求的返回时间,都要受最慢的那个请求拖累,页面整体的响应速度会下降。
|
||||
如果你把图片放到了不同子域下面,将会导致更大的DNS解析负担,而且浏览器对两个不同子域下的同一图片必须持有两份缓存,这也使得缓存效率下降。
|
||||
……
|
||||
|
||||
|
||||
所以我们也不难看出,一旦需要使用者通过各种Tricks,来解决基于技术根基而出现的各种问题时,就会导致TA再也没办法摆脱“两害相权取其轻”的困境。否则,这就不是Tricks,而是会成为一种标准的设计模式了。
|
||||
|
||||
连接复用技术的优势和缺陷
|
||||
|
||||
实际上,HTTP的设计者们也不是没有尝试过在协议层面去解决连接成本过高的问题,即使是HTTP协议的最初版本(指HTTP/1.0,忽略非正式的HTTP/0.9版本),就已经支持(HTTP/1.0中不是默认开启的,HTTP/1.1中变为默认)连接复用技术了,也就是今天我们所熟知的持久连接(Persistent Connection),或者叫连接Keep-Alive机制。
|
||||
|
||||
它的原理是,让客户端对同一个域名长期持有一个或多个不会用完即断的TCP连接。典型做法是在客户端维护一个FIFO队列,每次取完数据之后的一段时间内,不自动断开连接,以便获取下一个资源时可以直接复用,避免创建TCP连接的成本。
|
||||
|
||||
但是,连接复用技术依然是不完美的,最明显的副作用就是“队首阻塞”(Head-of-Line Blocking)问题。
|
||||
|
||||
我们来设想一下这样的场景:浏览器有10个资源需要从服务器中获取,这个时候它把10个资源放入队列,入列顺序只能是按照浏览器预见这些资源的先后顺序来决定。但是,如果这10个资源中的第1个就让服务器陷入了长时间运算状态,会发生怎样的状况呢?
|
||||
|
||||
答案是,当它的请求被发送到服务端之后,服务端就会开始计算,而在运算结果出来之前,TCP连接中并没有任何数据返回,此时后面的9个资源都必须阻塞等待。
|
||||
|
||||
虽然说服务端可以并行处理另外9个请求,比如第1个是复杂运算请求,消耗CPU资源;第2个是数据库访问,消耗数据库资源;第3个是访问某张图片,消耗磁盘I/O资源,等等,这就很适合并行。
|
||||
|
||||
但问题是,这些请求的处理结果无法及时发回给客户端,服务端也不能哪个请求先完成就返回哪个,更不可能把所有要返回的资源混杂到一起交叉传输。这是因为,只使用一个TCP连接来传输多个资源的话,一旦顺序乱了,客户端就很难区分清楚哪个数据包归属哪个资源了。
|
||||
|
||||
因此,在2014年IETF发布的RFC 7230中,提出了名为“HTTP管道”(HTTP Pipelining)复用技术,试图在HTTP服务器中也建立类似客户端的FIFO队列,让客户端一次性将所有要请求的资源名单全部发给服务端,由服务端来安排返回顺序,管理传输队列。
|
||||
|
||||
不过,无论队列维护是在服务端还是在客户端,其实都无法完全避免队首阻塞的问题。这是因为很难在真正传输之前,完全精确地评估出传输队列中每一项的时间成本,但由于服务端能够较为准确地评估资源消耗情况,这样确实能更紧凑地安排资源传输,保证队列中两项工作之间尽量减少空隙,甚至可能做到并行化传输,从而提升链路传输的效率。
|
||||
|
||||
可是,因为HTTP管道需要多方共同支持,协调起来相当复杂,因此推广得并不算成功。
|
||||
|
||||
后来,队首阻塞问题一直持续到HTTP/2发布后,才算是被比较完美地解决了。
|
||||
|
||||
解决方案:HTTP/2的多路复用技术
|
||||
|
||||
在HTTP/1.x中,HTTP请求就是传输过程中最小粒度的信息单位了,所以如果将多个请求切碎,再混杂在一块传输,客户端势必难以分辨重组出有效信息。
|
||||
|
||||
而在HTTP/2中,帧(Frame)才是最小粒度的信息单位,它可以用来描述各种数据,比如请求的Headers、Body,或者是用来做控制标识,如打开流、关闭流。
|
||||
|
||||
这里我说的流(Stream),是一个逻辑上的数据通道概念,每个帧都附带有一个流ID,以标识这个帧属于哪个流。这样,在同一个TCP连接中,传输的多个数据帧就可以根据流ID轻易区分出来,在客户端就能毫不费力地将不同流中的数据,重组出不同HTTP请求和响应报文来。
|
||||
|
||||
这项设计是HTTP/2的最重要的技术特征之一,被称为HTTP/2 多路复用(HTTP/2 Multiplexing)技术。
|
||||
|
||||
|
||||
|
||||
HTTP/2的多路复用
|
||||
|
||||
这样,有了多路复用的支持,HTTP/2就可以对每个域名只维持一个TCP连接(One Connection Per Origin),来以任意顺序传输任意数量的资源。这样就既减轻了服务器的连接压力,开发者也不用去考虑域名分片这种事情,来突破浏览器对每个域名最多6个连接数的限制了。
|
||||
|
||||
而更重要的是,没有了TCP连接数的压力,客户端就不需要再刻意压缩HTTP请求了,所有通过合并、内联文件(无论是图片、样式、脚本)以减少请求数的需求都不再成立,甚至反而是徒增副作用的反模式。
|
||||
|
||||
当然,我说这是反模式,可能还会有一些前端开发者不同意,觉得HTTP请求少一些总是好的。减少请求数量,最起码还减少了传输中耗费的Headers。
|
||||
|
||||
这里我们必须要先承认一个事实,在HTTP传输中,Headers占传输成本的比重是相当地大,对于许多小资源,甚至可能出现Headers的容量比Body的还要大,以至于在HTTP/2中必须专门考虑如何进行Header压缩的问题。
|
||||
|
||||
但实际上,有这样几个因素,就决定了即使是通过合并资源文件来减少请求数,对节省Headers成本来说,也并没有太大的帮助:
|
||||
|
||||
|
||||
Header的传输成本在Ajax(尤其是只返回少量数据的请求)请求中可能是比重很大的开销,但在图片、样式、脚本这些静态资源的请求中,一般并不占主要比重。
|
||||
|
||||
在HTTP/2中,Header压缩的原理是基于字典编码的信息复用,简而言之是同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好。所以,HTTP/2是单域名单连接的机制,合并资源和域名分片反而对性能提升不利。
|
||||
|
||||
与HTTP/1.x相反,HTTP/2本身反而变得更适合传输小资源了,比如传输1000张10K的小图,HTTP/2要比HTTP/1.x快,但传输10张1000K的大图,则应该HTTP/1.x会更快。这其中有TCP连接数量(相当于多点下载)的影响,更多的是由于TCP协议可靠传输机制导致的,一个错误的TCP包会导致所有的流都必须等待这个包重传成功,而这个问题就是HTTP/3.0要解决的目标了。因此,把小文件合并成大文件,在HTTP/2下是毫无好处的。
|
||||
|
||||
|
||||
传输压缩
|
||||
|
||||
好,了解了TCP连接数的各种优化机制之后,我们再来讨论下,在链路优化中除缓存、连接之外的另一个重要话题:压缩。同时,这也可以解决我们之前遗留的一个问题:如何不以断开TCP连接为标志,来判断资源已传输完毕?
|
||||
|
||||
HTTP很早就支持了GZip压缩,因为HTTP传输的主要内容,比如HTML、CSS、Script等,主要是文本数据,因此对于文本数据启用压缩的收益是非常高的,传输数据量一般会降至原有的20%左右。而对于那些不适合压缩的资源,Web服务器则能根据MIME类型,来自动判断是否对响应进行压缩。这样,对于已经采用过压缩算法存储的资源,比如JPEG、PNG图片,就不会被二次压缩,空耗性能了。
|
||||
|
||||
不过,大概没有多少人想过,压缩其实跟我们前面提到的,用于节约TCP的持久连接机制是存在冲突的。
|
||||
|
||||
在网络时代的早期,服务器的处理能力还很薄弱,为了启用压缩,会把静态资源预先压缩为.gz文件的形式给存放起来。当客户端可以接受压缩版本的资源时(请求的Header中包含Accept-Encoding: gzip),就返回压缩后的版本(响应的Header中包含Content-Encoding: gzip),否则就返回未压缩的原版。这种方式被称为“静态预压缩”(Static Precompression)。
|
||||
|
||||
而现代的Web服务器处理能力有了大幅提升,已经没有人再采用这种麻烦的预压缩方式了,都是由服务器对符合条件的请求,在即将输出时进行“即时压缩”(On-The-Fly Compression),整个压缩过程全部在内存的数据流中完成,不必等资源压缩完成再返回响应,这样可以显著提高“首字节时间”(Time To First Byte,TTFB),改善Web性能体验。
|
||||
|
||||
而这个过程中唯一不好的地方,就是服务器再也没有办法给出Content-Length这个响应Header了。因为输出Header时,服务器还不知道压缩后资源的确切大小。
|
||||
|
||||
那看到这里,你想明白即时压缩与持久连接的冲突在哪了吗?
|
||||
|
||||
实际上,持久连接机制不再依靠TCP连接是否关闭,来判断资源请求是否结束了。它会重用同一个连接,以便向同一个域名请求多个资源。这样,客户端就必须要有除了关闭连接之外的其他机制,来判断一个资源什么时候才算是传递完毕。
|
||||
|
||||
这个机制最初(在HTTP/1.0时)就只有Content-Length,即靠着请求Header中明确给出资源的长度,传输到达该长度即宣告一个资源的传输已经结束。不过,由于启用即时压缩后就无法给出Content-Length了,如果是HTTP/1.0的话,持久连接和即时压缩只能二选其一。事实上,在HTTP/1.0中这两者都支持,却默认都是不启用的。
|
||||
|
||||
另外,依靠Content-Length来判断传输结束的缺陷,不仅只有即时压缩这一种场景,对于动态内容(Ajax、PHP、JSP等输出)等应用场景,服务器也同样无法事先得知Content-Length。
|
||||
|
||||
不过,HTTP/1.1版本中已经修复了这个缺陷,并增加了另一种“分块传输编码”(Chunked Transfer Encoding)的资源结束判断机制,彻底解决了Content-Length与持久连接的冲突问题。
|
||||
|
||||
分块编码的工作原理相当简单:在响应Header中加入“Transfer-Encoding: chunked”之后,就代表这个响应报文将采用分块编码。此时,报文中的Body需要改为用一系列“分块”来传输。每个分块包含十六进制的长度值和对应长度的数据内容,长度值独占一行,数据从下一行开始。最后以一个长度值为0的分块,来表示资源结束。
|
||||
|
||||
给你举个具体的例子(例子来自于维基百科,为便于观察,只分块,未压缩):
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Sat, 11 Apr 2020 04:44:00 GMT
|
||||
Transfer-Encoding: chunked
|
||||
Connection: keep-alive
|
||||
|
||||
25
|
||||
This is the data in the first chunk
|
||||
|
||||
1C
|
||||
and this is the second one
|
||||
|
||||
3
|
||||
con
|
||||
|
||||
8
|
||||
sequence
|
||||
|
||||
0
|
||||
|
||||
|
||||
根据分块长度就可以知道,前两个分块包含显式的回车换行符(CRLF,即\r\n字符)
|
||||
|
||||
"This is the data in the first chunk\r\n" (37 字符 => 十六进制: 0x25)
|
||||
"and this is the second one\r\n" (28 字符 => 十六进制: 0x1C)
|
||||
"con" (3 字符 => 十六进制: 0x03)
|
||||
"sequence" (8 字符 => 十六进制: 0x08)
|
||||
|
||||
|
||||
所以解码后的内容为:
|
||||
|
||||
This is the data in the first chunk
|
||||
and this is the second one
|
||||
consequence
|
||||
|
||||
|
||||
另外,这里你要知道的是,一般来说,Web服务器给出的数据分块大小应该(但并不强制)是一致的,而不是像这个例子一样随意。
|
||||
|
||||
HTTP/1.1通过分块传输解决了即时压缩与持久连接并存的问题,到了HTTP/2,由于多路复用和单域名单连接的设计,已经不需要再刻意去强调持久连接机制了,但数据压缩仍然有节约传输带宽的重要价值。
|
||||
|
||||
快速UDP网络连接
|
||||
|
||||
OK,那么到这里,我们还需要明确一件事情,就是HTTP是应用层协议,而不是传输层协议,它的设计原本并不应该过多地考虑底层的传输细节。从职责上来讲,持久连接、多路复用、分块编码这些能力,已经或多或少超过了应用层的范畴。
|
||||
|
||||
所以说,要想从根本上改进HTTP,就必须直接替换掉HTTP over TCP的根基,即TCP传输协议,这便是最新一代HTTP/3协议的设计重点。
|
||||
|
||||
推动替换TCP协议的先驱者并不是IETF,而是Google公司。目前,世界上只有Google公司具有这样的能力,这并不是因为Google的技术实力雄厚,而是由于它同时持有着占浏览器市场70%份额的Chrome浏览器,与占移动领域半壁江山的Android操作系统。
|
||||
|
||||
2013年,Google在它的服务器(如Google.com、YouTube.com等)及Chrome浏览器上,同时启用了名为“快速UDP网络连接”(Quick UDP Internet Connections,QUIC)的全新传输协议。
|
||||
|
||||
在2015年,Google将QUIC提交给了IETF,并在IETF的推动下,对QUIC进行重新规范化(为以示区别,业界习惯将此前的版本称为gQUIC,规范化后的版本称为iQUIC),使其不仅能满足HTTP传输协议,日后还能支持SMTP、DNS、SSH、Telnet、NTP等多种其他上层协议。
|
||||
|
||||
2018年末,IETF正式批准了HTTP over QUIC使用HTTP/3的版本号,将其确立为最新一代的互联网标准。
|
||||
|
||||
那么,你从QUIC的名字上就能看出,它会以UDP协议作为基础。UDP协议没有丢包自动重传的特性,因此QUIC的可靠传输能力并不是由底层协议提供的,而是完全由自己来实现。由QUIC自己实现的好处是能对每个流能做单独的控制,如果在一个流中发生错误,协议栈仍然可以独立地继续为其他流提供服务。
|
||||
|
||||
这个特性对提高易出错链路的性能方面非常有用。因为在大多数情况下,TCP协议接到数据包丢失或损坏通知之前,可能已经收到了大量的正确数据,但是在纠正错误之前,其他的正常请求都会等待甚至被重发。这也是前面我在讲连接数优化的时候,提到HTTP/2没能解决传输大文件慢的根本原因。
|
||||
|
||||
此外,QUIC的另一个设计目标是面向移动设备的专门支持。
|
||||
|
||||
以前TCP、UDP传输协议在设计的时候,根本不可能设想到今天移动设备盛行的场景,因此肯定不会有任何专门的支持。而QUIC在移动设备上的优势就体现在网络切换时的响应速度上,比如当移动设备在不同WiFi热点之间切换,或者从WiFi切换到移动网络时,如果使用TCP协议,现存的所有连接都必定会超时、中断,然后根据需要重新创建。这个过程会带来很高的延迟,因为超时和重新握手都需要大量的时间。
|
||||
|
||||
为此,QUIC提出了连接标识符的概念,该标识符可以唯一地标识客户端与服务器之间的连接,而无需依靠IP地址。这样,在切换网络后,只需向服务端发送一个包含此标识符的数据包,就可以重用既有的连接。因为即使用户的IP地址发生了变化,原始连接标识符依然是有效的。
|
||||
|
||||
到现在,无论是TCP协议还是HTTP协议,都已经存在了数十年的时间。它们积累了大量用户的同时,也承载了很重的技术惯性。就算是Google和IETF,要是想把HTTP从TCP迁移出去,也不是件容易的事儿。最主要的一个问题就是,互联网基础设施中的许多中间设备,都只面向TCP协议去建造,也只对UDP做很基础的支持,有的甚至会完全阻止UDP的流量。
|
||||
|
||||
所以,Google在Chromium的网络协议栈中,同时启用了QUIC和传统TCP连接,并在QUIC连接失败时,能以零延迟回退到TCP连接,尽可能让用户无感知地、逐步地扩大QUIC的使用面。
|
||||
|
||||
根据W3Techs的数据,截至2020年10月,全球已经有48.9%的网站支持了HTTP/2协议,按照维基百科中的记录,这个数字在2019年6月份的时候,还只是36.5%。另外在HTTP/3方面,今天也已经有了7.2%的使用比例。
|
||||
|
||||
由此我们可以肯定地说,目前网络链路传输这个领域正处于新旧交替的时代,许多既有的设备、程序、知识,都会在未来几年的时间里出现重大更新。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我们一起了解了HTTP传输的优化技巧。HTTP的意思就是超文本传输协议,但它并不是只将内容顺利传输到客户端就算完成任务了,其中,如何做到高效、无状态也是很重要的目标。
|
||||
|
||||
另外你还要记住的是,在HTTP/2之前,要想在应用一侧优化传输,就必须要同时在其他方面付出相应的成本,而HTTP/2中的多路复用、头压缩等改进项,就从根本上给出了传输优化的解决方案。
|
||||
|
||||
一课一思
|
||||
|
||||
HTTP/2和HTTP/3已分别在2015年、2018年末成为正式标准,你参与的项目是否有使用到这些新的标准呢?对此你有什么思考和发现?
|
||||
|
||||
欢迎给我留言,分享你的看法。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
219
专栏/周志明的架构课/19_如何利用内容分发网络来提高网络性能?.md
Normal file
219
专栏/周志明的架构课/19_如何利用内容分发网络来提高网络性能?.md
Normal file
@@ -0,0 +1,219 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 _ 如何利用内容分发网络来提高网络性能?
|
||||
你好,我是周志明。
|
||||
|
||||
前面几讲中,我给你介绍了客户端缓存、域名解析、链路优化这三种与客户端关系较密切的传输优化机制。这节课,我们来讨论一个针对这三种机制的经典综合运用案例:内容分发网络(CDN,Content Distribution Network或Content Delivery Network)。
|
||||
|
||||
内容分发网络是一种十分古老的应用,你应该也听说过它的名字,多少知道它是用来做什么的。简单理解的话,CDN其实就是做“内容分销”工作的。
|
||||
|
||||
我给你举个例子吧。假设,我们把某个互联网系统比喻为一家开门营业的企业,那内容分发网络就是它遍布世界各地的分支销售机构。如果一位客户要买一块CPU,我们要是订机票飞到美国Intel总部去采购,那肯定是不合适的,到本地电脑城找个装机铺才是正常人的做法。所以在这个场景里,内容分发网络就相当于电脑城那吆喝着CPU三十块钱一斤的本地经销商。
|
||||
|
||||
然后,内容分发网络又是一种十分透明的应用,一般不需要我们参与它的工作过程。所以我想,如果你没有自己亲身使用和专门研究过,那可能就不太清楚它是如何为互联网站点分流的,也不太会注意到它的工作原理是什么。
|
||||
|
||||
实际上,内容分发网络的工作过程,主要涉及到路由解析、内容分发、负载均衡和它所能支持的应用内容四个方面。今天这节课,我们先来了解内容分发网络可以解决哪些网络传输问题,也就是先着重探讨除负载均衡以外的其他三个方面的工作。在下一讲中,我会专门跟你讨论负载均衡的内容。
|
||||
|
||||
好,如果忽略其他影响服务质量的因素,仅从网络传输的角度来看,一个互联网系统的速度快慢,主要取决于以下四点因素:
|
||||
|
||||
|
||||
网站服务器接入网络运营商的链路所能提供的出口带宽。
|
||||
用户客户端接入网络运营商的链路所能提供的入口带宽。
|
||||
从网站到用户之间,经过的不同运营商之间互联节点的带宽。一般来说,两个运营商之间只有固定的若干个点是互通的,所有跨运营商之间的交互都要经过这些点。
|
||||
从网站到用户之间的物理链路传输时延。你要是爱打游戏的话,应该就很清楚了,延迟(Ping值)通常比带宽更重要。
|
||||
|
||||
|
||||
以上四个网络问题,除了第二个只能由用户掏腰包,装个更好的宽带才能够解决之外,其余三个都能通过内容分发网络来改善。
|
||||
|
||||
所以说,一个运作良好的内容分发网络,能为互联网系统解决跨运营商、跨地域物理距离所导致的时延问题,也能给网站流量带宽起到分流、减负的作用。
|
||||
|
||||
举个例子,如果没有遍布全国乃至全世界的阿里云CDN网络支持,哪怕把整个杭州所有网民的上网权利都剥夺了,把带宽全部让给淘宝的机房,恐怕也撑不住双十一全国甚至是全球用户的疯狂围殴。
|
||||
|
||||
那么接下来,我们就从CDN工作流程的第一步“路由解析”开始,来全面了解下,CDN是如何进行网络加速的。
|
||||
|
||||
路由解析
|
||||
|
||||
在第16讲我给你介绍DNS域名解析的时候,提到过翻译域名不需要像查电话本一样,刻板地一对一翻译,DNS可以根据来访机器、网络链路、服务内容等各种信息,玩出很多花样。
|
||||
|
||||
而内容分发网络将用户请求路由到它的资源服务器上,其实就是依靠DNS服务器来实现的。
|
||||
|
||||
那么,根据我们现在对DNS域名解析的了解,一次没有内容分发网络参与的用户访问,它的解析过程应该是这样的:
|
||||
|
||||
|
||||
|
||||
即查询icyfenix.cn的请求,发送至本地DNS后,会递归查询,直至找到能够解析icyfenix.cn地址的权威DNS服务器,最终把解析结果返回给浏览器。
|
||||
|
||||
而有了内容分发网络的介入,这个解析过程会发生什么变化呢?
|
||||
|
||||
我们不妨先来看一段对网站“icyfenix.cn”进行DNS查询的真实应答记录,这个网站就是通过国内的内容分发网络,来给位于GitHub Pages上的静态页面加速的。
|
||||
|
||||
通过dig或者host命令,我们就能很方便地得到DNS服务器的返回结果(结果中头4个IP的城市地址是我手工加入的,后面的其他记录就不一个一个查了),如下所示:
|
||||
|
||||
$ dig icyfenix.cn
|
||||
|
||||
; <<>> DiG 9.11.3-1ubuntu1.8-Ubuntu <<>> icyfenix.cn
|
||||
;; global options: +cmd
|
||||
;; Got answer:
|
||||
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 60630
|
||||
;; flags: qr rd ra; QUERY: 1, ANSWER: 17, AUTHORITY: 0, ADDITIONAL: 1
|
||||
|
||||
;; OPT PSEUDOSECTION:
|
||||
; EDNS: version: 0, flags:; udp: 65494
|
||||
;; QUESTION SECTION:
|
||||
;icyfenix.cn. IN A
|
||||
|
||||
;; ANSWER SECTION:
|
||||
icyfenix.cn. 600 IN CNAME icyfenix.cn.cdn.dnsv1.com.
|
||||
icyfenix.cn.cdn.dnsv1.com. 599 IN CNAME 4yi4q4z6.dispatch.spcdntip.com.
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 101.71.72.192 #浙江宁波市
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 113.200.16.234 #陕西省榆林市
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 116.95.25.196 #内蒙古自治区呼和浩特市
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 116.178.66.65 #新疆维吾尔自治区乌鲁木齐市
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 118.212.234.144
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 211.91.160.228
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 211.97.73.224
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 218.11.8.232
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 221.204.166.70
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 14.204.74.140
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 43.242.166.88
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 59.80.39.110
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 59.83.204.12
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 59.83.204.14
|
||||
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 59.83.218.235
|
||||
|
||||
;; Query time: 74 msec
|
||||
;; SERVER: 127.0.0.53#53(127.0.0.53)
|
||||
;; WHEN: Sat Apr 11 22:33:56 CST 2020
|
||||
;; MSG SIZE rcvd: 152
|
||||
|
||||
|
||||
那么,根据这个解析信息,我们可以知道,DNS服务为“icyfenix.cn”的查询结果先返回了一个CNAME记录“icyfenxi.cn.cdn.dnsv1.com”,服务器在递归查询该CNAME时候,返回了另一个看起来更奇怪的CNAME“4yi4q4z6.dispatch.spcdntip.com”。继续查询后,这个CNAME返回了十几个位于全国不同地区的A记录。
|
||||
|
||||
很明显,这些A记录就是分布在全国各地、存有本站缓存的CDN节点。由此,我们就能清晰地了解到CDN路由解析的具体工作过程了:
|
||||
|
||||
|
||||
架设好“icyfenix.cn”的服务器后,将服务器的IP地址在你的CDN服务商上注册为“源站”,注册后你会得到一个CNAME,也就是这个例子当中的“icyfenxi.cn.cdn.dnsv1.com”。
|
||||
接着,将得到的CNAME在你购买域名的DNS服务商上,注册为一条CNAME记录。
|
||||
当第一位用户来访问你的站点时,会首先发生一次未命中缓存的DNS查询,域名服务商解析出CNAME后,会返回给本地DNS。到这里,后续的链路解析的主导权就开始由内容分发网络的调度服务接管了。
|
||||
本地DNS查询CNAME时,由于能解析该CNAME的权威服务器,只有CDN服务商所架设的权威DNS,这个DNS服务会根据一定的均衡策略和参数,比如拓扑结构、容量、时延等等,在全国各地能提供服务的CDN缓存节点中挑选一个最适合的,把它的IP替换成源站的IP地址,然后返回给本地DNS。
|
||||
浏览器从本地DNS拿到了IP地址后,就会把该IP当作源站服务器来进行访问,此时该IP的CDN节点上可能有,也可能没有缓存过源站的资源(这一点我们马上会在讲“内容分发”的部分展开讨论)。
|
||||
最后,经过内容分发后的CDN节点,就有能力代替源站向用户提供所请求的资源了。
|
||||
|
||||
|
||||
那么,把前面解析的这个步骤反映在时序图上,会是什么样子的呢?你可以参考我在这里给出的图例,然后对比一下我在前面所给出的没有CDN参与的时序图,看看它们都有什么不同之处:
|
||||
|
||||
|
||||
|
||||
好了,现在我们就已经了解了CDN中路由解析的工作流程了。下面我们一起来看看CDN加速的核心:内容分发。
|
||||
|
||||
内容分发
|
||||
|
||||
我们已经知道,在DNS服务器的协助下,无论是对用户还是服务器,内容分发网络都可以是完全透明的,在两者都不知情的情况下,由CDN的缓存节点接管用户向服务器发出的资源请求。
|
||||
|
||||
但随之而来的问题,就是缓存节点中必须要有用户想要请求的资源副本,才可能代替源站来响应用户请求。而这里面又包括了两个子问题:“如何获取源站资源”和“如何管理(更新)资源”。
|
||||
|
||||
所以,CDN是如何解决这两个问题的呢?
|
||||
|
||||
首先,对于“如何获取源站资源”这个问题,CDN获取源站资源的过程被称为“内容分发”,“内容分发网络”的名字也正是由此而来的,可见这是CDN的核心价值。
|
||||
|
||||
那么,在内容分发的过程中,我们可以采取两种主流的内容分发方式:
|
||||
|
||||
第一种:主动分发(Push)
|
||||
|
||||
顾名思义,主动分发就是由源站主动发起,将内容从源站或者其他资源库推送到用户边缘的各个CDN缓存节点上。这个推送的操作没有什么业界标准可循,我们可以采用任何传输方式(如HTTP、FTP、P2P等)、任何推送策略(如满足特定条件、定时、人工等)、任何推送时间,只要与我后面要说的更新策略相匹配即可。
|
||||
|
||||
不过你要注意,由于主动分发通常需要源站、CDN服务双方提供的程序API接口层面的配合,所以它对源站并不是透明的,只对用户一侧单向透明。
|
||||
|
||||
另外,主动分发的方式一般是用于网站要预载大量资源的场景。比如双十一之前的一段时间内,淘宝、京东等各个网络商城,就会开始把未来活动中需要用到的资源推送到CDN缓存节点中,特别常用的资源甚至会直接缓存到你的手机App的存储空间,或者浏览器的localStorage上。
|
||||
|
||||
第二种:被动回源(Pull)
|
||||
|
||||
被动回源就是指由用户访问所触发的全自动、双向透明的资源缓存过程。当某个资源首次被用户请求的时候,CDN缓存节点如果发现自己没有该资源,就会实时从源站中获取。这时资源的响应时间可粗略认为是资源从源站到CDN缓存节点的时间,再加上资源从CDN发送到用户的时间之和。
|
||||
|
||||
所以,被动回源的首次访问通常是比较慢的(但由于CDN的网络条件一般远高于普通用户,并不一定就会比用户直接访问源站更慢),不适合应用于数据量较大的资源。
|
||||
|
||||
但是被动回源也有优点,就是它可以做到完全的双向透明,不需要源站在程序上做任何的配合,使用起来非常方便。
|
||||
|
||||
这种分发方式是小型站点使用CDN服务的主流选择,如果你不是自建CDN,而是购买阿里云、腾讯云的CDN服务的站点,它们多数采用的就是这种方式。
|
||||
|
||||
其次,对于“CDN如何管理(更新)资源”这个问题,同样也没有统一的标准可言。尽管在HTTP协议中,关于缓存的Header定义中确实是有对CDN这类共享缓存的一些指引性参数,比如Cache-Control的s-maxage,但是否要遵循,完全取决于CDN本身的实现策略。
|
||||
|
||||
而且,更令人感到无奈的是,由于大多数网站的开发和运维人员并不十分了解HTTP缓存机制,所以就导致了,如果CDN完全照着HTTP Headers来控制缓存失效和更新,效果反而会更差,而且还可能会引发其他的问题。所以,CDN缓存的管理没有通用的准则。
|
||||
|
||||
现在,最常见的管理(更新)资源的做法是超时被动失效与手工主动失效相结合。
|
||||
|
||||
超时失效是指给予缓存资源一定的生存期,超过了生存期就在下次请求时重新被动回源一次。而手工失效是指,CDN服务商一般会给程序调用提供失效缓存的接口,在网站更新时,由持续集成的流水线自动调用该接口来实现缓存更新,比如“icyfenix.cn”就是依靠Travis-CI的持续集成服务,来触发CDN失效和重新预热的。
|
||||
|
||||
CDN应用
|
||||
|
||||
内容分发网络最初是为了快速分发静态资源而设计的,但今天的CDN能做到的事情,已经远远超越了开始建设时的目标。所以下面,我想带你来了解一下现在的CDN都可以做到什么,它都有哪些应用。这里我先说明一下,我不会把CDN的各种应用全部展开细说,而是只做个简要地列举说明,我的目的是想帮你建立一个总体的关于CDN的认知,只要理解和掌握了CDN的原理,相信你也能发掘出许多这里没有列举的应用。
|
||||
|
||||
|
||||
加速静态资源
|
||||
|
||||
|
||||
这是CDN本职工作。
|
||||
|
||||
|
||||
安全防御
|
||||
|
||||
|
||||
在广义上,你可以把CDN看作是你网站的堡垒机,源站只对CDN提供服务,然后由CDN来服务外界的其他用户,这样恶意攻击者就不容易直接威胁源站。CDN对防御某些攻击手段,如DDoS攻击等尤其有效。
|
||||
|
||||
但你也需要注意的是,把安全性都寄托在CDN上本身其实是不安全的,一旦源站的真实IP被泄露,就会面临很高的风险。
|
||||
|
||||
|
||||
协议升级
|
||||
|
||||
|
||||
不少CDN提供商都同时对接(代售CA的)SSL证书服务,这样就可以实现源站是HTTP协议的,而对外开放的网站是基于HTTPS的。
|
||||
|
||||
同理,这样的做法也可以实现源站到CDN是HTTP/1.x协议,而CDN提供的外部服务是HTTP/2或HTTP/3协议;或者是实现源站是基于IPv4网络的,CDN提供的外部服务支持IPv6网络,等等。
|
||||
|
||||
|
||||
状态缓存
|
||||
|
||||
|
||||
在第17讲我介绍客户端缓存的时候,简要提到了状态缓存的实现机制,即不经过服务器,客户端直接根据缓存信息来判断目标网站的状态。而CDN不仅可以缓存源站的资源,还可以缓存源站的状态,比如源站的301/302转向就可以缓存起来,让客户端直接跳转;还可以通过CDN开启HSTS、通过CDN进行OCSP装订,来加速SSL证书访问,等等。
|
||||
|
||||
另外,有一些情况下,我们甚至可以配置CDN对任意状态码(如404)进行一定时间的缓存,以减轻源站压力。但这个操作你要慎重,注意要在网站状态发生改变时去及时刷新缓存。
|
||||
|
||||
|
||||
修改资源
|
||||
|
||||
|
||||
CDN可以在给用户返回资源的时候,修改它的任何内容,以实现不同的目的。比如说,可以对源站未压缩的资源自动压缩,并修改Content-Encoding,以节省用户的网络带宽消耗;可以针对源站未启用客户端缓存的内容,加上缓存Header,来自动启用客户端缓存;可以修改CORS的相关Header,给源站不支持跨域的资源提供跨域能力,等等。
|
||||
|
||||
|
||||
访问控制
|
||||
|
||||
|
||||
CDN可以实现IP黑/白名单功能。比如,根据不同的来访IP提供不同的响应结果、根据IP的访问流量来实现QoS控制、根据HTTP的Referer来实现防盗链,等等。
|
||||
|
||||
|
||||
注入功能
|
||||
|
||||
|
||||
CDN可以在不修改源站代码的前提下,为源站注入各种功能。举个例子,下图是国际CDN巨头CloudFlare提供的Google Analytics、PACE、Hardenize等第三方应用,这些原本需要在源站中注入代码的应用,在CDN下都可以做到无需修改源站任何代码即可使用。
|
||||
|
||||
|
||||
|
||||
小结
|
||||
|
||||
CDN是一种已经存在了很长时间,也被人们广泛应用的分流系统。它能为互联网系统提供性能上的加速,也能帮助增强许多功能,比如说我今天所讲的安全防御、资源修改、功能注入,等等。
|
||||
|
||||
而且,这一切又实现得极为透明,可以完全不需要我们这样的开发者来配合,甚至可以在我们不知情的情况下完成,以至于CDN没什么存在感,虽然我们可能都说听过它,但却没有真正了解过它。所以学完了这一讲,你应该就对CDN有更全面的理解了。
|
||||
|
||||
另外,CDN本身就是透明多级分流系统的一个优秀范例,我希望你不仅可以学会CDN本身的功能与运作原理,而且可以在实际的工作中,将这种透明多级分流的思路应用于不同的场景,构建出更加健壮、能应对更大流量的系统。
|
||||
|
||||
一课一思
|
||||
|
||||
除了我们已经介绍到的DNS和CDN,你还了解软件业界里哪些常见的系统符合“透明多级分流”的特征呢?欢迎在留言区分享出来。
|
||||
|
||||
如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
261
专栏/周志明的架构课/20_常见的四层负载均衡的工作模式是怎样的?.md
Normal file
261
专栏/周志明的架构课/20_常见的四层负载均衡的工作模式是怎样的?.md
Normal file
@@ -0,0 +1,261 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 _ 常见的四层负载均衡的工作模式是怎样的?
|
||||
你好,我是周志明。
|
||||
|
||||
在上节课,我们学习了利用CDN来加速网络性能的工作内容,包括路由解析、内容分发、负载均衡和它所支持的应用。其中,负载均衡是相对独立的内容,它不仅在CDN方面有应用,在大量软件系统的生产部署中,也都离不开负载均衡器的支持。所以今天这节课,我们就一起来了解下负载均衡器的作用与原理。
|
||||
|
||||
在互联网时代的早期,网站流量还比较小,业务也比较简单,使用单台服务器基本就可以满足访问的需要了。但时至今日,互联网也好,企业级也好,一般实际用于生产的系统,几乎都离不开集群部署了。
|
||||
|
||||
一方面,不管是采用单体架构多副本部署还是微服务架构,也不管是为了实现高可用还是为了获得高性能,信息系统都需要利用多台机器来扩展服务能力,希望用户的请求不管连接到哪台机器上,都能得到相同的处理。
|
||||
|
||||
另一方面,如何构建和调度服务集群这件事情,又必须对用户一侧保持足够的透明,即使请求背后是由一千台、一万台机器来共同响应的,这也都不是用户会关心的事情,用户需要记住的只有一个域名地址而已。
|
||||
|
||||
那么,这里承担了调度后方的多台机器,以统一的接口对外提供服务的技术组件,就是“负载均衡器”(Load Balancer)了。
|
||||
|
||||
真正的大型系统的负载均衡过程往往是多级的。比如,在各地建有多个机房,或者是机房有不同网络链路入口的大型互联网站,然后它们会从DNS解析开始,通过“域名” → “CNAME” → “负载调度服务” → “就近的数据中心入口”的路径,先根据IP地址(或者其他条件)将来访地用户分配到一个合适的数据中心当中,然后才到了我们马上要讨论的各式负载均衡。
|
||||
|
||||
这里我先跟你说明一下:在DNS层面的负载均衡的工作模式,与我在前几讲中介绍的DNS智能线路、内容分发网络等的工作原理是类似的,它们之间的差别只是数据中心能提供的不仅是缓存,而是全方位的服务能力。所以这种负载均衡的工作模式我就不再重复介绍了,后面我们主要聚焦在讨论网络请求进入数据中心入口之后的其他级次的负载均衡。
|
||||
|
||||
好,那么接下来,我们就先从负载均衡的实现形式开始了解吧。
|
||||
|
||||
负载均衡的两种形式
|
||||
|
||||
实际上,无论我们在网关内部建立了多少级的负载均衡,从形式上来说都可以分为两种:四层负载均衡和七层负载均衡。
|
||||
|
||||
那么,在详细介绍它们是什么、如何工作之前,我们先来建立两个总体的、概念性的印象:
|
||||
|
||||
|
||||
四层负载均衡的优势是性能高,七层负载均衡的优势是功能强。
|
||||
做多级混合负载均衡,通常应该是低层的负载均衡在前,高层的负载均衡在后(你可以先想一想为什么?)。
|
||||
|
||||
|
||||
这里,我们所说的“四层”“七层”,一般指的是经典的OSI七层模型中,第四层传输层和第七层应用层。你可以参考下面表格中给出的维基百科上对OSI七层模型的介绍,我们在后面还会多次使用它。
|
||||
|
||||
|
||||
|
||||
另外我还想说明一点,就是现在人们所说的“四层负载均衡”,其实是多种均衡器工作模式的统称。“四层”的意思是说,这些工作模式的共同特点是都维持着同一个TCP连接,而不是说它就只工作在第四层。
|
||||
|
||||
事实上,这些模式主要都是工作在二层(数据链路层,可以改写MAC地址)和三层上(网络层,可以改写IP地址),单纯只处理第四层(传输层,可以改写TCP、UDP等协议的内容和端口)的数据无法做到负载均衡的转发,因为OSI的下面三层是媒体层(Media Layers),上面四层是主机层(Host Layers)。所以,既然流量都已经到达目标主机上了,也就谈不上什么流量转发,最多只能做代理了。
|
||||
|
||||
但出于习惯和方便,现在几乎所有的资料都把它们统称为四层负载均衡,这里我也就遵循习惯,同样称呼它为四层负载均衡。而如果你在某些资料上,看见“二层负载均衡”“三层负载均衡”的表述,这就真的是在描述它们工作的层次了,和我这里讲的“四层负载均衡”并不是同一类意思。
|
||||
|
||||
常见的四层负载均衡的工作模式
|
||||
|
||||
好,回到我们这一讲的重点上来,我们一起来看看几种常见的四层负载均衡的工作模式都是怎样的。
|
||||
|
||||
数据链路层负载均衡
|
||||
|
||||
这里你可以参考前面的OSI模型表格,数据链路层传输的内容是数据帧(Frame),比如我们常见的以太网帧、ADSL宽带的PPP帧等。当然了,在我们所讨论的具体上下文里,探究的目标必定就是以太网帧了。按照IEEE 802.3标准,最典型的1500 Bytes MTU的以太网帧结构如下表所示:
|
||||
|
||||
|
||||
|
||||
|
||||
阅读链接补充:-
|
||||
802.1Q 标签-
|
||||
以太类型-
|
||||
冗余校验-
|
||||
帧间距
|
||||
|
||||
|
||||
在这个帧结构中,其他数据项的含义你可以暂时不去理会,只需要注意到“MAC目标地址”和“MAC源地址”两项即可。
|
||||
|
||||
我们知道,每一块网卡都有独立的MAC地址,而以太帧上的这两个地址告诉了交换机,此帧应该是从连接在交换机上的哪个端口的网卡发出,送至哪块网卡的。
|
||||
|
||||
数据链路层负载均衡所做的工作,是修改请求的数据帧中的MAC目标地址,让用户原本是发送给负载均衡器的请求的数据帧,被二层交换机根据新的MAC目标地址,转发到服务器集群中,对应的服务器(后面都叫做“真实服务器”,Real Server)的网卡上,这样真实服务器就获得了一个原本目标并不是发送给它的数据帧。
|
||||
|
||||
由于二层负载均衡器在转发请求过程中,只修改了帧的MAC目标地址,不涉及更上层协议(没有修改Payload的数据),所以在更上层(第三层)看来,所有数据都是没有被改变过的。
|
||||
|
||||
这是因为第三层的数据包,也就是IP数据包中,包含了源(客户端)和目标(均衡器)的IP地址,只有真实服务器保证自己的IP地址与数据包中的目标IP地址一致,这个数据包才能被正确处理。
|
||||
|
||||
所以,我们在使用这种负载均衡模式的时候,需要把真实物理服务器集群所有机器的虚拟IP地址(Virtual IP Address,VIP),配置成跟负载均衡器的虚拟IP一样,这样经均衡器转发后的数据包,就能在真实服务器中顺利地使用。
|
||||
|
||||
另外,也正是因为实际处理请求的真实物理服务器IP和数据请求中的目的IP是一致的,所以响应结果就不再需要通过负载均衡服务器进行地址交换,我们可以把响应结果的数据包直接从真实服务器返回给用户的客户端,避免负载均衡器网卡带宽成为瓶颈,所以数据链路层的负载均衡效率是相当高的。
|
||||
|
||||
整个请求到响应的过程如下图所示:
|
||||
|
||||
|
||||
|
||||
那么这里你就可以发现,数据链路层负载均衡的工作模式是,只有请求会经过负载均衡器,而服务的响应不需要从负载均衡器原路返回,整个请求、转发、响应的链路形成了一个“三角关系”。所以,这种负载均衡模式也被很形象地称为“三角传输模式”(Direct Server Return,DSR),也有人叫它是“单臂模式”(Single Legged Mode)或者“直接路由”(Direct Routing)。
|
||||
|
||||
不过,虽然数据链路层负载均衡的效率很高,但它并不适用于所有的场合。除了那些需要感知应用层协议信息的负载均衡场景它无法胜任外(所有的四层负载均衡器都无法胜任,这个我后面介绍七层负载均衡器时会一并解释),在网络一侧受到的约束也很大。
|
||||
|
||||
原因是,二层负载均衡器直接改写目标MAC地址的工作原理,决定了它与真实服务器的通讯必须是二层可达的。通俗地说,就是它们必须位于同一个子网当中,无法跨VLAN。
|
||||
|
||||
所以,这个优势(效率高)和劣势(不能跨子网)就共同决定了,数据链路层负载均衡最适合用来做数据中心的第一级均衡设备,用来连接其他的下级负载均衡器。
|
||||
|
||||
好,我们再来看看第二种常见的四层负载均衡工作模式:网络层负载均衡。
|
||||
|
||||
网络层负载均衡
|
||||
|
||||
根据OSI七层模型我们可以知道,在第三层网络层传输的单位是分组数据包(Packets),这是一种在分组交换网络(Packet Switching Network,PSN)中传输的结构化数据单位。
|
||||
|
||||
我拿IP协议来给你举个例子吧。一个IP数据包由Headers和Payload两部分组成,Headers长度最大为60 Bytes,它是由20 Bytes的固定数据和最长不超过40 Bytes的可选数据组成的。按照IPv4标准,一个典型的分组数据包的Headers部分的结构是这样的:
|
||||
|
||||
|
||||
|
||||
同样,我们也不需要过多关注表中的其他信息,只要知道在IP分组数据包的Headers带有源和目标的IP地址即可。
|
||||
|
||||
源和目标IP地址代表了“数据是从分组交换网络中的哪台机器发送到哪台机器的”,所以我们就可以沿用与二层改写MAC地址相似的思路,通过改变这里面的IP地址,来实现数据包的转发。
|
||||
|
||||
具体有两种常见的修改方式:
|
||||
|
||||
第一种是保持原来的数据包不变,新创建一个数据包,把原来数据包的Headers和Payload整体作为另一个新的数据包的Payload,在这个新数据包的Headers中,写入真实服务器的IP作为目标地址,然后把它发送出去。
|
||||
|
||||
如此经过三层交换机的转发,当真实服务器收到数据包后,就必须在接收入口处,设计一个针对性的拆包机制,把由负载均衡器自动添加的那层Headers扔掉,还原出原来的数据包来进行使用。
|
||||
|
||||
这样,真实服务器就同样拿到了一个原本不是发给它(目标IP不是它)的数据包,从而达到了流量转发的目的。
|
||||
|
||||
在那个时候,还没有流行起“禁止套娃”的梗,所以设计者给这种“套娃式”的传输起名为“IP隧道”(IP Tunnel)传输,也是相当的形象了。
|
||||
|
||||
当然,尽管因为要封装新的数据包,IP隧道的转发模式比起直接路由的模式,效率会有所下降,但因为它并没有修改原有数据包中的任何信息,所以IP隧道的转发模式仍然具备三角传输的特性,即负载均衡器转发来的请求,可以由真实服务器去直接应答,无需再经过均衡器原路返回。
|
||||
|
||||
而且因为IP隧道工作在网络层,所以可以跨越VLAN,也就摆脱了我前面所讲的直接路由模式中网络侧的约束。现在,我们来看看这种转发模式从请求到响应的具体过程:
|
||||
|
||||
|
||||
|
||||
不过,这种转发模式也有缺点,就是它要求真实服务器必须得支持“IP隧道协议”(IP Encapsulation),也就是它得学会自己拆包扔掉一层Headers。当然这个其实并不是什么大问题,现在几乎所有的Linux系统都支持IP隧道协议。
|
||||
|
||||
可另一个问题是,这种模式仍然必须通过专门的配置,必须保证所有的真实服务器与均衡器有着相同的虚拟IP地址。因为真实服务器器在回复该数据包的时候,需要使用这个虚拟IP作为响应数据包的源地址,这样客户端在收到这个数据包的时候才能正确解析。
|
||||
|
||||
这个限制就相对麻烦了一些,它跟“透明”的原则冲突了,需由系统管理员去专门介入。而且,并不是在任何情况下,我们都可以对服务器进行虚拟IP的配置的,尤其是当有好几个服务共用一台物理服务器的时候。
|
||||
|
||||
那么在这种情况下,我们就必须考虑第二种改变目标数据包的方式:直接把数据包Headers中的目标地址改掉,修改后原本由用户发给均衡器的数据包,也会被三层交换机转发送到真实服务器的网卡上,而且因为没有经过IP隧道的额外包装,也就无需再拆包了。
|
||||
|
||||
但问题是,这种模式是修改了目标IP地址才到达真实服务器的,而如果真实服务器直接把应答包发回给客户端的话,这个应答数据包的源IP是真实服务器的IP,也就是均衡器修改以后的IP地址,那么客户端就不可能认识该IP,自然也就无法再正常处理这个应答了。
|
||||
|
||||
因此,我们只能让应答流量继续回到负载均衡,负载均衡把应答包的源IP改回自己的IP,然后再发给客户端,这样才能保证客户端与真实服务器之间正常通信。
|
||||
|
||||
如果你对网络知识有些了解的话,肯定会觉得这种处理似曾相识:这不就是在家里、公司、学校上网的时候,由一台路由器带着一群内网机器上网的“网络地址转换”(Network Address Translation,NAT)操作吗?
|
||||
|
||||
这种负载均衡的模式的确就被称为NAT模式。此时,负载均衡器就是充当了家里、公司、学校的上网路由器的作用。
|
||||
|
||||
NAT模式的负载均衡器运维起来也十分简单,只要机器把自己的网关地址设置为均衡器地址,就不需要再进行任何额外设置了。
|
||||
|
||||
我们来看看这种工作模式从请求到响应的过程:
|
||||
|
||||
|
||||
|
||||
不过这里,你还要知道的是,在流量压力比较大的时候,NAT模式的负载均衡会带来较大的性能损失,比起直接路由和IP隧道模式,甚至会出现数量级上的下降。
|
||||
|
||||
这个问题也是显而易见的,因为由负载均衡器代表整个服务集群来进行应答,各个服务器的响应数据都会互相争抢均衡器的出口带宽。这就好比在家里用NAT上网的话,如果有人在下载,你打游戏可能就会觉得卡顿是一个道理,此时整个系统的瓶颈很容易就出现在负载均衡器上。
|
||||
|
||||
不过还有一种更加彻底的NAT模式,就是均衡器在转发时,不仅修改目标IP地址,连源IP地址也一起改了,这样源地址就改成了均衡器自己的IP。这种方式被叫做Source NAT(SNAT)。
|
||||
|
||||
这样做的好处是真实服务器连网关都不需要配置了,它能让应答流量经过正常的三层路由,回到负载均衡器上,做到了彻底的透明。
|
||||
|
||||
但它的缺点是由于做了SNAT,真实服务器处理请求时就无法拿到客户端的IP地址了,在真实服务器的视角看来,所有的流量都来自于负载均衡器,这样有一些需要根据目标IP进行控制的业务逻辑就无法进行了。
|
||||
|
||||
应用层负载均衡
|
||||
|
||||
前面我介绍的两种四层负载均衡的工作模式都属于“转发”,即直接将承载着TCP报文的底层数据格式(IP数据包或以太网帧),转发到真实服务器上,此时客户端到响应请求的真实服务器维持着同一条TCP通道。
|
||||
|
||||
但工作在四层之后的负载均衡模式就无法再进行转发了,只能进行代理。此时正式服务器、负载均衡器、客户端三者之间,是由两条独立的TCP通道来维持通讯的。
|
||||
|
||||
那么,转发与代理之间的具体区别是怎样的呢?我们来看一个图例:
|
||||
|
||||
|
||||
|
||||
首先,“代理”这个词,根据“哪一方能感知到”的原则,可以分为“正向代理”“反向代理”和“透明代理”三类。
|
||||
|
||||
|
||||
正向代理就是我们通常简称的代理,意思就是在客户端设置的、代表客户端与服务器通讯的代理服务。它是客户端可知,而对服务器是透明的。
|
||||
反向代理是指设置在服务器这一侧,代表真实服务器来与客户端通讯的代理服务。此时它对客户端来说是透明的。
|
||||
透明代理是指对双方都透明的,配置在网络中间设备上的代理服务。比如,架设在路由器上的透明翻墙代理。
|
||||
|
||||
|
||||
那么根据这个定义,很显然,七层负载均衡器就属于反向代理中的一种,如果只论网络性能,七层负载均衡器肯定是无论如何比不过四层负载均衡器的。毕竟它比四层负载均衡器至少要多一轮TCP握手,还有着跟NAT转发模式一样的带宽问题,而且通常要耗费更多的CPU,因为可用的解析规则远比四层丰富。
|
||||
|
||||
所以说,如果你要用七层负载均衡器去做下载站、视频站这种流量应用,一定是不合适的,起码它不能作为第一级均衡器。
|
||||
|
||||
但是,如果网站的性能瓶颈并不在于网络性能,而是要论整个服务集群对外所体现出来的服务性能,七层负载均衡器就有它的用武之地了。这里,七层负载均衡器的底气就来源于,它是工作在应用层的,可以感知应用层通讯的具体内容,往往能够做出更明智的决策,玩出更多的花样来。
|
||||
|
||||
我举个生活中的例子。
|
||||
|
||||
四层负载均衡器就像是银行的自助排号机,转发效率高且不知疲倦,每一个达到银行的客户都可以根据排号机的顺序,选择对应的窗口接受服务;而七层负载均衡器就像银行的大堂经理,他会先确认客户需要办理的业务,再安排排号。这样,办理理财、存取款等业务的客户,可以根据银行内部资源得到统一的协调处理,加快客户业务办理流程;而有些无需柜台办理的业务,由大堂经理直接就可以解决了。
|
||||
|
||||
比如说,反向代理的工作模式就能够实现静态资源缓存,对于静态资源的请求就可以在反向代理上直接返回,无需转发到真实服务器。
|
||||
|
||||
这里关于代理的工作模式,相信你应该是比较熟悉的,所以这里关于七层负载均衡器的具体工作过程我就不详细展开了。下面我来列举一些七层代理可以实现的功能,让你能对它“功能强大”有个直观的感受:
|
||||
|
||||
|
||||
在上一讲我介绍CDN应用的时候就提到过,所有CDN可以做的缓存方面的工作(就是除去CDN根据物理位置就近返回这种优化链路的工作外),七层负载均衡器全都可以实现,比如静态资源缓存、协议升级、安全防护、访问控制,等等。
|
||||
七层负载均衡器可以实现更智能化的路由。比如,根据Session路由,以实现亲和性的集群;根据URL路由,实现专职化服务(此时就相当于网关的职责);甚至根据用户身份路由,实现对部分用户的特殊服务(如某些站点的贵宾服务器),等等。
|
||||
某些安全攻击可以由七层负载均衡器来抵御。比如,一种常见的DDoS手段是SYN Flood攻击,即攻击者控制众多客户端,使用虚假IP地址对同一目标大量发送SYN报文。从技术原理上看,因为四层负载均衡器无法感知上层协议的内容,这些SYN攻击都会被转发到后端的真实服务器上;而在七层负载均衡器下,这些SYN攻击自然就会在负载均衡设备上被过滤掉,不会影响到后面服务器的正常运行。类似地,我们也可以在七层负载均衡器上设定多种策略,比如过滤特定报文,以防御如SQL注入等应用层面的特定攻击手段。
|
||||
很多微服务架构的系统中,链路治理措施都需要在七层中进行,比如服务降级、熔断、异常注入,等等。我举个例子,一台服务器只有出现物理层面或者系统层面的故障,导致无法应答TCP请求,才能被四层负载均衡器感知到,进而剔除出服务集群,而如果一台服务器能够应答,只是一直在报500错,那四层负载均衡器对此是完全无能为力的,只能由七层负载均衡器来解决。
|
||||
|
||||
|
||||
均衡策略与实现
|
||||
|
||||
好,现在你应该也能知道,负载均衡的两大职责是“选择谁来处理用户请求”和“将用户请求转发过去”。那么到这里为止,我们只介绍了后者,即请求的转发或代理过程。
|
||||
|
||||
而“选择谁来处理用户请求”是指均衡器所采取的均衡策略,这一块因为涉及的均衡算法太多,我就不一一展开介绍了。所以接下来,我想从功能和应用的角度,来给你介绍一些常见的均衡策略,你可以在自己的实践当中根据实际需求去配置选择。
|
||||
|
||||
|
||||
轮循均衡(Round Robin)
|
||||
|
||||
|
||||
即每一次来自网络的请求,会轮流分配给内部中的服务器,从1到N然后重新开始。这种均衡算法适用于服务器组中的所有服务器都有相同的软硬件配置,并且平均服务请求相对均衡的情况。
|
||||
|
||||
|
||||
权重轮循均衡(Weighted Round Robin)
|
||||
|
||||
|
||||
即根据服务器的不同处理能力,给每个服务器分配不同的权值,使其能够接受相应权值数的服务请求。比如,服务器A的权值被设计成1,B的权值是3,C的权值是6,则服务器A、B、C将分别接收到10%、30%、60%的服务请求。这种均衡算法能确保高性能的服务器得到更多的使用率,避免低性能的服务器负载过重。
|
||||
|
||||
|
||||
随机均衡(Random)
|
||||
|
||||
|
||||
即把来自客户端的请求随机分配给内部中的多个服务器。这种均衡算法在数据足够大的场景下,能达到相对均衡的分布。
|
||||
|
||||
|
||||
权重随机均衡(Weighted Random)
|
||||
|
||||
|
||||
这种均衡算法类似于权重轮循算法,不过在处理请求分担的时候,它是个随机选择的过程。
|
||||
|
||||
|
||||
一致性哈希均衡(Consistency Hash)
|
||||
|
||||
|
||||
即根据请求中的某些数据(可以是MAC、IP地址,也可以是更上层协议中的某些参数信息)作为特征值,来计算需要落在哪些节点上,算法一般会保证同一个特征值,每次都一定落在相同的服务器上。这里一致性的意思就是,保证当服务集群的某个真实服务器出现故障的时候,只影响该服务器的哈希,而不会导致整个服务集群的哈希键值重新分布。
|
||||
|
||||
|
||||
响应速度均衡(Response Time)
|
||||
|
||||
|
||||
即负载均衡设备对内部各服务器发出一个探测请求(如Ping),然后根据内部中各服务器对探测请求的最快响应时间,来决定哪一台服务器来响应客户端的服务请求。这种均衡算法能比较好地反映服务器的当前运行状态,但要注意,这里的最快响应时间,仅仅指的是负载均衡设备与服务器间的最快响应时间,而不是客户端与服务器间的最快响应时间。
|
||||
|
||||
|
||||
最少连接数均衡(Least Connection)
|
||||
|
||||
|
||||
客户端的每一次请求服务,在服务器停留的时间可能会有比较大的差异。那么随着工作时间加长,如果采用简单的轮循或者随机均衡算法,每一台服务器上的连接进程可能会产生极大的不平衡,并没有达到真正的负载均衡。所以,最少连接数均衡算法就会对内部中需要负载的每一台服务器,都有一个数据记录,也就是记录当前该服务器正在处理的连接数量,当有新的服务连接请求时,就把当前请求分配给连接数最少的服务器,使均衡更加符合实际情况,负载也能更加均衡。这种均衡算法适合长时间处理的请求服务,比如FTP传输。
|
||||
|
||||
|
||||
…………
|
||||
|
||||
|
||||
另外,从实现角度来看,负载均衡器的实现有“软件均衡器”和“硬件均衡器”两类。
|
||||
|
||||
在软件均衡器方面,又分为直接建设在操作系统内核的均衡器和应用程序形式的均衡器两种。前者的代表是LVS(Linux Virtual Server),后者的代表有Nginx、HAProxy、KeepAlived,等等;前者的性能会更好,因为它不需要在内核空间和应用空间中来回复制数据包;而后者的优势是选择广泛,使用方便,功能不受限于内核版本。
|
||||
|
||||
在硬件均衡器方面,往往会直接采用应用专用集成电路(Application Specific Integrated Circuit,ASIC)来实现。因为它有专用处理芯片的支持,可以避免操作系统层面的损耗,从而能够达到最高的性能。这类的代表就是著名的F5和A10公司的负载均衡产品。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我给你介绍了数据链路层负载均衡和网络层负载均衡的基本原理。对于一个普通的开发人员来说,可能平常不太接触这些偏向底层网络的知识,但如果你要对软件系统工作有全局的把握,进阶成为一名架构人员,那么即使不会去实际参与网络拓扑设计与运维,至少也必须理解它们的工作原理,这是系统做流量和容量规划的必要基础。
|
||||
|
||||
一课一思
|
||||
|
||||
请你思考一下:为什么负载均衡不能只在某一个网络层次中完成,而是要进行多级混合的负载均衡?另外做多级混合负载均衡,为什么应该是低层的负载均衡在前,高层的负载均衡在后?
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
284
专栏/周志明的架构课/21_服务端缓存的三种属性.md
Normal file
284
专栏/周志明的架构课/21_服务端缓存的三种属性.md
Normal file
@@ -0,0 +1,284 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 _ 服务端缓存的三种属性
|
||||
你好,我是周志明。
|
||||
|
||||
在透明多级分流系统这个小章节中,我们的研究思路是以流量从客户端中发出开始,以流量到达服务器集群中真正处理业务的节点作为结束,一起探索了在这个过程中与业务无关的一些通用组件,包括DNS、CDN、客户端缓存,等等。
|
||||
|
||||
实际上,服务端缓存也是一种通用的技术组件,它主要用于减少多个客户端相同的资源请求,缓解或降低服务器的负载压力。所以,说它是一种分流手段也是很合理的。
|
||||
|
||||
另外,我们其实很难界定服务端缓存到底算不算与业务逻辑无关,因为服务端缓存通常是在代码中被显式调用的,这就很难说它是“透明分流”了。但是,服务端缓存作为流量到达服务端实际处理逻辑之前的最后一道防御线,把它作为这个小章节的最后一讲,倒也是合适的。
|
||||
|
||||
所以这节课,我就带你来了解下服务端缓存的相关知识点,你可以从中理解和掌握缓存的三种常见属性,然后灵活运用在自己的软件开发当中。
|
||||
|
||||
好,接下来,我们就从引入缓存的价值开始学起吧。
|
||||
|
||||
为系统引入缓存的理由
|
||||
|
||||
关于服务端缓存,首先你需要明确的问题是,在为你的系统引入缓存之前,它是否真的需要缓存呢?
|
||||
|
||||
我们很多人可能都会有意无意地,把硬件里那种常用于区分不同产品档次、“多多益善”的缓存(如CPU L1/2/3缓存、磁盘缓存,等等)代入到软件开发中去。但实际上,这两者的差别是很大的。毕竟,服务端缓存是程序的一部分,而硬件缓存是一种硬件对软件运行效率的优化手段。
|
||||
|
||||
在软件开发中,引入缓存的负面作用要明显大于硬件的缓存。主要有这样几个原因:
|
||||
|
||||
|
||||
从开发角度来说,引入缓存会提高系统的复杂度,因为你要考虑缓存的失效、更新、一致性等问题(硬件缓存也有这些问题,只是不需要由你来考虑,主流的ISA也都没有提供任何直接操作缓存的指令);
|
||||
从运维角度来说,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;
|
||||
从安全角度来说,缓存可能泄漏某些保密数据,这也是容易受到攻击的薄弱点。
|
||||
|
||||
|
||||
那么,冒着前面提到的这种种风险,你还是想要给系统引入缓存,是为了什么呢?其实无外乎有两种理由。
|
||||
|
||||
第一种,为了缓解CPU压力而做缓存。
|
||||
|
||||
比如说,把方法运行结果存储起来、把原本要实时计算的内容提前算好、把一些公用的数据进行复用,等等,这些引入缓存的做法,都可以节省CPU算力,顺带提升响应性能。
|
||||
|
||||
第二种,为了缓解I/O压力而做缓存。
|
||||
|
||||
比如说,通过引入缓存,把原本对网络、磁盘等较慢介质的读写访问,变为对内存等较快介质的访问;把原本对单点部件(如数据库)的读写访问,变为对可扩缩部件(如缓存中间件)的访问,等等,也顺带提升了响应性能。
|
||||
|
||||
这里请你注意,缓存虽然是典型的以空间换时间来提升性能的手段,但它的出发点是缓解CPU和I/O资源在峰值流量下的压力,“顺带”而非“专门”地提升响应性能。
|
||||
|
||||
所以我的言外之意就是,如果你可以通过增强CPU、I/O本身的性能(比如扩展服务器的数量)来满足需要的话,那升级硬件往往是更好的解决方案。即使需要你掏腰包多花一点儿钱,那通常也比引入缓存带来的风险更低。
|
||||
|
||||
这样,当你有了使用服务端缓存的明确目的后,下一步就是要如何选择缓存了。所以接下来,我们就一起讨论一下,设计或者选择缓存时要考虑哪些方面的属性。
|
||||
|
||||
缓存属性
|
||||
|
||||
其实,不少软件系统最初的缓存功能,都是以HashMap或者ConcurrentHashMap为起点开始的演进的。当我们在开发中发现,系统中某些资源的构建成本比较高,而这些资源又有被重复使用的可能性,那很自然就会产生“循环再利用”的想法,把它们放到Map容器中,下次需要时取出重用,避免重新构建。这种原始朴素的复用就是最基本的缓存了。
|
||||
|
||||
不过,一旦我们专门把“缓存”看作是一项技术基础设施,一旦它有了通用、高效、可统计、可管理等方面的需求,那么我们需要考虑的因素就会变得复杂起来了。通常我们在设计或者选择缓存时,至少需要考虑以下四个维度的属性:
|
||||
|
||||
|
||||
吞吐量:缓存的吞吐量使用OPS值(每秒操作数,Operations per Second,ops/s)来衡量,它反映了对缓存进行并发读、写操作的效率,即缓存本身的工作效率高低。
|
||||
命中率:缓存的命中率即成功从缓存中返回结果次数与总请求次数的比值,它反映了引入缓存的价值高低,命中率越低,引入缓存的收益越小,价值越低。
|
||||
扩展功能:缓存除了基本读写功能外,还提供了一些额外的管理功能,比如最大容量、失效时间、失效事件、命中率统计,等等。
|
||||
分布式支持:缓存可以分为“进程内缓存”和“分布式缓存”两大类,前者只为节点本身提供服务,无网络访问操作,速度快但缓存的数据不能在各个服务节点中共享。后者则相反。
|
||||
|
||||
|
||||
在今天这节课,我们就先来探讨下前三个属性(下一讲我们会重点讨论分布式缓存)。
|
||||
|
||||
吞吐量
|
||||
|
||||
首先你要知道,缓存的吞吐量只在并发场景中才有统计的意义,因为不考虑并发的话,即使是最原始的、以HashMap实现的缓存,访问效率也已经是常量时间复杂度,即O(1)。其中主要涉及到碰撞、扩容等场景的处理,这些都是属于数据结构基础知识,我就不展开讲了。
|
||||
|
||||
但HashMap并不是线程安全的容器,如果要让它在多线程并发下能正确地工作,就要用Collections.synchronizedMap进行包装,这相当于给Map接口的所有访问方法都自动加上了全局锁;或者,我们也可以改用ConcurrentHashMap来实现,这相当于给Map的访问分段加锁(从JDK 8起已取消分段加锁,改为CAS+Synchronized锁单个元素)。
|
||||
|
||||
而无论采用怎样的实现方法,线程安全措施都会带来一定的吞吐量损失。
|
||||
|
||||
所以进一步说,如果我们只比较吞吐量,完全不去考虑命中率、淘汰策略、缓存统计、过期失效等功能该如何实现,那也不必去选择哪种缓存容器更好了,JDK 8改进之后的ConcurrentHashMap,基本上就是你能找到的吞吐量最高的缓存容器了。
|
||||
|
||||
可是,在很多场景里,前面提到的这些功能至少有一两项是必须的,我们不可能完全不考虑。所以,这就涉及到了不同缓存方案的权衡问题。
|
||||
|
||||
根据Caffeine给出的一组目前业界主流进程内缓存的实现方案,其中包括了Caffeine、ConcurrentLinkedHashMap、LinkedHashMap、Guava Cache、Ehcache和Infinispan Embedded等缓存组件库的对比。从它们在8线程、75%读操作、25%写操作下的吞吐量表现Benchmarks来看,各种缓存组件库的性能差异还是十分明显的,最高与最低相差了足有一个数量级,你可以参考下图:
|
||||
|
||||
|
||||
|
||||
8线程、75%读、25%写的吞吐量比较
|
||||
|
||||
其中你可以发现,在这种并发读写的场景中,吞吐量会受多方面因素的共同影响。比如说,怎样设计数据结构以尽可能避免数据竞争、存在竞争风险时怎样处理同步(主要有使用锁实现的悲观同步和使用CAS实现的乐观同步)、如何避免伪共享现象(False Sharing,这也算是典型的用缓存提升开发复杂度的例子)发生,等等。
|
||||
|
||||
其中的第一点,“尽可能避免数据竞争”是最关键的。因为无论我们如何实现同步,都不会比直接不需要同步更快。
|
||||
|
||||
那么下面,我就以Caffeine为例,来给你介绍一些缓存如何避免竞争、提高吞吐量的设计方法。
|
||||
|
||||
我们知道,缓存中最主要的数据竞争来源于读取数据的同时,也会伴随着对数据状态的写入操作,而写入数据的同时,也会伴随着数据状态的读取操作。
|
||||
|
||||
比如说,读取数据时,服务器要同时更新数据的最近访问时间和访问计数器的状态(后面讲命中率时会提到,为了追求高效,程序可能不会记录时间和次数,比如通过调整链表顺序来表达时间先后、通过Sketch结构来表达热度高低),以实现缓存的淘汰策略;又或者,在读取时,服务器要同时判断数据的超期时间等信息,以实现失效重加载等其他扩展功能。
|
||||
|
||||
那么,针对前面所讲的伴随读写操作而来的状态维护,我们可以选择两种处理思路。
|
||||
|
||||
一种是以Guava Cache为代表的同步处理机制。即在访问数据时一并完成缓存淘汰、统计、失效等状态变更操作,通过分段加锁等优化手段来尽量减少数据竞争。
|
||||
|
||||
另一种是以Caffeine为代表的异步日志提交机制。这种机制参考了经典的数据库设计理论,它把对数据的读、写过程看作是日志(即对数据的操作指令)的提交过程。
|
||||
|
||||
尽管日志也涉及到了写入操作,而有并发的数据变更就必然面临着锁竞争。但是异步提交的日志,已经将原本在Map内的锁转移到了日志的追加写操作上,日志里腾挪优化的余地就比在Map中要大得多。
|
||||
|
||||
另外,在Caffeine的实现中,还设有专门的环形缓存区(Ring Buffer,也常称作Circular Buffer),来记录由于数据读取而产生的状态变动日志。而且为了进一步减少数据竞争,Caffeine给每条线程(对线程取Hash,哈希值相同的使用同一个缓冲区)都设置了一个专用的环形缓冲。
|
||||
|
||||
|
||||
额外知识:环形缓冲-
|
||||
|
||||
所谓环形缓冲,并不是Caffeine的专有概念,它是一种拥有读、写两个指针的数据复用结构,在计算机科学中有非常广泛的应用。-
|
||||
|
||||
我给你举个具体例子。比如说,一台计算机通过键盘输入,并通过CPU读取“HELLO WIKIPEDIA”这个长14字节的单词,那么通常就需要一个至少14字节以上的缓冲区才行。-
|
||||
|
||||
但如果是环形缓冲结构,读取和写入就应当一起进行,在读取指针之前的位置都可以重复使用。理想情况下,只要读取指针不落后于写入指针一整圈,这个缓冲区就可以持续工作下去,就能容纳无限多个新字符。否则,就必须阻塞写入操作,去等待读取清空缓冲区。
|
||||
|
||||
|
||||
|
||||
|
||||
环形缓存区工作原理
|
||||
|
||||
|
||||
然后,从Caffeine读取数据时,数据本身会在其内部的ConcurrentHashMap中直接返回,而数据的状态信息变更,就存入了环形缓冲中,由后台线程异步处理。-
|
||||
|
||||
而如果异步处理的速度跟不上状态变更的速度,导致缓冲区满了,那此后接收的状态的变更信息就会直接被丢弃掉,直到缓冲区重新有了富余。-
|
||||
|
||||
所以,通过环形缓冲和容忍有损失的状态变更,Caffeine大幅降低了由于数据读取而导致的垃圾收集和锁竞争,因而Caffeine的读取性能几乎能与ConcurrentHashMap的读取性能相同。-
|
||||
|
||||
另外你要知道,在向Caffeine写入数据时,还要求要使用传统的有界队列(ArrayQueue)来存放状态变更信息,写入带来的状态变更是无损的,不允许丢失任何状态。这是考虑到许多状态的默认值必须通过写入操作来完成初始化,因此写入会有一定的性能损失。根据Caffeine官方给出的数据,相比ConcurrentHashMap,Caffeine在写入时大约会慢10%左右。
|
||||
|
||||
|
||||
好,说完了吞吐量,我们接着来看看缓存的第二个属性:命中率。
|
||||
|
||||
命中率与淘汰策略
|
||||
|
||||
有限的物理存储,决定了任何缓存的容量都不可能是无限的,所以缓存需要在消耗空间与节约时间之间取得平衡,这就要求缓存必须能够自动、或者由人工淘汰掉缓存中的低价值数据。不过,由人工管理的缓存淘汰主要取决于开发者如何编码,不能一概而论,所以这里我们就只讨论由缓存自动进行淘汰的情况。
|
||||
|
||||
|
||||
这里我所说的“缓存如何自动地实现淘汰低价值目标”,现在也被称之为缓存的淘汰策略,或者是替换策略、清理策略。
|
||||
|
||||
|
||||
那么,在缓存实现自动淘汰低价值数据的容器之前,我们首先要定义,怎样的数据才算是“低价值”的数据。
|
||||
|
||||
由于缓存的通用性,这个问题的答案必须是与具体业务逻辑无关的,所以我们只能从缓存工作过程中收集到的统计结果,来确定数据是否有价值。这个通用的统计结果包括但不限于数据何时进入缓存、被使用过多少次、最近什么时候被使用,等等。
|
||||
|
||||
这就由此决定了,一旦确定了选择何种统计数据,以及如何通用地、自动地判定缓存中每个数据价值高低,也就相当于决定了缓存的淘汰策略是如何实现的。
|
||||
|
||||
那么目前,最基础的淘汰策略实现方案主要有三种,我来一一给你介绍下。
|
||||
|
||||
第一种:FIFO(First In First Out)
|
||||
|
||||
即优先淘汰最早进入被缓存的数据。FIFO的实现十分简单,但一般来说,它并不是优秀的淘汰策略,因为越是频繁被用到的数据,往往越会早早地被存入缓存之中。所以如果采用这种淘汰策略,很可能会大幅降低缓存的命中率。
|
||||
|
||||
第二种:LRU(Least Recent Used)
|
||||
|
||||
即优先淘汰最久未被使用访问过的数据。LRU通常会采用HashMap加LinkedList的双重结构(如LinkedHashMap)来实现。也就是,它以HashMap来提供访问接口,保证常量时间复杂度的读取性能;以LinkedList的链表元素顺序来表示数据的时间顺序,在每次缓存命中时,把返回对象调整到LinkedList开头,每次缓存淘汰时从链表末端开始清理数据。
|
||||
|
||||
所以你也能发现,对大多数的缓存场景来说,LRU都明显要比FIFO策略合理,尤其适合用来处理短时间内频繁访问的热点对象。但相反它的问题是,如果一些热点数据在系统中经常被频繁访问,但最近一段时间因为某种原因未被访问过,那么这时,这些热点数据依然要面临淘汰的命运,LRU依然可能错误淘汰掉价值更高的数据。
|
||||
|
||||
第三种:LFU(Least Frequently Used)
|
||||
|
||||
即优先淘汰最不经常使用的数据。LFU会给每个数据添加一个访问计数器,每访问一次就加1,当需要淘汰数据的时候,就清理计数器数值最小的那批数据。
|
||||
|
||||
LFU可以解决前面LRU中,热点数据间隔一段时间不访问就被淘汰的问题,但同时它又引入了两个新的问题。
|
||||
|
||||
第一个问题是需要对每个缓存的数据专门去维护一个计数器,每次访问都要更新,在前面讲“吞吐量”的时候,我也解释了这样做会带来高昂的维护开销;第二个问题是不便于处理随时间变化的热度变化,比如某个曾经频繁访问的数据现在不需要了,它也很难自动被清理出缓存。
|
||||
|
||||
可见,缓存淘汰策略会直接影响缓存的命中率,没有一种策略是完美的、能够满足全部系统所需的。
|
||||
|
||||
不过,随着淘汰算法的发展,近几年的确出现了许多相对性能要更好、也更为复杂的新算法。下面我就以LFU分支为例,针对它存在的这两个问题,给你讲讲近年来提出的TinyLFU和W-TinyLFU算法,都分别带来了什么样的优化效果。
|
||||
|
||||
|
||||
TinyLFU(Tiny Least Frequently Used)
|
||||
|
||||
|
||||
TinyLFU是LFU的改进版本。为了缓解LFU每次访问都要修改计数器所带来的性能负担,TinyLFU首先采用Sketch结构,来分析访问数据。
|
||||
|
||||
所谓的Sketch,它实际上是统计学中的概念,即指用少量的样本数据来估计全体数据的特征。这种做法显然牺牲了一定程度的准确性,但是只要样本数据与全体数据具有相同的概率分布,Sketch得出的结论仍不失为一种在高效与准确之间做好权衡的有效结论。
|
||||
|
||||
所以,借助Count–Min Sketch算法(可以看作是布隆过滤器的一种等价变种结构),TinyLFU可以用相对小得多的记录频率和空间,来近似地找出缓存中的低价值数据。
|
||||
|
||||
另外,为了解决LFU不便于处理随时间变化的热度变化问题,TinyLFU采用了基于“滑动时间窗”(在第38讲中我们会更详细地分析这种算法)的热度衰减算法。简单理解就是每隔一段时间,便会把计数器的数值减半,以此解决“旧热点”数据难以清除的问题。
|
||||
|
||||
|
||||
W-TinyLFU(Windows-TinyLFU)
|
||||
|
||||
|
||||
W-TinyLFU又是TinyLFU的改进版本。TinyLFU在实现减少计数器维护频率的同时,也带来了无法很好地应对稀疏突发访问的问题。
|
||||
|
||||
所谓的稀疏突发访问,是指有一些绝对频率较小,但突发访问频率很高的数据,比如某些运维性质的任务,也许一天、一周只会在特定时间运行一次,其余时间都不会用到,那么此时TinyLFU就很难让这类元素通过Sketch的过滤,因为它们无法在运行期间积累到足够高的频率。
|
||||
|
||||
而应对短时间的突发访问是LRU的强项,因此W-TinyLFU就结合了LRU和LFU两者的优点。从整体上看,它是LFU策略,从局部实现上看,它又是LRU策略。
|
||||
|
||||
怎么理解这个“整体”和“局部”呢?
|
||||
|
||||
W-TinyLFU的具体做法是,把新记录暂时放入一个名为Window Cache的前端LRU缓存里面,让这些对象可以在Window Cache中累积热度,如果能通过TinyLFU的过滤器,再进入名为Main Cache的主缓存中存储。
|
||||
|
||||
主缓存根据数据的访问频繁程度,分为了不同的段(LFU策略,实际上W-TinyLFU只分了两段),但单独某一段从局部来看,又是基于LRU策略去实现的(称为Segmented LRU)。每当前一段缓存满了之后,就会将低价值数据淘汰到后一段中去存储,直至最后一段也满了之后,该数据就彻底清理出缓存。
|
||||
|
||||
当然,只靠这种简单的、有限的介绍,你不一定能完全理解TinyLFU和W-TinyLFU的工作原理,但是你肯定能看出来,这些改进算法比起原来基础版本的LFU要复杂许多。
|
||||
|
||||
有时候,为了取得理想的效果,采用较为复杂的淘汰策略只是不得已的选择。
|
||||
|
||||
除了W-TinyLFU之外,Caffeine官方还制定了另外两种高级淘汰策略,ARC(Adaptive Replacement Cache)和LIRS(Low Inter-Reference Recency Set)。这里你可以看看这三种新的淘汰策略与基础的LFU策略之间的命中率对比:
|
||||
|
||||
|
||||
|
||||
几种淘汰算法在搜索场景下的命中率对比
|
||||
|
||||
在搜索场景中,三种高级策略的命中率比较为接近于理想曲线(Optimal),而LRU则差距最远。另外,在Caffeine官方给出的数据库、网站、分析类等应用场景中,这几种策略之间的绝对差距也不完全一样,但相对排名基本上没有改变,最基础的淘汰策略的命中率是最低的。如果你对其他缓存淘汰策略感兴趣的话,可以参考维基百科中对Cache Replacement Policies的介绍。
|
||||
|
||||
好,最后我们再来看看服务端缓存的第三种属性,也就是它提供的一些额外的管理功能。
|
||||
|
||||
扩展功能
|
||||
|
||||
一般来说,一套标准的Map接口(或者是来自JSR 107的javax.cache.Cache接口)就可以满足缓存访问的基本需要,不过在“访问”之外,专业的缓存往往还会提供很多额外的功能。
|
||||
|
||||
|
||||
加载器
|
||||
|
||||
|
||||
许多缓存都有“CacheLoader”之类的设计,加载器可以让缓存从只能被动存储外部放入的数据,变为能够主动通过加载器去加载指定Key值的数据,加载器也是实现自动刷新功能的基础前提。
|
||||
|
||||
|
||||
淘汰策略
|
||||
|
||||
|
||||
有的缓存淘汰策略是固定的,也有一些缓存可以支持用户根据自己的需要,来选择不同的淘汰策略。
|
||||
|
||||
|
||||
失效策略
|
||||
|
||||
|
||||
失效策略就是要求缓存的数据在一定时间后自动失效(移除出缓存)或者自动刷新(使用加载器重新加载)。
|
||||
|
||||
|
||||
事件通知
|
||||
|
||||
|
||||
缓存可能会提供一些事件监听器,让你在数据状态变动(如失效、刷新、移除)时进行一些额外操作。有的缓存还提供了对缓存数据本身的监视能力(Watch功能)。
|
||||
|
||||
|
||||
并发级别
|
||||
|
||||
|
||||
对于通过分段加锁来实现的缓存(以Guava Cache为代表),往往会提供并发级别的设置。
|
||||
|
||||
这里你可以简单地理解为,缓存内部是使用多个Map来分段存储数据的,并发级别就用于计算出使用Map的数量。如果这个参数设置过大,会引入更多的Map,你需要额外维护这些Map而导致更大的时间和空间上的开销;而如果设置过小,又会导致在访问时产生线程阻塞,因为多个线程更新同一个ConcurrentMap的同一个值时会产生锁竞争。
|
||||
|
||||
|
||||
容量控制
|
||||
|
||||
|
||||
缓存通常都支持指定初始容量和最大容量。设定初始容量的目的是减少扩容频率,这与Map接口本身的初始容量含义是一致的;而最大容量类似于控制Java堆的-Xmx参数,当缓存接近最大容量时,会自动清理掉低价值的数据。
|
||||
|
||||
|
||||
引用方式
|
||||
|
||||
|
||||
Java语言支持将数据设置为软引用或者弱引用,而提供引用方式的设置,就是为了将缓存与Java虚拟机的垃圾收集机制联系起来。
|
||||
|
||||
|
||||
统计信息
|
||||
|
||||
|
||||
缓存框架会提供诸如缓存命中率、平均加载时间、自动回收计数等统计信息。
|
||||
|
||||
|
||||
持久化
|
||||
|
||||
|
||||
也就是支持将缓存的内容存储到数据库或者磁盘中。进程内缓存提供持久化功能的作用不是太大,但分布式缓存大多都会考虑提供持久化功能。
|
||||
|
||||
小结
|
||||
|
||||
今天这节课,我给你介绍了缓存的三项属性:吞吐量、命中率和扩展功能。为了便于你回顾知识点,我把目前几款主流的进程内缓存方案整理成了一个表格,供你参考。
|
||||
|
||||
|
||||
|
||||
那么总的来说,表格里的四类就基本囊括了目前主流的进程内缓存方案。希望通过这节课的学习,你能够掌握服务端缓存的原理,能够独立分析各种缓存框架所提供的功能属性,明白它们有什么影响,有什么收益和代价。
|
||||
|
||||
一课一思
|
||||
|
||||
在这节课的开篇我就提到了缓存并非多多益善,引用它有收益也有风险。那么请你思考一下,缓存可能存在什么风险弊端?欢迎在留言区分享你的见解。这也是我们下一节课的主要话题。
|
||||
|
||||
好,感谢你的阅读,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。就到这里,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
199
专栏/周志明的架构课/22_分布式缓存如何与本地缓存配合,提高系统性能?.md
Normal file
199
专栏/周志明的架构课/22_分布式缓存如何与本地缓存配合,提高系统性能?.md
Normal file
@@ -0,0 +1,199 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 _ 分布式缓存如何与本地缓存配合,提高系统性能?
|
||||
你好,我是周志明。
|
||||
|
||||
今天,我们接着上节课服务端缓存的话题,继续来学习下分布式缓存的实现形式、与本地缓存搭配使用的方法,以及一起来了解下,在实际使用缓存的过程中,可能会存在的各种风险和应对手段。
|
||||
|
||||
分布式缓存
|
||||
|
||||
首先通过上节课的学习,现在我们已经知道了,服务端缓存可以分为“进程内缓存”和“分布式缓存”两大类。相比缓存数据在进程内存中读写的速度,一旦涉及到了网络访问,那么由网络传输、数据复制、序列化和反序列化等操作所导致的延迟,就要比内存访问高得多。
|
||||
|
||||
所以,对于分布式缓存来说,处理与网络有关的操作是影响吞吐量的主要因素,这也是比淘汰策略、扩展功能更重要的关注点。
|
||||
|
||||
而这就决定了,尽管也有Ehcache、Infinispan这类能同时支持分布式部署和进程内嵌部署的缓存方案,但在通常情况下,进程内缓存和分布式缓存在选型时,会有完全不同的候选对象和考察点。
|
||||
|
||||
所以说,我们在决定使用哪种分布式缓存之前,必须先确认好自己的需求是什么。
|
||||
|
||||
那么接下来,我们就从两个不同的需求场景出发,看看都可以选择哪些分布式缓存方案。我们先从数据访问的需求场景开始了解吧。
|
||||
|
||||
复制式缓存与集中式缓存
|
||||
|
||||
从访问的角度来说,如果是频繁更新但很少读取的数据,正常是不会有人把它拿去做缓存的,因为这样做没有收益。
|
||||
|
||||
然后,对于很少更新但频繁读取的数据,理论上更适合做复制式缓存;而对于更新和读取都较为频繁的数据,理论上就更适合做集中式缓存。
|
||||
|
||||
所以在这里,我就针对这两种比较通用的缓存形式,给你介绍一下二者之间的差别,以及各自具有代表性的产品。
|
||||
|
||||
|
||||
复制式缓存
|
||||
|
||||
|
||||
对于复制式缓存,你可以看作是“能够支持分布式的进程内缓存”,它的工作原理与Session复制类似:缓存中的所有数据,在分布式集群的每个节点里面都存有一份副本,当读取数据时,无需网络访问,直接从当前节点的进程内存中返回,因此理论上可以做到与进程内缓存一样高的读取性能;而当数据发生变化的时候,就必须遵循复制协议,将变更同步到集群的每个节点中,这时,复制性能会随着节点的增加呈现平方级下降,变更数据的代价就会变得十分高昂。
|
||||
|
||||
复制式缓存的代表是JBossCache,这是JBoss针对企业级集群设计的缓存方案,它可以支持JTA事务,依靠JGroup进行集群节点间数据同步。
|
||||
|
||||
以JBossCache为典型的复制式缓存,曾经有过一段短暂的兴盛期,但是在今天,我们基本上已经很难再见到使用这种缓存形式的大型信息系统了。
|
||||
|
||||
为什么今天JBossCache会被淘汰掉呢?
|
||||
|
||||
主要是因为JBossCache的写入性能实在是差到了不堪入目的程度,它在小规模集群中同步数据还算是差强人意,但在大规模集群下,动辄就会因为网络同步的速度跟不上写入速度,进而导致在内存中累计大量待重发对象,最终引发OutOfMemory崩溃。如果我们对JBossCache没有足够了解的话,稍有不慎就会被埋进坑里。
|
||||
|
||||
后来,为了缓解复制式同步的写入效率问题,JBossCache的继任者Infinispan提供了另一种分布式同步模式。它允许用户配置数据需要复制的副本数量,比如集群中有八个节点,我们可以要求每个数据只保存四份副本,这样就降低了复制数据时的网络负担。
|
||||
|
||||
此时,缓存的总容量就相当于是传统复制模式的一倍,如果要访问的数据在本地缓存中没有存储,Infinispan完全有能力感知网络的拓扑结构,知道应该到哪些节点中寻找数据。
|
||||
|
||||
|
||||
集中式缓存
|
||||
|
||||
|
||||
集中式缓存是目前分布式缓存的主流形式。集中式缓存的读、写都需要网络访问,它的好处是不会随着集群节点数量的增加而产生额外的负担,而坏处自然是读、写都不可能再达到进程内缓存那样的高性能。
|
||||
|
||||
集中式缓存还有一个必须提到的关键特点,那就是它与使用缓存的应用分处在独立的进程空间中。
|
||||
|
||||
这样做的好处是它能够为异构语言提供服务,比如用C语言编写的Memcached完全可以毫无障碍地为Java语言编写的应用提供缓存服务;但坏处是如果要缓存像对象这种复杂类型的话,基本上就只能靠序列化来支撑具体语言的类型系统了(支持Hash类型的缓存,可以部分模拟对象类型)。这样就不仅产生了序列化的成本,还很容易导致传输成本的大幅增加。
|
||||
|
||||
我举个例子,假设某个有100个字段的大对象变更了其中1个字段的值,通常缓存也不得不把整个对象的所有内容重新序列化传输出去,才能实现更新。所以,一般集中式缓存更提倡直接缓存原始数据类型,而不是对象。
|
||||
|
||||
相比之下,JBossCache则通过它的字节码自审(Introspection)功能和树状存储结构(TreeCache),做到了自动跟踪、处理对象的部分变动。如果用户修改了对象中某些字段的数据,缓存就只会同步对象中真正变更的那部分数据。
|
||||
|
||||
不过现在,因为Redis在集中式缓存中处于统治地位,已经打败了Memcached和其他集中式缓存框架,成为了集中式缓存的首选,甚至可以说成为了分布式缓存的首选,几乎到了不用管读取、写入哪种操作更频繁,都可以无脑上Redis的程度。
|
||||
|
||||
也正是因为如此,前面我在说到哪些数据适合用复制式缓存、哪些数据适合用集中式缓存的时候,我都加了个拗口的“理论上”。尽管Redis最初设计的本意是NoSQL数据库,而不是专门用来做缓存的,可今天它确实已经成为许多分布式系统中不可或缺的基础设施,被广泛用作缓存的实现方案。
|
||||
|
||||
而另一方面,访问缓存不仅仅要考虑如何快速取到数据,还需要考虑取到的是否是正确的数据,缓存的数据质量是另一个重要的考量因素。
|
||||
|
||||
从数据一致性的角度来说,缓存本身也有集群部署的需求。所以在理论上,我们需要好好考虑一下,如果不同的节点取到的缓存数据不一样,我们是否可以接受。比如说,我们刚刚放入缓存中的数据,另外一个节点马上访问发现未能读到;或者刚刚更新缓存中的数据,另外一个节点访问时,在短时间内读取到的仍是旧的数据,等等。
|
||||
|
||||
那么,根据分布式缓存集群是否能保证数据一致性,我们可以将它分为AP和CP两种类型(在“分布式事务”中已经介绍过CAP各自的含义)。
|
||||
|
||||
你可以发现,这里我又说的是“理论上”,这是因为我们在实际开发中,通常不太会使用缓存来处理追求强一致性的数据。当然我们是可以这样做,但其实没必要(可类比MESI等缓存一致性协议)。
|
||||
|
||||
给你举个例子。Redis集群就是典型的AP式,它具有高性能、高可用等特点,但它却并不保证强一致性。而能够保证强一致性的ZooKeeper、Doozerd、Etcd等分布式协调框架,我们可通常不会把它们当作“缓存框架”来使用,这些分布式协调框架的吞吐量相对Redis来说,是非常有限的。不过,ZooKeeper、Doozerd、Etcd倒是常跟Redis和其他分布式缓存搭配工作,用来实现其中的通知、协调、队列、分布式锁等功能。
|
||||
|
||||
透明多级缓存
|
||||
|
||||
那到这里,你也能发现,分布式缓存与进程内缓存各有所长,也有各有局限,它们是互补的,而不是竞争的关系。所以如果你有需要,完全可以同时互相搭配进程内缓存和分布式缓存,来构成透明多级缓存(Transparent Multilevel Cache,TMC)。
|
||||
|
||||
这里,我们先不去考虑“透明”这个词的定义是啥,单看“多级缓存”的话,倒还很好理解。
|
||||
|
||||
它的意思就是,使用进程内缓存做一级缓存,分布式缓存做二级缓存,如果能在一级缓存中查询到结果就直接返回,否则就到二级缓存中去查询;再将二级缓存中的结果回填到一级缓存,以后再访问该数据就没有网络请求了。
|
||||
|
||||
而如果二级缓存也查询不到,就发起对最终数据源的查询,将结果回填到一、二级缓存中去。
|
||||
|
||||
|
||||
|
||||
不过,尽管多级缓存结合了进程内缓存和分布式缓存的优点,但它的代码侵入性较大,需要由开发者承担多次查询、多次回填的工作,也不便于管理,像是超时、刷新等策略,都要设置多遍,数据更新更是麻烦,很容易会出现各个节点的一级缓存、二级缓存里的数据互相不一致的问题。
|
||||
|
||||
所以,我们必须“透明”地解决这些问题,多级缓存才具有实用的价值。
|
||||
|
||||
一种常见的设计原则,就是变更以分布式缓存中的数据为准,访问以进程内缓存的数据优先。
|
||||
|
||||
大致做法是当数据发生变动时,在集群内发送推送通知(简单点的话可以采用Redis的PUB/SUB,求严谨的话可以引入ZooKeeper或Etcd来处理),让各个节点的一级缓存自动失效掉相应数据。
|
||||
|
||||
然后,当访问缓存时,缓存框架提供统一封装好的一、二级缓存联合查询接口,接口外部只查询一次,接口内部自动实现优先查询一级缓存。如果没有获取到数据,就再自动查询二级缓存。
|
||||
|
||||
缓存风险
|
||||
|
||||
OK,现在,你也对不同需求场景下的不同分布式缓存实现方案有大概的了解了。而在上一节课开头,我提到过缓存并不是多多益善,它有利也有弊,是要真正到必要的时候才去考虑的解决方案。因此接下来,我就带你详细了解一下使用缓存的各种常见风险和注意事项,以及应对风险的方法。
|
||||
|
||||
缓存穿透
|
||||
|
||||
我们知道,引入缓存的目的是为了缓解CPU或者I/O的压力,比如对数据库做缓存,大部分流量都从缓存中直接返回,只有缓存未能命中的数据请求才会流到数据库中,数据库压力自然就减小了。
|
||||
|
||||
但是如果查询的数据在数据库中根本不存在的话,缓存里自然也不会有。这样,这类请求的流量每次都不会命中,每次都会触及到末端的数据库,缓存自然也就起不到缓解压力的作用了。那么,这种查询不存在数据的现象,就被称为缓存穿透。
|
||||
|
||||
缓存穿透有可能是业务逻辑本身就存在的固有问题,也有可能是被恶意攻击的所导致的。所以,为了解决缓存穿透,我们一般会采取下面两种办法:
|
||||
|
||||
|
||||
对于业务逻辑本身就不能避免的缓存穿透
|
||||
|
||||
|
||||
我们可以约定在一定时间内,对返回为空的Key值依然进行缓存(注意是正常返回但是结果为空,不要把抛异常的也当作空值来缓存了),这样在一段时间内,缓存就最多被穿透一次。
|
||||
|
||||
如果后续业务在数据库中对该Key值插入了新记录,那我们就应当在插入之后主动清理掉缓存的Key值。如果业务时效性允许的话,也可以设置一个较短的超时时间来自动处理缓存。
|
||||
|
||||
|
||||
对于恶意攻击导致的缓存穿透
|
||||
|
||||
|
||||
针对这种原因,我们通常会在缓存之前设置一个布隆过滤器来解决。所谓的恶意攻击是指,请求者刻意构造数据库中肯定不存在的Key值,然后发送大量请求进行查询。而布隆过滤器是用最小的代价,来判断某个元素是否存在于某个集合的办法。
|
||||
|
||||
如果布隆过滤器给出的判定结果是请求的数据不存在,那就直接返回即可,连缓存都不必去查。虽然维护布隆过滤器本身需要一定的成本,但比起攻击造成的资源损耗,还是比较值得的。
|
||||
|
||||
缓存击穿
|
||||
|
||||
我们都知道,缓存的基本工作原理是首次从真实数据源加载数据,完成加载后回填入缓存,以后其他相同的请求就从缓存中获取数据,缓解数据源的压力。
|
||||
|
||||
但是,如果缓存中的某些热点数据忽然因为某种原因失效了,比如典型地由于超期而失效,而此时又有多个针对该数据的请求同时发送过来,那么这些请求就会全部未能命中缓存,都到达真实数据源中去,导致其压力剧增。这种现象,就被称为缓存击穿。
|
||||
|
||||
所以,要如何避免缓存击穿问题呢?我们通常可以采取这样两种办法:
|
||||
|
||||
|
||||
加锁同步。以请求该数据的Key值为锁,这样就只有第一个请求可以流入到真实的数据源中,其他线程采取阻塞或重试策略。如果是进程内缓存出现了问题,施加普通互斥锁就可以了;如果是分布式缓存中出现的问题,就施加分布式锁,这样数据源就不会同时收到大量针对同一个数据的请求了。
|
||||
热点数据由代码来手动管理。缓存击穿是只针对热点数据被自动失效才引发的问题,所以对于这类数据,我们可以直接通过代码来有计划地完成更新、失效,避免由缓存的策略自动管理。
|
||||
|
||||
|
||||
缓存雪崩
|
||||
|
||||
现在我们了解了,缓存击穿是针对单个热点数据失效,由大量请求击穿缓存而给真实数据源带来了压力。
|
||||
|
||||
而另一种可能更普遍的情况,是不需要针对单个热点数据的大量请求,而是由于大批不同的数据在短时间内一起失效,导致了这些数据的请求都击穿了缓存,到达数据源,这同样也会令数据源在短时间内压力剧增。
|
||||
|
||||
那么,之所以会出现这种情况,往往是因为系统有专门的缓存预热功能,也可能是因为,大量的公共数据都是由某一次冷操作加载的,这样都可能会出现由此载入缓存的大批数据具有相同的过期时间,在同一时刻一起失效。
|
||||
|
||||
还有一种情况是缓存服务由于某些原因崩溃后重启,此时也会造成大量数据同时失效。那么以上出现的这种现象,就被称为缓存雪崩。
|
||||
|
||||
而要避免缓存雪崩的问题,我们通常可以采取这三种办法:
|
||||
|
||||
|
||||
提升缓存系统可用性,建设分布式缓存的集群。
|
||||
启用透明多级缓存,各个服务节点的一级缓存中的数据,通常会具有不一样的加载时间,这样做也就分散了它们的过期时间。
|
||||
将缓存的生存期从固定时间改为一个时间段内的随机时间,比如原本是一个小时过期,那可以在缓存不同数据时,设置生存期为55分钟到65分钟之间的某个随机时间。
|
||||
|
||||
|
||||
缓存污染
|
||||
|
||||
所谓的缓存污染是指,缓存中的数据与真实数据源中的数据不一致的现象。尽管我在前面有说过,缓存通常不追求强一致性,但这显然不能等同于,缓存和数据源间连最终的一致性都可以不要求了。
|
||||
|
||||
缓存污染多数是因为开发者更新缓存不规范造成的。比如说,你从缓存中获得了某个对象,更新了对象的属性,但最后因为某些原因,比如后续业务发生异常回滚了,最终没有成功写入到数据库,此时缓存的数据是新的,而数据库中的数据是旧的。
|
||||
|
||||
所以,为了尽可能地提高使用缓存时的一致性,人们已经总结了不少更新缓存时可以遵循的设计模式,比如Cache Aside、Read/Write Through、Write Behind Caching,等等。
|
||||
|
||||
这里,我想给你介绍下Cache Aside模式,因为这种设计模式最简单,成本也最低。它的主要内容只有两条:
|
||||
|
||||
|
||||
读数据时,先读缓存,缓存没有的话,再读数据源,然后将数据放入缓存,再响应请求。
|
||||
写数据时,先写数据源,然后失效(而不是更新)掉缓存。
|
||||
|
||||
|
||||
在读数据方面,一般不会有什么出错的余地。但是写数据时,我有必要专门给你强调两点。
|
||||
|
||||
一个是先后顺序一定要先数据源后缓存。你试想一下,如果采用先失效缓存后写数据源的顺序,那一定会存在一段时间内缓存已经删除完毕,但数据源还未修改完成的情况。此时新的查询请求到来,缓存未能命中,就会直接流到真实数据源中。
|
||||
|
||||
这样,请求读到的数据依然是旧数据,随后又重新回填到缓存中。而当数据源修改完成后,结果就成了数据在数据源中是新的,在缓存中是老的,两者就会有不一致的情况。
|
||||
|
||||
二个是应当失效缓存,而不是尝试去更新缓存。这很容易理解,如果去更新缓存,更新过程中数据源又被其他请求再次修改的话,缓存又要面临处理多次赋值的复杂时序问题。所以直接失效缓存,等下次用到该数据时自动回填,期间数据源中的值无论被改了多少次,都不会造成任何影响。
|
||||
|
||||
不过,Cache Aside模式依然也不能保证在一致性上绝对不出问题,否则我们就不需要设计出Paxos这样复杂的共识算法了。采用Cache Aside模式典型的出错场景,就是如果某个数据是从未被缓存过的,请求会直接流到真实数据源中,如果数据源中的写操作发生在查询请求之后,结果回填到缓存之前,也会出现缓存中回填的内容与数据库的实际数据不一致的情况。
|
||||
|
||||
但是,出现这种情况的概率实际上是很低的,Cache Aside模式仍然是以低成本更新缓存,并且获得相对可靠结果的解决方案。
|
||||
|
||||
小结
|
||||
|
||||
今天这一讲,我着重给你介绍了两种主要的分布式缓存形式,分别是复制式缓存和集中式缓存。其中我强调了,在选择使用不同缓存方案的时候,你需要注意对读效率和写效率,以及对访问效率和数据质量之间的权衡。而在实际的应用场景中,你其实可以考虑选择将两种缓存结合使用,构成透明多级缓存,以此达到各取所长的目的。
|
||||
|
||||
最后,在为系统引入缓存的时候,你还要特别注意可能会出现的风险问题,比如说缓存穿透、缓存击穿、缓存雪崩、缓存污染,等等。如果你对这些可能出现的风险问题有了一定的准备和应对方案,那么可以说,你基本上算是对服务端缓存建立了基本的整体认知了。
|
||||
|
||||
一课一思
|
||||
|
||||
不知道你还记不记得,在第16讲中,我提出过一个观点“能满足需求的前提下,最简单的系统就是最好的系统”。现在,你已经学完了“透明多级分流系统”这个小章节的所有内容,那么你对这个判定有什么新的看法吗?
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
||||
274
专栏/周志明的架构课/23_认证:系统如何正确分辨操作用户的真实身份?.md
Normal file
274
专栏/周志明的架构课/23_认证:系统如何正确分辨操作用户的真实身份?.md
Normal file
@@ -0,0 +1,274 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 _ 认证:系统如何正确分辨操作用户的真实身份?
|
||||
你好,我是周志明。
|
||||
|
||||
我们应该都很清楚,对于软件研发来说,即使只限定在“软件架构设计”这个语境下,系统安全仍然是一个很大的话题。它不仅包括“防御系统被黑客攻击”这样狭隘的安全,还包括一些与管理、运维、审计等领域主导的相关安全性问题,比如说安全备份与恢复、安全审计、防治病毒,等等。
|
||||
|
||||
不过在这门课程里,我们的关注重点并不会放在以上这些内容上,我们所谈论的软件架构安全,主要包括(但不限于)以下这些问题的具体解决方案:
|
||||
|
||||
|
||||
认证(Authentication):系统如何正确分辨出操作用户的真实身份?
|
||||
授权( Authorization):系统如何控制一个用户该看到哪些数据、能操作哪些功能?
|
||||
凭证(Credentials):系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
|
||||
保密(Confidentiality):系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
|
||||
传输(Transport Security):系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?
|
||||
验证(Verification):系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险?
|
||||
|
||||
|
||||
由于跟安全相关的问题,一般都不会给架构设计直接创造价值,而且解决起来又很繁琐复杂、费时费力,所以可能会经常性地被一部分开发人员给有意无意地忽略掉。
|
||||
|
||||
不过庆幸的是,这些问题基本上也都是与具体系统、具体业务无关的通用性问题,这就意味着它们往往会存在一些业界通行的、已经被验证过是行之有效的解决方案,乃至已经形成了行业标准,不需要我们再从头去构思如何解决。
|
||||
|
||||
所以,在“安全架构”这个小章节里,我会花六讲的时间,围绕系统安全的标准方案,带你逐一探讨以上这些问题的处理办法,并会以Fenix’s Bookstore作为案例实践。而出于方便你进行动手实操的目的,我不会在课程中直接贴出大段的项目代码(当然必要的代码示例还是会有的),所以我建议你要结合着从Fenix’s Bookstore的GitHub仓库中获取的示例代码来进行学习。
|
||||
|
||||
好,那么今天这节课,我们就从“认证”这个话题开始,一起来解决“系统如何正确分辨操作用户的真实身份”这个问题。
|
||||
|
||||
什么是认证?
|
||||
|
||||
认证(Authentication)、授权(Authorization)和凭证(Credentials)这三项可以说是一个系统中最基础的安全设计了,哪怕是再简陋的信息系统,大概也不可能忽略掉“用户登录”这个功能。
|
||||
|
||||
信息系统在为用户提供服务之前,总是希望先弄清楚“你是谁?”(认证)、“你能干什么?”(授权)以及“你如何证明?”(凭证)这三个基本问题的答案。然而,认证、授权与凭证这三个基本问题,又并不像部分开发者认为的那样,只是一个“系统登录”功能而已,仅仅是校验一下用户名、密码是否正确这么简单。
|
||||
|
||||
账户和权限信息作为一种必须最大限度保障安全和隐私,同时又要兼顾各个系统模块、甚至是系统间共享访问的基础主数据,它的存储、管理与使用都面临一系列复杂的问题。
|
||||
|
||||
因此,对于某些大规模的信息系统,账户和权限的管理往往要由专门的基础设施来负责,比如微软的活动目录(Active Directory,AD)或者轻量目录访问协议(Lightweight Directory Access Protocol,LDAP),跨系统的共享使用问题甚至还会用到区块链技术来解决。
|
||||
|
||||
另外,还有一个不少人会先入为主的认知偏差:尽管“认证”是解决“你是谁?”的问题,但这里的“你”并不一定是个人(真不是在骂你),也很有可能是指外部的代码,即第三方的类库或者服务。
|
||||
|
||||
因为最初在计算机软件当中,对代码认证的重要程度甚至要高于对最终用户的认证,比如早期的Java系统里,安全中的认证默认是特指“代码级安全”,即你是否信任要在你的电脑中运行的代码。
|
||||
|
||||
这是由Java当时的主要应用形式Java Applets所决定的:类加载器从远端下载一段字节码,以Applets的形式在用户的浏览器中运行,由于Java的语言操控计算机资源的能力要远远强于JavaScript,所以系统必须要先确保这些代码不会损害用户的计算机,否则就谁都不敢去用。
|
||||
|
||||
这一阶段的安全观念,就催生了现在仍然存在于Java技术体系中的“安全管理器”(java.lang.SecurityManager)、“代码权限许可”(java.lang.RuntimePermission)等概念。到了现在,系统对外部类库和服务的认证需求依然很普遍,但相比起五花八门的最终用户认证来说,代码认证的研究发展方向已经很固定了,基本上都是统一到证书签名上。
|
||||
|
||||
不过在咱们这节课里,对认证的探究范围只限于对最终用户的认证。关于对代码的认证,我会安排在“分布式的基石”模块中的第40讲“服务安全”来讲解。
|
||||
|
||||
好,那么在理解了什么是认证、界定了认证的范围之后,我们接下来看一下软件工业界是如何进行认证的。
|
||||
|
||||
认证的标准
|
||||
|
||||
在世纪之交,Java迎来了Web时代的辉煌,互联网的迅速兴起促使Java进入了快速发展时期。这时候,基于HTML和JavaScript的超文本Web应用,就迅速超过了“Java 2时代”之前的Java Applets应用,B/S系统对最终用户认证的需求,使得“安全认证”的重点逐渐从“代码级安全”转为了“用户级安全”,即你是否信任正在操作的用户。
|
||||
|
||||
在1999年,随J2EE 1.2(它是J2EE的首个版本,为了与J2SE同步,初始版本号直接就是1.2)一起发布的Servlet 2.2中,添加了一系列用于认证的API,主要包括了两部分内容:
|
||||
|
||||
|
||||
标准方面,添加了四种内置的、不可扩展的认证方案,即Client-Cert、Basic、Digest和Form。
|
||||
实现方面,添加了与认证和授权相关的一套程序接口,比如HttpServletRequest::isUserInRole()、HttpServletRequest::getUserPrincipal()等方法。
|
||||
|
||||
|
||||
到这儿你可能会觉得,这都是一项发布超过20年的老旧技术了,为啥还要专门提一嘴呢?这是因为,我希望从它包含的两部分内容中,引出一个架构安全性的经验原则:以标准规范为指导、以标准接口去实现。
|
||||
|
||||
因为安全涉及的问题很麻烦,但它的解决方案也相当的成熟。对于99%的系统来说,在安全上不去做轮子,不去想发明创造,严格遵循标准就是最恰当的安全设计。
|
||||
|
||||
然后,我之所以引用J2EE 1.2对安全的改进,还有一个原因,就是它内置支持的Basic、Digest、Form和Client-Cert四种认证方案都很有代表性,刚好分别覆盖了通讯信道、协议和内容层面的认证,这三种层面的认证又涵盖了主流的三种认证方式,下面我们分别来看看它们各自的含义和应用场景:
|
||||
|
||||
|
||||
通讯信道上的认证:你和我建立通讯连接之前,要先证明你是谁。在网络传输(Network)场景中的典型是基于SSL/TLS传输安全层的认证。
|
||||
通讯协议上的认证:你请求获取我的资源之前,要先证明你是谁。在互联网(Internet)场景中的典型是基于HTTP协议的认证。
|
||||
通讯内容上的认证:你使用我提供的服务之前,要先证明你是谁。在万维网(World Wide Web)场景中的典型是基于Web内容的认证。
|
||||
|
||||
|
||||
关于第一点“信道上的认证”,由于它涉及的内容较多,又与后面第28、29讲要介绍的微服务安全方面的话题关系密切,所以这节课我就不展开讲了(而且J2EE中的Client-Cert其实并不是用于TLS的,以它引出TLS并不合适)。
|
||||
|
||||
那么接下来,我们就针对后两种认证方式,来看看它们各自都有什么样的实现特点和工作流程。
|
||||
|
||||
基于通讯协议:HTTP认证
|
||||
|
||||
前面我在介绍J2EE 1.2这项老技术的时候,已经提前用到了一个技术名词,“认证方案”(Authentication Schemes)。它是指生成用户身份凭证的某种方法,这个概念最初是来源于HTTP协议的认证框架(Authentication Framework)。
|
||||
|
||||
IETF在RFC 7235中定义了HTTP协议的通用认证框架,要求所有支持HTTP协议的服务器,当未授权的用户意图访问服务端保护区域的资源时,应返回401 Unauthorized的状态码,同时要在响应报文头里,附带以下两个分别代表网页认证和代理认证的Header之一,告知客户端应该采取哪种方式,产生能代表访问者身份的凭证信息:
|
||||
|
||||
WWW-Authenticate: <认证方案> realm=<保护区域的描述信息>
|
||||
Proxy-Authenticate: <认证方案> realm=<保护区域的描述信息>
|
||||
|
||||
|
||||
而在接收到该响应后,客户端必须遵循服务端指定的认证方案,在请求资源的报文头中加入身份凭证信息,服务端核实通过后才会允许该请求正常返回,否则将返回403 Forbidden。其中,请求报文头要包含以下Header项之一:
|
||||
|
||||
Authorization: <认证方案> <凭证内容>
|
||||
Proxy-Authorization: <认证方案> <凭证内容>
|
||||
|
||||
|
||||
由此我们其实可以发现,HTTP认证框架提出的认证方案,是希望能把认证“要产生身份凭证”的目的,与“具体如何产生凭证”的实现给分开来。无论客户端是通过生物信息(指纹、人脸)、用户密码、数字证书,还是其他方式来生成凭证,都是属于如何生成凭证的具体实现,都可以包容在HTTP协议预设的框架之内。
|
||||
|
||||
HTTP认证框架的工作流程如下面的时序图所示:
|
||||
|
||||
|
||||
|
||||
不过,只有这种概念性的介绍,你可能还是会觉得有点儿枯燥和抽象,接下来我就以最基础的认证方案HTTP Basic Authentication为例,来给你解释下认证具体是如何工作的。
|
||||
|
||||
HTTP Basic认证是一种以演示为目的的认证方案,在一些不要求安全性的场合也有实际应用,比如你家里的路由器登录,有可能就是这种认证方式。
|
||||
|
||||
Basic认证产生用户身份凭证的方法是让用户输入用户名和密码,经过Base64编码“加密”后作为身份凭证。比如请求资源“GET/admin”后,浏览器会收到服务端如下响应:
|
||||
|
||||
HTTP/1.1 401 Unauthorized
|
||||
Date: Mon, 24 Feb 2020 16:50:53 GMT
|
||||
WWW-Authenticate: Basic realm="example from icyfenix.cn"
|
||||
|
||||
|
||||
此时,浏览器必须询问最终用户,要求提供用户名和密码,并会弹出类似下图所示的HTTP Basic认证窗口:
|
||||
|
||||
|
||||
|
||||
然后,用户在对话框中输入密码信息,比如输入用户名“icyfenix”,密码123456,浏览器会将字符串“icyfenix:123456”编码为“aWN5ZmVuaXg6MTIzNDU2”,然后发送给服务端,HTTP请求如下所示:
|
||||
|
||||
GET /admin HTTP/1.1
|
||||
Authorization: Basic aWN5ZmVuaXg6MTIzNDU2
|
||||
|
||||
|
||||
服务端接收到请求,解码后检查用户名和密码是否合法,如果合法就允许返回/admin的资源,否则就返回403 Forbidden禁止下一步操作。
|
||||
|
||||
这里要注意一点,Base64只是一种编码方式,而并不是任何形式的加密,所以Basic认证的风险是显而易见的,它只能是一种以演示为主要目的的认证方案。
|
||||
|
||||
那么,除Basic认证外,IETF还定义了很多种可用于实际生产环境的认证方案,比如:
|
||||
|
||||
|
||||
Digest:RFC 7616,HTTP摘要认证,你可以把它看作是Basic认证的改良版本,针对Base64明文发送的风险,Digest认证把用户名和密码加盐(一个被称为Nonce的变化值作为盐值)后,再通过MD5/SHA等哈希算法取摘要发送出去。这种认证方式依然是不安全的,无论客户端使用何种加密算法加密,无论是否采用了Nonce这样的动态盐值去抵御重放和冒认,当遇到中间人攻击时,依然存在显著的安全风险。在第27“保密”一讲中,我还会跟你具体讨论加解密方面的问题。
|
||||
Bearer:RFC 6750,基于OAuth 2.0规范来完成认证,OAuth 2.0是一个同时涉及到认证与授权的协议。在下节课讲解“授权”的时候,我会详细介绍OAuth 2.0。
|
||||
HOBA:RFC 7486 ,HOBA是HTTP Origin-Bound Authentication的缩写,这是一种基于自签名证书的认证方案。基于数字证书的信任关系主要有两类模型,一类是采用CA(Certification Authority)层次结构的模型,由CA中心签发证书;另一种是以IETF的Token Binding协议为基础的OBC(Origin Bound Certificates)自签名证书模型。同样在后面讲“传输”的时候,我会给你详细介绍数字证书。
|
||||
|
||||
|
||||
还有,在HTTP认证框架中,认证方案是允许自行扩展的,也并不要求一定要由RFC规范来定义,只要用户代理(User Agent,通常是浏览器,泛指任何使用HTTP协议的程序)能够识别这种私有的认证方案即可。
|
||||
|
||||
因此,很多厂商也扩展了自己的认证方案,比如:
|
||||
|
||||
|
||||
AWS4-HMAC-SHA256:相当简单粗暴的名字,就是亚马逊AWS基于HMAC-SHA256哈希算法的认证。
|
||||
NTLM / Negotiate:这是微软公司NT LAN Manager(NTLM)用到的两种认证方式。
|
||||
Windows Live ID:这个顾名思义即可。
|
||||
Twitter Basic:一个不存在的网站所改良的HTTP基础认证。
|
||||
……
|
||||
|
||||
|
||||
好,说完了基于通讯协议的认证方案,我们再来看看基于通讯内容的Web认证是如何实现的。
|
||||
|
||||
基于通讯内容:Web认证
|
||||
|
||||
IETF为HTTP认证框架设计了可插拔(Pluggable)的认证方案,原本是希望能涌现出各式各样的认证方案,去支持不同的应用场景。尽管前面我也列举了一些还算常用的认证方案,但目前的信息系统,尤其是在系统对终端用户的认证场景中,直接采用HTTP认证框架的比例其实是非常低的。
|
||||
|
||||
这也不难理解,HTTP是“超文本传输协议”,传输协议的根本职责是把资源从服务端传输到客户端,至于资源具体是什么内容,只能由客户端自行解析驱动。所以说,以HTTP协议为基础的认证框架,也只能面向传输协议而不是具体传输内容来设计。
|
||||
|
||||
如果用户想要从服务器中下载文件,弹出一个HTTP服务器的对话框让用户登录,是可以接受的;但如果用户访问信息系统中的具体服务,身份认证肯定希望是由系统本身的功能去完成的,而不是由HTTP服务器来负责认证。
|
||||
|
||||
那么,这种依靠内容而不是传输协议来实现的认证方式,在万维网里就被称为“Web认证”,由于在实现形式上,登录表单占了绝对的主流,因此它通常也被称为“表单认证”(Form Authentication)。
|
||||
|
||||
实际上,直到2019年之前,表单认证都没有什么行业标准可循,表单长什么样子、其中的用户字段、密码字段、验证码字段、是否要在客户端加密、采用何种方式加密、接受表单的服务地址是什么等等,都完全由服务端与客户端的开发者自行协商决定。
|
||||
|
||||
可“没有标准的约束”,反倒成了表单认证的一大优点,表单认证允许我们做出五花八门的页面,各种程序语言、框架或开发者本身,都可以自行决定认证的全套交互细节。
|
||||
|
||||
到这里你可能要说了,在前面讲认证标准的时候,我说“遵循规范、别造轮子就是最恰当的安全”,这里我又把表单认证的高自由度说成是一大优点,好话都让我给说全了。
|
||||
|
||||
其实啊,我提倡用标准规范去解决安全领域的共性问题,这条原则完全没有必要与界面是否美观合理、操作流程是否灵活便捷这些应用需求对立起来。
|
||||
|
||||
比如,想要支持密码或扫码等多种登录方式、想要支持图形验证码来驱逐爬虫与机器人、想要支持在登录表单提交之前进行必要的表单校验,等等,这些需求都很具体,不具备写入标准规范的通用性,但它们都具备足够的合理性,应当在实现层面去满足。
|
||||
|
||||
同时,如何控制权限保证不产生越权操作、如何传输信息保证内容不被窃听篡改、如何加密敏感内容保证即使泄漏也不被逆推出明文,等等,这些问题也已经有了通行的解决方案,明确定义在规范之中,因此也应当在架构层面去遵循。
|
||||
|
||||
所以说,表单认证与HTTP认证不见得是完全对立的,它们分别有不同的关注点,可以结合使用。就以Fenix’s Bootstore的登录功能为例,这个项目的页面表单是一个自行设计的Vue.js页面,但认证的整个交互过程,就遵循了OAuth 2.0规范的密码模式来完成。
|
||||
|
||||
2019年3月,万维网联盟批准了由FIDO(Fast IDentity Online,一个安全、开放、防钓鱼、无密码认证标准的联盟)领导起草的世界首份Web内容认证的标准“WebAuthn”(在这节课里,我们只讨论WebAuthn,不会涉及CTAP、U2F和UAF)。如果你的思维很严谨的话,可能又会觉得奇怪和矛盾了:不是才说了Web表单长什么样、要不要验证码、登录表单是否在客户端校验等等,是十分具体的需求,不太可能定义在规范上的吗?
|
||||
|
||||
确实如此,所以WebAuthn彻底抛弃了传统的密码登录方式,改为直接采用生物识别(指纹、人脸、虹膜、声纹)或者实体密钥(以USB、蓝牙、NFC连接的物理密钥容器)来作为身份凭证,从根本上消灭了用户输入错误产生的校验需求,以及防止机器人模拟产生的验证码需求等问题,甚至连表单界面都可能省略掉,所以这个规范不关注界面该是什么样子、要不要验证码、是否要前端校验等这些问题。
|
||||
|
||||
不过,由于WebAuthn相对比较复杂,在学习后面的内容之前,我建议如果你的设备和环境允许的话,可以先在GitHub网站的2FA认证功能中,实际体验一下通过WebAuthn完成的两段式登录。
|
||||
|
||||
在硬件方面,需要你用带有TouchBar的MacBook,或者其他支持指纹、FaceID验证的手机均可,现在应该在售的移动设备基本都带有生物识别的装置了。在软件方面,直至iOS13.6,iPhone和iPad都不支持WebAuthn,但Android和macOS系统中的Chrome,以及Windows的Edge浏览器,都已经可以正常使用WebAuthn了。
|
||||
|
||||
|
||||
|
||||
WebAuthn规范涵盖了“注册”与“认证”两大流程,我先来介绍下注册流程的大致步骤:
|
||||
|
||||
|
||||
用户进入系统的注册页面,这个页面的格式、内容和用户注册时需要填写的信息,都不包含在WebAuthn标准的定义范围内。
|
||||
当用户填写完信息,点击“提交注册信息”的按钮后,服务端先暂存用户提交的数据,生成一个随机字符串(规范中称为Challenge)和用户的UserID(在规范中称作凭证ID),返回给客户端。
|
||||
客户端的WebAuthn API接收到Challenge和UserID,把这些信息发送给验证器(Authenticator),这个验证器你可以理解为用户设备上TouchBar、FaceID、实体密钥等认证设备的统一接口。
|
||||
验证器提示用户进行验证,如果你的机器支持多种认证设备,还会提示用户选择一个想要使用的设备。验证的结果是生成一个密钥对(公钥和私钥),验证器自己存储好私钥、用户信息以及当前的域名。然后使用私钥对Challenge进行签名,并将签名结果、UserID和公钥一起返回给客户端。
|
||||
浏览器将验证器返回的结果转发给服务器。
|
||||
服务器核验信息,检查UserID与之前发送的是否一致,并对比用公钥解密后得到的结果与之前发送的Challenge是否一致,一致即表明注册通过,服务端存储该UserID对应的公钥。
|
||||
|
||||
|
||||
你可以参考一下这个注册步骤的时序图:
|
||||
|
||||
|
||||
|
||||
登录流程其实跟注册流程差不多,如果你理解了注册流程,登录就比较简单了,大致可以分为这样几个步骤:
|
||||
|
||||
|
||||
用户访问登录页面,填入用户名后即可点击登录按钮。
|
||||
服务器返回随机字符串Challenge、用户UserID。
|
||||
浏览器将Challenge和UserID转发给验证器。
|
||||
验证器提示用户进行认证操作。由于在注册阶段,验证器已经存储了该域名的私钥和用户信息,所以如果域名和用户都相同的话,就不需要生成密钥对了,直接以存储的私钥加密Challenge,然后返回给浏览器。
|
||||
服务端接收到浏览器转发来的被私钥加密的Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。
|
||||
|
||||
|
||||
WebAuthn采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案。私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;公钥是公开的,可以被任何人看到或存储。
|
||||
|
||||
另外,公钥可用于验证私钥生成的签名,但不能用来签名,除了得知私钥外,没有其他途径能够生成可被公钥验证为有效的签名,这样服务器就可以通过公钥是否能够解密,来判断最终用户的身份是否合法。
|
||||
|
||||
而且,WebAuthn还一揽子地解决了传统密码在网络传输上的风险,在“保密”一节课中,我们还会讲到无论密码是否在客户端进行加密、如何加密,对防御中间人攻击来说都是没有意义的。
|
||||
|
||||
更值得夸赞的是,WebAuthn还为登录过程带来了极大的便捷性,不仅注册和验证的用户体验十分优秀,而且彻底避免了用户在一个网站上泄漏密码,所有使用相同密码的网站都受到攻击的问题,这个优点可以让用户不需要再为每个网站想不同的密码。
|
||||
|
||||
当然,现在的WebAuthn还很年轻,普及率暂时还很有限,但我相信,几年之内它必定会发展成Web认证的主流方式,被大多数网站和系统所支持。
|
||||
|
||||
认证的实现
|
||||
|
||||
OK,在了解了业界标准的认证规范以后,我们再来看看在Java技术体系内,通常都是如何实现安全认证的。
|
||||
|
||||
Java其实也有自己的认证规范,第一个系统性的Java认证规范发布于Java 1.3时代,Sun公司提出了同时面向代码级安全和用户级安全的认证授权服务JAAS(Java Authentication and Authorization Service,1.3处于扩展包中,1.4纳入标准包)。不过,尽管JAAS已经开始照顾了最终用户的认证,但相对而言,该规范中代码级安全仍然占更主要的地位。
|
||||
|
||||
可能今天用过、甚至是听过JAAS的Java程序员都已经不多了,但是这个规范提出了很多在今天仍然活跃于主流Java安全框架中的概念。比如说,一般把用户存放在“Principal”之中、密码存在“Credentials”之中、登录后从安全上下文“Context”中获取状态等常见的安全概念,都可以追溯到这一时期所定下的API:
|
||||
|
||||
|
||||
LoginModule (javax.security.auth.spi.LoginModule)
|
||||
LoginContext (javax.security.auth.login.LoginContext)
|
||||
Subject (javax.security.auth.Subject)
|
||||
Principal (java.security.Principal)
|
||||
Credentials(javax.security.auth.Destroyable、javax.security.auth.Refreshable)
|
||||
|
||||
|
||||
可是,虽然JAAS开创了这些沿用至今的安全概念,但其规范本身,实质上并没有得到广泛的应用。我认为主要有两大原因。
|
||||
|
||||
一方面是由于,JAAS同时面向代码级和用户级的安全机制,使得它过度复杂化,难以推广。在这个问题上,Java社区一直有做持续的增强和补救,比如Java EE 6中的JASPIC、Java EE 8中的EE Security:
|
||||
|
||||
|
||||
JSR 115:Java Authorization Contract for Containers(JACC)
|
||||
JSR 196:Java Authentication Service Provider Interface for Containers(JASPIC)
|
||||
JSR 375: Java EE Security API(EE Security)
|
||||
|
||||
|
||||
而另一方面,也可能是更重要的一个原因,就是在21世纪的第一个十年里,以“With EJB”为口号、以WebSphere、Jboss等为代表J2EE容器环境,与以“Without EJB”为口号、以Spring、Hibernate等为代表的轻量化开发框架,产生了激烈的竞争,结果是后者获得了全面胜利。
|
||||
|
||||
这个结果就导致了依赖于容器安全的JAAS无法得到大多数人的认可。在今时今日,实际活跃于Java安全领域的,是两个私有的(私有的意思是不由JSR所规范的,即没有java/javax.*作为包名的)的安全框架:Apache Shiro和Spring Security。
|
||||
|
||||
那么,相较而言,Shiro更加便捷易用,而Spring Security的功能则要复杂强大一些。因此在后面课程中要介绍的Fenix’s Bookstore项目中,无论是单体架构、还是微服务架构,我都选择了Spring Security作为安全框架,这个选择与功能、性能之类的考量没什么关系,就只是因为Spring Boot、Spring Cloud全家桶的缘故(这里我不打算罗列代码来介绍Shiro与Spring Security的具体使用,如果你感兴趣可以参考Fenix’s Bookstore的源码仓库)。
|
||||
|
||||
只从目标上来看,两个安全框架提供的功能都很类似,大致包括以下四类:
|
||||
|
||||
|
||||
认证功能:以HTTP协议中定义的各种认证、表单等认证方式确认用户身份,这也是这节课所探讨的主要话题。
|
||||
安全上下文:用户获得认证之后,要开放一些接口,让应用可以得知该用户的基本资料、用户拥有的权限、角色,等等。
|
||||
授权功能:判断并控制认证后的用户对什么资源拥有哪些操作许可,这部分内容我会在下一节课讲“授权”时介绍。
|
||||
密码的存储与验证:密码是烫手的山芋,不管是存储、传输还是验证,都应该谨慎处理,这部分内容我会放到“保密”一讲去具体讨论。
|
||||
|
||||
|
||||
小结
|
||||
|
||||
这节课,我们了解了信道、协议和内容这三种主要标准化认证类型的其中两种,分别是HTTP认证(协议)和Web认证(内容)。现在你应该就很清楚HTTP认证和Web认证的特点了,那就是认证的载体不一样,决定了认证的形式和功能范围都有不同。
|
||||
|
||||
另外我还给你介绍了它们各自的工作流程,其中你要关注的重点是认证框架的整体的运作,不必一下子陷入到具体的认证方案上去。
|
||||
|
||||
除此之外,我还介绍了认证标准在Java中的落地实现。在Java技术体系中,原本也有自己的认证标准与实现,那就是依赖于JAAS的面向代码级和用户级的安全机制,不过目前应用更广泛的反而是两个私有的安全框架,这又是一个官方标准被民间草根框架击败的例子,可见软件中设计必须贴近实际用户,才能达到实用的效果。
|
||||
|
||||
一课一思
|
||||
|
||||
我相信你公司的系统一定也会使用用户登录功能,那么它是标准化的认证吗?是如何实现的呢?
|
||||
|
||||
欢迎给我留言,分享你的答案。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
225
专栏/周志明的架构课/24_授权(上):系统如何确保授权的过程可靠?.md
Normal file
225
专栏/周志明的架构课/24_授权(上):系统如何确保授权的过程可靠?.md
Normal file
@@ -0,0 +1,225 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 _ 授权(上):系统如何确保授权的过程可靠?
|
||||
你好,我是周志明。
|
||||
|
||||
在上节课,我们探讨了信息系统中关于安全认证的相关话题,它主要解决的是“你是谁”的问题。那么今天我们要探讨的授权话题,是要解决“你能干什么”的问题。
|
||||
|
||||
“授权”这个概念通常伴随着“认证”“审计”“账号”一同出现,被合称为AAAA(Authentication、Authorization、Audit、Account)。授权行为在程序中的应用也是非常广泛的,我们给某个类或某个方法设置范围控制符(如public、protected、private、 ),本质上也是一种授权(访问控制)行为。
|
||||
|
||||
而在安全领域中,我们所谈论的授权就更要具体一些,它通常涉及到以下两个相对独立的问题:
|
||||
|
||||
|
||||
确保授权的过程可靠
|
||||
|
||||
|
||||
对于单一系统来说,授权的过程是比较容易做到可控的,以前在很多语境上提到授权,实质上讲的都是访问控制,理论上两者是应该分开的。
|
||||
|
||||
而在涉及多方的系统中,授权过程则是一个比较困难,但必须要严肃对待的问题:如何既让第三方系统能够访问到所需的资源,又能保证其不泄露用户的敏感数据?现在,常用的多方授权协议主要有OAuth 2.0和SAML 2.0(两个协议涵盖的功能并不是直接对等的)。
|
||||
|
||||
|
||||
确保授权的结果可控
|
||||
|
||||
|
||||
授权的结果是用于对程序功能或者资源的访问控制(Access Control)。现在,已形成理论体系的权限控制模型有很多,比如自主访问控制(Discretionary Access Control,DAC)、强制访问控制(Mandatory Access Control,MAC)、基于属性的访问控制(Attribute-Based Access Control,ABAC),还有最为常用的基于角色的访问控制(Role-Based Access Control,RBAC)。
|
||||
|
||||
所以,在接下来的两节课中,我们将会围绕前面这两个问题,分别以Fenix’s Bookstore中用到的OAuth 2.0和RBAC为例,去探讨软件业界中授权的标准协议与实现。
|
||||
|
||||
好,下面我们就先来看看,OAuth 2.0的具体工作流程是什么样的吧。
|
||||
|
||||
OAuth 2.0解决的是第三方服务中涉及的安全授权问题
|
||||
|
||||
OAuth 2.0是一种相对复杂繁琐的认证授权协议。它是在RFC 6749中定义的国际标准,RFC 6749正文的第一句就阐明了OAuth 2.0是面向于解决第三方应用(Third-Party Application)的认证授权协议。
|
||||
|
||||
如果你的系统并不涉及到第三方,比如单体架构的Fenix’s Bookstore中,就既不为第三方提供服务,也不使用第三方的服务,那引入OAuth 2.0其实就没必要。
|
||||
|
||||
这里我为什么要强调第三方呢?在多方系统授权的过程中,具体会有什么问题,需要专门制定一个标准协议来解决呢?
|
||||
|
||||
我举个现实的例子来给你解释一下。“The Fenix Project”这部文档的官方网站,它的建设和更新的大致流程是:我以Markdown形式写好了某篇文章,上传到由GitHub提供的代码仓库,接着由Travis-CI提供的持续集成服务会检测到该仓库发生了变化,触发一次Vuepress编译活动,生成目录和静态的HTML页面,然后推送回GitHub Pages,再触发国内的CDN缓存刷新。
|
||||
|
||||
如果要想保证这个过程能顺利进行,就存在一系列必须要解决的授权问题,Travis-CI只有得到了我的明确授权,GitHub才能同意它读取我代码仓库中的内容。问题是,它该如何获得我的授权呢?
|
||||
|
||||
一种最简单粗暴的方案是把我的用户账号和密码都告诉Travis-CI,但这显然会导致下面这些问题:
|
||||
|
||||
|
||||
密码泄漏:如果Travis-CI被黑客攻破,将导致我的GitHub的密码也同时被泄漏。
|
||||
访问范围:Travis-CI将有能力读取、修改、删除、更新我放在GitHub上的所有代码仓库,而我并不希望它能够修改删除文件。
|
||||
授权回收:只有修改密码才能回收我授予给Travis-CI的权限,可是我在GitHub的密码只有一个,授权的应用除了Travis-CI之外却还有许多,修改了就意味着所有别的第三方的应用程序会全部失效。
|
||||
|
||||
|
||||
那么,前面列举的这些问题,也正是OAuth 2.0所要解决的问题,尤其是要求第三方系统在没有支持HTTPS传输安全的环境下,依然能够解决这些问题,这可不是件容易的事情。
|
||||
|
||||
因此,OAuth 2.0给出了很多种解决办法,这些办法的共同特征是以令牌(Token)代替用户密码作为授权的凭证。有了令牌之后,哪怕令牌被泄漏,也不会导致密码的泄漏;令牌上可以设定访问资源的范围以及时效性;每个应用都持有独立的令牌,哪个失效都不会波及其他。
|
||||
|
||||
这样一下子前面提出的三个问题就都解决了,有了一层令牌之后,整个授权的流程如下图所示:
|
||||
|
||||
|
||||
|
||||
这个时序图里涉及到了OAuth 2.0中的几个关键术语,我们根据前面的例子,一起来解读下它们的含义,这对理解后面要介绍的几种授权模式非常重要:
|
||||
|
||||
|
||||
第三方应用(Third-Party Application):需要得到授权访问我资源的那个应用,即此场景中的“Travis-CI”。
|
||||
授权服务器(Authorization Server):能够根据我的意愿提供授权(授权之前肯定已经进行了必要的认证过程,但它与授权可以没有直接关系)的服务器,即此场景中的“GitHub”。
|
||||
资源服务器(Resource Server):能够提供第三方应用所需资源的服务器,它与认证服务可以是相同的服务器,也可以是不同的服务器,即此场景中的“我的代码仓库”。
|
||||
资源所有者(Resource Owner): 拥有授权权限的人,即此场景中的“我”。
|
||||
操作代理(User Agent):指用户用来访问服务器的工具,对于人类用户来说,这个通常是指浏览器。但在微服务中,一个服务经常会作为另一个服务的用户,此时指的可能就是HttpClient、RPCClient或者其他访问途径。
|
||||
|
||||
|
||||
OAuth 2.0的认证流程
|
||||
|
||||
当然,“用令牌代替密码”确实是解决问题的好方法,但这充其量只能算个思路,距离可实施的步骤还是不够具体。所以,时序图中的“要求/同意授权”“要求/同意发放令牌”“要求/同意开放资源”这几个服务请求、响应要如何设计,就是执行步骤的关键了。
|
||||
|
||||
对此,OAuth 2.0一共提出了四种不同的授权方式(这是我为什么说OAuth 2.0较为复杂繁琐的其中一个原因),分别为:
|
||||
|
||||
|
||||
授权码模式(Authorization Code)
|
||||
简化模式(Implicit)
|
||||
密码模式(Resource Owner Password Credentials)
|
||||
客户端模式(Client Credentials)
|
||||
|
||||
|
||||
接下来,我们就一一来解读下这四种授权方式的具体流程,以此理解OAuth 2.0是如何实现多方系统中相对安全、相对可控的授权的。
|
||||
|
||||
授权码模式
|
||||
|
||||
授权码模式是四种模式中最严(luō)谨(suō)的,它考虑到了几乎所有敏感信息泄露的预防和后果。我们来看看这种模式的具体步骤:
|
||||
|
||||
|
||||
|
||||
这里你要注意,在开始进行授权过程之前,第三方应用要先到授权服务器上进行注册。所谓的注册,是指第三方应用向认证服务器提供一个域名地址,然后从授权服务器中获取ClientID和ClientSecret,以便能够顺利完成如下的授权过程:
|
||||
|
||||
|
||||
第三方应用将资源所有者(用户)导向授权服务器的授权页面,并向授权服务器提供ClientID及用户同意授权后的回调URI,这是第一次客户端页面转向。
|
||||
授权服务器根据ClientID确认第三方应用的身份,用户在授权服务器中决定是否同意向该身份的应用进行授权。注意,用户认证的过程未定义在此步骤中,在此之前就应该已经完成。
|
||||
如果用户同意授权,授权服务器将转向第三方应用在第1步调用中提供的回调URI,并附带上一个授权码和获取令牌的地址作为参数,这是第二次客户端页面转向。
|
||||
第三方应用通过回调地址收到授权码,然后将授权码与自己的ClientSecret一起作为参数,通过服务器向授权服务器提供的获取令牌的服务地址发起请求,换取令牌。该服务器的地址应该与注册时提供的域名处于同一个域中。
|
||||
授权服务器核对授权码和ClientSecret,确认无误后,向第三方应用授予令牌。令牌可以是一个或者两个,其中必定要有的是访问令牌(Access Token),可选的是刷新令牌(Refresh Token)。访问令牌用于到资源服务器获取资源,有效期较短,刷新令牌用于在访问令牌失效后重新获取,有效期较长。
|
||||
资源服务器根据访问令牌所允许的权限,向第三方应用提供资源。
|
||||
|
||||
|
||||
由此你也能看到,这个过程设计已经考虑到了几乎所有合理的意外情况。这里我再给你举几个容易遇到的意外情况的例子,以便你能够更好地理解为何OAuth 2.0要这样设计:
|
||||
|
||||
|
||||
会不会有其他应用冒充第三方应用骗取授权?
|
||||
|
||||
|
||||
ClientID代表一个第三方应用的“用户名”,这项信息是可以完全公开的。但ClientSecret应当只有应用自己才知道,这个代表了第三方应用的“密码”。在第5步发放令牌时,调用者必须能够提供ClientSecret才能成功完成。只要第三方应用妥善保管好ClientSecret,就没有人能够冒充它。
|
||||
|
||||
|
||||
为什么要先发放授权码,再用授权码换令牌?
|
||||
|
||||
|
||||
这是因为客户端转向(通常就是一次HTTP 302重定向)对于用户是可见的。换言之,授权码可能会暴露给用户以及用户机器上的其他程序,但由于用户并没有ClientSecret,光有授权码也无法换取到令牌,所以就避免了令牌在传输转向过程中被泄漏的风险。
|
||||
|
||||
|
||||
为什么要设计一个时限较长的刷新令牌和时限较短的访问令牌?不能直接把访问令牌的时间调长吗?-
|
||||
-
|
||||
这是为了缓解OAuth 2.0在实际应用中的一个主要缺陷。因为通常情况下,访问令牌一旦发放,除非超过了令牌中的有效期,否则很难有其他方式让它失效。所以访问令牌的时效性一般会设计得比较短,比如几个小时,如果还需要继续用,那就定期用刷新令牌去更新,授权服务器可以在更新过程中决定是否还要继续给予授权。至于为什么说很难让它失效,我们将放到下一讲“凭证”中去解释。
|
||||
|
||||
|
||||
不过,尽管授权码模式是很严谨的,但它并不够好用,这不仅仅体现在它那繁复的调用过程上,还体现在它对第三方应用提出了一个“貌似不难”的要求:第三方应用必须有应用服务器,因为第4步要发起服务端转向,而且要求服务端的地址必须与注册时提供的地址在同一个域内。
|
||||
|
||||
你不要觉得,要求一个系统要有应用服务器是天经地义理所当然的事情,“The Fenix Project”这部文档的官方网站就没有任何应用服务器的支持,里面使用到了Gittalk作为每篇文章的留言板,它对GitHub来说照样是第三方应用,需要OAuth 2.0授权来解决。
|
||||
|
||||
除了基于浏览器的应用外,现在越来越普遍的是移动或桌面端的客户端Web应用(Client-Side Web Applications),比如现在大量的基于Cordova、Electron、Node-Webkit.js的PWA应用,它们都不会有应用服务器的支持。
|
||||
|
||||
正是因为有这样的实际需求,就引出了OAuth 2.0的第二种授权模式:隐式授权。
|
||||
|
||||
隐式授权
|
||||
|
||||
隐式授权省略掉了通过授权码换取令牌的步骤,整个授权过程都不需要服务端的支持,一步到位。而使用的代价是在隐式授权中,授权服务器不会再去验证第三方应用的身份,因为已经没有应用服务器了,ClientSecret没有人保管,就没有存在的意义了。
|
||||
|
||||
但隐式授权中的授权服务器,还是会限制第三方应用的回调URI地址必须与注册时提供的域名一致,虽然有可能会被DNS污染之类的攻击所攻破,但这也算是它尽可能地努力了一下吧。同样的原因,隐式授权也不能避免令牌暴露给资源所有者,不能避免用户机器上可能意图不轨的其他程序、HTTP的中间人攻击等风险。
|
||||
|
||||
隐式授权的调用时序图如下图所示(后面展示的几种授权模式,时序图中我就不再画出资源访问部分的内容了,就是前面授权码图例中opt框里的那一部分,以便更聚焦重点):
|
||||
|
||||
|
||||
|
||||
你可以发现,在这个交互过程里,隐式模式与授权码模式的显著区别是授权服务器在得到用户授权后,直接返回了访问令牌,这很明显会降低授权的安全性。
|
||||
|
||||
但OAuth 2.0仍然在尽可能地努力做到相对安全,比如前面我提到在隐式授权中,尽管不需要用到服务端,但仍然需要在注册时提供回调域名,此时会要求该域名与接受令牌的服务处于同一个域内。此外,同样基于安全考虑,在隐式模式中也明确禁止发放刷新令牌。
|
||||
|
||||
还有一点,在RFC 6749对隐式授权的描述中,特别强调了令牌必须是“通过Fragment带回”的。如果你对超文本协议没有多少了解的话,可能还不知道Fragment是个什么东西,我们来看一下它的英文释义。
|
||||
|
||||
|
||||
Fragment-
|
||||
In computer hypertext, a fragment identifier is a string of characters that refers to a resource that is subordinate to another, primary resource. The primary resource is identified by a Uniform Resource Identifier (URI), and the fragment identifier points to the subordinate resource.-
|
||||
——URI Fragment,Wikipedia
|
||||
|
||||
|
||||
要是你看完后,还是觉得概念不好理解的话,我就简单告诉你,Fragment就是地址中“#”号后面的部分,比如这个地址:
|
||||
|
||||
|
||||
http://bookstore.icyfenix.cn/#/detail/1
|
||||
|
||||
|
||||
后面的/detail/1便是Fragment,这个语法是在RFC 3986中定义的。该规范中解释了这是用于客户端定位的URI从属资源,比如在HTML中,就可以使用Fragment来做文档内的跳转而不会发起服务端请求。
|
||||
|
||||
此外,RFC 3986还规定了,如果浏览器对一个带有Fragment的地址发出Ajax请求,那Fragment是不会跟随请求被发送到服务端的,只能在客户端通过Script脚本来读取。
|
||||
|
||||
所以,隐式授权巧妙地利用这个特性,尽最大努力地避免了令牌从操作代理到第三方服务之间的链路,存在被攻击而泄露出去的可能性。
|
||||
|
||||
至于认证服务器到操作代理之间的这一段链路的安全,则只能通过TLS(即HTTPS)来保证不会受到中间人攻击了,我们可以要求认证服务器必须都是启用HTTPS的,但无法要求第三方应用同样都支持HTTPS。
|
||||
|
||||
密码模式
|
||||
|
||||
前面所说的授权码模式和隐式模式都属于纯粹的授权模式,它们与认证没有直接的联系,如何认证用户的真实身份,跟如何进行授权是两个互相独立的过程。但在密码模式里,认证和授权就被整合成了同一个过程。
|
||||
|
||||
密码模式原本的设计意图是,仅限于在用户对第三方应用是高度可信任的场景中使用,因为用户需要把密码明文提供给第三方应用,第三方以此向授权服务器获取令牌。
|
||||
|
||||
这种高度可信的第三方是非常罕见的,尽管在介绍OAuth 2.0的材料中,经常举的例子是“操作系统作为第三方应用向授权服务器申请资源”,但真实应用中极少遇到这样的情况,合理性依然十分有限。
|
||||
|
||||
我认为,如果要采用密码模式,那“第三方”属性就必须弱化,把“第三方”看作是系统中与授权服务器相对独立的子模块,在物理上独立于授权服务器部署,但是在逻辑上与授权服务器仍同属一个系统。这样把认证和授权一并完成的密码模式,才会有合理的应用场景。
|
||||
|
||||
比如说,Fenix’s Bookstore就直接采用了密码模式,将认证和授权统一到一个过程中完成,尽管Fenix’s Bookstore中的Frontend工程和Account工程,都能直接接触到用户名和密码,但它们事实上都是整个系统的一部分,在这个前提下密码模式才具有可用性(关于分布式系统各个服务之间的信任关系,我会在“零信任网络”与“服务安全”两讲中和你作进一步讨论)。
|
||||
|
||||
这样,理解了密码模式的用途,你再去看它的调用过程就很简单了,也就是第三方应用拿着用户名和密码向授权服务器换令牌而已。具体的时序如下图所示:
|
||||
|
||||
|
||||
|
||||
此外你还要明确一件事,在密码模式下,“如何保障安全”的职责无法由OAuth 2.0来承担,只能由用户和第三方应用来自行保障,尽管OAuth 2.0在规范中强调到“此模式下,第三方应用不得保存用户的密码”,但这并没有任何技术上的约束力。
|
||||
|
||||
OK,我们再来看看OAuth 2.0的最后一种授权模式:客户端模式。
|
||||
|
||||
客户端模式
|
||||
|
||||
客户端模式是四种模式中最简单的,它只涉及到两个主体:第三方应用和授权服务器。如果我们严谨一点,现在叫“第三方应用”其实已经不合适了,因为已经没有了“第二方”的存在,资源所有者、操作代理在客户端模式中都是不必出现的。甚至严格来说,叫“授权”都已经不太恰当,毕竟资源所有者都没有了,也就不会有谁授予谁权限的过程。
|
||||
|
||||
那么,客户端模式就是指第三方应用(考虑到前后统一,我们还是继续沿用这个称呼)以自己的名义,向授权服务器申请资源许可。这种模式通常用于管理操作或者自动处理类型的场景中。
|
||||
|
||||
举个具体例子。比如我开了一家叫Fenix’s Bookstore的书店,因为小本经营,不像京东那样全国多个仓库可以调货,因此我必须保证只要客户成功购买,书店就必须有货可发,不允许超卖。但问题是,经常有顾客下了订单又拖着不付款,导致部分货物处于冻结状态。
|
||||
|
||||
所以,Fenix’s Bookstore中有一个订单清理的定时服务,自动清理超过两分钟还未付款的订单。在这个场景里,订单肯定是属于下单用户自己的资源,如果把订单清理服务看作是一个独立的第三方应用的话,它是不可能向下单用户去申请授权来删掉订单的,而是应该直接以自己的名义,向授权服务器申请一个能清理所有用户订单的授权。那么这个客户端模式的时序就会是这样的:
|
||||
|
||||
|
||||
|
||||
在微服务架构中,其实并不提倡同一个系统的各服务间有默认的信任关系,所以服务之间的调用也需要先进行认证授权,然后才能通讯。
|
||||
|
||||
那么此时,客户端模式便是一种常用的服务间认证授权的解决方案。Spring Cloud版本的Fenix’s Bookstore就是采用这种方案,来保证微服务之间的合法调用的;而Istio版本的Fenix’s Bookstore则启用了双向mTLS通讯,使用客户端证书来保障安全。它们可作为上一节课我介绍认证时,提到的“通讯信道认证”和“通讯内容认证”的例子,你要是感兴趣可以对比一下这两种方式的差异优劣。
|
||||
|
||||
此外,在OAuth 2.0中呢,还有一种与客户端模式类似的授权模式,在RFC 8628中定义为“设备码模式”(Device Code),这里我顺带提一下。
|
||||
|
||||
设备码模式用于在无输入的情况下区分设备是否被许可使用,典型的应用就是手机锁网解锁(锁网在国内较少,但在国外很常见)或者设备激活(比如某游戏机注册到某个游戏平台)的过程。它的时序如下图所示:
|
||||
|
||||
|
||||
|
||||
这里你可以记着,采用设备码模式在进行验证时,设备需要从授权服务器获取一个URI地址和一个用户码,然后需要用户手动或设备自动地到验证URI中输入用户码。在这个过程中,设备会一直循环,尝试去获取令牌,直到拿到令牌或者用户码过期为止。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我们学习了如何使用OAuth 2.0来解决涉及到多方系统调用时可靠授权的问题,并详细了解了OAuth 2.0协议的授权码模式、隐式授权模式、密码模式和客户端模式的工作流程。
|
||||
|
||||
实际上,无论是哪一种授权模式,它们都属于保障授权过程可靠的实现方案。那么,系统要如何确保授权的结果可控呢?别着急,在下节课中,我就来给你揭晓答案。
|
||||
|
||||
一课一思
|
||||
|
||||
OAuth 2.0的核心思想是令牌代替密码,令牌是我们讲“凭证”这节课的主角,在这里你能否先想象一下,所谓的“令牌”应该是一种怎样的数据结构?它有什么特点?有什么必须的信息?
|
||||
|
||||
欢迎在留言区分享你的答案。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
127
专栏/周志明的架构课/25_授权(下):系统如何确保授权的结果可控?.md
Normal file
127
专栏/周志明的架构课/25_授权(下):系统如何确保授权的结果可控?.md
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 _ 授权(下):系统如何确保授权的结果可控?
|
||||
你好,我是周志明。今天,我们接着上一讲的话题,继续来探究关于授权的第二个核心问题:系统如何确保授权的结果可控?
|
||||
|
||||
在上节课的开篇,我提到了授权的结果是用于对程序功能或者资源的访问控制(Access Control),并且也给你介绍了一种最为常用的权限控制模型RBAC(基于角色的访问控制,Role-Based Access Control)。
|
||||
|
||||
那么这节课,我就来和你聊聊这种访问控制模型的概念、原理和一些要注意的问题。希望你能在理解了RBAC是如何运作的之后,将其灵活运用在自己实际工作中关于功能、数据权限的管理上,而且这也是为后面学习Kubernetes的权限控制、服务安全等内容提前做的铺垫工作。
|
||||
|
||||
好,接下来,我们就从RBAC的几个基础概念开始学起吧。
|
||||
|
||||
RBAC的基础概念
|
||||
|
||||
首先,我们要明确,所有的访问控制模型,实质上都是在解决同一个问题:谁(User)拥有什么权限(Authority)去操作(Operation)哪些资源(Resource)。
|
||||
|
||||
这个问题初看起来并不太难,一种直观的解决方案就是在用户对象上设定一些权限,当用户使用资源时,检查是否有对应的操作权限即可。很多著名的安全框架,比如Spring Security的访问控制,本质上就是支持这么做的。
|
||||
|
||||
不过,这种把权限直接关联在用户身上的简单设计,在复杂系统上确实会导致一些比较繁琐的问题。
|
||||
|
||||
你可以试想一下,如果某个系统涉及到成百上千的资源,又有成千上万的用户,一旦两者搅合到一起,要为每个用户访问每个资源都分配合适的权限,就必定会导致巨大的操作量和极高的出错概率。
|
||||
|
||||
而这也正是RBAC所关注的核心问题。
|
||||
|
||||
RBAC模型在业界有很多种说法,其中,最具系统性且得到了普遍认可的说法,是美国乔治梅森(George Mason)大学信息安全技术实验室提出的RBAC96模型。
|
||||
|
||||
为了避免对每一个用户设定权限,RBAC将权限从用户身上剥离,改为绑定到“角色”(Role)上,“权限控制”这项工作,就可以具体化成针对“角色拥有操作哪些资源的许可”这个逻辑表达式的值是否为真的求解过程。
|
||||
|
||||
这个逻辑表达式中涉及的关键概念有用户、角色、资源等,我画了张图,你可以参考图中展示的RBAC主要元素之间的关系:
|
||||
|
||||
|
||||
|
||||
其中,你可能发现了,除了前面提到的用户、角色和资源以外,图上还出现了一个新的名词“许可”(Permission)。
|
||||
|
||||
许可其实是抽象权限的具象化体现。权限在RBAC系统中的含义是“允许何种操作作用于哪些资源之上”,这句话的具体实例即为“许可”。
|
||||
|
||||
提出许可这个概念的目的,其实跟提出角色的目的是完全一致的,只是许可会更抽象。角色为的是解耦用户与权限之间的多对多关系,而许可为的是解耦操作与资源之间的多对多关系。比如说,不同的数据都能够有增、删、改等操作,而如果把操作与数据搅和在一起,也会面临前面我提到的权限配置膨胀的问题。
|
||||
|
||||
不过现在,你可能快被这些概念、逻辑给绕晕了。没事儿,我再给你举个更具体的例子,帮你理清这一堆名词之间的关系。
|
||||
|
||||
想像一下,某个论文管理系统的UserStory中,与访问控制相关的Backlog可能会是这样描述的:
|
||||
|
||||
|
||||
Backlog:周同学(User)是某SCI杂志的审稿人(Role),职责之一是在系统中审核论文(Authority)。在审稿过程(Session)中,当他认为某篇论文(Resource)达到了可以公开发表的标准时,就会在后台点击“通过”按钮(Operation)来完成审核。
|
||||
|
||||
|
||||
所以,在这个Backlog中,“给论文点击通过按钮”就是一种许可,它是“审核论文”这项权限的具象化体现。现在你是不是就清楚一些了?
|
||||
|
||||
另外我还想强调的是,采用RBAC的角色、资源等概念不仅是为了简化配置操作,通过设定这些概念之间的关系与约束,还是很多关键的安全原则和设计原则的实现基础。下面,我们就从计算机安全中的“最小特权原则”(Least Privilege)开始来了解一下吧。
|
||||
|
||||
RBAC的概念间关系
|
||||
|
||||
在RBAC模型中,角色拥有许可的数量,是根据完成该角色工作职责所需的最小权限所赋予的。
|
||||
|
||||
最典型的例子是操作系统权限管理中的用户组。即根据对不同角色的职责分工,如管理员(Administrator)、系统用户(System)、验证用户(Authenticated Users)、普通用户(Users)、来宾用户(Guests)等,分配其各自的权限。这样就既保证了用户能够正常工作,也避免了用户出现越权操作的风险。
|
||||
|
||||
而当用户的职责发生变化时,在系统中就体现为它所隶属的角色被改变,比如将“普通用户角色”改变为“管理员角色”,就可以迅速让该用户具备管理员的多个细分权限,降低权限分配错误的风险。
|
||||
|
||||
另外,RBAC还允许定义不同角色之间的关联与约束关系,以此进一步强化它的抽象描述能力。
|
||||
|
||||
比如说,不同的角色之间可以有继承性,典型的就是RBAC-1模型的角色权限继承关系。
|
||||
|
||||
我举个例子。如果要描述开发经理应该和开发人员一样具有代码提交的权限,描述开发人员应该和任何公司的员工一样具有食堂就餐的权限,那么我们就可以直接把食堂就餐的权限赋予到公司员工的角色上,把代码提交的权限赋予到开发人员的角色上,再让开发人员的角色从公司员工派生,开发经理的角色从开发人员中派生即可。
|
||||
|
||||
另外,不同的角色之间也可以具有互斥性,典型的就是RBAC-2模型的角色职责分离关系。互斥性要求,当权限被赋予角色时、或角色被赋予用户时应该遵循的强制性职责分离规定。
|
||||
|
||||
我举个例子。角色的互斥约束可以限制同一用户,只能分配到一组互斥角色集合中至多一个角色,比如不能让同一名员工既当会计,也当出纳,否则资金安全无法保证。而角色的基数约束可以限制某一个用户拥有的最大角色数目,比如不能让同一名员工全部包揽产品、设计、开发、测试等工作,否则产品质量无法保证。
|
||||
|
||||
OK,现在我们就了解了通过RBAC建立的用户、角色等概念,并且也定义了它们之间的关联与约束关系,其实这些都属于RBAC中“Role Based”范畴的内容,而RB只是手段,是为了AC这个目的服务的。
|
||||
|
||||
所以接下来,我们就一起来看看“Access Control”范畴的内容,也就是RBAC的访问控制。
|
||||
|
||||
RBAC的访问控制
|
||||
|
||||
建立访问控制模型的基本目的就是为了管理垂直权限和水平权限。垂直权限即功能权限,比如前面提到的审稿编辑有通过审核的权限、开发经理有代码提交的权限、出纳有从账户提取资金的权限,这一类某个角色完成某项操作的许可,都可以直接翻译为功能权限。
|
||||
|
||||
由于实际应用与权限模型具有高度对应的关系,因此把权限从具体的应用中抽离出来,放到通用的模型中是相对容易的,Spring Security、Apache Shiro等权限框架就是这样的抽象产物,大多数系统都能采用这些权限框架来管理功能权限。
|
||||
|
||||
那么与此相对,要管理水平权限,也就是数据权限的话,则要困难得多。比如用户A、B都属于同一个角色,但它们各自在系统中产生的数据完全有可能是私有的,A访问或删除了B的数据也照样属于越权。
|
||||
|
||||
一般来说,数据权限是很难抽象与通用的,仅在角色层面进行控制并不能满足全部业务的需要。很多时候,数据权限必须具体到用户,甚至具体管理到发生数据的某一行、某一列之上,因此数据权限基本上只能由信息系统自主来完成,并不存在能放之四海皆准的通用数据权限框架。
|
||||
|
||||
Spring Security的RBAC实现
|
||||
|
||||
在课程后面要介绍的“不可变基础设施”的模块里,其中要讲解的一个“重要角色”Kubernetes,也是完全遵循RBAC模型来进行服务访问控制的。Fenix’s Bookstore所使用的Spring Security也参考了(但并没有完全遵循)RBAC来设计它的访问控制功能,所以这里我就以Spring Security为例,给你简要介绍一下RBAC的实现。
|
||||
|
||||
在Spring Security的设计里,用户和角色都可以拥有权限,比如在它的HttpSecurity接口就同时有着hasRole()和hasAuthority()方法,如果你是刚接触Spring Security的设计的话,可能会混淆它们之间的关系。那么下面我们就直接来看看,Spring Security的访问控制模型是长什么样子的,你也可以去对比一下前面的RBAC的关系图:
|
||||
|
||||
|
||||
|
||||
从实现角度来看,Spring Security中Role和Authority的差异很小,它们完全共享同一套存储结构,唯一的差别就只是Role会在存储时,自动带上“ROLE_”前缀罢了。
|
||||
|
||||
但从使用角度来看,Role和Authority的差异可以很大,用户可以自行决定系统中到底Permission只能对应到角色身上,还是可以让用户也拥有某些角色中没有的权限。
|
||||
|
||||
你应该会觉得,这一点好像不符合RBAC的思想,但我个人认为这是一种创新而非破坏,在Spring Security的文档上也说的很清楚:这取决于你自己如何使用。
|
||||
|
||||
|
||||
The core difference between these two(注:指Role和Authority) is the semantics we attach to how we use the feature. For the framework, the difference is minimal – and it basically deals with these in exactly the same way.
|
||||
|
||||
|
||||
这样,我们通过RBAC,就很容易控制最终用户在广义和精细级别上能够做什么,我们可以指定用户是管理员、专家用户或者只是普通用户,并使角色和访问权限与组织中员工的身份职位保持一致,仅根据需要为员工完成工作的最低限度来分配权限。
|
||||
|
||||
这些都是人们通过设计大量的软件系统、长时间积累下来的实践经验,将这些经验运用在软件产品上,绝大多数情况下都要比自己发明创造一个新的轮子更加安全。
|
||||
|
||||
小结
|
||||
|
||||
针对如何确保授权的结果可控的问题,这节课我们学习了一种最常用的解决方案:基于角色的访问控制(RBAC)。
|
||||
|
||||
其中,在Role-Based部分,我通过一些例子,给你介绍了以角色为中心的一系列概念,以及这些概念之间的关系与约束;在Access Control部分,我还介绍了垂直和水平权限的控制的差异,也以Spring Security为例,带你了解了它的大致运作过程。你需要记住以下几个核心要点:
|
||||
|
||||
|
||||
所有的访问控制模型,实质上都是在解决同一个问题:谁(User)拥有什么权限(Authority)去操作(Operation)哪些资源(Resource)。
|
||||
为避免对每一个用户设定权限,RBAC提出了角色和许可等概念,角色为的是解耦用户与权限之间的多对多关系,而许可为的是解耦操作与资源之间的多对多关系。
|
||||
建立访问控制模型的基本目的就是为了管理垂直权限和水平权限。垂直权限即功能权限,水平权限则是数据权限,它很难抽象与通用。
|
||||
|
||||
|
||||
一课一思
|
||||
|
||||
你是否使用过RBAC或者是其他权限控制模型?你是自己实现的,还是基于现成框架实现的?
|
||||
|
||||
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给其他的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
241
专栏/周志明的架构课/26_凭证:系统如何保证与用户之间的承诺是准确完整且不可抵赖的?.md
Normal file
241
专栏/周志明的架构课/26_凭证:系统如何保证与用户之间的承诺是准确完整且不可抵赖的?.md
Normal file
@@ -0,0 +1,241 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 _ 凭证:系统如何保证与用户之间的承诺是准确完整且不可抵赖的?
|
||||
你好,我是周志明。
|
||||
|
||||
在第24讲我给你介绍OAuth 2.0协议的时候,提到过每一种授权模式的最终目标都是拿到访问令牌,但我并没有讲拿回来的令牌应该长什么样子,反而还挖了一些坑没有填,即为什么说OAuth 2.0的一个主要缺陷是令牌难以主动失效。
|
||||
|
||||
所以,这节课我们要讨论的主角就是令牌了。我会带你了解令牌的结构、原理与实现,让你明确系统是如何保证它和用户之间的承诺是双方当时意图的体现、是准确完整且不可抵赖的;另外我还会跟你一起看看,如果不使用OAuth 2.0的话,通过最传统的状态管理机制的方式,系统要如何完成认证和授权。
|
||||
|
||||
好,那接下来,我们就先来看看HTTP协议中最传统的状态管理机制,Cookie-Session是如何运作的吧。
|
||||
|
||||
Cookie-Session:HTTP的状态管理机制
|
||||
|
||||
我们应该都知道,HTTP协议是一种无状态的传输协议,也就是协议对事务处理没有上下文的记忆能力,每一个请求都是完全独立的。但是我想,肯定很多人都没有意识到HTTP协议无状态的重要性。
|
||||
|
||||
为什么这么说呢?假如你做了一个简单的网页,其中包含了1个HTML、2个Script脚本、3个CSS,还有10张图片,那么这个网页要想成功地展示在用户屏幕前,就需要完成16次与服务器的交互来获取这些资源。
|
||||
|
||||
但是,因为网络传输等各种因素的影响,服务器发送的顺序与客户端请求的先后并没有必然的联系,所以按照可能出现的响应顺序,理论上最多会有P(16,16) = 20,922,789,888,000种可能性。
|
||||
|
||||
所以我们可以试想一下,如果HTTP协议不是设计成无状态的,这16次请求每一个都有依赖关联,先调用哪一个、先返回哪一个,都会对结果产生影响的话,那么服务器与客户端交互的协调工作会有多么复杂。
|
||||
|
||||
可是,HTTP协议的无状态特性,又有悖于我们最常见的网络应用场景,典型的就是认证授权,毕竟系统总得要获知用户身份才能提供合适的服务。因此,我们也希望HTTP能有一种手段,让服务器至少有办法区分出发送请求的用户是谁。
|
||||
|
||||
所以,为了实现这个目的,RFC 6265规范就定义了HTTP的状态管理机制,在HTTP协议中增加了Set-Cookie指令。
|
||||
|
||||
这个指令的含义是以键值对的方式向客户端发送一组信息,在此后一段时间内的每次HTTP请求中,这组信息会附带着名为Cookie的Header重新发回给服务端,以便服务器区分来自不同客户端的请求。
|
||||
|
||||
我们直接来看一个典型的Set-Cookie指令具体是怎么做的:
|
||||
|
||||
Set-Cookie: id=icyfenix; Expires=Wed, 21 Feb 2020 07:28:00 GMT; Secure; HttpOnly
|
||||
|
||||
|
||||
服务器在收到该指令以后,客户端再对同一个域的请求中,就会自动附带有键值对信息“id=icyfenix”,比如说:
|
||||
|
||||
GET /index.html HTTP/2.0
|
||||
Host: icyfenix.cn
|
||||
Cookie: id=icyfenix
|
||||
|
||||
|
||||
那么,根据每次请求传到服务端的Cookie,服务器就能分辨出请求来自于哪一个用户。由于Cookie是放在请求头上的,属于额外的传输负担,不应该携带过多的内容,而且放在Cookie中传输也并不安全,容易被中间人窃取或篡改,所以在实际情况中,通常是不会像这个例子一样,设置“id=icyfenix”这样的明文信息的。
|
||||
|
||||
一般来说,系统会把状态信息保存在服务端,而在Cookie里只传输一个无字面意义的、不重复的字符串,通常习惯上是以sessionid或者jsessionid为名。然后,服务器拿这个字符串为Key,在内存中开辟一块空间,以Key/Entity的结构,来存储每一个在线用户的上下文状态,再辅以一些超时自动清理之类的管理措施。
|
||||
|
||||
这种服务端的状态管理机制,就是今天我们非常熟悉的Session。Cookie-Session也就是最传统的,但在今天依然广泛应用于大量系统中的、由服务端与客户端联动来完成的状态管理机制。
|
||||
|
||||
Cookie-Session的方案在安全架构的系统当中,其实是占有一定天生优势的:因为状态信息都存储于服务器,只要依靠客户端的同源策略和HTTPS的传输层安全,保证Cookie中的键值不被窃取而出现被冒认身份的情况,就能完全规避掉信息在传输过程中被泄露和篡改的风险。
|
||||
|
||||
Cookie-Session方案另一大优点是服务端有主动的状态管理能力,可以根据自己的意愿随时修改、清除任意的上下文信息,比如很轻易就能实现强制某用户下线这样的功能。
|
||||
|
||||
不过,Cookie-Session在单节点的单体服务环境中确实是最合适的方案,但当服务器需要具备水平扩展服务能力,要部署集群时就有点儿麻烦了。
|
||||
|
||||
因为Session存储在服务器的内存中,那么当服务器水平拓展成多节点时,我们在设计时就必须在以下三种方案中选择其一:
|
||||
|
||||
|
||||
要么就牺牲集群的一致性(Consistency),让均衡器采用亲和式的负载均衡算法。比如根据用户IP或者Session来分配节点,每一个特定用户发出的所有请求,都一直被分配到其中某一个节点来提供服务,每个节点都不重复地保存着一部分用户的状态,如果这个节点崩溃了,里面的用户状态便完全丢失。
|
||||
要么就牺牲集群的可用性(Availability),让各个节点之间采用复制式的Session,每一个节点中的Session变动,都会发送到组播地址的其他服务器上,这样即使某个节点崩溃了,也不会中断某个用户的服务。但Session之间组播复制的同步代价比较高昂,节点越多时,同步成本就越高。
|
||||
要么就牺牲集群的分区容错性(Partition Tolerance),让普通的服务节点中不再保留状态,将上下文集中放在一个所有服务节点都能访问到的数据节点中进行存储。此时的矛盾是数据节点就成为了单点,一旦数据节点损坏或出现网络分区,整个集群都不能再提供服务。
|
||||
|
||||
|
||||
通过第14讲内容的学习,现在我们已经知道了,只要在分布式系统中共享信息,CAP就不可兼得,所以分布式环境中的状态管理一定会受到CAP的局限,无论怎样都不可能完美。
|
||||
|
||||
但如果,我们只是解决分布式下的认证授权问题,并顺带解决少量状态的问题,就不一定只能依靠共享信息去实现了。
|
||||
|
||||
我说这句话的言外之意是想先提醒一下你,接下来我要给你介绍的JWT令牌,跟Cookie-Session并不是完全对等的解决方案,它只用来处理认证授权问题,是Cookie-Session在认证授权问题上的替代品,充其量能携带少量非敏感的信息。而我们不能说,JWT要比Cookie-Session更加先进,它也更不可能全面取代Cookie-Session机制。
|
||||
|
||||
JWT:解决认证授权问题的无状态方案
|
||||
|
||||
好,现在我们知道了,Cookie-Session机制在分布式环境下会遇到CAP不可兼得的问题,而在多方系统中,也就更不可能谈什么Session层面的数据共享了,哪怕服务端之间能共享数据,客户端的Cookie也没法跨域。
|
||||
|
||||
所以,我们不得不重新捡起最初被抛弃的思路:当服务器存在多个,客户端只有一个时,那就把状态信息存储在客户端,每次随着请求发回服务器中去。可是奇怪了,前面我才说过,这样做的缺点是无法携带大量信息,而且有泄露和篡改的安全风险。
|
||||
|
||||
其实啊,信息量受限的问题目前并没有太好的解决办法,但是要确保信息不被中间人篡改,还是可以实现的,JWT便是这个问题的标准答案。
|
||||
|
||||
JWT(JSON Web Token)定义于RFC 7519标准之中,是目前广泛使用的一种令牌格式,尤其经常与OAuth 2.0配合应用于分布式的、涉及多方的应用系统中。
|
||||
|
||||
那么,在介绍JWT的具体构成之前,我们先来直观地看一下它是什么样子的:
|
||||
|
||||
|
||||
|
||||
这个示意图来源于JWT官网,数据则是我随意编的。图上右边的JSON结构,是JWT令牌中携带的信息,左边的字符串呈现了JWT令牌的本体。它最常见的使用方式是附在名为Authorization的Header发送给服务端,其前缀在RFC 6750中被规定为Bearer。
|
||||
|
||||
如果你没有忘记第23讲“认证方案”与第24讲“OAuth 2.0”的知识内容,那么当你在看到Authorization这个Header与Bearer这个前缀的时候,就应该能意识到,它是HTTP认证框架中的OAuth 2.0认证方案。下面的示例代码就展示了一次采用JWT令牌的HTTP实际请求:
|
||||
|
||||
GET /restful/products/1 HTTP/1.1
|
||||
Host: icyfenix.cn
|
||||
Connection: keep-alive
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJpY3lmZW5peCIsInNjb3BlIjpbIkFMTCJdLCJleHAiOjE1ODQ5NDg5NDcsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwianRpIjoiOWQ3NzU4NmEtM2Y0Zi00Y2JiLTk5MjQtZmUyZjc3ZGZhMzNkIiwiY2xpZW50X2lkIjoiYm9va3N0b3JlX2Zyb250ZW5kIiwidXNlcm5hbWUiOiJpY3lmZW5peCJ9.539WMzbjv63wBtx4ytYYw_Fo1ECG_9vsgAn8bheflL8
|
||||
|
||||
|
||||
另外我还要跟你强调的是,在前面的令牌结构示意图中,右边的状态信息是对令牌使用Base64URL转码后得到的明文,请你特别注意它是明文。
|
||||
|
||||
毕竟JWT只解决防篡改的问题,并不解决防泄露的问题,所以令牌默认是不加密的。尽管你自己要加密的话也并不难做到,接收时自行解密即可,但这样做其实没有太大的意义,具体原因我这里先卖个关子,下一节课我讲“保密”的时候再给你详细解释。
|
||||
|
||||
JWT令牌的三部分结构
|
||||
|
||||
那么,从前面给出的明文中你已经知道,JWT令牌是以JSON结构(毕竟名字就叫JSON Web Token)存储的,其结构总体上可以划分为三个部分,每个部分用点号“ . ”分隔开。
|
||||
|
||||
令牌的第一部分是令牌头(Header),其内容如下所示:
|
||||
|
||||
{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT"
|
||||
}
|
||||
|
||||
|
||||
这里你可以看到,它描述了令牌的类型(统一为typ:JWT)以及令牌签名的算法,示例中HS256为HMAC SHA256算法的缩写,其他各种系统支持的签名算法你可以参考JWT官网。
|
||||
|
||||
|
||||
额外知识:散列消息认证码-
|
||||
|
||||
在这一讲及后面其他关于安全的课程内容中,你会经常看到在某种哈希算法前出现“HMAC”的前缀,这是指散列消息认证码(Hash-based Message Authentication Code,HMAC)。你可以简单将它理解为一种带有密钥的哈希摘要算法,实现形式上通常是把密钥以加盐方式混入,与内容一起做哈希摘要。-
|
||||
|
||||
HMAC哈希与普通哈希算法的差别是,普通的哈希算法通过Hash函数结果易变性,保证了原有内容未被篡改,而HMAC不仅保证了内容未被篡改过,还保证了该哈希确实是由密钥的持有人所生成的。
|
||||
|
||||
|
||||
|
||||
|
||||
令牌的第二部分是负载(Payload),这是令牌真正需要向服务端传递的信息。针对认证问题,负载至少应该包含能够告知服务端“这个用户是谁”的信息;针对授权问题,令牌至少应该包含能够告知服务端“这个用户拥有什么角色/权限”的信息。
|
||||
|
||||
JWT的负载部分是可以完全自定义的,我们可以根据具体要解决的问题,设计自己所需要的信息,只是总容量不能太大,毕竟它受HTTP Header大小的限制。下面我们来看一个JWT负载的例子:
|
||||
|
||||
{
|
||||
"username": "icyfenix",
|
||||
"authorities": [
|
||||
"ROLE_USER",
|
||||
"ROLE_ADMIN"
|
||||
],
|
||||
"scope": [
|
||||
"ALL"
|
||||
],
|
||||
"exp": 1584948947,
|
||||
"jti": "9d77586a-3f4f-4cbb-9924-fe2f77dfa33d",
|
||||
"client_id": "bookstore_frontend"
|
||||
}
|
||||
|
||||
|
||||
另外,JWT在RFC 7519标准中,推荐(非强制约束)了七项声明名称(Claim Name),如果你在设计令牌时需要用到这些内容,我建议其字段名要与官方的保持一致:
|
||||
|
||||
|
||||
iss(Issuer):签发人。
|
||||
exp(Expiration Time):令牌过期时间。
|
||||
sub(Subject):主题。
|
||||
aud (Audience):令牌受众。
|
||||
nbf (Not Before):令牌生效时间。
|
||||
iat (Issued At):令牌签发时间。
|
||||
jti (JWT ID):令牌编号。
|
||||
|
||||
|
||||
|
||||
补充:除此之外,在RFC 8225、RFC 8417、RFC 8485等规范文档,以及OpenID等协议当中,都定义有约定好公有含义的名称,内容比较多,我就不贴出来了,你可以参考IANA JSON Web Token Registry。
|
||||
|
||||
|
||||
令牌的第三部分是签名(Signature)。签名的意思是:使用在对象头中公开的特定签名算法,通过特定的密钥(Secret,由服务器进行保密,不能公开)对前面两部分内容进行加密计算,产生签名值。
|
||||
|
||||
这里我们继续以前面例子里使用的JWT默认的HMAC SHA256算法为例,它会通过以下公式产生签名值:
|
||||
|
||||
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)
|
||||
|
||||
|
||||
签名的意义在于,它可以确保负载中的信息是可信的、没有被篡改的,也没有在传输过程中丢失任何信息。因为被签名的内容哪怕是发生了一个字节的变动,也会导致整个签名发生显著变化。
|
||||
|
||||
此外,由于签名这件事情,只能由认证授权服务器完成(只有它知道Secret),任何人都无法在篡改后重新计算出合法的签名值,所以服务端才能够完全信任客户端传上来的JWT中的负载信息。
|
||||
|
||||
JWT默认的签名算法HMAC SHA256是一种带密钥的哈希摘要算法,加密与验证过程都只能由中心化的授权服务来提供,所以这种方式一般只适合于授权服务与应用服务处于同一个进程中的单体应用。
|
||||
|
||||
另外,在多方系统,或者是授权服务与资源服务分离的分布式应用当中,通常会采用非对称加密算法来进行签名。这时候,除了授权服务端持有的可以用于签名的私钥以外,还会对其他服务器公开一个公钥,公开方式一般遵循JSON Web Key规范。
|
||||
|
||||
不过,这个公钥不能用来签名,但它能被其他服务用于验证签名是否由私钥所签发的。这样,其他服务器就也能不依赖授权服务器、无需远程通讯,即可独立判断JWT令牌中的信息的真伪了。
|
||||
|
||||
|
||||
在后面课程会展示的Fenix’s Bookstore的单体服务版本中,我们采用了默认的HMAC SHA256算法来加密签名,而在Istio服务网格版本里,终端用户认证会由服务网格的基础设施来完成,此时就改用了非对称加密的RSA SHA256算法来进行签名。如果你还想更深入地了解凭证安全,到时不妨对比一下这两部分的代码。更多关于哈希摘要、对称和非对称加密的讨论,我将会在“传输”这个小章节中继续展开介绍。
|
||||
|
||||
|
||||
JWT令牌的缺陷
|
||||
|
||||
现在我们知道,JWT令牌是多方系统中的一种优秀的凭证载体,它不需要任何一个服务节点保留任何一点状态信息,就能够保障认证服务与用户之间的承诺是双方当时真实意图的体现,是准确、完整、不可篡改且不可抵赖的。
|
||||
|
||||
同时,由于JWT本身可以携带少量信息,这十分有利于RESTful API的设计,比较容易地做成无状态服务,我们在做水平扩展时就不需要像前面Cookie-Session方案那样,考虑如何部署的问题了。在现实应用中,也确实有一些项目直接采用JWT来承载上下文信息,以此实现完全无状态的服务端,这样就可以获得任意加入或移除服务节点的巨大便利,天然具有完美的水平扩缩能力。
|
||||
|
||||
比如,在调试Fenix’s Bookstore的代码时,你随时都可以重启服务,在重启后,客户端仍然能毫无感知地继续操作流程;而对于有状态的系统,就必须通过重新登录、进行前置业务操作,来为服务端重建状态。尽管在大型系统中,只使用JWT来维护上下文状态,服务端完全不持有状态是不太现实的,不过将热点的服务单独抽离出来做成无状态,仍然是一种有效提升系统吞吐能力的架构技巧。
|
||||
|
||||
但是,JWT也并不是一种完美的解决方案,它存在着以下几个经常被提及的缺点:
|
||||
|
||||
|
||||
令牌难以主动失效
|
||||
|
||||
|
||||
JWT令牌一旦签发,理论上就和认证服务器没有什么瓜葛了,在到期之前就会始终有效,除非我们在服务器部署额外的逻辑去处理失效问题,而这对某些管理功能的实现是很不利的。比如说,一种十分常见的需求是:要求一个用户只能在一台设备上登录,在B设备登录后,之前已经登录过的A设备就应该自动退出。
|
||||
|
||||
如果我们采用JWT,就必须设计一个“黑名单”的额外逻辑,把要主动失效的令牌集中存储起来,而无论这个黑名单是实现在Session、Redis还是数据库当中,都会让服务退化成有状态服务,这就降低了JWT本身的价值。但在使用JWT时,设置黑名单依然是很常见的做法,需要维护的黑名单一般是很小的状态量,因此在许多场景中还是有存在价值的。
|
||||
|
||||
|
||||
相对更容易遭受重放攻击
|
||||
|
||||
|
||||
这里首先我要说明,Cookie-Session也是有重放攻击问题的,只是因为Session中的数据控制在服务端手上,应对重放攻击会相对主动一些。
|
||||
|
||||
但是,要在JWT层面解决重放攻击,就需要付出比较大的代价了,因为无论是加入全局序列号(HTTPS协议的思路)、Nonce字符串(HTTP Digest验证的思路)、挑战应答码(当下网银动态令牌的思路)、还是缩短令牌有效期强制频繁刷新令牌,在真正应用起来时都很麻烦。
|
||||
|
||||
而真要处理重放攻击的话,我建议的解决方案是在信道层次(比如启用HTTPS)上解决,而不提倡在服务层次(比如在令牌或接口其他参数上增加额外逻辑)上解决。
|
||||
|
||||
|
||||
只能携带相当有限的数据
|
||||
|
||||
|
||||
HTTP协议并没有强制约束Header的最大长度,但是,各种服务器、浏览器都会有自己的约束,比如Tomcat就要求Header最大不超过8KB,而在Nginx中则默认为4KB。所以在令牌中存储过多的数据,不仅耗费传输带宽,还有额外的出错风险。
|
||||
|
||||
|
||||
必须考虑令牌在客户端如何存储
|
||||
|
||||
|
||||
严谨地说,这个并不是JWT的问题,而是系统设计的问题。如果在授权之后,操作完关掉浏览器就结束了,那把令牌放到内存里面,压根不考虑持久化,其实才是最理想的方案。
|
||||
|
||||
但并不是谁都能忍受一个网站关闭之后,下次就一定强制要重新登录的。这样的话,你想想客户端该把令牌存放到哪里呢?Cookie?localStorage?还是Indexed DB?它们都有泄露的可能,而令牌一旦泄露,别人就可以冒充用户的身份做任何事情。
|
||||
|
||||
|
||||
无状态也不总是好的
|
||||
|
||||
|
||||
这个其实不也是JWT的问题。如果不能想像无状态会有什么不好的话,我给你提个需求:请基于无状态JWT的方案,做一个在线用户实时统计功能。兄弟,难搞哦。
|
||||
|
||||
小结
|
||||
|
||||
Cookie-Session机制是为HTTP量身定做的经典凭证实现方案,它曾经为信息系统解决过无数问题。不过,随着微服务的流行,分布式系统变得越来越主流,因此由于分布式下共享数据的CAP矛盾,就导致了Cookie-Session在一些场景中遇到了C与A难以取舍的情况。
|
||||
|
||||
而无状态的JWT方案在合适的场景下,确实可以带来实实在在的好处,它可以让服务端水平扩容变得异常容易,不用担心Session复制的效率问题,也不用担心Session挂掉后,整个集群全部无法正常工作的问题。
|
||||
|
||||
然而,场景二字仍然是关键词,脱离了具体场景,我们就很难说哪种凭证方案更好或者更坏,在这节课中,我也特别强调了JWT的几个缺点。你要记住,权衡才是架构设计中最关键的地方。
|
||||
|
||||
一课一思
|
||||
|
||||
这节课我给你介绍了Cookie-Session和JWT两种最常见的凭证实现,除此之外,你还知道其他凭证的实现方案吗?它们都有什么应用场景和优缺点?
|
||||
|
||||
欢迎给我留言,分享你的答案。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
184
专栏/周志明的架构课/27_保密:系统如何保证敏感数据无法被内外部人员窃取滥用?.md
Normal file
184
专栏/周志明的架构课/27_保密:系统如何保证敏感数据无法被内外部人员窃取滥用?.md
Normal file
@@ -0,0 +1,184 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 _ 保密:系统如何保证敏感数据无法被内外部人员窃取滥用?
|
||||
你好,我是周志明。这节课,我们来讨论在信息系统中,一个一直非常受人关注的安全性议题:保密。
|
||||
|
||||
保密是加密和解密的统称,意思就是以某种特殊的算法改变原有的信息数据,使得未授权的用户即使获得了已加密的信息,但因为不知道解密的方法,或者就算知晓解密的算法、但缺少解密所需的必要信息,所以仍然无法了解数据的真实内容。
|
||||
|
||||
那么,根据需要保密信息所处的不同环节,我们可以将其划分为“信息在客户端时的保密”“信息在传输时的保密”和“信息在服务端时的保密”三类,或者也可以进一步概括为“端的保密”和“链路的保密”两类。
|
||||
|
||||
这里,我们先把最复杂、最有效,但是又最早就有了标准解决方案的“传输”环节单独拿出来,放到后面两讲中展开探讨。在今天的这节课当中,我们只讨论两个端的环节,即在客户端和服务端中的信息保密问题。
|
||||
|
||||
保密的强度
|
||||
|
||||
好,首先我们要知道,保密是有成本的,追求越高的安全等级,我们就要付出越多的工作量与算力消耗。就连国家保密法都会把秘密信息划分为秘密、机密、绝密三级来区别对待,可见即使是信息安全,也应该有所取舍。
|
||||
|
||||
那么接下来,我就以用户登录为例,给你列举几种不同强度的保密手段,看看它们的防御关注点与弱点分别都是什么。这里你需要注意的是,以下提及到的不同保密手段,并不一定就是正确的做法,只是为了强调保密手段是有成本、有不同的强度的。
|
||||
|
||||
|
||||
以摘要代替明文
|
||||
|
||||
|
||||
如果密码本身比较复杂,那么一次简单的哈希摘要就至少可以保证,即使在传输过程中有信息泄露,也不会被逆推出原信息;即使密码在一个系统中泄露了,也不至于威胁到其他系统的使用。但这种处理不能防止弱密码被彩虹表攻击所破解。
|
||||
|
||||
|
||||
先加盐值再做哈希是应对弱密码的常用方法
|
||||
|
||||
|
||||
盐值可以替弱密码建立一道防御屏障,在一定程度上可以防御已有的彩虹表攻击。但它并不能阻止加密结果被监听、窃取后,攻击者直接发送加密结果给服务端进行冒认。
|
||||
|
||||
|
||||
将盐值变为动态值能有效防止冒认
|
||||
|
||||
|
||||
如果每次向服务端传输时,密码都掺入了动态的盐值,让每次加密的结果都不一样,那么即使传输给服务端的加密结果被窃取了,攻击者也不能冒用来进行另一次调用。不过,尽管在双方通讯均可能泄露的前提下,协商出只有通讯双方才知道的保密信息是完全可行的(后面两讲介绍“传输安全层”时会提到),但这样协商出盐值的过程将变得极为复杂,而且每次协商只能保护一次操作,因而也很难阻止攻击者对其他服务的重放攻击。
|
||||
|
||||
|
||||
加入动态令牌防止重放攻击
|
||||
|
||||
|
||||
我们可以给服务加入动态令牌,这样在网关或其他流量公共位置建立校验逻辑,服务端愿意付出在集群中分发令牌信息等代价的前提下,就可以做到防止重放攻击。但这种手段的弱点是,依然不能抵御传输过程中被嗅探而泄露信息的问题。
|
||||
|
||||
|
||||
启用HTTPS来应对因嗅探而导致的信息泄露问题
|
||||
|
||||
|
||||
启用HTTPS可以防御链路上的恶意嗅探,也能在通讯层面解决重放攻击的问题。但是它依然有因客户端被攻破而产生伪造根证书的风险、因服务端被攻破产生证书泄露被中间人冒认的风险、因CRL更新不及时或者OCSP Soft-fail产生吊销证书被冒用的风险,以及因TLS的版本过低或密码学套件选用不当产生加密强度不足的风险。
|
||||
|
||||
|
||||
进一步提升保密强度的不同手段
|
||||
|
||||
|
||||
为了抵御前面提到的这种种风险,我们还要进一步提升保密强度。比如说,银行会使用独立于客户端的存储证书的物理设备(俗称的U盾),来避免根证书被客户端中的恶意程序窃取伪造;当大型网站涉及到账号、金钱等操作时,会使用双重验证开辟出一条独立于网络的信息通道(如手机验证码、电子邮件),来显著提高冒认的难度;甚至一些关键企业(如国家电网)或机构(如军事机构),会专门建设遍布全国各地的、与公网物理隔离的专用内部网络,来保障通讯安全。
|
||||
|
||||
现在,通过了解以上这些逐步升级的保密措施,你应该能对“更高的安全强度同时也意味着要付出更多的代价”,有更加具体的理解了,并不是任何一个网站、系统、服务都需要无限拔高的安全性。
|
||||
|
||||
也许这个时候,你还会好奇另一个问题:安全的强度有尽头吗?存不存在某种绝对安全的保密方式?
|
||||
|
||||
答案可能会出乎你的意料,确实是有的。信息论之父香农就严格证明了一次性密码(One Time Password)的绝对安全性。
|
||||
|
||||
但是使用一次性密码必须有个前提,就是我们已经提前安全地把密码或密码列表传达给了对方。比如说,你给朋友人肉送去一本存储了完全随机密码的密码本,然后每次使用其中一条密码来进行加密通讯,用完一条丢弃一条。这样理论上可以做到绝对的安全,但显然这种绝对安全对于互联网来说没有任何的可行性。
|
||||
|
||||
所以下面,我们就来看一下在互联网中,信息在客户端的加密是否有必要和有价值。
|
||||
|
||||
客户端加密的意义
|
||||
|
||||
其实,客户端在用户登录、注册一类场景里是否需要对密码进行加密,这个问题一直存有争议。而我的观点很明确:为了保证信息不被黑客窃取而去做客户端加密,其实没有太大意义,对绝大多数的信息系统来说,启用HTTPS可以说是唯一的实际可行的方案。但是!为了保证密码不在服务端被滥用,而在客户端就开始加密的做法,还是很有意义的。
|
||||
|
||||
现在,大网站被拖库的事情层出不穷,密码明文被写入数据库、被输出到日志中之类的事情也屡见不鲜。所以在做系统设计的时候,我们就应该把明文密码这种东西当成是最烫手的山芋来看待,越早消灭掉越好。毕竟把一个潜在的炸弹从客户端运到服务端,对绝大多数系统来说都没有必要。
|
||||
|
||||
那我为什么会说,客户端加密对防御泄密没有意义呢?原因是网络通讯并不是由发送方和接收方点对点进行的,客户端无法决定用户送出的信息能不能到达服务端,或者会经过怎样的路径到达服务端,所以在传输链路必定是不安全的前提下,无论客户端做什么防御措施,最终都会沦为“马其诺防线”。
|
||||
|
||||
此外,前面我还多次提到过中间人攻击(即攻击者),它是指通过劫持掉客户端到服务端之间的某个节点,包括但不限于代理(通过HTTP代理返回赝品)、路由器(通过路由导向赝品)、DNS服务(直接将机器的DNS查询结果替换为赝品地址)等,来给你访问的页面或服务注入恶意的代码。极端情况下,甚至可能把你要访问的服务或页面整个给取代掉,此时不管你在页面上设计了多么精巧严密的加密措施,也都不会有保护作用。而攻击者只需劫持路由器,或者是在局域网内的其他机器上释放ARP病毒,便有可能做到这一点。
|
||||
|
||||
|
||||
额外知识:中间人攻击(Man-in-the-Middle Attack,MitM)-
|
||||
|
||||
在消息发出方和接收方之间拦截双方通讯。我们用写信来做个类比:你给朋友写了一封信,而邮递员可以拆开看你寄出去的信,甚至把信的内容改掉,然后重新封起来,再寄出去给你的朋友。朋友收到信之后给你回信,邮递员又可以拆开看,看完随便改,改完封好再送到你手上。你全程都不知道自己寄出去的信和收到的信都经过邮递员这个“中间人”转手和处理。换句话说,对于你和你朋友来讲,邮递员这个“中间人”角色是不可见的。
|
||||
|
||||
|
||||
当然了,对于“不应把明文传递到服务端”的这个观点,很多人也会有一些不同的意见。比如其中一种保存明文密码的理由是为了便于客户端做动态加盐,因为这样需要服务端先存储明文,或者是存储某种盐值/密钥固定的加密结果,才能每次用新的盐值重新加密,然后与客户端传上来的加密结果进行比对。
|
||||
|
||||
而对此我的看法是,这种每次从服务端请求动态盐值,在客户端加盐传输的做法通常都得不偿失,因为客户端无论是否动态加盐,都不可能代替HTTPS。真正防御性的密码加密存储确实应该在服务端中进行,但这是为了防御服务端被攻破而批量泄露密码的风险,并不是为了增强传输过程的安全性。
|
||||
|
||||
那么,在服务端是如何处理信息的保密问题的呢?
|
||||
|
||||
密码的存储和验证
|
||||
|
||||
接下来,我就以Fenix’s Bookstore中的真实代码为例,给你介绍一下针对一个普通安全强度的信息系统,密码要如何从客户端传输到服务端,然后存储进数据库。
|
||||
|
||||
这里的“普通安全强度”的意思是,在具有一定保密安全性的同时,避免消耗过多的运算资源,这样验证起来也相对便捷。毕竟对多数信息系统来说,只要配合一定的密码规则约束,比如密码要求长度、特殊字符等等,再配合HTTPS传输,就已经足够防御大多数风险了。即使是用户采用了弱密码、客户端通讯被监听、服务端被拖库、泄露了存储的密文和盐值等问题同时发生,也能够最大限度地避免用户明文密码被逆推出来。
|
||||
|
||||
好,下面我们就先来看看,在Fenix’s Bookstore中密码是如何创建出来的。
|
||||
|
||||
首先,用户在客户端注册,输入明文密码:123456。
|
||||
|
||||
password = 123456
|
||||
|
||||
|
||||
然后,客户端对用户密码进行简单哈希摘要,我们可选的算法有MD2/4/5、SHA1/256/512、BCrypt、PBKDF1/2,等等。这里为了突出“简单”的哈希摘要,我故意没有排除掉MD系这些已经有了高效碰撞手段的算法。
|
||||
|
||||
client_hash = MD5(password) // e10adc3949ba59abbe56e057f20f883e
|
||||
|
||||
|
||||
接着,为了防御彩虹表攻击,我们应进行加盐处理,客户端加盐只需要取固定的字符串即可,如果实在不安心,可以使用伪动态的盐值(“伪动态”是指服务端不需要额外通讯就可以得到的信息,比如由日期或用户名等自然变化的内容,加上固定字符串构成)。
|
||||
|
||||
client_hash = MD5(MD5(password) + salt) // SALT = $2a$10$o5L.dWYEjZjaejOmN3x4Qu
|
||||
|
||||
|
||||
现在,我们假设攻击者截获了客户端发出的信息,得到了摘要结果和采用的盐值,那攻击者就可以枚举遍历所有8位字符以内(“8位”只是举个例子,反正就是指弱密码,你如果拿1024位随机字符当密码用,加不加盐,彩虹表都跟你没什么关系)的弱密码,然后对每个密码再加盐计算,就得到了一个针对固定盐值的对照彩虹表。
|
||||
|
||||
所以为了应对这种暴力破解,我并不提倡在盐值上做动态化,更理想的方式是引入慢哈希函数来解决。
|
||||
|
||||
慢哈希函数是指这个函数的执行时间是可以调节的哈希函数,它通常是以控制调用次数来实现的。BCrypt算法就是一种典型的慢哈希函数,它在做哈希计算时,接受盐值Salt和执行成本Cost两个参数(代码层面Cost一般是混入在Salt中,比如上面例子中的Salt就是混入了10轮运算的盐值,10轮的意思是2的10次方哈希,Cost参数是放在指数上的,最大取值就31)。
|
||||
|
||||
那么,如果我们控制BCrypt的执行时间,大概是0.1秒完成一次哈希计算的话,按照1秒生成10个哈希值的速度,要算完所有的10位大小写字母和数字组成的弱密码,就大概需要P(62,10)/(360024365)/0.1=1,237,204,169年的时间。
|
||||
|
||||
client_hash = BCrypt(MD5(password) + salt) // MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
|
||||
|
||||
|
||||
好,接下来,我们要做的就只是防御服务端被拖库后,针对固定盐值的批量彩虹表攻击。具体做法是为每一个密码(指客户端传来的哈希值)产生一个随机的盐值。我建议采用“密码学安全伪随机数生成器”(Cryptographically Secure Pseudo-Random Number Generator,CSPRNG),来生成一个长度与哈希值相等的随机字符串。
|
||||
|
||||
对于Java语言来说,从Java SE 7开始,就提供了java.security.SecureRandom类,用于支持CSPRNG字符串生成。
|
||||
|
||||
SecureRandom random = new SecureRandom();
|
||||
byte server_salt[] = new byte[36];
|
||||
random.nextBytes(server_salt); // tq2pdxrblkbgp8vt8kbdpmzdh1w8bex
|
||||
|
||||
|
||||
好,我们继续进行这个密码的创建过程。我们把动态盐值混入客户端传来的哈希值,再做一次哈希,产生出最终的密文,并和上一步随机生成的盐值一起写入到同一条数据库记录中(由于慢哈希算法会占用大量的处理器资源,所以我并不推荐在服务端中采用)。
|
||||
|
||||
不过,如果你在学习课程后面的实战模块时,阅读了Fenix’s Bookstore的源码,就会发现这步依然采用了Spring Security 5中的BcryptPasswordEncoder。但是请注意,它默认构造函数中的Cost参数值为-1,经转换后实际只进行了2的10次方=1024次计算,所以不会对服务端造成额外的压力。
|
||||
|
||||
另外你还可以看到,代码中并没有显式地传入CSPRNG生成的盐值,这是因为BCryptPasswordEncoder本身就会自动调用CSPRNG产生盐值,并将该盐值输出在结果的前32位之中,所以也不需要专门在数据库中设计存储盐值字段。
|
||||
|
||||
这个过程我们用伪代码来表示一下:
|
||||
|
||||
server_hash = SHA256(client_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
|
||||
DB.save(server_hash, server_salt);
|
||||
|
||||
|
||||
到这里,你会发现这个加密存储的过程其实相对比较复杂,但是运算压力最大的过程(慢哈希)是在客户端完成的,对服务端的压力很小,也不用怕因网络通讯被截获而导致明文密码泄露的问题。
|
||||
|
||||
OK,等密码存储完之后,后面验证的过程就跟加密的操作是类似的,我们简单了解下这个步骤就可以了:
|
||||
|
||||
首先,在客户端,用户在登录页面中输入密码明文:123456,经过与注册相同的加密过程,向服务端传输加密后的结果。
|
||||
|
||||
authentication_hash = MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
|
||||
|
||||
|
||||
然后,在服务端,接收到客户端传输上来的哈希值,从数据库中取出登录用户对应的密文和盐值,采用相同的哈希算法,针对客户端传来的哈希值、服务端存储的盐值计算摘要结果。
|
||||
|
||||
result = SHA256(authentication_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
|
||||
|
||||
|
||||
最后,比较上一步的结果和数据库储存的哈希值是否相同,如果相同就说明密码正确,反之密码错误。
|
||||
|
||||
authentication = compare(result, server_hash) // yes
|
||||
|
||||
|
||||
小结
|
||||
|
||||
这节课我们其实讨论了两个观点:
|
||||
|
||||
|
||||
第一个观点是,安全并不是一个非此即彼的二元选项,它是连续值,而不是安全与不安全的问题。
|
||||
第二个观点是,你要明确在信息系统里,客户端加密、服务端解密两项操作的意义是什么。
|
||||
|
||||
|
||||
另外,针对“如何取得相对安全与良好性能之间平衡”这个问题,也是你在进行架构设计时必须权衡取舍的。
|
||||
|
||||
一课一思
|
||||
|
||||
这节课,我们讨论到了客户端对敏感信息加密后,传输是否有意义的话题。请说说你对这个问题的看法吧。
|
||||
|
||||
欢迎在留言区分享你的见解。如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
139
专栏/周志明的架构课/28_传输(上):传输安全的基础,摘要、加密与签名.md
Normal file
139
专栏/周志明的架构课/28_传输(上):传输安全的基础,摘要、加密与签名.md
Normal file
@@ -0,0 +1,139 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 _ 传输(上):传输安全的基础,摘要、加密与签名
|
||||
你好,我是周志明。今天我们接着上节课的话题,一起来探讨下信息在传输时的保密问题。
|
||||
|
||||
其实在前面几讲中,我已经为传输安全层挖下了不少坑,比如说:
|
||||
|
||||
|
||||
基于信道的认证是怎样实现的?
|
||||
为什么说,HTTPS是绝大部分信息系统防御通讯被窃听和篡改的唯一可行手段?
|
||||
传输安全层难道不也是一种自动化的加密吗?
|
||||
为什么说客户端如何加密都不能代替HTTPS呢?
|
||||
|
||||
|
||||
所以接下来,我会花两讲的时间,通过“假设链路上的安全得不到保障,攻击者要如何摧毁之前在认证、授权、凭证、保密中所提到的种种安全机制”这个场景,来给你讲解传输层安全所要解决的问题,同时这也能给你解答前面提到的这些问题。
|
||||
|
||||
另外,在上节课的开篇里我也提到过,安全架构中的传输环节是最复杂、最有效,但又是最早就有了标准解决方案的,它包含了许多今天在开发中经常听说,但却不为多数开发人员所知的细节,比如传输安全中的摘要、加密与签名,以及数字证书与传输安全层,等等。那么今天这一讲,我们就先来理清系统安全中,摘要、加密与签名这三种行为的异同之处。
|
||||
|
||||
哈希摘要的特点和作用
|
||||
|
||||
现在,让我们先从JWT令牌的一小段“题外话”,来引出这一讲要讨论的话题吧。
|
||||
|
||||
你应该已经知道,JWT令牌携带信息的可信度源于它是被签名过的信息,所以是不可篡改的,是令牌签发者真实意图的体现。然而,你是否了解过签名具体做了什么呢?为什么有签名就能够让负载中的信息变得不可篡改和不可抵赖呢?
|
||||
|
||||
要解释数字签名(Digital Signature)的话,就必须先从密码学算法的另外两种基础应用“摘要”和“加密”说起。
|
||||
|
||||
摘要也被叫做是数字摘要(Digital Digest)或数字指纹(Digital Fingerprint)。在JWT令牌中,默认的签名信息就是通过令牌头中指定的哈希算法(HMAC SHA256),针对令牌头、负载和密钥所计算出来的摘要值。
|
||||
|
||||
我们来看一个例子:
|
||||
|
||||
signature = SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)
|
||||
|
||||
|
||||
理想的哈希算法都具备这样两个特性:
|
||||
|
||||
一是易变性。这是指算法的输入端发生了任何一点细微变动,都会引发雪崩效应(Avalanche Effect),导致输出端的结果产生极大的变化。
|
||||
|
||||
这个特性经常被用来做校验,以此保护信息在传输的过程中不会被篡改。比如在互联网下载大文件,通常都会附有一个哈希校验码,用来确保下载下来的文件没有因网络或其他原因,与原文件产生任何偏差。
|
||||
|
||||
二是不可逆性。要知道,摘要的过程是单向的,我们不可能从摘要的结果中,逆向还原出输入值来。
|
||||
|
||||
其实这点只要你具备初中数学知识就能想明白,世间的信息有无穷多种,而不管摘要结果的位数是32、128还是512 Bit,即使它再大也总归是个有限的数字,所以输入数据与输出的摘要结果必然不是一一对应的关系。
|
||||
|
||||
可以想想看,如果我把一部电影进行了摘要,形成256 Bit的哈希值,应该不会有人指望能从这个哈希值中还原出一部电影的。
|
||||
|
||||
实际上,现在我们偶尔还能听到MD5、SHA1或其他哈希算法被破解了的新闻,这里的“破解”并不是“解密”的意思,而是指找到了该算法的高效率碰撞方法,它能够在合理的时间内,生成一个摘要结果为指定内容的输入比特流,但它并不能代表这个碰撞产生的比特流就会是原来的输入源。
|
||||
|
||||
所以通过这两个特性,我们能发现,摘要的意义就是在源信息不泄露的前提下辨别其真伪。易变性保证了,从公开的特征上就可以甄别出摘要结果是否来自于源信息;而不可逆性保证了,从公开的特征并不会暴露出源信息。这跟我们今天用来做身份识别的指纹、面容和虹膜的生物特征是具有高度可比性的。
|
||||
|
||||
摘要与加密和签名的本质区别
|
||||
|
||||
另外,在一些场合中,摘要也会被借用来做加密(如上节课“保密”中介绍的慢哈希Bcrypt算法)和签名(如第26讲中提到JWT签名中的HMAC SHA256算法)。但从严格意义上看,摘要与这两者是有本质的区别的。
|
||||
|
||||
加密与摘要的本质区别就在于,摘要是不可逆的,而加密是可逆的,逆过程就是解密。
|
||||
|
||||
在经典密码学时代,加密的安全主要是依靠机密性来保证的,也就是依靠保护加密算法或算法的执行参数不被泄露,来保障信息的安全。
|
||||
|
||||
而现代密码学并不依靠机密性,加解密算法都是完全公开的,信息的安全是建立在特定问题的计算复杂度之上。具体来说,就是算法根据输入端计算输出结果,这里耗费的算力资源很小;但根据输出端的结果反过来推算原本的输入,耗费的算力就极其庞大。
|
||||
|
||||
一个经常被用来说明计算复杂度的例子,就是大数的质因数分解,我们可以轻而易举地(以O(nlogn)的复杂度)计算出两个大素数的乘积:
|
||||
|
||||
97667323933 * 128764321253 = 12576066674829627448049
|
||||
|
||||
|
||||
我们知道,根据算术基本定理,质因数的分解形式是唯一的,而且示例前面的计算条件中,给出的运算因子已经是质数了,所以12,576,066,674,829,627,448,049的分解形式,就只有一种,即上面给出的唯一答案。
|
||||
|
||||
然而,如何对大数进行质因数分解,其实到今天都还没有找到多项式时间的算法,甚至我们都无法确切地知道,这个问题属于哪个复杂度类(Complexity Class)。
|
||||
|
||||
所以说,尽管这个加密过程理论上一定是可逆的,但实际上的算力差异决定了其逆过程根本无法实现。
|
||||
|
||||
|
||||
注:24位十进制数的因数分解完全在现代计算机的暴力处理能力范围内,这里只是举例。但目前很多计算机科学家都相信,大数分解问题就是一种P!=NP的证例,尽管也并没有人能证明它一定不存在多项式时间的解法。除了质因数分解外,离散对数和椭圆曲线也是具备实用性的复杂问题。
|
||||
|
||||
|
||||
那既然我们提到了密码学,下面我们就来了解下密码学中最重要的应用,信息加密算法,一起来学习、理解下加密是如何保护信息不被泄露的。
|
||||
|
||||
加密算法的两大类型:对称与非对称
|
||||
|
||||
在现代密码学算法中,根据加密与解密是否采用了同一个密钥,将算法分为了对称加密和非对称加密两大类型。这两类算法各有明确的优劣势与应用场景。
|
||||
|
||||
首先我们来看看对称加密算法。
|
||||
|
||||
对称加密的缺点显而易见:因为加密和解密都使用相同的密钥,那么当通讯的成员数量增加时,为了保证两两通讯都能采用独立的密钥,密钥数量就要与成员数量的平方成正比,这必然就会面临密钥管理的难题。
|
||||
|
||||
而更尴尬的难题是,当通讯双方原本就不存在安全的信道时,如何才能把一个只能让通讯双方才能知道的密钥传输给对方?而如果有通道可以安全地传输密钥,那为何不使用现有的通道传输信息呢?这个“蛋鸡悖论”曾经在很长的时间里,严重阻碍了密码学在真实世界中的推广应用。
|
||||
|
||||
因此,在1970年代中后期出现的非对称加密算法,就从根本上解决了密钥分发的难题。
|
||||
|
||||
非对称加密算法把密钥分成了公钥和私钥,公钥可以完全公开,无需安全传输的保证。私钥由用户自行保管,不参与任何通讯传输。这两个密钥谁加密、谁解密,就构成了两种不同的用途:
|
||||
|
||||
|
||||
公钥加密,私钥解密,这种就是加密,用于向私钥所有者发送信息,这个信息可能被他人篡改,但是无法被他人得知。举个例子,如果甲想给乙发一个安全保密的数据,那么应该甲乙各自有一个私钥,甲先用乙的公钥加密这段数据,再用自己的私钥加密这段加密后的数据,最后再发给乙。这样确保了内容既不会被读取,也不能被篡改。
|
||||
私钥加密,公钥解密,这种就是签名,用于让所有公钥所有者验证私钥所有者的身份,并能用来防止私钥所有者发布的内容被篡改。但是它不用来保证内容不被他人获得。
|
||||
|
||||
|
||||
这两种用途理论上肯定是成立的,但在现实中一般却不成立,因为单靠非对称加密算法,既做不了加密也做不了签名。原因是,不管加密还是解密,非对称加密算法的计算复杂度都相当高,性能比对称加密要差上好几个数量级(不是好几倍)。
|
||||
|
||||
要知道,加解密的性能不仅影响运行速度,还导致了现行的非对称加密算法都没有支持分组加密模式。分组的意思就是,由于明文长度与密钥长度在安全上具有相关性,通俗地说就是多长的密钥决定了它能加密多长的明文,如果明文太短就需要进行填充,太长就需要进行分组。
|
||||
|
||||
这也就是说,因为非对称加密本身的效率所限,难以支持分组,所以主流的非对称加密算法都只能加密不超过密钥长度的数据,这就决定了非对称加密不能直接用于大量数据的加密。
|
||||
|
||||
所以在加密方面,现在一般会结合对称与非对称加密的优点,通过混合加密来保护信道传输的安全。具体是怎么做的呢?
|
||||
|
||||
通常我们的做法是,用非对称加密来安全地传递少量数据给通讯的另一方,然后再以这些数据为密钥,采用对称加密来安全高效地大量加密传输数据。这种由多种加密算法组合的应用形式,就被称为“密码学套件”,非对称加密在这个场景中发挥的作用被称为“密钥协商”。
|
||||
|
||||
然后,在签名方面,现在一般会结合摘要与非对称加密的优点,通过对摘要结果做加密的形式来保证签名的适用性。由于对任何长度的输入源做摘要之后,都能得到固定长度的结果,所以只要对摘要的结果进行签名,就相当于对整个输入源进行了背书,这样就能保证一旦内容遭到了篡改,摘要结果就会变化,签名也就马上失效了。
|
||||
|
||||
这里,我也汇总了前面提到的这三种与密码学相关的应用,你可以参考下表格,去深入理解它们的主要特征、用途和局限性:
|
||||
|
||||
|
||||
|
||||
那么现在,让我们再回到开篇中提到的关于JWT令牌的几个问题上来:有了哈希摘要、对称和非对称加密,JWT令牌的签名就能保证负载中的信息不可篡改、不可抵赖吗?
|
||||
|
||||
其实还是不行的,在这个场景里,数字签名的安全性仍然存在一个致命的漏洞:公钥虽然是公开的,但在网络世界里,“公开”具体是一种什么操作?如何保证每一个获取公钥的服务,拿到的公钥就是授权服务器所希望它拿到的?
|
||||
|
||||
在网络传输是不可信任的前提下,公钥在网络传输的过程中可能已经被篡改了,所以如果获取公钥的网络请求被攻击者截获并篡改,返回了攻击者自己的公钥,那以后攻击者就可以用自己的私钥来签名,让资源服务器无条件信任它的所有行为了。
|
||||
|
||||
也就是说,如果是在现实世界中公开公钥,我们可以通过打电话、发邮件、发短信、登报纸、同时发布在多个网站上等多种网络通讯之外的途径来达成。但在程序与网络的世界中,就必须找到一种可信任的公开方法,而且这种方法不能依赖加密来实现,否则又会陷入到蛋鸡的问题之中。
|
||||
|
||||
小结
|
||||
|
||||
今天,我们从哈希摘要、对称加密和非对称加密这三种安全架构中常见的保密操作说起,一起学习了摘要、加密、签名这三种现代密码学算法基础应用的主要用途和区别差异。
|
||||
|
||||
首先我们要明确的是,哈希是不可逆的,它不能解密,并不是加密算法,只是一些场景把它当作加密算法使用。哈希的特点是易变性,输入发生1Bit变动,就可能导致输出结果50%的内容发生改变;无论输入长度多少,都有长度固定的输出(2的N次幂)。所以这些特点决定了哈希的主要应用是做摘要,用来保证原文未被修改。
|
||||
|
||||
而加密是现代密码学算法的关键应用,对称加密的设计难度比较小,执行速度快,加密明文长度不受限制,这些特点就决定了对于大量数据的加密传输,目前都是靠对称加密来完成的。但是对称加密难以解决如何把密钥传递给对方的问题,因而出现了非对称加密,它的特点是加密和解密使用的是不同的密钥,但是性能和加密明文的长度都受限。
|
||||
|
||||
一课一思
|
||||
|
||||
请你思考一下本节课最后提出的这个问题,即数字签名需要分发公钥,但在网络世界里,“公开”具体是一种什么操作呢?如何保证每一个获取公钥的服务,拿到的公钥就是授权服务器所希望它拿到的?欢迎给我留言。这个问题的答案呢,我们也会在下一节课中进行探讨。
|
||||
|
||||
好,感谢你的阅读,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。就到这里,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
197
专栏/周志明的架构课/29_传输(下):数字证书与传输安全层.md
Normal file
197
专栏/周志明的架构课/29_传输(下):数字证书与传输安全层.md
Normal file
@@ -0,0 +1,197 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 _ 传输(下):数字证书与传输安全层
|
||||
你好,我是周志明。
|
||||
|
||||
上节课,我们花了很多时间来学习传输安全层中的摘要、加密和签名的主要用途和差别,在最后,我给你留了一个问题:数字签名需要分发公钥,但在网络世界里,“公开”具体是一种什么操作?如何保证每一个获取公钥的服务,拿到的公钥就是授权服务器所希望它拿到的呢?在网络中一切皆不可信任的假设前提下,任何传输都有可能被篡改,那这个问题能够解决吗?
|
||||
|
||||
答案其实是可以的,这就是数字证书要解决的问题。
|
||||
|
||||
所以接下来,我们就先从数字证书如何达成共同信任开始说起,一起来了解下在传输安全的过程中,数字证书与传输安全层的相关实现细节。
|
||||
|
||||
如何通过数字证书达成共同信任?
|
||||
|
||||
有了哈希摘要、对称和非对称加密之后,签名还是无法保证负载中的信息不可篡改、不可抵赖。所以,当我们无法以“签名”的手段来达成信任时,就只能求助于其他途径。
|
||||
|
||||
现在,你不妨想想真实的世界中,我们是如何达成信任的。其实不外乎以下这两种:
|
||||
|
||||
|
||||
基于共同私密信息的信任-
|
||||
比如某个陌生号码找你,说是你的老同学,生病了要找你借钱。你能够信任他的方式是向对方询问一些你们两个应该知道,而且只有你们两个知道的私密信息,如果对方能够回答上来,他有可能真的是你的老同学,否则他十有八九就是个诈骗犯。
|
||||
|
||||
基于权威公证人的信任-
|
||||
如果有个陌生人找你,说他是警察,让你把存款转到他们的安全账号上。你能够信任他的方式是去一趟公安局,如果公安局担保他确实是个警察,那他有可能真的是警察,否则他也十有八九就是个诈骗犯。
|
||||
|
||||
|
||||
那回到网络世界中,我们其实并不能假设授权服务器和资源服务器是互相认识的,所以通常不太会采用第一种方式。而第二种就是目前保证公钥可信分发的标准,这个标准有一个名字:公开密钥基础设施(Public Key Infrastructure,PKI)。
|
||||
|
||||
|
||||
额外知识:公开密钥基础设施(Public Key Infrastructure,PKI)-
|
||||
|
||||
又称公开密钥基础架构、公钥基础建设、公钥基础设施、公开密码匙基础建设或公钥基础架构,是一组由硬件、软件、参与者、管理政策与流程组成的基础架构,其目的在于创造、管理、分配、使用、存储以及撤销数字证书。-
|
||||
|
||||
密码学上,公开密钥基础建设借着数字证书认证中心(Certificate Authority,CA)将用户的个人身份跟公开密钥链接在一起。对每个证书中心用户的身份必须是唯一的。链接关系通过注册和发布过程创建,取决于担保级别,链接关系可能由CA的各种软件或在人为监督下完成。PKI的确定链接关系的这一角色称为注册管理中心(Registration Authority,RA)。RA确保公开密钥和个人身份链接,可以防抵赖。
|
||||
|
||||
|
||||
咱们不必纠缠于PKI概念上的内容,只要知道里面定义的“数字证书认证中心”,就相当于前面例子中“权威公证人”的角色,它是负责发放和管理数字证书的权威机构。
|
||||
|
||||
当然,任何人包括你我,也都可以签发证书,只是不权威罢了,而CA作为受信任的第三方,就承担了公钥体系中公钥的合法性检验的责任。
|
||||
|
||||
可是,这里和现实世界仍然有一些区别。在现实世界里,你去找的公安局大楼不太可能是剧场布景冒认的;而网络世界里,在假设所有网络传输都有可能被截获冒认的前提下,“去CA中心进行认证”本身也是一种网络操作。那你就要问了,这跟之前的“去获取公钥”的操作,在本质上不是没什么差别吗?
|
||||
|
||||
其实还是有差别的,世界上的公钥成千上万、不可枚举,而权威的CA中心则应该是可数的。“可数的”就意味着可以不通过网络,而是在浏览器与操作系统出厂时就预置好,或者是提前就安装好(如银行的证书)。比如说,下图就是我机器上现存的根证书:
|
||||
|
||||
|
||||
|
||||
那到这里,其实就出现了我们这节课要探讨的主角之一:证书(Certificate)。
|
||||
|
||||
证书是权威CA中心对特定公钥信息的一种公证载体,你也可以理解为是权威CA对特定公钥未被篡改的签名背书。由于客户的机器上已经预置了这些权威CA中心本身的证书(可以叫做CA证书或者根证书),这样就让我们在不依靠网络的前提下,使用根证书里面的公钥信息,对其所签发的证书中的签名进行确认。
|
||||
|
||||
所以到这里,我们就终于打破了鸡生蛋、蛋生鸡的循环,使得整套数字签名体系有了坚实的逻辑基础。
|
||||
|
||||
PKI中采用的证书格式是X.509标准格式,它定义了证书中应该包含哪些信息,并描述了这些信息是如何编码的。其中最关键的,就是认证机构的数字签名和公钥信息两项内容。
|
||||
|
||||
那么下面,我们就通过一个标准X.509格式的CA证书的例子,来看看一个数字证书具体都包含了哪些内容。
|
||||
|
||||
第一是版本号(Version):它会指出该证书使用了哪种版本的X.509标准(版本1、版本2或是版本3)。版本号会影响证书中的一些特定信息,在这个例子当中,目前的版本为3。
|
||||
|
||||
Version: 3 (0x2)
|
||||
|
||||
|
||||
第二是序列号(Serial Number):这是由证书颁发者分配的本证书的唯一标识符。
|
||||
|
||||
Serial Number: 04:00:00:00:00:01:15:4b:5a:c3:94
|
||||
|
||||
|
||||
第三是签名算法标识符(Signature Algorithm ID):它是签证书的算法标识,由对象标识符加上相关的参数组成,用于说明本证书所用的数字签名算法。比如,SHA1和RSA的对象标识符就用来说明,该数字签名是利用RSA对SHA1的摘要结果进行加密。
|
||||
|
||||
Signature Algorithm: sha1WithRSAEncryption
|
||||
|
||||
|
||||
第四是认证机构的数字签名(Certificate Signature):这是使用证书发布者私钥生成的签名,以确保这个证书在发放之后没有被篡改过。
|
||||
|
||||
第五是认证机构(Issuer Name): 即证书颁发者的可识别名。
|
||||
|
||||
Issuer: C=BE, O=GlobalSign nv-sa, CN=GlobalSign Organization Validation CA - SHA256 - G2
|
||||
|
||||
|
||||
第六是有效期限(Validity Period): 即证书起始日期和时间以及终止日期和时间,意为指明证书在这两个时间内有效。
|
||||
|
||||
Validity
|
||||
Not Before: Nov 21 08:00:00 2020 GMT
|
||||
Not After : Nov 22 07:59:59 2021 GMT
|
||||
|
||||
|
||||
第七是主题信息(Subject):证书持有人唯一的标识符(Distinguished Name),这个名字在整个互联网上应该是唯一的,通常使用的是网站的域名。
|
||||
|
||||
Subject: C=CN, ST=GuangDong, L=Zhuhai, O=Awosome-Fenix, CN=*.icyfenix.cn
|
||||
|
||||
|
||||
第八是公钥信息(Public-Key): 它包括了证书持有人的公钥、算法(指明密钥属于哪种密码系统)的标识符和其他相关的密钥参数。
|
||||
|
||||
那么,到此为止,数字签名的安全性其实已经可以完全自洽了,但相信你大概也已经感受到了这条信任链的复杂与繁琐:如果从确定加密算法,到生成密钥、公钥分发、CA认证、核验公钥、签名、验证,每一个步骤都要由最终用户来完成的话,这种意义的“安全”估计只能一直是存于实验室中的阳春白雪。
|
||||
|
||||
所以,如何把这套繁琐的技术体系,自动化地应用于无处不在的网络通讯之中,就是接下来我们要讨论的主题了。
|
||||
|
||||
传输安全层是如何隐藏繁琐的安全过程的?
|
||||
|
||||
在计算机科学里,隔离复杂性的最有效手段(没有之一)就是分层,如果一层不够就再加一层,这点在网络中更是体现得淋漓尽致。
|
||||
|
||||
OSI模型、TCP/IP模型从物理特性(比特流)开始,将网络逐层封装隔离,到了HTTP协议这种面向应用的协议里,使用者就已经不会去关心网卡/交换机是如何处理数据帧、MAC地址的了;也不会去关心ARP如何做地址转换;不会去关心IP寻址、TCP传输控制等细节。
|
||||
|
||||
那么,想要在网络世界中,让用户无感知地实现安全通讯,最合理的做法就是在传输层之上、应用层之下加入专门的安全层来实现。这样对上层原本是基于HTTP的Web应用来说,甚至都察觉不到有什么影响。
|
||||
|
||||
而且,构建传输安全层这个想法,几乎可以说是和万维网的历史一样长,早在1994年,就已经有公司开始着手去实践了:
|
||||
|
||||
|
||||
1994年,网景(Netscape)公司开发了SSL协议(Secure Sockets Layer)的1.0版,这是构建传输安全层的起源,但是SSL 1.0从未正式对外发布过。
|
||||
1995年,Netscape把SSL升级到2.0版,正式对外发布,但是刚刚发布不久,就被发现有严重漏洞,所以并未大规模使用。
|
||||
1996年,修补好漏洞的SSL 3.0对外发布,这个版本得到了广泛的应用,很快成为Web网络安全层的事实标准。
|
||||
1999年,互联网标准化组织接替网景公司,将SSL改名为TLS(Transport Layer Security),随即就形成了传输安全层的国际标准。第一个正式的版本是RFC 2246定义的TLS 1.0,该版TLS的生命周期极长,直到2020年3月,主流浏览器(Chrome、Firefox、IE、Safari)才刚刚宣布同时停止TLS 1.0/1.1的支持。而讽刺的是,由于停止后许多政府网站被无法被浏览,此时又正值新冠病毒的爆发期,Firefox紧急发布公告宣布撤回该改动,因此目前TLS 1.0的生命还在顽强延续。
|
||||
2006年,TLS的第一个升级版1.1发布(RFC 4346),但它除了增加对CBC攻击的保护外,几乎没有任何改变,沦为了被遗忘的孩子,当时也很少有人会使用TLS 1.1,甚至TLS 1.1根本都没有被提出过有啥已知的协议漏洞。
|
||||
2008年,TLS 1.1发布2年之后,TLS 1.2标准发布(RFC 5246),迄今超过90%的互联网HTTPS流量都是由TLS 1.2所支持的,现在我们仍在使用的浏览器几乎都完美支持了该协议。
|
||||
2018年,最新的TLS 1.3(RFC 8446)发布,比起前面版本相对温和的升级,TLS 1.3做出了一些激烈的改动,修改了从1.0起一直没有大变化的两轮四次(2-RTT)握手,首次连接仅需一轮(1-RTT)握手即可完成;在有连接复用支持的时候,甚至可以把TLS 1.2原本的1-RTT下降到0-RTT,显著提升了访问速度。
|
||||
|
||||
|
||||
那么接下来,我就以现在被广泛使用的TLS 1.2为例,给你介绍一下传输安全层是如何保障所有信息都是第三方无法窃听(加密传输)、无法篡改(一旦篡改通讯算法会立刻发现)、无法冒充(证书验证身份)的。
|
||||
|
||||
TLS 1.2在传输之前的握手过程中,一共需要进行上下两轮、共计四次的通讯。我们来看一下这个握手过程的时序图:
|
||||
|
||||
|
||||
|
||||
下面,我们就一一来详细解读一下这个过程。
|
||||
|
||||
第一步,客户端请求:Client Hello
|
||||
|
||||
客户端向服务器请求进行加密通讯,在这个请求里面,它会以明文的形式,向服务端提供以下信息:
|
||||
|
||||
|
||||
支持的协议版本,比如TLS 1.2。但是你要注意,1.0至3.0分别代表了SSL1.0至3.0,而TLS1.0则是3.1,一直到TLS1.3的3.4。
|
||||
一个客户端生成的32 Bytes随机数。这个随机数将稍后用于产生加密的密钥。
|
||||
一个可选的SessionID。注意,你不要和前面的Cookie-Session机制混淆了,这个SessionID是指传输安全层的Session,它是为了TLS的连接复用而设计的。
|
||||
一系列支持的密码学算法套件。比如TLS_RSA_WITH_AES_128_GCM_SHA256,代表着密钥交换算法是RSA,加密算法是AES128-GCM,消息认证码算法是SHA256。
|
||||
一系列支持的数据压缩算法。
|
||||
其他可扩展的信息。为了保证协议的稳定,后续对协议的功能扩展大多都是添加到这个变长结构中。比如TLS 1.0中,由于发送的数据并不包含服务器的域名地址,导致了一台服务器只能安装一张数字证书,这对虚拟主机来说就很不方便,所以从TLS 1.1起,就增加了名为“Server Name”的扩展信息,以便一台服务器给不同的站点安装不同的证书。
|
||||
|
||||
|
||||
第二步,服务器回应:Server Hello
|
||||
|
||||
服务器接收到客户端的通讯请求后,如果客户端声明支持的协议版本和加密算法组合,与服务端相匹配的话,就向客户端发出回应。如果不匹配,将会返回一个握手失败的警告提示。这次回应同样是以明文发送的,主要包括以下信息:
|
||||
|
||||
|
||||
服务端确认使用的TLS协议版本。
|
||||
第二个32 Bytes的随机数,稍后用于产生加密的密钥。
|
||||
一个SessionID,以后可通过连接复用减少一轮握手。
|
||||
服务端在列表中选定的密码学算法套件。
|
||||
服务端在列表中选定的数据压缩方法。
|
||||
其他可扩展的信息。
|
||||
如果协商出的加密算法组合是依赖证书认证的,服务端还要发送出自己的X.509证书,而证书中的公钥是什么,也必须根据协商的加密算法组合来决定。
|
||||
密钥协商消息,这部分内容对于不同的密码学套件有着不同的价值。比如对于ECDH + anon这样的密钥协商算法组合来说(基于椭圆曲线的ECDH算法可以在双方通讯都公开的情况下,协商出一组只有通讯双方知道的密钥),就不需要依赖证书中的公钥,而是通过Server Key Exchange消息协商出密钥。
|
||||
|
||||
|
||||
第三步,客户端确认:Client Handshake Finished
|
||||
|
||||
由于密码学套件的组合复杂多样,这里我就只用RSA算法作为密钥交换算法来给你举个例子,介绍下客户端确认的后续过程。
|
||||
|
||||
首先,客户端在收到服务器应答后,要先验证服务器的证书合法性。然后,如果证书不是可信机构颁布的,或者是证书中的信息存在问题,比如域名与实际域名不一致、或证书已经过期、或通过在线证书状态协议得知证书已被吊销,等等,这都会向访问者显示一个“证书不可信任”的警告,由用户自行选择是否还要继续通信。
|
||||
|
||||
而如果证书没有问题,客户端就会从证书中取出服务器的公钥,并向服务器发送以下信息:
|
||||
|
||||
|
||||
客户端证书(可选)。部分服务端并不是面向全公众的,而是只对特定的客户端提供服务,此时客户端就需要发送它自身的证书来证明身份。如果不发送,或者验证不通过,服务端可自行决定是否要继续握手,或者返回一个握手失败的信息。客户端需要证书的TLS通讯,也被称为“双向TLS”(Mutual TLS,常简写为mTLS),这是云原生基础设施的主要认证方法,也是基于信道认证的最主流形式。
|
||||
第三个32 Bytes的随机数,这个随机数不再是明文发送,而是以服务端传过来的公钥加密的,它被称为PreMasterSecret,将与前两次发送的随机数一起,根据特定算法计算出48 Bytes的MasterSecret,这个MasterSecret也就是为后续内容传输时的对称加密算法所采用的私钥。
|
||||
编码改变通知,表示随后的信息都将用双方商定的加密方法和密钥发送。
|
||||
客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时也是前面发送的所有内容的哈希值,以供服务器校验。
|
||||
|
||||
|
||||
第四步,服务端确认:Server Handshake Finished
|
||||
|
||||
服务端向客户端回应最后的确认通知,包括以下信息:
|
||||
|
||||
|
||||
编码改变通知,表示随后的信息都将用双方商定的加密方法和密钥发送。
|
||||
服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时也是前面发送的所有内容的哈希值,以供客户端校验。
|
||||
|
||||
|
||||
那么到这里,整个TLS握手阶段就宣告完成,一个安全的连接就成功建立了。你要知道,每一个连接建立的时候,客户端和服务端都会通过上面的握手过程协商出许多信息,比如一个只有双方才知道的随机产生的密钥、传输过程中要采用的对称加密算法(例子中的AES128)、压缩算法等,此后该连接的通讯将使用此密钥和加密算法进行加密、解密和压缩。
|
||||
|
||||
这种处理方式对上层协议的功能上完全透明的,在传输性能上会有下降,但在功能上完全不会感知到有TLS的存在。建立在这层安全传输层之上的HTTP协议,就被称为“HTTP Over SSL/TLS”,也即是我们所熟知的HTTPS。
|
||||
|
||||
另外,从上面握手协商的过程中我们还可以得知,HTTPS并非不是只有“启用了HTTPS”和“未启用HTTPS”的差别,采用不同的协议版本、不同的密码学套件、证书是否有效、服务端/客户端对面对无效证书时的处理策略如何,都会导致不同HTTPS站点的安全强度的不同。因此并不能说只要启用了HTTPS,就必定能够安枕无忧。
|
||||
|
||||
小结
|
||||
|
||||
今天,我们通过在网络中如何安全分发公钥这个问题,引出了如何通过数字证书达成共同信任、如何通过PKI体系来签发数字证书。在了解了数字证书的工作原理后,你还要了解的重点是如何通过传输安全层,把繁琐的安全过程隐藏起来,让开发者不需要时刻注意到那些麻烦而又琐碎的安全细节。
|
||||
|
||||
一课一思
|
||||
|
||||
除了TLS,你还知道数字证书有什么具体应用吗?欢迎给我留言,分享你的思考。
|
||||
|
||||
如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
203
专栏/周志明的架构课/30_验证:系统如何确保提交给服务的数据是安全的?.md
Normal file
203
专栏/周志明的架构课/30_验证:系统如何确保提交给服务的数据是安全的?.md
Normal file
@@ -0,0 +1,203 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 _ 验证:系统如何确保提交给服务的数据是安全的?
|
||||
你好,我是周志明。今天是安全架构这个小章节的最后一讲,我们来讨论下“验证”这个话题,一起来看看,关于“系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险”这个问题的具体解决方案。
|
||||
|
||||
数据验证也很重要
|
||||
|
||||
数据验证与程序如何编码是密切相关的,你在做开发的时候可能都不会把它归入安全的范畴之中。但你细想一下,如果说关注“你是谁”(认证)、“你能做什么”(授权)等问题是很合理的安全,那么关注“你做的对不对”(验证)不也同样合理吗?
|
||||
|
||||
首先,从数量上来讲,因为数据验证不严谨而导致的安全问题,要比其他安全攻击所导致的问题多得多;其次,从风险上来讲,由于数据质量而导致的安全问题,要承受的风险可能有高有低,可当我们真的遇到了高风险的数据问题,面临的损失不一定就比被黑客拖库来得小。
|
||||
|
||||
当然不可否认的是,相比其他富有挑战性的安全措施,比如说,防御与攻击之间精彩的缠斗需要人们综合运用数学、心理、社会工程和计算机等跨学科知识,数据验证这项常规工作确实有点儿无聊。在日常的开发工作当中,它会贯穿于代码的各个层次,我们每个人肯定都写过。
|
||||
|
||||
但是,这种常见的代码反而是迫切需要被架构约束的。
|
||||
|
||||
这里我们要先明确一个要点:缺失的校验会影响数据质量,而过度的校验也不会让系统更加健壮,反而在某种意义上会制造垃圾代码,甚至还会有副作用。
|
||||
|
||||
我们来看看下面这个实际的段子:
|
||||
|
||||
前 端: 提交一份用户数据(姓名:某, 性别:男, 爱好:女, 签名:xxx, 手机:xxx, 邮箱:null)
|
||||
控制器: 发现邮箱是空的,抛ValidationException("邮箱没填")
|
||||
前 端: 已修改,重新提交
|
||||
安 全: 发送验证码时发现手机号少一位,抛RemoteInvokeException("无法发送验证码")
|
||||
前 端: 已修改,重新提交
|
||||
服务层: 邮箱怎么有重复啊,抛BusinessRuntimeException("不允许开小号")
|
||||
前 端: 已修改,重新提交
|
||||
持久层: 签名字段超长了插不进去,抛SQLException("插入数据库失败,SQL:xxx")
|
||||
…… ……
|
||||
前 端: 你们这些坑管挖不管埋的后端,各种异常都往前抛!
|
||||
用 户: 这系统牙膏厂生产的?
|
||||
|
||||
|
||||
你应该也知道,最基础的数据问题可以在前端做表单校验来处理,但服务端验证肯定也是要做的。那么在看完了前面这个段子以后,你可以想一想,服务端应该在哪一层去做校验呢?我想你可能会得出这样的答案:
|
||||
|
||||
|
||||
在Controller层做,在Service层不做。理由是从Service开始会有同级重用,当出现ServiceA.foo(params)调用ServiceB.bar(params)的时候,就会对params重复校验两次。
|
||||
在Service层做,在Controller层不做。理由是无业务含义的格式校验,已经在前端表单验证处理过了;而有业务含义的校验,不应该放在Controller中,毕竟页面控制器的职责是管理页面流,不该承载业务。
|
||||
在Controller、Service层各做各的。Controller做格式校验,Service层做业务校验,听起来很合理,但这其实就是前面段子中被嘲笑的行为。
|
||||
还有其他一些意见,比如说在持久层做校验,理由是这是最终入口,把守好写入数据库的质量最重要。
|
||||
|
||||
|
||||
这样的讨论大概是不会有一个统一、正确的结论的,但是在Java里确实是有验证的标准做法,即Java Bean Validation。这也是我比较提倡的做法,那就是把校验行为从分层中剥离出来,不是在哪一层做,而是在Bean上做。
|
||||
|
||||
Java Bean Validation
|
||||
|
||||
从2009年JSR 303的1.0,到2013年JSR 349更新的1.1,到目前最新的2017年发布的JSR 380,Java定义了Bean验证的全套规范。这种单独将验证提取、封装的做法,可以让我们获得不少好处:
|
||||
|
||||
|
||||
对于无业务含义的格式验证,可以做到预置。
|
||||
对于有业务含义的业务验证,可以做到重用。一个Bean作为参数或返回值,被用于多个方法的情况是很常见的,因此针对Bean做校验,就比针对方法做校验更有价值,这样利于我们集中管理,比如统一认证的异常体系、统一做国际化、统一给客户端的返回格式,等等。
|
||||
避免对输入数据的防御污染到业务代码。如果你的代码里面有很多像是下面这样的条件判断,就应该考虑重构了:
|
||||
|
||||
|
||||
// 一些已执行的逻辑
|
||||
if (someParam == null) {
|
||||
throw new RuntimeExcetpion("客官不可以!")
|
||||
}
|
||||
|
||||
|
||||
|
||||
利于多个校验器统一执行,统一返回校验结果,避免用户踩地雷、挤牙膏式的试错体验。
|
||||
|
||||
|
||||
据我所知,国内的项目使用Bean Validation的并不少见,但大多数程序员都只使用到它的Built-In Constraint,来做一些与业务逻辑无关的通用校验,也就是下面展示的这堆注解,看类名我们基本上就能明白它们的含义了:
|
||||
|
||||
@Null、@NotNull、@AssertTrue、@AssertFalse、@Min、@Max、@DecimalMin、@DecimalMax、@Negative、@NegativeOrZero、@Positive、@PositiveOrZeor、@Szie、@Digits、@Pass、@PassOrPresent、@Future、@FutureOrPresent、@Pattern、@NotEmpty、@NotBlank、@Email
|
||||
|
||||
|
||||
不过我们要知道,与业务相关的校验往往才是最复杂的校验。而把简单的校验交给Bean Validation,把复杂的校验留给自己,这简直是买椟还珠故事的程序员版本。其实,以Bean Validation的标准方式来做业务校验是非常优雅的。
|
||||
|
||||
接下来,我就用Fenix’s Bookstore项目在用户资源上的两个方法:“创建新用户”和“更新用户信息”,来给你举个例子:
|
||||
|
||||
/**
|
||||
* 创建新的用户
|
||||
*/
|
||||
@POST
|
||||
public Response createUser(@Valid @UniqueAccount Account user) {
|
||||
return CommonResponse.op(() -> service.createAccount(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
@PUT
|
||||
@CacheEvict(key = "#user.username")
|
||||
public Response updateUser(@Valid @AuthenticatedAccount @NotConflictAccount Account user) {
|
||||
return CommonResponse.op(() -> service.updateAccount(user));
|
||||
}
|
||||
|
||||
|
||||
这里你要注意其中的三个自定义校验注解,它们的含义分别是:
|
||||
|
||||
|
||||
@UniqueAccount:传入的用户对象必须是唯一的,不与数据库中任何已有用户的名称、手机、邮箱产生重复。
|
||||
@AuthenticatedAccount:传入的用户对象必须与当前登录的用户一致。
|
||||
@NotConflictAccount:传入的用户对象中的信息与其他用户是无冲突的,比如将一个注册用户的邮箱,修改成与另外一个已存在的注册用户一致的值,这便是冲突。
|
||||
|
||||
|
||||
这里的需求我们其实很容易就能想明白:当注册新用户时,要约束其不与任何已有用户的关键信息重复;而当用户修改自己的信息时,只能与自己的信息重复,而且只能修改当前登录用户的信息。
|
||||
|
||||
这些约束规则不仅仅是为这两个方法服务,它们还可能会在用户资源中的其他入口被使用到,乃至在其他分层的代码中被使用到。而在Bean上做校验,我们就能一揽子地覆盖前面提到的这些使用场景。
|
||||
|
||||
现在我们来看前面代码中用到的三个自定义注解对应校验器的实现类:
|
||||
|
||||
public static class AuthenticatedAccountValidator extends AccountValidation<AuthenticatedAccount> {
|
||||
public void initialize(AuthenticatedAccount constraintAnnotation) {
|
||||
predicate = c -> {
|
||||
AuthenticAccount loginUser = (AuthenticAccount) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
return c.getId().equals(loginUser.getId());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class UniqueAccountValidator extends AccountValidation<UniqueAccount> {
|
||||
public void initialize(UniqueAccount constraintAnnotation) {
|
||||
predicate = c -> !repository.existsByUsernameOrEmailOrTelephone(c.getUsername(), c.getEmail(), c.getTelephone());
|
||||
}
|
||||
}
|
||||
|
||||
public static class NotConflictAccountValidator extends AccountValidation<NotConflictAccount> {
|
||||
public void initialize(NotConflictAccount constraintAnnotation) {
|
||||
predicate = c -> {
|
||||
Collection<Account> collection = repository.findByUsernameOrEmailOrTelephone(c.getUsername(), c.getEmail(), c.getTelephone());
|
||||
// 将用户名、邮件、电话改成与现有完全不重复的,或者只与自己重复的,就不算冲突
|
||||
return collection.isEmpty() || (collection.size() == 1 && collection.iterator().next().getId().equals(c.getId()));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这样,业务校验就可以和业务逻辑完全分离开,在需要校验时,你可以用@Valid注解自动触发,或者通过代码手动触发执行。你可以根据自己项目的要求,把这些注解应用于控制器、服务层、持久层等任何层次的代码之中。
|
||||
|
||||
而且,采用Bean Validation也便于我们统一处理校验结果不满足时的提示信息。比如提供默认值、提供国际化支持(这里没做)、提供统一的客户端返回格式(创建一个用于ConstraintViolationException的异常处理器来实现),以及批量执行全部校验,避免出现开篇那个段子中挤牙膏的尴尬情况。
|
||||
|
||||
除此之外,对于Bean与Bean校验器,我还想给你两条关于编码的建议。
|
||||
|
||||
第一条建议是,要对校验项预置好默认的提示信息,这样当校验不通过时,用户能获得明确的修正提示。这里你可以参考下面的代码示例:
|
||||
|
||||
/**
|
||||
* 表示一个用户的信息是无冲突的
|
||||
*
|
||||
* “无冲突”是指该用户的敏感信息与其他用户不重合,比如将一个注册用户的邮箱,修改成与另外一个已存在的注册用户一致的值,这便是冲突
|
||||
**/
|
||||
@Documented
|
||||
@Retention(RUNTIME)
|
||||
@Target({FIELD, METHOD, PARAMETER, TYPE})
|
||||
@Constraint(validatedBy = AccountValidation.NotConflictAccountValidator.class)
|
||||
public @interface NotConflictAccount {
|
||||
String message() default "用户名称、邮箱、手机号码与现存用户产生重复";
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
|
||||
|
||||
第二条建议是,要把不带业务含义的格式校验注解放到Bean的类定义之上,把带业务逻辑的校验放到Bean的类定义的外面。
|
||||
|
||||
这两者的区别是,放在类定义中的注解能够自动运行,而放到类外面的业务校验需要像前面的示例代码那样,明确标出注解时才会运行。比如用户账号实体中的部分代码为:
|
||||
|
||||
public class Account extends BaseEntity {
|
||||
@NotEmpty(message = "用户不允许为空")
|
||||
private String username;
|
||||
|
||||
@NotEmpty(message = "用户姓名不允许为空")
|
||||
private String name;
|
||||
|
||||
private String avatar;
|
||||
|
||||
@Pattern(regexp = "1\\d{10}", message = "手机号格式不正确")
|
||||
private String telephone;
|
||||
|
||||
@Email(message = "邮箱格式不正确")
|
||||
private String email
|
||||
}
|
||||
|
||||
|
||||
你可以发现,这些校验注解都直接放在了类定义中,每次执行校验的时候它们都会被运行。因为Bean Validation是Java的标准规范,它执行的频率可能比编写代码的程序所预想的更高,比如使用Hibernate来做持久化时,便会自动执行Data Object上的校验注解。
|
||||
|
||||
对于那些不带业务含义的注解,在运行时是不需要其他外部资源的参与的,它们不会调用远程服务、访问数据库,这种校验重复执行实际上并没有什么成本。
|
||||
|
||||
但带业务逻辑的校验,通常就需要外部资源参与执行,这不仅仅是多消耗一点时间和运算资源的问题,因为我们很难保证依赖的每个服务都是幂等的,重复执行校验很可能会带来额外的副作用。因此应该放到外面,让使用者自行判断是否要触发。
|
||||
|
||||
另外,还有一些“需要触发一部分校验”的非典型情况,比如“新增”操作A需要执行全部校验规则,“修改”操作B中希望不校验某个字段,“删除”操作C中希望改变某一条校验规则,这个时候,我们就要启用分组校验来处理,设计一套“新增”“修改”“删除”这样的标识类,置入到校验注解的groups参数中去实现。
|
||||
|
||||
小结
|
||||
|
||||
这节课算是JSR 380 Bean Validation的小科普,Bean验证器在Java中存在的时间已经超过了十年,应用范围也非常广泛,但现在还是有很多的信息系统选择自己制造轮子,去解决数据验证的问题,而且做的也并没有Bean验证器好。
|
||||
|
||||
所以在这节课里,我给你总结了正确使用Bean验证器的一些最佳实践,涉及到不少具体的代码,建议你好好结合着代码进行学习和实践。在课程后面的实战模块中,我还会给你具体展示Fenix’s Bookstore的工程代码,到时你也可以结合着该模块一起学习,印证或增强实战学习的效果。
|
||||
|
||||
一课一思
|
||||
|
||||
你开发的系统是依靠Bean验证器完成数据验证的吗?如果不是,那么你的系统、或者是你知道的系统,是如何做校验的呢?你认为效果如何?
|
||||
|
||||
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
219
专栏/周志明的架构课/31_分布式共识(上):想用好分布式框架,先学会Paxos算法吧.md
Normal file
219
专栏/周志明的架构课/31_分布式共识(上):想用好分布式框架,先学会Paxos算法吧.md
Normal file
@@ -0,0 +1,219 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
31 _ 分布式共识(上):想用好分布式框架,先学会Paxos算法吧
|
||||
你好,我是周志明。从这节课起,我会用两讲带你学习分布式共识算法。
|
||||
|
||||
可靠与可用、共识与一致
|
||||
|
||||
在正式开始探讨分布式环境面临的各种技术问题和解决方案之前,我们先把目光从工业界转到学术界,学习两三种具有代表性的分布式共识算法,为后续分布式环境中操作共享数据打好理论基础。
|
||||
|
||||
我们先从一个最简单、最常见的场景开始:如果你有一份很重要的数据,要确保它长期存储在电脑上不会丢失,你会怎么做?
|
||||
|
||||
这不是什么脑筋急转弯的古怪问题,答案就是去买几块磁盘,把数据在不同磁盘上多备份几个副本。
|
||||
|
||||
假设一块磁盘每年损坏的概率是5%,那把文件复制到另一块磁盘上备份后,数据丢失的概率就变成了0.25%(两块磁盘同时损坏才会导致数据丢失)。以此类推,使用三块磁盘存储数据丢失的概率就是0.00125%,使用四块则是0.0000625%。换句话说,使用四块磁盘来保存同一份数据,就已经保证了这份数据在一年内有超过99.9999%的概率是安全可靠的。
|
||||
|
||||
那对应到软件系统里,保障系统可靠性的方法,与拿几个磁盘备份并没有什么本质区别。
|
||||
|
||||
单个节点的系统宕机导致无法访问数据的原因可能有很多,比如程序运行出错、硬件损坏、网络分区、电源故障,等等,一年中出现系统宕机的概率也许比5%还要大。这就决定了软件系统也必须有多台机器能够拥有一致的数据副本,才有可能对外提供可靠的服务。
|
||||
|
||||
但是,在软件系统里,要保障系统的可用性,面临的困难与磁盘备份却又有着本质的区别。
|
||||
|
||||
其中的原因也很好理解:磁盘之间是孤立的不需要互相通讯,备份数据是静态的,初始化后状态就不会发生改变,由人工进行的文件复制操作,很容易就能保证数据在各个备份盘中是一致的。但是,到了分布式系统里面,我们就必须考虑动态的数据如何在不可靠的网络通讯条件下,依然能在各个节点之间正确复制的问题。
|
||||
|
||||
现在,我们来修改下要讨论的场景:如果你有一份会随时变动的数据,要确保它能正确地存储在网络中几台不同的机器上,你会怎么做?
|
||||
|
||||
这时,你最容易想到的答案一定是“数据同步”:每当数据有变化,就把变化情况在各个节点间的复制看成是一种事务性的操作,只有系统里的每一台机器都反馈成功地完成磁盘写入后,数据的变化才能宣布成功。
|
||||
|
||||
我们在第13讲学过的2PC、3PC,就可以实现这种同步操作。同步的一种真实应用场景是,数据库的主从全同步复制(Fully Synchronous Replication)。比如,MySQL Cluster进行全同步复制时,所有Slave节点的Binlog都完成写入后,Master的事务才会进行提交。
|
||||
|
||||
不过,这里有一个显而易见的缺陷,尽管可以确保Master和Slave中的数据是绝对一致的,但任何一个Slave节点、因为任何原因未响应都会阻塞整个事务。也就是说,每增加一个Slave节点,整个系统的可用性风险都会增加一分。
|
||||
|
||||
以同步为代表的数据复制方法,叫做状态转移(State Transfer)。这类方法属于比较符合人类思维的可靠性保障手段,但通常要以牺牲可用性为代价。
|
||||
|
||||
但是,我们在建设分布式系统的时候,往往不能承受这样的代价。对于一些关键系统来说,在必须保障数据正确可靠的前提下,对可用性的要求也非常苛刻。比如,系统要保证数据要达到99.999999%可靠性,同时也要达到99.999%可用的程度。
|
||||
|
||||
这就引出了第三个问题:如果你有一份会随时变动的数据,要确保它正确地存储于网络中的几台不同机器之上,并且要尽可能保证数据是随时可用的,你会怎么做?
|
||||
|
||||
系统高可用和高可靠之间的矛盾,是由于增加机器数量反而降低了可用性带来的。为缓解这个矛盾,在分布式系统里主流的数据复制方法,是以操作转移(Operation Transfer)为基础的。我们想要改变数据的状态,除了直接将目标状态赋予它之外,还有另一种常用的方法,就是通过某种操作,把源状态转换为目标状态。
|
||||
|
||||
能够使用确定的操作,促使状态间产生确定的转移结果的计算模型,在计算机科学中被称为状态机(State Machine)。
|
||||
|
||||
状态机有一个特性:任何初始状态一样的状态机,如果执行的命令序列一样,那么最终达到的状态也一样。在这里我们可以这么理解这个特性,要让多台机器的最终状态一致,只要确保它们的初始状态和接收到的操作指令都是完全一致的就可以。
|
||||
|
||||
无论这个操作指令是新增、修改、删除或者其他任何可能的程序行为,都可以理解为要将一连串的操作日志正确地广播给各个分布式节点。
|
||||
|
||||
广播指令与指令执行期间,允许系统内部状态存在不一致的情况,也就是不要求所有节点的每一条指令都是同时开始、同步完成的,只要求在此期间的内部状态不能被外部观察到,且当操作指令序列执行完成的时候,所有节点的最终的状态是一致的。这种模型,就是状态机复制(State Machine Replication)。
|
||||
|
||||
在分布式环境下,考虑到网络分区现象是不可能消除的,而且可以不必去追求系统内所有节点在任何情况下的数据状态都一致,所以采用的是“少数服从多数”的原则。
|
||||
|
||||
也就是说,一旦系统中超过半数的节点完成了状态的转换,就可以认为数据的变化已经被正确地存储在了系统当中。这样就可以容忍少数(通常是不超过半数)的节点失联,使得增加机器数量可以用来提升系统整体的可用性。在分布式中,这种思想被叫做Quorum机制。
|
||||
|
||||
根据这些讨论,我们需要设计出一种算法,能够让分布式系统内部可以暂时容忍存在不同的状态,但最终能够保证大多数节点的状态能够达成一致;同时,能够让分布式系统在外部看来,始终表现出整体一致的结果。
|
||||
|
||||
这个让系统各节点不受局部的网络分区、机器崩溃、执行性能或者其他因素影响,能最终表现出整体一致的过程,就是各个节点的协商共识(Consensus)。
|
||||
|
||||
这里需要注意的是,共识(Consensus)与一致性(Consistency)是有区别的:一致性指的是数据不同副本之间的差异,而共识是指达成一致性的方法与过程。
|
||||
|
||||
由于翻译的关系,很多中文资料把Consensus同样翻译为一致性,导致网络上大量的“二手中文资料”都混淆了这两个概念。以后你再看到“分布式一致性算法”的时候,应该就知道它指的其实是“Distributed Consensus Algorithm”了。
|
||||
|
||||
好了,我们继续来学习分布式中的共识算法。说到这里,我们就不得不提Paxos算法了。
|
||||
|
||||
Paxos算法
|
||||
|
||||
Paxos算法,是由Leslie Lamport(就是大名鼎鼎的LaTeX中的“La”)提出的一种基于消息传递的协商共识算法。现在,Paxos算法已经成了分布式系统最重要的理论基础,几乎就是“共识”这两字的代名词了。
|
||||
|
||||
这个极高的评价来自提出Raft算法的论文“In Search of an Understandable Consensus Algorithm”,更是显得分量十足。
|
||||
|
||||
关于Paxos在分布式共识算法中的地位,还有这么一种说法:
|
||||
|
||||
|
||||
There is only one consensus protocol, and that’s “Paxos” — all other approaches are just broken versions of Paxos.-
|
||||
世界上只有一种共识协议,就是Paxos,其他所有共识算法都是Paxos的退化版本。-
|
||||
—— Mike Burrows,Inventor of Google Chubby
|
||||
|
||||
|
||||
虽然我认为“世界上只有Paxos一种分布式共识算法”的说法有些夸张,但是如果没有Paxos,那后续的Raft、ZAB等算法,ZooKeeper、etcd这些分布式协调框架,Hadoop、Consul这些在此基础上的各类分布式应用,都很可能会延后好几年面世。
|
||||
|
||||
但Paxos算法从被第一次提出,到成为分布式系统最重要的理论基础,可谓是经历了一番波折。我们来具体看看。
|
||||
|
||||
Paxos算法的诞生之路
|
||||
|
||||
为了解释清楚Paxos算法,Lamport虚构了一个叫做“Paxos”的希腊城邦,这个城邦按照民主制度制定法律,却又不存在一个中心化的专职立法机构,而是靠着“兼职议会”(Part-Time Parliament)来完成立法。这就无法保证所有城邦居民都能够及时地了解新的法律提案,也无法保证居民会及时为提案投票。
|
||||
|
||||
Paxos算法的目标,就是让城邦能够在每一位居民都不承诺一定会及时参与的情况下,依然可以按照少数服从多数的原则,最终达成一致意见。但是,Paxos算法并不考虑拜占庭将军问题,也就是假设信息可能丢失也可能延迟,但不会被错误传递。
|
||||
|
||||
Lamport在1990年首次发表了Paxos算法,选的论文题目就是“The Part-Time Parliament”。由于这个算法本身非常复杂,希腊城邦的比喻使得描述更为晦涩难懂。所以,这篇论文的三个审稿人,一致要求Lamport删掉希腊城邦的故事。这就让Lamport非常不爽,然后干脆就撤稿不发了,所以Paxos刚刚被提出的时候并没有引起什么反响。
|
||||
|
||||
到了八年之后,也就是1998年,Lamport把这篇论文重新整理后投到了“ACM Transactions on Computer Systems”。这次论文成功发表,Lamport的名气也确实吸引了一些人去研究,但是并没有多少人能看懂他到底在说什么。
|
||||
|
||||
时间又过去了三年,来到了2001年,Lamport认为前两次没有引起什么反响,是因为同行们无法理解他以“希腊城邦”来讲故事的幽默感。所以,他第三次以“Paxos Made Simple”为题,在“SIGACT News”杂志上发表这篇论文的时候,终于放弃了“希腊城邦”的比喻,尽可能用(他认为)简单直接、可读性较强的方式去介绍Paxos算法。这次的情况虽然比前两次要好一些,但以Paxos本应获得的重视程度来说,依然只能算是应者寥寥。
|
||||
|
||||
这一段听起来跟网络段子一般的经历,被Lamport以自嘲的形式放到了他的个人网站上。尽管我们作为后辈应该尊重Lamport老爷子,但是当我翻开“Paxos Made Simple”这篇论文,见到只有“The Paxos algorithm, when presented in plain English, is very simple.”这一句话的“摘要”时,还是忍不住在心里怀疑,Lamport这样写论文是不是在恶搞审稿人和读者,在嘲讽“你们这些愚蠢的人类!”。
|
||||
|
||||
虽然Lamport本人连发三篇文章都没能让大多数同行理解Paxos,但当时间来到了2006年,Google的Chubby、Megastore和Spanner等分布式系统,都使用Paxos解决了分布式共识的问题,并将其整理成正式的论文发表。
|
||||
|
||||
之后,得益于Google的行业影响力,再加上Chubby的作者Mike Burrows那略显夸张但足够吸引眼球的评价推波助澜,Paxos算法一夜间成为计算机科学分布式这条分支中,最炙手可热网红概念,开始被学术界众人争相研究。
|
||||
|
||||
2013年,因为对分布式系统的杰出理论贡献,Lamport获得了2013年的图灵奖。随后,才有了Paxos在区块链、分布式系统、云计算等多个领域大放异彩的故事。其实这样充分说明了,在技术圈里即使再有本事,也还是需要好好包装一下。
|
||||
|
||||
讲完段子吃过西瓜,希望你没有被这些对Paxos的“困难”做的铺垫所吓倒,反正又不让你马上去实现它。
|
||||
|
||||
其实说难不难,如果放弃些许严谨性,并简化掉最繁琐的分支细节和特殊情况的话,Paxos是完全可以去通俗地理解的。毕竟,Lamport在论文中只用两段话就描述“清楚”了Paxos的工作流程。
|
||||
|
||||
下面,我们来正式学习Paxos算法(在本小节中Paxos指的都是最早的Basic Paxos算法)。
|
||||
|
||||
Paxos算法的工作流程
|
||||
|
||||
Paxos算法将分布式系统中的节点分为提案节点、决策节点和记录节点三类。
|
||||
|
||||
提案节点:称为Proposer,提出对某个值进行设置操作的节点,设置值这个行为就是提案(Proposal)。值一旦设置成功,就是不会丢失也不可变的。
|
||||
|
||||
需要注意的是,Paxos是典型的基于操作转移模型而非状态转移模型来设计的算法,所以这里的“设置值”不要类比成程序中变量的赋值操作,而应该类比成日志记录操作。因此,我在后面介绍Raft算法时,就索性直接把“提案”叫做“日志”了。
|
||||
|
||||
决策节点:称为Acceptor,是应答提案的节点,决定该提案是否可被投票、是否可被接受。提案一旦得到过半数决策节点的接受,就意味着这个提案被批准(Accept)。提案被批准,就意味着该值不能再被更改,也不会丢失,且最终所有节点都会接受它。
|
||||
|
||||
记录节点:被称为Learner,不参与提案,也不参与决策,只是单纯地从提案、决策节点中学习已经达成共识的提案。比如,少数派节点从网络分区中恢复时,将会进入这种状态。
|
||||
|
||||
在使用Paxos算法的分布式系统里,所有的节点都是平等的,它们都可以承担以上某一种或者多种角色。
|
||||
|
||||
不过,为了便于确保有明确的多数派,决策节点的数量应该被设定为奇数个,且在系统初始化时,网络中每个节点都知道整个网络所有决策节点的数量、地址等信息。另外,在分布式环境下,如果说各个节点“就某个值(提案)达成一致”,代表的意思就是“不存在某个时刻有一个值为A,另一个时刻这个值又为B的情景”。
|
||||
|
||||
而如果要解决这个问题的复杂度,主要会受到下面两个因素的共同影响:
|
||||
|
||||
|
||||
系统内部各个节点间的通讯是不可靠的。不论对于系统中企图设置数据的提案节点,抑或决定是否批准设置操作的决策节点来说,它们发出、收到的信息可能延迟送达、也可能会丢失,但不去考虑消息有传递错误的情况。
|
||||
系统外部各个用户访问是可并发的。如果系统只会有一个用户,或者每次只对系统进行串行访问,那单纯地应用Quorum机制,少数节点服从多数节点,就已经足以保证值被正确地读写了。
|
||||
|
||||
|
||||
第一点“系统内部各个节点间的通讯是不可靠的”,是网络通讯中客观存在的现象,也是所有共识算法都要重点解决的问题。所以我们重点看下第二点“系统外部各个用户访问是可并发的”,即“分布式环境下并发操作的共享数据”问题。
|
||||
|
||||
为了方便理解,我们可以先不考虑是不是在分布式的环境下,只考虑并发操作。
|
||||
|
||||
假设有一个变量i当前在系统中存储的数值为2,同时有外部请求A、B分别对系统发送操作指令,“把i的值加1”和“把i的值乘3”。如果不加任何并发控制的话,将可能得到“(2+1)×3=9”和“2×3+1=7”这两种结果。因此,对同一个变量的并发修改,必须先加锁后操作,不能让A、B的请求被交替处理。这,可以说是程序设计的基本常识了。
|
||||
|
||||
但是,在分布式的环境下,还要同时考虑到分布式系统内,可能在任何时刻出现的通讯故障。如果一个节点在取得锁之后、在释放锁之前发生崩溃失联,就会导致整个操作被无限期的等待所阻塞。因此,算法中的加锁,就不完全等同于并发控制中以互斥量来实现的加锁,还必须提供一个其他节点能抢占锁的机制,以避免因通讯问题而出现死锁的问题。
|
||||
|
||||
我们继续看Paxos算法是怎么解决并发操作带来的竞争的。
|
||||
|
||||
Paxos算法包括“准备(Prepare)”和“批准(Accept)”两个阶段。
|
||||
|
||||
第一阶段“准备”(Prepare)就相当于抢占锁的过程。如果某个提案节点准备发起提案,必须先向所有的决策节点广播一个许可申请(称为Prepare请求)。提案节点的Prepare请求中会附带一个全局唯一的数字n作为提案ID,决策节点收到后,会给提案节点两个承诺和一个应答。
|
||||
|
||||
其中,两个承诺是指:承诺不会再接受提案ID小于或等于n的Prepare请求;承诺不会再接受提案ID小于n的Accept请求。
|
||||
|
||||
一个应答是指:在不违背以前作出的承诺的前提下,回复已经批准过的提案中ID最大的那个提案所设定的值和提案ID,如果该值从来没有被任何提案设定过,则返回空值。如果违反此前做出的承诺,也就是说收到的提案ID并不是决策节点收到过的最大的,那就可以直接不理会这个Prepare请求。
|
||||
|
||||
当提案节点收到了多数派决策节点的应答(称为Promise应答)后,可以开始第二阶段“批准”(Accept)过程。这时有两种可能的结果:
|
||||
|
||||
|
||||
如果提案节点发现所有响应的决策节点此前都没有批准过这个值(即为空),就说明它是第一个设置值的节点,可以随意地决定要设定的值;并将自己选定的值与提案ID,构成一个二元组(id, value),再次广播给全部的决策节点(称为Accept请求)。
|
||||
如果提案节点发现响应的决策节点中,已经有至少一个节点的应答中包含有值了,那它就不能够随意取值了,必须无条件地从应答中找出提案ID最大的那个值并接受,构成一个二元组(id, maxAcceptValue),然后再次广播给全部的决策节点(称为Accept请求)。
|
||||
|
||||
|
||||
当每一个决策节点收到Accept请求时,都会在不违背以前作出的承诺的前提下,接收并持久化当前提案ID和提案附带的值。如果违反此前做出的承诺,即收到的提案ID并不是决策节点收到过的最大的,那允许直接对此Accept请求不予理会。
|
||||
|
||||
当提案节点收到了多数派决策节点的应答(称为Accepted应答)后,协商结束,共识决议形成,将形成的决议发送给所有记录节点进行学习。整个过程的时序图如下所示:
|
||||
|
||||
|
||||
|
||||
到这里,整个Paxos算法的工作流程就结束了。
|
||||
|
||||
如果你之前没有专门学习过分布式的知识,那学到这里,你的感受很可能是:操作过程中的每一步都能看懂,但还是不能对Paxos算法究竟是如何解决协商共识这个问题,形成具体的认知。
|
||||
|
||||
所以接下来,我就不局限于抽象的算法步骤,以一个更具体例子来讲解Paxos。这个例子以及其中使用的图片,都来源于“Implementing Replicated Logs with Paxos”。
|
||||
|
||||
借助一个例子来理解Paxos算法
|
||||
|
||||
在这个例子中,我们只讨论正常通讯的场景,不会涉及网络分区。
|
||||
|
||||
假设一个分布式系统有五个节点,分别是S1、S2、S3、S4和S5;全部节点都同时扮演着提案节点和决策节点的角色。此时,有两个并发的请求希望将同一个值分别设定为X(由S1作为提案节点提出)和Y(由S5作为提案节点提出);我们用P代表准备阶段、用A代表批准阶段,这时候可能发生下面四种情况。
|
||||
|
||||
情况一:比如,S1选定的提案ID是3.1(全局唯一ID加上节点编号),先取得了多数派决策节点的Promise和Accepted应答;此时S5选定的提案ID是4.5,发起Prepare请求,收到的多数派应答中至少会包含1个此前应答过S1的决策节点,假设是S3。
|
||||
|
||||
那么,S3提供的Promise中,必将包含S1已设定好的值X,S5就必须无条件地用X代替Y作为自己提案的值。由此,整个系统对“取值为X”这个事实达成了一致。如下图所示:
|
||||
|
||||
|
||||
|
||||
情况二:事实上,对于情况一,X被选定为最终值是必然结果。但从图中可以看出,X被选定为最终值并不是一定要多数派的共同批准,而只取决于S5提案时Promise应答中是否已经包含了批准过X的决策节点。
|
||||
|
||||
比如下图所示,S5发起提案的Prepare请求时,X并未获得多数派批准,但由于S3已经批准的关系,最终共识的结果仍然是X。
|
||||
|
||||
|
||||
|
||||
情况三:当然,另外一种可能的结果是,S5提案时Promise应答中并未包含批准过X的决策节点。
|
||||
|
||||
比如,应答S5提案时,节点S1已经批准了X,节点S2、S3未批准但返回了Promise应答,此时S5以更大的提案ID获得了S3、S4和S5的Promise。这三个节点均未批准过任何值,那么S3将不会再接受来自S1的Accept请求,因为它的提案ID已经不是最大的了。所以,这三个节点将批准Y的取值,整个系统最终会对“取值为Y”达成一致。
|
||||
|
||||
|
||||
|
||||
情况四:从情况三可以推导出另一种极端的情况,如果两个提案节点交替使用更大的提案ID使得准备阶段成功,但是批准阶段失败的话,这个过程理论上可以无限持续下去,形成活锁(Live Lock)。在算法实现中,会引入随机超时时间来避免活锁的产生。
|
||||
|
||||
|
||||
|
||||
到这里,我们就又通过一个例子,算是通俗地学习了一遍Paxos算法。
|
||||
|
||||
虽然Paxos是以复杂著称的算法,但我们上面学习的是基于Basic Paxos、以正常流程(未出现网络分区等异常)、以通俗的方式讲解的Paxos算法,并没有涉及到严谨的逻辑和数学原理,也没有讨论Paxos的推导证明过程。所以,这对于大多数不从事算法研究的技术人员来说,理解起来应该也不会太过困难。
|
||||
|
||||
Basic Paxos的价值在于开拓了分布式共识算法的发展思路,但因为它有如下缺陷,一般不会直接用于实践:Basic Paxos只能对单个值形成决议,并且决议的形成至少需要两次网络请求和应答(准备和批准阶段各一次),高并发情况下将产生较大的网络开销,极端情况下甚至可能形成活锁。
|
||||
|
||||
总之,Basic Paxos是一种很学术化、对工业化并不友好的算法,现在几乎只用来做理论研究。实际的应用都是基于Multi Paxos和Fast Paxos算法的,在下一讲我们就会了解Multi Paxos以及和它理论等价的几个算法,比如Raft、ZAB等算法。
|
||||
|
||||
小结
|
||||
|
||||
今天这节课,我们从分布式系统的高可靠与高可用的矛盾开始,首先学习了分布式共识算法的含义,以及为什么需要这种算法。我们也明确了“共识”这个词,在这个上下文中所指含义,就是“各个分布式节点针对于某个取值达成一致”。
|
||||
|
||||
其次,我们了解了Basic Paxos算法发表的一些背景历史,以及这种算法的主要工作流程。尽管我们很少有机会去研究或者实现分布式共识算法,但理解它的基本原理,是我们日后理解和使用etcd、ZooKeeper等分布式框架的重要基础。
|
||||
|
||||
一课一思
|
||||
|
||||
今天这节课,我们一起学习了Paxos算法在正常情况下的工作流程。你认为Paxos在工程上需要考虑哪些异常情况呢?实现的难度在哪里呢?
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
190
专栏/周志明的架构课/32_分布式共识(下):MultiPaxos、Raft与Gossip,分布式领域的基石.md
Normal file
190
专栏/周志明的架构课/32_分布式共识(下):MultiPaxos、Raft与Gossip,分布式领域的基石.md
Normal file
@@ -0,0 +1,190 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
32 _ 分布式共识(下):Multi Paxos、Raft与Gossip,分布式领域的基石
|
||||
你好,我是周志明,这节课我们继续学习分布式共识算法。
|
||||
|
||||
在上节课的最后,我通过一个批准阶段重复失败例子,和你介绍了Basic Paxos的活锁问题,两个提案节点互不相让地提出自己的提案,抢占同一个值的修改权限,导致整个系统在持续性地“反复横跳”,从外部看就像是被锁住了。
|
||||
|
||||
同时,我还讲过一个观点,分布式共识的复杂性,主要来源于网络的不可靠、请求的可并发,这两大因素。活锁问题和许多Basic Paxos异常场景中所遭遇的麻烦,都可以看作是源于任何一个提案节点都能够完全平等地、与其他节点并发地提出提案而带来的复杂问题。
|
||||
|
||||
为此,Lamport专门设计(“专门设计”的意思是,在Paxos的论文中Lamport随意提了几句可以这么做)了一种Paxos的改进版本“Multi Paxos”算法,希望能够找到一种两全其美的办法:既不破坏Paxos中“众节点平等”的原则,又能在提案节点中实现主次之分,限制每个节点都有不受控的提案权利。
|
||||
|
||||
这两个目标听起来似乎是矛盾的,但现实世界中的选举,就很符合这种在平等节点中挑选意见领袖的情景。
|
||||
|
||||
Multi Paxos
|
||||
|
||||
Multi Paxos对Basic Paxos的核心改进是,增加了“选主”的过程:
|
||||
|
||||
|
||||
提案节点会通过定时轮询(心跳),确定当前网络中的所有节点里是否存在一个主提案节点;
|
||||
一旦没有发现主节点存在,节点就会在心跳超时后使用Basic Paxos中定义的准备、批准的两轮网络交互过程,向所有其他节点广播自己希望竞选主节点的请求,希望整个分布式系统对“由我作为主节点”这件事情协商达成一致共识;
|
||||
如果得到了决策节点中多数派的批准,便宣告竞选成功。
|
||||
|
||||
|
||||
当选主完成之后,除非主节点失联会发起重新竞选,否则就只有主节点本身才能够提出提案。此时,无论哪个提案节点接收到客户端的操作请求,都会将请求转发给主节点来完成提案,而主节点提案的时候,也就无需再次经过准备过程,因为可以视作是经过选举时的那一次准备之后,后续的提案都是对相同提案ID的一连串的批准过程。
|
||||
|
||||
我们也可以通俗地理解为:选主过后,就不会再有其他节点与它竞争,相当于是处于无并发的环境当中进行的有序操作,所以此时系统中要对某个值达成一致,只需要进行一次批准的交互即可。具体如下序列所示:
|
||||
|
||||
|
||||
|
||||
你可能会注意到,二元组(id, value)已经变成了三元组(id, i, value),这是因为需要给主节点增加一个“任期编号”,这个编号必须是严格单调递增的,以应付主节点陷入网络分区后重新恢复,但另外一部分节点仍然有多数派,且已经完成了重新选主的情况,此时必须以任期编号大的主节点为准。
|
||||
|
||||
从整体来看,当节点有了选主机制的支持后,就可以进一步简化节点角色,不必区分提案节点、决策节点和记录节点了,可以统称为“节点”,节点只有主(Leader)和从(Follower)的区别。此时的协商共识的时序图如下:
|
||||
|
||||
|
||||
|
||||
在这个理解的基础上,我们换一个角度来重新思考“分布式系统中如何对某个值达成一致”这个问题,可以把它分为下面三个子问题来考虑:
|
||||
|
||||
|
||||
如何选主(Leader Election)
|
||||
如何把数据复制到各个节点上(Entity Replication)
|
||||
如何保证过程是安全的(Safety)
|
||||
|
||||
|
||||
可以证明(具体证明就不列在这里了,感兴趣的读者可参考结尾给出的论文),当这三个问题同时被解决时,就等价于达成共识。
|
||||
|
||||
接下来,我们分别看下这三个子问题如何解决。
|
||||
|
||||
关于“如何选主”,虽然选主问题会涉及到许多工程上的细节,比如心跳、随机超时、并行竞选等,但从原理上来说,只要你能够理解Paxos算法的操作步骤,就不会有啥问题了。因为,选主问题的本质,仅仅是分布式系统对“谁来当主节点”这件事情的达成的共识而已。我们上节课,其实就已经解决了“分布式系统该如何对一件事情达成共识”这个问题。
|
||||
|
||||
我们继续来解决数据(Paxos中的提案、Raft中的日志)在网络各节点间的复制问题。
|
||||
|
||||
在正常情况下,当客户端向主节点发起一个操作请求后,比如提出“将某个值设置为X”,数据复制的过程为:
|
||||
|
||||
|
||||
主节点将X写入自己的变更日志,但先不提交,接着把变更X的信息在下一次心跳包中广播给所有的从节点,并要求从节点回复“确认收到”的消息;
|
||||
从节点收到信息后,将操作写入自己的变更日志,然后给主节点发送“确认签收”的消息;
|
||||
主节点收到过半数的签收消息后,提交自己的变更、应答客户端并且给从节点广播“可以提交”的消息;
|
||||
从节点收到提交消息后提交自己的变更,数据在节点间的复制宣告完成。
|
||||
|
||||
|
||||
那异常情况下的数据复制问题怎么解决呢?
|
||||
|
||||
网络出现了分区,部分节点失联,但只要仍能正常工作的节点数量能够满足多数派(过半数)的要求,分布式系统就仍然可以正常工作。假设有S1、S2、S3、S4和S5共5个节点,我们来看下数据复制过程。
|
||||
|
||||
|
||||
假设由于网络故障,形成了S1、S2和S3、S4、S5两个分区。
|
||||
|
||||
一段时间后,S3、S4、S5三个节点中的某一个节点比如S3,最先达到心跳超时的阈值,获知当前分区中已经不存在主节点了;于是,S3向所有节点发出自己要竞选的广播,并收到了S4、S5节点的批准响应,加上自己一共三票,竞选成功。此时,系统中同时存在S1和S3两个主节点,但由于网络分区,它们都不知道对方的存在。
|
||||
|
||||
这种情况下,客户端发起操作请求的话,可能出现这么两种情况:
|
||||
|
||||
|
||||
第一种,如果客户端连接到了S1、S2中的一个,都将由S1处理,但由于操作只能获得最多两个节点的响应,无法构成多数派的批准,所以任何变更都无法成功提交。
|
||||
|
||||
第二种,如果客户端连接到了S3、S4、S5中的一个,都将由S3处理,此时操作可以获得最多三个节点的响应,构成多数派的批准,变更就是有效的可以被提交,也就是说系统可以继续提供服务。
|
||||
|
||||
事实上,这两种“如果”的场景同时出现的机会非常少。为什么呢?网络分区是由软、硬件或者网络故障引起的,内部网络出现了分区,但两个分区都能和外部网络的客户端正常通讯的情况,极为少见。更多的场景是,算法能容忍网络里下线了一部分节点,针对咱们这个例子来说,如果下线了两个节点系统可以正常工作,但下线了三个节点的话,剩余的两个节点也不可能继续提供服务了。
|
||||
|
||||
|
||||
假设现在故障恢复,分区解除,五个节点可以重新通讯了:
|
||||
|
||||
|
||||
|
||||
|
||||
S1和S3都向所有节点发送心跳包,从它们的心跳中可以得知S3的任期编号更大、是最新的,所以五个节点均只承认S3是唯一的主节点。
|
||||
S1、S2回滚它们所有未被提交的变更。
|
||||
S1、S2从主节点发送的心跳包中获得它们失联期间发生的所有变更,将变更提交写入本地磁盘。
|
||||
此时分布式系统各节点的状态达成最终一致。
|
||||
|
||||
|
||||
|
||||
到这里,第二个问题“数据在网络节点间的复制问题”也就解决了。我们继续看第三个问题,如何保证过程是安全的。
|
||||
|
||||
你可能要说了,选主和数据复制这两个问题都是很具体的行为,但“安全”这个表述很模糊啊,怎么判断什么是安全或者不安全呢?
|
||||
|
||||
要想搞明白这个问题,我们需要先看下Safety和Liveness这两个术语。
|
||||
|
||||
在专业资料中,Safety和Liveness通常会被翻译为“协定性”和“终止性”。它们也是由Lamport最先提出的,定义是:
|
||||
|
||||
|
||||
协定性(Safety):所有的坏事都不会发生(Something “bad” will never happen)。
|
||||
终止性(Liveness):所有的好事都终将发生,但不知道是啥时候(Something “good” will must happen, but we don’t know when)。
|
||||
|
||||
|
||||
这种就算解释了你也看不明白的定义,是不是很符合Lamport老爷子一贯的写作风格?(我也是无奈地摊手苦笑)。不过没关系,我们不用去纠结严谨的定义,可以通过例子来理解它们的具体含义。
|
||||
|
||||
还是以选主问题为例,Safety保证了选主的结果一定是有且只有唯一的一个主节点,不可能同时出现两个主节点;而Liveness则要保证选主过程是一定可以在某个时刻能够结束的。
|
||||
|
||||
我们再回想一下活锁的内容的话,可以发现,在Liveness这个属性上,选主问题是存在理论上的瑕疵的,可能会由于活锁而导致一直无法选出明确的主节点。所以,Raft论文中只写了对Safety的保证,但由于工程实现上的处理,现实中是几乎不可能会出现终止性的问题。
|
||||
|
||||
最后,以上这种把共识问题分解为“Leader Election”、“Entity Replication”和“Safety”三个问题来思考、解决的解题思路,就是咱们这一节标题中的“Raft算法”。
|
||||
|
||||
《一种可以让人理解的共识算法》(In Search of an Understandable Consensus Algorithm)这篇论文提出了Raft算法,并获得了USENIX ATC 2014大会的Best Paper,更是成为了日后etcd、LogCabin、Consul等重要分布式程序的实现基础。ZooKeeper的ZAB算法和Raft的思路也非常类似,这些算法都被认为是与Multi Paxos的等价派生实现。
|
||||
|
||||
Gossip协议
|
||||
|
||||
Paxos、Raft、ZAB等分布式算法经常会被称作是“强一致性”的分布式共识协议,其实这样的描述扣细节概念的话是很别扭的,会有语病嫌疑,但我们都明白它的意思其实是在说“尽管系统内部节点可以存在不一致的状态,但从系统外部看来,不一致的情况并不会被观察到,所以整体上看系统是强一致性的”。
|
||||
|
||||
与它们相对的,还有另一类被冠以“最终一致性”的分布式共识协议,这表明系统中不一致的状态有可能会在一定时间内被外部直接观察到。
|
||||
|
||||
一种典型而且非常常见的最终一致的分布式系统,就是DNS系统,在各节点缓存的TTL到期之前,都有可能与真实的域名翻译结果存在不一致。
|
||||
|
||||
还有一种很有代表性的“最终一致性”的分布式共识协议,那就是Gossip协议。Gossip协议,主要应用在比特币网络和许多重要的分布式框架(比如Consul的跨数据中心同步)中。
|
||||
|
||||
Gossip最早是由施乐公司 Palo Alto研究中心在论文“Epidemic Algorithms for Replicated Database Maintenance”中提出的,是一种用于分布式数据库在多节点间复制同步数据的算法。
|
||||
|
||||
|
||||
扩展:施乐公司(Xerox),现在可能很多人不了解施乐了,或只把施乐当一家复印产品公司看待。其实,施乐是计算机许多关键技术的鼻祖,是图形界面的发明者、以太网的发明者、激光打印机的发明者、MVC架构的提出者、RPC的提出者、BMP格式的提出者……
|
||||
|
||||
|
||||
从论文题目中可以看出,最初它是被称作“流行病算法”(Epidemic Algorithm)的,但因为不太雅观,Gossip这个名字会更普遍。另外,你可能还会听到有人把它叫做“流言算法”“八卦算法”“瘟疫算法”等。其实,这些名字都是很形象化的描述,反映了Gossip的特点:要同步的信息如同流言一般传播、病毒一般扩散。
|
||||
|
||||
按照习惯,我也会把Gossip叫做“共识协议”,但首先必须强调它所解决的问题并不是直接与Paxos、Raft这些共识算法等价的,只是基于Gossip之上可以通过某些方法去实现与Paxos、Raft相类似的目标而已。
|
||||
|
||||
一个最典型的例子是,比特币网络中使用到了Gossip协议,用来在各个分布式节点中互相同步区块头和区块体的信息。这是整个网络能够正常交换信息的基础,但并不能称作共识。比特币使用工作量证明(Proof of Work,PoW),来对“这个区块由谁来记账”这一件事儿在全网达成共识。这个目标才可以认为与Paxos、Raft的目标是一致的。
|
||||
|
||||
接下来,我们一起学习下Gossip的具体工作过程。其实,和Paxos、Raft等算法相比,Gossip的过程可以说是十分简单了,可以看作是两个步骤的简单循环:
|
||||
|
||||
|
||||
如果有某一项信息需要在整个网络中的所有节点中传播,那从信息源开始,选择一个固定的传播周期(比如1秒),随机选择与它相连接的k个节点(称为Fan-Out)来传播消息。
|
||||
如果一个节点收到消息后发现这条消息之前没有收到过,就会在下一个周期内,把这条消息发送给除了给它发消息的那个节点之外的相邻的k个节点,直到网络中所有节点都收到了这条消息。尽管这个过程需要一定的时间,但理论上网络的所有节点最终都会拥有相同的消息。
|
||||
|
||||
|
||||
Gossip传播过程的示意图如下所示:
|
||||
|
||||
|
||||
|
||||
Gossip传播示意图
|
||||
|
||||
根据示意图和Gossip的过程描述,我们很容易发现,Gossip对网络节点的连通性和稳定性几乎没有任何要求,表现在两个方面:
|
||||
|
||||
|
||||
它一开始就将某些节点只能与一部分节点部分连通(Partially Connected Network)而不是全连通网络(Fully Connected Network)作为前提;
|
||||
能够容忍网络上节点的随意地增加或者减少、随意地宕机或者重启,新增加或者重启的节点的状态,最终会与其他节点同步达成一致。
|
||||
|
||||
|
||||
也就是说,Gossip把网络上所有节点都视为平等而普通的一员,没有中心化节点或者主节点的概念。这些特点使得Gossip具有极强的鲁棒性,而且非常适合在公众互联网(WAN)中应用。
|
||||
|
||||
同时,我们也很容易发现Gossip协议有两个缺点。
|
||||
|
||||
第一个缺点是,消息是通过多个轮次的散播而到达全网的,因此必然会存在各节点状态不一致的情况。而且,因为是随机选取的发送消息的节点,所以尽管可以在整体上测算出统计学意义上的传播速率,但我们还是没办法准确估计出单条消息的传播,需要多久才能达成全网一致。
|
||||
|
||||
第二个缺点是消息的冗余。这也是因为随机选取发送消息的节点,会不可避免地存在消息重复发送给同一节点的情况。这种冗余会增加网络的传输压力,也会给消息节点带来额外的处理负载。
|
||||
|
||||
达到一致性耗费的时间与网络传播中消息冗余量这两个缺点存在一定的对立关系,如果要改善其中一个,就会恶化另外一个。由此,Gossip传播消息时,有两种可能的方式:反熵(Anti-Entropy)和传谣(Rumor-Mongering)。这两个名字听起来都挺文艺的,我们具体分析下。
|
||||
|
||||
熵(Entropy)这个概念,在生活中很少见,但在科学中却很常用,它代表的是事物的混乱程度。反熵就是反混乱的意思,它把提升网络各个节点之间的相似度作为目标。
|
||||
|
||||
所以,在反熵模式下,为了达成全网各节点的完全一致的目标,会同步节点的全部数据,来消除各节点之间的差异。但是,在节点本身就会发生变动的前提下,这个目标将使得整个网络中消息的数量非常庞大,给网络带来巨大的传输开销。
|
||||
|
||||
而传谣模式是以传播消息为目标,仅仅发送新到达节点的数据,即只对外发送变更信息,这样消息数据量将显著缩减,网络开销也相对较小。
|
||||
|
||||
小结
|
||||
|
||||
对于普通开发者来说,分布式共识算法这两讲的内容理解起来还是有些困难的,因为算法更接近研究而不是研发的范畴。
|
||||
|
||||
但是,理解Paxos算法对深入理解许多分布式工具,比如HDFS、ZooKeeper、etcd、Consul等的工作原理,是无可回避的基础。虽然Paxos不直接应用于工业界,但它的变体算法,比如我们今天学习的Multi Paxos、Raft算法,以及今天我们没有提到的ZAB等算法,都是分布式领域中的基石。
|
||||
|
||||
一课一思
|
||||
|
||||
结合自己了解的某一款分布式框架,你可以总结下共识算法具体在其中解决了什么问题吗?
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
180
专栏/周志明的架构课/33_服务发现如何做到持续维护服务地址在动态运维中的时效性?.md
Normal file
180
专栏/周志明的架构课/33_服务发现如何做到持续维护服务地址在动态运维中的时效性?.md
Normal file
@@ -0,0 +1,180 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
33 _ 服务发现如何做到持续维护服务地址在动态运维中的时效性?
|
||||
你好,我是周志明。
|
||||
|
||||
前面的两节课,我们已经学习了与分布式相关的算法和理论,掌握了一致性、共识、Paxos等共识算法,为了解分布式环境中的操作共享数据打好了理论基础。那么从这一讲开始,我们就来一起了解下,在使用分布式服务构造大型系统的过程中,都可能会遇到哪些问题,以及针对这些问题,都可以选择哪些解决方案。
|
||||
|
||||
好,那在正式开始学习之前呢,让我们先来思考一个问题:为什么在微服务应用中,需要引入服务发现呢?它的意义是什么?
|
||||
|
||||
服务发现解耦对位置的依赖
|
||||
|
||||
事实上,服务发现的意义是解耦程序对服务具体位置的依赖,对于分布式应用来说,服务发现不是可选项,而是必须的。
|
||||
|
||||
要理解分布式中的服务发现,那不妨先以单机程序中的类库来类比,因为类库概念的普及,让计算机实现了通过位于不同模块的方法调用,来组装复用指令序列的目的,打开了软件达到更大规模的一扇大门。无论是编译期链接的C/CPP,还是运行期链接的Java,都要通过链接器(Linker),把代码里的符号引用转换为模块入口或进程内存地址的直接引用。
|
||||
|
||||
而服务概念的普及,让计算机可以通过分布于网络中的不同机器互相协作来复用功能,这是软件发展规模的第二次飞跃。此时,如何确定目标方法的确切位置,便是与编译链接有着等同意义的问题,解决该问题的过程,就被叫做“服务发现”(Service Discovery)。
|
||||
|
||||
|
||||
通过服务来实现组件-
|
||||
Microservice architectures will use libraries, but their primary way of componentizing their own software is by breaking down into services.-
|
||||
微服务架构也会使用到类库,但构成软件系统组件的主要方式是将其拆分为一个个服务。-
|
||||
—— Martin Fowler / James Lewis, Microservices, 2014
|
||||
|
||||
|
||||
所有的远程服务调用都是使用“全限定名(Fully Qualified Domain Name,FQDN)、端口号、服务标识”构成的三元组,来确定一个远程服务的精确坐标的。全限定名代表了网络中某台主机的精确位置,端口代表了主机上某一个提供服务的程序,服务标识则代表了该程序所提供的一个方法接口。
|
||||
|
||||
其中,“全限定名、端口号”的含义在各种远程服务中都一致,而“服务标识”则与具体的应用层协议相关,它可以是多样的,比如HTTP的远程服务,标识是URL地址;RMI的远程服务,标识是Stub类中的方法;SOAP的远程服务,标识是WSDL中的定义,等等。
|
||||
|
||||
也正是因为远程服务的多样性,导致了“服务发现”也会有两种不同的理解。
|
||||
|
||||
一种是以UDDI为代表的“百科全书式”的服务发现。上到提供服务的企业信息(企业实体、联系地址、分类目录等),下到服务的程序接口细节(方法名称、参数、返回值、技术规范等),它们都在服务发现的管辖范围之内。
|
||||
|
||||
另一种是类似于DNS这样的“门牌号码式”的服务发现。这种服务发现只满足从某个代表服务提供者的全限定名,到服务实际主机IP地址的翻译转换。它并不关心服务具体是哪个厂家提供的,也不关心服务有几个方法,各自都由什么参数所构成,它默认这些细节信息服务消费者本身就是了解的。此时,服务坐标就可以退化为简单的“全限定名+端口号”。
|
||||
|
||||
现如今,主要是后一种服务发现占主流地位,所以咱们这节课要探讨的服务发现,如无说明,都是指的后者。
|
||||
|
||||
在前面讲“透明多级分流系统”这个小章节的时候,我提到过,原本程序只依赖DNS把一个全限定名翻译为一个或者多个IP地址(或者SRV等其他记录),就可以实现服务发现了,后来的负载均衡器实质上也承担了一部分服务发现的职责(指外部IP地址到各个服务内部实际IP的转换)。我们也已经详细解析过,这种方式在软件追求不间断长时间运行的时代是很合适的。
|
||||
|
||||
但随着微服务的逐渐流行,服务的非正常宕机重启和正常的上下线操作变得更加频繁,仅靠着DNS服务器和负载均衡器等基础设施,就显得逐渐有些疲于应对,无法跟上服务变动的步伐了。
|
||||
|
||||
因此,人们开始尝试使用ZooKeeper这样的分布式K/V框架,通过软件自身来完成服务注册与发现。ZooKeeper曾短暂统治过远程服务发现,是微服务早期对服务发现的主流选择,但毕竟ZooKeeper是很底层的分布式工具,用户自己还需要做相当多的工作,才能满足服务发现的需求。
|
||||
|
||||
那到了2014年,在Netflix内部经受过长时间实际考验的、专门用于服务发现的Eureka,宣布了开源,并很快被纳入Spring Cloud,成为Spring默认的远程服务发现的解决方案。从此,Java程序员就无需再在服务注册这件事情上花费太多的力气。
|
||||
|
||||
然后到2018年,Spring Cloud Eureka进入维护模式以后,HashiCorp的Consul和阿里巴巴的Nacos就很快从Eureka手上接过传承的衣钵。此时的服务发现框架已经发展得相当成熟,考虑到了几乎方方面面的问题,比如可以支持通过DNS或者HTTP请求,进行符号与实际地址的转换,支持各种各样的服务健康检查方式,支持集中配置、K/V存储、跨数据中心的数据交换等多种功能,可以说是以应用自身去解决服务发现的一个顶峰。
|
||||
|
||||
而如今,云原生时代来临,基础设施的灵活性得到了大幅度地增强,最初使用基础设施来透明化地做服务发现的方式,又重新被人们所重视了,如何在基础设施和网络协议层面,对应用尽可能无感知、尽可能方便地实现服务发现,便是目前一个主要的发展方向。
|
||||
|
||||
接下来,我们就具体来看看服务发现的三个关键的子问题,并一起探讨、对比下最常见的用作服务发现的几种形式,以此让你了解服务发现中,可用性与可靠性之间的关系和权衡。
|
||||
|
||||
服务发现要解决注册、维护和发现三大功能问题
|
||||
|
||||
那么,第一个问题就是,“服务发现”具体是指进行过什么操作呢?我认为,这里面其实包含了三个必须的过程:
|
||||
|
||||
|
||||
服务的注册(Service Registration)
|
||||
|
||||
|
||||
当服务启动的时候,它应该通过某些形式(比如调用API、产生事件消息、在ZooKeeper/Etcd的指定位置记录、存入数据库,等等)把自己的坐标信息通知给服务注册中心,这个过程可能由应用程序来完成(比如Spring Cloud的@EnableDiscoveryClient注解),也可能是由容器框架(比如Kubernetes)来完成。
|
||||
|
||||
|
||||
服务的维护(Service Maintaining)
|
||||
|
||||
|
||||
尽管服务发现框架通常都有提供下线机制,但并没有什么办法保证每次服务都能优雅地下线(Graceful Shutdown),而不是由于宕机、断网等原因突然失联。所以,服务发现框架就必须要自己去保证所维护的服务列表的正确性,以避免告知消费者服务的坐标后,得到的服务却不能使用的尴尬情况。
|
||||
|
||||
现在的服务发现框架,一般都可以支持多种协议(HTTP、TCP等)、多种方式(长连接、心跳、探针、进程状态等)来监控服务是否健康存活,然后把不健康的服务自动下线。
|
||||
|
||||
|
||||
服务的发现(Service Discovery)
|
||||
|
||||
|
||||
这里所说的发现是狭义的,它特指消费者从服务发现框架中,把一个符号(比如Eureka中的ServiceID、Nacos中的服务名、或者通用的FDQN)转换为服务实际坐标的过程,这个过程现在一般是通过HTTP API请求,或者是通过DNS Lookup操作来完成的(还有一些相对少用的方式,如Kubernetes也支持注入环境变量)。
|
||||
|
||||
当然,我提到的这三点只是列举了服务发现中必须要进行的过程,除此之外它还是会有一些可选的功能的,比如在服务发现时,进行的负载均衡、流量管控、K/V存储、元数据管理、业务分组,等等,这些功能都属于具体服务发现框架的功能细节,这里就不再展开了。
|
||||
|
||||
下面我们来讨论另一个很常见的问题。
|
||||
|
||||
不知道你有没有观察过,很多谈论服务发现的文章,总是无可避免地会先扯到“CP”还是“AP”的问题上。那么,为什么服务发现对CAP如此关注、如此敏感呢?
|
||||
|
||||
其实,我们可以从服务发现在整个系统中所处的角色,来着手分析这个问题。
|
||||
|
||||
在概念模型中,服务中心所处的地位是这样的:提供者在服务发现中注册、续约和下线自己的真实坐标,消费者根据某种符号从服务发现中获取到真实坐标,它们都可以看作是系统中平等的微服务。我们来看看这个概念模型示意图:
|
||||
|
||||
|
||||
|
||||
不过,在真实的系统中,服务发现的地位还是有一些特殊,我们还不能把它完全看作是一个普通的服务。为啥呢?
|
||||
|
||||
这是因为,服务发现是整个系统中,其他所有服务都直接依赖的最基础的服务(类似相同待遇的大概就数配置中心了,现在服务发现框架也开始同时提供配置中心的功能,以避免配置中心又去专门搞出一集群的节点来),几乎没有办法在业务层面进行容错处理。而服务注册中心一旦崩溃,整个系统都会受到波及和影响,因此我们必须尽最大可能,在技术层面上保证系统的可用性。
|
||||
|
||||
所以,在分布式系统中,服务注册中心一般会以内部小集群的方式进行部署,提供三个或者五个节点(通常最多七个,一般也不会更多了,否则日志复制的开销太高)来保证高可用性。你可以看看下面给出的这个例子:
|
||||
|
||||
|
||||
|
||||
另外,这里你还要注意一点,就是这个图例中各服务发现节点之间的“Replicate”字样。
|
||||
|
||||
作为用户,我们当然希望服务注册一直可用、永远健康的同时,也能够在访问每一个节点中都取到一致的数据,而这两个需求就构成了CAP矛盾。
|
||||
|
||||
我拿前面提到的最有代表性的Eureka和Consul来举个例子。
|
||||
|
||||
这里,我以AP、CP两种取舍作为选择维度,Consul采用的是Raft协议,要求多数派节点写入成功后,服务的注册或变动才算完成,这就严格地保证了在集群外部读取到的服务发现结果一定是一致的;Eureka的各个节点间采用异步复制来交换服务注册信息,服务注册或变动时,并不需要等待信息在其他节点复制完成,而是马上在该服务发现节点就宣告可见(但其他节点是否可见并不保证)。
|
||||
|
||||
实际上,这两点差异带来的影响并不在于服务注册的快慢(当然,快慢确实是有差别),而在于你如何看待以下这件事情:
|
||||
|
||||
|
||||
假设系统形成了A、B两个网络分区后,A区的服务只能从区域内的服务发现节点获取到A区的服务坐标,B区的服务只能取到在B区的服务坐标,这对你的系统会有什么影响?
|
||||
|
||||
|
||||
|
||||
如果这件事情对你并没有什么影响,甚至有可能还是有益的,那你就应该倾向于选择AP的服务发现。比如假设A、B就是不同的机房,是机房间的网络交换机导致服务发现集群出现的分区问题,但每个分区中的服务仍然能独立提供完整且正确的服务能力,此时尽管不是有意而为,但网络分区在事实上避免了跨机房的服务请求,反而还带来了服务调用链路优化的效果。
|
||||
如果这件事情可能对你影响非常大,甚至可能带来比整个系统宕机更坏的结果,那你就应该倾向于选择CP的服务发现。比如系统中大量依赖了集中式缓存、消息总线、或者其他有状态的服务,一旦这些服务全部或者部分被分隔到某一个分区中,会对整个系统的操作正确性产生直接影响的话,那与其搞出一堆数据错误,还不如停机来得痛快。
|
||||
|
||||
|
||||
除此之外,在服务发现的过程中,对系统的可用性和可靠性的取舍不同,对服务发现框架的具体实现也有着决定性的影响。接下来,我们就具体来了解下几类不同的服务发现的实现形式。
|
||||
|
||||
服务发现需要有效权衡一致性与可用性的矛盾
|
||||
|
||||
数据一致性与服务可用性之间的矛盾是分布式系统永恒的话题。而在服务发现这个场景里,权衡的主要关注点是一旦出现分区所带来的后果,其他在系统正常运行过程中,出现的速度问题都是次要的。
|
||||
|
||||
所以最后,我们再来讨论一个很“务实”的话题:现在那么多的服务发现框架,哪一款最好呢?或者说我们应该如何挑选最适合的呢?
|
||||
|
||||
实际上,现在直接以服务发现、服务注册中心为目标,或者间接用来实现这个目标的方式主要有以下三类:
|
||||
|
||||
|
||||
第一类:在分布式K/V存储框架上自己实现的服务发现
|
||||
|
||||
|
||||
这类的代表是ZooKeeper、Doozerd、Etcd。这些K/V框架提供了分布式环境下读写操作的共识保证,Etcd采用的是我们学习过的Raft算法,ZooKeeper采用的是ZAB算法(一种Multi Paxos的派生算法),所以采用这种方案,就不必纠结CP还是AP的问题了,它们都是CP的。
|
||||
|
||||
这类框架的宣传语中往往会主动提及“高可用性”,它们的潜台词其实是“在保证一致性和分区容错性的前提下,尽最大努力实现最高的可用性”,比如Etcd的宣传语就是“高可用的集中配置和服务发现”(Highly-Available Key Value Store for Shared Configuration and Service Discovery)。
|
||||
|
||||
这些K/V框架的另一个共同特点是在整体较高复杂度的架构和算法的外部,维持着极为简单的应用接口,只有基本的CRUD和Watch等少量API,所以我们如果要在上面完成功能齐全的服务发现,有很多基础的能力,比如服务如何注册、如何做健康检查等等,都必须自己实现,因此现在一般也只有“大厂”才会直接基于这些框架去做服务发现了。
|
||||
|
||||
|
||||
第二类:以基础设施(主要是指DNS服务器)来实现服务发现
|
||||
|
||||
|
||||
这类的代表是SkyDNS、CoreDNS。在Kubernetes 1.3之前的版本,是使用SkyDNS作为默认的DNS服务,它的工作原理是从API Server中监听集群服务的变化,然后根据服务生成NS、SRV等DNS记录存放到Etcd中,kubelet会在每个Pod内部设置DNS服务的地址,作为SkyDNS的地址,在需要调用服务时,只需查询DNS,把域名转换成IP列表便可实现分布式的服务发现。
|
||||
|
||||
而在Kubernetes 1.3之后,SkyDNS不再是默认的DNS服务器,也不再使用Etcd存储记录,而是只将DNS记录存储在内存中的KubeDNS代替;到了1.11版,就更推荐采用扩展性很强的CoreDNS,此时我们可以通过各种插件来决定是否要采用Etcd存储、重定向、定制DNS记录、记录日志,等等。
|
||||
|
||||
那么采用这种方案的话,是CP还是AP就取决于后端采用何种存储,如果是基于Etcd实现的,那自然是CP的;如果是基于内存异步复制的方案实现的,那就是AP的。
|
||||
|
||||
也就是说,以基础设施来做服务发现,好处是对应用透明,任何语言、框架、工具都肯定是支持HTTP、DNS的,所以完全不受程序技术选型的约束。但它的坏处是透明的并不一定简单,你必须自己考虑如何去做客户端负载均衡、如何调用远程方法等这些问题,而且必须遵循或者说受限于这些基础设施本身所采用的实现机制。
|
||||
|
||||
比如在服务健康检查里,服务的缓存期限就必须采用TTL(Time to Live)来决定,这是DNS协议所规定的,如果想改用KeepAlive长连接来实时判断服务是否存活就很麻烦。
|
||||
|
||||
|
||||
第三类:专门用于服务发现的框架和工具
|
||||
|
||||
|
||||
这类的代表是Eureka、Consul和Nacos。-
|
||||
这一类框架中,你可以自己决定是CP还是AP的问题,比如CP的Consul、AP的Eureka,还有同时支持CP和AP的Nacos(Nacos采用类Raft协议做的CP,采用自研的Distro协议做的AP,注意这里的“同时”是“都支持”的意思,它们必须二取其一,不是说CAP全能满足)。
|
||||
|
||||
另外,还有很重要一点是,它们对应用并不是透明的。尽管Consul、Nacos也支持基于DNS的服务发现,尽管这些框架都基本上做到了以声明代替编码(比如在Spring Cloud中只改动pom.xml、配置文件和注解即可实现),但它们依然是应用程序有感知的。所以或多或少还需要考虑你所用的程序语言、技术框架的集成问题。
|
||||
|
||||
但这一点其实并不见得就是坏处,比如采用Eureka做服务注册,那在远程调用服务时,你就可以用OpenFeign做客户端,写个声明式接口就能跑,相当能偷懒;在做负载均衡时,你就可以采用Ribbon做客户端,要想换均衡算法的话,改个配置就成,这些“不透明”实际上都为编码开发带来了一定的便捷,而前提是你选用的语言和框架要支持。如果你的老板提出要在Rust上用Eureka,那你就只能无奈叹息了(原本这里我写的是Node、Go、Python等,查了一下这些居然都有非官方的Eureka客户端,用的人多就是有好处啊)。
|
||||
|
||||
小结
|
||||
|
||||
微服务架构中的一个重要设计原则是“通过服务来实现独立自治的组件”(Componentization via Services),微服务强调通过“服务”(Service)而不是“类库”(Library)来构建组件,这是因为两者具有很大的差别:类库是在编译期静态链接到程序中的,通过本地调用来提供功能;而服务是进程外组件,通过远程调用来提供功能。
|
||||
|
||||
在这节课中,我们共同了解了服务发现在微服务架构中的意义,它是将固定的代表服务的标识符转化为动态的真实服务地址,并持续维护这些地址在动态运维过程中的时效性。因此,为了完成这个目标,服务发现需要解决注册、维护和发现三大功能问题,并且需要妥善权衡分布式环境下一致性与可用性之间的矛盾,由此便派生出了以DNS、专有服务等不同形式,AP和CP两种不同权衡取向的实现方案。
|
||||
|
||||
而且,基于服务来构建程序,这也迫使微服务在复杂性与执行性能方面作出了极大的让步,而换来的收益就是软件系统“整体”与“部分”的物理层面的真正的隔离。
|
||||
|
||||
一课一思
|
||||
|
||||
使用DNS来做服务发现是最符合传统的做法,这也是现代虚拟化容器编排系统(如Kubernetes)所提供的方案。
|
||||
|
||||
那么请你思考一下,为何有了DNS,还会出现Eureka、Consul这些专有的服务发现框架?后者有哪些能力是前者无法提供的呢?你是否有办法在以DNS为代表的虚拟化基础设施中,解决服务发现问题,而无需使用在应用层面提供的框架、类库呢?
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
179
专栏/周志明的架构课/34_路由凭什么作为微服务网关的基础职能?.md
Normal file
179
专栏/周志明的架构课/34_路由凭什么作为微服务网关的基础职能?.md
Normal file
@@ -0,0 +1,179 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
34 _ 路由凭什么作为微服务网关的基础职能?
|
||||
你好,我是周志明。这节课我们要探讨的话题是微服务中的入口:网关。
|
||||
|
||||
网关(Gateway)这个词我们应该都很熟悉了,它在计算机科学中,尤其是计算机网络中十分常见,主要是用来表示位于内部区域边缘,与外界进行交互的某个物理或逻辑设备,比如你家里的路由器就属于家庭内网与互联网之间的网关。
|
||||
|
||||
在单体架构下,我们一般不太强调“网关”这个概念,因为给各个单体系统的副本分发流量的负载均衡器,实质上就承担着内部服务与外部调用之间的网关角色。
|
||||
|
||||
不过在微服务环境中,网关的存在感就极大地增强了,甚至成为微服务集群中必不可少的设施之一。
|
||||
|
||||
其中原因并不难理解。你可以想想看,在微服务架构下,每个服务节点都由不同的团队负责,它们有自己独立的、各不相同的能力,所以如果服务集群没有一个统一对外交互的代理人角色,那外部的服务消费者就必须知道所有微服务在集群中的精确坐标(上一讲我介绍过“坐标”的概念)。
|
||||
|
||||
这样,消费者不仅会受到服务集群的网络限制(不能确保集群中每个节点都有外网连接)、安全限制(不仅是服务节点的安全,外部自身也会受到如浏览器同源策略的约束)、依赖限制(服务坐标这类信息不属于对外接口承诺的内容,随时可能变动,不应该依赖它),就算是我们自己也不可能愿意记住每一个服务的坐标位置来编写代码。
|
||||
|
||||
所以,微服务中网关的首要职责,就是以统一的地址对外提供服务,将外部访问这个地址的流量,根据适当的规则路由到内部集群中正确的服务节点之上。也正是因为这样,微服务中的网关,也常被称为“服务网关”或者“API网关”。
|
||||
|
||||
可见,微服务的网关首先应该是个路由器,在满足此前提的基础上,网关还可以根据需要作为流量过滤器来使用,以提供某些额外的可选的功能。比如安全、认证、授权、限流、监控、缓存,等等。
|
||||
|
||||
简而言之:
|
||||
|
||||
|
||||
网关 = 路由器(基础职能) + 过滤器(可选职能)
|
||||
|
||||
|
||||
在“路由”这个基础职能里,服务网关主要考虑的是能够支持路由的“网络层次与协议”和“性能与可用性”两方面的因素。那么接下来,我们就围绕这两方面因素来讲解路由的原理与知识点。
|
||||
|
||||
网络层次与协议
|
||||
|
||||
在第20讲“负载均衡器”中,我曾给你介绍过四层流量转发与七层流量代理,这里所说的“层次”就是指OSI七层协议中的层次,更具体的话,其实就是“四层”和“七层”的意思。
|
||||
|
||||
仅从技术实现的角度来看,对于路由流量这项工作,负载均衡器与服务网关的实现是没有什么差别的,很多服务网关本身就是基于老牌的负载均衡器来实现的,比如Nginx、HAProxy对应的Ingress Controller,等等;而从路由目的这个角度来看,负载均衡器与服务网关的区别在于,前者是为了根据均衡算法对流量进行平均地路由,后者是为了根据流量中的某种特征进行正确地路由。
|
||||
|
||||
也就是说,网关必须能够识别流量中的特征,这意味着网关能够支持的网络层次、通讯协议的数量,将会直接限制后端服务节点能够选择的服务通讯方式:
|
||||
|
||||
|
||||
如果服务集群只提供如Etcd这类直接基于TCP访问的服务,那就可以只部署四层网关,以TCP报文中的源地址、目标地址为特征进行路由;
|
||||
如果服务集群要提供HTTP服务的话,就必须部署一个七层网关,根据HTTP的URL、Header等信息为特征进行路由;
|
||||
如果服务集群要提供更上层的WebSocket、SOAP等服务,那就必须要求网关同样能够支持这些上层协议,才能从中提取到特征。
|
||||
|
||||
|
||||
我们直接来看一个例子吧。
|
||||
|
||||
这里是一段基于SpringCloud实现的Fenix’s Bootstore中,用到的Netflix Zuul网关的配置。Zuul是HTTP网关,“/restful/accounts/**”和“/restful/pay/**”是HTTP中URL的特征,而配置中的“serviceId”就是路由的目标服务。
|
||||
|
||||
routes:
|
||||
account:
|
||||
path: /restful/accounts/**
|
||||
serviceId: account
|
||||
stripPrefix: false
|
||||
sensitiveHeaders: "*"
|
||||
|
||||
payment:
|
||||
path: /restful/pay/**
|
||||
serviceId: payment
|
||||
stripPrefix: false
|
||||
sensitiveHeaders: "*"
|
||||
|
||||
|
||||
最后呢,我还想给你一个提醒:现在,围绕微服务的各种技术都处于快速发展期,我其实并不提倡你针对每一种框架本身,去记忆配置的细节,也就是你并不需要去纠结前面给出的这些配置的确切写法、每个指令的含义。因为如果你从根本上理解了网关的原理,那你参考一下技术手册,很容易就能够将前面给出的这些信息改写成Kubernetes Ingress Controller、Istio VirtualServer或者是其他服务网关所需的配置形式。
|
||||
|
||||
OK, 我们再来了解下服务网关的另一个能够支持路由的重要因素:性能与可用性。
|
||||
|
||||
性能与可用性
|
||||
|
||||
性能与可用性是网关的一大关注点。因为网关是所有服务对外的总出口,是流量必经之地,所以网关的路由性能是全局的、系统性的,如果某个服务经过网关路由会有10毫秒的性能损失,就意味着整个系统所有服务的性能都会降低10毫秒。
|
||||
|
||||
网关的性能与它的工作模式和自身实现都有关系,但毫无疑问,工作模式是最主要的。如果网关能够采用三角传输模式(DSR,即数据链路层负载均衡模式),原理上就决定了性能一定会比代理模式来的强(DSR、代理等都是负载均衡的基础知识,你可以去回顾复习一下)。
|
||||
|
||||
不过,因为今天REST和JSON-RPC等基于HTTP协议的接口形式,在对外部提供的服务中占绝对主流的地位,所以我们这里所讨论的服务网关默认都必须支持七层路由,这样通常就默认无法转发,只能采用代理模式。
|
||||
|
||||
那么在这个前提约束下,网关的性能就主要取决于它们是如何代理网络请求的,也就是它们的网络I/O模型了。既然如此,下面我们就一起来了解下网络I/O的基础知识,剖析下网络I/O模型的工作原理,借此也掌握不同网关的特点与性能差异。
|
||||
|
||||
网络I/O的基础知识
|
||||
|
||||
在套接字接口的抽象下,网络I/O的本质其实是Socket的读取,Socket在操作系统接口中被抽象为了数据流,而网络I/O就可以理解为是对流的操作。
|
||||
|
||||
对于每一次网络访问,从远程主机返回的数据会先存放到操作系统内核的缓冲区中,然后再从内核的缓冲区,复制到应用程序的地址空间,所以当一次网络请求发生后,就会按顺序经历“等待数据从远程主机到达缓冲区”和“将数据从缓冲区拷贝到应用程序地址空间”两个阶段。
|
||||
|
||||
那么,根据完成这两个阶段的不同方法,我们可以把网络I/O模型总结为两类、五种模型。两类是指同步I/O与异步I/O;五种是指在同步I/O中又划分出了阻塞I/O、非阻塞I/O、多路复用I/O和信号驱动I/O四种细分模型。
|
||||
|
||||
同步就是指调用端发出请求之后,在得到结果之前必须一直等待,与之相对的就是异步,在发出调用请求之后将立即返回,不会马上得到处理结果,这个结果将通过状态变化和回调来通知给调用者。而阻塞和非阻塞I/O针对请求处理的过程,就是指在收到调用请求、返回结果之前,当前处理线程是否会被挂起。
|
||||
|
||||
当然,这种概念上的讲述估计你也不好理解,所以下面我就以“你如何领到盒饭”这个情景,来给你类比解释一下:
|
||||
|
||||
异步I/O(Asynchronous I/O)
|
||||
|
||||
这就好比你在某团外卖订了个盒饭,付款之后你自己该干嘛还干嘛去,饭做好了骑手自然会到门口打电话通知你。所以说,异步I/O中,数据到达缓冲区后,不需要由调用进程主动进行从缓冲区复制数据的操作,而是在复制完成后,由操作系统向线程发送信号,所以它一定是非阻塞的。
|
||||
|
||||
同步I/O(Synchronous I/O)
|
||||
|
||||
这就好比你自己去饭堂打饭,这时可能有以下几种情形发生:
|
||||
|
||||
|
||||
阻塞I/O(Blocking I/O)
|
||||
|
||||
|
||||
你去到饭堂,发现饭还没做好,你也干不了别的,只能打个瞌睡(线程休眠),直到饭做好。阻塞I/O是最直观的I/O模型,逻辑清晰,也比较节省CPU资源,但缺点就是线程休眠所带来的上下文切换,这是一种需要切换到内核态的重负载操作,不应当频繁进行。
|
||||
|
||||
|
||||
非阻塞I/O(Non-Blocking I/O)
|
||||
|
||||
|
||||
你去到饭堂,发现饭还没做好,你就回去了,然后每隔3分钟来一次饭堂看饭做好了没,直到饭做好。非阻塞I/O能够避免线程休眠,对于一些很快就能返回结果的请求,非阻塞I/O可以节省上下文切换的消耗,但是对于较长时间才能返回的请求,非阻塞I/O反而白白浪费了CPU资源,所以目前并不常用。
|
||||
|
||||
|
||||
多路复用I/O(Multiplexing I/O)
|
||||
|
||||
|
||||
多路复用I/O本质上是阻塞I/O的一种,但是它的好处是可以在同一条阻塞线程上处理多个不同端口的监听。可以类比这样一个情景:你是活雷锋,代表整个宿舍去饭堂打饭,去到饭堂,发现饭还没做好,还是继续打瞌睡,不过哪个舍友的饭好了,你就马上把那份饭送回去,然后继续打着瞌睡哼着歌等待其他的饭做好。多路复用I/O是目前的高并发网络应用的主流,它下面还可以细分select、epoll、kqueue等不同实现。
|
||||
|
||||
|
||||
信号驱动I/O(Signal-Driven I/O)
|
||||
|
||||
|
||||
你去到饭堂,发现饭还没做好,但你跟厨师熟,跟他说饭做好了叫你,然后回去该干嘛干嘛,等收到厨师通知后,你把饭从饭堂拿回宿舍。这里厨师的通知就是那个“信号”,信号驱动I/O与异步I/O的区别是“从缓冲区获取数据”这个步骤的处理,前者收到的通知是可以开始进行复制操作了,也就是你要自己把饭从饭堂拿回宿舍,在复制完成之前线程处于阻塞状态,所以它仍属于同步I/O操作;而后者收到的通知是复制操作已经完成,即外卖小哥已经把饭送到了。
|
||||
|
||||
那么显而易见,异步I/O模型是最方便的,毕竟能叫外卖谁愿意跑饭堂啊,但前提是你学校里得让送美团外卖。所以,异步I/O受限于操作系统,Windows NT内核早在3.5以后,就通过IOCP实现了真正的异步I/O模型。而Linux系统下,是在Linux Kernel 2.6才首次引入,目前也还并不完善,因此在Linux下实现高并发网络编程时,仍然是以多路复用I/O模型模式为主。
|
||||
|
||||
网关的性能考量
|
||||
|
||||
好,回到服务网关的话题上,现在我们掌握了网络I/O模型的知识,就可以在理论上定性分析不同网关的性能差异了。
|
||||
|
||||
服务网关处理一次请求代理时,包含了两组网络操作,分别是“作为服务端对外部请求的应答”和“作为客户端对内部服务的调用”。理论上这两组网络操作可以采用不同的网络I/O模型去完成,但一般来说并没有必要这样做。
|
||||
|
||||
为什么呢?我以Zuul网关来给你举个例子。
|
||||
|
||||
在Zuul 1.0时,它采用的是阻塞I/O模型,来进行最经典的“一条线程对应一个连接”(Thread-per-Connection)的方式来代理流量,而采用阻塞I/O就意味着它会有线程休眠,就有上下文切换的成本。
|
||||
|
||||
所以如果后端服务普遍属于计算密集型(CPU Bound,可以通俗理解为服务耗时比较长,主要消耗在CPU上)时,这种模式能够节省网关的CPU资源,但如果后端服务普遍都是I/O密集型(I/O Bound,可以理解服务都很快返回,主要消耗在I/O上),它就会由于频繁的上下文切换而降低性能。
|
||||
|
||||
那么到了Zuul的2.0版本,最大的改进就是基于Netty Server实现了异步I/O模型来处理请求,大幅度减少了线程数,获得了更高的性能和更低的延迟。根据Netflix官方自己给出的数据,Zuul 2.0大约要比Zuul 1.0快20%左右。当然还有一些网关,我们也可以自行配置,或者根据环境选择不同的网络I/O模型,典型的就是Nginx,可以支持在配置文件中指定select、poll、epoll、kqueue等并发模型。
|
||||
|
||||
不过,网关的性能高低一般只能去定性分析,要想定量地说哪一种网关性能最高、高多少,是很难的,就像我们都认可Chrome要比IE快,但具体要快上多少,我们很难说得清楚。
|
||||
|
||||
所以尽管我上面引用了Netflix官方对Zuul两个版本的量化对比,网络上也有不少关于各种网关的性能对比数据,但要是脱离具体应用场景去定量地比较不同网关的性能差异,还是难以令人信服,毕竟不同的测试环境、后端服务都会直接影响结果。
|
||||
|
||||
网关的可用性考量
|
||||
|
||||
OK,我们还有一点要关注的就是网关的可用性问题。任何系统的网络调用过程中都至少会有一个单点存在,这是由用户只通过唯一的一个地址去访问系统决定的。即使是淘宝、亚马逊这样全球多数据中心部署的大型系统,给多数据中心翻译地址的权威DNS服务器,也可以认为是它的单点。
|
||||
|
||||
而对于更普遍的小型系统(小型是相对淘宝这些而言)来说,作为后端对外服务代理人角色的网关,经常被看作是系统的入口,往往很容易成为网络访问中的单点。这时候,它的可用性就尤为重要。
|
||||
|
||||
另外,由于网关具有唯一性,它不像之前讲服务发现时的那些注册中心一样,可以直接做个集群,随便访问哪一台都可以解决问题。所以针对这个情况,在网关的可用性方面,我们应该考虑到以下几点:
|
||||
|
||||
|
||||
网关应尽可能轻量。尽管网关作为服务集群统一的出入口,可以很方便地做安全、认证、授权、限流、监控等功能,但在给网关附加这些能力时,我们还是要仔细权衡,取得功能性与可用性之间的平衡,不然过度增加网关的职责是很危险的。
|
||||
网关选型时,应该尽可能选择较成熟的产品实现。比如Nginx Ingress Controller、KONG、Zuul等等这些经受过长期考验的产品,我们不能一味只考虑性能,选择最新的产品,毕竟性能与可用性之间的平衡也需要做好权衡。
|
||||
在需要高可用的生产环境中,应当考虑在网关之前部署负载均衡器或者等价路由器(ECMP),让那些更成熟健壮的(往往是硬件物理设备)的设施去充当整个系统的入口地址,这样网关就可以很方便地设置多路扩展了。
|
||||
|
||||
|
||||
这里我提到了网关的唯一性、高可用与扩展,所以我顺带也说一下近年来随着微服务一起火起来的概念“BFF”(Backends for Frontends)。
|
||||
|
||||
这个概念目前还没有权威的中文翻译,不过在我们讨论的上下文里,它的意思可以理解为,网关不必为所有的前端提供无差别的服务,而是应该针对不同的前端,聚合不同的服务,提供不同的接口和网络访问协议支持。
|
||||
|
||||
比如,运行于浏览器的Web程序,由于浏览器一般只支持HTTP协议,服务网关就应该提供REST等基于HTTP协议的服务,但同时我们也可以针对运行于桌面系统的程序,部署另外一套网关,它能与Web网关有完全不同的技术选型,能提供基于更高性能协议(如gRPC)的接口,来获得更好的体验。
|
||||
|
||||
所以这个概念要表达的就是,在网关这种边缘节点上,针对同样的后端集群,裁剪、适配、聚合出适应不一样的前端服务,有助于后端的稳定,也有助于前端的赋能。
|
||||
|
||||
|
||||
|
||||
小结
|
||||
|
||||
这节课我们主要探讨的话题是网关,但我只给你介绍了网关的路由职能,其他可以在网关上实现的限流、容错、安全、认证等等的过滤职能,在课程中都有专门的讲解,所以这里我们就不展开了。
|
||||
|
||||
那么在路由方面,因为现在我们所讨论的服务网关默认都必须支持七层路由,通常就默认无法转发,只能采用代理模式。因此你要掌握这样一个核心知识点:在必须支持七层路由的前提下,网关的性能主要取决于它们是如何代理网络请求的,也就是说,你要了解它们的网络I/O模型。现在,在学习了典型的网络I/O模型的工作原理之后,希望你在后面的学习或者实践过程当中,看到网关的I/O模型,你就能够对它的特点与性能有个大致的判断。
|
||||
|
||||
一课一思
|
||||
|
||||
这节课的最后,我给你介绍了网关路由职能在BFF方面的应用。那么除了BFF之外,你还用网关来做什么?另外,如今微服务兴起,在网关这个概念独立之前,你觉得这项功能是如何实现的呢?
|
||||
|
||||
欢迎给我留言,分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
176
专栏/周志明的架构课/35_如何在客户端实现服务的负载均衡?.md
Normal file
176
专栏/周志明的架构课/35_如何在客户端实现服务的负载均衡?.md
Normal file
@@ -0,0 +1,176 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
35 _ 如何在客户端实现服务的负载均衡?
|
||||
你好,我是周志明。这节课我们来学习客户端负载均衡的实现原理。
|
||||
|
||||
在正式开始讨论之前,我们先来区分清楚几个容易混淆的概念,分别是前面两讲中我介绍过的服务发现、网关路由,以及这节课要探讨的负载均衡,还有在下一讲中将会介绍的调用容错。这几个技术名词都带有“从服务集群中寻找到一个合适的服务来调用”的含义,那么它们之间的差别都体现在哪呢?下面我就通过一个具体的案例场景来给你说明一下。
|
||||
|
||||
理解服务发现、网关路由、负载均衡、调用容错的具体区别
|
||||
|
||||
假设,你目前身处广东,要上Fenix’s Bookstore购买一本书。在程序业务逻辑里,购书的其中一个关键步骤是调用商品出库服务来完成货物准备,在代码中该服务的调用请求为:
|
||||
|
||||
PATCH https://warehouse:8080/restful/stockpile/3
|
||||
|
||||
{amount: -1}
|
||||
|
||||
|
||||
假设Fenix’s Bookstore是个大书店,在北京、武汉、广州的机房均部署有服务集群,那么此时按顺序会发生以下事件:
|
||||
|
||||
|
||||
首先是将warehouse这个服务名称转换为了恰当的服务地址。
|
||||
|
||||
|
||||
注意,这里的“恰当”是个宽泛的描述,一种典型的“恰当”就是因为调用请求来自于广东,DNS层面的负载均衡就会优先分配给传输距离最短的广州机房来应答。
|
||||
|
||||
其实按常理来说,这次出库服务的调用应该是集群内的流量,而不是用户浏览器直接发出的请求。所以尽管结果都一样,但更接近实际的情况应该是用户访问首页时,已经被DNS服务器分配到了广州机房,请求出库服务时,应优先选择同机房的服务进行调用,此时该服务的调用请求就变为:
|
||||
|
||||
PATCH https://guangzhou-ip-wan:8080/restful/stockpile/3
|
||||
|
||||
|
||||
|
||||
然后,广州机房的服务网关将该请求与配置中的特征进行比对,由URL中的“/restful/stockpile/**”得知该请求访问的是商品出库服务。因此,将请求的IP地址转换为内网中warehouse服务集群的入口地址:
|
||||
|
||||
|
||||
PATCH https://warehouse-gz-lan:8080/restful/stockpile/3
|
||||
|
||||
|
||||
|
||||
由于集群中部署有多个warehouse服务,所以在收到调用请求后,负载均衡器要在多个服务中根据某种标准或均衡策略(可能是随机挑选,也可能是按顺序轮询,或者是选择此前调用次数最少那个,等等),找出要响应本次调用的服务,我们称其为warehouse-gz-lan-node1。
|
||||
|
||||
|
||||
PATCH https://warehouse-gz-lan-node1:8080/restful/stockpile/3
|
||||
|
||||
|
||||
|
||||
接着,访问warehouse-gz-lan-node1服务,没有返回需要的结果,而是抛出了500错。
|
||||
|
||||
|
||||
HTTP/1.1 500 Internal Server Error
|
||||
|
||||
|
||||
|
||||
那么根据预置的故障转移(Failover)策略,负载均衡器会进行重试,把调用分配给能够提供该服务的其他节点,我们称其为warehouse-gz-lan-node2。
|
||||
|
||||
|
||||
PATCH https://warehouse-gz-lan-node2:8080/restful/stockpile/3
|
||||
|
||||
|
||||
|
||||
最后,warehouse-gz-lan-node2服务返回商品出库成功。
|
||||
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
|
||||
所以,从整体上看,前面步骤中的1、2、3、5,就分别对应了服务发现、网关路由、负载均衡和调用容错;而从细节上看,其中的部分职责又是有交叉的,并不是注册中心就只关心服务发现,网关只关心路由,均衡器只关心负载均衡。
|
||||
|
||||
比如,步骤1服务发现的过程中,“根据请求来源的物理位置来分配机房”这个操作,本质上是根据请求中的特征(地理位置)来进行流量分发,这其实是一种路由行为。在实际的系统中,DNS服务器(DNS智能线路)、服务注册中心(如Eureka等框架中的Region、Zone概念)或者负载均衡器(可用区负载均衡,如AWS的NLB,或Envoy的Region、Zone、Sub-zone)里都有可能实现路由功能。
|
||||
|
||||
而且除此之外,你有没有感觉到这个网络调用的过程好像过于繁琐了,一个从广州机房内网发出的服务请求,绕到了网络边缘的网关、负载均衡器这些设施上,然后再被分配回内网中另外一个服务去响应。这不仅消耗了带宽,降低了性能,也增加了链路上的风险和运维的复杂度。
|
||||
|
||||
可是,如果流量不经过这些设施,它们相应的职责就无法发挥作用了:如果不经过负载均衡器的话,甚至连请求应该具体交给哪一个服务去处理都无法确定。
|
||||
|
||||
所以既然如此,我们有什么办法可以简化这个调用过程吗?这就需要用到客户端负载均衡器了。
|
||||
|
||||
客户端负载均衡器的工作原理
|
||||
|
||||
我们知道,对于任何一个大型系统来说,负载均衡器都是必不可少的设施。以前,负载均衡器大多只部署在整个服务集群的前端,将用户的请求分流到各个服务进行处理,这种经典的部署形式现在被称为集中式的负载均衡(在第20讲中,我已经给你介绍过这种经典的负载均衡的工作原理,你可以去复习一下)。
|
||||
|
||||
而随着微服务的日渐流行,服务集群收到的请求来源就不再局限于外部了,越来越多的访问请求是由集群内部的某个服务发起,由集群内部的另一个服务进行响应的。对于这类流量的负载均衡,既有的方案依然是可行的,但针对内部流量的特点,直接在服务集群内部消化掉,肯定是更合理、更受开发者青睐的办法。
|
||||
|
||||
由此,一种全新的、独立位于每个服务前端的、分散式的负载均衡方式正逐渐变得流行起来,这就是本节课我们要讨论的主角:客户端负载均衡器(Client-Side Load Balancer)。
|
||||
|
||||
|
||||
|
||||
客户端负载均衡器的理念提出以后,此前的集中式负载均衡器也有了一个方便与它对比的名字,“服务端负载均衡器”(Server-Side Load Balancer)。
|
||||
|
||||
从上图中,你其实能够清晰地看到这两种均衡器的关键差别所在:服务端负载均衡器是集中式的,同时为多个节点提供服务,而客户端负载均衡器是和服务实例一一对应的,而且与服务实例并存于同一个进程之内。这能为它带来很多好处,比如说:
|
||||
|
||||
|
||||
均衡器与服务之间的信息交换是进程内的方法调用,不存在任何额外的网络开销。
|
||||
客户端均衡器不依赖集群边缘的设施,所有内部流量都仅在服务集群的内部循环,避免出现前面提到的,集群内部流量要“绕场一周”的尴尬局面。
|
||||
分散式的均衡器意味着它天然避免了集中式的单点问题,它的带宽资源将不会像集中式均衡器那样敏感,这在以七层均衡器为绝对主流、不能通过IP隧道和三角传输这样的方式来节省带宽的微服务环境中,显得更具优势。
|
||||
客户端均衡器要更加灵活,能够针对每一个服务实例单独设置均衡策略等参数。比如访问某个服务是否需要具备亲和性,选择服务的策略是随机、轮询、加权还是最小连接,等等,都可以单独设置而不影响其他服务。
|
||||
……
|
||||
|
||||
|
||||
但是你也要清楚,客户端均衡器并不是银弹,它的缺点同样是不少的:
|
||||
|
||||
|
||||
它与服务运行于同一个进程之内,就意味着它的选型要受到服务所使用的编程语言的限制。比如,用Golang开发的微服务,就不太可能搭配Spring Cloud Load Balancer来一起使用,因为要为每种语言都实现对应的能够支持复杂网络情况的均衡器是非常难的。客户端均衡器的这个缺陷其实有违于微服务中,技术异构不应受到限制的原则。
|
||||
从个体服务来看,由于客户端均衡器与服务实例是共用一个进程,均衡器的稳定性会直接影响整个服务进程的稳定性,而消耗的CPU、内存等资源也同样会影响到服务的可用资源。从集群整体来看,在服务数量达到成千乃至上万的规模时,客户端均衡器消耗的资源总量是相当可观的。
|
||||
由于请求的来源可能是来自集群中任意一个服务节点,而不再是统一来自集中式均衡器,这就会导致内部网络安全和信任关系变得复杂,当入侵者攻破任何一个服务时,都会更容易通过该服务突破集群中的其他部分。
|
||||
我们知道,服务集群的拓扑关系是动态的,每一个客户端均衡器必须持续跟踪其他服务的健康状况,以实现上线新服务、下线旧服务、自动剔除失败的服务、自动重连恢复的服务等均衡器必须具备的功能。由于这些操作都需要通过访问服务注册中心来完成,因此数量庞大的客户端均衡器需要一直持续轮询服务注册中心,这也会为它带来不小的负担。
|
||||
……
|
||||
|
||||
|
||||
代理客户端负载均衡器
|
||||
|
||||
在Java领域,客户端负载均衡器中最具代表性的产品,就是Netflix Ribbon和Spring Cloud Load Balancer了,随着微服务的流行,它们在Java微服务中已经积聚了相当可观的使用者。
|
||||
|
||||
而到了最近两三年,服务网格(Service Mesh)开始盛行,另一种被称为“代理客户端负载均衡器”(Proxy Client-Side Load Balancer,后面就简称“代理均衡器”)的客户端均衡器变体形式,开始引起不同编程语言的微服务开发者的共同关注,因为它解决了此前客户端均衡器的大部分缺陷。
|
||||
|
||||
代理均衡器对此前的客户端负载均衡器的改进,其实就是将原本嵌入在服务进程中的均衡器提取出来,放到边车代理中去实现,它的流量关系如下图所示:
|
||||
|
||||
|
||||
|
||||
这里你可以发现,虽然代理均衡器与服务实例不再是进程内通讯,而是通过虚拟化网络进行数据交换的,数据要经过操作系统的协议栈,要进行打包拆包、计算校验和、维护序列号等网络数据的收发等步骤(Envoy中支持使用Unix Domain Socket来进一步避免这种消耗),流量比起之前的客户端均衡器确实多经历了一次代理过程。
|
||||
|
||||
不过,Kubernetes严格保证了同一个Pod中的容器不会跨越不同的节点,相同Pod中的容器共享同一个网络和Linux UTS名称空间,因此代理均衡器与服务实例的交互,仍然要比真正的网络交互高效且稳定得多,代价很小。但它从服务进程中分离出来的收益则是非常明显的:
|
||||
|
||||
|
||||
代理均衡器不再受编程语言的限制。比如说,要发展一个支持Java、Golang、Python等所有微服务应用的通用代理均衡器,就具有很高的性价比。集中不同编程语言的使用者的力量,也更容易打造出能面对复杂网络情况的、高效健壮的均衡器。即使退一步说,独立于服务进程的均衡器,也不会因为自身的稳定性而影响到服务进程的稳定。
|
||||
在服务拓扑感知方面,代理均衡器也要更有优势。由于边车代理接受控制平面的统一管理,当服务节点拓扑关系发生变化时,控制平面就会主动向边车代理发送更新服务清单的控制指令,这避免了此前客户端均衡器必须长期主动轮询服务注册中心所造成的浪费。
|
||||
在安全性、可观测性上,由于边车代理都是一致的实现,有利于在服务间建立双向TLS通讯,也有利于对整个调用链路给出更详细的统计信息。
|
||||
……
|
||||
|
||||
|
||||
所以总体而言,边车代理这种通过同一个Pod的独立容器实现的负载均衡器,就是目前处理微服务集群内部流量最理想的方式。只是服务网格本身仍然是初生事物,它还不够成熟,对程序员的操作系统、网络和运维方面的知识要求也比较高,但我们有理由相信,随着时间的推移,未来服务网格将会是微服务的主流通讯方式。
|
||||
|
||||
地域与区域
|
||||
|
||||
OK,最后,我想再和你探讨一个与负载均衡相关,但又不仅仅只涉及到负载均衡的概念:地域与区域。
|
||||
|
||||
不知道你有没有注意到,在与微服务相关的许多设施中,都带有Region、Zone参数,比如前面我提到过的服务注册中心Eureka的Region、Zone,边车代理Envoy中的Region、Zone、Sub-zone,等等。如果你有云计算IaaS的使用经历,你也会发现几乎所有的云计算设备都有类似的概念。
|
||||
|
||||
实际上,Region和Zone是公有云计算先驱亚马逊AWS提出的概念,我们分别来看看它们的含义。
|
||||
|
||||
Region是地域的意思,比如华北、东北、华东、华南,这些都是地域范围。
|
||||
|
||||
面向全球或全国的大型系统的服务集群,往往都会部署在多个不同的地域,就像是这节课开篇我设计的那个案例场景一样,它就是通过不同地域的机房,来缩短用户与服务器之间的物理距离,提升响应速度的;而对于小型系统来说,地域一般就只在异地容灾时才会涉及到。
|
||||
|
||||
这里你需要注意的是,不同的地域之间是没有内网连接的,所有流量都只能经过公众互联网相连,如果微服务的流量跨越了地域,实际上就跟调用外部服务商提供的互联网服务没有任何差别了。所以,集群内部流量是不会跨地域的,服务发现、负载均衡器也默认是不会支持跨地域的服务发现和负载均衡。
|
||||
|
||||
Zone是区域的意思,它是可用区域(Availability Zones)的简称。区域的意思是在地理上位于同一地域内,但电力和网络是互相独立的物理区域,比如在华东的上海、杭州、苏州的不同机房,就是同一个地域的几个可用区域。
|
||||
|
||||
同一个地域的区域之间具有内网连接,流量不占用公网带宽,因此区域是微服务集群内,流量能够触及的最大范围。但是,你的应用是只部署在同一区域内,还是部署到几个可用区域中,主要取决于你是否有做异地双活的需求,以及对网络延时的容忍程度。
|
||||
|
||||
|
||||
如果你追求高可用,比如希望系统即使在某个地区发生电力或者骨干网络中断时,仍然可用,那你可以考虑将系统部署在多个区域中。这里你要注意异地容灾和异地双活的差别:容灾是非实时的同步,而双活是实时或者准实时的,跨地域或者跨区域做容灾都可以,但只能一般只能跨区域做双活,当然你也可以将它们结合起来同时使用,即“两地三中心”模式。
|
||||
如果你追求低延迟,比如对时间有高要求的SLA应用,或者网络游戏服务器等,那就应该考虑将系统部署在同一个区域中。因为尽管内网连接不受限于公网带宽,但毕竟机房之间的专线容量也是有限的,难以跟机房内部的交换机相比,延时也会受物理距离、网络跃点等因素的影响。
|
||||
|
||||
|
||||
当然,可用区域对应于城市级别的区域范围,在一些场景中仍然是过大了一些,即使是同一个区域中的机房,也可能存在不同的具有差异的子网络,所以部分的微服务框架也提供了Group、Sub-zone等做进一步的细分控制。这些参数的意思通常是加权或优先访问同一个子区域的服务,但如果子区域中没有合适的,还是会访问到可用区域中的其他服务。
|
||||
|
||||
另外,地域和区域原本是云计算中的概念,对于一些中小型的微服务系统,尤其是非互联网的企业信息系统来说,很多仍然没有使用云计算设施,只部署在某个专有机房内部,只为特定人群提供服务,这就不需要涉及地理上的地域、区域的概念了。这时你完全可以自己灵活延伸拓展Region、Zone参数的含义,达到优化虚拟化基础设施流量的目的。
|
||||
|
||||
比如说,将框架的这类设置与Kubernetes的标签、选择器相配合,实现内部服务请求其他服务时,优先使用同一个Node中提供的服务进行应答,以降低真实的网络消耗。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我们从“如何将流量转发到最恰当的服务”开始,讨论了客户端负载均衡器出现的背景、它与服务端负载均衡器之间的差异,以及通过代理来实现客户端负载均衡器的差别。
|
||||
|
||||
最后,借助本节课建立的上下文场景,我还给你介绍了地域与可用区域的概念。这些概念不仅在购买、使用云计算服务时会用到,还直接影响到了应用程序中路由、负载均衡的请求分发形式。
|
||||
|
||||
一课一思
|
||||
|
||||
你认为“负载均衡”这件事情,是负责设计程序的架构师、负责实现程序的开发者,还是负责部署程序的运维人员为主去考虑的呢?
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
112
专栏/周志明的架构课/36_面对程序故障,我们该做些什么?.md
Normal file
112
专栏/周志明的架构课/36_面对程序故障,我们该做些什么?.md
Normal file
@@ -0,0 +1,112 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
36 _ 面对程序故障,我们该做些什么?
|
||||
你好,我是周志明。接下来的两节课,我们一起来学习服务的容错性设计这个话题。
|
||||
|
||||
“容错性设计”(Design for Failure)是微服务的另一个核心原则,也是我在这门课中反复强调的开发观念的转变。
|
||||
|
||||
不过,虽然已经有了一定的心理准备,但在首次将微服务架构引入实际生产系统时,在服务发现、网关路由等支持下,踏出了服务化的第一步以后,我们还是很可能会经历一段阵痛期。随着拆分出的服务越来越多,随之而来的,我们也会面临以下两个问题的困扰:
|
||||
|
||||
|
||||
某一个服务的崩溃,会导致所有用到这个服务的其他服务都无法正常工作,一个点的错误经过层层传递,最终波及到调用链上与此有关的所有服务,这便是雪崩效应。如何防止雪崩效应,便是微服务架构容错性设计原则的具体实践,否则服务化程度越高,整个系统反而越不稳定。
|
||||
服务虽然没有崩溃,但由于处理能力有限,面临超过预期的突发请求时,大部分请求直至超时都无法完成处理。这种现象产生的后果跟交通堵塞是类似的,如果一开始没有得到及时地治理,后面就会需要很长时间才能使全部服务都恢复正常。
|
||||
|
||||
|
||||
这两个问题,就是“流量治理”这个话题要解决的了。在这个小章节,我们将围绕着如何解决这两个问题,提出服务容错、流量控制、服务质量管理等一系列解决方案。
|
||||
|
||||
当然了,这些措施并不是孤立的,它们相互之间存在很多联系,其中的许多功能还必须与咱们之前学习过的服务注册中心、服务网关、负载均衡器配合才能实现。理清楚了这些技术措施背后的逻辑链条,我们就找到了理解它们工作原理的捷径。
|
||||
|
||||
接下来,我们就从服务容错这个解决方案学起吧。
|
||||
|
||||
服务容错
|
||||
|
||||
Martin Fowler与James Lewis提出的“微服务的九个核心特征”是构建微服务系统的指导性原则,但不是技术规范,并没有严格的约束力。在实际构建系统时候,其中多数特征可能会有或多或少的妥协,比如分散治理、数据去中心化、轻量级通讯机制、演进式设计,等等。但也有一些特征是无法做出妥协的,其中典型的就是今天我们讨论的主题:容错性设计。
|
||||
|
||||
容错性设计不能妥协的原因在于,分布式系统的本质是不可靠的,一个大的服务集群中,程序可能崩溃、节点可能宕机、网络可能中断,这些“意外情况”其实全部都在“意料之中”。原本信息系统设计成分布式架构的主要动力之一,就是提升系统的可用性,最低限度也必须保证将原有系统重构为分布式架构之后,可用性不出现倒退才行。
|
||||
|
||||
如果说,服务集群中出现任何一点差错都能让系统面临“千里之堤溃于蚁穴”的风险,那么分布式恐怕就根本没有机会成为一种可用的系统架构形式了。
|
||||
|
||||
容错策略
|
||||
|
||||
那在实践中怎么落实容错性设计这条原则呢?除了思想观念上转变过来,正视程序必然是会出错的,对它进行有计划的防御之外,我们还必须了解一些常用的容错策略和容错设计模式,来指导具体的设计与编码实践。
|
||||
|
||||
那怎么理解容错策略和容错设计模式呢?其实,容错策略,指的是“面对故障,我们该做些什么”;而容错设计模式,指的是“要实现某种容错策略,我们该如何去做”。
|
||||
|
||||
所以,接下来我们先一起学习7种常见的容错策略,包括故障转移、快速失败、安全失败、沉默失败、故障恢复、并行调用和广播调用,然后下一讲我们再学习几种被实践证明有效的服务容错设计模式。
|
||||
|
||||
第一种容错策略,是故障转移(Failover)。
|
||||
|
||||
高可用的服务集群中,多数的服务,尤其是那些经常被其他服务依赖的关键路径上的服务,都会部署多个副本。这些副本可能部署在不同的节点(避免节点宕机)、不同的网络交换机(避免网络分区),甚至是不同的可用区(避免整个地区发生灾害或电力、骨干网故障)中。
|
||||
|
||||
故障转移是指,如果调用的服务器出现故障,系统不会立即向调用者返回失败结果,而是自动切换到其他服务副本,尝试其他副本能否返回成功调用的结果,从而保证了整体的高可用性。
|
||||
|
||||
故障转移的容错策略应该有一定的调用次数限制,比如允许最多重试三个服务,如果都发生报错,那还是会返回调用失败。引入调用次数的限制,不仅是因为重试有执行成本,更是因为过度的重试反而可能让系统处于更加不利的状况。
|
||||
|
||||
我们看一个例子。现在有Service A → Service B → Service C这么一条调用链。假设A的超时阈值为100毫秒,而B调用C需要60毫秒,然后不幸失败了,这时候做故障转移其实已经没有太大意义了。因为即使下一次调用能够返回正确结果,也很可能同样需要耗费60毫秒的时间,时间总和就已经超过了Service A的超时阈值。所以,在这种情况下故障转移反而对系统是不利的。
|
||||
|
||||
第二种容错策略,是快速失败(Failfast)。
|
||||
|
||||
有一些业务场景是不允许做故障转移的,因为故障转移策略能够实施的前提,是服务具有幂等性。那对于非幂等的服务,重复调用就可能产生脏数据,引起的麻烦远大于单纯的某次服务调用失败。这时候,就应该把快速失败作为首选的容错策略。
|
||||
|
||||
比如,在支付场景中,需要调用银行的扣款接口,如果该接口返回的结果是网络异常,那程序很难判断到底是扣款指令发送给银行时出现的网络异常,还是银行扣款后给服务返回结果时出现的网络异常。为了避免重复扣款,此时最恰当的方案就是尽快让服务报错并抛出异常,坚决避免重试,由调用者自行处理。
|
||||
|
||||
第三种容错策略,是安全失败(Failsafe)。
|
||||
|
||||
在一个调用链路中的服务,通常也有主路和旁路之分,并不见得每个服务都是不可或缺的,属于旁路逻辑的一个显著特点是,服务失败了也不影响核心业务的正确性。比如,开发基于Spring管理的应用程序时,通过扩展点、事件或者AOP注入的逻辑往往就属于旁路逻辑,典型的有审计、日志、调试信息,等等。
|
||||
|
||||
属于旁路逻辑的另一个显著特征是,后续处理不会依赖其返回值,或者它的返回值是什么都不会影响后续处理的结果。比如只是将返回值记录到数据库,并不使用它参与最终结果的运算。
|
||||
|
||||
对这类逻辑,一种理想的容错策略是,即使旁路逻辑调用失败了,也当作正确来返回,如果需要返回值的话,系统就自动返回一个符合要求的数据类型的对应零值,然后自动记录一条服务调用出错的日志备查即可。这种容错策略,被称为安全失败。
|
||||
|
||||
第四种容错策略,是沉默失败(Failsilent)。
|
||||
|
||||
如果大量的请求需要等到超时(或者长时间处理后)才宣告失败,很容易因为某个远程服务的请求堆积而消耗大量的线程、内存、网络等资源,进而影响到整个系统的稳定性。
|
||||
|
||||
面对这种情况,一种合理的失败策略是当请求失败后,就默认服务提供者一定时间内无法再对外提供服务,不再向它分配请求流量,并将错误隔离开来,避免对系统其他部分产生影响。这种容错策略,就被称为沉默失败。
|
||||
|
||||
第五种容错策略,是故障恢复(Failback)。
|
||||
|
||||
故障恢复一般不单独存在,而是作为其他容错策略的补充措施。在微服务管理框架中,如果设置容错策略为故障恢复的话,通常默认会采用快速失败加上故障恢复的策略组合。
|
||||
|
||||
故障恢复是指,当服务调用出错了以后,将该次调用失败的信息存入一个消息队列中,然后由系统自动开始异步重试调用。
|
||||
|
||||
从这个定义中也可以看出,故障恢复策略,一方面是尽力促使失败的调用最终能够被正常执行,另一方面也可以为服务注册中心和负载均衡器及时提供服务恢复的通知信息。很显然,故障恢复也要求服务必须具备幂等性,由于它的重试是后台异步进行,即使最后调用成功了,原来的请求也早已经响应完毕。所以,故障恢复策略一般用于对实时性要求不高的主路逻辑,也适合处理那些不需要返回值的旁路逻辑。
|
||||
|
||||
为了避免在内存中的异步调用任务堆积,故障恢复与故障转移一样,也应该有最大重试次数的限制。
|
||||
|
||||
故障转移、快速失败、安全失败、沉默失败和故障恢复这5种容错策略的英文,都是以“Fail”开头的,它们有一个共同点,都是针对调用失败时如何进行弥补的。接下来,咱们要学习的并行调用和广播调用这两种策略,则是在调用之前就开始考虑如何获得最大的成功概率。
|
||||
|
||||
第六种容错策略,是并行调用(Forking)。
|
||||
|
||||
并行调用策略,是指一开始就同时向多个服务副本发起调用,只要有其中任何一个返回成功,那调用便宣告成功。这种策略是在一些关键场景中,使用更高的执行成本换取执行时间和成功概率的策略。
|
||||
|
||||
这种处理思路,其实对应的就是,咱们在日常生活中,对一些重要环节采取的“双重保险”或者“多重保险”的处理思路。
|
||||
|
||||
第七种容错策略,是广播调用(Broadcast)。
|
||||
|
||||
广播调用与并行调用是相对应的,都是同时发起多个调用,但并行调用是任何一个调用结果返回成功便宣告成功,而广播调用则是要求所有的请求全部都成功,才算是成功。
|
||||
|
||||
也就是说,对于广播调用来说,任何一个服务提供者出现异常都算调用失败。因此,广播调用通常被用于实现“刷新分布式缓存”这类的操作。
|
||||
|
||||
小结
|
||||
|
||||
今天这一讲,我们学习了故障转移、快速失败、安全失败、沉默失败、故障恢复、并行调用和广播调用,一共7种容错策略。
|
||||
|
||||
其实,容错策略并不是计算机领域独有的,在交通、能源、航天等非常多的领域都有容错性设计,也会使用到上面这些策略,并在自己的行业领域中进行解读与延伸。
|
||||
|
||||
这里,我把今天讲到的7种容错策略进行了一次对比梳理,你可以在下面的表格中看到它们的优缺点和应用场景。
|
||||
|
||||
|
||||
|
||||
一课一思
|
||||
|
||||
你在实际工作中使用过哪种容错策略,是要用它来解决什么问题的呢?你能试着说说它是如何实现的吗?
|
||||
|
||||
欢迎在留言区分享你的答案。如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
||||
179
专栏/周志明的架构课/37_要实现某种容错策略,我们该怎么做?.md
Normal file
179
专栏/周志明的架构课/37_要实现某种容错策略,我们该怎么做?.md
Normal file
@@ -0,0 +1,179 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
37 _ 要实现某种容错策略,我们该怎么做?
|
||||
你好,我是周志明。今天,我们继续学习服务容错的实现方法。
|
||||
|
||||
在上一讲,我们首先界定了容错策略和容错设计模式这两个概念:容错策略,指的是“面对故障,我们该做些什么”;而容错设计模式,指的是“要实现某种容错策略,我们该如何去做”。
|
||||
|
||||
然后,我们讲了7种常见的容错策略,包括故障转移、快速失败、安全失败、沉默失败、故障恢复、并行调用和广播调用。
|
||||
|
||||
那么为了实现各种各样的容错策略,开发人员总结出了一些被实践证明有效的服务容错设计模式。这些设计模式,包括了这一讲我们要学习的,微服务中常见的断路器模式、舱壁隔离模式和超时重试模式等,以及我们下一讲要学习的流量控制模式,比如滑动时间窗模式、漏桶模式、令牌桶模式,等等。
|
||||
|
||||
我们先来学习断路器模式。
|
||||
|
||||
断路器模式
|
||||
|
||||
断路器模式是微服务架构中最基础的容错设计模式,以至于像Hystrix这种服务治理工具,我们往往会忽略了它的服务隔离、请求合并、请求缓存等其他服务治理职能,直接把它叫做微服务断路器或者熔断器。这下你明白断路器模式为啥是“最基础”的容错设计模式了吧,也明白为啥我们要首先学习这种模式了吧。
|
||||
|
||||
断路器模式最开始是由迈克尔 · 尼加德(Michael Nygard)在“Release It!”这本书中提出来的,后来又因为马丁 · 福勒(Martin Fowler)的文章“Circuit Breaker”而广为人知。
|
||||
|
||||
其实,断路器的思路很简单,就是通过代理(断路器对象)来一对一(一个远程服务对应一个断路器对象)地接管服务调用者的远程请求。那怎么实现的呢?
|
||||
|
||||
断路器会持续监控并统计服务返回的成功、失败、超时、拒绝等各种结果,当出现故障(失败、超时、拒绝)的次数达到断路器的阈值时,它的状态就自动变为“OPEN”。之后这个断路器代理的远程访问都将直接返回调用失败,而不会发出真正的远程服务请求。
|
||||
|
||||
通过断路器对远程服务进行熔断,就可以避免因为持续的失败或拒绝而消耗资源,因为持续的超时而堆积请求,最终可以避免雪崩效应的出现。由此可见,断路器本质上是快速失败策略的一种实现方式。
|
||||
|
||||
断路器模式的工作过程,可以用下面的序列图来表示:
|
||||
|
||||
|
||||
|
||||
从调用序列来看,断路器就是一种有限状态机,断路器模式就是根据自身的状态变化,自动调整代理请求策略的过程。
|
||||
|
||||
断路器一般可以设置为CLOSED、OPEN和HALF OPEN三种状态。
|
||||
|
||||
|
||||
CLOSED:表示断路器关闭,此时的远程请求会真正发送给服务提供者。断路器刚刚建立时默认处于这种状态,此后将持续监视远程请求的数量和执行结果,决定是否要进入OPEN状态。
|
||||
OPEN:表示断路器开启,此时不会进行远程请求,直接给服务调用者返回调用失败的信息,以实现快速失败策略。
|
||||
HALF OPEN:是一种中间状态。断路器必须带有自动的故障恢复能力,当进入OPEN状态一段时间以后,将“自动”(一般是由下一次请求而不是计时器触发的,所以这里的自动是带引号的)切换到HALF OPEN状态。在中间状态下,会放行一次远程调用,然后根据这次调用的结果成功与否,转换为CLOSED或者OPEN状态,来实现断路器的弹性恢复。
|
||||
|
||||
|
||||
CLOSED、OPEN和HALF OPEN这三种状态的转换逻辑和条件,如下图所示:
|
||||
|
||||
|
||||
|
||||
断路器的状态转换逻辑(图片引自“Application Resiliency Using Netflix Hystrix”)
|
||||
|
||||
OPEN和CLOSED状态的含义是十分清晰的,和我们日常生活中电路的断路器并没有什么差别,值得讨论的是这两种状态的转换条件是什么?
|
||||
|
||||
最简单直接的方案是,只要遇到一次调用失败,那就默认以后所有的调用都会接着失败,断路器直接进入OPEN状态。但这样做的效果非常差,虽然避免了故障扩散和请求堆积,却使得在外部看来系统表现的极其不稳定。
|
||||
|
||||
那怎么解决这个问题呢?一个可行的办法是,当一次调用失败后,如果还同时满足下面两个条件,断路器的状态就变为OPEN:
|
||||
|
||||
|
||||
一段时间(比如10秒以内)内,请求数量达到一定阈值(比如20个请求)。这个条件的意思是,如果请求本身就很少,那就用不着断路器介入。
|
||||
一段时间(比如10秒以内)内,请求的故障率(发生失败、超时、拒绝的统计比例)到达一定阈值(比如50%)。这个条件的意思是,如果请求本身都能正确返回,也用不着断路器介入。
|
||||
|
||||
|
||||
括号中举例的数值,10秒、20个请求、50%,是Netflix Hystrix的默认值。其他服务治理的工具,比如Resilience4j、Envoy等也有类似的设置,你可以在它们的帮助文档中找到对应的默认值。
|
||||
|
||||
借着断路器的上下文,我再顺带讲一下服务治理中两个常见的易混淆概念:服务熔断和服务降级之间的联系与差别。
|
||||
|
||||
断路器做的事情是自动进行服务熔断,属于一种快速失败的容错策略的实现方法。在快速失败策略明确反馈了故障信息给上游服务以后,上游服务必须能够主动处理调用失败的后果,而不是坐视故障扩散。这里的“处理”,指的就是一种典型的服务降级逻辑,降级逻辑可以是,但不应该只是,把异常信息抛到用户界面去,而应该尽力想办法通过其他路径解决问题,比如把原本要处理的业务记录下来,留待以后重新处理是最低限度的通用降级逻辑。
|
||||
|
||||
举个例子:你女朋友有事儿想召唤你,打你手机没人接,响了几声气冲冲地挂断后(快速失败),又打了你另外三个不同朋友的手机号(故障转移),都还是没能找到你(重试超过阈值)。这时候她生气地在微信上给你留言“三分钟不回电话就分手”,以此来与你取得联系。在这个不是太吉利的故事里,女朋友给你留言这个行为便是服务降级逻辑。
|
||||
|
||||
服务降级不一定是在出现错误后才被动执行的,我们在很多场景中谈论的降级更可能是指,需要主动迫使服务进入降级逻辑的情况。比如,出于应对可预见的峰值流量,或者是系统检修等原因,要关闭系统部分功能或关闭部分旁路服务,这时候就有可能会主动迫使这些服务降级。当然,此时服务降级就不一定是出于服务容错的目的了,更可能是属于下一讲我们要学习的流量控制的范畴。
|
||||
|
||||
舱壁隔离模式
|
||||
|
||||
了解了服务熔断和服务降级以后,我们再来看看微服务治理中常听见的另一概念:服务隔离。
|
||||
|
||||
舱壁隔离模式,是常用的实现服务隔离的设计模式。“舱壁”这个词来自造船业,它原本的意思是设计舰船时,要在每个区域设计独立的水密舱室,一旦某个舱室进水,也只会影响到这个舱室中的货物,而不至于让整艘舰艇沉没。
|
||||
|
||||
那对应到分布式系统中,服务隔离,就是避免某一个远程服务的局部失败影响到全局,而设置的一种止损方案。这种思想,对应的就是容错策略中的失败静默策略。那为什么会有一个服务失败会影响全局的事情发生呢?
|
||||
|
||||
咱们在刚刚学习断路器模式时,把调用外部服务的故障分为了失败、拒绝和超时三大类。
|
||||
|
||||
其中,“超时”引起的故障,尤其容易给调用者带来全局性的风险。这是因为,目前主流的网络访问大多是基于TPR并发模型(Thread per Request)来实现的,只要请求一直不结束(无论是以成功结束还是以失败结束),就要一直占用着某个线程不能释放。而线程是典型的整个系统的全局性资源,尤其是在Java这类将线程映射为操作系统内核线程来实现的语言环境中。
|
||||
|
||||
我们来看一个更具体的场景。
|
||||
|
||||
当分布式系统依赖的某个服务,比如“服务I”发生了超时,那在高流量的访问下,或者更具体点,假设平均1秒钟内会调用这个服务50次,就意味着该服务如果长时间不结束的话,每秒会有50条用户线程被阻塞。
|
||||
|
||||
如果这样的访问量一直持续,按照Tomcat默认的HTTP超时时间20秒来计算的话,20秒内将会阻塞掉1000条用户线程。此后才陆续会有用户线程因超时被释放出来,回归Tomcat的全局线程池中。
|
||||
|
||||
通常情况下,Java应用的线程池最大只会设置为200~400,这就意味着从外部来看,此时系统的所有服务已经全面瘫痪,而不仅仅是只有涉及到“服务I”的功能不可用。因为Tomcat已经没有任何空余的线程来为其他请求提供服务了。
|
||||
|
||||
|
||||
|
||||
由于某个外部服务导致的阻塞(图片来自Hystrix使用文档)
|
||||
|
||||
要解决这类问题,本质上就是要控制单个服务的最大连接数。一种可行的解决办法是为每个服务单独设立线程池,这些线程池默认不预置活动线程,只用来控制单个服务的最大连接数。
|
||||
|
||||
比如,对出问题的“服务I”设置了一个最大线程数为5的线程池,这时候它的超时故障就只会最多阻塞5条用户线程,而不至于影响全局。此时,其他不依赖“服务I”的用户线程,依然能够正常对外提供服务,如下图所示。
|
||||
|
||||
|
||||
|
||||
通过线程池将阻塞限制在一定范围内(图片来自Hystrix使用文档)
|
||||
|
||||
使用局部的线程池来控制服务的最大连接数,有很多好处,比如当服务出问题时能够隔离影响,当服务恢复后,还可以通过清理掉局部线程池,瞬间恢复该服务的调用。而如果是Tomcat的全局线程池被占满,再恢复就会非常麻烦。
|
||||
|
||||
但是,局部线程池有一个显著的弱点,那就是它额外增加了CPU的开销,每个独立的线程池都要进行排队、调度和下文切换工作。根据Netflix官方给出的数据,一旦启用Hystrix线程池来进行服务隔离,每次服务调用大概会增加3~10毫秒的延时。如果调用链中有20次远程服务调用的话,那每次请求就要多付出60毫秒至200毫秒的代价,来换取服务隔离的安全保障。
|
||||
|
||||
为应对这种情况,还有一种更轻量的控制服务最大连接数的办法,那就是信号量机制(Semaphore)。
|
||||
|
||||
如果不考虑清理线程池、客户端主动中断线程这些额外的功能,仅仅是为了控制单个服务并发调用的最大次数的话,我们可以只为每个远程服务维护一个线程安全的计数器,并不需要建立局部线程池。
|
||||
|
||||
具体做法是,当服务开始调用时计数器加1,服务返回结果后计数器减1;一旦计数器的值超过设置的阈值就立即开始限流,在回落到阈值范围之前都不再允许请求了。因为不需要承担线程的排队、调度和切换工作,所以单纯维护一个作为计数器的信号量的性能损耗,相对于局部线程池来说,几乎可以忽略不计。
|
||||
|
||||
以上介绍的是从微观的、服务调用的角度应用舱壁隔离设计模式,实际上舱壁隔离模式还可以在更高层、更宏观的场景中使用,不按调用线程,而是按功能、按子系统、按用户类型等条件来隔离资源都是可以的。比如,根据用户等级、用户是否是VIP、用户来访的地域等各种因素,将请求分流到独立的服务实例去,这样即使某一个实例完全崩溃了,也只是影响到其中某一部分的用户,把波及范围尽可能控制住。
|
||||
|
||||
一般来说,我们会选择将服务层面的隔离实现在服务调用端或者边车代理上,将系统层面的隔离实现在DNS或者网关处。
|
||||
|
||||
到这里,我们回顾下已经学习了哪几种安全策略的实现方式:
|
||||
|
||||
|
||||
使用断路器模式实现快速失败策略;
|
||||
使用舱壁隔离模式实现静默失败策略;
|
||||
在断路器中的案例中提到的主动对非关键的旁路服务进行降级,也可以算作是安全失败策略的一种体现。
|
||||
|
||||
|
||||
再对照着我们上一讲学习的7种常见安全策略来说,我们还剩下故障转移和故障恢复两种策略的实现没有学习。接下来,我就以重试模式和你介绍这两种容错策略的主流实现方案。
|
||||
|
||||
重试模式
|
||||
|
||||
故障转移和故障恢复这两种策略都需要对服务进行重复调用,差别就在于这些重复调用有可能是同步的,也可能是后台异步进行;有可能会重复调用同一个服务,也可能会调用服务的其他副本。但是,无论具体是通过怎样的方式调用、调用的服务实例是否相同,都可以归结为重试设计模式的应用范畴。
|
||||
|
||||
重试模式适合解决系统中的瞬时故障,简单地说就是有可能自己恢复(Resilient,称为自愈,也叫做回弹性)的临时性失灵,比如网络抖动、服务的临时过载(比如返回了503 Bad Gateway错误)这些都属于瞬时故障。
|
||||
|
||||
重试模式实现起来并不难,即使完全不考虑框架的支持,程序员自己编写十几行代码也能完成。也正因为实现起来简单,使用重试模式面临的最大风险就是滥用。那怎么避免滥用呢?在实践中,我们判断是否应该且是否能够对一个服务进行重试时,要看是否同时满足下面4个条件。
|
||||
|
||||
第一,仅在主路逻辑的关键服务上进行同步的重试。也就是说,如果不是关键的服务,一般不要把重试作为首选的容错方案,尤其不应该进行同步重试。
|
||||
|
||||
第二,仅对由瞬时故障导致的失败进行重试。尽管要做到精确判定一个故障是否属于可自愈的瞬时故障并不容易,但我们至少可以从HTTP的状态码上获得一些初步的结论。比如,当发出的请求收到了401 Unauthorized响应时,说明服务本身是可用的,只是你没有权限调用,这时候再去重试就没有什么意义。
|
||||
|
||||
功能完善的服务治理工具会提供具体的重试策略配置(如Envoy的Retry Policy),可以根据包括HTTP响应码在内的各种具体条件来设置不同的重试参数。
|
||||
|
||||
第三,仅对具备幂等性的服务进行重试。你可能会说了,如果服务调用者和提供者不属于同一个团队,那服务是否幂等,其实也是一个难以精确判断的问题。这个问题确实存在,但我们还是可以找到一些总体上通用的原则来帮助我们做判断。
|
||||
|
||||
比如,RESTful服务中的POST请求是非幂等的;GET、HEAD、OPTIONS、TRACE请求应该被设计成幂等的,因为它们不会改变资源状态;PUT请求一般也是幂等的,因为n个PUT请求会覆盖相同的资源n-1次;DELETE请求也可看作是幂等的,同一个资源首次删除会得到200 OK响应,此后应该得到204 No Content响应。
|
||||
|
||||
这些都是HTTP协议中定义的通用的指导原则,虽然对于具体服务如何实现并无强制约束力,但建设系统时遵循业界惯例本身就是一种良好的习惯。
|
||||
|
||||
第四,重试必须有明确的终止条件,常用的终止条件有超时终止和次数终止两种。
|
||||
|
||||
|
||||
超时终止。其实,超时机制并不限于重试策略,所有涉及远程调用的服务都应该有超时机制来避免无限期的等待。这里我只是强调重试模式更应该配合超时机制来使用,否则重试对系统很可能是有害的。在介绍故障转移策略时,我已经举过类似的例子,你可以再去看一下。
|
||||
次数终止。重试必须要有一定限度,不能无限制地做下去,通常是重试2~5次。因为重试不仅会给调用者带来负担,对服务提供者来说也同样是负担,所以我们要避免把重试次数设得太大。此外,如果服务提供者返回的响应头中带有Retry-After的话,尽管它没有强制约束力,我们也应该充分尊重服务端的要求,做个“有礼貌”的调用者。
|
||||
|
||||
|
||||
另外,由于重试模式可以在网络链路的多个环节中去实现,比如在客户端发起调用时自动重试、网关中自动重试、负载均衡器中自动重试,等等,而且现在的微服务框架都足够便捷,只需设置一两个开关参数,就可以开启对某个服务、甚至是全部服务的重试机制了。
|
||||
|
||||
所以,如果你没有太多经验的话,可能根本就意识不到其中会带来多大的负担。
|
||||
|
||||
这里我给你举个具体的例子:一套基于Netflix OSS建设的微服务系统,如果同时在Zuul、Feign和Ribbon上都打开了重试功能,且不考虑重试被超时终止的话,那总重试次数就相当于它们的重试次数的乘积。假设按它们都重试4次,且Ribbon可以转移4个服务副本来计算的话,理论上最多会产生高达4×4×4×4=256次调用请求。
|
||||
|
||||
小结
|
||||
|
||||
熔断、隔离、重试、降级、超时等概念,都是建立具有韧性的微服务系统的必须的保障措施。那么就目前来说,这些措施的正确运作,主要还是依靠开发人员对服务逻辑的了解,以及根据运维人员的经验去静态地调整、配置参数和阈值。
|
||||
|
||||
但是,面对能够自动扩缩(Auto Scale)的大型分布式系统,静态的配置越来越难以起到良好的效果。
|
||||
|
||||
所以这就要求,系统不仅要有能力可以自动地根据服务负载来调整服务器的数量规模,同时还要有能力根据服务调用的统计结果,或者启发式搜索的结果来自动变更容错策略和参数。当然,目前这方面的研究,还处于各大厂商在内部分头摸索的初级阶段,不过这正是服务治理未来的重要发展方向之一。
|
||||
|
||||
这节课我给你介绍的容错策略和容错设计模式,最终目的都是为了避免服务集群中,某个节点的故障导致整个系统发生雪崩效应。
|
||||
|
||||
但我们要知道,仅仅做到容错,只让故障不扩散是远远不够的,我们还希望系统或者至少系统的核心功能能够表现出最佳的响应的能力,不受或少受硬件资源、网络带宽和系统中一两个缓慢服务的拖累。在下一节课,我们还将会面向如何解决集群中的短板效应,去讨论服务质量、流量管控等话题。
|
||||
|
||||
一课一思
|
||||
|
||||
服务容错一般由底层的服务治理框架来负责实现,你使用过哪些服务治理框架呢?能对比一下这些框架的优缺点吗?
|
||||
|
||||
欢迎在留言区分享你的答案。如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
247
专栏/周志明的架构课/38_限流的目标与模式.md
Normal file
247
专栏/周志明的架构课/38_限流的目标与模式.md
Normal file
@@ -0,0 +1,247 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
38 _ 限流的目标与模式
|
||||
你好,我是周志明。
|
||||
|
||||
在前面两讲中,我们了解了分布式服务中的容错机制,这是分布式服务调用中必须考虑的因素。今天这节课,我们接着来学习分布式服务中另一个常见的机制:限流。
|
||||
|
||||
任何一个系统的运算、存储、网络资源都不是无限的,当系统资源不足以支撑外部超过预期的突发流量时,就应该要有取舍,建立面对超额流量自我保护的机制,而这个机制就是微服务中常说的“限流”。
|
||||
|
||||
限流的目标
|
||||
|
||||
在介绍限流具体的实现细节之前,我们先来做一道小学三年级难度的四则运算场景应用题:
|
||||
|
||||
|
||||
已知条件:-
|
||||
|
||||
系统中一个业务操作需要调用10个服务协作来完成,该业务操作的总超时时间是10秒,每个服务的处理时间平均是0.5秒,集群中每个服务均部署了20个实例副本。-
|
||||
|
||||
求解以下问题:-
|
||||
(1)单个用户访问,完成一次业务操作,需要耗费系统多少处理器时间?-
|
||||
答:0.5 × 10 = 5 Sec CPU Time-
|
||||
(2)集群中每个服务每秒最大能处理多少个请求?-
|
||||
答:(1 ÷ 0.5) × 20 = 40 QPS-
|
||||
(3)假设不考虑顺序且请求分发是均衡的,在保证不超时的前提下,整个集群能持续承受最多每秒多少笔业务操作?-
|
||||
答:40 × 10 ÷ 5 = 80 TPS-
|
||||
(4)如果集群在一段时间内持续收到100 TPS的业务请求,会出现什么情况?-
|
||||
答:这就超纲了小学水平,得看你们家架构师的本事了。
|
||||
|
||||
|
||||
前三个问题都很基础我就不说了,对于最后一个问题,如果仍然按照小学生的解题思路,最大处理能力为80 TPS的系统遇到100 TPS的请求,应该能完成其中的80 TPS,也就是只有20 TPS的请求失败或被拒绝才对。然而这其实是最理想的情况,也是我们追求的目标。
|
||||
|
||||
事实上,如果不做任何处理的话,更可能出现的结果是,这100个请求中的每一个都开始了处理,但是大部分请求都只完成了10次服务调用中的8次或者9次,然后就是超时没有然后了。
|
||||
|
||||
其实在很多的微服务应用中,多数服务调用都是白白浪费掉的,没有几个请求能够走完整笔业务操作。比如早期的12306系统就明显存在这样的问题,全国人民都上去抢票的结果,就是全国人民谁都买不上票。
|
||||
|
||||
那么,为了避免这种状况出现,一个健壮的系统就需要做到恰当的流量控制,更具体地说,需要妥善解决以下三个问题:
|
||||
|
||||
|
||||
依据什么限流?
|
||||
|
||||
|
||||
要不要控制流量、要控制哪些流量、控制力度要有多大,等等,这些操作都没法在系统设计阶段静态地给出确定的结论,必须根据系统此前一段时间的运行状况,甚至未来一段时间的预测情况来动态决定。
|
||||
|
||||
|
||||
具体如何限流?
|
||||
|
||||
|
||||
要想解决系统具体是如何做到允许一部分请求能够通行,而另外一部分流量实行受控制的失败降级的问题,就必须要了解和掌握常用的服务限流算法和设计模式。
|
||||
|
||||
|
||||
超额流量如何处理?
|
||||
|
||||
|
||||
超额流量可以有不同的处理策略,也许会直接返回失败(如429 Too Many Requests),或者被迫使它们进入降级逻辑,这种策略被称为否决式限流;也可能是让请求排队等待,暂时阻塞一段时间后继续处理,这种则被称为阻塞式限流。
|
||||
|
||||
流量统计指标
|
||||
|
||||
那么,要做好流量控制,首先就要弄清楚到底哪些指标能反映系统的流量压力大小。
|
||||
|
||||
相比较而言,容错的统计指标是明确的,容错的触发条件基本上只取决于请求的故障率,发生失败、拒绝与超时都算作故障。但限流的统计指标就不那么明确了,所以这里我们要先搞明白一个问题:限流中的“流”到底指什么呢?
|
||||
|
||||
要解答这个问题,我们得先梳理清楚经常用于衡量服务流量压力,但又比较容易混淆的三个指标的定义:
|
||||
|
||||
|
||||
每秒事务数(Transactions per Second,TPS)
|
||||
|
||||
|
||||
TPS是衡量信息系统吞吐量的最终标准。“事务”可以理解为一个逻辑上具备原子性的业务操作。比如你在Fenix’s Bookstore买了一本书要进行支付,这个“支付”就是一笔业务操作,无论支付成功还是不成功,这个操作在逻辑上就是原子的,即逻辑上不可能让你买本书可以成功支付前面200页,但失败了后面300页。
|
||||
|
||||
|
||||
每秒请求数(Hits per Second,HPS)
|
||||
|
||||
|
||||
HPS是指每秒从客户端发向服务端的请求数(这里你要把Hits理解为Requests而不是Clicks,国内某些翻译把它理解为“每秒点击数”多少有点望文生义的嫌疑)。如果只要一个请求就能完成一笔业务,那HPS与TPS是等价的,但在一些场景里(尤其常见于网页中),一笔业务可能需要多次请求才能完成。
|
||||
|
||||
比如你在Fenix’s Bookstore买了一本书要进行支付,尽管在操作逻辑上它是原子的,但在技术实现上,除非你是直接在银行开的商城中购物能够直接扣款,否则这个操作就很难在一次请求里完成,总要经过显示支付二维码、扫码付款、校验支付是否成功等过程,中间不可避免地会发生多次请求。
|
||||
|
||||
|
||||
每秒查询数(Queries per Second,QPS)
|
||||
|
||||
|
||||
QPS是指一台服务器能够响应的查询次数。如果只有一台服务器来应答请求,那QPS和HPS是等价的,但在分布式系统中,一个请求的响应,往往要由后台多个服务节点共同协作来完成。
|
||||
|
||||
比如你在Fenix’s Bookstore买了一本书要进行支付,在扫描支付二维码时,尽管客户端只发送了一个请求,但在这背后,服务端很可能需要向仓储服务确认库存信息避免超卖、向支付服务发送指令划转货款、向用户服务修改用户的购物积分,等等,这里面每次的内部访问,都要消耗掉一次或多次查询数。
|
||||
|
||||
总体来说,以上这三点都是基于调用计数的指标,而在整体目标上,我们当然最希望能够基于TPS来限流,因为信息系统最终是为人类用户提供服务的,用户并不关心业务到底是由多少个请求、多少个后台查询共同协作来实现的。
|
||||
|
||||
但是,系统的业务五花八门,不同的业务操作对系统的压力往往差异巨大,不具备可比性;而更关键的是,流量控制是针对用户实际操作场景来限流的,这不同于压力测试场景中无间隙(最多有些集合点)的全自动化操作,真实业务操作的耗时会无可避免地受限于用户交互带来的不确定性。比如前面例子中的“扫描支付二维码”这个步骤,如果用户在掏出手机扫描二维码前,先顺便回了两条短信,那整个付款操作就要持续更长时间。
|
||||
|
||||
那么此时,如果按照业务开始时计数器加1,业务结束时计数器减1,通过限制最大TPS来限流的话,就不能准确地反映出系统所承受的压力了。所以直接针对TPS来限流,实际上是很难操作的。
|
||||
|
||||
目前来说,主流系统大多倾向于使用HPS作为首选的限流指标,因为它相对容易观察统计,而且能够在一定程度上反映系统当前以及接下来一段时间的压力。
|
||||
|
||||
但你要知道的是,限流指标并不存在任何必须遵循的权威法则,根据系统的实际需要,哪怕完全不选择基于调用计数的指标都是有可能的。我举个简单的例子,下载、视频、直播等I/O密集型系统,往往会把每次请求和响应报文的大小作为限流指标,而不是调用次数。
|
||||
|
||||
比如说,只允许单位时间通过100MB的流量;再比如网络游戏等基于长连接的应用,可能会把登录用户数作为限流指标,热门的网游往往超过一定用户数就会让你在登录前排队等候。
|
||||
|
||||
限流设计模式
|
||||
|
||||
与容错模式类似,对于具体如何进行限流,业界内也有一些常见、常用、被实践证明有效的设计模式可以参考使用,包括流量计数器、滑动时间窗、漏桶和令牌桶这四种,下面我们一起来看看。
|
||||
|
||||
流量计数器模式
|
||||
|
||||
我们最容易想到的一种做限流的方法,就是设置一个计算器,根据当前时刻的流量计数结果是否超过阈值来决定是否限流。比如在前面的小学场景应用题中,我们计算得出了该系统能承受的最大持续流量是80 TPS,那我们就可以控制任何一秒内,发现超过80次的业务请求就直接拒绝掉超额部分。
|
||||
|
||||
这种做法很直观,而且有些简单的限流就是这么实现的,但它并不严谨,比如说以下两个结论,就很可能出乎你对限流算法的意料之外:
|
||||
|
||||
|
||||
即使每一秒的统计流量都没有超过80 TPS,也不能说明系统没有遇到过大于80 TPS的流量压力。
|
||||
|
||||
|
||||
你可以想像这样一个场景:如果系统连续两秒都收到了60 TPS的访问请求,但这两个60 TPS请求分别是前1秒里的后0.5秒,以及后1秒中的前0.5秒所发生的。这样虽然每个周期的流量都不超过80 TPS请求的阈值,但是系统确实是曾经在1秒内发生了超过阈值的120 TPS请求。
|
||||
|
||||
|
||||
即使连续若干秒的统计流量都超过了80 TPS,也不能说明流量压力就一定超过了系统的承受能力。
|
||||
|
||||
|
||||
你同样可以想像这样一个场景:如果10秒的时间片段中,前3秒的TPS平均值到了100,而后7秒的平均值是30左右,此时系统是否能够处理完这些请求而不产生超时失败?
|
||||
|
||||
答案是可以的,因为条件中给出的超时时间是10秒,而最慢的请求也能在8秒左右处理完毕。如果只基于固定时间周期来控制请求阈值为80 TPS,反而会误杀一部分请求,造成部分请求出现原本不必要的失败。
|
||||
|
||||
由此可见,流量计数器模式缺陷的根源在于,它只是针对时间点进行离散的统计。因此为了弥补该缺陷,一种名为“滑动时间窗”的限流模式就被设计了出来,它可以实现平滑的基于时间片段的统计。
|
||||
|
||||
滑动时间窗模式
|
||||
|
||||
滑动窗口算法(Sliding Window Algorithm)在计算机科学的很多领域中都有成功的应用,比如编译原理中的窥孔优化(Peephole Optimization)、TCP协议的阻塞控制(Congestion Control)等都使用到了滑动窗口算法。而对分布式系统来说,无论是服务容错中对服务响应结果的统计,还是流量控制中对服务请求数量的统计,也都经常要用到滑动窗口算法。
|
||||
|
||||
关于这个算法的运作过程,现在你可以充分发挥下想象力,在脑海中构造这样一个场景:在不断向前流淌的时间轴上,漂浮着一个固定大小的窗口,窗口与时间一起平滑地向前滚动。在任何时刻,我们静态地通过窗口内观察到的信息,都等价于一段长度与窗口大小相等、动态流动中的时间片段的信息。由于窗口观察的目标都是时间轴,所以它就被称为形象地称为“滑动时间窗模式”。
|
||||
|
||||
我再举个更具体的例子,假如我们准备观察的时间片段为10秒,并以1秒作为统计精度的话,那可以设定一个长度为10的数组(实际设计中通常是以双头队列来实现的,这里简化一下)和一个每秒触发1次的定时器。
|
||||
|
||||
现在,假设我们准备通过统计结果进行限流和容错,并定下限流阈值是最近10秒内收到的外部请求不要超过500个,服务熔断的阈值是最近10秒内的故障率不超过50%,那么在每个数组元素中(下图中称为Buckets),就应该存储请求的总数(实际是通过明细相加得到的)和其中成功、失败、超时、拒绝的明细数,具体如下图所示:
|
||||
|
||||
|
||||
|
||||
滑动窗口模式示意(图片来自Hystrix使用文档)
|
||||
|
||||
|
||||
补充:这里虽然引用了Hystrix文档的图片,但Hystrix实际上是基于RxJava实现的,RxJava的响应式编程思路与我下面描述的步骤差异比较大。我的本意并不是去讨论某一款流量治理工具的具体实现细节,因此这里你可以将以下描述的步骤作为原理来理解。
|
||||
|
||||
|
||||
这样,当频率固定为每秒1次的定时器被唤醒时,它应该完成以下几项工作,这也就是滑动时间窗的工作过程:
|
||||
|
||||
|
||||
将数组最后一位的元素丢弃掉,并把所有元素都后移一位,然后在数组第一个插入一个新的空元素。这个步骤即为“滑动窗口”。
|
||||
将计数器中所有统计信息写入到第一位的空元素中。
|
||||
对数组中所有元素进行统计,并复位清空计数器数据供下一个统计周期使用。
|
||||
|
||||
|
||||
所以简而言之,滑动时间窗口模式的限流完全解决了流量计数器的缺陷,它可以保证在任意时间片段内,只需经过简单的调用计数比较,就能控制住请求次数一定不会超过限流的阈值,在单机限流或者分布式服务单点网关中的限流中很常用。
|
||||
|
||||
不过,这种限流模式也有一些缺点,它通常只适用于否决式限流,对于超过阈值的流量就必须强制失败或降级,很难进行阻塞等待处理,也就很难在细粒度上对流量曲线进行整形,起不到削峰填谷的作用。而接下来我要介绍的这两种限流模式,就适用于阻塞式限流的处理策略。
|
||||
|
||||
我们先来看看漏桶模式。
|
||||
|
||||
漏桶模式
|
||||
|
||||
在计算机网络中,专门有一个术语“流量整形”(Traffic Shaping),用来描述如何限制网络设备的流量突变,使得网络报文以比较均匀的速度向外发送。流量整形通常都需要用到缓冲区来实现,当报文的发送速度过快时,首先在缓冲区中暂存,然后在控制算法的调节下,均匀地发送这些被缓冲的报文。
|
||||
|
||||
可以发现这里我提到了控制算法,常用的控制算法有漏桶算法(Leaky Bucket Algorithm)和令牌桶算法(Token Bucket Algorithm)两种,这两种算法的思路截然相反,但达到的效果又是相似的。
|
||||
|
||||
所谓漏桶,可以理解为就是你在小学做应用题时,一定遇到过的那个奇怪的水池:“一个水池,每秒以X升速度注水,同时又以Y升速度出水,问水池啥时候装满”。
|
||||
|
||||
那么针对限流模式的话,你可以把“请求”想像成是“水”,水来了都先放进池子里,水池同时又以额定的速度出水,让请求进入系统中。这样,如果一段时间内注水过快的话,水池还能充当缓冲区,让出水口的速度不至于过快。
|
||||
|
||||
不过,由于请求总是有超时时间的,所以缓冲区的大小也必须是有限度的,当注水速度持续超过出水速度一段时间以后,水池终究会被灌满。此时,从网络的流量整形的角度看,就体现为部分数据包被丢弃;而从信息系统的角度看,就体现为有部分请求会遭遇失败和降级。
|
||||
|
||||
另外漏桶在代码实现上也非常简单,它其实就是一个以请求对象作为元素的先入先出队列(FIFO Queue),队列长度就相当于漏桶的大小,当队列已满时就拒绝新的请求进入。漏桶实现起来很容易,比较困难的地方只在于如何确定漏桶的两个参数:桶的大小和水的流出速率。
|
||||
|
||||
首先是桶的大小。如果桶设置得太大,那服务依然可能遭遇流量过大的冲击,不能完全发挥限流的作用;如果设置得太小,那很可能就会误杀掉一部分正常的请求,这种情况与流量计数器模式中举过的例子是一样的。
|
||||
|
||||
而流出速率在漏桶算法中一般是个固定值,这对于开篇我提到的那个场景应用题中,固定拓扑结构的服务是很合适的;但同时你也应该明白,那是经过最大限度简化的场景,现实世界里系统的处理速度,往往会受到其内部拓扑结构变化和动态伸缩的影响。所以,能够支持变动请求处理速率的令牌桶算法,可能往往会是更受我们青睐的选择。
|
||||
|
||||
令牌桶模式
|
||||
|
||||
如果说漏桶是小学应用题中的奇怪水池,那令牌桶就是你去银行办事时摆在门口的那台排队取号机。它与漏桶一样都是基于缓冲区的限流算法,只是方向刚好相反:漏桶是从水池里往系统出水,令牌桶则是系统往排队机中放入令牌。
|
||||
|
||||
令牌桶模式具体是如何实现的呢?我来举个例子。
|
||||
|
||||
假设我们要限制系统在X秒内的最大请求次数不超过Y,那我们可以每间隔X/Y时间,就往桶中放一个令牌,当有请求进来时,首先要从桶中取得一个准入的令牌,然后才能进入系统处理。任何时候,一旦请求进入桶中发现没有令牌可取了,就应该马上失败或进入服务降级逻辑。
|
||||
|
||||
与漏桶类似,令牌桶同样有最大容量,这意味着当系统比较空闲的时候,桶中的令牌累积到一定程度就不再无限增加,而预存在桶中的令牌便是请求最大缓冲的余量。
|
||||
|
||||
这里可能说得有些抽象,你可以转化为以下步骤来指导程序编码:
|
||||
|
||||
|
||||
让系统以一个由限流目标决定的速率向桶中注入令牌,比如要控制系统的访问不超过100次,速率即设定为1/100=10毫秒。
|
||||
桶中最多可以存放N个令牌,N的具体数量是由超时时间和服务处理能力共同决定的。如果桶已满,第N+1个进入的令牌就会被丢弃掉。
|
||||
请求到时会先从桶中取走1个令牌,如果桶已空就进入降级逻辑。
|
||||
|
||||
|
||||
总体来说,令牌桶模式的实现看似可能比较复杂,每间隔固定时间,我们就要把新的令牌放到桶中,但其实我们并不需要真的用一个专用线程或者定时器来做这件事情,只要在令牌中增加一个时间戳记录,每次获取令牌前,比较一下时间戳与当前时间,就可以轻易计算出这段时间需要放多少令牌进去,然后一次性放完全部令牌即可,所以真正编码时并不会显得很复杂。
|
||||
|
||||
分布式限流
|
||||
|
||||
那么,在理解了实践可用的几种限流模式之后,我们接着再向实际的信息系统前进一步,一起来讨论下分布式系统中的限流问题。
|
||||
|
||||
此前,我们讨论的种种限流算法和模式全部是针对整个系统的限流,总是有意无意地假设或默认系统只提供一种业务操作,或者所有业务操作的消耗都是等价的,并不涉及不同业务请求进入系统的服务集群后,分别会调用哪些服务、每个服务节点处理能力有何差别等问题。
|
||||
|
||||
另外,这些限流算法直接使用在单体架构的集群上确实是完全可行的,但到了微服务架构下,它们就最多只能应用于集群最入口处的网关上,对整个服务集群进行流量控制,而无法细粒度地管理流量在内部微服务节点中的流转情况。
|
||||
|
||||
所以,我们把前面介绍的限流模式都统称为单机限流,把能够精细控制分布式集群中每个服务消耗量的限流算法称为分布式限流。
|
||||
|
||||
你可能要问,这两种限流算法在实现上的核心差别是什么呢?
|
||||
|
||||
答案是,要看二者是如何管理限流的统计指标的。
|
||||
|
||||
单机限流很好办,指标都是存储在服务的内存当中;而分布式限流的目的是要让各个服务节点的协同限流。无论是将限流功能封装为专门的远程服务,还是在系统采用的分布式框架中有专门的限流支持,都需要把每个服务节点的内存中的统计数据给开放出来,让全局的限流服务可以访问到才行。
|
||||
|
||||
一种常见的简单分布式限流方法,是将所有服务的统计结果都存入集中式缓存(如Redis)中,以实现在集群内的共享,并通过分布式锁、信号量等机制,解决这些数据在读写访问时的并发控制问题。
|
||||
|
||||
那么由此我们也能得出一个结论,在可以共享统计数据的前提下,原本用于单机的限流模式,理论上也是可以应用于分布式环境中的,可是它的代价也显而易见:每次服务调用都必须要额外增加一次网络开销,所以这种方法的效率肯定是不高的,当流量压力大的时候,限流本身反倒会显著降低系统的处理能力。
|
||||
|
||||
这也就是说,只要集中式存储统计信息,就不可避免地会产生网络开销。因此为了缓解这里产生的性能损耗,一种可以考虑的办法是在令牌桶限流模式的基础上,进行“货币化改造”改造。即不把令牌看作是只有准入和不准入的“通行证”,而把它看作是数值形式的“货币额度”。
|
||||
|
||||
具体是什么意思呢?也就是当请求进入集群时,首先在API网关处领取到一定数额的“货币”,为了体现不同等级用户重要性的差别,他们的额度可以有所差异,比如让VIP用户的额度更高甚至是无限的。
|
||||
|
||||
这里我们将用户A的额度表示为QuanityA。由于任何一个服务在响应请求时,都需要消耗集群中一定量的处理资源,所以在访问每个服务时都要求消耗一定量的“货币”。
|
||||
|
||||
假设服务X要消耗的额度表示为CostX,那当用户A访问了N个服务以后,他剩余的额度LimitN就会表示为:
|
||||
|
||||
|
||||
LimitN = QuanityA - ∑NCostX
|
||||
|
||||
|
||||
此时,我们可以把剩余额度LimitN作为内部限流的指标,规定在任何时候,只要剩余额度LimitN小于等于0时,就不再允许访问其他服务了。另外,这时还必须先发生一次网络请求,重新向令牌桶申请一次额度,成功后才能继续访问,不成功则进入降级逻辑。除此之外的任何时刻,即LimitN不为0时,都无需额外的网络访问,因为计算LimitN是完全可以在本地完成的。
|
||||
|
||||
这种基于额度的限流方案,对限流的精确度会有一定的影响,比如可能存在业务操作已经进行了一部分服务调用,却无法从令牌桶中再获取到新额度,因“资金链断裂”而导致业务操作失败的情况。这种失败的代价是比较高昂的,它白白浪费了部分已经完成了的服务资源,但总体来说,它仍然是一种在并发性能和限流效果上,都相对折衷可行的分布式限流方案。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我带你学习了限流的目标与指标这两项概念性的内容,现在你可以根据系统的服务和流量特征,来事先做好系统开发设计中针对流量的规划问题了。
|
||||
|
||||
另外,我还带你重点学习了单机限流的流量计数器、滑动时间窗、漏桶和令牌桶这四种实现模式,也了解了如何将单机限流升级为分布式限流的实现方案。你要注意的地方是,对于分布式系统容错的设计,是必须要有且无法妥协的措施。但限流与容错不一样,做分布式限流从不追求“越彻底越好”,我们往往需要权衡方案的代价与收益。
|
||||
|
||||
一课一思
|
||||
|
||||
请介绍一下你接触的生产系统中,都采用了什么样的流量治理手段或者框架?欢迎给我留言,分享你的答案。
|
||||
|
||||
如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
149
专栏/周志明的架构课/39_如何构建零信任网络安全?.md
Normal file
149
专栏/周志明的架构课/39_如何构建零信任网络安全?.md
Normal file
@@ -0,0 +1,149 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
39 _ 如何构建零信任网络安全?
|
||||
你好,我是周志明。
|
||||
|
||||
在学完第4讲的课程之后,现在我们知道了微服务的核心技术特征之一是分散治理(Decentralized Governance),这表明了微服务并不追求统一的技术平台,而是提倡让团队有自由选择的权利,不受制于语言和技术框架。
|
||||
|
||||
在开发阶段构建服务时,分散治理打破了由技术栈带来的约束,它带来的好处是不言自明的。但在运维阶段部署服务时,尤其是在考量起安全问题时,由Java、Golang、Python、Node.js等多种语言和框架共同组成的微服务系统,出现安全漏洞的概率肯定要比只采用其中某种语言、某种框架所构建的单体系统更高。
|
||||
|
||||
于是,为了避免由于单个服务节点出现漏洞被攻击者突破,进而导致整个系统和内网都遭到入侵,我们就必须打破一些传统的安全观念,以此来构筑更加可靠的服务间通讯机制。
|
||||
|
||||
基于边界的安全模型
|
||||
|
||||
长期以来,主流的网络安全观念都比较提倡根据某类与宿主机相关的特征,比如机器所处的位置,或者机器的IP地址、子网等等,把网络划分为不同的区域,不同的区域对应不同的风险级别和允许访问的网络资源权限,把安全防护措施集中部署在各个区域的边界之上,重点关注跨区域的网络流量。
|
||||
|
||||
现在我们所熟知的VPN、DMZ、防火墙、内网、外网等概念,可以说都是因此而生的,这种安全模型今天也被叫做是基于边界的安全模型(Perimeter-Based Security Model,简称“边界安全”)。
|
||||
|
||||
边界安全是完全合情合理的做法,在“安全架构”这个小章节中,我就强调过安全不可能是绝对的,我们必须在可用性和安全性之间做好权衡和取舍。不然我们想想,把一台“服务器”的网线拔掉、电源关掉,不让它对外提供服务,那它肯定是最安全的。
|
||||
|
||||
另外,边界安全着重检查的是经过网络区域边界的流量,而对可信任区域(内网)内部机器之间的流量,会给予直接信任、或者至少是较为宽松的处理策略,这样就减小了安全设施对整个应用系统复杂度的影响,以及网络传输性能的额外损耗,所以它当然是很合理的。
|
||||
|
||||
可是,今天单纯的边界安全,已经不能满足大规模微服务系统技术异构和节点膨胀的发展需要了。
|
||||
|
||||
这是因为边界安全的核心问题在于,边界上的防御措施即使自身能做到永远滴水不漏、牢不可破,也很难保证内网中它所尽力保护的某一台服务器不会成为“猪队友”,一旦“可信的”网络区域中的某台服务器被攻陷,那边界安全措施就成了马其诺防线,攻击者很快就能以一台机器为跳板,侵入到整个内网。
|
||||
|
||||
实际上,这是边界安全的基因所决定的固有缺陷,从边界安全被提出的第一天起,这就已经是可以预料到的问题了。不过在微服务时代,我们已经转变了开发观念,承认服务了总是会出错的,那么现在我们也必须转变安全观念,承认一定会有被攻陷的服务。
|
||||
|
||||
为此,我们就需要寻找到与之匹配的新的网络安全模型。
|
||||
|
||||
零信任安全模型
|
||||
|
||||
2010年,Forrester Research的首席分析师约翰 · 金德维格(John Kindervag)提出了零信任安全模型的概念(Zero-Trust Security Model,后面简称“零信任安全”),最初提出时它是叫“零信任架构”(Zero-Trust Architecture),这个概念在当时并没有引发太大的关注,但随着微服务架构的日渐兴盛,越来越多的开发和运维人员注意到零信任安全模型与微服务所追求的安全目标是高度吻合的。
|
||||
|
||||
零信任安全的中心思想是不应当以某种固有特征来自动信任任何流量,除非明确得到了能代表请求来源(不一定是人,更可能是另一台服务)的身份凭证,否则一律不会有默认的信任关系。
|
||||
|
||||
在2019年,Google发表了一篇在安全与研发领域里都备受关注的论文《BeyondProd: A New Approach to Cloud-Native Security》(BeyondCorp和BeyondProd是谷歌最新一代安全框架的名字),其中详细列举了传统的基于边界的网络安全模型,与云原生时代下基于零信任网络的安全模型之间的差异,并描述了要完成边界安全模型到零信任安全模型的迁移所要实现的具体需求点,这里我把它翻译、整理了出来,你可以参考下:
|
||||
|
||||
|
||||
|
||||
这个表格已经系统地阐述了零信任安全在微服务、云原生环境中的具体落地过程了,后续的整篇论文(除了介绍Google自己的实现框架外)就是以此为主线来展开论述的,但是这个表格还是过于简单,论文原文也写得比较分散晦涩,所以这里我就根据自己的理解,给你展开介绍一下其中的主要观点,以此让你进一步理解零信任安全的含义。
|
||||
|
||||
|
||||
零信任网络不等同于放弃在边界上的保护设施
|
||||
|
||||
|
||||
虽然像防火墙这样的位于网络边界的设施,是属于边界安全而不是零信任安全的概念,但它仍然是一种提升安全性的有效且必要的做法。在微服务集群的前端部署防火墙,把内部服务节点间的流量与来自互联网的流量隔离开,这种做法无论何时都是值得提倡的,因为这样至少能够让内部服务避开来自互联网未经授权流量的饱和攻击,比如最典型的DDoS拒绝服务攻击。
|
||||
|
||||
|
||||
身份只来源于服务
|
||||
|
||||
|
||||
我们知道,传统应用一般是部署在特定的服务器上的,这些机器的IP、MAC地址很少会发生变化,此时系统的拓扑状态是相对静态的。基于这个前提,安全策略才会使用IP地址、主机名等作为身份标识符(Identifiers),无条件信任具有特性身份表示的服务。
|
||||
|
||||
而如今的微服务系统,尤其是云原生环境中的微服务系统,其虚拟化基础设施已经得到了大范围的应用,这就会导致服务所部署的IP地址、服务实例的数量随时都可能发生变化。因此,身份只能来源于服务本身所能够出示的身份凭证(通常是数字证书),而不再是服务所在的IP地址、主机名或者其他特征。
|
||||
|
||||
|
||||
服务之间也没有固有的信任关系
|
||||
|
||||
|
||||
这点决定了只有已知的、明确授权的调用者才能访问服务,从而阻止攻击者通过某个服务节点中的代码漏洞来越权调用到其他服务。
|
||||
|
||||
另外,如果某个服务节点被成功入侵,这个原则也能阻止攻击者扩大其入侵范围。这个其实比较类似于微服务设计模式中,使用断路器、舱壁隔离实现容错来避免雪崩效应,在安全方面,我们也应当采用这种“互不信任”的模式来隔离入侵危害的影响范围。
|
||||
|
||||
|
||||
集中、共享的安全策略实施点
|
||||
|
||||
|
||||
这点与微服务的“分散治理”刚好相反,微服务提倡每个服务独立地负责自身所有的功能性与非功能性需求。而Google这个观点相当于为分散治理原则做了一个补充:分散治理,但涉及安全的非功能性需求(如身份管理、安全传输层、数据安全层)最好除外。
|
||||
|
||||
一方面,要写出高度安全的代码非常不容易,为此付出的精力甚至可能远高于业务逻辑本身。如果你有兴趣阅读基于Spring Cloud的Fenix’s Bookstore的源码,很容易就会发现在Security工程中的代码量是该项目中,所有微服务中最多的。
|
||||
|
||||
更重要的是另一方面,也就是如果让服务各自处理安全问题,很容易会出现实现不一致、或者出现漏洞时要反复修改多处地方的情况,而且还有一些安全问题,如果不立足于全局是很难彻底解决的(在下节课面向具体操作实践的“服务安全”中我还会详细讲述)。
|
||||
|
||||
所以,Google明确提出应该有集中式的“安全策略实施点”(原文中称为Choke Points),安全需求应该从微服务的应用代码下沉至云原生的基础设施里,这也就契合了论文的标题“Cloud-Native Security”。
|
||||
|
||||
|
||||
受信的机器运行来源已知的代码
|
||||
|
||||
|
||||
这条原则就限制了服务只能使用认证过的代码和配置,并且只能运行在认证过的环境中。
|
||||
|
||||
分布式软件系统除了促使软件架构发生了重大变化之外,对软件的发布流程也有很大的改变,使其严重依赖持续集成与持续部署(Continuous Integration / Continuous Delivery,CI/CD)。从开发人员编写代码,到自动化测试,到自动集成,再到漏洞扫描,最后发布上线,这整套CI/CD流程被称作“软件供应链”(Software Supply Chain)。
|
||||
|
||||
可是安全问题并不仅仅局限于软件运行阶段。我举个例子,之前造成过很大影响的XCodeGhost风波,就是针对软件供应链的攻击事件,它是在编译阶段把恶意代码嵌入到软件当中,只要安装了此软件的用户就可能触发恶意代码。
|
||||
|
||||
因此也是为了避免这样的事件再发生,零信任安全针对软件供应链的每一步,都加入了安全控制策略。
|
||||
|
||||
|
||||
自动化、标准化的变更管理
|
||||
|
||||
|
||||
这点也是提倡要通过基础设施,而不是应用代码去实现安全功能的另一个重要理由。如果将安全放在应用上,由于应用本身的分散治理,这决定了安全也必然是难以统一和标准化的,而做不到标准化就意味着做不到自动化。
|
||||
|
||||
相反,一套独立于应用的安全基础设施,就可以让运维人员轻松地了解基础设施变更对安全性的影响,并且可以在几乎不影响生产环境的情况下,发布安全补丁程序。
|
||||
|
||||
|
||||
强隔离性的工作负载
|
||||
|
||||
|
||||
“工作负载”的概念贯穿了Google内部的Borg系统与后来的Kubernetes系统,它是指在虚拟化技术支持下,运行的一组能够协同提供服务的镜像。下个模块我介绍云原生基础设施的时候,会详细介绍容器化,这里我先给你说明一个要点,就是容器化仅仅是虚拟化的一个子集。
|
||||
|
||||
实际上,容器比起传统虚拟机的隔离能力是有所降低的,这种设计对性能非常有利,却对安全相对不利,因此在强调安全性的应用里,会有专门关注强隔离性的容器运行工具出现。
|
||||
|
||||
Google的零信任安全实践
|
||||
|
||||
Google认为,零信任安全模型的最终目标是实现整个基础设施之上的自动化安全控制,服务所需的安全能力可以与服务自身一起,以相同方式自动进行伸缩扩展。
|
||||
|
||||
对于程序来说,做到安全是日常,风险是例外(Secure by Default and Insecure by Exception);对于人类来说,做到袖手旁观是日常,主动干预是例外(Human Actions Should Be by Exception, Not Routine)。
|
||||
|
||||
这的确是很美好的愿景,只是这种“喊口号”式的目标,在软件发展史上也提出过很多次,却一直难以真正达成,其中的原因我在开篇其实就提到过,安全不可能是绝对的,而是有成本的。
|
||||
|
||||
那么你其实也能很明显地发现,之所以在今天这节课,我们才真正严肃地讨论零信任网络模型,并不是因为它本身有多么巧妙、有什么此前没有想到的好办法,而是因为它受制于前面我提到的边界安全模型的“合理之处”,即“安全设施对整个应用系统复杂度的影响,以及网络传输性能的额外损耗”。
|
||||
|
||||
那到底零信任安全要实现这个目标的代价是什么呢?会有多大?这里我根据Google论文的观点来回答下这个问题:为了保护服务集群内的代码与基础设施,Google设计了一系列的内部工具,才最终得以实现前面所说的那些安全原则:
|
||||
|
||||
|
||||
为了在网络边界上保护内部服务免受DDoS攻击,设计了名为Google Front End(名字意为“最终用户访问请求的终点”)的边缘代理,负责保证此后所有流量都在TLS之上传输,并自动将流量路由到适合的可用区域之中。
|
||||
为了强制身份只来源于服务,设计了名为Application Layer Transport Security(应用层传输安全)的服务认证机制,这是一个用于双向认证和传输加密的系统,它可以自动把服务与它的身份标识符绑定,使得所有服务间流量都不必再使用服务名称、主机IP来判断对方的身份。
|
||||
为了确保服务间不再有默认的信任关系,设计了Service Access Policy(服务访问策略),来管理一个服务向另一个服务发起请求时需要提供的认证、鉴权和审计策略,并支持全局视角的访问控制与分析,以达成“集中、共享的安全策略实施点”这条原则。
|
||||
为了实现仅以受信的机器运行来源已知的代码,设计了名为Binary Authorization(二进制授权)的部署时检查机制,确保在软件供应链的每一个阶段,都符合内部安全检查策略,并对此进行授权与鉴权。同时谷歌设计了名为Host Integrity(宿主机完整性)的机器安全启动程序,在创建宿主机时,自动验证包括BIOS、BMC、Bootloader和操作系统内核的数字签名。
|
||||
为了工作负载能够具有强隔离性,设计了名为gVisor的轻量级虚拟化方案。这个方案与此前由Intel发起的Kata Containers的思路异曲同工,目的都是解决容器共享操作系统内核而导致隔离性不足的安全缺陷,它们的做法也都是为每个容器提供了一个独立的虚拟Linux内核。比如,gVisor是用Golang实现了一个名为Sentry的、能够提供传统操作系统内核能力的进程。严格来说,无论是gVisor还是Kata Containers,尽管都披着容器运行时的外衣,但它们在本质上都是轻量级虚拟机。
|
||||
|
||||
|
||||
到这里,作为一名普通的软件开发者,你在看完Google关于零信任安全的论文,或者学习完我这些简要的转述,了解到即使是Google也必须要花费如此庞大的精力,才能做到零信任安全,那你最有可能的感受,大概不是对零信任安全心生向往,而是准备对它挥手告别了。
|
||||
|
||||
其实哪怕不需要开发、不需要购买,免费把前面Google开发的安全组件赠送给我们,大多数的开发团队恐怕也没有足够的运维能力。
|
||||
|
||||
小结
|
||||
|
||||
在微服务时代以前,传统的软件系统与研发模式的确是很难承受零信任安全模型的代价的,只有到了云原生时代,虚拟化的基础设施长足发展,能将复杂性隐藏于基础设施之内,开发者不需要为了达成每一条安全原则,而专门开发或引入可感知的安全设施;只有容器与虚拟化网络的性能足够高,在可以弥补安全隔离与安全通讯的额外损耗的前提下,零信任网络的安全模型才有它生根发芽的土壤。
|
||||
|
||||
另外,零信任安全引入了比边界安全更细致、更复杂的安全措施的同时,也强调自动与透明的重要性。这既要保证系统各个微服务之间能安全通讯,同时也不削弱微服务架构本身的设计原则,比如集中式的安全并不抵触于分散治理原则,安全机制并不影响服务的自动伸缩和有效的封装,等等。
|
||||
|
||||
总而言之,只有零信任安全的成本在开发与运维上都是可接受的,它才不会变成仅仅具备理论可行性的“大饼”,不会给软件带来额外的负担。
|
||||
|
||||
当然,如何构建零信任网络安全是一个非常大而且比较前沿的话题,在下一节课,我会从实践的角度出发,更具体、更量化地给你展示零信任安全模型的价值与权衡。
|
||||
|
||||
一课一思
|
||||
|
||||
你认为零信任安全所付出的代价与收益是什么?在实践中,你应该如何权衡两者的权重呢?
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
300
专栏/周志明的架构课/40_如何实现零信任网络下安全的服务访问?.md
Normal file
300
专栏/周志明的架构课/40_如何实现零信任网络下安全的服务访问?.md
Normal file
@@ -0,0 +1,300 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
40 _ 如何实现零信任网络下安全的服务访问?
|
||||
你好,我是周志明。
|
||||
|
||||
在上节课“零信任网络安全”当中,我们探讨了与微服务运作特点相适应的零信任安全模型。今天这节课,我们会从实践和编码的角度出发,一起来了解在前微服务时代(以Spring Cloud为例)和云原生时代(以Kubernetes with Istio为例),零信任网络分别是如何实现安全传输、认证和授权的。
|
||||
|
||||
这里我要说明的是,由于这节课是面向实践的,必然会涉及到具体代码,为了便于讲解,在课程中我只贴出了少量的核心代码片段,所以我建议你在开始学习这节课之前,先去浏览一下这两个样例工程的代码,以便获得更好的学习效果。
|
||||
|
||||
建立信任
|
||||
|
||||
首先我们要知道,零信任网络里不存在默认的信任关系,一切服务调用、资源访问成功与否,都需要以调用者与提供者间已建立的信任关系为前提。
|
||||
|
||||
之前我们在第23讲也讨论过,真实世界里,能够达成信任的基本途径不外乎基于共同私密信息的信任和基于权威公证人的信任两种;而在网络世界里,因为客户端和服务端之间一般没有什么共同私密信息,所以真正能采用的就只能是基于权威公证人的信任,它有个标准的名字:公开密钥基础设施(Public Key Infrastructure,PKI)。
|
||||
|
||||
这里你可以先记住一个要点,PKI是构建传输安全层(Transport Layer Security,TLS)的必要基础。
|
||||
|
||||
在任何网络设施都不可信任的假设前提下,无论是DNS服务器、代理服务器、负载均衡器还是路由器,传输路径上的每一个节点都有可能监听或者篡改通讯双方传输的信息。那么要保证通讯过程不受到中间人攻击的威胁,唯一具备可行性的方案是启用TLS对传输通道本身进行加密,让发送者发出的内容只有接受者可以解密。
|
||||
|
||||
建立TLS传输,说起来好像并不复杂,只要在部署服务器时预置好CA根证书,以后用该CA为部署的服务签发TLS证书就行了。
|
||||
|
||||
但落到实际操作上,这个事情就属于典型的“必须集中在基础设施中自动进行的安全策略实施点”,毕竟面对数量庞大且能够自动扩缩的服务节点,依赖运维人员手工去部署和轮换根证书,肯定是很难持续做好的。
|
||||
|
||||
而除了随服务节点动态扩缩而来的运维压力外,微服务中TLS认证的频次也很明显地高于传统的应用。比起公众互联网中主流单向的TLS认证,在零信任网络中,往往要启用双向TLS认证(Mutual TLS Authentication,常简写为mTLS),也就是不仅要确认服务端的身份,还需要确认调用者的身份。
|
||||
|
||||
|
||||
单向TLS认证:只需要服务端提供证书,客户端通过服务端证书验证服务器的身份,但服务器并不验证客户端的身份。单向TLS用于公开的服务,即任何客户端都被允许连接到服务进行访问,它保护的重点是客户端免遭冒牌服务器的欺骗。
|
||||
双向TLS认证:客户端、服务端双方都要提供证书,双方各自通过对方提供的证书来验证对方的身份。双向TLS用于私密的服务,即服务只允许特定身份的客户端访问,它除了保护客户端不连接到冒牌服务器外,也保护服务端不遭到非法用户的越权访问。
|
||||
|
||||
|
||||
另外,对于前面提到的围绕TLS而展开的密钥生成、证书分发、签名请求(Certificate Signing Request,CSR)、更新轮换,等等,这其实是一套操作起来非常繁琐的流程,稍有疏忽就会产生安全漏洞。所以尽管它在理论上可行,但实践中如果没有自动化的基础设施的支持,仅靠应用程序和运维人员的努力,是很难成功实施零信任安全模型的。
|
||||
|
||||
那么接下来,我们就结合Fenix’s Bookstore的代码,聚焦于“认证”和“授权”这两个最基本的安全需求,来看看它们在微服务架构下,有或者没有基础设施支持的时候,各自都是如何实现的。
|
||||
|
||||
我们先来看看认证。
|
||||
|
||||
认证
|
||||
|
||||
根据认证的目标对象,我们可以把认证分为两种类型,一种是以机器作为认证对象,即访问服务的流量来源是另外一个服务,这被叫做服务认证(Peer Authentication,直译过来是“节点认证”);另一种是以人类作为认证对象,即访问服务的流量来自于最终用户,这被叫做请求认证(Request Authentication)。
|
||||
|
||||
当然,无论是哪一种认证,无论有没有基础设施的支持,它们都要有可行的方案来确定服务调用者的身份,只有建立起信任关系才能调用服务。
|
||||
|
||||
好,下面我们来了解下服务认证的相关实现机制。
|
||||
|
||||
服务认证
|
||||
|
||||
Istio版本的Fenix’s Bookstore采用了双向TLS认证,作为服务调用双方的身份认证手段。得益于Istio提供的基础设施的支持,我们不需要Google Front End、Application Layer Transport Security这些安全组件,也不需要部署PKI和CA,甚至无需改动任何代码,就可以启用mTLS认证。
|
||||
|
||||
不过,Istio毕竟是新生事物,如果你要在自己的生产系统中准备启用mTLS,还是要先想一下,是否整个服务集群的全部节点都受Istio管理?如果每一个服务提供者、调用者都会受到Istio的管理,那mTLS就是最理想的认证方案。你只需要参考以下简单的PeerAuthentication CRD配置,就可以对某个Kubernetes名称空间范围内的所有流量启用mTLS:
|
||||
|
||||
apiVersion: security.istio.io/v1beta1
|
||||
kind: PeerAuthentication
|
||||
metadata:
|
||||
name: authentication-mtls
|
||||
namespace: bookstore-servicemesh
|
||||
spec:
|
||||
mtls:
|
||||
mode: STRICT
|
||||
|
||||
|
||||
不过,如果你的分布式系统还没有达到完全云原生的程度,其中还存在部分不受Istio管理(即未注入Sidecar)的服务端或者客户端(这是很常见的),你也可以将mTLS传输声明为“宽容模式”(Permissive Mode)。
|
||||
|
||||
宽容模式的含义是受Istio管理的服务,会允许同时接受纯文本和mTLS两种流量。纯文本流量只用来和那些不受Istio管理的节点进行交互,你需要自行想办法解决纯文本流量的认证问题;而对于服务网格内部的流量,就可以使用mTLS认证。
|
||||
|
||||
这里你要知道的是,宽容模式为普通微服务向服务网格迁移提供了良好的灵活性,让运维人员能够逐个给服务进行mTLS升级。甚至在原本没有启用mTLS的服务中启用mTLS时,可以不中断现存已经建立的纯文本传输连接,完全不会被最终用户感知到。
|
||||
|
||||
这样,一旦所有服务都完成迁移,就可以把整个系统设置为严格TLS模式,即前面代码中的mode: STRICT。
|
||||
|
||||
在Spring Cloud版本的Fenix’s Bookstore里,因为没有基础设施的支持,一切认证工作就不得不在应用层面去实现。我选择的方案是借用OAtuh 2.0协议的客户端模式来进行认证的,其大体思路有如下两步。
|
||||
|
||||
第一步,每一个要调用服务的客户端,都与认证服务器约定好一组只有自己知道的密钥(Client Secret),这个约定过程应该是由运维人员在线下自行完成,通过参数传给服务,而不是由开发人员在源码或配置文件中直接设定。我在演示工程的代码注释中,也专门强调了这点,以免你被示例代码中包含密钥的做法所误导。
|
||||
|
||||
这个密钥其实就是客户端的身份证明,客户端在调用服务时,会先使用该密钥向认证服务器申请到JWT令牌,然后通过令牌证明自己的身份,最后访问服务。
|
||||
|
||||
你可以看看下面给出的代码示例,它定义了五个客户端,其中四个是集群内部的微服务,均使用客户端模式,并且注明了授权范围是SERVICE(授权范围在下面介绍授权中会用到),示例中的第一个是前端代码的微服务,它使用密码模式,授权范围是BROWSER。
|
||||
|
||||
/**
|
||||
* 客户端列表
|
||||
*/
|
||||
private static final List<Client> clients = Arrays.asList(
|
||||
new Client("bookstore_frontend", "bookstore_secret", new String[]{GrantType.PASSWORD, GrantType.REFRESH_TOKEN}, new String[]{Scope.BROWSER}),
|
||||
// 微服务一共有Security微服务、Account微服务、Warehouse微服务、Payment微服务四个客户端
|
||||
// 如果正式使用,这部分信息应该做成可以配置的,以便快速增加微服务的类型。clientSecret也不应该出现在源码中,应由外部配置传入
|
||||
new Client("account", "account_secret", new String[]{GrantType.CLIENT_CREDENTIALS}, new String[]{Scope.SERVICE}),
|
||||
new Client("warehouse", "warehouse_secret", new String[]{GrantType.CLIENT_CREDENTIALS}, new String[]{Scope.SERVICE}),
|
||||
new Client("payment", "payment_secret", new String[]{GrantType.CLIENT_CREDENTIALS}, new String[]{Scope.SERVICE}),
|
||||
new Client("security", "security_secret", new String[]{GrantType.CLIENT_CREDENTIALS}, new String[]{Scope.SERVICE})
|
||||
);
|
||||
|
||||
|
||||
第二步,每一个对外提供服务的服务端,都扮演着OAuth 2.0中的资源服务器的角色,它们都声明为要求提供客户端模式的凭证,如以下代码所示。客户端要调用受保护的服务,就必须先出示能证明调用者身份的JWT令牌,否则就会遭到拒绝。这个操作本质上是授权的过程,但它在授权过程中其实已经实现了服务的身份认证。
|
||||
|
||||
public ClientCredentialsResourceDetails clientCredentialsResourceDetails() {
|
||||
return new ClientCredentialsResourceDetails();
|
||||
}
|
||||
|
||||
|
||||
而且,由于每一个微服务都同时具有服务端和客户端两种身份,它们既消费其他服务,也提供服务供别人消费,所以在每个微服务中,都应该要包含以上这些代码(放在公共infrastructure工程里)。
|
||||
|
||||
另外,Spring Security提供的过滤器会自动拦截请求,驱动认证、授权检查的执行,以及申请和验证JWT令牌等操作,无论是在开发期对程序员,还是在运行期对用户,都能做到相对透明。
|
||||
|
||||
不过尽管如此,这样的做法仍然是一种应用层面的、不加密传输的解决方案。为什么呢?
|
||||
|
||||
前面我提到,在零信任网络中面对可能的中间人攻击,TLS是唯一可行的办法。其实我的言下之意是,即使应用层的认证能在一定程度上,保护服务不被身份不明的客户端越权调用,但是如果内容在传输途中被监听、篡改,或者被攻击者拿到了JWT令牌之后,冒认调用者的身份去调用其他服务,应用层的认证就无法防御了。
|
||||
|
||||
所以简而言之,这种方案并不适用于零信任安全模型,只有在默认内网节点间具备信任关系的边界安全模型上,才能良好工作。
|
||||
|
||||
好,我们再来说说请求认证。
|
||||
|
||||
请求认证
|
||||
|
||||
对于来自最终用户的请求认证,Istio版本的Fenix’s Bookstore仍然能做到单纯依靠基础设施解决问题,整个认证过程不需要应用程序参与(JWT令牌还是在应用中生成的,因为Fenix’s Bookstore并没有使用独立的用户认证服务器,只有应用本身才拥有用户信息)。
|
||||
|
||||
当来自最终用户的请求进入服务网格时,Istio会自动根据配置中的JWKS(JSON Web Key Set)来验证令牌的合法性,如果令牌没有被篡改过且在有效期内,就信任Payload中的用户身份,并从令牌的Iss字段中获得Principal。关于Iss、Principals等概念,我在安全架构这个小章节中都介绍过了,你可以去回顾复习一下第23到30讲。而JWKS倒是之前从没有提到过,它代表了一个密钥仓库。
|
||||
|
||||
我们知道在分布式系统中,JWT要采用非对称的签名算法(RSA SHA256、ECDSA SHA256等,默认的HMAC SHA256属于对称加密),认证服务器使用私钥对Payload进行签名,资源服务器使用公钥对签名进行验证。
|
||||
|
||||
而常与JWT配合使用的JWK(JSON Web Key)就是一种存储密钥的纯文本格式,在功能上,它和JKS(Java Key Storage)、P12(Predecessor of PKCS#12)、PEM(Privacy Enhanced Mail)这些常见的密钥格式并没有什么本质上的差别。
|
||||
|
||||
所以顾名思义,JWKS就是一组JWK的集合。支持JWKS的系统,能通过JWT令牌Header中的KID(Key ID)自动匹配出应该使用哪个JWK来验证签名。
|
||||
|
||||
以下是Istio版本的Fenix’s Bookstore中的用户认证配置。其中,jwks字段配的就是JWKS全文(实际生产中并不推荐这样做,应该使用jwkUri来配置一个JWKS地址,以方便密钥轮换),根据这里配置的密钥信息,Istio就能够验证请求中附带的JWT是否合法。
|
||||
|
||||
apiVersion: security.istio.io/v1beta1
|
||||
kind: RequestAuthentication
|
||||
metadata:
|
||||
name: authentication-jwt-token
|
||||
namespace: bookstore-servicemesh
|
||||
spec:
|
||||
jwtRules:
|
||||
- issuer: "[email protected]"
|
||||
# Envoy默认只认“Bearer”作为JWT前缀,之前其他地方用的都是小写,这里专门兼容一下
|
||||
fromHeaders:
|
||||
- name: Authorization
|
||||
prefix: "bearer "
|
||||
# 在rsa-key目录下放了用来生成这个JWKS的证书,最初是用java keytool生成的jks格式,一般转jwks都是用pkcs12或者pem格式,为方便使用也一起附带了
|
||||
jwks: |
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"e": "AQAB",
|
||||
"kid": "bookstore-jwt-kid",
|
||||
"kty": "RSA",
|
||||
"n": "i-htQPOTvNMccJjOkCAzd3YlqBElURzkaeRLDoJYskyU59JdGO-p_q4JEH0DZOM2BbonGI4lIHFkiZLO4IBBZ5j2P7U6QYURt6-AyjS6RGw9v_wFdIRlyBI9D3EO7u8rCA4RktBLPavfEc5BwYX2Vb9wX6N63tV48cP1CoGU0GtIq9HTqbEQs5KVmme5n4XOuzxQ6B2AGaPBJgdq_K0ZWDkXiqPz6921X3oiNYPCQ22bvFxb4yFX8ZfbxeYc-1rN7PaUsK009qOx-qRenHpWgPVfagMbNYkm0TOHNOWXqukxE-soCDI_Nc--1khWCmQ9E2B82ap7IXsVBAnBIaV9WQ"
|
||||
}
|
||||
]
|
||||
}
|
||||
forwardOriginalToken: true
|
||||
|
||||
|
||||
而Spring Cloud版本的Fenix’s Bookstore就要稍微麻烦一些,它依然是采用JWT令牌作为用户身份凭证的载体,认证过程依然在Spring Security的过滤器里中自动完成。不过因为这节课我们讨论的重点不在Spring Security的过滤器工作原理,所以它的详细过程就不展开了,只简单说说其主要路径:过滤器→令牌服务→令牌实现。
|
||||
|
||||
既然如此,Spring Security已经做好了认证所需的绝大部分工作,那么真正要开发者去编写的代码就是令牌的具体实现,即代码中名为“RSA256PublicJWTAccessToken”的实现类。
|
||||
|
||||
它的作用是加载Resource目录下的公钥证书public.cert(实在是怕“抄作业不改名字”的行为,我再一次强调不要将密码、密钥、证书这类敏感信息打包到程序中,示例代码只是为了演示,实际生产应该由运维人员管理密钥),验证请求中的JWT令牌是否合法。
|
||||
|
||||
@Named
|
||||
public class RSA256PublicJWTAccessToken extends JWTAccessToken {
|
||||
RSA256PublicJWTAccessToken(UserDetailsService userDetailsService) throws IOException {
|
||||
super(userDetailsService);
|
||||
Resource resource = new ClassPathResource("public.cert");
|
||||
String publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
|
||||
setVerifierKey(publicKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
如果JWT令牌合法,Spring Security的过滤器就会放行调用请求,并从令牌中提取出Principals,放到自己的安全上下文中(即“SecurityContextHolder.getContext()”)。
|
||||
|
||||
在开发实际项目的时候,你可以根据需要自行决定Principals的具体形式,比如既可以像Istio中那样,直接从令牌中取出来,以字符串的形式原样存放,节省一些数据库或者缓存的查询开销;也可以统一做些额外的转换处理,以方便后续业务使用,比如将Principals转换为系统中的用户对象。
|
||||
|
||||
Fenix’s Bookstore的转换操作是在JWT令牌的父类JWTAccessToken中完成的。所以可见,尽管由应用自己来做请求验证,会有一定的代码量和侵入性,但同时自由度确实也会更高一些。
|
||||
|
||||
这里为了方便不同版本实现之间的对比,在Istio版本中,我保留了Spring Security自动从令牌转换Principals为用户对象的逻辑,因此就必须在YAML中包含forwardOriginalToken: true的配置,告诉Istio验证完JWT令牌后,不要丢弃掉请求中的Authorization Header,而是要原样转发给后面的服务处理。
|
||||
|
||||
授权
|
||||
|
||||
那么,经过认证之后,合法的调用者就有了可信任的身份,此时就不再需要区分调用者到底是机器(服务)还是人类(最终用户)了,只需要根据其身份角色来进行权限访问控制就行,即我们常说的RBAC。
|
||||
|
||||
不过为了更便于理解,Fenix’s Bookstore提供的示例代码仍然沿用此前的思路,分别针对来自“服务”和“用户”的流量来控制权限和访问范围。
|
||||
|
||||
举个具体例子。如果我们准备把一部分微服务看作是私有服务,限制它只接受来自集群内部其他服务的请求,把另外一部分微服务看作是公共服务,允许它可以接受来自集群外部的最终用户发出的请求;又或者,我们想要控制一部分服务只允许移动应用调用,另外一部分服务只允许浏览器调用。
|
||||
|
||||
那么,一种可行的方案就是为不同的调用场景设立角色,进行授权控制(另一种常用的方案是做BFF网关)。
|
||||
|
||||
我们还是以Istio和Spring Cloud版本的Fenix’s Bookstore为例。
|
||||
|
||||
在Istio版本的Fenix’s Bookstore中,通过以下文稿这里给出的配置,就限制了来自bookstore-servicemesh名空间的内部流量,只允许访问accounts、products、pay和settlements四个端点的GET、POST、PUT、PATCH方法,而对于来自istio-system名空间(Istio Ingress Gateway所在的名空间)的外部流量就不作限制,直接放行。
|
||||
|
||||
apiVersion: security.istio.io/v1beta1
|
||||
kind: AuthorizationPolicy
|
||||
metadata:
|
||||
name: authorization-peer
|
||||
namespace: bookstore-servicemesh
|
||||
spec:
|
||||
action: ALLOW
|
||||
rules:
|
||||
- from:
|
||||
- source:
|
||||
namespaces: ["bookstore-servicemesh"]
|
||||
to:
|
||||
- operation:
|
||||
paths:
|
||||
- /restful/accounts/*
|
||||
- /restful/products*
|
||||
- /restful/pay/*
|
||||
- /restful/settlements*
|
||||
methods: ["GET","POST","PUT","PATCH"]
|
||||
- from:
|
||||
- source:
|
||||
namespaces: ["istio-system"]
|
||||
|
||||
|
||||
但针对外部的请求(不来自bookstore-servicemesh名空间的流量),又进行了另外一层控制,如果请求中没有包含有效的登录信息,就限制不允许访问accounts、pay和settlements三个端点,如以下配置所示:
|
||||
|
||||
apiVersion: security.istio.io/v1beta1
|
||||
kind: AuthorizationPolicy
|
||||
metadata:
|
||||
name: authorization-request
|
||||
namespace: bookstore-servicemesh
|
||||
spec:
|
||||
action: DENY
|
||||
rules:
|
||||
- from:
|
||||
- source:
|
||||
notRequestPrincipals: ["*"]
|
||||
notNamespaces: ["bookstore-servicemesh"]
|
||||
to:
|
||||
- operation:
|
||||
paths:
|
||||
- /restful/accounts/*
|
||||
- /restful/pay/*
|
||||
- /restful/settlements*
|
||||
|
||||
|
||||
由此可见,Istio已经提供了比较完善的目标匹配工具,比如前面配置中用到的源from、目标to,以及没有用到的条件匹配when,还有其他像是通配符、IP、端口、名空间、JWT字段,等等。
|
||||
|
||||
当然了,要说灵活和功能强大,它肯定还是不可能跟在应用中由代码实现的授权相媲美,但对绝大多数场景来说已经够用了。在便捷性、安全性、无侵入、统一管理等方面,Istio这种在基础设施上实现授权的方案,显然要更具优势。
|
||||
|
||||
而在Spring Cloud版本的Fenix’s Bookstore中,授权控制自然还是使用Spring Security、通过应用程序代码来实现的。
|
||||
|
||||
常见的Spring Security授权方法有两种。
|
||||
|
||||
第一种是使用它的ExpressionUrlAuthorizationConfigurer,也就是类似下面编码所示的写法来进行集中配置。这个写法跟前面在Istio的AuthorizationPolicy CRD中的写法,在体验上是比较相似的,也是几乎所有Spring Security资料中都会介绍的最主流的方式,比较适合对批量端点进行控制,不过在Fenix’s Bookstore的示例代码中并没有采用(没有什么特别理由,就是我的个人习惯而已)。
|
||||
|
||||
http.authorizeRequests()
|
||||
.antMatchers("/restful/accounts/**").hasScope(Scope.BROWSER)
|
||||
.antMatchers("/restful/pay/**").hasScope(Scope.SERVICE)
|
||||
|
||||
|
||||
第二种写法就是下面的示例代码中采用的方法了。它是通过Spring的全局方法级安全(Global Method Security)以及JSR 250的@RolesAllowed注解来做授权控制。
|
||||
|
||||
这种写法对代码的侵入性更强,需要以注解的形式分散写到每个服务甚至是每个方法中,但好处是能以更方便的形式做出更加精细的控制效果。比如,要控制服务中某个方法,只允许来自服务或者来自浏览器的调用,那直接在该方法上标注@PreAuthorize注解即可,而且它还支持SpEL表达式来做条件。
|
||||
|
||||
表达式中用到的SERVICE、BROWSER代表的是授权范围,就是在声明客户端列表时传入的,具体你可以参考这节课开头声明客户端列表的代码清单。
|
||||
|
||||
/**
|
||||
* 根据用户名称获取用户详情
|
||||
*/
|
||||
@GET
|
||||
@Path("/{username}")
|
||||
@Cacheable(key = "#username")
|
||||
@PreAuthorize("#oauth2.hasAnyScope('SERVICE','BROWSER')")
|
||||
public Account getUser(@PathParam("username") String username) {
|
||||
return service.findAccountByUsername(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的用户
|
||||
*/
|
||||
@POST
|
||||
@CacheEvict(key = "#user.username")
|
||||
@PreAuthorize("#oauth2.hasAnyScope('BROWSER')")
|
||||
public Response createUser(@Valid @UniqueAccount Account user) {
|
||||
return CommonResponse.op(() -> service.createAccount(user));
|
||||
}
|
||||
|
||||
|
||||
小结
|
||||
|
||||
这节课里,我们尝试以程序代码和基础设施两种方式,去实现功能类似的认证与授权,通过这两者的对比,探讨了在微服务架构下,应该如何把业界的安全技术标准引入并实际落地,实现零信任网络下安全的服务访问。
|
||||
|
||||
由此我们也得出了一个基本的结论:在以应用代码为主,去实现安全需求的微服务系统中,是很难真正落地零信任安全的,这不仅仅是由于安全需求所带来的庞大开发、管理(如密钥轮换)和建设(如PKI、CA)的工作量,更是因为这种方式很难符合上节课所提到的零信任安全中“集中、共享的安全策略实施点”“自动化、标准化的变更管理”等基本特征。
|
||||
|
||||
但另一方面,我们也必须看到,现在以代码去解决微服务非功能性需求的方案是很主流的,像Spring Cloud这些方案,在未来的很长一段时间里,都会是信息系统重点考虑的微服务框架。因此,去学习、了解如何通过代码,尽最大可能地去保证服务之间的安全通讯,仍然非常有必要。
|
||||
|
||||
一课一思
|
||||
|
||||
有人说在未来,零信任安全模型很可能会取代边界安全模型,成为微服务间通讯的标准安全观念,你认为这个判断是否会实现呢?或者你是否觉得这只是存在于理论上的美好期望?
|
||||
|
||||
欢迎在留言区分享你的答案。如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
119
专栏/周志明的架构课/41_分布式架构中的可观测到底说的是什么?.md
Normal file
119
专栏/周志明的架构课/41_分布式架构中的可观测到底说的是什么?.md
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
41 _ 分布式架构中的可观测到底说的是什么?
|
||||
你好,我是周志明。从这节课开始,我们将会花四节课的时间去学习“可观测性”方面的知识。
|
||||
|
||||
在以前,可观测性并不是软件设计中要重点考虑的问题,甚至很长时间里,人们也并没有把这种属性与可并发性、可用性、安全性等并列,作为系统的非功能属性之一,直到微服务与云原生时代的来临。
|
||||
|
||||
对于单体系统来说,可观测性可能确实是附属的边缘属性,但对于分布式系统来说,可观测性就是不可或缺的了。为什么呢?别着急,接下来我就跟你详细说道说道。
|
||||
|
||||
可观测性的概念
|
||||
|
||||
好,首先呢,我们来了解下可观测性的含义和特点。
|
||||
|
||||
随着分布式架构逐渐成为了架构设计的主流,可观测性(Observability)一词也日益被人频繁地提起。
|
||||
|
||||
最初,它是与可控制性(Controllability)一起,由匈牙利数学家鲁道夫 · 卡尔曼(Rudolf E. Kálmán)针对线性动态控制系统提出的一组对偶属性。可观测性原本的含义是“可以由系统的外部输出推断其内部状态的程度”。
|
||||
|
||||
在学术界,“可观测性”这个名词其实是最近几年才从控制理论中借用的舶来概念,不过实际上,计算机科学中关于可观测性的研究内容已经有了很多年的实践积累。通常,人们会把可观测性分解为三个更具体的方向进行研究,分别是:日志收集、链路追踪和聚合度量。
|
||||
|
||||
这三个方向各有侧重,但又不是完全独立的,因为它们天然就有重合或者可以结合的地方。
|
||||
|
||||
在2017年的分布式追踪峰会(2017 Distributed Tracing Summit)结束后,彼得 · 波本(Peter Bourgon)撰写了总结文章《Metrics, Tracing, and Logging》,就系统地阐述了这三者的定义、特征,以及它们之间的关系与差异,受到了业界的广泛认可。
|
||||
|
||||
|
||||
|
||||
日志、追踪、度量的目标与结合
|
||||
|
||||
假如你平时只开发单体系统,从来没有接触过分布式系统的观测工作,那你可能就只熟悉日志这一项工作,对追踪和度量会相对比较陌生。
|
||||
|
||||
然而按照彼得 · 波本(Peter Bourgon)给出的定义来看,尽管在分布式系统中,追踪和度量的必要性和复杂程度确实比单体系统时要更高,但是在单体时代,你肯定已经接触过这三项工作了,只是并没有意识到而已。
|
||||
|
||||
你可能会想进一步了解这三项工作的具体含义,想知道为什么要这样划分,下面我来给你简单介绍一下它们各自的特征,你就能明白其中的原因了:
|
||||
|
||||
|
||||
日志(Logging)
|
||||
|
||||
|
||||
我们都知道,日志的职责是记录离散事件,通过这些记录事后分析出程序的行为,比如曾经调用过什么方法、曾经操作过哪些数据,等等。通常,打印日志被认为是程序中最简单的工作之一,你在调试问题的时候,可能也经历过这样的情景“当初这里记得打点日志就好了”,可见这就是一项举手之劳的任务。
|
||||
|
||||
当然,输出日志的确很容易,但收集和分析日志却可能会很复杂,面对成千上万的集群节点、面对迅速滚动的事件信息、面对数以TB计算的文本,传输与归集都并不简单。对大多数程序员来说,分析日志也许就是最常遇见、也最有实践可行性的“大数据系统”了。
|
||||
|
||||
|
||||
追踪(Tracing)
|
||||
|
||||
|
||||
在单体系统时代,追踪的范畴基本只局限于栈追踪(Stack Tracing)。比如说,你在调试程序的时候,在IDE打个断点,看到的Call Stack视图上的内容便是跟踪;在编写代码时,处理异常调用了Exception::printStackTrace()方法,它输出的堆栈信息也是追踪。
|
||||
|
||||
而在微服务时代,追踪就不只局限于调用栈了,一个外部请求需要内部若干服务的联动响应,这时候完整的调用轨迹就会跨越多个服务,会同时包括服务间的网络传输信息与各个服务内部的调用堆栈信息。因此,分布式系统中的追踪在国内通常被称为“全链路追踪”(后面我就直接称“链路追踪”了),许多资料中也把它叫做是“分布式追踪”(Distributed Tracing)。
|
||||
|
||||
追踪的主要目的是排查故障,比如分析调用链的哪一部分、哪个方法出现错误或阻塞,输入输出是否符合预期,等等。
|
||||
|
||||
|
||||
度量(Metrics)
|
||||
|
||||
|
||||
度量是指对系统中某一类信息的统计聚合。比如,证券市场的每一只股票都会定期公布财务报表,通过财报上的营收、净利、毛利、资产、负载等等一系列数据,来体现过去一个财务周期中公司的经营状况,这就是一种信息聚合。
|
||||
|
||||
Java天生自带有一种基本的度量,就是由虚拟机直接提供的JMX(Java Management eXtensions)度量,像是内存大小、各分代的用量、峰值的线程数、垃圾收集的吞吐量、频率,等等,这些数据信息都可以从JMX中获得。
|
||||
|
||||
度量的主要目的是监控(Monitoring)和预警(Alert),比如说,当某些度量指标达到了风险阈值时就触发事件,以便自动处理或者提醒管理员介入。
|
||||
|
||||
那到这里,你应该也就知道为什么在单体系统中,除了接触过日志之外,其实也同样接触过其他两项工作了,因为追踪和度量本来就是我们调试和监控程序时的常用手段。
|
||||
|
||||
好,说完了学术界对于可观测性的定义和研究,下面我们来看看对于工业界,在云原生时代下,这三个方向都有哪些新的发展。
|
||||
|
||||
工业界的遥测产品
|
||||
|
||||
在工业界,目前针对可观测性的产品已经是一片红海,经过多年的角逐,日志、度量两个领域的胜利者算是基本尘埃落定了。
|
||||
|
||||
一方面,在日志领域,日志收集和分析大多被统一到了Elastic Stack(ELK)技术栈上,如果说未来还能出现什么变化的话,也就是其中的Logstash能看到有被Fluentd取代的趋势,让ELK变成EFK,但整套Elastic Stack技术栈的地位已经是相当稳固了。
|
||||
|
||||
而在度量方面,跟随着Kubernetes统一容器编排的步伐,Prometheus也击败了度量领域里以Zabbix为代表的众多前辈,即将成为云原生时代度量监控的事实标准。虽然从市场角度来说,Prometheus还没有达到Kubernetes那种“拔剑四顾,举世无敌”的程度,但是从社区活跃度上看,Prometheus已经占有了绝对的优势,在Google和CNCF的推动下,未来前途可期。
|
||||
|
||||
|
||||
额外知识:Kubernetes与Prometheus的关系-
|
||||
Kubernetes是CNCF第一个孵化成功的项目,Prometheus是CNCF第二个孵化成功的项目。-
|
||||
Kubernetes起源于Google的编排系统Borg,Prometheus起源于Google为Borg做的度量监控系统BorgMon。
|
||||
|
||||
|
||||
不过,追踪方面的情况与日志、度量有所不同,追踪是与具体网络协议、程序语言密切相关的。
|
||||
|
||||
我们在收集日志时,不必关心这段日志是由Java程序输出的,还是由Golang程序输出的,对程序来说它们就只是一段非结构化文本而已;同理,度量对程序来说,也只是一个个聚合的数据指标而已。
|
||||
|
||||
但链路追踪就不一样了,各个服务之间是使用HTTP还是gRPC来进行通信,会直接影响到追踪的实现,各个服务是使用Java、Golang还是Node.js来编写,也会直接影响到进程内调用栈的追踪方式。
|
||||
|
||||
所以,这就决定了追踪工具本身有较强的侵入性,通常是以插件式的探针来实现的;这也决定了在追踪领域很难出现一家独大的情况,通常要有多种产品来针对不同的语言和网络。
|
||||
|
||||
最近几年,各种链路追踪产品层出不穷,市面上主流的工具,既有像Datadog这样的一揽子商业方案,也有像AWS X-Ray和Google Stackdriver Trace这样的云计算厂商产品,还有像SkyWalking、Zipkin、Jaeger这样来自开源社区的优秀产品。
|
||||
|
||||
|
||||
|
||||
日志、追踪、度量的相关产品
|
||||
|
||||
这里我给出的示意图是CNCF Interactive Landscape中列出的日志、追踪、度量领域的著名产品。其实这里很多不同领域的产品是跨界的,比如,ELK可以通过Metricbeat来实现度量的功能;Apache SkyWalking的探针就可以同时支持度量和追踪两方面的数据来源;由OpenTracing进化而来OpenTelemetry,更是融合了日志、追踪、度量三者所长,有望成为三者兼备的统一可观测性的解决方案。在后面关于可观测性的三节课里,我也会紧扣每个领域中最具统治性的产品,给你做一个详细的介绍。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我们了解了可观测性的概念、特征与现状,并明确了在今天,可观测性一般会被分成事件日志、链路追踪和聚合度量三个主题方向进行探讨和研究。你可以记住以下几个核心要点:
|
||||
|
||||
|
||||
事件日志的职责是记录离散事件,通过这些记录事后分析出程序的行为;
|
||||
追踪的主要目的是排查故障,比如分析调用链的哪一部分、哪个方法出现错误或阻塞,输入输出是否符合预期;
|
||||
度量是指对系统中某一类信息的统计聚合,主要目的是监控和预警,当某些度量指标达到风险阈值时就触发事件,以便自动处理或者提醒管理员介入。
|
||||
|
||||
|
||||
另外,事件日志、链路追踪和聚合度量这三个主题也是未来三节课我们要学习的主角,到时你也可以与这节课的学习内容相互印证。
|
||||
|
||||
一课一思
|
||||
|
||||
尽管“可观测性”今天已经被提升到了与“可用性”“可并发性”等同等的高度,但实际是否如此呢?在你的公司设计软件系统的时候,可观测性的考虑权重有多大?
|
||||
|
||||
欢迎在留言区分享你的见解。好,感谢你的阅读,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。就到这里,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
230
专栏/周志明的架构课/42_分析日志真的没那么简单.md
Normal file
230
专栏/周志明的架构课/42_分析日志真的没那么简单.md
Normal file
@@ -0,0 +1,230 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
42 _ 分析日志真的没那么简单
|
||||
你好,我是周志明。
|
||||
|
||||
在上节课明确了可观测性的概念、特征与现状之后,我们知道了可观测性一般会被分成三种具体的表现形式,分别是日志、追踪和度量。那么这节课,我们就来讨论其中最普遍的形式:事件日志。
|
||||
|
||||
日志主要是用来记录系统运行期间发生过的离散事件。我想应该没有哪一个生产系统会缺少日志功能,不过我也相信,没有多少人会把日志看作是多关键的功能。它就像是阳光与空气,不可或缺但又不太被人重视。
|
||||
|
||||
除此之外,我想在座的很多人也都会说日志很简单,其实这是在说“打印日志”这个操作简单。打印日志的目的是为了日后能从中得到有价值的信息,而今天只要是稍微复杂点的系统,尤其是复杂的分布式系统,就很难只依靠tail、grep、awk来从日志中挖掘信息了,往往还要有专门的全局查询和可视化功能。
|
||||
|
||||
此时,从打印日志到分析查询之间,还隔着收集、缓冲、聚合、加工、索引、存储等若干个步骤,如下图所示:
|
||||
|
||||
|
||||
|
||||
日志处理过程
|
||||
|
||||
而这一整个链条中,会涉及到大量需要我们注意的细节,其复杂性并不亚于任何一项技术或业务功能的实现。所以接下来,我就以这个日志的处理过程为主线,以最成熟的Elastic Stack技术栈为例子,给你介绍该链条每个步骤的目的与方法。
|
||||
|
||||
好,下面我们就先来了解下日志处理中的输出工作。
|
||||
|
||||
输出
|
||||
|
||||
要是说好的日志能像文章一样,让人读起来身心舒畅,这话肯定有夸大的成分,不过好的日志应该能做到像“流水账”一样,可以毫无遗漏地记录信息,格式统一,内容恰当。其中,“恰当”是一个难点,它要求日志不应该过多,也不应该过少。
|
||||
|
||||
这里的“多与少”一般不针对输出的日志行数。尽管我听说过最夸张的系统,有单节点INFO级别下,每天的日志都能以TB计算(这样的是代码有问题的),给网络与磁盘I/O带来了不小的压力,但我通常不会用数量来衡量日志是否恰当。
|
||||
|
||||
我所说的“恰当”,是指日志中不该出现的内容不要有,而该有的不要少。具体是什么意思呢?下面我就分别给你举几个例子。
|
||||
|
||||
不该出现的内容不要有
|
||||
|
||||
首先,我们来看看有哪些常见的“不应该有”的日志内容:
|
||||
|
||||
|
||||
避免打印敏感信息
|
||||
|
||||
|
||||
不用专门去提醒,我们肯定都知道不该把密码、银行账号、身份证件等这些敏感信息打到日志里,但我就见过不止一个系统的日志中,能直接找到这些信息。一旦这些敏感信息随日志流到了后续的索引、存储、归档等步骤后,清理起来就会非常麻烦。
|
||||
|
||||
不过,日志中应该要包含必要的非敏感信息,比如当前用户的ID(最好是内部ID,避免登录名或者用户名称),有些系统就直接用MDC(Mapped Diagnostic Context)把用户ID自动打印在Pattern Layout上。
|
||||
|
||||
|
||||
避免引用慢操作
|
||||
|
||||
|
||||
要知道,日志中打印的信息应该是在上下文中可以直接取到的,而如果当前的上下文中根本没有这项数据,需要专门调用远程服务或者从数据库中获取,又或者要通过大量计算才能取到的话,那我们就应该先考虑下,这项信息放到日志中是不是必要且恰当的。
|
||||
|
||||
|
||||
避免打印追踪诊断信息
|
||||
|
||||
|
||||
即日志中不要打印方法输入参数、输出结果、方法执行时长之类的调试信息。
|
||||
|
||||
这个观点其实是反直觉的,不少公司甚至会提倡把这一点作为最佳实践,但是我仍然坚持把它归入反模式中。这是因为日志的职责是记录事件,而追踪诊断应该由追踪系统去处理,哪怕贵公司完全没有开发追踪诊断方面功能的打算,我也建议使用BTrace或者Arthas这类“On-The-Fly”的工具来解决。
|
||||
|
||||
还有,我之所以将其归为反模式,也是因为前面所说的敏感信息、慢操作等主要源头,就是这些原本想用于调试的日志。
|
||||
|
||||
比如,当前方法入口参数有个User对象,如果要输出这个对象的话,常见的做法是将它序列化成JSON字符串,然后打到日志里。那么这个时候,User里面的Password字段、BankCard字段就很容易被暴露出来。
|
||||
|
||||
再比如,当前方法的返回值是个Map,我们在开发期的调试数据只做了三五个Entity,然后觉得遍历一下把具体内容打到日志里面没什么问题。而到了生产期,这个Map里面有可能存放了成千上万个Entity,那么这时候打印日志就相当于引用慢操作了。
|
||||
|
||||
|
||||
避免误导他人
|
||||
|
||||
|
||||
你可能也知道,在日志中给以后调试除错的人挖坑是十分恶劣却又常见的行为。不过我觉得大部分人并不是专门要去误导别人,很可能只是无意识地这样做了。
|
||||
|
||||
比如,明明已经在逻辑中妥善处理好了某个异常,只是偏习惯性地调用printStackTrace()方法,把堆栈打到日志中,那么一旦这个方法附近出现问题,由其他人来除错的话,就很容易会盯着这段堆栈去找线索,从而浪费大量时间。
|
||||
|
||||
|
||||
……
|
||||
|
||||
|
||||
该出现的内容不要少
|
||||
|
||||
然后,日志中不该缺少的内容也“不应该少”,这里我同样给你举几个建议应该输出到日志中的内容的例子:
|
||||
|
||||
|
||||
处理请求时的TraceID
|
||||
|
||||
|
||||
当服务收到请求时,如果该请求没有附带TraceID,就应该自动生成唯一的TraceID来对请求进行标记,并使用MDC自动输出到日志。TraceID会贯穿整条调用链,目的是通过它把请求在分布式系统各个服务中的执行过程给串联起来。TraceID通常也会随着请求的响应返回到客户端,如果响应内容出现了异常,用户就能通过此ID快速找到与问题相关的日志。
|
||||
|
||||
这个TraceID其实是链路追踪里的概念,类似的还有用于标识进程内调用状况的SpanID,在Java程序中,这些都可以用Spring Cloud Sleuth来自动生成(下一讲我还会提到)。
|
||||
|
||||
另外,尽管TraceID在分布式追踪系统中会发挥最大的作用,但对单体系统来说,将TraceID记录到日志并返回给最终用户,对快速定位错误也仍然十分有价值。
|
||||
|
||||
|
||||
系统运行过程中的关键事件
|
||||
|
||||
|
||||
我们都知道,日志的职责就是记录事件,包括系统进行了哪些操作、发生了哪些与预期不符的情况、在运行期间出现了哪些未能处理的异常或警告、定期自动执行的各种任务,等等,这些都应该在日志中完整地记录下来。
|
||||
|
||||
那么原则上,程序中发生的事件只要有价值,就应该去记录,但我们还是要判断清楚事件的重要程度,选定相匹配的日志的级别。至于如何快速处理大量日志,这是后面的步骤需要考虑的问题,如果输出日志实在太频繁,以至于影响到了性能,就应该由运维人员去调整全局或单个类的日志级别来解决。
|
||||
|
||||
|
||||
启动时输出配置信息
|
||||
|
||||
|
||||
与避免输出诊断信息不同,对于系统启动时或者检测到配置中心变化时更新的配置,就应该把非敏感的配置信息输出到日志中,比如连接的数据库、临时目录的路径等等,因为初始化配置的逻辑一般只会执行一次,不便于诊断时复现,所以应该输出到日志中。
|
||||
|
||||
|
||||
……
|
||||
|
||||
|
||||
总而言之,日志输出是程序中非常普遍的行为,我们要把握好“不应该有”和“不应该少”这两个关键点。接下来,我们继续学习日志处理分析链路中,关于收集和缓冲这两个步骤。
|
||||
|
||||
收集与缓冲
|
||||
|
||||
我们知道,写日志是在服务节点中进行的,但我们不可能在每个节点都单独建设日志查询功能。这不是资源或工作量的问题,而是分布式系统处理一个请求要跨越多个服务节点,因此为了能看到跨节点的全部日志,就要有能覆盖整个链路的全局日志系统。
|
||||
|
||||
那么这个需求就决定了,当每个节点输出日志到文件后,就必须要把日志文件统一收集起来,集中存储、索引(这一步由Elasticsearch来负责),由此便催生出了专门的日志收集器。
|
||||
|
||||
最初,ELK(Elastic Stack)中的日志收集与下面要讲的加工聚合的职责,都是由Logstash来承担的。Logstash既部署在各个节点中作为收集的客户端(Shipper),也同时有独立部署的节点,扮演归集转换日志的服务端(Master)。毕竟Logstash有良好的插件化设计,而收集、转换、输出都支持插件化定制,所以它应对多重角色本身并没有什么困难。
|
||||
|
||||
但问题是,Logstash与它的插件是基于JRuby编写的,要跑在单独的Java虚拟机进程上,而且Logstash默认的堆大小就到了1GB。对于归集部分(Master)来说,这种消耗当然不算什么问题,但作为每个节点都要部署的日志收集器,这样的消耗就显得太过负重了。
|
||||
|
||||
所以后来,Elastic.co公司就把所有需要在服务节点中处理的工作,整理成了以Libbeat为核心的Beats框架,并使用Golang重写了一个功能较少,却更轻量高效的日志收集器,这就是今天流行的Filebeat。
|
||||
|
||||
现在的Beats已经是一个很大的家族了,除了Filebeat外,Elastic.co还提供用于收集Linux审计数据的Auditbeat、用于无服务计算架构的Functionbeat、用于心跳检测的Heartbeat、用于聚合度量的Metricbeat、用于收集Linux Systemd Journald日志的Journalbeat、用于收集Windows事件日志的Winlogbeat,用于网络包嗅探的Packetbeat,等等。
|
||||
|
||||
而如果再算上大量由社区维护的Community Beats,那几乎是你能想象到的数据都可以被收集到,以至于ELK在一定程度上也可以代替度量和追踪系统,实现它们的部分职能。
|
||||
|
||||
这对于中小型分布式系统来说是很便利的,但对于大型系统,我建议还是让专业的工具去做专业的事情。
|
||||
|
||||
还有一点你要知道,日志收集器不仅要保证能覆盖全部数据来源,还要尽力保证日志数据的连续性,这其实是不太容易做到的。为啥呢?
|
||||
|
||||
我给你举个例子。像淘宝这类大型的互联网系统,每天的日志量超过了10,000TB(10PB)量级,日志收集器的部署实例数能达到百万量级,那么此时归集到系统中的日志,要想与实际产生的日志保持绝对的一致性,是非常困难的,我们也不应该为此付出过高的成本。
|
||||
|
||||
所以换言之,日志的处理分析其实并不追求绝对的完整精确,只追求在代价可承受的范围内,尽可能地保证较高的数据质量。
|
||||
|
||||
一种最常用的缓解压力的做法,是将日志接收者从Logstash和Elasticsearch转移至抗压能力更强的队列缓存。比如在Logstash之前,架设一个Kafka或者Redis作为缓冲层,当面对突发流量,Logstash或Elasticsearch的处理能力出现瓶颈时,就自动削峰填谷,这样甚至当它们短时间停顿,也不会丢失日志数据。
|
||||
|
||||
加工与聚合
|
||||
|
||||
那么,在将日志集中收集之后,以及存入Elasticsearch之前,我们一般还要对它们进行加工转换和聚合处理,这一步通常就要使用到前面我提过的Logstash。
|
||||
|
||||
这是因为日志是非结构化数据,一行日志中通常会包含多项信息,如果不做处理,那在Elasticsearch就只能以全文检索的原始方式去使用日志,这样既不利于统计对比,也不利于条件过滤。
|
||||
|
||||
举个具体例子,下面是一行Nginx服务器的Access Log,代表了一次页面访问操作:
|
||||
|
||||
14.123.255.234 - - [19/Feb/2020:00:12:11 +0800] "GET /index.html HTTP/1.1" 200 1314 "https://icyfenix.cn" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36"
|
||||
|
||||
|
||||
在这一行日志里面,包含了下表所列的10项独立数据项:
|
||||
|
||||
|
||||
|
||||
所以也就是说,Logstash的基本职能是把日志行中的非结构化数据,通过Grok表达式语法转换为表格那样的结构化数据。而在进行结构化的同时,它还可能会根据需要,调用其他插件来完成时间处理(统一时间格式)、类型转换(如字符串、数值的转换)、查询归类(比如将IP地址根据地理信息库按省市归类)等各种额外处理的工作,然后以JSON格式输出到Elasticsearch中(这是最普遍的输出形式,Logstash输出也有很多插件可以具体定制不同的格式)。
|
||||
|
||||
如此一来,有了这些经过Logstash转换,已经结构化了的日志,Elasticsearch便可针对不同的数据项来建立索引,进行条件查询、统计、聚合等操作了。
|
||||
|
||||
而提到聚合,这也是Logstash的另一个常见职能。
|
||||
|
||||
我们已经知道,日志中存储的是离散事件,离散的意思就是每个事件都是相互独立的,比如有10个用户访问服务,他们操作所产生的事件都会在日志中分别记录。
|
||||
|
||||
那么,如果想从离散的日志中获得统计信息,比如想知道这些用户中正常返回(200 OK)的有多少、出现异常的(500 Internal Server Error)的有多少,再生成个可视化统计图表,一种解决方案是通过Elasticsearch本身的处理能力做实时的聚合统计。这是一种很便捷的方式,不过要消耗Elasticsearch服务器的运算资源。
|
||||
|
||||
另一种解决方案是在收集日志后自动生成某些常用的、固定的聚合指标,这种聚合就会在Logstash中通过聚合插件来完成。
|
||||
|
||||
这两种聚合方式都有不少的实际应用,前者一般用于应对即席查询,后者更多是用于应对固定查询。
|
||||
|
||||
存储与查询
|
||||
|
||||
OK,经过了前面收集、缓冲、加工、聚合之后的日志数据,现在就终于可以放入Elasticsearch中索引存储了。
|
||||
|
||||
可以说,Elasticsearch是整个Elastic Stack技术栈的核心。其他步骤的工具,比如Filebeat、Logstash、Kibana等都有替代品,有自由选择的余地,唯独Elasticsearch在日志分析这方面,完全没有什么值得一提的竞争者,几乎就是解决这个问题的唯一答案。
|
||||
|
||||
这样的结果肯定与Elasticsearch本身就是一款优秀的产品有关,然而更关键的是,Elasticsearch的优势正好与日志分析的需求完美契合,我们可以根据以下三个角度进行观察:
|
||||
|
||||
|
||||
从数据特征的角度看
|
||||
|
||||
|
||||
日志是典型的基于时间的数据流,但它与其他时间数据流,比如你的新浪微博、微信朋友圈这种社交网络数据又稍微有点儿区别:日志虽然增长速度很快,但已经写入的数据几乎没有再发生变动的可能。
|
||||
|
||||
由此可见,日志的数据特征就决定了所有用于日志分析的Elasticsearch,都会使用时间范围作为索引,比如根据实际数据量的大小,可能是按月、按周或者按日、按时。
|
||||
|
||||
这里我以按日索引为例,因为你能准确地预知明天、后天的日期,所以全部索引都可以预先创建,这就免去了动态创建时的寻找节点、创建分片、在集群中广播变动信息等开销。而又因为所有新的日志都是“今天”的日志,所以你只要建立“logs_current”这样的索引别名,来指向当前索引,就能避免代码因日期而变动。
|
||||
|
||||
|
||||
从数据价值的角度看
|
||||
|
||||
|
||||
日志基本上只会以最近的数据为检索目标,随着时间推移,早期的数据会逐渐失去价值。这点就决定了我们可以很容易地区出分冷数据和热数据,进而对不同数据采用不一样的硬件策略。
|
||||
|
||||
比如说,为热数据配备SSD磁盘和更好的处理器,为冷数据配备HDD磁盘和较弱的处理器,甚至可以放到更为廉价的对象存储(如阿里云的OSS、腾讯云的COS、AWS的S3)中归档。
|
||||
|
||||
不过这里我也想给你提个醒儿,咱们课程这部分的主题是日志在可观测性方面的作用,而还有一些基于日志的其他类型的应用,比如从日志记录的事件中去挖掘业务热点、分析用户习惯等等,这就属于大数据挖掘的范畴了,并不在我们讨论“价值”的范围之内,事实上它们更可能采用的技术栈是HBase与Spark的组合,而不是Elastic Stack。
|
||||
|
||||
|
||||
从数据使用的角度看
|
||||
|
||||
|
||||
要知道,分析日志很依赖全文检索和即席查询,这对实时性的要求就是处于实时与离线两者之间的“近实时”,也就是并不强求日志产生后立刻能查到,但我们也不能接受日志产生之后按小时甚至按天的频率来更新,而这些检索能力和近实时性,也正好都是Elasticsearch的强项。
|
||||
|
||||
Elasticsearch只提供了API层面的查询能力,它通常搭配同样出自Elastic.co公司的Kibana一起使用,我们可以把Kibana看作是Elastic Stack的GUI部分。
|
||||
|
||||
不过,尽管Kibana只负责图形界面和展示,但它提供的能力,远不止让你能在界面上执行Elasticsearch的查询那么简单。
|
||||
|
||||
Kibana宣传的核心能力是“探索数据并可视化”,也就是把存储在Elasticsearch中的,被检索、聚合、统计后的数据,定制形成各种图形、表格、指标、统计,以此观察系统的运行状态,找出日志事件中潜藏的规律和隐患。按Kibana官方的宣传语来说,就是“一张图片胜过千万行日志”。
|
||||
|
||||
|
||||
|
||||
Kibana可视化界面
|
||||
|
||||
小结
|
||||
|
||||
这节课,我们学习了日志从输出、收集、缓冲、加工、聚合、存储、查询等这些步骤的职责与常见的解决方案。
|
||||
|
||||
由于日志是程序中最基础的功能之一,我们每个人一定都做过,所以我只花了一节课的时间去讲解,而我的重点并不在于介绍具体的步骤该如何操作,而在于向你呈现每个步骤需要注意的事项。你可以记住以下几个核心要点:
|
||||
|
||||
|
||||
好的日志要能够毫无遗漏地记录信息、格式统一、内容恰当,而“恰当”的真正含义是指日志中不该出现的内容不要有,而该有的不要少。
|
||||
分布式系统处理一个请求要跨越多个服务节点,因此当每个节点输出日志到文件后,就必须要把日志文件统一收集起来,集中存储、索引,而这正是日志收集器需要做的工作。此外,日志收集器还要尽力保证日志数据的连续性。
|
||||
由于日志是非结构化数据,因此我们需要进行加工,把日志行中的非结构化数据转换为结构化数据,以便针对不同的数据项来建立索引,进行条件查询、统计、聚合等操作。
|
||||
|
||||
|
||||
一课一思
|
||||
|
||||
这节课里,我把日志中“打印追踪诊断信息”作为一种反模式来进行说明,这点其实是有争议的,很多公司、程序员都提倡在日志中打印尽可能多的调试信息,以便跟踪解决问题。那你是如何看待这点的呢?
|
||||
|
||||
欢迎在留言区分享你的见解。如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。好,感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
159
专栏/周志明的架构课/43_一个完整的分布式追踪系统是什么样子的?.md
Normal file
159
专栏/周志明的架构课/43_一个完整的分布式追踪系统是什么样子的?.md
Normal file
@@ -0,0 +1,159 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
43 _ 一个完整的分布式追踪系统是什么样子的?
|
||||
你好,我是周志明。这节课我们来讨论链路追踪的话题。
|
||||
|
||||
虽然在2010年之前,就已经有了X-Trace、Magpie等跨服务的追踪系统了,但现代分布式链路追踪公认的起源,是Google在2010年发表的论文《Dapper : a Large-Scale Distributed Systems Tracing Infrastructure》,这篇论文介绍了Google从2004年开始使用的分布式追踪系统Dapper的实现原理。
|
||||
|
||||
此后,所有业界有名的追踪系统,无论是国外Twitter的Zipkin、Naver的Pinpoint(Naver是Line的母公司,Pinpoint的出现其实早于Dapper论文的发表,在Dapper论文中还提到了Pinpoint),还是国内阿里的鹰眼、大众点评的CAT、个人开源的SkyWalking(后来进入Apache基金会孵化毕业),都受到了Dapper论文的直接影响。
|
||||
|
||||
那么,从广义上讲,一个完整的分布式追踪系统,应该由数据收集、数据存储和数据展示三个相对独立的子系统构成;而从狭义上讲,则就只是特指链路追踪数据的收集部分。比如Spring Cloud Sleuth就属于狭义的追踪系统,通常会搭配Zipkin作为数据展示,搭配Elasticsearch作为数据存储来组合使用。
|
||||
|
||||
而前面提到的那些Dapper的徒子徒孙们,就大多都属于广义的追踪系统,它们通常也被称为“APM系统”(Application Performance Management,应用性能管理)。
|
||||
|
||||
追踪与跨度
|
||||
|
||||
为了有效地进行分布式追踪,Dapper提出了“追踪”与“跨度”两个概念。
|
||||
|
||||
从客户端发起请求抵达系统的边界开始,记录请求流经的每一个服务,直到向客户端返回响应为止,这整个过程就叫做一次“追踪”(Trace,为了不产生混淆,我后面就直接使用英文Trace来指代了)。
|
||||
|
||||
由于每次Trace都可能会调用数量不定、坐标不定的多个服务,那么为了能够记录具体调用了哪些服务,以及调用的顺序、开始时点、执行时长等信息,每次开始调用服务前,系统都要先埋入一个调用记录,这个记录就叫做一个“跨度”(Span)。
|
||||
|
||||
Span的数据结构应该足够简单,以便于能放在日志或者网络协议的报文头里;也应该足够完备,起码要含有时间戳、起止时间、Trace的ID、当前Span的ID、父Span的ID等能够满足追踪需要的信息。
|
||||
|
||||
事实上,每一次Trace都是由若干个有顺序、有层级关系的Span所组成一颗“追踪树”(Trace Tree),如下图所示:
|
||||
|
||||
|
||||
|
||||
Trace和Spans(图片来源于Dapper论文)
|
||||
|
||||
那么这样来看,我们就可以从下面两个角度来观察分布式追踪的特征:
|
||||
|
||||
从目标来看,链路追踪的目的是为排查故障和分析性能提供数据支持,系统在对外提供服务的过程中,持续地接受请求并处理响应,同时持续地生成Trace,按次序整理好Trace中每一个Span所记录的调用关系,就能绘制出一幅系统的服务调用拓扑图了。
|
||||
|
||||
这样,根据拓扑图中Span记录的时间信息和响应结果(正常或异常返回),我们就可以定位到缓慢或者出错的服务;然后,将Trace与历史记录进行对比统计,就可以从系统整体层面分析服务性能,定位性能优化的目标。
|
||||
|
||||
从实现来看,为每次服务调用记录Trace和Span,并以此构成追踪树结构,看起来好像也不是很复杂。然而考虑到实际情况,追踪系统在功能性和非功能性上都有不小的挑战。
|
||||
|
||||
功能上的挑战来源于服务的异构性,各个服务可能会采用不同的程序语言,服务间的交互也可能会采用不同的网络协议,每兼容一种场景,都会增加功能实现方面的工作量。
|
||||
|
||||
而非功能性的挑战,具体就来源于以下这四个方面:
|
||||
|
||||
|
||||
低性能损耗:分布式追踪不能对服务本身产生明显的性能负担。追踪的主要目的之一就是为了寻找性能缺陷,越慢的服务就越是需要追踪,所以工作场景都是性能敏感的地方。
|
||||
对应用透明:追踪系统通常是运维期才事后加入的系统,所以应该尽量以非侵入或者少侵入的方式来实现追踪,对开发人员做到透明化。
|
||||
随应用扩缩:现代的分布式服务集群都有根据流量压力自动扩缩的能力,这就要求当业务系统扩缩时,追踪系统也能自动跟随,不需要运维人员人工参与。
|
||||
持续的监控:即要求追踪系统必须能够7x24小时工作,否则就难以定位到系统偶尔抖动的行为。
|
||||
|
||||
|
||||
所以总而言之,分布式追踪的主要需求是如何围绕着一个服务调用过程中的Trace和Span,来低损耗、高透明度地收集信息,不管是狭义还是广义的链路追踪系统,都要包含数据收集的工作,这是可以说是追踪系统的核心。那么接下来,我们就来了解下三种主流的数据收集方式。
|
||||
|
||||
数据收集的三种主流实现方式
|
||||
|
||||
目前,追踪系统根据数据收集方式的差异,可以分为三种主流的实现方式,分别是基于日志的追踪(Log-Based Tracing),基于服务的追踪(Service-Based Tracing)和基于边车代理的追踪(Sidecar-Based Tracing)。
|
||||
|
||||
基于日志的追踪
|
||||
|
||||
基于日志的追踪思路是将Trace、Span等信息直接输出到应用日志中,然后随着所有节点的日志归集过程汇聚到一起,再从全局日志信息中反推出完整的调用链拓扑关系。日志追踪对网络消息完全没有侵入性,对应用程序只有很少量的侵入性,对性能的影响也非常低。
|
||||
|
||||
但这种实现方式的缺点是直接依赖于日志归集过程,日志本身不追求绝对的连续与一致,这就导致了基于日志的追踪,往往不如其他两种追踪实现来的精准。
|
||||
|
||||
还有一个问题是,由于业务服务的调用与日志的归集并不是同时完成的,也通常不由同一个进程完成,有可能发生业务调用已经顺利结束了,但由于日志归集不及时或者精度丢失,导致日志出现延迟或缺失记录,进而产生追踪失真的情况。这也正是我在上节课介绍Elastic Stack时提到的观点,ELK在日志、追踪和度量方面都可以发挥作用,这对中小型应用确实能起到一定的便利作用,但对于大型系统来说,最好还是由专业的工具来做专业的事。
|
||||
|
||||
日志追踪的代表产品是Spring Cloud Sleuth,下面是一段由Sleuth在调用时自动生成的日志记录,你可以从中观察到TraceID、SpanID、父SpanID等追踪信息。
|
||||
|
||||
# 以下为调用端的日志输出:
|
||||
Created new Feign span [Trace: cbe97e67ce162943, Span: bb1798f7a7c9c142, Parent: cbe97e67ce162943, exportable:false]
|
||||
2019-06-30 09:43:24.022 [http-nio-9010-exec-8] DEBUG o.s.c.s.i.web.client.feign.TraceFeignClient - The modified request equals GET http://localhost:9001/product/findAll HTTP/1.1
|
||||
|
||||
X-B3-ParentSpanId: cbe97e67ce162943
|
||||
X-B3-Sampled: 0
|
||||
X-B3-TraceId: cbe97e67ce162943
|
||||
X-Span-Name: http:/product/findAll
|
||||
X-B3-SpanId: bb1798f7a7c9c142
|
||||
|
||||
# 以下为服务端的日志输出:
|
||||
[findAll] to a span [Trace: cbe97e67ce162943, Span: bb1798f7a7c9c142, Parent: cbe97e67ce162943, exportable:false]
|
||||
Adding a class tag with value [ProductController] to a span [Trace: cbe97e67ce162943, Span: bb1798f7a7c9c142, Parent: cbe97e67ce162943, exportable:false]
|
||||
|
||||
|
||||
基于服务的追踪
|
||||
|
||||
基于服务的追踪是目前最为常见的追踪实现方式,被Zipkin、SkyWalking、Pinpoint等主流追踪系统广泛采用。服务追踪的实现思路是通过某些手段给目标应用注入追踪探针(Probe),比如针对Java应用,一般就是通过Java Agent注入的。
|
||||
|
||||
探针在结构上可以看作是一个寄生在目标服务身上的小型微服务系统,它一般会有自己专用的服务注册、心跳检测等功能,有专门的数据收集协议,可以把从目标系统中监控得到的服务调用信息,通过另一次独立的HTTP或者RPC请求,发送给追踪系统。
|
||||
|
||||
因此,基于服务的追踪会比基于日志的追踪消耗更多的资源,也具有更强的侵入性,而换来的收益就是追踪的精确性与稳定性都有所保证,不必再依靠日志归集来传输追踪数据。
|
||||
|
||||
这里我放了一张Pinpoint的追踪效果截图,从图中可以看到参数、变量等相当详细的方法级调用信息。不知道你还记不记得,在上节课“日志分析”里,我把“打印追踪诊断信息”列为了反模式,并提到,如果需要诊断方法参数、返回值、上下文信息,或者方法调用耗时这类数据,通过追踪系统来实现,会是比通过日志系统实现更加恰当的解决方案。
|
||||
|
||||
|
||||
|
||||
Pinpoint的追踪截图
|
||||
|
||||
另外,我也必须给你说明清楚,像图例中的Pinpoint这种详细程度的追踪,对应用系统的性能压力是相当大的,一般仅在除错时开启,而且Pinpoint本身就是比较重负载的系统(运行它必须先维护一套HBase),这其实就严重制约了它的适用范围。目前服务追踪的其中一个发展趋势是轻量化,国产的SkyWalking正是这方面的佼佼者。
|
||||
|
||||
基于边车代理的追踪
|
||||
|
||||
基于边车代理的追踪是服务网格的专属方案,也是最理想的分布式追踪模型,它对应用完全透明,无论是日志还是服务本身,都不会有任何变化;它与程序语言无关,无论应用是采用什么编程语言来实现的,只要它还是通过网络(HTTP或者gRPC)来访问服务,就可以被追踪到;它也有自己独立的数据通道,追踪数据通过控制平面进行上报,避免了追踪对程序通信或者日志归集的依赖和干扰,保证了最佳的精确性。
|
||||
|
||||
而如果要说这种追踪实现方式还有什么缺点的话,那就是服务网格现在还不够普及。当然未来随着云原生的发展,相信它会成为追踪系统的主流实现方式之一。
|
||||
|
||||
还有一点就是,边车代理本身对应用透明的工作原理,决定了它只能实现服务调用层面的追踪,像前面Pinpoint截图那样的本地方法调用级别的追踪诊断,边车代理是做不到的。
|
||||
|
||||
现在,市场占有率最高的边车代理Envoy就提供了相对完善的追踪功能,但没有提供自己的界面端和存储端,所以Envoy和Sleuth一样,都属于狭义的追踪系统,需要配合专门的UI与存储来使用。SkyWalking、Zipkin、Jaeger、LightStep Tracing等系统,现在都可以接受来自于Envoy的追踪数据,充当它的界面端。
|
||||
|
||||
不过,虽然链路追踪在数据的收集这方面,已经有了几种主流的实现方式,但各种追踪产品通常并不互通。接下来我们就具体看看追踪在行业标准与规范方面存在的问题。
|
||||
|
||||
追踪规范化
|
||||
|
||||
要知道,比起日志与度量,追踪这个领域的产品竞争要相对激烈得多。
|
||||
|
||||
一方面,在这个领域内目前还没有像日志、度量那样出现具有明显统治力的产品,仍处于群雄混战的状态。另一方面,现在几乎市面上所有的追踪系统,都是以Dapper的论文为原型发展出来的,基本都算是同门师兄弟,在功能上并没有太本质的差距,却又受制于实现细节,彼此互斥,很难搭配工作。
|
||||
|
||||
之所以出现这种局面,我觉得只能怪当初Google发表的Dapper只是论文,而不是有约束力的规范标准,它只提供了思路,并没有规定细节。比如该怎样进行埋点、Span上下文具体该有什么数据结构、怎样设计追踪系统与探针或者界面端的API接口,等等,这些都没有权威的规定。
|
||||
|
||||
因此,为了推进追踪领域的产品标准化,2016年11月,CNCF技术委员会接受了OpenTracing作为基金会的第三个项目。OpenTracing是一套与平台无关、与厂商无关、与语言无关的追踪协议规范,只要遵循OpenTracing规范,任何公司的追踪探针、存储、界面都可以随时切换,也可以相互搭配使用。
|
||||
|
||||
在操作层面,OpenTracing只是制定了一个很薄的标准化层,位于应用程序与追踪系统之间,这样,探针与追踪系统只要都支持OpenTracing协议,就算它们不是同一个厂商的产品,那也可以互相通讯。此外,OpenTracing还规定了微服务之间在发生调用时,应该如何传递Span信息(OpenTracing Payload)。
|
||||
|
||||
关于这几点,我们具体可以参考下图例中的绿色部分:
|
||||
|
||||
|
||||
|
||||
符合OpenTracing的软件架构
|
||||
|
||||
如此一来,在OpenTracing规范公布后,几乎所有业界有名的追踪系统,比如Zipkin、Jaeger、SkyWalking等,都很快宣布支持OpenTracing。
|
||||
|
||||
但谁也没想到的是,Google自己却在这个时候出来表示反对,并提出了与OpenTracing目标类似的OpenCensus规范,随后又得到了巨头Microsoft的支持和参与。
|
||||
|
||||
OpenCensus不仅涉及到追踪,还把指标度量也纳入了进来;而在内容上,它不仅涉及到规范制定,还把数据采集的探针和收集器都一起以SDK(目前支持五种语言)的形式提供出来了。
|
||||
|
||||
这样,OpenTracing和OpenCensus就迅速形成了可观测性的两大阵营,一边是在这方面深耕多年的众多老牌APM系统厂商,另一边是分布式追踪概念的提出者Google,以及与Google同样庞大的Microsoft。
|
||||
|
||||
所以,对追踪系统的规范化工作,也并没有平息厂商竞争的混乱,反倒是把水搅得更浑了。
|
||||
|
||||
不过,正当群众们买好西瓜搬好板凳的时候,2019年,OpenTracing和OpenCensus又忽然宣布握手言和,它们共同发布了可观测性的终极解决方案OpenTelemetry,并宣布会各自冻结OpenTracing和OpenCensus的发展。
|
||||
|
||||
OpenTelemetry的野心很大,它不仅包括了追踪规范,还包括了日志和度量方面的规范、各种语言的SDK,以及采集系统的参考实现。距离一个完整的追踪与度量系统,只是差了一个界面端和指标预警这些会与用户直接接触的后端功能,OpenTelemetry“大度”地把它们留给具体产品去实现,勉强算是没有对一众APM厂商赶尽杀绝,留了一条活路。
|
||||
|
||||
可以说,OpenTelemetry一诞生就带着无比炫目的光环,直接进入CNCF的孵化项目,它的目标是统一追踪、度量和日志三大领域(目前主要关注的是追踪和度量,在日志方面,官方表示将放到下一阶段再去处理)。不过,OpenTelemetry毕竟是2019年才出现的新生事物,尽管背景渊源深厚,前途光明,但未来究竟如何发展,能否打败现在已有的众多成熟系统,目前仍然言之尚早。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我给你介绍了分布式追踪里“追踪”与“跨度”两个概念,要知道目前几乎所有的追踪工具都是围绕这两个Dapper提出的概念所设计的,因此理解它们的含义,对你使用任何一款追踪工具都会有帮助。
|
||||
|
||||
而在理论之外,我还讲解了三种追踪数据收集的实现方式,分别是基于日志、基于服务、基于边车代理的追踪,你可以重点关注下这几种方式各自的优势和缺点,以此在工作实践中选择合适的追踪方式。
|
||||
|
||||
一课一思
|
||||
|
||||
在你所负责的产品中,有引入链路追踪工具吗?如果有,是哪一款?达到你的期望了吗?如果没有,你是如何解决应用运行过程中的除错、性能分析等问题的呢?
|
||||
|
||||
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给其他的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
192
专栏/周志明的架构课/44_聚合度量能给我们解决什么问题?.md
Normal file
192
专栏/周志明的架构课/44_聚合度量能给我们解决什么问题?.md
Normal file
@@ -0,0 +1,192 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
44 _ 聚合度量能给我们解决什么问题?
|
||||
你好,我是周志明。这节课我们来探讨“可观测性”这个小章节的最后一个话题:聚合度量。
|
||||
|
||||
度量(Metrics)的目的是揭示系统的总体运行状态。相信你可能在一些电影里见过这样的场景:舰船的驾驶舱或者卫星发射中心的控制室,处在整个房间最显眼的位置,布满整面墙壁的巨型屏幕里显示着一个个指示器、仪表板与统计图表,沉稳端坐中央的指挥官看着屏幕上闪烁变化的指标,果断决策,下达命令……
|
||||
|
||||
而如果以上场景被改成指挥官双手在键盘上飞舞,双眼紧盯着日志或者追踪系统,试图判断出系统工作是否正常。这光想像一下,你都能感觉到一股身份与行为不一致的违和气息,由此可见度量与日志、追踪的差别。
|
||||
|
||||
简单来说,度量就是用经过聚合统计后的高维度信息,以最简单直观的形式来总结复杂的过程,为监控、预警提供决策支持。
|
||||
|
||||
我们大多数人的人生经历可能都会比较平淡,没有驾驶航母的经验,甚至连一颗卫星或者导弹都没有发射过,那就只好打开电脑,按CTRL+ALT+DEL呼出任务管理器,看看下面这个熟悉的界面,它也是一个非常具有代表性的度量系统。
|
||||
|
||||
|
||||
|
||||
Windows系统的任务管理器界面
|
||||
|
||||
在总体上,度量可以分为客户端的指标收集、服务端的存储查询以及终端的监控预警三个相对独立的过程,每个过程在系统中一般也会设置对应的组件来实现。
|
||||
|
||||
那么现在呢,你不妨先来看一下我在后面举例时会用到的Prometheus组件流程图,图中Prometheus Server左边的部分都属于客户端过程,而右边的部分就属于终端过程。
|
||||
|
||||
|
||||
|
||||
虽然说Prometheus在度量领域的统治力,暂时还不如日志领域中Elastic Stack的统治地位那么稳固,但在云原生时代里,它基本已经算得上是事实标准了。所以接下来,我就主要以Prometheus为例,给你介绍这三部分组件的总体思路、大致内容与理论标准。
|
||||
|
||||
指标收集
|
||||
|
||||
我们先来了解下客户端指标收集部分的核心思想。这一部分主要是解决两个问题:“如何定义指标”以及“如何将这些指标告诉服务端”。
|
||||
|
||||
如何定义指标?
|
||||
|
||||
首先我们来聊聊“如何定义指标”这个问题。乍一看你可能会觉得它应该是与目标系统密切相关的,必须根据实际情况才能讨论,但其实并不绝对。
|
||||
|
||||
要知道,无论目标是何种系统,它都具备了一些共性特征,虽然在确定目标系统前,我们无法决定要收集什么指标,但指标的数据类型(Metrics Types)是可数的,所有通用的度量系统都是面向指标的数据类型来设计的,现在我就来一一给你解读下:
|
||||
|
||||
|
||||
计数度量器(Counter):这是最好理解也是最常用的指标形式,计数器就是对有相同量纲、可加减数值的合计量。比如业务指标像销售额、货物库存量、职工人数等;技术指标像服务调用次数、网站访问人数等,它们都属于计数器指标。
|
||||
瞬态度量器(Gauge):瞬态度量器比计数器更简单,它就表示某个指标在某个时点的数值,连加减统计都不需要。比如当前Java虚拟机堆内存的使用量,这就是一个瞬态度量器;再比如,网站访问人数是计数器,而网站在线人数则是瞬态度量器。
|
||||
吞吐率度量器(Meter):顾名思义,它是用于统计单位时间的吞吐量,即单位时间内某个事件的发生次数。比如在交易系统中,常以TPS衡量事务吞吐率,即每秒发生了多少笔事务交易;再比如,港口的货运吞吐率常以“吨/每天”为单位计算,10万吨/天的港口通常要比1万吨/天的港口的货运规模更大。
|
||||
直方图度量器(Histogram):直方图就是指常见的二维统计图,它的两个坐标分别是统计样本和该样本对应的某个属性的度量,以长条图的形式记录具体数值。比如经济报告中,要衡量某个地区历年的GDP变化情况,常会以GDP为纵坐标、时间为横坐标构成直方图来呈现。
|
||||
采样点分位图度量器(Quantile Summary):分位图是统计学中通过比较各分位数的分布情况的工具,主要用来验证实际值与理论值的差距,评估理论值与实际值之间的拟合度。比如,我们说“高考成绩一般符合正态分布”,这句话的意思就是:高考成绩高低分的人数都比较少,中等成绩的比较多,按不同分数段来统计人数,得出的统计结果一般能够与正态分布的曲线较好地拟合。
|
||||
除了以上常见的度量器之外,还有Timer、Set、Fast Compass、Cluster Histogram等其他各种度量器,采用不同的度量系统,支持度量器类型的范围肯定会有所差别,比如Prometheus就支持了上面提到的五种度量器中的Counter、Gauge、Histogram和Summary四种。
|
||||
|
||||
|
||||
如何将这些指标告诉服务端?
|
||||
|
||||
然后是针对“如何将这些指标告诉服务端”这个问题,它通常有两种解决方案:拉取式采集(Pull-Based Metrics Collection)和推送式采集(Push-Based Metrics Collection)。
|
||||
|
||||
所谓Pull是指度量系统主动从目标系统中拉取指标;相对地,Push就是由目标系统主动向度量系统推送指标。
|
||||
|
||||
这两种方式实际上并没有绝对的好坏优劣,以前很多老牌的度量系统,比如Ganglia、Graphite、StatsD等是基于Push的,而以Prometheus、Datadog、Collectd为代表的另一派度量系统则青睐Pull式采集(Prometheus官方解释选择Pull的原因)。另外你也要知道,对于是要选择Push还是Pull,不仅是在度量中才有,所有涉及到客户端和服务端通讯的场景,都会涉及到该谁主动的问题,上一节课讲的追踪系统也是如此。
|
||||
|
||||
不过一般来说,度量系统只会支持其中一种指标采集方式,这是因为度量系统的网络连接数量,以及对应的线程或者协程数可能非常庞大,如何采集指标将直接影响到整个度量系统的架构设计。
|
||||
|
||||
然而,Prometheus在基于Pull架构的同时,还能够有限度地兼容Push式采集,这是为啥呢?原因是它有Push Gateway的存在。
|
||||
|
||||
如下图所示,这是一个位于Prometheus Server外部的相对独立的中介模块,它会把外部推送来的指标放到Push Gateway中暂存,然后再等候Prometheus Server从Push Gateway中去拉取。
|
||||
|
||||
|
||||
|
||||
Prometheus组件流程图
|
||||
|
||||
Prometheus设计Push Gateway的本意是为了解决Pull的一些固有缺陷,比如目标系统位于内网,需要通过NAT访问外网,而外网的Prometheus是无法主动连接目标系统的,这就只能由目标系统主动推送数据;又比如某些小型短生命周期服务,可能还等不及Prometheus来拉取,服务就已经结束运行了,因此也只能由服务自己Push来保证度量的及时和准确。
|
||||
|
||||
而在由Push和Pull决定完该谁主动以后,另一个问题就是:指标应该通过怎样的网络访问协议、取数接口、数据结构来获取呢?
|
||||
|
||||
跟计算机科学中其他类似的问题一样,人们一贯的解决方向是“定义规范”,应该由行业组织和主流厂商一起协商出专门用于度量的协议,目标系统按照协议与度量系统交互。比如说,网络管理中的SNMP、Windows硬件的WMI,以及第41讲中提到的Java的JMX都属于这种思路的产物。
|
||||
|
||||
但是,定义标准这个办法在度量领域中其实没有那么有效,前面列举的这些度量协议,只是在特定的一小块领域里流行过。
|
||||
|
||||
要究其原因的话,一方面是因为业务系统要使用这些协议并不容易,你可以想像一下,让订单金额存到SNMP中,让Golang的系统把指标放到JMX Bean里,即便技术上可行,这也不像是正常程序员会干的事;而另一方面,度量系统又不会甘心局限于某个领域,成为某项业务的附属品。
|
||||
|
||||
另外我们也要明确一个事实,度量面向的是广义上的信息系统,它横跨存储(日志、文件、数据库)、通讯(消息、网络)、中间件(HTTP服务、API服务),直到系统本身的业务指标,甚至还会包括度量系统本身(部署两个独立的Prometheus互相监控是很常见的)。
|
||||
|
||||
所以,这些度量协议其实都没有成为最正确答案的希望。
|
||||
|
||||
如此一来,既然没有了标准,有一些度量系统,比如老牌的Zabbix就选择同时支持了SNMP、JMX、IPMI等多种不同的度量协议。而另一些以Prometheus为代表的度量系统就相对强硬,它们不支持任何一种协议,只允许通过HTTP访问度量端点这一种访问方式。如果目标提供了HTTP的度量端点(如Kubernetes、Etcd等本身就带有Prometheus的Client Library)就直接访问,否则就需要一个专门的Exporter来充当媒介。
|
||||
|
||||
这里的Exporter是Prometheus提出的概念,它是目标应用的代表,它既可以独立运行,也可以与应用运行在同一个进程中,只要集成Prometheus的Client Library就可以了。
|
||||
|
||||
Exporter的作用就是以HTTP协议(Prometheus在2.0版本之前支持过Protocol Buffer,目前已不再支持)返回符合Prometheus格式要求的文本数据给Prometheus服务器。得益于Prometheus的良好社区生态,现在已经有大量、各种用途的Exporter,让Prometheus的监控范围几乎能涵盖到所有用户关心的目标,绝大多数用户都只需要针对自己系统业务方面的度量指标编写Exporter即可。你可以参考下这里给出的表格:
|
||||
|
||||
|
||||
|
||||
另外顺便一提,在前面我提到了一堆没有希望成为最终答案的协议,比如SNMP、WMI等等。不过现在一种名为OpenMetrics的度量规范正逐渐从Prometheus的数据格式中分离出来,有望成为监控数据格式的国际标准,最终结果究竟如何,要看Prometheus本身的发展情况,还有OpenTelemetry与OpenMetrics的关系如何协调。
|
||||
|
||||
存储查询
|
||||
|
||||
好,那么当指标从目标系统采集过来了之后,就应该存储在度量系统中,以便被后续的分析界面、监控预警所使用。
|
||||
|
||||
存储数据对于计算机软件来说其实是司空见惯的操作,但如果用传统关系数据库的思路来解决度量系统的存储,效果可能不会太理想。
|
||||
|
||||
我举个例子,假设你要建设一个中等规模、有着200个节点的微服务系统,每个节点要采集的存储、网络、中间件和业务等各种指标加一起,也按200个来计算,监控的频率如果按秒为单位的话,一天时间内就会产生超过34亿条记录,而对于这个结果你可能会感到非常意外:
|
||||
|
||||
|
||||
200(节点)× 200(指标)× 86400(秒)= 3,456,000,000(记录)
|
||||
|
||||
|
||||
因为在实际情况中,大多数这种200节点规模的系统,本身一天的生产数据都远到不了34亿条,那么建设度量系统,肯定不能让度量反倒成了业务系统的负担。可见,度量的存储是需要专门研究解决的问题。至于具体要如何解决,让我们先来观察一段Prometheus的真实度量数据吧:
|
||||
|
||||
{
|
||||
// 时间戳
|
||||
"timestamp": 1599117392,
|
||||
// 指标名称
|
||||
"metric": "total_website_visitors",
|
||||
// 标签组
|
||||
"tags": {
|
||||
"host": "icyfenix.cn",
|
||||
"job": "prometheus"
|
||||
},
|
||||
// 指标值
|
||||
"value": 10086
|
||||
}
|
||||
|
||||
|
||||
通过观察,我们可以发现这段度量数据的特征:每一个度量指标由时间戳、名称、值和一组标签构成,除了时间之外,指标不与任何其他因素相关。
|
||||
|
||||
当然,指标的数据总量固然是不小的,但它没有嵌套、没有关联、没有主外键,不必关心范式和事务,这些就都是可以针对性优化的地方。事实上,业界也早就有了专门针对该类型数据的数据库,即“时序数据库”(Time Series Database)。
|
||||
|
||||
|
||||
额外知识:时序数据库-
|
||||
|
||||
时序数据库是用于存储跟随时间而变化的数据,并且以时间(时间点或者时间区间)来建立索引的数据库。-
|
||||
|
||||
时序数据库最早是应用于工业(电力行业、化工行业)应用的各类型实时监测、检查与分析设备所采集、产生的数据,这些工业数据的典型特点是产生频率快(每一个监测点一秒钟内可产生多条数据)、严重依赖于采集时间(每一条数据均要求对应唯一的时间)、测点多信息量大(常规的实时监测系统均可达到成千上万的监测点,监测点每秒钟都在产生数据)。-
|
||||
|
||||
时间序列数据是历史烙印,它具有不变性、唯一性、有序性。时序数据库同时具有数据结构简单、数据量大的特点。
|
||||
|
||||
|
||||
我们应该注意到,存储数据库在写操作时,时序数据通常只是追加,很少删改或者根本不允许删改。因此,针对数据热点只集中在近期数据、多写少读、几乎不删改、数据只顺序追加等特点,时序数据库被允许可以做出很激进的存储、访问和保留策略(Retention Policies):
|
||||
|
||||
|
||||
以日志结构的合并树(Log Structured Merge Tree,LSM-Tree)代替传统关系型数据库中的B+Tree作为存储结构,LSM适合的应用场景就是写多读少,且几乎不删改的数据。
|
||||
设置激进的数据保留策略,比如根据过期时间(TTL),自动删除相关数据以节省存储空间,同时提高查询性能。对于普通的数据库来说,数据会存储一段时间后被自动删除的这个做法,可以说是不可想象的。
|
||||
对数据进行再采样(Resampling)以节省空间,比如最近几天的数据可能需要精确到秒,而查询一个月前的数据只需要精确到天,查询一年前的数据只要精确到周就够了,这样将数据重新采样汇总,就极大地节省了存储空间。
|
||||
|
||||
|
||||
而除此之外,时序数据库中甚至还有一种并不罕见却更加极端的形式,叫做轮替型数据库(Round Robin Database,RRD),它是以环形缓冲(在“服务端缓存”一节介绍过)的思路实现,只能存储固定数量的最新数据,超期或超过容量的数据就会被轮替覆盖,因此它也有着固定的数据库容量,却能接受无限量的数据输入。
|
||||
|
||||
所以,Prometheus服务端自己就内置了一个强大的时序数据库实现,我说它“强大”并不是客气,在DB-Engines中近几年它的排名就在不断提升,目前已经跃居时序数据库排行榜的前三。
|
||||
|
||||
这个时序数据库提供了一个名为PromQL的数据查询语言,能对时序数据进行丰富的查询、聚合以及逻辑运算。当然了,某些时序库(如排名第一的InfluxDB)也会提供类SQL风格的查询,但PromQL不是,它是一套完全由Prometheus自己定制的数据查询DSL,写起来的风格有点像带运算与函数支持的CSS选择器。比如,我要查找网站icyfenix.cn的访问人数的话,会是如下写法:
|
||||
|
||||
// 查询命令:
|
||||
total_website_visitors{host=“icyfenix.cn”}
|
||||
|
||||
// 返回结果:
|
||||
total_website_visitors{host=“icyfenix.cn”,job="prometheus"}=(10086)
|
||||
|
||||
|
||||
这样,通过PromQL就可以轻易实现指标之间的运算、聚合、统计等操作,在查询界面中也往往需要通过PromQL计算多种指标的统计结果,才能满足监控的需要,语法方面的细节我就不详细展开了,具体你可以参考Prometheus的文档手册。
|
||||
|
||||
最后我还想补充说明一下,时序数据库对度量系统来说确实是很合适的选择,但并不是说绝对只有用时序数据库才能解决度量指标的存储问题,Prometheus流行之前最老牌的度量系统Zabbix,用的就是传统关系数据库来存储指标。
|
||||
|
||||
好,接下来我们继续探讨聚合度量的第三个过程:终端的监控预警。
|
||||
|
||||
监控预警
|
||||
|
||||
首先要知道,指标度量是手段,而最终目的是要做分析和预警。
|
||||
|
||||
界面分析和监控预警是与用户更加贴近的功能模块,但对度量系统本身而言,它们都属于相对外围的功能。与追踪系统的情况类似,广义上的度量系统由面向目标系统进行指标采集的客户端(Client,与目标系统进程在一起的Agent,或者代表目标系统的Exporter等都可归为客户端),负责调度、存储和提供查询能力的服务端(Server,Prometheus的服务端是带存储的,但也有很多度量服务端需要配合独立的存储来使用),以及面向最终用户的终端(Backend,UI界面、监控预警功能等都归为终端)组成;而狭义上的度量系统就只包括客户端和服务端,不包含终端。
|
||||
|
||||
那么按照定义,Prometheus应该算是处于狭义和广义的度量系统之间,尽管它确实内置了一个界面解决方案“Console Template”,以模版和JavaScript接口的形式提供了一系列预设的组件(菜单、图表等),让用户编写一段简单的脚本就可以实现可用的监控功能。不过这种可用程度,往往不足以支撑正规的生产部署,只能说是为把度量功能嵌入到系统的某个子系统中,提供了一定的便利。
|
||||
|
||||
因而在生产环境下,大多是Prometheus配合Grafana来进行展示的,这是Prometheus官方推荐的组合方案。但该组合也并非唯一的选择,如果你要搭配Klbana甚至SkyWalking(8.x版之后的SkyWalking支持从Prometheus获取度量数据)来使用,也都是完全可行的。
|
||||
|
||||
另外,良好的可视化能力对于提升度量系统的产品力也非常重要,长期趋势分析(比如根据对磁盘增长趋势的观察,判断什么时候需要扩容)、对照分析(比如版本升级后对比新旧版本的性能、资源消耗等方面的差异)、故障分析(不仅从日志、追踪自底向上可以分析故障,高维度的度量指标也可能自顶向下寻找到问题的端倪)等分析工作,既需要度量指标的持续收集、统计,往往还需要对数据进行可视化,这样才能让人更容易地从数据中挖掘规律,毕竟数据最终还是要为人类服务的。
|
||||
|
||||
而除了为分析、决策、故障定位等提供支持的用户界面外,度量信息的另一种主要的消费途径就是用来做预警。比如你希望当磁盘消耗超过90%时,给你发送一封邮件或者是一条微信消息,通知管理员过来处理,这就是一种预警。
|
||||
|
||||
Prometheus提供了专门用于预警的Alert Manager,我们将Alert Manager与Prometheus关联后,可以设置某个指标在多长时间内、达到何种条件就会触发预警状态,在触发预警后,Alert Manager就会根据路由中配置的接收器,比如邮件接收器、Slack接收器、微信接收器,或者更通用的WebHook接收器等来自动通知我们。
|
||||
|
||||
小结
|
||||
|
||||
今天是“可观测性”章节的最后一节课,可观测性作为控制理论中的一个概念,从1960年代起就已经存在了,虽然它针对信息系统和分布式服务的适用性,是最近若干年中新发现的,但在某种程度上,这也算是过去20年对这些系统的监控方式的演变产物。
|
||||
|
||||
那么学完了今天这节课,你需要记住一个要点,即传统监控和可观测性之间的关键区别在于:可观测性是系统或服务内在的固有属性,而不是在系统之外对系统所做出的额外增强,后者是传统监控的处理思路。
|
||||
|
||||
除此之外,构建具有可观测性的服务,也是构建健壮服务不可缺少的属性,这是分布式系统架构师的职责。那么作为服务开发者和设计者,我们应该在其建设期间,就要设想控制系统会发出哪些信号、如何接收和存储这些信号,以及如何使用它们,以确保在用户能在受到影响之前了解问题、能使用度量数据来更好地了解系统的健康状况和状态。
|
||||
|
||||
一课一思
|
||||
|
||||
在你设计系统时,是否考虑过要对外部暴露哪些可观测的属性?你通常会暴露哪些数据?是以什么方式暴露的呢?欢迎在留言区分享你的见解和做法。
|
||||
|
||||
如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
118
专栏/周志明的架构课/45_模块导学:从微服务到云原生.md
Normal file
118
专栏/周志明的架构课/45_模块导学:从微服务到云原生.md
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
45 _ 模块导学:从微服务到云原生
|
||||
你好,我是周志明。
|
||||
|
||||
上一个模块,我们以“分布式的基石”为主题,了解并学习了微服务中的关键技术问题与解决方案。实际上,解决这些技术问题,原本就是我们架构师和程序员的本职工作。
|
||||
|
||||
而从今天开始,我们就进入了一个全新的模块,从微服务走到了云原生。在这个模块里,我会围绕“不可变基础设施”的相关话题,以容器、编排系统和服务网格的发展为主线,给你介绍虚拟化容器与服务网格是如何模糊掉软件与硬件之间的界限,如何在基础设施与通讯层面上帮助微服务隐藏复杂性,以此解决原本只能由程序员通过软件编程来解决的分布式问题。
|
||||
|
||||
什么是不可变基础设施?
|
||||
|
||||
“不可变基础设施”这个概念由来已久。2012年,马丁 · 福勒(Martin Fowler)设想的“凤凰服务器”与2013年查德 · 福勒(Chad Fowler)正式提出的“不可变基础设施”,都阐明了基础设施不变性给我们带来的好处。
|
||||
|
||||
而在云原生基金会定义的“云原生”概念中,“不可变基础设施”提升到了与微服务平级的重要程度。此时,它的内涵已经不再局限于只是方便运维、程序升级和部署的手段,而是升华为了向应用代码隐藏分布式架构复杂度、让分布式架构得以成为一种能够普遍推广的普适架构风格的必要前提。
|
||||
|
||||
|
||||
云原生定义(Cloud Native Definition)-
|
||||
|
||||
Cloud native technologies empower organizations to build and run scalable applications in modern, dynamic environments such as public, private, and hybrid clouds. Containers, service meshes, microservices, immutable infrastructure, and declarative APIs exemplify this approach.-
|
||||
|
||||
These techniques enable loosely coupled systems that are resilient, manageable, and observable. Combined with robust automation, they allow engineers to make high-impact changes frequently and predictably with minimal toil.-
|
||||
|
||||
云原生技术有利于各组织在公有云、私有云和混合云等新型动态环境中,构建和运行可弹性扩展的应用。云原生的代表技术包括容器、服务网格、微服务、不可变基础设施和声明式API。-
|
||||
|
||||
这些技术能够构建容错性好、易于管理和便于观察的松耦合系统。结合可靠的自动化手段,云原生技术使工程师能够轻松地对系统作出频繁和可预测的重大变更。-
|
||||
—— Cloud Native Definition, CNCF,2018
|
||||
|
||||
|
||||
不过,不可变基础设施是一种抽象的概念,不太容易直接对它分解描述,所以为了能把云原生这个本来就比较抽象的架构思想落到实处,我选择从我们都比较熟悉的,至少是能看得见、摸得着的容器化技术开始讲起。
|
||||
|
||||
虚拟化的目标与类型
|
||||
|
||||
容器是云计算、微服务等诸多软件业界核心技术的共同基石。容器的首要目标是让软件分发部署的过程,从传统的发布安装包、靠人工部署,转变为直接发布已经部署好的、包含整套运行环境的虚拟化镜像。
|
||||
|
||||
在容器技术成熟之前,主流的软件部署过程是由系统管理员编译或下载好二进制安装包,根据软件的部署说明文档,准备好正确的操作系统、第三方库、配置文件、资源权限等各种前置依赖以后,才能将程序正确地运行起来。
|
||||
|
||||
这样做当然是非常麻烦的,Chad Fowler在提出“不可变基础设施”这个概念的文章《Trash Your Servers and Burn Your Code》里,开篇就直接吐槽:要把一个不知道打过多少个升级补丁,不知道经历了多少任管理员的系统迁移到其他机器上,毫无疑问会是一场灾难。
|
||||
|
||||
另外我们也知道,让软件能够在任何环境、任何物理机器上达到“一次编译,到处运行”,曾经是Java早年的宣传口号,不过这并不是一个简单的目标,不设前提的“到处运行”,仅靠Java语言和Java虚拟机是不可能达成的。因为一个计算机软件要能够正确运行,需要通过以下三方面的兼容性来共同保障(这里仅讨论软件兼容性,不去涉及“如果没有摄像头就无法运行照相程序”这类问题):
|
||||
|
||||
|
||||
ISA兼容:目标机器指令集兼容性,比如ARM架构的计算机无法直接运行面向x86架构编译的程序。
|
||||
ABI兼容:目标系统或者依赖库的二进制兼容性,比如Windows系统环境中无法直接运行Linux的程序,又比如DirectX 12的游戏无法运行在DirectX 9之上。
|
||||
环境兼容:目标环境的兼容性,比如没有正确设置的配置文件、环境变量、注册中心、数据库地址、文件系统的权限等等,当任何一个环境因素出现错误,都会让你的程序无法正常运行。
|
||||
|
||||
|
||||
|
||||
额外知识:ISA与ABI-
|
||||
|
||||
指令集架构(Instruction Set Architecture,ISA)是计算机体系结构中与程序设计有关的部分,包含了基本数据类型、指令集、寄存器、寻址模式、存储体系、中断、异常处理以及外部I/O。指令集架构包含一系列的Opcode操作码(即通常所说的机器语言),以及由特定处理器执行的基本命令。-
|
||||
|
||||
应用二进制接口(Application Binary Interface,ABI)是应用程序与操作系统之间或其他依赖库之间的低级接口。ABI涵盖了各种底层细节,如数据类型的宽度大小、对象的布局、接口调用约定等等。ABI不同于应用程序接口(Application Programming Interface,API),API定义的是源代码和库之间的接口,因此同样的代码可以在支持这个API的任何系统中编译,而ABI允许编译好的目标代码在使用兼容ABI的系统中无需改动就能直接运行。
|
||||
|
||||
|
||||
这里,我把使用仿真(Emulation)以及虚拟化(Virtualization)技术来解决以上三项兼容性问题的方法,都统称为虚拟化技术。那么,根据抽象目标与兼容性高低的不同,虚拟化技术又分为了五类,下面我们就分别来看看:
|
||||
|
||||
|
||||
指令集虚拟化(ISA Level Virtualization)
|
||||
|
||||
|
||||
即通过软件来模拟不同ISA架构的处理器工作过程,它会把虚拟机发出的指令转换为符合本机ISA的指令,代表为QEMU和Bochs。
|
||||
|
||||
指令集虚拟化就是仿真,它提供了几乎完全不受局限的兼容性,甚至能做到直接在Web浏览器上运行完整操作系统这种令人惊讶的效果。但是,由于每条指令都要由软件来转换和模拟,它也是性能损失最大的虚拟化技术。
|
||||
|
||||
|
||||
硬件抽象层虚拟化(Hardware Abstraction Level Virtualization)
|
||||
|
||||
|
||||
即以软件或者直接通过硬件来模拟处理器、芯片组、内存、磁盘控制器、显卡等设备的工作过程。
|
||||
|
||||
硬件抽象层虚拟化既可以使用纯软件的二进制翻译来模拟虚拟设备,也可以由硬件的Intel VT-d、AMD-Vi这类虚拟化技术,将某个物理设备直通(Passthrough)到虚拟机中使用,代表为VMware ESXi和Hyper-V。这里你可以知道的是,如果没有预设语境,一般人们所说的“虚拟机”就是指这一类虚拟化技术。
|
||||
|
||||
|
||||
操作系统层虚拟化(OS Level Virtualization)
|
||||
|
||||
|
||||
无论是指令集虚拟化还是硬件抽象层虚拟化,都会运行一套完全真实的操作系统,来解决ABI兼容性和环境兼容性的问题,虽然ISA兼容性是虚拟出来的,但ABI兼容性和环境兼容性却是真实存在的。
|
||||
|
||||
而操作系统层虚拟化则不会提供真实的操作系统,而是会采用隔离手段,使得不同进程拥有独立的系统资源和资源配额,这样看起来它好像是独享了整个操作系统一般,但其实系统的内核仍然是被不同进程所共享的。
|
||||
|
||||
操作系统层虚拟化的另一个名字,就是这个模块的主角“容器化”(Containerization)。所以由此可见,容器化仅仅是虚拟化的一个子集,它只能提供操作系统内核以上的部分ABI兼容性与完整的环境兼容性。
|
||||
|
||||
而这就意味着,如果没有其他虚拟化手段的辅助,在Windows系统上是不可能运行Linux的Docker镜像的(现在可以,是因为有其他虚拟机或者WSL2的支持),反之亦然。另外,这也同样决定了,如果Docker宿主机的内核版本是Linux Kernel 5.6,那无论上面运行的镜像是Ubuntu、RHEL、Fedora、Mint,或者是其他任何发行版的镜像,看到的内核一定都是相同的Linux Kernel 5.6。
|
||||
|
||||
容器化牺牲了一定的隔离性与兼容性,换来的是比前两种虚拟化更高的启动速度、运行性能和更低的执行负担。
|
||||
|
||||
|
||||
运行库虚拟化(Library Level Virtualization)
|
||||
|
||||
|
||||
与操作系统虚拟化采用隔离手段来模拟系统不同,运行库虚拟化选择使用软件翻译的方法来模拟系统,它是以一个独立进程来代替操作系统内核,来提供目标软件运行所需的全部能力。
|
||||
|
||||
那么,这种虚拟化方法获得的ABI兼容性高低,就取决于软件能不能足够准确和全面地完成翻译工作,它的代表为WINE(Wine Is Not an Emulator的缩写,一款在Linux下运行Windows程序的软件)和WSL(特指Windows Subsystem for Linux Version 1)。
|
||||
|
||||
|
||||
语言层虚拟化(Programming Language Level Virtualization)
|
||||
|
||||
|
||||
即由虚拟机将高级语言生成的中间代码,转换为目标机器可以直接执行的指令,代表为Java的JVM和.NET的CLR。
|
||||
|
||||
不过,虽然各大厂商肯定都会提供在不同系统下接口都相同的标准库,但本质上,这种虚拟化技术并不是直接去解决任何ABI兼容性和环境兼容性的问题,而是将不同环境的差异抽象封装成统一的编程接口,供程序员使用。
|
||||
|
||||
小结
|
||||
|
||||
作为整个模块的开篇,我们这节课的学习目的是要明确软件运行的“兼容性”指的是什么,以及要能理解我们经常能听到的“虚拟化”概念指的是什么。只有理清了这些概念、统一了语境,在后续的课程学习中,我们关于容器、编排、云原生等的讨论,才不会产生太多的歧义。
|
||||
|
||||
一课一思
|
||||
|
||||
这节课介绍的五种层次的虚拟化技术,有哪些是你在实际工作中真正用过的?你是用来达成什么目的呢?
|
||||
|
||||
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给其他的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
87
专栏/周志明的架构课/46_容器的崛起(上):文件、访问、资源的隔离.md
Normal file
87
专栏/周志明的架构课/46_容器的崛起(上):文件、访问、资源的隔离.md
Normal file
@@ -0,0 +1,87 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
46 _ 容器的崛起(上):文件、访问、资源的隔离
|
||||
你好,我是周志明。接下来的两节课,我会以容器化技术的发展为线索,带你从隔离与封装两个角度,去学习和了解容器技术。
|
||||
|
||||
今天,我们就先来学习下Linux系统中隔离技术的发展历程,以此为下节课理解“以容器封装应用”的思想打好前置基础。
|
||||
|
||||
隔离文件:chroot
|
||||
|
||||
首先要知道,人们使用容器的最初目的,并不是为了部署软件,而是为了隔离计算机中的各类资源,以便降低软件开发、测试阶段可能产生的误操作风险,或者是专门充当蜜罐,吸引黑客的攻击,以便监视黑客的行为。
|
||||
|
||||
容器的起点呢,可以追溯到1979年Version 7 UNIX系统中提供的chroot命令,这个命令是英文单词“Change Root”的缩写,它所具备的功能是当某个进程经过chroot操作之后,它的根目录就会被锁定在命令参数所指定的位置,以后它或者它的子进程就不能再访问和操作该目录之外的其他文件。
|
||||
|
||||
1991年,世界上第一个监控黑客行动的蜜罐程序就是使用chroot来实现的,那个参数指定的根目录当时被作者被戏称为“Chroot监狱”(Chroot Jail),而黑客突破chroot限制的方法就叫做Jailbreak。后来,FreeBSD 4.0系统重新实现了chroot命令,把它作为系统中进程沙箱隔离的基础,并将其命名为FreeBSD jail。
|
||||
|
||||
再后来,苹果公司又以FreeBSD为基础研发出了举世闻名的iOS操作系统,此时,黑客们就把绕过iOS沙箱机制,以root权限任意安装程序的方法称为“越狱”(Jailbreak),当然这些故事都是题外话了。
|
||||
|
||||
到了2000年,Linux Kernel 2.3.41版内核引入了pivot_root技术来实现文件隔离,pivot_root直接切换了根文件系统(rootfs),有效地避免了chroot命令可能出现的安全性漏洞。咱们课程后面要提到的容器技术,比如LXC、Docker等等,也都是优先使用pivot_root来实现根文件系统切换的。
|
||||
|
||||
不过时至今日,chroot命令依然活跃在Unix系统,以及几乎所有主流的Linux发行版中,同时也以命令行工具(chroot(8))或者系统调用(chroot(2) )的形式存在着,但无论是chroot命令还是pivot_root,它们都不能提供完美的隔离性。
|
||||
|
||||
其实原本按照Unix的设计哲学,一切资源都可以视为文件(In UNIX,Everything is a File),一切处理都可以视为对文件的操作,在理论上应该是隔离了文件系统就可以安枕无忧才对。
|
||||
|
||||
可是,哲学归哲学,现实归现实,从硬件层面暴露的低层次资源,比如磁盘、网络、内存、处理器,再到经操作系统层面封装的高层次资源,比如UNIX分时(UNIX Time-Sharing,UTS)、进程ID(Process ID,PID)、用户ID(User ID,UID)、进程间通信(Inter-Process Communication,IPC)等等,都存在着大量以非文件形式暴露的操作入口。
|
||||
|
||||
所以我才会说,以chroot为代表的文件隔离,仅仅是容器崛起之路的起点而已。
|
||||
|
||||
隔离访问:namespaces
|
||||
|
||||
那么到了2002年,Linux Kernel 2.4.19版内核引入了一种全新的隔离机制:Linux名称空间(Linux Namespaces)。
|
||||
|
||||
名称空间的概念在很多现代的高级程序语言中都存在,它主要的作用是避免不同开发者提供的API相互冲突,相信作为一名开发人员的你肯定不陌生。
|
||||
|
||||
Linux的名称空间是一种由内核直接提供的全局资源封装,它是内核针对进程设计的访问隔离机制。进程在一个独立的Linux名称空间中朝系统看去,会觉得自己仿佛就是这方天地的主人,拥有这台Linux主机上的一切资源,不仅文件系统是独立的,还有着独立的PID编号(比如拥有自己的0号进程,即系统初始化的进程)、UID/GID编号(比如拥有自己独立的root用户)、网络(比如完全独立的IP地址、网络栈、防火墙等设置),等等,此时进程的心情简直不能再好了。
|
||||
|
||||
事实上,Linux的名称空间是受“贝尔实验室九号项目”(一个分布式操作系统,“九号”项目并非代号,操作系统的名字就叫“Plan 9 from Bell Labs”,充满了赛博朋克风格)的启发而设计的,最初的目的依然只是为了隔离文件系统,而不是为了什么容器化的实现。这点我们从2002年发布时,Linux只提供了Mount名称空间,并且其构造参数为“CLONE_NEWNS”(即Clone New Namespace的缩写)而非“CLONE_NEWMOUNT”,就能看出一些端倪。
|
||||
|
||||
到了后来,要求系统隔离其他访问操作的呼声就愈发强烈,从2006年起,内核陆续添加了UTS、IPC等名称空间隔离,直到目前最新的Linux Kernel 5.6版内核为止,Linux名称空间支持了以下八种资源的隔离(内核的官网Kernel.org上仍然只列出了前六种,从Linux的Man命令能查到全部八种):
|
||||
|
||||
|
||||
|
||||
|
||||
阅读链接补充:-
|
||||
Hostname-
|
||||
Domain names
|
||||
|
||||
|
||||
如今,对文件、进程、用户、网络等各类信息的访问,都被囊括在Linux的名称空间中,即使一些今天仍有没被隔离的访问(比如syslog就还没被隔离,容器内可以看到容器外其他进程产生的内核syslog),在以后也可以跟随内核版本的更新纳入到这套框架之内。
|
||||
|
||||
现在,距离完美的隔离性就只差最后一步了:资源的隔离。
|
||||
|
||||
隔离资源:cgroups
|
||||
|
||||
如果要让一台物理计算机中的各个进程看起来像独享整台虚拟计算机的话,不仅要隔离各自进程的访问操作,还必须能独立控制分配给各个进程的资源使用配额。不然的话,一个进程发生了内存溢出或者占满了处理器,其他进程就莫名其妙地被牵连挂起,这样肯定算不上是完美的隔离。
|
||||
|
||||
而Linux系统解决以上问题的方案就是控制群组(Control Groups,目前常用的简写为cgroups),它与名称空间一样,都是直接由内核提供的功能,用于隔离或者说分配并限制某个进程组能够使用的资源配额。这里的资源配额包括了处理器时间、内存大小、磁盘I/O速度,等等,具体你可以参考下这里给出的表格:
|
||||
|
||||
|
||||
|
||||
cgroups项目最早是由Google的工程师(主要是Paul Menage和Rohit Seth)在2006年发起的,当时取的名字就叫做“进程容器”(Process Containers),不过“容器”(Container)这个名词的定义在那时候还没有今天那么清晰,不同场景中常有不同的指向。
|
||||
|
||||
所以,为了避免混乱,到2007年这个项目才被重新命名为cgroups,在2008年合并到了2.6.24版的内核后正式对外发布,这一阶段的cgroups就被称为“第一代cgroups”。
|
||||
|
||||
后来,在2016年3月发布的Linux Kernel 4.5中,搭载了由Facebook工程师(主要是Tejun Heo)重新编写的“第二代cgroups”,其关键改进是支持Unified Hierarchy,这个功能可以让管理员更加清晰、精确地控制资源的层级关系。目前这两个版本的cgroups在Linux内核代码中是并存的,不过在下节课我会给你介绍的封装应用Docker,就暂时仅支持第一代的cgroups。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我给你介绍了容器技术和思想的起源:chroot命令,这是计算机操作系统中最早的成规模的隔离技术。
|
||||
|
||||
此外,你现在也了解到了namespaces和cgroups对资源访问与资源配额的隔离,它们不仅是容器化技术的基础,在现代Linux操作系统中也已经成为了无可或缺的基石。理解了这些基础性的知识,是学习和掌握下节课中讲解的容器应用,即不同封装对象、封装思想的必要前提。
|
||||
|
||||
一课一思
|
||||
|
||||
请你思考一下:Docker的起源依赖于Linux内核提供的隔离能力,那Docker就是Linux专属的吗?Windows系统中是否有文件、访问、资源的隔离手段?是否存在Windows版本的容器运行时呢?
|
||||
|
||||
|
||||
提示:你可以搜索关于Window Server Contianer的信息,与LXC(Linux Container)对比一下。
|
||||
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
198
专栏/周志明的架构课/47_容器的崛起(下):系统、应用、集群的封装.md
Normal file
198
专栏/周志明的架构课/47_容器的崛起(下):系统、应用、集群的封装.md
Normal file
@@ -0,0 +1,198 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
47 _ 容器的崛起(下):系统、应用、集群的封装
|
||||
你好,我是周志明。在理解了从隔离角度出发的容器化技术的发展之后,这节课我们接着从封装的角度来学习容器应用的发展。
|
||||
|
||||
封装系统:LXC
|
||||
|
||||
当文件系统、访问、资源都可以被隔离后,容器就已经具备它降生所需要的全部前置支撑条件了,并且Linux的开发者们也已经明确地看到了这一点。
|
||||
|
||||
因此,为了降低普通用户综合使用namespaces、cgroups这些低级特性的门槛,2008年Linux Kernel 2.6.24内核在刚刚开始提供cgroups的同一时间,就马上发布了名为Linux容器(LinuX Containers,LXC)的系统级虚拟化功能。
|
||||
|
||||
当然在这之前,在Linux上并不是没有系统级虚拟化的解决方案,比如传统的OpenVZ和Linux-VServer都能够实现容器隔离,并且只会有很低的性能损失(按OpenVZ提供的数据,只会有1~3%的损失),但它们都是非官方的技术,使用它们最大的阻碍是系统级虚拟化必须要有内核的支持。为此,它们就只能通过非官方内核补丁的方式来修改标准内核,才能获得那些原本在内核中不存在的能力。
|
||||
|
||||
如此一来,LXC就带着令人瞩目的光环登场,它的出现促使“容器”从一个阳春白雪的、只流传于开发人员口中的技术词汇,逐渐向整个软件业的公共概念、共同语言发展,就如同今天的“服务器”“客户端”和“互联网”一样。
|
||||
|
||||
不过,相信你现在肯定会好奇:为什么如今一提到容器,大家首先联想到的是Docker而不是LXC?为什么去问10个开发人员,至少有9个听过Docker,但如果问LXC,可能只有1个人听说过?
|
||||
|
||||
那么,我们首先可以知道的是,LXC的出现肯定是受到了OpenVZ和Linux-VServer的启发,摸着巨人的肩膀过河当然没有什么不对。但可惜的是,LXC在设定自己的发展目标时,也被前辈们的影响所局限了。
|
||||
|
||||
其实,LXC眼中的容器的定义与OpenVZ和Linux-VServer并没有什么差别,它们都是一种封装系统的轻量级虚拟机,而Docker眼中的容器的定义则是一种封装应用的技术手段。这两种封装理念在技术层面并没有什么本质区别,但在应用效果上差异可就相当大了。
|
||||
|
||||
我举个具体的例子,如果你要建设一个LAMP(Linux、Apache、MySQL、PHP)应用,按照LXC的思路,你应该先编写或者寻找到LAMP的template(可以暂且不准确地类比为LXC版本的Dockerfile吧),以此构造出一个安装了LAMP的虚拟系统。
|
||||
|
||||
如果按部署虚拟机的角度来看,这还算挺方便的,作为那个时代(距今也就十年)的系统管理员,所有软件、补丁、配置都是要自己搞定的,部署一台新虚拟机要花费一两天时间都很正常,而有了LXC的template,一下子帮你把LAMP都安装好了,还想要啥自行车?
|
||||
|
||||
但是,作为一名现代的系统管理员,这里的问题就相当大了:如果我想把LAMP改为LNMP(Linux、Nginx、MySQL、PHP)该怎么办?如果我想把LAMP里的MySQL 5调整为MySQL 8该怎么办?这些都得通过找到或者自己编写新的template来解决。
|
||||
|
||||
或者好吧,那这台机的软件、版本都配置对了,下一台机我要构建LYME或者MEAN,又该怎么办呢?以封装系统为出发点,如果仍然是按照先装系统再装软件的思路,就永远无法做到一两分钟甚至十几秒钟就构造出一个合乎要求的软件运行环境,这也就决定了LXC不可能形成今天的容器生态。
|
||||
|
||||
所以,接下来舞台的聚光灯终于落到了Docker身上。
|
||||
|
||||
封装应用:Docker
|
||||
|
||||
在2013年宣布开源的Docker,毫无疑问是容器发展历史上里程碑式的发明,然而Docker的成功似乎没有太多技术驱动的成分。至少对于开源早期的Docker而言,确实没有什么能构成壁垒的技术。
|
||||
|
||||
事实上,它的容器化能力直接来源于LXC,它的镜像分层组合的文件系统直接来源于AUFS,在Docker开源后不久,就有人仅用了一百多行的Shell脚本,便实现了Docker的核心功能(名为Bocker,提供了docker bulid/pull/images/ps/run/exec/logs/commit/rm/rmi等功能)。
|
||||
|
||||
那你可能就要问了:为何历史选择了Docker,而不是LXC或者其他容器技术呢?对于这个问题,我想引用下(转述非直译,有所精简)DotCloud公司(当年创造Docker的公司,已于2016年倒闭)创始人所罗门 · 海克斯(Solomon Hykes)在Stackoverflow上的一段问答:
|
||||
|
||||
|
||||
为什么要用Docker而不是LXC?(Why would I use Docker over plain LXC?)-
|
||||
|
||||
Docker除了包装来自Linux内核的特性之外,它的价值还在于:-
|
||||
|
||||
跨机器的绿色部署:Docker定义了一种将应用及其所有的环境依赖都打包到一起的格式,仿佛它原本就是绿色软件一样。而LXC并没有提供这样的能力,使用LXC部署的新机器很多细节都要依赖人的介入,虚拟机的环境基本上肯定会跟你原本部署程序的机器有所差别。-
|
||||
|
||||
以应用为中心的封装:Docker封装应用而非封装机器的理念贯穿了它的设计、API、界面、文档等多个方面。相比之下,LXC将容器视为对系统的封装,这局限了容器的发展。-
|
||||
|
||||
自动构建:Docker提供了开发人员从在容器中构建产品的全部支持,开发人员无需关注目标机器的具体配置,就可以使用任意的构建工具链,在容器中自动构建出最终产品。-
|
||||
|
||||
多版本支持:Docker支持像Git一样管理容器的连续版本,进行检查版本间差异、提交或者回滚等操作。从历史记录中,你可以查看到该容器是如何一步一步构建成的,并且只增量上传或下载新版本中变更的部分。-
|
||||
|
||||
组件重用:Docker允许将任何现有容器作为基础镜像来使用,以此构建出更加专业的镜像。-
|
||||
|
||||
共享:Docker拥有公共的镜像仓库,成千上万的Docker用户在上面上传了自己的镜像,同时也使用他人上传的镜像。-
|
||||
|
||||
工具生态:Docker开放了一套可自动化和自行扩展的接口,在此之上用户可以实现很多工具来扩展其功能,比如容器编排、管理界面、持续集成,等等。-
|
||||
—— Solomon Hykes,Stackoverflow,2013
|
||||
|
||||
|
||||
这段回答也被收录到了Docker官网的FAQ上,从Docker开源到今天从没有改变过。
|
||||
|
||||
其实,促使Docker一问世就惊艳世间的,并不是什么黑科技式的秘密武器,而是它符合历史潮流的创意与设计理念,还有充分开放的生态运营。由此可见,在正确的时候,正确的人手上有一个优秀的点子,确实有机会引爆一个时代。
|
||||
|
||||
|
||||
|
||||
这里我还想让你看一张图片,它是Docker开源一年后(截至2014年12月)获得的成绩。
|
||||
|
||||
我们可以发现,从开源到现在,只过了短短数年时间,Docker就已经成为了软件开发、测试、分发、部署等各个环节都难以或缺的基础支撑,而它自身的架构也发生了相当大的改变:Docker被分解为了几个子系统,包括Docker Client、Docker Daemon、Docker Registry、Docker Container等等,以及Graph、Driver、libcontainer等各司其职的模块。
|
||||
|
||||
所以此时,我们再说一百多行脚本就能实现Docker的核心功能,再说Docker没有太高的技术含量,就不太合适了。
|
||||
|
||||
2014年,Docker开源了自己用Golang开发的libcontainer,这是一个越过LXC直接操作namespaces和cgroups的核心模块,有了libcontainer以后,Docker就能直接与系统内核打交道,不必依赖LXC来提供容器化隔离能力了。
|
||||
|
||||
到了2015年,在Docker的主导和倡议下,多家公司联合制定了“开放容器交互标准”(Open Container Initiative,OCI),这是一个关于容器格式和运行时的规范文件,其中包含了运行时标准(runtime-spec )、容器镜像标准(image-spec)和镜像分发标准(distribution-spec,分发标准还未正式发布)。
|
||||
|
||||
|
||||
运行时标准定义了应该如何运行一个容器、如何管理容器的状态和生命周期、如何使用操作系统的底层特性(namespaces、cgroup、pivot_root等);
|
||||
容器镜像标准规定了容器镜像的格式、配置、元数据的格式,你可以理解为对镜像的静态描述;
|
||||
镜像分发标准则规定了镜像推送和拉取的网络交互过程。
|
||||
|
||||
|
||||
由此,为了符合OCI标准,Docker推动自身的架构继续向前演进。
|
||||
|
||||
首先,它是将libcontainer独立出来,封装重构成runC项目,并捐献给了Linux基金会管理。runC是OCI Runtime的首个参考实现,它提出了“让标准容器无所不在”(Make Standard Containers Available Everywhere)的口号。
|
||||
|
||||
而为了能够兼容所有符合标准的OCI Runtime实现,Docker进一步重构了Docker Daemon子系统,把其中与运行时交互的部分抽象为了containerd项目。
|
||||
|
||||
这是一个负责管理容器执行、分发、监控、网络、构建、日志等功能的核心模块,其内部会为每个容器运行时创建一个containerd-shim适配进程,默认与runC搭配工作,但也可以切换到其他OCI Runtime实现上(然而实际并没做到,最后containerd仍是紧密绑定于runC)。
|
||||
|
||||
后来到了2016年,Docker把containerd捐献给了CNCF管理。
|
||||
|
||||
可以说,runC与containerd两个项目的捐赠托管,既带有Docker对开源信念的追求,也带有Docker在众多云计算大厂夹击下自救的无奈,这两个项目也将会成为未来Docker消亡和存续的伏笔(到这节课的末尾你就能理解这句矛盾的话了)。
|
||||
|
||||
|
||||
|
||||
以上我列举的这些Docker推动的开源与标准化工作,既是对Docker为开源乃至整个软件业做出贡献的赞赏,也是为后面给你介绍容器编排时,讲解当前容器引擎的混乱关系做的前置铺垫。
|
||||
|
||||
我们当然很清楚的一个事实就是,Docker目前无疑在容器领域具有统治地位,但其统治的稳固程度不仅没到高枕无忧,说是危机四伏都不为过。
|
||||
|
||||
我之所以这么说的原因,是因为现在已经能隐隐看出足以威胁动摇Docker地位的潜在可能性,而引出这个风险的,就是Docker虽然赢得了容器战争的胜利,但Docker Swarm却输掉了容器编排战争。
|
||||
|
||||
实际上,从结果回望当初,Docker能赢得容器战争是存在了一些偶然性的,而能确定的是Docker Swarm输掉编排战争是必然的。为什么这么说呢?下面我就来揭晓答案。
|
||||
|
||||
封装集群:Kubernetes
|
||||
|
||||
如果说以Docker为代表的容器引擎,是把软件的发布流程从分发二进制安装包,转变为了直接分发虚拟化后的整个运行环境,让应用得以实现跨机器的绿色部署;那以Kubernetes为代表的容器编排框架,就是把大型软件系统运行所依赖的集群环境也进行了虚拟化,让集群得以实现跨数据中心的绿色部署,并能够根据实际情况自动扩缩。
|
||||
|
||||
我们从上节课的容器崛起之路,讲到现在Docker和Kubernetes这个阶段,已经不再是介绍历史了,从这里开始发生的变化,都是近几年软件业界中的热点事件,也是“容器的崛起”这个小章节我们要讨论的主要话题。不过现在,我暂时不打算介绍Kubernetes的技术细节,在“容器间网络”“容器持久化存储”及“资源调度”这几个章节中,我还会进行更详细的解析。
|
||||
|
||||
在今天这节课里,我们就先从宏观层面去理解Kubernetes的诞生与演变的驱动力,这对正确理解未来云原生的发展方向是至关重要的。
|
||||
|
||||
从Docker到Kubernetes
|
||||
|
||||
众所周知,Kubernetes可谓是出身名门,它的前身是Google内部已经运行多年的集群管理系统Borg,在2014年6月使用Golang完全重写后开源。自它诞生之日起,只要能与云计算稍微扯上关系的业界巨头,都对Kubernetes争相追捧,IBM、RedHat、Microsoft、VMware和华为都是它最早期的代码贡献者。
|
||||
|
||||
此时,距离云计算从实验室到工业化应用已经有十个年头,不过大量应用使用云计算的方式,还是停滞在了传统的IDC(Internet Data Center)时代,它们仅仅是用云端的虚拟机代替了传统的物理机而已。
|
||||
|
||||
尽管早在2013年,Pivotal(持有着Spring Framework和Cloud Foundry的公司)就提出了“云原生”的概念,但是要实现服务化、具备韧性(Resilience)、弹性(Elasticity)、可观测性(Observability)的软件系统依旧十分困难,在当时基本只能依靠架构师和程序员高超的个人能力,云计算本身还帮不上什么忙。
|
||||
|
||||
而在云的时代,不能充分利用云的强大能力,这让云计算厂商无比遗憾,也无比焦虑。
|
||||
|
||||
所以可以说,直到Kubernetes横空出世,大家才终于等到了破局的希望,认准了这就是云原生时代的操作系统,是让复杂软件在云计算下获得韧性、弹性、可观测性的最佳路径,也是为厂商们推动云计算时代加速到来的关键引擎之一。
|
||||
|
||||
2015年7月,Kubernetes发布了第一个正式版本1.0版,更重要的事件是Google宣布与Linux基金会共同筹建云原生基金会(Cloud Native Computing Foundation,CNCF),并且把Kubernetes托管到CNCF,成为其第一个项目。随后,Kubernetes就以摧枯拉朽之势消灭了容器编排领域的其他竞争对手,哪怕Docker Swarm有着Docker在容器引擎方面的先天优势,DotCloud后来甚至把Swarm直接内置入Docker之中,都不能稍稍阻挡Kubernetes前进的步伐。
|
||||
|
||||
但是我们也要清楚,Kubernetes的成功与Docker的成功并不一样。
|
||||
|
||||
Docker靠的是优秀的理念,它是以一个“好点子”引爆了一个时代。我相信就算没有Docker,也会有Cocker或者Eocker的出现,但由成立仅三年的DotCloud公司(三年后又倒闭)做成了这样的产品,确实有一定的偶然性。
|
||||
|
||||
而Kubernetes的成功,不仅有Google深厚的技术功底作支撑、有领先时代的设计理念,更加关键的是Kubernetes的出现,符合所有云计算大厂的切身利益,有着业界巨头不遗余力地广泛支持,所以它的成功便是一种必然。
|
||||
|
||||
|
||||
|
||||
Kubernetes与Docker两者的关系十分微妙,因此我们把握住两者关系的变化过程,是理解Kubernetes架构演变与CRI、OCI规范的良好线索。
|
||||
|
||||
Kubernetes是如何一步步与Docker解耦的?
|
||||
|
||||
在Kubernetes开源的早期,它是完全依赖且绑定Docker的,并没有过多地考虑日后有使用其他容器引擎的可能性。直到Kubernetes 1.5之前,Kubernetes管理容器的方式都是通过内部的DockerManager,向Docker Engine以HTTP方式发送指令,通过Docker来操作镜像的增删改查的,如上图最右边线路的箭头所示(图中的kubelet是集群节点中的代理程序,负责与管理集群的Master通信,其他节点的含义在下面介绍时都会有解释)。
|
||||
|
||||
现在,我们可以把这个阶段的Kubernetes与容器引擎的调用关系捋直,并结合前面提到的Docker捐献containerd与runC后重构的调用,一起来梳理下这个完整的调用链条:
|
||||
|
||||
|
||||
Kubernetes Master → kubelet → DockerManager → Docker Engine → containerd → runC
|
||||
|
||||
|
||||
然后到了2016年,Kubernetes 1.5版本开始引入“容器运行时接口”(Container Runtime Interface,CRI),这是一个定义容器运行时应该如何接入到kubelet的规范标准,从此Kubernetes内部的DockerManager,就被更为通用的KubeGenericRuntimeManager所替代了(实际上在1.6.6之前都仍然可以看到DockerManager),kubelet与KubeGenericRuntimeManager之间通过gRPC协议通信。
|
||||
|
||||
不过,由于CRI是在Docker之后才发布的规范,Docker是肯定不支持CRI的,所以Kubernetes又提供了DockerShim服务作为Docker与CRI的适配层,由它与Docker Engine以HTTP形式通信,从而实现了原来DockerManager的全部功能。
|
||||
|
||||
此时,Docker对Kubernetes来说就只是一项默认依赖,而非之前的不可或缺了,现在它们的调用链为:
|
||||
|
||||
|
||||
Kubernetes Master → kubelet → KubeGenericRuntimeManager → DockerShim → Docker Engine → containerd → runC
|
||||
|
||||
|
||||
接着再到2017年,由Google、RedHat、Intel、SUSE、IBM联合发起的CRI-O(Container Runtime Interface Orchestrator)项目发布了首个正式版本。
|
||||
|
||||
一方面,我们从名字上就可以看出来,它肯定是完全遵循CRI规范来实现的;另一方面,它可以支持所有符合OCI运行时标准的容器引擎,默认仍然是与runC搭配工作的,如果要换成Clear Containers、Kata Containers等其他OCI运行时,也完全没有问题。
|
||||
|
||||
不过到这里,开源版的Kubernetes虽然完全支持用户去自由选择(根据用户宿主机的环境选择)是使用CRI-O、cri-containerd,还是DockerShim来作为CRI实现,但在RedHat自己扩展定制的Kubernetes企业版,即OpenShift 4中,调用链已经没有了Docker Engine的身影:
|
||||
|
||||
|
||||
Kubernetes Master → kubelet → KubeGenericRuntimeManager → CRI-O→ runC
|
||||
|
||||
|
||||
当然,因为此时Docker在容器引擎中的市场份额仍然占有绝对优势,对于普通用户来说,如果没有明确的收益,也并没有什么动力要把Docker换成别的引擎。所以CRI-O即使摆出了直接挖掉Docker根基的凶悍姿势,实际上也并没有给Docker带来太多即时可见的影响。不过,我们能够想像此时Docker心中肯定充斥了难以言喻的危机感。
|
||||
|
||||
时间继续来到了2018年,由Docker捐献给CNCF的containerd,在CNCF的精心孵化下发布了1.1版,1.1版与1.0版的最大区别是此时它已经完美地支持了CRI标准,这意味着原本用作CRI适配器的cri-containerd从此不再被需要。
|
||||
|
||||
此时,我们再观察Kubernetes到容器运行时的调用链,就会发现调用步骤会比通过DockerShim、Docker Engine与containerd交互的步骤要减少两步,这又意味着用户只要愿意抛弃掉Docker情怀的话,在容器编排上就可以至少省略一次HTTP调用,获得性能上的收益。而且根据Kubernetes官方给出的测试数据,这些免费的收益还相当地可观。
|
||||
|
||||
如此,Kubernetes从1.10版本宣布开始支持containerd 1.1,在调用链中就已经能够完全抹去Docker Engine的存在了:
|
||||
|
||||
|
||||
Kubernetes Master → kubelet → KubeGenericRuntimeManager → containerd → runC
|
||||
|
||||
|
||||
而到了今天,要使用哪一种容器运行时,就取决于你安装Kubernetes时宿主机上的容器运行时环境,但对于云计算厂商来说,比如国内的阿里云ACK、腾讯云TKE等直接提供的Kubernetes容器环境,采用的容器运行时普遍都已经是containerd了,毕竟运行性能对它们来说就是核心生产力和竞争力。
|
||||
|
||||
小结
|
||||
|
||||
学完这节课,我们可以试着来做一个判断:在未来,随着Kubernetes的持续发展壮大,Docker Engine经历从不可或缺、默认依赖、可选择、直到淘汰,会是大概率的事件。从表面上看,这件事情是Google、RedHat等云计算大厂联手所为,可实际淘汰它的还是技术发展的潮流趋势。这就如同Docker诞生时依赖LXC,到最后用libcontainer取代掉LXC一样。
|
||||
|
||||
同时,我们也该看到事情的另一面:现在连LXC都还没有挂掉,反倒还发展出了更加专注于跟OpenVZ等系统级虚拟化竞争的LXD,就可以相信Docker本身也是很难彻底消亡的,已经养成习惯的CLI界面,已经形成成熟生态的镜像仓库等,都应该会长期存在,只是在容器编排领域,未来的Docker很可能只会以runC和containerd的形式存续下去,毕竟它们最初都源于Docker的血脉。
|
||||
|
||||
一课一思
|
||||
|
||||
在2021年1月初,Kubernetes宣布将会在v1.23版本中,把Dockershim从 Kubelet中移除,那么你会如何看待容器化日后的发展呢?
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
147
专栏/周志明的架构课/48_以容器构建系统(上):隔离与协作.md
Normal file
147
专栏/周志明的架构课/48_以容器构建系统(上):隔离与协作.md
Normal file
@@ -0,0 +1,147 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
48 _ 以容器构建系统(上):隔离与协作
|
||||
你好,我是周志明。从这节课开始,我们讨论的焦点会从容器本身,过渡到容器编排上。
|
||||
|
||||
我们知道,自从Docker提出“以封装应用为中心”的容器发展理念,成功取代了“以封装系统为中心”的LXC以后,一个容器封装一个单进程应用,已经成为了被广泛认可的最佳实践。
|
||||
|
||||
然而当单体时代过去之后,分布式系统里对于应用的概念已经不再等同于进程了,此时的应用需要多个进程共同协作,通过集群的形式对外提供服务,那么以虚拟化方法实现这个目标的过程,就被称为容器编排(Container Orchestration)。
|
||||
|
||||
而到今天,Kubernetes已经成为了容器编排的代名词。不过在课程中,我并不打算过多介绍Kubernetes具体有哪些功能,也不会为你说明它由Pod、Node、Deployment、ReplicaSet等各种类型的资源组成可用的服务、集群管理平面与节点之间是如何工作的、每种资源该如何配置使用,等等,如果你想了解这方面信息,可以去查看Kubernetes官网的文档库或任何一本以Kubernetes为主题的使用手册。
|
||||
|
||||
在课程中,我真正希望能帮你搞清楚的问题是“为什么Kubernetes会设计成现在这个样子?”“为什么以容器构建系统应该这样做?”
|
||||
|
||||
而要寻找这些问题的答案,最好是从它们设计的实现意图出发。所以在接下来的两节课中,我虚构了一系列从简单到复杂的场景,带你来理解并解决这些场景中的问题。
|
||||
|
||||
这里我还想说明一点,学习这两节课的内容并不要求你对Kubernetes有过多深入的了解,但需要你至少使用过Kubernetes和Docker,基本了解它的核心功能与命令;另外,课程中还会涉及到一点儿Linux系统内核资源隔离的基础知识,别担心,只要你仔细学习了“容器的崛起”这个小章节,就已经完全够用了。
|
||||
|
||||
构建容器编排系统时都会遇到什么问题?
|
||||
|
||||
好,现在我们来设想一下,如果让你来设计一套容器编排系统,协调各种容器来共同来完成一项工作,你可能会遇到什么问题?会如何着手解决呢?
|
||||
|
||||
我们先从最简单的场景开始吧:
|
||||
|
||||
|
||||
场景一:假设你现在有两个应用,其中一个是Nginx,另一个是为该Nginx收集日志的Filebeat,你希望将它们封装为容器镜像,以方便日后分发。
|
||||
|
||||
|
||||
最直接的方案就将Nginx和Filebeat直接编译成同一个容器镜像,这是可以做到的,而且并不复杂。不过这样做其实会埋下很大的隐患:它违背了Docker提倡的单个容器封装单进程应用的最佳实践。
|
||||
|
||||
Docker设计的Dockerfile只允许有一个ENTRYPOINT,这并不是什么随便添加的人为限制,而是因为Docker只能通过监视PID为1的进程(即由ENTRYPOINT启动的进程)的运行状态,来判断容器的工作状态是否正常,像是容器退出执行清理、容器崩溃自动重启等操作,Docker都必须先判断状态。
|
||||
|
||||
那么我们可以设想一下,即使我们使用了supervisord之类的进程控制器,来解决同时启动Nginx和Filebeat进程的问题,如果因为某种原因它们不停发生崩溃、重启,那Docker也无法察觉到,它只能观察到supervisord的运行状态。所以,场景一关于封装为容器镜像的需求会理所当然地演化成场景二。
|
||||
|
||||
|
||||
场景二:假设你现在有两个Docker镜像,其中一个封装了HTTP服务,为便于称呼,叫它Nginx容器,另一个封装了日志收集服务,叫它Filebeat容器。现在你要求Filebeat容器能收集Nginx容器产生的日志信息。
|
||||
|
||||
|
||||
其实,场景二的需求依然不难解决,只要在Nginx容器和Filebeat容器启动时,分别把它们的日志目录和收集目录挂载为宿主机同一个磁盘位置的Volume即可,在Docker中,这种操作是十分常用的容器间信息交换手段。
|
||||
|
||||
不过,容器间信息交换不仅仅是文件系统。
|
||||
|
||||
假如此时我又引入了一个新的工具confd,它是Linux下的一种配置管理工具,作用是根据配置中心(Etcd、ZooKeeper、Consul)的变化,自动更新Nginx的配置。那么这样的话,就又会遇到新的问题。
|
||||
|
||||
这是因为,confd需要向Nginx发送HUP信号,才便于通知Nginx配置已经发生了变更,而发送HUP信号自然就要求confd与Nginx能够进行IPC通信才行。
|
||||
|
||||
当然,尽管共享IPC名称空间不如共享Volume常见,但Docker同样支持了这个功能,也就是通过docker run命令提供了–ipc参数,用来把多个容器挂载到同一个父容器的IPC名称空间之下,以实现容器间共享IPC名称空间的需求。类似地,如果要共享UTS名称空间,可以使用–uts参数;要共享网络名称空间的话,就使用–net参数。
|
||||
|
||||
这就是Docker针对场景二这种不跨机器的多容器协作,所给出的解决方案了。
|
||||
|
||||
实际上,自动地为多个容器设置好共享名称空间,就是Docker Compose提供的核心能力。
|
||||
|
||||
不过,这种针对具体应用需求来共享名称空间的方案,确实可以工作,但并不够优雅,也谈不上有什么扩展性。要知道,容器的本质是对cgroups和namespaces所提供的隔离能力的一种封装,在Docker提倡的单进程封装的理念影响下,容器蕴含的隔离性也多了仅针对于单个进程的额外局限。
|
||||
|
||||
然而Linux的cgroups和namespaces,原本都是针对进程组而不只是单个进程来设计的,同一个进程组中的多个进程,天然就可以共享相同的访问权限与资源配额。
|
||||
|
||||
所以,如果现在我们把容器与进程在概念上对应起来,那容器编排的第一个扩展点,就是要找到容器领域中与“进程组”相对应的概念,这是实现容器从隔离到协作的第一步。在Kubernetes的设计里,这个对应物叫做Pod。
|
||||
|
||||
|
||||
额外知识:Pod名字的由来与含义-
|
||||
在容器正式出现之前的Borg系统中,Pod的概念就已经存在了,从Google的发表的《Large-Scale Cluster Management at Google with Borg》里可以看出,Kubernetes时代的Pod整合了Borg时代的“Prod”(Production Task的缩写)与“Non-Prod”的职能。由于Pod一直没有权威的中文翻译,我在后面课程中会尽量用英文指代,偶尔需要中文的场合就使用Borg中Prod的译法,即“生产任务”来指代。
|
||||
|
||||
|
||||
这样,有了“容器组”的概念,只需要把多个容器放到同一个Pod中,场景二的问题就可以解决了。
|
||||
|
||||
Pod的含义与职责
|
||||
|
||||
事实上,扮演容器组的角色,满足容器共享名称空间的需求,是Pod两大最基本的职责之一,同处于一个Pod内的多个容器,相互之间会以超亲密的方式协作。请注意,“超亲密”在这里的用法不是什么某种带强烈感情色彩的形容词,而是代表了一种有具体定义的协作程度。
|
||||
|
||||
具体是什么意思呢?
|
||||
|
||||
对于普通非亲密的容器来说,它们一般以网络交互方式(其他的如共享分布式存储来交换信息,也算跨网络)协作;对于亲密协作的容器来说,是指它们被调度到同一个集群节点上,可以通过共享本地磁盘等方式协作;而超亲密的协作,是特指多个容器位于同一个Pod这种特殊关系,它们将默认共享以下名称空间:
|
||||
|
||||
|
||||
UTS名称空间:所有容器都有相同的主机名和域名。
|
||||
网络名称空间:所有容器都共享一样的网卡、网络栈、IP地址,等等。因此,同一个Pod中不同容器占用的端口不能冲突。
|
||||
IPC名称空间:所有容器都可以通过信号量或者POSIX共享内存等方式通信。
|
||||
时间名称空间:所有容器都共享相同的系统时间。
|
||||
|
||||
|
||||
也就是说,同一个Pod的容器,只有PID名称空间和文件名称空间默认是隔离的。
|
||||
|
||||
PID的隔离让开发者的每个容器都有独立的进程ID编号,它们封装的应用进程就是PID为1的进程,开发人员可以通过Pod元数据定义中的spec.shareProcessNamespace,来改变这点。而一旦要求共享PID名称空间,容器封装的应用进程就不再具有PID为1的特征了,这就有可能导致部分依赖该特征的应用出现异常。
|
||||
|
||||
而在文件名称空间方面,容器要求文件名称空间的隔离是很理所应当的需求,因为容器需要相互独立的文件系统以避免冲突。但容器间可以共享存储卷,这是通过Kubernetes的Volume来实现的。
|
||||
|
||||
|
||||
额外知识:Kubernetes中Pod名称空间共享的实现细节-
|
||||
|
||||
Pod内部多个容器共享UTS、IPC、网络等名称空间,是通过一个名为Infra Container的容器来实现的,这个容器是整个Pod中第一个启动的容器,只有几百KB大小(代码只有很短的几十行,见这里),Pod中的其他容器都会以Infra Container作为父容器,UTS、IPC、网络等名称空间,实质上都是来自Infra Container容器。-
|
||||
|
||||
如果容器设置为共享PID名称空间的话,Infra Container中的进程将作为PID 1进程,其他容器的进程将以它的子进程的方式存在,此时就会由Infra Container来负责进程管理(比如清理僵尸进程)、感知状态和传递状态。-
|
||||
|
||||
由于Infra Container的代码除了注册SIGINT、SIGTERM、SIGCHLD等信号的处理器外,就只是一个以pause()方法为循环体的无限循环,永远处于Pause状态,所以它也常被称为“Pause Container”。
|
||||
|
||||
|
||||
好,除此之外,Pod的另一个基本职责是实现原子性调度。这里我们可以先明确一点,就是如果容器编排不跨越集群节点,那是否具有原子性其实都不影响大局。
|
||||
|
||||
但是在集群环境中,容器可能会跨机器调度时,这个特性就变得非常重要了。
|
||||
|
||||
如果以容器为单位来调度的话,不同容器就有可能被分配到不同机器上。而两台机器之间本来就是物理隔离,依靠网络连接的,所以这时候谈什么名称空间共享、cgroups配额共享都没有意义了,由此我们就从场景二又演化出了场景三。
|
||||
|
||||
|
||||
场景三:假设你现在有Filebeat、Nginx两个Docker镜像,在一个具有多个节点的集群环境下,要求每次调度都必须让Filebeat和Nginx容器运行于同一个节点上。
|
||||
|
||||
|
||||
其实,两个关联的协作任务必须一起调度的需求,在容器出现之前很久就有了。
|
||||
|
||||
我举个简单的例子。在传统的多线程(或多进程)并发调度中,如果两个线程(或进程)的工作是强依赖的,单独给谁分配处理时间而让另一个被挂起,都会导致某一个线程无法工作,所以也就有了协同调度(Coscheduling)的概念,它主要用来保证一组紧密联系的任务能够被同时分配资源。
|
||||
|
||||
这样来看的话,如果我们在容器编排中,仍然坚持把容器看作是调度的最小粒度,那针对容器运行所需资源的需求声明,就只能设定在容器上。如此一来,集群每个节点的剩余资源越紧张,单个节点无法容纳全部协同容器的概率就越大,协同的容器被分配到不同节点的可能性就越高。
|
||||
|
||||
说实话,协同调度是很麻烦的,实现起来要么很低效,比如Apache Mesos的Resource Hoarding调度策略,就要等所有需要调度的任务都完备后,才会开始分配资源;要么就是很复杂,比如Google就曾针对Borg的下一代Omega系统,发表过论文《Omega: Flexible, Scalable Schedulers for Large Compute Clusters》,其中介绍了它是如何通过乐观并发(Optimistic Concurrency)、冲突回滚的方式,做到高效率且高度复杂的协同调度。
|
||||
|
||||
而如果我们将运行资源的需求声明定义在Pod上,直接以Pod为最小的原子单位来实现调度的话,由于多个Pod之间一定不存在超亲密的协同关系,只会通过网络非亲密地协作,那就根本没有协同的说法,自然也不需要考虑复杂的调度了(关于Kubernetes的具体调度实现,我会在“资源与调度”这个小章节中展开讲解)。
|
||||
|
||||
Pod是隔离与调度的基本单位,也是我们接触的第一种Kubernetes资源。Kubernetes把一切都看作是资源,不同资源之间依靠层级关系相互组合协作,这个思想是贯穿Kubernetes整个系统的两大核心设计理念之一,不仅在容器、Pod、主机、集群等计算资源上是这样,在工作负载、持久存储、网络策略、身份权限等其他领域中,也都有着一致的体现。
|
||||
|
||||
|
||||
|
||||
另外我想说的是,因为Pod是Kubernetes中最重要的资源,又是资源模型中一种仅在逻辑上存在、没有物理对应的概念(因为对应的“进程组”也只是个逻辑概念),也是其他编排系统没有的概念,所以我这节课专门给你介绍了下它的设计意图,而不是像帮助手册那样直接给出它的作用和特性。
|
||||
|
||||
对于Kubernetes中的其他计算资源,像Node、Cluster等都有切实的物理对应物,很容易就能形成共同的认知,我就不一一介绍了,这里你只需要了解下它们的设计意图就行:
|
||||
|
||||
|
||||
容器(Container):延续了自Docker以来一个容器封装一个应用进程的理念,是镜像管理的最小单位。
|
||||
生产任务(Pod):补充了容器化后缺失的与进程组对应的“容器组”的概念,Pod中的容器共享UTS、IPC、网络等名称空间,是资源调度的最小单位。
|
||||
节点(Node):对应于集群中的单台机器,这里的机器既可以是生产环境中的物理机,也可以是云计算环境中的虚拟节点,节点是处理器和内存等资源的资源池,是硬件单元的最小单位。
|
||||
集群(Cluster):对应于整个集群,Kubernetes提倡的理念是面向集群来管理应用。当你要部署应用的时候,只需要通过声明式API将你的意图写成一份元数据(Manifests),把它提交给集群即可,而无需关心它具体分配到哪个节点(尽管通过标签选择器完全可以控制它分配到哪个节点,但一般不需要这样做)、如何实现Pod间通信、如何保证韧性与弹性,等等,所以集群是处理元数据的最小单位。
|
||||
集群联邦(Federation):对应于多个集群,通过联邦可以统一管理多个Kubernetes集群,联邦的一种常见应用是支持跨可用区域多活、跨地域容灾的需求。
|
||||
|
||||
|
||||
小结
|
||||
|
||||
学完了这节课,我们要知道,容器之间顺畅地交互通信是协作的核心需求,但容器协作并不只是通过高速网络来互相连接容器而已。如何调度容器,如何分配资源,如何扩缩规模,如何最大限度地接管系统中的非功能特性,让业务系统尽可能地免受分布式复杂性的困扰,都是容器编排框架必须考虑的问题,只有恰当解决了这一系列问题,云原生应用才有可能获得比传统应用更高的生产力。
|
||||
|
||||
一课一思
|
||||
|
||||
现在,我们能够明确隔离与协作的含义,也就是容器要让它管理的进程相互隔离,使用独立的资源与配额;容器编排系统要让它管理的各个容器相互协作,共同维持一个分布式系统的运作。但除了协作之外,你认为容器编排系统是否还有其他必须考虑的需求目标呢?
|
||||
|
||||
欢迎在留言区分享你的见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
149
专栏/周志明的架构课/49_以容器构建系统(下):韧性与弹性.md
Normal file
149
专栏/周志明的架构课/49_以容器构建系统(下):韧性与弹性.md
Normal file
@@ -0,0 +1,149 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
49 _ 以容器构建系统(下):韧性与弹性
|
||||
你好,我是周志明。今天,我们接着上节课“隔离与协作”的话题,继续来讨论容器编排的另一个目标:韧性与弹性。
|
||||
|
||||
我曾经看过一部电影,叫做《Bubble Boy》,主要讲了一个体内没有任何免疫系统的小男孩,每天只能生活在无菌的圆形气球里,对常人来说不值一提的细菌,都会直接威胁到他的性命。小男孩尽管能够降生于世,但并不能真正地与世界交流,这种生命是极度脆弱的。
|
||||
|
||||
真实世界的软件系统,跟电影世界中的小男孩所面临的处境其实差不多。
|
||||
|
||||
要知道,让容器能够相互连通、相互协作,仅仅是以容器构建系统的第一步,我们不仅希望得到一个能够运行起来的系统,而且还希望得到一个能够健壮运行的系统、能够抵御意外与风险的系统。
|
||||
|
||||
当然,在Kubernetes的支持下,你确实可以直接创建Pod将应用运行起来,但这样的应用就像是电影中只能存活在气球中的小男孩一样脆弱,无论是软件缺陷、意外操作或者硬件故障,都可能导致在复杂协作的过程中某个容器出现异常,进而出现系统性的崩溃。
|
||||
|
||||
为了解决这个问题,架构师专门设计了服务容错的策略和模式(你可以回顾复习第36、37讲)。而Kubernetes作为云原生时代的基础设施,也尽力帮助我们以最小的代价来实现容错,为系统健壮运行提供底层支持。
|
||||
|
||||
那么,Kubernetes所提供的帮助,就是指除资源模型之外的另一个核心设计理念:控制器设计模式。它的其中一种重要应用,就是这节课我们要探讨的主题,实现具有韧性与弹性的系统。
|
||||
|
||||
接下来,我们就从如何解决场景四的问题开始,一起来探讨下为什么Kubernetes要设计这些控制器,以及为什么这些控制器会被设计成现在这种样子。
|
||||
|
||||
编排系统如何快速调整出错的服务?
|
||||
|
||||
我们先来看看场景四的问题:
|
||||
|
||||
|
||||
场景四:假设有个由数十个Node、数百个Pod、近千个Container所组成的分布式系统,作为管理员,你想要避免该系统因为外部流量压力、代码缺陷、软件更新、硬件升级、资源分配等各种原因而出现中断的状况,那么你希望编排系统能为你提供何种支持?
|
||||
|
||||
|
||||
作为用户,我们当然最希望容器编排系统能自动地把所有意外因素都消灭掉,让每一个服务都永远健康,永不出错。但永不出错的服务是不切实际的,只有凑齐七颗龙珠才可能办得到。
|
||||
|
||||
所以我们就只能退而求其次,让编排系统在这些服务出现问题、运行状态不正确的时候,能自动将它们调整成正确的状态。
|
||||
|
||||
这种需求听起来其实也挺贪心的,但已经具备足够的可行性了。而且我们可以采取的应对办法在工业控制系统里,已经有了非常成熟的应用,它叫做控制回路(Control Loop)。
|
||||
|
||||
关于控制回路的一般工作过程,在Kubernetes官方文档中,是以“房间里空调自动调节温度”为例来具体介绍的:当你设置好了温度,就是告诉空调你对温度的“期望状态”(Desired State),而传感器测量出的房间实际温度是“当前状态”(Current State)。
|
||||
|
||||
那么,根据当前状态与期望状态的差距,控制器对空调制冷的开关进行调节控制,就能让其当前状态逐渐接近期望状态。
|
||||
|
||||
|
||||
|
||||
由此,我们把这种控制回路的思想迁移应用到容器编排上,自然会为Kubernetes中的资源附加上了期望状态与实际状态两项属性。
|
||||
|
||||
不管是已经出现在上节课的资源模型中,用于抽象容器运行环境的计算资源,还是没有登场的对应于安全、服务、令牌、网络等功能的资源(第40讲中曾提及过),如果用户想使用这些资源来实现某种需求,并不能像平常编程那样,去调用某个或某一组方法来达成目的。而是要通过描述清楚这些资源的期望状态,由Kubernetes中对应监视这些资源的控制器,来驱动资源的实际状态逐渐向期望状态靠拢,才能够达成自己的目的。
|
||||
|
||||
而这种交互风格就被叫做Kubernetes的声明式API,如果你之前有过实际操作Kubernetes的经验,那你日常在元数据文件中的spec字段所描述的就是资源的期望状态。
|
||||
|
||||
|
||||
额外知识:Kubernates的资源对象与控制器-
|
||||
目前,Kubernetes已内置支持相当多的资源对象,并且还可以使用CRD(Custom Resource Definition)来自定义扩充,你可以使用kubectl api-resources来查看它们。下面我根据用途分类,给你列举了一些常见的资源:-
|
||||
|
||||
|
||||
用于描述如何创建、销毁、更新、扩缩Pod,包括:Autoscaling(HPA)、CronJob、DaemonSet、Deployment、Job、Pod、ReplicaSet、StatefulSet
|
||||
|
||||
用于配置信息的设置与更新,包括:ConfigMap、Secret
|
||||
|
||||
用于持久性地存储文件或者Pod之间的文件共享,包括:Volume、LocalVolume、PersistentVolume、PersistentVolumeClaim、StorageClass
|
||||
|
||||
用于维护网络通信和服务访问的安全,包括:SecurityContext、ServiceAccount、Endpoint、NetworkPolicy
|
||||
|
||||
用于定义服务与访问,包括:Ingress、Service、EndpointSlice
|
||||
|
||||
|
||||
用于划分虚拟集群、节点和资源配额,包括:Namespace、Node、ResourceQuota-
|
||||
|
||||
这些资源在控制器管理框架中,一般都会有相应的控制器来管理,这里我也列举了一些常见的控制器,按照它们的启动情况进行了分类,如下:-
|
||||
|
||||
必须启用的控制器:EndpointController、ReplicationController、PodGCController、ResourceQuotaController、NamespaceController、ServiceAccountController、GarbageCollectorController、DaemonSetController、JobController、DeploymentController、ReplicaSetController、HPAController、DisruptionController、StatefulSetController、CronJobController、CSRSigningController、CSRApprovingController、TTLController
|
||||
|
||||
默认启用的可选控制器,可通过选项禁止:TokenController、NodeController、ServiceController、RouteController、PVBinderController、AttachDetachController
|
||||
|
||||
默认禁止的可选控制器,可通过选项启用:BootstrapSignerController、TokenCleanerController
|
||||
|
||||
|
||||
|
||||
那么,与资源相对应的,只要是实际状态有可能发生变化的资源对象,就通常都会由对应的控制器进行追踪,每个控制器至少会追踪一种类型的资源。
|
||||
|
||||
因此,为了管理众多资源控制器,Kubernetes设计了统一的控制器管理框架(kube-controller-manager)来维护这些控制器的正常运作,并设计了统一的指标监视器(kube-apiserver),用于在控制器工作时,为它提供追踪资源的度量数据。
|
||||
|
||||
Kubernetes控制器模式的工作原理
|
||||
|
||||
那么Kubernetes具体是怎么做的呢?在回答之前,我想先解释下,毕竟我们不是在写Kubernetes的操作手册,没办法展开和详解每个控制器,所以下面我就以两三种资源和控制器为代表,来举例说明一下。
|
||||
|
||||
OK,回到问题上。这里我们只要把场景四进一步具体化,转换成下面的场景五,就可以得到一个很好的例子了。
|
||||
|
||||
比如说,我们就以部署控制器(Deployment Controller)、副本集控制器(ReplicaSet Controller)和自动扩缩控制器(HPA Controller)为例,来看看Kubernetes控制器模式的工作原理。
|
||||
|
||||
|
||||
场景五:通过服务编排,我们让任何分布式系统自动实现以下三种通用的能力:
|
||||
|
||||
|
||||
Pod出现故障时,能够自动恢复,不中断服务;
|
||||
Pod更新程序时,能够滚动更新,不中断服务;
|
||||
Pod遇到压力时,能够水平扩展,不中断服务。
|
||||
|
||||
|
||||
|
||||
在这节课的一开始我提到过,虽然Pod本身也是资源,完全可以直接创建,但由Pod直接构成的系统是十分脆弱的,就像是那个气球中的小男孩,所以在实际生产中并不提倡。
|
||||
|
||||
正确的做法是通过副本集(ReplicaSet)来创建Pod。
|
||||
|
||||
ReplicaSet也是一种资源,它是属于工作负荷一类的资源,代表了一个或多个Pod副本的集合,你可以在ReplicaSet资源的元数据中,描述你期望Pod副本的数量(即spec.replicas的值)。
|
||||
|
||||
当ReplicaSet成功创建之后,副本集控制器就会持续跟踪该资源,一旦有Pod发生崩溃退出,或者状态异常(默认是靠进程返回值,你还可以在Pod中设置探针,以自定义的方式告诉Kubernetes出现何种情况Pod才算状态异常),ReplicaSet都会自动创建新的Pod来替代异常的Pod;如果因异常情况出现了额外数量的Pod,也会被ReplicaSet自动回收掉。
|
||||
|
||||
总之就是确保在任何时候,集群中这个Pod副本的数量都会向期望状态靠拢。
|
||||
|
||||
另外,我们还要清楚一点,就是ReplicaSet本身就能满足场景五中的第一项能力,可以保证Pod出现故障时自动恢复。但是在升级程序版本时,ReplicaSet就不得不主动中断旧Pod的运行,重新创建新版的Pod了,而这会造成服务中断。
|
||||
|
||||
因此,对于那些不允许中断的业务,以前的Kubernetes曾经提供过kubectl rolling-update命令,来辅助实现滚动更新。
|
||||
|
||||
所谓的滚动更新(Rolling Updates),是指先停止少量旧副本,维持大量旧副本继续提供服务,当停止的旧副本更新成功,新副本可以提供服务以后,再重复以上操作,直至所有的副本都更新成功。我们把这个过程放到ReplicaSet上,就是先创建新版本的ReplicaSet,然后一边让新ReplicaSet逐步创建新版Pod的副本,一边让旧的ReplicaSet逐渐减少旧版Pod的副本。
|
||||
|
||||
而到了现在,之所以kubectl rolling-update命令会被淘汰,其实是因为这样的命令式交互,完全不符合Kubernetes的设计理念(这是台面上的说法,我觉得淘汰的根本原因主要是因为它不够好用)。如果你希望改变某个资源的某种状态,就应该将期望状态告诉Kubernetes,而不是去教Kubernetes具体该如何操作。
|
||||
|
||||
所以,现在新的部署资源(Deployment)与部署控制器就被设计出来了。具体的实现步骤是这样的:我们可以由Deployment来创建ReplicaSet,再由ReplicaSet来创建Pod,当我们更新了Deployment中的信息以后(比如更新了镜像的版本),部署控制器就会跟踪到新的期望状态,自动地创建新ReplicaSet,并逐渐缩减旧的ReplicaSet的副本数,直到升级完成后,彻底删除掉旧ReplicaSet。这个工作过程如下图所示:
|
||||
|
||||
|
||||
|
||||
好,我们再来看看场景五中的最后一种情况。
|
||||
|
||||
你可能会知道,在遇到流量压力时,管理员完全可以手动修改Deployment中的副本数量,或者通过kubectl scale命令指定副本数量,促使Kubernetes部署更多的Pod副本来应对压力。然而这种扩容方式不仅需要人工参与,而且只靠人类经验来判断需要扩容的副本数量,也不容易做到精确与及时。
|
||||
|
||||
为此,Kubernetes又提供了Autoscaling资源和自动扩缩控制器,它们能够自动地根据度量指标,如处理器、内存占用率、用户自定义的度量值等,来设置Deployment(或者ReplicaSet)的期望状态,实现当度量指标出现变化时,系统自动按照“Autoscaling→Deployment→ReplicaSet→Pod”这样的顺序层层变更,最终实现根据度量指标自动扩容缩容。
|
||||
|
||||
小结
|
||||
|
||||
故障恢复、滚动更新、自动扩缩这些特性,在云原生时代中常常被概括成服务的弹性(Elasticity)与韧性(Resilience),ReplicaSet、Deployment、Autoscaling的用法,也是所有Kubernetes教材资料中都会讲到的“基础必修课”。
|
||||
|
||||
学完了这两节课,我还想再说明一点:如果你准备学习Kubernetes或者其他云原生的相关技术,我建议你最好不要死记硬背地学习每个资源的元数据文件该如何编写、有哪些指令、有哪些功能,更好的方式是站在解决问题的角度,去理解为什么Kubernetes要设计这些资源和控制器,理解为什么这些资源和控制器会被设计成现在这种样子。
|
||||
|
||||
一课一思
|
||||
|
||||
如果你觉得已经理解了前面几种资源和控制器的例子,那不妨思考几个问题:
|
||||
|
||||
|
||||
假设我想限制某个Pod持有的最大存储卷数量,应该如何设计?
|
||||
假设集群中某个Node发生硬件故障,Kubernetes要让调度任务避开这个Node,应该如何设计?
|
||||
假设一旦这个Node重新恢复,Kubernetes要能尽快利用上面的资源,又该如何去设计?
|
||||
|
||||
|
||||
其实,只要你真正接受了资源与控制器是贯穿整个Kubernetes的两大设计理念,即便不去查文档手册,也应该能推想出个大概轮廓,你在这个基础上再去看手册或者源码的时候,想必就能够事半功倍。
|
||||
|
||||
好,欢迎给我留言,分享你的答案。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
141
专栏/周志明的架构课/50_应用为中心的封装(上):Kustomize与Helm.md
Normal file
141
专栏/周志明的架构课/50_应用为中心的封装(上):Kustomize与Helm.md
Normal file
@@ -0,0 +1,141 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
50 _ 应用为中心的封装(上):Kustomize与Helm
|
||||
你好,我是周志明。
|
||||
|
||||
在理解了前面几节课所讲的容器技术发展的历程之后,不知你会不会有种“套娃式”的迷惑感?
|
||||
|
||||
|
||||
现在你已经知道,容器的崛起缘于chroot、namespaces、cgroups等内核提供的隔离能力,而系统级的虚拟化技术,使得同一台机器上互不干扰地运行多个服务成为了可能;
|
||||
为了降低用户使用内核隔离能力的门槛,随后出现了LXC,它是namespaces、cgroups特性的上层封装,这就让“容器”一词真正走出了实验室,开始走入工业界进行实际应用;
|
||||
然后,为了实现跨机器的软件绿色部署,出现了Docker,它(最初)是LXC的上层封装,彻底改变了软件打包分发的方式,迅速被大量企业广泛采用;
|
||||
而为了满足大型系统对服务集群化的需要,又出现了Kubernetes,它(最初)是Docker的上层封装,从而使得以多个容器共同协作构建出健壮的分布式系统,成为了今天云原生时代的技术基础设施。
|
||||
|
||||
|
||||
那这样你的疑惑可能也就出现了:Kubernetes会是容器化崛起之路的终点线吗?它达到了人们对云原生时代技术基础设施的期望了吗?
|
||||
|
||||
首先,从能力角度来看,可以说是的。Kubernetes被誉为云原生时代的操作系统,自诞生之日起它就因为出色的管理能力、扩展性和以声明代替命令的交互理念,收获了无数喝彩声。
|
||||
|
||||
但是,从易用角度来看,坦白说差距还非常大。云原生基础设施的其中一个重要目标,是接管掉业务系统复杂的非功能特性,这会让业务研发与运维工作变得足够简单,不受分布式的牵绊。然而,Kubernetes被诟病得最多的就是复杂,它从诞生之日起,就因为陡峭的学习曲线而出名。
|
||||
|
||||
我举个具体例子吧。如果要用Kubernetes部署一套Spring Cloud版的Fenix’s Bookstore,你需要分别部署一个到多个的配置中心、注册中心、服务网关、安全认证、用户服务、商品服务、交易服务,然后要对每个微服务都配置好相应的Kubernetes工作负载与服务访问,为每一个微服务的Deployment、ConfigMap、StatefulSet、HPA、Service、ServiceAccount、Ingress等资源都编写好元数据配置。
|
||||
|
||||
这个过程最难的地方不仅在于繁琐,还在于要想写出合适的元数据描述文件,你既需要懂开发(网关中服务调用关系、使用容器的镜像版本、运行依赖的环境变量等等这些参数,只有开发最清楚),又需要懂运维(要部署多少个服务、配置何种扩容缩容策略、数据库的密钥文件地址等等,只有运维最清楚),有时候还需要懂平台(需要什么样的调度策略、如何管理集群资源,通常只有平台组、中间件组或者核心系统组的同学才会关心),一般企业根本找不到合适的角色来为它管理、部署和维护应用。
|
||||
|
||||
这个事儿Kubernetes心里其实也挺委屈,因为以上提到的复杂性不能说是Kubernetes带来的,而是分布式架构本身的原罪。
|
||||
|
||||
对于大规模的分布式集群来说,无论是最终用户部署应用,还是软件公司管理应用,都存在着像前面提到的这诸多痛点。这些困难的实质是源于Docker容器镜像封装了单个服务,而Kubernetes通过资源封装了服务集群,却没有一个载体真正封装整个应用,这就使得它会把原本属于应用内部的技术细节给圈禁起来,不暴露给最终用户、系统管理员和平台维护者,而让使用者去埋单。
|
||||
|
||||
那么如此所造成的应用难以管理的矛盾,就在于封装应用的方法没能将开发、运维、平台等各种角色的关注点恰当地分离。
|
||||
|
||||
但是,既然在微服务时代,应用的形式已经不再局限于单个进程,那就也该到了重新定义“以应用为中心的封装”这句话的时候了。至于具体怎样的封装才算是正确的,其实到今天也还没有特别权威的结论,不过经过人们的尝试探索,已经能够窥见未来容器应用的一些雏形了。
|
||||
|
||||
所以接下来,我会花两节课的时间,给你介绍一下最近几年容器封装的两种主流思路,你可以从中理解容器“以应用为中心的封装”这个理念在不同阶段的内涵变化,这也是对“应用”这个概念的不断扩展升华的过程。
|
||||
|
||||
今天这节课呢,我们就先来了解下Kustomize和Helm,它们是封装“无状态应用”的典型代表。
|
||||
|
||||
|
||||
额外知识:无状态应用与有状态应用的区别-
|
||||
无状态应用(Stateless Application)与有状态应用(Stateful Application)说的是应用程序是否要自己持有其运行所需的数据,如果程序每次运行都跟首次运行一样,不依赖之前任何操作所遗留下来的痕迹,那它就是无状态的;反之,如果程序推倒重来之后,用户能察觉到该应用已经发生变化,那它就是有状态的。-
|
||||
下一讲要介绍的Operator与OAM就是支持有状态应用的封装方式,这里你可以先了解一下。
|
||||
|
||||
|
||||
Kustomize
|
||||
|
||||
最初,由Kubernetes官方给出的“如何封装应用”的解决方案是“用配置文件来配置文件”,这不是绕口令,你可以把它理解为是一种针对YAML的模版引擎的变体。
|
||||
|
||||
Kubernetes官方认为,应用就是一组具有相同目标的Kubernetes资源的集合,如果逐一管理、部署每项资源元数据太麻烦啰嗦的话,那就提供一种便捷的方式,把应用中不变的信息与易变的信息分离开,以此解决管理问题;把应用所有涉及的资源自动生成一个多合一(All-in-One)的整合包,以此解决部署问题。
|
||||
|
||||
而完成这项工作的工具就叫做Kustomize,它原本只是一个独立的小程序,从Kubernetes 1.14起,被纳入了kubectl命令之中,成为随着Kubernetes提供的内置功能。Kustomize使用Kustomization文件来组织与应用相关的所有资源,Kustomization本身也是一个以YAML格式编写的配置文件,里面定义了构成应用的全部资源,以及资源中需根据情况被覆盖的变量值。
|
||||
|
||||
Kustomize的主要价值是根据环境来生成不同的部署配置。只要建立多个Kustomization文件,开发人员就能以基于基准进行派生(Base and Overlay)的方式,对不同的模式(比如生产模式、调试模式)、不同的项目(同一个产品对不同客户的客制化)定制出不同的资源整合包。
|
||||
|
||||
在配置文件里,无论是开发关心的信息,还是运维关心的信息,只要是在元数据中有描述的内容,最初都是由开发人员来编写的,然后在编译期间由负责CI/CD的产品人员针对项目进行定制。最后在部署期间,由运维人员通过kubectl的补丁(Patch)机制更改其中需要运维去关注的属性,比如构造一个补丁来增加Deployment的副本个数,构造另外一个补丁来设置Pod的内存限制,等等。
|
||||
|
||||
k8s
|
||||
├── base
|
||||
│ ├── deployment.yaml
|
||||
│ ├── kustomization.yaml
|
||||
│ └── service.yaml
|
||||
└── overlays
|
||||
└── prod
|
||||
│ ├── load-loadbalancer-service.yaml
|
||||
│ └── kustomization.yaml
|
||||
└── debug
|
||||
└── kustomization.yaml
|
||||
|
||||
|
||||
从上面这段目录结构中,我们可以观察到一个由kustomize管理的应用结构,它主要由base和overlays组成。Kustomize使用Base、Overlay和Patch生成最终配置文件的思路,与Docker中分层镜像的思路有些相似,这样的方式既规避了以“字符替换”对资源元数据文件的入侵,也不需要用户学习额外的DSL语法(比如Lua)。
|
||||
|
||||
从效果来看,使用由Kustomize编译生成的All-in-One整合包来部署应用是相当方便的,只要一行命令就能够把应用涉及的所有服务一次安装好,在“探索与实践”小章节中会介绍的Kubernetes版本和Istio版本的Fenix’s Booktstore,都使用了这种方式来发布应用,你也不妨实际体验一下。
|
||||
|
||||
但是,毕竟Kustomize只是一个“小工具”性质的辅助功能,对于开发人员来说,Kustomize只能简化产品针对不同情况的重复配置,它其实并没有真正解决应用管理复杂的问题,要做的事、要写的配置,最终都没有减少,只是不用反复去写罢了;而对于运维人员来说,应用维护不仅仅只是部署那一下,应用的整个生命周期,除了安装外还有更新、回滚、卸载、多版本、多实例、依赖项维护等诸多问题,都很麻烦。
|
||||
|
||||
所以说,要想真正解决这些问题,还需要更加强大的管理工具,比如下面我要介绍的主角Helm。不过Kustomize能够以极小的成本,在一定程度上分离了开发和运维的工作,不用像Helm那样需要一套独立的体系来管理应用,这种轻量便捷,本身也是一种可贵的价值。
|
||||
|
||||
OK,下面我们就来具体讲讲Helm。
|
||||
|
||||
Helm与Chart
|
||||
|
||||
Helm是由Deis公司开发的一种更具系统性的管理和封装应用的解决方案,它参考了各大Linux发行版管理应用的思路,应用格式是Chart。
|
||||
|
||||
Helm一开始的目标就很明确:如果说Kubernetes是云原生操作系统的话,那Helm就要成为这个操作系统上面的应用商店与包管理工具。
|
||||
|
||||
我相信,Linux下的包管理工具和封装格式,如Debian系的apt-get命令与dpkg格式、RHEL系的yum命令与rpm格式,你肯定不会陌生。有了包管理工具,你只要知道应用的名称,就可以很方便地从应用仓库中下载、安装、升级、部署、卸载、回滚程序,而且包管理工具掌握着应用的依赖信息和版本变更情况,具备完整的自管理能力,每个应用需要依赖哪些前置的第三方库,在安装的时候都会一并处理好。
|
||||
|
||||
Helm模拟的就是这种做法,它提出了与Linux包管理直接对应的Chart格式和Repository应用仓库,另外针对Kubernetes中特有的一个应用经常要部署多个版本的特点,也提出了Release的专有概念。
|
||||
|
||||
Chart用于封装Kubernetes应用涉及到的所有资源,通常是以目录内的文件集合的形式存在的。目录名称就是Chart的名称(没有版本信息),比如官方仓库中WordPress Chart的目录结构是这样的:
|
||||
|
||||
WordPress
|
||||
├── templates
|
||||
│ ├── NOTES.txt
|
||||
│ ├── deployment.yaml
|
||||
│ ├── externaldb-secrets.yaml
|
||||
│ └── 版面原因省略其他资源文件
|
||||
│ └── ingress.yaml
|
||||
└── Chart.yaml
|
||||
└── requirements.yaml
|
||||
└── values.yaml
|
||||
|
||||
|
||||
其中有几个固定的配置文件:Chart.yaml给出了应用自身的详细信息(名称、版本、许可证、自述、说明、图标,等等),requirements.yaml给出了应用的依赖关系,依赖项指向的是另一个应用的坐标(名称、版本、Repository地址),values.yaml给出了所有可配置项目的预定义值。
|
||||
|
||||
可配置项就是指需要部署期间由运维人员调整的那些参数,它们以花括号包裹在templates目录下的资源文件中。当部署应用时,Helm会先将管理员设置的值覆盖到values.yaml的默认值上,然后以字符串替换的形式,传递给templates目录的资源模版,最后生成要部署到Kubernetes的资源文件。
|
||||
|
||||
由于Chart封装了足够丰富的信息,所以Helm除了支持命令行操作外,也能很容易地根据这些信息自动生成图形化的应用安装、参数设置界面。
|
||||
|
||||
我们再来说说Repository仓库。它主要是用于实现Chart的搜索与下载服务,Helm社区维护了公开的Stable和Incubator的中央仓库(界面如下图所示),也支持其他人或组织搭建私有仓库和公共仓库,并能够通过Hub服务,把不同个人或组织搭建的公共仓库聚合起来,形成更大型的分布式应用仓库,这也有利于Chart的查找与共享。
|
||||
|
||||
|
||||
|
||||
Helm Hub商店
|
||||
|
||||
所以整体来说,Helm提供了应用全生命周期、版本、依赖项的管理能力,同时,Helm还支持额外的扩展插件,能够加入CI/CD或者其他方面的辅助功能。
|
||||
|
||||
如此一来,它的定位就已经从单纯的工具升级到应用管理平台了,强大的功能让Helm收到了不少支持,有很多应用主动入驻到官方的仓库中。而从2018年起,Helm项目被托管到CNFC,成为其中的一个孵化项目。
|
||||
|
||||
总而言之,Helm通过模仿Linux包管理器的思路去管理Kubernetes应用,在一定程度上确实是可行的。不过,在Linux与Kubernetes中部署应用还是存在一些差别,最重要的一点是在Linux中99%的应用都只会安装一份,而Kubernetes里为了保证可用性,同一个应用部署多份副本才是常规操作。
|
||||
|
||||
所以,Helm为了支持对同一个Chart包进行多次部署,每次安装应用都会产生一个Release,Release就相当于该Chart的安装实例。对于无状态的服务来说,靠着不同的Release就已经足够支持多个服务并行工作了,但对于有状态的服务来说,服务会与特定资源或者服务产生依赖关系,比如要部署数据库,通常要依赖特定的存储来保存持久化数据,这样事情就变得复杂起来了。
|
||||
|
||||
既然Helm无法很好地管理这种有状态的依赖关系,那么这一类问题就是Operator要解决的痛点了。这也是我在下一节课要给你重点介绍的工具。
|
||||
|
||||
小结
|
||||
|
||||
今天,我给你介绍了两种比较常用,也较为具体的应用封装方式,分别是Kubernetes官方推出的Kustomize,以及目前在Kubernetes上较为主流的“应用商店”格式Helm与Chart。这样的封装对于无状态应用已经足够了,但对于有状态应用来说,仍然不能满足需要。
|
||||
|
||||
在下节课,我们将继续应用封装这个话题,一起来探讨如何为有状态应用提供支持。
|
||||
|
||||
一课一思
|
||||
|
||||
你是否尝试过在Kubernetes中部署一些需共享状态的集群应用?比如Etcd、Easticsearch等等?你是自己编写YAML,定义它们所需的各种资源的吗?
|
||||
|
||||
欢迎在留言区分享你的答案。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
274
专栏/周志明的架构课/51_应用为中心的封装(下):Operator与OAM.md
Normal file
274
专栏/周志明的架构课/51_应用为中心的封装(下):Operator与OAM.md
Normal file
@@ -0,0 +1,274 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
51 _ 应用为中心的封装(下):Operator与OAM
|
||||
你好,我是周志明。上节课我们了解了无状态应用的两种主流封装方式,分别是Kustomize和Helm。那么今天这节课,我们继续来学习有状态应用的两种封装方法,包括Operator和开放应用模型。
|
||||
|
||||
Operator
|
||||
|
||||
与Kustomize和Helm不同的是,Operator不应当被称作是一种工具或者系统,它应该算是一种封装、部署和管理Kubernetes应用的方法,尤其是针对最复杂的有状态应用去封装运维能力的解决方案,最早是由CoreOS公司(于2018年被RedHat收购)的华人程序员邓洪超提出的。
|
||||
|
||||
简单来说,Operator是通过Kubernetes 1.7开始支持的自定义资源(Custom Resource Definitions,CRD,此前曾经以TPR,即Third Party Resource的形式提供过类似的能力),把应用封装为另一种更高层次的资源,再把Kubernetes的控制器模式从面向内置资源,扩展到了面向所有自定义资源,以此来完成对复杂应用的管理。
|
||||
|
||||
具体怎么理解呢?我们来看一下RedHat官方对Operator设计理念的阐述:
|
||||
|
||||
|
||||
Operator设计理念-
|
||||
Operator是使用自定义资源(CR,本人注:CR即Custom Resource,是CRD的实例)管理应用及其组件的自定义Kubernetes控制器。高级配置和设置由用户在CR中提供。Kubernetes Operator基于嵌入在Operator逻辑中的最佳实践,将高级指令转换为低级操作。Kubernetes Operator监视CR类型并采取特定于应用的操作,确保当前状态与该资源的理想状态相符。-
|
||||
—— 什么是 Kubernetes Operator,RedHat
|
||||
|
||||
|
||||
这段文字是直接由RedHat官方撰写并翻译成中文的,准确严谨,但比较拗口,对于没接触过Operator的人来说并不友好,比如,你可能就会问,什么叫做“高级指令”?什么叫做“低级操作”?它们之间具体如何转换呢?等等。
|
||||
|
||||
其实要理解这些问题,你必须先弄清楚有状态和无状态应用的含义及影响,然后再来理解Operator所做的工作。在上节课,我给你补充了一个“额外知识”,已经介绍过了二者之间的区别,现在我们再来看看:
|
||||
|
||||
|
||||
有状态应用(Stateful Application)与无状态应用(Stateless Application)说的是应用程序是否要自己持有其运行所需的数据,如果程序每次运行都跟首次运行一样,不依赖之前任何操作所遗留下来的痕迹,那它就是无状态的;反之,如果程序推倒重来之后,用户能察觉到该应用已经发生变化,那它就是有状态的。
|
||||
|
||||
|
||||
无状态应用在分布式系统中具有非常巨大的价值,我们都知道分布式中的CAP不兼容原理,如果无状态,那就不必考虑状态一致性,没有了C,那A和P就可以兼得。换句话说,只要资源足够,无状态应用天生就是高可用的。但不幸的是,现在的分布式系统中,多数关键的基础服务都是有状态的,比如缓存、数据库、对象存储、消息队列,等等,只有Web服务器这类服务属于无状态。
|
||||
|
||||
站在Kubernetes的角度看,是否有状态的本质差异在于,有状态应用会对某些外部资源有绑定性的直接依赖,比如说,Elasticsearch在建立实例时,必须依赖特定的存储位置,只有重启后仍然指向同一个数据文件的实例,才能被认为是相同的实例。另外,有状态应用的多个应用实例之间,往往有着特定的拓扑关系与顺序关系,比如etcd的节点间选主和投票,节点们都需要得知彼此的存在,明确每一个节点的网络地址和网络拓扑关系。
|
||||
|
||||
为了管理好那些与应用实例密切相关的状态信息,Kubernetes从1.9版本开始正式发布了StatefulSet及对应的StatefulSetController。与普通ReplicaSet中的Pod相比,由StatefulSet管理的Pod具备几项额外特性。
|
||||
|
||||
|
||||
Pod会按顺序创建和按顺序销毁:StatefulSet中的各个Pod会按顺序地创建出来,而且,再创建后面的Pod之前,必须要保证前面的Pod已经转入就绪状态。如果要销毁StatefulSet中的Pod,就会按照与创建顺序的逆序来执行。
|
||||
Pod具有稳定的网络名称:Kubernetes中的Pod都具有唯一的名称,在普通的副本集中,这是靠随机字符产生的,而在StatefulSet中管理的Pod,会以带有顺序的编号作为名称,而且能够在重启后依然保持不变。
|
||||
Pod具有稳定的持久存储:StatefulSet中的每个Pod都可以拥有自己独立的PersistentVolumeClaim资源。即使Pod被重新调度到其他节点上,它所拥有的持久磁盘也依然会被挂载到该Pod,这点会在“容器持久化”这个小章节中进一步介绍。
|
||||
|
||||
|
||||
看到这些特性以后,你可能还会说,我还是不太理解StatefulSet的设计意图呀。没关系,我来举个例子,你一看就理解了。
|
||||
|
||||
如果把ReplicaSet中的Pod比喻为养殖场中的“肉猪”,那StatefulSet就是被当作宠物圈养的“荷兰猪”,不同的肉猪在食用功能上并没有什么区别,但每只宠物猪都是独一无二的,有专属于自己的名字、习性与记忆。事实上,早期的StatefulSet就曾经使用过PetSet这个名字。
|
||||
|
||||
StatefulSet出现以后,Kubernetes就能满足Pod重新创建后,仍然保留上一次运行状态的需求了。不过,有状态应用的维护并不仅限于此。比如说,对于一套Elasticsearch集群来说,通过StatefulSet,最多只能做到创建集群、删除集群、扩容缩容等最基本的操作,并不支持常见的运维操作,比如备份和恢复数据、创建和删除索引、调整平衡策略,等等。
|
||||
|
||||
我再举个实际例子,来说明一下,Operator是如何解决那些StatefulSet覆盖不到的有状态服务管理需求的。
|
||||
|
||||
假设我们要部署一套Elasticsearch集群,通常要在StatefulSet中定义相当多的细节,比如服务的端口、Elasticsearch的配置、更新策略、内存大小、虚拟机参数、环境变量、数据文件位置,等等。
|
||||
|
||||
这里我直接把满足这个需求的YAML全部贴出来,让你对咱们前面反复提到的Kubernetes的复杂性有更加直观的理解。
|
||||
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: elasticsearch-cluster
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app: es-cluster
|
||||
ports:
|
||||
- name: transport
|
||||
port: 9300
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: elasticsearch-loadbalancer
|
||||
spec:
|
||||
selector:
|
||||
app: es-cluster
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 9200
|
||||
type: LoadBalancer
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: es-config
|
||||
data:
|
||||
elasticsearch.yml: |
|
||||
cluster.name: my-elastic-cluster
|
||||
network.host: "0.0.0.0"
|
||||
bootstrap.memory_lock: false
|
||||
discovery.zen.ping.unicast.hosts: elasticsearch-cluster
|
||||
discovery.zen.minimum_master_nodes: 1
|
||||
xpack.security.enabled: false
|
||||
xpack.monitoring.enabled: false
|
||||
ES_JAVA_OPTS: -Xms512m -Xmx512m
|
||||
---
|
||||
apiVersion: apps/v1beta1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: esnode
|
||||
spec:
|
||||
serviceName: elasticsearch
|
||||
replicas: 3
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: es-cluster
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
initContainers:
|
||||
- name: init-sysctl
|
||||
image: busybox
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["sysctl", "-w", "vm.max_map_count=262144"]
|
||||
containers:
|
||||
- name: elasticsearch
|
||||
resources:
|
||||
requests:
|
||||
memory: 1Gi
|
||||
securityContext:
|
||||
privileged: true
|
||||
runAsUser: 1000
|
||||
capabilities:
|
||||
add:
|
||||
- IPC_LOCK
|
||||
- SYS_RESOURCE
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.9.1
|
||||
env:
|
||||
- name: ES_JAVA_OPTS
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: es-config
|
||||
key: ES_JAVA_OPTS
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
scheme: HTTP
|
||||
path: /_cluster/health?local=true
|
||||
port: 9200
|
||||
initialDelaySeconds: 5
|
||||
ports:
|
||||
- containerPort: 9200
|
||||
name: es-http
|
||||
- containerPort: 9300
|
||||
name: es-transport
|
||||
volumeMounts:
|
||||
- name: es-data
|
||||
mountPath: /usr/share/elasticsearch/data
|
||||
- name: elasticsearch-config
|
||||
mountPath: /usr/share/elasticsearch/config/elasticsearch.yml
|
||||
subPath: elasticsearch.yml
|
||||
volumes:
|
||||
- name: elasticsearch-config
|
||||
configMap:
|
||||
name: es-config
|
||||
items:
|
||||
- key: elasticsearch.yml
|
||||
path: elasticsearch.yml
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: es-data
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
|
||||
|
||||
可以看到,这里面的细节配置非常多。其实,之所以会这样,根本原因在于Kubernetes完全不知道Elasticsearch是个什么东西。所有Kubernetes不知道的信息、不能启发式推断出来的信息,都必须由用户在资源的元数据定义中明确列出,必须一步一步手把手地“教会”Kubernetes部署Elasticsearch,这种形式就属于咱们刚刚提到的“低级操作”。
|
||||
|
||||
如果我们使用Elastic.co官方提供的Operator,那就会简单多了。Elasticsearch Operator提供了一种kind: Elasticsearch的自定义资源,在它的帮助下,只需要十行代码,将用户的意图是“部署三个版本为7.9.1的ES集群节点”说清楚,就能实现跟前面StatefulSet那一大堆配置相同甚至是更强大的效果,如下面代码所示:
|
||||
|
||||
apiVersion: elasticsearch.k8s.elastic.co/v1
|
||||
kind: Elasticsearch
|
||||
metadata:
|
||||
name: elasticsearch-cluster
|
||||
spec:
|
||||
version: 7.9.1
|
||||
nodeSets:
|
||||
- name: default
|
||||
count: 3
|
||||
config:
|
||||
node.master: true
|
||||
node.data: true
|
||||
node.ingest: true
|
||||
node.store.allow_mmap: false
|
||||
|
||||
|
||||
有了Elasticsearch Operator的自定义资源,就相当于Kubernetes已经学会怎样操作Elasticsearch了。知道了所有它相关的参数含义与默认值,就不需要用户再手把手地教了,这种就是所谓的“高级指令”。
|
||||
|
||||
Operator将简洁的高级指令转化为Kubernetes中具体操作的方法,跟Helm或Kustomize的思路并不一样:
|
||||
|
||||
|
||||
Helm和Kustomize最终仍然是依靠Kubernetes的内置资源,来跟Kubernetes打交道的;
|
||||
Operator则是要求开发者自己实现一个专门针对该自定义资源的控制器,在控制器中维护自定义资源的期望状态。
|
||||
|
||||
|
||||
通过程序编码来扩展Kubernetes,比只通过内置资源来与Kubernetes打交道要灵活得多。比如,在需要更新集群中某个Pod对象的时候,由Operator开发者自己编码实现的控制器,完全可以在原地对Pod进行重启,不需要像Deployment那样,必须先删除旧Pod,然后再创建新Pod。
|
||||
|
||||
使用CRD定义高层次资源、使用配套的控制器来维护期望状态,带来的好处不仅仅是“高级指令”的便捷,更重要的是,可以在遵循Kubernetes的一贯基于资源与控制器的设计原则的同时,又不必受制于Kubernetes内置资源的表达能力。只要Operator的开发者愿意编写代码,前面提到的那些StatfulSet不能支持的能力,如备份恢复数据、创建删除索引、调整平衡策略等操作,都完全可以实现出来。
|
||||
|
||||
把运维的操作封装在程序代码上,从表面上看,最大的受益者是运维人员,开发人员要为此付出更多劳动。然而,Operator并没有被开发者抵制,相反,因为用代码来描述复杂状态往往反而比配置文件更加容易,开发与运维之间的协作成本降低了,还受到了开发者的一致好评。
|
||||
|
||||
因为Operator的各种优势,它变成了近两、三年容器封装应用的一股新潮流,现在很多复杂分布式系统都有了官方或者第三方提供的Operator(这里收集了一部分)。RedHat公司也持续在Operator上面进行了大量投入,推出了简化开发人员编写Operator的Operator Framework/SDK。
|
||||
|
||||
目前看来,应对有状态应用的封装运维,Operator也许是最有可行性的方案,但这依然不是一项轻松的工作。以etcd的Operator为例,etcd本身不算什么特别复杂的应用,Operator实现的功能看起来也相当基础,主要有创建集群、删除集群、扩容缩容、故障转移、滚动更新、备份恢复等功能,但是代码就已经超过一万行了。
|
||||
|
||||
现在,开发Operator的确还是有着相对较高的门槛,通常由专业的平台开发者而非业务开发或者运维人员去完成。但是,Operator非常符合技术潮流,顺应了软件业界所提倡的DevOps一体化理念,等Operator的支持和生态进一步成熟之后,开发和运维都能从中受益,未来应该能成长为一种应用封装的主流形式。
|
||||
|
||||
OAM
|
||||
|
||||
我要给你介绍的最后一种应用封装的方案,是阿里云和微软在2019年10月上海QCon大会上联合发布的开放应用模型(Open Application Model,OAM),它不仅是中国云计算企业参与制定,甚至是主导发起的国际技术规范,也是业界首个云原生应用标准定义与架构模型。
|
||||
|
||||
OAM思想的核心是将开发人员、运维人员与平台人员的关注点分离,开发人员关注业务逻辑的实现,运维人员关注程序的平稳运行,平台人员关注基础设施的能力与稳定性。毕竟,长期让几个角色厮混在同一个All-in-One资源文件里,并不能擦出什么火花,反而会将配置工作弄得越来越复杂,把“YAML Engineer”弄成容器界的嘲讽梗。
|
||||
|
||||
OAM对云原生应用的定义是:“由一组相互关联但又离散独立的组件构成,这些组件实例化在合适的运行时上,由配置来控制行为并共同协作提供统一的功能”。你可能看不懂是啥意思,没有关系,为了方便跟后面的概念对应,我先把这句话拆解一下:
|
||||
|
||||
|
||||
OAM定义的应用-
|
||||
一个Application由一组Components构成,每个Component的运行状态由Workload描述,每个Component可以施加Traits来获取额外的运维能力,同时,我们可以使用Application Scopes将Components划分到一或者多个应用边界中,便于统一做配置、限制、管理。把Components、Traits和Scopes组合在一起实例化部署,形成具体的Application Configuration,以便解决应用的多实例部署与升级。
|
||||
|
||||
|
||||
然后,我来具体解释一下上面列出来的核心概念,来帮助你理解OAM对应用的定义。在这句话里面,每一个用英文标注出来的技术名词,都是OAM在Kubernetes的基础上扩展而来的概念,每一个名词都有相对应的自定义资源。换句话说,它们并不是单纯的抽象概念,而是可以被实际使用的自定义资源。
|
||||
|
||||
我们来看一下这些概念的具体含义。
|
||||
|
||||
|
||||
Components(服务组件):自SOA时代以来,由Component构成应用的思想就屡见不鲜,然而,OAM的Component不仅仅是特指构成应用“整体”的一个“部分”,它还有一个重要的职责,那就是抽象出那些应该由开发人员去关注的元素。比如应用的名字、自述、容器镜像、运行所需的参数,等等。
|
||||
Workload(工作负荷):Workload决定了应用的运行模式,每个Component都要设定自己的Workload类型,OAM按照“是否可访问、是否可复制、是否长期运行”预定义了六种Workload类型,如下表所示。如果有必要,使用者还可以通过CRD与Operator去扩展。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Traits(运维特征):开发活动有大量复用功能的技巧,但运维活动却很缺少这样的技巧,平时能为运维写个Shell脚本或简单工具,就已经算是个高级的运维人员了。Traits就可以用来封装模块化后的运维能力,它可以针对运维中的可重复操作预先设定好一些具体的Traits,比如日志收集Trait、负载均衡Trait、水平扩缩容Trait,等等。这些预定义的Traits定义里,会注明它们可以作用于哪种类型的工作负荷,还包括能填哪些参数、哪些必填选填项、参数的作用描述是什么,等等。
|
||||
Application Scopes(应用边界):多个Component共同组成一个Scope,你可以根据Component的特性或作用域来划分Scope。比如,具有相同网络策略的Component放在同一个Scope中,具有相同健康度量策略的Component放到另一个Scope中。同时,一个Component也可能属于多个Scope,比如,一个Component完全可能既需要配置网络策略,也需要配置健康度量策略。
|
||||
Application Configuration(应用配置):将Component(必须)、Trait(必须)、Scope(非必须)组合到一起进行实例化,就形成了一个完整的应用配置。
|
||||
|
||||
|
||||
OAM使用咱们刚刚所说的这些自定义资源,对原先All-in-One的复杂配置做了一定层次的解耦:
|
||||
|
||||
|
||||
开发人员负责管理Component;
|
||||
运维人员将Component组合并绑定Trait,把它变成Application Configuration;
|
||||
平台人员或基础设施提供方负责提供OAM的解释能力,将这些自定义资源映射到实际的基础设施。
|
||||
|
||||
|
||||
这样,不同角色分工协作,就整体简化了单个角色关注的内容,让不同角色可以更聚焦、更专业地做好本角色的工作,整个过程如下图所示:
|
||||
|
||||
|
||||
|
||||
OAM角色关系图(图片来自OAM规范GitHub)
|
||||
|
||||
事实上,OAM未来能否成功,很大程度上取决于云计算厂商的支持力度,因为OAM的自定义资源一般是由云计算基础设施负责解释和驱动的,比如阿里云的EDAS就已内置了OAM的支持。
|
||||
|
||||
如果你希望能够应用在私有Kubernetes环境中,目前,OAM的主要参考实现是Rudr(已声明废弃)和Crossplane。Crossplane是一个仅发起一年多的CNCF沙箱项目,主要参与者包括阿里云、微软、Google、RedHat等工程师。Crossplane提供了OAM中全部的自定义资源和控制器,安装以后,就可以用OAM定义的资源来描述应用了。
|
||||
|
||||
小结
|
||||
|
||||
今天,容器圈的发展是一日千里,各种新规范、新技术层出不穷。在这两节课里,我特意挑选了非常流行,而且有代表性的四种,分别是“无状态应用”的典型代表Kustomize和Helm,和“有状态应用”的典型代表Operator和OAM。
|
||||
|
||||
其他我没有提到的应用封装技术,还有CNAB、Armada、Pulumi,等等。这些封装技术会有一定的重叠之处,但并非都是重复的轮子,在实际应用的时候,往往会联合其中多个工具一起使用。而至于怎么封装应用才是最佳的实践,目前也还没有定论,但可以肯定的是,以应用为中心的理念已经成为了明确的共识。
|
||||
|
||||
一课一思
|
||||
|
||||
在“虚拟化容器”这个小章节中,我安排了五节课来介绍虚拟化容器的原理和应用,不知道经过这几节课的学习后,你对容器是否有更新的认识?可以在留言区说说你对容器现状和未来的看法,我们一起交流讨论。
|
||||
|
||||
如果你觉得有收获,欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
170
专栏/周志明的架构课/52_Linux网络虚拟化(上):信息是如何通过网络传输被另一个程序接收到的?.md
Normal file
170
专栏/周志明的架构课/52_Linux网络虚拟化(上):信息是如何通过网络传输被另一个程序接收到的?.md
Normal file
@@ -0,0 +1,170 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
52 _ Linux网络虚拟化(上):信息是如何通过网络传输被另一个程序接收到的?
|
||||
你好,我是周志明。从这节课开始,我会用两讲的时间带你学习虚拟化网络方面的知识点。
|
||||
|
||||
如果不加任何限定,“虚拟化网络”其实是一项内容十分丰富,研究历史十分悠久的计算机技术,它完全不依附于虚拟化容器,而是作为计算机科学中一门独立的分支。像是网络运营商经常提起的“网络功能虚拟化”(Network Function Virtualization,NFV),还有网络设备商和网络管理软件提供商经常提起的“软件定义网络”(Software Defined Networking,SDN)等等,这些都属于虚拟化网络的范畴。
|
||||
|
||||
不过,对于我们这样普通的软件开发者来说,一般没有什么必要去完全理解和掌握虚拟化网络,因为这需要储备大量开发中不常用到的专业知识,而且还会消耗大量的时间成本。
|
||||
|
||||
所以在课程里,我们讨论的虚拟化网络是狭义的,它特指“如何基于Linux系统的网络虚拟化技术来实现的容器间网络通信”,更通俗一点说,就是只关注那些为了相互隔离的Linux网络名称空间可以相互通信,而设计出来的虚拟化网络设施。
|
||||
|
||||
另外我还要说明的是,在这个语境中的“虚拟化网络”就是直接为容器服务的,说它是依附于容器而存在的也完全可行。所以为了避免混淆,我在课程中会尽量回避“虚拟化网络”这个范畴过大的概念,而是会以“容器间网络”和“Linux网络虚拟化”为题来展开讲解。
|
||||
|
||||
好了,下面我们就从Linux下网络通信的协议栈模型,以及程序如何干涉在协议栈中流动的信息来开始了解吧。
|
||||
|
||||
Linux系统下的网络通信模型
|
||||
|
||||
如果抛开虚拟化,只谈网络的话,那我认为首先应该了解的知识,就是Linux系统的网络通信模型,即信息是如何从程序中发出,通过网络传输,再被另一个程序接收到的。
|
||||
|
||||
从整体上看,Linux系统的通信过程无论是按理论上的OSI七层模型,还是以实际上的TCP/IP四层模型来解构,都明显地呈现出“逐层调用,逐层封装”的特点,这种逐层处理的方式与栈结构,比如程序执行时的方法栈很类似,所以它通常被称为“Linux网络协议栈”,简称“网络栈”,有时也称“协议栈”。
|
||||
|
||||
下图就体现了Linux网络通信过程与OSI或者TCP/IP模型的对应关系,也展示了网络栈中的数据流动的路径,你可以看一下:
|
||||
|
||||
|
||||
|
||||
在图中传输模型的左侧,我特别标示出了网络栈在用户与内核空间的部分,也就是说几乎整个网络栈(应用层以下)都位于系统内核空间之中,而Linux系统之所以采用这种设计,主要是从数据安全隔离的角度出发来考虑的。
|
||||
|
||||
由内核去处理网络报文的收发,无疑会有更高的执行开销,比如数据在内核态和用户态之间来回拷贝的额外成本,所以就会损失一些性能,但是这样能够保证应用程序无法窃听到或者去伪造另一个应用程序的通信内容。当然,针对特别关注收发性能的应用场景,也有直接在用户空间中实现全套协议栈的旁路方案,比如开源的Netmap以及Intel的DPDK,都能做到零拷贝收发网络数据包。
|
||||
|
||||
另外,图中传输模型的箭头展示的是数据流动的方向,它体现了信息从程序中发出以后,到被另一个程序接收到之前经历的几个阶段,下面我来给你一一分析下。
|
||||
|
||||
|
||||
Socket
|
||||
|
||||
|
||||
应用层的程序是通过Socket编程接口,来和内核空间的网络协议栈通信的。Linux Socket是从BSD Socket发展而来的,现在的Socket已经不局限于作为某个操作系统的专属功能,而是成为了各大主流操作系统共同支持的通用网络编程接口,是网络应用程序实际上的交互基础。
|
||||
|
||||
在这里,应用程序通过读写收、发缓冲区(Receive/Send Buffer)来与Socket进行交互,在Unix和Linux系统中,出于“一切皆是文件”的设计哲学,对Socket的操作被实现为了对文件系统(socketfs)的读写访问操作,通过文件描述符(File Descriptor)来进行。
|
||||
|
||||
|
||||
TCP/UDP
|
||||
|
||||
|
||||
传输层协议族里,最重要的协议无疑就是传输控制协议(Transmission Control Protocol,TCP)和用户数据报协议(User Datagram Protocol,UDP)两种,它们也是在Linux内核中被直接支持的协议。此外还有流控制传输协议(Stream Control Transmission Protocol,SCTP)、数据报拥塞控制协议(Datagram Congestion Control Protocol,DCCP),等等。当然了,不同的协议处理流程大致都是一样的,只是封装的报文和头、尾部信息会有些不一样。
|
||||
|
||||
这里我以TCP协议为例,内核发现Socket的发送缓冲区中,有新的数据被拷贝进来后,会把数据封装为TCP Segment报文,常见的网络协议的报文基本上都是由报文头(Header)和报文体(Body,也叫荷载“Payload”)两部分组成。
|
||||
|
||||
接着,系统内核将缓冲区中用户要发送出去的数据作为报文体,然后把传输层中的必要控制信息,比如代表哪个程序发、由哪个程序收的源、目标端口号,用于保证可靠通信(重发与控制顺序)的序列号、用于校验信息是否在传输中出现损失的校验和(Check Sum)等信息,封装入报文头中。
|
||||
|
||||
|
||||
IP
|
||||
|
||||
|
||||
网络层协议最主要的就是网际协议(Internet Protocol,IP),其他的还会有因特网组管理协议(Internet Group Management Protocol,IGMP)、大量的路由协议(EGP、NHRP、OSPF、IGRP、……),等等。
|
||||
|
||||
这里我就以IP协议为例,它会把来自上一层(即前面例子中的TCP报文)的数据包作为报文体,然后再次加入到自己的报文头中,比如指明数据应该发到哪里的路由地址、数据包的长度、协议的版本号,等等,这样封装成IP数据包后再发往下一层。关于TCP和IP协议报文的内容,我曾在“负载均衡”这节课中详细讲解过,你可以去回顾复习下。
|
||||
|
||||
|
||||
Device
|
||||
|
||||
|
||||
Device即网络设备,它是网络访问层中面向系统一侧的接口。不过这里所说的设备,跟物理硬件设备并不是同一个概念,Device只是一种向操作系统端开放的接口,它的背后既可能代表着真实的物理硬件,也可能是某段具有特定功能的程序代码,比如即使不存在物理网卡,也依然可以存在回环设备(Loopback Device)。
|
||||
|
||||
许多网络抓包工具,比如tcpdump、Wirshark就是在此处工作的,我在前面第38讲介绍微服务流量控制的时候,曾提到过的网络流量整形,通常也是在这里完成的。
|
||||
|
||||
Device主要的作用是抽象出统一的界面,让程序代码去选择或影响收发包出入口,比如决定数据应该从哪块网卡设备发送出去;还有就是准备好网卡驱动工作所需的数据,比如来自上一层的IP数据包、下一跳(Next Hop)的MAC地址(这个地址是通过ARP Request得到的),等等。
|
||||
|
||||
|
||||
Driver
|
||||
|
||||
|
||||
网卡驱动程序(Driver)是网络访问层中面向硬件一侧的接口,网卡驱动程序会通过DMA把主存中的待发送的数据包,复制到驱动内部的缓冲区之中。数据被复制的同时,也会把上层提供的IP数据包、下一跳的MAC地址这些信息,加上网卡的MAC地址、VLAN Tag等信息,一并封装成为以太帧(Ethernet Frame),并自动计算校验和。而对于需要确认重发的信息,如果没有收到接收者的确认(ACK)响应,那重发的处理也是在这里自动完成的。
|
||||
|
||||
好了,上面这些阶段就是信息从程序中对外发出时,经过协议栈的过程了,而接收过程则是从相反方向进行的逆操作。
|
||||
|
||||
这里你需要记住,程序发送数据做的是层层封包,加入协议头,传给下一层;而接受数据则是层层解包,提取协议体,传给上一层。你可以通过类比来理解数据包的接收过程,我就不再啰嗦一遍数据接收的步骤了。
|
||||
|
||||
干预网络通信的Netfilter框架
|
||||
|
||||
到这里,我们似乎可以发现,网络协议栈的处理是一套相对固定和封闭的流程,在整套处理过程中,除了在网络设备这层,我们能看到一点点程序以设备的形式介入处理的空间以外,其他过程似乎就没有什么可供程序插手的余地了。
|
||||
|
||||
然而事实并非如此,从Linux Kernel 2.4版开始,内核开放了一套通用的、可供代码干预数据在协议栈中流转的过滤器框架,这就是Netfilter框架。
|
||||
|
||||
Netfilter框架是Linux防火墙和网络的主要维护者罗斯迪·鲁塞尔(Rusty Russell)提出并主导设计的,它围绕网络层(IP协议)的周围,埋下了五个钩子(Hooks),每当有数据包流到网络层,经过这些钩子时,就会自动触发由内核模块注册在这里的回调函数,程序代码就能够通过回调来干预Linux的网络通信。
|
||||
|
||||
下面我给你介绍一下这五个钩子分别都是什么:
|
||||
|
||||
|
||||
PREROUTING:来自设备的数据包进入协议栈后,就会立即触发这个钩子。注意,如果PREROUTING钩子在进入IP路由之前触发了,就意味着只要接收到的数据包,无论是否真的发往本机,也都会触发这个钩子。它一般是用于目标网络地址转换(Destination NAT,DNAT)。
|
||||
INPUT:报文经过IP路由后,如果确定是发往本机的,将会触发这个钩子,它一般用于加工发往本地进程的数据包。
|
||||
FORWARD:报文经过IP路由后,如果确定不是发往本机的,将会触发这个钩子,它一般用于处理转发到其他机器的数据包。
|
||||
OUTPUT:从本机程序发出的数据包,在经过IP路由前,将会触发这个钩子,它一般用于加工本地进程的输出数据包。
|
||||
POSTROUTING:从本机网卡出去的数据包,无论是本机的程序所发出的,还是由本机转发给其他机器的,都会触发这个钩子,它一般是用于源网络地址转换(Source NAT,SNAT)。
|
||||
|
||||
|
||||
|
||||
|
||||
Netfilter允许在同一个钩子处注册多个回调函数,所以数据包在向钩子注册回调函数时,必须提供明确的优先级,以便触发时能按照优先级从高到低进行激活。而因为回调函数会有很多个,看起来就像是挂在同一个钩子上的一串链条,所以钩子触发的回调函数集合,就被称为“回调链”(Chained Callbacks),这个名字也导致了后续基于Netfilter设计的Xtables系工具,比如下面我要介绍的iptables,都使用到了“链”(Chain)的概念。
|
||||
|
||||
那么,虽然现在看来,Netfilter只是一些简单的事件回调机制而已,但这样一套简单的设计,却成为了整座Linux网络大厦的核心基石,Linux系统提供的许多网络能力,比如数据包过滤、封包处理(设置标志位、修改TTL等)、地址伪装、网络地址转换、透明代理、访问控制、基于协议类型的连接跟踪、带宽限速,等等,它们都是在Netfilter的基础之上实现的。
|
||||
|
||||
而且,以Netfilter为基础的应用也有很多,其中使用最广泛的毫无疑问要数Xtables系列工具,比如iptables、ebtables、arptables、ip6tables,等等。如果你用过Linux系统来做过开发的话,那我估计至少这里面的iptables工具,你会或多或少地使用过,它常被称为是Linux系统“自带的防火墙”。
|
||||
|
||||
但其实,iptables实际能做的事情已经远远超出了防火墙的范畴,严谨地讲,iptables比较贴切的定位应该是能够代替Netfilter多数常规功能的IP包过滤工具。
|
||||
|
||||
要知道,iptables的设计意图是因为Netfilter的钩子回调虽然很强大,但毕竟要通过程序编码才够能使用,并不适合系统管理员用来日常运维,而它的价值就是以配置去实现原本用Netfilter编码才能做到的事情。
|
||||
|
||||
一般来说,iptables会先把用户常用的管理意图总结成具体的行为,预先准备好,然后就会在满足条件的时候自动激活行为,比如以下几种常见的iptables预置的行为:
|
||||
|
||||
|
||||
DROP:直接将数据包丢弃。
|
||||
REJECT:给客户端返回Connection Refused或Destination Unreachable报文。
|
||||
QUEUE:将数据包放入用户空间的队列,供用户空间的程序处理。
|
||||
RETURN:跳出当前链,该链里后续的规则不再执行。
|
||||
ACCEPT:同意数据包通过,继续执行后续的规则。
|
||||
JUMP:跳转到其他用户自定义的链继续执行。
|
||||
REDIRECT:在本机做端口映射。
|
||||
MASQUERADE:地址伪装,自动用修改源或目标的IP地址来做NAT
|
||||
LOG:在/var/log/messages文件中记录日志信息。
|
||||
……
|
||||
|
||||
|
||||
当然,这些行为本来能够被挂载到Netfilter钩子的回调链上,但iptables又进行了一层额外抽象,它不是把行为与链直接挂钩,而是会根据这些底层操作的目的,先总结为更高层次的规则。
|
||||
|
||||
我举个例子,假设你挂载规则的目的是为了实现网络地址转换(NAT),那就应该对符合某种特征的流量(比如来源于某个网段、从某张网卡发送出去)、在某个钩子上(比如做SNAT通常在POSTROUTING,做DNAT通常在PREROUTING)进行MASQUERADE行为,这样具有相同目的的规则,就应该放到一起才便于管理,所以也就形成了“规则表”的概念。
|
||||
|
||||
iptables内置了五张不可扩展的规则表(其中的security表并不常用,很多资料只计算了前四张表),我们来看看:
|
||||
|
||||
|
||||
raw表:用于去除数据包上的连接追踪机制(Connection Tracking)。
|
||||
mangle表:用于修改数据包的报文头信息,比如服务类型(Type Of Service,ToS)、生存周期(Time to Live,TTL),以及为数据包设置Mark标记,典型的应用是链路的服务质量管理(Quality Of Service,QoS)。
|
||||
nat表:用于修改数据包的源或者目的地址等信息,典型的应用是网络地址转换(Network Address Translation)。
|
||||
filter表:用于对数据包进行过滤,控制到达某条链上的数据包是继续放行、直接丢弃或拒绝(ACCEPT、DROP、REJECT),典型的应用是防火墙。
|
||||
security表:用于在数据包上应用SELinux,这张表并不常用。
|
||||
|
||||
|
||||
这五张规则表是有优先级的:raw→mangle→nat→filter→security,也就是前面我列举出的顺序。这里你要注意,在iptables中新增规则时,需要按照规则的意图指定要存入到哪张表中,如果没有指定,就默认会存入filter表。此外,每张表能够使用到的链也有所不同,具体表与链的对应关系如下所示:
|
||||
|
||||
|
||||
|
||||
那么,你从名字上其实就能看出,预置的五条链是直接源自于Netfilter的钩子,它们与五张规则表的对应关系是固定的,用户不能增加自定义的表,或者修改已有表与链的关系,但可以增加自定义的链。
|
||||
|
||||
新增的自定义链与Netfilter的钩子没有天然的对应关系,换句话说就是不会被自动触发,只有显式地使用JUMP行为,从默认的五条链中跳转过去,才能被执行。
|
||||
|
||||
可以说,iptables不仅仅是Linux系统自带的一个网络工具,它在容器间通信中也扮演着相当重要的角色。比如,Kubernetes用来管理Sevice的Endpoints的核心组件kube-proxy,就依赖iptables来完成ClusterIP到Pod的通信(也可以采用IPVS,IPVS同样是基于Netfilter的),这种通信的本质就是一种NAT访问。
|
||||
|
||||
当然,对于Linux用户来说,前面提到的内容可能都是相当基础的网络常识,但如果你平常比较少在Linux系统下工作,就可能需要一些用iptables充当防火墙过滤数据、充当作路由器转发数据、充当作网关做NAT转换的实际例子,来帮助理解了,这些操作在网上也很容易就能找到,这里我就不专门去举例说明了。
|
||||
|
||||
小结
|
||||
|
||||
Linux目前提供的八种名称空间里,网络名称空间无疑是隔离内容最多的一种,它为名称空间内的所有进程提供了全套的网络设施,包括独立的设备界面、路由表、ARP表,IP地址表、iptables/ebtables规则、协议栈,等等。
|
||||
|
||||
虚拟化容器是以Linux名称空间的隔离性为基础来实现的,那解决隔离的容器之间、容器与宿主机之间,乃至跨物理网络的不同容器间通信问题的责任,就很自然地落在了Linux网络虚拟化技术的肩上。这节课里,我们暂时放下了容器编排、云原生、微服务等等这些上层概念,走进Linux网络的底层世界,去学习了一些与设备、协议、通信相关的基础网络知识。
|
||||
|
||||
最后我想说的是,到目前为止,我给你介绍的Linux下网络通信的协议栈模型,以及程序如何干涉在协议栈中流动的信息,它们与虚拟化都没有产生什么直接联系,而是整个Linux网络通信的必要基础。在下节课,我们就要开始专注于跟网络虚拟化密切相关的内容了。
|
||||
|
||||
一课一思
|
||||
|
||||
说实话,今天的内容其实很适合以实现业务功能为主、平常并不直接接触网络设备的普通开发人员,而如果你是做平台基础设施开发或者运维的,那学习这节课可能就会觉得有点太基础或啰嗦了,因为这些都是基本的工作技能。
|
||||
|
||||
所以在最后,我想来了解一下,如果你是一名程序员,那你是否经常有机会接触这些网络方面的知识呢?如果有,你都用它们来做什么?欢迎给我留言。
|
||||
|
||||
另外,如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
298
专栏/周志明的架构课/53_Linux网络虚拟化(下):Docker所提供的容器通讯方案有哪些?.md
Normal file
298
专栏/周志明的架构课/53_Linux网络虚拟化(下):Docker所提供的容器通讯方案有哪些?.md
Normal file
@@ -0,0 +1,298 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
53 _ Linux网络虚拟化(下):Docker所提供的容器通讯方案有哪些?
|
||||
你好,我是周志明。今天我们接着上节课介绍的Linux网络知识,继续来学习它们在虚拟化网络方面的应用,从而为后续学习容器编排系统、理解各个容器是如何通过虚拟化网络来协同工作打好基础。
|
||||
|
||||
虚拟化网络设备
|
||||
|
||||
首先我们要知道,虚拟化网络并不需要完全遵照物理网络的样子来设计。不过,由于现在大量现成的代码,原来就是面向于物理存在的网络设备来编码实现的,另外也有出于方便理解和知识继承方面的考虑,因此虚拟化网络与物理网络中的设备还是具有相当高的相似性。
|
||||
|
||||
所以接下来,我就会从网络中那些与网卡、交换机、路由器等对应的虚拟设施,以及如何使用这些虚拟设施来组成网络入手,给你介绍容器间网络的通信基础设施。
|
||||
|
||||
好了,我们开始吧。
|
||||
|
||||
网卡:tun/tap、veth
|
||||
|
||||
首先是虚拟网卡设备。
|
||||
|
||||
目前主流的虚拟网卡方案有tun/tap和veth两种,其中tun/tap出现得时间更早,它是一组通用的虚拟驱动程序包,里面包含了两个设备,分别是用于网络数据包处理的虚拟网卡驱动,以及用于内核空间与用户空间交互的字符设备(Character Devices,这里具体指/dev/net/tun)驱动。
|
||||
|
||||
大概在2000年左右,Solaris系统为了实现隧道协议(Tunneling Protocol)开发了这套驱动,从Linux Kernel 2.1版开始,tun/tap移植到了Linux内核中,当时它是作为源码中的可选模块,而在2.4版之后发布的内核,都会默认编译tun/tap的驱动。tun和tap是两个相对独立的虚拟网络设备,其中tap模拟了以太网设备,操作二层数据包(以太帧),tun则是模拟了网络层设备,操作三层数据包(IP报文)。
|
||||
|
||||
那么,使用tun/tap设备的目的,其实是为了把来自协议栈的数据包,先交给某个打开了/dev/net/tun字符设备的用户进程处理后,再把数据包重新发回到链路中。这里你可以通俗地理解为,这块虚拟化网卡驱动一端连接着网络协议栈,另一端连接着用户态程序,而普通的网卡驱动则是一端连接着网络协议栈,另一端连接着物理网卡。
|
||||
|
||||
如此一来,只要协议栈中的数据包能被用户态程序截获并加工处理,程序员就有足够的舞台空间去玩出各种花样,比如数据压缩、流量加密、透明代理等功能,都能够在此基础上实现。
|
||||
|
||||
这里我就以最典型的VPN应用程序为例,程序发送给tun设备的数据包,会经过如下所示的顺序流进VPN程序:
|
||||
|
||||
|
||||
|
||||
应用程序通过tun设备对外发送数据包后,tun设备如果发现另一端的字符设备已经被VPN程序打开(这就是一端连接着网络协议栈,另一端连接着用户态程序),就会把数据包通过字符设备发送给VPN程序,VPN收到数据包,会修改后再重新封装成新报文,比如数据包原本是发送给A地址的,VPN把整个包进行加密,然后作为报文体,封装到另一个发送给B地址的新数据包当中。
|
||||
|
||||
这种把一个数据包套进另一个数据包中的处理方式,就被形象地形容为“隧道”(Tunneling),隧道技术是在物理网络中构筑逻辑网络的经典做法。而其中提到的加密,实际上也有标准的协议可以遵循,比如IPSec协议。
|
||||
|
||||
不过,使用tun/tap设备来传输数据需要经过两次协议栈,所以会不可避免地产生一定的性能损耗,因而如果条件允许,容器对容器的直接通信并不会把tun/tap作为首选方案,而是一般基于veth来实现的。
|
||||
|
||||
但tun/tap并没有像veth那样,有要求设备成对出现、数据要原样传输的限制,数据包到了用户态程序后,我们就有完全掌控的权力,要进行哪些修改、要发送到什么地方,都可以通过编写代码去实现,所以tun/tap方案比起veth方案有更广泛的适用范围。
|
||||
|
||||
那么这里我提到的veth,就是另一种主流的虚拟网卡方案了,在Linux Kernel 2.6版本,Linux开始支持网络名空间隔离的同时,也提供了专门的虚拟以太网(Virtual Ethernet,习惯简写为veth),让两个隔离的网络名称空间之间可以互相通信。
|
||||
|
||||
其实,直接把veth比喻成虚拟网卡并不是很准确,如果要和物理设备类比,它应该相当于由交叉网线连接的一对物理网卡。
|
||||
|
||||
|
||||
额外知识:直连线序、交叉线序-
|
||||
交叉网线是指一头是T568A标准,另外一头是T568B标准的网线。直连网线则是两头采用同一种标准的网线。-
|
||||
网卡对网卡这样的同类设备,需要使用交叉线序的网线来连接,网卡到交换机、路由器就采用直连线序的网线,不过现在的网卡大多带有线序翻转功能,直连线也可以网卡对网卡地连通了。
|
||||
|
||||
|
||||
veth实际上也不是一个设备,而是一对设备,因而它也常被称作veth pair。我们要使用veth,就必须在两个独立的网络名称空间中进行才有意义,因为veth pair是一端连着协议栈,另一端彼此相连的,在veth设备的其中一端输入数据,这些数据就会从设备的另一端原样不动地流出,它在工作时的数据流动如下图所示:
|
||||
|
||||
|
||||
|
||||
由于两个容器之间采用veth通信,不需要反复多次经过网络协议栈,这就让veth比起tap/tun来说,具备了更好的性能,也让veth pair的实现变得十分简单,内核中只用几十行代码实现一个数据复制函数,就可以完成veth的主体功能。
|
||||
|
||||
不过veth其实也存在局限性。
|
||||
|
||||
虽然veth以模拟网卡直连的方式,很好地解决了两个容器之间的通信问题,然而对多个容器间通信,如果仍然单纯只用veth pair的话,事情就会变得非常麻烦,毕竟,让每个容器都为与它通信的其他容器建立一对专用的veth pair,根本就不实际,真正做起来成本会很高。
|
||||
|
||||
因此这时,就迫切需要有一台虚拟化的交换机,来解决多容器之间的通信问题了。
|
||||
|
||||
交换机:Linux Bridge
|
||||
|
||||
既然有了虚拟网卡,我们很自然就会联想到让网卡接入到交换机里,来实现多个容器间的相互连接。而Linux Bridge就是Linux系统下的虚拟化交换机,虽然它是以“网桥”(Bridge)而不是“交换机”(Switch)为名,但在使用过程中,你会发现Linux Bridge看起来像交换机,功能使用起来像交换机、程序实现起来也像交换机,所以它实际就是一台虚拟交换机。
|
||||
|
||||
Linux Bridge是在Linux Kernel 2.2版本开始提供的二层转发工具,由brctl命令创建和管理。Linux Bridge创建以后,就能够接入任何位于二层的网络设备,无论是真实的物理设备(比如eth0),还是虚拟的设备(比如veth或者tap),都能与Linux Bridge配合工作。当有二层数据包(以太帧)从网卡进入Linux Bridge,它就会根据数据包的类型和目标MAC地址,按照如下规则转发处理:
|
||||
|
||||
|
||||
如果数据包是广播帧,转发给所有接入网桥的设备。
|
||||
如果数据包是单播帧,且MAC地址在地址转发表中不存在,则洪泛(Flooding)给所有接入网桥的设备,并把响应设备的接口与MAC地址学习(MAC Learning)到自己的MAC地址转发表中。
|
||||
如果数据包是单播帧,且MAC地址在地址转发表中已存在,则直接转发到地址表中指定的设备。
|
||||
如果数据包是此前转发过的,又重新发回到此Bridge,说明冗余链路产生了环路。由于以太帧不像IP报文那样有TTL来约束,所以一旦出现环路,如果没有额外措施来处理的话,就会永不停歇地转发下去。那么对于这种数据包,就需要交换机实现生成树协议(Spanning Tree Protocol,STP)来交换拓扑信息,生成唯一拓扑链路以切断环路。
|
||||
|
||||
|
||||
刚刚提到的这些名词,比如二层转发、泛洪、STP、MAC学习、地址转发表,等等,都是物理交换机中已经非常成熟的概念了,它们在Linux Bridge中都有对应的实现,所以我才说,Linux Bridge不仅用起来像交换机,实现起来也像交换机。
|
||||
|
||||
不过,它与普通的物理交换机也还是有一点差别的,普通交换机只会单纯地做二层转发,Linux Bridge却还支持把发给它自身的数据包,接入到主机的三层协议栈中。
|
||||
|
||||
对于通过brctl命令显式接入网桥的设备,Linux Bridge与物理交换机的转发行为是完全一致的,它也不允许给接入的设备设置IP地址,因为网桥是根据MAC地址做二层转发的,就算设置了三层的IP地址也没有意义。
|
||||
|
||||
然而,Linux Bridge与普通交换机的区别是,除了显式接入的设备外,它自己也无可分割地连接着一台有着完整网络协议栈的Linux主机,因为Linux Bridge本身肯定是在某台Linux主机上创建的,我们可以看作是Linux Bridge有一个与自己名字相同的隐藏端口,隐式地连接了创建它的那台Linux主机。
|
||||
|
||||
因此,Linux Bridge允许给自己设置IP地址,这样就比普通交换机多出了一种特殊的转发情况:如果数据包的目的MAC地址为网桥本身,并且网桥设置了IP地址的话,那该数据包就会被认为是收到发往创建网桥那台主机的数据包,这个数据包将不会转发到任何设备,而是直接交给上层(三层)协议栈去处理。
|
||||
|
||||
这时,网桥就取代了物理网卡eth0设备来对接协议栈,进行三层协议的处理。
|
||||
|
||||
那么设置这条特殊转发规则的好处是什么呢?就是只要通过简单的NAT转换,就可以实现一个最原始的单IP容器网络。这种组网是最基本的容器间通信形式,下面我举个具体例子来帮助你理解。
|
||||
|
||||
假设现在有以下几个设备,它们的连接情况如图所示,具体配置是这样的:
|
||||
|
||||
|
||||
网桥br0:分配IP地址192.168.31.1。
|
||||
容器:三个网络名称空间(容器),分别编号为1、2、3,均使用veth pair接入网桥,且有如下配置:
|
||||
|
||||
|
||||
在容器一端的网卡名为veth0,在网桥一端网卡名为veth1、veth2、veth3;
|
||||
三个容器中的veth0网卡分配IP地址:192.168.1.10、192.168.1.11、192.168.1.12;
|
||||
三个容器中的veth0网卡设置网关为网桥,即192.168.31.1;
|
||||
网桥中的veth1、veth2、veth3无IP地址。
|
||||
|
||||
物理网卡eth0:分配的IP地址14.123.254.86。
|
||||
外部网络:外部网络中有一台服务器,地址为122.246.6.183。
|
||||
|
||||
|
||||
|
||||
|
||||
这样一来,如果名称空间1中的应用程序想访问外网地址为122.246.6.183的服务器,由于容器没有自己的公网IP地址,程序发出的数据包必须经过处理之后,才能最终到达外网服务器。
|
||||
|
||||
我们来具体分析下这个处理步骤:
|
||||
|
||||
|
||||
应用程序调用Socket API发送数据,此时生成的原始数据包为:-
|
||||
a. 源MAC:veth0的MAC-
|
||||
b. 目标MAC:网关的MAC(即网桥的MAC)-
|
||||
c. 源IP:veth0的IP,即192.168.31.1-
|
||||
d. 目标IP:外网的IP,即122.246.6.183
|
||||
|
||||
从veth0发送的数据,会在veth1中原样出来,网桥将会从veth1中接收到一个目标MAC为自己的数据包,并且网桥有配置IP地址,这样就触发了Linux Bridge的特殊转发规则。这个数据包也就不会转发给任何设备,而是转交给主机的协议栈处理。
|
||||
|
||||
|
||||
|
||||
注意,从这步以后就是三层路由了,已经不在网桥的工作范围之内,而是由Linux主机依靠Netfilter进行IP转发(IP Forward)去实现的。
|
||||
|
||||
|
||||
|
||||
数据包经过主机协议栈,Netfilter的钩子被激活,预置好的iptables NAT规则会修改数据包的源IP地址,把它改为物理网卡eth0的IP地址,并在映射表中记录设备端口和两个IP地址之间的对应关系,经过SNAT之后的数据包,最终会从eth0出去,此时报文头中的地址为:-
|
||||
a. 源MAC:eth0的MAC-
|
||||
b. 目标MAC:下一跳(Hop)的MAC-
|
||||
c. 源IP:eth0的IP,即14.123.254.86-
|
||||
d. 目标IP:外网的IP,即122.246.6.183
|
||||
|
||||
可见,经过主机协议栈后,数据包的源和目标IP地址均为公网的IP,这个数据包在外部网络中,可以根据IP正确路由到目标服务器手上。这样,当目标服务器处理完毕,对该请求发出响应后,返回数据包的目标地址也是公网IP。当返回的数据包经过链路上所有跳点,由eth0达到网桥时,报文头中的地址为:-
|
||||
a. 源MAC:eth0的MAC-
|
||||
b. 目标MAC:网桥的MAC-
|
||||
c. 源IP:外网的IP,即122.246.6.183-
|
||||
d. 目标IP:eth0的IP,即14.123.254.86
|
||||
|
||||
可见,这同样是一个以网桥MAC地址为目标的数据包,同样会触发特殊转发规则,然后交给协议栈处理。这时,Linux会根据映射表中的转换关系做DNAT转换,把目标IP地址从eth0替换回veth0的IP,最终veth0收到的响应数据包为:-
|
||||
a. 源MAC:网桥的MAC-
|
||||
b. 目标MAC:veth0的MAC-
|
||||
c. 源IP:外网的IP,即122.246.6.183-
|
||||
d. 目标IP:veth0的IP,即192.168.31.1
|
||||
|
||||
|
||||
好了,这就是程序发出的数据包到达外网服务器之前的所有处理步骤。
|
||||
|
||||
在这个处理过程中,Linux主机独立承担了三层路由的职责,一定程度上扮演了路由器的角色。而且由于有Netfilter的存在,对网络层的路由转发,就不需要像Linux Bridge一样,专门提供brctl这样的命令去创建一个虚拟设备了。
|
||||
|
||||
通过Netfilter,很容易就能在Linux内核完成根据IP地址进行路由的功能。你也可以把Linux Bridge理解为是一个人工创建的虚拟交换机,而Linux内核是一个天然的虚拟路由器。
|
||||
|
||||
当然,除了我介绍的Linux Bridge这一种虚拟交换机的方案,还有OVS(Open vSwitch)等同样常见,而且更强大、更复杂的方案,这里我就不讨论了,感兴趣的话你可以去参考这个链接。
|
||||
|
||||
网络:VXLAN
|
||||
|
||||
那么,有了虚拟化网络设备后,下一步就是要使用这些设备组成网络了。
|
||||
|
||||
我们知道,容器分布在不同的物理主机上,每一台物理主机都有物理网络相互联通,然而这种网络的物理拓扑结构是相对固定的,很难跟上云原生时代下,分布式系统的逻辑拓扑结构变动频率,比如服务的扩缩、断路、限流,等等,都可能要求网络跟随做出相应的变化。
|
||||
|
||||
也正因为如此,软件定义网络(Software Defined Network,SDN)的需求在云计算和分布式时代,就变得前所未有地迫切。SDN的核心思路是在物理的网络之上,再构造一层虚拟化的网络,把控制平面和数据平面分离开来,实现流量的灵活控制,为核心网络及应用的创新提供良好的平台。
|
||||
|
||||
SDN里,位于下层的物理网络被称为Underlay,它着重解决网络的连通性与可管理性;位于上层的逻辑网络被称为Overlay,它着重为应用提供与软件需求相符的传输服务和网络拓扑。
|
||||
|
||||
事实上,SDN已经发展了十几年的时间,比云原生、微服务这些概念出现得要早得多。网络设备商基于硬件设备开发出了EVI(Ethernet Virtualization Interconnect)、TRILL(Transparent Interconnection of Lots of Links)、SPB(Shortest Path Bridging)等大二层网络技术;软件厂商也提出了VXLAN(Virtual eXtensible LAN)、NVGRE(Network Virtualization Using Generic Routing Encapsulation)、STT(A Stateless Transport Tunneling Protocol for Network Virtualization)等一系列基于虚拟交换机实现的Overlay网络。
|
||||
|
||||
不过,由于跨主机的容器间通信用的大多是Overlay网络,所以接下来,我会以VXLAN为例,给你介绍Overlay网络的原理。
|
||||
|
||||
VXLAN你可能没怎么听说过,但VLAN相信只要从事计算机专业的人都会有所了解。VLAN的全称是“虚拟局域网”(Virtual Local Area Network),从名称来看,它也算是网络虚拟化技术的早期成果之一了。
|
||||
|
||||
由于二层网络本身的工作特性,决定了VLAN非常依赖于广播,无论是广播帧(如ARP请求、DHCP、RIP都会产生广播帧),还是泛洪路由,它的执行成本会随着接入二层网络设备数量的增长而等比例地增加,当设备太多,广播又频繁的时候,很容易就会形成广播风暴(Broadcast Radiation)。
|
||||
|
||||
因此,VLAN的首要职责就是划分广播域,把连接在同一个物理网络上的设备区分开来。
|
||||
|
||||
划分的具体方法是在以太帧的报文头中加入VLAN Tag,让所有广播只针对具有相同VLAN Tag的设备生效。这样既缩小了广播域,也附带提高了安全性和可管理性,因为两个VLAN之间不能直接通信。如果确实有通信的需要,就必须通过三层设备来进行,比如使用单臂路由(Router on a Stick)或者三层交换机。
|
||||
|
||||
可是,VLAN有两个明显的缺陷,第一个缺陷在于VLAN Tag的设计。定义VLAN的802.1Q规范是在1998年提出的,当时的网络工程师完全不可能预料到在未来云计算会如此地普及,因而就只给VLAN Tag预留了32 Bits的存储空间,其中还要分出16 Bits存储标签协议识别符(Tag Protocol Identifier)、3 Bits存储优先权代码点(Priority Code Point)、1 Bits存储标准格式指示(Canonical Format Indicator),剩下的12 Bits才能用来存储VLAN ID(Virtualization Network Identifier,VNI)。
|
||||
|
||||
所以换句话说,VLAN ID最多只能有 \(\\mathrm{2}^{12}\)=4096种取值。当云计算数据中心出现后,即使不考虑虚拟化的需求,单是需要分配IP的物理设备,都有可能数以万计、甚至数以十万计,这样的话,4096个VLAN肯定是不够用的。
|
||||
|
||||
后来,IEEE的工程师们又提出802.1AQ规范力图补救这个缺陷,大致思路是给以太帧连续打上两个VLAN Tag,每个Tag里仍然只有12 Bits的VLAN ID,但两个加起来就可以存储 \(\\mathrm{2}^{24}\)=16,777,216个不同的VLAN ID了,由于两个VLAN Tag并排放在报文头上,802.1AQ规范还有了个QinQ(802.1Q in 802.1Q)的昵称别名。
|
||||
|
||||
QinQ是2011年推出的规范,但是直到现在其实都没有特别普及,这是因为除了需要设备支持外,它还解决不了VLAN的第二个缺陷:跨数据中心传递。
|
||||
|
||||
VLAN本身是为二层网络所设计的,但是在两个独立数据中心之间,信息只能跨三层传递。而由于云计算的灵活性,大型分布式系统完全有跨数据中心运作的可能性,所以此时如何让VLAN Tag在两个数据中心间传递,又成了不得不考虑的麻烦事。
|
||||
|
||||
由此,为了统一解决以上两个问题,IETF定义了VXLAN规范,这是三层虚拟化网络(Network Virtualization over Layer 3,NVO3)的标准技术规范之一,是一种典型的Overlay网络。
|
||||
|
||||
VXLAN采用L2 over L4 (MAC in UDP)的报文封装模式,把原本在二层传输的以太帧,放到了四层UDP协议的报文体内,同时加入了自己定义的VXLAN Header。在VXLAN Header里直接就有24 Bits的VLAN ID,同样可以存储1677万个不同的取值。
|
||||
|
||||
如此一来,VXLAN就可以让二层网络在三层范围内进行扩展,不再受数据中心间传输的限制了。VXLAN的整个报文结构如下图所示:
|
||||
|
||||
|
||||
|
||||
(图片来源:Orchestrating EVPN VXLAN Services with Cisco NSO)
|
||||
|
||||
VXLAN对网络基础设施的要求很低,不需要专门的硬件提供特别支持,只要三层可达的网络就能部署VXLAN。
|
||||
|
||||
VXLAN网络的每个边缘入口上,布置有一个VTEP(VXLAN Tunnel Endpoints)设备,它既可以是物理设备,也可以是虚拟化设备,主要负责VXLAN协议报文的封包和解包。互联网号码分配局(Internet Assigned Numbers Authority,IANA)也专门分配了4789作为VTEP设备的UDP端口(以前Linux VXLAN用的默认端口是8472,目前这两个端口在许多场景中仍有并存的情况)。
|
||||
|
||||
从Linux Kernel 3.7版本起,Linux系统就开始支持VXLAN。到了3.12版本,Linux对VXLAN的支持已经达到了完全完备的程度,能够处理单播和组播,能够运行于IPv4和IPv6之上,一台Linux主机经过简单配置之后,就可以把Linux Bridge作为VTEP设备来使用。
|
||||
|
||||
VXLAN带来了很高的灵活性、扩展性和可管理性,同一套物理网络中可以任意创建多个VXLAN网络,每个VXLAN中接入的设备,都像是在一个完全独立的二层局域网中一样,不会受到外部广播的干扰,也很难遭受外部的攻击,这就让VXLAN能够良好地匹配分布式系统的弹性需求。
|
||||
|
||||
不过,VXLAN也带来了额外的复杂度和性能开销,具体表现为以下两点:
|
||||
|
||||
|
||||
传输效率的下降,如果你仔细数过前面VXLAN报文结构中UDP、IP、以太帧报文头的字节数,你就会发现经过VXLAN封装后的报文,新增加的报文头部分就整整占了50 Bytes(VXLAN报文头占8 Bytes,UDP报文头占8 Bytes,IP报文头占20 Bytes,以太帧的MAC头占14 Bytes),而原本只需要14 Bytes而已,而且现在这14 Bytes的消耗也还在,只是被封到了最里面的以太帧中。以太网的MTU是1500 Bytes,如果是传输大量数据,额外损耗50 Bytes并不算很高的成本,但如果传输的数据本来就只有几个Bytes的话,那传输消耗在报文头上的成本就很高昂了。
|
||||
传输性能的下降,每个VXLAN报文的封包和解包操作都属于额外的处理过程,尤其是用软件来实现的VTEP,要知道额外的运算资源消耗,有时候会成为不可忽略的性能影响因素。
|
||||
|
||||
|
||||
副本网卡:MACVLAN
|
||||
|
||||
现在,理解了VLAN和VXLAN的原理后,我们就有足够的前置知识,去了解MACVLAN这最后一种网络设备虚拟化的方式了。
|
||||
|
||||
前面我提到,两个VLAN之间位于独立的广播域,是完全二层隔离的,要通信就只能通过三层设备。而最简单的三层通信就是靠单臂路由了。
|
||||
|
||||
接下来,我就以这里的示意图中给出的网络拓扑结构为例,来给你介绍下单臂路由是如何工作的。
|
||||
|
||||
|
||||
|
||||
假设位于VLAN-A中的主机A1,希望把数据包发送给VLAN-B中的主机B2,由于A、B两个VLAN之间二层链路不通,因此引入了单臂路由。单臂路由不属于任何VLAN,它与交换机之间的链路允许任何VLAN ID的数据包通过,这种接口被称为TRUNK。
|
||||
|
||||
这样,A1要和B2通信,A1就把数据包先发送给路由(只需把路由设置为网关即可做到),然后路由根据数据包上的IP地址得知B2的位置,去掉VLAN-A的VLAN Tag,改用VLAN-B的VLAN Tag重新封装数据包后,发回给交换机,交换机收到后就可以顺利转发给B2了。
|
||||
|
||||
这个过程并没什么复杂的地方,但不知道你有没有注意到一个问题:路由器应该设置怎样的IP地址呢?
|
||||
|
||||
由于A1、B2各自处于独立的网段上,它们又各自要把同一个路由作为网关使用,这就要求路由器必须同时具备192.168.1.0/24和192.168.2.0/24的IP地址。当然,如果真的就只有VLAN-A、VLAN-B两个VLAN,那把路由器上的两个接口分别设置不同的IP地址,然后用两条网线分别连接到交换机上,也勉强算是一个解决办法。
|
||||
|
||||
但要知道,VLAN最多可以支持4096个VLAN,那如果要接四千多条网线就太离谱了。因此为了解决这个问题,802.1Q规范中专门定义了子接口(Sub-Interface)的概念,它的作用是允许在同一张物理网卡上,针对不同的VLAN绑定不同的IP地址。
|
||||
|
||||
所以,MACVLAN就借用了VLAN子接口的思路,并且在这个基础上更进一步,不仅允许对同一个网卡设置多个IP地址,还允许对同一张网卡上设置多个MAC地址,这也是MACVLAN名字的由来。
|
||||
|
||||
原本MAC地址是网卡接口的“身份证”,应该是严格的一对一关系,而MACVLAN打破了这层关系。方法就是在物理设备之上、网络栈之下生成多个虚拟的Device,每个Device都有一个MAC地址,新增Device的操作本质上相当于在系统内核中,注册了一个收发特定数据包的回调函数,每个回调函数都能对一个MAC地址的数据包进行响应,当物理设备收到数据包时,会先根据MAC地址进行一次判断,确定交给哪个Device来处理,如下图所示。
|
||||
|
||||
这样,我们以交换机一侧的视角来看,这个端口后面就像是另一台已经连接了多个设备的交换机一样。
|
||||
|
||||
|
||||
|
||||
用MACVLAN技术虚拟出来的副本网卡,在功能上和真实的网卡是完全对等的,此时真正的物理网卡实际上也确实承担着类似交换机的职责。
|
||||
|
||||
在收到数据包后,物理网卡会根据目标MAC地址,判断这个包应该转发给哪块副本网卡处理,由同一块物理网卡虚拟出来的副本网卡,天然处于同一个VLAN之中,因此可以直接二层通信,不需要将流量转发到外部网络。
|
||||
|
||||
那么,与Linux Bridge相比,这种以网卡模拟交换机的方法在目标上其实没有什么本质上的不同,但MACVLAN在内部实现上,则要比Linux Bridge轻量得多。
|
||||
|
||||
从数据流来看,副本网卡的通信只比物理网卡多了一次判断而已,就能获得很高的网络通信性能;从操作步骤来看,由于MAC地址是静态的,所以MACVLAN不需要像Linux Bridge那样,要考虑MAC地址学习、STP协议等复杂的算法,这也进一步突出了MACVLAN的性能优势。
|
||||
|
||||
而除了模拟交换机的Bridge模式外,MACVLAN还支持虚拟以太网端口聚合模式(Virtual Ethernet Port Aggregator,VEPA)、Private模式、Passthru模式、Source模式等另外几种工作模式,有兴趣的话你可以去参考下相关资料,我就不再逐一介绍了。
|
||||
|
||||
容器间通信
|
||||
|
||||
好了,前面我们通过对虚拟化网络基础知识的一番铺垫后,现在,我们就可以尝试使用这些知识去解构容器间的通信原理了,毕竟运用知识去解决问题,才是学习网络虚拟化的根本目的。
|
||||
|
||||
在这节课里,我们先以Docker为目标,谈一谈Docker所提供的容器通信方案。当下节课介绍过CNI下的Kubernetes网络插件生态后,你也许会觉得Docker的网络通信相对简单,对于某些分布式系统的需求来说,甚至是过于简陋了。不过,虽然容器间的网络方案多种多样,但通信主体都是固定的,不外乎没有物理设备的虚拟主体(容器、Pod、Service、Endpoints等等)、不需要跨网络的本地主机、以及通过网络连接的外部主机三种层次。
|
||||
|
||||
所有的容器网络通信问题,其实都可以归结为本地主机内部的多个容器之间、本地主机与内部容器之间,以及跨越不同主机的多个容器之间的通信问题,其中的许多原理都是相通的,所以我认为Docker网络的简单,在作为检验前面网络知识有没有理解到位时,倒不失为一种优势。
|
||||
|
||||
好,下面我们就具体来看看吧。
|
||||
|
||||
Docker的网络方案在操作层面上,是指能够直接通过docker run --network参数指定的网络,或者是先被docker network create创建后再被容器使用的网络。安装Docker的过程中,会自动在宿主机上创建一个名为docker0的网桥,以及三种不同的Docker网络,分别是bridge、host和none,你可以通过docker network ls命令查看到这三种网络,具体如下所示:
|
||||
|
||||
$ docker network ls
|
||||
NETWORK ID NAME DRIVER SCOPE
|
||||
2a25170d4064 bridge bridge local
|
||||
a6867d58bd14 host host local
|
||||
aeb4f8df39b1 none null local
|
||||
|
||||
|
||||
事实上,这三种网络,对应着Docker提供的三种开箱即用的网络方案,它们分别为:
|
||||
|
||||
|
||||
桥接模式,使用--network=bridge指定,这种也是未指定网络参数时的默认网络。桥接模式下,Docker会为新容器分配独立的网络名称空间,创建好veth pair,一端接入容器,另一端接入到docker0网桥上。Docker会为每个容器自动分配好IP地址,默认配置下的地址范围是172.17.0.0/24,docker0的地址默认是172.17.0.1,并且会设置所有容器的网关均为docker0,这样所有接入同一个网桥内的容器,可以直接依靠二层网络来通信,在此范围之外的容器、主机就必须通过网关来访问(具体过程我在前面介绍Linux Bridge时已经举例讲解过了,这里不再啰嗦)。
|
||||
主机模式,使用--network=host指定。主机模式下,Docker不会为新容器创建独立的网络名称空间,这样容器一切的网络设施,比如网卡、网络栈等,都会直接使用宿主机上的,容器也就不会拥有自己独立的IP地址。在这个模式下与外界通信,也不需要进行NAT转换,没有性能损耗,但它的缺点也十分明显,因为没有隔离,就无法避免网络资源的冲突,比如端口号就不允许重复。
|
||||
空置模式,使用--network=none指定。空置模式下,Docker会给新容器创建独立的网络名称空间,但是不会创建任何虚拟的网络设备,此时容器能看到的只有一个回环设备(Loopback Device)而已。提供这种方式是为了方便用户去做自定义的网络配置,比如自己增加网络设备、自己管理IP地址,等等。
|
||||
|
||||
|
||||
而除了前面三种开箱即用的网络方案以外,Docker还支持由用户自行创建的网络,比如说:
|
||||
|
||||
|
||||
容器模式,创建容器后使用--network=container:容器名称指定。容器模式下,新创建的容器将会加入指定的容器的网络名称空间,共享一切的网络资源,但其他资源,比如文件、PID等默认仍然是隔离的。两个容器间可以直接使用回环地址(localhost)通信,端口号等网络资源不能有冲突。
|
||||
MACVLAN模式,使用docker network create -d macvlan创建。这种网络模式允许为容器指定一个副本网卡,容器通过副本网卡的MAC地址来使用宿主机上的物理设备,所以在追求通信性能的场合,这种网络是最好的选择。这里要注意,Docker的MACVLAN只支持Bridge通信模式,所以在功能表现上跟桥接模式是类似的。
|
||||
Overlay模式,使用docker network create -d overlay创建。Docker说的Overlay网络,实际上就是特指VXLAN,这种网络模式主要用于Docker Swarm服务之间进行通信。然而由于Docker Swarm败给了Kubernetes,并没有成为主流,所以这种网络模式实际上很少被人使用。
|
||||
|
||||
|
||||
小结
|
||||
|
||||
这节课我从模拟网卡、交换机这些网络设备开始,给你介绍了如何在Linux网络名称空间的支持下,模拟出一个物理上实际并不存在,但可以像物理网络一样,让程序可以进行通讯的虚拟化网路。
|
||||
|
||||
虚拟化网络是容器编排必不可少的功能,网络的功能和性能,对应用程序各个服务间通讯都有非常密切的关联,这一点你要重点关注。在实际生产中,容器编排系统就是由一批容器通过网络交互来共同对外提供服务的,其中的开发、除错、效率优化等工作,都离不开这些基础的网络知识。
|
||||
|
||||
一课一思
|
||||
|
||||
你在使用Docker时,有没有关注或者调整过它的容器通讯网络?在哪些需求场景下你做出过调整呢?
|
||||
|
||||
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给其他的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
201
专栏/周志明的架构课/54_容器网络与生态:与CNM竞争过后的CNI下的网络插件生态.md
Normal file
201
专栏/周志明的架构课/54_容器网络与生态:与CNM竞争过后的CNI下的网络插件生态.md
Normal file
@@ -0,0 +1,201 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
54 _ 容器网络与生态:与CNM竞争过后的CNI下的网络插件生态
|
||||
你好,我是周志明。前面的两节课,我们学习了Linux系统本身的网络虚拟化知识,今天这节课,我们就来看看这些理论知识实际是如何应用于容器间网络的。
|
||||
|
||||
容器网络的第一个业界标准,是源于Docker在2015年发布的libnetwork项目。如果你还记得在“容器的崛起”这个小章节中我提到的关于libcontainer的故事,那从名字上,你就能很容易地推断出libnetwork项目的目的与意义。libnetwork项目是Docker用Golang编写的、专门用来抽象容器间网络通信的一个独立模块。
|
||||
|
||||
类似于libcontainer是作为OCI的标准来实现的,libnetwork是作为Docker提出的CNM规范(Container Network Model)的标准实现而设计的。不过,跟ibcontainer因为孵化出runC项目,到今天都仍然广为人知的结局不一样,libnetwork随着Docker Swarm的失败,已经基本上失去了实用的价值,只具备历史与学术研究方面的价值了。
|
||||
|
||||
接下来,我就会从CNM规范的出现以及它与CNI的竞争开始说起,带你了解容器间网络所解决的问题。
|
||||
|
||||
CNM与CNI
|
||||
|
||||
首先,可以说,现在的容器网络的事实标准CNI(Container Networking Interface)与CNM在目标上几乎是完全重叠的,这就决定了CNI与CNM之间,只能是你死我活的竞争关系,而这与容器运行时提到的CRI和OCI的关系明显不一样。CRI与OCI的目标并不相同,所以两者有足够的空间可以和平共处。
|
||||
|
||||
不过,尽管CNM规范已是明日黄花,但它作为容器网络的先行者,对后续的容器网络标准的制定仍然有直接的指导意义。
|
||||
|
||||
要知道,提出容器网络标准的目的,就是为了把网络功能从容器运行时引擎、或者容器编排系统中剥离出去,毕竟网络的专业性和针对性极强,如果不把它变成外部可扩展的功能,而都由自己来做的话,不仅费时费力,还不讨好。这个特点从下图所列的一大堆容器网络提供商就可见一斑。
|
||||
|
||||
|
||||
|
||||
另外,网络的专业性与针对性也决定了CNM和CNI都采用了插件式的设计,这样需要接入什么样的网络,就设计一个对应的网络插件即可。所谓的插件,在形式上也就是一个可执行文件,再配上相应的Manifests描述。
|
||||
|
||||
为了方便插件编写,CNM把协议栈、网络接口(对应于veth、tap/tun等)和网络(对应于Bridge、VXLAN、MACVLAN等)分别抽象为Sandbox、Endpoint和Network,并在接口的API中提供了这些抽象资源的读写操作。
|
||||
|
||||
而CNI中尽管也有Sandbox、Network的概念,其含义也跟CNM的大致相同,不过在Kubernetes资源模型的支持下,它就不需要刻意去强调某一种网络资源应该如何描述、如何访问了,所以在结构上就显得更加轻便。
|
||||
|
||||
那么从程序功能上看,CNM和CNI的网络插件提供的能力,都可以划分为网络的管理与IP地址的管理两类,而插件可以选择只实现其中的某一个,也可以全部都实现。下面我们就具体来了解一下。
|
||||
|
||||
|
||||
管理网络创建与删除
|
||||
|
||||
|
||||
顾名思义,这项能力解决的是如何创建网络、如何将容器接入到网络,以及容器如何退出和删除网络的问题。这个过程实际上是对容器网络的生命周期管理,如果你更熟悉Docker命令,可以把它类比理解成基本上等同于docker network命令所做的事情。
|
||||
|
||||
CNM规范中定义了创建网络、删除网络、容器接入网络、容器退出网络、查询网络信息、创建通信Endpoint、删除通信Endpoint等十个编程接口,而CNI中就更加简单了,只要实现对网络的增加与删除两项操作即可。你甚至不需要学过Golang语言,只从名称上都能轻松看明白以下接口中,每个方法的含义是什么。
|
||||
|
||||
type CNI interface {
|
||||
AddNetworkList (net *NetworkConfigList, rt *RuntimeConf) (types.Result, error)
|
||||
DelNetworkList (net *NetworkConfigList, rt *RuntimeConf) error
|
||||
|
||||
AddNetwork (net *NetworkConfig, rt *RuntimeConf) (types.Result, error)
|
||||
DelNetwork (net *NetworkConfig, rt *RuntimeConf) error
|
||||
}
|
||||
|
||||
|
||||
|
||||
管理IP地址分配与回收
|
||||
|
||||
|
||||
这项能力解决的是如何为三层网络分配唯一的IP地址的问题。我们知道,二层网络的MAC地址天然就具有唯一性,不需要刻意考虑如何分配的问题。但是三层网络的IP地址只有通过精心规划,才能保证在全局网络中都是唯一的。否则,如果两个容器之间可能存在相同地址,那它们就最多只能做NAT,而不可能做到直接通信。
|
||||
|
||||
相比起基于UUID或者数字序列实现的全局唯一ID产生器,IP地址的全局分配工作要更加困难一些。
|
||||
|
||||
首先是要符合IPv4的网段规则,而且得保证不重复,这在分布式环境里就只能依赖etcd、ZooKeeper等协调工具来实现,Docker自己也提供了类似的libkv来完成这项工作;其次是必须考虑到回收的问题,否则一旦Pod发生持续重启,就有可能耗尽某个网段中的所有地址;最后还必须要关注时效性,原本IP地址的获取采用标准的DHCP协议(Dynamic Host Configuration Protocol)就可以了,但DHCP有可能产生长达数秒的延迟,对于某些生存周期很短的Pod,这就已经超出了它的忍受限度,所以在容器网络中,往往Host-Local的IP分配方式会比DHCP更加实用。
|
||||
|
||||
总而言之,虽然现在时过境迁,舞台的聚光灯已然落到了CNI身上,但CNM规范作为容器间网络的首个技术规范,依然起到了为继任者指明方向的作用。
|
||||
|
||||
CNM到CNI
|
||||
|
||||
容器网络标准能够提供一致的网络操作界面,不管是什么网络插件都使用一致的API,这就提高了网络配置的自动化程度和在不同网络间迁移的体验,对最终用户、容器提供商、网络提供商来说,都是三方共赢的事情。
|
||||
|
||||
从CNM规范发布以后,借助Docker在容器领域的强大号召力,很快就得到了网络提供商与开源组织的支持,不说专门为Docker设计针对容器互联的网络,最起码也会让现有的网络方案兼容于CNM规范,以便能在容器圈中多分一杯羹,比如Cisco的Contiv、OpenStack的Kuryr、Open vSwitch的OVN(Open Virtual Networking),以及来自开源项目的Calico和Weave等都是CNM阵营中的成员。
|
||||
|
||||
而唯一对CNM持有不同意见的,是那些和Docker存在直接竞争关系的产品,比如Docker的最大竞争对手,来自CoreOS公司的RKT容器引擎。
|
||||
|
||||
其实凭良心说,并不是其他容器引擎想刻意去抵制CNM,而是Docker制定CNM规范时,完全是基于Docker本身来设计的,并没有考虑CNM用于其他容器引擎的可能性。因而,为了平衡CNM规范的影响力,也是为了在Docker的垄断背景下寻找一条出路,RKT提出了与CNM目标类似的“RKT网络提案”(RKT Networking Proposal)。
|
||||
|
||||
事实上,一个业界标准成功与否,很大程度上取决于它的支持者阵营的规模,对于容器网络这种插件式的规范就更是如此了。Docker力推的CNM,毫无疑问是当时统一容器网络标准的最有力的竞争者,如果没有外力的介入,有很大的可能会成为最后的胜利者。
|
||||
|
||||
然而,影响容器网络发展的外力还是出现了,即使我之前没有提过CNI,你也应该很容易猜到,在容器圈里能够掀翻Docker的“外力”,也就只有Kubernetes一家而已。
|
||||
|
||||
Kubernetes开源的初期(Kubernetes 1.5提出CRI规范之前),在容器引擎上是选择彻底绑定于Docker的,但是在容器网络的选择上,Kubernetes一直都坚持独立于Docker,自己来维护网络。
|
||||
|
||||
在CNM和CNI提出以前的早期版本里,Kubernetes会使用Docker的空置网络模式(--network=none)来创建Pause容器,然后通过内部的kubenet来创建网络设施,再让Pod中的其他容器加入到Pause容器的名称空间中,共享这些网络设施。
|
||||
|
||||
|
||||
额外知识:kubenet-
|
||||
kubenet是kubelet内置的一个非常简单的网络,它是采用网桥来解决Pod间通信。kubenet会自动创建一个名为cbr0的网桥,当有新的Pod启动时,会由kubenet自动将其接入cbr0网桥中,再将控制权交还给kubelet,完成后续的Pod创建流程。kubenet采用Host-Local的IP地址管理方式,具体来说是根据当前服务器对应的Node资源上的PodCIDR字段所设的网段,来分配IP地址。当有新的Pod启动时,会由本地节点的IP段中分配一个空闲的IP给Pod使用。
|
||||
|
||||
|
||||
其实,在CNM规范还没有提出之前,Kubernetes自己来维护网络是必然的结果,因为Docker自带的网络基本上只聚焦于如何解决本地通信,完全无法满足Kubernetes跨集群节点的容器编排的需要。而当CNM规范提出之后,原本Kubernetes应该是除Docker外的最大受益者才对,毕竟CNM的价值就是能很方便地引入其他网络插件,来替代掉Docker自带的网络。
|
||||
|
||||
但Kubernetes却对Docker的CNM规范表现得很是犹豫,经过一番评估考量,Kubernetes最终决定,转为支持当时极不成熟的RKT的网络提案,他们与CoreOS合作,以RKT网络提案为基础发展出了CNI规范。
|
||||
|
||||
Kubernetes Network SIG的Leader、Google的工程师蒂姆·霍金(Tim Hockin)也曾专门撰写过一篇文章《Why Kubernetes doesn’t use libnetwork》,来解释为什么Kubernetes要拒绝CNM与libnetwork。
|
||||
|
||||
当时,容器编排战争还处于三国争霸(Kubernetes、Apache Mesos、Docker Swarm)的拉锯阶段,即使强势如Kubernetes,拒绝CNM其实也要冒不小的风险,付出很大的代价,因为这个决定不可避免地会引发一系列技术和非技术的问题,比如网络提供商要为Kubernetes专门编写不同的网络插件、由docker run启动的独立容器将会无法与Kubernetes启动的容器直接相互通信,等等。
|
||||
|
||||
而促使Kubernetes拒绝CNM的理由,也同样有来自于技术和非技术方面的。
|
||||
|
||||
首先在技术方面,Docker的网络模型做出了许多对Kubernetes无效的假设:Docker的网络有本地网络(不带任何跨节点协调能力,比如Bridge模式就没有全局统一的IP分配)和全局网络(跨主机的容器通信,例如Overlay模式)的区别,本地网络对Kubernetes来说毫无意义,而全局网络又默认依赖libkv,来实现全局IP地址管理等跨机器的协调工作。
|
||||
|
||||
这里的libkv,是指Docker建立的lib*家族中的另一位成员,它主要是用来对标etcd、ZooKeeper等分布式K/V存储,而这对于已经拥有了etcd的Kubernetes来说就如同鸡肋。
|
||||
|
||||
然后在非技术方面,Kubernetes决定放弃CNM的原因,很大程度上还是由于他们与Docker在发展理念上的冲突,Kubernetes当时已经开始推进Docker从必备依赖变为可选引擎的重构工作了,而Docker则坚持CNM只能基于Docker来设计。
|
||||
|
||||
蒂姆·霍金在他的文章中举了一个例子:CNM的网络驱动没有向外部暴露网络所连接容器的具体名称,只使用了一个内部分配的ID来代替,这就让外部(包括网络插件和容器编排系统)很难将网络连接的容器与自己管理的容器对应关联起来,而当他们向Docker开发人员反馈这个问题时,却以“工作符合预期结果”(Working as Intended)为理由,被直接关闭掉了这个问题。
|
||||
|
||||
蒂姆·霍金还专门列出了这些问题的详细清单,比如libnetwork #139、libnetwork #486、libnetwork #514、libnetwork #865、docker #18864。这种设计,被Kubernetes认为是在人为地给非Docker的第三方容器引擎使用CNM设置障碍。而在整个沟通过程中,Docker表现得也很强硬,明确表示他们对偏离当前路线或委托控制的想法都不太欢迎。
|
||||
|
||||
其实,刚刚提到的这些“非技术”的问题,即使没有Docker的支持,Kubernetes自己也不是不能从“技术上”去解决,但Docker的理念会让Kubernetes感到忧虑,因为Kubernetes在Docker之上扩展了很多功能,而Kubernetes却并不想这些功能永远绑定在Docker之上。
|
||||
|
||||
CNM与libnetwork是2015年5月1日发布的,CNI则是在2015年7月发布,两者的正式诞生只相差不到两个月时间,可见这显然是竞争的需要,而不是什么单纯的巧合。
|
||||
|
||||
当然,在五年之后的今天,这场容器网络的话语权之争已经尘埃落定,CNI获得了全面的胜利,除了Kubernetes和RKT之外,Amazon ECS、RedHat OpenShift、Apache Mesos、Cloud Foundry等容器编排圈子中,除了Docker之外,其他具有影响力的参与者都已经宣布支持CNI规范,而原本已经加入了CNM阵营的Contiv、Calico、Weave网络提供商,也纷纷推出了自己的CNI插件。
|
||||
|
||||
那么下面,我就带你具体了解下当前的网络插件生态,看看目前业界常用的容器间网络大体上是如何实现的。
|
||||
|
||||
网络插件生态
|
||||
|
||||
首先要说明的是,到今天为止,支持CNI的网络插件已经多达数十种,我不太可能逐一细说。不过,跨主机通信的网络实现方式,来去也就Overlay模式、路由模式、Underlay模式这三种,所以接下来,我就不妨以网络实现模式为主线,每种模式给你介绍一个具有代表性的插件,以达到对网络插件生态窥斑见豹的效果。
|
||||
|
||||
|
||||
Overlay模式
|
||||
|
||||
|
||||
我们已经学习过Overlay网络,知道这是一种虚拟化的上层逻辑网络,好处在于它不受底层物理网络结构的约束,有更大的自由度,更好的易用性;坏处是由于额外的包头封装,导致信息密度降低,额外的隧道封包解包会导致传输性能下降。
|
||||
|
||||
而在虚拟化环境(如 OpenStack)中,网络限制往往比较多,比如不允许机器之间直接进行二层通信,只能通过三层转发。那么,在这类被限制网络的环境里,基本上就只能选择Overlay网络插件。
|
||||
|
||||
常见的Overlay网络插件有Flannel(VXLAN模式)、Calico(IPIP模式)、Weave,等等。这里我就以Flannel-VXLAN为例来给你介绍一下。
|
||||
|
||||
由CoreOS开发的Flannel,可以说是最早的跨节点容器通信解决方案,在很多其他网络插件的设计中,都能找到Flannel的影子。
|
||||
|
||||
早在2014年,VXLAN还没有进入Linux内核的时候,Flannel就已经开始流行了。当时的Flannel只能采用自定义的UDP封包,实现自己私有协议的Overlay网络,由于封包、解包的操作只能在用户态中进行,而数据包在内核态的协议栈中流转,这就导致数据要反复在用户态、内核态之间拷贝,因此性能堪忧,从此Flannel就给人留下了速度慢的坏印象。
|
||||
|
||||
而当VXLAN进入了Linux内核以后,这种内核态用户态的转换消耗已经完全消失了,Flannel-VXLAN的效率比起Flannel-UDP有了很大提升,所以目前已经成为最常用的容器网络插件之一。
|
||||
|
||||
|
||||
路由模式
|
||||
|
||||
|
||||
路由模式其实是属于Underlay模式的一种特例,这里我把它单独作为一种网络实现模式来给你介绍一下。
|
||||
|
||||
相比起Overlay网络,路由模式的主要区别在于,它的跨主机通信是直接通过路由转发来实现的,因而不需要在不同主机之间进行隧道封包。这种模式的好处是性能相比Overlay网络有明显提升,而坏处是路由转发要依赖于底层网络环境的支持,并不是你想做就能做到的。
|
||||
|
||||
路由网络要求要么所有主机都位于同一个子网之内,都是二层连通的;要么不同二层子网之间由支持边界网关协议(Border Gateway Protocol,BGP)的路由相连,并且网络插件也同样支持BGP协议去修改路由表。
|
||||
|
||||
在上节课我介绍Linux网络基础知识的时候,提到过Linux下不需要专门的虚拟路由,因为Linux本身就具备路由的功能。而路由模式就是依赖Linux内置在系统之中的路由协议,把路由表分发到子网的每一台物理主机的。这样,当跨主机访问容器时,Linux主机可以根据自己的路由表得知,该容器具体位于哪台物理主机之中,从而直接将数据包转发过去,避免了VXLAN的封包解包而导致的性能降低。
|
||||
|
||||
常见的路由网络有Flannel(HostGateway模式)、Calico(BGP模式)等等。这里我就以Flannel-HostGateway为例,Flannel通过在各个节点上运行的Flannel Agent(Flanneld),把容器网络的路由信息设置到主机的路由表上,这样一来,所有的物理主机都拥有整个容器网络的路由数据,容器间的数据包可以被Linux主机直接转发,通信效率与裸机直连都相差无几。
|
||||
|
||||
不过,因为Flannel Agent只能修改它运行主机上的路由表,一旦主机之间隔了其他路由设备,比如路由器或者三层交换机,这个包就会在路由设备上被丢掉,而要解决这种问题,就必须依靠BGP路由和Calico-BGP这类支持标准BGP协议,修改路由表的网络插件共同协作才行。
|
||||
|
||||
|
||||
Underlay模式
|
||||
|
||||
|
||||
这里的Underlay模式特指让容器和宿主机处于同一网络,两者拥有相同的地位的网络方案。Underlay网络要求容器的网络接口能够直接与底层网络进行通信,因此这个****模式是直接依赖于虚拟化设备与底层网络能力的。常见的Underlay网络插件,有MACVLAN、SR-IOV(Single Root I/O Virtualization)等。
|
||||
|
||||
实际上,对于真正的大型数据中心、大型系统来说,Underlay模式才是最有发展潜力的网络模式。这种方案能够最大限度地利用硬件的能力,往往有着最优秀的性能表现。但也是由于它直接依赖于硬件与底层网络环境,必须根据软、硬件情况来进行部署,所以很难能做到Overlay网络那样的开箱即用的灵活性。
|
||||
|
||||
这里我以SR-IOV为例来给你介绍下。SR-IOV不是某种专门的网络名字,而是一种将PCIe设备共享给虚拟机使用的硬件虚拟化标准,目前用在网络设备上的应用比较多,理论上也可以支持其他的PCIe硬件。通过SR-IOV,程序员能够让硬件在虚拟机上实现独立的内存地址、中断和DMA流,而不需要虚拟机管理系统的介入。
|
||||
|
||||
对于容器系统来说,SR-IOV的价值是可以直接在硬件层面虚拟多张网卡,并且以硬件直通(Passthrough)的形式,交付给容器使用。但SR-IOV直通部署起来一般都很繁琐,现在容器用的SR-IOV方案,不少是使用MACVTAP来对SR-IOV网卡进行转接的。
|
||||
|
||||
不过,虽然MACVTAP提升了SR-IOV的易用性,但是这种转接又会带来额外的性能损失,并不一定会比其他网络方案有更好的表现。
|
||||
|
||||
好了,在了解过CNI插件的大致实现原理与分类后,相信你的下一个问题,就是哪种CNI网络最好?如何选择合适的CNI插件?
|
||||
|
||||
其实,选择CNI网络插件主要有两方面的考量因素。
|
||||
|
||||
首先就必须是你系统所处的环境是支持的,这点我在前面已经有针对性地介绍过。然后在环境可以支持的前提下,另一个因素就是性能与功能方面是否合乎你的要求。
|
||||
|
||||
关于性能方面,这里我引用一组测试数据来供你参考。这些数据来自于2020年8月刊登在IETF的论文《Considerations for Benchmarking Network Performance in Containerized Infrastructures》,其中测试了不同CNI插件在裸金属服务器之间(BMP to BMP,Bare Metal Pod)、虚拟机之间(VMP to VMP,Virtual Machine Pod),以及裸金属服务器与虚拟机之间(BMP to VMP)的本地网络和跨主机网络的通信表现。
|
||||
|
||||
其中,最具代表性的是裸金属服务器之间的跨主机通信,这里我把它的结果列了出来,你可以去看看:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
那么,从测试结果来看,MACVLAN和SR-IOV这样的Underlay网络插件的吞吐量最高、延迟最低,所以只从网络性能上看,它们肯定是最优秀的。而相对来说Flannel-VXLAN这样的Overlay网络插件,它的吞吐量只有MACVLAN和SR-IOV的70%左右,延迟更是高了两至三倍之多。
|
||||
|
||||
所以说,Overlay为了易用性、灵活性所付出的代价还是不可忽视的,但是对于那些不以网络I/O为性能瓶颈的系统来说,这样的代价并不是一定不能接受,就看你心中对通用性与性能是如何权衡取舍的了。
|
||||
|
||||
而在功能方面的问题就比较简单了,这完全取决于你的需求是否能够满足。
|
||||
|
||||
对于容器编排系统来说,网络并不是孤立的功能模块,只提供网络通信就可以的,比如Kubernetes的NetworkPolicy资源是用于描述“两个Pod之间是否可以访问”这类ACL策略。但它不属于CNI的范畴,所以不是每个CNI插件都会支持NetworkPolicy的声明。
|
||||
|
||||
如果你有这方面的需求,就应该放弃Flannel,去选择Calico、Weave等插件。类似的其他功能上的选择的例子还有很多,这里我就不一一列举了。
|
||||
|
||||
小结
|
||||
|
||||
如何保证信息安全准确快速地出传输、如何更好地连接不同的集群节点、如何连接异构的容器云平台,这些都是我们需要考虑的一系列的网络问题。
|
||||
|
||||
当然,容器网络技术也在持续地演进之中。我们要知道,容器间网络是把应用从单机扩展到集群的关键钥匙,但它也把虚拟化容器推入到了更复杂的境地,网络要去适应这种变化,要去适配容器的各种需求,所以才出现了百花齐放的容器网络方案。
|
||||
|
||||
一课一思
|
||||
|
||||
这节课里出现了许多中不同的容器网络,你认为对这些网络的选择,主要应该是架构师的职责,还是运维工程师的职责呢?
|
||||
|
||||
欢迎在留言区分享你的思考和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
320
专栏/周志明的架构课/55_谈谈Kubernetes的存储设计理念.md
Normal file
320
专栏/周志明的架构课/55_谈谈Kubernetes的存储设计理念.md
Normal file
@@ -0,0 +1,320 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
55 _ 谈谈Kubernetes的存储设计理念
|
||||
你好,我是周志明。从这节课起,我会用三讲带你学习容器编排系统存储方面的知识点。今天这节课,我们先来探讨下Kubernetes的存储设计理念。
|
||||
|
||||
Kubernetes的存储设计考量
|
||||
|
||||
在开始之前,我想先表明一下我对Kubernetes存储能力的态度。Kubernetes在规划持久化存储能力的时候,依然遵循着它的一贯设计哲学,用户负责以资源和声明式API来描述自己的意图,Kubernetes负责根据用户意图来完成具体的操作。不过我认为,就算只是描述清楚用户的存储意图,也不是一件容易的事情,相比Kubernetes提供的其他能力的资源,它内置的存储资源其实格外地复杂,甚至可以说是有些繁琐的。
|
||||
|
||||
如果你是Kubernetes的拥趸,不能认同我对Kubernetes的批评,那不妨来看一看下列围绕着“Volume”所衍生出的概念,它们仅仅是与Kubernetes存储相关概念的一个子集而已,你在看的时候也可以来思考一下,这些概念是否全都是必须的、是否还有整合的空间、是否有化繁为简的可能性:
|
||||
|
||||
|
||||
概念:Volume、PersistentVolume、PersistentVolumeClaim、Provisioner、StorageClass、Volume Snapshot、Volume Snapshot Class、Ephemeral Volumes、FlexVolume Driver、Container Storage Interface、CSI Volume Cloning、Volume Limits、Volume Mode、Access Modes、Storage Capacity……-
|
||||
操作:Mount、Bind、Use、Provision、Claim、Reclaim、Reserve、Expand、Clone、Schedule、Reschedule……
|
||||
|
||||
|
||||
其实啊,Kubernetes之所以有如此多关于存储的术语概念,最重要的原因是存储技术本来就有很多种类,为了尽可能多地兼容各种存储,Kubernetes不得不预置了很多In-Tree(意思是在Kubernetes的代码树里)插件来对接,让用户根据自己的业务按需选择。
|
||||
|
||||
同时,为了兼容那些不在预置范围内的需求场景,Kubernetes也支持用户使用FlexVolume或者CSI来定制Out-of-Tree(意思是在Kubernetes的代码树之外)的插件,实现更加丰富多样的存储能力。下表中列出了Kubernetes目前提供的一部分存储与扩展的插件:
|
||||
|
||||
|
||||
|
||||
事实上,迫使Kubernetes存储设计得如此复杂的原因,除了是要扩大兼容范畴之外,还有一个非技术层面的因素,就是Kubernetes是一个工业级的、面向生产应用的容器编排系统。
|
||||
|
||||
而这就意味着,即使Kubernetes发现了某些已存在的功能有更好的实现方式,但直到旧版本被淘汰出生产环境以前,原本已支持的功能都不允许突然间被移除或者替换掉。否则,当生产系统更新版本时,已有的功能就会出现异常,那就会极大威胁到产品的信誉。
|
||||
|
||||
当然,在一定程度上,我们可以原谅Kubernetes为了实现兼容而导致的繁琐,但这样的设计确实会让Kubernetes的学习曲线变得更加陡峭。
|
||||
|
||||
Kubernetes提供的官方文档的主要作用是为实际开发提供参考,它并不会告诉你Kubernetes中各种概念的演化历程、版本发布新功能的时间线、改动的缘由与背景等信息,只会以“平坦”的方式来陈述所有目前可用的功能,这可能有利于熟练的管理员快速查询到关键信息,却不利于初学者去理解Kubernetes的设计思想。
|
||||
|
||||
如此一来,因为很难理解那些概念和操作的本意,初学者往往就只能死记硬背,很难分辨出它们应该如何被“更正确”地使用。而介绍Kubernetes设计理念的职责,只能由Kubernetes官方的Blog 这类信息渠道,或者其他非官方资料去完成。
|
||||
|
||||
所以接下来,我会从Volume的概念开始,以操作系统到Docker,再到Kubernetes的演进历程为主线,带你去梳理前面提到的那些概念与操作,以此帮你更好地理解Kubernetes的存储设计。
|
||||
|
||||
首先,我们来看看Mount和Volume这两个概念。
|
||||
|
||||
Mount和Volume
|
||||
|
||||
Mount和Volume都是来源于操作系统的常用术语,Mount是动词,表示将某个外部存储挂载到系统中;Volume是名词,表示物理存储的逻辑抽象,目的是为物理存储提供有弹性的分割方式。
|
||||
|
||||
而我们知道,容器是源于对操作系统层的虚拟化,为了满足容器内生成数据的外部存储需求,我们也很自然地会把Mount和Volume的概念延至容器中。因此,要想了解容器存储的发展,我们不妨就以Docker的Mount操作为起始点。
|
||||
|
||||
目前,Docker内建支持了三种挂载类型,分别是Bind(--mount type=bind)、Volume(--mount type=volume)和tmpfs(--mount type=tmpfs),如下图所示。其中,tmpfs主要用于在内存中读写临时数据,跟我们这个小章节要讨论的对象“持久化存储”并不相符,所以后面我们只着重关注Bind和Volume两种挂载类型就可以了。
|
||||
|
||||
|
||||
|
||||
(图片来自Docker官网文档)
|
||||
|
||||
我们先来聊聊Bind。
|
||||
|
||||
Bind Mount是Docker最早提供的(发布时就支持)挂载类型,作用是把宿主机的某个目录(或文件)挂载到容器的指定目录(或文件)下,比如下面命令中,参数-v表达的意思就是把外部的HTML文档,挂到Nginx容器的默认网站根目录下:
|
||||
|
||||
docker run -v /icyfenix/html:/usr/share/nginx/html nginx:latest
|
||||
|
||||
|
||||
请注意,虽然命令中-v参数是--volume的缩写,但-v最初只是用来创建Bind Mount,而不是创建Volume Mount的。
|
||||
|
||||
这种迷惑的行为其实也并不是Docker的本意,只是因为Docker刚发布的时候考虑得不够周全,随随便便就在参数中占用了“Volume”这个词,到后来真的需要扩展Volume的概念来支持Volume Mount的时候,前面的-v已经被用户广泛使用了,所以也就只能如此将就着继续用。
|
||||
|
||||
从Docker 17.06版本开始,Bind就在Docker Swarm中借用了--mount参数过来,这个参数默认创建的是Volume Mount,用户可以通过明确的type子参数来指定另外两种挂载类型。比如说,前面给到的命令,就可以等价于下面所示的--mount版本:
|
||||
|
||||
docker run --mount type=bind,source=/icyfenix/html,destination=/usr/share/nginx/html nginx:latest
|
||||
|
||||
|
||||
从Bind Mount到Volume Mount,实质上是容器发展过程中对存储抽象能力提升的外在表现。我们根据“Bind”这个名字,以及Bind Mount的实际功能,其实可以合理地推测,Docker最初认为“Volume”就只是一种“外部宿主机的磁盘存储到内部容器存储的映射关系”,但后来它眉头一皱,发现事情并没有那么简单:存储的位置并不局限只在外部宿主机,存储的介质并不局限只是物理磁盘,存储的管理也并不局限只有映射关系。
|
||||
|
||||
我给你举几个例子。
|
||||
|
||||
比如,Bind Mount只能让容器与本地宿主机之间建立某个目录的映射,那么如果想要在不同宿主机上的容器共享同一份存储,就必须先把共享存储挂载到每一台宿主机操作系统的某个目录下,然后才能逐个挂载到容器内使用,这种跨宿主机共享存储的场景如下图所示:
|
||||
|
||||
|
||||
|
||||
(图片来自Docker官网文档)
|
||||
|
||||
这种存储范围超越了宿主机的共享存储,配置过程却要涉及到大量与宿主机环境相关的操作,只能由管理员人工地去完成,不仅繁琐,而且由于每台宿主机环境的差异,还会导致主机很难实现自动化。
|
||||
|
||||
再比如,即使只考虑单台宿主机的情况,基于可管理性的需求,Docker也完全有支持Volume Mount的必要。为什么这么说呢?
|
||||
|
||||
实际上,在Bind Mount的设计里,Docker只有容器的控制权,存放容器生产数据的主机目录是完全独立的,与Docker没有任何关系,它既不受Docker保护,也不受Docker管理。所以这就使得数据很容易被其他进程访问到,甚至是被修改和删除。如果用户想对挂载的目录进行备份、迁移等管理运维操作,也只能在Docker之外靠管理员人工进行,而这些都增加了数据安全与操作意外的风险。
|
||||
|
||||
因此,Docker希望能有一种抽象的资源,来代表在宿主机或网络中存储的区域,以便让Docker能管理这些资源,这样就很自然地联想到了操作系统里的Volume。
|
||||
|
||||
提出Volume最核心的一个目的,是为了提升Docker对不同存储介质的支撑能力,这同时也是为了减轻Docker本身的工作量。
|
||||
|
||||
要知道,存储并不是只有挂载在宿主机上的物理存储这一种介质。在云计算时代,网络存储逐渐成为了数据中心的主流选择,不同的网络存储都有各自的协议和交互接口。而且,并不是所有的存储系统都适合先挂载到操作系统,然后再挂载到容器的,如果Docker想要越过操作系统去支持挂载某种存储系统,首先必须要知道该如何访问它,然后才能把容器中的读写操作自动转移到该位置。
|
||||
|
||||
Docker把解决如何访问存储的功能模块叫做存储驱动(Storage Driver)。通过docker info命令,你能查看到当前Docker所支持的存储驱动。虽然Docker已经内置了市面上主流的OverlayFS驱动,比如Overlay、Overlay2、AUFS、BTRFS、ZFS等等,但面对云计算的快速迭代,只靠Docker自己来支持全部云计算厂商的存储系统是完全不现实的。
|
||||
|
||||
为此,Docker就提出了与Storage Driver相对应的Volume Driver(卷驱动)的概念。
|
||||
|
||||
我们可以通过docker plugin install命令安装外部的卷驱动,并在创建Volume时,指定一个与其存储系统相匹配的卷驱动。比如,我们希望数据存储在AWS Elastic Block Store上,就找一个AWS EBS的驱动;如果想存储在Azure File Storage上,也是找一个对应的Azure File Storage驱动即可。
|
||||
|
||||
而如果在创建Volume时,不指定卷驱动,那默认就是local类型,在Volume中存放的数据就会存储在宿主机的/var/lib/docker/volumes/目录之中。
|
||||
|
||||
Static Provisioning
|
||||
|
||||
好了,了解了Mount和Volume的概念含义之后,现在我们把讨论主角转回容器编排系统上。
|
||||
|
||||
这里,我们会从存储如何分配、持久存储与非持久存储的差异出发,来具体学习下Static Provisioning的设计。
|
||||
|
||||
首先我们可以明确一件事,即Kubernetes同样是把操作系统和Docker的Volume概念延续了下来,并对其进行了进一步的细化。
|
||||
|
||||
Kubernetes把Volume分为了持久化的PersistentVolume和非持久化的普通Volume两类,这里为了不跟我前面定义的Volume这个概念产生混淆,后面课程我提到的Kubernetes中非持久化的Volume时,都会带着“普通”这个前缀。
|
||||
|
||||
普通Volume的设计目标并不是为了持久地保存数据,而是为同一个Pod中多个容器提供可共享的存储资源,所以普通Volume的生命周期非常明确,也就是与挂载它的Pod有着相同的生命周期。
|
||||
|
||||
这样,就意味着尽管普通Volume不具备持久化的存储能力,但至少比Pod中运行的任何容器的存活期都更长,Pod中不同的容器能共享相同的普通Volume,当容器重新启动时,普通Volume中的数据也能够得到保留。
|
||||
|
||||
当然,一旦整个Pod被销毁,普通Volume也就不复存在了,数据在逻辑上也会被销毁掉。至于实际中是否会真正删除数据,就取决于存储驱动具体是如何实现Unmount、Detach、Delete接口的(这个小章节的主题是“持久化存储”,所以关于无持久化能力的普通Volume,我就不再展开了)。
|
||||
|
||||
如此一来,从操作系统里传承下来的Volume概念,就在Docker和Kubernetes中继续按照一致的逻辑延伸拓展了,只不过Kubernetes为了把它跟普通Volume区别开来,专门取了PersistentVolume这个名字。你可以从下图中直观地看出普通Volume、PersistentVolume和Pod之间的关系差异:
|
||||
|
||||
|
||||
|
||||
其实,我们从Persistent这个单词的意思,就能大致了解PersistentVolume的含义,它是指能够将数据进行持久化存储的一种资源对象。
|
||||
|
||||
PersistentVolume可以独立于Pod存在,生命周期与Pod无关,所以也就决定了PersistentVolume不应该依附于任何一个宿主机节点,否则必然会对Pod调度产生干扰限制。我们在前面“Docker的三种挂载类型”图例中,可以看到“Persistent”一列里都是网络存储,这便是很好的印证。
|
||||
|
||||
|
||||
额外知识:Local PersistentVolume-
|
||||
|
||||
对于部署在云端数据中心的系统,通过网络访问同一个可用区中的远程存储,速度是完全可以接受的。但对于私有部署的系统来说,基于性能考虑,使用本地存储往往会更加常见。-
|
||||
|
||||
因此,考虑到这样的实际需求,从1.10版起,Kubernetes开始支持Local PersistentVolume,这是一种将一整块本地磁盘作为PersistentVolume供容器使用的专用方案。-
|
||||
|
||||
所谓的“专用方案”就是字面意思,它并不适用于全部应用,Local PersistentVolume只是针对以磁盘I/O为瓶颈的特定场景的解决方案,因而它的副作用就很明显:由于不能保证这种本地磁盘在每个节点中都一定存在,所以Kubernetes在调度时就必须考虑到PersistentVolume分布情况,只能把使用了Local PersistentVolume的Pod调度到有这种PersistentVolume的节点上。-
|
||||
|
||||
尽管调度器中专门有个Volume Binding Mode模式来支持这项处理,但是一旦使用了Local PersistentVolume,还是会限制Pod的可调度范围。
|
||||
|
||||
|
||||
那么,在把PersistentVolume与Pod分离后,就需要专门考虑PersistentVolume该如何被Pod所引用的问题了。
|
||||
|
||||
实际上,原本在Pod中引用其他资源是常有的事,要么是通过资源名称直接引用,要么是通过标签选择器(Selectors)间接引用。但是类似的方法在这里却都不太妥当,至于原因,你可以先思考一下:“Pod该使用何种存储”这件事情,应该是系统管理员(运维人员)说的算,还是由用户(开发人员)说的算?
|
||||
|
||||
要我看,最合理的答案是他们一起说的才算,因为只有开发能准确评估Pod运行需要消耗多大的存储空间,只有运维能清楚地知道当前系统可以使用的存储设备状况。
|
||||
|
||||
所以,为了让这二者能够各自提供自己擅长的信息,Kubernetes又额外设计出了PersistentVolumeClaim资源。
|
||||
|
||||
其实在Kubernetes官方给出的概念定义中,也特别强调了PersistentVolume是由管理员(运维人员)负责维护的,用户(开发人员)通过PersistentVolumeClaim,来匹配到合乎需求的PersistentVolume。
|
||||
|
||||
|
||||
PersistentVolume & PersistentVolumeClaim-
|
||||
A PersistentVolume (PV) is a piece of storage in the cluster that has been provisioned by an administrator.-
|
||||
A PersistentVolumeClaim (PVC) is a request for storage by a user.-
|
||||
PersistentVolume是由管理员负责提供的集群存储。-
|
||||
PersistentVolumeClaim是由用户负责提供的存储请求。-
|
||||
—— Kubernetes Reference Documentation,Persistent Volumes
|
||||
|
||||
|
||||
PersistentVolume是Volume这个抽象概念的具象化表现,通俗点儿说,即它是已经被管理员分配好的具体的存储。
|
||||
|
||||
这里的“具体”是指有明确的存储系统地址,有明确的容量、访问模式、存储位置等信息;而PersistentVolumeClaim是Pod对其所需存储能力的声明,通俗地说就是“如果要满足这个Pod正常运行,需要满足怎样的条件”,比如要消耗多大的存储空间、要支持怎样的访问方式。
|
||||
|
||||
所以,实际上管理员和用户并不是谁引用谁的固定关系,而是根据实际情况动态匹配的。
|
||||
|
||||
下面我们就来看看这两者配合工作的具体过程:
|
||||
|
||||
|
||||
管理员准备好要使用的存储系统,它应该是某种网络文件系统(NFS)或者云储存系统,一般来说应该具备跨主机共享的能力。
|
||||
管理员会根据存储系统的实际情况,手工预先分配好若干个PersistentVolume,并定义好每个PersistentVolume可以提供的具体能力。如下面例子所示:
|
||||
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: nginx-html
|
||||
spec:
|
||||
capacity:
|
||||
storage: 5Gi # 最大容量为5GB
|
||||
accessModes:
|
||||
- ReadWriteOnce # 访问模式为RXO
|
||||
persistentVolumeReclaimPolicy: Retain # 回收策略是Retain
|
||||
nfs: # 存储驱动是NFS
|
||||
path: /html
|
||||
server: 172.17.0.2
|
||||
|
||||
|
||||
这里我们来简单分析下以上YAML中定义的存储能力:
|
||||
|
||||
|
||||
存储的最大容量是5GB。
|
||||
存储的访问模式是“只能被一个节点读写挂载”(ReadWriteOnce,RWO),另外两种可选的访问模式是“可以被多个节点以只读方式挂载”(ReadOnlyMany,ROX)和“可以被多个节点读写挂载”(ReadWriteMany,RWX)。
|
||||
存储的回收策略是Retain,即在Pod被销毁时并不会删除数据。另外两种可选的回收策略分别是Recycle ,即在Pod被销毁时,由Kubernetes自动执行rm -rf /volume/*这样的命令来自动删除资料;以及Delete,它让Kubernetes自动调用AWS EBS、GCE PersistentDisk、OpenStack Cinder这些云存储的删除指令。
|
||||
存储驱动是NFS,其他常见的存储驱动还有AWS EBS、GCE PD、iSCSI、RBD(Ceph Block Device)、GlusterFS、HostPath,等等。
|
||||
|
||||
|
||||
|
||||
用户根据业务系统的实际情况,创建PersistentVolumeClaim,声明Pod运行所需的存储能力。如下面例子所示:
|
||||
|
||||
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: nginx-html-claim
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce # 支持RXO访问模式
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi # 最小容量5GB
|
||||
|
||||
|
||||
可以看到,在以上YAML中,声明了要求容量不得小于5GB,必须支持RWO的访问模式。
|
||||
|
||||
|
||||
Kubernetes在创建Pod的过程中,会根据系统中PersistentVolume与PersistentVolumeClaim的供需关系,对两者进行撮合,如果系统中存在满足PersistentVolumeClaim声明中要求能力的PersistentVolume,就表示撮合成功,它们将会被绑定。而如果撮合不成功,Pod就不会被继续创建,直到系统中出现新的、或让出空闲的PersistentVolume资源。
|
||||
以上几步都顺利完成的话,意味着Pod的存储需求得到满足,进而继续Pod的创建过程。
|
||||
|
||||
|
||||
以上的整个运作过程如下图所示:
|
||||
|
||||
|
||||
|
||||
(图片来自《Kubernetes in Action》)
|
||||
|
||||
Kubernetes对PersistentVolumeClaim与PersistentVolume撮合的结果是产生一对一的绑定关系,“一对一”的意思是PersistentVolume一旦绑定在某个PersistentVolumeClaim上,直到释放以前都会被这个PersistentVolumeClaim所独占,不能再与其他PersistentVolumeClaim进行绑定。
|
||||
|
||||
这意味着即使PersistentVolumeClaim申请的存储空间比PersistentVolume能够提供的要少,依然要求整个存储空间都为该PersistentVolumeClaim所用,这有可能会造成资源的浪费。
|
||||
|
||||
比如,某个PersistentVolumeClaim要求3GB的存储容量,当前Kubernetes手上只剩下一个5GB的PersistentVolume了,此时Kubernetes只好将这个PersistentVolume与申请资源的PersistentVolumeClaim进行绑定,平白浪费了2GB空间。
|
||||
|
||||
假设后续有另一个PersistentVolumeClaim申请2GB的存储空间,那它也只能等待管理员分配新的PersistentVolume,或者有其他PersistentVolume被回收之后,才被能成功分配。
|
||||
|
||||
Dynamic Provisioning
|
||||
|
||||
对于中小规模的Kubernetes集群,PersistentVolume已经能够满足有状态应用的存储需求。PersistentVolume依靠人工介入来分配空间的设计虽然简单直观,却算不上是先进,一旦应用规模增大,PersistentVolume很难被自动化的问题就会凸显出来。
|
||||
|
||||
这是由于Pod创建过程中需要去挂载某个Volume时,都要求该Volume必须是真实存在的,否则Pod启动可能依赖的数据(如一些配置、数据、外部资源等)都将无从读取。Kubernetes虽然有能力随着流量压力和硬件资源状况,自动扩缩Pod的数量,但是当Kubernetes自动扩展出一个新的Pod后,并没有办法让Pod去自动挂载一个还未被分配资源的PersistentVolume。
|
||||
|
||||
想解决这个问题,要么允许多个不同的Pod都共用相同的PersistentVolumeClaim,这种方案确实只靠PersistentVolume就能解决,却损失了隔离性,难以通用;要么就要求每个Pod用到的PersistentVolume都是已经被预先建立并分配好的,这种方案靠管理员提前手工分配好大量的存储也可以实现,却损失了自动化能力。
|
||||
|
||||
无论哪种情况,都难以符合Kubernetes工业级编排系统的产品定位,对于大型集群,面对成百上千,来自成千上万的Pod,靠管理员手工分配存储肯定是无法完成的。在2017年Kubernetes发布1.6版本后,终于提供了今天被称为Dynamic Provisioning的动态存储解决方案,让系统管理员摆脱了人工分配的PersistentVolume的窘境,并把此前的分配方式称为Static Provisioning。
|
||||
|
||||
那Dynamic Provisioning方案是如何解放系统管理员的呢?我们先来看概念,Dynamic Provisioning方案是指在用户声明存储能力的需求时,不是期望通过Kubernetes撮合来获得一个管理员人工预置的PersistentVolume,而是由特定的资源分配器(Provisioner)自动地在存储资源池或者云存储系统中分配符合用户存储需要的PersistentVolume,然后挂载到Pod中使用,完成这项工作的资源被命名为StorageClass,它的具体工作过程如下:
|
||||
|
||||
|
||||
管理员根据储系统的实际情况,先准备好对应的Provisioner。Kubernetes官方已经提供了一系列预置的In-Tree Provisioner,放置在kubernetes.io的API组之下。其中部分Provisioner已经有了官方的CSI驱动,如vSphere的Kubernetes自带驱动为kubernetes.io/vsphere-volume,VMware的官方驱动为csi.vsphere.vmware.com。
|
||||
|
||||
管理员不再是手工去分配PersistentVolume,而是根据存储去配置StorageClass。Pod是可以动态扩缩的,而存储则是相对固定的,哪怕使用的是具有扩展能力的云存储,也会将它们视为存储容量、IOPS等参数可变的固定存储来看待,比如你可以将来自不同云存储提供商、不同性能、支持不同访问模式的存储配置为各种类型的StorageClass,这也是它名字中“Class”(类型)的由来,如下面这个例子:
|
||||
|
||||
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: standard
|
||||
provisioner: kubernetes.io/aws-ebs #AWS EBS的Provisioner
|
||||
parameters:
|
||||
type: gp2
|
||||
reclaimPolicy: Retain
|
||||
|
||||
|
||||
|
||||
用户依然通过PersistentVolumeClaim来声明所需的存储,但是应在声明中明确指出该由哪个StorageClass来代替Kubernetes处理该PersistentVolumeClaim的请求,如下面这个例子:
|
||||
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: standard-claim
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: standard #明确指出该由哪个StorageClass来处理该PersistentVolumeClaim的请求
|
||||
resource:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
|
||||
|
||||
|
||||
如果PersistentVolumeClaim中要求的StorageClass及它用到的Provisioner均是可用的话,那这个StorageClass就会接管掉原本由Kubernetes撮合的PersistentVolume和PersistentVolumeClaim的操作,按照PersistentVolumeClaim中声明的存储需求,自动产生出满足该需求的PersistentVolume描述信息,并发送给Provisioner处理。
|
||||
|
||||
Provisioner接收到StorageClass发来的创建PersistentVolume请求后,会操作其背后存储系统去分配空间,如果分配成功,就生成并返回符合要求的PersistentVolume给Pod使用。
|
||||
|
||||
前面这几步都顺利完成的话,就意味着Pod的存储需求得到了满足,会继续Pod的创建过程,整个过程如下图所示。
|
||||
|
||||
|
||||
|
||||
|
||||
(图片来自《Kubernetes in Action》)
|
||||
|
||||
好了,通过刚刚的讲述,相信你可以看出Dynamic Provisioning与Static Provisioning并不是各有用途的互补设计,而是对同一个问题先后出现的两种解决方案。你完全可以只用Dynamic Provisioning来实现所有的Static Provisioning能够实现的存储需求,包括那些不需要动态分配的场景,甚至之前例子里使用HostPath在本地静态分配存储,都可以指定no-provisioner作为Provisioner的StorageClass,以Local Persistent Volume来代替,比如下面这个例子:
|
||||
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: local-storage
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
|
||||
|
||||
所以说,相较于Static Provisioning,使用Dynamic Provisioning来分配存储无疑是更合理的设计,不仅省去了管理员的人工操作的中间层,也不再需要将PersistentVolume这样的概念暴露给最终用户,因为Dynamic Provisioning里的PersistentVolume只是处理过程的中间产物,用户不再需要接触和理解它,只需要知道由PersistentVolumeClaim去描述存储需求,由StorageClass去满足存储需求即可。只描述意图而不关心中间具体的处理过程是声明式编程的精髓,也是流程自动化的必要基础。
|
||||
|
||||
除此之外,由Dynamic Provisioning来分配存储还能获得更高的可管理性。如前面提到的回收策略,当希望PersistentVolume跟随Pod一同被销毁时,以前经常会配置回收策略为Recycle来回收空间,即让系统自动执行rm -rf /volume/*命令。
|
||||
|
||||
但是这种方式往往过于粗暴,要是遇到更精细的管理需求,如“删除到回收站”或者“敏感信息粉碎式彻底删除”这样的功能,实现起来就很麻烦。而Dynamic Provisioning中由于有Provisioner的存在,如何创建、如何回收都是由Provisioner的代码所管理的,这就带来了更高的灵活性。所以,现在Kubernetes官方已经明确建议废弃掉Recycle策略,如果有这类需求就改由Dynamic Provisioning去实现了。
|
||||
|
||||
另外,相较于Dynamic Provisioning,Static Provisioning的主要使用场景就局限于管理员能够手工管理存储的小型集群,它符合很多小型系统,尤其是私有化部署系统的现状,但并不符合当今运维自动化所提倡的思路。Static Provisioning的存在,某种意义上也可以视为是对历史的一种兼容,在可见的将来,Kubernetes肯定还是会把Static Provisioning作为用户分配存储的一种主要方案,来供用户选用。
|
||||
|
||||
小结
|
||||
|
||||
容器是镜像的运行时实例,为了保证镜像能够重复地产生出具备一致性的运行时实例,必须要求镜像本身是持久而稳定的,这就决定了在容器中发生的一切数据变动操作,都不能真正写入到镜像当中,否则必然会破坏镜像稳定不变的性质。
|
||||
|
||||
为此,容器中的数据修改操作,大多是基于写入时复制(Copy-on-Write)策略来实现的,容器会利用叠加式文件系统(OverlayFS)的特性,在用户意图对镜像进行修改时,自动将变更的内容写入到独立区域,再与原有数据叠加到一起,使其外观上看起来像是“覆盖”了原有内容。这种改动通常都是临时的,一旦容器终止运行,这些存储于独立区域中的变动信息也将被一并移除,不复存在。所以可见,如果不去进行额外的处理,容器默认是不具备持久化存储能力的。
|
||||
|
||||
而另一方面,容器作为信息系统的运行载体,必定会产生出有价值的、应该被持久保存的信息,比如扮演数据库角色的容器,大概没有什么系统能够接受数据库像缓存服务一样,重启之后会丢失全部数据;多个容器之间也经常需要通过共享存储来实现某些交互操作,比如我在第48讲中曾经举过的例子,Nginx容器产生日志、Filebeat容器收集日志,两者就需要共享同一块日志存储区域才能协同工作。
|
||||
|
||||
而正因为镜像的稳定性与生产数据持久性存在矛盾,所以我们才需要去重点了解这个问题:如何实现容器的持久化存储。
|
||||
|
||||
一课一思
|
||||
|
||||
不知你是否察觉,这节课里,还埋藏了一条暗线的逻辑,以Kubernetes的存储为样例,讨论当新的更好的解决方案出来之后,系统对既有旧方案和旧功能的兼容。这是很多场景中都会遇到的问题,系统设计必须考虑现实情况,必须有所妥协,很难单纯去追求理论上的最优解。越大规模的应用,通常都带着更大的现实牵绊。如果你也有这样的经历,不妨留言与我分享一下。
|
||||
|
||||
如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
166
专栏/周志明的架构课/56_Kubernetes存储扩展架构:一个真实的存储系统如何接入或移除新存储设备?.md
Normal file
166
专栏/周志明的架构课/56_Kubernetes存储扩展架构:一个真实的存储系统如何接入或移除新存储设备?.md
Normal file
@@ -0,0 +1,166 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
56 _ Kubernetes存储扩展架构:一个真实的存储系统如何接入或移除新存储设备?
|
||||
你好,我是周志明。
|
||||
|
||||
我们知道,容器存储具有很强的多样性,如何对接后端实际的存储系统,并且完全发挥出它所有的性能与功能,并不是Kubernetes团队所擅长的工作,这件事情只有存储提供商才能做到最好。所以,我们其实可以理解容器编排系统为什么会有很强烈的意愿,想把存储功能独立到外部去实现。
|
||||
|
||||
在上节课我已经反复提到过多次In-Tree、Out-of-Tree插件,那么今天这节课,我就会以存储插件的接口与实现为中心,带你去解析Kubernetes的容器存储生态。
|
||||
|
||||
Kubernetes存储架构
|
||||
|
||||
在正式开始讲解Kubernetes的In-Tree、Out-of-Tree存储插件前,我们有必要先去了解一点Kubernetes存储架构的知识。了解一个真实的存储系统是如何接入到新创建的Pod中,成为可以读写访问的Volume,以及当Pod被销毁时,Volume如何被回收,回归到存储系统之中的。
|
||||
|
||||
那么,对于刚刚所说的这几点,Kubernetes其实是参考了传统操作系统接入或移除新存储设备的做法,把接入或移除外部存储这件事情,分解为了以下三个操作:
|
||||
|
||||
|
||||
决定应准备(Provision)何种存储:Provision可类比为给操作系统扩容而购买了新的存储设备。这步确定了接入存储的来源、容量、性能以及其他技术参数,它的逆操作是移除(Delete)存储。
|
||||
将准备好的存储附加(Attach)到系统中:Attach可类比为将存储设备接入操作系统,此时尽管设备还不能使用,但你已经可以用操作系统的fdisk -l命令查看到设备。这步确定了存储的设备名称、驱动方式等面向系统侧的信息,它的逆操作是分离(Detach)存储设备。
|
||||
将附加好的存储挂载(Mount)到系统中:Mount可类比为将设备挂载到系统的指定位置,也就是操作系统中mount命令的作用。这步确定了存储的访问目录、文件系统格式等面向应用侧的信息,它的逆操作是卸载(Unmount)存储设备。
|
||||
|
||||
|
||||
实际上,前面步骤中提到的Provision、Delete、Attach、Detach、Mount、Unmount六种操作,并不是直接由Kubernetes来实现,而是在存储插件中完成的。它们会分别被Kubernetes通过两个控制器及一个管理器来进行调用,这些控制器、管理器的作用如下:
|
||||
|
||||
|
||||
PV控制器(PersistentVolume Controller)
|
||||
|
||||
|
||||
“以容器构建系统”这个小章节中我介绍过,Kubernetes里所有的控制器都遵循着相同的工作模式,即让实际状态尽可能接近期望状态。PV控制器的期望状态有两个,分别是“所有未绑定的PersistentVolume都能处于可用状态”以及“所有处于等待状态的PersistentVolumeClaim都能配对到与之绑定的PersistentVolume”。
|
||||
|
||||
它内部也有两个相对独立的核心逻辑(ClaimWorker和VolumeWorker)来分别跟踪这两种期望状态。可以简单地理解为PV控制器实现了PersistentVolume和PersistentVolumeClaim的生命周期管理职能。在这个过程中,它会根据需要调用存储驱动插件的Provision/Delete操作。
|
||||
|
||||
|
||||
AD控制器(Attach/Detach Controller)
|
||||
|
||||
|
||||
AD控制器的期望状态是“所有被调度到准备新创建Pod的节点,都附加好了要使用的存储;当Pod被销毁后,原本运行Pod的节点都分离了不再被使用的存储”。如果实际状态不符合该期望,会根据需要调用存储驱动插件的Attach/Detach操作。
|
||||
|
||||
|
||||
Volume管理器(Volume Manager)
|
||||
|
||||
|
||||
Volume管理器实际上是kubelet众多管理器的其中一个,它主要作用是支持本节点中Volume执行Attach/Detach/Mount/Unmount操作。你可能注意到这里不仅有Mount/Unmount操作,也出现了Attach/Detach操作。
|
||||
|
||||
这是历史原因导致的,因为最初版本的Kubernetes中并没有AD控制器,Attach/Detach的职责也在kubelet中完成。而现在kubelet默认情况下已经不再会执行Attach/Detach了,但有少量旧程序已经依赖了由kubelet来实现Attach/Detach的内部逻辑,所以kubelet不得不设计一个--enable-controller-attach-detach参数,如果将其设置为false的话,就会重新回到旧的兼容模式上,由kubelet代替AD控制器来完成Attach/Detach。
|
||||
|
||||
|
||||
|
||||
这样一来,后端的真实存储经过Provision、Attach、Mount操作之后,就形成了可以在容器中挂载的Volume。当存储的生命周期完结,经过Unmount、Detach、Delete操作之后,Volume便能够被存储系统回收。而对于某些存储来说,其中有一些操作可能是无效的,比如NFS,实际使用并不需要Attach,此时存储插件只需将Attach实现为空操作即可。
|
||||
|
||||
FlexVolume与CSI
|
||||
|
||||
Kubernetes目前同时支持FlexVolume与CSI(Container Storage Interface)两套独立的存储扩展机制。FlexVolume是Kubernetes早期版本(1.2版开始提供,1.8版达到GA状态)就开始支持的扩展机制,它是只针对Kubernetes的私有的存储扩展,目前已经处于冻结状态,可以正常使用但不再发展新功能了。
|
||||
|
||||
CSI则是从Kubernetes 1.9开始加入(1.13版本达到GA状态)的扩展机制,如同之前我介绍过的CRI和CNI那样,CSI是公开的技术规范。任何容器运行时、容器编排引擎只要愿意支持,都可以使用CSI规范去扩展自己的存储能力,这是目前Kubernetes重点发展的扩展机制。
|
||||
|
||||
由于是专门为Kubernetes量身订造的,所以FlexVolume的实现逻辑与上节课我介绍的Kubernetes存储架构高度一致。FlexVolume驱动其实就是一个实现了Attach、Detach、Mount、Unmount操作的可执行文件(甚至可以仅仅是个Shell脚本)而已。该可执行文件应该存放在集群每个节点的/usr/libexec/kubernetes/kubelet-plugins/volume/exec目录里,其工作过程也就是,当AD控制器和Volume管理器需要进行Attach、Detach、Mount、Unmount操作时,自动调用它的对应方法接口,如下图所示。
|
||||
|
||||
|
||||
|
||||
FlexVolume Driver工作过程
|
||||
|
||||
如果仅仅考虑支持最基本的Static Provisioning,那实现一个FlexVolume Driver确实是非常简单的。然而也是由于FlexVolume过于简单了,导致它应用起来会有诸多不便之处,比如说:
|
||||
|
||||
|
||||
FlexVolume并不是全功能的驱动:FlexVolume不包含Provision和Delete操作,也就无法直接用于Dynamic Provisioning,想要实现这个功能,除非你愿意再单独编写一个External Provisioner。
|
||||
FlexVolume部署维护都相对繁琐:FlexVolume是独立于Kubernetes的可执行文件,当集群节点增加时,需要由管理员在新节点上部署FlexVolume Driver。为了避免耗费过多人力,有经验的系统管理员通常会专门编写一个DaemonSet来代替人工来完成这项任务。
|
||||
FlexVolume实现复杂交互也相对繁琐:FlexVolume的每一次操作,都是对插件可执行文件的一次独立调用,这种插件实现方式在各种操作需要相互通讯时会很别扭。比如你希望在执行Mount操作的时候,生成一些额外的状态信息,并在后面执行Unmount操作时去使用这些信息时,却只能把信息记录在某个约定好的临时文件中,这样的做法对于一个面向生产的容器编排系统来说,实在是过于简陋了。
|
||||
|
||||
|
||||
相比起FlexVolume的种种不足,CSI可算是一个十分完善的存储扩展规范。这里“十分完善”可不是客套话,根据GitHub的自动代码行统计,FlexVolume的规范文档仅有155行,而CSI则长达2704行。
|
||||
|
||||
那么从总体上看,CSI规范可以分为需要容器系统去实现的组件,以及需要存储提供商去实现的组件两大部分。前者包括了存储整体架构、Volume的生命周期模型、驱动注册、Volume创建、挂载、扩容、快照、度量等内容,这些Kubernetes都已经完整地实现了,大体上包括以下几个组件:
|
||||
|
||||
|
||||
Driver Register:负责注册第三方插件,CSI 0.3版本之后已经处于Deprecated状态,将会被Node Driver Register所取代。
|
||||
External Provisioner:调用第三方插件的接口来完成数据卷的创建与删除功能。
|
||||
External Attacher:调用第三方插件的接口来完成数据卷的挂载和操作。
|
||||
External Resizer:调用第三方插件的接口来完成数据卷的扩容操作。
|
||||
External Snapshotter:调用第三方插件的接口来完成快照的创建和删除。
|
||||
External Health Monitor:调用第三方插件的接口来提供度量监控数据。
|
||||
|
||||
|
||||
但是,需要存储提供商去实现的组件才是CSI的主体部分,也就是我在前面多次提到的“第三方插件”。这部分着重定义了外部存储挂载到容器过程中所涉及操作的抽象接口和具体的通讯方式,主要包括以下三个gRPC接口:
|
||||
|
||||
|
||||
CSI Identity接口:用于描述插件的基本信息,比如插件版本号、插件所支持的CSI规范版本、插件是否支持存储卷创建、删除功能、是否支持存储卷挂载功能等等。此外Identity接口还用于检查插件的健康状态,开发者可以通过Probe接口对外提供存储的健康度量信息。
|
||||
CSI Controller接口:用于从存储系统的角度对存储资源进行管理,比如准备和移除存储(Provision、Delete操作)、附加与分离存储(Attach、Detach操作)、对存储进行快照等等。存储插件并不一定要实现这个接口的所有方法,对于存储本身就不支持的功能,可以在CSI Identity接口中声明为不提供。
|
||||
CSI Node接口:用于从集群节点的角度对存储资源进行操作,比如存储卷的分区和格式化、将存储卷挂载到指定目录上,或者将存储卷从指定目录上卸载,等等。
|
||||
|
||||
|
||||
|
||||
|
||||
CSI组件架构
|
||||
|
||||
与FlexVolume以单独的可执行程序的存在形式不同,CSI插件本身是由一组标准的Kubernetes资源所构成,CSI Controller接口是一个以StatefulSet方式部署的gRPC服务,CSI Node接口则是基于DaemonSet方式部署的gRPC服务。
|
||||
|
||||
这意味着虽然CSI实现起来要比FlexVolume复杂得多,但是却很容易安装——如同安装CNI插件及其它应用那样,直接载入Manifest文件即可,也不会遇到FlexVolume那样需要人工运维,或者自己编写DaemonSet来维护集群节点变更的问题。
|
||||
|
||||
此外,通过gRPC协议传递参数比通过命令行参数传递参数更加严谨,灵活和可靠,最起码不会出现多个接口之间协作只能写临时文件这样的尴尬状况。
|
||||
|
||||
从In-Tree到Out-of-Tree
|
||||
|
||||
Kubernetes原本曾内置了相当多的In-Tree的存储驱动,甚至还早于Docker宣布支持卷驱动功能,这种策略使得Kubernetes能够在云存储提供商发布官方驱动之前就将其纳入到支持范围中,同时也减轻了管理员维护的工作量,为它在诞生初期快速占领市场做出了一定的贡献。
|
||||
|
||||
但是,这种策略也让Kubernetes丧失了随时添加或修改存储驱动的灵活性,只能在更新大版本时才能加入或者修改驱动,导致云存储提供商被迫要与Kubernetes的发版节奏保持一致。此外,这个策略还涉及到第三方存储代码混杂在Kubernetes二进制文件中可能引起的可靠性及安全性问题。
|
||||
|
||||
因此,当Kubernetes成为市场主流以后——准确的时间点是从1.14版本开始,Kubernetes启动了In-Tree存储驱动的CSI外置迁移工作,按照计划,在1.21到1.22版本(大约在2021年中期)时,Kubernetes中主要的存储驱动,如AWS EBS、GCE PD、vSphere等都会迁移至符合CSI规范的Out-of-Tree实现,不再提供In-Tree的支持。
|
||||
|
||||
这种做法在设计上无疑是正确的,但是,这又会导致Kubernetes面临此前提过的该如何兼容旧功能的策略问题,我举个例子,下面YAML定义了一个Pod:
|
||||
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: nginx-pod-example
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:latest
|
||||
volumeMounts:
|
||||
- name: html-pages-volume
|
||||
mountPath: /usr/share/nginx/html
|
||||
- name: config-volume
|
||||
mountPath: /etc/nginx
|
||||
volumes:
|
||||
- name: html-pages-volume
|
||||
hostPath: # 来自本地的存储
|
||||
path: /srv/nginx/html
|
||||
type: Directory
|
||||
- name: config-volume
|
||||
awsElasticBlockStore: # 来自AWS ESB的存储
|
||||
volumeID: vol-0b39e0b08745caef4
|
||||
fsType: ext4
|
||||
|
||||
|
||||
可以发现,其中用到了类型为hostPath的Volume,这相当于Docker中驱动类型为local的Volume,不需要专门的驱动;而类型为awsElasticBlockStore的Volume,从名字上就能看出是指存储驱动为AWS EBS的Volume,当CSI迁移完成,awsElasticBlockStore从In-Tree卷驱动中移除掉之后,它就应该按照CSI的写法改写成如下形式:
|
||||
|
||||
- name: config-volume
|
||||
csi:
|
||||
driver: ebs.csi.aws.com
|
||||
volumeAttributes:
|
||||
- volumeID: vol-0b39e0b08745caef4
|
||||
- fsType: ext4
|
||||
|
||||
|
||||
这样的要求有悖于“升级版本不应影响还在大范围使用的已有功能”这条原则,所以Kubernetes 1.17中又提出了称为CSIMigration的解决方案,让Out-of-Tree的驱动能够自动伪装成In-Tree的接口来提供服务。
|
||||
|
||||
这里我想说明的是,我之所以专门来给你介绍Volume的CSI迁移,倒不是由于它算是多么重要的特性,而是这种兼容性设计本身就是Kubernetes设计理念的一个缩影,在Kubernetes的代码与功能中随处可见。好的设计需要权衡多个方面的利益,很多时候都得顾及现实的影响,要求设计向现实妥协,而不能仅仅考虑理论最优的方案。
|
||||
|
||||
小结
|
||||
|
||||
这节课,我们学习了Kubernetes的存储扩展架构,知道了一个真实的存储系统是如何接入到新创建的Pod中,成为可以读写访问的Volume,以及当Pod被销毁时,Volume如何被回收,回归到存储系统之中的。
|
||||
|
||||
此外我们还要明确的是,目前的Kubernetes系统中存在两种存储扩展接口,分别是FlexVolume与CSI,我们要知道这两种插件的相似与差异之处,以及这两种接口的大致的结构。
|
||||
|
||||
一课一思
|
||||
|
||||
你使用过哪些类型的存储?你了解过块存储、文件存储、对象存储等不同的存储系统之间的差异吗?可以在留言区说说你的看法,下节课我们就会来学习这部分的知识。
|
||||
|
||||
如果你觉得有收获,欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
125
专栏/周志明的架构课/57_Kubernetes存储生态系统:几种有代表性的CSI存储插件的实现.md
Normal file
125
专栏/周志明的架构课/57_Kubernetes存储生态系统:几种有代表性的CSI存储插件的实现.md
Normal file
@@ -0,0 +1,125 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
57 _ Kubernetes存储生态系统:几种有代表性的CSI存储插件的实现
|
||||
你好,我是周志明。
|
||||
|
||||
随着Kubernetes的CSI规范成为容器业界统一的存储接入标准,现在几乎所有的云计算厂商都支持自家的容器通过CSI规范去接入外部存储,能够应用于CSI与FlexVolume的存储插件更是多达数十上百款,下图就展示了部分容器存储提供商,可以说,容器存储已经算是形成了初步的生态环境。
|
||||
|
||||
不过在咱们的课程里,我并不会去展开讨论各种CSI存储插件的细节,我会采取跟CNI网络插件类似的讲述方式,以不同的存储类型为线索,介绍其中有代表性的实现。
|
||||
|
||||
|
||||
|
||||
部分容器存储提供商
|
||||
|
||||
实际上,目前出现过的存储系统和设备,我们都可以划分到块存储、文件存储和对象存储这三种存储类型之中,其划分的根本依据并不是各种存储是如何储存数据的,因为那完全是存储系统私有的事情。
|
||||
|
||||
我认为更合理的划分依据是,各种存储会提供什么形式的接口来供外部访问数据,而不同的外部访问接口会如何反过来影响存储的内部结构、性能与功能表现。虽然块存储、文件存储和对象存储可以彼此协同工作,但它们各自都有自己明确的擅长领域与优缺点。所以,只有理解它们的工作原理,因地制宜地选择最适合的存储,才能让系统达到最佳的工作状态。
|
||||
|
||||
那么接下来,我就按照它们出现的时间顺序来给你一一介绍下。
|
||||
|
||||
块存储
|
||||
|
||||
块存储是数据存储最古老的形式,它把数据都储存在一个或多个固定长度的块(Block)中,想要读写访问数据,就必须使用与存储相匹配的协议(SCSI、SATA、SAS、FCP、FCoE、iSCSI……)。
|
||||
|
||||
这里你可以类比一下前面第52讲提到的,网络通讯中网络栈的数据流动过程,你可以把存储设备中由块构成的信息流,与网络设备中由数据包构成的信息流进行对比。事实上,像iSCSI这种协议真的就是建设在TCP/IP网络之上,让上层以SCSI作为应用层协议对外提供服务的。
|
||||
|
||||
我们熟悉的硬盘就是最经典的块存储设备,以机械硬盘为例,一个块就是一个扇区,大小通常在512 Bytes至4096 Bytes之间。老式机械硬盘用柱面-磁头-扇区号(Cylinder-Head-Sector,CHS)组成的编号进行寻址,现代机械硬盘只用一个逻辑块编号(Logical Block Addressing,LBA)进行寻址。
|
||||
|
||||
为了便于管理,硬盘通常会以多个块(这些块甚至可以来自不同的物理设备,比如磁盘阵列的情况)来组成一个逻辑分区(Partition),将分区进行高级格式化之后就形成了卷(Volume),这就与第55讲中提到“Volume是源于操作系统的概念”衔接了起来。
|
||||
|
||||
块存储由于贴近底层硬件,没有文件、目录、访问权限等的牵绊,所以性能通常都是最优秀的(吞吐量高,延迟低)。
|
||||
|
||||
另外,尽管人类作为信息系统的最终用户,并不会直接面对块来操作数据,多数应用程序也是基于文件而不是块来读写数据的,但是操作系统内核中,许多地方就是直接通过块设备(Block Device)接口来访问硬盘,一些追求I/O性能的软件,比如高性能的数据库也会支持直接读写块设备以提升磁盘I/O。
|
||||
|
||||
而且因为块存储的特点是具有排它性,一旦块设备被某个客户端挂载后,其他客户端就无法再访问上面的数据了。因此,Kubernetes中挂载的块存储,大多的访问模式都要求必须是RWO(ReadWriteOnce)的。
|
||||
|
||||
文件存储
|
||||
|
||||
好,下面我们接着来说说文件存储。
|
||||
|
||||
文件存储是最贴近人类用户的数据存储形式,数据存储在长度不固定的文件之中,用户可以针对文件进行新增、写入、追加、移动、复制、删除、重命名等各种操作,通常文件存储还会提供有文件查找、目录管理、权限控制等额外的高级功能。
|
||||
|
||||
文件存储的访问不像块存储那样有五花八门的协议,其POSIX接口(Portable Operating System Interface,POSIX)已经成为了事实标准,被各种商用的存储系统和操作系统共同支持。具体POSIX的文件操作接口我就不去举例罗列了,你可以类比Linux下的各种文件管理命令来自行想象一下。
|
||||
|
||||
绝大多数传统的文件存储都是基于块存储之上去实现的,“文件”这个概念的出现是因为“块”对人类用户来说实在是过于难以使用、难以管理了。我们可以近似地认为文件是由块所组成的更高级存储单位,对于固定不会发生变动的文件,直接让每个文件连续占用若干个块,在文件头尾加入标志区分即可,就比如像磁带、CD-ROM、DVD-ROM,就采用了由连续块来构成文件的存储方案。
|
||||
|
||||
不过,对于可能发生变动的场景,我们就必须考虑如何跨多个不连续的块来构成为文件。这种需求从数据结构的角度看,只需要在每个块中记录好下一个块的地址,形成链表结构就能满足。但是链表的缺点是只能依次顺序访问,这样访问文件中的任何内容都要从头读取多个块,这显然过于低效了。
|
||||
|
||||
事实上,真正被广泛运用的解决方案是把形成链表的指针整合起来统一存放,这就是文件分配表(File Allocation Table,FAT)。既然已经有了专门组织块结构来构成文件的分配表,那在表中再加入其他控制信息,就能很方便地扩展出更多的高级功能。
|
||||
|
||||
比如除了文件占用的块地址信息外,在表中再加上文件的逻辑位置就形成了目录,加上文件的访问标志就形成了权限,我们还可以再加上文件的名称、创建时间、所有者、修改者等一系列的元数据信息,来构成其他应用形式。
|
||||
|
||||
人们把定义文件分配表应该如何实现、储存哪些信息、提供什么功能的标准称为文件系统(File System),FAT32、NTFS、exFAT、ext2/3/4、XFS、BTRFS等都是很常用的文件系统。而前面介绍存储插件接口时,我提到的对分区进行高级格式化操作,实际上就是在初始化一套空白的文件系统,供后续用户与应用程序访问。
|
||||
|
||||
文件存储相对于块存储来说是更高层次的存储类型,加入目录、权限等元素后形成的树状结构以及路径访问的方式,方便了人们对它的理解、记忆和访问;文件系统能够提供进程正在打开或正在读写某个文件的信息,这也有利于文件的共享处理。
|
||||
|
||||
但在另一方面,计算机需要把路径进行分解,然后逐级向下查找,最后才能查找到需要的文件。而要从文件分配表中确定具体数据存储的位置,就要判断文件的访问权限,并要记录每次修改文件的用户与时间,这些额外操作对于性能产生的负面影响也是无可避免的。因此,如果一个系统选择不采用文件存储的话,那磁盘I/O性能一般就是最主要的原因。
|
||||
|
||||
对象存储
|
||||
|
||||
对象存储是相对较新的数据存储形式,它是一种随着云数据中心的兴起而发展起来的存储,是以非结构化数据为目标的存储方案。
|
||||
|
||||
这里的“对象”可以理解为一个元数据及与其配对的一个逻辑数据块的组合,元数据提供了对象所包含的上下文信息,比如数据的类型、大小、权限、创建人、创建时间,等等,数据块则存储了对象的具体内容。你也可以简单地理解为数据和元数据这两样东西共同构成了一个对象。
|
||||
|
||||
每个对象都有属于自己的全局唯一标识,这个标识会直接开放给最终用户使用,作为访问该对象的主要凭据,通常会是以UUID的形式呈现。对象存储的访问接口就是根据该唯一标识,对逻辑数据块进行的读写删除操作的,通常接口都会十分简单,甚至连修改操作权限都不会提供。
|
||||
|
||||
对象存储基本上只会在分布式存储系统之上去实现,由于对象存储天生就有明确的“元数据”概念,不必依靠文件系统来提供数据的描述信息,因此,完全可以将一大批对象的元数据集中存放在某一台(组)服务器上,再辅以多台OSD(Object Storage Device)服务器来存储对象的数据块部分。
|
||||
|
||||
当外部要访问对象时,多台OSD能够同时对外发送数据,因此对象存储不仅易于共享、拥有庞大的容量,还能提供非常高的吞吐量。不过,由于需要先经过元数据查询确定OSD存放对象的确切位置,这个过程可能涉及多次网络传输,所以在延迟方面就会表现得相对较差。
|
||||
|
||||
由于对象的元数据仅描述对象本身的信息,与其他对象都没有关联,换而言之每个对象都是相互独立的,自然也就不存在目录的概念,可见对象存储天然就是扁平化的,与软件系统中很常见的K/V访问相类似。
|
||||
|
||||
不过许多对象存储会提供Bucket的概念,用户可以在逻辑上把它看作是“单层的目录”来使用。由于对象存储天生的分布式特性,以及极其低廉的扩展成本,使它很适合于CDN一类的应用,拿来存放图片、音视频等媒体内容,以及网页、脚本等静态资源。
|
||||
|
||||
选择合适的存储
|
||||
|
||||
那么,在理解了三种存储类型的基本原理后,接下来又到了治疗选择困难症的环节。主流的云计算厂商,比如国内的阿里云、腾讯云、华为云,都有自己专门的块存储、文件存储和对象存储服务,关于选择服务提供商的问题,我就不作建议了,你可以根据价格、合作关系、技术和品牌知名度等因素自行去处理。
|
||||
|
||||
而关于应该选择三种存储类型中哪一种的问题,这里我就以世界云计算市场占有率第一的亚马逊为例,给你简要对比介绍下它的不同存储类型产品的差异。
|
||||
|
||||
亚马逊的块存储服务是Amazon Elastic Block Store(AWS EBS),你购买EBS之后,在EC2(亚马逊的云计算主机)里看见的是一块原始的、未格式化的块设备。这点就决定了EBS并不能做为一个独立存储而存在,它总是和EC2同时被创建的,EC2的操作系统也只能安装在EBS之上。
|
||||
|
||||
EBS的大小理论上取决于建立的分区方案,也就是块大小乘以块数量。MBR分区的块数量是232,块大小通常是512 Bytes,总容量为2 TB;GPT分区的块数量是264,块大小通常是4096 Bytes,总容量64 ZB。当然这是理论值,64 ZB已经超过了世界上所有信息的总和,不会有操作系统支持这种离谱的容量,AWS也设置了上限是16 TB,在此范围内的实际值就只取决于你的预算额度;EBS的性能取决于你选择的存储介质类型(SSD、HDD),还有优化类型(通用性、预置型、吞吐量优化、冷存储优化等),这也会直接影响存储的费用成本。
|
||||
|
||||
EBS适合作为系统引导卷,适合追求磁盘I/O的大型工作负载以及追求低时延的应用,比如Oracle等可以直接访问块设备的大型数据库。但EBS只允许被单个节点挂载,难以共享,这点在单机时代虽然是天经地义的,但在云计算和分布式时代就成为了很要命的缺陷。除了少数特殊的工作负载外(如前面说的Oracle数据库),我并不建议将它作为容器编排系统的主要外置存储来使用。
|
||||
|
||||
亚马逊的文件存储服务是Amazon Elastic File System(AWS EFS),你购买EFS之后,只要在EFS控制台上创建好文件系统,并且管理好网络信息(如IP地址、子网)就可以直接使用,无需依附于任何EC2云主机。
|
||||
|
||||
EFS的本质是完全托管在云端的网络文件系统(Network File System,NFS),你可以在任何兼容POSIX的操作系统中直接挂载它,而不会在/dev中看到新设备的存在。按照前面开头我提到的Kubernetes存储架构中的操作来说,就是你只需要考虑Mount,无需考虑Attach了。
|
||||
|
||||
这样,得益于NFS的天然特性,EFS的扩缩可以是完全自动、实时的,创建新文件时无需预置存储,删除已有文件时也不必手动缩容以节省费用。在高性能网络的支持下,EFS的性能已经能够达到相当高的水平,尽管由于网络访问的限制,性能最高的EFS依然比不过最高水平的EBS,但仍然能充分满足绝大多数应用运行的需要。
|
||||
|
||||
还有最重要的一点优势是由于脱离了块设备的束缚,EFS能够轻易地被成百上千个EC2实例共享。考虑到EFS的性能、动态弹性、可共享这些因素,我给出的明确建议是它可以作为大部分容器工作负载的首选存储。
|
||||
|
||||
亚马逊的对象存储服务是Amazon Simple Storage Service(AWS S3),S3通常是以REST Endpoint的形式对外部提供文件访问服务的,这种方式下你应该直接使用程序代码来访问S3,而不是靠操作系统或者容器编排系统去挂载它。
|
||||
|
||||
如果你真的希望这样做,也可以通过存储网关(如AWS Storage Gateway)将S3的存储能力转换为NFS、SMB、iSCSI等访问协议。经过转换后,操作系统或者容器就能够将其作为Volume来挂载了。
|
||||
|
||||
S3也许是AWS最出名、使用面最广的存储服务,这个结果并不是由于它的性能优异,事实上S3的性能比起EBS和EFS来说是相对最差的,但它的优势在于它名字中“Simple”所标榜的简单。
|
||||
|
||||
我们挂载外部存储的目的,十有八九就是为了给程序提供存储服务,而使用S3就不必写一行代码,就能直接通过HTTP Endpoint进行读写访问,而且完全不需要考虑容量、维护和数据丢失的风险,这就是简单的价值。
|
||||
|
||||
除此之外,S3的另一大优势就是它的价格相对于EBS和EFS来说,往往要低一至两个数量级,因此程序的备份还原、数据归档、灾难恢复、静态页面的托管、多媒体分发等功能,就非常适合使用S3来完成。
|
||||
|
||||
小结
|
||||
|
||||
这节课我们了解学习了块存储、文件存储和对象存储这三种存储类型的基本原理,而关于应该选择这三种存储类型中哪一种的问题,我以亚马逊为例,给你简要对比了下它的不同存储类型产品的差异。
|
||||
|
||||
最后我还想补充一点,你可以来看看下面的图例,这是截取自亚马逊销售材料中三种存储的对比。说实话,从目前的存储技术发展来看,其实不会有哪一种存储方案能够包打天下。你要知道,不同业务系统的场景需求不同,对存储的诉求就会不同,那么选择自然也会不同。
|
||||
|
||||
|
||||
|
||||
(图片来自AWS的销售材料)
|
||||
|
||||
一课一思
|
||||
|
||||
计算机进入云计算时代已经有十年了,你是否在生产系统中使用过云存储?如果有,你用过哪些?如果没有,你认为障碍是什么呢?
|
||||
|
||||
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给其他的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
292
专栏/周志明的架构课/58_Kubernetes的资源模型与调度器设计.md
Normal file
292
专栏/周志明的架构课/58_Kubernetes的资源模型与调度器设计.md
Normal file
@@ -0,0 +1,292 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
58 _ Kubernetes的资源模型与调度器设计
|
||||
你好,我是周志明。
|
||||
|
||||
调度是容器编排系统最核心的功能之一,“编排”这个词本来也包含了“调度”的含义。调度是指为新创建出来的Pod,寻找到一个最恰当的宿主机节点来运行它,而这个过程成功与否、结果恰当与否,关键就取决于容器编排系统是怎么管理和分配集群节点的资源的。
|
||||
|
||||
那么这样一来,我们就可以认为,调度必须要以容器编排系统的资源管控为前提。
|
||||
|
||||
因此这节课,我们就从Kubernetes的资源模型谈起,来学习下Kubernetes是如何为一个新创建出来的Pod,寻找到一个最恰当的宿主机节点来运行的。
|
||||
|
||||
资源模型
|
||||
|
||||
在开始之前,我们先来理清一个概念:资源是什么。
|
||||
|
||||
在Kubernetes中,资源是非常常用的术语,从广义上来讲,Kubernetes系统中所有你能接触的方方面面,都被抽象成了资源,比如表示工作负荷的资源(Pod、ReplicaSet、Service、……),表示存储的资源(Volume、PersistentVolume、Secret、……),表示策略的资源(SecurityContext、ResourceQuota、LimitRange、……),表示身份的资源(ServiceAccount、Role、ClusterRole、……),等等。
|
||||
|
||||
事实上,“一切皆为资源”的设计也是Kubernetes能够顺利施行声明式API的必要前提。Kubernetes以资源为载体,建立了一套同时囊括了抽象元素(如策略、依赖、权限)和物理元素(如软件、硬件、网络)的领域特定语言。它通过不同层级间资源的使用关系,来描述上至整个集群甚至是集群联邦,下至某一块内存区域或者一小部分的处理器核心的状态,这些对资源状态的描述的集合,就共同构成了一幅信息系统工作运行的全景图。
|
||||
|
||||
在第48讲“以容器构建系统”里,我第一次提到了Kubernetes的资源模型,把它跟控制器模式一并列为了Kubernetes中最重要的两个设计思想。当然在这节课中,我们还会再次讨论资源模型,但是这里所说的主要是狭义上的物理资源,即特指排除了广义的那些逻辑上的抽象资源,只包括能够与真实物理底层硬件对应起来的资源,比如处理器资源、内存资源、磁盘存储资源,等等。
|
||||
|
||||
另外需要说明的是,因为咱们今天讨论的话题是调度,而作为调度最基本单位的Pod,只会与这些和物理硬件直接相关的资源产生供需关系,所以后面我提到的资源,如果没有额外说明的话,就都是特指狭义上的物理资源。
|
||||
|
||||
OK,现在我们说回到Kubernetes的资源模型上来。
|
||||
|
||||
首先,从编排系统的角度来看,Node是资源的提供者,Pod是资源的使用者,而调度是将两者进行恰当的撮合。
|
||||
|
||||
那么Kubernetes具体是如何撮合它们俩的呢?别着急,我们先从Node开始来了解。
|
||||
|
||||
Node通常能够提供三方面的资源:计算资源(如处理器、图形处理器、内存)、存储资源(如磁盘容量、不同类型的介质)和网络资源(如带宽、网络地址)。其中与调度关系最密切的是处理器和内存,虽然它们都属于计算资源,但两者在调度时又有一些微妙的差别:
|
||||
|
||||
|
||||
处理器这样的资源,被叫做是可压缩资源(Compressible Resources),特点是当可压缩资源不足时,Pod只会处于“饥饿状态”,运行变慢,但不会被系统杀死,也就是容器会被直接终止,或者是被要求限时退出。
|
||||
而像内存这样的资源,则被叫做是不可压缩资源(Incompressible Resources),特点是当不可压缩资源不足,或者超过了容器自己声明的最大限度时,Pod就会因为内存溢出(Out-Of-Memory,OOM)而被系统直接杀掉。
|
||||
|
||||
|
||||
Kubernetes给处理器资源设定的默认计量单位是“逻辑处理器的个数”。至于具体“一个逻辑处理器”应该如何理解,就要取决于节点的宿主机是如何解释的,它通常会是我们在/proc/cpuinfo中看到的处理器数量。比如,它有可能会是多路处理器系统上的一个处理器、多核处理器中的一个核心、云计算主机上的一个虚拟化处理器(Virtual CPU,vCPU),或者是处理器核心里的一条超线程(Hyper-Threading)。
|
||||
|
||||
总之,Kubernetes只负责保证Pod能够使用到“一个处理器”的计算能力,而对不同硬件环境构成的Kubernetes集群,乃至同一个集群中不同硬件的宿主机节点来说,“一个处理器”所代表的真实算力完全有可能是不一样的。
|
||||
|
||||
另外在具体设置方面,Kubernetes沿用了云计算中处理器限额设置的一贯做法。如果不明确标注单位,比如直接写0.5,默认单位就是Core,即0.5个处理器;当然也可以明确使用Millcores为单位,比如写成500 m,同样也代表0.5个处理器,因为Kubernetes规定了1 Core = 1000 Millcores。
|
||||
|
||||
而对于内存来说,它早已经有了广泛使用的计量单位,即Bytes,如果设置中不明确标注单位,就会默认以Bytes计数。
|
||||
|
||||
为了实际设置的方便,Kubernetes还支持以Ei、Pi、Ti、Gi、Mi、Ki,以及E、P、T、G、M、K为单位,这两者略微有一点儿差别。这里我就以Mi和M为例,它们分别是Mebibytes与Megabytes的缩写,前者表示1024×1024 Bytes,后者表示1000×1000 Bytes。
|
||||
|
||||
服务质量与优先级
|
||||
|
||||
那么到这里,我们要知道设定资源计量单位的目的,是为了管理员能够限制某个Pod对资源的过度占用,避免影响到其他Pod的正常运行。
|
||||
|
||||
Pod是由一个到多个容器组成的,资源最终是交由Pod的各个容器去使用,所以资源的需求是设定在容器上的,具体的配置是Pod的spec.containers[].resource.limits/requests.cpu/memory字段。但是,对资源需求的配额则不是针对容器,而是针对Pod整体,Pod的资源配额不需要手动设置,因为Pod的资源配额就是Pod包含的每个容器资源需求的累加值。
|
||||
|
||||
实际上,为容器设定最大的资源配额的做法,从cgroups诞生后就已经屡见不鲜了,但不知你有没有注意到,Kubernetes给出的配置中有limits和requests两个设置项?
|
||||
|
||||
这两者的区别其实很简单:request是给调度器用的,Kubernetes选择哪个节点运行Pod,只会根据requests的值来进行决策;而limits才是给cgroups用的,Kubernetes在向cgroups的传递资源配额时,会按照limits的值来进行设置。
|
||||
|
||||
Kubernetes会采用这样的设计,完全是基于“心理学”的原因,因为Google根据Borg和Omega系统长期运行的实践经验,总结出了一条经验法则:用户提交工作负载时设置的资源配额,并不是容器调度一定必须严格遵守的值,因为根据实际经验,大多数的工作负载运行过程中,真正使用到的资源,其实都远小于它所请求的资源配额。
|
||||
|
||||
|
||||
额外知识:Purchase Quota-
|
||||
Even though we encourage users to purchase no more quota than they need, many users overbuy because it insulates them against future shortages when their application’s user base grows.-
|
||||
即使我们已经努力建议用户不要过度申请资源配额,但仍难免有大量用户过度消费,他们总希望避免因用户增长而产生资源不足的现象。-
|
||||
—— Large-Scale Cluster Management at Google with Borg,Google
|
||||
|
||||
|
||||
当然,“多多益善”的想法完全符合人类的心理,大家提交的资源需求通常都是按照可能面临的最大压力去估计的,甚至考虑到了未来用户增长所导致的新需求。为了避免服务因资源不足而中断,都会往大了去申请,这点我们可以理解。
|
||||
|
||||
但是,如果直接按照申请的资源去分配限额,必然会导致服务器出现两方面的影响:一方面,在大多数时间里服务器都会有大量的硬件资源闲置;而另一方面,这些闲置资源又已经分配出去,有了明确的所有者,不能再被其他人利用,难以真正发挥价值。
|
||||
|
||||
不过我们也能想到,Kubernetes不太可能因为把一个资源配额的设置,拆分成了limits和requests两个设置项 ,就能完全解决这个矛盾。所以为此,Kubernetes还进行了许多额外的处理。
|
||||
|
||||
比如现在我们知道,一旦选择不按照最保守、最安全的方式去分配资源,就意味着容器编排系统必须要为有可能出现的极端情况买单。而如果允许节点给Pod分配的资源总和,超过了Kubernetes自己最大的可提供资源的话,假如某个时刻,这些Pod的总消耗真的超标了,就会不可避免地导致节点无法继续遵守调度时对Pod许下的资源承诺。
|
||||
|
||||
那么此时,Kubernetes就迫不得已要杀掉一部分Pod,以腾出资源来保证其余Pod能正常运行,这个操作就是我后面要给你介绍的驱逐机制(Eviction)。
|
||||
|
||||
而要想进行驱逐,首先Kubernetes就必须拿出当资源不足时,该先牺牲哪些Pod、该保留哪些Pod的明确准则,所以由此就形成了Kubernetes的服务质量等级(Quality of Service Level,QoS Level)和优先级(Priority)的概念。
|
||||
|
||||
我们先来了解下Kubernetes的服务质量等级的概念。
|
||||
|
||||
服务质量等级
|
||||
|
||||
质量等级是Pod的一个隐含属性,也是Kubernetes优先保障重要的服务,放弃一些没那么重要的服务的衡量准绳。
|
||||
|
||||
那到这里,不知道你有没有想到这样一个细节:如果不去设置limits和requests会怎样?
|
||||
|
||||
答案是不设置处理器和内存的资源,就意味着没有上限,该Pod可以使用节点上所有可用的计算资源。不过你先别高兴得太早,这类Pod能以最灵活的方式去使用资源,但也正是这类Pod在扮演着最不稳定的风险来源的角色。
|
||||
|
||||
在论文《Large-Scale Cluster Management at Google with Borg》中,Google明确地提出了针对这类Pod的一种近乎带着惩罚性质的处理建议:当节点硬件资源不足时,优先杀掉这类Pod。说得文雅一点的话,就是给予这类Pod最低的服务质量等级。
|
||||
|
||||
Kubernetes目前提供的服务质量等级一共分为三级,由高到低分别为Guaranteed、Burstable和BestEffort:
|
||||
|
||||
|
||||
如果Pod中所有的容器都设置了limits和requests,且两者的值相等,那此Pod的服务质量等级就是最高的Guaranteed;
|
||||
如果Pod中有部分容器的requests值小于limits值,或者只设置了requests而未设置limits,那此Pod的服务质量等级就是第二级Burstable;
|
||||
如果是前面说的那种情况,limits和requests两个都没设置,那就是最低的BestEffort了。
|
||||
|
||||
|
||||
一般来说,我们会建议把数据库应用等有状态的应用,或者是一些重要的、要保证不能中断的业务的服务质量等级定为Guaranteed。这样,除非是Pod使用超过了它们的limits所描述的不可压缩资源,或者节点的内存压力大到Kubernetes已经杀光所有等级更低的Pod了,否则它们都不会被系统自动杀死。
|
||||
|
||||
而相对地,我们也应该把一些临时的、不那么重要的任务设置为BestEffort,这样有利于它们调度时能在更大的节点范围中寻找宿主机,也利于它们在宿主机中利用更多的资源,快速地完成任务,然后退出,尽量缩减影响范围;当然,遇到系统资源紧张时,它们也更容易被系统杀掉。
|
||||
|
||||
|
||||
小说《动物庄园》:-
|
||||
All animals are equal, but some animals are more equal than others.-
|
||||
所有动物生来平等,但有些动物比其他动物更加平等。-
|
||||
—— Animal Farm: A Fairy Story,George Orwell, 1945
|
||||
|
||||
|
||||
优先级
|
||||
|
||||
除了服务质量等级以外,Kubernetes还允许系统管理员自行决定Pod的优先级,这是通过类型为PriorityClass的资源来实现的。优先级决定了Pod之间并不是平等的关系,而且这种不平等还不是谁会多占用一点儿的资源的问题,而是会直接影响Pod调度与生存的关键。
|
||||
|
||||
优先级会影响调度,这很容易理解,这就是说当多个Pod同时被调度的话,高优先级的Pod会优先被调度。而Pod越晚被调度,就越大概率地会因节点资源已被占用而不能成功。
|
||||
|
||||
但优先级影响更大的一方面,是指Kubernetes的抢占机制(Preemption),正常在没有设置优先级的情况下,如果Pod调度失败,就会暂时处于Pending状态被搁置起来,直到集群中有新节点加入或者旧Pod退出。
|
||||
|
||||
但是,如果有一个被设置了明确优先级的Pod调度失败,无法创建的话,Kubernetes就会在系统中寻找出一批牺牲者(Victims),把它们杀掉以便给更高优先级的Pod让出资源。
|
||||
|
||||
而这个寻找的原则,就是在优先级低于待调度Pod的所有已调度的Pod里,按照优先级从低到高排序,从最低的杀起,直至腾出的资源可以满足待调度Pod的成功调度为止,或者已经找不到更低优先级的Pod为止。
|
||||
|
||||
驱逐机制
|
||||
|
||||
说实话,前面我动不动就提要杀掉某个Pod,听起来实在是不够优雅,其实在Kubernetes中更专业的称呼是“驱逐”(Eviction,即资源回收),这也是我在前面提过要给你介绍的概念。
|
||||
|
||||
Pod的驱逐机制是通过kubelet来执行的,kubelet是部署在每个节点的集群管理程序,因为它本身就运行在节点中,所以最容易感知到节点的资源实时耗用情况。kubelet一旦发现某种不可压缩资源将要耗尽,就会主动终止节点上服务质量等级比较低的Pod,以保证其他更重要的Pod的安全。而被驱逐的Pod中,所有的容器都会被终止,Pod的状态会被更改为Failed。
|
||||
|
||||
现在,我们已经了解了内存这种最重要的不可压缩资源,那么在默认配置下,前面我所说的“资源即将耗尽”的“即将”,其具体阈值是可用内存小于100 Mi。
|
||||
|
||||
而除了可用内存(memory.available)外,其他不可压缩资源还包括有:宿主机的可用磁盘空间(nodefs.available)、文件系统可用inode数量(nodefs.inodesFree),以及可用的容器运行时镜像存储空间(imagefs.available)。后面三个的阈值,都是按照实际容量的百分比来计算的,具体的默认值如下:
|
||||
|
||||
memory.available < 100Mi
|
||||
nodefs.available < 10%
|
||||
nodefs.inodesFree < 5%
|
||||
imagefs.available < 15%
|
||||
|
||||
|
||||
管理员可以在kubelet启动时,通过命令行参数来修改这些默认值,比如说,如果是在可用内存只剩余100 Mi时才启动驱逐,那对大多数生产系统来说都过于危险了,所以我建议在生产环境中,可以考虑当内存剩余10%时就开始驱逐,具体的调整命令如下所示:
|
||||
|
||||
$ kubelet --eviction-hard=memory.available<10%
|
||||
|
||||
|
||||
如果你是一名Java、C#、Golang等习惯了自动内存管理机制的程序员,我还要提醒你一下,Kubernetes的驱逐不能完全等同于编程语言中的垃圾收集器。
|
||||
|
||||
这里主要体现在两个方面。
|
||||
|
||||
一方面,我们要知道垃圾收集是安全的内存回收行为,而驱逐Pod是一种毁坏性的清理行为,它有可能会导致服务产生中断,因而必须更加谨慎。比如说,要同时兼顾到硬件资源可能只是短时间内,间歇性地超过了阈值的场景,以及资源正在被快速消耗,很快就会危及高服务质量的Pod、甚至是整个节点稳定的场景。
|
||||
|
||||
如此一来,驱逐机制中就有了软驱逐(Soft Eviction)、硬驱逐(Hard Eviction)以及优雅退出期(Grace Period)的概念:
|
||||
|
||||
|
||||
软驱逐:通常会配置一个比较低的警戒线(比如可用内存仅剩20%),当触及此线时,系统就会进入一段观察期。如果只是暂时的资源抖动,在观察期内能够恢复到正常水平的话,那就不会真正启动驱逐操作。否则,资源持续超过警戒线一段时间,就会触发Pod的优雅退出(Grace Shutdown),系统会通知Pod进行必要的清理工作(比如将缓存的数据落盘),然后自行结束。在优雅退出期结束后,系统会强制杀掉还没有自行了断的Pod。
|
||||
硬驱逐:通常会配置一个比较高的终止线(比如可用内存仅剩10%),一旦触及此线,系统就会立即强制杀掉Pod,不理会优雅退出。
|
||||
|
||||
|
||||
软驱逐是为了减少资源抖动对服务的影响,硬驱逐是为了保障核心系统的稳定,它们并不矛盾,一般会同时使用,如以下例子中所示:
|
||||
|
||||
$ kubelet --eviction-hard=memory.available<10% \
|
||||
--eviction-soft=memory.available<20% \
|
||||
--eviction-soft-grace-period=memory.available=1m30s \
|
||||
--eviction-max-pod-grace-period=600
|
||||
|
||||
|
||||
另一方面,Kubernetes的驱逐跟垃圾收集器的不同之处,还在于垃圾收集可以“应收尽收”,而驱逐显然不行,系统不能无缘无故地把整个节点中所有可驱逐的Pod都清空掉。但是,系统通常也不能只清理到刚刚低于警戒线就停止,必须要考虑到驱逐之后的新Pod调度与旧Pod运行的新增消耗。
|
||||
|
||||
比如,kubelet驱逐了若干个Pod,让资源使用率勉强低于阈值,那么很可能在极短的时间内,资源使用率又会因为某个Pod稍微占用了些许资源,而重新超过阈值,再产生新一次驱逐,如此往复。
|
||||
|
||||
为此,Kubernetes提供了--eviction-minimum-reclaim参数,用于设置一旦驱逐发生之后,至少要清理出来多少资源才会终止。
|
||||
|
||||
不过,问题到这里还是没有全部解决。要知道,Kubernetes中很少会单独创建Pod,通常都是由ReplicaSet、Deployment等更高层资源来管理的。而这就意味着,当Pod被驱逐之后,它不会从此彻底消失,Kubernetes会自动生成一个新的Pod来取代,并经过调度,选择一个节点继续运行。
|
||||
|
||||
这样也就是说,如果没有进行额外的处理,那很大概率这个新生成的Pod,就会被调度到当前这个节点上重新创建,因为上一次调度就选择了这个节点,而且这个节点刚刚驱逐完一批Pod,得到了空闲资源,那它显然应该符合此Pod的调度需求。
|
||||
|
||||
所以,为了避免被驱逐的Pod出现“阴魂不散”的问题,Kubernetes还提供了另一个参数--eviction-pressure-transition-period来约束调度器,在驱逐发生之后多长时间内,不能往该节点调度Pod。
|
||||
|
||||
另外,关于驱逐机制,你还应该意识到,既然这些措施被设计为以参数的形式开启,那就说明了它们一定不是放之四海皆准的通用准则。
|
||||
|
||||
举个例子,假设当前Pod是由DaemonSet控制的,一旦该Pod被驱逐,你又强行不允许节点在一段时间内接受调度,那显然就有违DaemonSet的语义了。
|
||||
|
||||
不过到目前,Kubernetes其实并没有办法区分Pod是由DaemonSet,还是别的高层次资源创建的,所以刚刚的这种假设情况确实有可能发生,而比较合理的解决方案,是让DaemonSet创建Guaranteed而不是BestEffort的Pod。
|
||||
|
||||
总而言之,在Kubernetes还没有成熟到变为“傻瓜式”容器编排系统之前,因地制宜地合理配置和运维是都非常必要的。
|
||||
|
||||
最后我还想说明的是,关于服务质量、优先级、驱逐机制这些概念,都是在Pod层面上限制资源,是仅针对单个Pod的低层次约束。而在现实中,我们还经常会遇到面向更高层次去控制资源的需求,比如,想限制由多个Pod构成的微服务系统耗用的总资源,或者是由多名成员组成的团队耗用的总资源。
|
||||
|
||||
我举个具体例子,现在你想要在拥有32 GiB内存和16个处理器的集群里,允许A团队使用20 GiB内存和10个处理器的资源,再允许B团队使用10 GiB内存和4个处理器的资源,再预留2 GiB内存和2个处理器供将来分配。那么要满足这种资源限制的需求,Kubernetes的解决方案是应该先为它们建立一个专用的名称空间,然后再在名称空间里建立ResourceQuota对象,来描述如何进行整体的资源约束。
|
||||
|
||||
但是这样,ResourceQuota与调度就没有直接关系了,它针对的对象也不是Pod,所以这里我所说的资源,可以是广义上的资源,系统不仅能够设置处理器、内存等物理资源的限额,还可以设置诸如Pod最大数量、ReplicaSet最大数量、Service最大数量、全部PersistentVolumeClaim的总存储容量等各种抽象资源的限额。
|
||||
|
||||
甚至,当Kubernetes预置的资源模型不能满足约束需要的时候,还能够根据实际情况去拓展,比如要控制GPU的使用数量,完全可以通过Kubernetes的设备插件(Device Plugin)机制,拓展出诸如nvidia.com/gpu: 4这样的配置来。
|
||||
|
||||
默认调度器
|
||||
|
||||
好,了解了Kubernetes的资源模型和服务质量、优先级、驱逐机制这些概念以后,我们再回过头来,探讨下前面开头我提出的问题:Kubernetes是如何撮合Pod与Node的?这其实也是最困难的一个问题。
|
||||
|
||||
现在我们知道,调度是为新创建出来的Pod,寻找到一个最恰当的宿主机节点去运行它。而在这句话里,就包含有“运行”和“恰当”两个调度中的关键过程,它们具体是指:
|
||||
|
||||
|
||||
运行:从集群的所有节点中,找出一批剩余资源可以满足该Pod运行的节点。为此,Kubernetes调度器设计了一组名为Predicate的筛选算法。
|
||||
恰当:从符合运行要求的节点中,找出一个最适合的节点完成调度。为此,Kubernetes调度器设计了一组名为Priority的评价算法。
|
||||
|
||||
|
||||
这两个算法的具体内容稍后我会详细给你解释,这里我要先说明白一点:在几个、十几个节点的集群里进行调度,调度器怎么实现都不会太困难,但是对于数千个、乃至更多节点的大规模集群,要实现高效的调度就绝不简单。
|
||||
|
||||
请你想象一下,现在有一个由数千节点组成的集群,每次Pod的创建,都必须依据各节点的实时资源状态来确定调度的目标节点,然而我们知道,各节点的资源是随着程序运行无时无刻都在变动的,资源状况只有它本身才清楚。
|
||||
|
||||
这样,如果每次调度都要发生数千次的远程访问来获取这些信息的话,那压力与耗时都很难降下来。所以结果不仅会让调度器成为集群管理的性能瓶颈,还会出现因耗时过长,某些节点上资源状况已发生变化,调度器的资源信息过时,而导致调度结果不准确等问题。
|
||||
|
||||
|
||||
额外知识:Scheduler-
|
||||
Clusters and their workloads keep growing, and since the scheduler’s workload is roughly proportional to the cluster size, the scheduler is at risk of becoming a scalability bottleneck.-
|
||||
由于调度器的工作负载与集群规模大致成正比,随着集群和它们的工作负载不断增长,调度器很有可能会成为扩展性瓶颈所在。-
|
||||
—— Omega: Flexible, Scalable Schedulers for Large Compute Clusters,Google
|
||||
|
||||
|
||||
因此,针对前面所说的问题,Google在论文《Omega: Flexible, Scalable Schedulers for Large Compute Clusters》里总结了自身的经验,并参考了当时Apache Mesos和Hadoop on Demand(HOD)的实现,提出了一种共享状态(Shared State)的双循环调度机制。
|
||||
|
||||
这种调度机制后来不仅应用在Google的Omega系统(Borg的下一代集群管理系统)中,也同样被Kubernetes继承了下来,它整体的工作流程如下图所示:
|
||||
|
||||
|
||||
|
||||
状态共享的双循环
|
||||
|
||||
“状态共享的双循环”中,第一个控制循环可被称为“Informer Loop”,它是一系列Informer的集合,这些Informer会持续监视etcd中与调度相关资源(主要是Pod和Node)的变化情况,一旦Pod、Node等资源出现变动,就会触发对应Informer的Handler。
|
||||
|
||||
Informer Loop的职责是根据etcd中的资源变化,去更新调度队列(Priority Queue)和调度缓存(Scheduler Cache)中的信息。
|
||||
|
||||
比如当有新Pod生成,就将其入队(Enqueue)到调度队列中,如有必要,还会根据优先级触发上节课我提到的插队和抢占操作。再比如,当有新的节点加入集群,或者已有的节点资源信息发生变动,Informer也会把这些信息更新同步到调度缓存之中。
|
||||
|
||||
另一个控制循环可被称为“Scheduler Loop”,它的核心逻辑是不停地把调度队列中的Pod出队(Pop),然后使用Predicate算法进行节点选择。
|
||||
|
||||
Predicate本质上是一组节点过滤器(Filter),它会根据预设的过滤策略来筛选节点。Kubernetes中默认有三种过滤策略,分别是:
|
||||
|
||||
|
||||
通用过滤策略:最基础的调度过滤策略,用来检查节点是否能满足Pod声明中需要的资源。比如处理器、内存资源是否满足,主机端口与声明的NodePort是否存在冲突,Pod的选择器或者nodeAffinity指定的节点是否与目标相匹配,等等。
|
||||
卷过滤策略:与存储相关的过滤策略,用来检查节点挂载的Volume是否存在冲突(比如将一个块设备挂载到两个节点上),或者Volume的可用区域是否与目标节点冲突,等等。在“Kubernetes存储设计”中提到的Local PersistentVolume的调度检查,就是在这里处理的。
|
||||
节点过滤策略:与宿主机相关的过滤策略,最典型的是Kubernetes的污点与容忍度机制(Taints and Tolerations),比如默认情况下,Kubernetes会设置Master节点不允许被调度,这就是通过在Master中施加污点来避免的。前面我提到的控制节点处于驱逐状态,或者在驱逐后一段时间不允许调度,也是在这个策略里实现的。
|
||||
|
||||
|
||||
此外,Predicate算法所使用的一切数据,都来自于调度缓存,它绝对不会去远程访问节点本身。这里你要知道,只有Informer Loop与etcd的监视操作才会涉及到远程调用,而Scheduler Loop中,除了最后的异步绑定要发起一次远程的etcd写入外,其余全部都是进程内访问,这一点正是调度器执行效率的重要保证。
|
||||
|
||||
所谓的调度缓存,就是两个控制循环的共享状态(Shared State),这样的设计避免了调度器每次调度时主动去轮询所有集群节点,保证了调度器的执行效率。
|
||||
|
||||
但是它也存在一定的局限,也就是调度缓存并不能完全避免因节点信息同步不及时,而导致调度过程中实际资源发生变化的情况,比如节点的某个端口在获取调度信息后、发生实际调度前被意外占用了。
|
||||
|
||||
为此,当调度结果出来以后,在kubelet真正创建Pod以前,还必须执行一次Admit操作,在该节点上重新做一遍Predicate,来进行二次确认。经过Predicate算法筛选出来符合要求的节点集,会交给Priorities算法来打分(0~10分)排序,以便挑选出“最恰当”的一个。
|
||||
|
||||
这里的“恰当”其实是带有主观色彩的词语,Kubernetes也提供了不同的打分规则来满足不同的主观需求,比如最常用的LeastRequestedPriority规则,它的计算公式是:
|
||||
|
||||
score = (cpu((capacity-sum(requested))×10/capacity) + memory((capacity-sum(requested))×10/capacity))/2
|
||||
|
||||
|
||||
从公式上,我们能很容易地看出,这就是在选择处理器和内存空闲资源最多的节点,因为这些资源剩余越多,得分就越高。经常与它一起工作的是BalancedResourceAllocation规则,它的公式是:
|
||||
|
||||
score = 10 - variance(cpuFraction,memoryFraction,volumeFraction)×10
|
||||
|
||||
|
||||
在这个公式中,三种Fraction的含义是Pod请求的资源除以节点上的可用资源,variance函数的作用是计算各种资源之间的差距,差距越大,函数值越大。由此可知,BalancedResourceAllocation规则的意图是希望调度完成后,所有节点里各种资源分配尽量均衡,避免节点上出现诸如处理器资源被大量分配、而内存大量剩余的尴尬状况。
|
||||
|
||||
Kubernetes内置的其他的评分规则,还有ImageLocalityPriority、NodeAffinityPriority、TaintTolerationPriority,等等,有兴趣的话你可以去阅读Kubernetes的源码,这里我就不再逐一解释了。
|
||||
|
||||
这样,经过Predicate的筛选、Priorities的评分之后,调度器已经选出了调度的最终目标节点,最后一步就是通知目标节点的kubelet可以去创建Pod了。我们要知道,调度器并不会直接与kubelet通讯来创建Pod,它只需要把待调度的Pod的nodeName字段更新为目标节点的名字即可,kubelet本身会监视该值的变化来接手后续工作。
|
||||
|
||||
不过,从调度器在etcd中更新nodeName,到kubelet从etcd中检测到变化,再执行Admit操作二次确认调度可行性,最后到Pod开始实际创建,这个过程可能会持续一段不短的时间,如果一直等待这些工作都完成了,才宣告调度最终完成,那势必也会显著影响调度器的效率。
|
||||
|
||||
所以实际上,Kubernetes调度器采用了乐观绑定(Optimistic Binding)的策略来解决这个问题,它会同步地更新调度缓存中Pod的nodeName字段,并异步地更新etcd中Pod的nodeName字段,这个操作被称为绑定(Binding)。如果最终调度成功了,那etcd与调度缓存中的信息最终必定会保持一致,否则如果调度失败了,那就会由Informer来根据Pod的变动,将调度成功却没有创建成功的Pod清空nodeName字段,重新同步回调度缓存中,以便促使另外一次调度的开始。
|
||||
|
||||
最后,你可能会注意到这个部分的小标题,我用的是“默认调度器”,这其实是在强调以上行为仅是Kubernetes默认的行为。对调度过程的大部分行为,你都可以通过Scheduler Framework暴露的接口来进行扩展和自定义,如下图所示:
|
||||
|
||||
|
||||
|
||||
Scheduler Framework的可扩展性
|
||||
|
||||
可以看到,图中绿色的部分,就是Scheduler Framework暴露的扩展点。由于Scheduler Framework属于Kubernetes内部的扩展机制(通过Golang的Plugin机制来实现的,需静态编译),它的通用性跟我在前面课程中提到的其他扩展机制(比如CRI、CNI、CSI那些)无法相提并论,属于比较高级的Kubernetes管理技能了,这里我就简单地提一下,你稍作了解就行。
|
||||
|
||||
小结
|
||||
|
||||
调度可以分解为几个相对独立的子问题来研究,比如说,如何衡量工作任务的算力需求;如何区分工作任务的优先级,保障较重要的任务有较高的服务质量;如何在资源紧张时自动驱逐相对不重要的任务,等等。解决这一系列子问题的组件,就称为容器编排系统的调度器。
|
||||
|
||||
这节课,我带你学习了Kubernetes是如何为一个新创建出来的Pod,寻找到一个最恰当的宿主机节点来运行的。由于Kubernetes基于“超卖”所设计的资源调度机制,在更合理充分利用物理服务器资源的同时,也让资源调度成为了一项具有风险和挑战性的工作,所以你只有正确理解了这节课介绍的服务质量、优先级、驱逐机制等概念,在生产实践中,才能在资源利用率最大化与服务稳定性之间取得良好平衡。
|
||||
|
||||
一课一思
|
||||
|
||||
调度是容器编排系统的核心功能之一,但调度却不仅仅存在于容器编排之中。除了Kubernetes等编排系统外,你还遇到过哪些需要进行资源调度的场景呢?
|
||||
|
||||
欢迎在留言区分享你的答案。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
120
专栏/周志明的架构课/59_透明通讯的涅槃(上):通讯的成本.md
Normal file
120
专栏/周志明的架构课/59_透明通讯的涅槃(上):通讯的成本.md
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
59 _ 透明通讯的涅槃(上):通讯的成本
|
||||
你好,我是周志明。接下来这三节课,我们来学习目前最新的服务通讯方案:服务网格。
|
||||
|
||||
Kubernetes为它管理的工作负载提供了工业级的韧性与弹性,也为每个处于运行状态的Pod维护其相互连通的虚拟化网络。不过,程序之间的通信不同于简单地在网络上拷贝数据,一个可连通的网络环境,仅仅是程序间能够可靠通信的必要但非充分的条件。
|
||||
|
||||
作为一名经历过SOA、微服务、云原生洗礼的的分布式程序员,你必定已经深谙路由、容错、限流、加密、认证、授权、跟踪、度量等问题在分布式系统中的必要性。
|
||||
|
||||
在“远程服务调用”这个小章节里,我曾以“通信的成本”为主题,给你讲解了三十多年的计算机科学家们,对“远程服务调用是否可能实现为透明通信”的一场声势浩大的争论。而今天,服务网格的诞生在某种意义上,就可以说就是当年透明通信的重生,服务网格试图以容器、虚拟化网络、边车代理等技术所构筑的新一代通信基础设施为武器,重新对已经盖棺定论三十多年的程序间远程通信中,非透明的原则发起冲击。
|
||||
|
||||
今天,这场关于通信的变革仍然在酝酿发展当中。最后到底会是成功的逆袭,还是会成为另一场失败,我不敢妄言定论,但是作为程序通信发展历史的一名见证者,我会丝毫不吝啬对服务网格投去最高的期许与最深的祝愿。
|
||||
|
||||
通信的成本
|
||||
|
||||
程序间通信作为分布式架构的核心内容,我在第一个模块“演进中的架构”中,就已经从宏观角度讲述过它的演进过程。而在这节课里,我会从更微观、更聚焦的角度,分析不同时期应用程序该如何看待与实现通信方面的非功能性需求,以及它们是如何做到可靠通信的。
|
||||
|
||||
我会通过以下五个阶段的变化,帮助你理解分布式服务的通信是如何逐步演化成我们要探讨的主角“服务网格”的。
|
||||
|
||||
第一阶段:将通信的非功能性需求视作业务需求的一部分,由程序员来保障通信的可靠性。
|
||||
|
||||
这一阶段是软件企业刚刚开始尝试分布式时,选择的早期技术策略。这类系统原本所具有的通信能力不是作为系统功能的一部分被设计出来的,而是遇到问题后修补累积所形成的。
|
||||
|
||||
在刚开始时,系统往往只具备最基本的网络API,比如集成OKHTTP、gRPC这些库来访问远程服务,如果远程访问接收到异常,就编写对应的重试或降级逻辑去应对处理。而在系统进入生产环境以后,遇到并解决的一个个通信问题,就逐渐在业务系统中留下了越来越多关于通信的代码逻辑。
|
||||
|
||||
这些通信的逻辑由业务系统的开发人员直接编写,与业务逻辑直接共处在一个进程空间之中,如下图所示(注:这里以及后面的一系列图片中,我会以“断路器”和“服务发现”这两个常见的功能来泛指所有的分布式通信所需的能力,但你要知道实际上并不局限于这两个功能)。
|
||||
|
||||
|
||||
|
||||
控制逻辑和业务逻辑耦合
|
||||
|
||||
这一阶段的主要矛盾是绝大多数擅长业务逻辑的开发人员,其实都并不擅长处理通信方面的问题。要写出正确、高效、健壮的分布式通信代码,是一项极具专业性的工作。所以大多数的普通软件企业都很难在这个阶段支撑起一个靠谱的分布式系统来。
|
||||
|
||||
另一方面,把专业的通信功能强加于普通开发人员,这无疑为他们带来了更多工作量。尤其是这些“额外的工作”与原有的业务逻辑耦合在一起,让系统越来越复杂,也越来越容易出错。
|
||||
|
||||
第二阶段:将代码中的通信功能抽离重构成公共组件库,通信的可靠性由专业的平台程序员来保障。
|
||||
|
||||
开发人员解耦一贯依赖的有效办法是抽取分离代码与封装重构组件。实际上,微服务的普及也离不开一系列封装了分布式通信能力的公共组件库,其代表性产品有Twitter的Finagle、Spring Cloud中的许多组件等。
|
||||
|
||||
这些公共的通信组件由熟悉分布式的专业开发人员编写和维护,不仅效率更高、质量更好,还都提供了经过良好设计的API接口,让业务代码既可以使用它们的能力,又无需把处理通信的逻辑散布于业务代码当中。
|
||||
|
||||
|
||||
|
||||
抽取公共的分布式通信组件
|
||||
|
||||
分布式通信组件让普通程序员开发出靠谱的微服务系统成为可能,这是无可争议的成绩。但普通程序员使用它们的成本依然很高,不仅要学习分布式的知识,还要学习这些公共组件的功能的使用规范,最麻烦的是,对于同一种问题往往还需学习多种不同的组件才能解决。
|
||||
|
||||
造成这些问题的主要原因是因为通信组件是一段由特定编程语言开发出来的程序,是与语言绑定的,一个由Python编写的组件再优秀,对Java系统来说也没有太多的实用价值。目前,基于公共组件库开发微服务仍然是应用最为广泛的解决方案,但肯定不是一种完美的解决方案,这是微服务基础设施完全成熟之前必然会出现的应用形态,同时也一定是微服务进化过程中必然会被替代的过渡形态。
|
||||
|
||||
第三阶段:将负责通信的公共组件库分离到进程之外,程序间通过网络代理来交互,通信的可靠性由专门的网络代理提供商来保障。
|
||||
|
||||
为了能够让分布式通信组件与具体的编程语言脱钩,也为了避免程序员还要去专门学习这些组件的编程模型与API接口,这一阶段进化出了能专门负责可靠通信的网络代理。这些网络代理不再与业务逻辑部署于同一个进程空间,但仍然与业务系统处于同一个容器或者虚拟机当中,它们可以通过回环设备甚至是UDS(Unix Domain Socket)进行交互,可以说具备相当高的网络性能。
|
||||
|
||||
也就是说,只要让网络代理接管掉程序七层或四层流量,就能够在代理上完成断路、容错等几乎所有的分布式通信功能,前面提到过的Netflix Prana就属于这类产品的典型代表。
|
||||
|
||||
|
||||
|
||||
通过网络代理获得可靠的通信能力
|
||||
|
||||
在通过网络代理来提升通信质量的思路提出以后,其本身的使用范围其实并不算特别广泛,但它的方向是正确的。这种思路后来演化出了两种改进形态:
|
||||
|
||||
|
||||
第一种形态,将网络代理从进程身边拉远,让它与进程分别处于不同的机器上,这样就可以同时给多个进程提供可靠通信的代理服务。这种形态逐渐演变成了今天我们常见的微服务网关。
|
||||
第二种形态,如果将网络代理往进程方向推近,不仅能让它与进程处于同一个共享网络名称空间的容器组之中,还可以让它透明并强制地接管通讯,这便形成了下一阶段所说的边车代理。
|
||||
|
||||
|
||||
第四阶段:将网络代理以边车的形式注入到应用容器,自动劫持应用的网络流量,让通信的可靠性由专门的通信基础设施来保障。
|
||||
|
||||
与前一阶段的独立代理相比,以边车模式运作的网络代理拥有两个无可比拟的优势:
|
||||
|
||||
|
||||
它对流量的劫持是强制性的,通常是靠直接写容器的iptables转发表来实现。
|
||||
|
||||
|
||||
此前,独立的网络代理只有程序首先去访问它,它才能被动地为程序提供可靠的通信服务,只要程序依然有选择不访问它的可能性,代理就永远只能充当服务者而不能成为管理者。上阶段的图中,保留的两个容器网络设备直接连接的箭头,就代表了这种可能性,而这一阶段的图例中,服务与网络名称空间的虚线箭头代表了被劫持后,应用程序以为存在,但实际并不存在的流量。
|
||||
|
||||
|
||||
边车代理对应用是透明的,无需对已部署的应用程序代码进行任何改动,不需要引入任何的库(这点并不是绝对的,有部分边车代理也会要求有轻量级的SDK),也不需要程序专门去访问某个特定的网络位置。
|
||||
|
||||
|
||||
这意味着它对所有现存程序都具备开箱即用的适应性,无需修改旧程序就能直接享受到边车代理的服务,这样使得它的适用面就变得十分广泛。目前边车代理的代表性产品有Linkerd、Envoy、MOSN等。
|
||||
|
||||
|
||||
|
||||
边车代理模式
|
||||
|
||||
如果说边车代理还有什么不足之处的话,那大概就是来自于运维人员的不满了。边车代理能够透明且具有强制力地解决可靠通信的问题,但它本身也需要有足够的信息才能完成这项工作,比如获取可用服务的列表、得到每个服务名称对应的IP地址等等。
|
||||
|
||||
而这些信息不会从天上掉下来自动到边车里去,是需要由管理员主动去告知代理,或者代理主动从约定的好的位置获取的。可见,管理代理本身也会产生额外的通信需求。如果没有额外的支持,这些管理方面的通信都得由运维人员去埋单,由此而生的不满便可想而知。为了管理与协调边车代理,程序间通信进化到了最后一个阶段:服务网格。
|
||||
|
||||
第五阶段:将边车代理统一管控起来实现安全、可控、可观测的通信,将数据平面与控制平面分离开来,实现通用、透明的通信,这项工作就由专门的服务网格框架来保障。
|
||||
|
||||
从总体架构看,服务网格包括两大块内容,分别是由一系列与微服务共同部署的边车代理,以及用于控制这些代理的管理器所构成。代理与代理之间需要通信,用以转发程序间通信的数据包;代理与管理器之间也需要通信,用以传递路由管理、服务发现、数据遥测等控制信息。
|
||||
|
||||
服务网格使用数据平面(Data Plane)通信和控制平面(Control Plane)通信来形容这两类流量,下图中的实线就表示数据平面通信,虚线表示控制平面通信。
|
||||
|
||||
|
||||
|
||||
服务网格的控制平面通信与数据平面通信
|
||||
|
||||
实际上,数据平面与控制平面并不是什么新鲜概念,它最初就是用在计算机网络之中的术语,通常是指网络层次的划分。在软件定义网络中,也把解耦数据平面与控制平面作为其最主要的特征之一。服务网格把计算机网络的经典概念引入到了程序通信之中,既可以说是对程序通信的一种变革创新,也可以说是对网络通信的一种发展传承。
|
||||
|
||||
小结
|
||||
|
||||
分离数据平面与控制平面的实质是将“程序”与“网络”进行解耦,把网络可能出现的问题(比如中断后重试、降级),与可能需要的功能(比如实现追踪度量)的处理过程从程序中拿出来,放到由控制平面指导的数据平面通信中去处理,这样来制造出一种“这些问题在程序间通信中根本不存在”的假象,仿佛网络和远程服务都是完美可靠的。
|
||||
|
||||
而这种完美的假象,就让应用之间可以非常简单地交互,而不必过多地考虑异常情况;而且也能够在不同的程序框架、不同的云服务提供商环境之间平稳地迁移。与此同时,还能让管理者能够不依赖程序支持就得到遥测所需的全部信息,能够根据角色、权限进行统一的访问控制,这些都是服务网格的价值所在。
|
||||
|
||||
一课一思
|
||||
|
||||
远程通讯在性能上与本地访问有好几个数量级的差距,目前完全看不到有“透明”的可能性。不过,在功能上,在可预见的将来,是否有可能在实现透明的远程服务,业界仍然没有统一的共识,这个问题你的看法是什么?欢迎在留言区分享你的见解。
|
||||
|
||||
如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
276
专栏/周志明的架构课/60_透明通讯的涅槃(下):控制平面与数据平面.md
Normal file
276
专栏/周志明的架构课/60_透明通讯的涅槃(下):控制平面与数据平面.md
Normal file
@@ -0,0 +1,276 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
60 _ 透明通讯的涅槃(下):控制平面与数据平面
|
||||
你好,我是周志明。这节课,我会延续服务网格将“程序”与“网络”解耦的思路,通过介绍几个数据平面通信与控制平面通信中的核心问题的解决方案,帮助你更好地理解这两个概念。
|
||||
|
||||
在开始之前我想先说明一点,就是我们知道在工业界,数据平面领域已经有了Linkerd、Nginx、Envoy等产品,在控制平面领域也有Istio、Open Service Mesh、Consul等产品。不过今天我主要讲解的是目前市场占有率最高的Istio与Envoy,因为我的目的是要让你理解两种平面通信的技术原理,而非介绍Istio和Envoy的功能与用法,这节课中涉及到的原理在各种服务网格产品中一般都是通用的,并不局限于哪一种具体实现。
|
||||
|
||||
好,接下来我们就从数据平面通信开始,来了解一下它的工作内容。
|
||||
|
||||
数据平面
|
||||
|
||||
首先,数据平面由一系列边车代理所构成,它的核心职责是转发应用的入站(Inbound)和出站(Outbound)数据包,因此数据平面也有个别名叫转发平面(Forwarding Plane)。
|
||||
|
||||
同时,为了在不可靠的物理网络中保证程序间通信最大的可靠性,数据平面必须根据控制平面下发策略的指导,在应用无感知的情况下自动完成服务路由、健康检查、负载均衡、认证鉴权、产生监控数据等一系列工作。
|
||||
|
||||
那么,为了顺利完成以上所说的工作目标,数据平面至少需要妥善解决三个关键问题:
|
||||
|
||||
|
||||
代理注入:边车代理是如何注入到应用程序中的?
|
||||
流量劫持:边车代理是如何劫持应用程序的通信流量的?
|
||||
可靠通信:边车代理是如何保证应用程序的通信可靠性的?
|
||||
|
||||
|
||||
好,下面我们就具体来看看吧。
|
||||
|
||||
代理注入
|
||||
|
||||
从职责上说,注入边车代理是控制平面的工作,但从叙述逻辑上,将其放在数据平面中介绍更合适。因为把边车代理注入到应用的过程并不一定全都是透明的,所以现在的服务网格产品产生了以下三种将边车代理接入到应用程序中的方式。
|
||||
|
||||
|
||||
基座模式(Chassis):这种方式接入的边车代理对程序就是不透明的,它至少会包括一个轻量级的SDK,让通信由SDK中的接口去处理。基座模式的好处是在程序代码的帮助下,有可能达到更好的性能,功能也相对更容易实现。但坏处是对代码有侵入性,对编程语言有依赖性。这种模式的典型产品是由华为开源后捐献给Apache基金会的ServiceComb Mesher。基座模式的接入方式目前并不属于主流方式,我也就不展开介绍了。
|
||||
注入模式(Injector):根据注入方式不同,又可以分为:
|
||||
|
||||
|
||||
手动注入模式:这种接入方式对使用者来说不透明,但对程序来说是透明的。由于边车代理的定义就是一个与应用共享网络名称空间的辅助容器,这天然就契合了Pod的设定。因此在Kubernetes中要进行手动注入是十分简单的——就只是为Pod增加一个额外容器而已,即使没有工具帮助,自己修改Pod的Manifest也能轻易办到。如果你以前未曾尝试过,不妨找一个Pod的配置文件,用istioctl kube-inject -f YOUR_POD.YAML命令来查看一下手动注入会对原有的Pod产生什么变化。
|
||||
自动注入模式:这种接入方式对使用者和程序都是透明的,也是Istio推荐的代理注入方式。在Kubernetes中,服务网格一般是依靠“动态准入控制”(Dynamic Admission Control)中的Mutating Webhook控制器来实现自动注入的。
|
||||
|
||||
|
||||
|
||||
|
||||
额外知识-
|
||||
istio-proxy是Istio对Envoy代理的包装容器,其中包含用Golang编写的pilot-agent和用C++编写的envoy两个进程。pilot-agent进程负责Envoy的生命周期管理,比如启动、重启、优雅退出等,并维护Envoy所需的配置信息,比如初始化配置、随时根据控制平面的指令热更新Envoy的配置等。
|
||||
|
||||
|
||||
这里我以Istio自动注入边车代理(istio-proxy容器)的过程为例,给你介绍一下自动注入的具体的流程。只要你对Istio有基本的了解,你应该就能都知道,对任何设置了istio-injection=enabled标签的名称空间,Istio都会自动为其中新创建的Pod,注入一个名为istio-proxy的容器。之所以能做到自动这一点,是因为Istio预先在Kubernetes中注册了一个类型为MutatingWebhookConfiguration的资源,它的主要内容如下所示:
|
||||
|
||||
apiVersion: admissionregistration.k8s.io/v1beta1
|
||||
kind: MutatingWebhookConfiguration
|
||||
metadata:
|
||||
name: istio-sidecar-injector
|
||||
.....
|
||||
webhooks:
|
||||
- clientConfig:
|
||||
service:
|
||||
name: istio-sidecar-injector
|
||||
namespace: istio-system
|
||||
path: /inject
|
||||
name: sidecar-injector.istio.io
|
||||
namespaceSelector:
|
||||
matchLabels:
|
||||
istio-injection: enabled
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
apiVersions:
|
||||
- v1
|
||||
operations:
|
||||
- CREATE
|
||||
resources:
|
||||
- pods
|
||||
|
||||
|
||||
以上配置其实就告诉了Kubernetes,对于符合标签istio-injection: enabled的名称空间,在Pod资源进行CREATE操作时,应该先自动触发一次Webhook调用,调用的位置是istio-system名称空间中的服务istio-sidecar-injector,调用具体的URL路径是/inject。
|
||||
|
||||
在这次调用中,Kubernetes会把拟新建Pod的元数据定义作为参数发送给此HTTP Endpoint,然后从服务返回结果中得到注入了边车代理的新Pod定义,以此自动完成注入。
|
||||
|
||||
流量劫持
|
||||
|
||||
边车代理做流量劫持最典型的方式是基于iptables进行的数据转发,我曾在“Linux网络虚拟化”这个小章节中介绍过Netfilter与iptables的工作原理。这里我仍然以Istio为例,它在注入边车代理后,除了生成封装Envoy的istio-proxy容器外,还会生成一个initContainer,这个initContainer的作用就是自动修改容器的iptables,具体内容如下所示:
|
||||
|
||||
initContainers:
|
||||
image: docker.io/istio/proxyv2:1.5.1
|
||||
name: istio-init
|
||||
- command:
|
||||
- istio-iptables -p "15001" -z "15006"-u "1337" -m REDIRECT -i '*' -x "" -b '*' -d 15090,15020
|
||||
|
||||
|
||||
以上命令行中的istio-iptables是Istio提供的用于配置iptables的Shell脚本,这行命令的意思是让边车代理拦截所有的进出Pod的流量,包括拦截除15090、15020端口(这两个分别是Mixer和Ingress Gateway的端口,关于Istio占用的固定端口你可以参考官方文档所列的信息)外的所有入站流量,全部转发至15006端口(Envoy入站端口),经Envoy处理后,再从15001端口(Envoy出站端口)发送出去。
|
||||
|
||||
这个命令会在iptables中的PREROUTING和OUTPUT链中,挂载相应的转发规则,使用iptables -t nat -L -v命令,你可以查看到如下所示配置信息:
|
||||
|
||||
Chain PREROUTING
|
||||
pkts bytes target prot opt in out source destination
|
||||
2701 162K ISTIO_INBOUND tcp -- any any anywhere anywhere
|
||||
|
||||
Chain OUTPUT
|
||||
pkts bytes target prot opt in out source destination
|
||||
15 900 ISTIO_OUTPUT tcp -- any any anywhere anywhere
|
||||
|
||||
Chain ISTIO_INBOUND (1 references)
|
||||
pkts bytes target prot opt in out source destination
|
||||
0 0 RETURN tcp -- any any anywhere anywhere tcp dpt:ssh
|
||||
2 120 RETURN tcp -- any any anywhere anywhere tcp dpt:15090
|
||||
2699 162K RETURN tcp -- any any anywhere anywhere tcp dpt:15020
|
||||
0 0 ISTIO_IN_REDIRECT tcp -- any any anywhere anywhere
|
||||
|
||||
Chain ISTIO_IN_REDIRECT (3 references)
|
||||
pkts bytes target prot opt in out source destination
|
||||
0 0 REDIRECT tcp -- any any anywhere anywhere redir ports 15006
|
||||
|
||||
Chain ISTIO_OUTPUT (1 references)
|
||||
pkts bytes target prot opt in out source destination
|
||||
0 0 RETURN all -- any lo 127.0.0.6 anywhere
|
||||
0 0 ISTIO_IN_REDIRECT all -- any lo anywhere !localhost owner UID match 1337
|
||||
0 0 RETURN all -- any lo anywhere anywhere ! owner UID match 1337
|
||||
15 900 RETURN all -- any any anywhere anywhere owner UID match 1337
|
||||
0 0 ISTIO_IN_REDIRECT all -- any lo anywhere !localhost owner GID match 1337
|
||||
0 0 RETURN all -- any lo anywhere anywhere ! owner GID match 1337
|
||||
0 0 RETURN all -- any any anywhere anywhere owner GID match 1337
|
||||
0 0 RETURN all -- any any anywhere localhost
|
||||
0 0 ISTIO_REDIRECT all -- any any anywhere anywhere
|
||||
|
||||
Chain ISTIO_REDIRECT (1 references)
|
||||
pkts bytes target prot opt in out source destination
|
||||
0 0 REDIRECT tcp -- any any anywhere anywhere redir ports 1
|
||||
|
||||
|
||||
实际上,用iptables进行流量劫持是最经典、最通用的手段。不过,iptables重定向流量必须通过回环设备(Loopback)交换数据,流量不得不多穿越一次协议栈,如下图所示。
|
||||
|
||||
|
||||
|
||||
其实,这种方案在网络I/O不构成主要瓶颈的系统中并没有什么不妥,但在网络敏感的大并发场景下会因转发而损失一定的性能。因而目前,如何实现更优化的数据平面流量劫持,仍然是服务网格发展的前沿研究课题之一。
|
||||
|
||||
其中一种可行的优化方案,是使用eBPF(Extended Berkeley Packet Filter)技术,在Socket层面直接完成数据转发,而不需要再往下经过更底层的TCP/IP协议栈的处理,从而减少它数据在通信链路的路径长度。
|
||||
|
||||
|
||||
|
||||
另一种可以考虑的方案,是让服务网格与CNI插件配合来实现流量劫持,比如Istio就有提供自己实现的CNI插件。只要安装了这个CNI插件,整个虚拟化网络都由Istio自己来控制,那自然就无需再依赖iptables,也不必存在initContainers配置和istio-init容器了。
|
||||
|
||||
这种方案有很高的上限与自由度,不过,要实现一个功能全面、管理灵活、性能优秀、表现稳定的CNI网络插件决非易事,连Kubernetes自己都迫不及待想从网络插件中脱坑,其麻烦程度可想而知,因此目前这种方案使用并不广泛。
|
||||
|
||||
流量劫持技术的发展与服务网格的落地效果密切相关,有一些服务网格通过基座模式中的SDK也能达到很好的转发性能,但考虑到应用程序通用性和环境迁移等问题,无侵入式的低时延、低管理成本的流量劫持方案仍然是研究的主流方向。
|
||||
|
||||
可靠通信
|
||||
|
||||
注入边车代理、劫持应用流量,最终的目的都是为了代理能够接管应用程序的通信,然而,在代理接管了应用的通信之后,它会做什么呢?这个问题的答案是:不确定。
|
||||
|
||||
代理的行为需要根据控制平面提供的策略来决定,传统的代理程序,比如HAProxy、Nginx是使用静态配置文件来描述转发策略的,而这种静态配置很难跟得上应用需求的变化与服务扩缩时网络拓扑结构的变动。
|
||||
|
||||
因此针对这个问题,Envoy在这方面进行了创新,它将代理的转发的行为规则抽象成Listener、Router、Cluster三种资源。以此为基础,它又定义了应该如何发现和访问这些资源的一系列API,现在这些资源和API被统称为“xDS协议族”。自此以后,数据平面就有了如何描述各种配置和策略的事实标准,控制平面也有了与控制平面交互的标准接口,目前xDS v3.0协议族已经包含有以下具体协议:
|
||||
|
||||
|
||||
|
||||
这里我就不逐一介绍这些协议了,但我要给你说明清楚它们一致的运作原理。其中的关键是解释清楚这些协议的共同基础,即Listener、Router、Cluster三种资源的具体含义。
|
||||
|
||||
|
||||
Listener
|
||||
|
||||
|
||||
Listener可以简单理解为Envoy的一个监听端口,用于接收来自下游应用程序(Downstream)的数据。Envoy能够同时支持多个Listener,且不同的Listener之间的策略配置是相互隔离的。
|
||||
|
||||
自动发现Listener的服务被称为LDS(Listener Discovery Service),它是所有其他xDS协议的基础,如果没有LDS(也没有在Envoy启动时静态配置Listener的话),其他所有xDS服务也就失去了意义,因为没有监听端口的Envoy不能为任何应用提供服务。
|
||||
|
||||
|
||||
Cluster
|
||||
|
||||
|
||||
Cluster是Envoy能够连接到的一组逻辑上提供相同服务的上游(Upstream)主机。Cluster包含该服务的连接池、超时时间、Endpoints地址、端口、类型等信息。具体到Kubernetes环境下,可以认为Cluster与Service是对等的概念,但是Cluster实际上还承担了服务发现的职责。
|
||||
|
||||
自动发现Cluster的服务被称为CDS(Cluster Discovery Service),通常情况下,控制平面会将它从外部环境中获取的所有可访问服务全量推送给Envoy。与CDS紧密相关的另一种服务是EDS(Endpoint Discovery Service)。当Cluster的类型被标识为需要EDS时,则说明该Cluster的所有Endpoints地址应该由xDS服务下发,而不是依靠DNS服务去解析。
|
||||
|
||||
|
||||
Router
|
||||
|
||||
|
||||
Listener负责接收来自下游的数据,Cluster负责将数据转发送给上游的服务,而Router则决定Listener在接收到下游的数据之后,具体应该将数据交给哪一个Cluster处理。由此定义可知,Router实际上是承担了服务网关的职责。
|
||||
|
||||
自动发现Router的服务被称为RDS(Router Discovery Service),Router中最核心的信息是目标Cluster及其匹配规则,即实现网关的路由职能。此外,根据Envoy中的插件配置情况,也可能包含重试、分流、限流等动作,实现网关的过滤器职能。
|
||||
|
||||
|
||||
|
||||
Envoy的另外一个设计重点是它的Filter机制,Filter通俗地讲就是Envoy的插件,通过Filter机制,Envoy就可以提供强大的可扩展能力。插件不仅是无关重要的外围功能,很多Envoy的核心功能都是用Filter来实现的,比如对HTTP流量的治理、Tracing机制、多协议支持,等等。
|
||||
|
||||
另外,利用Filter机制,Envoy理论上还可以实现任意协议的支持以及协议之间的转换,也可以在实现对请求流量进行全方位的修改和定制的同时,还保持较高的可维护性。
|
||||
|
||||
控制平面
|
||||
|
||||
如果说数据平面是行驶中的车辆,那控制平面就是车辆上的导航系统;如果说数据平面是城市的交通道路,那控制平面就是路口的指示牌与交通信号灯。控制平面的特点是不直接参与程序间通信,只会与数据平面中的代理通信。在程序不可见的背后,默默地完成下发配置和策略,指导数据平面工作。
|
||||
|
||||
由于服务网格(暂时)没有大规模引入计算机网络中管理平面(Management Plane)等其他概念,所以控制平面通常也会附带地实现诸如网络行为的可视化、配置传输等一系列管理职能(其实还是有专门的管理平面工具的,比如Meshery、ServiceMeshHub)。这里我仍然以Istio为例具体介绍一下控制平面的主要功能。
|
||||
|
||||
Istio在1.5版本之前,Istio自身也是采用微服务架构开发的,它把控制平面的职责分解为Mixer、Pilot、Galley、Citadel四个模块去实现,其中Mixer负责鉴权策略与遥测;Pilot负责对接Envoy的数据平面,遵循xDS协议进行策略分发;Galley负责配置管理,为服务网格提供外部配置感知能力;Citadel负责安全加密,提供服务和用户层面的认证和鉴权、管理凭据和RBAC等安全相关能力。
|
||||
|
||||
不过,经过两、三年的实践应用,很多用户都在反馈Istio的微服务架构有过度设计的嫌疑。lstio在定义项目目标时,曾非常理想化地提出控制平面的各个组件都应可以独立部署,然而在实际的应用场景里却并不是这样,独立的组件反而带来了部署复杂、职责划分不清晰等问题。
|
||||
|
||||
|
||||
|
||||
(图片来自Istio官方文档)
|
||||
|
||||
因此,从1.5版本起,Istio重新回归单体架构,把Pilot、Galley、Citadel的功能全部集成到新的Istiod之中。当然,这也并不是说完全推翻之前的设计,只是将原有的多进程形态优化成单进程的形态,让之前各个独立组件变成了Istiod的内部逻辑上的子模块而已。
|
||||
|
||||
单体化之后出现的新进程Istiod就承担所有的控制平面职责,具体包括以下几种。
|
||||
|
||||
1. 数据平面交互:这是部分是满足服务网格正常工作所需的必要工作。
|
||||
|
||||
具体包括以下几个方面:
|
||||
|
||||
|
||||
边车注入:在Kubernetes中注册Mutating Webhook控制器,实现代理容器的自动注入,并生成Envoy的启动配置信息。
|
||||
策略分发:接手了原来Pilot的核心工作,为所有的Envoy代理提供符合xDS协议的策略分发的服务。
|
||||
配置分发:接手了原来Galley的核心工作,负责监听来自多种支持配置源的数据,比如kube-apiserver,本地配置文件,或者定义为网格配置协议(Mesh Configuration Protocol,MCP)的配置信息。原来Galley需要处理的API校验和配置转发功能也包含在内。
|
||||
|
||||
|
||||
2. 流量控制:这通常是用户使用服务网格的最主要目的。
|
||||
|
||||
具体包括以下几个方面:
|
||||
|
||||
|
||||
请求路由:通过VirtualService、DestinationRule 等Kubernetes CRD资源实现了灵活的服务版本切分与规则路由。比如根据服务的迭代版本号(如v1.0版、v2.0版)、根据部署环境(如Development版、Production版)作为路由规则来控制流量,实现诸如金丝雀发布这类应用需求。
|
||||
流量治理:包括熔断、超时、重试等功能,比如通过修改Envoy的最大连接数,实现对请求的流量控制;通过修改负载均衡策略,在轮询、随机、最少访问等方式间进行切换;通过设置异常探测策略,将满足异常条件的实例从负载均衡池中摘除,以保证服务的稳定性等等。
|
||||
调试能力:包括故障注入和流量镜像等功能,比如在系统中人为设置一些故障,来测试系统的容错稳定性和系统恢复的能力。又比如通过复制一份请求流量,把它发送到镜像服务,从而满足 A/B验证的需要。
|
||||
|
||||
|
||||
3. 通信安全:包括通信中的加密、凭证、认证、授权等功能。
|
||||
|
||||
具体包括以下几个方面:
|
||||
|
||||
|
||||
生成CA证书:接手了原来Galley的核心工作,负责生成通信加密所需私钥和CA证书。
|
||||
SDS服务代理:最初Istio是通过Kubernetes的Secret卷的方式将证书分发到Pod中的,从Istio 1.1之后改为通过SDS服务代理来解决。这种方式保证了私钥证书不会在网络中传输,仅存在于SDS代理和Envoy的内存中,证书刷新轮换也不需要重启Envoy。
|
||||
认证:提供基于节点的服务认证和基于请求的用户认证,这项功能我曾在服务安全的“认证”中详细介绍过。
|
||||
授权:提供不同级别的访问控制,这项功能我也曾在服务安全的“授权”中详细介绍过。
|
||||
|
||||
|
||||
4. 可观测性:包括日志、追踪、度量三大块能力。
|
||||
|
||||
具体包括以下几个方面:
|
||||
|
||||
|
||||
日志收集:程序日志的收集并不属于服务网格的处理范畴,通常会使用ELK Stack去完成,这里是指远程服务的访问日志的收集,对等的类比目标应该是以前Nginx、Tomcat的访问日志。
|
||||
链路追踪:为请求途经的所有服务生成分布式追踪数据并自动上报,运维人员可以通过Zipkin等追踪系统从数据中重建服务调用链,开发人员可以借此了解网格内服务的依赖和调用流程。
|
||||
指标度量:基于四类不同的监控标识(响应延迟、流量大小、错误数量、饱和度)生成一系列观测不同服务的监控指标,用于记录和展示网格中服务状态。
|
||||
|
||||
|
||||
小结
|
||||
|
||||
容器编排系统管理的最细粒度只能到达容器层次,在此粒度之下的技术细节,仍然只能依赖程序员自己来管理,编排系统很难提供有效的支持。
|
||||
|
||||
2016年,原Twitter基础设施工程师威廉·摩根(William Morgan)和奥利弗·古尔德(Oliver Gould)在GitHub上发布了第一代的服务网格产品Linkerd,并在很短的时间内围绕着Linkered组建了Buoyant公司。而后担任CEO的威廉·摩根在发表的文章《What’s A Service Mesh? And Why Do I Need One?》中,首次正式地定义了“服务网格”(Service Mesh)一词。
|
||||
|
||||
此后,服务网格作为一种新兴通信理念开始迅速传播,越来越频繁地出现在各个公司以及技术社区的视野中。之所以服务网格能够获得企业与社区的重视,就是因为它很好地弥补了容器编排系统对分布式应用细粒度管控能力高不足的缺憾。
|
||||
|
||||
说实话,服务网格并不是什么神秘难以理解的黑科技,它只是一种处理程序间通信的基础设施,典型的存在形式是部署在应用旁边,一对一为应用提供服务的边车代理,以及管理这些边车代理的控制程序。
|
||||
|
||||
“边车”(Sidecar)本来就是一种常见的容器设计模式,用来形容外挂在容器身上的辅助程序。早在容器盛行以前,边车代理就就已经有了成功的应用案例。
|
||||
|
||||
比如2014年开始的Netflix Prana项目,由于Netfilix OSS套件是用Java语言开发的,为了让非JVM语言的微服务(比如以Python、Node.js编写的程序)也同样能接入Netfilix OSS生态,享受到Eureka、Ribbon、Hystrix等框架的支持,Netflix建立了Prana项目,它的作用是为每个服务都提供一个专门的HTTP Endpoint,以此让非JVM语言的程序能通过访问该Endpoint,来获取系统中所有服务的实例、相关路由节点、系统配置参数等在Netfilix组件中管理的信息。
|
||||
|
||||
Netflix Prana的代理需要由应用程序主动去访问才能发挥作用,但在容器的刻意支持下,服务网格不需要应用程序的任何配合,就能强制性地对应用通信进行管理。
|
||||
|
||||
它使用了类似网络攻击里中间人流量劫持的手段,完全透明(既无需程序主动访问,也不会被程序感知到)地接管容器与外界的通信,把管理的粒度从容器级别细化到了每个单独的远程服务级别,这就让基础设施干涉应用程序、介入程序行为的能力大为增强。
|
||||
|
||||
如此一来,云原生希望用基础设施接管应用程序非功能性需求的目标,就能更进一步。从容器粒度延伸到远程访问,分布式系统继容器和容器编排之后,又发掘到了另一块更广袤的舞台空间。
|
||||
|
||||
一课一思
|
||||
|
||||
服务网格中,数据平面、控制平面的概念是从计算机网络中的SDN(软件定义网络)借用过来的,在此之前,你是否有接触过SDN方面的知识呢?它与今天的服务网格有哪些联系与差异?
|
||||
|
||||
欢迎在留言区分享你的答案和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
||||
202
专栏/周志明的架构课/61_服务网格与生态:聊聊服务网格的两项标准规范.md
Normal file
202
专栏/周志明的架构课/61_服务网格与生态:聊聊服务网格的两项标准规范.md
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
61 _ 服务网格与生态:聊聊服务网格的两项标准规范
|
||||
你好,我是周志明。这节课,我们来了解服务网格的主要规范与主流产品。
|
||||
|
||||
服务网格目前仍然处于技术浪潮的早期,不过现在业界早已普遍认可它的价值,基本上所有希望能影响云原生发展方向的企业都已经参与了进来。从最早2016年的Linkerd 和 Envoy,到2017年Google、IBM和Lyft共同发布的Istio,再到后来CNCF把Buoyant的 Conduit 改名为 Linkerd2,再度参与Istio竞争。
|
||||
|
||||
而到了2018年后,服务网格的话语权争夺战已经全面升级到由云计算巨头直接主导,比如Google把Istio搬上Google Cloud Platform,推出了Istio的公有云托管版本Google Cloud Service Mesh;亚马逊推出了用于AWS的App Mesh;微软推出了Azure完全托管版本的Service Fabric Mesh,发布了自家的控制平面 Open Service Mesh;国内的阿里巴巴也推出了基于Istio的修改版 SOFAMesh,并开源了自己研发的 MOSN 代理。可以说,云计算的所有玩家都正在布局服务网格生态。
|
||||
|
||||
不过,市场繁荣的同时也带来了碎片化的问题。要知道,一个技术领域能够形成被业界普遍承认的规范标准,是这个领域从分头研究、各自开拓的萌芽状态,走向工业化生产应用的成熟状态的重要标志,标准的诞生可以说是每一项技术普及之路中都必须经历的“成人礼”。
|
||||
|
||||
在前面的课程中,我们接触过容器运行时领域的 CRI规范、容器网络领域的 CNI规范、容器存储领域的 CSI规范,尽管服务网格诞生至今只有数年时间,但作为微服务、云原生的前沿热点,它也正在酝酿自己的标准规范,也就是这节课我们要讨论的主角:服务网格接口(Service Mesh Interface,SMI)与通用数据平面API(Universal Data Plane API,UDPA)。现在我们先来看下这两者之间的关系:
|
||||
|
||||
|
||||
|
||||
SMI规范与UDPA规范
|
||||
|
||||
实际上,服务网格是数据平面产品与控制平面产品的集合,所以在规范制订方面,很自然地也分成了两类:
|
||||
|
||||
|
||||
SMI规范提供了外部环境(实际上就是Kubernetes)与控制平面交互的标准,使得Kubernetes及在其之上的应用,能够无缝地切换各种服务网格产品;
|
||||
UDPA规范则提供了控制平面与数据平面交互的标准,使得服务网格产品能够灵活地搭配不同的边车代理,针对不同场景的需求,发挥各款边车代理的功能或者性能优势。
|
||||
|
||||
|
||||
可以发现,这两个规范并没有重叠,它们的关系与我在容器运行时中介绍到的CRI和OCI规范之间的关系很相似。下面我们就从这两个规范的起源和支持者的背景入手,了解一下它们要解决的问题及目前的发展状况。
|
||||
|
||||
服务网格接口
|
||||
|
||||
在2019年5月的KubeCon大会上,微软联合Linkerd、HashiCorp、Solo、Kinvolk和Weaveworks等一批云原生服务商,共同宣布了Service Mesh Interface规范,希望能在各家的服务网格产品之上建立一个抽象的API层,然后通过这个抽象来解耦和屏蔽底层服务网格实现,让上层的应用、工具、生态系统可以建立在同一个业界标准之上,从而实现应用程序在不同服务网格产品之间的无缝移植与互通。
|
||||
|
||||
如果你更熟悉Istio的话,那你可以把SMI的作用理解为是给服务网格提供了一套Istio中,VirtualService、DestinationRule、Gateway等私有概念对等的行业标准版本,只要使用SMI中定义的标准资源,应用程序就可以在不同的控制平面上灵活迁移,唯一的要求是这些控制平面都支持了SMI规范。
|
||||
|
||||
SMI与Kubernetes是彻底绑定的,规范的落地执行完全依靠在Kubernetes中部署SMI定义的CRD来实现,这一点在SMI的目标中被形容为“Kubernetes Native”,也就说明了微软等云服务厂商已经认定容器编排领域不会有Kubernetes之外的候选项了,这也是微软选择在KubeCon大会上公布SMI规范的原因。
|
||||
|
||||
但是在另外一端 ,SMI并不与包括行业第一的Istio,或者是微软自家的Open Service Mesh在内的任何控制平面所绑定,这点在SMI的目标中被形容为“Provider Agnostic”,说明微软务实地看到了服务网格领域目前还处于群雄混战的现状。Provider Agnostic对消费者有利,但对目前处于行业领先地位的Istio肯定是不利的,所以我们完全可以理解为什么SMI没有得到Istio及其背后的Google、IBM与Lyft的支持。
|
||||
|
||||
然而,在过去两年里,Istio无论是发展策略上、还是设计上(过度设计)的风评都不算很好,业界一直在期待Google和Istio能做出改进,这种期待在持续两年的失望之后,已经有很多用户在考虑Istio以外的选择了。
|
||||
|
||||
所以,SMI一经发布,就吸引了除Istio之外几乎所有的服务网格玩家的目光,大家全部参与了进来,这恐怕并不只是因为微软号召力巨大的缘故。而且为了对抗Istio的抵制,SMI自己还提供了一个 Istio的适配器,以便使用Istio的程序能平滑地迁移到SMI之上,所以遗留代码并不能为Istio构建出特别坚固的壁垒。
|
||||
|
||||
到了2020年4月,SMI被托管到CNCF,成为其中的一个Sandbox项目(Sandbox是最低级别的项目,CNCF只提供有限度的背书),如果能够经过孵化、毕业阶段的话,SMI就有望成为公认的行业标准,这也是开源技术社区里民主管理的一点好处。
|
||||
|
||||
|
||||
|
||||
SMI规范的参与者
|
||||
|
||||
好了,到这里我们就了解了SMI的背景与价值,现在我们再来学习一下SMI的主要内容。目前(v0.5版本)的SMI规范包括四方面的API构成,下面我们就分别来看一下。
|
||||
|
||||
|
||||
流量规范(Traffic Specs)
|
||||
|
||||
|
||||
目标是定义流量的表示方式,比如TCP流量、HTTP/1流量、HTTP/2流量、gRPC流量、WebSocket流量等应该如何在配置中抽象和使用。目前SMI只提供了TCP和HTTP流量的直接支持,而且都比较简陋,比如HTTP流量的路由中,甚至连以Header作为判断条件都不支持。
|
||||
|
||||
当然,我们可以暂时自我安慰地解释为SMI在流量协议的扩展方面是完全开放的,没有功能也有可能自己扩充,哪怕不支持的或私有协议的流量,也有可能使用SMI来管理。而我们知道,流量表示是做路由和访问控制的必要基础,因为它必须要根据流量中的特征为条件,才能进行转发和控制,而流量规范中已经自带了路由能力,访问控制就被放到独立的规范中去实现了。
|
||||
|
||||
|
||||
流量拆分(Traffic Split)
|
||||
|
||||
|
||||
目标是定义不同版本服务之间的流量比例,提供流量治理的能力,比如限流、降级、容错,等等,以满足灰度发布、A/B测试等场景。
|
||||
|
||||
SMI的流量拆分是直接基于Kubernetes的Service资源来设置的,这样做的好处是使用者不需要去学习理解新的概念,而坏处是要拆分流量,就必须定义出具有层次结构的Service,即Service后面不是Pod,而是其他Service。而Istio中则是设计了VirtualService这样的新概念来解决相同的问题,它是通过Subset来拆分流量。至于两者孰优孰劣,这就见仁见智了。
|
||||
|
||||
|
||||
流量度量(Traffic Metrics)
|
||||
|
||||
|
||||
目标是为资源提供通用集成点,度量工具可以通过访问这些集成点来抓取指标。这部分完全遵循了Kubernetes的Metrics API进行扩充。
|
||||
|
||||
|
||||
流量访问控制(Traffic Access Control)
|
||||
|
||||
|
||||
目标是根据客户端的身份配置,对特定的流量访问特定的服务提供简单的访问控制。SMI绑定了Kubernetes的ServiceAccount来做服务身份访问控制,这里说的“简单”不是指它使用简单,而是说它只支持ServiceAccount一种身份机制,在正式使用中这恐怕是不足以应付所有场景的,日后应该还需要继续扩充。
|
||||
|
||||
以上这四种API目前暂时都是Alpha版本,也就是意味着它们还不够成熟,随时可能发生变动。从目前的版本来看,至少跟Istio的私有API相比,SMI还没有看到明显的优势,不过考虑到SMI还处于项目早期阶段,不够强大也情有可原,希望未来SMI可以成长为一个足够坚实可用的技术规范,这也有助于避免数据平面出现一家独大的情况,有利于竞争与发展。
|
||||
|
||||
通用数据面API
|
||||
|
||||
好,现在我们接着来了解一下通用数据面API的规范内容。同样是2019年5月,CNCF创立了一个名为“通用数据平面API工作组”(Universal Data Plane API Working Group,UDPA-WG)的组织,其工作目标是制定类似于软件定义网络中,OpenFlow协议的数据平面交互标准。可以说,工作组的名字被敲定的那一刻,就已经决定了它所产出的标准名字,必定是叫“通用数据平面API”(Universal Data Plane API,UDPA)。
|
||||
|
||||
其实,如果不纠结于是否足够标准、是否是由足够权威的组织来制定的话,上节课我介绍数据平面时提到的Envoy xDS协议族,就已经完全满足了控制平面与数据平面交互的需要。
|
||||
|
||||
事实上,Envoy正是UDPA-WG工作组的主要成员,在2019年11月的EnvoyCon大会上,Envoy的核心开发者、UDPA的负责人之一,来自Google公司的哈维 · 图奇(Harvey Tuch)做了一场以“The Universal Dataplane API:Envoy’s Next Generation APIs”为题的演讲,他详细而清晰地说明了xDS与UDAP之间的关系:UDAP的研发就是基于xDS的经验为基础的,在未来xDS将逐渐向UDPA靠拢,最终将基于UDPA来实现。
|
||||
|
||||
|
||||
|
||||
UDPA规范与xDS协议融合时间表
|
||||
|
||||
上图是我在哈维 · 图奇的演讲PPT中,截取的UDPA与xDS的融合时间表,在演讲中,哈维 · 图奇还提到了xDS协议的演进节奏会定为,每年推出一个大版本、每个版本从发布到淘汰起要经历Alpha、Stable、Deprecated、Removed四个阶段、每个阶段持续一年时间,简单地说就是每个大版本xDS在被淘汰前,会有三年的固定生命周期。
|
||||
|
||||
基于UDPA的xDS v4 API,原本计划会在2020年发布,进入Alpha阶段,不过,我写下这段文字的时间是2020年的10月中旬,已经可以肯定地说前面所列的这些计划一定会破产,因为从目前公开的资料看来,UDPA仍然处于早期设计阶段,距离完备都还有一段很长的路程,所以基于UDPA的xDS v4在2020年是铁定出不来了。
|
||||
|
||||
另外,在规范内容方面,由于UDPA连Alpha状态都还没能达到,目前公开的资料还很少。从GitHub和Google文档上能找到的部分设计原型文件来看,UDAP的主要内容会分为传输协议(UDPA-TP,TransPort)和数据模型(UDPA-DM,Data Model)两部分,这两个部分是独立设计的,也就是说,以后完全有可能会出现不同的数据模型共用同一套传输协议的可能性。
|
||||
|
||||
服务网格生态
|
||||
|
||||
OK,到这里,我们就基本理清了服务网格的主要规范。其实,从2016年“Service Mesh”一词诞生至今,不过短短四年时间,服务网格就已经从研究理论变成了在工业界中广泛采用的技术,用户的态度也从观望走向落地生产。
|
||||
|
||||
那么到目前,服务网格市场已经形成了初步的生态格局,尽管还没有决出最终的胜利者,但我们已经能基本看清这个领域里几个有望染指圣杯的玩家。下面,我就按照数据平面和控制平面,给你分别介绍一下目前服务网格产品的主要竞争者。
|
||||
|
||||
首先我们来看看在数据平面的主流产品,主要有5种:
|
||||
|
||||
|
||||
Linkerd
|
||||
|
||||
|
||||
2016年1月发布的Linkerd是服务网格的鼻祖,使用Scala语言开发的Linkerd-proxy也就成为了业界第一款正式的边车代理。一年后的2017年1月,Linkerd成功进入CNCF,成为云原生基金会的孵化项目,但此时的Linkerd其实已经显露出了明显的颓势:由于Linkerd-proxy运行需要Java虚拟机的支持,启动时间、预热、内存消耗等方面,相比起晚它半年发布的挑战者Envoy,均处于全面劣势,因而Linkerd很快就被Istio和Envoy的组合所击败,结束了它短暂的统治期。
|
||||
|
||||
|
||||
Envoy
|
||||
|
||||
|
||||
2016年9月开源的Envoy是目前边车代理产品中,市场占有率最高的一款,已经在很多个企业的生产环境里经受过大量检验。Envoy最初由Lyft公司开发,后来Lyft与Google和IBM三方达成合作协议,Envoy就成了Istio的默认数据平面。Envoy使用C++语言实现,比起Linkerd在资源消耗方面有了明显的改善。
|
||||
|
||||
此外,由于采用了公开的xDS协议进行控制,Envoy并不只为Istio所私有,这个特性也让Envoy被很多其他的管理平面选用,为它夺得市场占有率桂冠做出了重要贡献。2017年9月,Envoy加入CNCF,成为CNCF继Linkerd之后的第二个数据平面项目。
|
||||
|
||||
|
||||
nginMesh
|
||||
|
||||
|
||||
2017年9月,在NGINX Conf 2017大会上,Nginx官方公布了基于著名服务器产品Nginx实现的边车代理nginMesh。nginMesh使用C语言开发(有部分模块用了Golang和Rust),是Nginx从网络通信踏入程序通信的一次重要尝试。
|
||||
|
||||
而我们知道,Nginx在网络通信和流量转发方面拥有其他厂商难以匹敌的成熟经验,因此本该成为数据平面的有力竞争者才对。然而结果却是Nginix在这方面投入资源有限,方向摇摆,让nginMesh的发展一直都不温不火,到了2020年,nginMesh终于宣告失败,项目转入“非活跃”(No Longer Under Active)状态。
|
||||
|
||||
|
||||
Conduit/Linkerd 2
|
||||
|
||||
|
||||
2017年12月,在KubeCon大会上,Buoyant公司发布了Conduit的0.1版本,这是Linkerd-proxy被Envoy击败后,Buoyant公司使用Rust语言重新开发的第二代的服务网格产品,最初是以Conduit命名,在Conduit加入CNCF后不久,Buoyant公司宣布它与原有的Linkerd项目合并,被重新命名为Linkerd 2(这样就只算一个项目了)。
|
||||
|
||||
使用Rust重写后,Linkerd2-proxy的性能与资源消耗方面,都已经不输Envoy了,但它的定位通常是作为Linkerd 2的专有数据平面,所以成功与否,在很大程度上还是要取决于Linkerd 2的发展如何。
|
||||
|
||||
|
||||
MOSN
|
||||
|
||||
|
||||
2018年6月,来自蚂蚁金服的MOSN宣布开源,MOSN是SOFAStack中的一部分,使用Golang语言实现,在阿里巴巴及蚂蚁金服中经受住了大规模的应用考验。由于MOSN是技术阿里生态的一部分,对于使用了Dubbo框架,或者SOFABolt这样的RPC协议的微服务应用,MOSN往往能够提供些额外的便捷性。2019年12月,MOSN也加入了CNCF Landscape。
|
||||
|
||||
OK,前面我介绍的是知名度和使用率最高的一部分数据平面,我在选择时其实也考虑了不同程序语言实现的代表性,其他的没提及的数据平面还有HAProxy Connect、Traefik、ServiceComb Mesher,等等,我就不再逐一介绍了。
|
||||
|
||||
然后,除了数据平面,服务网格中另外一条争夺激烈的战线是控制平面产品,主要包括了以下几种:
|
||||
|
||||
|
||||
Linkerd 2
|
||||
|
||||
|
||||
这是Buoyant公司的服务网格产品,可以发现无论是数据平面还是控制平面,他们都采用了“Linkerd”和“Linkerd 2”的名字。
|
||||
|
||||
现在Linkerd 2的身份,已经从领跑者变成了Istio的挑战者。不过虽然代理的性能已经赶上了Envoy,但功能上Linkerd 2还是不能跟Istio相媲美,在mTLS、多集群支持、支持流量拆分条件的丰富程度等方面,Istio都比Linkerd 2要更有优势,毕竟两者背后的研发资源并不对等,一方是创业公司Buoyant,而另一方是Google、IBM等巨头。
|
||||
|
||||
然而,相比起Linkerd 2,Istio的缺点很大程度上也是由于其功能丰富带来的,每个用户真的都需要支持非Kubernetes环境、支持多集群单控制平面、支持切换不同的数据平面等这类特性吗?其实我认为,在满足需要的前提下,更小的功能集合往往意味着更高的性能与易用性。
|
||||
|
||||
|
||||
Istio
|
||||
|
||||
|
||||
这是Google、IBM和Lyft公司联手打造的产品,它是以自己的Envoy为默认数据平面。Istio是目前功能最强大的服务网格,如果你苦恼于这方面产品的选型,直接挑选Istio的话,不一定是最合适的,但起码能保证应该是不会有明显缺陷的选择;同时,Istio也是市场占有率第一的控制平面,不少公司发布的服务网格产品都是在它的基础上派生增强而来的,比如蚂蚁金服的SOFAMesh、Google Cloud Service Mesh等。
|
||||
|
||||
不过,服务网格毕竟比容器运行时、容器编排要年轻,Istio在服务网格领域尽管占有不小的优势,但统治力还远远不能与容器运行时领域的Docker和容器编排领域的Kubernetes相媲美。
|
||||
|
||||
|
||||
Consul Connect
|
||||
|
||||
|
||||
Consul Connect是来自HashiCorp公司的服务网格,Consul Connect的目标是把现有由Consul管理的集群,平滑升级为服务网格的解决方案。
|
||||
|
||||
就像Connect这个名字所预示的“链接”含义一样,Consul Connect十分强调它整合集成的角色定位,它不跟具体的网络和运行平台绑定,可以切换多种数据平面(默认为Envoy),支持多种运行平台,比如Kubernetest、Nomad或者标准的虚拟机环境。
|
||||
|
||||
|
||||
OSM
|
||||
|
||||
|
||||
Open Service Mesh(OSM)是微软公司在2020年8月开源的服务网格,它同样是以Envoy为数据平面。OSM项目的其中一个主要目标,是作为SMI规范的参考实现。同时,为了跟强大却复杂的Istio进行差异化竞争,OSM明确以“轻量简单”为卖点,通过减少边缘功能和对外暴露的API数量,降低服务网格的学习使用成本。
|
||||
|
||||
现在,服务网格正处于群雄争霸的战国时期,世界三大云计算厂商中,亚马逊的AWS App Mesh走的是专有闭源的发展路线,剩下就只有微软与Google具有相似的体量,能够对等地掰手腕了。
|
||||
|
||||
但是,它们又选择了截然不同的竞争策略:OSM开源后,微软马上把它捐献给了CNCF,成为开源社区的一部分;与此相对,尽管CNCF与Istio都有着Google的背景关系,但Google却不惜违反与IBM、Lyft之间的协议,拒绝将Istio托管至CNCF,而是自建新组织转移了Istio的商标所有权。这种做法不出意外地遭到了开源界的抗议,让观众产生了一种微软与Google身份错位的感觉,在云计算的激烈竞争中,似乎已经再也分不清楚谁是恶龙、谁是屠龙少年了。
|
||||
|
||||
好,以上就是目前一些主流的控制平面的产品了,其他我没有提到的控制平面还有很多,比如Traefik Mesh、Kuma,等等,我就不再展开介绍了。如果你有兴趣的话,可以参考下我这里给出的链接。
|
||||
|
||||
小结
|
||||
|
||||
服务网格也许是未来的发展方向,但想要真正发展成熟并能大规模落地,还有很长的一段路要走。
|
||||
|
||||
一方面,相当多的程序员已经习惯了通过代码与组件库去进行微服务治理,并且已经积累了很多的经验,也能把产品做得足够成熟稳定,所以对服务网格的需求并不迫切;另一方面,目前服务网格产品的成熟度还有待提高,冒险迁移过于激进,也容易面临兼容性的问题。所以,也许我们要等到服务网格开始远离市场宣传的喧嚣,才会走向真正的落地。
|
||||
|
||||
一课一思
|
||||
|
||||
这节课已经是这门架构课程的最后一节了,希望这门课程能够对你有所启发,如果你学习完之后有什么感悟的话,希望你能留言与我分享。
|
||||
|
||||
不过接下来,我还会针对不同架构、技术方案(如单体架构、微服务、服务网格、无服务架构,等等),建立若干配套的代码工程,它们是整个课程中我所讲解的知识的实践示例。这些代码工程的内容就不需要录制音频了,你可以把它作为实际项目新创建时,可参考引用的基础代码。
|
||||
|
||||
好了,感谢你的阅读,如果你觉得有收获,把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
||||
171
专栏/周志明的架构课/62_Fenix'sBookstore的前端工程.md
Normal file
171
专栏/周志明的架构课/62_Fenix'sBookstore的前端工程.md
Normal file
@@ -0,0 +1,171 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
62 _ Fenix's Bookstore的前端工程
|
||||
你好,我是周志明,现在我们就到了课程的最后一个模块。在上节课的最后,我给你简要介绍过了这个模块的设置目的,也就是建立若干配套的代码工程,作为针对不同架构、技术方案(如单体架构、微服务、服务网格、无服务架构,等等)的演示程序。它们是整个课程中我所讲解的知识的实践示例。这些代码工程的内容不需要录制音频,你可以把它作为实际项目新创建时,可参考引用的基础代码。
|
||||
|
||||
这节课的内容是由这些工程的README.md文件同步而来的,不过因为没有经过持续集成工具自动处理,所以可能会有偶尔更新不一致的情况,我建议你可以到这些项目的GitHub页面上去查看最新情况。
|
||||
|
||||
|
||||
文档工程:
|
||||
|
||||
|
||||
软件架构探索;
|
||||
Vuepress支持的文档工程。
|
||||
|
||||
|
||||
前端工程:
|
||||
|
||||
|
||||
Mock.js支持的纯前端演示;
|
||||
Vue.js 2实现前端工程。
|
||||
|
||||
|
||||
后端工程:
|
||||
|
||||
|
||||
Spring Boot实现单体架构;
|
||||
Spring Cloud实现微服务架构;
|
||||
Kubernetes为基础设施的微服务架构;
|
||||
Istio为基础设施的服务网格架构;
|
||||
AWS Lambda为基础的无服务架构。
|
||||
|
||||
|
||||
|
||||
在课程最开始的“导读”一节课中,我已经说明了“The Fenix Project”的意义。Fenix’s Bookstore的主要目的是展示不同的后端技术架构,相对来说,前端并不是它的重点。不过,前端的页面比起后端的各种服务来是要直观得多的,它能让使用者更容易理解我们将要做的是一件什么事情。
|
||||
|
||||
假设你是一名驾驶初学者,合理的学习路径肯定应该是把汽车发动,然后慢慢行驶起来,而不是马上从“引擎动力原理”“变速箱构造”入手,去设法深刻地了解一台汽车。所以,下面我们就先来运行下程序,看看最终的效果是什么样子吧。
|
||||
|
||||
运行程序
|
||||
|
||||
我们通过以下几种途径,就可以马上浏览最终的效果:
|
||||
|
||||
|
||||
从互联网已部署(由Travis-CI提供支持)的网站(由GitHub Pages提供主机),直接在浏览器访问: http://bookstore.icyfenix.cn/。
|
||||
通过Docker容器方式运行:
|
||||
|
||||
|
||||
$ docker run -d -p 80:80 --name bookstore icyfenix/bookstore:frontend
|
||||
|
||||
|
||||
然后在浏览器访问:http://localhost。
|
||||
|
||||
|
||||
通过Git上的源码,以开发模式运行:
|
||||
|
||||
|
||||
# 克隆获取源码
|
||||
$ git clone https://github.com/fenixsoft/fenix-bookstore-frontend.git
|
||||
|
||||
# 进入工程根目录
|
||||
$ cd fenix-bookstore-frontend
|
||||
|
||||
# 安装工程依赖
|
||||
$ npm install
|
||||
|
||||
# 以开发模式运行,地址为localhost:8080
|
||||
$ npm run dev
|
||||
|
||||
|
||||
然后在浏览器访问:http://localhost:8080。
|
||||
|
||||
|
||||
|
||||
也许你已经注意到了,以上这些运行方式,都没有涉及到任何的服务端、数据库的部署。因为在现代的软件工程里,基于MVVM的工程结构可以让前、后端的开发完全分离,只要互相约定好服务的位置及模型即可。
|
||||
|
||||
Fenix’s Bookstore以开发模式运行的时候,会自动使用Mock.js拦截住所有的远程服务请求,并通过事先准备好的数据来完成对这些请求的响应。
|
||||
|
||||
同时,你也应当注意到,在以纯前端方式运行的时候,所有对数据的修改请求实际都是无效的。比如用户注册,无论你输入何种用户名、密码,由于请求的响应是静态预置的,所以最终都会以同一个预设的用户登录。也正是因为这样,我并没有提供“默认用户”“默认密码”一类的信息供用户使用,你可以随意输入即可登录。
|
||||
|
||||
不过,那些只维护在前端的状态依然是可以变动的,典型的比如对购物车、收藏夹的增删改。让后端服务保持无状态,而把状态维持在前端中的设计,对服务的伸缩性和系统的鲁棒性都有着很大的益处,多数情况下都是值得倡导的良好设计。而其伴随而来的状态数据导致请求头变大、链路安全性等问题,都会在后面的服务端部分专门讨论和解决。
|
||||
|
||||
构建产品
|
||||
|
||||
要知道,当你把程序用于正式部署时,一般不应该部署开发阶段的程序,而是要进行产品化(Production)与精简化(Minification)。你可以通过以下命令,由node.js驱动webpack来自动完成:
|
||||
|
||||
# 编译前端代码
|
||||
$ npm run build
|
||||
|
||||
|
||||
或者使用–report参数,同时输出依赖分析报告:
|
||||
|
||||
# 编译前端代码并生成报告
|
||||
$ npm run build --report
|
||||
|
||||
|
||||
编译结果会存放在/dist目录中,你应该把它拷贝到Web服务器的根目录下使用。而对于Fenix’s Bookstore的各个服务端来说,则通常是拷贝到网关工程中静态资源目录下。
|
||||
|
||||
与后端联调
|
||||
|
||||
同样,出于前后端分离的目的,理论上后端通常只应当依据约定的服务协议(接口定位、访问传输方式、参数及模型结构、服务水平协议等)提供服务,并以此为依据,进行不依赖前端的独立测试,最终集成时使用的是编译后的前端产品。
|
||||
|
||||
不过,在开发期就进行的前后端联合,在现今许多的企业之中仍然是主流形式,由一个人“全栈式”地开发某个功能时更是如此。所以,当你要在开发模式中进行联调时,需要修改项目根目录下的main.js文件,使其不导入Mock.js,也就是如下代码所示的条件语句判断为假:
|
||||
|
||||
/**
|
||||
* 默认在开发模式中启用mock.js代替服务端请求
|
||||
* 如需要同时调试服务端,请修改此处判断条件
|
||||
*/
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (process.env.MOCK) {
|
||||
require('./api/mock')
|
||||
}
|
||||
|
||||
|
||||
当然,也有其他一些相反的情况,比如需要在生产包中,仍然继续使用Mock.js提供服务时(比如Docker镜像icyfenix/bookstore:frontend就是如此),同样也应该修改这个条件,使其结果为真,在开发模式依然导入了Mock.js即可。
|
||||
|
||||
工程结构
|
||||
|
||||
Fenix’s Bookstore的工程结构完全符合vue.js工程的典型习惯,事实上它在建立时就是通过vue-cli初始化的。这项工程的结构与其中各个目录的作用主要如下所示:
|
||||
|
||||
+---build webpack编译配置,该目录的内容一般不做改动
|
||||
+---config webpack编译配置,用户需改动的内容提取至此
|
||||
+---dist 编译输出结果存放的位置
|
||||
+---markdown 与项目无关,用于支持markdown的资源(如图片)
|
||||
+---src
|
||||
| +---api 本地与远程的API接口
|
||||
| | +---local 本地服务,如localStorage、加密等
|
||||
| | +---mock 远程API接口的Mock
|
||||
| | | \---json Mock返回的数据
|
||||
| | \---remote 远程服务
|
||||
| +---assets 资源文件,会被webpack哈希和压缩
|
||||
| +---components vue.js的组件目录,按照使用页面的结构放置
|
||||
| | +---home
|
||||
| | | +---cart
|
||||
| | | +---detail
|
||||
| | | \---main
|
||||
| | \---login
|
||||
| +---pages vue.js的视图目录,存放页面级组件
|
||||
| | \---home
|
||||
| +---plugins vue.js的插件,如全局异常处理器
|
||||
| +---router vue-router路由配置
|
||||
| \---store vuex状态配置
|
||||
| \---modules vuex状态按名空间分隔存放
|
||||
\---static 静态资源,编译时原样打包,不会做哈希和压缩
|
||||
|
||||
|
||||
组件
|
||||
|
||||
Fenix’s Bookstore的前端部分是基于以下开源组件和免费资源构建的:
|
||||
|
||||
|
||||
Vue.js:渐进式JavaScript框架。
|
||||
Element:一套为开发者、设计师和产品经理准备的基于Vue 2.0的桌面端组件库。
|
||||
Axios:Promise based HTTP client for the browser and node.js。
|
||||
Mock.js:生成随机数据,拦截Ajax请求。
|
||||
DesignEvo:一款由PearlMountain有限公司设计研发的Logo设计软件。
|
||||
|
||||
|
||||
协议
|
||||
|
||||
课程的工程代码部分采用Apache 2.0协议进行许可。在遵循许可的前提下,你可以自由地对代码进行修改、再发布,也可以将代码用作商业用途。但要求你:
|
||||
|
||||
|
||||
署名:在原有代码和衍生代码中,保留原作者署名及代码来源信息;
|
||||
保留许可证:在原有代码和衍生代码中,保留Apache 2.0协议文件。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
133
专栏/周志明的架构课/63_基于SpringBoot的单体架构.md
Normal file
133
专栏/周志明的架构课/63_基于SpringBoot的单体架构.md
Normal file
@@ -0,0 +1,133 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
63 _ 基于Spring Boot的单体架构
|
||||
你好,我是周志明。
|
||||
|
||||
单体架构是Fenix’s Bookstore服务端的起始版本,它与后面的基于微服务(Spring Cloud、Kubernetes)、服务网格(Istio)、无服务(Serverless)架构风格实现的其他版本,在业务功能上的表现是完全一致的。
|
||||
|
||||
所以,如果你不是针对性地带着解决某个具体问题、了解某项具体工具或技术的目的而来,而是有比较充裕的时间,希望了解软件架构的全貌与发展的话,我就推荐你从这个工程入手,来探索现代软件架构。因为单体架构的结构相对来说比较直观和易于理解,这对后面要接触的其他架构风格,也可以起到良好的铺垫作用。
|
||||
|
||||
运行程序
|
||||
|
||||
好,同样地,我们可以根据以下几种途径来运行程序,看看它的最终效果是怎么样的。
|
||||
|
||||
|
||||
通过Docker容器的方式运行:
|
||||
|
||||
|
||||
$ docker run -d -p 8080:8080 --name bookstore icyfenix/bookstore:monolithic
|
||||
|
||||
|
||||
然后在浏览器访问:http://localhost:8080,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。
|
||||
|
||||
这里默认会使用HSQLDB的内存模式作为数据库,并在系统启动时自动初始化好了Schema,完全开箱即用。但这同时也意味着当程序运行结束时,所有的数据都不会被保留。而如果你希望使用HSQLDB的文件模式,或者其他非嵌入式的独立的数据库支持的话,也是很简单的。
|
||||
|
||||
这里我以常用的MySQL/MariaDB为例,程序中也已经内置了MySQL的表结构初始化脚本,你可以使用环境变量PROFILES来激活Spring Boot中针对MySQL所提供的配置,命令如下所示:
|
||||
|
||||
$ docker run -d -p 8080:8080 --name bookstore icyfenix/bookstore:monolithic -e PROFILES=mysql
|
||||
|
||||
|
||||
此时,你需要通过Docker link、Docker Compose或者直接在主机的Host文件中,提供一个名为mysql_lan的DNS映射,使程序能顺利链接到数据库。关于数据库的更多配置,你可以参考源码中的application-mysql.yml。
|
||||
|
||||
|
||||
通过Git上的源码,以Maven运行:
|
||||
|
||||
|
||||
# 克隆获取源码
|
||||
$ git clone https://github.com/fenixsoft/monolithic_arch_springboot.git
|
||||
|
||||
# 进入工程根目录
|
||||
$ cd monolithic_arch_springboot
|
||||
|
||||
# 编译打包
|
||||
# 采用Maven Wrapper,此方式只需要机器安装有JDK 8或以上版本即可,无需包括Maven在内的其他任何依赖
|
||||
# 如在Windows下应使用mvnw.cmd package代替以下命令
|
||||
$ ./mvnw package
|
||||
|
||||
# 运行程序,地址为localhost:8080
|
||||
$ java -jar target/bookstore-1.0.0-Monolithic-SNAPSHOT.j
|
||||
|
||||
|
||||
然后在浏览器访问:http://localhost:8080,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。
|
||||
|
||||
|
||||
通过Git上的源码,在IDE环境中运行:
|
||||
|
||||
|
||||
以IntelliJ IDEA为例,Git克隆本项目后,在File->Open菜单选择本项目所在的目录,或者pom.xml文件,以Maven方式导入工程。
|
||||
IDEA会自动识别出这是一个Spring Boot工程,并定位启动入口为BookstoreApplication,等到IDEA内置的Maven自动下载完所有的依赖包后,运行该类即可启动。
|
||||
如果你使用其他的IDE,没有对Spring Boot的直接支持,也可以自行定位到BookstoreApplication,这是一个带有main()方法的Java类,运行即可。
|
||||
你可以通过IDEA的Maven面板中,Lifecycle里面的package来对项目进行打包、发布。
|
||||
在IDE环境中修改配置(如数据库等)会更加简单,具体你可以参考工程里application.yml和application-mysql.yml中的内容。
|
||||
|
||||
|
||||
|
||||
技术组件
|
||||
|
||||
Fenix’s Bookstore单体架构的后端会尽可能地采用标准的技术组件进行构建,而不依赖于具体的实现,包括以下几种:
|
||||
|
||||
|
||||
JSR 370:Java API for RESTful Web Services 2.1(JAX-RS 2.1)-
|
||||
在RESTFul服务方面,采用的实现为Jersey 2,你也可以替换为Apache CXF、RESTeasy、WebSphere、WebLogic等。
|
||||
JSR 330:Dependency Injection for Java 1.0-
|
||||
在依赖注入方面,采用的实现为Spring Boot 2.0中内置的Spring Framework 5。虽然在大多数场合中都尽可能地使用了JSR 330的标准注解,但因为Spring在对@Named、@Inject等注解的支持表现上,跟它本身提供的注解存在差异,所以仍然会有少量地方使用了Spring的私有注解。如果你要替换成其他的CDI实现,比如HK2,就需要进行比较大的改动了。
|
||||
JSR 338:Java Persistence 2.2-
|
||||
在持久化方面,采用的实现为Spring Data JPA。你可以替换为Batoo JPA、EclipseLink、OpenJPA等实现,只需把使用CrudRepository所省略的代码手动补全回来即可,无需做其他改动。
|
||||
JSR 380:Bean Validation 2.0-
|
||||
在数据验证方面,采用的实现为Hibernate Validator 6,你也可以替换为Apache BVal等其他验证框架。
|
||||
JSR 315:Java Servlet 3.0-
|
||||
在Web访问方面,采用的实现为Spring Boot 2.0中默认的Tomcat 9 Embed,你也可以替换为Jetty、Undertow等其他Web服务器。
|
||||
|
||||
|
||||
不过,也有一些组件仍然依赖了非标准化的技术实现,包括以下两种:
|
||||
|
||||
|
||||
JSR 375:Java EE Security API specification 1.0-
|
||||
在认证/授权方面,在2017年才发布的JSR 375中,仍然没有直接包含OAuth2和JWT的直接支持。这里因为后续实现微服务架构时作对比的需要,在单体架构中,我选择了Spring Security 5作为认证服务,Spring Security OAuth 2.3作为授权服务,Spring Security JWT作为JWT令牌支持,并没有采用标准的JSR 375实现,比如Soteria。
|
||||
JSR 353/367:Java API for JSON Processing/Binding-
|
||||
在JSON序列化/反序列化方面,由于Spring Security OAuth的限制(使用JSON-B作为反序列化器时的结果与Jackson等有差异),我采用了Spring Security OAuth默认的Jackson,并没有采用标准的JSR 353/367实现,比如Apache Johnzon、Eclipse Yasson等。
|
||||
|
||||
|
||||
工程结构
|
||||
|
||||
Fenix’s Bookstore单体架构的后端参考(并未完全遵循)了DDD的分层模式和设计原则,整体分为以下四层。
|
||||
|
||||
1. Resource
|
||||
|
||||
对应DDD中的User Interface层,负责向用户显示信息或者解释用户发出的命令。
|
||||
|
||||
请注意,这里指的“用户”不一定是使用用户界面的人,而可以是位于另一个进程或计算机的服务。由于这个工程采用了MVVM前后端分离的模式,因此这里所指的用户,实际上是前端的服务消费者,所以这里我就以RESTful中的核心概念“资源”(Resource)来命名了。
|
||||
|
||||
2. Application
|
||||
|
||||
对应DDD中的Application层,负责定义软件本身对外暴露的能力,即软件本身可以完成哪些任务,并负责对内协调领域对象来解决问题。
|
||||
|
||||
根据DDD的原则,应用层要尽量简单,不包含任何业务规则或者知识,而只为下一层中的领域对象协调任务,分配工作,使它们互相协作,这一点在代码上表现为Application层中,一般不会存在任何的条件判断语句。
|
||||
|
||||
实际上在许多项目中,Application层都会被选为包裹事务(代码进入此层事务开始,退出此层事务提交或者回滚)的载体。
|
||||
|
||||
3.Domain
|
||||
|
||||
对应DDD中的Domain层,负责实现业务逻辑,即表达业务概念,处理业务状态信息以及业务规则这些行为,此层是整个项目的重点。
|
||||
|
||||
4. Infrastructure
|
||||
|
||||
对应DDD中的Infrastructure层,向其他层提供通用的技术能力,比如持久化能力、远程服务通讯、工具集,等等。
|
||||
|
||||
|
||||
|
||||
协议
|
||||
|
||||
课程的工程代码部分采用Apache 2.0协议进行许可。在遵循许可的前提下,你可以自由地对代码进行修改、再发布,也可以将代码用作商业用途。但要求你:
|
||||
|
||||
|
||||
署名:在原有代码和衍生代码中,保留原作者署名及代码来源信息;
|
||||
保留许可证:在原有代码和衍生代码中,保留Apache 2.0协议文件。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
173
专栏/周志明的架构课/64_基于SpringCloud的微服务架构.md
Normal file
173
专栏/周志明的架构课/64_基于SpringCloud的微服务架构.md
Normal file
@@ -0,0 +1,173 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
64 _ 基于Spring Cloud的微服务架构
|
||||
你好,我是周志明。
|
||||
|
||||
直到现在,由不同编程语言、不同技术框架所开发的微服务系统中,基于Spring Cloud的解决方案仍然是最主流的选择。这个结果既是Java在服务端应用所积累的深厚根基的体现,也是Spring在Java生态系统中统治地位的体现。
|
||||
|
||||
而且,从Spring Boot到Spring Cloud的过渡,让现存数量非常庞大的、基于Spring和Spring Boot的单体系统可以平滑地迁移到微服务架构中,让这些系统的大部分代码都能够无需修改,或少量修改即可保留重用。
|
||||
|
||||
在微服务时代的早期,Spring Cloud就集成了Netflix OSS(以及Spring Cloud Netflix进入维护期后对应的替代组件)这种成体系的微服务套件,基本上也能算“半透明地”满足了在微服务环境中,必然会面临的服务发现、远程调用、负载均衡、集中配置等非功能性的需求。
|
||||
|
||||
不过,我个人是一直不太倾向于Spring Cloud Netflix这种,以应用代码去解决基础设施功能问题的“解题思路”。因为以自顶向下的视角来看,这既是虚拟化的微服务基础设施完全成熟之前,必然会出现的应用形态,也是微服务进化过程中必然会被替代的过渡形态。
|
||||
|
||||
不过,我的看法如何并不重要,基于Spring Cloud Netflix的微服务在当前就是主流,甚至直到未来不算短的一段时间内仍然都会是主流。而且从应用的视角来看,能自底向上地观察基础设施在微服务中面临的需求和挑战,能用我们最熟悉的Java代码来解释分析问题,也有利于深入理解微服务的整体思想。所以,把它作为我们了解的第一种微服务架构的实现,我认为是十分适合的。
|
||||
|
||||
那么下面,我们就先来具体了解下,在这种微服务架构下Fenix’s Bookstore的需求场景是什么。
|
||||
|
||||
需求场景
|
||||
|
||||
小书店Fenix’s Bookstore生意日益兴隆,客人、货物、营收都在持续增长,业务越来越复杂,对信息系统的并发与可用方面的要求也越来越高。当然了,由于业务属性和质量属性要求的提升,信息系统需要更多的硬件资源去支撑,这是合情合理的。但是,如果我们把需求场景列得更具体些,就会发现“合理”下面还有很多的无可奈何之处。
|
||||
|
||||
比如说,制约软件质量与业务能力提升的最大因素是人,而不是硬件。要知道,大多数企业即使再有钱也很难招到大量的、靠谱的开发者。此时,无论是引入外包团队,还是让少量技术专家带着大量普通水平的开发者去共同完成一个大型系统,就成为了必然的选择。
|
||||
|
||||
在单体架构下,没有什么能有效阻断错误传播的手段,系统中“整体”与“部分”的关系没有物理的划分,只能靠研发与项目管理措施来尽可能地保障系统质量,少量的技术专家也很难阻止大量螺丝钉式的程序员,或者是不熟悉原有技术架构的外包人员,在某个不起眼的地方犯错并产生全局性的影响,所以并不容易做出整体可靠的大型系统。
|
||||
|
||||
再比如说,技术异构的需求从可选渐渐成为了必须。Fenix’s Bookstore的单体版本是以目前应用范围最广的Java编程语言来开发的,但我们依然可能遇到很多想做可Java却不擅长的事情。比如想去做人工智能,进行深度学习训练,发现大量的库和开源代码都离不开Python;想要引入分布式协调工具时,发现近几年,ZooKeeper已经有被后起之秀Golang的etcd蚕食替代的趋势;想要做集中式缓存,发现无可争议的首选是ANSI C编写的Redis,等等。
|
||||
|
||||
很多时候,为异构能力进行的分布式部署,并不是你想或者不想的问题,而是没有选择、无可避免的问题。
|
||||
|
||||
微服务的需求场景还有很多,这里我就不多列举了。总之,系统发展到一定程度,我们总能找到充分的理由去拆分与重构它。
|
||||
|
||||
在我设定的演示案例中,准备把单体的Fenix’s Bookstore 拆分为“用户”“商品”“交易”三个能够独立运行的子系统,它们将在一系列非功能性技术模块(认证、授权等)和基础设施(配置中心、服务发现等)的支撑下互相协作,以统一的API网关对外提供与原来单体系统功能一致的服务,其应用视图如下图所示:
|
||||
|
||||
|
||||
|
||||
运行程序
|
||||
|
||||
我们可以通过以下几种途径来运行程序,浏览最终的效果。
|
||||
|
||||
|
||||
通过Docker容器方式运行:
|
||||
|
||||
|
||||
微服务涉及到多个容器的协作,通过link单独运行容器,已经被Docker官方声明为不提倡的方式。所以在工程中我提供了专门的配置,以便你使用docker-compose来运行:
|
||||
|
||||
# 下载docker-compose配置文件
|
||||
$ curl -O https://raw.githubusercontent.com/fenixsoft/microservice_arch_springcloud/master/docker-compose.yml
|
||||
|
||||
# 启动服务
|
||||
$ docker-compose up
|
||||
|
||||
|
||||
然后在浏览器访问:http://localhost:8080,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。
|
||||
|
||||
|
||||
通过Git上的源码,以Maven编译、运行:
|
||||
|
||||
|
||||
由于我已经在配置文件中,设置好了各个微服务的默认的地址和端口号,以便于本地调试,所以如果你要在同一台机运行这些服务,并且每个微服务都只启动一个实例的话,那不加任何配置、参数,就可以正常以Maven编译、以Jar包形式运行。
|
||||
|
||||
另外,由于各个微服务需要从配置中心里获取具体的参数信息,因此唯一的要求只是“配置中心”的微服务必须作为第一个启动的服务进程,其他就没有别的前置要求了。具体的操作过程如下所示:
|
||||
|
||||
# 克隆获取源码
|
||||
$ git clone https://github.com/fenixsoft/microservice_arch_springcloud.git
|
||||
|
||||
# 进入工程根目录
|
||||
$ cd microservice_arch_springcloud
|
||||
|
||||
# 编译打包
|
||||
# 采用Maven Wrapper,此方式只需要机器安装有JDK 8或以上版本即可,无需包括Maven在内的其他任何依赖
|
||||
# 克隆后你可能需要使用chmod给mvnw赋予执行权限,如在Windows下应使用mvnw.cmd package代替以下命令
|
||||
$ ./mvnw package
|
||||
|
||||
# 工程将编译出七个SpringBoot Jar
|
||||
# 启动服务需要运行以下七个微服务组件
|
||||
# 配置中心微服务:localhost:8888
|
||||
$ java -jar ./bookstore-microservices-platform-configuration/target/bookstore-microservice-platform-configuration-1.0.0-SNAPSHOT.jar
|
||||
# 服务发现微服务:localhost:8761
|
||||
$ java -jar ./bookstore-microservices-platform-registry/target/bookstore-microservices-platform-registry-1.0.0-SNAPSHOT.jar
|
||||
# 服务网关微服务:localhost:8080
|
||||
$ java -jar ./bookstore-microservices-platform-gateway/target/bookstore-microservices-platform-gateway-1.0.0-SNAPSHOT.jar
|
||||
# 安全认证微服务:localhost:8301
|
||||
$ java -jar ./bookstore-microservices-domain-security/target/bookstore-microservices-domain-security-1.0.0-SNAPSHOT.jar
|
||||
# 用户信息微服务:localhost:8401
|
||||
$ java -jar ./bookstore-microservices-domain-account/target/bookstore-microservices-domain-account-1.0.0-SNAPSHOT.jar
|
||||
# 商品仓库微服务:localhost:8501
|
||||
$ java -jar ./bookstore-microservices-domain-warehouse/target/bookstore-microservices-domain-warehouse-1.0.0-SNAPSHOT.jar
|
||||
# 商品交易微服务:localhost:8601
|
||||
$ java -jar ./bookstore-microservices-domain-payment/target/bookstore-microservices-domain-payment-1.0.0-SNAPSHOT.jar
|
||||
|
||||
|
||||
由于在命令行启动多个服务、通过容器实现各服务隔离、扩展等都比较繁琐,我提供了一个docker-compose.dev.yml文件,便于你在开发期调试使用:
|
||||
|
||||
# 使用Maven编译出JAR包后,可使用以下命令直接在本地构建镜像运行
|
||||
$ docker-compose -f docker-compose.dev.yml up
|
||||
|
||||
|
||||
以上两种本地运行的方式你可以任选其一,服务全部启动后,在浏览器访问:http://localhost:8080,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。
|
||||
|
||||
|
||||
通过Git上的源码,在IDE环境中运行:
|
||||
|
||||
|
||||
以IntelliJ IDEA为例,Git克隆本项目后,在File -> Open菜单选择本项目所在的目录,或者pom.xml文件,以Maven方式导入工程。
|
||||
待Maven自动安装依赖后,即可在IDE或者Maven面板中编译全部子模块的程序。
|
||||
本工程下面的八个模块,其中除bookstore-microservices-library-infrastructure外,其余均是Spring Boot工程,将这七个工程的Application类加入到IDEA的Run Dashboard面板中。
|
||||
在Run Dashboard中先启动“bookstore-microservices-platform-configuration”微服务,然后可以一次性启动其余六个子模块的微服务。
|
||||
|
||||
|
||||
配置与横向扩展:-
|
||||
工程中还预留了一些环境变量,以便于配置和扩展,比如想要在非容器的单机环境中,模拟热点模块的服务扩容,就需要调整每个服务的端口号。预留的这类环境变量包括:
|
||||
|
||||
|
||||
# 修改配置中心的主机和端口,默认为localhost:8888
|
||||
CONFIG_HOST
|
||||
CONFIG_PORT
|
||||
|
||||
# 修改服务发现的主机和端口,默认为localhost:8761
|
||||
REGISTRY_HOST
|
||||
REGISTRY_PORT
|
||||
|
||||
# 修改认证中心的主机和端口,默认为localhost:8301
|
||||
AUTH_HOST
|
||||
AUTH_PORT
|
||||
|
||||
# 修改当前微服务的端口号
|
||||
# 比如,你打算在一台机器上扩容四个支付微服务以应对促销活动的流量高峰
|
||||
# 可将它们的端口设置为8601(默认)、8602、8603、8604等
|
||||
# 真实环境中,它们可能是在不同的物理机、容器环境下,这时扩容可无需调整端口
|
||||
PORT
|
||||
|
||||
# SpringBoot所采用Profile配置文件,默认为default
|
||||
# 比如,服务默认使用HSQLDB的内存模式作为数据库,如需调整为MySQL,可将此环境变量调整为mysql
|
||||
# 因为我默认预置了名为applicaiton-mysql.yml的配置,以及HSQLDB和MySQL的数据库脚本
|
||||
# 如果你需要支持其他数据库、修改程序中其他的配置信息,可以在代码中自行加入另外的初始化脚本
|
||||
PROFILES
|
||||
|
||||
# Java虚拟机运行参数,默认为空
|
||||
JAVA_OPTS
|
||||
|
||||
|
||||
技术组件
|
||||
|
||||
Fenix’s Bookstore采用基于Spring Cloud微服务架构,微服务部分主要采用了Netflix OSS组件进行支持,它们包括:
|
||||
|
||||
|
||||
配置中心:默认采用Spring Cloud Config,也可使用Spring Cloud Consul、Spring Cloud Alibaba Nacos代替。
|
||||
服务发现:默认采用Netflix Eureka,也可使用Spring Cloud Consul、Spring Cloud ZooKeeper、etcd等代替。
|
||||
服务网关:默认采用Netflix Zuul,也可使用Spring Cloud Gateway代替。
|
||||
服务治理:默认采用Netflix Hystrix,也可使用Sentinel、Resilience4j代替。
|
||||
进程内负载均衡:默认采用Netfilix Ribbon,也可使用Spring Cloud Loadbalancer代替。
|
||||
声明式HTTP客户端:默认采用Spring Cloud OpenFeign。声明式的HTTP客户端其实没有找替代品的必要性,如果需要,你可以考虑Retrofit,或者使用RestTemplete乃至于更底层的OkHTTP、HTTPClient以命令式编程来访问,多写一些代码而已。
|
||||
|
||||
|
||||
尽管Netflix套件的使用人数很多,但考虑到Spring Cloud Netflix已经进入维护模式,所以这里我都列出了上述组件的代替品。这些组件几乎都是声明式的,这确保了它们的替代成本相当低廉,只需要更换注解,修改配置,无需改动代码。你在阅读源码时也会发现,三个“platform”开头的服务,基本上没有任何实际代码的存在。
|
||||
|
||||
其他与微服务无关的技术组件(REST服务、安全、数据访问,等等),我已经在Fenix’s Bookstore单体架构中介绍过了,这里就不再重复。
|
||||
|
||||
协议
|
||||
|
||||
课程的工程代码部分采用Apache 2.0协议进行许可。在遵循许可的前提下,你可以自由地对代码进行修改、再发布,也可以将代码用作商业用途。但要求你:
|
||||
|
||||
|
||||
署名:在原有代码和衍生代码中,保留原作者署名及代码来源信息;
|
||||
保留许可证:在原有代码和衍生代码中,保留Apache 2.0协议文件。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
157
专栏/周志明的架构课/65_基于Kubernetes的微服务架构.md
Normal file
157
专栏/周志明的架构课/65_基于Kubernetes的微服务架构.md
Normal file
@@ -0,0 +1,157 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
65 _ 基于Kubernetes的微服务架构
|
||||
你好,我是周志明。
|
||||
|
||||
我在第5讲中,曾经把2017年描述为是“后微服务时代”的开端,这是容器生态发展历史中具有里程碑意义的一年。
|
||||
|
||||
在这一年,长期作为Docker竞争对手的RKT容器一派的领导者CoreOS,宣布放弃自己的容器管理系统Fleet,未来将会把所有容器管理的功能转移到Kubernetes之上去实现。
|
||||
|
||||
在这一年,容器管理领域的独角兽Rancher Labs,宣布放弃其内置了数年的容器管理系统Cattle,提出了“All-in-Kubernetes”战略,从2.0版本开始把1.x版本能够支持多种容器管理工具的Rancher,“反向升级”为只支持Kubernetes一种容器管理系统。
|
||||
|
||||
在这一年,Kubernetes的主要竞争者Apache Mesos在9月正式宣布了“Kubernetes on Mesos”集成计划,由竞争关系转为对Kubernetes提供支持,使其能够与Mesos的其他一级框架(如HDFS、Spark 和Chronos,等等)进行集群资源动态共享、分配与隔离。
|
||||
|
||||
在这一年,Kubernetes的最大竞争者Docker Swarm的母公司Docker,终于在10月被迫宣布Docker要同时支持Swarm与Kubernetes两套容器管理系统,事实上承认了Kubernetes的统治地位。
|
||||
|
||||
至此,这场已经持续了三、四年时间,以Docker Swarm、Apache Mesos与Kubernetes为主要竞争者的“容器战争”就终于有了明确的结果,Kubernetes登基加冕是容器发展中一个时代的终章,也将是软件架构发展下一个纪元的开端。
|
||||
|
||||
需求场景
|
||||
|
||||
当引入了基于Spring Cloud的微服务架构后,小书店Fenix’s Bookstore初步解决了扩容缩容、独立部署、运维和管理等问题,满足了产品经理不断提出的日益复杂的业务需求。
|
||||
|
||||
可是,对于团队的开发人员、设计人员、架构人员来说,并没有感觉到工作变得轻松,微服务中的各种新技术名词,比如配置中心、服务发现、网关、熔断、负载均衡等,就够一名新手学习好长一段时间;从产品角度来看,各种Spring Cloud的技术套件,比如Config、Eureka、Zuul、Hystrix、Ribbon、Feign等,也占据了产品的大部分编译后的代码容量。
|
||||
|
||||
而之所以在微服务架构里,我们选择在应用层面,而不是基础设施层面去解决这些分布式问题,完全是因为由硬件构成的基础设施,跟不上由软件构成的应用服务灵活性的无奈之举。
|
||||
|
||||
不过,当Kubernetes统一了容器编排管理系统之后,这些纯技术性的底层问题,就开始有了被广泛认可和采纳的基础设施层面的解决方案。为此,Fenix’s Bookstore也迎来了它在“后微服务时代”中的下一次架构演进,这次升级的目标主要有两点。
|
||||
|
||||
|
||||
目标一:尽可能缩减非业务功能代码的比例。
|
||||
|
||||
|
||||
在Fenix’s Bookstore中,用户服务(Account)、商品服务(Warehouse)、交易服务(Payment)三个工程是真正承载业务逻辑的,认证授权服务(Security)可以认为是同时涉及到了技术与业务,而配置中心(Configuration)、网关(Gateway)和服务注册中心(Registry)则是纯技术性。我们希望尽量消除这些纯技术的工程,以及那些依附在其他业务工程上的纯技术组件。
|
||||
|
||||
|
||||
目标二:尽可能在不影响原有代码的前提下完成迁移。
|
||||
|
||||
|
||||
得益于Spring Framework 4中的Conditional Bean等声明式特性的出现,近年来新发布的Java技术组件中,声明式编程(Declarative Programming)已经逐步取代了命令式编程(Imperative Programming),成为主流的选择。
|
||||
|
||||
在声明式编程的支持下,我们可以从目的而不是过程的角度,去描述编码意图,让代码几乎不会与具体的技术实现产生耦合。而如果要更换一种技术实现,我们也只需要调整配置中的声明即可。
|
||||
|
||||
那么,从升级结果来看,如果仅以Java代码的角度来衡量,本工程与此前基于Spring Cloud的实现没有任何差异,两者的每一行Java代码都是一模一样的。
|
||||
|
||||
而实际上,真正的区别在于Kubernetes的实现版本中,直接删除了配置中心、服务注册中心的工程,在其他工程的pom.xml中,也删除了如Eureka、Ribbon、Config等组件的依赖。取而代之的是新增了若干以YAML配置文件为载体的Skaffold和Kubernetes的资源描述,这些资源描述文件,将会动态构建出DNS服务器、服务负载均衡器等一系列虚拟化的基础设施,去代替原有的应用层面的技术组件。升级改造之后的应用架构如下图所示:
|
||||
|
||||
|
||||
|
||||
运行程序
|
||||
|
||||
在已经部署Kubernetes集群的前提下,你可以通过以下几种途径运行程序,浏览最终的效果:
|
||||
|
||||
|
||||
直接在Kubernetes集群环境上运行:
|
||||
|
||||
|
||||
工程在编译时,就已经通过Kustomize产生出集成式的资源描述文件,你可以通过该文件直接在Kubernetes集群中运行程序:
|
||||
|
||||
# 资源描述文件
|
||||
$ kubectl apply -f https://raw.githubusercontent.com/fenixsoft/microservice_arch_kubernetes/master/bookstore.yml
|
||||
|
||||
|
||||
注意,在命令执行的过程中,一共需要下载几百MB的镜像,尤其是当Docker中没有各层基础镜像缓存时,请你根据自己的网速保持一定的耐心。等未来GraalVM对Spring Cloud的支持更成熟一些后,你也可以考虑采用GraalVM来改善这一点。
|
||||
|
||||
好,当所有的Pod都处于正常工作状态后,你可以在浏览器访问:http://localhost:30080,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。
|
||||
|
||||
|
||||
通过Skaffold在命令行或IDE中以调试方式运行:
|
||||
|
||||
|
||||
一般开发基于Kubernetes的微服务应用,是在本地针对单个服务编码、调试完成后,通过CI/CD流水线部署到Kubernetes中进行集成的。如果只是针对集成测试,这并没有什么问题,但同样的做法应用在开发阶段就相当不方便了,我们不希望每做一处修改,都要经过一次CI/CD流程,这会非常耗时而且难以调试。
|
||||
|
||||
Skaffold是Google在2018年开源的一款加速应用在本地或远程Kubernetes集群中,构建、推送、部署和调试的自动化命令行工具。对于Java应用来说,它可以帮助我们做到监视代码变动,自动打包出镜像,将镜像打上动态标签并更新部署到Kubernetes集群,为Java程序注入开放JDWP调试的参数,并根据Kubernetes的服务端口自动在本地生成端口转发。
|
||||
|
||||
以上都是根据skaffold.yml中的配置来进行的,开发时Skaffold会通过dev指令来执行这些配置,具体的操作过程如下所示:
|
||||
|
||||
# 克隆获取源码
|
||||
$ git clone https://github.com/fenixsoft/microservice_arch_kubernetes.git && cd microservice_arch_kubernetes
|
||||
|
||||
# 编译打包
|
||||
$ ./mvnw package
|
||||
|
||||
# 启动Skaffold
|
||||
# 此时将会自动打包Docker镜像,并部署到Kubernetes中
|
||||
$ skaffold dev
|
||||
|
||||
|
||||
服务全部启动后,你可以在浏览器访问:http://localhost:30080,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。
|
||||
|
||||
另外,由于面向的是开发环境,基于效率原因,我并没有像传统CI工程那样,直接使用Maven的Docker镜像来打包Java源码,而这就决定了在构建Dockerfile时,我们要监视的变动目标将是Jar文件,而不是Java源码。Skaffold的执行是由Jar包的编译结果来驱动的,它只在进行Maven编译、输出了新的Jar包后才会更新镜像。
|
||||
|
||||
这样做的原因,一方面是考虑到在Maven镜像中打包,不方便利用本地的仓库缓存,尤其在国内网络中,速度实在难以忍受;另一方面,是我其实并不希望每保存一次源码时,都自动构建和更新一次镜像,毕竟比起传统的HotSwap或者Spring Devtool Reload来说,更新镜像重启Pod是一个更加重负载的操作。未来CNCF的Buildpack成熟之后,应该可以绕过笨重的Dockerfile,对打包和容器热更新做更加精细化的控制。
|
||||
|
||||
另外,如果你有IDE调试的需求,我推荐你采用Google Cloud Code(Cloud Code同时提供了VS Code和IntelliJ Idea的插件)来配合Skaffold使用,毕竟这是同一个公司出品的产品,搭配起来能获得几乎与本地开发单体应用一致的编码和调试体验。
|
||||
|
||||
技术组件
|
||||
|
||||
Fenix’s Bookstore采用基于Kubernetes的微服务架构,并采用Spring Cloud Kubernetes做了适配,其中主要的技术组件包括以下几种。
|
||||
|
||||
|
||||
环境感知
|
||||
|
||||
|
||||
Spring Cloud Kubernetes本身引入了Fabric8的Kubernetes Client作为容器环境感知,不过引用的版本很旧,比如Spring Cloud Kubernetes 1.1.2中,采用的是Fabric8 Kubernetes Client 4.4.1,Fabric8提供的兼容性列表中,这个版本只支持到Kubernetes 1.14,虽然实测在1.16上也能用,但是在1.18上就无法识别到最新的Api-Server。
|
||||
|
||||
因此Maven引入依赖时,你需要手工处理,排除旧版本,引入新版本(本工程采用的是4.10.1)。
|
||||
|
||||
|
||||
配置中心
|
||||
|
||||
|
||||
采用Kubernetes的ConfigMap来管理,通过Spring Cloud Kubernetes Config自动将ConfigMap的内容注入到Spring配置文件中,并实现动态更新。
|
||||
|
||||
|
||||
服务发现
|
||||
|
||||
|
||||
采用Kubernetes的Service来管理,通过Spring Cloud Kubernetes Discovery自动将HTTP访问中的服务转换为FQDN。
|
||||
|
||||
|
||||
负载均衡
|
||||
|
||||
|
||||
采用Kubernetes Service本身的负载均衡能力实现(就是DNS负载均衡),就可以不再需要Ribbon这样的客户端负载均衡了。Spring Cloud Kubernetes从1.1.2开始,也已经移除了对Ribbon的适配支持,也(暂时)没有对其代替品Spring Cloud LoadBalancer提供适配。
|
||||
|
||||
|
||||
服务网关
|
||||
|
||||
|
||||
网关部分仍然保留了Zuul,没有采用Ingress来代替。这里我主要有两点考虑,一是Ingress Controller不算是Kubernetes的自带组件,它可以有不同的选择(如KONG、Nginx、Haproxy等),同时也需要独立安装,因此作为演示工程,出于环境复杂度最小化的考虑,我没有使用Ingress;二是Fenix’s Bookstore的前端工程是存放在网关中的,移除了Zuul之后也仍然要维持一个前端工程的存在,不能进一步缩减工程数量,也就削弱了移除Zuul的动力。
|
||||
|
||||
|
||||
服务熔断
|
||||
|
||||
|
||||
这里仍然采用Hystrix,Kubernetes本身无法做到精细化的服务治理,包括熔断、流控、监视,等等,我们将在基于Istio的服务网格架构中解决这个问题。
|
||||
|
||||
|
||||
认证授权
|
||||
|
||||
|
||||
这里仍然采用Spring Security OAuth 2.0,Kubernetes的RBAC授权可以解决服务层面的访问控制问题,但Security是跨越了业务和技术的边界的,认证授权模块本身仍然承担着对前端用户的认证、授权职责,这部分是与业务相关的。
|
||||
|
||||
协议
|
||||
|
||||
课程的工程代码部分采用Apache 2.0协议进行许可。在遵循许可的前提下,你可以自由地对代码进行修改、再发布,也可以将代码用作商业用途。但要求你:
|
||||
|
||||
|
||||
署名:在原有代码和衍生代码中,保留原作者署名及代码来源信息;
|
||||
保留许可证:在原有代码和衍生代码中,保留Apache 2.0协议文件。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
149
专栏/周志明的架构课/66_基于Istio的服务网格架构.md
Normal file
149
专栏/周志明的架构课/66_基于Istio的服务网格架构.md
Normal file
@@ -0,0 +1,149 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
66 _ 基于Istio的服务网格架构
|
||||
你好,我是周志明。
|
||||
|
||||
当软件架构演进到基于Kubernetes实现的微服务时,已经能够相当充分地享受到虚拟化技术发展的红利,比如应用能够灵活地扩容缩容、不再畏惧单个服务的崩溃消亡、立足应用系统更高层来管理和编排各服务之间的版本、交互。
|
||||
|
||||
可是,单纯的Kubernetes仍然不能解决我们面临的所有分布式技术问题。
|
||||
|
||||
在上一讲针对基于Kubernetes架构中的“技术组件”的介绍里,我已经说过,光靠着Kubernetes本身的虚拟化基础设施,很难做到精细化的服务治理,比如熔断、流控、观测,等等;而即使是那些它可以提供支持的分布式能力,比如通过DNS服务来实现的服务发现与负载均衡,也只能说是初步解决了分布式中如何调用服务的问题而已,只靠DNS其实很难满足根据不同的配置规则、协议层次、均衡算法等,去调节负载均衡的执行过程这类高级的配置需求。
|
||||
|
||||
Kubernetes提供的虚拟化基础设施,是我们尝试从应用中剥离分布式技术代码踏出的第一步,但只从微服务的灵活与可控这一点来说,基于Kubernetes实现的版本其实比上一个Spring Cloud版本里用代码实现的效果(功能强大、灵活程度)有所倒退,这也是当时我们没有放弃Hystrix、Spring Security OAuth 2.0等组件的原因。
|
||||
|
||||
所以说,Kubernetes给予了我们强大的虚拟化基础设施,这是一把好用的锤子,但我们却不必把所有问题都看作钉子,不必只局限于纯粹基础设施的解决方案。
|
||||
|
||||
现在,基于Kubernetes之上构筑的服务网格(Service Mesh)是目前最先进的架构风格,也就是通过中间人流量劫持的方式,以介乎于应用和基础设施之间的边车代理(Sidecar),来做到既让用户代码可以专注业务需求,不必关注分布式的技术,又能实现几乎不亚于此前Spring Cloud时代的那种,通过代码来解决分布式问题的可配置、安全和可观测性。
|
||||
|
||||
而这个目标,现在已经成为了最热门的服务网格框架Istio的Slogan:Connect, Secure, Control, And Observe Services。
|
||||
|
||||
需求场景
|
||||
|
||||
得益于Kubernetes的强力支持,小书店Fenix’s Bookstore已经能够依赖虚拟化基础设施进行扩容缩容,把用户请求分散到数量动态变化的Pod中处理,可以应对相当规模的用户量了。
|
||||
|
||||
不过,随着Kubernetes集群中的Pod数量规模越来越庞大,到一定程度之后,运维的同学就会无奈地表示,已经不能够依靠人力来跟进微服务中出现的各种问题了:一个请求在哪个服务上调用失败啦?是A有调用B吗?还是C调用D时出错了?为什么这个请求、页面忽然卡住了?怎么调度到这个Node上的服务比其他Node慢那么多?这个Pod有Bug,消耗了大量的TCP链接数……
|
||||
|
||||
而另外一方面,随着Fenix’s Bookstore程序规模与用户规模的壮大,开发团队的人员数量也变得越来越多。尽管根据不同微服务进行拆分,可以把每个服务的团队成员都控制在“2 Pizza Teams”的范围以内,但一个很现实的问题是高端技术人员的数量总是有限的,人多了就不可能保证每个人都是精英,如何让普通的、初级的程序员依然能够做出靠谱的代码,成为这一阶段技术管理者要重点思考的难题。
|
||||
|
||||
这时候,团队内部就出现了一种声音:微服务太复杂了,已经学不过来了,让我们回归单体吧……
|
||||
|
||||
所以在这样的故事背景下,Fenix’s Bookstore就迎来了它的下一次技术架构的演进,这次的进化的目标主要有两点:
|
||||
|
||||
|
||||
目标一:实现在大规模虚拟服务下可管理、可观测的系统。
|
||||
|
||||
|
||||
必须找到某种方法,针对应用系统整体层面,而不是针对单一微服务来连接、调度、配置和观测服务的执行情况。
|
||||
|
||||
此时,可视化整个系统的服务调用关系,动态配置调节服务节点的断路、重试和均衡参数,针对请求统一收集服务间的处理日志等功能,就不再是系统锦上添花的外围功能了,而是关系到系统能否正常运行、运维的必要支撑点。
|
||||
|
||||
|
||||
目标二:在代码层面,裁剪技术栈深度,回归单体架构中基于Spring Boot的开发模式,而不是Spring Cloud或者Spring Cloud Kubernetes的技术架构。
|
||||
|
||||
|
||||
我们并不是要去开历史的倒车,相反,我们是很贪心地希望开发重新变得简单的同时,又不能放弃现在微服务带来的一切好处。
|
||||
|
||||
在这个版本的Fenix’s Bookstore里,所有与Spring Cloud相关的技术组件,比如上个版本遗留的Zuul网关、Hystrix断路器,还有上个版本新引入的用于感知适配Kubernetes环境的Spring Cloud Kubernetes,都将会被拆除掉。如果只观察单个微服务的技术堆栈,它跟最初的单体架构几乎没有任何不同,甚至还更加简单了,连从单体架构开始一直保护着服务调用安全的Spring Security都移除掉了。
|
||||
|
||||
|
||||
由于Fenix’s Bookstore借用了Spring Security OAuth 2.0的密码模式做为登录服务的端点,所以在Jar包层面Spring Security还是存在的,但其用于安全保护的Servlet和Filter已经被关闭掉。
|
||||
|
||||
|
||||
那么从升级目标上,我们可以明确地得到一种导向,也就是我们必须控制住服务数量膨胀后传递到运维团队的压力,只有让“每个运维人员能支持服务的数量”这个比例指标有指数级的提高,才能确保微服务下运维团队的健康运作。
|
||||
|
||||
而对于开发团队,我们可以只要求一小部分核心的成员对微服务、Kubernetes、Istio等技术有深刻理解即可,其余大部分的开发人员,仍然可以基于最传统、普通的Spirng Boot技术栈来开发功能。升级改造之后的应用架构如下图所示:
|
||||
|
||||
|
||||
|
||||
运行程序
|
||||
|
||||
在已经部署Kubernetes与Istio的前提下,我们可以通过以下几种途径运行程序,来浏览最终的效果:
|
||||
|
||||
|
||||
在Kubernetes无Sidecar状态下运行:
|
||||
|
||||
|
||||
在业务逻辑的开发过程中,或者其他不需要双向TLS、不需要认证授权支持、不需要可观测性支持等非功能性能力增强的环境里,可以不启动Envoy(但还是要安装Istio的,因为用到了Istio Ingress Gateway),工程在编译时已经通过Kustomize产生出集成式的资源描述文件:
|
||||
|
||||
# Kubernetes without Envoy资源描述文件
|
||||
$ kubectl apply -f https://raw.githubusercontent.com/fenixsoft/servicemesh_arch_istio/master/bookstore-dev.yml
|
||||
|
||||
|
||||
请注意,资源文件中对Istio Ingress Gateway的设置是针对Istio默认安装编写的,即以istio-ingressgateway作为标签,以LoadBalancer形式对外开放80端口,对内监听8080端口。在部署时,可能需要根据实际情况进行调整,你可以观察以下命令的输出结果来确认这一点:
|
||||
|
||||
$ kubectl get svc istio-ingressgateway -nistio-system -o yaml
|
||||
|
||||
|
||||
然后,在浏览器访问:http://localhost,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。
|
||||
|
||||
|
||||
在Istio服务网格环境上运行:
|
||||
|
||||
|
||||
工程在编译时,已经通过Kustomize产生出集成式的资源描述文件,你可以通过该文件直接在Kubernetes with Envoy集群中运行程序:
|
||||
|
||||
# Kubernetes with Envoy 资源描述文件
|
||||
$ kubectl apply -f https://raw.githubusercontent.com/fenixsoft/servicemesh_arch_istio/master/bookstore.yml
|
||||
|
||||
|
||||
当所有的Pod都处于正常工作状态后(这个过程一共需要下载几百MB的镜像,尤其是Docker中没有各层基础镜像缓存时,请根据自己的网速保持一定的耐心。未来GraalVM对Spring Cloud的支持更成熟一些后,可以考虑采用GraalVM来改善这一点),在浏览器访问:http://localhost,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。
|
||||
|
||||
|
||||
通过Skaffold在命令行或IDE中以调试方式运行:
|
||||
|
||||
|
||||
这个运行方式与上一讲调试Kubernetes服务是完全一致的。它是在本地针对单个服务编码、调试完成后,通过CI/CD流水线部署到Kubernetes中进行集成的。不过如果只是针对集成测试,这并没有什么问题,但同样的做法应用在开发阶段就非常不方便了,我们不希望每做一处修改都要经过一次CI/CD流程,这会非常耗时而且难以调试。
|
||||
|
||||
Skaffold是Google在2018年开源的一款加速应用在本地或远程Kubernetes集群中,构建、推送、部署和调试的自动化命令行工具。对于Java应用来说,它可以帮助我们做到监视代码变动,自动打包出镜像,将镜像打上动态标签并更新部署到Kubernetes集群,为Java程序注入开放JDWP调试的参数,并根据Kubernetes的服务端口自动在本地生成端口转发。
|
||||
|
||||
以上都是根据skaffold.yml中的配置来进行的,开发时skaffold通过dev指令来执行这些配置,具体的操作过程如下所示:
|
||||
|
||||
# 克隆获取源码
|
||||
$ git clone https://github.com/fenixsoft/servicemesh_arch_istio.git && cd servicemesh_arch_istio
|
||||
|
||||
# 编译打包
|
||||
$ ./mvnw package
|
||||
|
||||
# 启动Skaffold
|
||||
# 此时将会自动打包Docker镜像,并部署到Kubernetes中
|
||||
$ skaffold dev
|
||||
|
||||
|
||||
服务全部启动后,你可以在浏览器访问:http://localhost,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。注意,这里开放和监听的端口同样取决于Istio Ingress Gateway,你可能需要根据系统环境来进行调整。
|
||||
|
||||
|
||||
调整代理自动注入:
|
||||
|
||||
|
||||
项目提供的资源文件中,默认是允许边车代理自动注入到Pod中的,而这会导致服务需要有额外的容器初始化过程。开发期间,我们可能需要关闭自动注入以提升容器频繁改动、重新部署时的效率。如果需要关闭代理自动注入,请自行调整bookstore-kubernetes-manifests目录下的bookstore-namespaces.yaml资源文件,根据需要将istio-injection修改为enable或者disable。
|
||||
|
||||
如果关闭了边车代理,就意味着你的服务丧失了访问控制(以前是基于Spring Security实现的,在Istio版本中这些代码已经被移除)、断路器、服务网格可视化等一系列依靠Envoy代理所提供能力。但这些能力是纯技术的,与业务无关,并不影响业务功能正常使用,所以在本地开发、调试期间关闭代理是可以考虑的。
|
||||
|
||||
技术组件
|
||||
|
||||
Fenix’s Bookstore采用基于Istio的服务网格架构,其中主要的技术组件包括:
|
||||
|
||||
|
||||
配置中心:通过Kubernetes的ConfigMap来管理。
|
||||
服务发现:通过Kubernetes的Service来管理,由于已经不再引入Spring Cloud Feign了,所以在OpenFeign中,我们直接使用短服务名进行访问。
|
||||
负载均衡:未注入边车代理时,依赖KubeDNS实现基础的负载均衡,一旦有了Envoy的支持,就可以配置丰富的代理规则和策略。
|
||||
服务网关:依靠Istio Ingress Gateway来实现,这里已经移除了Kubernetes版本中保留的Zuul网关。
|
||||
服务容错:依靠Envoy来实现,这里已经移除了Kubernetes版本中保留的Hystrix。
|
||||
认证授权:依靠Istio的安全机制来实现,这里实质上已经不再依赖Spring Security进行ACL控制,但Spring Security OAuth 2.0仍然以第三方JWT授权中心的角色存在,为系统提供终端用户认证,为服务网格提供令牌生成、公钥JWKS等支持。
|
||||
|
||||
|
||||
协议
|
||||
|
||||
课程的工程代码部分采用Apache 2.0协议进行许可。在遵循许可的前提下,你可以自由地对代码进行修改、再发布,也可以将代码用作商业用途。但要求你:
|
||||
|
||||
|
||||
署名:在原有代码和衍生代码中,保留原作者署名及代码来源信息;
|
||||
保留许可证:在原有代码和衍生代码中,保留Apache 2.0协议文件。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
90
专栏/周志明的架构课/67_基于云计算的无服务架构.md
Normal file
90
专栏/周志明的架构课/67_基于云计算的无服务架构.md
Normal file
@@ -0,0 +1,90 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
67 _ 基于云计算的无服务架构
|
||||
你好,我是周志明。
|
||||
|
||||
首先我们要知道,无服务架构(Serverless)跟微服务架构本身没有继承替代的关系,它们并不是同一种层次的架构,无服务的云函数可以作为微服务的一种实现方式,甚至可能是未来很主流的实现方式。在课程中,我们的话题主要还是聚焦在如何解决分布式架构下的种种问题,所以相对来说,无服务架构并不是重点,不过为了保证架构演进的完整性,我仍然建立了无服务架构的简单演示工程。
|
||||
|
||||
另外还要明确一点,由于无服务架构在原理上就决定了它对程序的启动性能十分敏感,这天生就不利于Java程序,尤其不利于Spring这类启动时组装的CDI框架。因此基于Java的程序,除非使用GraalVM做提前编译、将Spring的大部分Bean提前初始化,或者迁移至Quarkus这种以原生程序为目标的框架上,否则是很难实际用于生产的。
|
||||
|
||||
运行程序
|
||||
|
||||
Serverless架构的Fenix’s Bookstore是基于亚马逊AWS Lambda平台运行的,这是最早商用,也是目前全球规模最大的Serverless运行平台。不过从2018年开始,中国的主流云服务厂商,比如阿里云、腾讯云也都推出了各自的Serverless云计算环境,如果你需要在这些平台上运行Fenix’s Bookstore,你要根据平台提供的Java SDK对StreamLambdaHandler的代码做少许调整。
|
||||
|
||||
现在,假设你已经完成了AWS注册、配置AWS CLI环境以及IAM账号的前提下,就可以通过以下几种途径来运行程序,浏览最终的效果:
|
||||
|
||||
|
||||
通过AWS SAM(Serverless Application Model) Local在本地运行:
|
||||
|
||||
|
||||
AWS CLI中附有SAM CLI,但是版本比较旧,你可以通过如下地址安装最新版本的SAM CLI。另外,SAM需要Docker运行环境支持,你可参考此处部署。
|
||||
|
||||
首先编译应用出二进制包,执行以下标准Maven打包命令即可:
|
||||
|
||||
$ mvn clean package
|
||||
|
||||
|
||||
根据pom.xml中assembly-zip的设置,打包将不会生成SpringBoot Fat JAR,而是产生适用于AWS Lambda的ZIP包。打包后,确认已经在target目录生成了ZIP文件,并且文件名称与代码中提供的sam.yaml配置的一致,然后在工程根目录下运行如下命令,启动本地SAM测试:
|
||||
|
||||
$ sam local start-api --template sam.yaml
|
||||
|
||||
|
||||
在浏览器访问:http://localhost:3000,系统预置了一个用户(user:icyfenix,pw:123456),你也可以注册新用户来测试。
|
||||
|
||||
|
||||
通过AWS Serverless CLI将本地ZIP包上传至云端运行:
|
||||
|
||||
|
||||
在确认已经配置了AWS凭证后,工程中已经提供了serverless.yml配置文件,确认文件中ZIP的路径与实际Maven生成的一致,然后在命令行执行:
|
||||
|
||||
$ sls deploy
|
||||
|
||||
|
||||
此时,Serverless CLI会自动将ZIP文件上传至AWS S3,然后生成对应的Layers和API Gateway,运行结果如下所示:
|
||||
|
||||
$ sls deploy
|
||||
Serverless: Packaging service...
|
||||
Serverless: Uploading CloudFormation file to S3...
|
||||
Serverless: Uploading artifacts...
|
||||
Serverless: Uploading service bookstore-serverless-awslambda-1.0-SNAPSHOT-lambda-package.zip file to S3 (53.58 MB)...
|
||||
Serverless: Validating template...
|
||||
Serverless: Updating Stack...
|
||||
Serverless: Checking Stack update progress...
|
||||
..............
|
||||
Serverless: Stack update finished...
|
||||
Service Information
|
||||
service: spring-boot-serverless
|
||||
stage: dev
|
||||
region: us-east-1
|
||||
stack: spring-boot-serverless-dev
|
||||
resources: 10
|
||||
api keys:
|
||||
None
|
||||
endpoints:
|
||||
GET - https://cc1oj8hirl.execute-api.us-east-1.amazonaws.com/dev/
|
||||
functions:
|
||||
springBootServerless: spring-boot-serverless-dev-springBootServerless
|
||||
layers:
|
||||
None
|
||||
Serverless: Removing old service artifacts from S3...
|
||||
|
||||
|
||||
访问输出结果中的地址(比如上面显示的https://cc1oj8hirl.execute-api.us-east-1.amazonaws.com/dev/)即可浏览结果。
|
||||
|
||||
这里要注意,由于Serverless对响应速度的要求本来就较高,所以我不建议再采用HSQLDB数据库来运行程序了,毕竟每次冷启动都重置一次数据库本身也并不合理。代码中有提供MySQL的Schema,我建议采用AWS RDB MySQL/MariaDB作为数据库来运行。
|
||||
|
||||
协议
|
||||
|
||||
课程的工程代码部分采用Apache 2.0协议进行许可。在遵循许可的前提下,你可以自由地对代码进行修改、再发布,也可以将代码用作商业用途。但要求你:
|
||||
|
||||
|
||||
署名:在原有代码和衍生代码中,保留原作者署名及代码来源信息;
|
||||
保留许可证:在原有代码和衍生代码中,保留Apache 2.0协议文件。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
165
专栏/周志明的架构课/春节特别放送(上)_有的放矢,事半功倍.md
Normal file
165
专栏/周志明的架构课/春节特别放送(上)_有的放矢,事半功倍.md
Normal file
@@ -0,0 +1,165 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
春节特别放送(上)_ 有的放矢,事半功倍
|
||||
你好,我是周老师的课程编辑王惠。今天是正月初一,首先在这里祝你春节快乐、牛年吉祥,在新的一年,学业进步、工作顺利~另外疫情当前,你也要注意保护好身体。
|
||||
|
||||
不知你还记不记得在开篇词里,我们曾发起过一个活动:
|
||||
|
||||
|
||||
在课程更新的过程中,分享出你的学习心得、实践感悟等等,或者也可以分享出你自己在架构设计中的实践经历、遇到的坑以及避坑的经验。在期中和期末时,课程编辑会甄选出优秀的留言分享内容,专门做一个展示模块,最后还会送出这门课程的纸质版图书。
|
||||
|
||||
|
||||
那么到了这里,我们就已经跟随老师走完一半的学习之旅了,相信这两个半月的时间里,你学到了很多,究竟掌握得怎么样呢?
|
||||
|
||||
所以在春节假期这段时间,正好我们可以把那些硬核艰深的架构知识放一放,轻松一下,一起来复盘复盘我们学过的知识点,做到有的放矢地学习。
|
||||
|
||||
然后,我们再通过同学们的优秀留言,来理解那些自己可能还不够熟悉的课程内容,或者体验一下自己没有经历过的实践过程、没有亲身踩过的坑,希望以此能帮助你更高效地学习课程。
|
||||
|
||||
好,我们开始吧。
|
||||
|
||||
“演进中的架构”模块内容复盘
|
||||
|
||||
这个模块里,我们一起了解了微服务发展历程中出现的大量技术名词、概念,以及了解了这些技术的时代背景和探索过程,同时也在此过程中,更深入地理解了Unix设计哲学的思想。
|
||||
|
||||
|
||||
原始分布式时代。这是计算机科学对分布式和服务化的第一次探索,DCE、CORBA等都是早期的分布式基础架构,原始分布式架构设计的主要目的,就是为了追求简单、符合 Unix 哲学的分布式系统,这也是软件开发者对分布式系统最初的美好愿景。
|
||||
|
||||
单体系统时代。单体作为迄今为止使用人数最多的一种软件架构风格,具有易于分层、易于开发、易于部署测试、进程内的高效交互等优势。它也存在一些关键性的问题,比如存在隔离与自治能力上的欠缺、不兼容“Phoenix”的特性等。但这并不意味着单体最终会被微服务所取代,未来它仍然会长期存在。
|
||||
|
||||
SOA时代。虽然SOA架构具有完善的理论和工具,可以解决分布式系统中几乎所有主要的技术问题,曾经也被视为更大规模的软件发展的方向,但它最终还是没能成为一种普适的软件架构。为什么呢?实际上这正是由于SOA架构过于严谨精密的流程与理论,使得它脱离了人民群众,从而走上了被架构者抛弃的不归路。
|
||||
|
||||
微服务时代。早期的微服务架构作为SOA的一种轻量化的补救方案,是在SOA发展的同时被催生出来的产物。但发展到现在,可以说微服务已然成为了一种独立的架构风格。在该架构模式下,我们需要解决什么问题,就引入什么工具;团队熟悉什么技术,就使用什么框架,对开发者来说十分友善。不过我们也同样需要警惕,因为在微服务中,对于那些分布式服务的问题不再有统一的解决方案,因此可以说微服务所带来的自由是一把双刃剑。
|
||||
|
||||
后微服务时代。现在人们常说的“云原生”时代,就是课程中所讲的后微服务时代,因为它跟前面的微服务时代中追求的目标相比,并没有什么本质的改变,都是通过一系列小型服务去构建大型系统。可以说,容器化技术、虚拟化技术的发展和兴起,对软件架构、软件开发产生了很大改变,软件和硬件的界限开始变得模糊,业务与技术能够完全分离,远程与本地完全透明,如同老师所说,也许这就是分布式架构最好的时代。
|
||||
|
||||
无服务时代。无服务是近几年出现的新概念,它最大的卖点就是简单,只涉及了后端设施和函数两块内容,其设计目标是为了让开发者能够更纯粹地关注业务。不过我们要注意,与单体架构、微服务架构不同,无服务架构天生的一些特点,比如冷启动、无状态、运行时间有限制等等,决定了它不是一种具有普适性的架构模式,我们也不要误会它比微服务更先进。
|
||||
|
||||
|
||||
模块留言精选
|
||||
|
||||
第1讲
|
||||
|
||||
来自@Jxin
|
||||
|
||||
我认为,可以从两个方面来看待“简单”,分别是业务和技术。
|
||||
|
||||
先说业务。现代软件系统的业务复杂性越来越高,而分离关注点,无疑是应对日益增长的业务复杂性的有效手段。但如果依旧是一个大型单体系统(所有业务单元都在一个容器下),那么跨业务单元的知识诉求便很难避免了,并且在开发迭代以及版本发布中,彼此还会相互影响。而微服务的出现,就为其提供了设定物理边界的技术基础,这就使得多个特性团队对业务知识的诉求可以收敛在自身领域内,降低了单个特性团队所需了解的业务知识。
|
||||
|
||||
再来说下技术。这里我认为主要体现在技术隔离上。就如同RPC可以让你像调用本地方法一样调用远程方法,微服务技术组件的出现,大多是为了让开发人员可以基于意图,去使用各种协调分布式系统的工具,而不用深入具体工具的实现细节,去研究怎么解决的分布式难题。
|
||||
|
||||
另外,就像SpringBoot提到的生产就绪,微服务的生态已经不局限于开发的阶段。在部署和运行阶段都有健全组件的支持。它可以让开发人员基于意图就可以简便地实现金丝雀发布,基于意图就能拿到所有系统运行期的数据。而所有的这些便利,都算是技术隔离带来的好处。
|
||||
|
||||
来自@J.Spring
|
||||
|
||||
目前我们团队在做从传统HTTP直接调用、向SOA服务化架构的改造,这个过程让我对SpringCloud这种面向HTTP的服务,以及Dubbo-RPC服务产生了疑问。
|
||||
|
||||
因为单论简单,SpringCloud看起来更简单,但它缺乏完善且强大的服务治理能力。而Dubbo框架看似沉重,却拥有很强大的服务治理功能。
|
||||
|
||||
所以我认为,简单的东西可能后期会变得复杂。而一开始的复杂,可能后期会变得简单。
|
||||
|
||||
第2讲
|
||||
|
||||
来自@STOREFEE
|
||||
|
||||
如果可以很明显地预估到项目的开发规模不会很大,但是对性能要求很高,局部范围需要经常迭代,而且需要多点部署的场景,那就非常适合单体架构。
|
||||
|
||||
不过我观察到,凡是和互联网沾边的流行的软件项目,基本其规模都在不断膨胀,趋向于包罗万象。因为现在很多用户会觉得软件越来越多,去切换不同的东西太麻烦了,有的还得申请账号、填写资料等,比较繁琐。最明显的例子就是石墨和飞书,前几年感觉石墨文档很贴近Word,挺不错的。另外,现在飞书、企业微信等工具,都整合了企业聊天、会议、文档、存储、绘图等一系列的东西。
|
||||
|
||||
所以说像这种一站式服务,绝对会采用非单体架构。
|
||||
|
||||
来自@小高
|
||||
|
||||
单体架构并不是一无是处的。在公司的初始阶段,为了让业务快速上线,就必须得采用单体架构。然后随着业务的增长,架构才得以演进。
|
||||
|
||||
还是那句话,架构不是一成不变的,而是持续演进的。或许,微服务也不是终点。
|
||||
|
||||
第3讲
|
||||
|
||||
来自@Wacky小恺
|
||||
|
||||
在目前的信息技术行业中,如果按照严谨的SOA架构去设计系统,那么不仅为开发人员带来了负担,也加重了用户的学习成本,使得在快速迭代中,需求会被架构所限制。
|
||||
|
||||
我认为软件的设计应当为简洁的、无门槛使用的,比如国民产品微信,不需要过多的学习成本即可使用。而SOA的风格是自上向下的工业标准,自然不符合时代的潮流,“不接地气”,因而就会被时代所抛弃。
|
||||
|
||||
来自@Frank
|
||||
|
||||
我之前也使用过SOAP协议来开发服务,那时候,我们公司自己搞了一个ESB,但是好景不长。一开始是所有服务调用均走ESB,不过后来由于某些原因,直接绕过了ESB,当时我其实并不理解为什么要这么做。后面随着不断学习才慢慢明白,之前搞得ESB,服务之间的协议等等的 “太重”了,实施维护成本很高,不适合自身业务的发展。
|
||||
|
||||
第4讲
|
||||
|
||||
来自@陈珙
|
||||
|
||||
我做.Net实施微服务的时候,当时业界还没有特别成熟的选型与方案,所以自己在组件、方案之间选型对比、整合花了不少的功夫。
|
||||
|
||||
老师说架构师是做平衡与取舍,而开发工程师是实施。我也这么认为。微服务的分而治之、化繁为简的思想是减少了业务开发复杂度,但同时引入了很多组件支撑服务,因此加大了技术复杂度。
|
||||
|
||||
我自己是有几条设计原则的,如下:
|
||||
|
||||
|
||||
技术服务于架构,架构服务于业务;
|
||||
康威定律;
|
||||
架构的实施是需要对应开发模式支撑的。
|
||||
|
||||
|
||||
那总结起来就是,业务规模与团队规模决定了架构的规模,一个增删查改的系统并不需要用微服务架构;使用了前后端分离,那么团队里多数是有前端工程师;由微服务架构拆分引起的量变导致质变,结合DevOps能更好地支持运作。
|
||||
|
||||
来自@Mr.Chen
|
||||
|
||||
其实用不用微服务架构,主要取决于业务,撇开业务谈架构都是在耍流氓!
|
||||
|
||||
我们公司面向企业私有化的项目就没有用微服务,主要是用户的并发量小,考虑到部署和运维的简单,直接上单体架构。
|
||||
|
||||
第5讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
软件架构的发展方向,是慢慢地把与业务无关的技术问题,从软件的层面剥离出来,在硬件的基础设施之内就被悄悄解决掉,让开发人员只专注于业务,真正“围绕业务能力构建”团队与产品。
|
||||
|
||||
把复杂的问题交给计算机硬件解决,使得开发人员只需要关注业务,让开发越来越简单,同时能够调用的计算机资源也会越来越强大。
|
||||
|
||||
这也符合奥卡姆剃刀原则:“如无必要,勿增实体”。如果问题能让计算机自动解决,就不要麻烦人类。
|
||||
|
||||
来自@Jxin
|
||||
|
||||
分布式架构发展到服务网格后,真的是到达“最好的时代”了吗?我的回答是:没有最好,只有更好。
|
||||
|
||||
云原生下,SLS的FaaS和服务网格的纯应用包,这两个各自的需求差异还是挺大的。前者算是技术架构上对效率和成本的创新,后者算是业务架构上对技术分离的追求。这是两个发展分支,但是也不知道会不会产生新的问题。
|
||||
|
||||
不过,业务知识的易传递性、代码的开发、软件发布的效率、高可用和高性能的诉求,等等,这些在可见的未来,应该还会是需要持续解决的问题。
|
||||
|
||||
第6讲
|
||||
|
||||
来自@大D
|
||||
|
||||
2011年我刚毕业进公司,开始使用Mule、ESB做集成,当时我也是初次接触WebService这一套东西,SOAP、WSDL等等用了一年也没搞明白都是干啥用的,感觉就是俩字“复杂”。再后来,公司的产品采用OSGI的方式,自己通过订制Eclipse插件的方式开发了一套IDE,每次打包要勾选一堆的依赖,解决依赖冲突、查找依赖,苦不堪言。这些东西本身就有很多技术壁垒和学习成本。
|
||||
|
||||
再后来,Maven流行,开始各种分模块,后面公司用MQ实现了一套总线,现在看来它就类似于老师讲的事件驱动架构,这个架构还是要自己解决很多负载、补偿、事务等问题,不过总体来说比之前有进步。
|
||||
|
||||
然后直到微服务的出现,感觉轻松了很多,框架层面的东西已经有了很多的解决方案,选择一个合适的就行,其他的专注于业务开发即可。
|
||||
|
||||
现在的年轻人确实赶上了一个好时代,不用理解那么多的复杂实现,可以更多地磨练自身编码能力。但我觉得经历过的都是财富,不然也不会对老师的课程产生强烈的共鸣。
|
||||
|
||||
来自@walkingonair
|
||||
|
||||
我正在腾讯云上摸索无服务的架构模式,完全赞同老师的说法。选择无服务的初始原因是由于微信小程序生态的强大,在腾讯云上进行产品的开发,能大大降低人力成本、运维成本,提高产品的开发速度,帮助创业小公司度过艰难的初期。
|
||||
|
||||
同时,无服务的架构模式,也能在业务量快速上升时,只需要简单增加成本投入,即可快速提高整个架构的业务承载能力,满足未来更大的业务增长。
|
||||
|
||||
但这种架构模式的不足也是十分明显的:
|
||||
|
||||
|
||||
我虽然是一个全栈开发工程师,但是平常使用最多的还是Java语言,而Java的运行离不开Java虚拟机,那么在这种架构下使用Java开发的云函数,性能上能得到保证吗?这个问题我保持怀疑态度,这也导致我在语言方面选择的是Node,而且它也更适合小程序开发者。
|
||||
虽然无服务与编程语言无关,但是工程师的开发能力与语言有关,代码的规范、设计、管理方面与语言有关。就像老师所说的,做到普适性还有很长的路要走。
|
||||
由于无服务架构对非业务层(云函数)的封装,一些特殊需求变得难以实现。例如云数据库的封装和限制,使基于云函数的开发、批量数据变得难以处理,函数运行的超时时间限制和数据库对大批量获取的限制等等,都是瓶颈。
|
||||
无服务架构虽然屏蔽了除业务开发外的实现,但是也对开发人员提出了更高的要求。云函数的实现,需要满足无状态、幂等的要求,否则或许会出现“匪夷所思”的Bug。
|
||||
当前云开发的各项功能还不完善,开发人员权限的管理、各种资源的授权分配、云函数和云数据库等产品的管理在大型企业的模式下难以适用。
|
||||
|
||||
|
||||
总而言之,云开发有着诱人的优点,但也有一些致命的不足。从架构演化的角度来说,无服务架构未来值得期待,这也是我选择无服务架构的最大原因。
|
||||
|
||||
|
||||
|
||||
|
||||
276
专栏/周志明的架构课/春节特别放送(下)_积累沉淀,知行合一.md
Normal file
276
专栏/周志明的架构课/春节特别放送(下)_积累沉淀,知行合一.md
Normal file
@@ -0,0 +1,276 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
春节特别放送(下)_ 积累沉淀,知行合一
|
||||
你好,我是编辑王惠,今天初四啦,同学们过年好啊~
|
||||
|
||||
今天呢,我们继续来复盘课程的第二个模块“架构师的视角”中的核心知识点,以及再次来感受、学习下在该模块中各位优秀同学的所学所得、所思所想。
|
||||
|
||||
“架构师的视角”模块内容复盘
|
||||
|
||||
在这个模块里,我们系统性地了解了在做架构设计时,架构师都应该思考哪些问题、可以选择哪些主流的解决方案和行业标准做法,以及这些主流方案都有什么优缺点、会给架构设计带来什么影响,等等,以此对架构设计这种抽象的工作有了更具体、更具象的认知。
|
||||
|
||||
服务风格设计
|
||||
|
||||
|
||||
远程服务调用: RPC以模拟进程间方法调用为起点,表示数据、传递数据和表示方法,是RPC必须解决的三大基本问题。解决这些问题可以有很多方案,这也是 RPC 协议/框架出现群雄混战局面的一个原因,而另一个原因是简单的框架很难能达到功能强大的要求。一个RPC框架要想取得成功,就要选择一个发展方向,因此我们也就有了朝着面向对象发展、朝着性能发展和朝着简化发展这三条线。
|
||||
RESTful服务: 面向过程和面向对象两种编程思想虽然出现的时间有先后,但在人类使用计算机语言来处理数据的工作中,无论用哪种思维来抽象问题都是合乎逻辑的。而面向资源编程这种思想,是把问题空间中的数据对象作为抽象的主体,把解决问题时从输入数据到输出结果的处理过程,看作是一个(组)数据资源的状态不断发生变换而导致的结果。这符合目前网络主流的交互方式,所以REST常常被看作是为基于网络的分布式系统量身定做的交互方式。
|
||||
|
||||
|
||||
事务处理
|
||||
|
||||
|
||||
本地事务: 本地事务是指仅操作特定单一事务资源的、不需要“全局事务管理器”进行协调的事务。ARIES理论提出了Write-Ahead Logging式的日志写入方法,通过分析、重做、回滚三个阶段实现了STEAL、NO-FORCE,从而实现了既高效又严谨的日志记录与故障恢复。此外在实现隔离性这方面,我们要知道不同隔离级别以及幻读、脏读等问题,都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。
|
||||
全局事务: 全局事务可以理解为是一种适用于单个服务使用多个数据源场景的事务解决方案,其中的两段式提交和三段式提交模式还会在一些多数据源的场景中用到,它们追求ACID的强一致性,这个目标不仅给它带来了很高的复杂度,而且吞吐量和使用效果上也不够好。
|
||||
共享事务: 共享事务是指多个服务共用同一个数据源,虽然目前共享事务确实已经很少见,不过通过了解事务演进的过程,也更便于我们理解其他三种事务类型。
|
||||
分布式事务: 现在系统设计的主流,已经变成了不追求ACID而是强调BASE的弱一致性事务,也就是分布式事务,它是指多个服务同时访问多个数据源的事务处理机制。我们要知道,分布式系统中不存在放之四海皆准的万能事务解决方案,针对具体场景,选择合适的解决方案,达到一致性与可用性之间的最佳平衡,是我们作为一名设计者必须具备的技能。
|
||||
|
||||
|
||||
透明多级分流系统
|
||||
|
||||
|
||||
客户端缓存: 客户端缓存具体包括“状态缓存”、“强制缓存”和“协商缓存”三类,利用好客户端的缓存能够节省大量网络流量,这是为后端系统分流,以实现更高并发的第一步。
|
||||
域名解析: 域名解析对于大多数信息系统,尤其是基于互联网的系统来说是必不可少的组件,它的主要作用就是把便于人类理解的域名地址,转换为便于计算机处理的IP地址。
|
||||
传输链路: 这也是一种与客户端关系较为密切的传输优化机制。这里我们要明确一点,即HTTP并不是只将内容顺利传输到客户端就算完成任务了,如何做到高效、无状态也是很重要的目标。另外在HTTP/2之前,要想在应用一侧优化传输,就必须要同时在其他方面付出相应的成本,而HTTP/2 中的多路复用、头压缩等改进项,就从根本上给出了传输优化的解决方案。
|
||||
内容分发网络: 内容分发网络(CDN)是一种已经存在了很长时间,也被人们广泛应用的分流系统,其工作过程主要涉及到路由解析、内容分发、负载均衡和它所能支持的应用内容四个方面。CDN能为互联网系统提供性能上的加速,也能帮助增强许多功能,比如说安全防御、资源修改、功能注入等。而且这一切又实现得极为透明,可以完全不需要开发者来配合。
|
||||
负载均衡: 负载均衡的两大职责就是“选择谁来处理用户请求”和“将用户请求转发过去”。如今一般实际用于生产的系统几乎都离不开集群部署,而在其中用于承担调度后方的多台机器,以统一的接口对外提供服务的技术组件,就是负载均衡器了。理解其工作原理,对于我们做系统的流量和容量规划工作是很有必要的。
|
||||
服务端缓存: 服务端缓存也是一种通用的技术组件,它主要用于减少多个客户端相同的资源请求,缓解或降低服务器的负载压力,因此可以作为一种分流手段。
|
||||
|
||||
|
||||
安全架构
|
||||
|
||||
|
||||
认证: 认证解决的是“你是谁?”的问题,即如何正确分辨出操作用户的真实身份。在课程中我们了解了三种主流的认证方式,分别为通讯信道上的认证、通讯协议上的认证、通讯内容上的认证。
|
||||
授权: 授权解决的是“你能干什么?”的问题,即如何控制一个用户该看到哪些数据、能操作哪些功能。我们可以使用OAuth 2.0来解决涉及到多方系统调用时可靠授权的问题,而针对如何确保授权的结果可控的问题,可以通过基于角色的访问控制(RBAC)来解决。
|
||||
凭证: 凭证解决的是“你要如何证明?”的问题,即如何保证它与用户之间的承诺是准确、完整且不可抵赖的。对此我们也了解了Cookie-Session机制和无状态的JWT两种凭证实现方案,它们分别适用于不同的场景,因此我们在做架构设计时要做好权衡。
|
||||
保密: 即解决如何保证敏感数据无法被内外部人员所窃取、滥用的问题。这里我们要知道,保密是有成本的,追求越高的安全等级,我们就要付出越多的工作量与算力消耗。
|
||||
传输: 即解决如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充的问题。传输环节是最复杂、最有效,但又是最早就有了标准解决方案的,不管是哈希摘要、对称加密和非对称加密这三种安全架构中常见的保密操作,还是通过数字证书达成共同信任、通过传输安全层隐藏繁琐的安全过程。
|
||||
验证: 验证解决的是“你做的对不对?”的问题,即如何确保提交的信息不会对系统稳定性、数据一致性、正确性产生风险。虽然貌似数据验证并不属于安全的范畴,但其实它与程序如何编码是密切相关的。这里我们需要明确一点,就是缺失的校验会影响数据质量,而过度的校验也不会让系统更加健壮,反而在某种意义上会制造垃圾代码,甚至还会有副作用。
|
||||
|
||||
|
||||
模块留言精选
|
||||
|
||||
第7讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
让计算机能够跟调用本地方法一样,去调用远程方法,应该是RPC的终极目标。但是目前的技术水平无法实现这一终极目标,所以就有了其他更可行的折中方案。
|
||||
|
||||
事物是慢慢演化发展的,目标可以远大,但是做事还是要根据实际情况,实事求是。
|
||||
|
||||
第8讲
|
||||
|
||||
来自@Mr.Chen
|
||||
|
||||
RPC只是服务(进程)之间简化调用的一种方式,它可以让开发者聚焦于业务本身。而对于服务间通信的各种细节交给框架处理这个维度来说,如果撇开这一层面,分布式系统的服务调用可以采用任何一种通信方式,比如HTTP、Socket等。
|
||||
|
||||
第9讲
|
||||
|
||||
来自@tt
|
||||
|
||||
对于REST,我的第一印象就是服务端无状态,有利于水平扩展,但更多的是停留于具体的技术层面。
|
||||
|
||||
过程如果有意义的话,一定会产生一个结果,这个结果就是资源的状态发生了转移(幂等前提下的重试不算),但是过程的细节更多,所以抽象程度无法做到向面向资源看齐。在一个过程中,我们可以对有关联关系的不同层次与结构的资源同时进行处理,但是面向资源却不容易做到。
|
||||
|
||||
我能想到的例子是转账操作,同时操作两个资源(即账户),而且要保证事务的ACID。在转账的过程中要处理很多异常情况,尤其是涉及到多方交易的时候,所以写这样的交易就非常复杂,容易出错。
|
||||
|
||||
如果用面向资源的角度去考察,可以看成是对三个资源的操作:转出账户、转入账户以及事务。这里把事务列为单独的资源,是为了呼应上面提到的一个资源状态变化引起的关联资源的变化。
|
||||
|
||||
如果转账操作利用TCC(Try-Confirm-Cancel)的方法,我觉得就是一种更偏向于面向资源的做法,每次只改变一个资源的状态。如果某个关联资源的状态改变失败,就对它发起一个逆操作(比如冲正)。这样可以做到很高的并发,在做到保序性的前提下,做差错处理也很简单。相当于把一个复杂操作分解成了多个简单操作,这样开发起来也很快,很容易复用。有点类似CISC和RISC指令集的关系。
|
||||
|
||||
第10讲
|
||||
|
||||
来自@陈珙
|
||||
|
||||
谢谢老师的分享,我也谈谈自己实践REST和RPC后的感想,主要在第一、第二的争议点,感触非常深。
|
||||
|
||||
|
||||
争议一:面向资源的编程思想只适合做CRUD,只有面向过程、面向对象编程才能处理真正复杂的业务逻辑。-
|
||||
争议二:REST与HTTP完全绑定,不适用于要求高性能传输的场景中。
|
||||
|
||||
|
||||
之前我们用REST到了第2成熟度,关于第一点争议,包括我现在的想法,也是认为面向资源更加适合做数据读写接口的场景,例如某NoSQL的应用服务API封装,或者提供某内部使用API的微服务更加适合。
|
||||
|
||||
原因主要有两个。首先,如果是作为提供给前端使用,处理起复杂业务的时候不好抽象;其次,原本一个接口可以处理的复杂逻辑,但是因为REST原因,导致接口粒度要细到N个,假如由前端人员对接,那么就会增大他们的业务组合难度(我更加倾向大部分的业务逻辑由后端解决,前端尽量关注数据展示与动画交互,数据离后端人员最近)。
|
||||
|
||||
那么当粒度细化了以后,就会引申出第二个争议所说的性能问题。这里也有两方面原因:首先是因为要做接口的编排组合;其次也是因为REST被HTTP绑得死死的,那么开发人员就不得不去关注那些细节了。
|
||||
|
||||
举个例子,HTTP Status Code的参数,除了是body外可能还会是header,也有可能是URI参数,对于实际开发的便捷度来说并不够友好。
|
||||
|
||||
另外课程中老师还提到了GraphQL。该技术的确能缓解Query的部分问题,但是我认为它同时也存在不可避免的问题,就是如何让使用端可以很好地了解并对接数据源及其属性?
|
||||
|
||||
对于这种存在争议性的东西,我的建议是尽可能地少引入团队。毕竟争议性越大,就意味着大家对它的理解越少,无论是引入推广还是实施的具体效果,都会存在很长周期的磨合与统一。
|
||||
|
||||
不过它也不是一文不值的。我们团队做的是解决方案,解决的是针对性的问题场景,对于一些比较清晰、比较接近数据的场景,如NoSQL的API,或者简单的、方便抽象的、相对需求稳定的业务场景,如某内部微服务的API,我认为是可以尝试使用的。
|
||||
|
||||
第11讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
FORCE策略要求事务提交后,变动的数据马上写入磁盘,没有日志保护,但是这样不能保证事务的原子性。比如用户的账号扣了钱、写入了数据库,这时候系统崩溃了,商品库存的变更信息和商家账号的变更信息都还没来得及写入数据库,这样数据就不一致了。
|
||||
|
||||
因此为了实现原子性,保证能够恢复崩溃,绝大多数的数据库都采用NO-FORCE策略。而为了实现NO-FORCE策略,就需要引入Redo Log(重做日志)来实现,即使修改数据时系统崩溃了,重启后根据Redo Log,就可以选择恢复现场,继续修改数据,或者直接回滚整个事务。
|
||||
|
||||
换句话说就是,我先把我要改的东西记录在日志里,再根据日志统一写到磁盘中。万一我在写入磁盘的过程中晕倒了,等我醒来的时候,我照着日志重新做一遍,也能成功。
|
||||
|
||||
Commit Logging方式实行了NO-FORCE策略,照理说这样已经实现了事务的功能,已经很牛了,但是当一个事务中的数据量特别大的时候,等全部变更写入Redo Log然后再统一写入磁盘,这样性能就不是很好,就会很慢,老板就会不开心。
|
||||
|
||||
那能不能在事务提交之前,偷偷地先写一点数据到磁盘呢(偷跑)?
|
||||
|
||||
答案是可以的,这就是STEAL策略。但是问题来了,你偷摸地写了数据,万一事务要回滚,或者系统崩溃了,这些提前写入的数据就变成了脏数据,必须想办法把它恢复才行。
|
||||
|
||||
这就需要引入Undo Log(回滚日志),在偷摸写入数据之前,必须先在Undo Log中记录都写入了什么数据、改了什么地方,到时候事务回滚了,就按照Undo Log日志,一条条恢复到原来的样子,就像没有改过一样。
|
||||
|
||||
这种能够偷摸先写数据的方式,就叫做Write-Ahead Logging。性能提高了,同时也更复杂了。不过虽然它复杂了点,但是效果很好啊,MySQL、SQLite、PostgreSQL、SQL Server等数据库都实现了WAL机制呢。
|
||||
|
||||
第12讲
|
||||
|
||||
来自@Wacky小恺
|
||||
|
||||
在软件开发的发展历程中,“提供简洁的API”始终贯穿至今,因此这一讲中提到的透明事务,在我看来对普通开发人员的使用层面来说,是完全有必要的。
|
||||
|
||||
但是作为开发人员,一定要有精益求精的品质,也许我们在日常使用中已经习惯了使用简洁的API来实现强大的功能。但如果遇到棘手的问题,或者需要自己思考解决方案的场景,那么“内功”就能显露出它的威力。
|
||||
|
||||
第13讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
学校组织知识竞赛,学生们(参与者)以一组为单位参加比赛,由一个监考老师(协调者)负责监考。考试分为考卷和答题卡,学生必须先在十分钟内把答案写在考卷上(记录日志),然后在三分钟内把答案涂到答题卡上(提交事务)。
|
||||
|
||||
两段式提交
|
||||
|
||||
|
||||
准备阶段: 老师宣布:“开始填写考卷,时间十分钟”。十分钟内,写好考卷的学生就回答:Prepared。十分钟一到,动作慢还没写好的学生,就回答:Non-Prepared。如果有学生回答Non-Prepared,该小组被淘汰。
|
||||
提交阶段: 如果所有的学生都回答了Prepared,老师就会在笔记本上记下,“开始填答题卡”(Commit),然后对所有的学生说:“开始填答题卡”(发送 Commit 指令)。学生听到指令后,就开始根据考卷去涂答题卡。
|
||||
如果学生在涂答题卡的时候,过于紧张把答题卡涂错了,还可以根据考卷重新涂;如果所有的学生在规定时间内都填好了答题卡,老师宣布该小组考试通过。
|
||||
|
||||
|
||||
三段式提交
|
||||
|
||||
|
||||
CanCommit阶段: 老师先给学生看一下考卷,问问学生能不能在十分钟内做完。如果有学生说没信心做完,该小组直接淘汰。
|
||||
PreCommit阶段: 如果学生都说能做完,老师就宣布:“开始填写考卷,时间十分钟”,和两段式提交的准备阶段一样。
|
||||
DoCommit阶段: 和两段式提交的提交阶段一样。
|
||||
|
||||
|
||||
第14讲
|
||||
|
||||
来自@Goku
|
||||
|
||||
XA事务成立的前提是所有的服务都可用,在分布式环境下使用的代价是如果有一个服务不可用,那么整个系统就不可用了。另外,XA事务可能会对业务有侵入,而依靠可靠消息队列和重试机制则不需要侵入业务。
|
||||
|
||||
第15讲
|
||||
|
||||
来自@tt
|
||||
|
||||
我觉得可靠事件队列最适用的场景就是在内部系统中做高可靠。
|
||||
|
||||
TCC的范围扩大了一些,适合于新设计的系统;SAGA的适用性最广,因为对服务提供的接口没有要求,可以有落地人工处理做保证。我们现在涉及三方互联的老系统,都可以看作是SAGA的一种形式。
|
||||
|
||||
第16讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
一个不起眼的DNS竟然暗藏了这么多精妙的设计,计算机技术发展的每个阶段成果,都是人类智慧的结晶。
|
||||
|
||||
关于奥卡姆剃刀原则,我也想做点补充。奥卡姆剃刀原则,又被称为“简约之原则”,它是由14世纪圣方济各会修道士奥卡姆(英格兰的一个地方)的威廉(William of Occam)提出来的,他说过这样一段话:
|
||||
|
||||
|
||||
切勿浪费较多东西,去做“用较少的东西,同样可以做好的事情”。
|
||||
|
||||
|
||||
更有名的一句话是:如无必要,勿增实体。
|
||||
|
||||
在历史上各个时代,最高深的物理学理论,从形式上讲都不复杂,从牛顿力学,到爱因斯坦的相对论,到今天物理学的标准模型。例如,质能方程 E=mc^2 ,欧拉恒等式 e^(iπ) + 1 = 0,都以极简的方式描述了极其复杂的规律。
|
||||
|
||||
关于计算机系统,在能满足需求的前提下,最简单的系统就是最好的系统。很多人为了显示自己的技术水平,明明是很简单的需求,却上了一堆高大上的技术,为了技术而技术,忘了技术的本质是为业务服务的,这显然违背了奥卡姆剃刀原则。
|
||||
|
||||
第17讲
|
||||
|
||||
来自@追忆似水年华
|
||||
|
||||
我在做前端开发的时候,遇到了微信会自动缓存页面静态资源的问题,必须要手动刷新页面才行,有时候还得刷新好几遍才可以,有些极端情况则是短时间内连续刷新依然显示旧页面,这个问题在公司内一些同事的手机上均出现过。学习了周老师的这节课,对缓存有了基本的了解,明天就用Charles抓包,看看微信内对网页的客户端缓存策略是什么。
|
||||
|
||||
第18讲
|
||||
|
||||
来自@Jxin
|
||||
|
||||
这里想补充一下QUIC相对于TCP的两点内容:
|
||||
|
||||
|
||||
自定义重传机制: TCP是通过采样往返时间RTT不断调整的,但这个采样存在不准的问题。第一次发送包A超时未返回,第二次重发包A,这时收到了包A的响应,但TCP并不能识别当前包A的响应是第一次发送,还是第二次重发返回的,这时不管怎么减,都可能出现计时偏长或过偏短的问题。而QUIC为每次发送包都打了版本号(包括重发),所以可以很好地识别返回的包是哪次发送包的,进而计算也就相对准确。
|
||||
自定义流量控制: TCP的流量控制是通过滑动窗口协议,是在连接上控制的窗口。QUIC也玩滑动窗口,但是力度是可以细分到具体的Stream。
|
||||
|
||||
|
||||
其实应用层的协议多种多样,比如直播的RTMP、物联网终端的MQTT等,但感觉都是两害取其轻的专项优化、对症下药的方案。只有QUIC直面了TCP的问题,通过应用层的编码实现,系统地提供更好的“TCP连接”。
|
||||
|
||||
第19讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
在网上看到华为云CDN主要的应用场景,希望借此可以帮助我们更好地理解内容:
|
||||
|
||||
|
||||
网站加速:CDN网络能够对加速域名下的静态内容提供良好的加速服务。支持自定义缓存规则,用户可以根据数据需求设置缓存过期时间,缓存格式包括但不限于zip、exe、wmv、gif、png、bmp、wma、rar、jpeg、jpg等。
|
||||
文件下载加速:适用于使用http/https文件下载业务的网站、下载工具、游戏客户端、App商店等。
|
||||
点播加速:适用于提供音视频点播服务的客户。通过分布在各个区域的CDN节点,将音视频内容扩展到距离用户较近的地方,随时随地为用户提供高品质的访问体验。
|
||||
全站加速:适用于各行业动静态内容混合,含较多动态资源请求(如asp、jsp、php等格式的文件)的网站。
|
||||
|
||||
|
||||
第20讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
为什么负载均衡不能只在某一个网络层次中完成,而是要进行多级混合的负载均衡?
|
||||
|
||||
因为每一个网络层的功能是不一样的,这样就决定了每一层都有自己独有的数据,在不同的网络层做负载均衡能达到不同的效果。比如要修改MAC地址,在数据链路层修改最方便,要修改IP地址最好在网络层修改。
|
||||
|
||||
关于网络分层,我这里也打个比方。小帅在网上下单买东西,卖家需要寄快递,把要寄的商品(物理层)打包到包装盒里(数据链路层),然后把包装盒放到快递盒子里(网络层),在快递单上写上寄件地址和收件地址(Headers)。
|
||||
|
||||
然后快递员打电话给小帅拿快递(传输层),这里出现了TCP三次握手连接:
|
||||
|
||||
|
||||
快递员:“喂,这里有你的快递,麻烦到门口拿一下”。
|
||||
小帅:“好的,我这就过来”。
|
||||
快递员:“那我在门口等你”。
|
||||
|
||||
|
||||
小帅拿到快递后,在网上点击确认收货按钮,确认收货(返回 http Status Code 200,应用层)。
|
||||
|
||||
第22讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
“能满足需求的前提下,最简单的系统就是最好的系统”。这句话的隐藏前提是,我们的选择空间要足够大。不管是CDN、负载均衡、客户端缓存、服务端缓存,还是分布式缓存,都给我们提供了大量的选择余地,可以根据自己系统的实际情况,灵活地选择最适合的方案。
|
||||
|
||||
这句话的另一种理解是:没有最好的方案,只有最合适的方案。
|
||||
|
||||
第25讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
角色是为了解耦用户和权限之间的多对多关系。比如有100个用户,他们的权限都是一样的,如果给每个用户都设一遍权限,这就太麻烦了,而且还很容易出错。这时候设置一个角色,把对应的权限配置到角色上,然后这100个用户加到这个角色中就行了。
|
||||
|
||||
角色还有一个好处,如果角色的权限变了,所有角色中用户的权限也会同时变更,不用一个个用户去设置了。
|
||||
|
||||
许可是为了解耦操作与资源之间的多对多关系,比如有新增用户、编辑用户、删除用户的三种操作,通常这些都是一起的,要么都能操作,要么都不能操作。这时候就可以把这三种操作打包成一个用户维护许可,用许可和角色关联更简洁。
|
||||
|
||||
关于Spring Security中的Role和Authority我是这么理解的:Role就是普通的角色,拥有一组许可,这个角色下的所有用户的权限都是一样的。
|
||||
|
||||
但是如果一个角色中的一些用户有个性化的需求,比如销售助理角色,本来没有查看客户的权限,但是某个销售助理比较特殊,需要查看客户的信息,这时如果是单角色的系统,就需要新增一个“销售助理可查看客户角色”,这样很容易导致角色数量爆炸。
|
||||
|
||||
而有了Authority,就可以满足这种个性化需求,只要把查看客户的权限加到Authority中赋予用户就行了。
|
||||
|
||||
第26讲
|
||||
|
||||
来自@zhanyd
|
||||
|
||||
Cookie-Session就相当于是坐飞机托运了行李,只要带着登机牌就行了。但是一旦托运了行李,行李就和飞机绑定了,你就不能随意换航班了。
|
||||
|
||||
JWT就相当于是坐飞机拎着行李到处跑,每次过安检还要打开行李箱检查,而且箱子太小也带不了多少东西。但它的优点是可以随意换航班,行李都在自己身边。
|
||||
|
||||
|
||||
|
||||
|
||||
115
专栏/周志明的架构课/用户故事_詹应达:持续成长,不惧未来.md
Normal file
115
专栏/周志明的架构课/用户故事_詹应达:持续成长,不惧未来.md
Normal file
@@ -0,0 +1,115 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
用户故事 _ 詹应达:持续成长,不惧未来
|
||||
你好,我是编辑王惠。今天是大年初六,春节假期也快要结束啦,春节玩得还开心吗?在放松的同时也别忘了要继续学习哦。
|
||||
|
||||
今天这一讲,我们邀请了一位优秀的同学来做分享,如果你看过之前更新的两篇春节特别放送,那应该会很熟悉他的名字@zhanyd。
|
||||
|
||||
其实在梳理课程留言的时候,我就注意到了詹应达同学一直在跟随周老师的脚步,学习和实践软件架构的相关知识点。留言的内容十分有见地、提出的问题也能看出是经过了他深入的思考。所以我邀请他来和我们分享一下他的学习心得与体会。
|
||||
|
||||
OK,下面我们就一起来看看吧!
|
||||
|
||||
|
||||
|
||||
你好,我是詹应达(zhanyd),一名工作十多年的程序员,目前在温州做制造业信息化相关的工作,很高兴能和你分享我学习这门课程的心得。
|
||||
|
||||
为什么要学这门课?
|
||||
|
||||
首先我想和你聊聊我为什么想要学习这门课。
|
||||
|
||||
作为一个三线城市的程序员,CRUD BOY,想在工作中不断学习、突破瓶颈,有质的飞跃?说实话,我觉得真的很难。
|
||||
|
||||
就拿我自己来说吧,我们公司搞开发的就那么几个人,大家的水平都差不多,都是“面向百度编程”,而且业务上只有简单的CRUD和复杂的CRUD,高并发?分布式?不存在的。要想提升技术能力的话,就只能靠自己的悟性了。
|
||||
|
||||
可是高并发和分布式系统在程序员的能力进阶之路上,都是绕不开的高墙。所以就算是工作中没需求,我也想要学,不然像我这种“高龄”程序员,再不往上提升的话,迟早会被市场淘汰。
|
||||
|
||||
然而,分布式系统是出了名的难搞,我想学,但是无从下手,在网上东看篇文章,西学点概念,都是很零碎的知识,不成体系,又没深度。再这样下去,我迟早会知难而退,对分布式系统敬而远之。
|
||||
|
||||
不过幸运的是,后来我在极客时间找到了周老师的课程,看了课程的目录,真是如获至宝。现在也跟着课程更新学完了一半的内容,收获颇多,老师对不同架构风格的阐释,让我可以从全局性的视角来理解分布式架构的发展历史、来龙去脉,了解各种架构技术的时代背景和探索过程,从而让我能去深入理解架构设计的本质。
|
||||
|
||||
而且除此之外,课程里的很多内容都有既高屋建瓴、又深入浅出的特点,就算是对架构知识的积累不够多的人,都可以做到不紧不慢地跟上老师的脚步。所以,我也想来谈谈这门课程最吸引我的三个原因。
|
||||
|
||||
自成体系
|
||||
|
||||
分布式架构内容太多、太复杂,让人望而生畏,无从下手?不要怕,这门课程的知识内容真的非常全面,它并没有像现在市面上的一些介绍分布式的书籍资料,只是着眼于一个很小的分支,而是涵盖了分布式系统的方方面面,且自成体系,既有广度,又有深度。
|
||||
|
||||
在课程中,周老师已经给我们画好了学习地图,按照这个地图去探索分布式架构,就一定不会迷路,而且这个地图还能让我们对分布式架构的演化历程、如何解决分布式系统难题的高手思路,以及不同的技术方案都有什么优缺点、如果对各种技术做好取舍等,都有具体化、具象化的感知,我们能够清楚地知道自己所掌握的知识程度,也能以此查漏补缺,弥补认知上的不足。
|
||||
|
||||
授之以渔
|
||||
|
||||
周老师不仅在课程中表达了自己的观点,用自己的话把架构技术知识讲解得很透彻,而且在课程里针对很多理论概念都给出了超链接,把参考资料以及思考的过程展示给我们看。
|
||||
|
||||
其中包括很多专业的论文,老师会找到原始的出处,带我们去看第一手资料,从技术的诞生开始,把技术的来龙去脉讲得清清楚楚,有理有据。
|
||||
|
||||
我平时在网上看惯了二手、三手的资料,很多内容都是互相搬运,甚至可能是错的。而老师提供的原始论文以及高质量的文章,最能接近技术的本质,信息量极大,不仅教会了我们知识,还教会了我们学习的方法,既授之以鱼,又授之以渔。
|
||||
|
||||
独到见解
|
||||
|
||||
在学习课程的过程中,我常常惊叹于老师的知识面之广、对技术的理解之深。这么多的知识点,老师都能讲得驾轻就熟,把复杂的知识概念解释得通俗易懂。
|
||||
|
||||
有些内容我以前找了好多资料,还是搞不懂其中的概念,而老师一两节课就讲明白了,对新人非常友好。举个简单的例子,像是CAP定理、TCC、SAGA等概念,我一直都处于懵懵懂懂的状态,而且时间一长就会觉得这都是些高深难懂的理论,大厂专用,不是我等能学会的,严重打击了我的自信心。结果老师在讲“分布式事务”这两节课的时候,就从原理上解释得清清楚楚,我一看就懂了。
|
||||
|
||||
还有,老师很多时候并不会囿于“术”的层面去讲解知识点,还会从“道”的层面帮我们拓展技术视野。比如我们很熟悉的奥卡姆剃刀原则:如无必要,勿增实体。这不仅体现在架构设计上,对于我们的生活来说,也是一样的道理,只有适合自己的才是最好的。可以说,周老师的课程真正做到了“道”与“术”的平衡,为我们揭开了软件架构设计的神秘面纱。
|
||||
|
||||
总而言之,在这门课程中,我找到了学习分布式系统的绝好资料和学习路径,手拿老师的课程地图,和同学们一起脚踏实地地学习,做到心中有数,不会迷路、不孤单。
|
||||
|
||||
我对于学习方法的思考
|
||||
|
||||
我猜,一直跟随学习课程的同学,应该对我比较眼熟,因为几乎每节课学完后,我都会来留言,不管是说说对这节课的思考也好,还是只简单地刷一下存在感也好,我都一直在坚持做这件事。
|
||||
|
||||
其实我这么做是有原因的。
|
||||
|
||||
不知道你有没有类似的感受,以前在学习其他专栏课程的时候,明明学得很认真、很仔细,每节课的知识点我都觉得理解了、学明白了,但总是过几天就会忘掉,再久一点儿就只剩一个很模糊的印象了。
|
||||
|
||||
后来我发现这里存在一个问题,就是从学校到职场,我们一直都在学习各种新知识、新技术,但貌似具体的学习方法却从没有系统地学习过。
|
||||
|
||||
而直到我接触了学习金字塔,才终于知道这是怎么回事了。
|
||||
|
||||
|
||||
|
||||
原来,人的学习分为了被动学习和主动学习两个层次。
|
||||
|
||||
在单纯地听讲时,人们只能记住5%的内容,阅读只能记住10%的内容,这些都是在低效地、被动地学习,所以怪不得我听过看过的内容忘得这么快。而主动地、高效地学习,是要去思考、总结和归纳,并且要找人讨论、实践,然后以教为学。
|
||||
|
||||
所以也就是说,我们一定要有输出,用输出来倒逼输入。这正如同周老师在开篇词里所说的:把自己“认为掌握了的”知识给叙述出来,能够写得条理清晰,讲得理直气壮;能够让别人听得明白,释去心中疑惑;能够把自己的观点交给别人审视,乃至质疑。在这个过程之中,就会挖掘出很多潜藏在“已知”背后的“未知”。
|
||||
|
||||
那么具体我们要如何学会主动学习呢?这里我想啰嗦一下非常著名的费曼学习法。简单来说分为四步:
|
||||
|
||||
|
||||
选择一个你要了解的概念或知识点;
|
||||
试着把它讲给10岁的小孩子听;
|
||||
如果卡壳了,或者说不明白,就重新去查资料学习;
|
||||
确认自己理解清楚了,再用最简洁的语言或者比喻重新讲一遍。
|
||||
|
||||
|
||||
因此按照费曼学习法,我在开始学习课程之前就立了个Flag:每篇文章下面都要写留言,而且最好能用通俗的语言或比喻表达出来。
|
||||
|
||||
不得不说,写留言真的很有效果,一些看似已经明白的概念,如果用自己的话再说一次,就会发现其中的很多细节我其实根本没搞懂,我要不停地查资料,把不懂的地方搞明白,然后再试着联系生活场景来打比方,用自己的话讲出来。有时候一条一两百字的留言,我甚至要写好几个小时。
|
||||
|
||||
|
||||
第26讲中关于我对Cookie-Session和JWT两种凭证实现方案的理解:-
|
||||
Cookie-Session相当于坐飞机托运了行李,只要带着登机牌就行了,但是一旦托运了行李,行李就和飞机绑定了,你就不能随意换航班了;JWT相当于坐飞机拎着行李到处跑,每次过安检还要打开行李箱检查,而且箱子太小也带不了多少东西,但优点是可以随意换航班,行李都在自己身边。
|
||||
|
||||
|
||||
而且留言还有一个好处,就是我可以通过其他同学的提问,以及与同学们的交流讨论,再次巩固学到的知识点,让不同的思想碰撞,从而更接近学习的本质;更重要的也是能通过周老师的回复和指点,可以进一步拓展认知,收获应用、实践的经验。
|
||||
|
||||
周老师这样的大神,我在平时是绝无可能碰到的,能和老师近距离地交流,是非常难得的机会,要好好珍惜。
|
||||
|
||||
写在最后
|
||||
|
||||
说起来,我也算是周老师的老读者了,《深入理解Java虚拟机》读完之后收获非常大,对Java虚拟机有了全面的认识,老师总能抓住事物的本质,把问题说得明明白白,这门课程也保持了老师一贯的高品质。
|
||||
|
||||
周老师在课程里提到过技术人的成长捷径,就是“做技术不仅要去看、去读、去想、去用,更要去写、去说”,这让我印象非常深刻。
|
||||
|
||||
所以在最后,我还想说的是,我们一定要保持成长型的思维模式,也就是相信自己只要努力就可以做得更好。我一直认为,成功主要来源于尽自己最大的努力做事情,来源于主动学习和自我提高,不管我们的起点有多低,受到过多少挫折,只要我们有成长型思维,努力奋斗、不怕失败,保持终身学习、不断成长,就能在如今技术日新月异的时代,不被淘汰、不惧未来。
|
||||
|
||||
我都可以做到,相信你一定能行!
|
||||
|
||||
好了,我的分享就到这里啦,不知道你是怎么学习这门课程的?有没有什么独特的学习方法和心路历程呢?欢迎你在留言区分享,我们一起交流,相互鼓励,共同进步!
|
||||
|
||||
|
||||
|
||||
|
||||
116
专栏/周志明的架构课/结束语_程序员之路.md
Normal file
116
专栏/周志明的架构课/结束语_程序员之路.md
Normal file
@@ -0,0 +1,116 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 _ 程序员之路
|
||||
你好,我是周志明。
|
||||
|
||||
到这里,我们的软件架构之旅就要到终点站了,首先感谢你与我一起学完了这门70多讲、30多万字的课程。
|
||||
|
||||
这门课讲的是软件架构,不过这并不意味着你学完这门课程就要做架构师。我想,在座的同学在现在、将来或者至少过去曾经是一名程序员,所以在结束语中,我想来跟你聊一点儿与技术相关,但又不局限于具体技术的话题。
|
||||
|
||||
程序员的发展观
|
||||
|
||||
程序员通俗地说就是写程序代码的人,但在不少人的认知里,今天去写代码,却是为了日后可以不必再写代码。
|
||||
|
||||
从职业经理人的视角来看,不管是架构师、资深专家,还是研发部门管理者,这些程序员的“进阶职业”似乎都已经脱离了字面意义上的“写代码的人”,衡量他们工作目标的依据主要是治下的程序员是否有更高的工作效率、更好的投入产出。那么如此一来,不少程序员想成为“不必再写代码”的人,倒是也可以理解。
|
||||
|
||||
不过,从技术人员的视角来看,程序员这个群体天生就带有一种工匠式的图腾崇拜精神,大家都奉行达者为师,并不迷信管理自己的人,但尊重能够指导自己的人,爱讲逻辑、爱讲道理,讲不通至少还能“Talk is cheap, show me the code”。而如此一来,要脱离技术去管理好一群程序员,可是相当困难的。
|
||||
|
||||
其实,我之所以说这些,是希望以后无论你的职业目标是永远做一名程序员,还是架构师,或者是成为一名研发管理者,都不要轻易地离开技术领域的一线。
|
||||
|
||||
离开技术、放弃编码的决定,很可能会像你高考之后放下的数学、生物、地理等知识那样,一旦放手,以后就很难有机会再重新捡起来。
|
||||
|
||||
久而久之,你对代码、技术、产品状态与团队研发状态的理解,就会渐渐和团队成员产生偏差错位,从而丧失在细节上给予指导的能力,丧失在专业问题上提出接地气解决方案的能力,只能在短期内无法验证对错的大战略方向上提意见,在会议、流程及团队管理措施上下功夫,在职业经理人式的宣讲与汇报上寻找存在感。
|
||||
|
||||
如果是这样,那么你就从团队的导师变成了管理者,最后你跟团队的关系,就会从携手并肩奋斗的伙伴,完全演变成了只能靠公司制度与管理职位的权力来维系的雇佣关系。
|
||||
|
||||
当然我也相信,假如能够轻松地做好技术,也没有人愿意随便放弃。我听过的离开技术一线最常见的原因,就是“年纪大了,时间不够用了”或者要“聚焦精力去做管理了”。对这种现象,我的看法是:确实很难轻松地做好技术,但是在做好技术工作的前提下,却有可能比较轻松地做好架构和管理工作。
|
||||
|
||||
我自己也是一名架构师和管理者,在作自我介绍的场合,用的头衔却从来都是“兼职一些管理工作的程序员”,这是一种人设标签。如果你问我,为什么管理几十人、几百人的团队的同时,还能抽出时间去编码、去写作、去关注具体的细节与技术的潮流发展,我会理所当然地回答,“因为我是一名程序员”啊。
|
||||
|
||||
这句话的第一层意思是,我是程序员,去编码是天经地义的。另一层意思是,我是程序员,与一群最讲道理、最直来直往、最不需要琢磨小心思的程序员协同工作,管理才不需要耗费太多的精力,所以“兼职管理”才是可行的。
|
||||
|
||||
程序员的价值观
|
||||
|
||||
聊完编程与程序员的发展观,我们再来探讨两个关于程序员价值观方面的问题:
|
||||
|
||||
|
||||
在工作中所需要的知识技能,自己并不感兴趣,该怎么办?
|
||||
在工作中接触不到的知识技能,有没有必要专门去了解、学习,乃至刻意锻炼?
|
||||
|
||||
|
||||
我们知道,工作的职责能跟自己感兴趣的方向一致、能跟自己知识体系的缺失形成互补,这样的机会是可遇不可求的。今天的软件业已经高度成熟了,分工日益细致,对于大多数人来说,聚焦在少数几个点上拧螺丝是常态,能够在广袤的舞台上造火箭才是特例。
|
||||
|
||||
所以,前面两个问题不一定是每位同学都认真思考过,但我相信它应该是每位程序员都实际遇到过的。比如,有位同学就在课程开篇词中提了一个问题,不知在你的职业生涯中的某个时刻,是不是也有过相似的感受:
|
||||
|
||||
|
||||
周老师,想了解一下你之前是怎样从业务往架构转型的?我是工作两年的小白,一直都很想学习架构方面的课程,但是由于工作全是业务逻辑,而且是极其复杂繁琐的业务,每天都是对着协议研究业务实现,感觉自己都困在业务里面无法自拔。
|
||||
|
||||
|
||||
人生苦短,光阴易逝,把有限的时间和精力投入到对自己最有价值的方向上显得尤为关键,大多数人都能接受“选择永远比努力更重要”的观点,但进一步问“什么才是好的选择”时,就只有少数人能对自己学习的知识技能、从事的工作方向做出定量的价值判断。
|
||||
|
||||
所以,这里我就以这位同学的问题为例,拿出自己的判断模型,供你参考:
|
||||
|
||||
|
||||
价值 = (技能收益 + 知识收益) × 提升空间 / 投入成本
|
||||
|
||||
|
||||
技能收益
|
||||
|
||||
刚刚的问题里提到的“每天都是对着协议研究业务实现”,就属于典型的技能,它往往代表着直接收益。
|
||||
|
||||
我认为,一项工作中每天都要用到的技能,不管你是否感兴趣,都值得花一些时间精力去掌握,因为它至少是对你短期的利益起到了明确的支撑作用;反之,永远都不会派上用场的屠龙术,再高大上也是水月镜花。
|
||||
|
||||
所以,正视技能收益的意义就在于可以避免自己变得过度浮躁,而不是用“兴趣不合”“发展不符”之类的借口去过度挑剔。
|
||||
|
||||
我也提倡兴趣驱动,提倡快乐工作,但不设前提条件的兴趣驱动就未免太过“凡尔赛”了。首先在社会中务实地生存,不涉及是否快乐,先把本分工作做对做好,再追求兴趣选择和机遇发展,这才是对多数人的最大的公平。
|
||||
|
||||
知识收益
|
||||
|
||||
问题中提到的“架构方面的课程”,有不少都属于知识。知识的收益往往是间接的,它最终会体现在缩减了模型中的“投入成本”因素,即降低认知负荷(Cognitive Load)上。世界上鲜有“烟囱式”的专业人才,专才的知识体系基本还是“金字塔式”的,这些人在领域里能够显著超过他人高度的前提条件,往往就是他们拥有超过他人的知识广度。
|
||||
|
||||
而具体到软件开发中,像计算机体系结构、编译原理、操作系统等原理性的知识,对于不写编译器、不开发操作系统的程序员来说,在实践中是几乎找不到直接的应用场景的。可是毫无疑问,这些知识就是程序员知识体系的基石,是许多实用技能和常见工具溯源的归宿。
|
||||
|
||||
我们花费一定的成本去学习这类知识,目的是要把自己的知识点筑成体系,把大量的、不同的、零散的知识点,通过内化、存储、整理、归档、输出等方式组合起来,以点成线、以线成面,最终形成系统的、有序的、清晰的脉络结构,这就是知识体系。
|
||||
|
||||
程序员是需要终身学习的群体,当有新的信息输入时,如果能在知识体系中快速找到它应该安放的位置,定位它的问题与解题空间,找到它与其他知识点的关联关系,那你接受新信息的认知负荷就降低了。通俗地讲,你就有了比别人更高的学习效率,更敏锐的技术触觉。
|
||||
|
||||
提升空间
|
||||
|
||||
如果一项工作对你来说是个全新的领域,甚至能称为是一项挑战,那风险的背后往往也蕴藏着更高的收益。但我把提升空间归入到价值判断的因素之中,更重要的目的是规避舒适区的陷阱。
|
||||
|
||||
人性会在持续的颓废时发出示警,却也容易被无效的努力所欺骗。我们去做一些已经完全得心应手的事情时,自然不会耗费什么精力,也不会觉得痛苦困难,如果把它当作打游戏看电影般的娱乐消遣,放松自己是合适的,但我们不应该再指望从中追求什么价值。
|
||||
|
||||
而没有价值,是因为提升空间已经下降到零了,可我们要注意,其中的投入成本根本不可能为零,因为成本中不仅包括精力,还包括时间。花时间重复去做已经完全熟练的事情,相当于计算分子为零的算式,结果自然是没有价值的。
|
||||
|
||||
投入成本
|
||||
|
||||
在这门架构课程中,我经常讲的一个词是“权衡”,经常说的一句话是“凡事不能只讲收益不谈成本”。在我的价值模型里,收益大小也是必须在确定的成本下,才有衡量比较的意义。这里的成本,既包括你花费的时间、金钱与机会,也包括你投入的知识、精神与毅力。
|
||||
|
||||
强调投入成本,是希望你不要去钻牛角尖。如果一项知识或技能,你学习起来非常吃力,花费大力气弄懂之后,过一段时间却又迅速地忘掉了,这很可能是因为你既没有实际应用它的场景,也没有在知识体系中建立好掌握它的稳固的前置基础。这种就属于你目前还拿不动的东西,不如趁早放手,先做好减法,才能做好加法;你也不必觉得可惜,如果它对你来说是必要的,就一定还会再次出现,想躲也躲不掉。
|
||||
|
||||
好了,这就是我的价值判断模型,每个人都应该有属于自己的价值观,你可以参考,但不必非得跟谁的一致。我也并不是提倡凡事都要把价值判断当成公式一样去计算,而是希望你能养成一种类似的思维习惯。
|
||||
|
||||
将思考具象化
|
||||
|
||||
前面我谈论的是发展观、价值观这种大方向的话题,最后,我想以一个具体可操作的小话题来结束这篇结束语:程序员应该如何构筑自己知识体系?顺便我也跟你解释一下,为何这门课程会是一门公开课。
|
||||
|
||||
我践行的知识整理方法是“将思考具象化”。因为我们知道,思考这件事是外界不可知的,其过程如何、其结果如何只有自己心里才清楚。如果不把自己思考的内容输出给他人,很容易就会被自己所欺骗,误以为自己已经理解得足够完备了。
|
||||
|
||||
在开篇词中,我提到过做这门课程的目的:做技术不仅要去看、去读、去想、去用,更要去写、去说。把自己“认为掌握了”的知识给叙述出来,能够说得条理清晰,讲得理直气壮;能够让别人听得明白,释去心中疑惑;能够把自己的观点交给别人的审视,乃至质疑。在这个过程中,就会挖掘出很多潜藏在“已知”背后的“未知”。
|
||||
|
||||
这个目的也是它成为免费公开课的原因:课程本身就是我对自己知识体系整理的成果,是我思考的具象化表现,在这件事情中,我自己是最大的受益者,而其后所做的极客时间课程,以及出版的纸质书籍,都可以算是额外的收获。这样看来,经济上的回报也就不那么重要了。
|
||||
|
||||
实际上,在这门架构课里,我不仅在探讨架构的知识与技术,也很希望能够把自己如何思考、整理、构筑知识体系的方法展示出来。之前的用户故事中,詹同学把它总结为“用输出来倒逼输入”,我看了心中觉得颇感知音。在此,一并感谢每位同学的支持与同行。
|
||||
|
||||
最后我想说的是,课程结束并非终点,我们还可以在留言区互动交流,也祝你享受成长,学有所成。另外,我准备了一份毕业问卷,希望你能花两三分钟填写一下,我也非常期待听到你对这门课的反馈。
|
||||
|
||||
就到这里,我们再会!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
23
专栏/周志明的架构课/结课测试_一套习题,测出你的掌握程度.md
Normal file
23
专栏/周志明的架构课/结课测试_一套习题,测出你的掌握程度.md
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结课测试 _ 一套习题,测出你的掌握程度
|
||||
你好,我是周志明。
|
||||
|
||||
咱们课程到这里已经正式更新完了,感谢你一直以来的认真学习和支持。在临近告别前,我给你准备了一个结课小测试,来帮你检验自己的学习效果。
|
||||
|
||||
这套测试题一共有5道单选题和5道多选题,满分100分,核心考点都出自课程里讲到的知识点,希望可以帮助你进行一场自测。
|
||||
|
||||
除此之外,我也很想知道你对这门课的建议,这里我给你准备了一份问卷。欢迎你在问卷里聊一聊你的想法,也许有机会获得礼物或者是课程阅码哦。
|
||||
|
||||
|
||||
|
||||
好了,话不多说,点击下面的按钮开始测试吧!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user