目录
一、什么是微服务
微服务是一种技术架构,通常我们可以把它理解为一组可以相互之间协同工作的应用程序或服务,这些应用程序或服务能够被单独部署到不同的服务器中,并且能够自主运行和维护。
微服务技术只是一个名称而已,或许我们在日常工作中已经或多或少在使用其中的一种或几种技术和架构,但我们并没有将其称之为微服务。而且,对于微服务的大小和规模,也并没有严格的限制,也许几百行代码或者数个文件,也或者比这个规模更大一点,都没有关系。我们不应该在这一点上过多地纠结,而应该更多地去关心微服务到底解决了什么问题?这才是最主要的。
二、为什么需要微服务
换句话说,微服务架构到底解决了什么问题?要回答这个问题,我们先对比一下单体架构(Monolith)与微服务架构(Microservice)。
单体架构
在单体架构中,代码通常都放在一个代码库中,这就意味着所有的开发人员都必须基于同一个源代码库来工作。另外,单体架构的应用程序通常都运行在单一服务器或虚机中,并且数据被持久化到单个数据库。其优点是:
- 开发环境比较简单,所使用的技术栈统一。
- 所有的代码和资源都在同一个代码库中,容易查找和定位错误。
- 应用程序的部署和更新操作比较容易,我们只需要在一台服务器或虚机上替换掉旧的应用程序即可。
单体架构有其自身的优点,如果你的应用程序符合这一定位并且运行良好,那么其实你完全没有必要使用微服务架构,或者说目前暂时还不需要。但是,当应用程序的规模不断增加时,问题就会逐渐被暴露出来。随着代码库变得越来越大,复杂性也会显著增加,其中积累的技术债也会越来越多,最终会导致代码变得难以维护,而且各个模块间的依赖关系也会非常复杂,往往一个小的功能调整也会涉及到许多代码的改动。
另外一个问题就是单体应用程序的更新部署往往会导致服务的中断,从而影响到整个产品的用户体验和可用性。
最后就是单体应用程序的功能扩展往往比较困难。当访问量增加时,我们除了增加服务器的配置和数量外(垂直扩展),无法简单地对程序进行横向扩展和功能扩充。这个时候你会发现,无论你采用何种技术栈,都无法摆脱单体架构本身所带来的这些问题,你所要考虑的是调整整个应用程序的架构,使其更符合敏捷开发模式的思维。
有一点值得注意的是,我们需要将传统的分布式单体架构与微服务架构区别开来。
所谓分布式单体架构,是指将组成应用程序的各个不同的模块或服务部署在不同的服务器或虚机中,它们都共享同一个中央数据库,彼此之间相互关联,形成一个紧耦合的结构。任何一个模块或服务的缺失都会导致整个应用程序无法正常工作。同时,这种结构也会给更新和部署带来挑战。
微服务架构
在微服务架构中,我们将应用程序分解成足够小的部分,每一部分可以有自己独立的源代码库并且由不同的小型开发团队来进行开发和维护。就每一个微服务而言,其开发成本相对较低,随着项目的迭代,在必要的时候也可以丢弃某些微服务或者完全改写部分微服务。
项目中的每一个微服务都可以自由选择所使用的技术和开发工具。你完全可以在其中一个微服务中使用关系型数据库,而在另一个微服务中使用文档型数据库。也可以在一个微服务中使用函数式编程语言(例如JavaScript或者Python),而在另一个微服务中使用面向对象编程语言(例如C#或Java)。每一个微服务的开发团队都可以自由选择最合适的技术。
由于微服务之间是松耦合的,每一个微服务可以单独部署和运行,这样我们就可以实现整个应用程序的零宕机更新。而且如果有需要,每一个微服务也可以随时重新发布和更新,而不影响整个应用程序的运行。
另外,根据需要我们也可以非常方便地对单个微服务进行扩展,从而将成本控制在最低。
微服务架构的这些特性更加符合敏捷开发的原则,从而使得我们可以更快地适应不断变化的业务和需求。我想这也是为什么近年来微服务架构变得如此流行的原因之一。
不过,在使用微服务架构之前,你需要首先了解下面这些你将要面对的挑战:
- 初始化并运行每个微服务,然后在整个应用程序的上下文中进行测试。由于每个微服务所使用的技术不同,运行的环境也千差万别,所以从下载源代码开始,你可能需要花费比较多的时间才能让这些微服务运行起来。考虑使用容器来自动完成所有这些预备步骤是一个不错的选择。
- 由于微服务之间的交互往往非常复杂,如果处理不当,你会很容易地陷入到多个微服务之间大量低效而冗长的通信陷阱中,这会导致整个应用程序性能低下。
- 虽然单个微服务的部署较为容易,但是当大量微服务同时工作时,手动部署和更新将变得更加低效和繁琐。因此,自动化部署将变得至关重要。
- 最后一点就是监控微服务的运行。我们当然不希望逐个地去检查每个微服务当前的运行状态,而是希望这些微服务能够自动报告它们的运行状况。因此,我们需要一种监控机制,能够让系统管理员在一个集中的地方查看所有微服务的运行日志和数据。
不过,你不用太担心,有许多开源的软件和工具能够帮忙我们解决上述这些问题。
Github上一个非常好的例子eShopOnContainers可以用来帮助我们学习和理解微服务架构。下面是这个项目的微服务架构图:
三、微服务架构
从一个已有的单体架构的应用程序开始进行微服务架构的重构往往是一个不错的选择。随着业务量和功能的增加,我们可以考虑使用微服务架构来扩充应用程序中原有的功能,或者每次添加新功能时,都为其创建一个新的微服务。这比从一开始就选择使用微服务架构进行设计要相对容易一些,因为微服务架构的好处通常不会体现在小型项目中。所以,考虑让项目持续迭代一段时间,直到我们能够非常清晰地确定服务的边界,通过微服务架构来进行功能的划分。
因此,对于每一个微服务,我们都需要明确它们各自的职责,并定义公共接口。
每个微服务管理各自的数据
前面我们已经介绍过,微服务是自治的并且可以单独运行和部署,而实现这一特性的关键就是确保每个微服务都拥有自己的数据存储。也就是说,微服务架构中不允许出现多个微服务共享同一个数据的情况。任何想要访问其它微服务中数据的情况都应该通过公共接口来完成。
你可能在想,如果没有一个中心的共享数据库,如果保证数据的一致性呢?尤其是对于事务而言,数据的一致性至关重要。在微服务架构中,我们不能在单个数据库事务中去更新来自不同的微服务中的数据,我们要么通过较为复杂的分布式事务来实现这一功能,要么通过微服务自己来保证数据的一致性。而后者是通常较为推荐的做法。这就意味着,我们无法保证在一个相对较短的时间内数据的一致性,而是需要等待一段时间之后才能获得整体状态一致的数据。具体的操作是,当其中一个微服务的数据更改时,在一个短暂的时间内其它微服务中的数据无法与其保持一致,这个时候,你需要有一种机制能够应对这种暂时性的数据非一致性,直到所有微服务中的数据最终获得一致性。在微服务中使用缓存机制是一个不错的选择,这可以大大减少从多个微服务聚合数据以执行单个操作的成本。
查看Github上的eShopOnContainers的微服务架构图,我们可以看到其中不同的微服务都有各自的数据存储。例如Identity微服务使用了SQL Server数据,Ordering微服务也使用了SQL Server数据库,但它和Identity微服务使用的不是同一个数据库,它们之间也不能相互访问。Basket微服务使用的则是完全不同的Redis缓存数据库。
现在考虑一个实际场景,Ordering微服务中有一个OrderItem实例,它代表用户的一个订单,其中包括ProductId、ProductName和UnitPrice。Catalog微服务中有一个CatalogItem实例,它代表商品信息,其中包括ID、Name和Price。当用户购买商品时,Ordering微服务中不仅会记录商品的ID,同时还会将商品的名称和价格复制到自己的数据库中。那么,如果将来Catalog微服务中的商品信息发生了变化,这个数据并不一定会及时地同步到Ordering微服务中。那么,如果我们只在Ordering微服务中存储商品ID,而当需要的时候通过API去请求Catalog微服务以获取商品的详细信息岂不是更好?不一定!
Ordering微服务中商品数据的冗余反映的是用户购买商品时的情形,它与存储在Catalog微服务中商品现在的信息并非一致。Ordering微服务并不关心商品当前的价格和名称,它关心的是用户下单时商品的价格和名称。所以事实上,这不是真正的数据冗余,这些数据在不同的微服务上下文中具备不同的含义。微服务之间的这种数据冗余以及不一致性不仅不会导致系统出现问题,相反,它是由不同的业务需求所决定的。
微服务的组成部分
微服务不一定是在单个服务器或虚拟机上运行的单个进程,它通常至少有两部分组成:一个由代码构成的WebAPI,而另一个则是数据库。所以这至少是两个不同的进程,而且它们通常并不在同一台服务器或虚拟机中运行。进一步地,如果我们对服务进行横向扩展,并同时对数据库进行分片配置,那么一个微服务甚至会在好几个不同的服务器或虚拟机中运行,而且它可能还需要某种定时任务来执行数据维护,并监听和触发各种不同的消息。因此,实际上一个微服务可能会涉及到多个不同的服务器和进程,所有的这些部分一起构成了一个微服务。
我们对比看一下eShopOnContainers的微服务架构图,其中的Ordering微服务包含一个名为Ordering.API的WebAPI,另外还有一个名为Ordering.BackgroundTasks的后台任务,这是两个独立运行的进程。我们不必让微服务的所有代码都在一个进程中运行。
从概念上讲,每个微服务及其公共接口都有明确的边界定义,数据只能通过这些公共接口访问。
每个微服务都是可独立部署的
微服务可独立部署和存在,这就意味着依赖于该微服务的客户端程序不需要同时升级。要做到这一点,你必须确保微服务的公共接口始终保持对旧客户端程序的兼容。实际上,我们通常把这些公共接口称之为微服务与客户端之间的协议,协议是不能单方面更改的。你可能会问,随着业务和需求的增长,这些公共接口怎么能永远保持不变呢?最简单的解决办法是永远只对公共接口进行增量修改,例如增加新的接口,或在已有的接口上对数据增加新的属性。
如果不可避免地要对公共接口进行重大调整,可以考虑下面两种实践方式:
一是让开发客户端的团队等待新的微服务上线之后再更新客户端程序,以确保对微服务的调整不会影响到旧客户端程序的运行。
另外一个推荐的做法是针对不同版本的客户端程序创建自动化测试,并将其加入到持续集成构建中,这样每次部署之前,如果有自动化测试未完成,构建就会失败,我们就可以查找原因并分析是否存在兼容性问题。
需要注意的一点是,有一种模式可以引入客户端和微服务都使用的共享代码,从而可以非常方便地生成一个客户端包来简化微服务的调用。我们应该尽量避免使用这种方式,不要将微服务的开发和测试与客户端紧密耦合在一起,因为这会导致客户端对微服务的强依赖而迫使它们同时升级。
如何确定微服务的边界
如何确定微服务的边界是一项比较困难的事情,因为错误的微服务边界定义会导致后期系统性能的下降,并且一旦这些微服务部署到了生产环境,后期再做调整就比较困难了。因此,在项目开始之前,值得我们花一些时间来认真考虑如何确定微服务的边界。
我们前面也介绍过,从已有的单体架构的应用程序开始进行微服务架构设计会使任务变得相对容易一些。你会发现其中有一些模块本身就与应用程序的其它部分松散耦合,它们之间通过比较清晰的接口访问数据,这些模块比较容易转换成微服务。
另外一种方式是从数据库层面着手,看看从某些概念上是否可以将部分表组合在一起,形成一个相对独立的部分。因为每个微服务都拥有自己独立的数据,所以从数据库层面抽取相对独立的部分也是一个好的想法,我们应该尽量避免从多个不同的微服务之间获取数据。
微服务始终应该围绕着业务来进行组织,这方面可以参考领域驱动设计(Domain Driven Design)的概念,它推荐从应用程序的上下文中来确定边界,并为其定义模型,这意味着不同的微服务将使用不同的模型,即使它们使用的数据看起来没什么区别,但对应的业务场景却不同。
在eShopOnContainers微服务架构中,Ordering微服务和Catalog微服务尽管都与商品信息有关,但它们处在不同的上下文中,因此它们实际上具有不同的属性,可以自由地为同一条信息使用不同的名称。
在确定微服务的边界时,你可能会遇到一些陷阱,例如对所有数据库中的业务表进行简单的包装并生成对应的CRUD服务,这些服务充其量只能称之为数据库表的实体类,而不能称之为微服务,因为它们只具有添加和更新实体的方法,而并没有将与实体相关的业务逻辑包含进来。另一个导致微服务边界模糊的情况是,当多个微服务相互之间存在循环依赖关系时,会导致频繁的相互通信,我们应该尽量避免这种情况的出现。
让我们详细了解一下eShopOnContainers微服务架构中的具体实现,来看看以上这些原则在实际应用中是如何体现的。
Catalog Microservice | 存储商品的详细信息。 |
Basket Microservice | 跟踪和存储客户的购物篮信息。 |
Ordering Microservice | 处理客户的订单信息。 |
Identity Microservice | 用于处理用户身份认证。 |
- 职责分离,有助于提高弹性。即使Ordering微服务不可用,依然不影响客户浏览商品信息并将其添加到购物车。
- 不同的微服务都被设计为处理不同的数据量和访问模式。Catalog微服务需要支持灵活的查询,以满足客户根据各种不同的查询方式来搜索到想要购买的商品。所以,Catalog微服务需要将数据存储在支持大量丰富查询的数据库中以满足业务的需要,例如这里选择了SQL Server。而Basket微服务只需要存储比较短暂的数据,这些数据甚至都不需要写入数据库,所以这里使用了Redis内存缓存来保存客户购物车中的数据。Ordering微服务用来处理用户的订单信息,因此对数据的可靠性有非常严格的要求。另外它还需要处理一些敏感数据,例如客户的收获地址和付款信息等,所以对安全性也有非常高的要求。Identity微服务用来处理身份验证,我们将在后面的微服务安全部分对其进行详细说明。
- 这不是唯一的确定微服务的方式,你可以根据不同的业务需求对其中的功能进行重新组合。就目前来看,这种设计还不错。
四、构建微服务
当我们开发微服务应用程序时,我们希望它能运行在不同的环境中,例如开发人员希望在自己工作的电脑上运行和调试代码;测试人员希望在临时搭建的测试环境中运行,当然也可能是在云上运行;当微服务正式发布之后,它会在我们的生产环境中运行。那么我们如何托管微服务,使其非常方便地运行在不同的环境中呢?
下面列出了几种不同的方式:
- 传统的方式是使用虚拟机。你可以为每一个微服务选择一台虚拟机,当然如果你的微服务数量特别多的话,这么做成本可能会比较高。你也可以选择将多个微服务打包到一台虚拟机上,但是你需要为其中的每一个微服务安装不同的框架和依赖包,这会让初始化工作变得较为繁琐。
- 第二种方式是选择PASS(Platform as a Service)平台。有许多云提供商都提供了微服务的托管服务,你只需要专注于微服务的具体实现, PASS平台负责微服务的管理和基础设施,并可以为每个内置负载均衡的微服务提供自动扩展和DNS条目,另外还有标准的安全和监控功能等。
- 第三种方式是选择使用容器。这是当下最流行的方式之一。容器可以将应用程序及其所有的依赖项都打包在一起,然后非常方便地在任何容器主机上轻松移植并运行。容器主机可以是本地工作的电脑,也可以在云上,这大大简化了开发和部署的任务。示例应用程序eShopOnContainers就使用了容器来构建微服务,具体步骤可以查看Github上的文档。容器可以使构建和运行微服务的过程变得简单,如果我们单独为每一个微服务安装所有依赖的软件并配置所有内容,将会是一个非常痛苦的过程,可能需要好几天的时间,期间也可能会遇到各种各样的问题,而容器会使这一切变得非常简单。而且,许多开发工具也允许将调试器附加到容器内运行的代码,这对开发人员debug微服务也带来了很多的便利。
如何开始创建一个微服务
首先,你需要一个用来保存源代码的仓库,例如Github。虽然从技术上来说你可以将所有微服务的代码都保存在同一个源代码仓库中,但是如果微服务的数量很多,这会大大增加微服务间的耦合程度,这显然违背了微服务设计的初衷。
其次,我们还需要一个能够自动化持续集成构建微服务的系统,每当我们提交代码到源代码管理器的时候,它都会自动构建一个新的微服务版本,同时还会执行自动化测试,如果测试未通过,则新的构建就会失败,并自动发消息给相应的开发人员。测试是构建微服务的一个非常重要的部分。
测试微服务
测试是构建微服务中一个非常关键的部分。在构建微服务的过程中,有几种不同类型的测试:
- 单元测试。单元测试是针对代码级别的,通常运行比较快。在单元测试中,我们要尽可能保证高的代码覆盖率,尤其是那些针对特定业务的逻辑和模块。
- 集成测试。也叫做服务级别的测试,是针对于单个微服务的测试。通常我们需要将单个微服务部署到服务器中,并进行相应的配置,然后通过调用暴露出来的公共接口来测试微服务的功能是否正常。相对于单元测试而言,集成测试更难编写,但它们对于微服务的质量和稳定性来说非常有价值,所以值得我们花时间去创建一个测试框架,然后为每个微服务创建集成测试。这些测试也应该作为自动化构建微服务的一部分。
- 端到端测试。这些测试是模拟生产环境中所有运行的微服务,我们可以通过UI界面来执行一些关键的业务操作流程,以保证尽可能多的微服务之间的协同工作是否能否达到预期的目标。这部分的测试在编写和维护方面更加困难,而且往往很容易出错,因为任何一个底层逻辑的改变都有可能导致测试失败。你也可以尝试其它类型的测试来验证尽可能多的功能,但往往端到端的测试在验证某些关键功能方面仍然具有价值,例如在冒烟测试中快速检测系统关键点的功能是否正常。
微服务模板
在创建微服务时,并不是每次都从零开始。从一个标准模板开始创建微服务可以省去很多工作。你可以根据需要在自己的代码仓库中维护微服务模板,也可以在某个特定的微服务的基础上进行改写。有许多通用的功能都可以标准化,例如:
- 日志(Logging),它将系统中所有微服务的日志集中管理。
- 健康检查(Health Checking),如果每个微服务都可以报告自身的运行状态,告知是否正在运行,是否可以与依赖的其它微服务进行通信,这将是一个不错的设计。
- 配置(Configuration),让所有微服务都采用统一的方式进行配置不失为一个好的想法。
- 身份认证(Authentication),我们可以使用一些标准的身份认证机制,这可以降低开发过程中一些错误配置而导致我们的微服务存在安全漏洞。
- 构建脚本(Build Scripts),使用标准的构建方法可以避免我们少走弯路。例如eShopOnContainers中使用容器,每个微服务都使用一个Docker文件来生成容器镜像。
当然,你可以根据需要将某些特定的功能添加到模板中。模板的好处是提供了开箱即用功能(Out of Box),这大大减少了启动和运行微服务所需的时间,并且还可以确保系统中所有微服务的一致性。不过这种所谓的一致性不应该限制微服务所使用的技术,例如我们不应该限制所有的微服务都使用同一种编程语言,尽管这可以给开发人员在不同的微服务之间工作带来便利,但是我们不应该限制这种技术自由,微服务的开发团队可以自由选择最佳的开发工具。我们的目标是使微服务开发团队尽可能高效地工作,将时间花在实现微服务的业务需求上,而不是围绕微服务技术本身。
每个开发人员都应该具备独立处理和运行一个微服务的能力,例如在集成测试环境中检查各个功能点是否正常。而且,每个开发人员也应该能够在一个完整的系统中测试他们开发的某个功能。开发人员可以在本地环境中运行所有的内容,也可以在云上直接访问整个系统。但是无论采用哪种方式,工作流程都应该尽量简单并可以自动完成。
五、微服务之间的通信
微服务通信模式
微服务本身并没有规定通信规则,换句话说,一个微服务并没有规定可以被哪些应用程序访问,或者被哪些其它的微服务调用。应用程序与微服务间的直接通信,或者微服务与微服务间的直接调用,往往会因为其中错综复杂的关系而导致级联故障,任何一个环节的错误都会导致与其关联的其它部分无法正常工作。而且,如果执行某个操作需要调用多个不同的微服务才能完成,也会导致整个系统性能下降。我们应该尽量较少微服务之间的通信。
下图展示了一个较好的微服务之间的通信架构图:
微服务之间可以将数据以消息的方式发布到事件总线上,这样其它的微服务就可以以消息订阅的方式来完成微服务之间的数据通信,后面我们会讨论这样做的好处。另外,我们也可以在前端应用程序和微服务之间建立一个API网关,用来隔离它们之间的直接通信。对于前端应用程序而言,后端的微服务是透明的,所有来自前端应用程序的调用都可以通过API网关路由到对应的微服务。这样的设计可以带来几个好处:首先我们可以在API网关级别实现用户身份认证;其次是将前端应用程序与后端微服务之间进行解耦,从而使我们可以更加灵活地对微服务进行修改,同时保证面向公众的API的一致性。假如有第三方的开发团队基于我们的公共API来开发客户端程序,那么保证API的一致性就变得尤其重要,因为我们无法控制第三方开发团队的升级计划。在API网关中,我们甚至可以针对不同的客户端来聚合或转换数据格式,以满足前端的需要。
在示例eShopOnContainers的微服务架构中,我们可以看到,它有一个用来接收前端请求的API网关,并且移动应用和两个网站都通过API网关来访问后端的微服务。此外,我们从架构图中也可以看到,微服务之间并不会直接访问,而是通过事件总线以消息订阅的方式来实现异步数据通信。
同步通信
这里所说的同步通信与我们在编程语言中(例如Node.js中的同步与异步)所遇到的同步是两个不同的概念。在前端应用程序通过API网关调用后端服务的过程中,有时我们需要等待服务器马上返回结果,而不是将该请求发送到消息队列,然后服务器在处理完之后再返回结果。例如eShopOnContainers中有一个需要显示前10个最受欢迎的商品的功能,它是通过API网关向Catalog微服务发送一个请求,然后Catalog微服务从数据库中获取对应的数据并返回给前端应用程序。就整个过程而言,这是一种同步通信机制,因为前端应用程序在发送请求之后一直处于等待状态,直到后端服务返回结果,才进行下一步的动作(在网页上显示数据,或者给出错误提示)。
有许多方式可以用来实现通信,但HTTP仍是迄今为止最为流行的方式,它作为行业标准,几乎所有的编程语言都支持,这意味着我们可以非常方便地在网页或移动应用中来调用我们的API和微服务。另外,HTTP还具备一些其它的特性,例如响应头文件和状态标识、缓存功能、代理机制等等。在用HTTP请求数据时,通常使用JSON或XML数据格式,但推荐使用JSON,其优势之一是绝大多数编程语言都原生支持JSON格式,而且如果你使用JavaScript作为编程语言,JSON本身就是其数据类型的一部分。所以如果你正在开发网页应用程序,那么JSON数据格式是最完美的选择。
另外一种经常与微服务架构相关的模式是将API封装为RESTful资源,这是另一种用来组织和有效表达服务器资源的实践方式。
异步通信
有时我们在调用后端服务时不希望一直等待服务器返回结果,服务器会在适当的时候处理我们的请求。例如eShopOnContainers中提交订单的过程:
当客户通过前端应用程序提交订单时,后端系统需要完成一系列的操作,包括从第三方支付系统检查客户是否已成功完成支付,从供应商处检查商品库存状态并启动发货流程等。这个过程可能会持续几天的时间,所以对前端用户而言,他只需要知道订单已经成功提交,而并不需要一直等待整个过程处理完毕,他可以在稍后查询订单的处理进度。我们甚至可以在这个过程中添加订单状态更新通知功能,当订单的状态更新时,通过电子邮件或者短信告知用户订单的状态,并在成功发货后通知用户。
常用的微服务异步通信模式是将消息发送到事件总线(即消息队列),微服务间并不直接进行通信,而是创建一条消息并发送给消息代理,其它的微服务订阅这些消息并在适当的时候处理消息的内容。这种模式带来许多好处,首先,它将微服务彼此完全解耦,微服务间通过消息代理完成通信。当某一个微服务暂时不可用时,仍然不影响其它微服务的正常运行,它可以继续向消息代理发送消息,当不可用的微服务再次上线后,便可以从消息队列中继续处理消息内容。其次,这种架构也有利于支持更高级的缩放,如果消息队列中的消息数量不断增加,我们可以对微服务进行扩展,以帮助快速处理积压的消息。如果你使用云托管平台,通常可以自动支持这一操作。如果使用容器,则可以通过为运行容器的虚拟机集群配置一些自动扩展的功能来实现这一操作。
消息的类型有很多,但最常用的有两种,即命令和事件。
命令表示要执行某种特定的操作,例如发送电子邮件,它不一定需要同步完成,该命令只需要将电子邮件的地址和内容说明清楚,剩下的事情就交给电子邮件微服务来完成。如果我们将大量的命令消息发布到消息队列中,电子邮间微服务可能不会马上处理这些消息,但最终它们都会被一一处理完。
另一种消息类型是事件,它表示发生了某件事。这有点像语法中的过去式,当事件发布时,系统中任何对此事件“感兴趣”并订阅了该事件的微服务都可以响应并执行它们各自定义的操作。在面向对象编程中,这一模式被称为“发布-订阅”模式,例如C#中的事件处理机制就是采用的这种模式。在eShopOnContainers中,提交订单的过程就存在OrderPlaced事件,该事件会执行多个不同的操作,如支付、发送电子邮件、检查商品库存等。这里每个需要响应该事件的微服务都从发布到事件总线上的消息触发对应的操作。
弹性通信模式
我们无法保证系统中所有的微服务都一直正常运行,甚至我们无法保证网络通信没有故障,所以,我们需要提前预料到可能出现的各种问题。这里有几种技术和模式可以用来帮助我们提高系统的稳定性和通信弹性。
- 自动重试功能。有时会因为网络通信故障或者微服务本身不可用而导致某个操作在一开始的时候失败,如果系统能够在稍后自动重试会是一个不错的设计。例如通过一个HTTP请求对数据库进行查询,第一次尝试失败了,然后过几秒钟再试一次,如果仍然失败则等待一段时间后再试一次。许多现代的编程框架已经内置了对这种功能的支持,可以使我们非常容易地实现这一特性,例如.NET中的Polly。
- 断路器。自动重试功能固然好用,但是如果由于某种原因导致重试的次数过多,或者一直处于重试状态,则可能会导致系统性能下降,或者由于过于频繁的请求而让下游服务拒绝响应。断路器可以用来很好地解决这一问题。断路器位于客户端和服务器之间,一开始它允许所有的请求通过,此时我们称断路器处于关闭状态。一旦检测到任何错误,例如服务器返回特定的错误代码或者根本没有响应,那么断路器就会打开,这意味着客户端的所有请求都会立刻失败而不会发送给服务器。我们可以在断路器上配置超时时间,超时时间过后,断路器再次关闭,所有客户端的请求会被正常发送给服务器。如果服务器仍然没有响应,断路器会再次打开并保持一段时间,所有来自客户端的请求都被会拒绝。这是一种实现起来非常简单但是功能却很强大的技术。同样,许多编程框架也都内置了该功能,你完全不必自己来实现。
- 缓存。灵活地运用缓存可以提高系统的弹性。如果我们缓存从服务器或者下游服务中接收的数据,那么如果短时间内服务不可用,我们仍然可以使用缓存中的数据。当然,前提是我们已经考虑过旧数据给系统带来的影响。
消息代理在微服务架构中如此受欢迎的原因之一是它具有对弹性的内在支持,我们可以向消息代理发布消息,下游服务当前是否在线都没有关系,当服务再次启动时,可以继续处理积压的消息。另外,消息代理通常还具备重试发送消息的能力,如果消息处理程序因为某种原因无法正常处理消息,消息可以返回给消息代理以便稍后重新发送。当然,重试的次数也不是无限的,如果消息在被进行一定数量的重试后仍然无法被消息处理程序正常处理,消息代理会认为这是一条“死”消息而将它放入“死”消息队列中。
另外需要注意的一点是我们无法保证消息总是按顺序被接收的,这是由异步通信的本质所决定的。所以对消息处理程序而言,即便收到的消息是无序的,也能按照正确的逻辑进行处理。还有一种情况是消息处理程序可能会接收到重复的消息,这往往是因为消息代理重复发送消息而导致的,我们需要确保消息处理是幂等的。对于一条消息而言,消息处理程序处理一次和处理两次的结果相同,则认为消息处理是幂等的。在eShopOnContainers中,提交订单时的扣款和发货过程就属于这种情况,我们不希望这个过程中客户被扣款两次,而且也不希望对同一订单重复发货,所以,我们必须确保在代码中进行了严格的检查来避免出现这样的情况。
微服务发现
为了让微服务能够相互通信,每个微服务都需要有一个地址,如何才能知道所有微服务的地址呢?假设我们有三台虚机,每台虚机上都运行着各种微服务,甚至同一个微服务的多个不同实例分别运行在不同的虚机中,我们不可能给每一个在虚机上运行的微服务都分配一个固定的IP地址,那么如果解决这个问题呢?
一种方法是使用注册中心,它记录了所有当前运行的微服务的地址。每个微服务在启动时都向注册中心报告并注册自己的信息,这样其它微服务在需要的时候就可以通过注册中心找到该微服务。如果你使用云托管平台,通常它都支持这部分功能,我们不需要自己创建注册中心。而且,在云托管平台上,我们部署的每个微服务通常都会自动分配一个DNS名称,同时它还支持负载均衡,这意味着我们可以通过DNS名称来找到微服务,而不用关心微服务是在哪台虚拟机上。
如果使用容器,则可以考虑使用Kubernetes。Kubernetes内置了DNS,我们不需要知道每个容器的IP地址,只需要知道微服务的名称即可。Kubernetes负责将请求路由到对应容器并在必要时进行负载均衡。
六、微服务安全
微服务应用程中通常都要存储和处理大量数据,这些数据常常都包含了各种敏感信息。例如,在eShopOnContainers中,Catalog微服务用来处理商品信息,这些信息应该对所有客户开放,因此这部分数据不是敏感数据。但是在Ordering微服务中,我们需要保存哪些客户订购了哪些商品,同时还包含客户的送货地址和付款信息,这些数据都是非常敏感的,如果一旦泄露,将会产生非常严重的后果。因此,对于那些处理敏感数据的微服务,我们必须采取有效的保护措施。
对数据进行加密
数据的加密包括几个方面,首先是数据传输过程中的加密,以防止数据被中间方监听。推荐使用业界标准的加密算法,而不要尝试自己发明新的加密算法。对于HTTP通信,可以通过配置传输层安全或者TLS(Transport Layer Security)来实现加密,使所有的请求都通过HTTPS进行访问。实现HTTPS访问需要配置SSL证书,这是一个比较复杂的过程,你需要为每一个服务申请一个证书,该证书由第三方受信任的机构颁发,证书有一定的有效期,过期之后需要更新证书。使用云托管平台可以简化这个过程,它默认就支持HTTPS访问机制。
数据的加密还包括对静态数据的加密,所谓静态数据,就是指存储在硬盘上的数据。任何保存在硬盘上的数据都应该被加密。许多云提供商都提供了标准的静态数据加密服务,所以我们无需自己花时间去配置和管理加密密钥。需要注意的一点是,不应该仅仅对正在使用的静态数据进行加密,任何备份的数据也应该被加密。
身份认证
仅仅对数据进行加密是远远不够的,我们还需要确保所有的HTTPS请求是安全的,换句话说,我们需要知道请求者是谁,他们是否获得授权以访问我们的服务。最常见的实现身份认证的方式是在每一个HTTP请求中包含一个Header,其中带有用来识别用户身份的用户名和密码,这种方式被称为基本身份认证方式(Basic authentication),例如用来实现用户登录的功能就可以使用此方法。但是这种认证方式会暴露用户的密码,而且微服务代码中如果对用户的密码处理不当,也会带来一定的安全隐患。因此这种认证方式并不推荐。
还有一种方式是为微服务的每个客户端提供自己的密钥,然后客户端在HTTP请求头中包含API密钥。但是这种方式带来了密钥管理上的问题,一旦密钥管理程序受到攻击或者密钥被泄露,后果也是非常严重的。
另外一种方式是使用客户端证书,通过使用公钥加密,证书为我们提供了一种非常安全的身份认证方式。不过,证书的安装和管理也会带来一些的问题。最好的方式是按照业界标准在微服务中实现身份认证,例如OAuth 2.0和OpenID。这通常是通过授权服务器(Authorization Server)来实现的。在eShopOnContainers中,这一部分的功能包含在Identity微服务中。首先,客户端通过发送某种凭据向授权服务器进行身份认证,如果认证通过,授权服务器返回一个有限时间的访问令牌(token)。然后,客户端将该令牌放在HTTP请求头中访问微服务,由于令牌使用了公钥加密进行签名,所以授权服务器可以验证令牌的真实性和有效性。因为OAuth 2.0和OpenID都是行业标准,有很多现成的第三方组件和服务可以直接使用,所以你不需要自己来实现,例如示例程序eShopOnContainers中使用了名为IdentityServer4的开源产品。
授权
身份认证用来告诉我们访问者是谁,而授权的作用是知道可以干什么。例如,当用户登录到eShopOnContainers时,他有权查看自己的历史订单,但无权查看其他用户的历史订单。许多Web API框架都内置了授权机制,用于检查用户的角色和权限,例如eShopOnContainers中使用的ASP.NET Core框架。
对于微服务提供的每一个API,我们都应该认证考虑哪些用户可以被访问,哪些用户不能访问。在微服务中,有一种特殊的情况我们需要特别注意。假设用户Mark通过了身份认证并成功访问了Ordering微服务的某个功能,而该功能又会调用另一个微服务,例如Payment微服务。Ordering微服务知道当前访问的用户是Mark,并知道Mark可以执行哪些操作,但是当Ordering微服务调用Payment微服务时,由于Payment微服务信任Ordering微服务,它假定所有来自Ordering微服务的请求都是安全的,所以可能会存在某种安全隐患,使得用户Mark能以某种方式欺骗Payment微服务而使用其他用户的信用卡来支付自己的订单。解决方法就是当Ordering微服务访问Payment微服务时也需要同时传入用户Mark的访问令牌,用来告诉Payment微服务最终的用户身份,如果Payment微服务识别出请求者不是Mark用户,则请求被拒绝,这样可以有效地避免Payment微服务的调用被欺骗的问题。
网络安全
我们可以使用防火墙、IP白名单和虚拟网络等网络功能来共同保护我们的微服务。下图中,我们可以看到其中有三个相互通信的微服务,如果我们将其放到虚拟网络中,那么我们就有能力拒绝任何来自虚拟网络外部的访问请求,而只允许微服务之间彼此进行通信。这种方式可以很好地保护后端服务的网络安全,这些微服务可能只需要在它们之间进行通信,而不需要从外部直接访问。但是,如果确实存在需要从外部直接访问微服务的情况呢?例如,一个单页面应用程序(SPA),它可能需要直接访问某个微服务,这是否意味着我们需要放开虚拟网络对外部请求的限制而允许访问某些微服务呢?有关这一点我们在前面“微服务通信模式”一节中已经讨论过了,一个好的实践方式是使用API网关将外部请求与虚拟网络中的微服务隔离开,这样我们就可以非常有选择性地确定哪些API可以从外部访问,哪些API不能从外部访问。而且,单一的访问入口也有利于对API网关配置防火墙,以防范DDOS攻击和SQL注入等。
通常,在微服务应用中,有一个面向公众的网站,例如eShopOnContainers网站,但同时可能还有一些其它的应用只面向一小部分用户,例如针对系统管理员访问的系统后台管理程序,或者专门针对市场部进行营销活动的页面等。对于这部分应用,我们希望只有特定的用户才能访问,而不是对公众开放。我们可以通过IP白名单的方式只允许来自特定IP地址的请求(例如系统管理员或市场部用户的IP地址),而拒绝来自其它IP地址的请求。
微服务的安全涉及到多方面的技术,而不仅仅是其中的一两个,为了保证后台服务的安全,我们应该避免仅仅依赖单个的保护机制,而应该尽可能地使用多层安全保护,这就是所谓的纵深防御机制。
纵深防御机制
前面我们谈了有关微服务安全的许多方面,包括对数据进行加密、对用户进行身份认证、授权用户只访问允许的资源、通过使用虚拟网络和IP白名单拒绝来自未经授权的网络请求等。纵深防御指的是我们不应该只依赖于其中一种技术来保护我们的应用程序和服务,这是因为一旦某一个防御被突破,那么攻击者就可以免费获取到所有数据。我们需要结合多种不同的安全措施来保证我们数据的安全性,从而尽可能地降低数据泄露的可能性。处理的数据越敏感,所需要的防护也就越多。除了上面提到的一些安全防护机制外,还有一些额外的防御措施。
黑客们会使用各种非常复杂的工具和技术来入侵和破坏我们的服务和应用,对于开发人员而言,了解这些攻击技术可以有效地采取一些防护措施来阻止攻击。我们可以安排信息安全专家对应用程序进行渗透测试,这可以知道我们的应用程序是否能够防御最先进的黑客技术,同时专家也可以提供一些有关如何提供应用程序安全性的建议。另外,创建自动化测试来验证安全性设置是否正常工作并有效,而不要仅仅假设我们已经进行了正确的网络和安全设置。可以有针对性地进行一些安全测试,例如用未经授权的用户访问API,还可以检测攻击行为何时进行,例如尝试多次重复登录和HTTP请求、对于敏感文件的网络钓鱼访问、SQL注入攻击等。所有这些攻击都可以实时检测到,我们可以进行系统报警配置,当攻击行为发生时,系统可以主动做出响应,例如阻止攻击者的IP地址请求,或者暂时关闭系统的部分功能以防止攻击等。最后,我们应该对系统中所有正在执行的操作都记录日志,这样我们才可以准确地知道谁在什么时间做了什么,当系统受到攻击或者数据发生泄露时,才能有机会了解事情是如何发生的,以及哪些数据被泄露。
七、发布微服务
自动化部署
在单体应用程序中,部署过程往往比较简单,而且通过手动就可以轻易地完成,我们只需要将所有步骤和注意事项在文档中描述清楚,然后按部就班地逐一执行就可以了。但是这种操作方式对微服务而言并不适用,因为微服务需要部署的东西往往非常多,相互之间的依赖关系也比较复杂,而且部署过程相对也较为频繁,所以我们强烈推荐通过自动化的方式来完成微服务的部署。
在微服务的部署中,这一过程被称之为CICD(Continuous Integration/Continuous Delivery),即所谓的持续集成和部署。那么如何构建一个可以持续集成和部署的自动化过程呢?持续集成从我们将代码签入到Github使开始,首先集成服务器会执行一系列的单元测试,以保证我们签入代码的质量是相对可靠的;然后通过CI管道(Pipeline)按照预先定义好的步骤部署微服务,可以将微服务部署到本地虚拟机或者云端;一旦微服务部署成功,就需要进行服务级别的集成测试;如果测试通过,接下来就可以将微服务部署到QA环境中进行整个系统级别的测试,如运行自动化的端到端的测试;在正式将微服务投入到生产环境之前,通常还需要一些额外的准备工作,如进行一些必要的手动测试、执行风险评估扫描、相关签署工作等;最后,我们将准备好的release发布到生产环境。一个完整的流程看起来像这样:
部署环境
通常,我们希望微服务能够被部署到不同的环境中。开发人员希望将微服务部署到本地虚拟机,以便在开发过程中能够在本地调试代码。同时我们还需要有一个测试环境,能够在其中对微服务进行端到端的测试,并且QA小组也希望能够在测试环境中进行一些必要的手动测试。有时,我们可能还需要专门用于渗透测试和性能测试的环境。除此之外,我们还需要生产环境,这是我们的微服务最终运行的环境,也是我们交付给客户能够真正使用的环境,不言而喻,生产环境是最重要的。在某些情况下,我们可能会有多个生产环境,比如为不同的客户准备不同的生产环境,在不同的地理区域中准备不同的生产环境等。因此,参数化我们的自动化部署脚本非常有必要,这可以使我们的部署过程尽可能简单。我们可以使用JSON或者YAML为不同的环境定义不同的参数,当然,也可以使用各种工具如Docker Compose或者Kubernetes来获得更加成熟的解决方案。
构建注册表(Artifact Registry)
将构建工件(Artifact)存储在某种注册表中可以使我们非常方便地部署微服务的任意版本,从而使得我们可以轻松地将微服务回滚到之前的某个特定版本。示例应用程序eShopOnContainers选择将每个微服务构建为Docker容器镜像,这样做有很多好处,其中之一是版本命名的标准化,并且可以存储在容器注册表中,当我们使用Docker来进行部署时,操作会变得很容易。例如名称“eshoponcontainers/orderingservice:1.3.1”代表eshop应用程序中ordering微服务的1.3.1版本。由于eShopOnContainers应用程序使用容器作为交付机制,因此它非常适合使用Kubernetes。如果我们使用Kubernetes集群,那么可以通过YAML配置文件来定义应用程序中所有的微服务。Kubernetes是基于状态策略来运行的,配置文件定义了哪些微服务需要运行以及如何进行设置,Kubernetes将配置文件中的内容与集群上的配置文件进行比较,如果不一致,则相应地添加或删除容器,直到运行的内容与所要求的内容相匹配。所以,对于微服务版本的升级,或者其它任何属性的改变(例如环境变量或者副本数量等),只需要更改配置文件即可。有关Kubernetes的详细介绍可以参考官网网站的说明。
独立升级
在前面的章节中,我们讨论过微服务之间松耦合的重要性,我们不希望单个微服务的升级同时依赖于其它的微服务。我们的自动化流程可以一次性部署所有的微服务,但是一次只能更新(或升级)一个微服务。如何对单个微服务进行升级是一个值得思考的问题。你可能会想,不就是停止服务,更新,然后再重新启动吗?如此简单的步骤还需要大费周折吗?当然,如果你不想服务被中断,这个方法当然行之有效。不过,在生产环境中,某些微服务是不允许被中断的,我们应该通过技术手段尽可能地在微服务的升级过程中减少或者避免停机时间。
一种有效的方法是同时运行新旧两个版本的微服务,然后通过负载均衡将流量从旧版本移动到新版本。这样升级过程中不会有停机时间,客户几乎不会感觉到升级过程中的任何停顿。
另一种方法是同时运行多个微服务的副本,然后逐个升级替换。例如有三个V1版本的微服务实现负载均衡,我们可以每次添加一个V2版本的新实例并同时删除一个V1版本的实例,直到所有的V1被替换成V2,并最终完成对整个微服务的升级过程,但前提是两个不同版本的微服务之间能够兼容。值得一提的是,Kubernetes集成了许多高级的升级策略,你可以查看官方文档并使用这些策略。
另外还需要考虑的一个问题是,当升级出现问题时,我们可以无缝地回滚到之前的版本。Kubernetes同样可以非常轻松地帮我们解决这一问题,回滚操作只需要更新配置文件使其指向之前版本的标签即可。
监控微服务
微服务的一大挑战是我们需要监控的东西非常多。一个微服务可能同时在多个不同的服务器上以多个不同的进程运行,所以通过手动的方式连接到这些虚拟机集群中的每个工作节点来检查和查看工作日志几乎变得不可能,而我们需要的是通过一个系统来自动监控和管理这些日志,管理员可以在一个集中的地方操作和查看这些日志。通常,这个系统会有一个称之为仪表盘(Dashboard)的界面,能够让我们实时了解系统的整体运行状况,并在出现问题时报告具体原因。通常,它分为这几个部分:
- 主机指标。包括CPU和内存使用情况。通过这些指标我们可以检测是否需要对主机容量进行扩充以满足更多的需求。许多云提供商都会提供这一类的监测数据,同时还包括警报功能,当主机运行状况超过阈值时会自动发出通知(短信、邮件或其它通知方式)。
- 应用程序级别的监测数据(Web API)。包括HTTP请求数量,以及失败的请求数量和错误代码等。例如,如果日志中有很多401 Unauthorized的响应代码,那么很可能我们的服务受到了黑客攻击,或者存在错误的配置项;如果日志中有很多500的错误代码,那么表明我们的代码中存在某种错误。如果使用消息代理,则需要跟踪并查看消息队列中是否存在大量的死消息,以表明我们的程序是否在处理消息时遇到了问题。另外,一个好的实践是让每一个微服务都支持端点检查,这是一个特定的Web API,允许定期被调用来检查微服务运行是否正常,当它被调用时,只需要回复是否OK即可,用以表明服务是否启动并工作正常,同时它还可以返回一些额外的信息,例如该微服务所依赖的下游服务是否正常工作等。
- 易于访问的日志文件。每个微服务都应该记录日志,我们需要将这些日志集中到一个地方,以方面查看和管理。使用容器的好处是可以通过一种标准化的方式来捕获日志,并将日志集中到一个统一的地方。有许多第三方的开源产品可以帮助我们实现这个功能,例如可以将日志发送到Elasticsearch,然后使用Kibana查看日志,或者在Microsoft Azure中使用Application Insights。许多云托管平台也提供了很多易于启用的监控和日志功能。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/bian-cheng-ji-chu/101765.html