Open WangShuXian6 opened 5 years ago
EricEvans的《领域驱动设计》一书帮助我们理解了用代码呈现真实世界的重要性,并且告诉我们如何更好地进行建模。
持续交付理论告诉我们如何更有效及更高效地发布软件产品,并指出保持每次提交均可发布的重要性。
基于对Web的理解,我们寻找到了机器与机器交互的更好方式。
AlistairCockburn的六边形架构理论( http://alistair.cockburn.us/Hexagonal+architecture )把我们从分层架构中拯救出来,从而能够更好地体现业务逻辑。
借助虚拟化平台,我们能够按需创建机器并且调整其大小,借助基础设施的自动化我们也很容易从一台机器扩展到多台。
随着领域驱动设计、持续交付、按需虚拟化、基础设施自动化、小型自治团队、大型集群系统这些实践的流行,微服务也应运而生。它并不是被发明出来的,而是从现实世界中总结出来的一种趋势或模式。但是没有前面提及的这些概念,微服务也很难出现。
1.1 微服务就是一些协同工作的小而自治的服务。
1.1.1 很小,专注于做好一件事
在一个单块系统内,通常会创建一些抽象层或者模块来保证代码的内聚性。 内聚性是指将相关代码放在一起,在考虑使用微服务的时候,内聚性这一概念很重要。Robert C. Martin 有一个对单一职责原则(Single Responsibility Principle,http://programmer.97things.oreilly.com/wiki/index.php/The_Single_Responsibility_Principle )的论述:“把因相同原因而变化的东西聚合到一起,而把因不同原因而变化的东西分离开来。”该论述很好地强调了内聚性这一概念。
根据业务的边界来确定服务的边界,这样就很容易确定某个功能代码应该放在哪里。而且由于该服务专注于某个边界之内,因此可以很好地避免由于代码库过大衍生出的很多相关问题。
澳大利亚 RealEstate.com.au 的 Jon Eaves 认为,一个微服务应该可以在两周内完全重写,这个经验法则在他所处的特定上下文中是有效的。
服务越小,微服务架构的优点和缺点也就越明显。使用的服务越小,独立性带来的好处就越多。但是管理大量服务也会越复杂,本书的剩余部分会详细讨论这一复杂性。
1.1.2 自治性
一个微服务就是一个独立的实体。它可以独立地部署在 PAAS(Platform As A Service,平台即服务)上,也可以作为一个操作系统进程存在。我们要尽量避免把多个服务部署到同一台机器上,尽管现如今机器的概念已经非常模糊了!后面会讨论到,尽管这种隔离性会引发一些代价,但它能够大大简化分布式系统的构建,而且有很多新技术可以帮助解决这种部署模型带来的问题。
服务之间均通过网络调用进行通信,从而加强了服务之间的隔离性,避免紧耦合。
这些服务应该可以彼此间独立进行修改,并且某一个服务的部署不应该引起该服务消费方的变动。 对于一个服务来说,我们需要考虑的是什么应该暴露,什么应该隐藏。如果暴露得过多,那么服务消费方会与该服务的内部实现产生耦合。这会使得服务和消费方之间产生额外的协调工作,从而降低服务的自治性。
服务会暴露出 API(Application Programming Interface,应用编程接口),然后服务之间通过这些 API 进行通信。 API 的实现技术应该避免与消费方耦合,这就意味着应该选择与具体技术不相关的 API 实现方式,以保证技术的选择不被限制。本书后面会讨论选择好的解耦性 API 的重要性。
如果系统没有很好地解耦,那么一旦出现问题,所有的功能都将不可用。 有一个黄金法则是:你是否能够修改一个服务并对其进行部署,而不影响其他任何服务? 如果答案是否定的,那么本书剩余部分讨论的那些好处对你来说就没什么意义了。
为了达到解耦的目的,你需要正确地建模服务和 API。
1.2 主要好处
微服务有很多不同的好处,其中很多好处也适用于任何一个分布式系统。但相对于分布式系统或者面向服务的架构而言,微服务要更胜一筹,它会把这些好处推向极致。
1.2.1 技术异构性
在一个由多个服务相互协作的系统中,可以在不同的服务中使用最适合该服务的技术。尝试使用一种适合所有场景的标准化技术,会使得所有的场景都无法得到很好的支持。
如果系统中的一部分需要做性能提升,可以使用性能更好的技术栈重新构建该部分。系统中的不同部分也可使用不同的数据存储技术,比如对于社交网络来说,图数据库能够更好地处理用户之间的交互操作,但是对于用户发布的帖子而言,文档数据库可能是一个更好的选择。
1.2.2 弹性
弹性工程学的一个关键概念是舱壁。如果系统中的一个组件不可用了,但并没有导致级联故障,那么系统的其他部分还可以正常运行。服务边界就是一个很显然的舱壁。在单块系统中,如果服务不可用,那么所有的功能都会不可用。对于单块服务的系统而言,可以通过将同样的实例运行在不同的机器上来降低功能完全不可用的概率,然而微服务系统本身就能够很好地处理服务不可用和功能降级问题。
微服务系统可以改进弹性,但你还是需要谨慎对待,因为一旦使用了分布式系统,网络就会是个问题。不但网络会是个问题,机器也如此,因此我们需要了解出现问题时应该如何对用户进行展示。
第 11 章会就弹性处理和对故障模式的处理做更多讨论。
1.2.3 扩展
庞大的单块服务只能作为一个整体进行扩展。即使系统中只有一小部分存在性能问题,也需要对整个服务进行扩展。如果使用较小的多个服务,则可以只对需要扩展的服务进行扩展,这样就可以把那些不需要扩展的服务运行在更小的、性能稍差的硬件上
1.2.4 简化部署
在有几百万代码行的单块应用程序中,即使只修改了一行代码,也需要重新部署整个应用程序才能够发布该变更。这种部署的影响很大、风险很高,因此相关干系人不敢轻易做部署。于是在实际操作中,部署的频率就会变得很低。这意味着在两次发布之间我们对软件做了很多功能增强,但直到最后一刻才把这些大量的变更一次性发布到生产环境中。这时,另外一个问题就显现出来了:两次发布之间的差异越大,出错的可能性就更大!
在微服务架构中,各个服务的部署是独立的,这样就可以更快地对特定部分的代码进行部署。如果真的出了问题,也只会影响一个服务,并且容易快速回滚,这也意味着客户可以更快地使用我们开发的新功能。Amazon 和 Netflix 等组织采用这种架构主要就是基于上述考虑。这种架构很好地清除了软件发布过程中的种种障碍。
微服务部署领域的技术在过去几年时间里发生了巨大的变化,第 6 章会对该话题做更深入的讨论。
1.2.5 与组织结构相匹配
我们经历过太多由于团队和代码库过大引起问题的情况。当团队是分布式的时候,问题会更明显。我们也知道在小型代码库上工作的小团队更加高效。
微服务架构可以很好地将架构与组织结构相匹配,避免出现过大的代码库,从而获得理想的团队大小及生产力。服务的所有权也可以在团队之间迁移,从而避免异地团队的出现。在第 10 章讲解康威定律时会对该话题做更深入的讨论。
1.2.6 可组合性
分布式系统和面向服务架构声称的主要好处是易于重用已有功能。而在微服务架构中,根据不同的目的,人们可以通过不同的方式使用同一个功能,在考虑客户如何使用该软件时这一点尤其重要。单纯考虑桌面网站或者移动应用程序的时代已经过去了。现在我们需要考虑的应用程序种类包括 Web、原生应用、移动端 Web、平板应用及可穿戴设备等,针对每一种都应该考虑如何对已有功能进行组合来实现这些应用。现在很多组织都在做整体考虑,拓展他们与客户交互的渠道,同时也需要相应地调整架构来辅助这种变化的发生。
在微服务架构中,系统会开放很多接缝供外部使用。当情况发生改变时,可以使用不同的方式构建应用,而整体化应用程序只能提供一个非常粗粒度的接缝供外部使用。如果想要得到更有用的细化信息,你需要使用榔头撬开它!第 5 章会讨论如何将已有的单块应用程序分解成为多个微服务,并且达到可重用、可组合的目的。
1.2.7 对可替代性的优化
如果你在一个大中型组织工作,很可能接触过一些庞大而丑陋的遗留系统。这些系统无人敢碰,却对公司业务的运营至关重要。更糟糕的是,这些程序是使用某种奇怪的 Fortran 变体编写的,并且只能运行在 25 年前就应该被淘汰的硬件上。为什么这些系统直到现在还没有被取代?其实你很清楚答案:工作量很大,而且风险很高。
当使用多个小规模服务时,重新实现某一个服务或者是直接删除该服务都是相对可操作的。想想看,在单块系统中你是否会在一天内删掉上百行代码,并且确信不会引发问题?微服务中的多个服务大小相似,所以重写或移除一个或者多个服务的阻碍也很小。
使用微服务架构的团队可以在需要时轻易地重写服务,或者删除不再使用的服务。当一个代码库只有几百行时,人们也不会对它有太多感情上的依赖,所以很容易替换它。
SOA(Service-Oriented Architecture,面向服务的架构)是一种设计方法,其中包含多个服务,而服务之间通过配合最终会提供一系列功能。一个服务通常以独立的形式存在于操作系统进程中。服务之间通过网络调用,而非采用进程内调用的方式进行通信。
人们逐渐认识到 SOA 可以用来应对臃肿的单块应用程序,从而提高软件的可重用性,比如多个终端用户应用程序可以共享同一个服务。它的目标是在不影响其他任何人的情况下透明地替换一个服务,只要替换之后的服务的外部接口没有太大的变化即可。这种性质能够大大简化软件维护甚至是软件重写的过程。
SOA 本身是一个很好的想法,但尽管做了很多尝试,人们还是无法在如何做好 SOA 这件事情上达成共识。在我看来,业界的大部分尝试都没能把它作为一个整体来看待,因此很难给出一个比该领域现有厂家提供的方案更好的替代方案。
实施 SOA 时会遇到这些问题:通信协议(例如 SOAP)如何选择、第三方中间件如何选择、服务粒度如何确定等,目前也存在一些关于如何划分系统的指导性原则,但其中有很多都是错误的。本书的剩余部分会分别讨论这些问题。一些激进人士可能会认为这些厂家提出并推动 SOA 运动的目的不过就是想要卖更多的产品,而这些相似的产品最终破坏了 SOA 的目标。
现有的 SOA 知识并不能帮助你把很大的应用程序划小。它没有提到多大算大,也没有讨论如何在现实世界中有效地防止服务之间的过度耦合。由于这些点没有说清楚,所以你在实施 SOA 时会遇到很多问题。
在现实世界中,由于我们对项目的系统和架构有着更好的理解,所以能够更好地实施 SOA,而这事实上就是微服务架构。就像认为 XP 或者 Scrum 是敏捷软件开发的一种特定方法一样,你也可以认为微服务架构是 SOA 的一种特定方法。
2.1 不准确的比较
架构师的一个重要职责是,确保团队有共同的技术愿景,以帮助我们向客户交付他们想要的系统。在某些场景下,架构师只需要和一个团队一起工作,这时他们等同于技术引领者。在其他情况下,他们要对整个项目的技术愿景负责,通常需要协调多个团队之间,甚至是整个组织内的工作。不管处于哪个层次,架构师这个角色都很微妙。在一般的组织中,非常出色的开发人员才能成为架构师,但通常会比其他角色招致更多的批评。相比其他角色而言,架构师对多个方面都有更加直接的影响,比如所构建系统的质量、同事的工作条件、组织应对变化的能力等。
我们把自己称作软件“工程师”或者“建筑师”1,但其实我们不是,对吧?建筑师和工程师所具备的精确性和纪律性是遥不可及的,而且他们在社会中的重要性也很容易理解。我的一个朋友在成为认证建筑师的前一天说:“如果明天我在酒吧对于如何盖房子给了你错误的建议,那么我要为此负责。从法律的角度来说,因为我是一个认证建筑师,所以做错了很可能会被起诉。”他们在社会中有非常重要的作用,因此需要经过专门认证才能工作。比如,在英国你至少需要学习七年才能成为一名建筑师。这些职业在几千年前就存在了,而我们呢?相差甚远。这也就是为什么我觉得很多形式的 IT 证书都没价值,因为我们对什么是好的知之甚少。
1“建筑师”和“架构师”在英文中都是 architect,而“架构师”这个词的含义借鉴的是建筑师在建筑中的角色,接下来会在不同的上下文中分别使用“建筑师”和“架构师”两种译法。
有些人想要得到社会的认可,所以借鉴了这些已经被大众认知的行业中的名词,但这样可能会造成两个问题。首先,这么做的前提是我们要清楚自己应该干什么,而多数情况下事实并非如此。并不是说建筑和桥梁就一定不会倒塌,而是它们倒塌的次数要比我们程序崩溃的次数少得多,所以跟工程师相类比是不公平的。其次,经过一些粗略的观察就会发现这种类比是站不住脚的。因为如果桥梁建筑和编程类似的话,那么建到一半的时候你可能会发现对岸比预想的要远 50 米,而且其材质是花岗岩而不是泥土,更糟糕的是我们最终想要的是一座公路桥而不是步行桥。软件并没有类似这种真正的工程师和建筑师在物理规则方面的约束,事实上,我们要创造的东西从设计上来说就是要足够灵活,有很好的适应性,并且能够根据用户的需求进行演化。
也许“建筑师”这个术语是很有问题的。建筑师的工作是做好详细的计划,然后让别人去理解和执行。进行这项工作需要对艺术性和工程性进行权衡,并且对整体进行监督,而其他所有的视角都要屈从于建筑师的视角,当然有时候他们也会考虑结构工程师基于物理规则的一些建议。在我们的行业中,这种建筑师的视角会导致一些非常糟糕的实践。人们试图通过大量的图表和文档创建出一个完美的方案,而忽略了很多基础性的未知因素。使用这种方式会导致人们很难真正理解实现起来的难度,甚至不知道这个设计到底能否奏效,想要对系统了解更多之后再对设计进行修改就更不可能了。
当我们把自己和工程师或者建筑师做比较时,很有可能会做出错误的决定。不幸的是,架构师这个词已经被大众接受了,所以现在我们能够做的事情就是在上下文中去重新定义这个词的含义。
2.2 架构师的演化视角
与建造建筑物相比,在软件中我们会面临大量的需求变更,使用的工具和技术也具有多样性。我们创造的东西并不是在某个时间点之后就不再变化了,甚至在发布到生产环境之后,软件还能继续演化。对于我们创造的大多数产品来说,交付到客户手里之后,还是要响应客户的变更需求,而不是简单地交给客户一个一成不变的软件包。因此架构师必须改变那种从一开始就要设计出完美产品的想法,相反我们应该设计出一个合理的框架,在这个框架下可以慢慢演化出正确的系统,并且一旦我们学到了更多知识,应该可以很容易地应用到系统中。
尽管截止到目前,本章都在警告你不要跟其他行业做过多的比较,但是我发现,有一个角色可以更好地跟 IT 架构师相类比。这个想法是 Erik Doernenburg 告诉我的,他认为更好的类比是城市规划师,而不是建筑师。如果你玩过 SimCity,那么你应该很熟悉城市规划师这个角色。城市规划师的职责是优化城镇布局,使其更易于现有居民生活,同时也会考虑一些未来的因素。为了达到这个目的,他需要收集各种各样的信息。规划师影响城市演化的方法很有趣,他不会直接说“在那个地方盖一栋这样的楼”,相反他会对城市进行分区。就像在 SimCity 中一样,你可能会把城市的某一部分规划成为工业区,另外一部分规划成为居民区,然后其他人会自己决定具体要盖什么建筑物。当然这个决定会受到一定的约束,比如工厂一定要盖在工业区。城市规划师更多考虑的是人和公共设施如何从一个区域移到另一个区域,而不是具体在每个区域中发生的事情。
很多人把城市比作生物,因为城市会时不时地发生变化。当居民对城市的使用方式有所变化,或者受到外力的影响时,城市就会相应地演化。城市规划师应该尽量去预期可能发生的变化,但是也需要明白一个事实:尝试直接对各个方面进行控制往往不会奏效。
上面描述的城镇和软件的对应关系应该是很明显的。当用户对软件提出变更需求时,我们需要对其进行响应并做出相应的改变。未来的变化很难预见,所以与其对所有变化的可能性进行预测,不如做一个允许变化的计划。为此,应该避免对所有事情做出过于详尽的设计。城市这个系统应该让生活在其中的住户感到开心。有一件经常被人们忽略的事情是:系统的使用者不仅仅是终端用户,还有工作在其上的开发人员和运维人员,他们也对系统的需求变更负责。借鉴 Frank Buschmann 的一个说法:架构师的职责之一就是保证该系统适合开发人员在其上工作。
城市规划师就像建筑师一样,需要知道什么时候他的计划没有得到执行。尽管他会引入较少的规范,并尽量少地对发展的方向进行纠正,但是如果有人决定要在住宅区建造一个污水池,他应该能制止。
所以我们的架构师应该像城市规划师那样专注在大方向上,只在很有限的情况下参与到非常具体的细节实现中来。他们需要保证系统不但能够满足当前的需求,还能够应对将来的变化。而且他们还应该保证在这个系统上工作的开发人员要和使用这个系统的用户一样开心。听起来这是很高的标准,那么从哪里开始呢?
2.3 分区
前面我们将架构师比作城市规划师,那么在这个比喻里面,区域的概念对应的是什么呢?它们应该是我们的服务边界,或者是一些粗粒度的服务群组。作为架构师,不应该过多关注每个区域内发生的事情,而应该多关注区域之间的事情。这意味着我们应该考虑不同的服务之间如何交互,或者说保证我们能够对整个系统的健康状态进行监控。至于多大程度地介入区域内部事务,在不同的情况下则有所不同。很多组织采用微服务是为了使团队的自治性最大化
如果一个服务决定通过 HTTP 暴露 REST 接口,另一个用的是 protocol buffers,第三个用的是 Java RMI,那么作为一个服务的消费者就需要支持各种形式的交互,这对于消费者来说简直就是噩梦。这也就是为什么我强调我们应该“担心服务之间的交互,而不需要过于关注各个服务内部发生的事情”。
代码架构师
如果想确保我们创造的系统对开发人员足够友好,那么架构师需要理解他们的决定对系统会造成怎样的影响。最低的要求是:架构师需要花时间和团队在一起工作,理想情况下他们应该一起进行编码。对于实施结对编程的团队来说,架构师很容易花一定的时间和团队成员进行结对。理想情况下,你应该参与普通的工作,这样才能真正理解普通的工作是什么样子。架构师和团队真正坐在一起,这件事情再怎么强调也不过分!相比通过电话进行沟通或者只看看团队的代码,一起和团队工作的这种方式会更加有效。至于和团队在一起工作的频率可以取决于团队的大小,关键是它必须成为日常工作的一部分。如果你和四个团队在一起工作,那么每四周和每个团队都工作半天,可以帮助你有效地和团队进行沟通,并了解他们都在做什么。
2.5 要求的标准
2.5.1 监控
能够清晰地描绘出跨服务系统的健康状态非常关键。这必须在系统级别而非单个服务级别进行考虑。在第 8 章会讲到,往往在需要诊断一个跨服务的问题或者想要了解更大的趋势时,你才需要知道每个服务的健康状态。简单起见,我建议确保所有的服务使用同样的方式报告健康状态及其与监控相关的数据。
你可能会选择使用推送机制,也就是说,每个服务主动把数据推送到某个集中的位置。你可以使用 Graphite 来收集指标数据,使用 Nagios 来检测健康状态,或者使用轮询系统来从各个节点收集数据,但无论你的选择是什么,都应尽量保持标准化。每个服务内的技术应该对外不透明,并且不要为了服务的具体实现而改变监控系统。日志功能和监控情况类似:也需要集中式管理。
2.5.2 接口
选用少数几种明确的接口技术有助于新消费者的集成。使用一种标准方式很好,两种也不太坏,但是 20 种不同的集成技术就太糟糕了。这里说的不仅仅是关于接口的技术和协议。举个例子,如果你选用了 HTTP/REST,在 URL 中你会使用动词还是名词?你会如何处理资源的分页?你会如何处理不同版本的 API ?
2.5.3 架构安全性
一个运行异常的服务可能会毁了整个系统,而这种后果是我们无法承担的,所以,必须保证每个服务都可以应对下游服务的错误请求。没有很好处理下游错误请求的服务越多,我们的系统就会越脆弱。你可以至少让每个下游服务使用它们自己的连接池,进一步让每个服务使用一个断路器。在第 11 章中讨论规模化微服务时,会就这个话题做更深入的讨论。
返回码也应该遵守一定的规则。如果你的断路器依赖于 HTTP 返回码,并且一个服务决定使用 2XX 作为错误码,或者把 4XX 和 5XX 混用,那么这种安全措施就没什么意义了。即使你使用的不是 HTTP,也应该注意类似的问题。对以下几种请求做不同的处理可以帮助系统及时失败,并且也很容易追溯问题:(1)正常并且被正确处理的请求;(2)错误请求,并且服务识别出了它是错误的,但什么也没做;(3)被访问的服务宕机了,所以无法判断请求是否正常。如果我们的服务没有很好地遵守这些规则,那么整个系统就会更加脆弱。
2.6 代码治理
提供范例和服务代码模板。
2.6.1 范例
编写文档是有用的。我很清楚这样做的价值,这也正是我写这本书的原因。但是开发人员更喜欢可以查看和运行的代码。如果你有一些很好的实践希望别人采纳,那么给出一系列的代码范例会很有帮助。这样做的一个初衷是:如果在系统中人们有比较好的代码范例可以模仿,那么他们也就不会错得很离谱。
理想情况下,你提供的优秀范例应该来自真实项目,而不是专门实现的一个完美的例子。因为如果你的范例来自真正运行的代码,那么就可以保证其中所体现的那些原则都是合理的。
2.6.2 裁剪服务代码模板
如果能够让所有的开发人员很容易地遵守大部分的指导原则,那就太棒了。一种可能的方式是,当开发人员想要实现一个新服务时,所有实现核心属性的那些代码都应该是现成的。
针对自己的开发实践裁剪出一个服务代码模板,不但可以提高开发速度,还可以保证服务的质量。
当然,如果你的组织使用多种不同的技术栈,那么针对每种技术栈都需要这样一个服务代码模板。你也可以把它当作一种在团队中巧妙地限制语言选择的方式。
有一点需要注意的是,创建服务代码模板不是某个中心化工具的职责,也不是指导(即使是通过代码)我们应怎样工作的架构团队的职责。应该通过合作的方式定义出这些实践,所以你的团队也需要负责更新这个模板(内部开源的方式能够很好地完成这项工作)。
我也见过一个团队的士气和生产力是如何被强制使用的框架给毁掉的。基于代码重用的目的,越来越多的功能被加到一个中心化的框架中,直至把这个框架变成一个不堪重负的怪兽。如果你决定要使用一个裁剪的服务代码模板,一定要想清楚它的职责是什么。理想情况下,应该可以选择是否使用服务代码模板,但是如果你强制团队使用它,一定要确保它能够简化开发人员的工作,而不是使其复杂化。
你还需要知道,重用代码可能引入的危险。在重用代码的驱动下,我们可能会引入服务之间的耦合。有一个我接触过的组织非常担心这个问题,所以他们会手动把服务代码模板复制到各个服务中。这样做的问题是,如果核心服务代码模板升级了,那么需要花很长时间把这些升级应用到整个系统中。但相对于耦合的危险而言,这个问题倒没那么严重。还有一些我接触过的团队,把服务代码模板简单地做成了一个共享的库依赖,这时他们就要非常小心地防止对 DRY(Don’t Repeat Yourself,避免重复代码)的追求导致系统过度耦合!这是一个很微妙的话题,所以第 4 章会做更深入的讨论。
2.9 集中治理和领导
架构师的部分职责是治理。那么治理又是什么意思呢? COBIT(Control Objectives for Information and Related Technology,信息和相关技术的控制目标)给出了一个很好的定义:
治理通过评估干系人的需求、当前情况及下一步的可能性来确保企业目标的达成,通过排优先级和做决策来设定方向。对于已经达成一致的方向和目标进行监督。
——COBIT 5
在 IT 的上下文中有很多事情需要治理,而架构师会承担技术治理这部分的职责。如果说,架构师的一个职责是确保有一个技术愿景,那么治理就是要确保我们构建的系统符合这个愿景,而且在需要的时候还应对愿景进行演化。
愿景
确保在系统级有一个经过充分沟通的技术愿景,这个愿景应该可以帮助你满足客户和组织的需求。
同理心
理解你所做的决定对客户和同事带来的影响。
合作
和尽量多的同事进行沟通,从而更好地对愿景进行定义、修订及执行。
适应性
确保在你的客户和组织需要的时候调整技术愿景。
自治性
在标准化和团队自治之间寻找一个正确的平衡点。
治理
确保系统按照技术愿景的要求实现。
演进式架构师应该理解,成功要靠不断地取舍来实现。总会存在一些原因需要你改变工作的方式,但是具体做哪些改变就只能依赖于自己的经验了。而僵化地固守自己的想法无疑是最糟糕的做法。
确定服务之间的边界,以期最大化微服务的好处,避开它的劣势。
3.2 什么样的服务是好服务
松耦合和高内聚
3.2.1 松耦合
如果做到了服务之间的松耦合,那么修改一个服务就不需要修改另一个服务。使用微服务最重要的一点是,能够独立修改及部署单个服务而不需要修改系统的其他部分,这真的非常重要。
什么会导致紧耦合呢?一个典型的错误是,使用紧耦合的方式做服务之间的集成,从而使得一个服务的修改会致使其消费者的修改。
一个松耦合的服务应该尽可能少地知道与之协作的那些服务的信息。这也意味着,应该限制两个服务之间不同调用形式的数量,因为除了潜在的性能问题之外,过度的通信可能会导致紧耦合。
3.2.2 高内聚
我们希望把相关的行为聚集在一起,把不相关的行为放在别处。为什么呢?因为如果你要改变某个行为的话,最好能够只在一个地方进行修改,然后就可以尽快地发布。如果需要在很多不同的地方做这些修改,那么可能就需要同时发布多个微服务才能交付这个功能。在多个不同的地方进行修改会很慢,同时部署多个服务风险也很高,这两者都是我们想要避免的。
所以,找到问题域的边界就可以确保相关的行为能放在同一个地方,并且它们会和其他边界以尽量松耦合的形式进行通信。
3.3 限界上下文
任何一个给定的领域都包含多个限界上下文,每个限界上下文中的东西(Eric 更常使用模型这个词,应该比“东西”好得多)分成两部分,一部分不需要与外部通信,另一部分则需要。每个上下文都有明确的接口,该接口决定了它会暴露哪些模型给其他的上下文。
“一个由显式边界限定的特定职责。”( http://blog.sapiensworks.com/post/2012/04/17/DDD-The-Bounded-Context-Explained.aspx )如果你想要从一个限界上下文中获取信息,或者向其发起请求,需要使用模型和它的显式边界进行通信。在这本书中,Evans 使用细胞作为比喻:“细胞之所以会存在,是因为细胞膜定义了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。”
3.3.1 共享的隐藏模型
财务部门和仓库之间共享的模型
为了算出公司的估值,财务部的雇员需要库存信息,所以库存项就变成了两个上下文之间的共享模型。然而,我们不会盲目地把库存项在仓库上下文中的所有内容都暴露出去。比如,尽管在仓库内部有相应的模型来表示库存项,但是我们不会直接把这个模型暴露出去。也就是对该模型来说,存在内部和外部两种表示方式。很多情况下,这都会导致是否要采用 REST 的讨论。
有时候,同一个名字在不同的上下文中有着完全不同的含义。比如,退货表示的是客户退回的一些东西。在客户的上下文中,退货意味着打印运送标签、寄送包裹,然后等待退款。在仓库的上下文中,退货表示的是一个即将到来的包裹,而且这个包裹会重新入库。退货这个概念会与将要执行的任务相关,比如我们可能会发起一个重新入库的请求。这个退货的共享模型会在多个不同的进程中使用,并且在每个限界上下文中都会存在相应的实体,不过,这些实体仅仅是在每个上下文的内部表示而已。
3.3.2 模块和服务
明白应该共享特定的模型,而不应该共享内部表示这个道理之后,就可以避免潜在的紧耦合(即我们不希望成为的样子)风险。我们还识别出了领域内的一些边界,边界内部是相关性比较高的业务功能,从而得到高内聚。这些限界上下文可以很好地形成组合边界。
一旦你发现了领域内部的限界上下文,一定要使用模块对其进行建模,同时使用共享和隐藏模型。
这些模块边界就可以成为绝佳的微服务候选。一般来讲,微服务应该清晰地和限界上下文保持一致。熟练之后,就可以省掉在单块系统中先使用模块的这个步骤,而直接使用单独的服务。然而对于一个新系统而言,可以先使用一段时间的单块系统,因为如果服务之间的边界搞错了,后面修复的代价会很大。所以最好能够等到系统稳定下来之后,再确定把哪些东西作为一个服务划分出去。
如果服务边界和领域的限界上下文能保持一致,并且微服务可以很好地表示这些限界上下文的话,那么恭喜你,你跨出了走向高内聚低耦合的微服务架构的第一步。
3.3.3 过早划分
过早将一个系统划分成为微服务的代价非常高,尤其是在面对新领域时。很多时候,将一个已有的代码库划分成微服务,要比从头开始构建微服务简单得多。
3.4 业务功能
当你在思考组织内的限界上下文时,不应该从共享数据的角度来考虑,而应该从这些上下文能够提供的功能来考虑。比如,仓库的一个功能是提供当前的库存清单,财务上下文能够提供月末账目或者为一个新招的员工创建工资单。为了实现这些功能,可能需要交换存储信息的模型,但是我见过太多只考虑模型从而导致贫血的、基于 CRUD(create,read, update,delete)的服务。所以首先要问自己“这个上下文是做什么用的”,然后再考虑“它需要什么样的数据”。
建模服务时,应该将这些功能作为关键操作提供给其协作者(其他服务)。
集成是微服务相关技术中最重要的一个。做得好的话,你的微服务可以保持自治性,你也可以独立地修改和发布它们;但做得不好的话会带来灾难。希望本章能够帮助你在微服务之旅中,避免曾经在 SOA 中遇到的那些问题。
4.1 寻找理想的集成技术
微服务之间通信方式的选择非常多样化,但哪个是正确的呢?SOAP ?XML-RPC ?REST ?Protocol Buffers ?后面会逐一讨论,但是在此之前需要考虑的是,我们到底希望从这些技术中得到什么。
4.1.1 避免破坏性修改
有时候,对某个服务做的一些修改会导致该服务的消费方也随之发生改变。后面会讨论如何处理这种情形,但是我们希望选用的技术可以尽量避免这种情况的发生。比如,如果一个微服务在一个响应中添加了一个字段,那么已有的消费方不应该受到影响。
4.1.2 保证API的技术无关性
如果你从事 IT 业已经超过 15 分钟,不用我说也应该知道,你工作的领域在不断地变化,而唯一不变的就是变化。新的工具、框架、语言层出不穷,它们使我们的工作更高效。现在你在做 .NET,那一年之后呢,或者五年之后呢?说不定什么时候,你会想要尝试一个能够让你的工作更有效率的技术栈。
我很喜欢保持开放的心态,这也正是我喜欢微服务的原因。因此我认为,保证微服务之间通信方式的技术无关性是非常重要的。这就意味着,不应该选择那种对微服务的具体实现技术有限制的集成方式。
4.1.3 使你的服务易于消费方使用
理想情况下,消费方应该可以使用任何技术来实现
4.1.4 隐藏内部实现细节
我们不希望消费方与服务的内部实现细节绑定在一起,因为这会增加耦合。与细节绑定意味着,如果想要改变服务内部的一些实现,消费方就需要跟着做出修改。这会增加修改的成本,而这恰恰是我们想要避免的。这也会导致为了避免消费方的修改而尽量少地对服务本身进行修改,而这会导致服务内部技术债的增加。所以,所有倾向于暴露内部实现细节的技术都不应该被采用。
4.2 为用户创建接口
创建客户这个业务,乍一看似乎就是简单的 CRUD 操作,但对于大多数系统来说并不止这些。添加新客户可能会触发一个新的流程,比如进行付账设置、发送欢迎邮件等。而且修改或者删除客户也可能会触发其他的业务流程。
4.3 共享数据库/坏的原则
最常见的集成形式就是数据库集成。使用这种方式时,如果其他服务想要从一个服务获取信息,可以直接访问数据库。如果想要修改,也可以直接在数据库中修改。这种方式看起来非常简单,而且可能是最快的集成方式,这也正是它这么流行的原因。
这使得外部系统能够查看内部实现细节,并与其绑定在一起。存储在数据库中的数据结构对所有人来说都是平等的,所有服务都可以完全访问该数据库
数据库是一个很大的共享 API,但同时也非常不稳定。如果我想改变与之相关的逻辑,比如说帮助台如何管理客户,这就需要修改数据库。为了不影响其他服务,我必须非常小心地避免修改与其他服务相关的表结构。这种情况下,通常需要做大量的回归测试来保证功能的正确性。
消费方与特定的技术选择绑定在了一起。可能现在来看,使用关系型数据库做存储是合理的,所以我的消费方会使用一个合适的驱动(很有可能是与具体数据库相关的)来与之一起工作。说不定一段时间之后我们会意识到,使用非关系型数据库才是更好的选择。如果消费方和客户服务非常紧密地绑定在了一起,那么能够轻易替换这个数据库吗?正如前面所讨论的,隐藏实现细节非常重要,因为它让我们的服务拥有一定的自治性,从而可以轻易地修改其内部实现。再见,松耦合。
肯定会有一部分逻辑负责对客户进行修改。那么这个逻辑应该放在什么地方呢?如果消费方直接操作数据库,那么它们都需要对这些逻辑负责。对数据库进行操作的相似逻辑可能会出现在很多服务中。如果仓库、注册用户界面、呼叫中心都需要编辑客户的信息,那么当修复一个 bug 的时候,你需要修改三个不同的地方,并且对这些修改分别做部署。再见,内聚性。
好的微服务的核心原则 就是高内聚和低耦合。但是使用数据库集成使得这两者都很难实现。服务之间很容易通过数据库集成来共享数据,但是无法共享行为。内部表示暴露给了我们的消费方,而且很难做到无破坏性的修改,进而不可避免地导致不敢做任何修改,所以无论如何都要避免这种情况。
在本章的剩余部分中,我们会介绍服务之间不同风格的集成方式,这些方式都可以保证服务的内部实现得以隐藏。
4.4 同步与异步
服务之间的通信应该是同步的还是异步的呢?这个基础性的选择会不可避免地引导我们使用不同的实现。
如果使用同步通信,发起一个远程服务调用后,调用方会阻塞自己并等待整个操作的完成。如果使用异步通信,调用方不需要等待操作完成就可以返回,甚至可能不需要关心这个操作完成与否。
同步通信听起来合理,因为可以知道事情到底成功与否。 异步通信对于运行时间比较长的任务来说比较有用,否则就需要在客户端和服务器之间开启一个长连接,而这是非常不实际的。当你需要低延迟的时候,通常会使用异步通信,否则会由于阻塞而降低运行的速度。对于移动网络及设备而言,发送一个请求之后假设一切工作正常(除非被告知不正常),这种方式可以在很大程度上保证在网络很卡的情况下用户界面依然很流畅。另一方面,处理异步通信的技术相对比较复杂
这两种不同的通信模式有着各自的协作风格,即请求 / 响应或者基于事件。 对于请求 / 响应来说,客户端发起一个请求,然后等待响应。这种模式能够与同步通信模式很好地匹配,但异步通信也可以使用这种模式。我可以发起一个请求,然后注册一个回调,当服务端操作结束之后,会调用该回调。
对于使用基于事件的协作方式来说,情况会颠倒过来。客户端不是发起请求,而是发布一个事件,然后期待其他的协作者接收到该消息,并且知道该怎么做。我们从来不会告知任何人去做任何事情。基于事件的系统天生就是异步的。整个系统都很聪明,也就是说,业务逻辑并非集中存在于某个核心大脑,而是平均地分布在不同的协作者中。基于事件的协作方式耦合性很低。客户端发布一个事件,但并不需要知道谁或者什么会对此做出响应,这也意味着,你可以在不影响客户端的情况下对该事件添加新的订阅者。
哪些因素会影响对这两种风格的选择呢?一个重要的因素是这种风格能否很好地解决复杂问题,比如如何处理跨服务边界的流程,而且这种流程有可能会运行很长时间。
4.5 编排与协同
MusicCorp 中创建用户时发生了什么:
(1) 在客户的积分账户中创建一条记录
(2) 通过邮政系统发送一个欢迎礼包
(3) 向客户发送欢迎电子邮件
在图 4-2 中可以很容易地使用流程图对这个概念进行建模。 创建新客户的流程
两种架构风格
使用编排(orchestration)的话,我们会依赖于某个中心大脑来指导并驱动整个流程,就像管弦乐队中的指挥一样。
使用协同(choreography)的话,我们仅仅会告知系统中各个部分各自的职责,而把具体怎么做的细节留给它们自己,就像芭蕾舞中每个舞者都有自己的方式,同时也会响应周围其他人。
编排的解决方案 可能最简单的方式就是让客户服务作为中心大脑。在创建时它会跟积分账户、电子邮件服务及邮政服务通过请求 / 响应的方式进行通信,如图 4-3 所示。客户服务本身可以对当前进行到了哪一步进行跟踪。
通过编排处理客户创建
它会检查客户账户是否创建成功、电子邮件是否发送出去及邮包是否寄出。图 4-2 中的流程图可以直接转换成为代码。甚至有工具可以帮你实现,比如一个合适的规则引擎。也有一些商业工具可以完成这些工作,它们通常被称作商业流程建模软件。假如使用的是同步的请求 / 响应模式,我们甚至能知道每一步是否都成功了。
编排方式的缺点是,客户服务作为中心控制点承担了太多职责,它会成为网状结构的中心枢纽及很多逻辑的起点。我见过这个方法会导致少量的“上帝”服务,而与其打交道的那些服务通常都会沦为贫血的、基于 CRUD 的服务。
使用协同,可以仅仅从客户服务中使用异步的方式触发一个事件,该事件名可以叫作“客户创建”。电子邮件服务、邮政服务及积分账户可以简单地订阅这些事件并且做相应处理,如图 4-4 所示。这种方法能够显著地消除耦合。如果其他的服务也关心客户创建这件事情,它们简单地订阅该事件即可。缺点是,看不到如图 4-2 中展示的那种很明显的业务流程视图。
通过协同处理客户创建事件
这意味着,需要做一些额外的工作来监控流程,以保证其正确地进行。举个例子,如果积分账户存在的 bug 导致账户没有创建成功,程序是否能够捕捉到这个问题?处理该问题的一种方法是,构建一个与图 4-2 中展示的业务流程相匹配的监控系统。实际的监控活动是针对每个服务的,但最终需要把监控的结果映射到业务流程中。在这个流程图中我们可以看出系统是如何工作的。
通常来讲,我认为使用协同的方式可以降低系统的耦合度,并且你能更加灵活地对现有系统进行修改。但是,确实需要额外的工作来对业务流程做跨服务的监控。我还发现大多数重量级的编排方案都非常不稳定且修改代价很大。基于这些事实,我倾向于使用协同方式,在这种方式下每个服务都足够聪明,并且能够很好地完成自己的任务。
针对请求 / 响应方式,可以考虑两种技术:RPC(Remote Procedure Call,远程过程调用)和 REST(REpresentational State Transfer,表述性状态转移)
4.6 远程过程调用
远程过程调用允许你进行一个本地调用,但事实上结果是由某个远程服务器产生的。RPC 的种类繁多,其中一些依赖于接口定义(SOAP、Thrift、protocol buffers 等)。不同的技术栈可以通过接口定义轻松地生成客户端和服务端的桩代码。举个例子,我可以让一个 Java 服务暴露一个 SOAP 接口,然后使用 WSDL(Web Service Definition Language,Web 服务描述语言)定义的接口生成 .NET 客户端的代码。其他的技术,比如 Java RMI,会导致服务端和客户端之间更紧的耦合,这种方式要求双方都要使用相同的技术栈,但是不需要额外的共享接口定义。然而所有的这些技术都有一个核心特点,那就是使用本地调用的方式和远程进行交互。
4.6.1 技术的耦合
有时候 RPC 技术对于互操作性有一定的限制。
从某种程度上来讲,这种技术上的耦合也是暴露内部实现细节的一种方式。举个例子,使用 RMI 不仅把客户端绑定在了 JVM 上,服务端也是如此。
RPC 的核心想法是隐藏远程调用的复杂性。但是很多 RPC 的实现隐藏得有些过头了,进而会造成一些问题。使用本地调用不会引起性能问题,但是 RPC 会花大量的时间对负荷进行封装和解封装,更别提网络通信所需要的时间。这意味着,要使用不同的思路来设计远程和本地的 API。简单地把一个本地的 API 改造成为跨服务的远程 API 往往会带来问题。最糟的情况是,开发人员会在不知道该调用是远程调用的情况下对其进行使用。
你还需要考虑网络本身。分布式计算中一个非常著名的错误观点就是“网络是可靠的”( https://blogs.oracle.com/jag/resource/Fallacies.html ),事实上网络并不可靠。即使客户端和服务端都正常运行,整个调用也有可能会出错。这些错误有可能会很快发生,也有可能会过一段时间才会显现出来,它们甚至有可能会损坏你的报文。你应该做出一个假设:有一些恶意的攻击者随时有可能对网络进行破坏,因此网络的出错模式也不止一种。服务端可能会返回一个错误信息,或者是请求本身就是有问题的。你能够区分出不同的故障模式吗?如果可以,分别如何处理?如果仅仅是因为远程服务刚刚启动,所以响应才会有点慢,该怎么办?在第 11 章中讨论弹性时,会就这些话题做更多讨论。
4.6.3 脆弱性
有一些很流行的 RPC 实现可能会造成一些令人讨厌的脆弱性,Java 的 RMI 就是一个很好的例子。考虑一个非常简单的 Java 接口,通过该接口可以向客户服务发起一个远程调用。
这里存在一个问题,因为对规格说明进行了修改,所以所有的客户端都需要重新生成桩,无论该客户端是否需要这个新方法。对每一个具体的点来说,这种修改还是可控的,但事实上这样的修改会非常普遍。RPC 接口最后通常都会包含很多与对象进行交互或者创建对象的方法。造成这种后果的一部分原因就是,没有意识到现在是在做远程调用,而非本地调用。
4.6.4 RPC很糟糕吗
如果你决定要选用 RPC 这种方式的话,需要注意一些问题:不要对远程调用过度抽象,以至于网络因素完全被隐藏起来;确保你可以独立地升级服务端的接口而不用强迫客户端升级,所以在编写客户端代码时要注意这方面的平衡;在客户端中一定不要隐藏我们是在做网络调用这个事实;在 RPC 的方式中经常会在客户端使用库,但是这些库如果在结构上组织得不够好,也可能会带来一些问题
RPC 是请求 / 响应协作方式中的一种,相比使用数据库做集成的方式,RPC 显然是一个巨大的进步。但是我们还有其他的选择。
4.7 REST
REST 是受 Web 启发而产生的一种架构风格。REST 风格包含了很多原则和限制,但是这里我们仅仅专注于,如何在微服务的世界里使用 REST 更好地解决集成问题。REST 是 RPC 的一种替代方案。
其中最重要的一点是资源的概念。资源,比如说 Customer,处于服务之内。服务可以根据请求内容创建 Customer 对象的不同表示形式。也就是说,一个资源的对外显示方式和内部存储方式之间没有什么耦合。举个例子,客户端可能会请求一个 Customer 的 JSON 表示形式,而 Customer 在内部的存储方式可以完全不同。一旦客户端得到了该 Customer 的表示,就可以发出请求对其进行修改,而服务端可以选择应答与否。
REST 风格包含的内容很多,上面仅仅给出了简单的介绍。我强烈建议你看一看 Richardson 的成熟度模型( http://martinfowler.com/articles/richardsonMaturityModel.html ),其中有对 REST 不同风格的比较。
REST 本身并没有提到底层应该使用什么协议,尽管事实上最常用 HTTP。我以前也见过使用其他协议来实现 REST 的例子,比如串口或者 USB,当然这会引入大量的工作。HTTP 的一些特性,比如动词,使得在 HTTP 之上实现 REST 要简单得多,而如果使用其他协议的话,就需要自己实现这些特性。
4.7.1 REST和HTTP
HTTP 本身提供了很多功能,这些功能对于实现 REST 风格非常有用。比如说 HTTP 的动词(如 GET、POST 和 PUT)就能够很好地和资源一起使用。事实上,REST 架构风格声明了一组对所有资源的标准方法,而 HTTP 恰好也定义了一组方法可供使用。GET 使用幂等的方式获取资源,POST 创建一个新资源。这就意味着,我们可以避免很多不同版本的 createCustomer 及 editCustomer 方法。相反,简单地 POST 一个 Customer 的表示到服务端,然后服务端就可以创建一个新的资源,接下来可以发起一个 GET 请求来获取资源的表示。从概念上来说,对于一个 Customer 资源,访问接口只有一个,但是可以通过 HTTP 协议的不同动词对其进行不同的操作。
HTTP 周边也有一个大的生态系统,其中包含很多支撑工具和技术。比如 Varnish 这样的 HTTP 缓存代理、mod_proxy 这样的负载均衡器、大量针对 HTTP 的监控工具等。这些组件可以帮助我们很好地处理 HTTP 流量,并使用聪明的方式对其进行路由,而且这些操作基本上都对终端用户透明。HTTP 还提供了一系列安全控制机制供我们直接使用。从基本
认证到客户端证书,HTTP 生态系统提供了大量的工具来简化安全性处理,第 9 章会就该话题做更多讨论。即便如此,你需要正确地使用 HTTP 才能得到这些好处。如果用得不好,它就会像其他技术一样变得既不安全又难以扩展。如果用得好,你会得到很多好处。
需要注意的是,HTTP 也可以用来实现 RPC。比如 SOAP 就是基于 HTTP 进行路由的,但不幸的是它只用到 HTTP 很少的特性,而动词和 HTTP 的错误码都被忽略了。很多时候,似乎那些已有的并且很好理解的标准和技术会被忽略,然后新推出的标准又只能使用全新的技术来实现,而这些新技术的提供者也就是制定那些新标准的公司!
4.7.2 超媒体作为程序状态的引擎
REST 引入的用来避免客户端和服务端之间产生耦合的另一个原则是“HATEOAS”(Hypermedia As The Engine Of Application State,超媒体作为程序状态的引擎。
超媒体的概念是:有一块内容,该内容包含了指向其他内容的链接,而这些内容的格式可以不同(如文本、图像、声音等)。这个概念你应该很熟悉,因为你可以在任何一个网页上看到超媒体控制形式的链接,当你点击链接时可以看到相关的内容。HATEOAS 背后的想法是,客户端应该与服务端通过那些指向其他资源的链接进行交互,而这些交互有可能造成状态转移。它不需要知道 Customer 在服务端的 URI,相反客户端根据链接导航到它想要的东西。
在专辑的信息中可以看到一系列超媒体控制。
<album>
<name>Give Blood</name>
<link rel="/artist" href="/artist/theBrakes" /> ➊
<description>
Awesome, short, brutish, funny and loud. Must buy!
</description>
<link rel="/instantpurchase" href="/instantPurchase/1234" /> ➋
</album>
➊ 这个超媒体控制告诉我们去哪里找作者的信息。
➋ 如果我想要买这张专辑,应该知道如何购买。
这个文档中存在两个超媒体控制。读取该文档的客户端需要知道,应该从关系(即示例中的 rel)为 artist 的那个链接中获取作者的信息,而文档中的 instantpurchase 也是协议的一部分,访问该链接即可购买该专辑。就像人类需要理解如何识别购物网站上的购物车一样,客户端也需要理解该 API 的语义。
作为一个客户端,我不需要知道购买专辑的 URI,只需要访问专辑资源,找到其购买链接,然后访问它即可。购买链接的位置可能会改变,URI 也可能会变,该站点甚至可以发送给我额外的信息,但是作为客户端不用在意这些。这就使得客户端和服务端之间实现了松耦合。
这样底层细节就被很好地隐藏起来了。我们可以随意改变链接的展现形式,只要客户端仍然能够通过特定的协议找到它即可。类似地,购物车也可以从一个很简单的链接变成一个复杂一些的 JavasScript 控件。你也可以随意向文档中添加新的链接,从而给客户端提供对资源状态的额外操作。除非我们改变某个链接的语义或者删除该链接,否则客户端不会受到影响。
使用这些链接来对客户端和服务端进行解耦,从长期来看有着很显著的好处,因为你不需要一再调整客户端代码来匹配服务端的修改。通过使用这些链接,客户端能够自行获取相关 API,这对于实现新客户端来说非常方便。
这种方式的一个缺点是,客户端和服务端之间的通信次数会比较多,因为客户端需要不断地发现链接、请求、再发现链接,直到找到自己想要进行的那个操作,所以这里终究还是需要做一些取舍。我建议,你一开始先让客户端去自行遍历和发现它想要的链接,然后如果有必要的话再想办法优化。别忘了前面我们提到了很多跟 HTTP 相关的工具可以帮助我们。
我个人很喜欢让客户端自行遍历和发现 API 这种形式。自行发现和解耦的好处非常之大,然而很显然并非所有人都买账,因为我身边很多人都不是这么做的。我认为主要原因是这么做需要一定的投入,但是回报的时间往往比较长。
4.7.4 留心过多的约定
由于 REST 越来越流行,帮助我们构建 RESTFul Web 服务的框架也随之流行起来。然而有些工具会为了短期利益而牺牲长期利益,为了让你一开始启动得足够快,它们会使用一些不好的实践。举个例子,有些框架可以很容易地表示数据库对象,并把它们反序列化成进程内的对象,然后直接暴露给外部。我记得在一个会议上看到有人使用 Spring Boot 演示了这种做法,并且宣称这是它们的主要优势。这种方式内在的耦合性所带来的痛苦会远远大于从一开始就消除概念之间的耦合所需要的代价。
我们很容易把存储的数据直接暴露给消费者,那么如何避免这个问题呢?在我的团队中一个很有效的模式是先设计外部接口,等到外部接口稳定之后再实现微服务内部的数据持久化。在此期间,简单地将实体持久化到本地磁盘的文件上,当然这并非长久之计。这样做可以保证服务的接口是由消费者的需求驱动出来的,从而避免数据存储方式对外部接口的影响。其缺点是推迟了数据存储部分的集成。我认为对于新的服务来说,这个取舍是可接受的。
4.7.5 基于HTTP的REST的缺点
有些 Web 框架无法很好地支持所有的 HTTP 动词。这就意味着你很容易处理 GET 和 POST 请求,但是 PUT 和 DELETE 就很麻烦了。像 Jersey 这样比较好的 REST 框架就不存在这个问题,但如果在框架选择上有所限制的话,你可能就无法完整地使用 REST 风格了。
性能上也可能会遇到问题。基于 HTTP 的 REST 支持不同的格式,比如 JSON 或者二进制,所以负载相对 SOAP 来说更加紧凑,当然和像 Thrift 这样的二进制协议是没法比的。在要求低延迟的场景下,每个 HTTP 请求的封装开销可能是个问题。
虽然 HTTP 可以用于大流量的通信场景,但对低延迟通信来说并不是最好的选择。相比之下,有一些构建于 TCP(Transmission Control Protocol,传输控制协议)或者其他网络技术之上的协议更加高效。比如 WebSockets,虽然名字中有 Web,但其实它基本上跟 Web 没什么关系。在初始的 HTTP 握手之后,客户端和服务端之间就仅仅通过 TCP 连接了。对于向浏览器传输数据这个场景而言,WebSockets 更加高效。如果这是你的需求的话,那么注意你其实并不需要使用很多 HTTP 的特性,更别说其他跟 REST 相关的东西了。
对于服务和服务之间的通信来说,如果低延迟或者较小的消息尺寸对你来说很重要的话,那么一般来讲 HTTP 不是一个好主意。你可能需要选择一个不同的底层协议,比如 UDP(User Datagram Protocol,用户数据报协议)来满足你的性能要求。很多 RPC 框架都可以很好地运行在除了 TCP 之外的其他网络协议上。
有些 RPC 的实现支持高级的序列化和反序列化机制,然而对于 REST 而言,这部分工作就要自己做了。这部分工作可能会成为服务端和客户端之间的一个耦合点,因为实现一个具有容错性的读取器不是一件容易的事情(后面会做讨论),但是从快速启动的角度来看,它们还是非常有吸引力的。
尽管有这些缺点,在选择服务之间的交互方式时,基于 HTTP 的 REST 仍然是一个比较合理的默认选择。如果想了解更多,我建议你阅读《REST 实战》这本书,该书对 REST 做了非常详细的介绍。
4.8 实现基于事件的异步协作方式
4.8.1 技术选择
主要有两个部分需要考虑:微服务发布事件机制和消费者接收事件机制。
传统上来说,像 RabbitMQ 这样的消息代理能够处理上述两个方面的问题。生产者(producer)使用 API 向代理发布事件,代理也可以向消费者提供订阅服务,并且在事件发生时通知消费者。这种代理甚至可以跟踪消费者的状态,比如标记哪些消息是该消费者已经消费过的。这种系统通常具有较好的可伸缩性和弹性,但这么做也是有代价的。它会增加开发流程的复杂度,因为你需要一个额外的系统(即消息代理)才能开发及测试服务。你也需要额外的机器和专业知识来保持这些基础设施的正常运行。但一旦做好了,它会是实现松耦合、事件驱动架构的一种非常有效的方法。通常来说我很喜欢这种方式。
消息代理仅仅是中间件世界中的一小部分而已。队列本身是很合理、很有用的东西。但是中间件厂商通常倾向于把很多的软件打包进去,比如像企业级服务总线这样的东西。谨记这个原则:尽量让中间件保持简单,而把业务逻辑放在自己的服务中。
另一种方法是使用 HTTP 来传播事件。ATOM 是一个符合 REST 规范的协议,可以通过它提供资源聚合(feed)的发布服务,而且有很多现成的客户端库可以用来消费该聚合。这样当客户服务发生改变时,只需简单地向该聚合发布一个事件即可。消费者会轮询该聚合以查看变化。另一方面,现成的 ATOM 规范和与之相关的库用起来非常方便,而且 HTTP 能够很好地处理伸缩性。但正如前面所提到的,HTTP 不擅长处理低延迟的场景,而且使用 ATOM 的话,用户还需要自己追踪消息是否送达及管理轮询等工作。
很多人花费大量时间,在 ATOM 上实现越来越多任何一个还不错的消息代理都能够提供的功能。举个例子,消费者竞争模式(Competing Consumer pattern)描述了一种使用多个工作者实例同时消费消息的方法,工作者实例的数量可以增加,而且它们应该可以独立于彼此正常工作。但是有一种场景需要避免,即多个工作者处理了同一条消息,从而造成浪费。如果使用消息代理,一个标准的队列就可以很好地处理这种场景。而使用 ATOM 的话,就需要自己在所有的工作者之间维护一个共享的状态来减少上述情况的发生。
如果你已经有了一个好的、具有弹性的消息代理的话,就用它来处理事件的订阅和发布吧。但如果没有的话,你可以看一看 ATOM。但要注意沉没成本的陷阱,比如当你发现有越来越多的消息代理可以满足需求时,就应该在某个时间点做出相应的调整。
4.8.2 异步架构的复杂性
事件驱动的系统看起来耦合非常低,而且伸缩性很好。但是这种编程风格也会带来一定的复杂性,这种复杂性并不仅仅包括对消息的发布订阅操作。举个例子,考虑一个非常耗时的异步请求 / 响应,需要考虑响应返回时需要怎么处理。该响应是否回到发送请求的那个节点?如果是的话,节点服务停止了怎么办?如果不是,是否需要把信息事先存储到某个其他地方,以便于做相应处理?如果 API 设计得好的话,短生命周期的异步操作还是比较容易管理的,但尽管如此,对于习惯了进程间同步调用的程序员来说,使用异步模式也需要思维上的转换。
现在来看一个大家可以引以为戒的故事。2006 年,我在一家银行帮客户构建定价系统,系统需要根据市场事件来决定投资组合中的哪些项需要重新定价。一旦确定了需要做的事情之后,就把它们全都放到一个消息队列中。当时我们使用一个网格来创建定价工作者池,这样就可以根据需求来调整定价集群的规模。这些工作者使用消费者竞争模式,每个工作者都不停地处理这些消息,直到没有消息可处理为止。
系统运行起来了,我们感觉很棒。但是在某一次发布之后,我们遇到了一个很令人讨厌的问题。我们的工作者不停地崩溃,不停地崩溃,不停地崩溃。
最终我们发现了问题所在。代码中存在一个 bug,某一种定价请求会导致工作者崩溃。我们当时使用了事务处理队列:当工作者崩溃之后,这个请求上的锁会超时,然后该请求就会被放回到队列中。另一个工作者会重新尝试处理该请求,然后它也会崩溃。这就是 Martin Fowler 提到的灾难性故障转移(catastrophic failover)的一个典型例子(http://martinfowler.com/bliki/CatastrophicFailover.html)。
除了代码中的 bug 外,我们还忘了设置一个作业最大重试次数。所以后面不但修复了 bug 本身,还设置了这个最大重试次数。但是我们也意识到需要有一种方式来查看甚至是重发这些有问题的消息。所以最后实现了一个消息医院(或者叫死信队列),所有失败的消息都会被发送到这里。我们还创建了一个界面来显示这些消息,如果需要的话还可以触发一个重试。如果你只熟悉点到点的同步通信,就很难快速发现这个问题。
事件驱动架构和异步编程会带来一定的复杂性,所以我通常会很谨慎地选用这种技术。你需要确保各个流程有很好的监控机制,并考虑使用关联 ID,这种机制可以帮助你对跨进程的请求进行追踪
4.9 服务即状态机
不管你选择做一个 REST 忍者,还是坚持使用像 SOAP 这样的基于 RPC 的机制,服务即状态机的概念都很强大。前面提到过(可能已经提的太多了)服务应该根据限界上下文进行划分。我们的客户微服务应该拥有与这个上下文中行为相关的所有逻辑。
当消费者想要对客户做修改时,它会向客户服务发送一个合适的请求。客户服务根据自己的逻辑决定是否接受该请求。客户服务控制了所有与客户生命周期相关的事件。我们想要避免简单地对 CRUD 进行封装的贫血服务。如果出现了在客户服务之外与其进行相关的修改的情况,那么你就失去了内聚性。
把关键领域的生命周期显式建模出来非常有用。我们不但可以在唯一的一个地方处理状态冲突(比如,尝试更新已经被移除的用户),而且可以在这些状态变化的基础上封装一些行为。
我仍然认为基于 HTTP 的 REST 相比其他集成技术更合理,但不管你选的是什么,都要记住上面的原则。
4.10 响应式扩展
响应式扩展(Reactive extensions,Rx)提供了一种机制,在此之上,你可以把多个调用的结果组装起来并在此基础上执行操作。调用本身可以是阻塞或者非阻塞的。Rx 改变了传统的流程。以往我们会获取一些数据,然后基于此进行操作,现在你可以做的是简单地对操作的结果进行观察,结果会根据相关数据的改变自动更新。一些 Rx 的实现允许你对这些被观察者应用某种函数变换,比如在 RxJava 中就可以使用类似 map 或者 filter 这样的经典函数。
很多 Rx 实现都在分布式系统中找到了归宿。因为调用的细节被屏蔽了,所以事情也更容易处理。我可以简单地对下游服务调用的结果进行观察,而不需要关心它是阻塞的还是非阻塞的,唯一需要做的就是等待结果并做出响应。其漂亮之处在于,我可以把多个不同的调用组合起来,这样就可以更容易地对下游服务的并发调用做处理。
当你需要做一些基于多个服务调用的操作时,尝试一下适合你所选用技术栈的响应式扩展。你会惊讶地发现它让你的代码变得非常简单。
4.11 微服务世界中的DRY和代码重用的危险
开发人员对 DRY 这个缩写非常熟悉,即 Don’t Repeat Yourself。虽然从字面上看 DRY 仅仅是避免重复代码,但其更精确的含义是避免系统行为和知识的重复。一般来讲这是很合理的建议。如果有相同的代码来做同样的事情,代码规模就会变大,从而降低可维护性。如果你想要修改的行为在系统的很多部分都有重复实现的话,那么就很容易漏掉某些部分的修改,从而导致 bug,所以强制性地使用 DRY 一般来讲是合理的。
使用 DRY 可以得到重用性比较好的代码。把重复代码抽取出来,然后就可以在多个地方进行调用。比如说可以创建一个随处可用的共享库。但是这个方法在微服务的架构中可能是危险的。
我们想要避免微服务和消费者之间的过度耦合,否则对微服务任何小的改动都会引起消费方的改动。而共享代码就有可能会导致这种耦合。比如,客户端可以通过库共享其中表示系统核心实体的公共领域对象,而所有的服务也会使用这个库。所以当任何部分需要对库做修改时,都会引起其他部分的重新部署。如果你的系统通过消息队列进行通信,那么你需要过滤(由不同步的部署导致的)失效的内容,忘记这么做会引起严重的问题。
跨服务共用代码很有可能会引入耦合。但使用像日志库这样的公共代码就没什么问题,因为它们对外是不可见的。Realestate.com.au 使用了很多深度定制化的服务模板来快速创建新服务。他们不会在服务之间共用代码,而是把这些代码复制到每个新的服务中,以防止耦合的发生。
在微服务内部不要违反 DRY,但在跨服务的情况下可以适当违反 DRY。服务之间引入大量的耦合会比重复代码带来更糟糕的问题。
客户端库
很多团队坚持在最开始的时候为服务开发一个客户端库。原因在于,这样不仅能简化对服务的使用,还能避免不同消费者之间存在重复的与服务交互的代码。
这么做的问题在于,如果开发服务端 API 和客户端 API 的是同一批人,那么服务端的逻辑就有可能泄露到客户端中。我对此很清楚,因为我以前就这么做过。潜入客户端库的逻辑越多,内聚性就越差,然后你必须在修复一个服务端问题的同时,也需对多个客户端进行修改。这样做也会限制技术的选择,尤其是当你强制消费方使用该客户端库时。
我喜欢 AWS(Amazon Web Service)使用的那种客户端库的模式。它允许你直接使用底层的 SOAP 或者 REST 接口,但事实上所有人最终都会使用 SDK(Software Development Kits,软件开发工具箱),该 SDK 对底层 API 进行了抽象。值得一提的是,这些 SDK 要么是由社区提供的,要么是由 API 开发团队之外的的 AWS 员工开发的。这种程度的分离似乎是有效的,它避免了使用客户端库的一些问题。该模式效果很好,其中一部分原因是客户端自主决定何时进行升级。如果你一定要使用客户端库,请确保使用这种正确的方式。
如果你想要使用客户端库,一定要保证其中只包含处理底层传输协议的代码,比如服务发现和故障处理等。千万不要把与目标服务相关的逻辑放到客户端库中。想清楚你是否要坚持使用客户端库,或者你是否允许别人使用不同的技术栈来对底层 API 进行调用。最后,确保由客户端来负责何时进行客户端库的升级,这样才能保证每个服务可以独立于其他服务进行发布!
4.12 按引用访问
如何传递领域实体的相关信息是一个值得讨论的话题。很重要的一个想法是,微服务应该包含核心领域实体(比如客户)全生命周期的相关操作。前面讨论了把与客户有关的逻辑放在客户服务中的重要性。在这种设计下,如果想要做任何与客户相关的改动,就必须向客户服务发起请求。它遵守了一个原则,即客户服务应该是关于客户信息的唯一可靠来源。
想象这样一个场景,你从客户服务获取了一个客户资源,那么就能看到该资源在你发起请求那一刻的样子。但是有可能在你发送了请求之后,其他人对该资源进行了修改,所以你所持有的其实是该客户资源曾经的样子。你持有这个资源的时间越久,其内容失效的可能性就越高。当然,避免不必要的数据请求可以让系统更高效。
有时候使用本地副本没什么问题,但在其他场景下你需要知道该副本是否已经失效。所以当你持有一个本地副本时,请确保同时持有一个指向原始资源的引用,这样在你需要的时候就可以对本地副本进行更新。
考虑这样一个例子:发货之后需要请求邮件服务来发送一封邮件。一种做法是,把客户的邮件地址、姓名、订单详情等信息发送到邮件服务。但是邮件服务有可能会将这个请求放入队列,然后在将来的某个时间再从队列中取出来,在这个时间差中,客户和订单的信息有可能就会发生变化。更合理的方式应该是,仅仅发送表示客户资源和订单资源的 URI,然后等邮件服务器就绪时再回过头来查询这些信息。
在考虑基于事件的协作时,你会发现一个很棒的对位(counterpoint)2。使用事件时,不仅需要知道该事件是否发生,还需要知道到底发生了什么。所以当收到一个客户资源变化的更新事件时,我想要知道事件发生时该客户的状态。同时为了能够在处理事件时得到资源的最新状态,也应该拥有该实体的引用以便于查询。
2对位原指音乐创作中,使两条或者更多条相互独立的旋律同时发声并且彼此融洽的技术,这里比喻同时做两件事情并达到很好的效果。——译者注
当然在使用引用时也需要做一些取舍。如果总是从客户服务去查询给定客户的相关信息,那么客户服务的负载就会过大。如果在获取资源的同时,可以得到资源的有效性时限(即该资源在什么时间之前是有效的)信息的话,就可以进行相应的缓存,从而减小服务的负载。HTTP 在缓存控制方面提供了很多现成的支持,第 11 章会讨论其中的一些措施。
另一个问题是,有些服务可能不需要知道整个客户资源,所以坚持进行查询这种方式会引入潜在的耦合。有人提出邮件服务器应该更加简单,只需要简单地把邮件地址和客户名称发给它就可以了。我认为这里并不存在非常明确的统一规则,原则上来说,应该在不确定数据是否能够保持有效的情况下,谨慎地进行处理。
4.13 版本管理
服务的接口难免发生改变,那么如何管理这些改变呢?
4.13.1 尽可能推迟
减小破坏性修改影响的最好办法就是尽量不要做这样的修改。本章讨论了很多不同的集成技术,你可以通过选用正确的技术来做到这一点。比如数据库集成很容易引入破坏性的修改。然而 REST 就好得多,因为对于内部实现的修改不太容易引起服务接口的变化。
另一个延迟破坏性修改的关键是鼓励客户端的正确行为,避免过早地将客户端和服务端紧密绑定起来。
使用 XPath 来从中提取出想要的信息,这样字段的位置就可以更加灵活。这种读取器的实现能够忽略我们不在乎的那些修改,Martin Fowler 称其为容错性读取器
客户端尽可能灵活地消费服务响应这一点符合 Postel 法则(也叫作鲁棒性原则,https://tools.ietf.org/html/rfc761)。该法则认为,系统中的每个模块都应该“宽进严出”,即对自己发送的东西要严格,对接收的东西则要宽容。这个原则最初的上下文是网络设备之间的交互,因为在这个场景中,所有奇怪的事情都有可能发生。在请求 / 响应的场景下,该原则可以帮助我们在服务发生改变时,减少消费方的修改。
4.13.2 及早发现破坏性修改
及早发现会对消费者产生破坏的修改非常重要,因为即使使用最好的技术,也难以避免破坏性修改的出现。我强烈建议使用消费者驱动的契约来及早定位这些问题,第 7 章会对该技术做详细的讲解。如果你支持多种不同的客户端库,那么最好针对最新的服务对所有的客户端运行测试。一旦意识到,你可能会对某一个消费者造成破坏,那么可以选择要么尽量避免该破坏性修改,要么接受它,并跟维护这些服务的人员好好聊一聊。
4.13.3 使用语义化的版本管理
如果一个客户端能够仅仅通过查看服务的版本号,就知道它是否能够与之进行集成,那就太棒了!语义化版本管理(http://semver.org/)就是一种能够支持这种方式的规格说明。语义化版本管理的每一个版本号都遵循这样的格式:MAJOR.MINOR.PATCH。其中 MAJOR 的改变意味着其中包含向后不兼容的修改;MINOR 的改变意味着有新功能的增加,但应该是向后兼容的;最后,PATCH 的改变代表对已有功能的缺陷修复。
为了更好地理解语义化版本管理,让我们来看一个简单的用例。帮助台应用能够与 1.2.0 版本的客户服务一起使用。如果新功能的增加引起了客户服务的版本变成了 1.3.0,那么帮助台应用不应该看到任何行为的变化,并且自身也不需要做任何改动。由于当前的客户端可能会依赖于在 1.2.0 版本中新加入的功能,所以不能保证,现在的版本可以和 1.1.0 版本的客户服务一起工作。当客户服务升级到 2.0.0 版本时,本地应用程序应该也需要做相应的修改。
4.13.4 不同的接口共存
如果已经做了可以做的所有事情来避免对接口的修改(但还是无法避免),那么下一步的任务就是限制其影响。我们不想强迫客户端跟随服务端一起升级,因为希望微服务可以独立于彼此进行发布。我用过的一种比较成功的方法是,在同一个服务上使新接口和老接口同时存在。所以在发布一个破坏性修改时,可以部署一个同时包含新老接口的版本。
这可以帮助我们尽快发布新版本的微服务,其中包含了新的接口,同时也给了消费者时间做迁移。一旦所有的消费者不再访问老的接口,就可以删除掉该接口及相关的代码
这其实就是一个扩展 / 收缩模式的实例,它允许我们对破坏性修改进行平滑的过度。首先扩张服务的能力,对新老两种方式都进行支持。然后等到老的消费者都采用了新的方式,再通过收缩 API 去掉旧的功能。
如果采用这种不同版本接口共存的方式,你需要一种方法来对不同的请求进行路由。对于使用 HTTP 的系统来说,可以在请求中添加版本信息,也可以将其添加在 URI 中,比如 /v1/customer/ 和 /v2/customer/。我也很犹豫采用哪种方法。一方面,我不希望客户端的代码对 URI 模板进行硬编码;但从另一方面来看,这种方法确实非常明确,请求路由也比较容易。
对于 RPC 来说,事情会更加棘手。我以前使用过 protocol buffers 来把方法放到不同的命名空间中。比如 v1.createCustomer 和 v2.createCustomer。但是当你尝试在网络上对相同类型的不同版本进行传输时,就会非常痛苦。
4.13.5 同时使用多个版本的服务
另一种经常被提起的版本管理的方法是,同时运行不同版本的服务,然后把老用户路由到老版本的服务,而新用户可以看到新版本的服务,如图 4-6 所示。当改变老用户的代价过高时,Netflix 会保守地采用这种方式,尤其在某些场景下,遗留的设备会与老版本的 API 强行绑定。我个人不太喜欢这个想法,也理解为什么用 Netflix 的很少。首先,如果我需要修复一个服务内部的 bug,需要修复两个版本,并做两次部署。而且我很可能也需要在代码库中拉分支,这无疑会引入很多问题。其次,把用户路由到正确的服务中去也是一件比较复杂的事情。想要实现这一点,要么寻求中间件的帮助,要么自己写很多的 nginx 脚本,但这样做的话系统会难以理解和管理。最后,考虑服务中可能需要管理的持久化状态。不同版本的服务创建的用户,都需要被存储在同一个数据库中,并且它们对于不同的服务均可见,这可能会引入更多的复杂性。
短期内同时使用两个版本的服务是合理的,尤其是当你做蓝绿部署或者金丝雀发布时(第 7 章会详细讨论这些模式)。在这些情况下,不同版本的服务可能只会共存几分钟或者几个小时,而且一般只会有两个版本。升级消费者到新版本的时间越长,就越应该考虑在同一个微服务中暴露两套 API 的做法。我对于共存两个服务的这种做法,是否适用于一般的项目保持怀疑态度。
4.14 用户界面
4.14.3 API组合
使用多个 API 来表示用户界面 这种通信模式非常繁琐。与服务之间过多的交互对移动设备来说会有些吃力,而且对使用流量套餐的用户来说也很不利!使用 API 入口(gateway)可以很好地缓解这一问题,在这种模式下多个底层的调用会被聚合成为一个调用,当然它也有一定的局限性,后面会做讨论。
4.14.5 为前端服务的后端
对与后端交互比较频繁的界面及需要给不同设备提供不同内容的界面来说,一个常见的解决方案是,使用服务端的聚合接口或 API 入口。该入口可以对多个后端调用进行编排,并为不同的设备提供定制化的内容,如图 4-9 所示。该入口如果变得太厚,包含的逻辑太多,就会难以维护。它们会被逐渐交由单独的团队来管理,并且因为它们变得太厚,很多功能的修改都会导致这部分代码的修改。
使用单块入口来处理与 UI 之间的交互
这样做会得到一个聚合所有服务的巨大的层。由于所有的东西都被放在了一起,也就失去了不同用户界面之间的隔离性,从而限制了独立于彼此进行发布的能力。我个人比较喜欢的模式是,保证一个这样的后端只为一个应用或者用户界面服务
对于前端使用专用后端
这种模式有时也叫作 BFF(Backends For Frontends,为前端服务的后端)。它允许团队在专注于给定 UI 的同时,也会处理与之相关的服务端组件。后端虽然嵌入在服务端,但它也是用户界面的组成部分。一些类型的 UI 只需要服务端的最小化足迹(footprint)即可,而其他一些可能需要的更多。API 认证和授权层可以处在 BFF 和 UI 之间。第 9 章会进一步探索这部分内容。
与任何一种聚合层类似,使用这种方法的风险在于包含不该包含的逻辑。业务逻辑应该处在服务中,而不应该泄露到这一层。这些 BFF 应该仅仅包含与实现某种特定的用户体验相关的逻辑。
4.14.6 一种混合方式
前面提到的那些选择各自都有其适用的范围。一个组织会选择基于片段组装的方式来构建网站,但对于移动应用来说,BFF 可能是更好的方式。关键是要保持底层服务能力的内聚性。比如,预定音乐和改变客户信息的逻辑应该处在相应的服务中,避免这些逻辑在系统中到处散布。将太多的逻辑放入到刚才提到的那种中间层中是一个常见的陷阱,在实际中需要非常小心地做权衡来避免这个问题。
4.15 与第三方软件集成
对于一般规模的组织来说,如果某个软件非常特殊,并且它是你的战略性资产的话,那就自己构建;如果不是这么特别的话,那就购买。
举个例子,一般规模的组织不会把工资系统当作它的战略性资产,因为全世界的人领工资的方式都大同小异。类似地,大部分组织倾向于购买现成的 CMS(Content Management System,内容管理系统),因为这一类工具对它们的业务来说并不是那么关键。我参与过 Guardian 网站的早期构建工作,定制的内容管理系统对于新闻行业来说非常关键,所以他们决定自己构建。
尽量把集成和定制化的工作放在自己能够控制的部分。
4.15.2 定制化
很多企业购买的工具都声称可以为你做深度定制化。一定要小心!这些工具链的定制化往往会比从头做起还要昂贵!如果你决定购买一个产品,但是它提供的能力不完全适合你,也许改变组织的工作方式会比对软件进行定制化更加合理。
4.15.4 在自己可控的平台进行定制化 任何定制化都只在自己可控的平台上进行,并限制工具的消费者的数量。为了更好地理解这个概念,接下来看两个例子。
多职责的CRM系统
我们还经常会遇到 CRM(Customer Relationship Management,客户关系管理)工具,即使最坚强的架构师也会对它感到恐惧。这个行业的主要厂家包括 Salesforce 和 SAP,这些工具试图为你包揽所有的事情。所以这些工具可能会出现单点失败,并且还可能会变成一团乱七八糟的依赖。我见过的很多 CRM 工具的实现都是粘性(内聚性的反方向)服务的典范。
这种工具的使用范围往往一开始会比较小,但随着时间的发展它会在你的组织中变得越来越重要,以至于后续的方向和选择都会围绕它来做。但这么重要的系统竟然不是自己做的,而是第三方厂家提供的,这是个很严重的问题。
4.15.5 绞杀者模式
你通常难以完全控制遗留系统和 COTS 平台,所以当你使用它们时要考虑如果需要移除或者绕过它们的话,应该如何操作。一个有用的模式叫作绞杀者模式(Strangler Application Pattern,http://martinfowler.com/bliki/StranglerApplication.html)。与在 CMS 系统前面套一层自己的代码非常类似,绞杀者可以捕获并拦截对老系统的调用。这里你就可以决定,是把这些调用路由到现存的遗留代码中还是导向新写的代码中。这种方式可以帮助我们逐步对老系统进行替换,从而避免影响过大的重写。
在微服务的上下文中,通常不会使用单一的单块应用来拦截所有对已有遗留系统的调用,相反你可能会使用一系列的微服务来实施这些拦截。在这种情况下,捕获并重定向这些原始调用可能会变得更加复杂,可能需要使用一个代理来为你做这些事情。
无论如何避免数据库集成
理解 REST 和 RPC 之间的取舍,但总是使用 REST 作为请求 / 响应模式的起点
相比编排,优先选择协同
避免破坏性修改、理解 Postel 法则、使用容错性读取器
将用户界面视为一个组合层
5.1 关键是接缝
服务应该是高内聚、低耦合的。而在单块系统中,这两点往往都会被破坏。我们应该只把经常一起变化的部分放在一起,从而实现内聚性,但是在单块系统中,所有不相关的代码都被放在了一起。类似地,松耦合也不复存在:修改一行代码很容易,但是无法保证这一行修改不会对单块系统中的其他部分造成影响。除此之外,为了发布这个功能,我们还需要把整个系统重新部署一次。
从接缝处可以抽取出相对独立的一部分代码,对这部分代码进行修改不会影响系统的其他部分。识别出接缝不仅仅能够清理代码库,更重要的是,这些被识别出的接缝可以成为服务的边界。
限界上下文就是一个非常好的接缝,因为它的定义就是组织内高内聚和低耦合的边界。所以第一步是开始识别出代码中的这些边界。
很多编程语言都提供了命名空间的概念,来帮助我们把相似的代码组织到一起
5.3 分解单块系统的原因
决定把单块系统变小是一个很好的开始。但我强烈建议你慢慢开凿这些系统。增量的方式可以让你在进行的过程中学习微服务,同时也可以限制出错所造成的影响
考虑一下把哪部分代码抽取出去得到的收益最大,而不是为了抽取而抽取
5.4 杂乱的依赖
当你已经识别出了一些备选接缝,另一个需要考虑的点是,这部分代码与系统剩余部分之间的依赖有多乱。我们想要拉取出来的接缝应该尽量少地被其他组件所依赖。如果你识别出来的几个接缝之间可以形成一个有向无环图(前面提到的包建模工具可以对此提供帮助),就能够看出来哪些接缝会比较难处理。
通常经过这样的分析就会发现,数据库是所有杂乱依赖的源头。
5.5 数据库
前面详细讨论了使用数据库作为服务之间集成方式的做法。而且我已经非常明确地表示我不喜欢这么做!这意味着需要找到数据库中的接缝,这样就可以把它们分离干净。然而数据库是一个棘手的怪物。
5.7 例子:打破外键关系
首先要去除财务部分的代码对行条目表的访问,因为这张表属于产品目录相关的代码,所以当产品目录服务分离出去以后,财务和产品目录两部分代码就会不可避免地使用数据库进行集成。快速的修改方式是,让财务部分的代码通过产品目录服务暴露的 API 来访问数据,而不是直接访问数据库。这个 API 调用会成为微服务化的第一步
现在你会发现一个事实:你需要做两次数据库调用来生成报告。没错,做成两个独立的服务之后也会是这样。这时很多人就会对性能表示担忧。我对这些担忧给出的答案很简单:你的系统需要多快?系统现在是多快?如果能够对当前性能做一个测试,并且还知道你的期望是什么,那就可以放心地做这些修改。有时候让系统的一部分变慢会带来更大的好处,尤其是当这个“慢”事实上还在可接受的范围内时。
那外键关联怎么办?我们也只能放弃它了。所以你可能需要把这个约束从数据库移到代码中来实现。这也就意味着,我们可能需要实现跨服务的一致性检查,或者周期性触发清理数据的任务。这样做与否通常不由技术专家决定。举个例子,如果订单服务包含产品目录项的 ID 列表,那么当产品目录项被删除,而一个订单项却指向该不合法的产品目录 ID 时,该如何处理呢?应该允许这样的事情发生吗?如果允许了,订单又应该显示成什么样子呢?如果不允许,那么如何检查这个约束是否被破坏了呢?事实上,首先应该知道系统的期望行为是什么,然后再根据期望行为做决定。
5.8 例子:共享静态数据
有这么几个解决方案可供选择。第一个是为每个包复制一份该表的内容,也就是说,未来每个服务也都会保存这样一份副本。当然这会导致一个潜在的一致性问题。比如说,当澳大利亚东海岸新成立了一个国家叫作 Newmantopia,你有可能会漏修改掉一些服务中的表。
第二个方法是,把这些共享的静态数据放入代码,比如放在属性文件中,或者简单地放在一个枚举中。数据一致性的问题仍然存在,虽然从经验上看,修改配置文件比修改在线数据库要简单得多。通常这是比较合理的办法。
第三个方法有些极端,即把这些静态数据放入一个单独的服务中。在我以前遇到过的一些场景中,数据量和复杂性及相关的规则值得我们这样做,但如果仅仅是国家代码的话就不必了。
从个人经验来看,大部分场景下,都可以通过把这些数据放入配置文件或者代码中来解决问题,而且它对于大部分场景来说都很容易实现。
5.9 例子:共享数据
现在来考虑一个更为复杂的例子,共享的可变数据对于分离系统来说通常是一个大麻烦。财务代码会追踪客户产生的订单信息,同时也会追踪退货和退款。仓库代码也会在客户订单被分发或者接受之后更新订单信息。所有的这些数据都会在网站某个地方统一显示出来,这样客户就可以看到他们的账户活动记录。为简单起见,我们把所有这些信息都放在了通用的客户表中
无论是财务相关的代码还是仓库相关的代码,都会向同一个表写入数据,有时还会从中读取数据。在这种情况下应如何做分离?其实这种情况很常见:领域概念不是在代码中进行建模,相反是在数据库中隐式地进行建模。这里缺失的领域概念是客户。
需要把抽象的客户概念具象化。作为一个中间步骤,我们可以创建一个新的包,叫作 Customer。然后让财务和仓库这些包,通过 API 来访问此新创建的包。按照这个思路做下去,最终可以得到一个清晰的客户服务
识别出客户的限界上下文
5.10 例子:共享表
展示的是最后一个示例。产品目录需要存储记录的名字和价格,而仓库需要保存仓储的电子记录。最初我们把这两个东西放在了同一个地方,即比较通用的行条目表。当把代码全都放在一起时,事实上很难意识到我们把不同的关注点放在了一起,但现在问题就很明显了。接下来,就可以采取行动把它们存储在不同的表中。
这里的答案是分成两个表,如图 5-8 所示。可以对仓库创建库存项表,对产品目录详情创建产品目录项表
5.11 重构数据库
实施分离
先分离数据库结构,暂时不对服务进行分离
表结构分离之后,对于原先的某个动作而言,对数据库的访问次数可能会变多。因为以前简单地用一个 SELECT 语句就能得到所有的数据,现在则需要分别从不同的地方拿到数据,然后在内存中进行连接。还有,分成两个表结构会破坏事务完整性,这会对应用程序造成很大的影响,后面会对此进行详细讨论。先分离数据库结构但不分离服务的好处在于,可以随时选择回退这些修改或是继续做,而不影响服务的任何消费者。我们对数据库分离感到满意之后,就可以考虑对整个应用程序的分离了。
5.12 事务边界
事务不仅仅存在于数据库中,尽管这个词大多数是在这个上下文中使用。举个例子,消息代理就允许你在一个事务中提交和接收数据。
使用单块表结构时,所有的创建或者更新操作都可以在一个事务边界内完成。分离数据库之后,这种好处就没有了。
如果这个插入订单表的操作失败,我们可以显式地清除所有的状态,从而保证系统状态的一致性。可如果插入订单表成功,但插入提取表失败了呢?
5.12.1 再试一次
其实,对我们来说知道订单被捕获并被处理就足够了,因为可以后面再对仓库的提取表做一次插入操作。我们可以把这部分操作放在一个队列或者日志文件中,之后再尝试对其进行触发。对于某些操作来说这是合理的,但要保证重试能够修复这个问题。
很多地方会把这种形式叫作最终一致性。相对于使用事务来保证系统处于一致的状态,最终一致性可以接受系统在未来的某个时间达到一致。这种方法对于长时间的操作来说尤其管用。在第 11 章中我们会讨论扩展(scaling)模式,届时会对该话题做进一步讨论。
5.12.2 终止整个操作
另一个选择是拒绝整个操作。在这种情况下,我们需要把系统重置到某种一致的状态。提取表的处理比较简单,因为插入失败会导致事务的回退。但是订单表已经提交了的事务该怎么处理呢?解决方案是,再发起一个补偿事务来抵消之前的操作。对于我们来说,可能就是简单的一个 DELETE 操作来把订单从数据库中删除。然后还需要向用户报告该操作失败了。在单块系统中这些情况很好处理,但是分开之后怎么做呢?发起补偿事务的代码应该在客户服务、订单服务,还是其他什么地方呢?**
那如果补偿事务失败了该怎么办呢?这显然是有可能的,这时在订单表中就会有一条记录在提取表中没有对应的记录。在这种情况下,你要么重试补偿事务,要么使用一些后台任务来清除这些不一致的状态。可以给后台的维护人员提供一个界面来进行该操作,或者将其自动化。
如果需要同步的操作不仅仅是两个,而是三个、四个,甚至五个,你该如何处理?不同情况下的补偿事务会非常难以理解,更不用说实现了
5.12.3 分布式事务
手动编配补偿事务非常难以操作,一种替代方案是使用分布式事务。分布式事务会横跨多个事务,然后使用一个叫作事务管理器的工具来统一编配其他底层系统中运行的事务。就像普通的事务一样,一个分布式的事务会保证整个系统处于一致的状态。唯一不同的是,这里的事务会运行在不同系统的不同进程中,通常它们之间使用网络进行通信。
处理分布式事务(尤其是上面处理客户订单这类的短事务)常用的算法是两阶段提交。在这种方式中,首先是投票阶段。在这个阶段,每个参与者(在这个上下文中叫作 cohort)会告诉事务管理器它是否应该继续。如果事务管理器收到的所有投票都是成功,则会告知它们进行提交操作。只要收到一个否定的投票,事务管理器就会让所有的参与者回退。
这种方式会使得所有的参与者暂停并等待中央协调进程的指令,从而很容易导致系统的中断。如果事务管理器宕机了,处于等待状态的事务就永远无法完成。如果一个 cohort 在投票阶段发送消息失败,则所有其他参与者都会被阻塞,投票结束之后的提交也有可能会失败。该算法隐式地认为上述这些情况不会发生,即如果一个 cohort 在投票阶段投了赞成票,则它一定能提交成功。cohort 需要一种机制来保证这件事情的发生。这意味着此算法并不是万无一失的,而只是尝试捕获大部分的失败场景。
协调进程也会使用锁,也就是说,进行中的事务可能会对某些资源持有一个锁。很多人会对在资源上加锁有担忧,因为它会使系统很难扩展,尤其是在分布式系统的上下文中。
分布式事务在某些特定的技术栈上已有现成的实现,比如 Java 的事务 API,该 API 允许你把类似数据库和消息队列这样完全不同的资源,放在一个事务中进行操作。很多算法都很复杂且容易出错,所以我建议你避免自己去创建这套 API。如果你确定这就是你要采取的方式的话,尽量使用现有的实现。
5.12.4 应该怎么办呢
所有这些方案都会增加复杂性。如你所见,分布式事务很容易出错,而且不利于扩展。这种通过重试和补偿达成最终一致性的方式,会使得定位问题更加困难,而且有可能需要其他的补偿措施来修复潜在数据的不一致。
如果现在有一个业务操作发生在跨系统的单个事务中,那么问问自己是否真的需要这么做。是否可以简单地把它们放到不同的本地事务中,然后依赖于最终一致性的概念?这种系统的构建和扩展都会比较容易(第 11 章会对此做进一步讨论)
显式地创建一个概念来表示这个事务。你可以把这个概念当作一个句柄或者钩子,在此之上,能够相对容易地进行类似补偿事务这样的操作,这也是在系统中监控这些复杂概念的一种方式。举个例子,你可以创建一个叫作“处理中的订单”的概念,围绕这个概念可以把所有与订单相关的端到端操作(及相应的异常)管理起来。
5.14 报告数据库
标准的关系型数据库使用 SQL 作为查询接口,它能够和很多现成的报告工具协同工作,但不一定是适用于产品数据库的最佳选择。比如说,有可能我们的应用程序数据更适合建模成为一个图,就像 Neo4j 那样;或者像 MongoDB 这样的文档存储。类似地,对于报告系统来说,可以尝试 Cassandra 这种基于列的数据库,因为它对大数据量处理得很好。如果只能使用一种数据库,那么就很难做这些选择及尝试新技术。
5.15 通过服务调用来获取数据
这个模型有很多变体,但它们都依赖 API 调用来获取想要的数据。对于一个非常简单的报告系统(比如展示过去 15 分钟内下的订单数量的系统)来说,这是可行的。为了从两个或者多个系统中获取数据,你需要进行多次调用,然后进行组装。
但是当你需要访问大量数据时,这种方法就完全不适用了。比如我们想要看看过去 24 个月内客户在音乐商店的购买行为,并从中寻找到客户行为的趋势及其对收入的影响。为了完成这个需求,我们至少需要从客户和财务两个系统中获取大量的数据。在报告系统本地保留这些数据的副本是非常危险的,因为我们不知道它们是否已经发生了修改(即使是历史数据也可能会被修改),所以为了生成一份精准的报告,需要获取过去两年的所有财务记录和客户记录。即使客户数量不多,这个操作也会非常慢。
报告系统也经常依赖于一些第三方工具来获取数据,而使用 SQL 接口能够简化报告工具链的集成。虽然定期把数据拉入 SQL 数据库是可行的,但它还是会带来一些问题。
对于某些服务暴露的资源来说,可以通过添加一些缓存头来加快数据的获取速度,还可以把这些数据缓存在反向代理之类的地方。但是报告天然就允许用户访问不同时期的历史数据,这意味着,如果用户访问的资源是别人没有访问过的(或者在很长一段时间内没有人访问),则缓存无法命中。
你可以提供批量 API 来简化这个过程。举个例子,我们的客户服务可以允许你传过来一组客户 ID 来批量获取数据,或者甚至提供一个接口来分页访问所有的客户。一个更极端的版本是,把对所有用户的请求建模成为一个资源。发起调用的系统可以 POST 一个 BatchRequest,其中携带一个位置信息,服务器可以将所有数据写入该文件。客户服务会返回 HTTP 202 响应码来表示请求已经接受了,但还没有处理。调用系统接下来轮询这个资源,直到得到一个 201 Created 状态,这表示请求已经被满足了,然后发起调用的系统就可以获取这个数据。通过这种方式可以将大数据文件导出,而不需要 HTTP 之上的开销,只是简单地把一个 CSV 文件存储到共享的位置而已。
我见过使用上述方法来批量插入数据,而且能够工作得很好。但是对于报告系统而言,我并不是很喜欢这种方式,因为我觉得有其他潜在的更简单的方式,而且还能够在处理传统报告领域时,更有效地应对伸缩性问题。
5.16 数据导出
和报告系统拉取数据的方式相比,我们还可以把数据推送到报告系统中。使用标准的 HTTP 来进行大量调用时,会带来很大的额外开销,更不用提为报告系统创建专用 API 所带来的开销。一种替代方式是,使用一个独立的程序直接访问其他服务使用的那些数据库,把这些数据导出到单独的报告数据库
这时你会说:“但是 Sam,你说过使用数据库集成是不好的!”很高兴你这么问,不枉我前面多次强调这个问题!但如果实现得好的话,这个场景可以是一个例外,因为它使得报告这件事情变得足够简单,从而可以抵消耦合带来的缺点。
一开始,相应服务的维护团队可以负责数据的导出工作。简单地使用 Cron 去触发一些命令行程序就可以完成这个任务。这个程序需要同时使用服务和报告系统的数据库。导出任务的职责是,把一种形式映射成为另一种形式。通过让同一个团队来维护服务本身和数据导出,可以缓解二者之间的耦合。事实上,我会建议你对服务和数据导出程序统一做版本管理,并且把数据导出的构建,作为服务本身构建的一个生成物给创建出来,当然这里假设服务和报告总是同时部署的。因为二者总是一起部署,而且服务和报告系统之外的实体不会访问这些数据,所以传统的数据库集成所带来的问题,很大程度上得到了缓解。
关于报告系统表结构的耦合仍然存在,但是我们可以把它看作一个类似公共 API 的比较稳定的东西。一些数据库提供的技术能够帮助我们进一步消除这些问题。图 5-14 显示了一个关系型数据库的例子,在报告数据库中包含了所有服务的表结构,然后使用视图之类的技术来创建一个聚合。使用这种方式,导出数据的服务只需要知道自己的报告视图结构即可。但是这种方式的性能就取决于你所选用的数据库系统了。
当然,这里其实是把集成的复杂度推到了表结构这个层次,然后依靠数据库的能力来确保这种方式是可行的。虽然数据导出一般来讲是合理并且容易实施的建议,但我认为,分割的视图结构所带来的复杂度还是得不偿失的,尤其当你要在数据库中管理修改时。
另一个方向
在曾经做过的另一个项目中,我们把一系列数据以 JSON 格式导出到 AWS S3,有效地把 S3 变成了一个巨大的数据集市!这种方式一直都很好,直到后来规模变得越来越大,且出现了把这些数据导出到类似 Excel 和 Tableau 这种标准报告工具中的需求,这种方法的效果就不够好了。
5.17 事件数据导出
每个微服务可以在其管理的实体发生状态改变时发送一些事件。比如,我们的客户服务可能会在客户增删改时发送一些事件。对于这些暴露事件聚合(feed)的微服务来说,可以编写自己的事件订阅器把数据导出到报告数据库中,如图 5-15 所示。
使用这种方式的话,与源微服务底层数据库之间的耦合就被消除掉了。我们只需要绑定到服务所发送的事件即可,而设计这些事件,本来就是用来暴露给外部消费者的。考虑事件是具有时效性的,也很容易确定什么样的数据应该被发送到中央报告系统的存储中。因为可以在事件发生时就给报告系统发送数据,而不是靠原有的周期性数据导出,所以数据就能更快地流入报告系统。
还有,如果我们记录了哪些事件已经被处理,而且发现老的事件已经被导入到报告系统中,那么每次只需要对新事件进行处理即可。这意味着插入操作会更加高效,因为只需要发送增量数据。我们可以在数据导出的方式中做类似的事情,但是需要自己实现,然而事件流(x 在时间戳 y 上发生)的时效性特性能够帮助我们很好地完成这个目标。
因为事件数据导出的方式与服务的内部实现耦合很小,所以可以把这部分工作交给另一个独立的团队(而非持有数据的那个团队)来维护。只要事件流的设计没有造成订阅者和服务之间的耦合,则这个事件映射器就可以独立于它所订阅的服务来进行演化。
这个方法主要的缺点是,所有需要的信息都必须以事件的形式广播出去,所以在数据量比较大时,不容易像数据导出方式那样直接在数据库级别进行扩展。不管怎样,如果你已经暴露出了合适的事件,我还是建议你考虑这种方式,因为它能够带来低耦合及更好的数据时效性。
5.18 数据导出的备份
Netflix 已经决定把 Cassandra 作为在众多服务中进行数据备份的标准方式。
为了备份 Cassandra 数据,标准的方式是复制一份数据,然后将其放到另外一个安全的地方。Netflix 存储的这些文件叫作 SSTables,它们被存储在 Amazon 的 S3 对象存储服务中,而该服务提供了非常好的数据持久化保证。
在进行报告时,Netflix 需要对所有这些数据进行处理,但由于要考虑扩展性问题,所以是一个非凡的挑战。它使用了 Hadoop,将 SSTable 的备份数据作为任务的数据源。慢慢地,Netflix 实现了一个能够处理大量数据的流水线,并且开源了出来,它就是 Aegisthus 项目( https://github.com/Netflix/aegisthus )。但和数据导出一样,这个模式仍然会引入与最终报告系统表结构(或者说目标系统)之间的耦合。
5.19 走向实时
前面列出了很多把数据从不同的地方汇聚到同一个地方的模式。但是否所有的报告都必须从一个地方出呢?我们有仪表盘、告警、财务报告、用户分析等应用。这些使用场景对于时效性的要求不同,所以需要使用不同的技术。在第 8 章中会讲到,现在我们越来越靠近能够把数据按需路由到多个不同地方的通用事件系统了。
5.20 修改的代价
把设计画在白板上。在你认为的服务边界上运行用例,然后看看会发生什么。比如对于我们的音乐商店来说,想象一下,当客户搜索一条记录、在网站上注册,或者在购买专辑时会发生什么样的调用?你看到奇怪的循环引用了吗?你看到两个服务之间的通信过多,以至于它们应该被合并成为一个吗?
这里我采用了一种在设计面向对象系统时的典型技术:CRC(class-responsibility-collaboration,类 - 职责 - 交互)卡片。你可以在一张卡片写上类的名字、它的职责及与谁进行交互。当我进行设计时,会把每个服务的职责列出来,写清楚它提供了什么能力,和哪些服务之间有协作关系。遍历的用例越多,你就越能知道这些组件是否以正确的方式在一起工作。
5.21 理解根本原因
我们做了很多关于如何把大服务拆分成一些小服务的讨论,但是这些大服务又是怎么产生的呢?第一件需要理解的事情是,服务一定会慢慢变大,直至大到需要拆分。我们希望系统的架构随着时间的推移增量地进行变化。关键是要在拆分这件事情变得太过昂贵之前,意识到你需要做这个拆分。
我们通过寻找服务边界把系统分解开来,且这可以是一个增量的过程。在最开始就要养成及时寻找接缝的好习惯,从而减少分割服务的代价,这样才能够在未来遇到新需求时继续演化我们的系统。如你所见,有些工作是很艰难的。但由于可以增量进行,所以也没什么好怕的。
那么现在可以开始对服务进行分割了,但是我们也引入了一些新的问题:需要部署上线的组件变多了!所以接下来让我们进入到部署的世界。
部署一个单块系统的流程非常简单。然而在众多相互依赖的微服务中,部署却是完全不同的情况。如果部署的方法不合适,那么其带来的复杂程度会让你很痛苦
6.1 持续集成简介
CI(Continuous Integration,持续集成)已经出现很多年了,但还是值得花点时间来好好复习一下它的基本用法,因为在微服务之间的映射、构建及代码库版本管理等方面,存在很多不同的选择。
CI 能够保证新提交的代码与已有代码进行集成,从而让所有人保持同步。CI 服务器会检测到代码已提交并签出,然后花些时间来验证代码是否通过编译以及测试能否通过。
作为这个流程的一部分,我们经常会生成一些构建物(artifact)以供后续验证使用,比如启动一个服务并对其运行测试。理想情况下,这些构建物应该只生成一次,然后在本次提交所对应的所有部署环节中使用。这不仅可以避免多次重复做一件事情,还可以保证部署上线的构建物与测试通过的那个是同一个。为了重用构建物,需要把它们放在某个仓储中。CI 本身会提供这样的仓储,你也可以使用一个独立系统来做这件事情。
CI 的好处有很多。通过它,我们能够得到关于代码质量的某种程度的快速反馈。CI 可以自动化生成二进制文件。用于生成这些构建物的所有代码都在版本的控制之下,所以如果需要的话,可以重新生成这个版本的构建物。通过 CI 我们能够从已部署的构建物回溯到相应的代码,有些 CI 工具,还可以使在这些代码和构建物上运行过的测试可视化。正是因为上述这些好处,CI 才会成为一项如此成功的实践。
你是否每天签入代码到主线?
你应该保证代码能够与已有代码进行集成。如果你的代码和其他人的代码没被频繁地放在一起,那么将来的集成就会非常困难。即使你只使用生命周期很短的分支来管理这些修改,也要尽可能频繁地把代码检入到单个主线分支中。
你是否有一组测试来验证修改?
如果没有测试,我们只能知道集成后没有语法错误,但无法知道系统的行为是否已经被破坏。没有对代码行为进行验证的 CI 不是真正的 CI。
当构建失败后,团队是否把修复CI当作第一优先级的事情来做?
绿色的构建意味着,我们的修改已经安全地和已有代码集成在了一起。红色的构建意味着,最后一次修改很可能有问题,这时只能提交修复构建的代码。如果你允许别人在构建失败时提交更多的修改,用于修复构建的时间就会大大增加。我见过在一个团队中构建失败持续了好几天,最后花了很长时间才修复这个构建。
6.2 把持续集成映射到微服务
当持续集成遇上微服务时,需要考虑如何把 CI 的构建和每个微服务映射起来。前面我已经提过很多次,每个服务应该能够独立于其他服务进行部署。所以如何在微服务、CI 构建及源代码三者之间,建立起合适的映射呢?
如果从最简单的做法开始,我们可以先把所有东西放在一起。如图 6-1 所示,现在我们有一个巨大的代码库,其中包括所有的代码,并且只有一个构建。向该代码库任何一次的代码提交都会触发构建,在这个构建中我们会运行与所有微服务相关的验证,然后产生多个构建物,所有这些都在同一个构建中完成。
把所有微服务放在同一个代码库中,并且只有一个 CI 构建
在同步发布(lock-step release)中,你需要一次性部署多个服务。如果你认为这不是个问题的话,那么上述模式就可以工作得很好。一般来讲,我们绝对应该避免这个模式,但在项目初期是个例外。当仅有一个团队在所有的服务上工作时,这种模式在短时间内是可接受的。
这种模式存在很多明显的缺点。如果我仅仅修改了图 6-1 中用户服务中的一行代码,所有其他的服务都需要进行验证和构建,而事实上它们或许并不需要重新进行验证和构建,所以这里我们花费了不必要的时间。这会影响 CI 的周期时间,也会影响单个修改从开发到上线的速度。更糟糕的是,我不知道哪些构建物应该被重新部署,哪些不应该。我是否需要部署所有的服务来保证所有的修改都能生效?这就很难说清楚了。而且通过提交消息来猜测哪个服务真正被修改了,也是一件很困难的事情。使用这种方式的组织,往往都会退回到同时部署所有代码的模式,而这也正是我们非常不想看到的。
很不幸,如果这一行的修改导致构建失败,那么在构建得到修复之前,与其他服务相关的代码也无法提交。想象一下,如果有很多团队在共享一个巨大的构建,那么谁会对此负责?
这种方法的一个变体是保留一个代码库,但是存在多个 CI 会分别映射到代码库的不同部分,如图 6-2 所示。如果代码库的目录结构定义得合理,就会很容易把其中一部分映射到一个构建中。总的来说我不太喜欢这个方法,因为这个模式可能是把双刃剑。一方面它会简化检出 / 检入的流程,但另一方面,它会让你觉得同时提交对多个服务的修改是一件很容易的事情,从而做出将多个服务耦合在一起的修改。但是相对于只有一个构建的多个服务来说,这个方法已经好很多了。
每个微服务都有自己的 CI,这样就可以在将该微服务部署到生产环境之前做一个快速的验证
如果你对一个服务负责,就应该同时对相关的代码库和构建负责。在这样的世界中,跨微服务做修改会更加困难,但是我认为,相比单块代码库和单块构建流程所带来的问题而言,这个问题更容易解决(比如使用命令行脚本)
每个与微服务相关的测试也应该和其本身的代码放在一起,这样就很容易知道对于某个服务来说应该运行哪些测试。
所以每个微服务都会有自己的代码库和构建流程。我们也会使用 CI 构建流程,全自动化地创建出用于部署的构建物。现在让我们看得更远一些,看看持续交付的概念如何与微服务进行结合。
6.3 构建流水线和持续交付
在早些年使用持续集成时,我们意识到了把一个构建分成多个阶段是很有价值的。比方说在测试中可能有很多运行很快、涉及范围很小的测试;还有一些比较耗时、涉及范围较大的测试,这些测试通常数量也比较少。如果所有测试一起运行的话,有可能一个快速测试已经失败了,但是因为需要等待那些耗时测试的完成,所以还是无法得到快速反馈。而且如果快速测试失败了,再接着运行剩下的耗时测试也是不合理的!解决这个问题的一个方案是,将构建分解成为多个阶段,从而得到我们熟知的构建流水线。在第一个阶段运行快速测试,在第二个阶段运行耗时测试。
构建流水线可以很好地跟踪软件构建进度:每完成一个阶段,就离终点更近一步。流水线也能够可视化本次构建物的软件质量。构建物会在整个构建的第一个环节生成,然后它会被用在整个流水线中。随着构建物通过不同的阶段,我们越来越能确定该软件能够在生产环境下正常工作。
CD(Continuous Delivery,持续交付)基于上述的这些概念,并在此之上有所发展。CD 能够检查每次提交是否达到了部署到生产环境的要求,并持续地把这些信息反馈给我们,它会把每次提交当成候选发布版本来对待。
在 CD 中,我们会把多阶段构建流水线的概念进行扩展,从而覆盖软件通过的所有阶段,无论是手动的还是自动的
一个使用构建流水线建模的标准发布流程
我们需要一个真正重视 CD 概念的工具来辅助它的实施。我看过很多人尝试对 CI 工具进行扩展来做 CD,大多数情况下会得到一个复杂的系统,而这个系统,也不可能比一开始就为 CD 设计的工具好用。完全支持 CD 的工具能够定义和可视化这些流水线,并对发布到生产环境的整个过程进行建模。当某个版本的代码经过流水线时,如果它通过了某个自动验证的步骤,就会移动到下一阶段。有些阶段可能是手动的,举个例子,如果你有一个手动的 UAT(User Acceptance Testing,用户验收测试)流程,那么也应该可以使用 CD 工具来对其建模。应该可以在 CD 工具中看到下一个可用于部署到 UAT 环境的构建,并触发部署流程,如果通过了手动检查,就可以将该阶段标记为成功,这样它就能够移动到下一阶段了。
通过对整个软件上线过程进行建模,软件质量的可视化得到了极大改善,这可以大大减少发布之间的间隔,因为可以在一个集中的地方看到构建和发布流程,这也是可以引入改进的一个焦点。
在微服务的世界,我们想要保证服务之间可以独立于彼此进行部署,所以每个服务都有自己独立的 CI。在流水线中,构建物会沿着上线方向进行移动。构建物的大小和形态可能会有很大差别,后面会看到一些最常见的例子。
不可避免的例外
所有好的规则都需要考虑例外。“每个微服务一个构建”的方法,基本上在大多数情况下都是合理的,那么是否有例外呢?当一个团队刚开始启动一个新项目时,尤其是什么都没有的情况下,你可能会花很多时间来识别出服务的边界。所以在你识别出稳定的领域之前,可以把初始服务都放在一起。
在最开始的阶段,经常会发生跨服务边界的修改,所以时常会有些内容移入或者移出某个服务。在这个阶段,把所有服务都放在一个单独的构建中,可以减轻跨服务修改所带来的代价。
当然,在这个阶段你必须把所有服务打包发布,但这应该是一个过渡步骤。当服务的 API 稳定之后,就可以开始把它们移动到各自的构建中。如果几周(或者几个月)之后,你的服务边界还是不够稳定,那么再把它们合并回单块服务中(当然还可以在边界内部保持模块性),然后花些时间去了解领域。这也是我们 SnapCI 团队的经验,在第 3 章讨论过这个问题。
6.4 平台特定的构建物
大多数技术栈都有相应的构建物类型,同时也有相关的工具来创建和安装这些构建物。
但是从微服务部署的角度来看,在有些技术栈中只有构建物本身是不够的。虽然可以把 Java 的 JAR 包做成可执行文件,并在其中运行一个嵌入式的 HTTP 进程,但对于类似于 Ruby 和 Python 这样的应用程序来说,你需要使用一个运行在 Apache 或者 Nginx 中的进程管理器。所以为了部署和启动这些构建物,需要安装和配置一些其他软件,然后再启动这些构建物。类似于 Puppet 和 Chef 这样的自动化配置管理工具,就可以很好地解决这个问题。
自动化可以对不同构建物的底层部署机制进行屏蔽。Chef、Puppet 及 Ansible 都支持一些通用技术栈的构建物部署。但有一些构建物的部署会非常简单。
6.5 操作系统构建物
有一种方法可以避免多种技术栈下的构建物所带来的问题,那就是使用操作系统支持的构建物。举个例子,对基于 RedHat 或者 CentOS 的系统来说,可以使用 RPM;对 Ubuntu 来说,可以使用 deb 包;对 Windows 来说,可以使用 MSI。
6.6 定制化镜像
使用类似 Puppet、Chef 及 Ansible 这些自动化配置管理工具的一个问题是,需要花费大量时间在机器上运行这些脚本。考虑这样一个例子:对服务器进行配置,使其能够部署 Java 应用程序。假设我的服务器在 AWS 上,使用的是标准的 Ubuntu 镜像。为了运行 Java 应用程序,需要做的第一件事情是安装 Oracle JVM。这个简单的过程可能就会花费五分钟,其中一些时间用于启动机器上,剩下的则用于安装 JVM。然后我们才能开始考虑把软件放上去。
上面这个例子比较简单,实际情况下还需要安装其他常用软件。比如,可能需要使用 collectd 来收集操作系统的状态,使用 logstash 来做日志的聚合,还可能需要安装 nagios 来做监控(第 8 章会详细讨论这部分内容)。随着时间的推移,越来越多的东西被添加进来,所以自动化配置环境所需的时间也会越来越长。
Puppet、Chef 和 Ansible 这类的工具,能够很智能地避免重复安装已安装的软件。但不幸的是,这并不意味着在已经存在的机器上运行这些脚本总会很快,因为仅仅是做这些检查就会花费很多时间。同时,我们也想避免一台机器运行的时间过长,因为这会引起配置漂移(后面会详细解释)。如果使用按需计算平台,那么可以每天(如果不是更频繁的话)按需关闭和启动新的实例,所以这些声明式的配置管理工具的使用可能会受到限制。
随着时间的推移,看着同样的工具被一遍遍重复安装,也是一种煎熬。如果在 CI 上运行这些脚本,那么也无法得到快速反馈。在进行部署时,服务停止的时间也会增加,因为你在等待软件的安装。类似于蓝 / 绿部署(第 7 章会详细讲解)的模式,可以帮助你缓解这个问题,因为它允许我们在老版本服务不下线的同时,去部署新版本的服务。
一种减少启动时间的方法是创建一个虚拟机镜像,其中包含一些常用的依赖,如图 6-5 所示。我用过的所有虚拟化平台,都允许用户构建自己的镜像,而且现在的工具提供的便利程度,也远远超越了多年前的那些工具。使用这种方法之后事情就变得简单一些了。现在你可以把公共的工具安装在镜像上,然后在部署软件时,只需要根据该镜像创建一个实例,之后在其之上安装最新的服务版本即可。
你只需要构建一次镜像,然后根据这些镜像启动虚拟机,不需要再花费时间来安装相应的依赖,因为它们已经在镜像中安装好了,这样就可以节省很多时间。如果你的核心依赖没有改变,那么新版本的服务就可以继续使用相同的基础镜像。
这个方法也有一些缺点。首先,构建镜像会花费大量的时间。这意味着,在开发环境中可能需要使用其他替代部署方案,避免花费很长时间去创建一个二进制部署物。其次,产生的镜像可能会很大。当你创建 VMWare 镜像时,这会是一个很大的问题。想象一下,在网络上传送一个 20GB 的镜像文件是怎样一个场景。后面会介绍一种容器技术:Docker,它可以避免上述的一些问题。
6.6.1 将镜像作为构建物
把服务本身也包含在镜像中,这样就把镜像变成了构建物。现在当你启动镜像时,服务就已经就绪了。Netflix 就是因为这个快速启动的好处,把自己的服务内建在了 AWS AMI 中。
我们不需要关心运行在镜像中的服务,所使用的语言是 Ruby 还是 Java,最终的构建物是 gem 还是 JAR 包,我们唯一需要关心的就是它是否工作。然后把精力放在镜像创建和部署的自动化上即可。这个简洁的方法有助于我们实现另一个部署概念:不可变服务器。
6.6.2 不可变服务器
通过把配置都存到版本控制中,我们可以自动化重建服务,甚至重建整个环境。 但是如果部署完成后,有人登录到机器上修改了一些东西呢?这就会导致机器上的实际配置和源代码管理中的配置不再一致,这个问题叫作配置漂移。
为了避免这个问题,可以禁止对任何运行的服务器做手动修改。相反,无论修改多么小,都需要经过构建流水线来创建新的机器。事实上,即使不使用镜像,你也可以实现类似的模式,但它是把镜像作为构建物的一个非常合理的扩展。你甚至可以在镜像的创建过程中禁止 SSH,以确保没有人能够登录到机器上做任何修改。
当然,在使用这个方法时,也需要考虑前面提到的周期时间这个因素。同时需要保证,机器上的持久化数据也被保存到了其他地方 1。尽管存在这些复杂性,但我看到很多团队使用这种模式之后,部署过程变得更容易理解,环境问题也更容易定位。前面我已经说过,任何能够简化工作的措施都值得尝试!
1因为该机器随时可能会被销毁
6.7 环境
当软件在 CD 流水线的不同阶段之间移动时,它也会被部署到不同的环境中。如果考虑图 6-4 中所示的构建流水线,其中起码存在 4 个环境:一个用来运行耗时测试,一个用来做 UAT,一个用来做性能测试,另一个用于生产环境。我们的微服务构建物从头到尾都是一样的,但环境不同。至少它们的主机是隔离的,配置也不一样。而事实上情况往往会复杂得多。举个例子,我们的生产环境可能会包括两个数据中心的多台主机,使用负载均衡来管理,而测试环境可能会把所有的服务运行在一台机器上。这些环境之间的不同可能会引起一些问题。
从笔记本到 UAT,最终再到生产环境,我们希望前面的那些环境能不断地靠近生产环境,这样就可以更快地捕获到由环境差异导致的问题。你需要持续地做权衡。有时候重建类生产环境所消耗的时间和代价会让人望而却步,所以你必须做出妥协。比如说,把软件部署到 AWS 上需要 25 分钟,而在本地的 Vagrant 实例中部署服务会快得多。
类生产环境和快速反馈之间的平衡不是一成不变的。要持续关注将来产生的那些 bug 和反馈时间,然后按需去调节这个平衡。
管理单块系统的环境很具有挑战性,尤其是当你对那些很容易自动化的系统没有访问权的时候。当你需要对每个微服务考虑多个环境时,事情会更加艰巨。
6.8 服务配置
服务需要一些配置。理想情况下,这些配置的工作量应该很小,而且仅仅局限于环境间的不同之处,比如用来连接数据库的用户名和密码。应该最小化环境间配置的差异。如果你的配置修改了很多服务的基本行为,或者不同环境之间的配置差异很大,那么你可能就只能在一套环境中发现某个特定的问题,这是极其痛苦的事情。
如果存在不同环境之间的配置差异,应该如何在部署流程中对其进行处理呢?一种方法是对每个环境创建不同的构建物,并把配置内建在该构建物中。刚开始看这种方法好像挺有道理。配置已经被内建了,只需要简单的部署,它应该就能够正常工作了,对吧?其实这是有问题的。还记得持续交付的概念吗?我们想要创建一个构建物作为候选发布版本,并使其沿着流水线向前移动,最终确认它能够被发布到生产环境。想象一下,我构建了一个 Customer-Service-Test 构建物和 Customer-Service-Prod 构建物。如果 Customer-Service-Test 构建物通过了测试,但我真正要部署的构建物却是 Customer-Service-Prod,又要如何验证这个软件最终会真正运行在生产环境中呢?
首先,创建这些构建物比较耗时。其次,你需要在构建的时候知道存在哪些环境。你要如何处理敏感的配置数据?我可不想把生产环境的数据库密码提交到源代码中,但是如果在创建这些构建物时需要的话,通常这也是难以避免的。
一个更好的方法是只创建一个构建物,并将配置单独管理。从形式上来说,这针对的可能是每个环境的一个属性文件,或者是传入到安装过程中的一些参数。还有一个在应对大量微服务时比较流行的方法是,使用专用系统来提供配置,第 11 章会详细讨论这个话题。
6.9 服务与主机之间的映射
6.9.1 单主机多服务
这个模型与应用程序容器的模型类似。从某种角度来看,应用程序容器就是单主机多服务模型的一个特例,所以后面会单独考虑这个场景。这个模型也会简化开发人员的工作,因为将多个服务部署在生产环境的单个主机上,与把多个服务部署在本地开发机上的过程很类似。如果想要考虑其他模型,也应该从概念上对开发人员保持简单性。
但这个模型也有一些挑战。首先,它会使监控变得更加困难。举个例子,当监控 CPU 使用率时,应该监控每个单独的服务还是整个机器呢?服务之间的相互影响也是不可避免的。如果一个服务的负载很高,那么它有可能会过多占用系统其他部分的资源。
服务的部署也会变得更复杂,因为很难保证对一个服务的部署不会影响其他的服务。
这个模型对团队的自治性也不利。如果不同团队所维护的服务安装在了同一台主机上,那么谁来配置这些服务所在的主机呢?很有可能最后有一个专门的团队来做这些事情,这就意味着,需要和更多人协调才能完成服务的部署。
在单个主机上部署多个服务,会增加对单个服务进行扩展的复杂性。如果一个微服务处理的数据很敏感,底层主机的配置也可能会有所不同,或者干脆把这台主机放置在不同的网络中。把所有东西放在一台主机上意味着,即使每个服务的需求是不一样的,我们也不得不对它们一视同仁。
6.9.2 应用程序容器
单台主机多个微服务
这种设置也可以节省语言运行时的开销。
即便如此,我还是认为这种应用程序容器的做法存在很多问题,所以在使用时需要非常谨慎。
我对某些容器提供的特性也有质疑。它们中的很多实现,都在兜售通过集群管理来支持内存中的共享会话状态的能力,而这无论如何都是应该避免的方式,因为它会影响服务的可伸缩性。当我们考虑在微服务世界中使用的聚合监控时,它们提供的监控能力又难以支撑
试图在类似于 JVM 这样的平台上,做一些应用程序的生命周期管理是很困难的,这比简单地重启一下 JVM 要复杂得多。因为你的多个应用程序都处在同一个进程中,所以分析资源的使用和线程也非常复杂。记住,即使你真的从某个特定技术的容器中获得了一些好处,它们也不是免费的。先不说它们中的大多数都是付费软件这一点,它们在资源上的额外开销也特别值得考虑。
从根本上来说,这个方法还是想要试图优化资源的使用,但在如今的环境下已经没有必要这么做了。无论你最终是否使用将多个服务放在一个主机中的部署模型,我都会强烈建议你看看自包含的微服务构建物。对于 .NET 来说,可能是类似 Nancy 这样的东西,而 Java 很多年前就已经支持了。举个例子,令人敬仰的 Jetty 嵌入式容器中,使用了一个非常轻量级的自包含 HTTP 服务器,而它正是 Dropwizard 技术栈的核心。Google 非常广泛地采用了嵌入式 Jetty 容器来直接服务静态内容,所以这种做法的伸缩性肯定是没有问题的。
6.9.3 每个主机一个服务
这种模型避免了单主机多服务的问题,并简化了监控和错误恢复。这种方式也可以减少潜在的单点故障。一台主机宕机只会影响一个服务,虽然在虚拟化平台上不一定真的是这样。
我们也可以独立于其他服务很容易地对某一个服务进行扩展,安全性措施也可以更有目的性地在更小范围内进行。
这样做之后我们才有可能采用一些不同的部署技术,比如前面提到的基于镜像的部署或者不可变服务器模式。
管理更多的服务器,运行更多不同的主机也会引入很多的隐式代价。尽管存在这些问题,但我仍然认为在使用微服务架构时这是比较好的模型。
6.9.4 平台即服务
当使用 PaaS(Platform-as-a-Service,平台即服务)时,你工作的抽象层次要比在单个主机上工作时的高。
Heroku 就是一个黄金级的 PaaS。它不仅能够管理服务的运行,还能以非常简单的方式提供数据库等服务。
当 PaaS 解决方案正常工作时,它们工作得特别好。但是如果出现问题,你通常没办法通过操作底层操作系统来修复这些问题。所以这也是你要做的取舍。
希望 PaaS 能够成为部署平台的首选,而不是自己管理主机及每个服务的部署。
6.10 自动化
很多问题都可以使用自动化来解决。当机器数量比较少时,手动管理所有的事情是有可能的。我以前就这么做过。记得当时我管理了少量的生产环境机器,登录到机器上进行日志收集、软件部署、进程查看等工作。我的生产力似乎仅受能够打开的终端窗口的数量的限制,所以当我开始使用了第二个显示器时,生产力得到了很大的提高。但是这种方式很快就不适用了。
单主机单服务的模式会引入很多主机,从而产生很多的管理开销。如果你手动做所有的事情,那么管理开销确实会很大,如果服务器的数量翻倍,你的工作量也会翻倍!但是如果我们将主机控制、服务部署等工作自动化,那么工作量肯定就不会随着主机数量的增加而线性增长。
但即使我们控制了主机的数量,还是会有很多服务。这就意味着有更多的部署要处理、更多的服务要监控、更多的日志要收集,所以自动化很关键。
自动化还能够帮助开发人员保持工作效率。自助式配置单个服务或者一组服务的能力,会大大简化开发人员的工作。理想情况下,开发人员使用的工具链应该和部署生产环境时使用的完全一样,这样就可以及早发现问题。本章的很多技术都采纳了这个思想。
使用支持自动化的技术非常重要。让我们从管理主机的工具开始考虑这个问题,你能否通过写一行代码来启动或者关闭一个虚拟机?你能否自动化部署写好的软件?你能否不需要手工干预就完成数据库的变更?想要游刃有余地应对复杂的微服务架构,自动化是必经之路。
6.11 从物理机到虚拟机
6.11.1 传统的虚拟化技术
把机器划分成大量的 VM 并不是免费的
6.11.2 Vagrant
Vagrant 是一个很有用的部署平台,通常在开发和测试环境时使用,而非生产环境。Vagrant 可以在你的笔记本上创建一个虚拟的云。它的底层使用的是标准的虚拟化系统(通常是 VirtualBox,但也可以使用其他平台)。你可以使用文本文件来定义一系列虚拟机,并且可以在其中定义网络配置及镜像等信息。可以把这个文本文件提交到代码库中,与团队的其他成员共享。
这些工具能够帮助你在本地机器上轻松地创建出类生产环境。你可以同时创建多个 VM,通过关掉其中的几台来测试故障模式,并且可以把本地目录映射到虚拟机中,这样就可以在修改完代码之后立即看到效果。即使对于使用类似 AWS 这样的按需云平台的团队来说,使用 Vagrant 带来的快速反馈也能够给他们带来不少好处。
但它的缺点是,开发机上会有很多额外的资源消耗。如果一个服务占用一台虚拟机,你可能就很难在本地机器上搭建起整个系统。结果就是为了让开发和测试有好的体验,可能需要把其中一些依赖打桩,从而让事情变得可控一些。
6.11.3 Linux容器
Linux 用户可以使用另外一种虚拟化的替代方案。相比使用 hypervisor 隔离和控制虚拟主机的方法来说,Linux 容器可以创建一个隔离的进程空间,进而在这个空间中运行其他的进程。
容器也能够很好地与虚拟机一起工作。我见过很多项目都会选择在 AWS 的 EC2 上,启动一个比较大的实例,然后在此之上运行 LXC 容器。EC2 是一个能够提供短暂型按需计算的平台,容器非常灵活并且速度很快,灵活运用可以很好地利用二者的优势。
6.11.4 Docker
Docker 是构建在轻量级容器之上的平台。它帮你处理了大多数与容器管理相关的事情。你可以在 Docker 中创建和部署应用,这些基于容器的应用与 VM 世界中的镜像很类似。Docker 也能管理容器的配置,并帮你处理一些网络问题,甚至还提供了自己的 registry 概念,允许你存储 Docker 应用程序的版本。
Docker 应用抽象对我们来说非常有用,就像使用 VM 镜像技术时,底层实现服务的技术是不可见的一样。在服务的构建中可以创建出 Docker 应用程序,然后把它们存储在 Docker registry 中,那么就搞定了。
Docker 还可以缓解运行过多服务进行本地开发和测试的问题。我们可以在 Vagrant 中启动单个 VM,然后在其中运行多个 Docker 实例,每个实例中包含一个服务,而非原来的一个 Vagrant 虚拟机中包含一个服务。接下来,就可以使用 Vagrant 来创建和销毁 Docker 平台本身,并使用 Docker 来快速配置每个服务了。
很多与 Docker 相关的技术,能够帮助我们更好地使用它。CoreOS 是一个专门为 Docker 设计的操作系统。它是一个经过裁剪的 Linux OS,仅提供了有限的功能以保证 Docker 的运行。这意味着,它比其他操作系统消耗的资源更少,从而可以把更多的资源留给容器。它甚至没有类似 debs 或 RPM 这样的包管理器,所有的软件都被装在一个独立的 Docker 应用程序中,并仅在各自的容器中运行。
Docker 本身并不能解决所有的问题,它只是一个在单机上运行的简单的 PaaS。你还需要一些工具,来帮助你跨多台机器管理 Docker 实例上的服务。调度层的一个关键需求是,当你向其请求一个容器时会帮你找到相应的容器并运行它。在这个领域,Google 最近的开源工具 Kubernetes 和 CoreOS 集群技术能够提供一定的帮助,而且似乎每个月都有新的竞争者出现。另一个基于 Docker 的有趣的工具是 Deis( http://deis.io/ ),它试图在 Docker 之上,提供一个类似于 Heroku 那样的 PaaS。
在很多情况下,这种 Docker 加上一个合适的调度层的解决方案介于 IaaS 和 PaaS 之间,很多地方使用 CaaS(Communications-as-a-Service,容器即服务)来描述它。
6.12 一个部署接口
不管用于部署的底层平台和构建物是什么,使用统一接口来部署给定的服务都是一个很关键的实践。在很多场景下,都有触发部署的需求,从本地开发测试到生产环境部署。这些不同环境的部署机制应该尽量相似,我可不想因为部署流程不一致,导致一些只能在生产环境才能发现的问题。
在这个领域工作了这么多年后,我深信,参数化的命令行调用是触发任何部署的最合理的方式。可以使用 CI 工具来触发脚本的调用,或者手动键入。我在不同的技术栈下都编写过包装脚本来完成部署工作,从 Windows 批处理到 bash,再到 Python Fabric 脚本等。但所有这些命令行脚本的格式都大同小异。
我们要知道部署的是什么,所以需要提供已知实体的名字,在这里也就是微服务的名字,并且还需要知道该实体的版本。在不同的情况下可能会有三种不同的答案。当在本地工作时,使用本地版本即可;进行测试时,需要使用最近通过的构建,也就是在构建物仓库中最新的构建物;或者当进行测试和定位问题时,需要部署一个确切版本的构建物。
第三件也是最后一件需要我们知道的事情是,应该把微服务部署到哪个环境中。正如前面讨论过的,我们的微服务拓扑在不同的环境中,可能是不同的,但这些信息应该对我们不可见。
想象一下,我们创建了一个简单的需要这三个参数的部署脚本。在进行本地开发时,我们想要把目录服务部署到本地环境。我可能会键入:
$ deploy artifact=catalog environment=local version=local
代码一旦提交,CI 就会进行一次构建,并生成一个新的构建物,其编号为 b456。在大多数 CI 工具中,这个值都会在整个流水线中传递。到测试阶段后,CI 可以按照下面的方式运行命令:
$ deploy artifact=catalog environment=ci version=b456
同时,QA 会想要拉取最新的目录服务到集成测试环境,来进行一些探索性测试,并准备了一个 showcase。这时可以运行:
$ deploy artifact=catalog environment=integrated_qa version=latest
我使用最多的工具是 Fabric,它是一个被设计用来将命令行调用映射到函数的 Python 库,同时也能够提供类似 SSH 这样的机制来控制远程机器。结合 Boto 这样的 AWS 客户端来使用,你就能完全自动化大型 AWS 环境。对于 Ruby 来说,Capistrano 类似于 Fabric。在 Windows 上使用 PowerShell,可以达到同样的效果。
环境定义
很显然,为了完成上述工作,还需要使用某种方式对环境进行定义,并对服务在环境中的配置进行描述。你可以把环境定义想象成微服务到计算、网络和存储资源之间的映射。我以前使用 YAML 文件进行描述,使用脚本从中获取数据。示例 6-1 是几年前我在一个使用 AWS 的项目上,做过的一些工作的简化版。
development:
nodes:
- ami_id: ami-e1e1234
size: t1.micro ➊
credentials_name: eu-west-ssh ➋
services: [catalog-service]
region: eu-west-1
production:
nodes:
- ami_id: ami-e1e1234
size: m3.xlarge ➊
credentials_name: prod-credentials ➋
services: [catalog-service]
number: 5 ➌
➊ 改变实例的大小从而充分利用资源。你不需要一个 16 核、64GB 内存的机器来做探索性测试!
➋ 能够在不同环境中设置不同凭证(credential)的能力很关键。敏感环境的凭证信息会被存储在不同的代码库中,这些代码库只有少数人可以访问。
➌ 我们决定默认情况下,如果一个服务有多台节点需要配置,就自动为其创建一个负载均衡。
目录服务的信息存储到了其他地方。它在不同的环境中是一致的
很多行为都是基于约定的。举个例子,我们决定规范化服务可以使用的端口,无论它们在哪里运行。当服务有一个以上实例时就会自动配置负载均衡器(AWS 的 ELB 很容易处理这件事情)。
构建一个类似于这样的系统的工作量很大。这些代价基本上都需要在前期付出,但是做好之后,它能够很好地管理部署的复杂性。我希望将来你不需要自己做这些事情。Terraform 是来自 Hashicorp 的一个很新的工具,它就可以帮你做上述事情。一般我不太会在书里提这么新的工具,因为与其说它是个工具,还不如说只是一个想法而已,但它正在朝着上述那个方向发展。目前这个工具还在初期,但它的功能似乎非常有趣。它已经支持了多个不同平台的部署工作,所以在将来,也许它能够很好地胜任这项工作。
6.13 小结
首先,专注于保持服务能够独立于其他服务进行部署的能力,无论采用什么技术,请确保它能够提供这个能力。我倾向于一个服务一个代码库,对于每个微服务一个 CI 这件事情,我不仅仅是倾向,并且非常坚持,因为只有这样才能实现独立部署。
接下来,如果可能的话,将每个服务放到单独的主机 / 容器中。看看类似 LXC 或者 Docker 这样的替代技术,如何简化对多个服务的管理。但要记住的一点是,无论你采用什么技术,自动化的文化对一切管理来说都非常重要。自动化一切,如果你采用的技术不支持的话,就去选用一个新的技术吧!使用类似 AWS 这样的平台,能够在你进行自动化时提供大量的便利。
确保你理解部署技术的选择会对开发人员有怎样的影响,并确保他们也能够感受到。创建工具对任何给定服务到不同环境的自助部署提供服务,是非常重要的事情,因为它对开发、测试和运维人员都能提供很大的帮助。
如何高效且有效地测试分布式系统的功能依然是一个挑战。本章会剖析一下测试细粒度系统面临的问题和挑战,并提出一些解决方案,帮助大家更有信心地发布新的功能。
7.1 测试类型
Brian Marick 想出了一个非常棒的分类测试体系,恰好就是用象限的方式。图 7-1 展示了 Lisa Crispin 和 Janet Gregory 在《敏捷软件测试》一书中,用来将不同测试类型分类的测试象限,这个象限是 Matrick 的演化版本。
Lisa Crispin 和 Janet Gregory 在《敏捷软件测试》
在这个象限中,每种测试类型都有自己相应的位置。在不同系统中,每种类型的测试占比是有差别的。重点是要理解你在测试方面可以有不同的选择。放弃大规模的手工测试,尽可能多地使用自动化是近年来业界的一种趋势
测试微服务架构的系统跟测试独立系统的区别,很大程度上在于各种类型的自动化测试
7.2 测试范围
测试金字塔
这个金字塔模型不仅可以帮助我们思考不同的测试类型应该覆盖的范围,还能帮助我们思考应该为这些不同的测试类型投入多大的比例。如图 7-2 所示,Cohn 在他的原始模型中把自动化测试划分为单元测试、服务测试和用户界面测试三层。
这个原始模型存在一个问题:不同的人对这些术语有不同的解读。尤其是“服务”这个词经常有各种不同的解读,而单元测试也有很多定义。只测试一行代码是单元测试吗?我会说是。那测试多个函数或者多个类仍然是单元测试吗?我会说不是,不过很多人并不同意!从现在开始,尽管单元测试和服务测试这两个名称有歧义,我还是继续使用它们。不过对于用户界面测试,接下来我们改称它为端到端测试。
7.2.1 单元测试
单元测试通常只测试一个函数和方法调用。通过 TDD(Test-Driven Design,测试驱动开发)写的测试就属于这一类,由基于属性的测试技术所产生的测试也属于这一类。在单元测试中,我们不会启动服务,并且对外部文件和网络连接的使用也很有限。通常情况下你需要大量的单元测试。如果做得合理,它们运行起来会非常非常快,在现代硬件环境上运行上千个这种测试,可能连一分钟都不需要。
在 Marick 的术语中,单元测试是帮助我们开发人员的,是面向技术而非面向业务的。我们也希望通过它们来捕获大部分的缺陷。因此,如图 7-4 所示,在我们示例的客户服务中,单元测试是彼此独立的,分别覆盖一些小范围的代码。
7.2.2 服务测试
服务测试是绕开用户界面、直接针对服务的测试。在独立应用程序中,服务测试可能只测试为用户界面提供服务的一些类。对于包含多个服务的系统,一个服务测试只测试其中一个单独服务的功能。
只测试一个单独的服务可以提高测试的隔离性,这样我们就可以更快地定位并解决问题。如图 7-5 所示,为了达到这种隔离性,我们需要给所有的外部合作者打桩,以便只把服务本身保留在测试范围内。
一些服务测试可能会像单元测试一样快,但如果你在测试中使用了真实的数据库,或通过网络跟打桩的外部合作者交互,那么测试时间会增加。服务测试比简单的单元测试覆盖的范围更大,因此当运行失败时,也比单元测试更难定位问题。不过,相比更大范围的测试,服务测试中包含的组件已经少多了,因此也没大范围的测试那么脆弱。
7.2.3 端到端测试
端到端测试会覆盖整个系统。这类测试通常需要打开一个浏览器来操作图形用户界面(GUI),也很容易模仿类似上传文件这样的用户交互。
这种类型的测试会覆盖大范围的产品代码。因此,当它们通过的时候你会感觉很好,会确定这些被测试过的代码在生产环境下也能工作。但是待会儿就会看到,伴随着覆盖范围的增大,一些在使用微服务过程中很难消除的负作用也会随之而来。
7.2.4 权衡
在使用这个金字塔时,应该了解到越靠近金字塔的顶端,测试覆盖的范围越大,同时我们对被测试后的功能也越有信心。而缺点是,因为需要更长的时间运行测试,所以反馈周期会变长。并且当测试失败时,比较难定位是哪个功能被破坏。而越靠近金字塔的底部,一般来说测试会越快,所以反馈周期也会变短,测试失败后更容易定位被破坏的功能,持续集成的构建时间也很短。另外,还能避免我们在不知道已经破坏了某个功能的情况下转去做新的任务。这些更小范围的测试失败后,我们更容易定位错误的地方,甚至经常能定位到具体哪行代码。从另一个角度来看,当只测试了一行代码时,我们又很难有充足的信心认为,系统作为一个整体依然能正常工作。
当范围更大的测试(比如服务测试或者端到端测试)失败以后,我们会尝试写一个单元测试来重现问题,以便将来能够以更快的速度捕获同样的错误。我们通过这种方式来持续地缩短反馈周期。
7.2.5 比例
既然所有的测试都有优缺点,那每种类型需要占多大的比例呢?一个好的经验法则是,顺着金字塔向下,下面一层的测试数量要比上面一层多一个数量级。
举个例子,我曾在一个单块系统上工作过,这个系统有 4000 个单元测试、1000 个服务测试和 60 个端到端测试。我们发现测试的反馈周期很长,其原因在于有太多的服务测试和端到端测试(后者是反馈周期变长的罪魁祸首),之后我们便尽量使用小范围的测试来替换这些大范围的测试。
一种常见的测试反模式,通常被称为测试甜筒或倒金字塔。在这种反模式中,有一些甚至没有小范围的测试,只有大范围的测试。这些项目的测试运行起来往往极度缓慢,反馈周期很长。如果把这些缓慢的测试作为持续集成的一部分,那就很难做到多次构建。而长时间的构建也意味着当提交有错误时,需要很长一段时间才能发现这个问题。
7.3 实现服务测试
在自动化测试的所有类型中,单元测试是比较简单的,相关的资料也非常多。而服务测试和端到端测试的实现则要复杂得多。
服务测试只想要测试一个单独服务的功能,为了隔离其他的相关服务,需要一种方法给所有的外部合作者打桩。如果想要测试像图 7-3 那样的客户服务,我们需要部署这个服务的实例,然后给它所有的下游合作服务打桩。
构建是持续集成的第一步,它会为服务创建一个二进制的包,所以部署服务很明确。不过我们该如何给下游的合作者打桩呢?
对于每一位下游合作者,我们都需要一个打桩服务,然后在运行服务测试的时候启动它们(或者确保它们正常运行)。我们还需要配置被测服务,在测试过程中连接这些打桩服务。接着,为了模仿真实的服务,我们需要配置打桩服务为被测服务的请求发回响应。例如,我们可以配置积分账户为不同的客户返回不同的预设积分。
7.3.1 mock还是打桩
打桩,是指为被测服务的请求创建一些有着预设响应的打桩服务。比如我可能会设置积分账户,当有请求询问客户 123 的余额时,它应该返回 15 000。这时候的测试不关心这个打桩服务被访问了 0 次、1 次还是 100 次。另一种替换打桩的方式是 mock。
与打桩相比,mock 还会进一步验证请求本身是否被正确调用。如果与期望请求不匹配,测试便会失败。这种方式的实现,需要我们创建更智能的模拟合作者,但过度使用 mock 会让测试变得脆弱。相比之下,前面提到的打桩并不在乎请求发生了 0 次、1 次还是很多次。
有时候可以用 mock 来验证预期的副作用是否发生。例如,可以使用 mock 来验证创建一个客户后,与其相关的积分余额是否也被创建。无论在单元测试还是在服务测试中,使用打桩还是 mock 都是很微妙的选择。不过一般来说,我在服务测试中使用打桩的次数要远远超过使用 mock 的次数
打桩和 mock 之间的区别很明显。不过据我了解,很多人会感到困惑,特别是当再引入其他诸如 fakes、spies 和 dummies 这些术语时。Martin Fowler 把包括打桩和 mock 在内的所有这些术语统称为测试替身(Test Double, http://www.martinfowler.com/bliki/TestDouble.html )。
7.3.2 智能的打桩服务
Mountebank( http://www.mbtest.org/ )打桩 /mock 服务器
可以把 Mountebank 看作一个通过 HTTP 可编程的小应用软件。虽然它是用 Node.js 编写的,但对调用它的服务来说这完全是透明的。当启动后,你可以发送命令告诉它需要打桩什么端口、使用哪种协议(目前支持 TCP、HTTP 和 HTTPS,未来会支持更多)以及当收到请求时该响应什么内容。当你想把它当 mock 来使用时,它还支持对预期行为的设置。你可以在 Montebank 的一个实例上,很方便地添加或删除打桩接口,这样就可以使用一个实例来打桩多个下游的合作服务。
在运行客户服务的服务测试时,我们需要启动客户服务本身外加一个 Mountebank 的实例来作为积分账户的替身。如果这些测试通过,我立马就可以部署客户服务啦!等等,真的可以吗?那些调用客户服务的服务(比如帮助台和网络商店)怎么办?我们更新的内容是否会影响它们?是的,我们差点忘记了,测试金字塔顶部还有一个非常重要的测试:端到端测试。
7.4 微妙的端到端测试
在微服务系统中,界面展示的一个功能往往涉及多个服务
运行端到端测试需要部署多个服务。显然,这种测试可以覆盖更大的范围,也让我们对系统的正常工作更有信心。另一方面,这种测试运行起来比较慢,定位失败也更加困难。
假设我们开发了客户服务的一个新版本。我们想尽快把新版本部署到生产环境,但又担心引入的某些变化会破坏帮助台或者网络商店的功能。没问题,让我们部署所有的服务,然后对帮助台和网络商店运行一些端到端测试来验证是否引入了缺陷。一个不成熟的方案是,直接在客户服务流水线的最后增加这些测试
加在客户服务流水线的最后:这种方式正确吗?
到目前为止还好。但我们首先需要问自己一个问题:应该使用其他服务的哪个版本 ? 是否应该使用与生产环境相同的帮助台和网络商店版本?这是一个合理的假设,但是如果帮助台或网络商店也有新的版本准备上线,那该怎么办呢 ?
另一个问题是,如果说客户服务的测试需要部署多个服务,然后运行端到端测试来覆盖,那么其他服务的端到端测试该怎么办?如果它们也测试同样的功能,就会发现这些测试有很多的重叠,而且需要在运行测试前花费大量的成本来重复部署这些服务。
解决这两个问题的一种优雅的方法是,让多个流水线扇入(fan in)到一个独立的端到端测试的阶段(stage)。使用这种方法,任意一个服务的构建都会触发一次端到端测试,如图 7-8 所示。一些更好地支持构建流水线的 CI 工具可以很方便地实现这样的扇入模型。
覆盖多个服务的端到端测试的一种标准方式
这样,任意一个服务在任何时候只要发生变化,我们都会运行针对这些服务的测试。如果测试通过,便会触发端到端测试。这个方法听起来很棒,不是吗?不过,这样做还是会有一些问题。
7.5 端到端测试的缺点
7.6 脆弱的测试
随着测试范围的扩大,纳入测试的服务数量也会相应地增加。这些服务有可能会使测试失败,而这种失败并不是因为功能真的被破坏了,而是由其他一些原因引起的。举例来说,如果我们有一个测试来验证订购 CD 的功能,这个功能涉及四到五个服务,其中任意一个服务停止运行都会导致测试的失败,但这种失败与被测的功能本身没有关系。同样,一个临时的网络故障也可能导致测试失败,这也跟被测的功能本身没有关系。
包含在测试中的服务数量越多,测试就会越脆弱,不确定性也就越强。如果测试失败以后每个人都只是想重新运行一遍测试,然后希望有可能通过,那么这种测试是脆弱的。不仅这种涉及多个服务的测试很脆弱,涉及多线程功能的测试通常也会有问题,测试失败有时是因为资源竞争、超时等,有时是功能真的被破坏了。脆弱的测试是我们的敌人,因为这种测试的失败不能告诉我们什么有用的信息。如果所有人都习惯于重新构建 CI,以期望刚失败的测试通过,那么最终结果只能是看到堆积的提交,然后突然间你会发现有一大堆功能早已经被破坏了。
当发现脆弱的测试时,我们应该竭尽全力去解决这个问题。否则,人们就会开始对测试套件失去信心,因为它们“总是这样失败”。一个包含脆弱测试的测试套件往往会成为 Diane Vaughn 所说的异常正常化(the normalization of deviance)的受害者,也就是说,随着时间的推移,我们对事情出错变得习以为常,并开始接受它们是正常的。1 因为人类有这种倾向,所以在开始接受失败测试是正常的之前,应该尽快找到这些脆弱的测试并消除它们。
Martin Fowler 建议发现脆弱的测试时应该立刻记录下来,当不能立即修复时,需要把它们从测试套件中移除,然后就可以不受打扰地安心修复它们。修复时,首先看看能不能通过重写来避免被测代码运行在多个线程中,再看看是否能让运行的环境更稳定。更好的方法是,看看能否用不易出现问题的小范围测试取代脆弱的端到端测试。有时候,改变被测软件本身以使之更容易测试也是一个正确的方向。
7.6.1 谁来写这些测试
既然这些测试是某服务流水线的一部分,一个比较合理的想法是,拥有这些服务的团队应该写这些测试
当一个服务涉及多个团队,而且端到端测试也被多个团队共享时,谁该负责实现和维护这些测试?
很多反模式。一种情况是,这些测试对所有人开放,所有团队成员都可以在无须对测试套件质量有任何理解的情况下随意添加测试。这往往会导致测试用例爆炸,有时甚至会导致我们前面谈到的测试甜筒。我还曾经看到过这样的情况,因为测试没有真正的拥有者,所以它们的结果会被忽略。当测试失败后,每个人都认为是别人的问题,大家根本不在乎测试是否通过。
有些组织的答案是由一个专门的团队来写这些测试。这可能是灾难性的。开发软件的人渐渐远离测试代码,周期时间(cycle time)会变长,因为服务的拥有者实现功能需要等待测试团队来写端到端测试。因为这些测试由别的团队编写,实现服务的团队很少参与,所以很难了解如何运行和修复这些测试。很不幸,这是一个非常常见的组织模式,只要团队没有在第一时间测试自己所写的代码,就会出现很大的问题。
在这方面做到正确真的很难。我们不想做重复的工作,也不想过度集权,比如让测试远离实现服务的团队。我发现最好的平衡是共享端到端测试套件的代码权,但同时对测试套件联合负责。团队可以随意提交测试到这个套件,但实现服务的团队必须全都负责维护套件的健康。如果你想在多个团队中大范围地使用端到端测试,这种方法是必要的,然而我看到的团队很少这样做,所以存在的问题也很多。
7.6.2 测试多长时间
运行这些端到端测试需要很长时间。
运行缓慢和脆弱性是很大的问题。一个测试套件需要花整整一天时间来运行,然后经常有与功能破坏无关的测试失败,这就是个灾难。即使真的是功能被破坏了,也需要花很长时间才能发现,而此时大家已经开始转做其他的事情了,切换大脑的上下文来修复测试是很痛苦的。
并行运行测试可以改善缓慢的问题。可以使用 Selenium Grid 等工具来达到这个效果。然而这种方法并不能代替去真正了解什么需要被测试,以及哪些不必要的测试可以被删掉。
然而在处理覆盖范围广的测试套件时,删除测试是非常有用的。如果相同的特性在 20 个不同的测试中被覆盖,而运行这些测试需要 10 分钟,也许我们可以删掉其中的一半。如果要这样做,你需要更好地理解风险,而这刚好是人类所不擅长的。结果就是,你很少能见到有人能够精细地对大范围、高负担的测试进行管理和维护。希望它发生与真正让它发生是不一样的。
7.6.3 大量的堆积
端到端测试的反馈周期过长,不仅会影响开发人员的生产效率,同时任何失败的修复周期也都会变长,这也就不可避免地减少了端到端测试通过的次数。如果只有在所有测试通过的前提下才能部署软件(你应该这么做),那么服务被部署的次数也会减少。
这可能会导致大量的堆积。在修复失败的端到端测试的同时,上游团队一直在提交更多的变更。结果是,除了使修复构建更加困难外,要部署的变更内容也多了。解决这个问题的一个方法是,端到端测试失败后禁止提交代码,但考虑到测试套件的运行时间过长,这个要求通常是不切实际的。试想一下这样的命令:“你们 30 个开发人员在这个耗时 7 小时的构建修复之前不准提交代码!”
部署的变更内容越多,发布的风险就会越高,我们就越有可能破坏一些功能。 保障频繁发布软件的关键是基于这样的一个想法:尽可能频繁地发布小范围的改变。
7.6.4 元版本
为应用于多个服务上的修改使用相同的版本,会使得我们很快接受这样的理念:同时修改和部署多个服务是可以接受的。这个成了常态,成了正常的情况。而这样做后,我们就会丢弃微服务的主要优势之一:独立于其他服务单独部署一个服务的能力。
把多个服务一起进行部署经常会导致服务的耦合。不用很长时间,本来分离得很好的服务就会与其他服务纠缠得越来越紧密,而你可能从未注意到,因为从未试图单独部署它们。最终,系统杂乱无序,你必须同时部署多个服务。正如我们前面所讨论的,这种耦合会使我们处于比使用一个单块应用还要糟糕的地步。
7.7 测试场景,而不是故事
尽管有如上所述的缺点,但对许多用户来说,覆盖一两个服务的端到端测试还是可管理的,也是有意义的。但覆盖 3 个、4 个、10 个或 20 个服务的测试怎么办?不用多长时间,这些测试套件便会变得非常臃肿,而在最坏的情况下,这个测试场景甚至可能会出现笛卡儿积式的爆炸。
如果我们掉进陷进,为每一个新添加的功能增加一个新的端到端测试,那么这种情况会加剧恶化。当你给我展示每实现一个新的故事便添加一个新的端到端测试的代码库时,我将向你展示一个臃肿的测试套件、很长的反馈周期和巨大的重叠测试覆盖率。
解决这个问题的最佳方法是,把测试整个系统的重心放到少量核心的场景上来。把任何在这些核心场景之外的功能放在相互隔离的服务测试中覆盖。团队之间需要就这些核心场景达成一致,并共同拥有。对于音乐商店来说,我们可能会专注于像购买 CD、退货或创建一个客户等高价值的交互,它们的数量应该很少。
通过专注于少量(“少量”的意思是即使对于一个复杂系统来说,也应该是非常低的两位数)的测试,我们可以缓解端到端测试的缺点,但并不能避免所有的缺点。还有更好的方法吗?
7.8 拯救消费者驱动的测试
使用之前所提到的端到端测试,我们试图解决的关键问题是什么?是试图确保部署新的服务到生产环境后,变更不会破坏新服务的消费者。有一种不需要使用真正的消费者也能达到同样目的的方式,它就是 CDC(Consumer-Driven Contract,消费者驱动的契约)。
当使用 CDC 时,我们会定义服务(或生产者)的消费者的期望。这些期望最终会变成对生产者运行的测试代码。如果使用得当,这些 CDC 应该成为生产者 CI 流水线的一部分,这样可以确保,如果这些契约被破坏了的话,生产者就无法部署。更重要的是,从测试反馈周期的角度来看,因为只需针对生产者运行这些 CDC 测试,所以它比要解决同样问题的端到端测试更快,也更可靠。
让我们再看一下客户服务的这个例子。客户服务有两个相互独立的消费者:帮助台和网络商店。这两个消费者都有对客户服务的某些期望。在这个例子中,我们将创建两个测试集合,每个集合分别体现帮助台和网络商店对客户服务的使用方式。一个好的实践是,生产者和消费者团队协作来写这部分测试,所以,帮助台和网络商店的团队成员可以跟客户服务的团队成员结对来编写这些测试。
因为这些 CDC 是对客户服务如何工作的期望,所以如图 7-9 所示,客户服务本身的所有下游依赖都可以使用打桩。从测试范围的角度来看,如图 7-10 所示,它们与测试金字塔中的服务测试处在同一层,但侧重点却非常不同。这些测试侧重在消费者如何使用服务,测试失败的解决方式与服务测试相比会有很大的不同。如果在客户服务的构建过程中一个 CDC 失败了,消费者很明显将会受到影响。此时你可以选择修复这个问题,或者如我们在第 4 章中所提到的,启动一个引入破坏性变化的讨论。所以通过 CDC,无需使用时间可能很长的端到端测试,我们就可以在进入生产环境之前发现破坏性变化。
客户服务的消费者驱动的测试
把消费者契约的测试集成到测试金字塔
7.8.1 Pact
Pact(https://github.com/realestate-com-au/pact)是一个消费者驱动的测试工具,最初是在开发 RealEstate.com.au 的过程中创建的,现在已经开源,功能大部分是由 Beth Skurrie 组织开发的。该工具最初是使用 Ruby 语言,现在支持包括 JVM 和 .NET 的版本。
Pact 的工作方式非常有趣,如图 7-11 所示。开始时,消费者使用 Ruby DSL 来定义生产者的期望。然后启动一个本地 mock 服务器,并对其运行期望来生成 Pact 规范文件。Pact 规范文件是一个标准的 JSON 规范,所以事实上,你可以手写该规范,但使用语言支持的 API 来生成该规范显然要容易得多。同时,它还能提供给你一个 mock 服务器,以后可用来独立地测试消费者。
在生产者这边,你可以使用 JSON Pact 规范来驱动对生产者 API 的调用,然后验证响应以测试消费者的规范是否被满足。因此生产者代码库需要访问 Pact 文件。正如我们在第 6 章所讨论的,我们期望消费者和生产者是异构的,所以与语言无关的 JSON 规范是一个非常好的选择。这意味着,你可以使用 Ruby 的客户端来生成消费者规范,然后在 Pact 的 JVM 版本上用该规范来验证一个 Java 的生产者。
Pact 的 JSON 规范是由消费者生成的,该规范需要成为一个生产者可访问的构建物。你可以把它存储在 CI/CD 工具的构建物仓库中,或者使用 Pact Broker,Pact Broker 允许你存储 Pact 规范的多个版本。这就允许你针对消费者的多个不同版本运行消费者驱动的契约测试。比如说,假如你想测在生产环境上的消费者,也想测开发中的消费者的最新版本,这个功能就会很有用。
7.8.2 关于沟通
在敏捷中,故事通常被认为是一种促进沟通的方式。CDC 也起到类似的作用。它们可以推动关于如何编写一组服务的 API 的讨论,当其被破坏时也可以触发 API 该如何演进的讨论。
重要的是,CDC 需要消费者和生产服务之间具有良好的沟通和信任。如果双方都在同一个团队(或就是同一个人! ),那么这应该不难。然而,如果你消费的服务由第三方提供,那么 CDC 可能不适用,因为你们可能缺乏充分的沟通及信任。在这种情况下,对有可能出错的组件不得不使用有限的大范围的端到端测试。换一个场景,如果是为成千上万的潜在消费者创建 API(比如一个公开可用的 Web 服务的 API),你可能不得不自己扮演消费者(或者说一部分消费者)的角色来定义这些测试。破坏大量的外部消费者是非常糟糕的事情,这种情况下 CDC 就显得尤为重要!
7.9 还应该使用端到端测试吗
本章之前的内容详细地描述了端到端测试的大量缺点,而随着测试覆盖的服务数量的增加,这些缺点会更加凸显。一段时间以来,通过跟实施大规模微服务的人一直保持交流,我意识到随着时间的推移,大部分人更喜欢使用类似 CDC 的工具和更好的监控来代替端到端测试。但这并不意味着端到端测试应该被全部扔掉。他们会在使用一种叫作语义监控(semantic monitoring)的技术来监控生产系统时,用到端到端场景测试,我们在第 8 章会讨论更多这方面的内容。
可以把运行端到端测试当作把服务部署到生产环境的辅助轮。当你正在学习使用 CDC 及提高生产环境的监控和部署技术时,这些端到端测试能形成一个有用的安全网,你可以认为这是在周期时间和低风险之间做取舍。不过在改善其他方面时,你可以慢慢减少对端到端测试的依赖,直至完全不需要。
再怎么测试也不可能消除所有的缺陷,所以生产环境中有效的监控和修复还是有必要的。理解了这一点,你就能够理解从生产环境中学习是一个明智的决定。
7.10 部署后再测试
大多数测试会在系统部署到生产环境之前完成。我们通过测试定义一系列的模型,希望证明在功能需求和非功能需求方面,系统的工作方式和行为都符合预期。但如果我们的模型并不完美,那么系统在面对愤怒的使用者时就会出现问题。缺陷会溜进生产环境,新的失效模式会出现,用户也会以我们意想不到的方式来使用系统。
我们对此通常的反应是,定义更多的测试来改进我们的模型,以便将来尽早捕获更多的问题,从而减少发生在生产环境中缺陷的次数。然而我们必须承认,使用这种方法得到的收益会逐渐减少。仅仅依靠部署之前进行的测试,我们不可能把缺陷率降为零。
7.10.1 区分部署和上线
在更多问题发生之前捕获它们。要达到这个目的,一种方式是突破传统的在部署之前运行测试的方法。如果可以部署软件到生产环境,在有真正生产负载(production load)之前运行测试,我们可以发现特定环境中的问题。一个常见的例子是,用来验证部署后的系统是否正常工作的、针对新部署软件的一系列的冒烟测试套件。这些测试帮助我们识别与环境有关的任何问题。如果你能够使用一条命令来部署任何给定的微服务(应该这么做),应该把自动运行冒烟测试也加到这条命令中。
另一个例子是所谓的蓝 / 绿部署。使用蓝 / 绿部署时,我们会部署两份软件,但只有一个接受真正的请求。
让我们考虑一个简单的例子,如图 7-12 所示。在生产环境中,我们使用客户服务的 v123 版本。我们想要部署一个新版本 v456。v123 正常工作的同时,我们部署 v456 版本,但先不直接接受请求。相反,我们先对新部署的版本运行一些测试。等测试没有问题后,我们再切换生产负荷到新部署的 v456 版本的客户服务。通常情况下,我们会保留旧版本一小段时间,这样如果我们发现任何错误,能够快速恢复到旧的版本。
使用蓝 / 绿部署区分部署和上线
实施蓝 / 绿部署有几个前提条件。首先,你需要能够切换生产流量到不同的主机(或主机集群)上。切换可以通过改变 DNS 条目,或更改负载均衡的配置。你还需要提供足够多的主机,以支持并行运行两个版本的微服务。如果你正在使用一个弹性云提供商,这个要求对你来说可能很简单。使用蓝 / 绿部署可以降低风险,也让你有能力在遇到问题时尽快恢复。如果做得足够好,整个过程可以完全自动化,在无需人工干预的情况下完整地部署或恢复。
保持旧版本运行,除了给予我们在切换生产流量前可以测试服务这个好处外,还可以大幅度地减少发布软件所需要的停机时间。使用某些生产流量重定向的机制时,我们甚至可以做到在客户无感知的情况下进行版本切换,达到零宕机部署。
还有一种方式值得我们详细讨论一下,它有时会与蓝 / 绿部署相混淆,因为它会使用一些类似的实现技术。这种方式被称为金丝雀发布(canary releasing)。
7.10.2 金丝雀发布
金丝雀发布是指通过将部分生产流量引流到新部署的系统,来验证系统是否按预期执行。“按预期执行”可以涵盖很多内容,包括功能性的和非功能性的。例如,我们可以验证新部署服务的请求响应时间是否在 500 毫秒以内,或者查看新服务和旧服务是否有相同的错误率比例(proportional error rate)。甚至更进一步,如果我们要发布一个新版本的推荐服务,可以同时运行两个版本,然后看看新版本的推荐服务是否能够达到预期的销售量,以确保我们没有发布一个次优算法的服务。如果新版本没有达到预期,我们可以迅速恢复到旧版本。如果达到了预期,我们可以引导更多的流量到新版本。金丝雀发布与蓝 / 绿发布的不同之处在于,新旧版本共存的时间更长,而且经常会调整流量。
Netflix 广泛使用这种方法。发布前会部署新的服务版本,同时部署一个与生产环境相同版本的作为基线。然后,Netflix 会在几个小时内,引导一小部分生产流量到新版本和基线上,同时为两个计分。如果金丝雀的分数高于基线的分数,Netflix 才会全面部署新版本到生产环境。
当考虑使用金丝雀发布时,你需要选择是要引导部分生产请求到金丝雀,还是直接复制一份生产请求。有些团队选择先复制一份生产请求,然后引导复制的请求到金丝雀。使用这种方法,现运行的生产版本和金丝雀版本可以有相同的请求,只是生产环境的请求结果是外部可见的。这方便大家对新旧版本做比较,同时又避免假如金丝雀失败,影响到客户的请求。不过,复制生产请求的工作可能会很复杂,尤其是在事件 / 请求不是幂等的情况下。
金丝雀发布是一种功能强大的技术,帮助大家用实际的请求来验证软件的新版本,同时可能推出一个糟糕的新版本,提供工具来帮助控制风险。不过,它也确实比蓝 / 绿部署需要更复杂的配置和更多的思考。你可以比蓝 / 绿部署共存多版本服务的时间更长,不过也会比蓝 / 绿部署占用更多,时间更长的硬件资源。你还需要更复杂的请求路由,因为为了对发布工作更有信心,你可能需要增加或减少请求。不过如果你已经实现蓝 / 绿部署,那实现金丝雀发布需要的部分构建块可能已经有了。
7.10.3 平均修复时间胜过平均故障间隔时间
通过使用蓝 / 绿部署和金丝雀发布技术,我们找到了方法,在类生产环境(甚至就是生产环境)上测试,我们还构建工具来帮助管理有可能发生的失败。使用这些方法其实是,默认我们无法在软件发布之前,发现和捕获所有的问题。
有时花费相同的努力让发布变更变得更好,比添加更多的自动化功能测试更加有益。在 Web 操作的世界,这通常被称为平均故障间隔时间(Mean Time Between Failures,MTBF)和平均修复时间(Mean Time To Repair,MTTR)之间的权衡优化。
减少修复时间的技术可以简单到尽快回滚加上良好的监控(在第 8 章我们将讨论),类似蓝 / 绿部署。如果我们能早点发现生产中的问题,尽快回滚,就可以减少对客户的影响。我们还可以使用蓝 / 绿部署技术,部署软件的一个新版本,在生产环境对它进行测试之后再引导用户到新版本。
对于不同的组织,MTBF 和 MTTR 之间的权衡会有所不同,这取决于对在生产环境中失败带来的影响的正确理解。 然而,我看到大多数的组织,花费了大量的时间编写功能测试套件,而花费很少的时间,甚至完全没有考虑如何更好地监控和如何从故障中恢复。因此,尽管他们可能在部署生产环境前发现并消除了很多缺陷,但是也无法保证能够消除所有的缺陷,并且假如有些缺陷在生产环境中真的出现,他们根本没有做好任何应对准备。
除了 MTBF 和 MTTR 之外,还有别的权衡存在。例如,如果你正试图了解是否有人会真正使用你的软件,那需要尽快发布软件,这比构建健壮的软件更有意义,因为可以验证之前的想法或业务模型是否工作。在上面例子的情况下,测试可能都是多余的,因为不知道你的想法是否工作,其影响要远远大于生产环境上的一个缺陷。在这种情况下,在生产之前完全不测试是非常明智的。
7.11 跨功能的测试
非功能性需求,是对系统展现的一些特性的一个总括的术语,这些特性不能像普通的特性那样简单实现。它包括以下方面,比如一个网页可接受的延迟时间,系统能够支持的用户数量,用户界面如何让残疾人也可以访问,或者如何保障客户数据的安全。
非功能性这个术语一直很困扰我。这个术语所涵盖的一些内容,本质上似乎也是功能性的啊!我的一个同事 Sarah Taraporewalla,使用跨功能需求(Cross-Functional Requirement,CFR)来替换非功能需求,我非常喜欢这个术语,因为它展现了这样一个事实,这些系统行为仅仅是许多横切工作融合的结果。
很多的 CFR 只能在生产环境测试。也就是说,我们可以定义一些测试策略来帮助我们看看,是否至少是朝着满足这些目标的方向前进。这些测试归类为属性测试象限。性能测试是其中一个很好的例子
对于一些 CFR,你可能希望在一个单独的服务上跟踪。例如,你可能希望你的支付服务的持久性明显高一些,而音乐推荐服务则允许更多的停机时间,因为你知道,即使 10 分钟左右无法推荐类似于金属乐队的艺术家,你的核心业务也不会受影响。这些权衡最终会对你如何设计和演化系统有一个比较大的影响,再强调一次,合适粒度的微服务会给你更多的机会做这些权衡。
CFR 的测试也应该遵循金字塔。一些测试必须使用端到端,例如负载测试,但其他不需要。例如,一旦你发现一个端到端的负载测试的性能瓶颈,编写一个小范围的测试,帮助你在未来发现这个问题。其他 CFR 的测试很容易使用更快的测试。我记得在一个项目中,我们坚持确保 HTML 标记使用适当的可访问性特性,来帮助残疾人使用我们的网站。检查生成的标记来确保适当的特性,不需要任何网络的往返,很快就可以完成。
考虑 CFR 时常太迟了。我强烈建议尽早去看 CFR,并定期审查。
性能测试
性能测试作为满足跨功能需求的一个方法是值得明确说明的。将系统拆分为较小的微服务后,跨网络边界调用的次数明显增加了。之前操作可能只涉及一次的数据库调用,现在可能涉及三四次跨网络边界来调用其他服务,还有匹配数量的数据库调用。所有这些调用都可能减缓系统操作的速度。因此,追踪延迟的根源显得尤为重要。当有多个同步的调用链时,链的任何部分变得缓慢,整个链都会受影响,最终会对整体速度有明显的影响。这使得用一些方法对微服务系统进行性能测试,比对单块系统更重要。通常性能测试被推迟的原因是,最初没有足够的系统资源用于测试。我理解这个原因,但通常性能测试会一直拖延,如果不是直到上线都没有发生的话,也通常只在上线前才会发生!不要掉入这个拖延的陷阱。
与功能测试类似,性能测试也可以是各种范围测试的混合。你可能决定想测试单个独立服务的性能,但开始的时候,可以用测试来检查系统中的核心场景的性能。你可以简单地使用端到端场景的测试,然后大量并发运行。
为了产生有价值的结果,我们经常需要模拟客户逐渐增多,然后在给定的客户场景一起运行。这可以帮助我们发现,调用延迟随着负荷的增加如何变化,这意味着性能测试需要运行一段时间。此外,我们希望性能测试的环境与系统的生产环境尽可能匹配,以确保看到的结果能表明在生产系统也会有同样的表现。这意味着,我们需要一个类似生产的数据量,并需要更多的机器来匹配基础设施——一项蛮有挑战的任务。即使我们仍在纠结是否让性能测试的环境真正类似于生产环境,性能测试对追踪性能的瓶颈仍然是有价值的。只是需要注意,结果可能是假阴性,甚至更糟,是假阳性。
由于性能测试运行的时间长,因此在每次构建的时候都运行性能测试并不是可行的。一个常见的做法是,每天运行一个子集,每周运行一个更大的集合。不管选择哪种方法,我们都要确保尽可能频繁地运行。越长时间没有运行性能测试,就越难追踪最初引起性能问题的原因。性能问题很难解决,因此,如果新引入的问题可以通过查看少量的提交来发现,我们的生活将会更加轻松。
性能测试需要有目标。有了目标以后,可以基于运行结果让构建变红或变绿,变红(失败)的构建是需要行动的一个清晰信号。
性能测试需要与系统性能的监控同时进行(在第 8 章我们将做更多讨论)。理想情况下,应该在性能测试环境下使用与生产环境中相同的可视化工具,这样我们更容易对两者进行比较。
7.12 小结
优化快速反馈,并相应地使用不同类型的测试。
尽可能使用消费者驱动的契约测试,来替换端到端测试。
使用消费者驱动的契约测试,提供团队之间的对话要点。
尝试理解投入更多的努力测试与更快地在生产环境发现问题之间的权衡(MTBF 与 MTTR 权衡的优化)。
将系统拆分成更小的、细粒度的微服务会带来很多好处。然而,它也增加了生产系统的监控复杂性。
现在有多个服务需要监控,有多个日志需要筛选,多个地方有可能因为网络延迟而出现问题。
这里的答案很简单:监控小的服务,然后聚合起来看整体。我们从最简单的系统——一个节点,来展示该如何做。
8.1 单一服务,单一服务器
先,我们希望监控主机本身。CPU、内存等所有这些主机的数据都有用。我们想知道,系统健康的时候它们应该是什么样子的,这样当它们超出边界值时,就可以发出警告。如果我们想运行自己的监控软件,可以使用 Nagios,或者使用像 New Relic 这样的托管服务来帮助我们监控主机。
接下来,我们要查看服务器本身的日志。如果用户报告了一个错误,这些日志应该可以告诉我们,在何时何地发生了这个错误。这个时候,对于单台主机来说,只需要登录到主机上使用命令行工具扫描日志就可以了。我们甚至可以更进一步,使用 logrotate 帮助我们移除旧的日志,避免日志占满了磁盘空间。
最后,我们可能还想要监控应用程序本身。最低限度是要监控服务的响应时间。你可以通过查看运行服务的 Web 服务器,或者服务本身的日志做到这一点。如果我们想更进一步,可能还需要追踪报告中错误出现的次数。
8.2 单一服务,多个服务器
现在我们服务的多个副本实例,运行在多个独立的主机上。如图 8-2 所示,通过负载均衡分发不同的请求到不同的服务实例
当 CPU 占用率高时,如果这个问题发生在所有的主机上,那么可能是服务的问题,但如果只发生在一台主机上,那么可能是主机本身的问题,也许是一个流氓操作进程?
在这种情况下,我们依然想追踪有关主机的数据,根据它们来发出警告。但现在,除了要查看所有主机的数据,还要查看单个主机自己的数据。换句话说,我们既想把数据聚合起来,又想深入分析每台主机。Nagios 允许以这样的方式组织我们的主机,到目前为止一切还好。
接下来就是日志。我们的服务运行在多个服务器上,登录到每台服务器查看日志,可能会让我们感到厌倦。如果只有几个主机,我们还可以使用像 ssh-multiplexers 这样的工具,在多个主机上运行相同的命令。用一个大的显示屏,和一个 grep "Error" app.log,我们就可以定位错误了。
对于像响应时间这样的监控,我们可以在负载均衡器中进行追踪,很容易就能拿到聚合后的数据。不过负载均衡器本身也需要监控;如果它的行为异常,也会导致问题。对于服务本身的监控,我们可能更关心健康的服务是什么样的,这样当我们配置负载均衡器的时候,就可以从应用程序中移除不健康的节点。希望我们到这里的时候至少有一些想法了……
8.3 多个服务,多个服务器
多个服务合作为我们的用户提供功能,这些服务运行在多个物理的或虚拟的主机上
互相合作的多个服务分布在多台主机上
答案是,从日志到应用程序指标,集中收集和聚合尽可能多的数据到我们的手上。
8.4 日志,日志,更多的日志
让日志能够集中在一起方便使用。这方面的一个例子是 logstash( http://logstash.net ),它可以解析多种日志文件格式,并将它们发送给下游系统进行进一步调查。
Kibana( https://www.elastic.co/products/kibana )是一个基于 ElasticSearch 查看日志的系统, 如图 8-4 所示。你可以使用查询语法来搜索日志,它允许在查询时指定时间和日期范围,或使用正则表达式来查找匹配的字符串。Kibana 甚至可以把你发给它的日志生成图表,只需看一眼就能知道已经发生了多少错误。
使用 Kibana 查看聚合的日志
8.5 多个服务的指标跟踪
与查看不同主机上的日志遇到的挑战类似,我们也需要寻找更好的方式来收集和查看指标。当我们观察一个复杂系统的指标时,很难知道什么样是好的。我们的网站每秒会有大约 50 条 4XX 的 HTTP 错误状态码,这是个问题吗?午餐过后,产品目录服务的 CPU 负载增加了 20%,是有什么问题发生了吗?要想知道什么时候该紧张,什么时候该放松,秘诀是收集系统指标足够长的时间,直到有清晰的模式浮现。
在更复杂的环境中,我们会频繁地重建服务的新实例,所以我们希望选择一个系统能够方便地从新的主机收集指标。我们希望能够看到整个系统聚合后的指标(例如,平均的 CPU 负载),但也会想要给定的一些服务实例聚合后的指标,甚至某单个服务实例的指标。这意味着,我们需要将指标的元数据关联,用来帮助推导出这样的结构。
Graphite 就是一个让上述要求变得很容易的系统。它提供一个非常简单的 API,允许你实时发送指标数据给它。然后你可以通过查看这些指标生成的图表和其他展示方式来了解当前的情况。它处理容量的方式很有趣 .。通过有效地配置,它可以减少旧指标的精度,以确保容量不要太大。例如,最近的十分钟,每隔 10 秒记录一次主机 CPU 的指标,然后在过去的一天,以分钟为单位对数据进行聚合,而在过去的几年,减少到以 30 分钟为单位进行聚合。通过这种方式,你不需要大量的存储空间,就可以保存很长一段时间内的信息。
Graphite 也允许你跨样本做聚合,或深入到某个部分,这样就可以查看整个系统、一组服务或一个单独实例的响应时间。如果由于一些原因,你不能使用 Graphite,在选择其他任何工具时,要确保这些工具具备跟 Graphite 类似的功能。另外,要确保你可以获得原始的数据,以便在需要之时生成自己的报告或仪表盘。
了解趋势另一个重要的好处是帮助我们做容量规划。我们的系统到达极限了吗?多久之后需要更多的主机?在过去,当我们还在使用物理主机时,通常一年才会考虑一次这个问题。在供应商提供按需计算的 IaaS(Infrastructure as a Service,基础设施即服务)的新时代,我们可以在几分钟内(如果不是秒级的话)实现扩容和缩容。这意味着,如果了解我们的使用模式,就可以确保恰好有足够的基础设施来满足我们的需求。在跟踪趋势和理解应该如何使用这些数据方面,使用的方式越智能,我们的系统就越省钱,而且响应性也就越好。
8.6 服务指标
当你在 Linux 机器上安装 collectd 并让它指向 Graphite 时,会发现我们运行的操作系统会生成大量的指标。同样,像 Nginx 或 Varnish 这样的支撑子系统,也会暴露很多有用的信息,例如响应时间或缓存命中率
强烈建议你公开自己服务的基本指标。作为 Web 服务,最低限度应该暴露如响应时间和错误率这样的一些指标。如果你的服务器前面没有一个 Web 服务器来帮忙做的话,这一点就更重要了。但是你真的应该做得更多。例如,账户服务会想要暴露客户查看过往订单的次数,而网络商店可能希望知道过去的一天赚了多少钱。
为什么我们要关心这个呢?嗯,原因很多。首先,有一句老话,80% 的软件功能从未使用过。我无法评论这个数字是否准确,但是作为一个已在软件行业工作近 20 年的程序员,我知道自己花了很多时间在一些从未被真正使用的功能上。如果能知道这些未被使用的功能是什么,不是很好的事情吗?
为什么我们要关心这个呢?嗯,原因很多。首先,有一句老话,80% 的软件功能从未使用过。我无法评论这个数字是否准确,但是作为一个已在软件行业工作近 20 年的程序员,我知道自己花了很多时间在一些从未被真正使用的功能上。如果能知道这些未被使用的功能是什么,不是很好的事情吗?
最后,我们永远无法知道什么数据是有用的!很多次,直到机会已经错过很久后,我才发现如果当时记录了数据,事情就容易理解得多。所以我倾向于暴露一切数据,然后依靠指标系统对它们进行处理。
很多平台都存在一些库来帮助服务发送指标到一个标准系统中。Codahale 的 Metrics 库( http://metrics.codahale.com/ )就是这样一个运行在 JVM 上的库。它允许你存储一些指标,例如计数器、计时器或计量表(gauge); 支持带时间限制的指标(这样你就可以指定如“过去五分钟的订单数量”这样的指标);它还为将数据发送到 Graphite 和其他汇总报告系统提供现成的支持。
8.7 综合监控
我们可以通过定义正常的 CPU 级别,或者可接受的响应时间,判断一个服务是否健康。如果我们的监控系统监测到实际值超出这些安全水平,就可以触发警告。类似像 Nagios 这样的工具,完全有能力做这个。
系统中存在大量的组件,意味着我们会收集到很多低层次的指标,它们会带来很大的噪声。我们没有机会逐渐扩展系统,或让系统运行几个月来理解什么样的指标是好的,例如 CPU 率或一些单个组件的延迟等。我们的方法是,对下游系统没有登记的部分投资组合,生成假的价格事件。每一分钟左右,Nagios 执行一个命令行任务,插入一个假事件到队列中。我们的系统会正常响应这个事件并做相应处理,唯一不同的是结果仅用于测试。如果在给定的时间内没有看到重新定价,Nagios 会认为这是一个问题。
我们创建的这个假事件就是一个合成事务的例子。使用此合成事务来确保系统行为在语义上的正确性,这也是这种技术通常被称为语义监控的原因。
在实践中,我发现使用这样合成事务执行语义监控的方式,比使用低层指标的告警更能表明系统的问题。当然,它们不会取代低层次的指标,那些细节有助于我们了解为什么语义监测会报告问题。
实现语义监控
在过去,实现语义监控是一个相当困难的任务,但现在这个事情简直就是信手拈来!你的系统有测试的,对吧?
系统中存在针对指定服务的端到端测试,甚至是针对整个系统的端到端测试,仔细看看就会发现,这些都是实现语义监控所需要的。而且,我们的系统已经开放了启动测试和查看结果所需要的钩子(hook)。所以,为什么不在运行的系统上运行这些测试的子集,作为系统语义监控的一种方式呢?
当然,还有些事情需要我们做。首先,要非常小心地准备数据以满足测试的要求。我们的测试需要找到一个方法来适配不同的实时数据,因为这些数据会随着时间的推移而改变,或者设置一个不同数据来源。例如,我们可以在生产环境上设置一组假用户和一些已知的数据集。
同样,我们必须确保不会触发意料之外的副作用。一个朋友告诉我,有一个电子商务公司不小心在其订单的生产系统上跑测试,直到大量的洗衣机送达总部,它才意识到这个错误。
8.8 关联标识
最终用户看到的任何功能都由大量的服务配合提供,一个初始调用最终会触发多个下游的服务调用。
例如,考虑客户注册的例子。客户填写表单的所有信息,然后点击提交。界面背后,我们使用支付服务检查信用卡信息的有效性,告知邮寄服务在邮局寄送一个欢迎礼包,并调用我们的电子邮件服务发送欢迎邮件。现在,如果支付服务的调用发生了一个奇怪的错误,该如何处理呢?
如果看一下日志,就会发现只有支付服务注册了一个错误。如果我们足够幸运,可以找到引发问题的请求,甚至可以看看当时调用的参数。但这只是简单的情况,更为复杂的初始请求有可能生成一个下游的调用链,并且以异步的方式处理触发的事件。我们如何才能重建请求流,以重现和解决这个问题呢?通常我们需要在初始调用更大的上下文中看待这个错误;换句话说,就像查看栈跟踪那样,我们也想查看调用链的上游。
在这种情况下,一个非常有用的方法是使用关联标识(ID)。在触发第一个调用时,生成一个 GUID。然后把它传递给所有的后续调用,如图 8-5 所示。类似日志级别和日期,你也可以把关联标识以结构化的方式写入日志。使用合适的日志聚合工具,你能够对事件在系统中触发的所有调用进行跟踪:
15-02-2014 16:01:01 Web-Frontend INFO [abc-123] Register
15-02-2014 16:01:02 RegisterService INFO [abc-123] RegisterCustomer ...
15-02-2014 16:01:03 PostalSystem INFO [abc-123] SendWelcomePack ...
15-02-2014 16:01:03 EmailSystem INFO [abc-123] SendWelcomeEmail ...
15-02-2014 16:01:03 PaymentGateway ERROR [abc-123] ValidatePayment ...
使用关联标识来跟踪跨多个服务的调用
当然,你需要确保每个服务知道应该传递关联标识。此时你需要标准化,强制在系统中执行该标准。一旦这样做了,你就可以创建工具来跟踪各种交互。这样的工具可以用于跟踪事件风暴、不常发生的特殊场景,甚至识别出时间过长的事务,因为你能勾勒出整个级联的调用。
像 Zipkin( http://twitter.github.io/zipkin/ )这样的软件,也可以跨多个系统边界跟踪调用。基于 Google 自己的跟踪系统 Dapper 的创意,Zipkin 可以提供非常详细的服务间调用的追踪信息,还有一个界面帮助显示数据。我个人觉得 Zipkin 有点重量级,需要自定义客户端并且支持收集系统。既然因为其他目的,你已经把日志聚合,所以更简单的方式应该是重用已经收集的数据,而不是必须再附加一个数据源。也就是说,如果你发现需要一个更先进的工具跟踪服务间的调用,可能才需要 Zipkin 这样的软件。
建议你尽早考虑使用它,尤其是如果你的系统使用事件驱动的架构模式,因为这种模式会导致一些奇怪的意外行为。
传递关联标识时需要保持一致性,这是使用共享的、薄客户端库的一个强烈的信号。团队达到一定规模时,很难保证每个人都以正确的方式调用下游服务以收集正确的数据。只需服务链中的某个服务忘记传递关联标识,你就会丢失重要的信息。如果你决定创建一个内部客户端库来标准化这样的工作,请确保它很薄且不依赖提供的任何特定服务。例如,如果你正在使用 HTTP 作为通信协议,只需包装标准的 HTTP 客户端库,添加代码确保在 HTTP 头传递关联标识即可。
8.9 级联
级联故障特别危险。想象这样一个情况,我们的音乐商店网站和产品目录服务之间的网络连接瘫痪了,服务本身是健康的,但它们之间无法交互。如果只查看某个服务的健康状态,我们不会知道已经出问题了。使用合成监控(例如,模拟客户搜索一首歌)会将问题暴露出来。但为了确定问题的原因,我们需要报告这一事实是一个服务无法访问另一个服务。
因此,监控系统之间的集成点非常关键。每个服务的实例都应该追踪和显示其下游服务的健康状态,从数据库到其他合作服务。你也应该将这些信息汇总,以得到一个整合的画面。你会想了解下游服务调用的响应时间,并检测是否有错误。
你可以使用库实现一个断路器网络调用,以帮助你更加优雅地处理级联故障和功能降级
8.10 标准化
你应该尝试以标准格式的方式记录日志。你一定想把所有的指标放在一个地方,你可能需要为度量提供一个标准名称的列表;如果一个服务指标叫作 ResponseTime,另一个叫作 RspTimeSecs,而它们的意思是一样的,这会非常令人讨厌。
和以前一样,工具在标准化方面可以提供帮助。正如我之前说的,关键是让做正确的事情变得容易,所以为什么不提供预配置的虚拟机镜像,镜像内置 logstash 和 collectd,还有一个公用的应用程序库,使得与 Graphite 之间的交互变得非常容易?
8.11 考虑受众
我们收集这些数据都是为了一个目的。更具体地说,我们为不同的人收集这些数据,帮助他们完成工作;这些数据会触发一些事件。有些数据会触发支持团队立即采取行动,比如我们的一个综合监控测试失败了。 其他数据,比如 CPU 负载在过去一周增加了 2%,我们在做容量规划的时候可能才会对其感兴趣。同样,你的老板可能想立即知道,上次发布后收入下降了 25%,但可能不需要知道,“Justin Bieber”搜索在最近一小时上涨了 5%。
人们现在希望看到并立即处理的数据,与当进行深入分析时所需要的是不同的。因此,对于查看这些数据的不同类型的人来说,需考虑以下因素:
他们现在需要知道什么
他们之后想要什么
他们如何消费数据
提醒他们现在需要知道的东西。在房间的某个角落放置一个大显示屏来显示此信息,并使得以后需要做深入分析数据时,他们也能够很方便地访问。花时间了解他们想要使用的数据。讨论定量信息的图形化显示所涉及的所有细微差别已经超出了本书的范围,一个不错的起点是 Stephen Few 的优秀图书:Information Dashboard Design: Displaying Data for Ata-Glance Monitoring。
为什么不能以同样的方式处理运营指标和业务指标?最终,两种类型的指标分解成事件后,都说明在 X 时间点发生了一些事情。如果我们可以统一收集、聚合及存储这些事件的系统,使它们可用于报告,最终会得到一个更简单的架构。
Riemann(http://riemann.io/)是一个事件服务器,允许高级的聚合和事件路由,所以该工具可以作为上述解决方案的一部分。Suro(https://github.com/Netflix/suro)是 Netflix 的数据流水线,其解决的问题与 Riemann 类似。Suro 明确可以处理两种数据,用户行为的相关指标和更多的运营数据(如应用程序日志)。然后这些数据可以被分发到不同的系统中,像 Storm 的实时分析、离线批处理的 Hadoop 或日志分析的 Kibana。
许多组织正在朝一个完全不同的方向迈进:不再为不同类型的指标提供专门的工具链,而是提供伸缩性很好的更为通用的事件路由系统。这些系统能提供更多的灵活性,同时还能简化我们的架构。
8.13 小结
最低限度要跟踪请求响应时间。做好之后,可以开始跟踪错误率及应用程序级的指标。
最低限度要跟踪所有下游服务的健康状态,包括下游调用的响应时间,最好能够跟踪错误率。一些像 Hystrix 这样的库,可以在这方面提供帮助。
标准化如何收集指标以及存储指标。
如果可能的话,以标准的格式将日志记录到一个标准的位置。如果每个服务各自使用不同的方式,聚合会非常痛苦!
监控底层操作系统,这样你就可以跟踪流氓进程和进行容量规划。
聚合 CPU 之类的主机层级的指标及应用程序级指标。
确保你选用的指标存储工具可以在系统和服务级别做聚合,同时也允许你查看单台主机的情况。
确保指标存储工具允许你维护数据足够长的时间,以了解你的系统的趋势。
使用单个可查询工具来对日志进行聚合和存储。
强烈考虑标准化关联标识的使用。
了解什么样的情况需要行动,并根据这些信息构造相应的警报和仪表盘。
调查对各种指标聚合方式做统一化的可能性,像 Suro 或 Riemann 这样的工具可能会对你有用。
我还试图描绘了系统监控发展的方向:从专门只做一件事的系统转向通用事件处理系统,从而可以全面地审视你的系统。这是一个令人激动的新兴空间,虽然全面讲解已经超出了本书的范围,但希望我介绍的已足够你起步。如果你想知道更多,我早期出版的 Lightweight Systems for Realtime Monitoring 一书中有一些我的想法和更详细的介绍。
关于大型系统的安全漏洞导致数据暴露给各种危险人物的故事,我们已经听说了太多。最近的爱德华 · 斯诺登泄密事件,更加让我们意识到公司持有的用户信息的价值,以及保存在为客户构建的系统中的数据的价值
我们需要考虑,在数据从一个点到另一个点的传输过程中,如何保护它们,也需要考虑在其他情况下如何进行保护。我们需要考虑底层操作系统及网络的安全。有太多需要考虑的点,有太多可以做的事情!那到底需要多安全呢?我们如何知道什么是足够安全呢?
我们还需要考虑人的因素。谁在使用我们的系统,他又会做些什么?而这又与我们的服务器如何交互有什么关系?让我们从这里开始。
9.1 身份验证和授权
当谈到与我们系统交互的人和事时,身份验证和授权是核心概念。在安全领域中,身份验证是确认他是谁的过程。对于一个人,通常通过用户输入的用户名和密码来验证。我们认为只有用户本人才能够知道这些信息,因此输入这些信息的人一定是他。当然,还存在其他更复杂的系统。我的手机可以用指纹来确认我是我本人。通常来说,当我们抽象地讨论进行身份验证的人或事时,我们称之为主体(principal)。
通过授权机制,可以把主体映射到他可以进行的操作中。通常,当一个主体通过身份验证后,我们将获得关于他的信息,这些信息可以帮助我们决定其可以进行的操作。例如,当我们知道他在哪个部门或办公室工作后,系统可以通过这些信息来决定他能做什么和不能做什么。
一般来讲,对于单一的单块系统来说,应用程序本身会处理身份验证和授权。例如 Django,一个 Python 的 Web 框架,提供了现成的用户管理功能。不过,在分布式系统这个领域,我们需要考虑更高级的方案。我们不希望每个人使用不同的用户名和密码来登录不同的系统。我们的目的是要有一个单一的标识且只需进行一次验证。
9.1.1 常见的单点登录实现
身份验证和授权的一种常用方法是,使用某种形式的 SSO(Single Sign-On,单点登录)解决方案。在企业级领域中占据统治地位的 SAML 和 OpenID Connect,也提供了这方面的能力。虽然术语略有不同,但它们或多或少使用了相同的核心概念。这里使用的术语来自 SAML。
当主体试图访问一个资源(比如基于 Web 的接口)时,他会被定向到一个身份提供者那里进行身份验证。这个身份提供者会要求他提供用户名和密码,或使用更先进的双重身份验证。一旦身份提供者确认主体已通过身份验证,它会发消息给服务提供者,让服务提供者来决定是否允许他访问资源。
这个身份提供者可能是一个外部托管系统,也可能是你自己组织内部的系统。例如,谷歌提供了一个 OpenID Connect 身份提供者。不过,对于企业来说,通常有自己的身份提供者,它会连接到公司的目录服务。目录服务可能使用 LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议)或活动目录(Active Directory)。这些系统允许你存储主体的信息,例如他们在组织中扮演什么样的角色。通常情况下,目录服务和身份提供者是同一个系统,不过有时也会有所不同,但保持连接。例如,Okta 是一个托管的 SAML 身份提供者,它可以处理像双重身份验证这样的任务,但可以连接到你公司的目录服务,将其作为信息来源。
SAML 是一个基于 SOAP 的标准,尽管有库和工具支持它,但用起来还是相当复杂。基于 Google 和其他公司处理 SSO 的方式,OpenID Connect 已经成为了 OAuth 2.0 具体实现中的一个标准。它使用简单的 REST 调用,因为提高了其易用性,在我看来很有可能进军企业级应用。现在其最大的障碍是缺乏支持它的身份提供者。对于一个面向公众的网站,你或许可以使用 Google 作为提供者,但对于内部系统,或对于数据需要有更多控制权的系统而言,你会希望有自己的内部身份提供者。在写本书的时候,相比 SAML 丰富的选择(包括似乎无处不在的活动目录),OpenAM 和 Gluu 是这个领域为数不多的两个选项。除非等到现有的身份提供者开始支持 OpenID Connect,不然它的发展会仅限于公共身份提供者这种有限的情况。
因此,尽管我认为 OpenID Connect 是未来的方向,但很有可能需要一段时间,它才能被广泛地应用。
9.1.2 单点登录网关
在微服务系统中,每个服务可以自己处理如何重定向到身份提供者,并与其进行握手。显然,这意味着大量的重复工作。使用共享库可以解决这个问题,但我们必须小心地避免可能来自共享代码的耦合。而且如果有多个不同的技术栈,共享库也很难提供帮助。
你可以使用位于服务和外部世界之间的网关(如图 9-1 所示)作为代理,而不是让每个服务管理与身份提供者握手。基本想法是,我们可以集中处理重定向用户的行为,并且只在一个地方执行握手。
使用网关实现单点登录
然而,我们仍然需要解决下游服务如何接受主体信息的问题,例如用户名和角色。如果你使用 HTTP,可以把这些信息放到 HTTP 头上。在这方面,Shibboleth 这样的工具可以帮助你。我见过人们把它和 Apache 一起使用,这种方式能够很好地处理与基于 SAML 的身份提供者的集成。
另一个问题是,如果我们决定把身份认证的责任移到网关,那么孤立地在微服务中定位问题就变得更难。还记得在第 7 章我们探讨过的重现类生产环境的挑战吗?如果选择使用网关路由,请确保你的开发人员不需要太多的工作,就可以启动一个网关及其背后的服务。
这种方法的最后一个问题是,它会带给你一种虚假的安全感。我喜欢深度防御的理念,从网络边界,到子网,到防火墙,到主机,到操作系统,再到底层硬件。你需要在所有这些方面都实现安全措施的能力,我们将很快提到其中的一些。我见过有些人把所有的鸡蛋都放在一个篮子里,依靠网关来处理每一步的安全措施。我们都知道当这个点发生故障后,会发生什么……
显然,你还可以使用这个网关来做其他事情。例如,如果你使用 Apache 的一个实例运行 Shibboleth,也可以在这一级别决定终止 HTTPS,运行入侵检测,等等。不过,一定要小心。网关层承担越来越多的功能后,最终本身会是一个庞大的耦合点。而且功能越多,受攻击面就越大。
9.1.3 细粒度的授权
网关可以提供相当有效的粗粒度的身份验证。例如,它可以阻止任何未登录用户访问帮助台应用程序。假如我们的网关在身份验证完成时能提取出主体的属性,则可以据此做出更细致的决定。例如,我们通常将人放到某些组或分配某些角色,通过使用这些信息来了解他们能做什么。所以,对于帮助台应用程序,我们可能只允许具有某个特定的角色(例如工作人员)的主体访问。不过,超出允许(或禁止)的特定资源或端点的访问部分,它们可以留给微服务本身来处理,它会对允许哪些操作做进一步的决定。
回到我们的帮助台应用程序:我们会允许任何员工查看任何信息和所有细节吗?更可能的是,工作上会有不同的角色。例如,CALL_CENTER 组中的主体可以查看除付款细节外所有客户的信息。该主体也可以发起退款,但是额度会受限制。然而,CALL_CENTER_TEAM_LEADER 角色的主体,可以进行更大额度的退款。
应该在微服务内部做这些决定。我见过,人们以可怕的方式,使用身份提供者提供的各种属性,比如像 CALL_CENTER_50_DOLLAR_REFUND 这种非常细粒度的角色,将属于系统行为的某个特定部分的信息放到目录服务中。这对系统维护来说是一场噩梦,并且很难让我们的服务拥有独立的生命周期,因为有关服务行为的一部分信息突然间被放置到了别的地方,甚至有可能是由组织中一个不同部分管理的系统。
相反,你应该倾向于使用粗粒度的角色,围绕组织的工作方式建模。回到之前的章节,请记住,我们构建的软件要与组织的工作方式相匹配。所以也请以这种方式来使用角色。
9.2 服务间的身份验证和授权
9.2.1 在边界内允许一切
我们的第一个选项是,在边界内对服务的任何调用都是默认可信的。
取决于数据的敏感性,这种方式可能没有问题。一些组织尝试在他们的网络范围内确保安全,因此认为,当两个服务彼此访问时,它们不需要额外做任何事情。然而,如果一个攻击者入侵你的网络,你将对典型的中间人攻击基本没有任何防备。如果攻击者决定拦截并读取你正在发送的数据,在你不知情时更改数据,甚至在某些情况下假装是你正在通信的对象,你将不得而知。
迄今为止,边界内信任这种形式被大多数组织采用。他们可能决定在通信中使用 HTTPS,但仅此而已。我可没说这是一件好事!对于大多数使用这种模式的组织来说,我担心隐式信任模型并不是一个明智的决定,而更糟糕的是,很多时候人们没有在一开始意识到它的风险。
9.2.2 HTTP(S)基本身份验证
HTTP 基本身份验证,允许客户端在标准的 HTTP 头中发送用户名和密码。服务端可以验证这些信息,并确认客户端是否有权访问服务。这样做的好处在于,这是一种非常容易理解且得到广泛支持的协议。问题在于,通过 HTTP 有很高的风险,因为用户名和密码并没有以安全的方式发送。任何中间方都可以看到 HTTP 头的信息并读取里面的数据。因此,HTTP 基本身份验证通常应该通过 HTTPS 进行通信。
当使用 HTTPS 时,客户端获得强有力的保证,它所通信的服务端就是客户端想要通信的服务端。它给予我们额外的保护,避免人们窃听客户端和服务端之间的通信,或篡改有效负载。
服务端需要管理自己的 SSL 证书,当需要管理多台机器时会出现问题。一些组织自己承担签发证书的过程,这是一个额外的行政和运营负担。管理这方面的自动化工具远不够成熟,使用它们后你会发现,需要自己处理的事情就不止证书签发了。自签名证书不容易撤销,因此需要对灾难情景有更多的考虑。看看你是否能够避免自签名,以避开所有的这些工作。
SSL 之上的流量不能被反向代理服务器(比如 Varnish 或 Squid)所缓存,这是使用 HTTPS 的另一个缺点。这意味着,如果你需要缓存信息,就不得不在服务端或客户端内部实现。你可以在负载均衡中把 Https 的请求转成 Http 的请求,然后在负载均衡之后就可以使用缓存了。
还需要考虑,如果我们已经在使用现成的 SSO 方案(比如包含用户名密码信息的 SAML),该怎么办。我们想要基本身份验证使用同一套认证信息,然后在同一个进程里颁发和撤销吗?让服务与实现 SSO 所使用的那个目录服务进行通信即可做到这一点。或者,我们可以在服务内部存储用户名和密码,但需要承担存在重复行为的风险。
注意:使用这种方法,服务器只知道客户端有用户名和密码。我们不知道这个信息是否来自我们期望的机器;它可能来自网络中的其他人。
9.2.3 使用SAML或OpenID Connect
如果你已经在使用 SAML 或 OpenID Connect 作为身份验证和授权方案,你可以在服务之间的交互中也使用它们。如果你正在使用一个网关,可以使用同一个网关来路由所有内网通信,但如果每个服务自己处理集成,那么系统应该就自然而然这么工作。这样做的好处在于,你利用现有的基础设施,并把所有服务的访问控制集中在中央目录服务器。如果想要避免中间人的攻击,我们仍然需要通过 HTTPS 来路由通信。
客户端有一组凭证,用于向身份提供者验证自身,而服务获取所需的信息,用于任何细粒度的身份验证。
这意味着你需要为客户端创建账户,有时被称为服务账户。许多组织普遍使用这种方法。不过,需提醒一句:如果你打算创建服务账户,应尽量限制其使用范围。因此,考虑每个微服务都要有自己的一组凭证。如果凭证被泄露,你只需撤销有限的受影响的凭证即可,这使得撤销 / 更改访问更简单。
然而,还有其他几个缺点。首先,像使用基本身份验证一样,我们需要安全地存储凭证:用户名和密码放在哪里?客户端需要找到一些安全的方法来存储这些数据。另一个问题是,在这个领域的技术实现方面,做身份验证需要写相当繁琐的代码。尤其是 SAML,在其之上实现一个客户端非常痛苦。OpenID Connect 的工作流要简单些,但正如我们前面所讨论过的,它尚未被很好地支持。
9.2.4 客户端证书
确认客户端身份的另一种方法是,使用 TLS(Transport Layer Security,安全传输层协议),TLS 是 SSL 在客户端证书方面的继任者。在这里,每个客户端都安装了一个 X.509 证书,用于客户端和服务器端之间建立通信链路。服务器可以验证客户端证书的真实性,为客户端的有效性提供强有力的保证。
使用这种方法,证书管理的工作要比只使用服务器端证书更加繁重。它不只是创建和管理数量更多的证书这么简单;相反,所有的复杂性在于证书本身,你很有可能会花费大量的时间来试图诊断服务端为什么不接受你认为的一个完全有效的客户端证书。接下来,我们要考虑在最坏的情况下,撤销和补发证书的难度。使用通配符证书能够解决一些问题,但不是全部。这些额外的负担意味着,当你特别关注所发数据的敏感性,或无法控制发送数据所使用的网络时,才考虑使用这种技术。因此,你应该在通过互联网发送非常重要的数据时,才使用安全通信。
9.2.5 HTTP之上的HMAC
正如我们前面所讨论的,如果担心用户名和密码被泄露,基本身份验证使用普通 HTTP 并不是非常明智的。传统的替代方式是使用 HTTPS 路由通信,但也有一些缺点。除了需要管理证书,HTTPS 通信的开销使得服务器压力增加(尽管,老实说,这比几年前影响要小得多),而且通信难以被轻松地缓存。
另一种方法使用 HMAC(Hash-based Message Authentication Code,基于哈希的消息码)对请求进行签名,它是 OAuth 规范的一部分,并被广泛应用于亚马逊 AWS 的 S3 API。
使用 HMAC,请求主体和私有密钥一起被哈希处理,生成的哈希值随请求一起发送。然后,服务器使用请求主体和自己的私钥副本重建哈希值。如果匹配,它便接受请求。这样做的好处是,如果一个中间人更改了请求,那么哈希值会不匹配,服务器便知道该请求已被篡改过。并且,私钥永远不会随请求发送,因此不存在传输中被泄露的问题。额外的好处是,这个通信更容易被缓存,而且生成哈希的开销要低于处理 HTTPS 通信的开销(虽然你的情况有可能不同)。
这种方法有三个缺点。首先,客户端和服务器需要一个共享的、以某种方式交流的密钥。它们如何共享?可能是在两端都硬编码,但这样会带来一个问题,当密钥被泄露后,你需要撤销访问。如果你通过一些替代的协议来共享密钥,那么需要确保这个协议是非常安全的!
其次,这是一种模式,而不是标准,因此有各种不同的实现方式。结果就是,缺乏一个优秀的、开放的且有效的实现方式。通常来说,如果你对这种方式感兴趣,需要多去看看和理解不同的实现方式。你可以先去看看亚马逊的 S3,然后参考它的方式,特别是类似于 SHA-256 中使用的适当长度的密钥和合理的哈希函数。JWT(JSON Web Tokens,JSON Web 令牌,http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html)也值得一看,它们使用类似的方式实现,并且似乎正在吸引更多的关注。但是要注意,正确实现这个方式的难度。我的同事曾经与一个团队实施自己的 JWT 方案,仅仅因为忽略了一个布尔检查,就导致整个身份验证码都失效!希望随着时间的推移,我们可以看到更多可重用库的实现。
最后,要理解这种方法只能保证第三方无法篡改请求,且私钥本身不会泄露。但请求中所带的其他数据,对网络嗅探来说仍是可见的。
9.2.6 API密钥
像 Twitter、谷歌、Flickr 和 AWS 这样的服务商,提供的所有公共 API 都使用 API 密钥。API 密钥允许服务识别出是谁在进行调用,然后对他们能做的进行限制。限制通常不仅限于特定资源的访问,还可以扩展到类似于针对特定的调用者限速,以保护其他人服务调用的质量等。
具体该如何使用 API 密钥方式来处理你的微服务间的访问,取决于你所使用的具体技术。一些系统使用一个共享的 API 密钥,并且使用一种类似于刚才所说的 HMAC 的方式。更常见的方法是,使用一个公钥私钥对。通常情况下,正如集中管理人的身份标识一样,我们也会集中管理密钥。网关模型在这个领域很受欢迎。
其受欢迎的原因一部分源于这样一个事实,API 密钥重点关注的是对程序来说的易用性。相对于处理 SAML 握手,基于 API 密钥的身份验证更简单直接。
API 密钥的解决方案在商业和开源领域存在很多选项,每种系统提供的具体功能有所不同。有些产品只处理 API 密钥交换和一些基本的密钥管理。其他的工具提供包括限速、变现、API 目录和发现系统等功能。
一些 API 系统允许你将 API 密钥和现有目录服务联系起来。这允许将 API 密钥发布给你组织中的主体(代表人或系统),从而可以跟管理普通凭证一样,来控制这些密钥的生命周期。这为通过不同的方式访问系统,但保持一样的可靠信息来源提供了可能性。例如,如图 9-2 所示,SSO 使用 SAML 对人进行身份验证及服务间进行通信时使用 API 密钥。
9.2.7 代理问题
图 9-3:一个混淆代理人可以实施诡计的例子
当我登录后,可以单击一个链接来查看订单的详细信息。为了显示这些信息,我们需要从订单服务中找到原始订单,但我们也想要查看订单的物流信息。所以点击链接 / orderStatus/12345,会使在线商店通过在线商店服务向订单服务和物流服务发送请求,以获得这些信息。但这些下游服务是否该接受在线商店的调用呢?我们可以采用一种隐式信任的方式:因为调用在我们的信任边界内,所以是可以接受的。我们甚至可以使用证书或 API 密钥,来确认真的是在线商店请求这些信息。但这是否就足够了呢?
有一种安全漏洞叫作混淆代理人问题,指的是在服务间通信的上下文中,攻击者采用一些措施欺骗代理服务,让它调用其下游服务,从而做到一些他不应该能做的事情。例如,作为一个用户,当我登录到在线购物系统时,可以查看我的账户详情。但如果我使用登录后的凭证,欺骗在线购物用户界面去请求别人的信息,那该怎么办?
在这个例子中,如何阻止我查询不属于我的订单?一旦登录,我可以尝试给不属于我的其他订单发送请求,看是否能得到有用的信息。我们可以尝试让在线商店本身防止这种情况的发生,通过检查订单是谁的,然后拒绝别人访问不属于自己的订单。然而,如果有很多不同的应用程序来访问这些信息,可能会在很多地方重复这个逻辑。我们可以直接路由用户界面的请求到订单服务,让它来验证请求,不过这样会遇到我们在第 4 章讨论过的各种缺点。
另一种方法是,当在线商店给订单服务发送请求时,它不仅要说明想要哪个订单,还要说明以谁的名义来调用。一些身份验证方案允许我们传递原始主体的凭证给下游,不过使用 SAML 时,这会是一场噩梦,包含嵌套的 SAML 断言在技术上是可实现的——不过非常之难,以至于从来没人实现过。当然,这可能变得更加复杂。想象一下,如果在线商店调用的服务,转而调用更多的下游服务。我们需要在验证代理可信性上花费多少精力?
不幸的是,这个问题没有简单的答案,因为它本身就不是一个简单的问题。不过,要知道它的存在。根据所讨论操作的敏感性,你可能需要在隐式信任、验证调用方的身份或要求调用者提供原始主体的凭证这些安全方式里做一个选择。
9.3 静态数据的安全
9.3.1 使用众所周知的加密算法
搞砸数据加密最简单的方法是,尝试实现你自己的加密算法,或甚至试图实现别人的。无论使用哪种编程语言,都有被广泛认可的加密算法可供使用,它们都是经过同行评审,并定期打补丁的。使用那些算法!并且订阅选择的算法的邮件列表 / 公告列表,以确保你知道他们新发现的漏洞,这样就可以给算法打补丁或更新了。
对于静态数据的加密,除非你有一个很好的理由选择别的,否则选择你的开发平台上的 AES-128 或 AES-256 的一个广为人知的实现即可。
1通常来说,密钥的长度决定了暴力破解密钥所需的工作量。因此可以认为密钥的长度越长,你的数据越安全。然而,受人尊敬的安全专家 Bruce Schneier 对于 AES-256 中某些类型的密钥实现表示担心(https://www.schneier.com/blog/archives/2009/07/another_new_aes.html)。你在阅读本书的时候在这方面需要做更多的研究,以了解当前的建议是什么。
关于密码,你应该考虑使用一种叫作加盐密码哈希(salted password hashing,https://crackstation.net/hashing-security.htm#properhashing)的技术。
实现得不好的加密比没有加密更糟糕,因为虚假的安全感会让你的视线从球上面移开(双关语)。
9.3.2 一切皆与密钥相关
9.3.2 一切皆与密钥相关
加密的过程依赖一个数据加密算法和一个密钥,然后使用二者对数据进行加密。那么,你的密钥存储在哪里?如果加密数据是因为担心有人窃取整个数据库,那么把密钥存储在同一个数据库中,并不会真正消除这种担心!因此,我们需要把密钥存储到其他地方
一个解决方案是,使用单独的安全设备来加密和解密数据。 另一个方案是,使用单独的密钥库,当你的服务需要密钥的时候可以访问它。密钥的生命周期管理(和更改它们的权限)是非常重要的操作,而这些系统可以帮你处理这个事情。
有些数据库甚至包含内置的加密支持,比如 SQL Server 的透明数据加密(Transparent Data Encryption),旨在以一种透明的方式处理这个问题。即使你选择的数据库已经这样做了,也需要研究密钥是如何处理的,并且理解你要防范的威胁是否真的消除了。
加密很复杂。避免实现自己的方案,花些时间在已有的方案研究上!
9.3.3 选择你的目标
假设一切都需要加密,可以在一定程度上把事情简化,不需要再去猜测什么应该或不应该被保护。然而,你仍然需要考虑哪些数据可以被放入日志文件,以帮助识别问题,而且一切加密的计算开销会变得相当重,因此需要更强大的硬件。当你把数据库迁移作为重构数据库模式的一部分时,这就更具挑战性。根据所做的更改,数据可能需要解密、迁移和重加密。
通过把系统划分为更细粒度的服务,你可能发现加密整个数据存储是可行的,但即使可行也不要这么做。限制加密到一组指定的表是明智的做法。
9.3.4 按需解密
第一次看到数据的时候就对它加密。只在需要时进行解密,并确保解密后的数据不会存储在任何地方。
9.3.5 加密备份
备份是有好处的。我们想要备份重要的数据,那些我们非常担心的需要加密的数据,几乎也自然重要到需要备份!所以它看起来像是显而易见的观点,但是我们需要确保备份也被加密。这意味着,我们需要知道应该用哪个密钥来处理哪个版本的数据,特别是当密钥更改时。清晰的密钥管理变得非常重要。
9.4 深度防御
正如我前面所提到的,我不喜欢把所有的鸡蛋都放在一个篮子里,而是做深度防御。我们已经介绍了传输数据的安全以及静态数据的安全。但还有其他防护方法可以帮助我们
9.4.1 防火墙
有一个或多个防火墙是一个非常明智的预防措施。有些非常简单,只在特定端口限制特定的通信类型。其他的则要复杂一些。例如,ModSecurity 是一种应用程序防火墙,可以在特定的 IP 范围限制连接数,并检测其他类型的恶意攻击。
多个防火墙是有价值的。你可能决定在本地主机上使用 IPTables,设置允许的入口和出口,以确保这个主机的安全。这些规则可以根据本地运行的服务进行定制,而外围的防火墙则控制一般的访问。
9.4.2 日志
好的日志实践,特别是聚合多个系统的日志的能力,虽然不能起到预防的作用,但可以帮助检测出发生了不好的事情,以便之后进行恢复。例如,在应用安全补丁后,你经常能够在日志中看到是否有人曾经利用过某种安全漏洞。打补丁可以确保它不再发生,但如果已经发生了,你可能需要进入恢复模式。
日志可以让你事后看看是否有不好的事情发生过。但是请注意,我们必须小心那些存储在日志里的信息!敏感信息需要被剔除,以确保没有泄露重要的数据到日志里,如果泄露的话,最终可能会成为攻击者的重要目标。
9.4.3 入侵检测(和预防)系统
IDS(Intrusion Detection Systems,入侵检测系统)可以监控网络或主机,当发现可疑行为时报告问题。IPS(Intrusion Prevention Systems,入侵预防系统),也会监控可疑行为,并进一步阻止它的发生。不同于防火墙主要是对外阻止坏事进来,IDS 和 IPS 是在可信范围内积极寻找可疑行为。当你从零开始时,IDS 可能更有意义。这些系统是基于启发式的(正如很多的应用防火墙),很有可能刚开始的通用规则,对于你的服务行为来说过于宽松或过于严格。
使用在告警方面相对更加积极的 IDS 之前,应该先使用相对被动的 IDS,因为在这种情况下更容易优化规则。
9.4.4 网络隔离
对于单块系统而言,我们在通过构造网络来提供额外的保护方面能做的很有限。不过,在微服务系统中,你可以把服务放进不同的网段,以进一步控制服务间的通信。例如,AWS 提供自动创建 VPN(Virtual Private Cloud,虚拟私有云)的能力,它允许主机处在不同的子网中。然后你可以通过定义对等互连规则(peering rules),指定哪个 VPC 可以跟对方通信,甚至可以通过网关把流量路由到代理中,实际上,它提供了多个网络范围,在其中可以实施额外的安全措施。
这允许你基于团队的所有权或者风险水平来进行网络分段。
9.4.5 操作系统
我们的系统依赖于大量的不是我们自己编写的软件,即操作系统和其他的支持工具,其中的安全漏洞有可能会暴露我们的应用程序。在这里,基本的建议能让你走得很远。给操作系统的用户尽量少的权限,开始时也许只能运行服务,以确保即使这种账户被盗,造成的伤害也最小。
接下来,定期为你的软件打补丁。这需要自动化,并且你需要知道机器是否与最新的补丁级别不同步。像微软的 SCCM,或红帽的 Spacewalk 这样的工具,在这方面能提供帮助,因为它们可以帮助你查看机器是否已更新到最新的补丁,如果没有的话发起更新
如果使用像 Ansible、Puppet 或 Chef 这样的工具,很可能你对自动化推送更新已经相当满意了。这些工具也可以帮助你走很长的路,但不会为你做一切。
这真的是最基本的东西,但令人惊讶的是,我经常看到很多重要的软件运行在未安装补丁的、陈旧的操作系统上。你可能拥有世界上最好的受保护的应用程序级安全,但如果有一个旧版本的 Web 服务器作为 root 用户运行在你的机器上,而机器没有应用缓冲区溢出的漏洞补丁,那么你的系统仍然是极其脆弱的。
如果你正在使用的是 Linux 操作系统,另一件事是,看看操作系统本身安全模块的发展。例如,AppArmour,允许你自定义应用程序的预期行为,内核会对其进行监控。如果它开始做一些不该做的事情时,内核就会介入。AppArmour 已经存在一段时间了,SeLinux 也是。尽管从技术上来说,它们俩在任何新版 Linux 系统上应该都可用,但实际上,某些发行版对其中一个的支持会比另外一个要好。例如,Ubuntu 和 SuSE 默认使用 AppArmour,而 RedHat 一直以来都支持 SELinux。一个新的选择是 GrSSecurity,旨在扩展 AppArmour 和 GrSecurity 功能的同时,增加其易用性,但它需要一个定制的内核才能工作。我建议你三个工具都看看,然后挑选一个最适合自己使用场景的工具,但我喜欢在工作中多加一层保护和预防的想法。
9.5 一个示例
一个细粒度的架构,在安全实施上给了我们更多的自由。对于那些处理最敏感信息的,或暴露最有价值的功能的部分,我们可以采用最严格的安全措施。但对系统的其他部分,我们可以采用宽松一些的安全措施。
在这个例子中,我们的客户使用标准的 Web 浏览器,在 MusicCorp 网站上购物。我们还引入第三方版税网关的概念:我们已经开始与第三方公司合作,为新的流媒体服务收取版税。它会间断性地获取已下载音乐的记录——这个信息我们需要小心地保护,以防止被竞争对手获取。最后,我们将产品目录数据暴露给其他第三方。例如,允许在音乐评论网站中,嵌入艺术家或歌曲的相关信息。在我们的网络范围内,有一些协作的服务仅在内部使用。
对于浏览器,我们会为无需安全保护的内容使用标准 HTTP,以便其能被缓存。对于有安全需要的、登录后才可访问的页面,所有的内容都通过 HTTPS 传输,这样,如果我们的客户使用像公共 WiFi 那样的网络,能够给他们提供额外的保护。
当涉及第三方的版税支付系统时,我们所关注的不仅仅是公开数据的性质,还要确保我们得到的请求是合法的。在这里,我们坚持让第三方使用客户端证书。所有的数据传输都通过一条安全的、加密的通道,这能够提高我们确保请求主体合法性的能力。当然,我们也需要考虑当数据离开控制范围后,会发生什么事情。合作伙伴是否会跟我们一样关心这些数据?
对于产品目录数据的聚合,我们希望尽可能广泛地共享这些信息,让人们很方便地从我们这里购买音乐!然而,我们不希望这个信息被滥用,而且想要知道是谁在使用我们的数据。在这里,使用 API 密钥是一个绝佳的选择。
在网络范围内部,情况有点微妙。我们有多担心有人威胁我们的内部网络?理想情况下,最低限度也应该使用HTTPS,但是它的管理有点痛苦。我们决定,花费更多精力来夯实网络边界上的防护(至少在刚开始时),这包括使用一个正确配置的防火墙,选择适当的硬件或软件安全装置检查恶意流量(例如,端口扫描或拒绝服务攻击,denial-of-service attacks)。
也就是说,我们担心的是数据及其存储的地方。我们并不担心产品目录服务;毕竟,我们希望共享这些数据,并为它提供了一个 API !但是我们很担心客户的数据。在这里,我们决定加密客户服务中的数据,并需要在读取时解密。如果攻击者真的潜入我们的网络,他们仍然可以发送请求给客户服务的 API,但当前的实现并不允许批量检索客户数据。如果这个情况真的发生,我们可能需要考虑使用客户端证书来保护这些信息。即使攻击者攻破数据库所在的机器,下载了全部内容,他们也将需要访问用于加密和解密数据的密钥才能使用这些数据。
9.6 保持节俭
为什么不尽快删改尽可能多的、可以作为个人身份的数据?当用户请求登录时,我们需要永远存储完整的 IP 地址吗?或者我们是否可以用 x 替换最后几位数字?我们需要存储用户的姓名、年龄、性别和出生日期,以便为他提供产品建议,还是其年龄范围和邮编这样的信息就已经足够了?
这样做的好处是多方面的。首先,如果你不存储它,就没有人能偷走它。第二,如果你不存储它,就没有人(例如,政府机构)可以要它!
德国短语 Datensparsamkeit 表达了这一概念。这个短语起源于德国的隐私法,它封装了这一概念,即只存储完成业务运营或满足当地法律所需要的信息。
这显然与存储更多信息这个方向有冲突,但这仅仅是意识到这个冲突存在的一个开始!
9.7 人的因素
需要流程和政策,来处理组织中的人为因素。当有人离开组织时,你如何撤销访问凭证?你如何保护自己免受社会工程学的攻击?
9.8 黄金法则
要实现自己的加密算法。不要发明自己的安全协议。
比如 AES,都在行业中身经百战,其底层算法一直被同行审查,软件实现多年来也一直被严格测试和打补丁。它们已经足够好了!重新发明轮子在很多情况下通常只是浪费时间,但在安全领域,它会带来直接的危害。
9.9 内建安全
就像对待自动化功能测试那样,我们不想把安全留给一组不同的人实现,也不想把所有的事情留到最后一分钟才去做。帮助培养开发人员的安全意识很关键,提高每个人对安全问题的普遍意识,有助于从最开始减少这些问题。让人们熟悉 OWASP 十大列表和 OWASP 的安全测试框架,是一个很好的起点。不过,安全专家也绝对有用武之地,如果你能联系到他们,可以让他们来帮助你。
有些自动化工具可以帮助我们探测系统漏洞,比如发现跨站脚本攻击。ZAP(Zed Attack Proxy)就是一个很好的例子。它由 OWASP 出品,尝试重现对网站的恶意攻击。一些其他工具使用静态分析,寻找可能会导致安全漏洞的常见编码错误,例如针对 Ruby 的 Brakeman(http://brakemanscanner.org/)。这些工具可以很容易地集成到日常的 CI 构建中,以及标准的代码签入过程中。其他类型的自动化测试相对来说比较复杂。例如,像 Nessus 之类的漏洞扫描工具,它需要人为来解释运行结果。也就是说,这些测试仍然是可自动化的,但以类似运行负载测试的节奏来运行它们,可能比较合适。
微软的安全开发生命周期(Security Development Lifecycle,http://www.microsoft.com/en-us/sdl/default.aspx)也有一些很好的模型来帮助交付团队内建安全。其中一些内容似乎过于瀑布了,不过还是值得参考的,看看哪些方面可以融入到你当前的工作中。
9.10 外部验证
对于安全,我认为进行外部评估的价值很大。由外部方实施的类似渗透测试这样的实验,真的可以模拟现实世界的意图。这样做还可以避开这样的问题:团队并不总能看到自己所犯的错误,因为他们太接近于问题本身了。如果是一个足够大的公司,可能有一个专门的信息安全团队帮助你。如果不是,找一个外部方也可以。早点接触他们,了解他们是如何工作的,并向其学习做一个安全测试需要关注哪些内容。
你还需要考虑,每次发布前需要多少验证。一般来说,并不是每次小的增量发布都需要做一个完整的渗透测试,可能大的变化才需要。你的需求取决于你自己能承担的风险。
9.11 小结
把系统分解为更细粒度的服务,让我们在解决问题上有更多的选择。微服务不仅可能会减少任何安全破坏的影响,它还给予我们更多的能力对数据敏感的情况,采取开销更大、更复杂和更安全的方案,而当风险低时,采用更轻量级的方案。
一旦你了解系统不同部分的威胁级别,就可以知道什么时候需要考虑传输中的安全,什么时候需要考虑静态安全,或根本不用考虑安全。
最后,理解深度防御的重要性。给你的操作系统持续打补丁,即使你认为自己是一个摇滚明星,也不要尝试实现自己的加密算法!
如果你想要一个基于浏览器的应用程序安全的基本概述,优秀的非营利的 OWASP(Open Web Application Security Project,开放式 Web 应用程序安全项目,https://www.owasp.org/) 是一个很好的起点,其定期更新的十大安全风险文档,应被视为所有开发人员的必备读物。最后,如果你想要获得关于密码学的更全面的讨论,请查阅由 Niels Ferguson、Bruce Schneier 和 Tadayoshi Kohno 所著的 Cryptography Engineering。
逐步了解安全的过程,往往也是理解人以及他们如何使用我们系统的过程。关于微服务,还有一个与人相关的方面没有做讨论,就是组织结构和系统架构之间的相互影响。正如安全一样,我们会发现,忽视人的因素会是一个严重的错误。
考虑组织方面的问题。在这一章,我们将了解到忽略公司的组织结构会带来什么样的危险。
梅尔 · 康威于 1968 年 4 月 在 Datamation 杂志上发表了一篇名为“How Do Committees Invent”的论文,文中指出:
任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。
这句话被称为康威定律,经常以各种形式被引述。埃里克 · S. 雷蒙德在《新黑客字典》中总结这一现象时指出:“如果你有四个小组开发一个编译器,那你会得到一个四步编译器。”
自从康威的论文提交以来,人们在这一领域进行了大量的研究,探讨组织结构和他们创建的系统之间的关系。
10.1.1 松耦合组织和紧耦合组织
在 Exploring the Duality Between Product and Organizational Architectures 一书中,作者 Alan MacCormack、John Rusnak 和 Carliss Baldwin 研究了大量不同的软件系统,把创建这些系统的组织大致分为松耦合组织和紧耦合组织。紧耦合组织的一个例子是商业产品公司,他们的员工都在一起工作,并有着一致的愿景和目标;而松耦合组织的典型代表是分布式开源社区。
在研究中,通过匹配不同类型组织中比较相似的产品,他们发现,组织的耦合度越低,其创建的系统的模块化就越好,耦合也越低;组织的耦合度越高,其创建的系统的模块化也越差。
10.2 Netflix和Amazon
组织和架构应该一致,信奉这个理念的两个典范是 Amazon 和 Netflix。在早期,Amazon 就开始理解了,团队对他们所管理系统的整个生命周期负责的好处。它想要团队共同拥有和运营其创建的系统,并管理整个生命周期。Amazon 也相信,小团队会比大团队的工作更有效。于是产生了著名的“两个比萨团队”,即没有一个团队应该大到两个比萨不够吃。帮助小团队对服务的整个生命周期负责,是驱动 Amazon 开发 AWS 的一个主要原因。团队需要一些工具来自助式地获取相应的计算资源等。
Netflix 从这个例子中学到了很多,因此从一开始,它就确保其本身是由多个小而独立的团队组成,以保证他们创建的服务也能独立于彼此。这确保了系统的架构可以快速地优化。实际上,Netflix 为了想要的系统架构,才设计了这样的组织结构。
组织结构对系统的性质和质量确实有着深刻的影响
10.4 适应沟通途径
当协调变化的成本增加后,有一件事情会发生:人们要么想方设法降低协调 / 沟通成本,要么停止更改。而后者正是导致我们最终产生庞大的、难以维护的代码库的原因。
参与创建系统的开发人员之间存在地理位置差异,是一个应该对服务进行分解的很明显的信号,一般来说,你应该分配单个服务的所有权给可以保持低成本变化的团队。
也许你的公司决定,在另一个国家新开一间办公室,通过这种方式来增加项目的人数。这个时候,积极思考系统的哪部分可以移交给新团队。也许适应沟通途径这种方式,能够驱动你做出将某个接缝拆分出去的决定。
如果构建系统的组织更加松耦合(例如,由异地的团队组成),其所构建的系统则倾向于更加模块化,因此耦合度也越低。一个拥有许多服务的单个团队对其管理的服务会倾向于更紧密地集成,而这种方式在分布式组织中是很难维护的。
10.5 服务所有权
它意味着拥有服务的团队负责对该服务进行更改。只要更改不破坏服务的消费者,团队就可以随时重新组织代码。对于许多团队而言,所有权延伸到服务的方方面面,从应用程序的需求、构建、部署到运维。这种模式在微服务的世界尤为普遍,一个小团队更容易负责一个小服务。所有权程度的增加会提高自治和交付速度。团队需要自己负责部署和维护应用程序,这会激励团队创建出易于部署的服务;也就是说,当没有人能够接受你扔出去的东西时,也就不用担心人们会犯“把东西扔出墙”这种错误了!
当然我很喜欢这种模式。它把决定权交给最合适的人,赋予团队更多的权力和自治,也使其对工作更负责。我见过太多太多的开发人员,把系统移交给测试或部署阶段后,就认为他们的工作已经完成了。
10.6 共享服务的原因
这种方式效果不佳
10.6.1 难以分割
拆分服务的成本太高是多个团队负责单个服务的原因之一,你的组织或许看不到这一点。这常见于大型的单块系统中
10.6.2 特性团队
特性团队(即基于特性开发的团队)的想法,是一个小团队负责开发一系列特性需要的所有功能,即使这些功能需要跨越组件(甚至服务)的边界。特性团队的目标很合理。这种结构促使团队保持关注在最终的结果上,并确保工作是集成起来的,避免了跨多个不同的团队试图协调变化的挑战。
在许多情况下,特性团队是对传统的 IT 组织中,团队结构围绕技术边界进行组织的一种修正。例如,你可能有一个团队专门负责用户界面,另一个团队负责应用程序逻辑,第三个团队负责处理数据库。这种环境下,特性团队迈出了一大步,它跨越所有层提供完整的功能。
大范围地采用特性团队后,所有服务都是共享的。每个人都可以改变任意一个服务,任意一段代码。在这种情况下,服务守护者的角色如果还存在的话,会变得复杂得多。不幸的是,采用这种模式后我很少看到守护者,这会导致我们前面讨论的种种问题。
但是,让我们再考虑一下什么是微服务:服务会根据业务领域,而不是技术进行建模。如果负责某个微服务的团队与业务领域相匹配,则它更容易保持对客户的关注,也更容易进行以特性为导向的开发,因为它对服务相关的所有技术有一个全面的了解并且拥有所有权。
当然,也会出现横跨多个服务的特性,但由于我们避免技术导向的团队,这种可能性会大大降低。
10.6.3 交付瓶颈
共享服务的另一个关键原因是,这样做可以避免交付瓶颈。
10.7 内部开源
那么,如果我们已经尽了最大的努力,仍然无法找到方法来避免共享几个服务该怎么办?在这个时候,拥抱内部开源模式可能更合理。
标准的开源项目中,一小部分人被认为是核心提交者,他们是代码的守护者。如果你想修改一个开源项目,要么让一个提交者帮你修改,要么你自己修改,然后提交给他们一个 pull 请求。核心的提交者对代码库负责,他们是代码库的所有者。
在组织内部,这种模式也可以很好地工作。也许最初在服务上工作的人,不再跟团队在一起了,也许他们现在分散在组织的不同地方。好吧,如果他们仍然具备提交的权限,你可以找到他们并寻求帮助,或许跟他们结对,或者如果你有合适的工具,可以给他们发一个 pull 请求。
10.7.1 守护者的角色
我们仍然希望得到高质量的服务。我们想要体面的代码质量,服务代码的组织方式应该表现出某种一致性。我们也要确保现在的更改不会让未来计划中的更改变得更加困难。这意味着,我们在内部也要采用跟标准开源项目同样的模式。这需要分离出一组受信任的提交者(核心团队)和不受信任的提交者(团队外提交变更的人)。
核心团队需要对更改有某种程度的审批。它需要确保所有的更改符合该代码库的惯例,也就是遵循跟代码库其他代码一致的编码准则。因此,做审批的人不得不花时间在提交者身上,以确保得到高质量的更改。
好的守护者会花费大量的精力与提交者进行清晰的沟通,并对他们的工作方式进行引导。糟糕的守护者会以此为借口,向别人发号施令,或施加类似宗教战争般固执的技术决策。这两种行为我都见过,我可以明确告诉你一件事:无论使用哪种方式,都需要时间。当考虑允许不受信赖的提交者提交更改到你的代码库时,你必须做出决定,专门设置一个守护者的开销是否值得:核心团队是否可以把花费在审批更改上的时间,用在更有意义的事情上?
10.7.2 成熟
服务越不稳定或越不成熟,就越难让核心团队之外的人提交更改。在服务的核心模块到位前,团队可能不知道什么样的代码是好的,因此也很难知道什么是一个好的提交。在这个阶段,服务本身正处于快速变化的状态。
大多数开源项目在完成第一个版本的核心代码前,往往不允许让更广泛的不受信任的提交者们提交代码。在我们自己的组织中采用类似的方式也是合理的。如果一个服务已经相当成熟,而且很少改变,比如购物车服务,也许这个时候,才是开源并让其他人贡献代码的最好时机。
10.7.3 工具
为了更好地支持内部开源模型,你需要一些工具。使用支持 pull 请求(或类似的其他方式)的分布式版本控制工具是很重要的。根据组织的大小,你可能还需要支持讨论和修改提交申请的工具;可能这并不意味着需要一个完整的代码评审系统,但将评论附到提交申请中的能力是非常有用的。最后,你需要让提交者能够很容易地构建和部署软件以供他人使用。这通常需要良好的构建和部署流水线,以及集中构件物仓库。
10.8 限界上下文和团队结构
如前文所述,我们以限界上下文来定义服务的边界。因此,我们希望团队也与限界上下文保持一致。这有很多好处。首先,团队会发现它在限界上下文内更容易掌握领域的概念,因为它们是相互关联的。其次,限界上下文中的服务更有可能发生交互,保持一致可以简化系统设计和发布的协调工作。最后,在交付团队与业务干系人进行交互方面,它有利于团队与此领域内的一两个专家创建良好的合作关系。
10.9 孤儿服务
如果你的服务采用真正的多语言方案,使用多个技术栈,那么当你的团队不了解孤儿服务的技术栈时,那么更改它的挑战可能会加大。
10.10 案例研究:RealEstate.com.au
REA 的核心业务是房地产,但包含多个不同的方面,每一方面都是一条业务线(Line Of Business,LOB)。例如,一条业务线是澳大利亚的住宅房地产销售,而另一条业务线涉及 REA 的海外业务
与业务相一致的不仅仅是交付团队的组织,还包括架构,例如集成方式。一个业务线内,服务间可以不受任何限制地以任何方式来通信,只要团队确定的服务守护者认为合适即可。但是在业务线之间,所有通信都必须是异步批处理,这是非常小的架构团队的几个严格的规则之一。这种粗粒度的通信与不同业务之间的粗粒度的通信是匹配的。坚持异步批处理,每条业务线在自身的行为和管理上有很大的自由度。它可以随时停止其服务,只要能满足其他业务线的批量集成,以及自己业务干系人的需求,那么没有人会在意。
这种结构不仅使团队,也让不同的业务实现了很好的自治性。
这些组织的系统架构和组织结构对变化都有着很好的适应性,这能够产生巨大的效益,因为这样的组织改进了团队的自治性,并且能够加快新需求和新功能的发布速度。很多组织都意识到,系统架构并非凭空产生的,REA 是其中之一
10.11 反向的康威定律
系统设计能改变组织吗?
无论系统有什么设计缺陷,我们都不得不通过改变组织结构来推动系统的更改。许多年后,这个过程仍然在进行中!
10.12 人
“不管一开始看起来什么样,它永远是人的问题。”
——杰拉尔德 · 温伯格,咨询第二定律
我们必须承认,在微服务环境中,开发人员很难只在自己的小世界中编写代码。他需要意识到像跨网络边界调用及隐式失败等隐式问题。我们还讨论过微服务的能力,它让尝试新技术更为容易,从数据存储到编程语言。但如果你从一个单块系统的世界走来,那里的大多数开发人员只需要使用一种语言,并且对运维完全没有意识,那么直接把他们扔到微服务的世界,就像是粗鲁地把他们从单纯的世界中叫醒一样。
同样,赋权给开发团队以增加自治也是充满挑战的。过去的人们习惯把工作扔给别人,习惯指责别人,现在让他们对自己的工作完全负责可能会让其感觉不舒服。
他们是你现在拥有系统的构建者,并将继续构建系统的未来。不考虑当前员工的感受,或不考虑他们现有的能力来提出一个该如何做事的设想,有可能会导致一个糟糕的结果。
每个组织都有自己的节奏。了解你的员工能够承受的变化,不要逼他们改变太快!也许在短时间内,你仍然需要一个单独的团队来处理线上支持或生产环境部署,以便给开发人员足够的时间调整到新的实践中。然而,你可能不得不接受,需要组织中不同人的参与才能搞定这个工作。无论方法是什么,你需要跟员工清楚地阐明,在微服务的世界里每个人的责任,以及为何这些责任如此重要。这能够帮助你了解技能差距并思考如何弥补它们。对许多人来说,这将是一个非常可怕的旅程。请记住,如果没有把人们拉到一条船上,你想要的任何变化从一开始就注定会失败。
10.13 小结
康威定律强调了试图让系统设计跟组织结构不匹配所导致的危险。这引导我们试图将服务所有权与同地团队相匹配,而两者本身与组织限界上下文是匹配的。当两者不一致时,我们会得到本章所阐述的那些摩擦点。认识到两者之间的联系,我们要确保正在构建的系统对组织而言是合理的。
我们这里讲的所有内容,都会涉及组织规模化后的挑战。当我们的系统开始扩大规模,不再是几个分散的服务时,还需要担心其他一些技术方面的考虑。
11.1 故障无处不在
我们知道事情可能会出错,硬盘可能会损坏,软件可能会崩溃。任何读过“分布式计算的故障”( https://en.wikipedia.org/wiki/Fallacies_of_Distributed_Computing )的人都会告诉你,网络也是不可靠的。我们可以尽力尝试去限制引起故障的因素,但达到一定规模后,故障难以避免。例如,现在的硬盘比以往任何时候都更可靠,但它们最终也会损坏。你的硬盘越多,其中一个会发生故障的可能性就越大;从统计学来看,规模化后故障将成为必然事件。
如果我们可以很好地处理服务的故障,那么就可以对服务进行原地升级,因为计划内的停机要比计划外的更容易处理。
我们也可以在试图阻止不可避免的故障上少花一点时间,而花更多时间去优雅地处理它。
假设一切都会失败,会让你从不同的角度去思考如何解决问题。
规模化后,即使你买最好的工具,最昂贵的硬件,也无法避免它们会发生故障的事实。因此,你需要假定故障会发生。如果以这种想法来处理你做的每一件事情,为其故障做好准备,那么就会做出不同的权衡。如果你知道一个服务器将会发生故障,系统也可以很好地应对,那么又何必在阻止故障上花很多精力呢?为什么不像谷歌那样,使用裸主板和一些便宜的组件(一些尼龙搭扣),而不必过多地担心单节点的弹性?
11.2 多少是太多
跨功能需求就是,要考虑数据的持久性、服务的可用性、吞吐量和服务可接受的延迟等这些方面。
有一个自动扩容系统,能够应对负载增加或单节点的故障,这可能是很棒的,但对于一个月只需运行一两次的报告系统就太夸张了,因为这个系统,即使宕机一两天也没什么大不了的。同样,搞清楚如何做蓝 / 绿部署,使服务在升级时无需停机,对你的在线电子商务系统来说可能会有意义,但对企业内网的知识库来说可能有点过头了。
了解你可以容忍多少故障,或者系统需要多快,取决于系统的用户。反过来,这会帮助你了解哪些技术将对你有意义。也就是说,你的用户不是经常能阐明需求到底是什么,所以你需要通过问问题来提取正确的信息,并帮助他们了解提供不同级别服务的相对成本。
如前所述,服务不同,这些跨功能需求也不一样,不过我建议你定义一些默认的跨功能需求,然后在特定的用例中重载它们。当考虑是否以及如何扩展你的系统,以便更好地处理负载或故障时,首先请尝试理解以下需求。
响应时间/延迟
各种操作需要多长时间?我们可以使用不同数量的用户来测量它,以了解负载的增加会对响应时间造成什么样的影响。鉴于网络的性质,你经常会遇到异常值,所以将监控的响应目标设置成一个给定的百分比是很有用的。目标还应该包括你期望软件处理的并发连接 / 用户数。所以,你可能会说:“我期望这个网站,当每秒处理 200 个并发连接时,90% 的响应时间在 2 秒以内。”
可用性
你能接受服务出现故障吗?这是一个 24/7 服务吗?当测量可用性时,有些人喜欢查看可接受的停机时间,但这个对调用服务的人又有什么用呢?对于你的服务,我只能选择信赖或者不信赖。测量停机时间,只有从历史报告的角度才有用。
数据持久性
多大比例的数据丢失是可以接受的?数据应该保存多久?很有可能每个案例都不同。例如,你可能为了节省空间,选择将用户会话的日志只保存一年,但你的金融交易记录可能需要保存很多年。
一旦有这些需求,你就会想要一种方式,系统性地持续测量。例如,可能你决定使用性能测试,以确保系统性能满足可接受的目标,不过可能你也想要在生产环境上监控这些数据。
11.3 功能降级
构建一个弹性系统,尤其是当功能分散在多个不同的、有可能宕掉的微服务上时,重要的是能够安全地降级功能。
想象一下,我们电子商务网站上的一个标准的 Web 页面。要把网站的各个功能组合在一起,我们需要几个微服务联合发挥作用。一个微服务可能显示出售专辑的详细信息,另一个显示价格和库存数量。我们还需要展示购物车内容,这可能是另一个微服务。现在,如果这些微服务中的任何一个宕掉,都会导致整个 Web 页面不可用,那么我们可以说,该系统的弹性还不如只使用一个服务的系统。
我们需要做的是理解每个故障的影响,并弄清楚如何恰当地降级功能。如果购物车服务不可用,我们可能会有很多麻烦,但仍然可以显示列表清单页面。也许可以仅仅隐藏掉购物车,将其替换成一个新的图标“马上回来!”。
对简单的单块应用程序来说,我们不需要做很多决定。系统不是好的,就是坏的。但对于微服务架构,我们需要考虑更多微妙的情况。很多情况下,需要做的往往不是技术决策。从技术方面我们可能知道,当购物车宕掉了有哪些处理方式,但除非理解业务的上下文,否则我们不知道该采取什么行动。比如,也许关闭整个网站,也许仍然允许人们浏览物品目录,也许把用户界面上的购物车控件变成一个可下订单的电话号码。对于每个使用多个微服务的面向用户的界面,或每个依赖多个下游合作者的微服务来说,你都需要问自己:“如果这个微服务宕掉会发生什么?”然后你就知道该做什么了。
11.4 架构性安全措施
有一些模式,组合起来被称为架构性安全措施,它们可以确保如果事情真的出错了,不会引起严重的级联影响。这些都是你需要理解的非常关键的点,我强烈建议在你的系统中把它们标准化,以确保不会因为一个服务的问题导致整个系统的崩塌。我们很快将看到应该考虑的这些关键的安全措施,我想分享一个简短的故事,概述一下哪些事情可能会出错。
响应非常缓慢是最糟糕的故障模式之一。如果一个系统宕掉了,你很快就会发现。但当它只是很慢的时候,你需要等待一段时间,然后再放弃。但无论故障原因是什么,我们创建了一个容易被故障级联影响的系统。一个无法控制的下游服务,可以让整个系统宕掉。
处理系统缓慢要比处理系统快速失败困难得多。在分布式系统中,延迟是致命的。
即使我们连接池的超时设置是正确的,所有的出站请求还是共享一个 HTTP 连接池。这意味着,即使其他的服务很健康,一个缓慢的服务就可能耗尽所有可用的 worker。最后,很明显下游服务是不健康的,但我们仍然一直发送通信。
为了避免这种情况再次发生,我们最终修复了以下三个问题:正确地设置超时,实现舱壁隔离不同的连接池,并实现一个断路器,以便在第一时间避免给一个不健康的系统发送调用。
11.5 反脆弱的组织
在《反脆弱》一书中,作者 Nassim Taleb 认为事物实际上受益于失败和混乱。
Netflix 完全是基于 AWS 的基础设施一样,Netflix 的经营规模也是众所周知的。这两个因素意味着,它必须很好地应对故障。实际上 Netflix 通过引发故障来确保其系统的容错性。
一些公司喜欢组织游戏日,在那天系统会被关掉以模拟故障发生,然后不同团队演练如何应对这种情况。我在谷歌工作期间,在各种不同的系统中都能遇到这种活动,并且我认为经常组织这类演练对于很多公司来说都是有益的。谷歌比简单的模拟服务器故障更进一步,作为年度 DiRT(Disaster Recovery Test,灾难恢复测试,http://queue.acm.org/detail.cfm?id=2371516)演习的一部分,它甚至模拟地震等大规模的自然灾害。Netflix 也采用了更积极的方式,每天都在生产环境中通过编写程序引发故障。
这些项目中最著名的是混乱猴子(Chaos Monkey),在一天的特定时段随机停掉服务器或机器。知道这可能会发生在生产环境,意味着开发人员构建系统时不得不为它做好准备。混乱猴子只是 Netflix 的故障机器人猴子军队(Simian Army)的一部分。混乱大猩猩(Chaos Gorilla)用于随机关闭整个可用区(AWS 中对数据中心的叫法),而延迟猴子(Latency Monkey)在系统之间注入网络延迟。Netflix 已使用开源代码许可证(https://github.com/Netflix/SimianArmy)开源了这些工具。对许多人来说,你的系统是否真的健壮的终极验证是,在你的生产环境上释放自己的猴子军队。
通过让软件拥抱和引发故障,并构建系统来应对,这只是 Netflix 做的一部分事情。它还知道当失败发生后从失败中学习的重要性,并在错误真正发生时采用不指责文化。作为这种学习和演化过程的一部分,开发人员被进一步授权,他们每个人都需要负责管理他的生产服务。
通过引发故障,并为其构建系统,Netflix 已经确保它的系统能够更好地规模化以及支持其客户的需求。
不是每个人都需要做到像谷歌或 Netflix 那样极致,但重要的是,理解分布式系统所需的思维方式上的转变。事情将会失败。你的系统正分布在多台机器上(它们会发生故障),通过网络(它也是不可靠的)通信,这些都会使你的系统更脆弱,而不是更健壮。所以,无论你是否打算提供像谷歌或 Netflix 那样规模化的服务,在分布式架构下,准备好如何应对各种故障的发生是非常重要的
11.5.1 超时
超时是很容易被忽视的事情,但在使用下游系统时,正确地处理它是很重要的。在考虑下游系统确实已经宕掉之前,我需要等待多长时间?
如果等待太长时间来决定调用失败,整个系统会被拖慢。如果超时太短,你会将一个可能还在正常工作的调用错认为是失败的。如果完全没有超,一个宕掉的下游系统可能会让整个系统挂起。
给所有的跨进程调用设置超时,并选择一个默认的超时时间。当超时发生后,记录到日志里看看发生了什么,并相应地调整它们。
11.5.2 断路器
在自己家里,断路器会在电流尖峰时保护你的电子设备。如果出现尖峰,断路器会切断电路,保护你昂贵的家用电器。你也可以手动使用断路器切断家里的部分电源,让电器安全地工作。Michael Nygard 在 Release It! 一书中,介绍了使用同样的想法作为软件的保护机制会产生奇妙的效果。
想想我们之前分享的故事。下游的遗留广告应用程序在最终返回错误之前,响应非常慢。即使我们正确地设置超时,也需要等待很长时间才能得到错误。接着我们等下次请求进来时将再次尝试,同样等待。下游服务发生故障已经够糟糕的了,它还让我们的系统变得很慢。
使用断路器时,当对下游资源的请求发生一定数量的失败后,断路器会打开。接下来,所有的请求在断路器打开的状态下,会快速地失败。一段时间后,客户端发送一些请求查看下游服务是否已经恢复,如果它得到了正常的响应,将重置断路器。
断路器的概述
如何实现断路器依赖于请求失败的定义,但当使用 HTTP 连接实现它们时,我会把超时或 5XX 的 HTTP 返回码作为失败的请求。通过这种方式,当一个下游资源宕掉,或超时,或返回错误码时,达到一定阈值后,我们会自动停止向它发送通信,并启动快速失败。当它恢复健康后,我们会自动重新发送请求。
正确地设置断路器会有点棘手。你不想太轻易地启动断路器,也不想花太多时间来启动。同样,你要确保在下游服务真正恢复健康后才发送通信。跟超时一样,我会选取一些合理的默认值并在各处使用,然后在特定的情况下调整它们。
当断路器断开后,你有一些选项。其中之一是堆积请求,然后稍后重试它们。对于一些场景,这可能是合适的,特别是你所做的工作是异步作业的一部分时。然而,如果这个调用作为同步调用链的一部分,快速失败可能更合适。这意味着,沿调用链向上传播错误,或更微妙的降级功能。
如果我们有这种机制(如家里的断路器),就可以手动使用它们,以使所做的工作更加安全。例如,如果作为日常维护的一部分,我们想要停用一个微服务,可以手动启动依赖它的所有系统的断路器,使它们在这个微服务失效的情况下快速失败。一旦微服务恢复,我们可以重置断路器,让一切都恢复正常。
11.5.3 舱壁
舱壁(bulkhead),是把自己从故障中隔离开的一种方式。在航运领域,舱壁是船的一部分,合上舱口后可以保护船的其他部分。所以如果船板穿透之后,你可以关闭舱壁门。如果失去了船的一部分,但其余的部分仍完好无损。
在软件架构术语中,有很多不同的舱壁可供我们考虑
应该为每个下游服务的连接使用不同的连接池。这样的话,正如我们在图 11-3 看到的,如果一个连接池被用尽,其余连接并不受影响。这可以确保,如果下游服务将来运行缓慢,只有那一个连接池会受影响,其他调用仍可以正常进行。
关注点分离也是实现舱壁的一种方式。通过把功能分离成独立的微服务,减少了因为一个功能的宕机而影响另一个的可能性。
看看你的系统所有可能出错的方面,无论是微服务内部还是微服务之间。你手头有舱壁可以使用吗?我建议,至少为每个下游连接建立一个单独的连接池。不过,你可能想要更进一步,也考虑使用断路器。
我们可以把断路器看作一种密封一个舱壁的自动机制,它不仅保护消费者免受下游服务问题的影响,同时也使下游服务避免更多的调用,以防止可能产生的不利影响。鉴于级联故障的危险,我建议对所有同步的下游调用都使用断路器。当然,不需要重新创造你自己的断路器。Netflix 的 Hystrix 库( https://github.com/Netflix/Hystrix )是一个基于 JVM 的断路器,附带强大的监控。还有其他的基于不同技术栈的断路器实现,比如 .NET 的 Polly( https://github.com/App-vNext/Polly ),或 Ruby 的 circuit_breaker mixin( https://github.com/wsargent/circuit_breaker )。
每个下游服务一个连接池,以提供舱壁
舱壁是三个模式里最重要的。超时和断路器能够帮助你在资源受限时释放它们,但舱壁可以在第一时间确保它们不成为限制。例如,Hystrix 允许你在一定条件下,实现拒绝请求的舱壁,以避免资源达到饱和,这被称为减载(load shedding)。有时拒绝请求是避免重要系统变得不堪重负或成为多个上游服务瓶颈的最佳方法。
11.5.4 隔离
一个服务越依赖于另一个,另一个服务的健康将越能影响其正常工作的能力。如果我们使用的集成技术允许下游服务器离线,上游服务便不太可能受到计划内或计划外宕机的影响。
服务间加强隔离还有另一个好处。当服务间彼此隔离时,服务的拥有者之间需要更少的协调。团队间的协调越少,这些团队就更自治,这样他们可以更自由地管理和演化服务
11.6 幂等
对幂等操作来说,其多次执行所产生的影响,均与一次执行的影响相同。如果操作是幂等的,我们可以对其重复多次调用,而不必担心会有不利影响。当我们不确定操作是否被执行,想要重新处理消息,从而从错误中恢复时,幂等会非常有用。
让我们考虑一个简单调用的例子,当客户下一个订单后给他增加一些积分。我们以示例 11-1 所示的负载发起这个调用。
示例 11-1:给一个账户增加积分
<credit> <amount>100</amount> <forAccount>1234</account> </credit>
如果多次收到这个调用,我们会多次增加 100 点。因此,按照这种情况,这个调用不是幂等的。然而,如示例 11-2 所示,当有更多的信息后,我们就可以让积分账户把这次调用变成幂等操作。
示例 11-2:添加更多的信息使这个调用变成幂等操作
<credit> <amount>100</amount> <forAccount>1234</account> <reason> <forpurchase>4567</forpurchase> </reason> </credit>
现在我们知道,这次信用与一个特定的订单 4567 相关。假如一个给定的订单只能获得唯一的积分,我们可以在不增加总积分的情况下,再次应用这个积分。
这种机制在基于事件的协作中也会工作得很好,尤其是当你有多个相同类型的服务实例都订阅同一个事件时,会非常有用。即使我们存储了哪些事件被处理过,在某些形式的异步消息传递中,可能还留有小窗口,两个 worker 会看到相同的信息。通过以幂等方式处理这些事件,我们确保不会导致任何问题。
有些人太极端化这一概念,认为它意味着,后续的调用如果使用相同的参数,对系统不会有任何的影响,这让我们处在一个有趣的位置。例如,我们仍然希望记录调用的发生及其响应时间到日志中,以收集这个数据来做监控。这里的关键点是,我们认为那些业务操作是幂等的,而不是整个系统状态的。
有些 HTTP 动词,例如 GET 和 PUT,在 HTTP 规范里被定义成幂等的,但要让这成为事实,依赖于你的服务在处理这些调用时是否使用了幂等方式。如果使用了这些动词,但操作不是幂等的,然而调用者认为它们可以安全地重复执行,你可能会让自己陷入困境。记住,仅仅因为你使用 HTTP 作为底层协议,并不意味着就可以免费得到它提供的一切好处。
11.7 扩展
一般来说,我们扩展系统的原因有以下两个。首先,为了帮助处理失败;如果我们担心有些东西会失败,那么多一些这些东西会有帮助,对吗?其次,我们为性能扩展,无论是处理更多的负载、减少延迟或两者兼而有之
11.7.1 更强大的主机
一些操作可能受益于更强大的主机。一个有着更快的 CPU 和更好的 I/O 的机器,通常可以改善延迟和吞吐量,允许你在更短的时间内处理更多的工作。这种形式的扩展通常被称为垂直扩展,它是非常昂贵的,
这种形式的扩展无法改善我们服务器的弹性!尽管如此,这可能是一个可以快速见效的很好的方式,特别是当你正在使用虚拟化供应商的服务,并且它允许你轻松地调整机器的大小时
11.7.2 拆分负载
单服务单主机模型肯定要优于多服务单主机模型。然而在最初的时候,很多人决定将多个微服务共存于一台主机,以降低成本或简化主机管理(尽管这个原因有待商榷)。因为微服务是通过网络通信的独立进程,所以把它们切换到使用自己的主机来提高吞吐量和伸缩性,应该是一件很容易的事。这还可以增加系统的弹性,因为单台主机的宕机将影响较少数量的微服务。
我们也可能因为要扩展需要把现有的微服务拆分成几个部分,以更好地处理负载
举一个简单的例子,想象我们的账户服务提供创建和管理个人客户的财务账户的功能,同时也暴露一个 API 用于运行查询来生成报表。这个查询功能会给系统带来一个严重的负载。这个查询不是那么重要,因为白天需要保持订单流时并不需要它。然而,为客户管理财务账单的能力是至关重要的,因此我们不能承担它宕机带来的后果。通过把这两个功能拆分到单独的服务,减少了关键账户服务上的负载,并且引入一个用以查询的新的账户报表服务(也许使用我们在第 4 章中描述的一些技术,但作为一个非关键系统,并不需要像核心账户服务那样以富有弹性的方式部署)。
11.7.3 分散风险
不要把多个服务放到一台主机上,因为主机的宕机会影响多个服务。
主机实际上是一个虚拟的概念。如果所有的服务都在不同的主机上,但这些主机实际上都是运行在一台物理机上的虚拟主机呢?如果物理机宕掉,同样也会失去多个服务。一些虚拟化平台能够确保你的主机分布在多个不同的物理机上,以减小发生上述情况的可能性。
对于内部的虚拟化平台,常见的做法是,虚拟机的根分区映射到单个 SAN(Storage Area Network,存储区域网络)。如果 SAN 故障,会影响所有连接的虚拟机。SAN 是大型的、昂贵的,并且被设计成不会发生故障。不过,在过去的 10 年中,那些大型且昂贵的 SAN 至少发生过两次故障,而且每次的后果都相当严重。
另一种常见的减少故障的方法是,确保不要让所有的服务都运行在同一个数据中心的同一个机架上,而是分布在多个数据中心。如果你使用基础服务供应商,知道 SLA(Service-Level Agreement,服务等级协议)是否提供和具备相应的计划是非常重要的。如果需要确保你的服务在每季度不超过四小时的宕机时间,但是主机供应商只能保证每个季度不超过八小时的宕机时间,你必须改变 SAL 或选取一个替代解决方案。
将一个灾难恢复托管平台放到一个不同的供应商那里,以确保他们不至于脆弱得因为一家公司出错而受影响。
11.7.4 负载均衡
当你想让服务具有弹性时,要避免单点故障。对于公开一个同步 HTTP 接口这样典型的微服务来说,要达到这个目的最简单的方法是,如图 11-4 所示,在一个负载均衡器后,放置多台主机运行你的微服务实例。对于微服务的消费者来说,你不知道你是在跟一个微服务实例通信,还是一百个。
使用负载均衡来扩展客户服务实例的一个例子
负载均衡器各种各样,从大型昂贵的硬件设备,到像 mod_proxy 这样基于软件的负载均衡器。它们都有一些共同的关键功能。它们都是基于一些算法,将调用分发到一个或多个实例中,当实例不再健康时移除它们,并当它们恢复健康后再添加进来。
一些负载均衡器提供了其他有用的功能。常见的一个是 SSL 终止,通过 HTTPS 连接入站负载均衡器后,当到实例本身时转换成 HTTP 连接。从经验上看,管理 SSL 的开销非常大,拥有一个负载均衡器来处理这个过程是相当有用的。如今,这在很大程度上也简化了单个主机运行实例的配置。不过,使用 HTTPS 的原因,正如我们在第 9 章讨论的,是确保请求不容易受到中间人的攻击,所以如果使用 SSL 终止,在某种程度上可能会暴露我们自己。缓解这个问题的一个方法,正如我们在图 11-5 中看到的,是把所有的微服务实例都 放在一个独立的 VLAN 里。VLAN 是一个虚拟局域网,所有的外部请求只能通过一个路由器访问内部。在这个例子中,这个路由器也就是 SSL 终端负载均衡器。VLAN 外部跟微服务通信的唯一方式是通过 HTTPS,而内部的所有通信都是通过 HTTP。
使用更安全的 VLAN,负载均衡提供的 HTTPS 终止
AWS 以 ELBs(Elastic Load Balancers,弹性负载均衡器)的形式,提供 HTTPS 终止的负载均衡器,你可以使用其安全组或 VPCs(Virtual Private Clouds,私有虚拟云)来实现 VLAN。另外,像 mod_proxy 这样的软件,可以发挥类似软件负载均衡器的作用。许多组织使用硬件负载均衡器,不过它很难实现自动化。正因为如此,我自己倾向于在硬件负载均衡器后使用软件负载均衡器,这样允许团队自由地按需重新配置它们。事实上,硬件负载均衡器本身往往也会成为单点故障!不过,无论采用哪种方式,当考虑负载均衡器的配置时,要像对待服务的配置一样对待它:确保它存放在版本控制系统中,并且可以被自动化地应用。
负载均衡器允许我们以对服务的所有消费者透明的方式,增加更多的微服务实例。这提高了我们应对负载的能力,并减少了单个主机故障的影响。然而,很多(如果不是大多数的话)微服务会有某种形式的持久化数据存储,很有可能是在另一台机器上的数据库。如果多个微服务实例运行在多台机器上,但只有一台主机在运行数据库实例,那么数据库依然是一个单点故障源
11.7.5 基于worker的系统
负载均衡不是服务的多个实例分担负载和降低脆弱性的唯一方式。根据操作性质的不同,基于 worker 的系统可能和负载均衡一样有效。在这里,所有的实例工作在一些共享的待办作业列表上。列表里可能是一些 Hadoop 的进程,或者是共享的作业队列上的一大批监听器。这些类型的操作非常适合批量或异步作业。比如像图像缩略图处理、发送电子邮件或生成报告这样的任务。
该模型同样适用于负载高峰,你可以按需增加额外的实例来处理更多的负载。只要作业队列本身具有弹性,该模型就可以用于改善作业的吞吐量,也可以改善其弹性,因为它很容易应对 worker 故障(或 worker 不存在)带来的影响。作业有可能需要更长的时间,但不会丢失。
我在一些组织中看到过这种方式且工作得很好,这些组织在一天的某些时候会有大量未使用的计算能力。例如,在半夜你可能不需要很多机器运行电子商务系统,因此可以暂时使用它们来运行生成报告任务的 worker。
使用基于 worker 的系统时,虽然 worker 本身不需要很高的可靠性,但保存待办作业列表的系统时需要。你可以使用一个持久化的消息代理来解决这个问题,或使用像 Zookeeper 这样的系统。如果我们使用已有的软件来做这件事情,好处是可以享用很多前人所做的努力。然而,我们仍然需要知道,如何配置和维护这些系统,使得它们具有弹性。
系统最初的架构,可能和能够应对很大负载容量的架构是不同的
你的设计应该“考虑 10 倍容量的增长,但超过 100 倍容量时就要重写了”。在某些时刻,你需要做一些相当激进的事情,以支持负载容量增加到下一个级别
重新设计可能意味着拆分现有的单块系统
或可能意味着挑选新的数据存储方式,以便更好地应对负载
还可能意味着采用新的技术,例如从同步请求 / 响应转换成基于事件的系统,采用新的部署平台,改变整个技术栈,或所有介于这些之间的方案。
有人将到一定比例阈值时需要重新设计架构作为从一开始就构建大规模系统的理由,这是很危险的,甚至可能是灾难性的。在开始一个新项目时,我们往往不知道真正想要构建的是什么,也不知道它是否会成功。我们需要快速实验,并以此了解需要构建哪些功能。如果在前期为准备大量的负载而构建系统,将在前期做大量的工作,以准备应对也许永远不会到来的负载,同时耗费了本可以花在更重要的事情上的精力,例如,理解是否真有人会使用我们的产品。Eric Ries1 讲述了一个故事,他花了六个月的时间构建了一个产品,却压根没有人下载。他反思说,他本可以在网页上放一个链接,当有人点击时返回 404,以此来检验是否真的有这样的需求。与此同时他可以在海滩上度过六个月,并且这种方式跟花六个月构建产品学到的知识是一样多的!
11.8 扩展数据库
11.8.1 服务的可用性和数据的持久性
对于所有写入数据库的数据,我可以将一份副本存储到一个弹性文件系统。如果数据库出现故障,数据不会丢失,因为有一个副本,但数据库本身是不可用的,这会使我们的微服务也不可用。一个更常用的模式是使用副本。把写入主数据库的所有数据,都复制到备用副本数据库。如果主数据库出现故障,我的数据是安全的,但如果没有一个机制让主数据库恢复或提升副本为主数据库,即使数据是安全的,数据库依然不可用。
11.8.2 扩展读取
很多服务都是以读取数据为主的。例如保存我们出售物品信息的目录服务。添加新物品记录是相当不规律的,如果说每笔写入的目录数据都有 100 次以上的读取,这个数字不会让你感到惊讶。令人高兴的是,扩展读取要比扩展写入更容易。缓存的数据在这里可以发挥很大的作用,我们稍后会进行更深入的讨论。另一种模式是使用只读副本。
在像 MySQL 或 Postgres 这样的 RDBMS(Relational Database Management System,关系型数据库管理系统)中,数据可以从主节点复制到一个或多个副本。这样做通常是为了确保有一份数据的备份以保证安全,但我们也可以用它来分发读取。正如我们在图 11-6 中看到的,服务可以在单个主节点上进行所有的写操作,但是读取被分发到一个或多个只读副本。从主数据库复制到副本,是在写入后的某个时刻完成的,这意味着使用这种技术读取,有时候看到的可能是失效的数据,但是最终能够读取到一致的数据,这样的方式被称为最终一致性。如果你能够处理暂时的不一致,这是一个相当简单和常见的用来扩展系统的方式。
使用只读副本来扩展读取
几年前,使用只读副本进行扩展风靡一时,不过现在我建议你首先看看缓存,因为它可以提供更显著的性能改善,而且工作量往往更少。
11.8.3 扩展写操作
一种方法是使用分片。采用分片方式,会存在多个数据库节点。当你有一块数据要写入时,对数据的关键字应用一个哈希函数,并基于这个函数的结果决定将数据发送到哪个分片。举一个非常简单的(实际上是很糟的)的例子,你可以想象将客户记录 A~M 写到一个数据库实例,而 N~Z 写到另一个数据库实例。你可以在应用程序里管理这部分逻辑,但一些数据库,例如 Mongo,已经帮你处理了很多。
分片写操作的复杂性来自于查询处理。查找单个记录是很容易的,因为可以应用哈希函数找到数据应该在哪个实例上,然后从正确的分片获取它。但如果查询跨越了多个节点呢?例如,查找所有年满 18 岁的顾客。如果你要查询所有的分片,要么需要查询每个分片,然后在内存里进行拼接,要么有一个替代的读数据库包含所有的数据集。跨分片查询往往采用异步机制,将查询的结果放进缓存。例如,Mongo 使用 map/reduce 作业来执行这些查询。
越来越多的系统支持在不停机的情况下添加额外的分片,而重新分配数据会放在后台执行;例如,Cassandra 在这方面就处理得很好。不过,添加一个分片到现有的集群依然是有风险的,因此你需要确保对它进行了充分的测试。
写入分片可能会扩展写容量,但不会提高弹性。如果客户记录 A~M 总是去实例 X,那么当实例 X 不可用时,A~M 的记录依然无法访问。Cassandra 在这方面提供额外的功能,可以确保数据在一个环(ring,Cassandra 的术语,来描述它的节点集合)内复制到多个节点。
扩展数据库写操作非常棘手,而各种数据库在这方面的能力开始真正分化。我经常看到,当人们无法轻松地扩展现有的写容量时,才改变数据库技术。如果这发生在你身上,买一个大点的机器往往是快速解决这个问题的方法,但长远来看,你可能需要看看像 Cassandra、Mongo 或者 Riak 这样的数据库系统,它们不同的扩展模型能否给你提供一个长期的解决方案。
11.8.4 共享数据库基础设施
某些类型的数据库,例如传统的 RDBMS,在概念上区分数据库本身和模式(schema)。这意味着,一个正在运行的数据库可以承载多个独立的模式,每个微服务一个。这可以有效地减少需要运行系统的机器的数量,从这一点来说它很有用,不过我们也引入了一个重要的单点故障。如果该数据库的基础设施出现故障,它会影响多个微服务,这可能导致灾难性故障。如果你正以这样的方式配置数据库,请确保慎重考虑了风险,并且确定该数据库本身具有尽可能高的弹性。
11.8.5 CQRS
CQRS( Command-Query Responsibility Segregation,命令查询职责分离)模式,是一个存储和查询信息的替代模型。传统的管理系统中,数据的修改和查询使用的是同一个系统。使用 CQRS 后,系统的一部分负责获取修改状态的请求命令并处理它,而另一部分则负责处理查询。
收到的命令会请求状态的变化,如果这些命令验证有效,它们将被应用到模型。命令应该包含与它们意图相关的信息。它们可以以同步或异步的方式处理,允许扩展不同的模型来处理;例如,我们可以只是将请求排进队列,之后再处理它们。
这里的关键是,内部用于处理命令和查询的模型本身是完全独立的。例如,我可能选择把命令作为事件,只是将命令列表存储在一个数据存储中(这一过程称为事件溯源,event sourcing)。我的查询模型可以查询事件库,从存储的事件推算出领域对象的状态,或只是从系统的命令部分获取一个聚合,来更新其他不同类型的存储。在许多方面,我们得到跟之前讨论的只读副本方式同样的好处,但 CQRS 中的副本数据,不需要和处理数据修改的数据存储相同。
这种形式的分离允许不同类型的扩展。我们系统的命令和查询部分可能是在不同的服务或在不同的硬件上,完全可以使用不同类型的数据存储。这解锁了处理扩展的大量方法。你甚至可以通过实现不同的查询方式来支持不同类型的读取格式,比如支持图形展示的数据格式,或是基于键 / 值形式的数据格式。
但要提醒大家一句:相对于单一数据存储处理所有的 CRUD 操作的模式,这种模式是一个相当大的转变。我见过不止一个经验丰富的开发团队在纠结如何正确地使用这一模式!
11.9 缓存
缓存是性能优化常用的一种方法,通过存储之前操作的结果,以便后续请求可以使用这个存储的值,而不需花时间和资源重新计算该值。通常情况下,缓存可以消除不必要的到数据库或其他服务的往返通信,让结果返回得更快。如果使用得当,它可以带来巨大的性能好处。HTTP 在处理大量请求时,伸缩性如此良好的原因就是内置了缓存的概念。
即使对一个简单的单块 Web 应用程序来说,你也可以选择在很多不同的地方,使用多种不同的方式进行缓存。在微服务的架构下,每个服务都有自己的数据源和行为,对于在何处以及如何缓存,我们有更多的选择。对于一个分布式系统,通常认为缓存可以放在客户端或服务端。
11.9.1 客户端、代理和服务器端缓存
使用客户端缓存的话,客户端会存储缓存的结果。由客户端决定何时(以及是否)获取最新副本。理想情况下,下游服务将提供相应的提示,以帮助客户端了解如何处理响应,因此客户端知道何时以及是否需要发送一个新的请求。代理服务器缓存,是将一个代理服务器放在客户端和服务器之间。反向代理或 CDN(Content Delivery Network,内容分发网络),是很好的使用代理服务器缓存的例子。服务器端缓存,是由服务器来负责处理缓存,可能会使用像 Redis 或 Memcache 这样的系统,也可能是一个简单的内存缓存。
哪种缓存最合理取决于你正在试图优化什么。
客户端缓存可以大大减少网络调用的次数,并且是减少下游服务负载的最快方法之一。但是使用由客户端负责缓存这种方式,如果你想改变缓存的方式,让大批的消费者全都变化是很困难的。让过时的数据失效也比较棘手,尽管我们会在稍后讨论一些应对机制。
使用代理服务器缓存时,一切对客户端和服务器都是不透明的。这通常是增加缓存到现有系统的一个非常简单的方法。如果代理服务器被设计成对通用的流量进行缓存,它也可以缓存多个服务。一个常见的例子是,反向代理 Squid 或 Varnish,它们可以缓存任何 HTTP 通信。在客户端和服务器间加入代理服务器,会引入额外的网络跳数(network hops),虽然以我的经验来说,它很少会导致出现问题,因为缓存本身的性能优化已经超过了其他额外的网络开销。
使用服务器缓存,一切对客户端都是不透明的,它们不需要关心任何事情。缓存在服务器外围或服务器限界内时,很容易了解一些类似数据是否失效这样的事情,还可以跟踪和优化缓存命中率。在你有多种类型客户端的情况下,服务器缓存可能是提高性能的最快方式。
我工作过的每一个面向公众的网站,最终都是混合使用这三种方法。不过对于几个分布式系统,我没有使用任何缓存。所有这些都取决于你需要处理多少负载,对数据及时性有多少要求,以及你的系统现在能做什么。知道你有几个不同的工具,这只是一个开始而已。
11.9.2 HTTP缓存
HTTP 提供了一些非常有用的控制手段,帮助我们在客户端或服务器端缓存
首先,使用 HTTP,我们可以在对客户端的响应中使用 cache-control 指令。这些指令告诉客户他们是否应该缓存资源,以及应该缓存几秒。我们还可以设置 Expires 头部,它不再指定一段内容应该缓存多长时间,而是指定一个日期和时间,资源在该日期和时间后被认为失效,需要再次获取。你共享的资源本质,决定了哪一种方法最为合适。标准的静态网站内容,像 CSS 和图片,通常很适合使用简单的 cache-control TTL(Time To Live,生存时间值)。另一方面,如果你事先知道什么时候会更新一个新版本的资源,设置 Expires 头部将更有意义。以上两种方法都非常有用,客户端甚至无需发请求给服务器。
除了 cache-control 和 Expires,我们在 HTTP 的兵器库里还有另一种选择:实体标签(Entity Tags)或称为 Etag。ETag 用于标示资源的值是否已改变。如果我更新了客户记录,虽然访问资源的 URI 相同,但值已经不同,所以我会改变 ETag。有一种非常强大的请求方式叫作条件 GET。当发送一个 GET 请求时,我们可以指定附加的头告诉服务器,只有满足某些条件时才会返回资源。
例如,假如我们想要获取一个客户的记录,其返回的 ETag 是 o5t6fkd2sa。稍后,也许因为 cache-control 指令告诉我们这个资源可能已经失效,所以我们想确保得到最新的版本。当发出后续的 GET 请求,我们可以发送一个 If-None-Match:o5t6fkd2sa。这个条件判断请求告诉服务器,如果 ETag 值不匹配则返回特定 URI 的资源。如果我们的已经是最新版本,服务器会直接返回响应 304(未修改),告诉客户端缓存的已经是最新版本。如果有可用的新版本,我们会得到响应 200 OK、更新后的资源以及新的 ETag。
在这样广泛使用的规范里内置了这些控制手段,这意味着可以利用大量已存在的软件,来帮助我们处理缓存。像 Squid 或 Varnish 这样的反向代理服务器可以位于客户端和服务器间的网络上,可以按需存储缓存内容和使内容过期。这些系统旨在快速处理大量的并发请求,并且它们也是面向公众网站扩展的一种标准方式。像 AWS 的 CloudFront 或 Akamai 这样的 CDN,可以把请求路由到调用附近的缓存,以确保通信不会跨越半个地球。简单地说,HTTP 客户端库和客户端缓存可以帮我们做大量的工作。
ETag、Expires 和 cache-control 会有一些重叠,如果你决定全部使用它们,那么最终有可能会得到相互矛盾的信息!关于各种方式的优点的深入讨论,可以看一下《REST 实战》,或阅读 HTTP 1.1 规范的第 13 章(http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.3),它们描述了客户端和服务器应该如何实现这些不同的控制手段。
无论你是否决定使用 HTTP 作为服务间通信的协议,客户端缓存和减少客户端与服务器之间不必要的通信,都是值得尝试的措施。如果你决定选择一个不同的协议,请了解何时以及如何为客户提供提示,以帮助其理解可以缓存的时间。
11.9.3 为写使用缓存
你会发现尽管自己经常在读取时使用缓存,但在一些用例中,为写使用缓存也是有意义的。例如,如果你使用后写式(writebehind)缓存,可以先写入本地缓存中,并在之后的某个时刻将缓存中的数据写入下游的、可能更规范化的数据源中。当你有爆发式的写操作,或同样的数据可能会被写入多次时,这是很有用的。后写式缓存是在缓冲可能的批处理写操作时,进一步优化性能的很有用的方法。
使用后写式缓存,如果对写操作的缓冲做了适当的持久化,那么即使下游服务不可用,我们也可以将写操作放到队列里,然后当下游服务可用时再将它们发送过去。
11.9.4 为弹性使用缓存
缓存可以在出现故障时实现弹性。使用客户端缓存,如果下游服务不可用,客户端可以先简单地使用缓存中可能失效了的数据。我们还可以使用像反向代理这样的系统提供的失效数据。对一些系统来说,使用失效但可用的数据,比完全不可用的要好,不过这需要你自己做出判断。显然,如果我们没有把请求的数据放在缓存中,那么可做的事情不多,但还是有一些方法的。
我曾经在《卫报》中见过一种技术,随后在其他地方也见过,就是定期去爬(crawl)现有的工作的网站,生成一个可以在意外停机时使用的静态网站。虽然这个爬下来的版本不比工作系统的缓存内容新,但在必要时,它可以确保至少有一个版本的网站可以显示。
11.9.5 隐藏源服务
使用普通的缓存,如果请求缓存失败,请求会继续从数据源获取最新的数据,请求调用会一直等到结果返回。在普通情况下,这是期望的行为。但是,如果遭受大量的请求缓存失败,也许是因为提供缓存的整个机器(或一组机器)宕掉,大量的请求会被发送到源服务。
对于那些提供高度可缓存数据的服务,从设计上来讲,源服务本身就只能处理一小部分的流量,因为大多数请求已经被源服务前面的缓存处理了。如果我们突然得到一个晴天霹雳的消息,由于整个缓存区消失了,源服务就会接收到远大于其处理能力的请求。
在这种情况下,保护源服务的一种方式是,在第一时间就不要对源服务发起请求。相反,如图 11-7 所示,在需要时源服务本身会异步地填充缓存。如果缓存请求失败,会触发一个给源服务的事件,提醒它需要重新填充缓存。所以如果整个分片消失了,我们可以在后台重建缓存。可以阻塞请求直到区域被重新填充,但这可能会使缓存本身的争用,从而导致一些问题。更合适的是,如果想优先保持系统的稳定,我们可以让原始请求失败,但要快速地失败。
保护源服务,在后台异步重建缓存
在某些情况下这种方法可能没有意义,但当系统的一部分发生故障时,它是确保系统仍然可用的一种方式。让请求快速失败,确保不占用资源或增加延迟,我们避免了级联下游服务导致的缓存故障,并给自己一个恢复的机会。
1.9.6 保持简单
避免在太多地方使用缓存!在你和数据源之间的缓存越多,数据就越可能失效,就越难确定客户端最终看到的是否是最新的数据。这在一个涉及多个服务的微服务架构调用链中,很有可能产生问题。再强调一次,缓存越多,就越难评估任何数据的新鲜程度。所以如果你认为缓存是一个好主意,请保持简单,先在一处使用缓存,在添加更多的缓存前慎重考虑!
11.9.7 缓存中毒:一个警示
使用缓存时,我们经常认为最糟糕的事情是,我们会在一段时间内使用到失效数据。但如果发现你会永远使用失效数据,该怎么办?
当考虑在一个面向公众的 Web 应用程序中提供内容服务时,在你和客户间可能存在多个缓存。可能不仅你在网站上使用 CDN,有些 ISP 也会使用缓存。你可以控制这些缓存吗?即使你可以,还有一个缓存是你无法控制的:用户浏览器中的缓存。
缓存可以很强大,但是你需要了解数据从数据源到终点的完整缓存路径,从而真正理解它的复杂性以及使它出错的原因。
11.10 自动伸缩
如果你足够幸运,可以完全自动化地创建虚拟主机以及部署你的微服务实例,那么你已经具备了对微服务进行自动伸缩的基本条件。
众所周知的趋势有可能会触发伸缩的发生。可能系统的负载高峰是从上午 9 点到下午 5 点,因此你可以在早上 8:45 启动额外的实例,然后在下午 5:15 关掉这些你不再需要的实例,以节省开支
另一方面,你可以响应式地进行负载调整,比如在负载增加或某个实例发生故障时,来增加额外的实例,或在不需要时移除它们。关键是要知道一旦发现有上升的趋势,你能够多快完成扩展。如果你只能在负载增加的前几分钟得到消息,但是扩展至少需要 10 分钟,那么你需要保持额外的容量来弥合这个差距。良好的负载测试套件在这里是必不可少的。你可以使用它们来测试自动伸缩规则。如果没有测试能够重现触发伸缩的不同负载,那么你只能在生产环境上发现规则的错误,但这时的后果不堪设想!
新闻网站是一个很好的混合使用预测型伸缩和响应型伸缩的例子。在我上个工作过的新闻网站上,能清楚地看到其日常趋势,从早晨一直到午餐时间负载上升,随后开始下降。这种模式每天都在重复,但在周末流量波动则不太明显。这呈现给你相当明显的趋势,可以据此对资源进行主动扩容(缩容)。另一方面,一个大新闻可能会导致意外的高峰,在短时间内需要更多的容量。
事实上相比响应负载,自动伸缩被更多应用于响应故障。
响应型伸缩和预测型伸缩都非常有用,如果你使用的平台允许按需支付所使用的计算资源,它们可以节省更多的成本。但这也需要仔细观察你提供的数据。我建议,首先在故障的情况下使用自动伸缩,同时收集数据。一旦你想要为负载伸缩,一定要谨慎不要太仓促缩容。在大多数情况下,手头有多余的计算能力,比没有足够的计算能力要好得多!
11.11 CAP定理
在分布式系统中有三方面需要彼此权衡:一致性(consistency)、可用性(availability)和分区容忍性(partition tolerance)。具体地说,这个定理告诉我们最多只能保证三个中的两个。
AP 可用性 分区容忍 CP 一致性 分区容忍
一致性是当访问多个节点时能得到同样的值。 可用性意味着每个请求都能获得响应。 分区容忍性是指集群中的某些节点在无法联系后,集群整体还能继续进行服务的能力。
自从 Eric Brewer 发表了他的初始猜想后,这个想法得到了数学证明
CAP 定理背后是一套严密的逻辑推理
CAP 定理背后的思想
假设我们的库存服务部署在两个独立的数据中心。我们在每个数据中心的服务实例都有一个数据库支持,并且这两个数据库通过彼此通信进行数据同步。读和写操作都通过本地数据库节点,然后使用副本对不同节点之间的数据进行同步。
使用双主数据库彼此通信来进行数据同步
当出现失败后会发生什么。想象一个简单的场景,比如两个数据中心之间的网络断开了。此时同步会失败,对主数据库 DC1 的写入操作不会传送到 DC2 上,反之亦然。大多数数据库支持一些设置和某种队列技术,以确保之后我们可以恢复,但在此期间会发生什么呢?
11.11.1 牺牲一致性
假设我们完全不停用库存服务。如果现在我更改了 DC1 上的数据,DC2 的数据库将看不到它。这意味着,任何访问我们在 DC2 上库存节点的请求,看到的可能是已经失效的数据。换句话说,我们的系统仍然可用,两个节点在系统分区之后仍然能够服务请求,但失去了一致性。这通常被称为一个 AP 系统。我们无法保证所有的这三个方面。
在这种分区情况下,如果我们继续接受写操作,那就需要接受这样的一个事实,在将来的某个时候它们不得不重新同步。分区持续的时间越长,这个重新同步就会越困难。
现实情况是,即使我们没有数据库节点之间的网络故障,数据复制也不是立即发生的。正如前面提到的,系统放弃一致性以保证分区容忍性和可用性的这种做法,被称为最终一致性;也就是说,我们希望在将来的某个时候,所有节点都能看到更新后的数据,但它不会马上发生,所以我们必须清楚用户将看到失效数据的可能性。
11.11.2 牺牲可用性
如果我们需要保证一致性,相反想要放弃其他方面,会发生什么呢?好吧,为了保证一致性,每个数据库节点需要知道,它所拥有的数据副本和其他数据库节点中的数据完全相同。现在在分区情况下,如果数据库节点不能彼此通信,则它们无法协调以保证一致性。由于无法保证一致性,所以我们唯一的选择就是拒绝响应请求。换句话说,我们牺牲了可用性。系统是一致的和分区容忍的,即 CP。在这种模式下,我们的服务必须考虑如何做功能降级,直到分区恢复以及数据库节点之间可以重新同步。
保持多个节点之间的一致性是非常困难的。有些事情(可能所有的)在分布式系统中会更困难。请想一下,假设我想从本地数据库节点读取一条记录,如何确定它是最新的?我必须去询问另一个节点。但我不得不要求不允许更新数据库节点,直到读取完成;换句话说,我需要启动一个事务,跨多个数据库节点读取以确保一致性。但是一般读取时不使用事务,不是吗?因为事务性读取很慢。它们需要锁。一个读取可以阻塞整个系统。系统所有的一致性都需要一定程度的锁才能完成。
我们已经讨论过,分布式系统一定会出现失败的情况。考虑跨一组一致性节点的事务性读取。我要求在启动读取时,远程节点锁定给定的记录。等我完成读取后,告诉远程节点释放锁,但在这时我发现节点之间的通信失败了,现在怎么办?即使是在单个进程的系统中,锁都很容易出错,在分布式系统中当然就更难做好了。
还记得我们在第 5 章讨论的分布式事务吗?它们很具有挑战性,核心原因是需要确保多个节点的一致性问题。
让多节点实现正确的一致性太难了,我强烈建议如果你需要它,不要试图自己发明使用的方式。相反,选择一个提供这些特性的数据存储或锁服务。例如 Consul(我们很快就会讨论到),设计实现了一个强一致性的键 / 值存储,在多个节点之间共享配置。就像“人们不会让好朋友实现自己的加密算法”,现在成为“人们不会让好朋友实现自己的分布式一致性数据存储”。如果你认为需要实现自己的 CP 数据存储,首先请阅读完所有相关的论文,然后再拿一个博士学位,最后准备几年的时间来试错。与此同时,我会使用一些合适的、现成的工具,或者放弃一致性,去努力构建一个最终一致性的 AP 系统。
1.11.3 牺牲分区容忍性
我们要挑选 CAP 中的两点,对吗?所以,我们有最终一致的 AP 系统。我们有一致的,但很难实现和扩展的 CP 系统。为什么没有 CA 系统呢?嗯,我们应如何牺牲分区容忍性呢?如果系统没有分区容忍性,就不能跨网络运行。换句话说,需要在本地运行一个单独的进程。所以,CA 系统在分布式系统中根本是不存在的。
11.11.4 AP还是CP
哪个是正确的,AP 还是 CP ?好吧,现实中要视情况而定。因为我们知道,在人们构建系统的过程中需要权衡。我们知道 AP 系统扩展更容易,而且构建更简单,而 CP 系统由于要支持分布式一致性会遇到更多的挑战,需要更多的工作。但我们可能不了解这种权衡对业务的影响。对于库存系统,如果一个记录过时了 5 分钟,这可接受吗?如果答案是肯定的,那么解决方案可以是一个 AP 系统。但对于银行客户的余额来说呢?能使用过时的数据吗?如果不了解操作的上下文,我们无法知道正确的做法是什么。了解 CAP 定理只是让你知道这些权衡的存在,以及需要问什么问题。
11.11.5 这不是全部或全不
我们的系统作为一个整体,不需要全部是 AP 或 CP 的。目录服务可能是 AP 的,因为我们不太介意过时的记录。但库存服务可能需要是 CP 的,因为我们不想卖给客户一些没有的东西,然后不得不道歉。
个别服务甚至不必是 CP 或 AP 的。
让我们考虑一下积分账户服务,那里存储了客户已经积攒的忠诚度积分的记录。我们可以不在乎显示给客户的余额是失效的,但当涉及更新余额时,我们必须保证一致性,以确保客户不会使用比他们实际拥有的更多的积分。这个微服务是 CP 还是 AP 的,还是两个都是?事实上,我们所做的是把关于 CAP 定理的权衡,推到单独服务的每个功能中去。
另一种复杂性是,即使对于一致性或可用性而言,也可以有选择地部分采用。许多系统允许我们更精细地做权衡。例如,Cassandra 允许为每个调用做不同的权衡。因此如果需要严格的一致性,我可以在执行一个读取时,保持其阻塞直到所有副本回应确认数据是一致的,或直到特定数量的副本做出回应,或仅仅是一个节点做出回应。显然,如果我保持阻塞直到所有副本做出回应,那么当其中一个不可用时,我会被阻塞很长一段时间。但是如果我满足于只需要一个节点做出回应,接受缺乏一些一致性,这样可以降低一个副本不可用所导致的影响。
你会经常看到关于有人打破 CAP 定理的文章。其实他们并没有,他们所做的其实是创建一个系统,其中有些功能是 CP 的,有些是 AP 的。CAP 定理背后有相应的数学证明。尽管在学校尝试过多次,但最终我不得不承认数学规律是无法打破的。
11.11.6 真实世界
我们讨论过的大部分,是电子世界内存中存储的比特和字节。我们以近乎小孩子的方式谈论一致性,想象在所构建系统的范围内,可以使世界停止,让一切都有意义。然而,我们所构建的只是现实世界的一个映射,有些也是我们无法控制的,对吗?
让我们重新考虑一下库存系统,它会映射到真实世界的实体物品。我们在系统里记录了专辑的数量,在一天开始时,有 100 张 The Brakes 的 Give Blood 专辑。卖了一张后,剩 99 张。很简单,对吧?但如果订单在派送的过程中,有人不小心把一张专辑掉到地上并且被踩坏了,现在该怎么办?我们的系统说 99 张,但货架上是 98 张。
如果我们要让库存系统保持 AP,然后不得不偶尔需要与某个用户联系,告诉他已购买的一个专辑实际上缺货,这种体验如何?这会是世界上最糟糕的事情吗?事实上,这样做更容易构建系统及对其进行扩容,同时保证其正确性。
我们必须认识到,无论系统本身如何一致,它们也无法知道所有可能发生的事情,特别是我们保存的是现实世界的记录。这就是在许多情况下,AP 系统都是最终正确选择的原因之一。除了构建 CP 系统的复杂性外,它本身也无法解决我们面临的所有问题。
11.12 服务发现
一旦你已经拥有不少微服务,关注点就会不可避免地转向它们究竟在何处
所有我见过的解决方案,都会把事情分成两部分进行处理。首先,它们提供了一些机制,让一个实例注册并告诉所有人:“我在这里!”其次,它们提供了一种方法,一旦服务被注册就可以找到它。然后,当考虑在一个不断销毁和部署新实例的环境中,服务发现会变得更复杂。
常见的服务发现解决方案
DNS
最好先从简单的开始。DNS 让我们将一个名称与一个或多个机器的 IP 地址相关联。例如,我们可以决定,总能在 accounts.musiccopr.com 上发现账户服务。接着会将这个域名关联到运行该服务的主机的 IP 地址上,或者关联到一个负载均衡器,然后给不同的实例分发负载。这意味着,我们不得不把更新这些条目作为部署服务的一部分。
当处理不同环境中的服务实例时,我见过的一个很好的方式是使用域名模板。例如,我们可以使用一个形如“< 服务名 >-< 环境 >.musiccorp.com”的模板,然后基于此模板生成 accounts-uat.musiccorp.com 或 accounts-dev.musiccorp.com 这样的域名项。
处理不同环境的更先进的方式是,在不同的环境中使用不同的域名服务器。所以我可以假定,总是可以通过 accounts.musiccorp.com 找到账户服务,但根据其所处环境的不同,可能会解析到不同的主机上。如果你已经将环境放进不同的网段,并且可以很容易地管理 DNS 服务器和条目,这可能是相当简洁的解决方式,但如果你不能从这种设置中获取更多其他的好处,相对来说这个投入就太大了。
DNS 有许多优点,其中最主要的优点是它是标准的,并且大家对这个标准都非常熟悉,几乎所有的技术栈都支持它。不幸的是,尽管有很多服务可以管理组织内的 DNS,但其中很少是为处理这种高度可控制主机的场景而设计的,这使得更新 DNS 条目有些痛苦。亚马逊的 Route53 服务在这方面确实做得不错,但在可选的自托管服务中,还没有像它一样好的,虽然(我们很快就会讨论)Consul 在这方面可能会提供一些帮助。除了更新 DNS 条目存在的问题,DNS 规范本身也会导致一些问题。
域名的 DNS 条目有一个 TTL。客户端可以认为在这个时间内该条目是有效的。当我们想要更改域名所指向的主机时,需要更新该条目,但不得不假定客户至少在 TTL 所指示的时间内持有旧的 IP。DNS 可以在多个地方缓存条目(甚至 JVM 也会缓存 DNS 条目,除非你告诉它不要这么做),它们被缓存的地方越多,条目就越可能会过时。
绕过这个问题的一种方法是,如图 11-9 所示,让你的域名条目指向负载均衡器,接着由它来指向服务实例。当你部署一个新的实例时,可以从负载均衡器中移除旧的实例,并添加新的实例。有些人使用 DNS 轮询调度,DNS 条目会指向一组机器。这种技术存在很严重的问题,因为底层主机对客户端是不可见的,因此当某个主机有问题时,很难停止对该主机的请求路由。
使用 DNS 指向负载均衡器,以避免失效 DNS 条目的问题
当你只有单个节点时,使用 DNS 直接引用主机就可以了。但对于那些有多个主机实例的情况,应该将 DNS 条目解析到负载均衡器,它可以正确地把单个主机移入和移出服务。
11.13 动态服务注册
作为一种在高度动态的环境发现节点的方法,DNS 存在一些缺点,从而催生了大量的替代系统,其中大部分包括服务注册和一些集中的注册表,注册表进而可以提供查找这些服务的能力。通常,这些系统所做的不仅仅是服务注册和服务发现,这可能也不是一件好事。这是一个拥挤的领域,因此只看其中几个选项,让你大概了解一下有哪些选择可用。
11.13.1 Zookeeper
Zookeeper(http://zookeeper.apache.org/)最初是作为 Hadoop 项目的一部分进行开发的。它被用于令人眼花缭乱的众多使用场景中,包括配置管理、服务间的数据同步、leader 选举、消息队列和命名服务(对我们有用的)。
像许多相似类型的系统,Zookeeper 依赖于在集群中运行大量的节点,以提供各种保障。这意味着,你至少应该运行三个 Zookeeper 节点。Zookeeper 的大部分优点,围绕在确保数据在这些节点间安全地复制,以及当节点故障后仍能保持一致性上。
Zookeeper 的核心是提供了一个用于存储信息的分层命名空间。客户端可以在此层次结构中,插入新的节点,更改或查询它们。此外,它们可以在节点上添加监控功能,以便当信息更改时节点能够得到通知。这意味着,我们可以在这个结构中存储服务位置的信息,并且可以作为一个客户端来接收更改消息。Zookeeper 通常被用作通用配置存储,因此你也可以存储与特定服务相关的配置,这可以帮助你完成类似动态更改日志级别,或关闭正在运行的系统特性这样的任务。我个人倾向于不使用 Zookeerp 这样的系统作为配置源,因为我认为这使得在给定服务中定位变得更加困难。
Zookeeper 本身所提供的特性是相当通用的,这就是它适用于这么多场景的原因。你可以认为它只是信息树的一个副本,当它发生更改时对你做出提醒。这意味着,你通常会在它上面构建一些功能,以适应你的特定场景。幸运的是,大多数语言都提供了客户端库。
在众多选项中,Zookeeper 可以说是比较老的,而且对比新的替代品,在服务发现方面没有提供很多现成的功能。即便如此,它还是被充分使用和测试过的,并得到了广泛使用。Zookeeper 底层算法的正确实现相当困难。例如,我知道一个数据库供应商,只使用 Zookeeper 作为 leader 选举,以确保在出现故障的情况下,能够正确提升主节点。这个客户认为 Zookeeper 太重量级了,然后自己实现了 PAXOS 算法来替换 Zookeeper,结果花费了大量时间来修复其中的缺陷。人们常说,你不应该实现自己的加密算法库。我想延伸这个说法,你也不应该实现自己的分布式协调系统。使用已有的可工作的选择是非常明智的。
11.13.2 Consul
和 Zookeeper 一样,Consul(http://www.consul.io/)也支持配置管理和服务发现。但它比 Zookeeper 更进一步,为这些关键使用场景提供了更多的支持。例如,它为服务发现提供一个 HTTP 接口。Consul 提供的杀手级特性之一是,它实际上提供了现成的 DNS 服务器。具体来说,对于指定的名字,它能提供一条 SRV 记录,其中包含 IP 和端口。这意味着,如果系统的一部分已经在使用 DNS,并且支持 SRV 记录,你就可以直接开始使用 Consul,而无需对现有系统做任何更改。
Consul 还内置了一些你可能觉得有用的其他功能,比如,对节点执行健康检查的能力。这意味着,Consul 提供的功能与其他专门的监测工具提供的有重叠,虽然你更可能使用 Consul 作为这些信息的数据源,然后把它放到更全面的仪表板或报警系统中。不过,Consul 的高容错设计,以及在处理大量使用临时节点系统方面的专注,确实让我好奇是否在一些场景下,它最终可以取代像 Nagios 和 Sensu 这样的系统。
Consul 从注册服务、查询键 / 值存储到插入健康检查,都使用的是 RESTful HTTP 接口,这使集成不同技术栈变得非常简单。另一个让我非常喜欢的事情是,Consul 背后的团队把底层集群管理拆分了出来。Consul 底层的 Serf 可以处理集群中的节点监测、故障管理和报警。然后 Consul 在其之上添加了服务发现和配置管理。这种关注点分离的做法很吸引我,这应该不会让你感到奇怪,因为这个主题贯穿本书!
Consul 很新,鉴于它使用算法的复杂性,我通常犹豫是否要推荐用它来完成这种重要的工作。尽管如此,Hashicorp,其背后的团队,确实在创建非常有用的开源技术方面有很好的记录(Packer 和 Vagrand),这个项目还在积极发展中,我也和几个在生产环境上使用过它的人聊过。鉴于此,我认为它很值得一看。
11.13.3 Eureka
Netflix 的开源系统 Eureka(https://github.com/Netflix/eureka),追随 Consul 和 Zookeeper 等系统的趋势,但它没有尝试成为一个通用配置存储。实际上,它有非常确定的目标使用场景。
Eureka 还提供了基本的负载均衡功能,它可以支持服务实例的基本轮询调度查找。它提供了一个基于 REST 的接口,因此你可以编写自己的客户端,或者使用它自己提供的 Java 客户端。Java 客户端提供了额外的功能,如对实例的健康检查。很显然,如果你绕过 Eureka 的客户端,直接使用 REST 接口,就可以自行实现了。
通过客户端直接处理服务发现,可以避免一个单独的进程。但每个客户端需要实现服务发现。Netflix 在 JVM 上进行了标准化,让所有的客户端都使用 Eureka 来达到这个目的。如果你在一个多语言的环境中,挑战会更大。
11.13.4 构造你自己的系统
我自己用过并且在其他地方见过的一种方法是,构造你自己的系统。曾经在一个项目上,我们大量使用AWS,它提供了将标签添加到实例的能力。当启动服务实例时,我使用标签来帮助定义实例是做什么的。这允许你关联一些丰富的元数据到给定的主机,例如:
服务 = 账户
环境 = 生产
版本 = 154
我可以使用 AWS 的 API,来查询与给定 AWS 账户相关联的所有实例,找到所关心的机器。在这里,AWS 本身处理与每个实例相关联的元数据的存储,并为我们提供查询的能力。然后我构建了命令行工具来实现与这些实例之间的交互,并构建仪表板使状态监控变得更加容易,尤其是当你让每个服务实例都提供健康检查接口时。
上一次,我没有做到使用 AWS 的 API 来发现服务依赖关系这一步,但事实上我可以做到。很明显,如果你希望当下游服务的位置发生变化时,上游服务能得到提醒,就需要自己构建系统。
11.13.5 别忘了人
到目前为止,我们看过的系统让服务实例注册自己并查找所需要通信的其他服务变得非常容易。但是我们有时也想要这些信息。无论你选择什么样的系统,要确保有工具能让你在这些注册中心上生成报告和仪表盘,显示给人看,而不仅仅是给电脑看。
11.14 文档服务
通过将系统分解为更细粒度的微服务,我们希望以 API 的形式暴露出很多接缝,人们可以用它来做很多很棒的事情。如果正确地进行了服务发现,就能够知道东西在哪里。但是我们如何知道这些东西的用处,或如何使用它们?一个明显的选择是 API 的文档。当然,文档往往会过时。理想情况下,我们会确保文档总是和最新的微服务 API 同步,并当大家需要知道服务在哪里时,能够很容易地看到这个文档。两种不同的技术,Swagger 和 HAL,试图使这成为现实,这两个都值得一看。
11.14.1 Swagger
Swagger 让你描述 API,产生一个很友好的 Web 用户界面,使你可以查看文档并通过 Web 浏览器与 API 交互。能够直接执行请求是一个非常棒的特性。例如,你可以定义 POST 模板,明确微服务期望的内容是什么样的。
11.14.2 HAL和HAL浏览器
HAL(Hypertext Application Language,超文本应用程序语言,http://stateless.co/hal_specification.html)本身是一个标准,用来描述我们公开的超媒体控制的标准。正如我们在第 4 章中提过的,超媒体控制是一种方法,它允许客户逐步探索我们的 API 来使用服务,并且其耦合度比其他集成技术都低。如果你决定采用 HAL 的超媒体标准,那么不仅可以利用广泛使用的客户端库消费 API(在撰写本文时,HAL 维基列出了多种不同语言的 50 个支持库),也可以使用 HAL 的浏览器,它提供了一种通过 Web 浏览器探索 API 的方式。
就像 Swagger,这个用户界面不仅可以充当活文档,还可以对服务本身执行调用。虽然它的执行调用并不是那么顺畅。使用 Swagger 时,你可以对像发一个 POST 请求这样的事情定义模板,而使用 HAL 时你需要自己做更多。另一方面是,超媒体控制的内在能力能够让你更有效地探索 API 公开的服务,因为就可以很容易地跟随链接。事实证明,Web 浏览器很擅长做这种事情!
跟 Swagger 不同,驱动这个文档的所有信息和沙箱都被嵌入在超媒体控制中。这是一把双刃剑。如果你已经在使用超媒体控制,几乎可以毫不费力地提供一个 HAL 浏览器,让客户探索你的 API。然而,如果没有使用超媒体,那你要么不使用 HAL,要么改造你的 API 来使用超媒体,这个行为很可能会破坏现有的消费者。
HAL 还描述了一些超媒体标准,并有相应的客户端支持库,这是一个额外的好处,也许这就是在已使用超媒体控件的人中,使用 HAL 作为 API 文档比使用 Swagger 更多的原因。如果你在使用超媒体,我更推荐使用 HAL 而不是 Swagger。但是如果你没有使用超媒体,也不能判断将来是否切换,我肯定会建议使用 Swagger。
11.15 自描述系统
在 SOA 的早期演化过程中,UDDI(Universal Description, Discovery, and Integration,通用描述、发现与集成服务)标准的出现,帮助人们理解了哪些服务正在运行。这些方法都相当重量级,并催生出一些替代技术去试图理解我们的系统。Martin Fowler 提出了人文注册表(http://martinfowler.com/bliki/HumaneRegistry.html)的概念,它是一个更轻量级的方法,在这个方法中有一个地方,可以让人们记录组织中有关服务的信息,和维基一样简单。
有一个关于我们系统行为的全景图是非常重要的,特别是在规模化后。我们已经讨论了许多不同的技术,它们会帮助我们理解系统。通过追踪下游服务的健康状态和使用关联标识,可以帮助我们识别调用链,得到关于服务如何交互的真实数据。使用像 Consul 这样的服务发现系统,可以看到我们的微服务在哪里运行。HAL 让我们在任何给定的接口上查看有哪些功能,同时健康检查页面和监控系统,让我们知道系统整体的和单个服务的健康状态。
所有这些信息都能以编程的方式使用。所有这些数据使我们的人文注册表,比一个毫无疑问会过时的简单维基页面更强大。所以我们应该使用它来显示系统发出的所有信息。通过创建自定义的仪表盘,我们可以将大量信息结合在一起,帮助我们理解生态系统。
无论如何,从活系统中抽取出一些数据,来形成静态 Web 页面或维基是一个很好的开始。随着时间的推移,获取的信息越来越多。简化这些信息的获取,是系统运行规模化后管理浮现出来的复杂性的关键工具。
在前面的章节我们已经讨论了相当多的内容,从微服务的定义到如何划分它的边界,从集成技术到安全和监控。我们甚至还探讨了微服务架构下,架构师的角色应该是什么样子的。虽然每个微服务本身很小,但是它对架构的影响却很广很大,所以还是需要学习很多东西。在本章,我会尝试总结一些贯穿全书的关键点。
12.1 微服务的原则
你可以选择全部采用这些原则,或者定制采用一些在自己的组织中有意义的部分。但请注意,组合使用这些原则的价值:整体使用的价值要大于部分使用之和。所以,如果决定要舍弃其中一个原则,请确保你明白其带来的损失。
对于每个原则,我已经在本书中尝试列出了一些支持它们的实践。俗话说的好:条条道路通罗马。你可能会使用自己的方式来实现这些原则,但下面列出的实践能够给你带来一个好的开始。
微服务的原则
12.1.1 围绕业务概念建模
经验表明,围绕业务的限界上下文定义的接口,比围绕技术概念定义的接口更加稳定。针对系统如何工作这个领域进行建模,不仅可以帮助我们形成更稳定的接口,也能确保我们能够更好地反映业务流程的变化。使用限界上下文来定义可能的领域边界。
12.1.2 接受自动化文化
微服务引入了很多复杂性,其中的关键部分是,我们不得不管理大量的服务。解决这个问题的一个关键方法是,拥抱自动化文化。前期花费一定的成本,构建支持微服务的工具是很有意义的。自动化测试必不可少,因为相比单块系统,确保我们大量的服务能正常工作是一个更复杂的过程。调用一个统一的命令行,以相同的方式把系统部署到各个环境是一个很有用的实践,这也是采用持续交付对每次提交后的产品质量进行快速反馈的一个关键部分。
请考虑使用环境定义来帮助你明确不同环境间的差异,但同时保持使用统一的方式进行部署的能力。考虑创建自定义镜像来加快部署,并且创建全自动化不可变服务器,这会更容易定位系统本身的问题。
12.1.3 隐藏内部实现细节
为了使一个服务独立于其他服务,最大化独自演化的能力,隐藏实现细节至关重要。限界上下文建模在这方面可以提供帮助,因为它可以帮助我们关注哪些模型应该共享,哪些应该隐藏。服务还应该隐藏它们的数据库,以避免陷入数据库耦合,这在传统的面向服务的架构中也是最常见的一种耦合类型。使用数据泵(data pump)或事件数据泵(event data pump),将跨多个服务的数据整合到一起,以实现报表的功能。
在可能的情况下,尽量选择与技术无关的 API,这能让你自由地选择使用不同的技术栈。请考虑使用 REST,它将内部和外部的实现细节分离方式规范化,即使是使用 RPC,你仍然可以采用这些想法。
12.1.4 让一切都去中心化
为了最大化微服务能带来的自治性,我们需要持续寻找机会,给拥有服务的团队委派决策和控制权。在这个过程初期,只要有可能,就尝试使用资源自助服务,允许人们按需部署软件,使开发和测试尽可能简单,并且避免让独立的团队来做这些事。
在这个旅程中,确保团队保持对服务的所有权是重要的一步,理想情况下,甚至可以让团队自己决定什么时候让那些更改上线。使用内部开源模式,确保人们可以更改其他团队拥有的服务,不过请注意,实现这种模式需要很多的工作量。让团队与组织保持一致,从而使康威定律起作用,并帮助正在构建面向业务服务的团队,让他们成为其构建的业务领域的专家。一些全局的引导是必要的,尝试使用共同治理模型,使团队的每个成员共同对系统技术愿景的演化负责。
像企业服务总线或服务编配系统这样的方案,会导致业务逻辑的中心化和哑服务,应该避免使用它们。使用协同来代替编排或哑中间件,使用智能端点(smart endpoint)确保相关的逻辑和数据,在服务限界内能保持服务的内聚性。
12.1.5 可独立部署
我们应当始终努力确保微服务可以独立部署。甚至当需要做不兼容更改时,我们也应该同时提供新旧两个版本,允许消费者慢慢迁移到新版本。这能够帮助我们加快新功能的发布速度。拥有这些微服务的团队,也能够越来越具有自治性,因为他们不需要在部署过程中不断地做编配。当使用基于 RPC 的集成时,避免使用像 Java RMI 提供的那种使用生成的桩代码,紧密绑定客户端 / 服务器的技术。
通过采用单服务单主机模式,可以减少部署一个服务引发的副作用,比如影响另一个完全不相干的服务。请考虑使用蓝 / 绿部署或金丝雀部署技术,区分部署和发布,降低发布出错的风险。使用消费者驱动的契约测试,在破坏性的更改发生前捕获它们。
请记住,你可以更改单个服务,然后把它部署到生产环境,无需联动地部署其他任何服务,这应该是常态,而不是例外。你的消费者应该自己决定何时更新,你需要适应他们。
12.1.6 隔离失败
微服务架构能比单块架构更具弹性,前提是我们了解系统的故障模式,并做出相应的计划。如果我们不考虑调用下游可能会失败的事实,系统会遭受灾难性的级联故障,系统也会比以前更加脆弱。
当使用网络调用时,不要像使用本地调用那样处理远程调用,因为这样会隐藏不同的故障模式。所以确保使用的客户端库,没有对远程调用进行过度的抽象。
如果我们心中持有反脆弱的信条,预期在任何地方都会发生故障,这说明我们正走在正确的路上。请确保正确设置你的超时,了解何时及如何使用舱壁和断路器,来限制故障组件的连带影响。如果系统只有一部分行为不正常,要了解其对用户的影响。知道网络分区可能意味着什么,以及在特定情况下牺牲可用性或一致性是否是正确的决定。
12.1.7 高度可观察
我们不能依靠观察单一服务实例,或一台服务器的行为,来看系统是否运行正常。相反,我们需要从整体上看待正在发生的事情。通过注入合成事务到你的系统,模拟真实用户的行为,从而使用语义监控来查看系统是否运行正常。聚合你的日志和数据,这样当你遇到问题时,就可以深入分析原因。而当需要重现令人讨厌的问题,或仅仅查看你的系统在生产环境是如何交互时,关联标识可以帮助你跟踪系统间的调用。
12.2 什么时候你不应该使用微服务
这个问题我被问过很多次了。我的第一条建议是,你越不了解一个领域,为服务找到合适的限界上下文就越难。正如我们前面所讨论的,服务的界限划分错误,可能会导致不得不频繁地更改服务间的协作,而这种更改成本很高。所以,如果你不了解一个单块系统领域的话,在划分服务之前,第一件事情是花一些时间了解系统是做什么的,然后尝试识别出清晰的模块边界。
从头开发也很具有挑战性。不仅仅因为其领域可能是新的,还因为对已有东西进行分类,要比对不存在的东西进行分类要容易得多!因此,请再次考虑首先构建单块系统,当稳定以后再进行拆分。
当微服务规模化以后,你面临的许多挑战会变得更加严峻。如果你主要采用手工的方式做事情,当只有一两个服务时还可以应对,如果是 5 到 10 个服务呢?坚持老式的监控实践,查看诸如 CPU 和内存指标的这种方式,在只有几个服务时还好,但服务间的协作越多,你就会越痛苦。随着增加更多的服务,你会发现自己在不断触及这些痛点。我希望这本书的建议,可以帮你预见其中的一些问题,并且了解一些如何解决这些问题的具体技巧。我之前说过,REA 和 Gilt 在有能力大规模使用微服务之前,花费了一定时间来构建工具和实践,帮助管理微服务。这些经历让我更加相信逐步开始的重要性,它可以帮助你了解组织改变的意愿和能力,这将有助于正确地采用微服务。
12.3 临别赠言
微服务架构会给你带来更多的选择,也需要你做更多的决策。相比简单的单块系统,在微服务的世界里,做决策是一个更为常见的活动。我可以保证,你总会在一些决策上出错。既然知道了我们难免要做一些错事,那该怎么办呢?嗯,我会建议你,尽量缩小每个决策的影响范围。这样一来,如果做错了,只会影响系统的一小部分。学会拥抱演进式架构的概念,在这种概念下,系统会在你学到一些新东西之后扩展和变化。不要去想大爆炸式的重写,取而代之的是随着时间的推移,逐步对系统进行一系列更改,这样做可以保持系统的灵活性。
希望到目前为止,我给你分享了足够多的知识和经验,能帮助你决定微服务是否适合你。如果微服务适合你,我希望你把它看作一个旅程,而不是终点。逐步前行。一块块地拆分你的系统,逐步学习。习惯这一点:从很多方面来说,持续地改变和演进系统,这条规则比我在本书中分享给你的任何一个知识都要重要。变化是无法避免的,所以,拥抱它吧!
微服务设计 Building Microservices