微服务开发秘籍-全-

微服务开发秘籍(全)

原文:zh.annas-archive.org/md5/356f7fb67e87c6e854d6378995fc5e59

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

为什么选择微服务?

微服务在过去几年中变得越来越受欢迎。与任何新的架构概念一样,误解的空间很大。即使是“微服务”这个术语也令人困惑。新入门者常常不确定微服务的大小是否合适(提示:这实际上并不是关于代码库的大小)并且可能会卡在如何开始这种架构风格上。

面向服务的架构并非新事物。在 20 世纪 90 年代,各种公司都在推广 Web 服务作为解决大型、不灵活代码库的解决方案。承诺是 Web 服务将提供可重用的能力,这些能力可以轻松地被您的代码库消费。像 SOAP 和 WSDL 这样的技术开始被采用,但似乎从未实现易用的承诺。与此同时,像 PHP、Ruby 和 Python 这样的开源语言以及像 Symfony、Rails 和 Django 这样的框架使得开发单体 Web 中心代码库变得更加容易。

快进几十年,我们开始看到对服务的兴趣再次兴起。那么,发生了什么变化?一方面,随着丰富 Web 和移动应用程序的出现,每个系统现在都是一个分布式系统。得益于云计算的出现,计算和存储资源比以往任何时候都便宜。容器正在改变我们关于部署和运营服务的方式。许多消费型服务正在超出其单体代码库的规模,团队发现它们难以扩展。微服务可以帮助解决许多这些挑战。

微服务的前提条件

微服务并非万能药。虽然它们有许多好处(我们将在后面讨论),但它们也引入了一些特定的挑战。在决定转向微服务之前,确保某些基础设施和工具到位非常重要。马丁·福勒(Martin Fowler)写过关于微服务的前提条件(martinfowler.com/bliki/MicroservicePrerequisites.html),菲尔·卡卡多(Phil Calcado)也写过(philcalcado.com/2017/06/11/calcados_microservices_prerequisites.html)。我不会重复别人的话;相反,我只想说,在开始开发微服务之前,拥有一定程度的自动化和监控是值得的。您的团队应该能够舒适地分担值班任务,并且您应该有一个管理警报和升级的系统,例如 PagerDuty(pagerduty.com/)。

微服务的优势

微服务的各种好处将在下一节中讨论。

规模化

在单体代码库中,扩展是一个全有或全无的方法。微服务使得根据各自需求扩展应用的不同部分变得更容易。例如,你可能有一个应用的部分在所有用户请求的关键路径上(即认证/授权服务),而其他部分只被用户子集使用(即搜索或消息)。不同的流量模式将转化为不同的扩展需求和不同的扩展服务的技术。需要为每个用户请求进行读取的服务应该使用使读取便宜的数据库。不需要提供强一致性结果的服务可以利用广泛的缓存。

团队组织

当工程师团队在各自的代码库和各自的部署上工作时,他们能够独立做出很多决策,而无需与其他团队协调。这意味着工程师可以自由地提交代码,设计自己的代码审查流程,并部署到生产环境,而无需总是需要协调。在单体应用中,工程师不得不将他们的更改放入一个队列中,然后与其他团队的更改一起在设定的时间部署,这是很常见的。如果出现问题(毒部署是导致中断最常见的原因之一),则整个更改集都会回滚,导致多个团队的工作延迟。微服务通过允许团队拥有更大的自主权来帮助你避免这种情况。

可靠性

当单体应用失败时,它往往会完全失败。数据库不可用,然后应用尝试在连接池中使用过时的连接,最终服务请求的线程或进程会锁定,用户只能看到死亡的白屏或无法使用的移动应用。微服务允许你根据应用特定部分的失败情况来决定如何处理。如果你的服务无法连接到数据库,可能最好是返回一个过时的缓存,或者一个空响应。如果你的服务不得不放弃并开始返回 HTTP 503 响应,上游服务可以通过应用反向压力来响应,允许服务赶上。微服务为你提供了更多的自由来隔离系统中的故障,从而为用户提供更愉快的体验。

本书将作为你在开发微服务时遇到的各种主题的便捷参考。我们将从帮助你从单体架构过渡到微服务套件的食谱开始。随后的章节将解决在选择最佳架构和管理微服务时出现的特定领域或挑战。涵盖代码的食谱将包括可用的、简单的、经过测试的示例,你可以在自己的应用程序中使用。我希望这本书能帮助你思考、规划和执行基于微服务的应用程序的开发。祝您享受阅读!

本书面向对象

如果你是一名希望构建有效且可扩展的微服务的开发者,那么这本书就是为你准备的。假设你具备微服务架构的基本知识。

本书涵盖内容

第一章,打破单体,展示了如何从单体架构过渡到微服务架构,食谱重点在于架构设计。你将学习如何使用这种新的架构风格开始开发功能时管理一些初始挑战。

第二章,边缘服务,教你如何使用开源软件将你的服务暴露给公共互联网,控制路由,扩展你的服务功能,并在部署和扩展微服务时处理许多常见挑战。

第三章,服务间通信,讨论了将使你能够自信地处理微服务架构中必然需要的各种交互的食谱。

第四章,客户端模式,讨论了建模依赖服务调用和从各种服务中聚合响应以创建特定客户端 API 的技术。我们还将讨论管理不同的微服务环境,以及使 RPC 与 JSON 和 HTTP 保持一致,以及 gRPC 和 Thrift 二进制协议。

第五章,可靠性模式,讨论了在设计构建微服务时可以使用的许多有用的可靠性模式,以准备和减少预期和意外的系统故障的影响。

第六章,安全,包含了帮助你学习在构建、部署和运营微服务时需要考虑的许多良好实践的食谱。

第七章,监控和可观察性,介绍了监控和可观察性的几个原则。我们将演示如何修改我们的服务以生成结构化日志。我们还将查看指标,使用多个不同的系统来收集、聚合和可视化指标。

第八章,扩展性,讨论了使用不同工具进行负载测试。我们还将设置 AWS 中的自动扩展组,使其按需可扩展。这将随后是容量规划策略。

第九章,部署微服务,讨论了容器、编排和调度,以及将更改安全地发送给用户的各种方法。本章的食谱应作为良好的起点,特别是如果您习惯于在虚拟机或裸机服务器上部署单体应用。

为了充分利用本书

本书假设读者对微服务架构有基本了解。其他必要的说明将在各自的食谱中提及。

下载示例代码文件

您可以从 www.packtpub.com 的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com 登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Microservices-Development-Cookbook。我们还有其他来自我们丰富的图书和视频目录的代码包,可在 github.com/PacktPublishing/ 上找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“在 app/services/attachments_service.rb 文件中打开新创建的服务对象,并将上传文件的责任移动到 AttachmentsService#upload 方法。”

代码块设置如下:

class AttachmentsService

  def upload(message_id, user_id, file_name, data, media_type)
    message = Message.find_by!(message_id, user_id: user_id)
    file = StorageBucket.files.create(
      key:  file_name,
      body: StringIO.new(Base64.decode64(data), 'rb'),
      public: true
    )

任何命令行输入或输出都如下所示:

brew install docker-compose

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“安装和管理 Kubernetes 集群超出了本书的范围。幸运的是,一个名为 Minikube 的项目允许您在开发机器上轻松运行单个节点 Kubernetes 集群。”

警告或重要说明看起来像这样。

小贴士和技巧看起来像这样。

部分

在这本书中,您会发现几个频繁出现的标题(准备工作如何操作…)。

为了清楚地说明如何完成食谱,请按照以下方式使用这些部分:

准备工作

本节将向您介绍在食谱中可以期待的内容,并描述如何设置任何软件或任何为食谱所需的初步设置。

如何操作…

本节包含遵循食谱所需的步骤。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解更多关于 Packt 的信息,请访问packtpub.com

第一章:打破单体架构

在本章中,我们将涵盖以下食谱:

  • 组织团队以拥抱微服务

  • 按业务能力分解

  • 识别边界上下文

  • 生产环境中的数据迁移

  • 重构单体架构

  • 将单体架构演进为服务

  • 逐步完善测试套件

  • 使用 Docker 进行本地开发

  • 将请求路由到服务

简介

微服务中最困难的事情之一就是开始。许多团队发现自己正在将功能构建到一个不断增长、难以管理的单体代码库中,并且不知道如何开始将其拆分成更易于管理的、可独立部署的服务。本章中的食谱将解释如何从单体过渡到微服务。许多食谱将完全不涉及任何代码;相反,它们将专注于架构设计和如何最佳地构建团队以在微服务上工作。

你将学习如何从单一的大型代码库开始逐步过渡到微服务套件。你还将了解在开始使用这种新的架构风格开发功能时如何应对一些初始挑战。

组织你的团队

康威定律告诉我们,组织将产生结构与其沟通结构相匹配的设计。这通常意味着工程团队的组织结构将对它产生的软件设计结构产生深远的影响。当一个新创业公司开始构建软件时,团队规模很小——有时可能只有一到两名工程师。在这种配置下,工程师负责一切,包括前端和后端系统,以及运维。单体架构非常适合这种组织结构,允许工程师在任何给定时间都能在系统中的任何部分工作,而无需在代码库之间移动。

当团队规模扩大,你开始考虑微服务的优势时,你可以考虑采用一种通常被称为康威逆向行动的技术。这种技术建议通过演进团队和组织结构来鼓励出现你希望看到的架构风格。对于微服务而言,这通常意味着将工程师组织成小团队,你最终希望他们负责少量相关的服务。提前为这种结构做好准备可以激励工程师通过限制团队内部的沟通和决策开销来构建服务。简单来说,当添加功能作为服务的成本大于添加到单体架构的成本时,单体架构将继续存在。以这种方式组织团队可以降低开发服务的成本。

本食谱针对那些在公司中具有影响力以实施组织结构变革的管理者和其他领导者。

如何做到这一点…

重组团队永远不是一项简单的工作,有许多非显而易见因素需要考虑。如个性、个人优势和劣势以及过去的历史等因素超出了本食谱的范围,但在做出任何改变时都应该仔细考虑。本食谱中的步骤提供了一种将团队从围绕单体代码库组织转变为针对微服务优化的一种可能方式,但并非每个组织都有一劳永逸的食谱。

如果你认为这些步骤适用,请以此为指导,否则请以此作为灵感,并鼓励思考和讨论:

  1. 与你组织中的其他利益相关者合作,制定产品路线图。你可能会对你的组织在短期内将面临哪些挑战的信息有限,但尽你所能。在路线图上对短期项目非常详细,而对长期项目非常概括是完全自然的。

  2. 使用产品路线图,尝试确定你需要的技术能力,以帮助你向用户提供价值。例如,你可能正在计划开发一个高度依赖搜索的功能。你也可能有多个依赖内容上传和管理的功能。这意味着搜索和上传是你知道需要投资的两个技术能力。

  3. 随着你看到模式开始显现,尝试确定你应用程序的主要功能区域,注意你预计每个区域将投入多少工作量。将更高的优先级分配给你预计在短期内和中期内需要大量投资的那些功能区域。

  4. 创建新的团队,理想情况下由四到六名工程师组成,负责你应用程序中的一个功能区域。从你预计在接下来的一个季度左右需要最多工作的功能区域开始。这些团队可以专注于后端服务,也可以是包括移动和网页工程师的跨职能团队。拥有跨职能团队的优点是,团队可以自主地交付应用程序的整个垂直组件。服务工程师与使用他们服务的工程师的组合也将促进更多的信息共享,并希望产生同理心。

讨论

使用这种方法,你应该最终拥有小型、团结和专注于你应用程序核心区域的小团队。团队的本质是,团队内部的个人应该开始看到创建单独管理和部署的代码库的好处,他们可以在没有与其他团队协调更改和部署的昂贵开销的情况下自主工作。

为了帮助说明这些步骤,想象一下你的组织正在开发一个图片消息应用。该应用允许用户用智能手机拍照并发送照片,以及一条消息,给他们的联系人列表中的朋友。他们的朋友也可以向他们发送带消息的照片。这个虚构产品的路线图可能包括添加对短视频、照片滤镜和对表情符号的支持的需求。你现在知道,记录、上传和播放视频的能力、应用照片滤镜的能力以及发送富文本的能力将对你的组织非常重要。此外,根据经验,你知道用户需要注册、登录并维护朋友列表。

使用前面的例子,你可能会决定将工程师组织成一个媒体团队,负责上传、处理和播放、滤镜以及存储和交付,一个消息团队,负责发送带有相关文本的图片或视频消息,以及一个用户团队,负责提供可靠的认证、注册、入职和社交功能。

按业务能力分解

在产品开发的早期阶段,单体架构最适合以最快、最简单的方式向用户提供功能。这是合适的,因为在产品开发的这个阶段,你还没有扩展团队、代码库或服务客户流量的奢侈问题。遵循良好的设计实践,你将应用程序的关注点分离成易于阅读的、模块化的代码模式。这样做允许工程师独立地工作在不同的代码部分,并限制了在合并分支到主分支和部署代码时需要解决复杂的合并冲突的可能性。

微服务要求你比你在单体中遵循的良好设计实践更进一步。为了围绕微服务组织你的小型、自主团队,你应该首先考虑确定你的应用程序提供的核心业务能力。业务能力是商学院的一个术语,描述了你的组织创造价值的不同方式。例如,你的内部订单管理负责处理客户订单。如果你有一个允许用户提交用户生成内容(如照片)的社交应用,你的照片上传系统提供了一个业务能力。

在考虑系统设计时,业务能力与面向对象设计(OOD)中的单一职责原则(SRP)密切相关。微服务本质上是将 SRP 扩展到代码库。考虑这一点将帮助你设计适当规模的微服务。服务应该有一个主要任务,并且应该很好地完成它。这可能包括存储和提供图像、传递消息或创建和验证用户账户。

如何做到这一点...

通过业务能力分解你的单体应用是一个过程。这些步骤可以并行执行,以应对你识别出的每个新服务的需求,但你可能想从一个服务开始,并将你学到的经验应用到后续的工作中:

  1. 识别一个目前由你的单体应用提供的业务能力。这将是我们第一个服务的目标。理想情况下,这个业务能力是你之前在食谱中工作的路线图上有所关注的,并且可以分配给你的一个新创建的团队。让我们以我们的虚构照片消息服务为例,并假设我们将从上传和显示媒体的能力作为我们第一个识别的业务能力。这个功能目前作为你的Ruby on Rails单体应用中的一个单一模型和控制器实现:

图片

  1. 在前面的屏幕截图中,AttachmentsController有四个方法(在 Ruby on Rails 术语中称为actions),它们大致对应于你想要在Attachment资源上执行的创建、检索、更新、删除CRUD)操作。我们并不严格需要它,因此将省略更新操作。这非常适合 RESTful 服务,因此你可以设计、实现和部署一个具有以下 API 的微服务:
POST /attachments
GET /attachments/:id
DELETE /attachments/:id
  1. 随着新微服务的部署(迁移数据将在后面的食谱中讨论),你现在可以开始修改客户端代码路径以使用新服务。你可以通过替换AttachmentsController动作方法中的代码来开始,使其向我们的新微服务发送 HTTP 请求。关于如何做到这一点,将在本章后面的将单体应用演进为服务食谱中介绍。

识别边界上下文

当设计微服务时,一个常见的困惑点是服务应该有多大或多小。这种困惑可能导致工程师专注于诸如特定服务中的代码行数等问题。代码行数是衡量软件的一个糟糕指标;更多地关注服务所扮演的角色,无论是从它提供的业务能力还是它帮助管理的领域对象来看,都更有用。我们希望设计与其他服务耦合度低的服务,因为这样可以限制我们在产品中引入新功能或对现有功能进行更改时需要更改的内容。我们还想给服务赋予单一责任。

当分解单体应用时,在决定要提取哪些服务时查看数据模型通常很有用。在我们的虚构图片消息应用中,我们可以想象以下数据模型:

图片

我们有一个消息表、一个用户表和一个附件表。消息实体与用户实体有一个一对多的关系;每个用户可以有多个来自或针对他们的消息,每条消息也可以有多个附件。随着应用程序的发展和我们添加更多功能,会发生什么?前面的数据模型没有包含关于社交图谱的任何内容。让我们假设我们想让用户能够关注其他用户。我们将定义以下内容为一个非对称关系,仅仅因为用户 1 关注用户 2,并不意味着用户 2 也关注用户 1。

有许多方式来建模这种关系;我们将关注其中最简单的一种,即邻接表。看看下面的图:

图片

我们现在有一个实体,关注者,用来表示两个用户之间的关注关系。这在我们的单体应用中运行得很好,但在微服务中引入了挑战。如果我们构建两个新的服务,一个处理附件,另一个处理社交图谱(两个不同的职责),我们现在有两个用户定义。这种模型的重复通常是必要的。另一种选择是让多个服务访问和更新同一个模型,这非常脆弱,并且可能导致代码不可靠。

这就是边界上下文可以发挥作用的地方。边界上下文是领域驱动设计DDD)中的一个术语,它定义了系统中的一个特定模型有意义的区域。在前面的例子中,社交图谱服务将有一个用户模型,其边界上下文将是用户的社会图谱(足够简单)。媒体服务将有一个用户模型,其边界上下文将是照片和视频。识别这些边界上下文很重要,尤其是在解构单体应用时——你经常会发现,随着单体代码库的增长,之前讨论的业务能力(上传和查看照片和视频,以及用户关系)可能会最终共享同一个庞大而臃肿的用户模型,然后就需要解开这个模型。这可能是一个棘手但启发性和重要的过程。

如何实现...

决定如何在系统中定义边界上下文可以是一项有益的工作。这个过程本身鼓励团队就系统中的模型和各个系统之间必须发生的各种交互进行许多有趣的讨论:

  1. 在一个团队能够开始定义其工作的边界上下文之前,它首先应该开始列出其工作的系统部分所拥有的模型。例如,媒体团队显然拥有附件模型,但它还需要了解用户和消息的信息。附件模型可能完全在媒体团队服务的上下文中维护,但其他部分将需要有一个定义良好的边界上下文,以便在必要时与其他团队进行沟通。

  2. 一旦一个团队确定了可能共享的模型,与使用类似模型或相同模型的其它团队进行讨论是个好主意。

  3. 在这些讨论中,明确模型的边界,并决定是否共享模型实现(在微服务世界中这需要服务之间的调用)或者各自发展并维护独立的模型实现。如果选择开发独立的模型实现,那么明确模型应用的边界上下文就变得非常重要。

  4. 团队应该明确记录关于团队、应用程序的特定部分或特定代码库的模型边界,这些部分或代码库应该使用该模型。

生产环境中的数据迁移

单体代码库通常使用主关系型数据库进行持久化。现代 Web 框架通常包含对象关系映射ORM),这允许你使用与数据库中的表对应的类来定义你的领域对象。这些模型类的实例对应于表中的行。随着单体代码库的增长,添加额外的数据存储,如文档或键值存储,并不罕见。

微服务不应与单体连接到相同数据库共享访问权限。这样做在尝试协调数据迁移,如模式更改时,不可避免地会导致问题。即使是模式无关的存储,当你在某个代码库中更改数据写入方式,但在另一个代码库中不更改数据读取方式时,也会引起问题。出于这些和其他原因,最好让微服务完全管理它们用于持久化的数据存储。

当从单体过渡到微服务时,制定数据迁移策略非常重要。过于频繁地,一个团队会提取微服务的代码,而留下数据,给自己未来的痛苦埋下伏笔。除了管理迁移的困难之外,单体关系型数据库的故障现在将对服务产生级联影响,导致难以调试的生产事件。

管理大规模数据迁移的一种流行技术是设置双重写入。当你的新服务部署后,你将有两个写入路径——一个是从原始单体代码库到其数据库,另一个是从你的新服务到其自己的数据存储。确保写入同时发送到这两个代码路径。现在你将从新服务投入生产的那一刻开始复制数据,这样你就可以使用脚本或类似的离线任务回填旧数据。一旦数据开始写入两个数据存储,你现在可以修改所有各种读取路径。无论代码在哪里直接查询单体数据库,都将其替换为对新服务的调用。一旦所有读取路径都已修改,删除任何仍然写入旧位置的写入路径。现在你可以删除旧数据(你备份了吗?)。

如何做到这一点...

将数据从单体数据库迁移到由新服务前端的新存储,而不影响可用性或一致性,在向微服务过渡时是一个困难但常见的任务。使用我们虚构的图片消息应用,我们可以想象一个场景,其中我们想要创建一个新的微服务来处理媒体上传。在这个场景中,我们会遵循一个常见的双重写入模式:

  1. 在编写用于处理媒体上传的新服务之前,我们假设单体架构看起来像以下图示,其中 HTTP 请求由单体处理,它可能将 multipart/form-encoded 的内容体作为二进制对象读取,并将文件存储在分布式文件存储中(例如亚马逊的 S3 服务)。然后,文件的相关元数据被写入一个名为附件的数据库表,如图所示:

图片 2

  1. 编写新服务后,你现在有两个写入路径。在单体中的写入路径中,调用你的服务以复制单体数据库以及由你的新服务前端的数据存储中的数据。你现在正在复制新数据,可以编写脚本回填旧数据。你的架构现在看起来像这样:

图片 1

  1. 在你的客户端单体代码中找到所有读取路径,并将它们更新为使用你的新服务。所有读取现在都将发送到你的服务,这将能够提供一致的结果。

  2. 在你的客户端单体代码中找到所有写入路径,并将它们更新为使用你的新服务。所有读取和写入现在都将发送到你的服务,你可以安全地删除旧数据和相关代码路径。你的最终架构应该看起来像以下这样(我们将在后面的章节中讨论边缘代理):

图片 3

使用这种方法,你将能够安全地将数据从单体数据库迁移到为新的微服务创建的新存储中,而无需停机。跳过这一步是很重要的;否则,你将无法真正实现微服务架构的好处(尽管,可以说,你将体验到所有弊端!)。

重构单体架构

在向微服务过渡时,一个常见的错误是忽略单体,只是作为服务构建新功能。这通常发生在团队觉得单体已经失控,代码难以控制,最好是宣布破产并让它腐烂的时候。这特别诱人,因为构建没有遗留负担的绿色代码的想法听起来比重构脆弱的遗留代码要好得多。

抵制放弃单体架构的诱惑。为了通过业务能力成功地将单体架构分解,并开始将其演进为一组结构良好、单一职责的微服务,你需要确保你的单体代码库处于良好状态,并且结构良好、经过充分测试。否则,你将面临新服务的激增,这些服务无法干净地建模你的领域(因为它们与单体中的功能重叠),并且你将继续在与单体中存在的任何代码打交道时遇到麻烦。你的用户不会满意,而且随着技术债务的负担变得难以承受,你团队的精力很可能会开始下降。

相反,采取持续、积极的步骤,使用良好的、稳固的设计原则来重构你的单体架构。已经有许多关于重构的书籍(我推荐马丁·福勒的《重构》和迈克尔·费瑟斯的《与遗留代码有效工作》),但最重要的是要知道,重构永远不是全有或全无的努力。很少有产品团队或公司有耐心或奢侈的时间等待工程团队停止世界并花费时间使代码更容易更改,而尝试这样做的工程团队很少会成功。重构必须是一个持续、稳定的过程。

无论你的团队如何安排工作,确保你为重构预留了适当的时间。一个指导原则是,每次你打算进行更改时,首先使更改变得容易进行,然后再进行更改。你的目标是使单体代码更容易工作、更容易理解,并且更不易破碎。你还应该能够开发出一个健壮的测试套件,这将非常有用。

当你的单体应用处于更好的状态时,你可以开始连续缩小单体应用,同时提取服务。大多数单体代码库的另一个方面是服务于通过浏览器提供的动态生成的视图和静态资源。如果你的单体应用负责这项工作,考虑将你的 Web 应用程序组件移动到一个单独提供的 JavaScript 应用程序中。这将允许你从多个方向缩小单体应用。

如何操作...

任何代码库的重构都是一个过程。对于单体应用,有一些技术可以非常有效。在这个例子中,我们将记录可以采取的步骤,使 Ruby on Rails 代码库的重构变得容易:

  1. 使用前面菜谱中描述的技术,在你的应用程序中识别业务能力和边界上下文。让我们专注于上传图片和视频的能力。

  2. controllersmodelsviews旁边创建一个名为app/services的目录。这个目录将包含你所有的服务对象。服务对象是许多 Rails 应用程序中用来将概念服务分解为一个 Ruby 对象的模式,该对象不继承任何 Ruby on Rails 功能。这将使将封装在服务对象中的功能移动到单独的微服务变得更容易。你的服务对象的结构没有一种固定的方式。我更喜欢让每个对象代表一个服务,并将我希望该服务负责的操作移动到该服务对象作为方法。

  3. app/services目录下创建一个名为attachments_service.rb的新文件,并给它以下定义:

class AttachmentsService

  def upload
    # ... 
  end

  def delete!
    # ...
  end

end
  1. 查看app/controllers/attachments_controller.rb文件中AttachmentsController#create方法的源代码,它目前负责创建Attachment实例并将文件数据上传到附件存储,在这个例子中是一个 Amazon S3 存储桶。这是我们需要转移到新创建的服务对象的功能:
# POST /messages/:message_id/attachments
def create
  message = Message.find_by!(params[:message_id], user_id: 
  current_user.id)
  file = StorageBucket.files.create(
    key:  params[:file][:name],
    body: StringIO.new(Base64.decode64(params[:file][:data]),
    'rb'),
    public: true
  )
  attachment = Attachment.new(attachment_params.merge!(message: 
  message))
  attachment.url = file.public_url
  attachment.file_name = params[:file][:name]
  attachment.save
  json_response({ url: attachment.url }, :created)
end
  1. app/services/attachments_service.rb文件中打开新创建的服务对象,并将上传文件的责任转移到AttachmentsService#upload方法:
class AttachmentsService

  def upload(message_id, user_id, file_name, data, media_type)
    message = Message.find_by!(message_id, user_id: user_id)
    file = StorageBucket.files.create(
      key:  file_name,
      body: StringIO.new(Base64.decode64(data), 'rb'),
      public: true
    )
    Attachment.create(
      media_type: media_type,
      file_name:  file_name,
      url:        file.public_url,
      message:    message
    )
  end

  def delete!
  end
end
  1. 现在将app/controllers/attachments_controller.rb中的AttachmentsController#create方法上传,以使用新创建的AttachmentsService#upload方法:
# POST /messages/:message_id/attachments
def create
  service = AttachmentService.new
  attachment = service.upload(params[:message_id], current_user.id, 
   params[:file][:name], params[:file][:data], 
   params[:media_type])
  json_response({ url: attachment.url }, :created)
end
  1. AttachmentsController#destroy方法中的代码重复此过程,将责任转移到新的服务对象。当你完成时,AttachmentsController中的代码不应直接与Attachments模型交互;相反,它应该通过AttachmentsService服务对象进行交互。

现在,你已经将附件管理的责任隔离到单个服务类中。这个类应该封装所有最终将转移到新附件服务中的业务逻辑。

将单体应用演化成服务

从单体应用过渡到服务时最复杂的方面之一可能是请求路由。在后面的菜谱和章节中,我们将探讨将你的服务暴露给互联网的话题,以便移动和 Web 客户端应用可以直接与它们通信。然而,目前,让你的单体应用充当路由器可以作为有用的中间步骤。

当你将单体应用拆分成小型、可维护的微服务时,你可以用对服务的调用替换单体应用中的代码路径。根据你用来构建单体应用的编程语言或框架,这些代码段可以被称为控制器操作、视图或其他。我们将继续假设你的单体应用是用流行的 Ruby on Rails 框架构建的;在这种情况下,我们将查看控制器操作。我们还将假设你已经开始了重构单体应用,并已根据前一个菜谱创建了零个或多个服务对象。

在进行这项工作时,遵循最佳实践非常重要。在后面的章节中,我们将介绍电路断路器等概念,这些概念在服务间通信时变得很重要。目前,请注意,从你的单体应用到服务的 HTTP 调用可能会失败,你应该考虑如何最好地处理这种情况。

如何实现...

  1. 打开我们在前一个菜谱中创建的服务对象。我们将修改服务对象,使其能够调用一个负责管理附件的外部微服务。为了简化,我们将使用 Ruby 标准库中提供的 HTTP 客户端。服务对象应在app/services/attachments_service.rb文件中:
class AttachmentsService

  BASE_URI = "http://attachment-service.yourorg.example.com/"

  def upload(message_id, user_id, file_name, data, media_type)
    body = {
      user_id: user_id,
      file_name: file_name,
      data: StringIO.new(Base64.decode64(params[:file]
      [:data]), 'rb'),
      message: message_id,
      media_type: media_type
    }.to_json
    uri = URI("#{BASE_URI}attachment")
    headers = { "Content-Type" => "application/json" }
    Net::HTTP.post(uri, body, headers)
  end

end
  1. 打开位于pichat/app/controllers/attachments_controller.rb文件,查看以下创建操作。由于前一章中进行的重构工作,我们只需要进行少量更改,使控制器与我们的新服务对象一起工作:
class AttachmentsController < ApplicationController
  # POST /messages/:message_id/attachments
  def create
    service = AttachmentService.new
    response = service.upload(params[:message_id], current_user.id,
     params[:file][:name], params[:file][:data], 
     params[:media_type])
    json_response(response.body, response.code)
  end
  # ...
end

测试套件的演进

在一开始就拥有良好的测试套件将极大地帮助你从单体应用过渡到微服务。每次你从单体代码库中移除功能时,你的测试都需要更新。用使外部网络调用到你的服务的测试替换 Rails 应用中的单元和功能测试可能很有诱惑力,但这种方法有许多缺点。进行外部调用的测试可能会因为间歇性网络连接问题而失败,并且经过一段时间后运行时间会非常长。

而不是进行外部网络调用,你应该修改你的单体测试以模拟微服务。使用钩子来表示对微服务的调用的测试将更加健壮,并且运行速度更快。只要你的微服务满足你开发的 API 合同,测试将可靠地指示你的单体代码库的健康状况。对微服务进行向后不兼容的更改是另一个将在后续食谱中讨论的话题。

准备工作

我们将在测试中使用 webmock 钩子来模拟外部 HTTP 请求,因此请更新你的单体 gemfile,在测试组中包含 webmock 钩子:

group :test do
  # ...
  gem 'webmock'
end

你还应该更新 spec/spec_helper.rb 以禁用外部网络请求。这将确保你在编写其余的测试代码时保持诚实:

require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: false)

如何操作...

现在你已经将 webmock 包含到你的项目中,你可以在你的规格说明中开始模拟 HTTP 请求。再次打开 specs/spec_helper.rb 并添加以下内容:

stub_request(:post, "attachment-service.yourorg.example.com").
  with(body: {media_type: 1}, headers: {"Content-Type" => /image\/.+/}).
  to_return(body: { foo: bar })

使用 Docker 进行本地开发

正如我们所讨论的,微服务解决了一组特定的问题,但同时也引入了一些新的挑战。你的团队中的工程师可能会遇到的一个挑战是进行本地开发。对于单体来说,需要管理的移动部件较少——通常,你只需在你的工作站上运行数据库和应用程序服务器就可以完成工作。然而,当你开始创建新的微服务时,情况变得更加复杂。

容器是管理这种复杂性的好方法。Docker 是一个流行的开源软件容器化平台。Docker 允许你指定如何将你的应用程序作为容器运行——一个轻量级的标准化单元,用于部署。关于 Docker 有大量的书籍和在线文档,所以我们不会在这里过多详细说明,只需知道容器封装了运行你的应用程序所需的所有信息。如前所述,单体应用程序通常至少需要一个应用程序服务器和一个数据库服务器——这些将各自运行在自己的容器中。

Docker Compose 是一个用于运行多容器应用程序的工具。Compose 允许你在 YAML 配置文件中定义你的应用程序容器。使用此文件中的信息,你可以构建并运行你的应用程序。Compose 将在单独的容器中管理配置文件中定义的所有各种服务,允许你在工作站上运行复杂的系统进行本地开发。

准备工作

在你能够按照本食谱中的步骤操作之前,你需要安装所需的软件:

  1. 安装 Docker。从 Docker 网站下载安装包(www.docker.com/docker-mac)并按照说明操作。

  2. 在 macOS X 上执行以下命令行来安装 docker-compose

brew install docker-compose

在 Ubuntu Linux 上,你可以执行以下命令行:

apt-get install docker-compose

安装这两个包后,您就可以按照本菜谱中的步骤进行了。

如何操作...

  1. 在您的 Rails 应用程序的根目录下,创建一个名为 Dockerfile 的单个文件,内容如下:
 FROM ruby:2.3.3
 RUN apt-get update -qq && apt-get install -y build-essential 
 libpq-dev nodejs
  RUN mkdir /pichat
  WORKDIR /pichat
  ADD Gemfile /pichat/Gemfile
  ADD Gemfile.lock /pichat/Gemfile.lock
  RUN bundle install
  ADD . /pichat
  1. 创建一个名为 docker-compose.yml 的文件,内容如下:
version: '3'
services:
 db:
 image: mysql:5.6.34
 ports:
 - "3306:3306"
 environment:
 MYSQL_ROOT_PASSWORD: root

 app:
 build: .
 environment:
 RAILS_ENV: development
 command: bundle exec rails s -p 3000 -b '0.0.0.0'
 volumes:
 - .:/pichat ports:
 - "3000:3000"
 depends_on:
 - db
  1. 通过运行 docker-compose up app 命令来启动您的应用程序。您应该在浏览器中输入 http://localhost:3000/ 来访问您的单体应用。您可以使用这种方法来编写新的服务。

将请求路由到服务

在之前的菜谱中,我们关注的是让您的单体应用将请求路由到服务。这项技术是一个良好的开始,因为它不需要客户端更改即可工作。您的客户端仍然向您的单体应用发送请求,而您的单体应用通过其控制器操作将请求封装到您的微服务中。然而,在某个时候,为了真正从微服务架构中受益,您可能希望从关键路径中移除单体应用,并允许您的客户端直接向您的微服务发送请求。工程师直接将他们组织的第一个微服务暴露给互联网并不罕见,通常使用不同的主机名。然而,随着您开发更多服务,您需要一定程度的监控、安全和可靠性方面的统一性,这开始变得难以管理。

面向互联网的系统面临许多挑战。它们需要能够处理许多安全关注、速率限制、流量周期性峰值等问题。为每个公开暴露到公共互联网的服务执行此操作将变得非常昂贵,而且会很快。相反,您应该考虑拥有一个单一的前端服务,该服务支持将公共互联网的请求路由到内部服务。一个好的前端服务应该支持常见的功能,例如动态路径重写、负载减轻和身份验证。幸运的是,有许多优秀的开源前端服务解决方案。在本菜谱中,我们将使用 Netflix 的一个名为 Zuul 的项目。

如何操作...

  1. 创建一个新的名为 Edge Proxy 的 Spring Boot 服务,主类名为 EdgeProxyApplication

  2. Spring Cloud 包含一个内置的 Zuul 代理。通过在您的 EdgeProxyApplication 类中添加 @EnableZuulProxy 注解来启用它:

package com.packtpub.microservices;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@EnableZuulProxy
@SpringBootApplication
public class EdgeProxyApplication {

  public static void main(String[] args) {
    SpringApplication.run(EdgeProxyApplication.class, args);
  }

}
  1. src/main/resources/ 目录下创建一个名为 application.properties 的文件,内容如下:
zuul.routes.media.url=http://localhost:8090
ribbon.eureka.enabled=false
server.port=8080

在前面的代码中,它告诉 zuul/media 路由到运行在端口 8090 的服务。我们将在后续章节中讨论服务发现时详细介绍 eureka 选项,现在只需确保它设置为 false

到目前为止,您的服务应该能够代理请求到相应的服务。您刚刚迈出了构建微服务架构的最大一步。恭喜您!

第二章:边缘服务

在本章中,我们将介绍以下食谱:

  • 使用边缘代理服务器控制对您的服务的访问

  • 使用边车扩展您的服务

  • 使用 API 网关将请求路由到服务

  • 使用边缘代理服务器进行速率限制

  • 使用 Hystrix 停止级联故障

  • 使用服务网格来提取共享关注点

简介

现在您已经有一些将单体分解为微服务的经验,您已经看到许多挑战存在于单体或服务代码库之外。将服务公开到互联网、控制路由和构建弹性都是可以通过通常称为边缘服务来解决的问题。这些服务存在于我们架构的边缘,通常处理来自公共互联网的请求。幸运的是,由于许多这些挑战非常普遍,因此存在开源项目来为我们处理其中大部分。在本章中,我们将使用大量的开源软件。

通过本章中的食谱,您将学习如何使用开源软件将您的服务公开到公共互联网,控制路由,扩展您服务的功能,并在部署和扩展微服务时处理许多常见挑战。您还将了解使客户端开发对服务更容易的技术以及如何标准化您微服务架构的监控和可观察性。

使用边缘代理服务器控制对您的服务的访问

在第一章《打破单体》中,我们修改了单体代码库以提供对微服务的简单路由。这种方法是可行的,并且需要很少的努力,使其成为一个理想的中间步骤。最终,您的单体将成为您架构开发和弹性的瓶颈。当您尝试扩展服务并构建更多微服务时,您的单体需要在您对服务进行 API 更改时每次都进行更新和部署。此外,您的单体还需要处理对服务的连接,并且可能没有很好地配置来处理边缘关注点,例如负载减轻或断路器。在第一章《打破单体》的“将请求路由到服务”食谱中,我们介绍了边缘代理的概念。使用边缘代理服务器将您的服务公开到公共互联网允许您提取出公开暴露的服务必须解决的许多共享关注点。如请求路由、负载减轻、背压和身份验证等需求都可以在一个单一的边缘代理层中处理,而不是由每个需要公开到互联网的服务重复处理。

边缘代理是一种位于您基础设施边缘的代理服务器,提供对内部服务的访问。您可以将边缘代理视为内部服务架构的“前门”——它允许互联网上的客户端向您部署的内部服务发出请求。有多个开源边缘代理具有强大的功能集和社区,因此我们不必编写和维护自己的边缘代理服务器。最受欢迎的开源边缘代理服务器之一是名为Zuul的,由 Netflix 构建。Zuul 是一个边缘服务,提供动态路由、监控、弹性、安全等功能。Zuul 被打包成一个 Java 库。使用 Java 框架 Spring Boot 编写的服务可以使用内嵌的 Zuul 服务来提供边缘代理功能。在本食谱中,我们将逐步构建一个小型 Zuul 边缘代理,并配置它将请求路由到我们的服务。

运营笔记

继续我们上一章的示例应用程序,想象一下我们的照片消息应用(从现在起我们将称之为pichat)最初是作为一个 Ruby on Rails 单体代码库实现的。当产品首次推出时,我们将应用程序部署到了位于单一弹性负载均衡器ELB)后面的亚马逊网络服务。我们为单体创建了一个单一的自动扩展组ASG),命名为pichat-asg

我们 ASG 中的每个 EC2 实例都在运行 NGINX,它处理静态文件(图像、JavaScript、CSS)的请求,并将请求代理到同一主机上运行的、为我们 Rails 应用程序提供服务的独角兽。SSL 在 ELB 处终止,HTTP 请求被转发到 NGINX。ELB 通过虚拟私有云VPC)内部的 DNS 名称monolith.pichat-int.me进行访问。

我们现在创建了一个单一的attachment-service,该服务处理通过平台发送的消息中附加的视频和图像。attachment-service是用 Java 编写的,使用 Spring Boot 平台,并部署在其自己的 ASG 中,命名为attachment-service-asg,该 ASG 有自己的 ELB。我们创建了一个私有 DNS 记录,命名为attachment-service.pichat-int.me,它指向这个 ELB。

在考虑了这种架构和拓扑之后,我们现在希望根据路径将来自公共互联网的请求路由到我们的 Rails 应用程序或我们新创建的附件服务。

如何做到这一点...

  1. 为了展示如何使用 Zuul 将请求路由到服务,我们首先将创建一个基本的 Java 应用程序,该应用程序将作为我们的边缘代理服务。Spring Cloud Java 项目提供了一个内嵌的 Zuul 服务,这使得创建使用zuul库的服务变得非常简单。我们将首先创建一个基本的 Java 应用程序。创建build.gradle文件,内容如下:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.springframework.boot:spring-boot-gradle-plugin:1.4.4.RELEASE"
        classpath "io.spring.gradle:dependency-management-plugin:0.5.6.RELEASE"
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencyManagement {
    imports {
        mavenBom 'org.springframework.cloud:spring-cloud-netflix:1.4.4.RELEASE'
    }
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '1.4.4.RELEASE'
    compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-zuul'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 创建一个名为EdgeProxyApplication的单个类。这将是我们的应用程序的入口点:
package com.packtpub.microservices.ch02.edgeproxy;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@EnableZuulProxy
@SpringBootApplication
public class EdgeProxyApplication {
    public static void main(String[] args) {
        SpringApplication.run(EdgeProxyApplication.class, args);
    }
}
  1. 在你的应用程序的src/main/resources目录中创建一个名为application.yml的文件。此文件将指定你的路由配置。在这个例子中,我们将假设我们的单体应用程序可以通过monolith.pichat-int.me内部主机访问,并且我们希望将/signup/auth/login路径暴露给公共互联网:
zuul:
 routes:
  signup:
   path: /signup
   url: http://monolith.pichat-int.me
  auth:
   path: /auth/login
   url: http://monolith.pichat-int.me
  1. 使用./gradlew bootRun启动项目,你应该能够访问/signup/auth/login URL,这些将通过代理转发到我们的单体应用程序。

  2. 我们希望将attachment-service URL 暴露给互联网。附件服务公开以下端点:

POST / # Creates an attachment
GET / # Fetch attachments, can filter by message_id
DELETE /:attachment_id # Deletes the specified attachment
GET /:id # Get the specific attachment
  1. 我们需要决定我们想在公共 API 中使用哪些路径。修改application.properties以添加以下条目:
zuul:
 routes:
  signup:
   path: /signup
   url: http://monolith.pichat-int.me
  auth:
   path: /auth/login
   url: http://monolith.pichat-int.me
  attachments:
   path: /attachments/**
   url: http://attachment-service.pichat-int.me
  1. 现在,所有对/attachments/*的请求都将被转发到附件服务以及注册,而auth/login将继续由我们的单体应用程序提供服务。

  2. 我们可以通过在本地运行我们的服务并向localhost:8080/signuplocalhost:8080/auth/loginlocalhost:8080/attachments/foo发送请求来测试这一点。你应该能够看到请求被路由到相应的服务。当然,服务将返回错误,因为attachment-service.pichat-int.me无法解析,但这表明路由按预期工作:

$ curl -D - http://localhost:8080/attachments/foo
HTTP/1.1 500
X-Application-Context: application
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 27 Mar 2018 12:52:21 GMT
Connection: close

{"timestamp":1522155141889,"status":500,"error":"Internal Server Error","exception":"com.netflix.zuul.exception.ZuulException","message":"attachment-service.pichat-int.me"}%

通过旁路扩展你的服务

当你开始开发微服务时,通常会在每个服务中嵌入一定量的样板代码。日志记录、指标和配置等都是常见地从服务复制到服务的功能,导致大量样板代码和复制粘贴的代码。随着你的架构增长和服务的增加,这种设置变得越来越难以维护。通常的结果是,你最终会有一堆不同的日志记录、指标、服务发现等方法,这使得系统难以调试和维护。更改像指标命名空间或向服务发现客户端添加功能这样简单的事情可能需要多个团队和代码库的协调。更现实的是,你的微服务架构将继续在不一致的日志记录、指标和服务发现约定中增长,这使得开发者更难操作,从而增加了整体运营的痛苦。

旁路模式描述了一种模式,即通过在同一台机器上运行一个单独的进程或容器来扩展服务的功能。常见的功能,如指标、日志记录、服务发现、配置,甚至网络 RPC,都可以从你的应用程序中提取出来,并由与之并行的旁路服务处理。这种模式通过实现一个可以由所有服务使用的单独进程来标准化架构中的共享关注点,使其变得容易。

实现边车的一个常见方法是构建一个小的、独立的进程,通过常用的协议(如 HTTP)公开一些功能。例如,假设您希望所有服务都使用一个集中的服务发现服务,而不是依赖于在应用程序配置中设置 DNS 主机和端口。为了实现这一点,您需要确保您的服务发现服务在所有用您的服务和单体编写的语言中都有最新的客户端库。更好的方法是在每个运行服务发现客户端的服务旁边运行一个边车。然后,您的服务可以将请求代理到边车,并让它确定将它们发送到何处。作为额外的优势,您可以配置边车以发出关于服务之间网络 RPC 请求的一致度量。

这是一个如此常见的模式,以至于有多个开源解决方案可供选择。在本食谱中,我们将使用 spring-cloud-netflix-sidecar 项目,该项目包含一个简单的 HTTP API,允许非 JVM 应用程序使用 JVM 客户端库。Netflix 边车假定您正在使用 Eureka,这是一个旨在支持客户端服务发现需求的服务注册表。我们将在后面的章节中更详细地讨论服务发现。边车还假定您的非 JVM 应用程序正在提供健康检查端点,并将使用此端点向 Eureka 宣告其健康状态。我们的 Rails 应用程序公开了一个这样的端点,/health,当正常运行时,将返回一个包含键 status 和 UP 值的小 JSON 有效负载。

如何做到这一点...

  1. 首先创建一个基本的 Spring Boot 服务。包括 Spring Boot Gradle 插件,并为 Spring Boot 和 Spring Cloud Netflix 边车项目添加依赖项:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.springframework.boot:spring-boot-gradle-plugin:1.4.4.RELEASE"
        classpath "io.spring.gradle:dependency-management-plugin:0.5.6.RELEASE"
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencyManagement {
    imports {
        mavenBom 'org.springframework.cloud:spring-cloud-netflix:1.4.4.RELEASE'
    }
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '1.4.4.RELEASE'
    compile group: 'org.springframework.cloud', name: 'spring-cloud-netflix-sidecar', version: '1.4.4.RELEASE'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 我们准备创建一个简单的 Spring Boot 应用程序。我们将使用 @EnableSidecar 注解,它还包括 @EnableZuulProxy@EnableCircuitBreaker@EnableDiscoveryClient 注解:
package com.packtpub.microservices;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.cloud.netflix.sidecar.EnableSidecar;
import org.springframework.stereotype.Controller;

@EnableSidecar
@Controller
@EnableAutoConfiguration
public class SidecarController {
    public static void main(String[] args) {
        SpringApplication.run(SidecarController.class, args);
    }
}
  1. Netflix 边车应用程序需要一些配置设置。创建一个名为 application.yml 的新文件,并包含以下内容:
server:
 port: 5678

sidecar:
 port: 3000
 health-uri: http://localhost:3000/health
  1. 边车现在将公开一个 API,允许非 JVM 应用程序定位已注册到 Eureka 的服务。如果我们的 attachment-service 已注册到 Eureka,边车将代理请求从 http://localhost:5678/attachment/1234http://attachment-service.pichat-int.me/1234

使用 API 网关路由请求到服务

正如我们在其他食谱中看到的,微服务应该提供特定的业务能力,并且应该围绕一个或多个领域概念进行设计,周围是边界上下文。这种设计服务边界的方法可以很好地引导您走向简单、独立可扩展的服务,这些服务可以由一个团队管理并部署,该团队专注于您的应用程序或业务的一个特定领域。

在设计用户界面时,客户端通常会从各种后端微服务中聚合相关但不同的实体。在我们的虚构消息应用中,例如,显示实际消息的屏幕可能包含来自消息服务、媒体服务、赞服务、评论服务等信息。所有这些信息可能很麻烦去收集,并可能导致大量往返后端请求。

例如,将 Web 应用程序从具有服务器端渲染 HTML 的单体应用迁移到单页 JavaScript 应用,很容易导致单个页面加载时产生数百个XMLHttpRequests

为了减少往返后端服务的请求数量,考虑创建一个或多个 API 网关,提供满足客户端需求的 API。API 网关可用于以使 API 用户更容易使用的方式呈现后端实体。在前面的示例中,对单个消息端点的请求可以返回有关消息本身、消息中包含的媒体、赞和评论以及其他信息。

这些实体可以使用扇出请求模式从各种后端服务并发收集:

设计考虑因素

使用 API 网关提供对微服务的访问的好处之一是,您可以为一个特定的客户端创建一个单一、统一的 API。在大多数情况下,您可能希望为移动客户端创建一个特定的 API,甚至可能为 iOS 和 Android 分别创建一个 API。这种 API 网关的实现通常被称为后端前端(BFF),因为它为每个前端应用程序提供了一个单一的逻辑后端。Web 应用程序与移动设备的需求非常不同。

在我们的情况下,我们将专注于创建一个端点,该端点提供消息查看屏幕所需的所有数据。这包括消息本身以及附件(如果有)、发送者的用户详情以及任何额外的消息接收者。如果消息是公开的,它还可以有赞和评论,我们假设这些由一个独立的服务提供。我们的端点可能看起来像这样:

GET /message_details/:message_id

该端点将返回类似于以下内容的响应:

{
  "message_details": {
    "message": {
      "id": 1234,
      "body": "Hi There!",
      "from_user_id": "user:4321"
    },
    "attachments": [{
      "id": 4543,
      "media_type": 1,
      "url": "http://..."
    }],
    "from_user": {
      "username": "paulosman",
      "profile_pic": "http://...",
      "display_name": "Paul Osman"
    },
    "recipients": [
      ...
    ],
    "likes": 200,
    "comments": [{
      "id": 943,
      "body": "cool pic",
      "user": {
        "username": "somebody",
        "profile_pic": "http://..."
      }
    }]
  }
}

此响应应包含客户端显示我们的消息查看屏幕所需的所有内容。数据本身来自各种服务,但正如我们将看到的,我们的 API 网关完成了这些请求并汇总了响应。

如何操作...

API 网关负责公开 API,进行多次服务调用,聚合结果,并将它们返回给客户端。Finagle Scala 框架通过将服务调用表示为可以组合表示依赖关系的 futures 来使这变得自然。为了与其他本书中的示例保持一致,我们将使用 Spring Boot 框架在 Java 中构建一个小型的示例网关服务:

  1. 创建项目骨架。创建一个新的 Java 项目,并在 Gradle 构建文件中添加以下依赖项和插件。在本菜谱中,我们将使用 Spring Boot 和 Hystrix:
plugins {
    id 'org.springframework.boot' version '1.5.9.RELEASE'
}

group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

apply plugin: 'java'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '1.5.9.RELEASE'
    compile group: 'com.netflix.hystrix', name: 'hystrix-core', version: '1.0.2'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}

在上一节中的 JSON 示例中,我们可以清楚地看到我们正在收集和聚合一些独特的领域概念。为了本例的目的,我们将假设我们有一个消息服务,它可以检索有关消息的信息,包括点赞、评论和附件,以及一个用户服务。我们的网关服务将调用消息服务以检索消息本身,然后调用其他服务以获取相关数据,我们将这些数据在一个单独的响应中拼接起来。为了本菜谱的目的,假设消息服务运行在端口 4567 上,而用户服务运行在端口 4568 上。我们将创建一些存根服务来模拟这些假设的微服务的数据。

  1. 创建一个模型来表示我们的 Message 数据:
package com.packtpub.microservices.gateway.models;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

@JsonIgnoreProperties(ignoreUnknown = false)
public class Message {

    private String id;
    private String body;

    @JsonProperty("from_user_id")
    private String fromUserId;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }

    public String getFromUserId() {
        return fromUserId;
    }

    public void setFromUserId(String fromUserId) {
        this.fromUserId = fromUserId;
    }
}

重要的是,非依赖性服务调用应以非阻塞、异步的方式进行。幸运的是,Hystrix 有一个选项可以异步执行命令,返回 Future<T>

  1. 创建一个新的包,例如 com.packtpub.microservices.gateway.commands,并包含以下类:
  • 创建名为 AttachmentCommand 的类,内容如下:
package com.packtpub.microservices.gateway.commands;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;

public class AttachmentCommand extends HystrixCommand<String> {
    private String messageId;

    public AttachmentCommand(String messageId) {
        super(HystrixCommandGroupKey.Factory.asKey("AttachmentCommand"));
        this.messageId = messageId;
    }

    @Override
    public String run() {
        RestTemplate template = new RestTemplate();
        String attachmentsUrl = "http://localhost:4567/message/" + messageId + "/attachments";
        ResponseEntity<String> response = template.getForEntity(attachmentsUrl, String.class);
        return response.getBody();
    }
}
  • 创建名为 CommentCommand 的类,内容如下:
package com.packtpub.microservices.commands;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;

public class CommentCommand extends HystrixCommand<String> {

    private String messageId;

    public CommentCommand(String messageId) {
        super(HystrixCommandGroupKey.Factory.asKey("CommentGroup"));
        this.messageId = messageId;
    }

    @Override
    public String run() {
        RestTemplate template = new RestTemplate();
        String commentsUrl = "http://localhost:4567/message/" + messageId + "/comments";
        ResponseEntity<String> response = template.getForEntity(commentsUrl, String.class);
        return response.getBody();
    }
}
  • 创建名为 LikeCommand 的类,内容如下:
package com.packtpub.microservices.commands;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;

public class LikeCommand extends HystrixCommand<String> {

    private String messageId;

    public LikeCommand(String messageId) {
        super(HystrixCommandGroupKey.Factory.asKey("Likegroup"));
        this.messageId = messageId;
    }

    @Override
    public String run() {
        RestTemplate template = new RestTemplate();
        String likesUrl = "http://localhost:4567/message/" + messageId + "/likes";
        ResponseEntity<String> response = template.getForEntity(likesUrl, String.class);
        return response.getBody();
    }
}
  • 我们的 MessageClient 类与之前的示例略有不同——它不会返回服务响应中的 JSON 字符串,而是会返回一个对象表示,在这种情况下,是我们的 Message 类的一个实例:
package com.packtpub.microservices.commands;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import com.packtpub.microservices.models.Message;
import org.springframework.web.client.RestTemplate;

public class MessageClient extends HystrixCommand<Message> {

    private final String id;

    public MessageClient(String id) {
        super(HystrixCommandGroupKey.Factory.asKey("MessageGroup"));
        this.id = id;
    }

    @Override
    public Message run() {
        RestTemplate template = new RestTemplate();
        String messageServiceUrl = "http://localhost:4567/message/" + id;
        Message message = template.getForObject(messageServiceUrl, Message.class);
        return message;
    }
}
  • 创建名为 UserCommand 的类,内容如下:
package com.packtpub.microservices.commands;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;

public class UserCommand extends HystrixCommand<String> {

    private String id;

    public UserCommand(String id) {
        super(HystrixCommandGroupKey.Factory.asKey("UserGroup"));
        this.id = id;
    }

    @Override
    public String run() {
        RestTemplate template = new RestTemplate();
        String userServiceUrl = "http://localhost:4568/user/" + id;
        ResponseEntity<String> response = template.getForEntity(userServiceUrl, String.class);
        return response.getBody();
    }
}
  1. 在单个控制器中拼接这些 Hystrix 命令的执行,该控制器将我们的 API 作为 /message_details/:message_id 端点公开:
package com.packtpub.microservices;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.packtpub.microservices.commands.*;
import com.packtpub.microservices.models.Message;
import org.springframework.boot.SpringApplication;
import org.springframework.http.MediaType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;

@SpringBootApplication
@RestController
public class MainController {

    @RequestMapping(value = "/message_details/{id}", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Map<String, HashMap<String, String>> messageDetails(@PathVariable String id)
            throws ExecutionException, InterruptedException, IOException {

        Map<String, HashMap<String, String>> result = new HashMap<>();
        HashMap<String, String> innerResult = new HashMap<>();

        Message message = new MessageClient(id).run();
        String messageId = message.getId();

        Future<String> user = new UserClient(message.getFromUserId()).queue();
        Future<String> attachments = new AttachmentClient(messageId).queue();
        Future<String> likes = new LikeClient(messageId).queue();
        Future<String> comments = new CommentClient(messageId).queue();

        ObjectMapper mapper = new ObjectMapper();
        StringWriter writer = new StringWriter();
        mapper.writeValue(writer, message);

        innerResult.put("message", writer.toString());
        innerResult.put("from_user", user.get());
        innerResult.put("attachments", attachments.get());
        innerResult.put("comments", comments.get());
        innerResult.put("likes", likes.get());

        result.put("message_details", innerResult);

        return result;
    }

    public static void main(String[] args) {
        SpringApplication.run(MainController.class, args);
    }
}
  1. 这就是了。使用 ./gradlew bootRun 运行服务,并通过以下请求进行测试:
$ curl -H "Content-Type: application/json" http://localhost:8080/message_details/1234 

使用 Hystrix 阻止级联故障

在复杂系统中,故障诊断可能很困难。通常,症状可能出现在原因很远的地方。用户可能会因为一些管理个人资料图片或与用户资料间接相关的下游服务而导致登录时出现高于正常的错误率。一个服务的错误往往无谓地传播到用户请求,并负面影响用户体验和因此对您应用程序的信任。此外,一个失败的服务可能会产生级联效应,将小系统故障变成高严重性、影响客户的突发事件。在设计微服务时,考虑故障隔离并决定如何处理不同的故障场景是很重要的。

可以使用多种模式来提高分布式系统的弹性。断路器是常用的模式,用于从向暂时过载的服务发送请求中退避。断路器最初在 Michael Nygard 的书籍《Release It!》中描述。调用服务默认处于关闭状态,意味着请求被发送到下游服务。

如果调用服务在很短的时间内收到过多的失败,它可以将其断路器的状态更改为打开,并开始快速失败。而不是等待下游服务再次失败并增加失败服务的负载,它只是向上游服务发送错误,给过载的服务恢复的时间。经过一段时间后,断路器再次关闭,请求开始流向下游服务。

有许多可用的框架和库实现了断路器。一些框架,如 Twitter 的 Finagle,会自动将每个 RPC 调用包装在断路器中。在我们的例子中,我们将使用流行的 Netflix 库hystrix。Hystrix 是一个通用、容错库,它将隔离的代码结构化为命令。当执行命令时,它会检查断路器的状态以决定是否发出或短路请求。

如何实现...

Hystrix 作为一个 Java 库提供,我们将通过构建一个小型的 Java Spring Boot 应用程序来演示其使用:

  1. 创建一个新的 Java 应用程序,并将依赖项添加到您的build.gradle文件中:
plugins {
    id 'org.springframework.boot' version '1.5.9.RELEASE'
}

group 'com.packetpub.microservices'
version '1.0-SNAPSHOT'

apply plugin: 'java'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '1.5.9.RELEASE'
    compile group: 'com.netflix.hystrix', name: 'hystrix-core', version: '1.0.2'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 我们将创建一个简单的MainController,它返回一个简单的消息。这是一个虚构的例子,但它演示了上游服务对下游服务的调用。最初,我们的应用程序将只返回硬编码的Hello, World!消息。接下来,我们将字符串移动到 Hystrix 命令中。最后,我们将消息移动到由 Hystrix 命令包装的服务调用中:
package com.packtpub.microservices;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@EnableAutoConfiguration
@RestController
public class MainController {
    @RequestMapping("/message")
    public String message() {
        return "Hello, World!";
    }

    public static void main(String[] args) {
        SpringApplication.run(MainController.class, args);
    }
}
  1. 将消息移动到HystrixCommand
package com.packtpub.microservices;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;

public class CommandHelloWorld extends HystrixCommand<String> {

    private String name;

    CommandHelloWorld(String name) {
        super(HystrixCommandGroupKey.Factory.asKey("ExampleGroup"));
        this.name = name;
    }

    @Override
    public String run() {
        return "Hello, " + name + "!";
    }
}
  1. MainController中的方法替换为使用HystrixCommand
@RequestMapping("/message")
public String message() {
    return new CommandHelloWorld("Paul").execute();
}
  1. 将消息生成移动到另一个服务。在这里,我们硬编码假设的消息服务 URL,这不是一个好的实践,但为了演示目的可以这样做:
package com.packtpub.microservices;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;

public class CommandHelloWorld extends HystrixCommand<String> {

    CommandHelloWorld() {
        super(HystrixCommandGroupKey.Factory.asKey("ExampleGroup"));
    }

    @Override
    public String run() {
        RestTemplate restTemplate = new RestTemplate();
        String messageResourceUrl = "http://localhost:4567/";
        ResponseEntity<String> response = restTemplate.getForEntity(messageResourceUrl, String.class);
        return response.getBody();
    }

    @Override
    public String getFallback() {
        return "Hello, Fallback Message";
    }
}
  1. 更新MainController类以包含以下内容:
package com.packetpub.microservices;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@EnableAutoConfiguration
@RestController
public class MainController {

    @RequestMapping("/message")
    public String message() {
        return new CommandHelloWorld().execute();
    }

    public static void main(String[] args) {
        SpringApplication.run(MainController.class, args);
    }
}
  1. 我们的 MainController 类现在通过一个 Hystrix 命令封装了对服务的调用,以生成要发送给客户端的消息。你可以通过创建一个非常简单的生成消息字符串的服务来测试这一点。sinatra 是一个简单易用的 Ruby 库,非常适合创建测试服务。创建一个名为 message-service.rb 的新文件:
require 'sinatra'

get '/' do
  "Hello from Sinatra"
end
  1. 通过运行 ruby message-service.rb 来启动服务,然后向你的 Hystrix 启用的服务发送几个示例请求。你可以通过修改服务以返回 503 来模拟失败,这表示它暂时过载:
require 'sinatra'

get '/' do
  halt 503, 'Busy'
end

你的 Spring 服务现在应该尝试连接到服务,但在遇到 503 时使用回退值。此外,在尝试了多次之后,命令的断路器将被触发,服务将开始默认使用回退一段时间。

速率限制

除了断路器等技术之外,速率限制还可以是防止分布式系统级联故障的有效方法。速率限制可以有效地防止垃圾邮件,抵御 拒绝服务 (DoS) 攻击,并保护系统的一部分不会因为过多的并发请求而过载。通常作为全局或按客户端限制实现,速率限制通常是代理或负载均衡器的一部分。在这个配方中,我们将使用 NGINX,这是一个流行的开源负载均衡器、Web 服务器和反向代理。

大多数速率限制实现都使用 漏桶算法——一个起源于计算机网络交换机和电信网络的算法。正如其名所示,漏桶算法基于一个隐喻,即桶中有一个小漏洞,它控制着一个恒定的速率。水以脉冲的形式倒入桶中,但漏洞保证了水在桶中以稳定、固定的速率存在。如果水的流入速度超过水从桶中流出,最终桶会溢出。在这种情况下,溢出代表被丢弃的请求。

当然可以实施自己的速率限制解决方案;甚至有一些算法的实现是开源的,可供使用。然而,使用像 NGINX 这样的产品来为你进行速率限制要容易得多。在这个配方中,我们将配置 NGINX 来代理对我们的微服务的请求。

如何做到这一点...

  1. 通过运行以下命令来安装 NGINX:
apt-get install nginx
  1. nginx 有一个 config 文件,nginx.conf。在基于 Ubuntu 的 Linux 系统上,这可能会在 /etc/nginx/nginx.conf 目录中。打开文件,查找 http 块并添加以下内容:
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
    location /auth/signin {
        limit_req zone=mylimit;
        proxy_pass http://my_upstream;
    }
}

如前述代码所示,速率限制是通过两个配置指令实现的。limit_req_zone指令定义了速率限制的参数。在这个例子中,我们根据客户端的 IP 地址实现了每秒 10 个请求的速率限制。limit_req指令将我们的速率限制应用于特定的路径或位置。在这种情况下,我们将其应用于对/auth/signin的所有请求,可能是因为我们不希望机器人编写创建账户的脚本!

使用服务网格处理共享关注点

随着 Web 服务框架和标准的演变,样板代码或共享应用程序关注点的数量减少了。这是因为,集体地,我们确定了我们应用程序的哪些部分是通用的,因此不需要每个程序员或团队重新实现。当人们刚开始联网计算机时,编写网络感知应用程序的程序员必须担心许多现在由操作系统网络堆栈抽象出的底层细节。同样,所有微服务共享某些通用关注点。例如,Twitter 的 Finagle 框架将所有网络调用封装在断路器中,增加了容错性并隔离了系统中的故障。Finagle 和 Spring Boot,这是我们一直在使用的大多数这些食谱的 Java 框架,都支持暴露一个标准的指标端点,该端点标准化了为微服务收集的基本网络、JVM 和应用指标。

每个微服务都应该考虑一些共享的应用程序关注点。从可观察性的角度来看,服务应该努力输出一致的指标和结构化日志。为了提高我们系统的可靠性,服务应该将网络调用封装在断路器中,并实现一致的重试和退避逻辑。为了支持网络和服务拓扑的变化,服务应该考虑实现客户端负载均衡并使用集中式服务发现。

而不是在每个服务中实现所有这些功能,理想的做法是将它们抽象出来,使其位于我们的应用程序代码之外,这样就可以单独维护和操作。就像我们操作系统网络堆栈的功能一样,如果每个这些功能都是由我们的应用程序可以依赖的某个部分实现的,我们就无需担心它们是否可用。这就是服务网格背后的理念。

运行服务网格配置涉及在每个微服务后面运行一个网络代理。服务之间不是直接通信,而是通过各自的代理进行通信,这些代理作为边车安装。实际上,你的服务将与运行在本地主机的自己的代理进行通信。当网络请求通过服务代理发送时,代理可以控制要发出的指标和输出的日志消息。代理还可以直接与你的服务注册表集成,并在活动节点之间均匀分配请求,跟踪故障并在达到一定阈值时快速失败。以这种方式运行系统可以减轻系统的操作复杂性,同时提高架构的可靠性和可观察性。

与本章讨论的大多数食谱一样,运行服务网格有众多开源解决方案。我们将重点关注 Linkerd,这是一个由 buoyant 构建和维护的开源代理服务器。Linkerd 的原始作者在 Twitter 工作过,之后成立了 buoyant,因此 Linkerd 集成了 Twitter 团队学到的许多经验教训。它与 Finagle Scala 框架共享许多功能,但可以与任何语言编写的服务一起使用。在本食谱中,我们将介绍安装和配置 Linkerd,并讨论我们如何使用它来控制 Ruby on Rails 单体 API 和我们新开发的媒体服务之间的通信。

如何做到这一点...

为了演示在代理后面运行服务,我们将安装并运行一个 Linkerd 实例,并配置它来处理对你的服务发出的请求。Linkerd 网站上有关于在 Docker、Kubernetes 和其他选项中运行它的说明。为了简化,我们将专注于在本地运行 Linkerd 和我们的服务:

  1. github.com/linkerd/linkerd/releases 下载最新的 Linkerd 版本。

  2. 通过执行以下命令提取 tarball:

$ tar xvfz linkerd-1.3.4.tgz
$ cd linkerd-1.3.4
  1. 默认情况下,linkerd 随附一个使用基于文件的服务的发现配置。我们将在下一节讨论此方法的替代方案,但现在,创建一个名为 disco/media-service 的新文件,并包含以下内容:
localhost 8080
  1. 这将主机名和端口映射到名为 media-service 的服务。Linkerd 使用此文件通过名称查找服务,并确定主机名和端口的映射。

  2. 按照以下方式运行 Linkerd:

$ ./linkerd-1.3.4-exec config/linkerd.yaml
  1. 在端口 8080 上启动服务。切换到 media-service 目录并运行服务:
$ ./gradlew bootRun
  1. Linkerd 正在端口 4140 上运行。使用以下请求测试代理是否工作:
$ curl -H "Host: attachment-service" http://localhost:4140/

第三章:交互式服务通信

本章将涵盖以下食谱:

  • 服务间通信

  • 制作并发异步请求

  • 使用服务发现查找服务

  • 服务器端负载均衡

  • 客户端负载均衡

  • 构建事件驱动的微服务

  • API 演进

简介

在前面的章节中,我们已经介绍了如何开始将单体代码库拆分成微服务,以及将你的微服务公开到公共互联网的最佳实践。到目前为止,我们假设所有的微服务都是没有依赖的独立应用程序。这些简单的微服务接收请求,检索数据或写入数据库,并向客户端返回响应。这种线性工作流程在现实世界的系统中很少见。在现实世界的微服务架构中,服务将经常需要调用其他服务以满足用户请求。典型的用户请求通常会在你的系统中创建数十个对服务的请求。

管理服务间的通信带来了一系列挑战。在服务能够与其他服务通信之前,它需要通过某种服务发现机制来定位它。在生成对下游服务的请求时,我们还需要一种方法来在服务的各个实例之间分配流量,以最小化延迟并均匀分配负载,同时不损害数据完整性。我们需要考虑如何处理服务故障并防止它们在我们的系统中级联。

有时一个服务需要异步地与其他服务通信,在这些情况下,我们可以使用事件驱动的架构模式来创建反应式工作流程。将我们的系统拆分成多个服务也意味着不同的服务将独立地演进它们的 API,因此我们需要处理变化的方法,以确保不会破坏上游服务。

在本章中,我们将讨论旨在解决这些挑战的食谱。到本章结束时,你将能够自信地处理在微服务架构中我们必然会需要的各种交互。

服务间通信

在大规模系统中,问题很少出现在服务本身,而更多出现在服务之间的通信中。因此,我们需要仔细考虑服务之间通信的所有各种挑战。在讨论服务之间通信时,可视化我们系统中信息流是有用的。数据在两个方向上流动——从客户端(上游)到数据库或事件总线(下游),以请求的形式,并以响应的形式返回。当我们提到上游服务时,我们是在描述系统中信息流中更靠近用户的部分。当我们提到下游服务时,我们是在描述系统中离用户更远的部分——换句话说,用户发起一个请求,该请求被路由到服务,然后该服务向其他下游服务发送请求,如以下图所示:

在前面的图中,原始的 User 位于 edge-proxy-service 的上游,edge-proxy-service 位于 auth-serviceattachment-serviceuser-service 的上游。

为了演示服务之间的通信,我们将创建一个简单的服务,该服务使用 Spring Boot Java 框架同步调用另一个服务。遵循我们虚构的消息应用示例,我们将创建一个负责发送消息的消息服务。消息服务必须调用社交图服务,以确定消息的发送者和接收者是否是朋友,然后才允许发送消息。以下简化图展示了服务之间的关系:

如您所见,用户从 /message 端点发送一个 POST 请求到 message-service。然后,message-service 服务使用 /friendships/:id 端点向 social-service 服务发送一个 HTTP GET 请求。social-service 服务返回一个表示用户友谊的 JSON。

如何实现...

  1. 创建一个名为 message-service 的新 Java/Gradle 项目,并将以下内容添加到 build.gradle 文件中:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 创建一个名为 com.packtpub.microservices.ch03.message 的新包和一个名为 Application 的新类。这将是我们的服务入口点:
package com.packtpub.microservices.ch03.message;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  1. 创建模型。创建一个名为 com.packtpub.microservices.ch03.message.models 的包和一个名为 Message 的类。这是消息的内部表示。这里缺少很多内容。我们实际上没有在这个代码中持久化消息,因为最好保持这个示例简单:
package com.packtpub.microservices.ch03.message.models;

public class Message {

    private String toUser;
    private String fromUser;
    private String body;

    public Message() {}

    public Message(String toUser, String fromUser, String body) {
        this.toUser = toUser;
        this.fromUser = fromUser;
        this.body = body;
    }

    public String getToUser() {
        return toUser;
    }

    public String getFromUser() {
        return fromUser;
    }

    public String getBody() {
        return body;
    }
}
  1. 创建一个名为 com.packtpub.microservices.ch03.message.controllers 的新包和一个名为 MessageController 的新类。目前,我们的控制器除了接受请求、解析 JSON 并返回消息实例之外,没有做太多事情,正如您可以从以下代码中看到的那样:
package com.packtpub.microservices.ch03.message.controllers;

import com.packtpub.microservices.models.Message;
import org.springframework.web.bind.annotation.*;

@RestController
public class MessageController {

    @RequestMapping(
            path="/messages",
            method=RequestMethod.POST,
            produces="application/json")
    public Message create(@RequestBody Message message) {
        return message;
    }
}
  1. 通过运行它并尝试发送一个简单的请求来测试这个基本服务:
$ ./gradlew bootRun
Starting a Gradle Daemon, 1 busy Daemon could not be reused, use --status for details

> Task :bootRun

 . ____ _ __ _ _
 /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/ ___)| |_)| | | | | || (_| | ) ) ) )
 ' |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot :: (v1.5.9.RELEASE)

...

查看以下命令行:

$ curl -H "Content-Type: application/json" -X POST http://localhost:8080/messages -d'{"toUser": "reader", "fromUser": "paulosman", "body": "Hello, World"}'

{"toUser":"reader","fromUser":"paulosman","body":"Hello, World"}

现在我们有一个基本的服务正在运行,但它相当愚蠢,没有做太多。我们不会在本章中讨论持久性,但让我们通过检查社交服务来验证我们的两个用户之间是否有友谊,从而增加一些智能,在允许发送消息之前。为了我们的示例,假设我们有一个可以让我们通过请求检查用户之间关系的有效社交服务,如下所示:

GET /friendships?username=paulosman&filter=reader

{
  "username": "paulosman",
  "friendships": [
    "reader"
  ]
}
  1. 在我们能够消费此服务之前,让我们创建一个模型来存储其响应。在 com.packtpub.microservices.ch03.message.models 包中,创建一个名为 UserFriendships 的类:
package com.packtpub.microservices.ch03.message.models;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import java.util.List;

@JsonIgnoreProperties(ignoreUnknown = true)
public class UserFriendships {
    private String username;
    private List<String> friendships;

    public UserFriendships() {}

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public List<String> getFriendships() {
        return friendships;
    }

    public void setFriendships(List<String> friendships) {
        this.friendships = friendships;
    }
}
  1. 修改 MessageController,添加一个方法来获取一个用户的友谊列表,可选地按用户名过滤。请注意,在这个例子中,我们硬编码了 URL,这是一个坏习惯。我们将在下一个配方中讨论这个问题的替代方案。查看以下代码:
private List<String> getFriendsForUser(String username, String filter) {
    String url = "http://localhost:4567/friendships?username=" + username + "&filter=" + filter;
    RestTemplate template = new RestTemplate();
    UserFriendships friendships = template.getForObject(url, UserFriendships.class);
    return friendships.getFriendships();
}
  1. 修改我们之前编写的 create 方法。如果用户是朋友,我们将继续并像以前一样返回消息;如果用户不是朋友,服务将响应 403,表示请求被禁止:
@RequestMapping(
            path="/messages",
            method=RequestMethod.POST,
            produces="application/json")
    public ResponseEntity<Message> create(@RequestBody Message message) {
        List<String> friendships = getFriendsForUser(message.getFromUser(), message.getToUser());

        if (friendships.isEmpty())
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();

        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest().path("/{id}")
                .buildAndExpand(message.getFromUser()).toUri();

        return ResponseEntity.created(location).build();
    }

异步请求

在上一个配方中,我们为每个请求从消息服务到社交服务进行单个服务调用。这有一个好处,就是实现起来非常简单,当使用单线程语言,如 Python、Ruby 或 JavaScript 时,这通常是唯一的选择。以这种方式同步执行网络调用在每次请求只进行一次时是可以接受的——因为无论如何你都无法在调用完成之前响应用户,所以调用阻塞线程并不重要。然而,当你进行多个请求时,阻塞网络调用将严重影响应用程序的性能和可伸缩性。我们需要的是一个简单的方法来利用 Java 的并发特性。

如果你正在使用 Scala 编写你的微服务,你可以利用 Future 类型,它用于表示异步计算。Finagle RPC 框架甚至将 futures 作为其建模依赖 RPC 的基本抽象之一。Java 也有 futures,Spring Boot 框架提供了一些有用的工具,使得包装网络调用变得简单,从而使它们异步,因此是非阻塞的。

在这个菜谱中,我们将重新配置我们在上一个菜谱中引入的消息服务。而不是检查消息的发送者和接收者是否是朋友,我们现在假设我们的应用程序使用非对称关注模型。为了一个用户向另一个用户发送消息,这两个用户必须相互关注。这要求消息服务向社交服务进行两次网络调用,检查发送者是否关注接收者,同时检查接收者是否关注发送者。以下简化图表示了服务之间的关系:

Spring Boot 有一些有用的工具,我们可以使用 Java 的CompletableFuture类型来使方法异步。我们将修改我们之前的消息服务,使其对搜索服务进行两次并发调用。

如何做到这一点...

  1. 打开MessageController文件,插入以下内容:
package com.packtpub.microservices.ch03.message.controllers;

import com.packtpub.microservices.models.Message;
import com.packtpub.microservices.models.UserFriendships;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import java.net.URI;
import java.util.List;

@RestController
public class MessageController {

    @RequestMapping(
            path="/messages",
            method=RequestMethod.POST,
            produces="application/json")
    public ResponseEntity<Message> create(@RequestBody Message message) {
        List<String> friendships = getFriendsForUser(message.getFromUser(), message.getToUser());

        if (friendships.isEmpty())
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();

        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest().path("/{id}")
                .buildAndExpand(message.getFromUser()).toUri();

        return ResponseEntity.created(location).build();
    }

    private List<String> getFriendsForUser(String username, String filter) {
        String url = "http://localhost:4567/friendships?username=" + username + "&filter=" + filter;
        RestTemplate template = new RestTemplate();
        UserFriendships friendships = template.getForObject(url, UserFriendships.class);
        return friendships.getFriendships();
    }
}
  1. getFriendsForUser方法替换为一个新的方法,称为isFollowing。我们给这个新方法添加了@Async注解,这告诉 Spring Boot 这个方法将在不同的线程中运行:
import org.springframework.scheduling.annotation.Async;
import java.util.concurrent.CompletableFuture;

...

@Async
public CompletableFuture<Boolean> isFollowing(String fromUser, String toUser) {

    String url = String.format(
      "http://localhost:4567/followings?user=%s&filter=%s",
      fromUser, toUser);

    RestTemplate template = new RestTemplate();
    UserFollowings followings = template.forObject(url, UserFollowings.class);

    return CompletableFuture.completedFuture(
        followings.getFollowings().isEmpty()
    );
}
  1. 修改create方法以进行两个服务调用。我们需要等待这两个调用都完成后才能决定如何进行,但这两个服务调用将并发进行:
@RequestMapping(
            path="/messages",
            method=RequestMethod.POST,
            produces="application/json")
    public ResponseEntity<Message> create(@RequestBody Message message) {

    CompletableFuture<Boolean> result1 = isFollowing(message.getFromUser(), message.getToUser());
    CompletableFuture<Boolean> result2 = isFollowing(message.getToUser(), message.getFromUser());

    CompletableFuture.allOf(result1, result2).join();

    // if both are not true, respond with a 403
    if (!(result1.get() && result2.get()))
        ResponseEntity.status(HttpStatus.FORBIDDEN).build();

    ... // proceed

}
  1. 为了使@Async注解在单独的线程上调度方法,我们需要配置一个Executor。这在我们Application类中完成,如下所示:
package com.packtpub.microservices;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@SpringBootApplication
@EnableAsync
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args).close();
    }

    @Bean
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(2);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("SocialServiceCall-");
        executor.initialize();
        return executor;
    }

}

我们的服务现在并发异步地调用社交服务,以确保消息的发送者和接收者相互关注。我们使用定义在我们应用程序配置部分中的Executor来自定义我们的Async调度器。我们已经配置了ThreadPoolTaskExecutor类,将线程数限制为2,队列大小为500。在配置Executor时需要考虑许多因素,例如预期服务接收的流量量和服务处理请求的平均时间。在这个例子中,我们将保持这些值不变。

服务发现

在服务能够相互调用之前,它们需要能够使用某种服务发现机制找到彼此。这意味着能够将服务名称转换为网络位置(IP 地址和端口)。传统的应用程序维护了要发送请求的服务网络位置,可能在一个配置文件中(或者更糟糕的是,硬编码在应用程序代码中)。这种方法假设网络位置相对静态,这在现代云原生应用程序中不会是情况。微服务架构的拓扑结构不断变化。节点通过自动扩展被添加和删除,我们必须假设某些节点可能会完全失败或通过具有不可接受的高延迟来处理请求。随着微服务架构的发展,你需要考虑一个功能更丰富的服务发现机制。

在选择服务发现机制时,用于支持你的服务注册表的数据存储极为重要。你需要一个经过良好测试、经验丰富的系统。Apache ZooKeeper 是一个开源的分层键值存储,通常用于分布式锁定、服务发现、维护配置信息和其他分布式协调任务。ZooKeeper 的发展部分受到 2006 年谷歌发表的一篇论文的启发,该论文描述了 Chubby,这是一个用于分布式锁存储的内部开发系统。在这个配方中,我们将使用 ZooKeeper 来构建服务发现机制。

Spring Cloud ZooKeeper 是一个项目,它为 Spring Boot 应用程序提供简单的 ZooKeeper 集成。

如何操作...

对于这个配方,有两个步骤集合,如下一节所示。

在服务注册表中注册

此配方需要一个正在运行的 ZooKeeper 集群。至少,你需要在你的开发机器上本地运行一个 ZooKeeper 节点。有关安装和运行 ZooKeeper 的说明,请访问优秀的 ZooKeeper 文档。查看以下步骤:

  1. 在这个示例中,我们将创建一个服务来处理用户账户的创建和检索。创建一个名为 users-service 的新 Gradle Java 应用程序,并使用以下 build.gradle 文件:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'io.spring.gradle', name: 'dependency-management-plugin', version: '0.5.6.RELEASE'
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: "io.spring.dependency-management"

sourceCompatibility = 1.8

dependencyManagement {
    imports {
        mavenBom 'org.springframework.cloud:spring-cloud-zookeeper-dependencies:1.1.1.RELEASE'
    }
}

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'io.reactivex', name: 'rxjava', version: '1.1.5'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
    compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-zookeeper-discovery', version: '1.1.1.RELEASE'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 由于我们已经将 spring-boot-starter-zookeeper-discovery 声明为依赖项,我们可以访问必要的注解来告诉我们的应用程序在启动时将自己注册到 ZooKeeper 服务注册表中。创建一个名为 Application 的新类,它将作为我们服务的入口点:
package com.packtpub.microservices.ch03.servicediscovery;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@EnableDiscoveryClient
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  1. 应用程序现在尝试连接到 ZooKeeper 节点,默认情况下在本地主机的 2181 端口上运行。这个默认设置适用于本地开发,但在生产环境中仍然需要更改。添加一个名为 src/resources/application.yml 的文件,并包含以下内容:
spring:
  cloud:
    zookeeper:
      connect-string: localhost:2181
  1. 要在服务注册表中给你的服务一个有意义的名称,修改 application.yml 文件并添加以下内容:
spring:
  cloud:
    zookeeper:
      connect-string: localhost:2181
  application:
    name: users-service

查找服务

现在我们已经将服务注册到服务注册表中,我们将创建另一个服务来演示使用 Spring ZooKeeper DiscoveryClient查找该服务的运行实例:

  1. 打开我们之前创建的消息服务客户端。将以下行添加到build.gradle文件中:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'io.spring.gradle', name: 'dependency-management-plugin', version: '0.5.6.RELEASE'
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = 1.8

dependencyManagement {
 imports {
 mavenBom 'org.springframework.cloud:spring-cloud-zookeeper-dependencies:1.1.1.RELEASE'
    }
}

repositories {
    mavenCentral()
}

dependencies {
 compile 'io.reactivex:rxjava:1.3.4'
 compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-zookeeper-discovery', version: '1.1.1.RELEASE'
    compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-feign', version: '1.2.5.RELEASE'
    compile group: 'org.springframework.kafka', name: 'spring-kafka', version: '2.1.1.RELEASE'    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 我们正在使用由 Netflix 开发的 HTTP 客户端,称为Feign。Feign 允许你声明性地构建 HTTP 客户端,并默认支持服务发现。创建一个名为UsersClient.java的新文件,并包含以下内容:
package com.packtpub.microservices.ch03.servicediscovery.clients;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.feign.EnableFeignClients;
import org.springframework.cloud.netflix.feign.FeignClient;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.List;

@Configuration
@EnableFeignClients
@EnableDiscoveryClient
public class UsersClient {

    @Autowired
    private Client client;

    @FeignClient("users-service")
    interface Client {
        @RequestMapping(path = "/followings/{userId}", method = RequestMethod.GET)
        @ResponseBody
        List<String> getFollowings(@PathVariable("userId") String userId);
    }

    public List<String> getFollowings(String userId) {
        return client.getFollowings(userId);
    }
}
  1. 打开MessageController.java文件,并将UsersClient的一个实例作为字段添加:
package com.packtpub.microservices;
...
@RestController
public class MessagesController {
    ...
    @Autowired
    private UsersClient usersClient;
    ...
}
  1. isFollowing方法中手动构建 URL 而不是,我们可以使用 Feign 客户端自动获取用户的友谊列表,如下所示:
@Async
public CompletableFuture<Boolean> isFollowing(String fromUser, String toUser) {

    List<String> friends = usersClient.getFollowings(fromUser)
            .stream()
            .filter(toUser::equals)
            .collect(Collectors.toList());

    return CompletableFuture.completedFuture(friends.isEmpty());
}

由于我们使用服务注册表,我们不再需要担心那些持有可能更改的主机名值的笨拙配置。此外,我们现在可以开始决定我们想要如何将负载分配给服务的可用实例。

服务器端负载均衡

当考虑在运行应用程序实例的服务器集群中分配负载时,思考一下网络应用程序架构的简要(且不完整)历史很有趣。一些最早的 Web 应用程序是由 Apache 或类似 Web 服务器守护程序软件托管的静态 HTML 页面。逐渐地,应用程序变得更加动态,使用诸如通过 CGI 执行的服务器端脚本等技术。即使是动态应用程序,也仍然是托管并由 Web 服务器守护程序直接提供服务的文件。这种简单的架构长期有效。然而,随着应用程序接收到的流量增加,需要一种方法来在应用程序的相同无状态实例之间分配负载。

存在许多负载均衡技术,包括轮询 DNS 或 DNS 地理位置。对于微服务而言,最简单且最常见的形式的负载均衡是使用一个软件程序,该程序将请求转发到后端服务器集群中的一个。根据我们选择的负载均衡器的特定负载均衡算法,可以以多种不同的方式分配负载。简单的负载均衡算法包括轮询和随机选择。在现实世界的生产应用程序中,我们通常会选择一种负载均衡算法,该算法在选择将请求转发到集群中的节点时,会考虑报告的指标,例如负载或活动连接数。

有许多流行的开源应用程序可以有效地为微服务执行负载均衡。HAProxy是一个流行的开源负载均衡器,可以进行 TCP 和 HTTP 负载均衡。NGINX 是一个流行的开源 Web 服务器,可以有效地用作反向代理、应用程序服务器、负载均衡器,甚至是 HTTP 缓存。如今,更多的组织处于开发部署在云平台上的微服务的位置,例如亚马逊网络服务或谷歌云平台,它们各自都有服务器端负载均衡的解决方案。

AWS 提供了一个名为弹性负载均衡ELB)的负载均衡解决方案。ELB 可以被配置为将流量转发到自动扩展组的成员。自动扩展组是一组被视为逻辑组的 EC2 实例。ELB 使用健康检查(TCP 或 HTTP),这有助于负载均衡器确定是否将流量转发到特定的 EC2 实例。

在这个菜谱中,我们将使用 AWS CLI 工具创建一个自动扩展组并将其附加到它。在这个菜谱中,我们不会涵盖配置管理或部署,所以想象一下,你有一个微服务在每个自动扩展组的 EC2 实例上运行。

如何操作...

  1. 在这个菜谱中,我们将使用 AWS CLI,这是一个用 Python 编写的命令行实用程序,它使得与 AWS API 交互变得简单。我们假设你有一个 AWS 账户,并且已经安装并配置了 AWS CLI 应用程序。有关安装说明,请参阅 AWS 文档(docs.aws.amazon.com/cli/latest/index.html#)。

  2. 创建一个启动配置。启动配置是自动扩展组在创建新的 EC2 实例时使用的模板。它们包含我们创建新实例时想要使用的信息,例如实例类型和大小。给你的启动配置起一个独特的名字——在我们的例子中,我们将简单地称它为users-service-launch-configuration

$ aws create-launch-configuration --launch-configuration-name users-service-launch-configuration \
 --image-id ami-05355a6c --security-groups sg-8422d1eb \
 --instance-type m3.medium
  1. 创建一个使用我们新启动配置的自动扩展组:
$ aws create-auto-scaling-group --auto-scaling-group-name users-service-asg \
 --launch-configuration-name users-service-launch-configuration \
 --min-size 2 \
 --max-size 10
  1. 按照以下步骤创建一个 ELB:
$ aws create-load-balancer --load-balancer-name users-service-elb \
 --listeners "Protocol=HTTP,LoadBalancerPort=80,InstanceProtocol=HTTP,InstancePort=8080"
  1. 通过运行以下命令行将 ASG 附加到我们的负载均衡器:
$ aws autoscaling attach-load-balancers --auto-scaling-group-name users-service-asg --load-balancer-names users-service-elb

客户端负载均衡

服务器端负载均衡是一种经过验证和实战考验的将负载分配给应用程序的方法。然而,它也有一些缺点,例如,单个负载均衡器可以处理的传入连接数量有一个上限。这至少可以通过轮询 DNS 来解决,这将负载分配到多个负载均衡器,但这种配置可能会很快变得繁琐且成本高昂。负载均衡器应用程序也可能成为复杂微服务架构中的故障点。

服务器端负载均衡的一个越来越受欢迎的替代方案是客户端负载均衡。在这个约定中,客户端负责将请求均匀地分布到服务的运行实例。客户端可以跟踪节点的延迟和失败率,并选择减少经历高延迟或高失败率的节点的流量。这种负载均衡方法可以非常有效且简单,尤其是在大规模应用程序中。

Ribbon 是 Netflix 开发的一个开源库,它提供了许多功能,其中包括对客户端负载均衡的支持。在这个配方中,我们将修改我们的消息服务以使用 ribbon 进行客户端负载均衡。我们将不再将用户友情的请求发送到用户服务的单个实例,而是将负载分配给多个可用的实例。

如何做到这一点...

  1. 打开 message-service 项目,并在 build.gradle 中添加以下行:
...
dependencies {
  ...
  compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-ribbon', version: '1.4.2.RELEASE'
}
...
  1. 导航到 src/main/resources/application.yml 并为 users-service 添加以下配置:
users-service:
  ribbon:
    eureka:
      enabled: false
    listOfServers: localhost:8090,localhost:9092,localhost:9999
    ServerListRefreshInterval: 15000
  1. 创建一个名为 UsersServiceConfiguration 的新 Java 类。这个类将配置我们希望 ribbon 在决定如何分配负载时遵循的特定规则:
package com.packtpub.microservices.ch03.clientsideloadbalancing;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;

import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.IPing;
import com.netflix.loadbalancer.IRule;
import com.netflix.loadbalancer.PingUrl;
import com.netflix.loadbalancer.AvailabilityFilteringRule;

public class UsersServiceConfiguration {

  @Autowired
  IClientConfig ribbonClientConfig;

  @Bean
  public IPing ribbonPing(IClientConfig config) {
    return new PingUrl();
  }

  @Bean
  public IRule ribbonRule(IClientConfig config) {
    return new AvailabilityFilteringRule();
  }

}
  1. 打开 MessageController 并在 MessageController 类中添加以下注释:
@RibbonClient(name = "users-service", configuration = UsersServiceConfiguration.class)
@RestClient
public class MessageController {

}
  1. 注释 RestTemplate 类以指示我们希望它使用 ribbon 负载均衡支持,并修改我们的 URL 以使用服务名称,而不是之前硬编码的主机名:
@RibbonClient(name = "users-service", configuration = UsersServiceConfiguration.class)
@RestClient
public class MessageController {
    ...
    @LoadBalanced
    @Bean
    RestTemplate restTemplate(){
      return new RestTemplate();
    }
    ...

    @Async
    public CompletableFuture<Boolean> isFollowing(String fromUser, String toUser) {

        String url = String.format(
                "http://localhost:4567/followings?user=%s&filter=%s",
                fromUser, toUser);

        RestTemplate template = new RestTemplate();
        UserFriendships followings = template.getForObject(url, UserFriendships.class);

        return CompletableFuture.completedFuture(
                followings.getFriendships().isEmpty()
        );
    }
}

构建事件驱动微服务

到目前为止,我们所有的服务间通信配方都涉及一个服务直接调用一个或多个其他服务。当需要下游服务的响应来满足用户请求时,这是必要的。然而,这并不总是必需的。在您想对系统中的事件做出反应的情况下,例如,当您想发送电子邮件或通知,或者当您想更新分析存储时,使用事件驱动架构是更可取的。在这个设计中,一个服务向代理发送消息,另一个应用程序消费该消息并执行操作。这有利于解耦发布者和消费者(因此您的消息服务不必担心发送电子邮件通知等)并且也从用户请求的关键路径上移除了可能昂贵的操作。事件驱动架构还提供了一定程度的容错性,因为消费者可能会失败,并且可以重新播放消息以重试任何失败的操作。

Apache Kafka 是一个开源的流处理平台。在其核心,它是一个分布式事务日志架构的事件代理。Apache Kafka 的完整描述足以写成一整本书——为了一个很好的介绍,我强烈推荐阅读 LinkedIn 的博客文章,该文章介绍了 Kafka (engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying))。为了遵循这个菜谱,你需要知道的最少信息是 Kafka 是一个分布式事件存储,允许你向称为 topics 的类别发布消息。然后另一个进程可以从一个主题中消费消息并对它们做出反应。

回到我们虚构的消息应用程序,当用户向另一个用户发送消息时,我们希望能够以多种方式通知接收者。根据接收者的偏好,我们可能会发送电子邮件或推送通知,或者两者都发送。在这个菜谱中,我们将修改之前菜谱中的消息服务,将事件发布到名为 messages 的 Kafka 主题。然后我们将构建一个消费者应用程序,它监听消息主题中的事件,并可以通过发送接收者通知来做出反应。

如何做到这一点...

Spring for Apache Kafka (spring-kafka) 是一个项目,它使得将 Spring 应用程序与 Apache Kafka 集成变得容易。它为发送和接收消息提供了有用的抽象。

注意,要遵循这个菜谱中的步骤,你需要有一个 Kafka 和 ZooKeeper 的版本正在运行并且可访问。安装和配置这两件软件超出了这个菜谱的范围,所以请访问相应的项目网站,并遵循他们精心编写的入门指南。在这个菜谱中,我们假设你有一个 Kafka 在 9092 端口上运行的单个代理,以及一个在 2181 端口上运行的单个 ZooKeeper 实例。

消息生产者

  1. 打开之前菜谱中的 message-service 项目。修改 build.gradle 文件并将 spring-kafka 项目添加到依赖列表中:
dependencies {
    compile group: 'org.springframework.kafka', name: 'spring-kafka', version: '2.1.1.RELEASE'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. spring-kafka 项目提供了一个向 Kafka 代理发送消息的模板。为了在我们的项目中使用这个模板,我们需要创建一个 ProducerFactory 接口并将其提供给模板的构造函数。

  2. 打开 Application.java 文件并添加以下内容。请注意,我们在这里硬编码了 Kafka 代理的网络位置——在实际应用程序中,你至少应该将此值放置在某种配置中(最好是遵循 12 因素约定):

package com.packtpub.microservices.ch03.message;

import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;

import java.util.HashMap;
import java.util.Map;

@SpringBootApplication
@EnableAsync
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Bean
    public Map<String, Object> producerConfigs() {
        Map<String, Object> props = new HashMap<>();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        return props;
    }

    @Bean
    public ProducerFactory<Integer, String> producerFactory() {
        return new DefaultKafkaProducerFactory<>(producerConfigs());
    }

    @Bean
    public KafkaTemplate<Integer, String> kafkaTemplate() {
        return new KafkaTemplate<Integer, String>(producerFactory());
    }
}
  1. 现在我们可以在我们的应用程序中使用 KafkaTemplate,将其添加到 MessageController 类中。同时,使用 Jackson 的 ObjectMapper 类将我们的 Message 实例转换为 JSON 字符串,然后将其发布到 Kafka 主题。打开 MessageController 类并添加以下字段:
...
import org.springframework.kafka.core.KafkaTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
...

@RestController
public class MessageController {

    @Autowired
    private KafkaTemplate kafkaTemplate;

    @Autowired
    private ObjectMapper objectMapper;

    ...
}
  1. 现在我们已经可以访问 Jackson 的ObjectMapperKafkaTemplate类,创建一个用于发布事件的函数。在这个例子中,我们正在将输出打印到标准错误和标准输出。在实际应用程序中,您会配置一个记录器,例如 log4j,并使用适当的日志级别:
@RestController
public class MessageController {

    ...

    private void publishMessageEvent(Message message) {
        try {
            String data = objectMapper.writeValueAsString(message);
            ListenableFuture<SendResult> result = kafkaTemplate.send("messages", data);
            result.addCallback(new ListenableFutureCallback<SendResult>() {
                @Override
                public void onFailure(Throwable ex) {
                    System.err.println("Failed to emit message event: " + ex.getMessage());
                }

                @Override
                public void onSuccess(SendResult result) {
                    System.out.println("Successfully published message event");
                }
            });
        } catch (JsonProcessingException e) {
            System.err.println("Error processing json: " + e.getMessage());
        }
    }
}
  1. create方法中添加以下行,调用之前创建的publishMessageEvent方法:
@RequestMapping(
            path="/messages",
            method=RequestMethod.POST,
            produces="application/json")
public ResponseEntity<Message> create(@RequestBody Message message) {

    ...

    publishMessageEvent(message);
    return ResponseEntity.created(location).build();
}
  1. 要测试此示例,请使用kafka-topics.sh Kafka 实用工具(包含在 Kafka 二进制发行版中)创建一个消息主题,如下所示:
bin/kafka-topics.sh --create \
 --zookeeper localhost:2181 \
 --replication-factor 1 --partitions 1 \
 --topic messages

消息消费者

现在我们正在发布消息发送事件,下一步是构建一个小的消费者应用程序,它可以对我们的系统中的这些事件做出反应。我们将在这个菜谱中讨论与 Kafka 相关的框架;实现电子邮件和推送通知功能留作读者的练习:

  1. 创建一个名为message-notifier的新 Gradle Java 项目,并使用以下build.gradle文件:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.kafka', name: 'spring-kafka', version: '2.1.1.RELEASE'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 创建一个名为Application的新 Java 类,包含 Spring Boot 应用程序样板代码:
package com.packtpub.microservices.ch03.consumer;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

API 的演变

API 是客户端和服务器之间的合同。API 的向后不兼容更改可能会对服务的客户端造成意外的错误。在微服务架构中,必须采取预防措施以确保服务 API 的更改不会无意中在整个系统中引起级联问题。

一种流行的方法是通过 URL 或通过请求头中的内容协商来对 API 进行版本控制。由于它们通常更容易处理,并且通常更容易缓存,因此 URL 前缀或查询字符串更常见——在这种情况下,API 端点要么带有版本字符串前缀(即/v1/users),要么通过查询字符串参数指定版本或日期(即/v1/users?version=1.0/v1/users?version=20180122)。

使用边缘代理或服务网格配置,甚至可以在环境中运行多个版本的软件,并根据 URL 路由请求到服务的新或旧版本。这改变了服务的传统生命周期——您可以在不再接收任何流量时安全地退役一个版本。这在公共 API 的情况下非常有用,您对客户端几乎没有控制权。

微服务与公共 API 不同。在公共 API 中,客户端和服务器之间的合同通常更持久。在微服务架构中,更容易追踪使用您服务的客户端并说服他们升级他们的代码!尽管如此,有时 API 版本化是必要的。因为能够成功响应多个版本的 API 是一种维护负担,我们希望尽可能避免它。为此,有一些实践可以用来避免做出向后不兼容的更改。

如何操作...

  1. 使用我们的示例应用程序pichat,让我们假设我们想要将消息体的名称从body更改为message_text。这会带来一个问题,因为我们的消息服务被设计为接受以下请求:
GET /messages?user_id=123
GET /messages/123
POST /messages
DELETE /messages/123
  1. GET请求的情况下,客户端会期望在响应中有一个名为body的字段所组成的 JSON 对象。在POST请求的情况下,客户端会将包含名为body的字段作为 JSON 对象发送负载。我们不能简单地移除body字段,因为这会破坏现有的客户端,因此需要修改 API 版本。相反,我们将在旧字段的基础上简单地添加新字段,如下所示:
{
  "message": {
    "from_user": "sender",
    "to_user": "recipient",
    "body": "Hello, there",
    "message_text": "Hello, there"
  }
}
  1. 现在,你可以逐步跟踪使用这些响应的客户端;一旦它们都升级了,你就可以安全地从 JSON 响应中移除已弃用的字段。

第四章:客户端模式

本章将涵盖以下食谱:

  • 使用依赖未来建模并发

  • 后端为前端

  • 与 HTTP 和 JSON 保持一致的 RPC

  • 使用 gRPC

  • 使用 Thrift

简介

当构建面向服务的架构时,很容易陷入思考如何以最通用的方式表示由特定服务控制的数据实体和行为。事实是,我们很少以通用的方式使用服务——我们通常结合多个服务的调用,并使用响应来创建一个新的、聚合的响应体。我们经常以类似于我们过去从数据库中聚合数据的方式调用服务,因此我们必须考虑我们系统中不同类型之间的关系以及如何最好地建模数据依赖。

我们还希望使客户端开发变得简单。在设计通用 API 时,很容易陷入思考正确做事的方式(如果你曾经听到有人批评 API 设计不是 RESTful 的,这可能会听起来很熟悉),而不是思考简单做事的方式。如果客户端需要调用数十次才能获取所需的数据,那么服务就没有什么用处。在设计涉及微服务的系统时,从客户端的角度考虑数据聚合是至关重要的。

客户端不仅要考虑他们正在调用的服务,而且通常还必须考虑他们想要配置以调用的那些服务的实例。在微服务架构中,这些通常会有很多更复杂的环境。

在本章中,我们将讨论建模依赖服务调用以及从各种服务中聚合响应以创建特定客户端 API 的技术。我们还将讨论管理不同的微服务环境以及使 RPC 与 JSON 和 HTTP 保持一致,以及 gRPC 和 Thrift 二进制协议。

使用依赖未来建模并发

在之前的配方中,我们了解到可以使用异步方法来执行由单独线程处理的服务调用。这是至关重要的,因为如果网络 I/O 操作阻塞,将严重限制我们的服务能够处理请求数量的多少。如果服务在网络上阻塞,它将只能处理相对较少的请求,这要求我们投入更多资源进行横向扩展。在我们使用的示例中,消息服务需要调用社交图服务以获取两个用户的信息,即消息的发送者和接收者,并在允许发送消息之前确保这两个用户相互关注。我们修改了请求方法,使其返回包装响应的CompletableFuture实例,然后在验证消息的发送者和接收者是否具有对称关注关系之前等待所有结果完成。当你在执行多个不依赖的请求(你不需要一个请求的响应来执行后续请求)时,这种模型运行良好。在这种情况下,当我们有依赖的服务调用时,我们需要一种更好的方式来建模这种依赖。

在我们的pichat应用程序中,我们需要渲染一个屏幕,列出我们关注的用户的信息。为了做到这一点,我们需要调用社交图服务以获取用户列表,然后调用用户服务以获取每个用户的详细信息,如显示名称和头像。这个用例涉及到执行依赖的服务调用。我们需要一种有效的方式来建模这种服务调用,同时仍然以允许它们在单独的执行线程中运行的方式调度异步操作。

在本配方中,我们将通过使用CompletableFuture的组合以及 Java 8 流来演示如何建模依赖的服务调用。我们将创建一个示例客户端应用程序,该应用程序调用社交服务以获取当前登录用户关注的用户列表,然后调用用户服务以获取每个用户的详细信息。

如何实现...

为了建模依赖的异步服务调用,我们将利用 Java 8 的两个特性。流对于数据处理很有用,因此我们将在示例中使用它们从关注列表中提取用户名并将函数映射到每个元素。Java 8 的CompletableFuture可以组合,这允许我们自然地表达未来之间的依赖关系。

在本配方中,我们将创建一个简单的客户端应用程序,该应用程序调用社交服务以获取当前用户关注的用户列表。对于返回的每个用户,应用程序将从用户服务获取用户详细信息。我们将构建这个示例作为命令行应用程序,以便于演示,但它也可以是另一个微服务、Web 或移动客户端。

为了构建一个具有 Spring Boot 应用程序所有功能的命令行应用程序,我们将稍微作弊一下,只实现CommandLineRunner并在run()方法中调用System.exit(0);

在我们开始构建我们的应用程序之前,我们将概述我们假设的社会服务和用户服务服务的响应。我们可以通过在本地 Web 服务器上托管适当的 JSON 响应来模拟这些服务。我们将分别使用端口80008001来表示社会服务和用户服务。社会服务有一个端点/followings/:username,它返回一个包含指定用户关注者列表的 JSON 对象。JSON 响应将类似于以下片段:

{
  "username": "paulosman",
  "followings": [
    "johnsmith",
    "janesmith",
    "petersmith"
  ]
}

用户服务有一个名为/users/:username的端点,它将返回一个包含用户详细信息(包括用户名、全名和头像 URL)的 JSON 表示:

{
  "username": "paulosman",
  "full_name": "Paul Osman",
  "avatar_url": "http://foo.com/pic.jpg"
}

现在我们有了我们的服务和我们已经概述了从每个服务期望的响应,让我们继续构建我们的客户端应用程序,按照以下步骤进行:

  1. 创建一个名为UserDetailsClient的新 Java/Gradle 应用程序,并具有以下build.gradle文件:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle
        -plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    testCompile group: 'junit', name: 'junit', version: '4.12'
    compile group: 'org.springframework.boot', 
    name: 'spring-boot-starter-web'
}
  1. com.packtpub.microservices.ch04.user.models包中创建一个名为UserDetails的新类。我们将使用这个类来模拟用户服务响应:
package com.packtpub.microservices.ch04.user.models;

import com.fasterxml.jackson.annotation.JsonProperty;

public class UserDetails {
    private String username;

    @JsonProperty("display_name")
    private String displayName;

    @JsonProperty("avatar_url")
    private String avatarUrl;

    public UserDetails() {}

    public UserDetails(String username, String displayName, 
    String avatarUrl) {
        this.username = username;
        this.displayName = displayName;
        this.avatarUrl = avatarUrl;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getDisplayName() {
        return displayName;
    }

    public void setDisplayName(String displayName) {
        this.displayName = displayName;
    }

    public String getAvatarUrl() {
        return avatarUrl;
    }

    public void setAvatarUrl(String avatarUrl) {
        this.avatarUrl = avatarUrl;
    }

    public String toString() {
        return String.format("[UserDetails: %s, %s, %s]", username, 
        displayName, avatarUrl);
    }
}
  1. com.packtpub.microservices.ch04.user.models包中创建另一个名为Followings的类。这将用于模拟社会服务的响应:
package com.packtpub.microservices.ch04.user.models;

import java.util.List;

public class Followings {
    private String username;
    private List<String> followings;

    public Followings() {}

    public Followings(String username, List<String> followings) {
        this.username = username;
        this.followings = followings;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public List<String> getFollowings() {
        return followings;
    }

    public void setFollowings(List<String> followings) {
        this.followings = followings;
    }

    public String toString() {
        return String.format("[Followings for username: %s - %s]", 
        username, followings);
    }
}
  1. 为调用我们的社会服务创建一个服务表示。不出所料,我们将把它命名为SocialService并将其放在com.packtpub.microservices.ch04.user.services包中:
package com.packtpub.microservices.ch04.user.services;

import com.packtpub.microservices.models.Followings;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.concurrent.CompletableFuture;

@Service
public class SocialService {

    private final RestTemplate restTemplate;

    public SocialService(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    @Async
    public CompletableFuture<Followings> 
    getFollowings(String username) {
        String url = String.format("http://localhost:8000/followings/
        %s", username);
        Followings followings = restTemplate.getForObject(url, 
        Followings.class);
        return CompletableFuture.completedFuture(followings);
    }
}
  1. 为我们的用户服务创建一个服务表示。相应地,我们将把这个类命名为UserService并放在同一个包中:
package com.packtpub.microservices.services;

import com.packtpub.microservices.models.Followings;
import com.packtpub.microservices.models.UserDetails;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.concurrent.CompletableFuture;

@Service
public class UserService {
    private final RestTemplate restTemplate;

    public UserService(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    @Async
    public CompletableFuture<UserDetails> 
    getUserDetails(String username) {
        String url = String.format("http://localhost:8001/users/
        %s", username);
        UserDetails userDetails = restTemplate.getForObject(url, 
        UserDetails.class);
        return CompletableFuture.completedFuture(userDetails);
    }
}
  1. 我们现在有了用于模拟我们服务响应的类,以及表示我们将要调用的服务的服务对象。现在是时候通过创建我们的主类来将这些所有内容结合起来,这个主类将以依赖的方式调用这两个服务,使用未来的组合性来模拟依赖关系。创建一个名为UserDetailsClient的新类,如下所示:
package com.packtpub.microservices.ch04.user;

import com.packtpub.microservices.models.Followings;
import com.packtpub.microservices.models.UserDetails;
import com.packtpub.microservices.services.SocialService;
import com.packtpub.microservices.services.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.stream.Collectors;

@SpringBootApplication
public class UserDetailsClient implements CommandLineRunner {

    public UserDetailsClient() {}

    @Autowired
    private SocialService socialService;

    @Autowired
    private UserService userService;

    public CompletableFuture<List<UserDetails>> 
    getFollowingDetails(String username) {
        return socialService.getFollowings(username).thenApply(f ->
                f.getFollowings().stream().map(u ->userService.
                getUserDetails(u)).map(CompletableFuture::join).
                collect(Collectors.toList()));
    }

    public static void main(String[] args) {
        SpringApplication.run(UserDetailsClient.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        Future<List<UserDetails>> users = getFollowingDetails
        ("paulosman");
        System.out.println(users.get());
        System.out.println("Heyo");
        System.exit(0);
    }
}

魔法实际上发生在以下方法中:

CompletableFuture<List<UserDetails>> getFollowingDetails(String username) 
{
  return socialService.getFollowings(username).thenApply(
    f -> f.getFollowings().stream().map(u ->
      userService.getUserDetails(u)).map(
        CompletableFuture::join).collect(Collectors.toList()));
}

回想一下,SocialService中的getFollowings方法返回CompletableFuture<Followings>CompletableFuture有一个名为thenApply的方法,它接受未来的最终结果(Followings)并将其应用于 Lambda 表达式。在这种情况下,我们正在使用Followings并通过 Java 8 Stream API 对由社会服务返回的用户名列表调用mapmap将每个用户名应用于一个函数,该函数在UserService上调用getUserDetailsCompletableFuture::join方法用于将List<Future<T>>转换为Future<List<T>>,这在执行这些类型的依赖服务调用时是一个常见操作。最后,我们收集结果并将它们作为列表返回。

前端后端

当软件从桌面和基于 Web 的应用程序转向移动应用程序时,分布式架构变得更加普遍。这成为许多组织构建平台而不是仅仅构建产品的焦点。这种方法对 API 的重视程度更高,这些 API 可以暴露给客户以及第三方合作伙伴。随着 API 成为任何基于 Web 应用程序的既定事实,尝试在用于向第三方合作伙伴提供功能的同一 API 上构建客户端应用程序(移动或 JavaScript)变得流行。这种想法是,如果你公开了一个设计良好、通用目的的 API,你将拥有构建任何类型应用程序所需的一切。一般架构看起来是这样的:

这种方法的缺陷在于它假设你的第一方(移动和 Web)和第三方(合作伙伴)应用程序的需求始终会保持一致,而这很少是事实。更常见的情况是,你希望在第三方集成中鼓励某些类型的功能,在第一方客户端中鼓励另一组功能。此外,你希望对第一方客户端的变化更加宽容(甚至鼓励)——你的客户端应用程序将不断发展,并不断改变它们的 API 需求。最后,你无法预见到第三方合作伙伴将如何使用你的 API 的所有可能用例,因此通用设计是有益的,但你将能够预见到你的移动和 Web 应用程序的需求,而在 API 设计中过于通用可能会经常阻碍你的产品需求。一个很好的例子是将服务器端网站重写为单页 JavaScript 应用程序。使用通用 API,这类项目可能导致需要数十个XMLHttpRequests才能渲染单个页面视图。

Backend For FrontendBFF)是一种涉及为不同类别的客户端应用程序创建单独、定制 API的架构模式。在你的架构中,而不是一个单一的 API 层,可以根据你想要支持多少类别客户端应用程序来开发单独的 BFF 层。你如何分类客户端完全取决于你业务的需求。你可能会决定为所有移动客户端使用单个 BFF 层,或者你可能将它们分为 iOS BFF 和 Android BFF。同样,你可能会选择为你的 Web 应用程序和第三方合作伙伴(曾经是单一 API 的主要驱动因素)使用单独的 BFF 层:

在这个系统中,每个客户端类别都会向其自己的 BFF 层发出请求,然后可以聚合对下游服务的调用并构建一个统一、定制的 API。

如何做到这一点...

为了设计和构建一个 bff 层,我们首先应该设计 API。实际上,我们已经完成了这个步骤。在前面的菜谱中,我们展示了如何使用CompletableFuture异步地向我们的系统、社交服务发送请求,然后对于每个返回的用户,异步地向用户详情服务发送请求以获取某些用户配置信息。这对于我们的移动应用来说是一个很好的 bff 层用例。想象一下,我们的移动应用有一个屏幕,显示用户关注的用户列表,包括他们的头像、用户名和显示名。由于社交图信息(用户关注的用户列表)和用户配置信息(头像、用户名和显示名)是两个独立服务的责任,要求我们的移动客户端聚合对这些服务的调用以渲染关注页面是相当繁琐的。相反,我们可以创建一个移动 bff 层来处理这种聚合,并向客户端返回方便的响应。我们的请求端点如下:

GET /users/:user_id/following

我们期望得到的响应体应该如下所示:

{
  "username": "paulosman",
  "followings": [
    {
      "username": "friendlyuser",
      "display_name": "Friendly User",
      "avatar_url": "http://example.com/pic.jpg"
    },
    {
      ...
    }
  ]
}

如我们所见,bff 将返回一个包含我们渲染移动应用中关注屏幕所需所有信息的响应:

  1. 创建一个名为bff-mobile的新 Gradle/Java 项目,并包含以下build.gradle文件:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', 
        name: 'spring-boot-gradle-plugin', 
        version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    testCompile group: 'junit', name: 'junit', version: '4.12'
    compile group: 'org.springframework.boot', 
    name: 'spring-boot-starter-web'
}
  1. 创建一个名为com.packtpub.microservices.mobilebff的新包和一个名为Main的新类:
package com.packtpub.microservices.ch04.mobilebff;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Main {
    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}
  1. 创建一个名为com.packtpub.microservices.ch04.mobilebff.models的新包和一个名为User的新类:
package com.packtpub.microservices.ch04.mobilebff.models;

import com.fasterxml.jackson.annotation.JsonProperty;

public class User {
    private String username;

    @JsonProperty("display_name")
    private String displayName;

    @JsonProperty("avatar_url")
    private String avatarUrl;

    public User() {}

    public User(String username, String displayName, 
    String avatarUrl) {
        this.username = username;
        this.displayName = displayName;
        this.avatarUrl = avatarUrl;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getDisplayName() {
        return displayName;
    }

    public void setDisplayName(String displayName) {
        this.displayName = displayName;
    }

    public String getAvatarUrl() {
        return avatarUrl;
    }

    public void setAvatarUrl(String avatarUrl) {
        this.avatarUrl = avatarUrl;
    }

    public String toString() {
        return String.format(
                "[User username:%s, displayName:%s, avatarUrl:%s]",
                username, displayName, avatarUrl);
    }
}
  1. 创建另一个模型,称为Followings
package com.packtpub.microservices.ch04.mobilebff.models;

import java.util.List;

public class Followings {
    private String username;

    private List<String> followings;

    public Followings() {}

    public Followings(String username, List<String> followings) {
        this.username = username;
        this.followings = followings;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public List<String> getFollowings() {
        return followings;
    }

    public void setFollowings(List<String> followings) {
        this.followings = followings;
    }
}
  1. 我们将要创建的最后一个模型称为HydratedFollowings。这个模型与Followings模型类似,但不同之处在于它不将用户列表存储为字符串,而是包含一个User对象的列表:
package com.packtpub.microservices.ch04.mobilebff.models;

import java.util.List;

public class HydratedFollowings {
    private String username;

    private List<User> followings;

    public HydratedFollowings() {}

    public HydratedFollowings(String username, List<User> 
    followings) {
        this.username = username;
        this.followings = followings;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public List<User> getFollowings() {
        return followings;
    }

    public void setFollowings(List<User> followings) {
        this.followings = followings;
    }
}
  1. 创建服务客户端。创建一个名为com.packtpub.microservices.ch04.mobilebff.services的包和一个名为SocialGraphService的新类:
package com.packtpub.microservices.ch04.mobilebff.services;

import com.packtpub.microservices.ch04.mobilebff.models.Followings;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.concurrent.CompletableFuture;

@Service
public class SocialGraphService {

    private final RestTemplate restTemplate;

    public SocialGraphService(RestTemplateBuilder 
    restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    @Async
    public CompletableFuture<Followings> 
    getFollowing(String username) {
        String url = String.format("http://localhost:4567/followings/
        %s", username);
        Followings followings = restTemplate.getForObject(url, 
        Followings.class);
        return CompletableFuture.completedFuture(followings);
    }
}
  1. 创建一个新的类,称为UsersService,它将作为我们的用户服务的客户端:
package com.packtpub.microservices.ch04.mobilebff.services;

import com.packtpub.microservices.ch04.mobilebff.models.User;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.concurrent.CompletableFuture;

@Service
public class UsersService {

    private final RestTemplate restTemplate;

    public UsersService(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    @Async
    public CompletableFuture<User> getUserDetails(String username) {
        String url = String.format("http://localhost:4568/users/
        %s", username);
        User user = restTemplate.getForObject(url, User.class);
        return CompletableFuture.completedFuture(user);
    }
}
  1. 让我们通过创建我们的控制器来整合所有内容,该控制器公开了端点。如果你完成了前面的菜谱,这段代码看起来会很熟悉,因为我们正在使用完全相同的模式来模拟依赖的异步服务调用。创建一个名为com.packtpub.microservices.ch04.mobilebff.controllers的包和一个名为UsersController的新类:
package com.packtpub.microservices.ch04.mobilebff.controllers;

import com.packtpub.microservices.ch04.mobilebff.models.HydratedFollowings;
import com.packtpub.microservices.ch04.mobilebff.models.User;
import com.packtpub.microservices.ch04.mobilebff.services.SocialGraphService;
import com.packtpub.microservices.ch04.mobilebff.services.UsersService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;

@RestController
public class UsersController {

    @Autowired
    private SocialGraphService socialGraphService;

    @Autowired
    private UsersService userService;

    @RequestMapping(path = "/users/{username}/followings", 
    method = RequestMethod.GET)
    public HydratedFollowings getFollowings(@PathVariable String username) 
    throws ExecutionException, InterruptedException {
        CompletableFuture<List<User>> users = socialGraphService.getFollowing
        (username).thenApply(f -> f.getFollowings().stream().map(
                        u -> userService.getUserDetails(u)).map(
                                CompletableFuture::join).collect(Collectors.toList()));
        return new HydratedFollowings(username, users.get());
    }
}
  1. 就这样!运行应用程序,并对/users/username/followings执行GET请求。你应该会得到一个包含用户用户名和用户关注的每个用户的详细信息的完整填充的 JSON 响应。

与 HTTP 和 JSON 一致的 RPC

当构建多个微服务时,服务之间的一致性和约定开始产生实际影响。当微服务架构中出现问题时,你可能会花费大量时间调试许多服务——能够对特定服务接口的性质做出某些假设可以节省大量时间和精力。拥有一致的 RPC 方式还可以让你将某些关注点编码到库中,这些库可以在服务之间轻松共享。例如,身份验证、如何解释头信息、响应体中包含哪些信息以及如何请求分页响应等问题可以通过一致的方法变得简单。此外,应该尽可能使错误报告的方式保持一致。

由于微服务架构通常由不同团队使用不同的编程语言编写,因此任何旨在实现一致 RPC 语义的努力都必须在尽可能多的语言中实现,可能作为库。这可能很麻烦,但为了确保客户端在与各种服务交流时可以假设的一致性,这种努力是非常值得的。

在这个配方中,我们将专注于使用 Spring Boot 编写的 Java 服务。我们将编写一个自定义序列化器,以一致的方式呈现资源和资源集合,包括分页信息。然后,我们将修改我们的消息服务以使用我们新的序列化器。

如何做到这一点...

在这个配方中,我们将创建一个包装类来表示带有分页信息的资源集合。我们还将使用jackson库中的JsonRootName注解来使单个资源表示保持一致。以下代码应添加到之前配方中介绍的消息服务中:

  1. 创建一个名为ResourceCollection的新类。这个类将是一个普通的 POJO,包含表示页码、项目列表和可以用来访问集合中下一页的 URL 的字段:
package com.packtpub.microservices.ch04.message.models;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonRootName;

import java.util.List;

@JsonRootName("result")
public class ResourceCollection<T> {

    private int page;

    @JsonProperty("next_url")
    private String nextUrl;

    private List<T> items;

    public ResourceCollection(List<T> items, int page, String nextUrl) {
        this.items = items;
        this.page = page;
        this.nextUrl = nextUrl;
    }

    public int getPage() {
        return page;
    }

    public void setPage(int pageNumber) {
        this.page = page;
    }

    public String getNextUrl() {
        return nextUrl;
    }

    public void setNextUrl(String nextUrl) {
        this.nextUrl = nextUrl;
    }

    public List<T> getItems() {
        return items;
    }

    public void setItems(List<T> items) {
        this.items = items;
    }
}
  1. 创建或修改Message模型。在这里,我们使用JsonRootName注解将Message表示包装在一个包含item键的单个 JSON 对象中。为了保持一致的表示,我们应该将这些添加到我们服务公开的所有作为资源的模型中:
package com.packtpub.microservices.ch04.message.models;

import com.fasterxml.jackson.annotation.JsonRootName;

@JsonRootName("item")
public class Message {
    private String id;
    private String toUser;
    private String fromUser;
    private String body;

    public Message(String id, String toUser, String fromUser, String body) {
        this.id = id;
        this.toUser = toUser;
        this.fromUser = fromUser;
        this.body = body;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getToUser() {
        return toUser;
    }

    public void setToUser(String toUser) {
        this.toUser = toUser;
    }

    public String getFromUser() {
        return fromUser;
    }

    public void setFromUser(String fromUser) {
        this.fromUser = fromUser;
    }

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }
}
  1. 以下控制器返回一条消息列表和一条特定消息。我们使用之前创建的ResourceCollection类来包装消息列表:
package com.packtpub.microservices.ch04.message.controllers;

import com.packtpub.microservices.ch04.message.models.Message;
import com.packtpub.microservices.ch04.message.models.ResourceCollection;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@RestController
public class MessageController {

    @RequestMapping(value = "/messages", method = RequestMethod.GET)
    public ResourceCollection<Message> messages(@RequestParam(name="page", required=false, defaultValue="1") int page,
                                       HttpServletRequest request) {
        List<Message> messages = Stream.of(
                new Message("1234","paul", "veronica", "hello!"),
                new Message("5678","meghann", "paul", "hello!")
        ).collect(Collectors.toList());

        String nextUrl = String.format("%s?page=%d", request.getRequestURI(), page + 1);

        return new ResourceCollection<>(messages, page, nextUrl);
    }

    @RequestMapping(value = "/messages/{id}", method = RequestMethod.GET)
    public Message message(@PathVariable("id") String id) {
        return new Message(id, "paul", "veronica", "hi dad");
    }
}
  1. 如果你通过向/messages发送请求来测试请求项目集合,现在应该返回以下 JSON:
{
    "result": {
        "page": 1,
        "items": [
            {
                "id": "1234",
                "toUser": "paul",
                "fromUser": "veronica",
                "body": "hello!"
            },
            {
                "id": "5678",
                "toUser": "meghann",
                "fromUser": "paul",
                "body": "hello!"
            }
        ],
        "next_url": "/messages?page=2"
    }
}
  1. 对于单个资源,应返回以下 JSON:
{
    "item": {
        "id": "123",
        "toUser": "paul",
        "fromUser": "veronica",
        "body": "hi dad"
    }
}

对于如何表示资源或资源列表有一定的标准化可以极大地简化在微服务架构中与服务的协作。然而,使用 JSON 和 HTTP 进行这一操作涉及相当多的手动工作,这可以被抽象化。在接下来的菜谱中,我们将探讨使用 Thrift 和 gRPC,这两种是 HTTP/JSON RPC 的替代方案。

使用 Thrift

JSON 和 HTTP 是简单直接的数据传输和定义解决方案,应该能满足许多微服务架构的需求。然而,如果您需要类型安全和通常更好的性能,那么查看二进制解决方案(如 Thrift 或 gRPC)可能是有价值的。

Apache Thrift 是 Facebook 发明的一种接口定义语言(IDL)和二进制传输协议。它允许您通过定义服务公开的结构(在大多数语言中类似于对象)和异常来指定 API。在 IDL 中定义的 Thrift 接口用于在支持的语言中生成代码,然后用于管理 RPC 调用。支持的语言包括 C、C++、Python、Ruby 和 Java。

二进制协议(如 Thrift)的好处主要是性能提升和类型安全。根据所使用的 JSON 库,序列化和反序列化大型 JSON 负载可能相当昂贵,而且 JSON 没有客户端在处理响应时可以使用的任何类型系统。此外,由于 Thrift 包含一个可以用于在支持的任何语言中生成代码的 IDL,因此很容易让 Thrift 处理客户端和服务器代码的生成,从而减少需要手动完成的工作量。

由于 Apache Thrift 不使用 HTTP 作为传输层,因此导出 Thrift 接口的服务将启动自己的 Thrift 服务器。在这个菜谱中,我们将定义我们的消息服务的 IDL,并使用 Thrift 生成处理程序代码。然后,我们将创建处理启动服务、监听指定端口等的服务器样板代码。

如何做到这一点...

  1. 创建一个新的 Gradle/Java 项目,包含以下build.gradle文件:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        maven {
            url "https://plugins.gradle.org/m2/"
        }
    }
    dependencies {
        classpath "gradle.plugin.org.jruyi.gradle:thrift-gradle-plugin:0.4.0"
    }
}

apply plugin: 'java'
apply plugin: 'org.jruyi.thrift'
apply plugin: 'application'

mainClassName = 'com.packtpub.microservices.ch04.MessageServiceServer'

compileThrift {
    recurse true

    generator 'html'
    generator 'java', 'private-members'
}

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.apache.thrift', name: 'libthrift', version: '0.11.0'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 创建一个名为src/main/thrift的目录和一个名为service.thrift的文件。这是我们服务的 IDL 文件。我们将定义一个MessageException异常、实际的Message对象和一个MessageService接口。有关 Thrift IDL 文件的特定语法信息,Thrift 项目网站有很好的文档(thrift.apache.org/docs/idl)。为了简化,我们将在我们的服务中定义一个单一的方法,该方法返回特定用户的消息列表:
namespace java com.packtpub.microservices.ch04.thrift

exception MessageException {
    1: i32 code,
    2: string description
}

struct Message {
    1: i32 id,
    2: string from_user,
    3: string to_user,
    4: string body
}

service MessageService {
    list<Message> inbox(1: string username) throws (1:MessageException e)
}
  1. 运行组装的 Gradle 任务将生成前面 IDL 的代码。现在我们将创建 MessageService 类的实现。这将扩展前面 IDL 中的自动生成的接口。为了简单起见,我们的 MessageService 实现将不会连接到任何数据库,而是将使用在构造函数中构建的静态、硬编码的收件箱表示:
package com.packtpub.microservices.ch04.thrift;

import com.packtpub.microservices.ch04.thrift.Message;
import com.packtpub.microservices.ch04.thrift.MessageException;
import com.packtpub.microservices.ch04.thrift.MessageService;
import org.apache.thrift.TException;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class MessageServiceImpl implements MessageService.Iface {

    private Map<String, List<Message>> messagesRepository;

    MessageServiceImpl() {
        // populate our mock repository with some sample messages
        messagesRepository = new HashMap<>();
        messagesRepository.put("usertwo", Stream.of(
            new Message(1234, "userone", "usertwo", "hi"),
            new Message(5678, "userthree", "usertwo", "hi")
        ).collect(Collectors.toList()));
        messagesRepository.put("userone", Stream.of(
            new Message(1122, "usertwo", "userone", "hi"),
            new Message(2233, "userthree", "userone", "hi")
        ).collect(Collectors.toList()));
    }

    @Override
    public List<Message> inbox(String username) throws TException {
        if (!messagesRepository.containsKey(username))
            throw new MessageException(100, "Inbox is empty");
        return messagesRepository.get(username);
    }
}
  1. 创建服务器。创建一个名为 MessageServiceServer 的新类,如下所示:
package com.packtpub.microservices.ch04.thrift;

import com.packtpub.microservices.ch04.thrift.MessageService;
import org.apache.thrift.server.TServer;
import org.apache.thrift.server.TSimpleServer;
import org.apache.thrift.transport.TServerSocket;
import org.apache.thrift.transport.TServerTransport;
import org.apache.thrift.transport.TTransportException;

public class MessageServiceServer {

    private TSimpleServer server;

    private void start() throws TTransportException {

        TServerTransport serverTransport = new TServerSocket(9999);
        server = new TSimpleServer(new TServer.Args(serverTransport)
                .processor(new MessageService.Processor<>(new MessageServiceImpl())));
        server.serve();
    }

    private void stop() {
        if (server != null && server.isServing())
            server.stop();
    }

    public static void main(String[] args) {
        MessageServiceServer service = new MessageServiceServer();
        try {
            if (args[1].equals("start"))
                service.start();
            else if (args[2].equals("stop"))
                service.stop();
        } catch (TTransportException e) {
            e.printStackTrace();
        }
    }
}

您的服务现在已构建并使用 Apache Thrift 进行 RPC。作为一个进一步的练习,您可以尝试使用相同的 IDL 生成客户端代码,该代码可用于调用此服务。

使用 gRPC

gRPC 是一个最初由 Google 发明的 RPC 框架。与 Thrift 不同,gRPC 利用现有的技术,特别是 协议缓冲区 用于其 IDL 和 HTTP/2 用于其传输层。完成上一个配方后,gRPC 的某些方面将与 Thrift 的某些方面相似。而不是使用 Thrift IDL,类型和服务是在一个 .proto 文件中定义的。然后可以使用协议缓冲区的编译器使用该 .proto 文件生成代码。

如何做到这一点...

  1. 创建一个具有以下 build.gradle 文件的新的 Gradle/Java 项目。值得注意的是,我们正在安装和配置 protobuf Gradle 插件,这将允许我们使用 Gradle 从 protobuf 文件生成代码,并且我们正在列出所需的 protobuf 库作为依赖项。最后,我们必须告诉我们的 IDE 去哪里查找生成的类:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.3'
    }
}

apply plugin: 'java'
apply plugin: 'com.google.protobuf'
apply plugin: 'application'

mainClassName = 'com.packtpub.microservices.ch04.grpc.MessageServer'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

def grpcVersion = '1.10.0'

dependencies {
    compile group: 'com.google.api.grpc', name: 'proto-google-common-protos', version: '1.0.0'
    compile group: 'io.grpc', name: 'grpc-netty', version: grpcVersion
    compile group: 'io.grpc', name: 'grpc-protobuf', version: grpcVersion
    compile group: 'io.grpc', name: 'grpc-stub', version: grpcVersion
    testCompile group: 'junit', name: 'junit', version: '4.12'
}

protobuf {
    protoc {
        artifact = 'com.google.protobuf:protoc:3.5.1-1'
    }
    plugins {
        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
        }
    }
    generateProtoTasks {
        all()*.plugins {
            grpc {}
        }
    }
}

// Inform IDEs like IntelliJ IDEA, Eclipse or NetBeans about the generated code.
sourceSets {
    main {
        java {
            srcDirs 'build/generated/source/proto/main/grpc'
            srcDirs 'build/generated/source/proto/main/java'
        }
    }
}
  1. 创建一个名为 src/main/proto 的新目录和一个名为 message_service.proto 的新文件。这将是我们的服务 protobuf 定义。像上一个配方一样,我们将保持简单,只公开一个返回指定用户消息列表的方法:
option java_package = "com.packtpub.microservices.ch04.grpc";

message Username {
    required string username = 1;
}

message Message {
    required string id = 1;
    required string from_user = 2;
    required string to_user = 3;
    required string body = 4;
}

message InboxReply {
    repeated Message messages = 1;
}

service MessageService {
    rpc inbox(Username) returns (InboxReply) {}
}
  1. 实现实际的服务。为了做到这一点,我们需要创建一个名为 MessageServer 的新类,其中包含启动和停止我们服务器所需的所有必要模板代码。我们还将创建一个名为 MessageService 的内部类,它扩展了由前一个 IDL 生成的 MessageServiceGrpc.MessageServiceImplBase 类:
package com.packtpub.microservices.ch04.grpc;

import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;

import java.io.IOException;

public class MessageServer {

    private final int port;
    private final Server server;

    private MessageServer(int port) throws IOException {
        this(ServerBuilder.forPort(port), port);
    }

    private MessageServer(ServerBuilder<?> serverBuilder, int port) {
        this.port = port;
        this.server = serverBuilder.addService(new MessageService()).build();
    }

    public void start() throws IOException {
        server.start();
        Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                // Use stderr here since the logger may has been reset by its JVM shutdown hook.
                System.err.println("*** shutting down gRPC server since JVM is shutting down");
                MessageServer.this.stop();
                System.err.println("*** server shut down");
            }
        });
    }

    public void stop() {
        if (server != null) {
            server.shutdown();
        }
    }

    private void blockUntilShutdown() throws InterruptedException {
        if (server != null) {
            server.awaitTermination();
        }
    }

    private static class MessageService extends MessageServiceGrpc.MessageServiceImplBase {
        public void inbox(MessageServiceOuterClass.Username request,
                          StreamObserver<MessageServiceOuterClass.InboxReply> responseObserver) {
            MessageServiceOuterClass.InboxReply reply = MessageServiceOuterClass.InboxReply.newBuilder().addMessages(
                MessageServiceOuterClass.Message.newBuilder()
                    .setId("1234")
                    .setFromUser("Paul")
                    .setToUser("Veronica")
                    .setBody("hi")
            ).addMessages(
                MessageServiceOuterClass.Message.newBuilder()
                    .setId("5678")
                    .setFromUser("FooBarUser")
                    .setToUser("Veronica")
                    .setBody("Hello again")
            ).build();
            responseObserver.onNext(reply);
            responseObserver.onCompleted();
        }
    }

    public static void main(String[] args) throws Exception {
        MessageServer server = new MessageServer(8989);
        server.start();
        server.blockUntilShutdown();
    }
}

第五章:可靠性模式

在本章中,我们将介绍以下食谱:

  • 使用断路器实现背压

  • 使用指数退避重试请求

  • 通过缓存提高性能

  • 使用 CDN 作为服务的前端

  • 优雅地降低用户体验

  • 使用控制游戏日测试你的故障场景

  • 引入自动化混沌

简介

可靠性正在成为分布式系统领域越来越受欢迎的话题。站点可靠性工程师SRE)或混沌工程师的职位广告变得越来越常见,随着越来越多的组织转向云原生技术,系统故障总是现实,这一点变得越来越不容忽视。网络将经历拥塞,交换机、其他硬件组件将失败,系统中的各种潜在故障模式将在生产中让我们感到惊讶。完全防止故障是不可能的,因此我们应该尝试设计我们的系统,使其尽可能能够容忍故障。

微服务为设计可靠性提供了有趣和有用的机会。因为微服务鼓励我们将系统分解为封装单一职责的服务,我们可以使用许多有用的可靠性模式来隔离故障,当故障发生时。微服务架构在规划可靠性时也提出了许多挑战。对网络请求的依赖增加、异构配置、多个数据存储和连接池以及不同的技术栈都导致了一个本质上更复杂的环境,其中可能出现不同的故障模式。

无论是在处理微服务架构还是单体代码库,我们都发现自己从根本上对系统在某种故障状态下的行为感到惊讶[1](你可以点击此链接获取更多信息:www.youtube.com/watch?v=tZ2wj2pxO6Q)。从一开始就在我们的系统中构建弹性,使我们能够优化在这些情况下的反应。在本章中,我们将讨论在设计构建微服务时可以使用的许多有用的可靠性模式,以准备和减少预期和意外的系统故障的影响。

使用断路器

分布式系统中的故障可能难以调试。一个症状(延迟激增或高错误率)可能出现在根本原因(慢速数据库查询、垃圾回收周期导致服务减慢请求处理)很远的地方。有时,整个系统完全中断可能是系统一小部分故障的结果,尤其是在系统组件难以处理负载增加时。

在可能的情况下,我们希望防止系统一部分的故障级联到其他部分,导致广泛且难以调试的生产问题。此外,如果故障是暂时的,我们希望系统在故障结束后能够自我修复。如果特定服务因为负载的暂时激增而出现问题,我们应该设计我们的系统,使其防止对不健康服务的请求,在再次开始发送流量之前,给服务恢复的时间。

断路器在房屋中用于防止过度使用电力加热内部电线并烧毁房屋。如果断路器检测到过度使用且无法处理从其吸取的电流量,则电路会被跳闸。经过一段时间后,电路可以再次关闭,使系统正常工作。

这种相同的方法可以转化为软件,并应用于微服务架构。当一个服务调用另一个服务时,我们应该将 RPC 调用封装在断路器中。如果请求连续失败,表明服务不健康,断路器将被打开,防止进一步的请求尝试。调用服务可以“快速失败”并决定如何处理故障模式。在可配置的时间段后,我们可以允许另一个请求通过,如果它成功,则再次关闭断路器,使系统恢复正常操作。您可以查看以下相关的流程图:

图片

大多数流行编程语言都有实现断路器的库。Netflix 开发的 Hystrix 容错库,在之前的菜谱中使用,就是这样的库之一。一些框架,如 Twitter 的 Finagle,会自动将 RPC 封装在断路器中,跟踪故障并自动管理断路器的状态。开源服务网格软件,如 ConduitLinkerd,也会自动将断路器添加到 RPC 中。在本菜谱中,我们将介绍一个名为 resilience4j 的库,并使用其断路器实现来允许在达到故障阈值时,一个服务到另一个服务的调用快速失败。为了使示例更具体,我们将修改一个消息服务,该服务调用社交图谱服务以确定两个用户是否相互关注,并将 RPC 调用封装在断路器中。

如何操作...

为了演示在断路器中包装服务调用,我们将创建一个版本的pichat消息服务,该服务公开发送和检索消息的端点。要从发送者向接收者发送消息,这两个用户必须存在好友关系。好友关系由社交图服务处理。为了简单起见,我们将使用 Ruby 编写一个简单的模拟社交图服务,就像我们在之前的食谱中所做的那样。模拟服务将公开一个端点,列出指定用户的友谊。以下是 Ruby 中模拟社交图服务的源代码:

require 'sinatra'
require 'json'

get '/friendships/:username' do
  content_type :json
  {
    'username': params[:username],
    'friendships': [
      'pichat:users:johndoe',
      'pichat:users:janesmith',
      'pichat:users:anotheruser'
    ]
  }.to_json
end

在我们的模拟服务中,我们使用pichat:users:username格式的字符串来识别系统中的用户。这些是伪 URI,它们唯一地标识了系统中的用户。现在,只需知道这些是用于识别系统中用户的唯一字符串。

我们的模拟社交图服务公开以下单个端点:

GET /friendships/paulosman

前一个端点返回一个 JSON 响应体,表示请求用户拥有的好友关系:

{
  "username": "fdsa",
  "friendships": [
    "pichat:users:foobar",
    "pichat:users:asomefdsa"
  ]
}

当我们的模拟社交图服务在本地主机上运行,端口4567(Ruby Sinatra 应用程序的默认端口)时,我们就可以开始编写我们的消息服务了。与之前的食谱一样,我们将使用 Java 和 Spring Boot 框架。我们还将使用resilience4j断路器库来包装从消息服务到社交图服务的调用。首先,我们将开发我们的消息服务代码,然后我们将添加resilience4j断路器库来为我们的服务添加一层弹性,如下面的步骤所示:

  1. 创建一个新的 Gradle Java 项目,并将以下代码添加到build.gradle文件中:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    testCompile group: 'junit', name: 'junit', version: '4.12'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
}
  1. 我们的消息服务代码将有两个 bean 被自动注入到我们的控制器中。第一个是一个内存中的消息存储库(在现实世界的例子中,这将被替换为一个更持久的持久层),第二个是用于社交图服务的客户端。在我们创建这些之前,让我们创建一些支持对象。创建一个新的包名为com.packtpub.microservices.ch05.message.exceptions,并创建一个新的类名为MessageNotFoundException。这将用于表示消息无法找到,这将导致我们的服务返回404响应,如下所示:
package com.packtpub.microservices.ch05.exceptions;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class MessageNotFoundException extends Exception {
    public MessageNotFoundException(String message) { super(message); }
}
  1. 在异常包中创建另一个类名为MessageSendForbiddenException。这将用于表示由于发送者和接收者不是朋友,无法发送消息。我们的服务将返回403禁止响应,如下所示:
package com.packtpub.microservices.ch05.message.exceptions;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.FORBIDDEN)
public class MessageSendForbiddenException extends Exception {
    public MessageSendForbiddenException(String message) { super(message); }
}
  1. 创建SocialGraphClient类。创建一个新的包名为com.packtpub.microservices.ch05.message.clients,并创建一个新的类名为SocialGraphClient,如下所示:
package com.packtpub.microservices.ch05.message.clients;

import com.packtpub.microservices.ch05.models.Friendships;
import org.springframework.web.client.RestTemplate;

import java.util.List;

public class SocialGraphClient {
    private String baseUrl;

    public SocialGraphClient(String baseUrl) {
        this.baseUrl = baseUrl;
    }

    public List<String> getFriendships(String username) {
        String requestUrl = baseUrl + "/friendships/" + username;
        RestTemplate template = new RestTemplate();
        UserFriendships friendships = template.getForObject(requestUrl, UserFriendships.class);
        return friendships.getFriendships();
    }
}
  1. 让我们创建我们的模型。我们需要一个模型来表示特定用户拥有的UserFriendships,以及一个模型来表示Messages。创建一个新的包com.packtpub.microservices.ch05.models和一个名为Friendships的新类,如下所示:
package com.packtpub.microservices.ch05.models;

import java.util.List;

public class Friendships {
    private String username;
    private List<String> friendships;

    public Friendships() {
        this.friendships = new ArrayList<>();
    }

    public Friendships(String username) {
        this.username = username;
        this.friendships = new ArrayList<>();
    }

    public Friendships(String username, List<String> friendships) {
        this.username = username;
        this.friendships = friendships;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public List<String> getFriendships() {
        return friendships;
    }

    public void setFriendships(List<String> friendships) {
        this.friendships = friendships;
    }
}
  1. 在同一包中创建一个新的类,名为Message,如下所示:
package com.packtpub.microservices.ch05.message.models;

import com.fasterxml.jackson.annotation.JsonProperty;

public class Message {
    private String id;
    private String sender;
    private String recipient;
    private String body;
    @JsonProperty("attachment_uri")
    private String attachmentUri;

    public Message() {}

    public Message(String sender, String recipient, String body, String attachmentUri) {
        this.sender = sender;
        this.recipient = recipient;
        this.body = body;
        this.attachmentUri = attachmentUri;
    }

    public Message(String id, String sender, String recipient, String body, String attachmentUri) {
        this.id = id;
        this.sender = sender;
        this.recipient = recipient;
        this.body = body;
        this.attachmentUri = attachmentUri;
    }

    public String getId() {
        return id;
    }

    public String getSender() {
        return sender;
    }

    public void setSender(String sender) {
        this.sender = sender;
    }

    public String getRecipient() {
        return recipient;
    }

    public void setRecipient(String recipient) {
        this.recipient = recipient;
    }

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }

    public String getAttachmentUri() {
        return attachmentUri;
    }

    public void setAttachmentUri(String attachmentUri) {
        this.attachmentUri = attachmentUri;
    }
}
  1. 在创建了我们的模型之后,我们现在可以继续我们的内存消息存储库。这个类简单地使用HashMap通过UUID键存储消息。这些消息是不可持久的,并且不会在服务重启后存活,因此这不是生产服务推荐的技术。该类有两个方法:saved,它生成 UUID 并将消息存储在映射中,以及get,它尝试从映射中检索消息。如果没有找到消息,将抛出异常,如下所示:
package com.packtpub.microservices.ch05.message;

import com.packtpub.microservices.ch05.message.exceptions.MessageNotFoundException;
import com.packtpub.microservices.ch05.message.models.Message;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

public class MessageRepository {

    private Map<String, Message> messages;

    public MessageRepository() {
        messages = new HashMap<>();
    }

    public Message save(Message message) {
        UUID uuid = UUID.randomUUID();
        Message saved = new Message(uuid.toString(), message.getSender(), message.getRecipient(),
                message.getBody(), message.getAttachmentUri());
        messages.put(uuid.toString(), saved);
        return saved;
    }

    public Message get(String id) throws MessageNotFoundException {
        if (messages.containsKey(id)) {
            Message message = messages.get(id);
            return message;
        } else {
            throw new MessageNotFoundException("Message " + id + " could not be found");
        }
    }
}
  1. 我们的服务有一个用于消息的单个控制器。该控制器有两个端点,一个允许调用者通过 ID 检索消息(如果消息未找到,则返回404响应),另一个尝试发送消息(如果消息的发送者和接收者不是好友,则返回403响应):
package com.packtpub.microservices.ch05.message;

import com.packtpub.microservices.ch05.message.clients.SocialGraphClient;
import com.packtpub.microservices.ch05.message.exceptions.MessageNotFoundException;
import com.packtpub.microservices.ch05.message.exceptions.MessageSendForbiddenException;
import com.packtpub.microservices.ch05.message.models.Message;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import java.net.URI;
import java.util.List;

@RestController
public class MessageController {

    @Autowired
    private MessageRepository messagesStore;

    @Autowired
    private SocialGraphClient socialGraphClient;

    @RequestMapping(path = "/messages/{id}", method = RequestMethod.GET, produces = "application/json")
    public Message get(@PathVariable("id") String id) throws MessageNotFoundException {
        return messagesStore.get(id);
    }

    @RequestMapping(path = "/messages", method = RequestMethod.POST, produces = "application/json")
    public ResponseEntity<Message> send(@RequestBody Message message) throws MessageSendForbiddenException {

        List<String> friendships = socialGraphClient.getFriendships(message.getSender());
        if (!friendships.contains(message.getRecipient())) {
            throw new MessageSendForbiddenException("Must be friends to send message");
        }

        Message saved = messagesStore.save(message);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest().path("/{id}")
                .buildAndExpand(saved.getId()).toUri();
        return ResponseEntity.created(location).build();
    }
}
  1. 创建一个Application类,它简单地运行我们的应用程序并创建必要的 bean,这些 bean 将被连接到我们的控制器,如下所示:
package com.packtpub.microservices.ch05.message;

import com.packtpub.microservices.ch05.message.clients.SocialGraphClient;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class Application {
    @Bean
    public MessageRepository messageRepository() {
        return new MessageRepository();
    }

    @Bean
    public SocialGraphClient socialGraphClient() {
        return new SocialGraphClient("http://localhost:4567");
    }

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}

此服务运行正常,并满足我们的主要要求:如果发送者和接收者不是好友,则无法发送消息,但它容易受到我们描述的所有问题的困扰。如果社交图服务出现问题,消息服务将依赖于RestTemplate客户端的超时,这将影响消息服务能够处理请求数量。此外,如果社交图服务过载并开始返回503(一个表示服务暂时不可用的 HTTP 状态码),消息服务没有机制允许社交图服务恢复。现在让我们介绍resilience4j断路器库,并将对社交图服务的调用封装起来:

  1. 打开build.gradle并添加resilience4j断路器库到依赖项列表,如下所示:
...
dependencies {
    testCompile group: 'junit', name: 'junit', version: '4.12'
 compile group: 'io.github.resilience4j', name: 'resilience4j-circuitbreaker', version: '0.11.0'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
}
...
  1. 修改SocialGraphClient以在调用社交图客户端时使用CircuitBreaker。如果SocialGraphClient返回失败,我们将返回一个空的Friendships实例,这将导致我们的服务对用户请求以403禁止(默认关闭)的方式响应。在这里我们将使用默认的断路器配置,但你应该查阅resilience4j的文档,其中包含大量关于配置断路器以满足你服务特定需求的信息。看看以下代码:
package com.packtpub.microservices.ch05.clients;

import com.packtpub.microservices.ch05.models.Friendships;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.vavr.CheckedFunction0;
import io.vavr.control.Try;
import org.springframework.web.client.RestTemplate;

import java.util.List;

public class SocialGraphClient {
    private String baseUrl;

 private CircuitBreaker circuitBreaker;

    public SocialGraphClient(String baseUrl) {
        this.baseUrl = baseUrl;
        this.circuitBreaker = CircuitBreaker.ofDefaults("socialGraphClient");
    }

    public List<String> getFriendships(String username) {

        CheckedFunction0<Friendships> decoratedSupplier = CircuitBreaker.decorateCheckedSupplier(circuitBreaker, () -> {
            String requestUrl = baseUrl + "/friendships/" + username;
            RestTemplate template = new RestTemplate();
            return template.getForObject(requestUrl, Friendships.class);
        });

        Try<Friendships> result = Try.of(decoratedSupplier); 
 return result.getOrElse(new Friendships(username)).getFriendships();    }
}

现在我们将服务中的危险网络调用包装在断路器中,防止社交图服务中的故障级联到消息服务。在社交图服务出现暂时性故障的情况下,消息服务最终会快速失败,并给社交图服务恢复的时间。你可以通过强制模拟社交图服务返回错误代码来测试这一点——这是一个留给读者作为有趣练习的任务!

使用指数退避重试请求

分布式系统中的故障是不可避免的。我们不想完全防止故障,而是要设计能够自我修复的系统。为了实现这一点,当客户端启动重试时,必须有一个良好的策略。服务可能会暂时不可用或遇到需要值班工程师手动响应的问题。在任何一种情况下,客户端都应该能够排队并重试请求,以获得最佳的成功机会。

在出现错误时无限重试不是一个有效的策略。想象一下,一个服务开始经历高于正常水平的失败率,甚至可能高达 100%的请求失败。如果客户端持续不断地排队重试而不放弃,你最终会遇到暴风群问题——客户端无限期地重试请求。随着失败时间的推移,更多的客户端将遇到故障,导致更多的重试。你将遇到以下图表所示的交通模式,这与你在拒绝服务攻击期间看到的图表相似。最终结果将是相同的——由于服务过载而导致的级联故障,以及合法流量的流失。你的应用程序将变得不可用,而失败的服务将更难隔离和修复:

图片

防止暴风群(thundering herds)的解决方案是添加一个指数增加重试间隔的退避算法,并在一定数量的失败后放弃。这种方法被称为有界指数退避。在重试之间添加一个指数增加的睡眠函数实现了我们目标的一半——客户端将减缓他们的重试尝试,分散负载。不幸的是,客户端的重试仍然会聚集,导致你的服务在一段时间内被许多并发请求猛烈打击。我们策略的第二部分通过向我们的睡眠函数添加随机值或抖动来解决这个问题,以分散重试。总结来说,我们的重试策略有以下三个要求:

  • 重试必须使用指数退避来间隔

  • 重试必须通过添加抖动进行随机化

  • 重试必须在特定时间后终止

大多数 HTTP 库都将支持满足这些要求的重试策略。在本食谱中,我们将查看由 Google 编写的 Java HTTP client库。

如何做到这一点...

  1. 为了演示使用指数退避和抖动,我们将创建一个简单的 Ruby 服务示例,它只有一个简单的任务:返回一个表示失败的 HTTP 状态码。在之前的菜谱中,我们使用了 sinatra Ruby 库来完成这个任务,所以我们将继续使用这个,一个简单地返回每个请求的 503 HTTP 状态码的服务,如下所示:
require 'sinatra'

get '/' do
  halt 503
end
  1. 使用 Google HTTP client 库创建一个 HTTP 客户端。首先,创建一个新的 Gradle Java 项目,包含以下 build.gradle 文件,该文件导入必要的库和插件,如下所示:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

apply plugin: 'java'
apply plugin: 'application'

mainClassName = 'com.packtpub.microservices.ch05.retryclient.Main'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'com.google.http-client', name: 'google-http-client', version: '1.23.0'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 创建一个名为 com.packtpub.microservices.ch05.retryclient 的新包。创建一个名为 Main 的新类。在 Main 类中,我们只是创建一个 HTTP 请求并执行它。如果请求成功,我们将打印其状态码和一条友好的消息。如果成功失败,我们仍然会打印其状态码,但会附带一条表示出错的短信。我们 HTTP 客户端的第一个版本将不会尝试任何重试。此代码的目的是编写尽可能简单的客户端,而不是展示 Google HTTP client 库的功能,但我鼓励你查阅项目的文档以了解更多信息。让我们看一下以下代码:
package com.packtpub.microservices.ch05.retryclient;

import com.google.api.client.http.*;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.util.ExponentialBackOff;

import java.io.IOException;

public class Main {

    static final HttpTransport transport = new NetHttpTransport();

    public static void main(String[] args) {
        HttpRequestFactory factory = transport.createRequestFactory();
        GenericUrl url = new GenericUrl("http://localhost:4567/");

        try {
            HttpRequest request = factory.buildGetRequest(url);
            HttpResponse response = request.execute();
            System.out.println("Got a successful response: " + response.getStatusCode());
        } catch (HttpResponseException e) {
            System.out.println("Got an unsuccessful response: " + e.getStatusCode());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  1. 如果你使用你的 IDE 运行前面的代码,或者从命令行运行 ./gradlew run,你会看到代码尝试进行单个 HTTP 请求,从我们的 Ruby 服务中收到 503,然后放弃。现在让我们使用一个可配置的退避策略,它具有随机化因子以添加抖动,如下所示:
package com.packtpub.microservices.ch05.retryclient;

import com.google.api.client.http.*;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.util.ExponentialBackOff;

import java.io.IOException;

public class Main {

    static final HttpTransport transport = new NetHttpTransport();

    public static void main(String[] args) {
        HttpRequestFactory factory = transport.createRequestFactory();
        GenericUrl url = new GenericUrl("http://localhost:4567/");

        try {
            HttpRequest request = factory.buildGetRequest(url);
            ExponentialBackOff backoff = new ExponentialBackOff.Builder()
 .setInitialIntervalMillis(500)
 .setMaxElapsedTimeMillis(10000)
 .setMaxIntervalMillis(6000)
 .setMultiplier(1.5)
 .setRandomizationFactor(0.5)
 .build(); 
 request.setUnsuccessfulResponseHandler(
 new HttpBackOffUnsuccessfulResponseHandler(backoff));
            HttpResponse response = request.execute();
            System.out.println("Got a successful response: " + response.getStatusCode());
        } catch (HttpResponseException e) {
            System.out.println("Got an unsuccessful response: " + e.getStatusCode());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  1. 如果你现在运行程序并查看你的 Ruby 服务的日志,你会看到代码会尝试多次进行请求,在重试之间增加睡眠时间,最终在约 10 秒后放弃。在现实世界的设置中,这可能会给服务足够的时间可能恢复,同时不会产生一群暴风雨般的用户,从而消除任何修复的可能性。

通过缓存提高性能

微服务应该设计成这样,即单个服务通常是唯一读取或写入特定数据存储的东西。在这个模型中,服务对其提供的业务能力中涉及的领域模型拥有完全的所有权。清晰的边界使得考虑系统中数据的生命周期变得更加容易。我们系统中的某些模型会频繁更改,但许多模型会被读取得比写入得多。在这些情况下,我们可以使用缓存来存储不经常更改的数据,从而在每次请求对象时节省我们向数据库发送请求。数据库查询通常比缓存查找更昂贵,因此尽可能使用缓存是理想的。

除了帮助提高性能外,一个有效的缓存层还可以帮助提高服务的可靠性。无法保证数据库的 100% 可用性,因此,在数据库故障的情况下,服务可以回退到提供缓存数据。在大多数情况下,对于用户来说,即使数据是旧的并且可能过时,也比完全收不到数据要好。拥有一个缓存层允许您配置您的服务,使其作为另一个可用数据源来为您的服务用户提供服务。

在这个菜谱中,我们将创建一个简单的示例服务,该服务提供有关应用程序用户的信息。它将有两个端点,第一个端点将接受 POST 请求并将正确格式化的用户持久化到数据库中。第二个端点将通过指定的 ID 获取用户表示。ID 存储为 UUID,这比自增 ID 更好,原因很多,我们将在后面的章节中详细说明。我们将从基本服务开始,然后添加缓存,以便我们可以看到具体需要哪些步骤。在这个菜谱中,我们将使用 Redis,这是一个流行的开源内存数据结构存储库,特别适用于存储键值对。

如何做到这一点...

  1. 创建一个名为 caching-user-service 的 Gradle Java 项目,并使用以下 build.gradle 文件。请注意,我们正在添加对 Java Persistence APIJPA)和 Java MySQL client 库的依赖项:
group 'com.packtpub.microservices.ch05'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:2.0.0.RELEASE")
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '2.0.0.RELEASE'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '2.0.0.RELEASE'
    compile group: 'mysql', name: 'mysql-connector-java', version: '6.0.6'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 创建 Main 类。像往常一样,这是我们的应用程序的主要入口点,相当简单:
package com.packtpub.microservices.ch05.userservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Main {
    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}
  1. com.packtpub.microservices.ch05.userservice.models 包中创建一个 User 类。这将成为我们的实体表示,并包含将存储在数据库中并最终存储在我们的 Redis 缓存中的字段:
package com.packtpub.microservices.ch05.userservice.models;

import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.annotations.GenericGenerator;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class User {

    @Id
    @GeneratedValue(generator = "uuid")
    @GenericGenerator(name = "uuid", strategy = "uuid2")
    private String id;

    private String username;

    @JsonProperty("full_name")
    private String fullName;

    private String email;

    public User() {}

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getFullName() {
        return fullName;
    }

    public void setFullName(String fullName) {
        this.fullName = fullName;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}
  1. 要将我们的 User 实体连接到 MySQL 数据库,创建一个 UserRepository 接口,该接口扩展了由 springframework 数据包定义的 CrudRepository 接口,如下所示:
package com.packtpub.microservices.ch05.userservice.db;

import com.packtpub.microservices.ch05.userservice.models.User;
import org.springframework.data.repository.CrudRepository;

public interface UserRepository extends CrudRepository<User, String> {}
  1. 创建 UserController 类。这是一个 RestController,它将某些端点映射到之前讨论的功能,即创建和检索用户记录。这里的一切都应该很熟悉。值得注意的是,findById 方法返回 Optional<T>,因此我们使用 maporElseGet 来返回一个包含用户响应体的 200 OK HTTP 响应或一个 404 状态,如下面的代码所示:
package com.packtpub.microservices.ch05.userservice.controllers;

import com.packtpub.microservices.ch05.userservice.db.UserRepository;
import com.packtpub.microservices.ch05.userservice.models.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Optional;

@RestController
public class UserController {

    @Autowired
    private UserRepository userRepository;

    @RequestMapping(path = "/users", method = RequestMethod.POST, produces = "application/json")
    public User create(@RequestBody User user) {
        User savedUser = userRepository.save(user);
        return savedUser;
    }

    @RequestMapping(path = "/users/{id}", method = RequestMethod.GET, produces = "application/json")
    public ResponseEntity<User> getById(@PathVariable("id") String id) {
        Optional<User> user = userRepository.findById(id);

        return user.map(u -> new ResponseEntity<>(u, HttpStatus.OK)).orElseGet(
                () -> new ResponseEntity<>(HttpStatus.NOT_FOUND));
    }
}
  1. 将以下 application.properties 文件添加到 src/main/resources 目录。它包含连接到本地 MySQL 实例所需的必要配置。假设您已经安装了 MySQL 并在本地运行它。您还应该创建一个名为 users 的数据库,一个名为 userservice 的用户,密码为 password。请注意,我们将 ddl-auto 设置为 create,这对于开发来说是一个好习惯,但不应该用于生产:
spring.jpa.hibernate.ddl-auto=create
spring.datasource.url=jdbc:mysql://localhost:3306/users?serverTimezone=UTC&&&useSSL=false
spring.datasource.username=userservice
spring.datasource.password=password
  1. 让我们添加一些缓存!我们首先需要再次打开application.properties文件,并为在端口6379(默认端口)上本地运行的redis实例添加一些配置,如下所示:
spring.jpa.hibernate.ddl-auto=create
spring.datasource.url=jdbc:mysql://localhost:3306/users?serverTimezone=UTC&&&useSSL=false
spring.datasource.username=userservice
spring.datasource.password=password
spring.cache.type=redis
spring.redis.host=localhost
spring.redis.port=6379
  1. 在我们的应用程序配置为使用 MySQL 作为主数据源和 Redis 作为缓存后,我们现在可以覆盖CrudRepository<T, ID>接口中的方法,并添加注释来指示它进行缓存。我们希望在每次使用User对象调用save方法时都写入缓存,并在每次使用有效的用户 ID 字符串调用findById时都从缓存中读取:
package com.packtpub.microservices.ch05.userservice.db;

import com.packtpub.microservices.ch05.userservice.models.User;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends CrudRepository<User, String> {
    @Override
    @Cacheable(value = "users", key = "#id")
 Optional<User> findById(String id);

    @Override
    @CachePut(value = "users", key = "#user.id")
 User save(User user);
}
  1. 就这样!您可以通过运行服务、创建用户、验证用户是否同时在 MySQL 数据库和 Redis 缓存中,然后从数据库中删除用户来测试这一点。对users/ID端点的请求仍然会返回用户记录。在完成此服务之前,您需要确保如果用户被删除,缓存将被无效化。任何修改用户的其他端点都应该使缓存无效化或重写缓存。这留给读者作为练习!

使用 CDN 作为服务的前端

内容分发网络CDN)通过全球分布的代理服务器网络交付内容,从而提高性能和可用性。当用户(通常通过他们的移动设备)通过 CDN 对您的 API 进行请求时,他们将根据他们的地理位置与许多存在点PoPs)之一建立网络连接。无需为每个请求往返到源数据中心,内容可以在 CDN 的边缘进行缓存,这大大减少了用户的响应时间,并减少了不必要的、昂贵的流量到源。

如果您计划拥有全球用户基础,CDN(内容分发网络)是必需的。如果您的应用程序 API 的每个请求都必须完整往返到单个源,那么您将为那些物理上远离您托管应用程序数据中心的世界各地的用户创造一个次优体验。即使您在多个数据中心托管您的应用程序,您也永远无法使用 CDN 为尽可能多的用户提供与不使用 CDN 时一样高性能的体验。

除了性能之外,CDN 还可以提高您应用程序的可用性。正如我们在前面的菜谱中讨论的那样,您系统中的许多实体被读取的频率远高于它们被写入的频率。在这些情况下,您可以配置您的 CDN 将服务的数据包缓存一定的时间(通常由 TTL 或生存时间指定)。从您的服务缓存响应可以减少发送到源头的流量,使其更难耗尽容量(计算、存储或网络)。此外,如果您的服务开始出现高延迟,或完全或部分故障,您可以配置 CDN 在服务故障时提供缓存响应而不是继续向故障服务发送流量。这至少允许您在服务中断的情况下向用户提供内容。

一些 CDN 提供商具有 API,允许您自动使资源无效。在这些情况下,您可以配置您的微服务以使资源无效,就像您使用基于 Redis 或 Memcached 的缓存那样,如前一个菜谱中所述。

市面上有许多不同的 CDN 提供商。其中一些大型提供商包括AkamaiEdgecast。亚马逊网络服务提供了一种称为 CloudFront 的 CDN 服务,可以配置为向 AWS 中的源服务器或 S3 存储桶中托管的静态资源发送请求。CDN 市场中更受开发者欢迎的提供之一来自一家名为Fastly的公司。Fastly 是使用开源的 Web 应用程序加速器Varnish构建的。

作为提供商,Fastly 允许您上传自己的Varnish 配置语言VCL)文件,从而有效地允许您根据请求的任何方面(传入的标题、路径段、查询字符串参数等)创建缓存规则。此外,Fastly 提供了一种快速清除 API,允许您根据 URI 使资源无效。

在本菜谱中,我们将介绍创建 CDN 提供商账户并开始通过 CDN 提供服务的基本步骤。我们将使用一个假设的服务,该服务通过主机名api.pichat.me对公众互联网开放。该服务通过检查传入请求的 Authorization 头中的有效 OAuth2 令牌值来验证请求。

如何做到这一点...

  1. 在本例中,我们将使用 Fastly 作为 CDN 提供商创建一个账户。截至本文撰写时,注册 URL 为https://www.fastly.com/signup

  2. Fastly 会要求您创建一个服务。为您的服务输入一个名称,以及应用程序运行的源服务器的域名(api.pichat.me)和主机名。

  3. 使用您的域名 DNS 提供商,为api.pichat.me创建一个 CNAME,将您的域名指向 Fastly 的服务器。阅读更新的文档以了解要使用的主机名。

  4. 一旦设置好并创建了你的服务,对你的主机名的请求现在将通过 Fastly CDN 进行。阅读 Fastly 文档 (docs.fastly.com/guides/basic-setup/) 以了解如何为你的服务自定义 VCL 和其他设置。

优雅地降级用户体验

我们现在已经明白,一定程度的失败是不可避免的。在一个足够复杂的系统中,有时总会发生一些故障。通过使用本章中的技术,我们可以尝试减少这些故障中任何一个影响客户的可能性。尽管我们尽力防止其发生,但在应用程序的生命周期中,某种类型的故障可能会在某个时刻影响客户体验。然而,面对系统故障,用户可能会表现出令人惊讶的同情心,只要用户体验能够优雅地降级。

考虑以下场景:你正在使用一个允许你浏览产品目录并查找销售该产品的本地商店的应用程序,同时提供诸如地址、电话号码和营业时间等重要信息。假设提供本地商店信息的那个服务变得不可用。这显然以一种不太理想的方式影响了用户体验,但应用程序可以以多种方式处理这种故障。最糟糕的方式可能是允许故障级联并使产品目录崩溃。稍微好一点的方式可能是允许用户继续搜索产品,但当用户去查找销售该产品的本地商店时,他们通过某种信息框被告知本地商店信息目前不可用。这很令人沮丧,但至少他们还可以查看产品信息,如价格、型号和颜色。更好的方式是认识到该服务未运行,并有一些信息横幅通知用户本地商店信息暂时不可用。有了这些信息,我们可以通知用户情况,让他们决定是否仍然想继续搜索产品。这种体验不是最优的,但我们避免了不必要的用户沮丧。

通过 Gameday 练习验证容错性

本章包含了一些食谱,可以帮助你创建更可靠、更具弹性的微服务架构。每个食谱都记录了一种预测和应对某种故障场景的模式或技术。我们构建弹性系统时的目标是尽可能减少对用户的影响来容忍故障。在构建分布式系统时,预测和为故障设计是至关重要的,但如果没有验证我们的系统是否以我们预期的方式处理故障,我们做的就不仅仅是希望,而希望绝对不是一种策略!

在构建系统时,单元测试和功能测试是我们信心构建工具包的必要部分。然而,这些工具本身并不足够。单元测试和功能测试通过隔离依赖项来工作,例如,好的单元测试不依赖于网络条件,而功能测试不涉及在生产级流量条件下进行测试,而是专注于在各种理想条件下软件组件协同工作。为了对系统的容错性有更大的信心,有必要观察它在生产环境中对失败的响应。

Gameday 练习是构建系统弹性信心的另一种有用工具。这些练习涉及在生产环境中强制某些故障场景,以验证我们对容错性的假设是否符合现实。约翰·奥尔斯帕在论文《生产中的故障注入》中详细描述了这种做法。如果我们接受完全避免失败是不可能的,那么强制失败并观察我们的系统如何响应它作为计划内的练习是有意义的。当整个团队都在现场并准备采取行动时,让系统首次失败比在凌晨 3 点系统警报唤醒值班工程师时更好。

计划 Gameday 练习提供了大量价值。工程师们应该聚在一起,头脑风暴他们服务可能遇到的各类故障场景。然后应安排工作以尝试减少或消除这些场景的影响(即,在数据库故障的情况下,回滚到缓存)。每个 Gameday 练习都应该有一个规划文档,描述正在测试的系统、各种故障场景,包括将采取的步骤来模拟故障、围绕系统应该如何响应故障的期望,以及预期对用户(如果有)的影响。随着 Gameday 练习的进行,团队应逐一处理每个场景,记录观察结果——重要的是要确保我们期望看到的指标正在发出,我们期望触发的警报确实触发了,并且故障以我们期望的方式得到处理。在观察到差异时,记录任何期望与现实之间的差异。这些观察结果应成为计划中的工作,以弥合理想世界与现实世界之间的差距。

与逐步审查代码不同,这个配方将演示一个过程和模板,可以用来运行 Gameday 练习。以下不是进行 Gameday 练习的唯一方法,但应该作为您组织的一个良好起点。

前置条件

总是有些先决条件你应该在尝试运行游戏日练习之前确保满足。具体来说,你的团队应该习惯于使用必要的指标和警报来对代码进行仪表化,以便在生产环境中提供一定程度的可观察性。你的团队应该有在理解良好且经过实践的事故响应流程中工作的经验,这包括定期进行回顾,以便在生产事件中不断改进。

最后,你的组织应该习惯于公开讨论故障和意外的生产事件,并致力于鼓励持续改进的过程。这些先决条件应该表明,你的团队有必要的组织支持和心理安全感来执行这些类型的弹性练习。

如何做...

  1. 游戏日练习的第一步是选择一个将要测试的系统。当你刚开始进行游戏日时,选择一个理解良好、之前已经失败并且对用户影响有限的系统是明智的。

  2. 一旦选择了服务,召集负责其开发和运营的团队,并开始头脑风暴不同的故障场景。如果有一个数据存储,考虑如果由于硬件故障突然不可用会发生什么。也许数据库可以被手动关闭。如果数据库以不安全的方式终止会发生什么?服务运行在某种集群配置中,那么如果从负载均衡器中移除一个节点会发生什么?当所有节点都失败并被从负载均衡池中移除时会发生什么?另一个需要测试的领域是意外的延迟。在分布式系统中,足够高的延迟与服务不可用难以区分,因此这里可能存在许多有趣的错误。召集团队讨论所有这些场景(以及其他场景)可以是一种了解系统的极好方式。记录下你计划测试的所有场景。

  3. 安排一个时间和房间进行游戏日实验(如果你是远程团队,安排所有人一起进行视频通话)。邀请负责被测试服务的团队、客户支持团队的代表以及任何对实验感兴趣的利益相关者。

  4. 使用模板,例如这里包含的模板,详细规划实验的进行方式。在预定时间当天,首先概述正在测试的系统。这是一个确保每个人都对系统工作方式有统一看法的好机会。然后逐个场景进行,将实际操作分配给团队中的某个人。

  5. 记录实验期间的观察结果,详细说明系统对故障注入的反应。

  6. 如果实验中观察到的结果与预期不同,安排后续任务,以工单的形式让团队纠正差异。

Gameday 练习的模板

以下模板可用于计划和执行 Gameday 练习。

系统: 消息服务

系统概述:

对待测试系统的详细描述(可能包括图表)。记录请求如何路由到系统、与之交互的一些主要系统、它使用的数据存储及其一般配置,以及它依赖的任何下游服务是个好主意。

仪表板:

在 Gameday 练习进行期间,查看重要仪表板的链接。

测试场景:

场景: 由于节点被终止,数据库变得不可用。

方法:

使用 AWS CLI 工具手动关闭数据库 EC2 节点(包括实际命令)。

预期:

列出你期望服务如何反应。包括关于预期指标变化、应触发的警报、系统行为和用户影响等详细信息。

观察:

记录实际测试过程中的观察结果。

后续行动项目:

为实验结果导致的任何后续工作创建工单。

引入自动化混沌

运行手动 Gameday 练习是引入故障注入实践的好方法。在生产环境中强制故障有助于增强对系统弹性的信心,并识别改进的机会。Gameday 帮助团队更好地理解他们的系统在面对多种故障场景时的行为。随着团队进行更多练习,它将开始积累执行常见任务(如在网络中引入延迟或增加 CPU 使用率)的工具。工具有助于自动化日常任务,提高 Gameday 练习的效率。有各种开源和商业工具旨在自动化混沌工程,团队可以立即利用这些工具。

Gameday 练习是计划并安排的。一些组织更进一步,引入持续故障注入作为确保系统能够顺利处理常见故障场景的一种方式。2011 年初,Netflix 宣布创建了 Simian Army——一套旨在将常见故障注入生产环境的工具集。Simian Army 中最著名的成员之一,Chaos Monkey,会随机关闭生产环境中的节点。Simian Army 工具已经开源,可供您的组织使用。它们可以计划作为 Gameday 练习的一部分运行,或者设置为特定的时间表(即周一至周五,上午 9 点至下午 5 点,当值工程师通常在办公室时)。

在这个领域的先驱者,PagerDuty,自 2013 年以来一直在进行“故障周五”。每周五,工程师们聚在一起攻击特定的服务。随着时间的推移,工程师们开始在他们的聊天机器人中构建命令以执行常见功能,例如将节点从其他网络流量中隔离出来,甚至添加了一个“轮盘”命令,该命令可以随机选择主机进行重启。

已开发托管商业服务以帮助自动化混沌工程。Gremlin 是一个托管产品,旨在通过提供对安装在您环境中节点上的代理执行的“攻击”库来帮助团队运行 Gameday 练习。Gremlin 提供了一个 API 和一个 Web 界面,允许用户配置旨在激增资源使用(CPU、内存、磁盘)的攻击,通过终止进程或重启主机来模拟随机故障,并模拟常见的网络条件,如延迟和网络时间协议NTP)漂移。拥有像 Gremlin 这样的产品可以降低开始进行故障注入所需的前期工作量。

另一个开源工具是混沌工具包,这是一个 CLI 工具,旨在使设计和运行实验更容易。在本配方中,我们将安装混沌工具包,并使用它来对一个假设的用户服务执行简单实验。用户服务将是我们在本章早期“通过缓存提高性能”配方中编写的同一个服务。

如何操作...

  1. 混沌工具包是用 Python 编写的,可以使用pip进行安装。我们需要一个有效的 Python3 环境。本配方将假设您正在使用 Homebrew 在 macOS X 上安装它。首先,安装pyenv——一个支持管理多个 Python 开发环境的实用工具,如下所示:
$ brew install pyenv
  1. 通过执行以下命令行来安装 Python3:
$ pyenv install 3.4.2
$ pyenv global 3.4.2
  1. 使用新安装的 Python3 环境,执行以下命令行来安装混沌工具包:
 $ pip install -U chaostoolkit
  1. 混沌工具包使用 JSON 文件来描述实验。每个实验都应该有一个标题、描述,以及可选的一些用于对实验进行分类的标签。steady-state-hypothesis部分描述了服务在正常条件下的预期行为。在我们的情况下,我们假设如果找到用户,服务将返回200,如果没有找到用户,则返回404
{
  "title": "Kill MySQL process",
  "description": "The user service uses a MySQL database to store user information. This experiment will test how the service behaves when the database is unavailable.",
  "tags": [
    "database", "mysql"
  ],
  "steady-state-hypothesis": {
    "title": "Service responds when MySQL is running",
    "probes": [
      {
        "type": "probe",
        "name": "service-is-running",
        "tolerance": [200, 404],
        "provider": {
          "type": "http",
          "url": "http://localhost:8080/users/12345"
        }
      }
    ]
  },
  "method": [
    {
      "name": "kill-mysql-process",
      "type": "action",
      "provider": {
        "type": "process",
        "path": "/usr/local/bin/mysql.server",
        "arguments": ["stop"],
        "timeout": 10
      }
    }
  ]
}
  1. 运行以下实验:
$ chaos run
  1. 如果成功,输出应表明当 MySQL 不可用时,服务响应良好。然而,在其当前状态下,实验将使 MySQL 停止,这并不理想。现在你有一些东西要修复,这留给读者作为练习,并且你可以重新运行你的实验。恭喜!你刚刚运行了你的第一个自动化混沌实验。

第六章:安全性

在本章中,我们将介绍以下菜谱:

  • 验证您的微服务

  • 保护容器

  • 安全配置

  • 安全日志

  • 基础设施即代码

简介

与本书中涵盖的许多主题一样,微服务架构中的安全性是关于权衡的。在微服务架构中,单个代码库的责任有限。如果攻击者能够破坏单个运行的服务,他们只能执行由该特定微服务管理的操作。然而,微服务架构的分布式特性意味着攻击者有更多的目标可以在运行在不同集群中的服务中进行潜在利用。这些集群之间的网络流量,包括边缘服务和内部服务之间的流量,为攻击者提供了许多发现漏洞的机会。

由于微服务架构的分布式特性,在配置服务如何相互通信时必须考虑网络拓扑。这种关注也存在于单体代码库中,其中单个代码库的运行实例需要通过网络与数据库服务器、缓存、负载均衡器等进行通信。可以认为微服务架构使这些挑战更加明显,因此迫使工程师更早地考虑这些问题。

安全性是一个大话题。本章将讨论在构建、部署和运营微服务时需要考虑的一些良好实践,但重要的是要注意,这并不是一个详尽的考虑清单。在开发任何系统时,都应考虑良好的 API 实践和深度防御,微服务也不例外。我强烈推荐OWASP (www.owasp.org/index.php/Main_Page) 作为学习更多关于 Web 应用安全资源的参考。

验证您的微服务

在第一章,打破单体,我们介绍了一个 Ruby on Rails 代码库,它为我们的虚构图片分享应用pichat提供动力。Rails 代码库通过检查授权头来验证每个请求。如果存在头信息,应用程序将尝试使用从环境变量中读取的共享密钥对其进行解码(参见安全配置菜谱)。如果授权头中提供的令牌有效,解码的值包含有关用户的环境信息,包括用户 ID。然后使用这些信息从数据库中检索用户,以便应用程序对发出请求的用户有上下文。如果授权头缺失或无法成功解码,应用程序将引发异常,并向调用者返回 HTTP 401,包括错误信息。为了获取要包含在授权头中的令牌,客户端应用程序可以向/auth/login端点发送带有有效用户凭据的POST请求。以下 CURL 命令演示了此流程:

$ curl -D - -X POST http://localhost:9292/auth/login -d'email=p@eval.ca&password=foobar123'

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
ETag: W/"3675d2006d59e01f8665f20ffef65fe7"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 6660a102-059f-4afe-b17c-99375db305dd
X-Runtime: 0.150903
Transfer-Encoding: chunked

{"auth_token":"eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE1MzE2ODUxNjR9.vAToW_mWlOnr-GPzP79EvN62Q2MpsnLIYanz3MTbZ5Q"}

现在我们有了令牌,我们可以将其包含在后续请求的头部中:

$ curl -X POST -D - -H 'Authorization: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE1MzE2ODUxNjR9.vAToW_mWlOnr-GPzP79EvN62Q2MpsnLIYanz3MTbZ5Q' http://localhost:9292/messages -d'body=Hello&user_id=1'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
ETag: W/"211cdab551e63ca48de48217357f1cf7"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 1525333c-dada-40ff-8c25-a0e7d151433c
X-Runtime: 0.019609
Transfer-Encoding: chunked

{"id":1,"body":"Hello","user_id":1,"created_at":"2018-07-14T20:08:19.369Z","updated_at":"2018-07-14T20:08:19.369Z","from_user_id":1}

由于pichat-api是一个单体代码库,它正在扮演许多不同的角色以支持此流程。它正在充当授权服务、认证网关、用户存储和授权客户端。这种责任的耦合正是我们希望在微服务架构中避免的。

幸运的是,在保持流程不变的同时,很容易将这些责任分割到单独的代码库中。使用共享密钥在JSON Web TokensJWT)中编码信息允许单个微服务在不需要对每个请求都向集中式认证服务发出请求的情况下安全地验证请求。获取认证令牌可以是集中式服务的责任,但可以使用 API 网关或前端的后端使这一事实对客户端透明。以下图表演示了某些责任将如何分割:

图片

我们将创建一个认证服务来处理用户注册并交换 JWT 凭证。然后我们将使用我们在第二章,边缘服务中介绍的 Zuul 开源项目创建一个简单的API 网关

如何操作...

让我们看看以下步骤:

  1. 让我们创建一个认证服务。使用以下build.gradle文件创建一个新的 Java 项目:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
    compile group: 'org.springframework.security', name: 'spring-security-core'
    compile group: 'org.springframework.security', name: 'spring-security-config'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa'
    compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
    compile group: 'mysql', name: 'mysql-connector-java'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}

我们将在 MySQL 数据库中存储用户凭证,因此我们声明mysql-connector-java作为依赖项。我们还将使用一个名为jjwt的开源 JWT 库。

存储用户凭据是一个重要的话题。用户密码绝不应该以纯文本形式存储,许多散列算法,如 MD5 和 SHA1,已被证明容易受到各种暴力攻击。在这个例子中,我们将使用 bcrypt。在实际应用中,我们会考虑多个散列步骤,例如首先使用 SHA512 散列,然后通过 bcrypt。我们还会考虑为每个用户添加一个盐。开放网络应用安全项目有很多关于存储密码的优秀建议:www.owasp.org/index.php/Password_Storage_Cheat_Sheet

  1. 创建一个名为 Application 的新类。它将包含我们的主方法以及 PasswordEncoder
package com.packtpub.microservices.ch06.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@SpringBootApplication
public class Application {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  1. 我们将用户凭据建模为一个简单的 POJO,包含 emailpassword 字段。创建一个名为 com.packtpub.microservices.ch06.auth.models 的新包和一个名为 UserCredential 的新类:
package com.packtpub.microservices.ch06.auth.models;

import org.hibernate.annotations.GenericGenerator;

import javax.persistence.*;

@Entity
public class UserCredential {
    @Id
    @GeneratedValue(generator = "uuid")
    @GenericGenerator(name = "uuid", strategy = "uuid2")
    private String id;

    @Column(unique=true)
    private String email;

    private String password;

    public UserCredential(String email) {
        this.email = email;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}
  1. 创建一个模型来表示对成功登录和注册请求的响应。成功的响应将包含一个包含 JWT 的 JSON 文档。创建一个名为 AuthenticationToken 的新类:
package com.packtpub.microservices.ch06.auth.models;

import com.fasterxml.jackson.annotation.JsonProperty;

public class AuthenticationToken {

    @JsonProperty("auth_token")
    private String authToken;

    public AuthenticationToken() {}

    public AuthenticationToken(String authToken) {
        this.authToken = authToken;
    }

    public String getAuthToken() {
        return this.authToken;
    }

    public void setAuthToken(String authToken) {
        this.authToken = authToken;
    }
}
  1. UserCredential 类将通过 Java 持久化 API 访问。为此,我们必须首先创建 CrudRepository。创建一个名为 com.packtpub.microservices.ch06.auth.data 的新包和一个名为 UserCredentialRepository 的新类。除了继承自 CrudRepository,我们还将定义一个用于通过电子邮件检索 UserCredential 实例的单个方法:
package com.packtpub.microservices.ch06.auth.data;

import com.packtpub.microservices.ch06.auth.models.UserCredential;
import org.springframework.data.repository.CrudRepository;

public interface UserCredentialRepository extends CrudRepository<UserCredential, String> {
    UserCredential findByEmail(String email);
}
  1. 当用户尝试使用无效凭据注册或登录时,我们希望返回 HTTP 401 状态码以及一条消息,指出他们提供了无效凭据。为了做到这一点,我们将在我们的控制器方法中创建一个单独的异常:
package com.packtpub.microservices.ch06.auth.exceptions;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.UNAUTHORIZED)
public class InvalidCredentialsException extends Exception {
    public InvalidCredentialsException(String message) { super(message); }
}
  1. 创建控制器。登录和注册端点将由单个控制器提供服务。注册方法将简单地验证输入并创建一个新的 UserCredential 实例,使用我们之前创建的 CrudRepository 包持久化它。然后,它将使用新注册用户的用户 ID 作为主题编码 JWT。登录方法将验证提供的凭据并提供一个以用户 ID 作为主题的 JWT。控制器需要访问在主类中定义的 UserCredentialRepositoryPasswordEncoder。创建一个名为 com.packtpub.microservices.ch06.auth.controllers 的新包和一个名为 UserCredentialController 的新类:
package com.packtpub.microservices.ch06.auth.controllers;

import com.packtpub.microservices.ch06.auth.data.UserCredentialRepository;
import com.packtpub.microservices.ch06.auth.exceptions.InvalidCredentialsException;
import com.packtpub.microservices.ch06.auth.models.AuthenticationToken;
import com.packtpub.microservices.ch06.auth.models.UserCredential;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;

@RestController
public class UserCredentialController {

    @Autowired
    private UserCredentialRepository userCredentialRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Value("${secretKey}")
    private String keyString;

    private String encodeJwt(String userId) {
        System.out.println("SIGNING KEY: " + keyString);
        Key key = new SecretKeySpec(
                DatatypeConverter.parseBase64Binary(keyString),
                SignatureAlgorithm.HS256.getJcaName());

        JwtBuilder builder = Jwts.builder().setId(userId)
                .setSubject(userId)
                .setIssuer("authentication-service")
                .signWith(SignatureAlgorithm.HS256, key);

        return builder.compact();
    }

    @RequestMapping(path = "/register", method = RequestMethod.POST, produces = "application/json")
    public AuthenticationToken register(@RequestParam String email, @RequestParam String password, @RequestParam String passwordConfirmation) throws InvalidCredentialsException {
        if (!password.equals(passwordConfirmation)) {
            throw new InvalidCredentialsException("Password and confirmation do not match");
        }

        UserCredential cred = new UserCredential(email);
        cred.setPassword(passwordEncoder.encode(password));
        userCredentialRepository.save(cred);

        String jws = encodeJwt(cred.getId());
        return new AuthenticationToken(jws);
    }

    @RequestMapping(path = "/login", method = RequestMethod.POST, produces = "application/json")
    public AuthenticationToken login(@RequestParam String email, @RequestParam String password) throws InvalidCredentialsException {
        UserCredential user = userCredentialRepository.findByEmail(email);

        if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
            throw new InvalidCredentialsException("Username or password invalid");
        }

        String jws = encodeJwt(user.getId());
        return new AuthenticationToken(jws);
    }
}
  1. 由于我们正在连接到本地数据库,并且我们在签名 JWT 时使用共享密钥,因此我们需要创建一个小的属性文件。在 src/main/resources 目录下创建一个名为 application.yml 的文件:
server:
  port: 8081

spring:
  jpa.hibernate.ddl-auto: create
  datasource.url: jdbc:mysql://localhost:3306/user_credentials
  datasource.username: root
  datasource.password:

secretKey: supers3cr3t

现在我们有一个工作的认证服务,下一步是创建一个简单的 API 网关,使用开源网关服务 Zuul。除了将请求路由到下游服务外,API 网关还将使用认证过滤器来验证需要认证的请求中是否传递了有效的 JWT。

  1. 创建一个具有以下build.gradle文件的 Java 项目:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencyManagement {
    imports {
        mavenBom 'org.springframework.cloud:spring-cloud-netflix:1.4.4.RELEASE'
    }
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
    compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-zuul'
    compile group: 'org.springframework.security', name: 'spring-security-core'
    compile group: 'org.springframework.security', name: 'spring-security-config'
    compile group: 'org.springframework.security', name: 'spring-security-web'
    compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}

注意,我们正在使用与认证服务相同的 JWT 库。

  1. 创建一个名为com.packtpub.microservices.ch06.gateway的新包和一个名为Application的新类:
package com.packtpub.microservices.ch06.gateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@EnableZuulProxy
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  1. 我们将通过创建OncePerRequestFilter的子类来创建一个认证过滤器,该过滤器旨在为每个请求调度提供单次执行。该过滤器将解析Authorization头中的 JWT,并尝试使用共享密钥对其进行解码。如果 JWT 可以被验证和解码,我们可以确信它是由有权访问共享密钥的发行者编码的。我们将此视为我们的信任边界;任何有权访问共享密钥的人都可以被信任,因此我们可以信任 JWT 的主题是认证用户的 ID。创建一个名为AuthenticationFilter的新类:
package com.packtpub.microservices.ch06.gateway;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.bind.DatatypeConverter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Optional;

public class AuthenticationFilter extends OncePerRequestFilter {

    private String signingSecret;

    AuthenticationFilter(String signingSecret) {
        this.signingSecret = signingSecret;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Optional<String> token = Optional.ofNullable(request.getHeader("Authorization"));
        Optional<Authentication> auth = token.filter(t -> t.startsWith("Bearer")).flatMap(this::authentication);
        auth.ifPresent(a -> SecurityContextHolder.getContext().setAuthentication(a));
        filterChain.doFilter(request, response);
    }

    private Optional<Authentication> authentication(String t) {
        System.out.println(signingSecret);
        String actualToken = t.substring("Bearer ".length());
        try {
            Claims claims = Jwts.parser()
                    .setSigningKey(DatatypeConverter.parseBase64Binary(signingSecret))
                    .parseClaimsJws(actualToken).getBody();
            Optional<String> userId = Optional.ofNullable(claims.getSubject()).map(Object::toString);
            return userId.map(u -> new UsernamePasswordAuthenticationToken(u, null, new ArrayList<SimpleGrantedAuthority>()));
        } catch (Exception e) {
            return Optional.empty();
        }

    }
}
  1. 将此与 API 网关项目的安全配置一起连接起来。创建一个名为SecurityConfig的新类:
package com.packtpub.microservices.ch06.gateway;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletResponse;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Value("${jwt.secret}")
    private String signingSecret;

    @Override
    protected void configure(HttpSecurity security) throws Exception {
        security
            .csrf().disable()
            .logout().disable()
            .formLogin().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .anonymous()
            .and()
                .exceptionHandling().authenticationEntryPoint(
                    (req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_UNAUTHORIZED))
            .and()
            .addFilterAfter(new AuthenticationFilter(signingSecret),
                    UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests()
            .antMatchers("/auth/**").permitAll()
            .antMatchers("/messages/**").authenticated()
            .antMatchers("/users/**").authenticated();
    }
}

如我们所见,我们允许对认证服务(以/auth/...为前缀的请求)的任何请求。我们要求对用户或消息服务的请求进行认证。

  1. 我们需要一个配置文件来存储共享密钥以及 Zuul 服务器的路由信息。在src/main/resources目录中创建一个名为application.yml的文件:
server:
  port: 8080

jwt:
  secret: supers3cr3t

zuul:
  routes:
    authentication-service:
      path: /auth/**
      url: http://127.0.0.1:8081
    message-service:
      path: /messages/**
      url: http://127.0.0.1:8082
    user-service:
      path: /users/**
      url: http://127.0.0.1:8083
  1. 现在我们有一个工作认证服务和能够验证 JWT 的 API 网关,我们可以通过使用前面配置文件中定义的端口来运行 API 网关、认证服务和消息服务来测试我们的认证方案。以下 CURL 请求现在显示,有效的凭据可以交换 JWT,并且 JWT 可以用来访问受保护资源。我们还可以显示,没有有效 JWT 的请求会被拒绝访问受保护资源。

注意,在这个例子中,消息服务仍然不做任何请求授权。任何进行认证请求的人理论上都可以访问任何人的消息。消息服务应该修改为检查 JWT 主题中的用户 ID,并且只允许访问属于该用户的消息。

  1. 我们可以使用curl来测试注册新用户账户:
$ curl -X POST -D - http://localhost:8080/auth/register -d'email=p@eval.ca&password=foobar123&passwordConfirmation=foobar123'

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
X-Application-Context: application:8080
Date: Mon, 16 Jul 2018 03:27:17 GMT
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked

{"auth_token":"eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJmYWQzMGZiMi03MzhmLTRiM2QtYTIyZC0zZGNmN2NmNGQ1NGIiLCJzdWIiOiJmYWQzMGZiMi03MzhmLTRiM2QtYTIyZC0zZGNmN2NmNGQ1NGIiLCJpc3MiOiJhdXRoZW50aWNhdGlvbi1zZXJ2aWNlIn0.TzOKItjBU-AtRMqIB_D1n-qv6IO_zCBIK8ksGzsTC90"}
  1. 现在我们有了 JWT,我们可以将其包含在消息服务的请求头中,以测试 API 网关是否能够验证和解码令牌:
$ curl -D - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI3YmU4N2U3Mi03ZjhhLTQ3ZjktODk3NS1mYzM5ZTE0NjNmODAiLCJzdWIiOiI3YmU4N2U3Mi03ZjhhLTQ3ZjktODk3NS1mYzM5ZTE0NjNmODAiLCJpc3MiOiJhdXRoZW50aWNhdGlvbi1zZXJ2aWNlIn0.fpFbHhdSEVKk95m5Q7iNjkKyM-eHkCGGKchTTKgbGWw" http://localhost:8080/messages/123

HTTP/1.1 404
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
X-Application-Context: application:8080
Date: Mon, 16 Jul 2018 04:05:40 GMT
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked

{"timestamp":1532318740403,"status":404,"error":"Not Found","exception":"com.packtpub.microservices.ch06.message.exceptions.MessageNotFoundException","message":"Message 123 could not be found","path":"/123"}

我们从消息服务得到 404 错误表明请求已经到达该服务。如果我们修改请求头中的 JWT,我们应该得到 401 错误:

$ curl -D - -H "Authorization: Bearer not-the-right-jwt" http://localhost:8080/messages/123

HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Mon, 23 Jul 2018 04:06:47 GMT

{"timestamp":1532318807874,"status":401,"error":"Unauthorized","message":"No message available","path":"/messages/123"}

保护容器

容器的出现为管理微服务架构的组织解决了许多问题。容器允许将服务捆绑为自包含的单元,软件及其依赖项可以构建为一个单一的项目,然后将其运送到任何环境中运行或调度。而不是依赖于复杂的配置管理解决方案来管理生产系统的微小更改,容器支持不可变基础设施的概念;一旦基础设施构建完成,就不需要升级或维护。相反,你只需构建新的基础设施,然后丢弃旧的。

容器还允许组织优化其存储和计算资源的使用。由于软件可以构建为容器,多个应用程序可以在单个虚拟机或硬件上运行,每个应用程序都意识不到其他应用程序的存在。虽然多租户有许多优点,但在同一虚拟机上运行多个服务引入了新的攻击场景,恶意用户可能会利用这些场景。如果攻击者能够利用一个服务中的漏洞,他们可能会利用这个漏洞攻击同一虚拟机上运行的其他服务。在这种配置中,默认情况下,集群被视为安全边界;如果你有权访问集群,你必须被信任。

根据组织的需要,将集群视为安全边界可能不足以满足需求,并且可能希望容器之间有更多的安全和隔离。seccomp 安全设施在 Linux 内核的 2.6.12 版本中被引入。它支持限制从进程可以做出的系统调用。使用 seccomp 策略运行容器化应用程序实际上将服务以及容器中运行的任何其他进程隔离。在这个菜谱中,我们将向您展示如何检查 Linux 内核中是否配置了 seccomp,并演示如何使用自定义 seccomp 策略运行容器。

如何操作...

  1. 为了在 Docker 容器中使用 seccomp 策略,你必须在配置了 seccomp 支持的 Linux 内核的主机操作系统上运行容器。要检查这一点,你可以在内核配置文件中搜索 CONFIG_SECCOMP
$ grep CONFIG_SECCOMP= /boot/config-$(uname -r)
CONFIG_SECCOMP=y
  1. 现在我们已经验证了 Linux 内核中已启用 seccomp,我们可以查看 Docker 一起打包的默认配置文件(github.com/moby/moby/blob/master/profiles/seccomp/default.json)。此默认策略对于大多数需求来说已经足够,并且相当严格。如果启用了 seccomp 支持,容器将使用此策略运行。

  2. 为了进一步验证 seccomp 是否已配置并且 Docker 能够支持它,我们将创建一个简单的自定义策略,然后在容器中运行一个命令来演示策略正在被强制执行。创建一个名为 policy.json 的文件:

{
    "defaultAction": "SCMP_ACT_ALLOW",
    "syscalls": [
        {
            "name": "chown",
            "action": "SCMP_ACT_ERRNO"
        }
    ]
}
  1. 现在,运行一个执行 shell 的容器,然后尝试创建一个文件,然后更改所有权。错误消息表明容器正在受到 seccomp 策略的限制:
$ docker run --rm -it --security-opt seccomp:policy.json busybox /bin/sh
/ # touch foo
/ # chown root foo
chown: foo: Operation not permitted

安全配置

服务通常需要某种形式的配置。服务配置存储了所有可能根据服务部署的环境而变化的信息。例如,当在开发人员的工作站上本地运行服务时,服务可能应该连接到也在本地运行的数据库。然而,在生产环境中,服务应该连接到生产数据库。配置中通常存储的数据包括数据存储的位置和凭证、访问令牌或其他第三方服务的凭证以及操作信息,例如发送指标的位置或初始化连接池或配置网络连接超时时应使用的值。

将配置与代码分开存储很重要。当你进行配置更改时,你不应该需要将更改提交到源代码存储库、创建新的构建和运行单独的部署。理想情况下,应该有一种简单的方法来更改配置,而无需部署服务的新版本。将配置存储在代码中(例如,在源代码文件中硬编码密码)也是从安全角度的不良做法。任何有权访问源代码的人都可以访问配置,在秘密的情况下,这通常是不希望的。尽可能频繁地滚动密钥和凭证是一种良好的做法,这样即使秘密被泄露或容易受到泄露的威胁,它也不会有效很长时间。硬编码秘密会使这变得困难,在实践中通常意味着它不会发生。

一种常见的最佳实践是将配置存储在环境变量中。这是一种将配置值暴露给进程的好方法,可以根据服务运行的环境轻松更改。环境变量对于非秘密配置值很好,例如主机名、超时和日志级别。环境变量不足以存储秘密。

将秘密存储为环境变量会使这些值对在相同容器或进程空间中运行的所有进程都可用,这使得它们容易受到拦截。有各种方法可以将秘密与应用程序配置的其他部分分开存储。在 Kubernetes 集群上部署的应用程序可以使用一种特殊类型的对象,称为 secret,它旨在用于此目的。Kubernetes 机密在节点之间传输时使用驻留在主节点上的私钥进行加密,然而,在静止状态下,机密以明文形式存储。理想情况下,秘密应该以加密值的形式存储,并且只有明确允许这样做的过程才能解密。

Vault 是一个由 HashiCorp 活跃维护的开源项目。其目的是提供一个易于使用的系统,用于安全地存储和访问机密。除了秘密存储外,Vault 还提供访问日志审计、细粒度访问控制和轻松滚动。在这个菜谱中,我们将创建一个新的服务,称为 attachment-service,该服务负责处理消息的图像和视频附件。Attachment-service 将使用 Vault 获取用于上传照片和视频文件的 S3 存储桶的有效 AWS 凭据。该服务还将使用 Vault 获取存储附件元数据的 MySQL 数据库的凭证。非敏感配置,如数据库名称或上传照片和视频的 S3 存储桶名称,将作为环境变量提供给服务。

如何做到这一点...

为了演示如何使用 Vault 安全存储敏感配置数据,我们首先将创建一个附件服务,该服务使用环境变量存储敏感信息。然后我们将集成 Vault,以便从安全存储中读取相同的配置:

  1. 创建一个名为 attachment-service 的新 Java 项目,并使用以下 build.gradle 文件:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '1.5.9.RELEASE'
    compile group: 'mysql', name: 'mysql-connector-java'
    compile group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.11.375'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 创建一个名为 com.packtpub.microservices.ch06.attachment 的新包,并创建一个名为 Application 的新类,该类将作为我们服务的入口点。除了运行我们的 Spring Boot 应用程序外,此类还将公开一个 bean,即 Amazon S3 客户端。请注意,我们目前使用的是 EnvironmentVariableCredentialsProvider 类,它从一组环境变量中读取凭证,这不是我们希望在生产中做的事情:
package com.packtpub.microservices.ch06.attachment;

import com.amazonaws.auth.EnvironmentVariableCredentialsProvider;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class Application {

    @Bean
    public AmazonS3 getS3Client() {
        AmazonS3ClientBuilder client = AmazonS3ClientBuilder.standard();
        return client.withCredentials(
                new EnvironmentVariableCredentialsProvider()).withRegion(Regions.US_WEST_2).build();
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  1. 创建一个名为 com.packtpub.microservices.ch06.attachment.models 的新包和一个名为 Attachment 的新类。这将是我们存储在关系型数据库中的附件表示:
package com.packtpub.microservices.ch06.attachment.models;

import org.hibernate.annotations.GenericGenerator;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Attachment {

    @Id
    @GeneratedValue(generator = "uuid")
    @GenericGenerator(name = "uuid", strategy = "uuid2")
    private String id;

    @Column(unique = true)
    private String messageId;
    private String url;
    private String fileName;
    private Integer mediaType;

    public Attachment(String messageId, String url, String fileName, Integer mediaType) {
        this.messageId = messageId;
        this.url = url;
        this.fileName = fileName;
        this.mediaType = mediaType;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getMessageId() {
        return messageId;
    }

    public void setMessageId(String messageId) {
        this.messageId = messageId;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getFileName() {
        return fileName;
    }

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }

    public Integer getMediaType() {
        return mediaType;
    }

    public void setMediaType(Integer mediaType) {
        this.mediaType = mediaType;
    }
}
  1. 为了对之前定义的 Attachment 类执行基本操作,我们将创建一个名为 com.packtpub.microservices.ch06.attachment.data 的新包和一个名为 AttachmentRepository 的接口,该接口扩展了 CrudRepository。我们还将定义一个自定义方法签名,允许调用者找到与特定消息相关的所有附件:
package com.packtpub.microservices.ch06.attachment.data;

import com.packtpub.microservices.ch06.attachment.models.Attachment;
import org.springframework.data.repository.CrudRepository;

import java.util.List;

public interface AttachmentRepository extends CrudRepository<Attachment, String> {
    public List<Attachment> findByMessageId(String messageId);
}
  1. 我们还需要一种方式来建模传入的请求。我们的服务将接受作为请求体中 JSON 发送的请求。JSON 对象将包含一个文件名,并包含作为 Base64 编码字符串的文件数据。创建一个名为 AttachmentRequest 的新类,其定义如下:
package com.packtpub.microservices.ch06.attachment.models;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.util.Map;

public class AttachmentRequest {
    private String fileName;

    private String data;

    public AttachmentRequest() {}

    public AttachmentRequest(String fileName, String data) {
        this.fileName = fileName;
        this.data = data;
    }

    public String getFileName() {
        return fileName;
    }

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }

    public String getData() {
        return data;
    }

    public void setData(String data) {
        this.data = data;
    }

    @JsonProperty("file")
    private void unpackFileName(Map<String, String> file) {
        this.fileName = file.get("name");
        this.data = file.get("data");
    }
}
  1. 在我们的控制器中,我们将在下一节定义它,如果找不到特定消息的附件,我们需要返回 HTTP 404 响应给调用者。为了做到这一点,创建一个名为 com.packtpub.microservices.ch06.attachment.exceptions 的新包和一个名为 AttachmentNotFoundException 的新类:
package com.packtpub.microservices.ch06.attachment.exceptions;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "No attachment(s) found")
public class AttachmentNotFoundException extends RuntimeException {}
  1. 我们将在控制器中将所有内容组合在一起。在这个基本示例中,定义了两个方法;一个用于列出特定消息的附件,另一个用于创建新的附件。附件将上传到 Amazon S3 存储桶,其名称在配置值中指定。创建一个名为com.packtpub.microservices.ch06.attachment.controllers的新包和一个名为AttachmentController的新类:
package com.packtpub.microservices.ch06.attachment.controllers;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.packtpub.microservices.ch06.attachment.data.AttachmentRepository;
import com.packtpub.microservices.ch06.attachment.exceptions.AttachmentNotFoundException;
import com.packtpub.microservices.ch06.attachment.models.Attachment;
import com.packtpub.microservices.ch06.attachment.models.AttachmentRequest;
import org.apache.commons.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.List;

@RestController
public class AttachmentController {

    @Autowired
    private AttachmentRepository attachmentRepository;

    @Autowired
    private AmazonS3 s3Client;

    @Value("${s3.bucket-name}")
    private String bucketName;

    @RequestMapping(path = "/message/{message_id}/attachments", method = RequestMethod.GET, produces = "application/json")
    public List<Attachment> getAttachments(@PathVariable("message_id") String messageId) {
        List<Attachment> attachments = attachmentRepository.findByMessageId(messageId);
        if (attachments.isEmpty()) {
            throw new AttachmentNotFoundException();
        }
        return attachments;
    }

    @RequestMapping(path = "/message/{message_id}/attachments", method = RequestMethod.POST, produces = "application/json")
    public Attachment create(@PathVariable("message_id") String messageId, @RequestBody AttachmentRequest request) {

        byte[] byteArray = Base64.decodeBase64(request.getData());

        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(byteArray.length);
        metadata.setContentType("image/jpeg");
        metadata.setCacheControl("public, max-age=31536000");
        InputStream stream = new ByteArrayInputStream(byteArray);

        String fullyResolved = String.format("%s/%s", messageId, request.getFileName());

        s3Client.putObject(
            new PutObjectRequest(bucketName, fullyResolved, stream, metadata)
                .withCannedAcl(CannedAccessControlList.PublicRead));

        String url = String.format("https://%s.s3.amazonaws.com/%s", bucketName, fullyResolved);

        Attachment attachment = new Attachment(messageId, url, request.getFileName(), 1);
        attachmentRepository.save(attachment);
        return attachment;
    }
}
  1. 为了使这一切都能正常工作,我们必须创建一个属性文件。Java 属性文件支持从环境变量获取值的语法,如下面的代码所示。在src/main/resources目录中创建一个名为application.yml的新文件:
spring:
  jpa.hibernate.ddl-auto: create
  datasource.url: ${DATABASE_URL}
  datasource.username: ${DATABASE_USERNAME}
  datasource.password: ${DATABASE_PASSWORD}

s3:
  bucket-name: ${BUCKET_NAME}

这个示例已经足够好。AWS SDK 中的EnvironmentVariableCredentialsProvider期望AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY被设置,我们指定许多非敏感配置值应该从环境变量中读取。这显然比硬编码配置值要好,但我们仍然将秘密暴露给了在同一个容器或进程空间中运行的所有进程。环境变量也必须在某个地方设置(通过配置管理系统或指定在 Dockerfile 中),所以我们还没有解决存储敏感秘密的问题。接下来,我们将修改我们的新服务以从 Vault 读取 S3 凭证。

在这个菜谱中,我们将以开发模式运行 Vault。为生产使用安装 Vault 是一个大主题,无法在一个菜谱中适当覆盖。有关 Vault 的生产使用,请参阅www.vaultproject.io/intro/index.html提供的优秀文档。

  1. 在你的本地开发机器上安装vault。有关任何平台的说明,请参阅www.vaultproject.io。如果你运行的是 macOS X 并且使用HomeBrew,你可以使用单个命令安装 Vault:
$ brew install vault
  1. 以开发模式运行vault server,提供一个易于记住的根令牌:
$ vault server --dev --dev-root-token-id="00000000-0000-0000-0000-000000000000"
  1. 启用一个新的kv秘密引擎实例,其路径特定于此服务:
$ vault secrets enable -path=secret/attachment-service
  1. 将 AWS 访问密钥和密钥对写入vault作为秘密。用你的实际 AWS 访问密钥 ID 和 AWS 密钥访问密钥替换占位符:
$ vault write secret/attachment-service attachment.awsAccessKeyId=<access-key> attachment.awsSecretAccessKey=<access-secret>
  1. 为了使我们的服务能够从 Vault 读取这些值,我们将使用一个简化 Spring Boot 应用程序 Vault 集成的库。修改我们的项目build.gradle文件并添加以下依赖项:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '1.5.9.RELEASE'
 compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-vault-config', version: '1.1.1.RELEASE'
    compile group: 'mysql', name: 'mysql-connector-java'
    compile group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.11.375'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 我们的应用程序需要一个配置类来存储从 Vault 读取的值。创建一个名为com.packtpub.microservices.ch06.attachment.config的新包和一个名为Configuration的新类:
package com.packtpub.microservices.ch06.attachment.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("attachment")
public class Configuration {

    private String awsAccessKeyId;

    private String awsSecretAccessKey;

    public String getAwsAccessKeyId() {
        return awsAccessKeyId;
    }

    public void setAwsAccessKeyId(String awsAccessKeyId) {
        this.awsAccessKeyId = awsAccessKeyId;
    }

    public String getAwsSecretAccessKey() {
        return awsSecretAccessKey;
    }

    public void setAwsSecretAccessKey(String awsSecretAccessKey) {
        this.awsSecretAccessKey = awsSecretAccessKey;
    }
}
  1. 修改Application类以创建我们刚刚创建的类的实例。然后在创建 S3 客户端时使用该实例,这样我们就可以使用从 Vault 获取的凭证而不是环境变量:
package com.packtpub.microservices.ch06.attachment;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;

import com.packtpub.microservices.ch06.attachment.config.Configuration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
@EnableConfigurationProperties(Configuration.class)
public class Application {

 private final Configuration config;

 public Application(Configuration config) {
 this.config = config;
    }

    @Bean
    public AmazonS3 getS3Client() {
        AmazonS3ClientBuilder client = AmazonS3ClientBuilder.standard();
        AWSCredentials credentials = new BasicAWSCredentials(config.getAwsAccessKeyId(), config.getAwsSecretAccessKey());
        return client.withCredentials(
 new AWSStaticCredentialsProvider(credentials)).withRegion(Regions.US_WEST_2).build();
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

就这样!附件服务现在已配置为从 Vault 读取 AWS 凭证。

安全日志

与跟踪和指标一起,日志是可观察系统的一个基本组成部分(我们将在第七章监控和可观察性中更广泛地讨论可观察性)。日志是特定系统中事件的有序、带时间戳的序列。

在微服务架构中,拥有多个服务带来的复杂性增加,使得良好的日志记录变得至关重要。良好的日志的具体标准是主观的,但一般来说,良好的日志应该帮助工程师拼凑出可能导致特定错误状态或错误的可能事件。日志通常按级别组织,这是一个可配置的切换,允许开发者指示服务对发送到日志的信息进行更多或更少的详细说明。

虽然对于观察生产环境中系统的行为至关重要,但日志也可能带来隐私和安全风险。从系统发送到日志的大量信息可能会给潜在的攻击者提供有关您系统用户的信息,或者可以用来攻击系统其他部分的敏感信息,如令牌或密钥。微服务架构将可能的攻击面分散开来,这使得精心规划的日志记录策略变得更加重要,以确定服务应该如何记录信息。

基础设施即代码

微服务架构通常需要更频繁地提供计算资源。系统中的节点越多,攻击者可以扫描的可能漏洞的攻击面就越大。最容易让系统变得脆弱的方法之一是失去对库存的跟踪,并让多个异构配置保持活跃。在像 Puppet 或 Ansible 这样的配置管理系统流行之前,通常有一套自定义的 shell 脚本,这些脚本会在系统中引导新的服务器。这已经足够好了,但随着系统需求的增长和 shell 脚本的修改,将系统的旧部分更新到不断变化的标准变得难以管理。这种类型的配置漂移通常会使得系统的遗留部分容易受到攻击。配置管理系统通过允许团队使用代码(通常使用声明性语法)来描述系统中的节点应该如何配置,从而解决了许多这些问题。配置管理系统通常不处理实际计算资源的提供,例如计算节点、数据存储或网络存储。

基础设施即代码是通过机器可读的代码文件来管理基础设施的配置和运维过程,而不是手动操作。使用代码来描述基础设施可以有效地进行版本控制、代码审查和系统变更的回滚。能够自动化创建数据库节点或将计算节点添加到集群的过程,可以让开发者从关注应用程序中解放出来,相对有信心地确保他们不会在野外留下旧配置。与不可变基础设施相结合,基础设施即代码为系统免受易受攻击、被遗忘组件的侵害提供了额外的安全网。

在本食谱中,我们将演示如何使用由 HashiCorp 创建的开源工具Terraform来配置一组 AWS 资源,包括 EC2 实例和 Redis ElastiCache。我们将确保使用 Terraform 配置的资源在网络访问、备份和其他安全考虑方面共享配置。

如何做到这一点...

  1. 在使用 Terraform 之前,您必须安装它。安装说明可在项目网站上找到,但如果您正在运行 macOS X 并使用 HomeBrew(brew.sh/),则可以发出以下命令:
$ brew install terraform
  1. 创建一个名为example.tf的新文件。该文件将包含我们的 EC2 实例和 ElastiCache 实例的配置。我们将使用默认的亚马逊机器镜像AMI),并启用每天保留五天的快照:
 provider "aws" {
     access_key = "ACCESS_KEY"
     secret_key = "SECRET_KEY"
     region = "us-east-1"
 }

 resource "aws_instance" "example" {
   ami           = "ami-b374d5a5"
   instance_type = "t2.micro"
 }

 resource "aws_elasticache_cluster" "example" {
   cluster_id           = "cluster-example"
   engine               = "redis"
   node_type            = "cache.m3.medium"
   num_cache_nodes      = 1
   parameter_group_name = "default.redis3.2"
   port                 = 6379
   snapshot_window      = "05:00-09:00"
   snapshot_retention_limit = 5
 }

ACCESS_KEYSECRET_KEY替换为有效的 AWS 访问密钥对。

  1. 初始化 Terraform。这将安装前面文件中引用的 AWS 提供程序:
$ terraform init
  1. Terraform 通过呈现执行计划并询问您是否希望通过应用该计划来继续操作。运行以下命令并在提示时输入yes
$ terraform apply

aws_instance.example: Refreshing state... (ID: i-09b5cf5ed923d60f4)

已生成执行计划,如下面的代码所示。资源操作用以下符号表示:+ create

Terraform 将执行以下操作:

+ aws_elasticache_cluster.example
 id: <computed>
 apply_immediately: <computed>
 availability_zone: <computed>
 az_mode: <computed>
 cache_nodes.#: <computed>
 cluster_address: <computed>
 cluster_id: "cluster-example"
 configuration_endpoint: <computed>
 engine: "redis"
 engine_version: <computed>
 maintenance_window: <computed>
 node_type: "cache.m3.medium"
 num_cache_nodes: "1"
 parameter_group_name: <computed>
 port: "6379"
 replication_group_id: <computed>
 security_group_ids.#: <computed>
 security_group_names.#: <computed>
 snapshot_retention_limit: "5"
 snapshot_window: "05:00-09:00"
 subnet_group_name: <computed>

Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?

Terraform 将执行前面描述的操作。只有yes会被接受以批准:

Enter a value: yes

...
  1. 登录您的 AWS 管理控制台,您将看到已创建一个新的 Redis 集群和 EC2 实例。Terraform 还可以帮助您清理。为了销毁这两个资源,运行销毁命令并在提示时输入yes
$ terraform destroy

Terraform 是一个功能强大的工具。在本食谱中,我们用它创建了一个 EC2 实例和一个 ElastiCache 集群实例。您可以用 Terraform 做更多的事情——基础设施即代码的主题可以填满一本自己的食谱。幸运的是,HashiCorp 提供的文档(www.terraform.io/docs/index.html)非常出色,我建议您阅读它们。

使用基础设施即代码解决方案将使资源配置和管理过程变得更加安全,限制因配置过时而丢失旧有基础设施的可能性。

第七章:监控和可观察性

在本章中,我们将介绍以下配方:

  • 结构化 JSON 记录

  • 使用 StatsD 和 Graphite 收集指标

  • 使用 Prometheus 收集指标

  • 使用跟踪简化调试

  • 当出现问题时发出警报

简介

微服务增加了架构的复杂性。随着系统中移动部件的增加,监控和观察系统的行为变得更加重要和更具挑战性。在微服务架构中,影响一个服务的故障条件可能会以意想不到的方式级联,影响整个系统。数据中心某个地方的故障交换机可能正在导致某个服务的异常高延迟,这可能导致来自 API 网关的请求间歇性超时,这可能导致意外的用户影响,从而触发警报。这种场景在微服务架构中并不罕见,需要提前考虑,以便工程师可以轻松确定影响客户的故障的性质。分布式系统注定会经历某些故障,必须特别考虑将可观察性构建到系统中。

微服务还迫使我们需要转向 DevOps。许多传统的监控解决方案是在操作是系统管理员或操作工程师特殊且独立群体唯一责任的时代开发的。系统管理员和操作工程师通常对系统级或主机级指标感兴趣,例如 CPU、内存、磁盘和网络使用情况。这些指标很重要,但只是可观察性的一小部分。可观察性也必须由编写微服务的工程师考虑。使用指标来观察系统特有的事件同样重要,例如抛出某些类型的异常或发送到队列的事件数量。

规划可观察性也为我们提供了在生产环境中有效测试系统所需的信息。用于预演和集成测试的临时环境可能很有用,但它们无法测试整个类别的故障状态。如第五章可靠性模式中所述,Gamedays 和其他形式的故障注入对于提高系统的弹性至关重要。可观察的系统适合这种测试,使工程师能够对我们的系统理解充满信心。

在本章中,我们将介绍监控和可观察性的几个原则。我们将演示如何修改我们的服务以生成结构化日志。我们还将查看指标,使用多个不同的系统来收集、聚合和可视化指标。最后,我们将探讨跟踪,这是一种查看请求如何穿越系统的各个组件的方法,并在检测到影响用户的错误条件时发出警报。

结构化 JSON 记录

输出有用的日志是构建可观察服务的关键部分。有用的日志构成是主观的,但一个好的指导原则是日志应包含关于系统关键事件的带时间戳的信息。一个好的日志系统支持可配置的日志级别概念,因此可以根据工程师的需求在特定时间内调整发送到日志的信息量。例如,当在生产环境中测试服务的故障场景时,提高日志级别并获取系统事件的更多详细信息可能是有用的。

Java 应用程序中最受欢迎的两个日志库是 Log4j (logging.apache.org/log4j/2.x/) 和 Logback (logback.qos.ch/)。默认情况下,这两个库将以非结构化格式输出日志条目,通常是空格分隔的字段,包括时间戳、日志级别和消息等信息。这很有用,但在微服务架构中尤其有用,其中多个服务可能向集中日志存储输出事件日志;输出具有一致性的结构化日志非常有用。

JSON 已经成为在系统间传递消息的通用标准。几乎每种流行的语言都有解析和生成 JSON 的库。它轻量级,但结构化,使其成为事件日志等数据的好选择。以 JSON 格式输出事件日志使得将服务日志输入集中存储并分析查询日志数据变得更加容易。

在这个菜谱中,我们将修改我们的消息服务以使用流行的 logback 库来生成 Java 应用程序的日志。

如何做到这一点...

让我们看看以下步骤:

  1. 从 第六章,安全,打开消息服务项目。我们将做的第一个更改是向 build.gradle 文件中添加 logback 库:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '1.5.9.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web'
    compile group: 'io.github.resilience4j', name: 'resilience4j-circuitbreaker', version: '0.11.0'
 compile group: 'net.logstash.logback', name: 'logstash-logback-encoder', version: '4.7'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 创建一个 logback.xml 配置文件。在配置文件中,我们将创建一个名为 jsonLogger 的单个记录器,它引用一个名为 consoleAppender 的单个追加器:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
    </appender>
    <logger name="jsonLogger" additivity="false" level="DEBUG">
        <appender-ref ref="consoleAppender"/>
    </logger>
    <root level="INFO">
        <appender-ref ref="consoleAppender"/>
    </root>
</configuration>
  1. 将单个示例日志消息添加到 Application.java 以测试我们新的日志配置:
package com.packtpub.microservices.ch07.message;

import com.packtpub.microservices.ch07.message.clients.SocialGraphClient;
import org.apache.log4j.LogManager;
import org.apache.log4j.Logger;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@SpringBootApplication
@EnableAsync
public class Application {

 private Logger logger = LogManager.getLogger(Application.class);

    @Bean
    public MessageRepository messageRepository() {
        return new MessageRepository();
    }

    @Bean
    public SocialGraphClient socialGraphClient() {
        return new SocialGraphClient("http://localhost:4567");
    }

    public static void main(String[] args) {
 logger.info("Starting application");
        SpringApplication.run(Application.class, args);
    }

    @Bean
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(2);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("SocialServiceCall-");
        executor.initialize();
        return executor;
    }
}
  1. 运行应用程序并查看日志消息现在以 JSON 格式输出:
$ ./gradlew bootRun

> Task :bootRun
{"@timestamp":"2018-08-09T22:08:22.959-05:00","@version":1,"message":"Starting application","logger_name":"com.packtpub.microservices.ch07.message.Application","thread_name":"main","level":"INFO","level_value":20000}

 .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
 '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v1.5.9.RELEASE)

{"@timestamp":"2018-08-09T22:08:23.786-05:00","@version":1,"message":"Starting Application on fartlek.local with PID 82453 (/Users/posman/projects/microservices-cookbook/chapter07/message-service/build/classes/java/main started by posman in /Users/posman/projects/microservices-cookbook/chapter07/message-service)","logger_name":"com.packtpub.microservices.ch07.message.Application","thread_name":"main","level":"INFO","level_value":20000}

使用 StatsD 和 Graphite 收集指标

指标是随时间变化的数值测量。我们系统中收集的最常见的指标类型是计数器、计时器和仪表。计数器正是其名称的含义,在一定时间内增加一定次数的值。计时器可以用来测量系统中的重复事件,例如处理请求或执行数据库查询所需的时间。仪表只是可以记录的任意数值。

StatsD 是一个开源的网络守护进程,于 2011 年在 Etsy 发明。指标数据被推送到一个 statsd 服务器,通常在同一服务器上,在发送到持久后端之前聚合数据。与 statsd 一起使用最常用的后端之一是 Graphite,一个开源的时间序列存储引擎和绘图工具。Graphite 和 StatsD 一起构成了一个非常流行的指标栈。它们易于入门,拥有庞大的社区和大量的工具和库。

Spring Boot 有一个名为 Actuator 的子项目,它为服务添加了多个生产就绪功能。Actuator 为我们的服务免费提供了一些指标,与名为 micrometer 的项目一起,Actuator 实现了一个对各种指标后端的供应商中立 API。我们将在这个菜谱和下一个菜谱中使用 Actuator 和 micrometer。

在这个菜谱中,我们将向之前菜谱中使用的 message-service 添加 Actuator。我们将创建一些自定义指标,并演示如何使用 statsdgraphite 绘制应用程序的指标。我们将在本地 docker 容器中运行 statsdgraphite

如何做到这一点……

让我们看看以下步骤:

  1. 打开之前菜谱中的 message-service 项目。我们将升级 Spring Boot 的版本,并将 actuatormicrometer 添加到我们的依赖列表中。修改 build.gradle 文件,使其看起来如下所示:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '2.0.4.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '2.0.4.RELEASE'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-actuator', version: '2.0.4.RELEASE'
    compile group: 'io.micrometer', name: 'micrometer-core', version: '1.0.6'
    compile group: 'io.micrometer', name: 'micrometer-registry-statsd', version: '1.0.6'
    compile group: 'io.github.resilience4j', name: 'resilience4j-circuitbreaker', version: '0.11.0'
    compile group: 'log4j', name: 'log4j', version: '1.2.17'
    compile group: 'net.logstash.logback', name: 'logstash-logback-encoder', version: '5.2'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. src/main/resources 目录下打开 application.yml 文件,并添加以下内容:
server:
  port:
    8082

management:
  metrics:
    export:
      statsd:
        enabled: true
        flavor: "etsy"
        host:
          0.0.0.0
        port:
          8125
  1. 我们的应用程序现在支持向本地运行的 statsd 实例发出指标。打开 MessageController.java 文件,并将 Timed 注解添加到类以及 get 方法中:
package com.packtpub.microservices.ch07.message.controllers;

import com.packtpub.microservices.ch07.message.MessageRepository;
import com.packtpub.microservices.ch07.message.clients.SocialGraphClient;
import com.packtpub.microservices.ch07.message.exceptions.MessageNotFoundException;
import com.packtpub.microservices.ch07.message.exceptions.MessageSendForbiddenException;
import com.packtpub.microservices.ch07.message.models.Message;
import com.packtpub.microservices.ch07.message.models.UserFriendships;
import io.micrometer.core.annotation.Timed;
import io.micrometer.statsd.StatsdMeterRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import java.net.URI;
import java.util.List;
import java.util.concurrent.CompletableFuture;

@RestController
@Timed
public class MessageController {

    @Autowired
    private MessageRepository messagesStore;

    @Autowired
    private SocialGraphClient socialGraphClient;

    @Autowired
    private StatsdMeterRegistry registry;

    @Timed(value="get.messages")
    @RequestMapping(path = "/{id}", method = RequestMethod.GET, produces = "application/json")
    public Message get(@PathVariable("id") String id) throws MessageNotFoundException {
        registry.counter("get_messages").increment();
        return messagesStore.get(id);
    }

    @RequestMapping(path = "/", method = RequestMethod.POST, produces = "application/json")
    public ResponseEntity<Message> send(@RequestBody Message message) throws MessageSendForbiddenException {

        List<String> friendships = socialGraphClient.getFriendships(message.getSender());
        if (!friendships.contains(message.getRecipient())) {
            throw new MessageSendForbiddenException("Must be friends to send message");
        }

        Message saved = messagesStore.save(message);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest().path("/{id}")
                .buildAndExpand(saved.getId()).toUri();
        return ResponseEntity.created(location).build();
    }

    @Async
    public CompletableFuture<Boolean> isFollowing(String fromUser, String toUser) {

        String url = String.format(
                "http://localhost:4567/followings?user=%s&filter=%s",
                fromUser, toUser);

        RestTemplate template = new RestTemplate();
        UserFriendships followings = template.getForObject(url, UserFriendships.class);

        return CompletableFuture.completedFuture(
                followings.getFriendships().isEmpty()
        );
    }
}
  1. 为了证明指标实际上正在发出,我们将在本地 docker 容器中运行 statsd 和 graphite。安装了 docker 后,运行以下命令,它将从 dockerhub 拉取一个镜像并在本地运行一个容器:
 docker run -d --name graphite --restart=always \
 -p 80:80 -p 2003-2004:2003-2004 -p 2023-2024:2023-2024 \
 -p 8125:8125/udp -p 8126:8126 \
 hopsoft/graphite-statsd
  1. 现在,访问 http://localhost 来查看你的指标!

使用 Prometheus 收集指标

Prometheus 是一个开源的监控和警报工具包,最初于 2012 年在 SoundCloud 开发。它受到了谷歌的 Borgmon 的启发。与 statsd 等系统采用的推送模型不同,Prometheus 使用拉模型来收集指标。不是每个服务都负责将指标推送到 statsd 服务器,而是 Prometheus 负责抓取具有指标的服务公开的端点。这种责任反转在按比例操作指标时提供了一些好处。Prometheus 中的目标可以手动配置或通过服务发现配置。

与使用分层格式存储指标数据的系统(如 Graphite)相比,Prometheus 采用多维数据模型。Prometheus 中的时间序列数据由一个指标名称(例如 http_request_duration_seconds)和一个或多个标签(例如 service=message-servicemethod=POST)标识。这种格式可以更容易地在多个不同的应用程序之间标准化指标,这在微服务架构中尤其有价值。

在这个菜谱中,我们将继续使用 message-service 以及 Actuator 和 micrometer 库。我们将配置 micrometer 以使用 Prometheus 指标注册表,并公开 Prometheus 可以抓取以收集我们服务指标的端点。然后我们将配置 Prometheus 以抓取运行在本地的 message-service,并在本地运行 Prometheus 以验证我们是否可以查询我们的指标。

如何操作...

  1. 打开消息服务并编辑 build.gradle 文件,以包含 actuator 和 micrometer-prometheus 依赖项:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '2.0.4.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '2.0.4.RELEASE'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-actuator', version: '2.0.4.RELEASE'
    compile group: 'io.micrometer', name: 'micrometer-core', version: '1.0.6'
    compile group: 'io.micrometer', name: 'micrometer-registry-prometheus', version: '1.0.6'
    compile group: 'io.github.resilience4j', name: 'resilience4j-circuitbreaker', version: '0.11.0'
    compile group: 'log4j', name: 'log4j', version: '1.2.17'
    compile group: 'net.logstash.logback', name: 'logstash-logback-encoder', version: '5.2'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. 将以下内容添加到 application.yml 中。这将启用一个端点,该端点公开 Prometheus 指标注册表中收集的指标。请注意,我们正在为 actuator 添加的管理端点打开另一个端口:
server:
  port:
    8082

management:
  server:
    port:
      8081
  endpoint:
    metrics:
      enabled: true
    prometheus:
      enabled: true
  endpoints:
    web:
      base-path: "/manage"
      exposure:
        include: "*"
  metrics:
    export:
      prometheus:
        enabled: true
  1. 我们现在可以测试我们的服务是否在 /manage/prometheus 端点公开指标。运行服务并执行以下 curl 请求:
$ curl http://localhost:8081/manage/prometheus

# HELP tomcat_global_request_seconds
# TYPE tomcat_global_request_seconds summary
tomcat_global_request_seconds_count{name="http-nio-8082",} 0.0
tomcat_global_request_seconds_sum{name="http-nio-8082",} 0.0
# HELP tomcat_sessions_active_max
# TYPE tomcat_sessions_active_max gauge
tomcat_sessions_active_max 0.0
# HELP process_uptime_seconds The uptime of the Java virtual machine
# TYPE process_uptime_seconds gauge
process_uptime_seconds 957.132
# HELP jvm_gc_live_data_size_bytes Size of old generation memory pool after a full GC
# TYPE jvm_gc_live_data_size_bytes gauge
jvm_gc_live_data_size_bytes 1.9244032E7
  1. /tmp 目录中创建一个名为 prometheus.yml 的新文件,其中包含有关我们的目标信息,并配置和运行 Prometheus 在 Docker 容器中:
# my global config
global:
 scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
 evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
 # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
 alertmanagers:
 - static_configs:
 - targets:
 # - alertmanager:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
 # - "first_rules.yml"
 # - "second_rules.yml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
 # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
 - job_name: 'prometheus'

 # metrics_path defaults to '/metrics'
 # scheme defaults to 'http'.

 static_configs:
 - targets: ['localhost:9090']

 - job_name: 'message-service'
 metrics_path: '/manage/prometheus'
 static_configs:
 - targets: ['localhost:8081']
  1. 下载并解压适用于您平台的 Prometheus 版本。说明在 Prometheus 网站上(prometheus.io/docs/introduction/first_steps/)。使用我们在上一步创建的配置文件运行 Prometheus:
$ ./prometheus --config.file=/tmp/prometheus.yml
  1. 在您的浏览器中打开 http://localhost:9090 以执行 Prometheus 查询并查看您的指标!在您开始向服务发送请求之前,您将看到的唯一指标将是 JVM 和系统指标,但这应该能给您一个关于您可以使用 Prometheus 进行何种查询以及演示抓取器如何工作的概念。

使用跟踪简化调试

在微服务架构中,单个请求可以经过几个不同的服务,并导致写入几个不同的数据存储和事件队列。在生产事件调试时,并不总是清楚问题是否存在于一个系统或另一个系统中。这种缺乏具体性意味着指标和日志只是整个画面的一小部分。有时我们需要放大并查看从用户代理到终端服务再到用户代理的整个请求生命周期。

在 2010 年,谷歌的工程师发表了一篇论文,描述了Dapper (research.google.com/archive/papers/dapper-2010-1.pdf),这是一个大规模分布式系统跟踪基础设施。论文中描述了谷歌如何使用内部开发的跟踪系统来帮助观察系统行为和调试性能问题。这项工作启发了其他人,包括 Twitter 的工程师,他们在 2012 年引入了一个名为Zipkin (blog.twitter.com/engineering/en_us/a/2012/distributed-systems-tracing-with-zipkin.html)的开源分布式跟踪系统。Zipkin 最初是 Dapper 论文的一个实现,但后来发展成了一套完整的性能分析和检查 Twitter 基础设施请求的工具。

在跟踪领域进行的所有工作都明显表明需要某种标准化的 API。OpenTracing (opentracing.io/)框架正是为此而做的尝试。OpenTracing 定义了一个规范,详细说明了跟踪的跨语言标准。许多不同公司的工程师都为此做出了贡献,包括最初创建 Jaeger (eng.uber.com/distributed-tracing/)的 Uber 工程师,Jaeger 是一个符合 OpenTracing 规范的开放源代码、端到端分布式跟踪系统。

在这个菜谱中,我们将修改我们的 message-service 以添加跟踪支持。然后,我们将在 docker 容器中运行 Jaeger,这样我们就可以在实践中看到一些跟踪信息。

如何做到这一点...

  1. 打开 message-service 项目,并用以下内容替换build.gradle的内容:
group 'com.packtpub.microservices'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'org.springframework.boot', name: 'spring-boot-gradle-plugin', version: '2.0.4.RELEASE'
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '2.0.4.RELEASE'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-actuator', version: '2.0.4.RELEASE'
    compile group: 'io.micrometer', name: 'micrometer-core', version: '1.0.6'
    compile group: 'io.micrometer', name: 'micrometer-registry-statsd', version: '1.0.6'
    compile group: 'io.opentracing.contrib', name: 'opentracing-spring-cloud-starter-jaeger', version: '0.1.13'
    compile group: 'io.github.resilience4j', name: 'resilience4j-circuitbreaker', version: '0.11.0'
    compile group: 'log4j', name: 'log4j', version: '1.2.17'
    compile group: 'net.logstash.logback', name: 'logstash-logback-encoder', version: '5.2'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}
  1. src/main/resources目录中打开application.yml文件,并添加一个opentracing配置部分。在这里,我们正在配置我们的opentracing实现以连接到本地运行的端口6831上的 Jaeger 实例:
opentracing:
  jaeger:
    udp-sender:
      host: "localhost"
      port:
        6831

spring:
  application:
    name: "message-service"
  1. 为了收集跟踪信息,我们将在本地运行一个 Jaeger 实例。Docker 使用以下命令使这变得简单:
docker run -d --name jaeger \
 -e COLLECTOR_ZIPKIN_HTTP_PORT=9411 \
 -p 5775:5775/udp \
 -p 6831:6831/udp \
 -p 6832:6832/udp \
 -p 5778:5778 \
 -p 16686:16686 \
 -p 14268:14268 \
 -p 9411:9411 \
 jaegertracing/all-in-one:latest
  1. 运行 message-service 并发出一些示例请求(即使它们导致 404)。在浏览器中打开http://localhost:16686,你会看到 Jaeger 的 Web 界面。点击搜索并探索迄今为止收集到的跟踪数据!

当出现问题时提醒我们

如果你正在认真考虑微服务,你可能正在运行一个 24/7 的服务。客户要求你的服务在任何时候都可以使用。将这种对可用性的需求增加与分布式系统不断经历某种故障的现实进行对比。没有系统是完全健康的。

无论你是采用单体架构还是微服务架构,试图完全避免生产事故都是没有意义的。相反,你应该尝试优化你应对故障的方式,通过减少解决故障所需的时间来限制其对客户的影响。

减少解决事故所需的时间(通常以平均解决时间或 MTTR 衡量)首先需要减少平均检测时间MTTD)。在服务处于影响客户的故障状态时,能够准确地向值班工程师发出警报对于保持正常运行至关重要。好的警报应该是可操作的且紧急的;如果你的系统在故障不可操作或非紧急(不影响客户)时通知值班工程师,你可能会让值班工程师疲惫不堪,并造成通常所说的告警疲劳。告警疲劳是真实存在的,并且可能对正常运行时间产生比任何数量的软件错误或故障硬件更灾难性的影响。持续改进你的系统告警,以获得阈值和其他因素的恰到好处,防止误报,同时保持对真正影响客户的故障的告警。

告警基础设施不是你想自己构建的东西。PagerDuty是一个 SaaS 工具,允许你为特定服务的值班工程师团队创建升级策略和日程安排。使用 PagerDuty,你可以设置一个轮换日程,例如,一个五人团队的工程师可以预期每五周值班一周。升级策略允许你在值班工程师不可用的情况下(例如,他们可能在高速公路上开车)配置一系列步骤。升级策略通常配置为在事故未被确认一定时间后,向二级值班日程、经理甚至整个团队发送警报。使用像 PagerDuty 这样的系统,可以让团队中的工程师在享受必要的非值班时间的同时,知道影响客户的故障将会得到及时响应。

可以使用任何数量的支持集成手动配置警报,但这既耗时又容易出错。相反,我们希望有一个系统,允许您自动化创建和维护服务的警报。本章中涵盖的 Prometheus 监控和警报工具包包括一个名为 Alertmanager 的工具,它允许您做到这一点。在这个配方中,我们将修改我们的消息服务以使用 Alertmanager 添加警报。具体来说,我们将配置一个警报,当平均响应时间超过 500 毫秒且至少持续 5 分钟时触发。我们将从已经包含 Prometheus 指标的消息服务版本开始工作。在这个配方中,我们不会添加任何 PagerDuty 集成,因为这需要 PagerDuty 账户才能继续。PagerDuty 在其网站上有一个优秀的集成指南。我们将配置 alertmanager 以发送基于简单 WebHook 的警报。

如何操作...

现在,让我们看看以下步骤:

  1. 在之前的配方中,我们使用名为 prometheus.yml 的文件配置了 Prometheus。我们需要将 alertmanager 配置添加到该文件中,所以请再次打开它并添加以下内容:
# my global config
global:
  scrape_interval:     15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
 alertmanagers:
 - static_configs:
 - targets:
 - localhost:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
 - "rules.yml"
  # - "first_rules.yml"
  # - "second_rules.yml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
  - job_name: 'prometheus'

    # metrics_path defaults to '/metrics'
    # scheme defaults to 'http'.

    static_configs:
    - targets: ['localhost:9090']

  - job_name: 'message-service'
    metrics_path: '/manage/prometheus'
    static_configs:
    - targets: ['localhost:8081']
  1. 创建一个名为 /tmp/rules.yml 的新文件。该文件定义了我们希望 Prometheus 能够创建警报的规则:
groups:
- name: message-service-latency
  rules:
  - alert: HighLatency
    expr: rate(http_server_requests_seconds_sum{job="message-service", instance="localhost:8081"}[1m]) / rate(http_server_requests_seconds_count{job="message-service", instance="localhost:8081"}[1m]) > .5
    for: 1m
    labels:
      severity: 'critical'
    annotations:
      summary: High request latency
  1. 创建一个名为 /tmp/alertmanager.yml 的新文件。这是描述我们的警报配置的文件。它被分成几个不同的部分,这些部分是某些配置选项的全局集合,这些选项会影响 alertmanager 的工作方式。名为 receivers 的部分是我们配置警报通知系统的位置。在这种情况下,它是一个运行在本地的服务的 WebHook。这只是为了演示目的;我们将编写一个小的 Ruby 脚本,该脚本监听 HTTP 请求并将有效负载打印到标准输出:
global:
  resolve_timeout: 5m

route:
  group_by: ['alertname']
  group_wait: 10s
  group_interval: 10s
  repeat_interval: 1h
  receiver: 'web.hook'

receivers:
- name: 'web.hook'
  webhook_configs:
  - url: 'http://127.0.0.1:4567/'
  1. 这是将打印出我们的警报的小型 Ruby 服务的源代码:
require 'sinatra'

post '/' do
    body = request.body.read()
    puts body
    return body
end
  1. 运行 Ruby 脚本,重启 prometheusalertmanager。这三个系统运行后,我们将准备好测试我们的警报:
$ ruby echo.rb
...

$ ./prometheus --config.file=/tmp/prometheus.yml

$ ./alertmanager --config.file=/tmp/alertmanager.yml
...
  1. 为了使我们的警报触发,打开消息服务并添加以下行到 MessageController.java。这是一行代码,它将迫使控制器在返回响应之前暂停 600 毫秒。请注意,这超出了我们在规则配置中描述的阈值:
@RequestMapping(path = "/{id}", method = RequestMethod.GET, produces = "application/json")
public Message get(@PathVariable("id") String id) throws MessageNotFoundException {

 try { Thread.sleep(600); } catch (InterruptedException e) } e.printStackTrace(); } 
    return messagesStore.get(id);
}
  1. 在设置完成后,运行您更新的消息服务并向其发送多个请求。一分钟过后,Prometheus 应该会通知 Alertmanager,然后 Alertmanager 应该会通知您本地的调试 Ruby 服务。您的警报正在工作!

第八章:扩展

在本章中,我们将涵盖以下菜谱:

  • 使用 Vegeta 进行微服务负载测试

  • 使用 Gatling 进行微服务负载测试

  • 构建自动扩展集群

简介

使用微服务而不是单体架构的一个显著优势是,微服务可以单独扩展以满足它们所服务的独特流量需求。必须为每个请求执行工作的服务将具有与只需要为特定类型的请求执行工作的服务非常不同的扩展需求。

由于微服务封装了对单个领域实体的所有权,它们可以独立进行负载测试。它们还可以根据需求自动配置扩展。在本章中,我们将讨论使用两种不同的负载测试工具进行负载测试,并在 AWS 中设置自动扩展组,以便按需扩展。最后,我们将讨论容量规划策略。

使用 Vegeta 进行微服务负载测试

负载测试是预测您的服务随时间如何表现的重要部分。当我们进行负载测试时,我们不应该只问简单的问题,例如“我们的系统每秒能处理多少请求?”相反,我们应该试图了解我们的整个系统在各种负载条件下的表现。为了回答这个问题,我们需要了解构成我们系统的基础设施以及特定服务所依赖的依赖关系。

例如,该服务是否位于负载均衡器后面?CDN 呢?还使用了哪些其他缓存机制?所有这些问题以及更多都可以通过我们系统良好的可观察性得到解答。

Vegeta是一个开源的负载测试工具,旨在以恒定的请求速率测试 HTTP 服务。它是一个多功能的工具,可以用作命令行工具或库。在这个菜谱中,我们将专注于使用命令行工具。Vegeta 允许你将目标指定为单独文件中的 URL,可选地带有自定义头和请求体,这些可以作为命令行工具的输入。然后,命令行工具可以攻击文件中的目标,有各种选项来控制请求速率和持续时间,以及其他变量。

在这个菜谱中,我们将使用 Vegeta 测试我们在前几章中一直在使用的消息服务。我们将测试一个简单的请求路径,包括创建新消息和检索消息列表。

如何实现...

让我们看看以下步骤:

  1. 我们将修改我们的消息服务并添加一个新的端点,允许我们查询特定用户的全部消息。这引入了收件箱的概念,因此我们将修改我们的MessageRepository类以添加一个新的内存映射,将用户名映射到消息列表,如下面的代码所示。请注意,在生产系统中,我们会选择一个更持久和灵活的存储方案,但这对演示目的已经足够了:
package com.packtpub.microservices.ch08.message;

import com.packtpub.microservices.ch08.message.exceptions.MessageNotFoundException;
import com.packtpub.microservices.ch08.message.models.Message;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public class MessageRepository {

    private ConcurrentHashMap<String, Message> messages;
    private ConcurrentHashMap<String, List<Message>> inbox;

    public MessageRepository() {
        messages = new ConcurrentHashMap<>();
        inbox = new ConcurrentHashMap<>();
    }

    public Message save(Message message) {
        UUID uuid = UUID.randomUUID();
        Message saved = new Message(uuid.toString(), message.getSender(), message.getRecipient(),
                message.getBody(), message.getAttachmentUri());
        messages.put(uuid.toString(), saved);
        List<Message> userInbox = inbox.getOrDefault(message.getRecipient(), new ArrayList<>());
        userInbox.add(saved);
        inbox.put(message.getRecipient(), userInbox);
        return saved;
    }

    public Message get(String id) throws MessageNotFoundException {
        if (messages.containsKey(id)) {
            return messages.get(id);
        } else {
            throw new MessageNotFoundException("Message " + id + " could not be found");
        }
    }

    public List<Message> getByUser(String userId) {
        return inbox.getOrDefault(userId, new ArrayList<>());
    }
}
  1. 修改MessageController以添加端点本身:
package com.packtpub.microservices.ch08.message.controllers;

import com.packtpub.microservices.ch08.message.MessageRepository;
import com.packtpub.microservices.ch08.message.clients.SocialGraphClient;
import com.packtpub.microservices.ch08.message.exceptions.MessageNotFoundException;
import com.packtpub.microservices.ch08.message.exceptions.MessageSendForbiddenException;
import com.packtpub.microservices.ch08.message.exceptions.MessagesNotFoundException;
import com.packtpub.microservices.ch08.message.models.Message;
import com.packtpub.microservices.ch08.message.models.UserFriendships;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import java.net.URI;
import java.util.List;
import java.util.concurrent.CompletableFuture;

@RestController
public class MessageController {

    @Autowired
    private MessageRepository messagesStore;

    @Autowired
    private SocialGraphClient socialGraphClient;

    @RequestMapping(path = "/{id}", method = RequestMethod.GET, produces = "application/json")
    public Message get(@PathVariable("id") String id) throws MessageNotFoundException {
        return messagesStore.get(id);
    }

    @RequestMapping(path = "/", method = RequestMethod.POST, produces = "application/json")
    public ResponseEntity<Message> send(@RequestBody Message message) throws MessageSendForbiddenException {
        List<String> friendships = socialGraphClient.getFriendships(message.getSender());

        if (!friendships.contains(message.getRecipient())) {
            throw new MessageSendForbiddenException("Must be friends to send message");
        }

        Message saved = messagesStore.save(message);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest().path("/{id}")
                .buildAndExpand(saved.getId()).toUri();
        return ResponseEntity.created(location).build();
    }

    @RequestMapping(path = "/user/{userId}", method = RequestMethod.GET, produces = "application/json")
    public ResponseEntity<List<Message>> getByUser(@PathVariable("userId") String userId) throws MessageNotFoundException {
        List<Message> inbox = messagesStore.getByUser(userId);
        if (inbox.isEmpty()) {
            throw new MessageNotFoundException("No messages found for user: " + userId);
        }
        return ResponseEntity.ok(inbox);
    }

    @Async
    public CompletableFuture<Boolean> isFollowing(String fromUser, String toUser) {
        String url = String.format(
                "http://localhost:4567/followings?user=%s&filter=%s",
                fromUser, toUser);

        RestTemplate template = new RestTemplate();
        UserFriendships followings = template.getForObject(url, UserFriendships.class);

        return CompletableFuture.completedFuture(
                followings.getFriendships().isEmpty()
        );
    }
}

  1. 我们需要一个模拟的社交图服务,因此在一个名为socialgraph.rb的文件中创建以下 Ruby 脚本并运行它:
require 'sinatra'

get '/friendships/:user' do
    content_type :json
    {
        username: "user:32134",
        friendships: [
            "user:12345"
        ]
    }.to_json
end
  1. 安装vegeta。如果你在 Mac OS X 上并且已经安装了 HomeBrew,你可以直接使用以下命令:
$ brew update && brew install vegeta
  1. 在我们能够使用vegeta启动附件之前,我们需要创建一个targets文件。我们将发出的第一个请求将创建一个带有指定请求体的消息。第二个请求将通过用户 ID 获取消息列表。创建一个名为message-request-body.json的文件,如下面的代码所示:
{
    "sender": "user:32134",
    "recipient": "user:12345",
    "body": "Hello there!",
    "attachment_uri": "http://foo.com/image.png"
}
  1. 创建另一个名为targets.txt的文件,如下面的代码所示:
POST http://localhost:8082/
Content-Type: application/json
@message-request-body.json

GET http://localhost:8082/user:12345
  1. 我们的消息服务和模拟社交图服务都已运行,我们现在可以使用以下代码来对这些两个服务进行负载测试:
$ cat targets.txt| vegeta attack -duration=60s -rate=100 | vegeta report -reporter=text

Requests      [total, rate]            6000, 100.01
Duration      [total, attack, wait]    1m0.004668981s, 59.99172349s, 12.945491ms
Latencies     [mean, 50, 95, 99, max]  10.683968ms, 5.598656ms, 35.108562ms, 98.290388ms, 425.186942ms
Bytes In      [total, mean]            667057195, 111176.20
Bytes Out     [total, mean]            420000, 70.00
Success       [ratio]                  99.80%
Status Codes  [code:count]             201:3000  500:12  200:2988
Error Set:
50

尝试不同的持续时间值和请求速率,看看系统行为如何变化。如果你将速率增加到 1,000,会发生什么?根据硬件和其他因素,单线程的 Ruby 模拟服务可能会超负荷运行并触发我们添加到消息服务的熔断器。这可能会改变某些细节,例如成功率,因此这是一个重要的观察点。如果你单独对模拟 Ruby 服务进行负载测试,会发生什么?

在这个菜谱中,我们负载测试了依赖于社交图服务的消息服务。这两个服务都在本地运行,这对于演示目的来说是必要的,并让我们对这两个系统的行为有了一些了解。在生产系统中,在生产环境中对服务进行负载测试至关重要,以确保你包括了所有参与服务请求的基础设施(负载均衡器、缓存等)。在生产系统中,你还可以监控仪表板,寻找系统在负载条件下的行为变化。

使用 Gatling 对微服务进行负载测试

Gatling 是一个开源的负载测试工具,它允许用户使用基于Scala 的 DSL脚本来编写自定义场景。场景可以超越简单的直线路径测试,包括多个步骤,甚至可以模拟用户行为,如暂停并根据测试输出做出如何继续的决定。Gatling 可以用来自动化微服务或基于浏览器的 Web 应用的负载测试。

在上一个菜谱中,我们使用 Vegeta 向我们的消息服务发送恒定的请求速率。我们的请求路径创建了一条新消息,然后检索了用户的全部消息。这种方法的优势在于能够测试随着消息列表的增长,检索用户所有消息的响应时间。Vegeta 擅长此类测试,但由于它从静态文件中获取攻击目标,因此你不能使用 Vegeta 根据先前请求的响应构建动态请求路径。

由于 Gatling 使用 DSL 来编写负载测试场景,因此可以发出请求,捕获响应的一些元素,并使用该输出来做出关于未来请求的决定。在本食谱中,我们将使用 Gatling 编写一个涉及创建消息然后通过其 ID 检索该特定消息的负载测试场景。这与之前食谱中做的测试非常不同,因此这是一个很好的机会来展示 Vegeta 和 Gatling 之间的差异。

如何做到这一点...

让我们检查以下步骤:

  1. 为你的平台下载gatling。Gatling 以 ZIP 包的形式分发,可在gatling.io/download/下载。将包解压到你的选择目录中:
$ unzip gatling-charts-highcharts-bundle-2.3.1-bundle.zip
...
$ cd gatling-charts-highcharts-bundle-2.3.1
  1. gatling的模拟默认放置在user-files/simulations目录中。创建一个名为messageservice的新子目录和一个名为BasicSimulation.scala的新文件。这是包含描述你的场景的代码的文件。在我们的场景中,我们将使用 Gatling DSL 来编写一个 POST 请求到创建消息端点,然后是一个 GET 请求到消息端点,如下面的代码所示:
package messageservice

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class BasicSimulation extends Simulation {

  val httpConf = http
    .baseURL("http://localhost:8082")
    .acceptHeader("application/json")

  val scn = scenario("Create a message")
    .exec(
      http("createMessage")
        .post("/")
        .header("Content-Type", "application/json")
        .body(StringBody("""{"sender": "user:32134", "recipient": "user:12345", "body": "Hello there!", "attachment_uri": "http://foo.com/image.png"}"""))
        .check(header(HttpHeaderNames.Location).saveAs("location"))

    )
    .pause(1)
    .exec(
      http("getMessage")
        .get("${location}")
    )

  setUp(scn.inject(atOnceUsers(50)).protocols(httpConf))
}

  1. 创建与之前食谱中使用的相同的模拟 Ruby 服务并运行它:
require 'sinatra'

get '/friendships/:user' do
    content_type :json
    {
        username: "user:32134",
        friendships: [
            "user:12345"
        ]
    }.to_json
end
  1. 同时运行 Ruby 模拟服务和我们的消息服务。从 Gatling 目录中,通过运行bin/gatling.sh启动 Gatling。你将被提示选择要运行的模拟。选择messageservice.BasicSimulation
$ bin/gatling.sh
GATLING_HOME is set to /Users/posman/projects/microservices-cookbook/chapter08/gatling-charts-highcharts-bundle-2.3.1
Choose a simulation number:
 [0] computerdatabase.BasicSimulation
 [1] computerdatabase.advanced.AdvancedSimulationStep01
 [2] computerdatabase.advanced.AdvancedSimulationStep02
 [3] computerdatabase.advanced.AdvancedSimulationStep03
 [4] computerdatabase.advanced.AdvancedSimulationStep04
 [5] computerdatabase.advanced.AdvancedSimulationStep05
 [6] messageservice.BasicSimulation
6
Select simulation id (default is 'basicsimulation'). Accepted characters are a-z, A-Z, 0-9, - and _

Select run description (optional)

Simulation messageservice.BasicSimulation started...
..
  1. 输出将显示关于负载测试结果的某些统计数据。请求将被分类到小于 800 毫秒、800 毫秒到 1,200 毫秒之间以及超过 1,200 毫秒的几个类别。将显示一个 HTML 文件的链接。在浏览器中打开它,以查看关于你的负载测试的图表和其他有用的可视化。

正如我们在本食谱中看到的,Gatling 在运行负载测试方面提供了很多灵活性。通过使用 DSL 的一些巧妙脚本,可以更接近地模拟生产流量,通过解析日志文件和生成请求,根据延迟、响应或其他请求元素做出动态决策。Gatling 和 Vegeta 都是优秀的负载测试工具,你可以使用它们来探索你的系统在各种负载条件下的运行情况。

构建自动扩展集群

随着虚拟化和向基于云的基础设施的迁移,应用程序可以存在于弹性基础设施上,该基础设施根据预期的或测量的流量模式进行扩展和收缩。如果你的应用程序经历高峰期,你不需要在非高峰期配置全部容量,从而浪费计算资源和金钱。从虚拟化到容器和容器调度器,动态基础设施越来越普遍,这种基础设施会根据系统的需求进行变化。

微服务非常适合自动扩展。因为我们可以分别扩展系统的不同部分,所以更容易衡量特定服务及其依赖项的扩展需求。

创建自动扩展集群有许多方法。在下一章中,我们将讨论容器编排工具,但不会跳过,自动扩展集群也可以在任何云服务提供商中创建。在本菜谱中,我们将介绍使用 Amazon Web Services 创建自动扩展计算集群,特别是 Amazon EC2 自动扩展。我们将创建一个集群,其中包含多个运行在我们的消息服务背后的 EC2 实例,并位于 应用程序负载均衡器 (ALB) 后面。我们将配置我们的集群,以便根据 CPU 利用率自动添加实例。

如何做到这一点...

让我们检查以下步骤:

  1. 此菜谱需要 AWS 账户。如果您还没有 AWS 账户,请访问 aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/ 创建一个账户,并在 docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html 创建一组访问密钥。安装 aws cli 工具。如果您在 OS X 上并且已安装 HomeBrew,可以使用以下命令完成此操作:
$ brew install aws
  1. 配置 aws 命令行工具,输入您创建的访问密钥:
$ aws configure
  1. 创建启动配置。启动配置是自动扩展组在创建新实例时使用的模板。在这种情况下,我们选择了 Amazon AMI 和 t2.nano 作为我们的 EC2 实例类型(有关更多详细信息,请参阅 aws.amazon.com/ec2/instance-types/),如下面的代码所示:
$ aws autoscaling create-launch-configuration --launch-configuration-name message-service-launch-configuration --image-id ari-f606f39f --instance-type t2.nano
  1. 创建实际的自动扩展组。自动扩展组具有可配置的最大和最小大小,这指定了自动扩展组可以根据需求缩小或扩大的程度。在这种情况下,我们将创建一个具有最小 1 个实例和最大 5 个实例的自动扩展组,如下面的代码所示:
$ aws autoscaling create-auto-scaling-group --auto-scaling-group-name message-service-asg --launch-configuration-name message-service-launch-configuration --max-size 5 --min-size 1 --availability-zones "us-east-1a"
  1. 我们希望我们的自动扩展组中的实例可以通过负载均衡器访问,因此我们现在将创建它:
$ aws elb create-load-balancer --load-balancer-name message-service-lb --listeners "Protocol=HTTP,LoadBalancerPort=80,InstanceProtocol=HTTP,InstancePort=8082" --availability-zones us-east-1a

{
 "DNSName": "message-service-lb-1741394248.us-east-1.elb.amazonaws.com"
}
  1. 为了自动扩展我们的自动扩展组,我们需要定义一个指标。集群可以根据内存、CPU 利用率或请求速率进行扩展。在这种情况下,我们将配置我们的扩展策略以使用 CPU 利用率。如果 CPU 利用率达到 20% 的平均值,我们的自动扩展组将创建更多实例。创建一个名为 config.json 的文件:
{
  "TargetValue": 20.0,
  "PredefinedMetricSpecification":
    {
      "PredefinedMetricType": "ASGAverageCPUUtilization"
    }
}
  1. 将扩展策略附加到我们的自动扩展组。
$ aws autoscaling put-scaling-policy --policy-name cpu20 --auto-scaling-group-name message-service-asg --policy-type TargetTrackingScaling --target-tracking-configuration file://config.json

我们的自扩展组现在配置为当 CPU 利用率超过 20%的平均值时进行扩展。启动配置还可以包括安装和配置您的服务的引导步骤——通常使用某种配置管理工具,例如ChefPuppet——或者它可以配置为从私有 Docker 仓库拉取 Docker 镜像。

第九章:微服务部署

在本章中,我们将介绍以下菜谱:

  • 配置您的服务以在容器中运行

  • 使用 Docker Compose 运行多容器应用程序

  • 在 Kubernetes 上部署您的服务

  • 使用金丝雀部署进行测试发布

简介

多年来,我们向用户交付软件的方式发生了巨大的变化。在不太遥远的过去,通过在服务器集合上运行 shell 脚本来部署到生产环境是很常见的,这些服务器从某种源代码存储库中拉取更新。这种方法的问题很明显——扩展这个方法很困难,启动服务器容易出错,部署可能会陷入不希望的状态,从而导致用户体验不可预测。

配置管理系统的出现,如ChefPuppet,在某种程度上改善了这种情况。与在远程服务器上运行自定义 bash 脚本或命令不同,远程服务器可以被标记为一种角色,指示它们如何配置和安装软件。声明式自动化配置的风格更适合大规模软件部署。还采用了服务器自动化工具,如FabricCapistrano;它们旨在自动化将代码推送到生产的过程,并且至今仍非常受欢迎,用于不在容器中运行的应用程序。

容器已经彻底改变了我们交付软件的方式。容器允许开发者将他们的代码及其所有依赖项打包,包括库、运行时、操作系统工具和配置。这使得代码的交付无需配置主机服务器,通过减少移动部件的数量,大大简化了过程。

在容器中运输服务的过程被称为不可变基础设施,因为一旦构建了镜像,通常不会对其进行更改;相反,服务的新版本会导致构建新的镜像。

软件部署方式中的另一个重大变化是十二要素方法的普及(12factor.net/)。十二要素(或12f,如通常书写)是由 Heroku 的工程师编写的一系列指南。在核心上,十二要素应用程序旨在与环境松散耦合,从而产生可以与各种日志工具、配置系统、软件包管理系统和源代码控制系统一起使用的服务。可以说,十二要素应用程序最普遍采用的概念是配置通过环境变量访问,日志输出到标准输出。正如我们在前面的章节中看到的,这是我们与 Vault 等系统集成的这种方式。这些章节值得一读,但我们在本书中已经遵循了许多十二要素中描述的概念。

在本章中,我们将讨论容器、编排和调度,以及将更改安全地发送给用户的各种方法。这是一个非常活跃的话题,新的技术正在被发明和讨论,但本章中的菜谱应该是一个很好的起点,特别是如果你习惯于在虚拟机或裸机服务器上部署单体应用。

配置你的服务在容器中运行

正如我们所知,服务由源代码和配置组成。例如,用 Java 编写的服务可以被打包成一个包含编译后的 Java 字节码的 Java 存档JAR)文件,以及配置和属性文件等资源。一旦打包,JAR 文件就可以在任何运行 Java 虚拟机JVM)的机器上执行。

然而,为了使这一切工作,我们想要运行服务的机器必须安装了 JVM。通常,它必须是 JVM 的特定版本。此外,机器可能需要安装一些其他实用程序,或者可能需要访问共享文件系统。虽然这些不是服务本身的组成部分,但它们构成了我们所说的服务的运行时环境。

Linux 容器是一种技术,允许开发者将应用程序或服务及其完整的运行时环境打包在一起。容器将特定应用程序的运行时与容器运行的宿主机的运行时分离出来。

这使得应用程序更加可移植,使得将服务从一个环境迁移到另一个环境变得更加容易。工程师可以在他们的笔记本电脑上运行一个服务,然后将其移动到预生产环境,然后进入生产环境,而无需更改容器本身。容器还允许你轻松地在同一台机器上运行多个服务,因此提供了更多灵活性,以部署应用程序架构,并提供了优化运营成本的机会。

Docker 是一个容器运行时和一系列工具,允许你为你的服务创建自包含的执行环境。今天还有其他流行的容器运行时被广泛使用,但 Docker 被设计成使容器可移植和灵活,使其成为构建服务容器的理想选择。

在这个菜谱中,我们将使用 Docker 创建一个打包我们的消息服务的镜像。我们将通过创建一个 Dockerfile 文件,并使用 Docker 命令行工具来创建镜像,然后运行该镜像作为容器来实现这一点。

如何操作…

这个菜谱的步骤如下:

  1. 首先,从上一章打开我们的消息服务项目。在项目的根目录下创建一个名为 Dockerfile 的新文件:
FROM openjdk:8-jdk-alpine
VOLUME /tmp
EXPOSE 8082
ARG JAR_FILE=build/libs/message-service-1.0-SNAPSHOT.jar
ADD ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
  1. Dockerfile 文件定义了我们将用于构建 message-service 镜像的基础镜像。在这种情况下,我们基于一个带有 OpenJDK 8 的 Alpine Linux 镜像。接下来,我们暴露了我们的服务绑定的端口,并定义了如何将我们的服务作为 JAR 文件打包后运行。我们现在可以使用 Dockerfile 文件来构建镜像。这可以通过以下命令完成:
$ docker build . -t message-service:0.1.1
  1. 你可以通过运行 docker images 并查看我们的镜像是否列出来验证前面的命令是否成功。现在我们准备好通过在容器中执行我们的服务来运行消息服务。这是通过 docker run 命令完成的。我们还将给它一个端口映射并指定我们想要用于运行服务的镜像:
$ docker run -p 0.0.0.0:8082:8082 message-service:0.1.1

使用 Docker Compose 运行多容器应用

服务很少独立运行。一个微服务通常连接到某种类型的数据存储,并且可能有其他运行时依赖。为了在微服务上工作,有必要在开发者的机器上本地运行它。要求工程师手动安装和管理服务的所有运行时依赖以在微服务上工作将是不切实际且耗时的。相反,我们需要一种自动管理运行时服务依赖的方法。

容器通过将运行时环境和配置与应用程序代码一起打包为可运输的工件,使得服务更加便携。为了最大限度地发挥使用容器进行本地开发的益处,能够声明所有依赖并在单独的容器中运行它们将是非常好的。这正是 Docker Compose 设计来做的。

Docker Compose 使用声明性的 YAML 配置文件来确定应用程序应该如何在多个容器中执行。这使得快速启动服务、数据库以及服务的任何其他运行时依赖变得非常容易。

在这个食谱中,我们将遵循前一个食谱中的某些步骤来为 authentication-service 项目创建一个 Dockerfile 文件。然后,我们将创建一个 Docker Compose 文件,指定 MySQL 作为 authentication-service 的依赖项。然后,我们将查看如何配置我们的项目并在本地运行它,一个容器运行我们的应用程序,另一个运行数据库服务器。

如何操作...

对于这个食谱,你需要执行以下步骤:

  1. 打开 authentication-service 项目并创建一个名为 Dockerfile 的新文件:
FROM openjdk:8-jdk-alpine
VOLUME /tmp
EXPOSE 8082
ARG JAR_FILE=build/libs/authentication-service-1.0-SNAPSHOT.jar
ADD ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
  1. Docker Compose 使用一个名为 docker-compose.yml 的文件来声明容器化应用应该如何运行:
version: '3'
services:
 authentication:
 build: .
 ports:
 - "8081:8081"
 links:
 - docker-mysql
 environment:
 DATABASE_HOST: 'docker-mysql'
 DATABASE_USER: 'root'
 DATABASE_PASSWORD: 'root'
 DATABASE_NAME: 'user_credentials'
 DATABASE_PORT: 3306
 docker-mysql:
 ports:
 - "3306:3306"
 image: mysql
 restart: always
 environment:
 MYSQL_ROOT_PASSWORD: 'root'
 MYSQL_DATABASE: 'user_credentials'
 MYSQL_ROOT_HOST: '%'
  1. 由于我们将连接到在 docker-mysql 容器中运行的 MySQL 服务器,我们需要修改 authentication-service 的配置,以便在连接到 MySQL 时使用该主机:
server:
 port: 8081

spring:
 jpa.hibernate.ddl-auto: create
 datasource.url: jdbc:mysql://docker-mysql:3306/user_credentials
 datasource.username: root
 datasource.password: root

hibernate.dialect: org.hibernate.dialect.MySQLInnoDBDialect

secretKey: supers3cr3t
  1. 你现在可以使用以下命令运行 authentication-service 和 MySQL:
$ docker-compose up
Starting authentication-service_docker-mysql_1 ...
  1. 就这样!authentication-service 应该现在已经在本地容器中运行了。

在 Kubernetes 上部署你的服务

容器通过允许你将代码、依赖项和运行时环境打包在一个工件中,使得服务可移植。部署容器通常比部署不运行在容器中的应用程序更容易。主机不需要任何特殊的配置或状态;它只需要能够执行容器运行时。在单个主机上部署一个或多个容器的能力,在管理生产环境时产生了另一个挑战——调度和编排容器在特定主机上运行并管理扩展。

Kubernetes 是一个开源的容器编排工具。它负责调度、管理和扩展你的容器化应用程序。使用 Kubernetes,你无需担心将容器部署到一台或多台特定的主机上。相反,你只需声明你的容器需要哪些资源,然后让 Kubernetes 决定如何执行工作(容器运行在哪个主机上,它旁边运行哪些服务等等)。Kubernetes 是从 Google 工程师发布的 Borg 论文 (research.google.com/pubs/pub43438.html) 中发展而来的,这篇论文描述了他们如何使用 Borg 集群管理器在 Google 的数据中心管理服务。

Kubernetes 是 Google 在 2014 年启动的一个开源项目,并且被部署容器代码的组织广泛采用。

安装和管理 Kubernetes 集群超出了本书的范围。幸运的是,一个名为 Minikube 的项目允许你在开发机器上轻松运行单个节点的 Kubernetes 集群。尽管集群只有一个节点,但当你部署服务时与 Kubernetes 交互的方式通常是一样的,所以这里的步骤可以适用于任何 Kubernetes 集群。

在这个菜谱中,我们将安装 Minikube,启动单个节点的 Kubernetes 集群,并部署我们在前几章中使用的 message-service 命令。我们将使用 Kubernetes CLI 工具 (kubectl) 与 Minikube 进行交互。

如何操作...

对于这个菜谱,你需要完成以下步骤:

  1. 为了演示如何将我们的服务部署到 kubernetes 集群,我们将使用一个名为 minikube 的工具。minikube 工具使得在可以运行在笔记本电脑或开发机器上的虚拟机(VM)上运行单个节点的 kubernetes 集群变得容易。在 macOS X 上,你可以使用 HomeBrew 来安装它:
$ brew install minikube
  1. 在这个菜谱中,我们还将使用 kubernetes CLI 工具,所以请安装它们。在 macOS X 上,使用 HomeBrew,你可以输入以下内容:
$ brew install kubernetes-cli
  1. 现在我们已经准备好启动我们的单个节点 kubernetes 集群。你可以通过运行 minikube start 来完成这个操作:
$ minikube start
Starting local Kubernetes v1.10.0 cluster...
Starting VM...
Getting VM IP address...
Moving files into cluster...
Setting up certs...
Connecting to cluster...
Setting up kubeconfig...
Starting cluster components...
Kubectl is now configured to use the cluster.
Loading cached images from config file
  1. 接下来,将 minikube 集群设置为 kubectl CLI 工具的默认配置:
$ kubectl config use-context minikube
Switched to context "minikube".
  1. 通过运行 cluster-info 命令来验证一切配置是否正确:
$ kubectl cluster-info
Kubernetes master is running at https://192.168.99.100:8443
KubeDNS is running at https://192.168.99.100:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

为了进一步调试和诊断集群问题,使用 kubectl cluster-info dump

  1. 现在,你应该能够在浏览器中启动kubernetes仪表板:
$ minikube dashboard
Waiting, endpoint for service is not ready yet...
Opening kubernetes dashboard in default browser...
  1. minikube工具使用多个环境变量来配置 CLI 客户端。使用以下命令评估环境变量:
$ eval $(minikube docker-env)
  1. 接下来,我们将使用之前配方中创建的Dockerfile文件为我们的服务构建 docker 镜像:
$ docker build -t message-service:0.1.1
  1. 最后,在kubernetes集群上运行message-service命令,告诉kubectl正确的镜像和要暴露的端口:
$ kubectl run message-service --image=message-service:0.1.1 --port=8082 --image-pull-policy=Never
  1. 我们可以通过列出集群上的 pods 来验证message-service命令是否在kubernetes集群中运行:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
message-service-87d85dd58-svzmj 1/1 Running 0 3s
  1. 为了访问message-service命令,我们需要将其作为新的服务暴露出来:
$ kubectl expose deployment message-service --type=LoadBalancer
service/message-service exposed
  1. 我们可以通过列出kubernetes服务上的服务来验证上一条命令:
$ kubectl get services

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 59d
message-service LoadBalancer 10.105.73.177 <pending> 8082:30382/TCP 4s
  1. minikube工具有一个方便的命令来访问在kubernetes集群上运行的服务。运行以下命令将列出message-service命令正在运行的 URL:
$ minikube service list message-service
|-------------|----------------------|-----------------------------|
| NAMESPACE | NAME | URL |
|-------------|----------------------|-----------------------------|
| default | kubernetes | No node port |
| default | message-service | http://192.168.99.100:30382 |
| kube-system | kube-dns | No node port |
| kube-system | kubernetes-dashboard | http://192.168.99.100:30000 |
|-------------|----------------------|-----------------------------|
  1. 使用curl尝试向服务发送请求以验证其是否正常工作。恭喜!你已经在kubernetes上部署了message-service命令。

使用金丝雀部署进行测试发布

部署最佳实践的改进极大地提高了多年来部署的稳定性。自动化可重复步骤、标准化我们的应用程序与运行时环境的交互方式,以及将我们的应用程序代码与运行时环境打包在一起,都使得部署比以前更安全、更容易。

将新代码引入生产环境并非没有风险。本章中讨论的所有技术都有助于防止可预测的错误,但它们并不能阻止实际软件错误对我们所编写的软件用户产生负面影响。金丝雀部署是一种减少这种风险并增加对部署到生产环境的新代码的信心的一种技术。

在金丝雀部署中,你首先将代码发送到一小部分生产流量。然后你可以监控指标、日志、跟踪或其他允许你观察软件工作情况的工具。一旦你确信一切按预期进行,你可以逐渐增加接收更新版本流量的百分比,直到所有生产流量都由你服务的最新版本提供服务。

“金丝雀部署”这个术语来自煤矿工人用来保护自己免受一氧化碳或甲烷中毒的技术。通过在矿井中放置金丝雀,有毒气体会在矿工之前杀死金丝雀,给矿工一个提前的警告信号,告诉他们应该离开。同样,金丝雀部署允许我们向一小部分用户暴露风险,而不会影响生产环境的其余部分。幸运的是,在将代码部署到生产环境时,不需要伤害任何动物。

金丝雀部署过去一直很难正确实施。以这种方式运输软件的团队通常必须想出某种功能切换解决方案,以控制对正在部署的应用程序特定版本的请求。幸运的是,容器使这变得容易得多,而 Kubernetes 则使其变得更加容易。

在这个菜谱中,我们将使用金丝雀部署来部署我们的message-service应用程序的更新。由于 Kubernetes 能够从 Docker 容器仓库拉取镜像,我们将在本地运行一个仓库。通常,你会使用自托管的仓库或像Docker HubGoogle Container Registry这样的服务。首先,我们将确保在minikube中运行一个稳定的message-service命令版本,然后我们将引入一个更新,并逐渐将其推出到 100%流量。

如何做到这一点...

按照以下步骤设置金丝雀部署:

  1. 打开我们在之前菜谱中工作的message-service项目。将以下Dockerfile文件添加到项目的根目录:
FROM openjdk:8-jdk-alpine
VOLUME /tmp
EXPOSE 8082
ARG JAR_FILE=build/libs/message-service-1.0-SNAPSHOT.jar
ADD ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
  1. 为了让 Kubernetes 知道服务是否正在运行,我们需要添加一个存活性探针端点。打开MessageController.java文件,并添加一个方法来响应/ping路径的 GET 请求:
package com.packtpub.microservices.ch09.message.controllers;

import com.packtpub.microservices.ch09.message.MessageRepository;
import com.packtpub.microservices.ch09.message.clients.SocialGraphClient;
import com.packtpub.microservices.ch09.message.exceptions.MessageNotFoundException;
import com.packtpub.microservices.ch09.message.exceptions.MessageSendForbiddenException;
import com.packtpub.microservices.ch09.message.models.Message;
import com.packtpub.microservices.ch09.message.models.UserFriendships;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import java.net.URI;
import java.util.List;
import java.util.concurrent.CompletableFuture;

@RestController
public class MessageController {

    @Autowired
    private MessageRepository messagesStore;

    @Autowired
    private SocialGraphClient socialGraphClient;

    @RequestMapping(path = "/{id}", method = RequestMethod.GET, produces = "application/json")
    public Message get(@PathVariable("id") String id) throws MessageNotFoundException {
        return messagesStore.get(id);
    }

    @RequestMapping(path = "/ping", method = RequestMethod.GET)
    public String readinessProbe() {
        return "ok";
    }

    @RequestMapping(path = "/", method = RequestMethod.POST, produces = "application/json")
    public ResponseEntity<Message> send(@RequestBody Message message) throws MessageSendForbiddenException {
        List<String> friendships = socialGraphClient.getFriendships(message.getSender());

        if (!friendships.contains(message.getRecipient())) {
            throw new MessageSendForbiddenException("Must be friends to send message");
        }

        Message saved = messagesStore.save(message);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest().path("/{id}")
                .buildAndExpand(saved.getId()).toUri();
        return ResponseEntity.created(location).build();
    }

    @RequestMapping(path = "/user/{userId}", method = RequestMethod.GET, produces = "application/json")
    public ResponseEntity<List<Message>> getByUser(@PathVariable("userId") String userId) throws MessageNotFoundException  {
        List<Message> inbox = messagesStore.getByUser(userId);
        if (inbox.isEmpty()) {
            throw new MessageNotFoundException("No messages found for user: " + userId);
        }
        return ResponseEntity.ok(inbox);
    }

    @Async
    public CompletableFuture<Boolean> isFollowing(String fromUser, String toUser) {
        String url = String.format(
                "http://localhost:4567/followings?user=%s&filter=%s",
                fromUser, toUser);

        RestTemplate template = new RestTemplate();
        UserFriendships followings = template.getForObject(url, UserFriendships.class);

        return CompletableFuture.completedFuture(
                followings.getFriendships().isEmpty()
        );
    }
}
  1. 让我们在 5000 端口上启动我们的容器仓库:
$ docker run -d -p 5000:5000 --restart=always --name registry registry:2
  1. 由于我们正在使用一个未配置有效 SSL 证书的本地仓库,请以能够从非安全仓库拉取的能力启动minikube
$ minikube start --insecure-registry 127.0.0.1
  1. 构建名为message-service的 docker 镜像,然后使用以下命令将镜像推送到本地容器仓库:
$ docker build . -t message-service:0.1.1
...
$ docker tag message-service:0.1.1 localhost:5000/message-service
...
$ docker push localhost:5000/message-service
  1. Kubernetes Deployment对象描述了 Pod 和ReplicaSet的期望状态。在我们的部署中,我们将指定我们希望始终运行三个message-service Pod 的副本,并且我们将指定我们之前创建的存活性探针。为了为我们的message-service创建一个部署,创建一个名为deployment.yaml的文件,并包含以下内容:
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: message-service
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app: "message-service"
        track: "stable"
    spec:
      containers:
        - name: "message-service"
          image: "localhost:5000/message-service"
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8082
          livenessProbe:
            httpGet:
              path: /ping
              port: 8082
              scheme: HTTP
            initialDelaySeconds: 10
            periodSeconds: 30
            timeoutSeconds: 1
  1. 接下来,使用kubectl,我们将创建我们的部署对象:
$ kubectl create -f deployment.yaml
  1. 你现在可以通过运行kubectl get pods来验证我们的部署是活跃的,并且 Kubernetes 正在创建 Pod 副本:
$ kubectl get pods
  1. 现在我们的应用程序正在 Kubernetes 中运行,下一步是创建一个更新并将其推出到 Pod 子集。首先,我们需要创建一个新的 docker 镜像;在这种情况下,我们将称之为版本 0.1.2,并将其推送到本地仓库:
$ docker build . -t message-service:0.1.2
...
$ docker tag message-service:0.1.2 localhost:5000/message-service
$ docker push localhost:5000/message-service
  1. 现在我们可以配置一个部署,在将其推出到其他 Pod 之前先运行我们的最新版本镜像。
posted @ 2025-10-27 09:04  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报