Docker-Kubernetes-和-Terraform-微服务启动指南-全-
Docker、Kubernetes 和 Terraform 微服务启动指南(全)
原文:Bootstrapping Microservices with Docker, Kubernetes, and Terraform
译者:飞龙
前置内容
前言
我第一次尝试用微服务构建应用程序是在 2013 年左右。那一年 Docker 最初发布,但当时我还没有听说过它。当时,我们为每个微服务运行一个单独的虚拟机来构建一个应用程序。正如你所预期的,这是一种运行微服务的非常昂贵的方式。
由于运行成本高昂,我们当时选择了创建较少的微服务,而不是更多的微服务,将越来越多的功能推入现有的微服务中,以至于我们真的无法再称之为微服务了。当然,它仍然是一个分布式应用程序,只是没有我们期望的那么“微型”。
到那时,我已经知道微服务是一个强大的想法,如果它们更便宜的话。我把微服务放回架子上,但记下我应该稍后再看看它们。
在接下来的几年里,我作为旁观者看着围绕微服务的工具和技术的发展,得益于开源编码的兴起(以及其持续的增长)。我还看着云计算的成本持续下降,这是由供应商之间的竞争推动的。随着时间的推移,很明显,使用微型组件构建和运行分布式应用程序正变得更加经济高效。
经过似乎是一生的时间,到 2018 年初,我正式回到了微服务的世界。我有两个机会,我认为微服务是合适的。两个都是初创公司。第一个是为一家有前途的年轻公司启动一个新微服务应用程序的合同工作。第二个是为我的初创公司构建一个微服务应用程序。
为了成功,我知道我需要新的工具。我需要一个有效的方式来打包微服务。我需要一个可以部署微服务的计算平台。关键的是,我需要能够自动化部署。
到那时,Docker 已经在我们的行业中占据了很大的份额,所以我知道它作为打包微服务的方式是一个安全的赌注。我也喜欢 Kubernetes 作为微服务的计算平台的外观,但一开始我对它非常不确定。然而,Kubernetes 承诺了一个摆脱云供应商锁定的未来——这非常吸引人。
到这个时候,我已经读过很多关于微服务的书籍。这些书籍都很有趣,在理论上提供了很好的价值。我确实喜欢阅读理论,但这些书籍缺乏实际案例,这些案例本可以帮助我克服自己的学习曲线。即使作为一个经验丰富的开发者,我也在努力寻找起点!我知道从过去的经验中,项目开始时做出的糟糕技术决策会一直困扰我到项目结束。
学习 Kubernetes 特别困难。从外面看,它似乎难以渗透。但我有工作要做,我需要一种交付软件的方式。所以我继续努力。进展艰难,我几次差点放弃 Kubernetes。
当我发现 Terraform 时,情况发生了变化。这对我来说是拼图缺失的一块。它使得 Kubernetes 变得可理解和可用,到了我除了承诺使用它之外别无选择的地步。
Terraform 是让我能够描述我的应用程序结构的工具。然后 Terraform 可以存在于我的持续交付(CD)管道中,并自动保持我的应用程序更新!我开始编写基础设施即代码,感觉就像我进入了高级别。
我凭借长期评估技术和快速学习工作的经验,加上一些试验和错误的尝试,强行克服了学习曲线。我的努力交付了性能良好、灵活、可靠、可扩展的软件,并且至今仍在运行。在这段时间里,我写这本书的愿望产生了,并逐渐增长到不得不采取行动的地步。
一个新的使命形成了——我想让微服务更容易获得。我感到有必要写这本书;这是我想要但没有的书。我知道我可以帮助人们,而最好的方式就是通过一本实用的书,这本书。一本书,它会一步一步地展示微服务并不难或复杂;这完全取决于你的方法和你的视角。你现在手中就有这个劳动成果。我经历了艰难,这样你就不必了。
致谢
在《自举微服务》中,我与你们分享了我多年辛苦积累的经验。没有周围人的支持和鼓励,这样的经验是不可能的。
有许多人帮助我走到了今天。如果没有我的父母 Garry 和 Jan 买给我的第一台电脑,我就不会成为开发者。我的生活伴侣 Antonella,她不懈地支持我完成了两本书。我的商业伙伴 Majella,她倾听我关于技术的所有抱怨,并且仍然推动我前进。谢谢大家!
当然,感谢 Manning 提供的机会,尤其是感谢 Helen Stergius,她再次编辑了我的书。希望这次作为更有经验的作者,我能让你们的工作变得更轻松。还要感谢 Manning 团队全体成员的努力。
感谢技术校对员 Alain Couniot 以及所有在将这本书提升到下一个水平方面发挥了巨大作用的审稿人:Angelo Simone Scotto、Anupam Sengupta、Barnaby Norman、Björn Neuhaus、Bonnie Malec、Chris Kolosiwsky、Chris Viner、Dan Sheikh、Dhruvesh Patel、Donald McLamb、Eric Platon、Ernesto Bossi Carranza、Giampiero Granatella、John Guthrie、Julien Pohie、Marcin Sęk、Michele Adduci、Miguel Montalvo、Rich Ward、Rinor Maloku、Ruben Vandeginste 和 Weyert de Boer。
当然,技术编辑 Jeanne Boyarsky 值得特别的感谢。她做了出色的“吹毛求疵”(正如她所说),而且由于她的参与,这本书变得更好。
最后,我想感谢开发社区。你们的反馈和鼓励使这本书的写作变得愉快。我写这本书是为了你们!
关于本书
使用微服务构建应用程序——构建分布式应用程序——可能是一个复杂的过程,学习起来可能很困难。如果你陷入了一个现代复杂的应用程序,可能很难从森林中看到树木。除了简单地编码之外,还有更多需要考虑的因素。而且,这并不是一个容易独自承担的旅程。
要使用微服务,你必须了解如何构建分布式应用程序。但仅凭这一点是不够的。你还必须学习开发、测试和部署此类应用程序所需的深入而复杂的工具。我们如何组装一个健壮的开发工具包?我们从哪里开始?
在过程中还有更多问题。我们如何打包和部署微服务?我们如何配置我们的开发环境以进行本地测试?我们如何让我们的微服务相互通信,以及我们如何管理数据?最重要的是,我们如何将微服务部署到生产环境?然后,一旦进入生产环境,我们如何管理、监控和修复可能涉及数百个微服务的问题?
本书,自举微服务,回答了这些问题以及更多!它是你使用最新工具构建应用程序的指南。我们将从无到有,一直到一个在生产环境中运行的工作微服务应用程序。
在这本书中,你不会找到很多理论。自举微服务是实用且基于项目的。我们将一起研究许多微服务的示例,最终达到生产阶段,涵盖你作为一个自信的微服务开发者所需了解的所有内容。
本书中的每个示例都附带可在 GitHub 上找到的工作代码。你可以亲自尝试并做出自己的实验性更改。
应该阅读本书的人
本书面向任何想要了解更多关于微服务实际工作方面的人;那些需要明确指南来组装他们的工具集并将应用程序完整地推向生产的人。本书不教授编码,因此建议具备基本的编码技能。
注意:如果你对现代编程语言(如 C#、Java、Python 或 JavaScript)有一些基本或入门级经验,你应该能够跟随这本书的内容。
代码示例尽可能简单,但本书不仅仅关于代码。它更多地是关于教你如何组装构建微服务应用程序所需的工具包。
如果你没有编程经验,但学习速度快,你可以在阅读Bootstrapping Microservices时学习基本的 JavaScript(通过另一本书、教程、视频等)。就像我说的,代码示例尽可能简单,所以你有机会在没有太多编程经验的情况下阅读代码并理解其精髓。我们的编码冒险从第二章开始,在那里你将学习如何使用 JavaScript 和 Node.js 构建一个简单的微服务。
本书是如何组织的:一个路线图
在本书的 11 章中,我们从构建单个微服务一直扩展到在准备就绪的 Kubernetes 集群中运行多个微服务。
-
第一章是微服务介绍,并解释了为什么我们要使用这些服务。
-
第二章通过使用 Node.js 和 JavaScript 构建一个简单的微服务来展开。我们学习了如何使用实时重新加载以实现更流畅的开发过程。
-
第三章介绍了 Docker,用于打包和发布我们的微服务,使其准备好部署。
-
第四章扩展到多个微服务,并介绍了 Docker Compose,用于在开发工作站上模拟我们的微服务应用程序。然后我们涵盖了微服务的数据管理,包括拥有数据库和外部文件存储。
-
第五章升级了我们的开发环境,以实现整个应用程序的实时重新加载。然后我们涵盖了微服务之间的通信,包括用于直接消息的 HTTP 和用于间接消息的 RabbitMQ。
-
第六章介绍了 Terraform 和 Kubernetes。我们使用 Terraform 在 Microsoft Azure 上创建一个私有容器注册和 Kubernetes 集群。
-
第七章使用 Terraform 将微服务部署到我们的 Kubernetes 集群。我们部署了一个数据库、一个 RabbitMQ 服务器,最后是一个微服务。我们还探讨了如何创建一个持续交付(CD)管道,以自动化将我们的应用程序部署到生产环境。
-
第八章展示了我们可以将多个级别的自动化测试应用到微服务中。
-
第九章概述了示例应用程序,并回顾了在部署示例应用程序时学到的技能。
-
第十章探讨了我们可以构建可靠和容错性微服务的方法,然后监控这些服务以维护应用程序的健康状态。
-
第十一章通过展示将你的微服务应用程序扩展以支持不断增长的业务、组织以管理不断增长的开发团队的实际方法来结束。它还涉及安全性、重构单体以及如何在预算内使用微服务进行构建。
关于代码
本书包含许多源代码示例,既有编号列表,也有与普通文本并行的代码。在两种情况下,源代码都使用固定宽度字体格式化,以将其与普通文本区分开来。
在许多情况下,原始源代码已经被重新格式化;我们添加了换行并重新调整了缩进,以适应书中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续接标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常已被从列表中删除。许多列表旁边都有代码注释,突出显示重要概念。
本书示例的代码可以从 Manning 网站www.manning.com/books/bootstrapping-microservices-with-docker-kubernetes-and-terraform和 GitHubgithub.com/bootstrapping-microservices.下载。
您可以下载每个章节(第二章到第九章)的 zip 文件,或者您可以使用 Git 克隆每个章节的 Git 代码仓库。每个示例都设计得尽可能简单,自包含且易于运行。随着您在书中的进展,您将以不同的方式运行代码。
我们首先在 Node.js(第二章)下直接运行单个微服务代码,然后在 Docker(第三章)下运行。接着,我们在 Docker Compose(第四章和第五章)下运行多个微服务。
接下来,我们在 Terraform 下运行代码,首先是本地(第六章),然后是在我们的持续交付管道中(第七章)。在第六章和第七章中,我们还将我们的微服务运行在云端的 Kubernetes 集群上。在第八章中,我们回到 Node.js,在 Jest 和 Cypress 下运行自动化测试。最后,在第九章中,我们回顾迄今为止学到的技能(从第二章到第八章)。第九章的示例代码是一个简单但完整的微服务应用程序,您可以在开发和生产环境中运行它。
在代码示例中,我力求遵循标准约定和最佳实践。我要求您通过 GitHub 提供反馈并报告任何问题。
liveBook 讨论论坛
购买《使用 Docker、Kubernetes 和 Terraform 启动微服务》(Bootstrapping Microservices with Docker, Kubernetes, and Terraform)包括免费访问由 Manning Publications 运行的私有网络论坛,您可以在论坛上对本书发表评论、提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问livebook.manning.com/#!/book/bootstrapping-microservices-with-docker-kubernetes-and-terraform/discussion。您还可以在livebook.manning.com/#!/discussion了解更多关于 Manning 的论坛和行为准则。
Manning 对读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要这本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。
关于作者
Ashley Davis 是一位拥有超过 20 年软件开发经验的软件工匠、企业家和作家,从编码到管理团队,再到创立公司。他为各种公司工作过,从小型初创公司到最大的国际公司。在这个过程中,他通过写作和开源编码回馈了社区。
Ashley 是 Sortal 的首席技术官,Sortal 是一个利用机器学习的魔法自动整理数字资产的产品。他是 Data-Forge Notebook 的创造者,这是一个使用 JavaScript 和 TypeScript 进行探索性编码和数据可视化的笔记本式桌面应用程序。Ashley 还是一位热衷于算法交易的交易员,积极进行交易并开发量化交易软件。
想了解他的书籍更新、开源编码等内容,请关注 Ashley 的推特 @ashleydavis75,在 Facebook 上的“数据整理师”关注他,或访问他的博客 www.the-data-wrangler.com。
想了解更多关于 Ashley 的背景信息,请访问他的个人网页 (www.codecapers.com.au) 或他的领英资料页 (www.linkedin.com/in/ashleydavis75)。
关于封面插图
《使用 Docker、Kubernetes 和 Terraform 自举微服务》封面上的插图被标注为“加泰罗尼亚”或西班牙东北部的加泰罗尼亚人。这幅插图取自雅克·格拉塞·德·圣索沃尔(1757-1810)的作品集,名为《Costumes civils actuels de tous les peoples connus》,1788 年在法国出版。每一幅插图都是手工精心绘制和着色的。格拉塞·德·圣索沃尔的丰富藏品生动地提醒我们,200 年前世界的城镇和地区在文化上有多么不同。他们彼此孤立,说着不同的方言和语言。在街道或乡村,仅凭他们的服装就能轻易识别他们居住的地方以及他们的职业或社会地位。
自那以后,我们的着装方式已经改变,而当时区域间的多样性,如此丰富,现在已经逐渐消失。现在很难区分不同大陆的居民,更不用说不同的城镇、地区或国家了。也许我们用文化多样性换取了更加丰富多彩的个人生活——当然,是更加多样化和快节奏的技术生活。
在难以区分一本计算机书与另一本的时候,曼宁通过基于两百年前丰富多样的区域生活所设计的书封面,庆祝了计算机行业的创新精神和主动性,这些画面由格拉塞·德·圣索沃尔重新赋予生命。
1 为什么是微服务?
本章节涵盖
-
本书的学习方法
-
微服务的什么和为什么
-
使用微服务的利弊
-
单体架构有什么问题?
-
微服务设计的基础知识
-
我们构建的应用程序的快速概述
随着软件的日益庞大和复杂,我们需要更好的方法来管理和减轻其复杂性。随着它随着我们的业务增长,我们需要更好的方法来分割它,以便多个团队能够参与到构建工作中。
随着我们苛刻的客户群的增长,我们也必须能够扩展我们的软件。同时,我们的应用程序应该具有容错能力,能够快速扩展以满足峰值需求。那么我们如何在演进和开发我们的应用程序的同时满足现代业务的需求?
微服务是一种在当代软件开发中扮演关键角色的架构模式。由微服务组成的分布式应用程序解决了这些问题以及更多,但通常比传统的单体应用程序更难设计、更复杂、更耗时。如果这些术语是新的——微服务、分布式应用程序和单体应用程序——它们将很快得到解释。
传统智慧认为微服务太难。我们被告知从“单体优先”开始,并在需要扩展时将其重构为微服务。但我认为这种态度并不能使构建应用程序的工作变得更容易!你的应用程序总是倾向于复杂性,最终你将需要对其进行扩展。当你决定需要改变时,你现在面临着一个极其困难的任务,那就是在员工和客户已经依赖它的情况下,安全地将你的单体架构转换为微服务。
现在也是构建微服务的完美时机。各种因素的汇聚——可访问且廉价的云基础设施、不断改进的工具以及自动化机会的增加——正在推动整个行业向更小、更小的服务发展,即微服务。随着时间的推移,应用程序变得更加复杂,但微服务为我们提供了更好的方法来管理这种复杂性。没有比现在更好的时间去“以微服务为先”。
在这本书中,我将向你展示,以微服务为先的方法不再像以前那样令人畏惧。我相信平衡已经坚定地倾向于微服务。剩下的问题是学习微服务是困难的。学习曲线陡峭,阻碍了许多开发者构建微服务的努力。我们将一起打破学习曲线。我们将对单体架构说“呸”,并从头开始构建一个简单但完整的视频流应用,使用微服务。
1.1 本书是实用的
你为什么在读这本书?你之所以在读这本书,是因为你想要或者需要构建一个微服务应用,这对于现代开发者来说是一个重要的技能集,但获得这个技能集却很困难,你需要一些指导。你可能已经读过其他关于微服务的书籍,并且还在 wonder 我从哪里开始? 我理解你的困扰。
微服务的学习难度很大。你不仅要学习深入且复杂的工具,还必须学会构建分布式应用。这需要新的设计模式、协议和通信方法。在任何人看来,这都是一大堆要学习的内容。
在这本书中,我们突破了构建微服务应用看似无法逾越的学习曲线。当你独自面对时,你必须忍受的学习曲线可能看起来难以克服,但,而不是这样,我们将共同经历这个开发冒险。我们将尽可能简单开始,逐步构建,最终将应用部署到生产环境中。
这本书是关于突破学习曲线,启动一个可以无限期使用的、可不断更新和构建的应用,以满足我们客户和用户持续变化的需求。图 1.1 展示了突破学习曲线的概念。虽然我们的示例应用很小且简单,但从一开始,我们将构建可扩展性路径,以便将来将其扩展为一个真正的巨大分布式应用。

图 1.1 突破学习曲线。在这本书中,我们将只学习最基本的知识,仅够启动我们的应用。
这本书与其他所有关于微服务的书籍有什么不同?其他书籍明显是理论的。这对于一个经验丰富的开发者或架构师来说是一个很好的方法,他们想要扩展他们的知识,但通过这种方式获得实际技能是具有挑战性的,并且不能帮助你导航启动新应用的雷区。你在项目启动时做出的技术选择可能会长期困扰你。
这本书 确实是 不同的;这本书 不是 理论的。我们将采取实际的学习方法。书中穿插了一些理论,但我们实际上会构建一个实质性的微服务应用。我们将从零开始,逐步将应用带入存在并投入生产。我们将在我们的开发工作站(或个人电脑)上构建和测试应用,最终,我们将将其部署到云端。
我们将一起启动我们的微服务应用,而不必学习任何工具或技术的最深层细节。本书的学习模型示例如图 1.2 所示。

图 1.2 本书的学习模型。我们将对这些深奥而复杂的技术进行简要了解,只为启动我们的应用程序提供必要的部分。
这本书是关于从零开始构建微服务应用程序的。有些人已经问我为什么我没有写这本书来展示如何将单体转换为微服务应用程序?这是许多人想要学习的内容。
我以这种方式写这本书,因为从头开始学习如何编写应用程序比学习如何重构现有应用程序要容易得多。我也相信这些技能是有用的,因为随着时间的推移,越来越多的应用程序将以微服务优先的方式编写。
在任何情况下,重构现有应用程序都比构建全新的应用程序复杂得多。这是一个包含许多复杂变量的过程,并且高度依赖于遗留代码库的特定情况。我假设,一旦你了解了(实际上,一旦你亲身体验了)如何创建绿色田野(新)微服务应用程序,你将更容易找到自己的单体转换策略。
我可以向你保证,当你能够以微服务优先的方式构建应用程序时,你将更有能力清楚地看到从现有单体到微服务的路线。从单体到微服务的这一旅程无疑仍然会很有挑战性,所以请保持关注。在第十一章中,我们将更深入地讨论这个话题。
在整本书中,你将学习到具体实用的技术,以便将微服务应用程序启动起来。当然,有许许多多的方法和许多不同的工具可以用来实现这一点。我在教你一个单一的配方和一套工具(尽管是一个流行的工具集)。毫无疑问,你会发现许多改进这个配方和适应你自身情况的方法。当然,其他经验丰富的开发者已经拥有他们自己的配方来做这件事。我试图说的是,这是我的方法,这只是许多可行方法中的一种;然而,我可以证明我已经在生产中尝试了这本书中的每一个技术,并发现它们效果良好。所以,无需多言,让我们开始我们的学习和探索之旅。
1.2 我将学到什么?
在整本书中,我们将从简单到复杂逐步进行。我们将从最简单的任务——创建单个微服务开始。在 11 章中,我们将逐步构建一个更复杂的应用程序和基础设施,但我们将以增量步骤进行,这样你就不会迷失方向。阅读这本书并实践所教授的技能后,你可以期待能够
-
创建单个微服务
-
使用 Docker 打包和发布微服务
-
在你的开发工作站上使用 Docker Compose 开发微服务应用程序
-
使用 Jest 和 Cypress 测试你的代码、微服务和应用程序
-
将第三方服务器集成到你的应用程序中(例如 MongoDB 和 RabbitMQ 等)
-
使用 HTTP 和 RabbitMQ 消息在微服务之间进行通信
-
存储您的微服务运行所需的数据和文件
-
使用 Terraform 和 Kubernetes 创建生产基础设施
-
使用 Terraform 将微服务部署到生产环境中
-
创建一个持续交付管道,以便在更新代码时自动部署您的应用程序
1.3 我需要了解什么?
您可能会想知道,进入本书之前您需要了解什么。我努力编写这本书,尽可能少地假设您已经知道的内容。我们将从绝对的基础开始,一直深入到一些相当复杂的概念。我认为这里对每个人来说都有所帮助,无论您作为开发者的经验如何。
如果您对计算机编程有一些入门级理解,那么进入本书会更好。我认为您不需要太多,只要您能阅读代码并理解其功能即可。但请放心;我会尽可能多地解释代码中发生的重要事情。
如果您有编程背景,您将没有问题跟随本书中的示例。如果您在阅读本书的同时学习编程,您可能会发现这相当具有挑战性,但并非不可能,您可能需要做一些额外的工作。
本书使用 Node.js 作为微服务的示例,但一开始,您不需要了解 JavaScript 或 Node.js。您将在学习过程中获得足够的知识来跟随。本书还使用 Microsoft Azure 作为生产部署的示例。同样,一开始,您也不需要了解 Azure。
请放心,这本书不是关于 Node.js 或 Azure 的;它是关于使用 Docker、Kubernetes 和 Terraform 等现代工具构建微服务应用程序的。您从本书中学到的大多数技能都可以转移到其他语言和其他云服务提供商。因为我必须选择一个编程语言和云服务提供商来演示本书中的技术,所以我选择了 Node.js 和 Azure。这就是我目前在生产中主要使用的内容。
如果 Node.js 和 Azure 不是您所擅长,通过您的一些额外研究和实验,您将能够找出如何用您喜欢的编程语言替换 Node.js 和 JavaScript,并用您首选的云服务提供商替换 Azure。实际上,我最初选择使用 Docker、Kubernetes 和 Terraform 的主要原因正是这些工具提供了自由——选择编程语言的自由和摆脱云服务提供商锁定自由的自由。
1.4 管理复杂性
与任何应用一样,微服务应用随着时间的推移会变得更加复杂。但它们不需要从这种状态开始!这本书采取的方法是我们可以从一个简单的起点开始,并且每个开发迭代也可以同样简单。此外,每个微服务都是小巧简单的。当你阅读这本书时,你会发现使用微服务构建应用程序并不像你想象的那么困难(尽管有些人这么说)。
微服务为我们提供了一种在细粒度上管理复杂性的方法,这是我们几乎每天都在工作的层面——单个微服务的层面。在那个层面,微服务并不复杂。事实上,为了获得微服务这个名称,它们必须小巧简单。单个微服务旨在由单个开发者或小型团队管理!
虽然如此,但通过持续的开发和演变,一个复杂的系统将会出现。不可否认,微服务应用将会变得复杂。但这并不是立即发生的;它需要时间。在这个过程中,我们将使用微服务来管理你应用的增长复杂性,以便它不会成为负担。
微服务应用是一种复杂自适应系统的形式,其复杂性自然地从其组成部分的交互中产生。尽管整个系统可能过于复杂,以至于任何凡人都无法理解,但每个组成部分仍然保持小巧、可管理和易于理解。不过,不用担心;这本书中我们构建的示例应用并不复杂。
以这种微服务态度(在工具和自动化的帮助下)进行开发,使我们能够构建极其庞大且可扩展的应用程序,而不会被复杂性所压倒。而且,在阅读这本书之后,你将能够聚焦于最复杂的微服务应用的任何部分,并发现其组件简单易懂。
1.5 什么是微服务?
在我们能够理解微服务应用之前,我们首先必须理解什么是微服务。
定义 A 微服务 是一个微小且独立的软件进程,它在自己的部署计划上运行,并且可以独立更新。
让我们分解这个定义。微服务是一个小型、独立的软件进程,它有自己的独立部署频率。也就是说,必须能够独立于其他微服务更新每个微服务。
微服务可以由单个开发者或一组开发者拥有和运营。开发者或团队也可能管理多个其他微服务。每个开发者/团队对其拥有的微服务负责。在现代编程世界中,这通常包括开发、测试、部署和运维。然而,我们可能会发现,当我们为一家小公司或初创公司(像我一样)工作,或者当我们在学习(正如我们在本书中所做的那样)时,我们必须自己管理多个微服务,甚至可能是一个完整的微服务应用程序。
一个单独的微服务可能对外界开放,以便我们的客户可以与之交互,或者它可能纯粹是一个内部服务,不可外部访问。它通常可以访问数据库、文件存储或其他一些状态持久化方法。图 1.3 展示了这些内部和外部关系。

图 1.3 一个单独的微服务可以连接到外部世界或其他服务,它也可以拥有数据库和/或附加的文件存储。
单独来看,一个微服务本身并不做什么。然而,一个设计良好的系统可以被分解成这样的简单服务。服务必须相互协作,以提供更大应用程序的功能和特性。这把我们带到了微服务应用程序的主题。
1.6 什么是微服务应用程序?
传统上,微服务应用程序被称为分布式应用程序,它是由存在于单独进程中的微小组件组成的系统,并通过网络进行通信。每个服务或组件都位于一个逻辑上独立的(虚拟)计算机上,有时甚至位于一个物理上独立的计算机上。
定义 一个微服务应用程序是由许多小型服务组成的分布式程序,这些服务协作以实现整个项目的特性和功能。
通常,一个微服务应用程序有一个或多个外部暴露的服务,以便用户可以与系统交互。图 1.4 显示了两个这样的服务作为基于 Web 和手机用户的网关。您还可以在图 1.4 中看到,许多服务在集群内部协同工作。它被称为集群,因为它是一组计算机,对我们(开发者)来说,它是一个单一的、统一的计算能力块,我们可以按照我们的意愿对其进行指导。附近我们还有一个数据库服务器。在图 1.4 中,它显示在集群之外,但它也可以轻松地托管在集群内部。我们将在第四章中更多地讨论这一点。

图 1.4 一个微服务应用程序由多个小型独立服务组成,这些服务在一个集群中运行。
集群托管在集群编排平台上,我们使用 Kubernetes 来完成这项任务。编排是我们服务的自动化管理。这正是 Kubernetes 为我们所做的一一它帮助我们部署和管理我们的服务。
集群本身、我们的数据库以及其他虚拟基础设施都托管在我们选择的云服务提供商上。我们将学习如何在 Microsoft Azure 上部署此基础设施,但通过你自己的努力,你可以更改本书中的示例以部署到 Amazon Web Services (AWS)或 Google Cloud Platform (GCP)。
微服务应用程序可以采取多种形式,非常灵活,可以安排以适应许多情况。任何特定的应用程序可能都有一个熟悉的整体结构,但其所包含的服务将执行不同的任务,这取决于我们客户的需要和我们的业务领域。
1.7 单体的弊端是什么?
单体是什么,它有什么问题,以至于我们宁愿使用微服务?尽管分布式计算已经存在了几十年,但应用程序通常以单体形式构建。这是在云革命和微服务之前,大多数软件开发的方式。图 1.5 显示了简单视频流应用程序中的服务可能的样子,并比较了应用程序的单体版本和微服务版本之间的差异。
定义:单体是一个运行在单个进程中的完整应用程序。

图 1.5 单体与微服务对比。你可以看到,使用微服务构建提供了许多相对于传统单体应用程序的优势。
与微服务应用程序相比,构建单体架构要容易得多。你需要的技术和架构技能更少。当构建一个新应用程序时,例如早期产品,你希望在投入微服务应用程序所需的高技术投资之前测试商业模式的有效性,这是一个很好的起点。
原型设计时,单体架构是一个非常好的选择。它也可能是一个范围较小或快速稳定且不需要在其生命周期内演变或增长的应用程序的最佳选择。如果你的应用程序始终如此小巧,那么将其设计为单体架构是有意义的。
决定是先单体后微服务,还是先微服务后单体,这是一个平衡行为,传统上是由单体获胜的。然而,在这本书中,我将向你展示,鉴于现代工具的改进以及廉价便捷的云基础设施,至少考虑先构建微服务架构是很重要的。
大多数产品通常需要成长和进化,随着你的单体架构变得更大,具有更多有用功能,就更加难以证明丢弃临时原型是合理的。所以,在未来的某个时候,你可能会发现自己被困在单体架构中,而此时你真正需要的是微服务应用的灵活性、安全性和可扩展性。
单体架构带来了一系列潜在问题。这些问题最初很小,我们总是怀有最好的意图,保持代码干净和井井有条。一支优秀的开发团队能够让单体架构保持优雅和井井有条多年。但随着时间的推移,愿景可能会丢失,有时一开始就没有强烈的愿景。所有代码都在同一个进程中运行,因此没有障碍,也没有什么可以阻止我们编写一团糟的意大利面代码,这种代码在以后几乎不可能拆分。
人员流动也产生了重大影响。随着开发人员离开团队,他们带走了关键知识,而新来的开发人员则需要建立自己对应用的思维模型,这可能与原始愿景相冲突。时间流逝,代码多次易手,这些负面因素共同导致代码库退化成所谓的“一团糟”。这个名字描述了当应用不再有可识别架构时的混乱状态。
更新单体架构的代码是一件风险很高的事情。要么全有,要么全无。当你推送一个破坏单体的代码更改时,整个应用将停止运行,你的客户将陷入困境,你的公司会损失金钱。我们可能只想更改一行代码,但仍然必须部署整个单体,并承担破坏它的风险。这种风险加剧了部署恐惧。恐惧减缓了开发速度。
此外,随着单体架构的结构退化,我们以不可预见的方式破坏它的风险增加。测试变得更加困难,并滋生更多的部署恐惧。我已经说服你尝试微服务了吗?等等,还有更多!
由于现有单体架构的规模庞大,测试变得困难,由于其极低的粒度级别,扩展也变得困难。最终,单体架构会扩展到消耗其运行的机器的物理极限。随着老化的单体架构消耗越来越多的物理资源,运行成本越来越高。我亲眼见过这种情况!公平地说,这种可能性对于任何单体架构来说可能还遥不可及,但即使只是几年增长,单体架构也会把你带到你不想去的地方。
尽管单体架构最终会遇到困难,但它仍然是启动新应用最简单的方式。难道我们不应该总是从单体架构开始,并在需要扩展时进行重构吗?我的回答是:视情况而定。
许多应用程序始终会保持小型。野外有大量的小型单体应用,它们能很好地完成工作,不需要扩展或进化。因为这些应用没有扩张,所以它们不会遭受增长带来的问题。如果你认为你的应用程序将保持小型和简单,并且不需要进化,那么你绝对应该将其构建为一个单体。
然而,有许多应用程序我们可以很容易地预测将受益于微服务优先的方法。这些是我们知道将在多年内持续进化的应用程序类型。其他可以从中受益的应用程序是那些需要灵活、可扩展或从一开始就有安全约束的应用程序。如果你从微服务开始构建这些类型的应用程序,那么构建起来会容易得多,因为将现有单体转换为微服务既困难又风险高。
当然,如果你需要首先验证你的商业想法,那么你可以通过最初构建一个单体来这样做。然而,即使在这种情况下,我仍然认为,有了合适的工具,使用微服务进行原型设计并不比使用单体原型设计困难多少。毕竟,单体如果不是一个单一的大型服务又是什么呢?
你甚至可以考虑使用本书中的技术来启动你的单体,将其作为一个单一服务在 Kubernetes 集群中。现在你拥有了两者之最佳!当需要分解为微服务的时候,你已经处于最佳位置去做这件事,并且在你方便的时候,你可以开始从单体中剥离微服务。并且,有了现代工具提供的自动化部署的便利性,拆解和重建你的应用程序或创建用于开发和测试的副本环境变得容易。如果你想要或需要首先创建一个单体,你仍然可以从本书中介绍的技术和工具中受益。
如果你确实从单体开始,为了你自己的精神健康,并且尽可能早地,要么丢弃它并替换它,要么逐步将其重构为微服务。我们将在第十一章中更多地讨论拆分现有单体的问题。
1.8 为什么微服务现在这么受欢迎?
为什么现在微服务似乎正变得越来越受欢迎?这是否只是一个短暂的潮流?
不,这并不是一个短暂的潮流。分布式计算已经存在很长时间,并且始终在单体应用之上拥有许多优势。然而,传统上,以这种方式构建应用程序更加复杂和昂贵。开发者只有在面对最苛刻的问题时才会寻求这些更强大的应用架构:那些解决方案的价值会超过实现成本的案例。
然而,近年来,随着云计算、虚拟化和自动化工具的创建,用于管理我们的虚拟基础设施,构建这样的分布式系统变得更加便宜。随着用分布式应用程序替换单体应用程序变得更加便宜,我们自然会考虑这种方法如何改善我们应用程序的结构。在这样做的时候,我们分布式系统的组件已经缩小到可能的最小尺寸,因此现在我们称之为 微服务。
正是因为这个原因,微服务现在很受欢迎。它们不仅通常是一种构建复杂现代应用的值得尝试的方法,而且它们也越来越具有成本效益。分布式计算比以往任何时候都更容易获得,因此自然有更多的开发者开始使用它。目前,它似乎正在接近临界质量,因此正在进入主流。
但为什么微服务如此优秀?它们是如何改善我们应用程序的结构?这个问题引出了微服务的优势。
1.9 微服务的优势
构建分布式应用程序带来了许多优势。每个服务都可能拥有自己的专用 CPU、内存和其他资源。通常情况下,我们在许多服务之间共享物理基础设施,这就是微服务具有成本效益的原因。但我们在必要时也可以将这些服务分离出来,以便为工作负载最重的服务分配专用资源。我们可以这样说,每个小型服务都是独立可扩展的,这使我们能够精细调整应用程序的性能。在本节中,我们将探讨这些优势:
-
允许精细控制- 微服务使我们能够对可扩展性进行精细控制
-
最小化部署风险- 微服务帮助我们最小化部署风险,同时最大化开发速度
-
允许选择自己的技术栈- 微服务使我们能够为手头的任务选择合适的技术栈,这样我们就不会局限于单一的技术栈
拥有分布式应用程序为我们提供了更好的可靠性和降低部署风险的可能性。当我们更新特定服务时,我们可以这样做,而不会冒着破坏整个应用程序的风险。当然,我们仍然可能冒着破坏应用程序一部分的风险,但这比关闭整个应用程序更容易恢复。当出现问题时,只需回滚系统的一小部分,而不是整个系统,这要容易得多。降低部署风险对促进频繁部署有连锁反应,这对于敏捷性和保持快速的开发速度至关重要。
这些好处并不是什么新鲜事。毕竟,我们已经构建了分布式应用程序很长时间了,但这样的系统现在建造成本更低,工具也更好了。以这种方式构建应用程序并从中获益比以往任何时候都更容易。随着成本的降低和部署便利性的增加,我们的服务倾向于向微级别发展,这也带来了它自己的配套好处。
较小的服务启动比大型服务快。这有助于使我们的系统更容易扩展,因为我们可以快速复制任何过载的服务。较小的服务也更容易进行测试和故障排除。尽管测试整个系统仍然可能很困难,但我们更容易证明它的每个部分都在按预期工作。
使用许多小型且可独立升级的部分构建应用程序意味着我们可以拥有一个在其生命周期内更容易扩展、演变和重新排列的应用程序。我们强制在组件之间建立流程边界的事实意味着我们永远不会被诱惑去编写意大利面代码。实际上,如果我们确实编写了糟糕的代码(我们都有糟糕的日子,对吧?),糟糕代码的影响将被控制和隔离,因为每个微服务(为了获得这个名字)应该足够小,以至于可以在几周甚至几天内被丢弃并重新编写。从这个意义上说,我们正在设计我们的代码以供丢弃。我们正在设计它以便随着时间的推移进行替换。我们应用程序的持续和迭代替换不仅成为可能,而且被积极鼓励,这正是我们应用程序架构能够适应现代商业不断变化需求的关键。
对于使用微服务的开发者来说,另一个真正令人兴奋的好处是我们不再受限于为我们的应用程序使用单一技术栈。我们应用程序中的每个服务都可能包含任何技术栈。对于大型公司来说,这意味着不同的团队可以选择自己的技术栈;他们可以根据自己的经验或根据最适合当前工作的技术栈来选择。各种技术栈可以存在于我们的集群中,并使用共享协议和通信机制协同工作。
能够在技术栈之间进行切换对于应用程序的长期健康至关重要。随着技术领域的不断演变,就像它总是做的那样,旧的技术栈会失去优势,最终必须被新的技术栈所取代。微服务创建了一种可以逐步转换为更新技术栈的结构。作为开发者,我们不再需要滞留在过时的技术上了。
技术栈(tech stack)
你的技术栈是你构建每个微服务所使用的工具、软件和框架的组合。你可以将其视为你的应用程序所需的基本基础元素。
一些栈有名字。例如,MEAN(Mongo, Express, Angular, Node.js)或 LAMP(Linux, Apache, MySQL, PHP)。但你的栈只是你使用的工具的组合,它不需要名字就是有效的。
当构建单体应用时,我们必须选择一个技术栈,并且必须在这个栈上保持不变,直到单体应用停止运行。微服务架构之所以吸引人,是因为它给了我们在一个应用程序中使用多个技术栈的潜力。这允许我们在应用演变的过程中随着时间的推移改变我们的技术栈。
1.10 微服务的缺点
本章如果不讨论人们与微服务相关的两个主要问题,就不会完整:
-
微服务更难
-
人们常常害怕复杂性
第一个问题是陡峭的学习曲线。学习如何构建微服务需要你学习不仅仅是复杂的技术组合,还要学习构建分布式应用的原则和技术。虽然学习如何构建微服务是困难的,但这本书将帮助你缩短学习曲线。
注意,我能够理解如果你对面前的东西感到畏惧。但最近,在构建分布式应用的工具开发方面取得了巨大的进步。我们的工具现在更加复杂,更容易使用,最重要的是,比以往任何时候都更加自动化。
现在,一个经验丰富的开发者现在能够独立启动微服务应用程序,而不需要团队的支持。我知道这一点,因为我已经为多家初创公司做过多次。尽管如此,我还是很惊讶一个人能取得多大的成就。我们将在第十一章中更多地讨论初创公司、小型团队和独立开发者如何快速有效地与微服务合作。
公平地说,工具仍然很复杂。通常,你需要数月甚至更长时间才能独自克服学习曲线——掌握这些工具需要大量的时间!但本书采取了不同的方法。我们将一起学习启动我们的应用程序并使其在生产中运行所需的最基本知识。我们将一起创建一个简单但实用的微服务应用程序。在这个过程中,我们还将学习构建分布式应用的基础知识。
正如我提到的,微服务开发者实际上面临两个问题。第二个问题是构建微服务应用程序,实际上任何分布式应用程序,都将比构建等效的单体应用更复杂。这一点很难反驳。我首先想说,是的,构建单体应用在开始时更简单,在许多情况下这是正确的决定。如果你的应用程序是那些必须后来转换为微服务或重构的应用程序之一,那么你应该考虑解开你的大泥球最终的成本。
不要被复杂性吓倒;无论你是否喜欢,它都会发生。幸运的是,微服务为我们提供了管理复杂性的具体方法。
如果你仔细思考,你可能会承认,至少在某些情况下,构建微服务实际上比构建单体应用程序更简单。如果这次讨论还没有说服你,考虑这一点:任何重要的应用程序最终都会变得复杂。如果不是一开始,它将会随着时间的推移变得更加复杂。你无法在现代软件开发中隐藏复杂性,它总是最终会追上你。相反,让我们控制这种局面,直面复杂性。我们想要的更好的工具来帮助我们管理复杂性。微服务作为一种架构模式就是这样一种工具。
将微服务视为将痛苦提前,到一个处理起来更经济的地方的方法。为此痛苦,我们得到了什么回报?微服务为我们提供了管理应用程序复杂性的具体方法。它们提供了硬边界,防止我们编写意大利面代码。微服务使我们能够更容易地重新布线我们的应用程序,随着时间的推移进行扩展和升级。微服务还迫使我们应用更好的设计。我们无法阻止复杂性,但我们可以管理它,而现代分布式应用程序的工具已经在这里帮助我们了。
1.11 微服务的现代工具
这本书全部关于工具。我们将一起学习许多不同工具的基础知识。首先,我们必须能够创建一个微服务。我们将使用 JavaScript 和 Node.js 来完成这项工作,下一章将教你这方面的基础知识。
我们使用 Node.js,因为那是我的选择。然而,就微服务而言,服务内部的技术堆栈并不特别重要。我们同样可以用 Python、Ruby、Java、Go 或几乎任何其他语言来构建我们的微服务。在我们旅途中,我们将遇到许多工具,但这些都是最重要的:
-
Docker —打包和部署我们的服务
-
Docker Compose —在开发工作站上测试我们的微服务应用程序
-
Kubernetes —在云端托管我们的应用程序
-
Terraform —构建我们的云基础设施,我们的 Kubernetes 集群,并部署我们的应用程序
技术景观总是在变化,工具也是如此。那么,为什么我们应该学习任何特定的工具集,当工具不断过时并被取代时?嗯,这是因为我们始终需要好的工具来有效地工作。有了更好的工具,我们可以做得更好,或者也许我们只是更有效地完成同样的工作。无论如何,这有助于我们提高生产力。
我选择这本书的工具,因为这些工具可以显著简化构建微服务应用程序的工作,并使其更快。所有技术都会随时间而变化,但我认为这些特定的工具不会很快消失。它们很受欢迎,目前是我们拥有的最好的工具,并且都在一个人的工具箱中占据有用的位置。
当然,这些工具最终会被取代,但希望在这段时间里,我们能够从这些工具中提取出显著的价值,并构建许多优秀的应用程序。当工具发生变化时,它们肯定会由更好的工具所取代,这些工具将进一步提高抽象层次,使我们的工作更加容易,减少挫败感。
Docker 是所有工具中几乎无处不在的一个。它似乎是从无到有,并接管了我们的行业。另一方面,Kubernetes 并不像 Docker 那样无处不在,尽管它确实有一个强大的未来,因为它允许我们超越云供应商的界限。如果你曾经感到被特定的云提供商所困,这是一个好消息。我们几乎可以在任何云平台上运行基于 Kubernetes 的应用程序,并在需要时拥有行动的自由。
Terraform 是一个相对较新的工具,但我认为它是一个变革者。它是一种声明性配置语言,允许我们编写脚本以创建云资源并部署我们的服务。Terraform 的重要之处在于,它是一种可以与任何潜在云供应商一起工作的单一语言。无论你现在或将来选择哪个云供应商,Terraform 很可能都会支持它,你不需要学习新的东西。
想想看:Terraform 意味着我们可以轻松地用代码创建云基础设施。这真是个大事情!在过去,我们不得不辛苦地物理拼接基础设施,但现在我们能够用代码来创建它。这个概念被称为基础设施即代码,它是持续交付的关键推动力,这在第七章中我们将探讨的现代软件开发中的重要内容。
1.12 设计微服务应用程序
这不是一本关于理论的书籍,但在我们进入实际内容之前,我必须简要提及一些软件设计方面。我保证这只是一些基础原则,而且有很多其他书籍可以帮助你在这一领域打下更好的基础。
在一开始,我想说,设计微服务应用程序与设计任何软件并没有太大的不同。你可以阅读任何关于软件设计的优秀书籍,并将相同的原理和技术应用于微服务。我没有太多严格遵循的规则,但我认为这些规则特别重要:
-
不要过度设计或试图使你的架构具有前瞻性。从你应用程序的简单设计开始。
-
在开发过程中应用持续重构,以保持其尽可能简单。
-
让一个好的设计自然出现。
我觉得最后一个规则特别受到微服务的鼓励。你无法预先规划一个大的微服务应用程序。架构必须在开发过程中以及应用程序的生命周期中逐渐显现。
我并不是说你不应该做任何规划。你肯定应该在开发的每个阶段进行规划。我想说的是,你应该为你的计划可能发生变化做好准备!你应该能够快速应对变化的情况,这也是微服务所支持的另一个优点。抛开规则不谈,让我们简要讨论三个似乎特别适用于微服务的原则:
-
单一职责原则
-
松散耦合
-
高内聚
通常,我们希望每个微服务尽可能小和简单。一个单独的服务应该只覆盖业务的一个概念领域。也就是说,每个服务应该有一个单一、明确的职责区域。这通常被称为单一职责原则。
微服务应该是松散耦合并且具有高内聚。松散耦合意味着服务之间的连接最小化,并且除非必要,它们不共享信息。当我们减少微服务之间的连接和依赖关系时,我们使得升级单个服务而不会导致问题在应用程序中传播变得更容易。松散耦合帮助我们拆分并重新布线我们的应用程序,使其进入新的配置。这使得我们的应用程序更加灵活,能够对业务变化的需求做出快速响应。
微服务内部包含的代码应该是高度内聚的。这意味着微服务中的所有代码都属于一起,并有助于解决服务职责区域内的难题。如果一个微服务解决了一个以上的问题或有一个更大的职责区域,那么这表明它不是高度内聚的。
一种非常适合微服务的架构范式被称为领域驱动设计(DDD)。使用 DDD 是理解业务领域并作为软件来建模业务的一个很好的方法。这种技术源自埃里克·埃文斯(Eric Evans)所著的《领域驱动设计》(Domain Driven Design)一书(2003 年)。我自己多次使用过它,并发现它非常适合设计分布式应用程序。具体来说,边界上下文的概念非常适合微服务的边界,如图 1.6 所示。

图 1.6 领域驱动设计(DDD)中的边界上下文等同于微服务的边界。
此图显示了我们的视频流领域中的概念边界可能如何适应微服务。例如,用户、点赞和视频等概念存在于我们的微服务中,而某些概念(如视频)创建了微服务之间的关系。例如,在图 1.6 中,视频的概念在推荐和视频存储微服务之间几乎是相同的(但可能会有所不同)。
有一个编码原则似乎正受到微服务的攻击。许多开发者遵循“不要重复自己”(DRY)的座右铭。但在微服务的世界中,我们正在发展出比以前认为可接受的高容忍度来容忍重复的代码。
微服务应用中的硬边界过程无疑使得代码共享变得更加困难,而领域驱动设计(DDD)的实践似乎鼓励复制概念,如果不是复制代码。此外,当微服务由不同的团队拥有时,我们就会遇到团队之间已经存在的所有共享代码的障碍。
请放心,有很好的方法在微服务之间共享代码,我们不会简单地抛弃 DRY 原则。当这样做有意义时,我们仍然希望在微服务之间共享代码。
1.13 一个示例应用
到这本书的结尾,我们将构建一个简单但完整的微服务应用。在本节中,我们将形成一个关于最终产品外观的概念。
我们将要构建的示例产品是一个视频流应用。每个优秀的产品都值得一个名字,所以在经过多次头脑风暴和提出各种想法之后,我最终选择了“FlixTube”,这是视频流世界的未来之王。不是吗,总得从某个地方开始?
为什么选择视频流作为示例?简单来说,这是一个有趣的例子,而且出人意料地容易创建(至少在简单形式上)。它也是微服务的一个知名用例,Netflix 成功地将其推向了极致。(报道各有不同,但我们知道他们运行了成百上千个微服务。)
我们将使用 FlixTube 示例应用来展示构建微服务应用的过程。它将只有少数几个微服务,但我们将内置未来可扩展性所需的路径,包括向集群添加更多虚拟机、复制服务以实现规模和冗余,以及将服务提取到单独的代码仓库,以便它们可以拥有单独的部署计划并由不同的团队管理。
我们的应用程序将有一个基于浏览器的前端,让我们的用户可以查看视频列表。从那里,他们可以选择一个视频,它就会开始播放。在开发过程中,我们将使用 Docker Compose 启动我们的应用程序,我们将在第四章和第五章中介绍。我们将在第三章中构建和发布我们的微服务 Docker 镜像。在第六章和第七章中,我们将部署我们的应用程序到生产环境。在第八章中,我们将回到开发阶段进行一些自动化测试。
我们的应用程序将包含视频流、存储和上传的服务,以及面向客户的网关。我们将在第九章中部署完整的应用程序,如图 1.7 所示。在第十章和第十一章中,我们将探讨这种架构如何帮助我们随着应用程序的增长而扩展。你准备好开始使用微服务构建了吗?

图 1.7 我们示例应用程序在生产环境中运行在 Kubernetes 上。
摘要
-
我们采取的是一种实用而非理论的方法来学习如何构建微服务应用程序。
-
微服务是小型且独立的进程,每个进程都擅长做一件事。
-
微服务应用程序由许多小型进程组成,它们共同工作以创建应用程序的功能。
-
单体是一个由单个大型服务组成的应用程序。
-
虽然构建微服务应用程序比构建单体更复杂,但并不像你想象的那么困难。
-
由微服务构建的应用程序比单体应用程序更灵活、可扩展、可靠和容错。
-
Docker、Kubernetes 和 Terraform 等现代工具的结合使得构建微服务应用程序比以前更容易。
-
领域驱动设计(DDD)是设计微服务应用程序的有效方法。
-
DDD 中的边界上下文很好地映射到微服务的边界。
-
我们预览了本书中将构建的示例应用程序。
2 创建你的第一个微服务
本章涵盖
-
我们的开发哲学
-
建立单一服务的开发环境
-
构建视频流微服务
-
生产环境和开发环境的设置
-
使用 Node.js 运行我们的微服务
我们这本书的目标是组装一个由多个微服务组成的应用程序。但在我们能够构建多个微服务之前,我们首先必须学会如何构建单个微服务。
因为我们必须从某个地方开始,在本章中,我们将创建我们的第一个微服务。这是一个简单的微服务,做得很少,但它说明了这个过程,以便你可以理解并重复它。实际上,这就是我们将通过本书的进程来创建多个微服务的方法。
这个第一个微服务是一个简单的 HTTP 服务器,它向在网页浏览器中观看的用户提供流媒体视频。这是我们通往构建 FlixTube(我们的视频流应用)道路上的第一步。视频流可能听起来很困难,但在这个阶段我们检查的简单代码不应该带来太多麻烦。
在这本书中,我们的微服务是用 JavaScript 编写的,并在 Node.js 上运行。但重要的是要注意,我们可以为我们的微服务使用任何技术栈。使用微服务构建应用程序给了我们在技术栈选择上很大的自由度。
你不必使用 JavaScript 来构建微服务。你可以同样容易地使用 Python、C#、Ruby、Java、Go 或你阅读这本书时流行的任何语言来构建你的微服务。然而,我必须做出选择,因为这是一本实用的书,我们需要深入实际的编码细节。但请记住,你可以同样容易地使用你自己的首选编程语言来构建你的微服务。
我们即将开始对 Node.js 进行快速浏览。当然,我们无法涵盖所有细节,正如本书的主题,我们只会简要地介绍可能的内容。在本章结束时,你将找到关于 Node.js 的其他书籍的参考,以便深入了解。
如果你已经熟悉 Node.js,那么你会发现本章的大部分内容都很熟悉,你可能想跳过它。但请快速浏览一下,因为其中有一些关于设置开发环境、准备生产部署以及为快速迭代开发做准备的重要提示,这些内容将在整本书中用到。
带好你的帽子!这本书一开始很简单,但很快就会变成一段非常刺激的旅程。
2.1 新工具
因为这本书全部关于工具,所以在大多数章节中,我们都会从您需要安装的新工具开始,以便跟随章节中的示例。从我们的第一个微服务开始,表 2.1 展示了我们需要使用的工具:Git、Node.js 和 Visual Studio (VS) Code。我们将使用 Git 获取代码。我们将使用 Node.js 运行和测试我们的第一个微服务,并使用 VS Code 编辑我们的代码和 Node.js 项目。
在本书中,我会告诉您每个工具的版本号,这些工具用于开发本书中的示例。这将为您提供可以用来跟随示例的版本号。
这些工具的后续版本也应该可以工作,因为好的工具通常具有向后兼容性,但偶尔的版本重大更新可能会破坏旧示例。如果发生这种情况,请通过在 GitHub 上记录问题(见下一节)来通知我。
表 2.1 第二章中引入的工具
| 工具 | 版本 | 用途 |
|---|---|---|
| Git | 2.27.0 | 版本控制是日常开发中不可或缺的一部分,在本章中,我们使用 Git 获取第二章的代码副本。 |
| Node.js | 12.18.1 | 我们使用 Node.js 运行我们的微服务。 |
| Visual Studio (VS) Code | 1.46.1 | 我们使用 VS Code 编辑我们的代码和其他资产。 |
当然,您可以使用其他一些集成开发环境(IDE)或文本编辑器来编辑您的代码。我推荐 VS Code,因为它不会出错!
2.2 获取代码
本书附带许多工作示例项目。每个项目的代码都可在 GitHub 上找到。您可以在那里克隆或下载代码仓库,以跟随书中的示例。我强烈建议您在阅读本书时运行这些示例。这是您获得实践经验并充分利用学习效果的最佳方式。
按照标准惯例,这些示例易于运行,并且都具有类似的设置。一旦您了解了基础知识(我们将介绍),您会发现运行这些示例很容易。随着我们的进展,示例会变得更加复杂,但我仍会尽量让它们易于访问,解释它们的工作原理,并帮助您启动和运行。
要找到 GitHub 上的 Bootstrapping Microservices 组织,请将您的网络浏览器指向 github.com/bootstrapping-microservices。在这里,您将看到按章节组织的代码仓库集合,从本章的 chapter-2 仓库开始。
每章都有自己的代码仓库,例如,github.com/bootstrapping-microservices/chapter-2。在每个仓库下,您可以找到按本章中列出的示例项目组织的代码。如果您发现代码有任何问题,或者您在尝试运行它时遇到困难,请在 GitHub 上针对相应的代码仓库记录问题,以便我可以帮助您使其工作。
2.3 为什么选择 Node.js?
在这本书中,我们使用 Node.js 来构建我们的微服务。为什么是 Node.js 呢?构建微服务的一个优点是我们可以选择我们喜欢的技术栈。我恰好喜欢 Node.js,但我选择它的原因还有其他。
使用 Docker(我们将在第三章中探讨)构建我们的微服务意味着我们可以从多个技术栈中组合应用程序。这听起来可能像会让事情变得更复杂,而且可能确实如此,但它给了我们混合和匹配技术的能力。我们可以用它来确保我们使用的是每种情况下最合适的栈。
注意,Node.js 非常适合构建微服务。它是面向网络的,并且性能高。我们计划构建许多服务,所以让我们善待自己,选择一个让我们的工作变得更简单的平台。
Node.js 也非常流行且广为人知。这听起来可能不多,但很重要,因为它意味着围绕 Node.js 有一个由人、工具和资源组成的生态系统。在你需要帮助时有一个庞大的社区可以依靠是很重要的。这使得在学习过程中寻找帮助变得更加容易,而且在持续软件开发期间得到支持也是一件好事。
Node.js 是为微服务而生的。从名字上就能看出这一点。"Node"暗示了它在构建基于分布式网络的应用程序节点方面的用途。(JavaScript 从 11 年前开始从浏览器中移出,并从此确立了自己作为一款极富竞争力的服务器端编程语言的地位。)
Node.js 是为创建小型、高性能和轻量级服务而生的,它摒弃了其他许多平台带来的负担。在 Node.js 中构建 HTTP 服务器是微不足道的。这使得我们能够快速启动新的微服务变得容易。这是一个很好的动力,因为我们计划创建许多小型服务。Node.js 对这本书来说也很方便,因为它意味着你不需要花很多时间学习如何编写基本的微服务代码,而且正如你很快就会看到的,我们可以使用 Node.js 用很少的代码构建微服务。
使用 JavaScript 可以促进全栈编程。如今,JavaScript 几乎无处不在。我们可以在应用程序的后端使用它来构建微服务。我们也可以在基于 Web 的前端使用它(当然,JavaScript 就是从这里诞生的)。不仅如此,我们还可以使用 JavaScript 进行桌面开发(Electron)、移动开发(Ionic)、嵌入式开发(物联网设备)。正如我在上一本书《用 JavaScript 进行数据处理》中展示的那样,我们还可以在处理数据时使用 JavaScript,而数据领域通常由 Python 主导。尽可能多地使用 JavaScript 意味着我们可以在应用程序的任何地方自由穿梭,而不会触发心理上的上下文切换。
我们从 Node.js 获得的另一件大事是 npm,即 Node 包管理器,这是一个用于安装 Node.js 代码库的命令行工具。这并不特别与构建微服务相关,但拥有一个出色的包管理器和大量开源包在指尖上是非常有用的。作为开发者,我的超级能力是拥有超过 350,000 个代码库(截至 2017 年报道时)并且可以轻松访问。无论我需要做什么,通常只需进行一次快速的 npm 搜索即可!
注意 Node.js 是开源的,你可以在 GitHub 上找到它的代码,网址为 github.com/nodejs/node.
什么是 npm?
Npm 是 Node 包管理器。它是一个与在线 npm 仓库通信的命令行应用程序,允许你在 Node.js 项目中管理第三方包。安装现成的包是快速解决你原本需要编写更多代码才能解决的问题的方法!你可以在 npm 网站上搜索包,网址为 www.npmjs.com。
2.4 我们的开发哲学
在我们开始编码之前,我想简要介绍一下我的开发哲学,我们将在这本书的整个过程中使用它。你将看到这一点会反复出现,所以一个简短的解释是必要的。我将用以下三个要点总结我的开发哲学:
-
迭代
-
保持其工作状态
-
从简单到复杂
迭代 是关键因素。我指的是个人编码迭代,而不是敏捷中通常被称为冲刺的更大迭代。我们通过一系列个人迭代构建应用程序的代码。我们将像图 2.1 所示那样,一次迭代一次地添加代码。每次迭代都会给我们反馈。反馈使我们能够发现我们是否偏离了轨道,并立即进行课程修正。快速迭代使我们能够将我们的工作与不断发展的目标紧密对齐。
注意 小而快速的编码增量对于高效的软件开发者是必不可少的。
在每次迭代中,我们进行少量的编码。多小?这取决于我们正在做什么以及有多难。但关键是它应该足够小,以至于我们可以轻松理解和测试我们刚刚编写的代码。
每次迭代都必须产生可工作和经过测试的代码。这是最重要的因素。你有没有输入过一整页的代码然后挣扎数小时才让它工作?当我们以小而经过良好测试的代码迭代工作,一天编码结束时,累积的结果是一大块 可工作 的代码。你可以在图 2.1 中看到这是如何工作的。

图 2.1 一系列小的代码更改最终产生大量可工作的代码。
产生大量可工作代码的概念展示了我的第二个要点:保持其工作状态。如果我们以小、易于测试的增量工作,我们很少会遇到麻烦。当输入大量代码时,我们面临许多困难,使代码工作。很可能会有一大堆损坏(不可工作)的代码。但即使代码看起来似乎可以工作,它可能仍然隐藏着许多尚未发现的“坏东西”。
注意,每次小的编码迭代都应该产生可工作且经过测试的代码。一系列此类迭代的总和将产生大量坚如磐石的代码。
当我们遇到麻烦时,我们可以轻松地将代码回滚到先前的迭代,以恢复其工作状态。因为我们的代码迭代很小,所以在需要回滚时,我们不会失去太多进度。遇到麻烦根本就不是什么麻烦!
当然,恢复先前的迭代意味着您正在将代码提交到 Git 或其他形式的版本控制中。这一点不言而喻。即使您没有使用版本控制(您真的应该使用),那么找到另一种方式来保存迭代结果的责任就落在您身上。
我的编程哲学中的第三和最后一个要点是从简单开始。我们应该从最简单的起点开始编码,并逐步迭代我们的应用程序以增加复杂性。所有应用程序都会随着时间的推移而变得复杂;从长远来看,这是不可避免的。但我们绝对不应该从复杂性开始。不要试图一次性以“大爆炸”的方式构建一个复杂的系统。这可能不会对您产生好的结果。
注意,复杂性是应用程序总是会达到的地方,但这并不意味着它们必须从那里开始。每次代码更改也应该简单,避免在任何单一迭代中引入过多的复杂性。
从最简单的代码开始,然后通过一次迭代一次迭代地构建,您可以将其构建得更加复杂。这个过程如图 2.2 所示。不要急于承担复杂性。尽可能保持简单。随着我们的应用程序变得越来越复杂,我们需要引入工具、技术、流程和模式来帮助我们管理这种复杂性。
使用微服务构建是管理复杂性的一个工具。再次强调,任何给定的微服务都应该简单。它应该小巧。对现有微服务进行小更新应该容易。将新的微服务添加到现有应用程序中应该毫不费力。即使应用程序本身已经变得极其复杂,这些说法也是正确的。
随着我们的代码变得更加复杂,这并不意味着我们的迭代也需要这样。我们应该努力保持每次代码修改尽可能简单。简单的更改易于理解,更容易测试,并集成到应用程序中。所有这些都有助于提高正在发展的系统继续按我们希望的方式表现的概率。
小贴士:在解决复杂应用程序中的问题时,不要害怕将问题从应用程序中提取出来,并在更简单的环境中重现它。如果你能用更少的代码隔离一个问题,那么这个问题就有更少的空间来隐藏!
如果我们在一个复杂的应用程序中遇到难以解决的问题,我们现在有一个新的选择。如图 2.2 所示,箭头从末端回到起点,在任何时候,我们都可以从复杂的应用程序中提取出有问题的代码,并在一个更简单的环境中重现它。

图 2.2 从简单开始,通过一系列小迭代逐步过渡到复杂
幸运的是,当用 JavaScript 编码时,这相当容易做到。我们可能会在单元测试中加载我们的代码,这样我们就可以反复运行代码来调试和修复它。如果这不可能,我们可能会将代码提取到一个单独的 Node.js 项目中,以隔离问题并使其更容易解决。我经常启动 Data-Forge Notebook(一个我构建并公开发布的应用程序)来运行隔离的代码,使其更容易解决问题。
但如果我们无法轻松提取代码怎么办?在这种情况下,我喜欢做的就是围绕有问题的代码拆解应用程序。尽可能地将代码从应用程序中提取出来(直到你尽可能好地隔离了问题)。
我们为什么要这样做呢?这是因为当你隔离了一个问题,它就没有藏身之处。发现问题通常比发现问题后解决它们要花费更多的时间。因此,拥有更快的方法来定位我们代码中问题的位置,是我们提高生产力的最佳方式之一。我们将在第十章中更多地讨论调试过程和隔离问题。
这也是我们喜欢微服务的一个原因。我们的应用程序已经模块化了,因此我们应该能够轻松地移除非必要的微服务。话虽如此,以这种方式从应用程序中删除代码确实是一种高级技术,很容易导致应用程序损坏!
我在这里阐述我的开发哲学,因为我认为它可以帮助你成为一个更好、更高效的开发者。我们的软件通过小而经过充分测试的增量进行演变,这是我们的主要目标。我们将代码从一个工作状态迭代到另一个工作状态。我们的代码在任何时候都不应该出现根本性的错误。
你将在本章以及整本书中看到这一哲学在行动。从简单开始。从小处着手。通过小步迭代。保持其运作。不知不觉中,我们将构建出庞大而复杂的成果!但这并不是一蹴而就的。它是通过一系列小变化实现的,这些变化累积起来,最终成为巨大的成就。
2.5 建立我们的单一服务开发环境
要创建和开发微服务,我们需要设置我们的开发环境。这为我们提供了一个创建和编辑代码的方法,然后运行它以确保它工作。在本章中,我们将构建一个单微服务,并直接在我们的开发工作站(或个人电脑)上使用 Node.js 运行它。我们将使用 VS Code 或其他你选择的 IDE 或文本编辑器来编辑我们的代码。让我们从设置我们的环境开始。|
Node.js 本身易于在任何主要操作系统上安装和运行,因此你可以选择 Linux、Windows 或 MacOS 来开发你的微服务。(你的选择在表 2.2 中总结。)|
在 Node.js 下直接运行单个服务相当简单,正如你将在本章接下来的部分中看到的那样。但是,当涉及到开发测试多个微服务时,我们将在第四章中介绍,事情会变得更加复杂。那时,我们将需要借助 Docker(从第三章开始)的帮助。现在,让我们专注于在我们的选择操作系统下直接在 Node.js 下运行我们的微服务。|
即使我们在开发、测试和调试多个微服务之后,在开发、测试和故障排除过程中,我们有时也会想要将单个微服务从应用程序中提取出来单独运行,以便我们可以专注于这个隔离的部分,而无需担心整个应用程序及其带来的所有负担。拥有一个单服务开发环境不仅是在早期阶段的一个方便的垫脚石,而且在持续开发过程中随时可以投入使用。|
表 2.2 运行 Node.js 的选项|
| 平台 | 备注 |
|---|---|
| Linux | Node.js 是为 Linux 构建的,因此在 Linux 上运行得相当不错!在本章中,我将大多数命令演示在 Ubuntu Linux 上。如果你也在使用 Ubuntu 或其他 Linux 变体,你将能够很好地跟随书中的示例。 |
| Windows | Node.js 也在 Windows 下运行良好。实际上,我大部分日常的开发、测试和故障排除都是使用 Windows。 |
| MacOS | Node.js 也在 MacOS 下运行良好。 |
备注:当仅使用 Node.js 时,你可以在任何平台上使用它,实际上并没有一个比其他任何更好的平台!|
|
图 2.3 第二章的单服务开发环境|
图 2.3 展示了我们的单微服务开发环境的样子。我们将使用 VS Code 或其他替代编辑器编辑我们的代码。我们的微服务项目是一个 Node.js 项目,包含 JavaScript 代码。(我很快会展示如何创建它。)在 Node.js 下运行我们的项目会产生一个正在运行的微服务实例。所有这些都在我们选择的宿主操作系统上运行:Linux、Windows 或 MacOS。|
2.5.1 安装 Git|
这本书的示例项目和代码位于 GitHub 上的 Bootstrapping Microservices 组织下(参见第 2.2 节中的链接)。图 2.4 展示了每个代码仓库的结构。每个子目录(example-1、example-2 等等)都是你可以自己运行以跟随本书的项目(假设你不想自己输入所有代码)。

图 2.4 GitHub 仓库中的每个示例项目都是一个完整的可运行项目,你可以自己运行。
获取代码最简单的方式是从 GitHub 下载它作为 zip 文件。为此,你应该前往代码仓库(例如,第二章的 chapter-2 仓库)并寻找克隆或下载按钮。点击它,然后选择下载 ZIP。
当然,获取代码的最佳方式是使用 Git 来 克隆 代码仓库。为此,你首先需要安装 Git。你可能已经安装了它,例如,如果你(像我一样)用它来日常工作。或者你可能运行的是预装了 Git 的 Linux 变体。在 MacOS 上,你可能已经安装了 Xcode,它自带了 Git。
我们如何知道我们是否安装了 Git?要找出你安装了哪个版本的 Git(如果有),打开一个终端(在 Windows 上打开命令提示符,或者更好的是安装来自微软商店的 Windows Terminal)并运行以下命令:
git --version
如果 Git 已经安装,你会看到它的版本号,可能像这样:
git version 2.27.0
如果你还没有 Git,安装它并不困难。请访问 Git 网站 git-scm.com,并按照那里的说明在你的平台上下载和安装。
你是命令行的新手吗?
使用命令行是作为软件开发者工作最优秀和最高效的方式之一。使用用户界面和可视化编辑器对于完成最常见的日常任务来说很棒,但对于更复杂或定制的任务,我们需要习惯使用命令行。如果你是初学者,建议首先为你的操作系统做一次命令行教程。
2.5.2 克隆代码仓库
安装了 Git 后,你现在可以克隆这本书每一章的代码仓库。例如,在这个时候,你应该克隆第二章的仓库,以便你可以跟随这一章:
git clone github.com/bootstrapping-microservices/chapter-2.git
这个命令从 GitHub 获取代码仓库的副本,并将其放置在你的本地硬盘上(在当前工作目录下)的名为 chapter-2 的目录下。我不会在未来章节中再次解释如何克隆仓库。但在每个新章节的开始,我会告诉你如何获取该章节的代码,然后你可以使用 Git 获取自己的副本。随时回来这里提醒如何使用 Git。
2.5.3 获取 Visual Studio (VS) Code
我使用 Visual Studio (VS) Code 进行所有编码。我推荐它给你,因为它是一个编辑代码的绝佳环境。你可以在 VS Code 网站上找到 Windows、Linux 和 MacOS 的下载和安装说明。
我喜欢 VS Code,因为它轻量级、性能出色且可配置。它也常用于 Node.js 和 JavaScript 项目。这本书不需要任何额外的插件,但值得注意的是,有大量易于安装的插件适用于不同的编程语言和任务。你还可以根据你的所有开发需求自定义 VS Code。
当然,如果你已经有了自己偏好的 IDE 或文本编辑器,请随意使用,因为这实际上并不会造成任何区别。当我在整本书中提到 VS Code 时,你只需假装它是你偏好的文本编辑器即可!
2.5.4 安装 Node.js
为了运行我们的微服务,我们需要 Node.js。这是我们不能没有的东西,因为这本书中的示例微服务都是 Node.js 项目。所有的代码示例都是用 JavaScript 编写的,它运行在 Node.js 上。如果你已经安装了 Node.js,你可以打开一个终端,使用以下命令检查版本:
node --version
v12.18.1
npm --version
6.14.5
这些是我目前用于 node 和 npm 的版本。你可以使用这些版本或更新的版本。
注意:我们使用 npm 命令来安装第三方包。当你安装 Node.js 时,你也会得到 npm。
在任何平台上安装 Node.js 都很简单。要安装 Node.js,请访问 Node.js 网站 nodejs.org 以获取下载和安装说明。这并不困难,你不应该有任何问题。
如果你已经安装了 Node.js 并且想要获取一个新版本,或者如果你想管理多个版本的 Node.js,那么查看接下来的第二个侧边栏中描述的 NVM 是值得的。
安装 Node.js 后,打开一个终端并再次确认它已正确安装。为此,请打印版本号:
node --version
npm --version
现在我们已经安装了 Node.js,我们准备好构建和运行我们的第一个微服务了。
了解你正在使用哪个版本是很重要的!
使用 --version 参数是一个检查你是否已安装某些内容的不错方法,但了解你拥有哪个版本也很重要。当你在一个真实系统中工作时,确保你在开发和生产中使用相同的版本是至关重要的。这是确保你的代码将在生产中运行的最好方式。
需要运行不同版本的 Node.js 吗?
如果你需要运行多个版本的 Node.js 呢?这实际上很容易发生。
假设你正在维护或需要处理多个使用不同版本的 Node.js 构建的生产应用程序。或者,也许你只是在单个应用程序上工作,但它已经开发了一段时间,不同的微服务使用不同的 Node.js 版本。在这些情况下,我强烈建议你使用 nvm(Node 版本管理器)来安装不同的 Node.js 版本并在它们之间切换。
实际上存在两个不同的应用程序,分别称为 nvm 和,你选择哪一个取决于你的操作系统。请参阅以下链接以获取设置说明:
-
对于 Linux 和 MacOS,你需要这个:
github.com/nvm-sh/nvm。 -
对于 Windows,请使用:
github.com/coreybutler/nvm-windows。
这不是给胆小的人做的!你必须熟练使用命令行来安装此软件。
2.6 构建视频流 HTTP 服务器
现在我们有了我们的开发环境,我们可以构建我们的第一个微服务。这不是一个困难的项目,我们只是构建它来展示创建基本微服务的过程。这是创建我们的示例微服务应用程序 FlixTube 的第一步。你可以在阅读本章时跟随代码,逐行输入你看到的代码,或者你可以先阅读它,然后尝试 GitHub 上第二章仓库中可用的示例项目。
我们正在构建的微服务是一个简单的视频流服务。流媒体视频可能听起来很复杂,在真实的生产应用程序中,这确实可能变得复杂。但我们是从小事做起。你可能会惊讶于我们实际上需要多少代码来创建这个服务。
图 2.5 显示了本章项目最终结果的输出。我们的微服务通过端口 3000 和路由 video 将流媒体视频发送到网络浏览器。我们可以直接通过浏览器观看视频,只需将其指向 http://localhost:3000/video。

图 2.5 在 Chrome 中直接观看我们的微服务的流媒体视频
在图 2.5 中,你可以看到我们使用 Chrome 来观看视频。我们使用的示例视频是从 sample-videos.com 下载的。在这里,我们使用了尽可能短的视频,但你可以自由下载较大的示例视频进行自己的测试。
要创建我们的微服务,我们必须经过以下步骤:
-
为我们的微服务创建一个 Node.js 项目。
-
安装 Express 并创建一个简单的 HTTP 服务器。
-
添加一个 HTTP GET 路由 /video 来检索流媒体视频。
在创建这个基本的第一个微服务之后,我们将简要讨论我们如何配置我们的微服务。然后我们将介绍一些生产环境和开发设置的基本原则。
2.6.1 创建 Node.js 项目
在我们开始编写代码之前,我们需要一个 Node.js 项目,我们的代码可以存储在其中。我们即将创建的项目如图 2.6 所示。这是一个基本的 Node.js 项目,具有单个入口点:脚本文件 index.js。您还可以看到 package.json 和 package-lock.json,这些文件跟踪我们项目的依赖项和元数据。依赖项本身安装在 node_modules 目录下。让我们创建这个项目吧!

图 2.6 我们的第一个 Node.js 项目
定义 A Node.js 项目 包含了我们的 Node.js 应用程序的源代码和配置。这是我们编辑创建微服务功能的代码的地方。
如果您是从头创建项目(而不仅仅是运行 GitHub 上的代码),您必须首先为项目创建一个目录。您可以在 Linux 和 MacOS 的终端中使用mkdir命令来完成此操作:
mkdir my-new-project
如果您正在使用 Windows,您可以使用md命令代替:
md my-new-project
现在使用cd命令切换到您的新目录:
cd my-new-project
您现在可以创建一个 stub Node.js 项目。这意味着我们正在创建我们的 package.json 文件。我们可以使用 npm init命令来完成此操作:
npm init -y
-y 参数意味着在初始化我们的项目时,我们不需要回答任何交互式问题。这仅仅使得创建我们的项目稍微快一点。
运行 npm init 后,我们现在有一个所有字段都设置为默认值的 package.json 文件。您可以在列表 2.1 中看到一个示例。由于此文件中的字段具有默认值,您可能希望在以后回来并将这些值设置为更适合您项目的值。不过,目前我们将保持这些值不变。
列表 2.1 我们刚刚生成的空 Node.js 包文件
{
"name": "my-new-project" ①
"version": "1.0.0", ②
"description": "", ②
"main": "index.js",
"scripts": { ③
"test": "..." ③
}, ③
"keywords": [], ④
"author": "", ④
"license": "ISC" ④
}
① 包名。默认为包含包的目录名;在这种情况下,my-new-project,因为我们是在我们刚刚创建的 my-new-project 目录中初始化的。
② 如果您要将此包发布到www.npmjs.com.,这些字段就很重要。
③ npm 脚本放在这里。我们将在本章后面更详细地讨论这一点。
④ 如果您要将此包发布到www.npmjs.com.,这些字段就很重要。
在创建您的 Node.js 项目后,我鼓励您在 VS Code 中打开文件夹,通过打开 package.json 文件并检查它来探索您的新项目。在 VS Code 中打开项目后,您现在就可以开始向项目中添加一些代码了。
package.json 与 package-lock.json
虽然 package.json 是由 npm 自动生成和更新的,但您也可以手动编辑它。这样,您可以手动更改 Node.js 项目的元数据和 npm 模块依赖项。
通常,package.json 不会指定依赖项的确切版本号(尽管如果你想的话,也可以指定)。相反,package.json 通常为每个依赖项设置最小版本,并且也可以设置版本范围。此外,package.json 仅跟踪项目的顶级依赖项。你不需要指定依赖项的依赖项;这会自动为你处理。这使得 package.json 更小、更简洁,因此更易于人类阅读。
package.json 的问题在于你和你的同事可能会运行不同版本的依赖项。更糟糕的是,你运行的版本可能与生产环境中的版本不同。这是因为 package.json 通常不指定确切的版本,因此根据你何时调用npm install,你可能会从其他人那里得到不同的版本。这是一场混乱的预兆!确实,这使得重现生产问题变得困难,因为你不能保证能够重现生产环境中运行的精确配置。
package-lock.json 是在 npm 版本 5 中引入的,用于解决这个问题。它是一个生成的文件,并不设计用于手动编辑。它的目的是跟踪整个依赖项树(包括依赖项的依赖项)以及每个依赖项的确切版本。
你应该将 package-lock.json 提交到你的代码仓库。与队友和生产环境共享此文件是确保每个人对其项目副本都有相同配置的最佳方式。
2.6.2 安装 Express
为了从我们的微服务中流式传输视频,我们将将其作为一个 HTTP 服务器(也称为web 服务器)。也就是说,它将响应对浏览器的 HTTP 请求,在这种情况下,是浏览器请求播放流媒体视频。为了实现我们的 HTTP 服务器,我们将使用 Express。
注意,Express 是 Node.js 上构建 HTTP 服务器的既定标准框架。使用 Express 比使用低级 Node.js API 更容易做到这一点。
Express 是 Node.js 上构建 HTTP 服务器的最受欢迎的代码库。你可以在 Express 网站上找到它的文档和示例,网址为expressjs.com/。在那里,我鼓励你探索 Express 的许多其他功能。当然,我们可以在没有 Express 的情况下直接在 Node.js 上构建 HTTP 服务器,但 Express 允许我们在更高的抽象级别上以更少的代码、无需使用低级 Node.js API 所需的螺丝钉代码来完成这项工作。
使用 Express 也是一个很好的理由,让我们学习如何在微服务中安装 npm 包。npm 是 Node.js 的包管理器,它为我们提供了整个包的世界。这包括许多库和框架,如 Express,我们可以使用它们在编码时快速轻松地完成一系列工作。否则,我们可能需要编写更多的代码(并且在过程中可能引发大量错误)才能达到相同的效果。我们可以使用以下命令从终端安装 Express:npm install。
npm install --save express
运行此命令会将 express 包安装到我们的项目中。--save 参数会导致依赖项被添加到并跟踪在 package.json 文件中。请注意,--save 现实中并不是必需的。在 Node.js 的旧版本中,这是必需的;如今,这是默认设置。我明确地包含了 --save 以便突出其功能,但您实际上不再需要使用它。

图 2.7 注意 express 子目录安装到 node_modules 目录的位置。
您可以在图 2.7 和列表 2.2 中看到我们的包安装结果。图 2.7 显示,在 Node.js 项目的 node_modules 目录中创建了一个 express 子目录。您还会注意到,与 Express 一起安装了许多其他包。这些其他包是 Express 的依赖项,npm 已经为我们自动安装了它们。
列表 2.2 展示了安装 Express 后更新的 package.json 文件。与列表 2.1 的区别在于,我们现在有一个包含 Express 版本 4.17.1 的 dependencies 字段。这标识了我们的 Node.js 项目所依赖的 Express 版本。
还请注意,在列表 2.2 的标题中,有一个指向 GitHub 上 chapter-2 代码库中实际存在的文件的引用。这显示了您可以在哪里找到该文件的副本。在这种情况下,它是 chapter-2/example-1/package.json。如果您访问 chapter-2 仓库 (github.com/bootstrapping-microservices/chapter-2) 并在 example-1 子目录中查找,您将看到文件 package.json。这正是本代码列表中显示的文件。您可以通过将此链接放入您的网络浏览器中直接找到此文件:
github.com/bootstrapping-microservices/chapter-2/blob/master/example-1/package.json。
本书中的大多数列表都遵循此约定。它们显示了一个文件(或在这种情况下,一个完整版本)的片段,该文件是 GitHub 上一个工作示例项目的一部分。要查看此文件在上下文中的情况,您可以遵循其 GitHub 位置的引用或您本地克隆的代码库副本中的引用。
从那里,你可以要么检查代码在项目中的存在,要么(你应该)运行代码,因为这本书中的每个示例(在这种情况下,第二章的 example-1)都是一个可以轻松运行的项目,你可以自己运行它来巩固你所学的内容。
列表 2.2 安装了 Express 的包文件(chapter-2/example-1/package.json)
{
"name": "example-1",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "⁴.17.1" ①
}
}
① 当这本书编写时,我安装的 express 包的版本是 4.17.1。
通过 package.json 文件跟踪依赖项意味着你可以轻松地将你的项目和代码传递给其他程序员(例如你的队友),以便他们可以轻松地复制你的工作。这也意味着我可以将此代码提供给你,并且你可以轻松地让它运行起来。
例如,假设你想让 example-1 运行。首先,你需要像第 2.6.2 节中展示的那样克隆 chapter-2 代码仓库,然后从终端,切换到代码仓库目录:
cd chapter-2
现在切换到你想运行的特定示例目录。在这种情况下,是 example-1:
cd example-1
然后,你可以使用 npm 安装所有依赖项:
npm install
命令 npm install(不指定任何特定包)会安装 package.json 中列出的所有依赖项。在这种情况下,只列出了 Express,因此只安装了它(及其依赖项)。对于本书中的其他示例,可能会有更多的依赖项。但我们仍然只需要在每个示例中调用一次 npm install,这就足够安装运行每个示例项目所需的所有内容。
2.6.3 创建 Express 模板
在我们将视频流添加到我们的微服务之前,我们必须首先创建标准的 Express 模板 HTTP 服务器。列表 2.3 是官方 Express 入门指南(可在 expressjs.com/ 获取)中获得的传统 Hello World 示例。
这只是一小段代码,但这是我们项目需要的简单起点。你现在应该在 Node.js 项目中创建一个 index.js 文件,并输入这段代码。如果觉得这项工作太多,那么只需打开 chapter-2 仓库中的 example-1,检查你将找到的预配置的 index.js 文件。
列表 2.3 中的代码启动了一个网络服务器,尽管是最简单的网络服务器。它使用 Express 的 get 函数定义了一个路由处理程序,返回字符串 Hello World!。然后调用 listen 函数来启动这个 HTTP 服务器,监听端口 3000 上的 HTTP 请求。
列表 2.3 一个最小的 Express 网络服务器(chapter-2/example-1/index.js)
const express = require('express'); ①
const app = express(); ②
const port = 3000; ③
app.get('/', (req, res) => { ④
res.send('Hello World!'); ⑤
});
app.listen(port, () => { ⑥
console.log(`Example app listening
➥ on port ${port}!`); ⑦
});
① 加载 Express 库以在我们的代码中使用
② 创建一个 Express 应用实例
③ 我们的服务器将在端口 3000 上监听。
④ 创建主 HTTP 路由的处理程序
⑤ 处理程序在网页浏览器中打印 Hello World!
⑥ 启动 HTTP 服务器
⑦ 当服务器启动时,回调会打印一条消息。
我们将文件命名为 index.js;为什么是这个名字呢?这是 Node.js 应用程序主入口点的标准名称。它只是一个约定,所以叫 index.js。我们也可以很容易地将其命名为其他名称,比如 main.js 或 server.js。选择权在你。通过将其命名为 index.js,我们给它起了一个许多其他 Node.js 开发者会立即认出为 主 文件的名字。
端口号允许我们在同一台计算机上运行多个 HTTP 服务器。每个服务器都可以有自己的端口号,这样它们就不会相互冲突。选择端口 3000 是另一个约定。通常,我们会将 Node.js 应用程序设置为监听端口 3000,但在生产环境中,我们通常会将其设置为标准的 HTTP 端口 80。稍后,我们将看到如何将端口号设置为在微服务启动时提供的配置选项。
我们可以选择另一个端口,如果你已经在端口 3000 上运行了其他东西,你可能需要这样做。例如,如果你发现端口 3000 不适用于你,尝试将其更改为不同的数字,比如端口 4000。
我们将使用从 4000 开始的一系列端口号(4000、4001 等等),当我们同时运行多个微服务时。现在我们准备好运行这个超简单的 Web 服务器了。
index.js 是什么?
按照惯例,index.js 是 Node.js 应用的入口点 JavaScript 文件。在尝试理解现有的 Node.js 项目时,index.js 是你应该开始的地方。
2.6.4 运行我们的简单 Web 服务器
要测试我们的初出茅庐的 HTTP 服务器,我们将从终端运行它。首先,我们需要确保我们处于包含列表 2.3 中的 index.js 文件的同一目录。如果你是从头开始构建项目的,你将需要切换到你创建的目录。例如
cd my-new-project
否则,如果你正在使用第二章 GitHub 仓库中的代码,你应该切换到 example-1 目录:
cd chapter-2
cd example-1
现在你可以使用 Node.js 来运行 JavaScript 代码并启动 HTTP 服务器:
node index.js
我们在这里所做的是用 index.js 作为参数运行 Node.js。我们正在告诉 Node.js 运行我们的脚本文件。Node.js 执行该文件中的 JavaScript 代码,如果成功,我们将在终端看到以下输出:
Example app listening on port 3000!
现在我们可以测试一下这是否成功了。打开你的网页浏览器,将其指向 http://localhost:3000。你应该会看到显示的 Hello World 消息。
我们还可以使用 cURL,如果你在 Linux 或 MacOS(如果你有 Git Bash,Windows 也可以)上工作,那么你可能已经安装了它,作为测试 HTTP 端点的一种快速手段。在你的 HTTP 服务器已经在另一个终端运行的情况下,打开一个新的终端,并使用 cURL 来访问你的端点:
curl http://localhost:3000
你应该看到如下输出:
Hello World!
注意:使用 cURL 意味着你可以从命令行运行这样的快速测试,而无需打开你的网页浏览器。
我们现在有一个基本的 HTTP 服务器正在运行,是时候向其中添加流式视频了。当你准备好停止你的 HTTP 服务器时,回到它运行的终端并按 Ctrl-C 退出 Node.js 应用程序。
2.6.5 添加流式视频
在列表 2.3 中,我们只有一个返回“Hello World”的 HTTP 路由处理程序。现在我们将改变这一点,并为浏览器创建一个流式视频的 REST API。
REST API(通常只称为 API)是一种表示状态转移(REST)应用程序编程接口(API)。这个名字听起来很复杂,但实际上并不复杂。在 simplest sense,REST API 只是一组 HTTP 路由处理程序,它们与后端运行的系统和服务进行交互。
通常 REST API 的路由会返回数据,但我们将添加一个新的路由,返回流式视频。你可以在图 2.8 中看到它的样子。该图显示了我们的 HTTP 服务器如何从文件系统中读取视频,并通过端口 3000 和视频路由将其传递给网页浏览器。

图 2.8 网页浏览器通过视频路由与我们的微服务交互
我们定义了新的视频路由,如列表 2.4 所示。如果你正在跟随代码,你可以更新你之前创建的 Express 模板 HTTP 服务器。否则,你可以打开章节-2 存储库中的 example-2,在 VS Code 中查看更新的 index.js 的样子。
列表 2.4 从本地文件系统中读取视频并将其流式传输到浏览器。这是一个简单的起点,只做了我们需要的,那就是流式视频,这是我们的微服务应用 FlixTube 的核心功能。视频本身可以在 example-2 目录下的 videos 子目录中找到。在尝试运行此代码之前,请随意检查视频。我们将在这个示例视频中整本书进行测试,所以你会非常熟悉它!
列表 2.4 使用 Node.js 的简单流式视频服务器(chapter-2/example-2/index.js)
const express = require("express");
const fs = require("fs"); ①
const app = express();
const port = 3000;
app.get("/video", (req, res) => { ②
const path =
➥ "../videos/SampleVideo_1280x720_1mb.mp4"; ③
fs.stat(path, (err, stats) => { ④
if (err) { ⑤
console.error("An error occurred");
res.sendStatus(500);
return;
} ⑤
res.writeHead(200, { ⑥
"Content-Length": stats.size,
"Content-Type": "video/mp4",
}); ⑥
fs.createReadStream(path).pipe(res); ⑦
});
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}!`);
});
① 加载(内置)fs 库,以便我们可以使用 Node.js 文件系统 API。
② 定义流式视频的 HTTP 路由。这是一个流式视频的 REST API!
③ 我们将流式传输到浏览器的视频文件路径
④ 获取视频文件大小。我们将将其编码在 HTTP 头部作为对网页浏览器的响应。
⑤ 处理可能发生的任何错误
⑥ 向网页浏览器发送响应头,包括内容长度和 MIME 类型
⑦ 将视频流式传输到网页浏览器。是的,就这么简单!
列表 2.4 中的代码是 Node.js 流的一个示例。这是一个比我们在这里有时间深入探讨的更复杂的话题,但简单来说,我们在这里是从视频文件中打开一个可读流。然后我们将流通过管道传输到我们的 HTTP 响应(查找对 pipe 函数的调用)。
我们已经创建了一个通道,通过它可以将视频字节逐字节流送到浏览器。我们为视频流设置了这条管道,然后让 Node.js 和 Express 处理其余部分。Node.js 和 Express 使这变得很容易!要运行此代码,首先切换到 example-2 子目录:
cd chapter-2/example-2
然后安装依赖项:
npm install
现在以这种方式启动我们的流媒体视频微服务的第一个迭代:
node index.js
现在,我们可以将浏览器指向 http://localhost:3000/video 来观看视频。它看起来将与图 2.5 中显示的相似。
注意:在此阶段不要使用 cURL 进行测试;它与流媒体视频配合得不好。如果你这样做,它将在你的终端中打印出大量的垃圾信息。然而,当查看 JSON REST API 的输出时,cURL 非常有用,所以将其放在工具箱中是有利的。
为了测试本书的代码,我使用了 Chrome 网络浏览器。我发现这样的简单视频流在 Safari 网络浏览器下无法工作。有关如何在 Safari 中使视频流工作的详细信息,请参阅我在“The Data Wrangler”博客上的文章“mng.bz/l1Xd”。我们将在第八章中更多地讨论我们可以测试微服务的方法。
2.6.6 配置我们的微服务
在这一点上,花一点时间思考我们如何配置我们的微服务是值得的。这是一个重要的关注点,并将帮助我们更好地利用我们创建的微服务。在未来的章节中,我们将看到如何使用它们的配置将微服务连接起来的示例。然而,现在,让我们看看一个简单的例子,以展示如何配置一个微服务。
我们需要一种方法来配置我们的微服务,使其知道在启动 HTTP 服务器时使用哪个端口号。我们可以使用多种技术来配置微服务,例如配置文件和命令行参数。这些技术是有效的,但另一种技术已成为配置微服务的标准方式,并且得到了我们将使用的工具的良好支持。
我们将使用环境变量来配置我们的微服务。具体来说,在这种情况下,我们需要一个单独的环境变量来设置 HTTP 服务器的端口号。图 2.9 显示了我们将如何将 PORT 环境变量连接到我们的微服务。

图 2.9 使用 PORT 环境变量配置我们的微服务
在 Node.js 中使用环境变量配置我们的代码相当简单。我们只需访问 process.env 中相应命名的字段。您可以在列表 2.5 中看到它是如何工作的,我们的代码使用 process.env.PORT 来获取端口号的值。如果未提供 PORT 环境变量,代码会抛出错误。我喜欢添加这种错误检查,这样微服务就可以清楚地声明它期望的配置。这意味着我们不可能在不配置的情况下意外启动我们的微服务。如果我们这样做,微服务将拒绝启动,并且会告诉我们原因。
我认为微服务拒绝启动比仅仅因为忘记配置而操作可能错误的配置要好。微服务随后会向我们展示如何解决问题。这意味着我们不必在代码中四处寻找以解决问题。
列表 2.5 配置微服务(摘自第二章第 3 个示例的 index.js)
const express = require("express");
const fs = require("fs");
const app = express();
if (!process.env.PORT) { ①
throw new Error("Please specify the port number ①
➥ for the HTTP server with the environment variable PORT."); ①
} ①
const PORT = process.env.PORT; ②
// ... code omitted for brevity ...
app.listen(PORT, () => { ③
console.log(`Service listening on port ${PORT}!`);
});
① 当所需的 environment variable 未提供时抛出错误。如果没有指定,您也可以选择一个默认值。
② 将环境变量复制到全局变量以方便访问
③ 使用输入到微服务的端口号启动 HTTP 服务器
现在让我们运行这段代码:
cd example-3
npm install
node index.js
哎呀。我们忘记配置所需的 environment variable,我们的微服务已经抛出了错误。我们怎么这么快就忘记了应该配置的环境变量?没问题。错误日志方便地提供了一个有用的消息,告诉我们如何修复问题:
chapter-2\example-3\index.js:7
throw new Error("Please specify the port number for the HTTP server
➥ with the environment variable PORT.");
^
Error: Please specify the port number for the HTTP server
➥ with the environment variable PORT.
at Object.<anonymous> (chapter-2\example-3\index.js:7:11)
现在我们必须在再次尝试运行代码之前设置 PORT 环境变量。在 Linux 和 MacOS 上,我们将使用以下命令来设置它:
export PORT=3000
如果在 Windows 上工作,请这样做代替:
set PORT=3000
再次运行文件:
node index.js
现在应该可以正常工作了。我们设置了 PORT 环境变量,这样微服务就知道它应该使用哪个端口号来运行其 HTTP 服务器。为了测试这一点,我们可以将浏览器指向 http://localhost:3000/video。我们应该看到我们的视频像之前一样播放。
现在我们可以为 HTTP 服务器配置端口,因此我们可以在我们的开发工作站上直接轻松启动多个独立的微服务。只有当它们具有不同的端口号时,我们才能这样做。因为我们可以设置端口号,所以我们很容易使用不同的端口启动每个微服务。
通过环境变量配置我们的微服务非常重要,并且这是我们将在未来的章节中再次使用的方法。例如,当我们向应用程序添加数据库(第四章)以及将我们的微服务连接到消息队列服务器(第五章)时,我们将需要它。
我们还可以使用环境变量将秘密和敏感数据传递给微服务(例如,我们数据库的密码)。我们需要小心处理这些信息,并且不应该将其存储在任何人都可以看到的代码中。在第十一章中,我们将讨论管理敏感配置(如密码和 API 密钥)的重要问题。
2.6.7 生产环境设置
到目前为止,我们已经设置了我们的微服务在我们的开发工作站上运行。这很好,但在我们进入有趣的部分(Docker、Kubernetes 和 Terraform)之前,我们需要知道如何设置我们的微服务以在生产环境中运行。
当我说 生产环境 时,你可能想知道我在说什么。生产环境简单地说就是我们的 面向客户 的环境。那就是我们的应用程序托管的地方,以便我们的客户可以访问它。对于这本书,我们的生产环境是 Kubernetes,我们正在准备在我们的 Kubernetes 集群中运行我们的应用程序,使其公开可访问。
我已经说过,为了使现有的 Node.js 项目准备好运行,你必须首先安装依赖项,如下所示:
npm install
好吧,为了使我们的微服务准备好在生产环境中运行,我们需要使用这个命令的稍作不同的版本:
npm install --only=production
我们添加了参数 --only=production 来安装仅在生产环境中需要的依赖项。这很重要,因为当我们创建 Node.js 项目时,我们通常会有一些所谓的 开发依赖项,我们只需要在开发中使用,我们不想将这些安装到我们的生产环境中。你还没有看到开发依赖项的示例,但在下一节中你将看到。到目前为止,我们像这样在我们的开发工作站上运行我们的 HTTP 服务器:
node index.js
这是可以的,但我们希望使用以下惯例来运行它:
npm start
运行命令 npm start 是启动 Node.js 应用程序的常规方法。这是我们用来启动应用程序的 npm 脚本的一个特例。在列表 2.6 中,你可以看到我们已经更新了 package.json 文件,在scripts字段下添加了一个 start 脚本。这仅仅是通过index.js作为参数运行 Node.js。
没有惊喜,但这个惯例的好处是,对于几乎任何 Node.js 项目(至少是遵循这个惯例的项目),你可以运行 npm start,而无需知道主文件是否名为 index.js 或是否有其他名称。你也不需要知道应用程序是否接受任何特殊的命令行参数,因为这些也可以在这里记录。
这为你提供了一个无论你查看哪个项目以及特定应用程序如何启动都要记住的命令。这使得理解如何使用任何 Node.js 项目变得容易得多,即使是其他人创建的项目。
列表 2.6 向 package.json 添加 start 脚本(第二章/示例-1/package.json)
{
"name": "example-1",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js" ①
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "⁴.17.1"
}
}
① 将 npm start 脚本添加到 package.json 中,让我们可以用“npm start”运行此项目。
尝试一下。你会在 2.6 列表中注意到,我已经更新了 example-3 的 package.json 文件,以包含一个 npm start 脚本。要尝试它,请将你的目录更改为 example-3,并运行npm start(确保你先运行npm install来获取依赖项,或者如果你只想获取生产依赖项,则执行npm install --only=production)。
从现在起,在这本书中,我们将使用npm start来在生产环境中运行我们的每个微服务。将来,我会将这称为以生产模式运行我们的微服务。记住这个命令是值得的,因为你在野外遇到的许多其他 Node.js 应用程序都遵循这个约定,这是一个你可以记住的快捷方式,有助于你使其他人的代码工作。
我们将使用刚刚学到的命令来使我们的微服务在生产环境中运行。这些命令将在第三章中用来在我们的 Docker 中运行微服务,所以到时候我们会回到这些命令。
另一个你可能听说过的有用命令是npm test。这是 Node.js 项目传统上用来启动自动化测试的命令。我们将在第八章中回到并调查这个问题。
2.6.8 实时重新加载以实现快速迭代
现在我们有了方便的方式来设置和运行我们的微服务在生产环境中,我们也可以寻找更好的方法来在开发中运行它。在我们编辑代码时实时重新加载我们的代码有助于简化我们的开发工作流程并促进生产力。当我们更改代码时,我们可以立即看到代码执行的结果。无论结果是错误还是成功的输出都不重要。重要的是我们得到了快速反馈,这缩短了我们的迭代周期,并加快了我们的开发速度。
在本节中,我们将为实时重新加载做好准备。这种工作方式如图 2.10 所示,它非常重要,因为它自动化了我们的微服务(在开发过程中的)重启。这有助于我们更快地通过个人编码迭代,看到即时结果,并提高生产力。迭代和快速反馈是我开发哲学中的关键,正如我在第 2.4 节中指出的。(实时重新加载也与测试驱动开发(TDD)很好地配合,我们将在第八章中讨论。)

图 2.10 设置实时重新加载有助于我们提高生产力。
要创建我们的实时重新加载管道,我们将安装一个名为 nodemon 的包。图 2.10 显示了它是如何工作的。我们使用 nodemon 来运行我们的微服务,并且它自动监视我们项目中的代码更改。当检测到代码更改时,nodemon 会自动为我们重启微服务,节省我们手动重启的麻烦。
这可能听起来并没有做什么,但我发现它使得开发周期变得快速而流畅。一旦你尝试过它,你可能会 wonder 你最初是如何没有它也能做到的。我们可以在 Node.js 项目中按照以下方式安装 nodemon:
npm install --save-dev nodemon
注意这次我们使用了 --save-dev 参数。这使得 npm 将此包作为 dev dependency 而不是 normal dependency。我在上一节讨论仅在生产环境中运行的生产只依赖项安装时提到了这一点。在这里,你可以看到为什么安装一个你希望在开发中拥有但排除在生产中的依赖项是有用的。
我们在开发期间使用 nodemon,但在生产环境中不需要安装它,在那里,它至多是无用的冗余,在最坏的情况下,可能还存在安全风险。我并没有理由相信 nodemon 特定有任何安全问题。但一般来说,我们在生产环境中安装的越少,越好。这是一个我们将在第十一章讨论安全问题时再次讨论的话题。
这意味着当我们运行 npm install --only=production 时,我们将排除帮助我们进行开发的包,如 nodemon。通常,当我们运行 Node.js 代码时,我们这样做:
node index.js
现在我们将使用 nodemon,我们将用 nodemon 替换 node 并这样运行它:
npx nodemon index.js
这是什么突然出现的 npx 命令?这是一个随 Node.js 一起提供的有用命令,它允许我们从命令行运行已安装的依赖项。在 npx 被添加到 Node.js 之前,我们通常全局安装模块,如 nodemon。现在我们可以直接从当前项目的依赖中运行这样的工具。这真有助于我们使用模块的正确版本,并防止我们的系统因全局安装的模块而变得杂乱无章。
停止在 nodemon 下运行的微服务与在 Node.js 下运行时相同。只需在运行它的终端中键入 Ctrl-C,微服务就会停止。
我通常喜欢将 nodemon 包裹在一个名为 start:dev 的 npm 脚本中。这是我的个人约定,但我发现许多其他开发者也有类似的做法,通常只是名字不同。你可以在列表 2.7 中看到我们更新后的项目设置。在 package.json 的底部,nodemon 已被添加为 devDependency,你可以在 scripts 部分看到我们新的脚本,start:dev。
列表 2.7 为开发添加启动脚本(chapter-2/example-3/package.json)
{
"name": "example-3",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js", ①
"start:dev": "nodemon index.js" ②
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "⁴.17.1"
},
"devDependencies": { ③
"nodemon": "².0.4" ④
} ③
}
① 正常的启动脚本在生产和测试环境中启动服务。
② 我们新的 start:dev 脚本在开发环境中启动服务。
③ 开发依赖项在这里;这些是在生产环境中未安装的依赖项。
④ 我们刚刚添加的 nodemon 包的新依赖项
在上一节中,你学习了使用npm start的约定。我们配置了我们的项目,以便我们可以这样在生产模式下运行我们的代码:
npm start
现在我们已经定义了start:dev命令,我们可以这样在开发模式下运行我们的微服务:
npm run start:dev
注意使用npm run来运行我们的新脚本。我们可以使用npm run来运行我们添加到 package.json 文件中的任何 npm 脚本。对于npm start和npm test(我们将在第八章中学习),我们可以省略run部分,因为 npm 对这些特定的约定有特殊支持。
现在这告诉你,这个start:dev脚本不是像start和test那样的 Node.js 约定。这就是为什么我们必须特别使用npm run命令来调用它。使用start:dev来在开发模式下运行只是我个人的约定。不过,我们将在整本书中使用它,我相信你也会在自己的开发过程中发现它很有用。
在这些命令就绪后,我们可以在生产模式或开发模式下运行我们的微服务。区分这一点很重要,这样我们就可以分别满足每种模式的不同需求。
在开发模式下,我们希望优化以实现快速迭代和生产力。相反,在生产模式下,我们希望优化性能和安全。这些需求相互矛盾;因此,这些需求必须分别处理。当我们的应用程序接近生产部署时,你将在第六章和第七章中再次看到这一点,这一点变得很重要。
注意:本书中即将出现的所有微服务都遵循我们在前两节中规定的约定。
2.6.9 运行本章完成的代码
如果你到了这一步还没有尝试本章的代码,现在就是时候了。这里有一个快速总结,以展示运行本章示例是多么简单。获取第二章代码的本地副本,无论是下载它还是从 GitHub 克隆第二章存储库。
-
要查看流媒体视频,你想尝试 example-2。
-
要查看使用环境变量配置微服务的示例,请尝试 example-3。
例如,假设你想尝试 example-3。打开终端并切换到适当的子目录:
cd chapter-2/example-3
现在安装依赖项:
npm install
如果你想要模拟生产部署,你可以这样做:
npm install --only=production
现在要像在生产环境中运行一样运行它,请输入
npm start
或者,要使用实时重载快速开发,你可以输入这个:
npm run start:dev
这些是你需要记住的主要命令,以便在本书中运行任何 Node.js 示例。将此页标记为书签,并在需要记住如何做的时候跳回它。
2.7 Node.js 复习
在我们继续之前,我们有时间快速复习一下本章学到的所有 Node.js 命令。表 2.3 列出了这些命令。
表 2.3 Node.js 命令复习(续)
| 命令 | 描述 |
|---|---|
node --version |
检查 Node.js 是否已安装;打印版本号。 |
npm init -y |
创建一个默认的 Node.js 项目,其中包含我们的 package.json 的占位符,该文件跟踪我们的 Node.js 项目的元数据和依赖项。 |
npm install --save ➥ <package-name> |
安装一个 npm 包。npm 上有许多其他包可供选择,你可以通过插入特定的包名来安装任何包。 |
npm install |
安装 Node.js 项目的所有依赖项。这也安装了所有在 package.json 中之前记录的包。 |
node <script-file> |
运行一个 Node.js 脚本文件。我们调用 node 命令,并给它提供我们的脚本文件名作为参数。如果你想,你可以将你的脚本命名为 main.js 或 server.js,但最好遵守约定,只将其命名为 index.js。 |
npm start |
不论主脚本文件的名称是什么或它期望的命令行参数是什么,都是传统的 npm 脚本用于启动 Node.js 应用程序。通常这会转换为 package.json 文件中的 node index.js,但它取决于项目的作者以及他们如何设置它。好事是,无论特定项目结构如何,你只需要记住 npm start。 |
npm run start:dev |
我个人用于启动开发中 Node.js 项目的约定。我将此添加到 package.json 中的脚本中。通常,它会运行类似 nodemon 的东西,以便在你工作时实时重新加载你的代码。 |
2.8 继续学习
本章是关于使用 Node.js 构建基本 HTTP 服务器的快速介绍。不幸的是,我们只是触及了表面。但本书不是关于 Node.js 的;那只是我们用来到达微服务之地的交通工具。然而,我确实有一些参考资料供你学习,如果你希望深入了解并提高你在 Node.js 和 Git 方面的专业知识:
-
《Getting MEAN with Mongo, Express, Angular, and Node》,第 2 版,由 Simon Holmes 和 Clive Harber 著(Manning,2019)
-
《Node.js in Practice》由 Alex R. Young 和 Marc Harter 著(Manning,2014)
-
《Node.js in Action》,第 2 版,由 Alex R. Young、Bradley Meck 和 Mike Cantelon 著(Manning,2017)
-
《Learn Git in a Month of Lunches》由 Rick Umali 著(Manning,2015)
此外,你还可以查看在线上可找到的广泛的 Node.js 文档,网址为 nodejs.org/en/docs/。
接下来,我们将转向打包和发布我们的微服务,使其准备好部署到云端。为此,我们将使用 Docker,这是一个在我们的行业中变得无处不在且不可或缺的工具。Docker 使微服务更加易于访问,并且对我们的软件构建和部署方式产生了革命性的影响。
摘要
-
我们讨论了开发哲学:迭代,保持其工作状态,从简单开始。
-
我们为在一个单一微服务上工作建立了我们的开发环境。
-
你学习了如何创建一个新的 Node.js 项目。
-
我们创建了一个简单的 HTTP 服务器。
-
我们为服务器添加了视频流功能。
-
我们为生产环境设置了我们的项目。
-
我们在开发中使用代码的实时重载来快速迭代。
3 发布你的第一个微服务
本章涵盖
-
学习 Docker 镜像和容器之间的区别
-
在你的开发环境中使用 Docker
-
将你的微服务打包成 Docker 镜像
-
创建一个私有 Docker 注册库
-
将你的微服务发布到你的 Docker 注册库
-
在 Docker 容器中实例化你的微服务
到本书结束时,我们将将多个微服务部署到我们的生产环境:一个 Kubernetes 集群。但在我们可以部署整个微服务应用程序之前,我们必须首先能够打包和发布单个微服务!在本章中,我们将把我们在第二章中创建的视频流微服务发布出来,以便它可以部署到我们的集群中。
为了将微服务部署到在云中运行的集群,我们必须将其发布到某个可访问的地方。为了实现这一点,我们必须首先将我们的代码、资源和依赖项打包成一个单一包。然后,我们需要在云中找到一个位置来托管这个包。为此,我们将创建一个容器注册库。如果你还没有听说过容器,这将在不久后解释。
在本书中,我们希望模拟为一家私营公司构建专有应用程序的过程。安全和隐私非常重要,这就是为什么我们将创建一个私有容器注册库而不是公共注册库。我们将在 Azure 上手动创建这个容器注册库,但在第六章中,我们将学习如何使用代码构建我们的注册库。
在本章结束时,我们将测试我们能否直接从远程容器注册库实例化我们发布的微服务。这允许我们在我们的开发工作站(或个人电脑)上测试我们发布的微服务。
3.1 新工具
本章介绍了一个重要的新工具:Docker。在本章中,我们奠定了一些必要的基础。这是因为从现在开始,我们将广泛使用 Docker,你需要具备一些基本技能才能理解它是如何工作的。这将有助于你在出现问题时进行故障排除。
表 3.1 本章介绍的工具
| 工具 | 版本 | 目的 |
|---|---|---|
| Docker | 19.03.12 | 我们使用 Docker 来打包、发布和测试我们的微服务。 |
Docker 在 Linux、MacOS 和 Windows 10 上运行。如果你在 Windows 10 家庭版上工作,你首先需要安装 WSL2(Windows 集成的 Linux 内核),并在 3.7.1 节中查看下载链接。
3.2 获取代码
本章只有一个示例项目,它基于第二章的示例 2。它是我们在那一章中创建的视频流微服务。为了在本章中跟随,你需要下载代码或克隆存储库。
-
你可以从这里下载代码的 zip 文件:
-
你可以使用 Git 像这样克隆代码:
git clone https://github.com/bootstrapping-microservices/chapter-3.git
如需安装和使用 Git 的帮助,请参阅第二章。如果您在代码方面遇到问题,请在 GitHub 上的存储库中记录一个问题。
3.3 什么是容器?
简而言之,容器(正如其名称所暗示的)是包含其他东西的东西。它包含什么?在这种情况下,我们将用它来包含(或托管)微服务。
定义 一个 容器 是虚拟化服务器的一种方式。
更正式地说,容器提供了一种虚拟化操作系统和硬件的方法。这使我们能够抽象(或虚拟化)微服务所需资源。容器提供了一种在单台计算机上划分资源的方法,以便我们可以将这些资源在许多此类服务之间共享。容器是帮助使运行微服务具有成本效益的现代技术之一。
容器通常与虚拟机(VM)进行比较。虚拟机和容器都允许我们将微服务隔离,以防止它们相互干扰。在容器被发明之前,我们在虚拟机上运行我们的服务,实际上,在适当的情况下,我们仍然可以选择这样做。图 3.1 比较了虚拟机和容器,以便您可以可视化它们之间的差异。

图 3.1 比较虚拟机和容器
正如您在图 3.1 中所看到的,虚拟机比容器更重量级。虚拟机包含其操作系统在完全虚拟化硬件上运行的完整副本。另一方面,容器虚拟化了操作系统以及硬件。因此,容器更小,工作更少,这使得我们能够更有效地使用计算资源。
最终,我们将在我们的 Kubernetes 集群上运行许多容器。但到目前为止,我们的目标是实例化一个容器来托管我们在上一章中创建的视频流微服务。
3.4 什么是镜像?
镜像是对某物的快照。单词“镜像”在许多不同的场景中使用。我们可能正在谈论一张照片,或者我们可能正在谈论虚拟机的硬盘快照。在这本书中,我们谈论的是 Docker 镜像。
定义 一个 镜像 是一个服务器的可启动快照(在我们的情况下,是一个微服务),包括它运行所需的所有代码、依赖项和资产。
在本章的示例中,我们创建了一个视频流微服务的快照。镜像是不可变的,这意味着一旦生成,就无法修改。这是需要知道的重要事情。我们可能已经对一个镜像应用了测试或安全检查,因为我们知道镜像不能被篡改,我们知道我们的测试和安全检查将保持有效。
你可以将镜像视为微服务的休眠版本,是它在运行之前存储的一种方式。它处于等待启动为容器的状态,准备在我们需要将其实例化到应用程序中时使用。
图 3.2 展示了如何从一个镜像启动容器。镜像本身包含创建容器所需的一切:微服务的代码、其依赖项以及微服务执行其工作所需的其他资产和资源。

图 3.2 要在云中运行我们的微服务,我们将在容器中实例化其 Docker 镜像。
很快,我们将为我们的微服务构建一个镜像,并以容器的方式运行它。在那之前,让我们更多地了解 Docker。
3.5 为什么选择 Docker?
你肯定已经听说过 Docker 了?这可能是你购买这本书的原因之一。几乎每个构建基于云的应用程序的人都在使用 Docker 或想要使用它。让我们看看为什么这是这样。
Docker 在软件行业中几乎是无处不在。虽然有一些 Docker 的替代品,但作为打包和部署容器的技术,Docker 已经吸引了主流的关注。它广为人知,并且得到了良好的支持。
Docker 甚至在其他领域也取得了进展。例如,我听说有人使用 Docker 将应用程序部署到物联网设备上。它完成了我们需要的任务。但它究竟为我们做了什么?
Docker 是我们用来打包和发布微服务的工具。尽管你可以从 Docker 中学到很多东西,但我们将学习我们完成这项任务所需的最少知识。在本章末尾,我将提供一些参考资料,供你深入研究,更广泛地了解 Docker。
我喜欢将 Docker 视为通用包管理器:统治所有包管理器的那个!通常,你不会这样考虑 Docker,但如果你仔细思考,这确实有些道理。包管理器这部分相当明显;我们使用 Docker 来打包和发布我们的工作。我说它是通用的,因为它支持许多不同的技术栈。Docker 是开源的,你可以在以下位置找到 CLI 工具的代码:
你可以在以下位置看到 Docker 制造商的其他开源项目:
www.docker.com/community/open-source
标准化你的环境
Docker 也非常适合标准化你的环境,确保所有开发者运行的是相同的发展环境。这反过来又意味着与生产环境相同。它最大化了代码在开发环境中工作,同时在生产环境中也能工作的概率,这给了开发者更好的机会在代码到达客户之前发现问题。
3.6 我们用 Docker 做什么?
让我们分解这个问题。我们将使用 Docker 来
-
将我们的微服务打包成 Docker 镜像
-
将我们的镜像发布到我们的私有容器注册库
-
在容器中运行我们的微服务
最后一个要点最为重要。我们希望微服务在我们的生产环境中运行,但我们只能在我们首先打包并发布它之后做到这一点。
我们还没有准备好将微服务部署到生产环境,因此我们将专注于学习在开发工作站上打包、发布和测试镜像所需的 Docker 命令。
图 3.3 展示了我们需要在这里完成的一般步骤。我们将使用视频流微服务的 Node.js 项目(图 3.3 左侧),将其打包成 Docker 镜像,然后发布到我们的私有容器注册库。从那里,我们可以将微服务部署到我们的 Kubernetes 集群;尽管如此,这项工作我们将留到第七章进行。

图 3.3 在本章中,我们将学习如何将 Docker 镜像发布到云中的私有容器注册库。
3.7 使用 Docker 扩展我们的开发环境
在我们能够使用 Docker 之前,我们必须升级我们的开发环境。为了跟随本章内容,您需要在您的电脑上安装 Docker。在本节中,我们将安装 Docker 并确保其准备就绪。
图 3.4 展示了安装 Docker 后我们的开发环境将看起来是什么样子。尽管您可以看到我们将运行 Node.js 微服务,但您并不总是需要以这种方式运行微服务。然而,当您测试单个微服务时,您只需像我们在第二章中做的那样直接在宿主操作系统上运行它即可。

图 3.4 将我们的开发环境扩展到在容器中运行微服务
由于我们需要能够使用 Docker 打包我们的微服务,因此能够在发布前后本地测试它是很有用的。对于在 Kubernetes 上表现不佳的任何微服务,测试能力将非常有用。我们将在第十章中进一步讨论这一点。
3.7.1 安装 Docker
要安装 Docker,请访问 Docker 网站 docs.docker.com。到达网站后,找到下载/安装链接,按照说明安装适用于您平台的 Docker。表 3.2 提供了安装 Docker 的具体细节。
如果您使用的是 Windows 10,请注意,对于 Home 版本与 Pro/Enterprise 版本安装 Docker 的说明是分开的。在 Windows 10 Home 版本上,您需要在安装 Docker 之前安装 WSL2(Windows 集成的 Linux 内核)。按照表 3.2 中的说明进行操作。
表 3.2 Docker 支持的平台
| 平台 | 描述 |
|---|---|
| Linux/MacOS/Windows 10 Pro/Enterprise | 请访问 Docker 网站 docs.docker.com。点击下载/安装链接,按照说明在您的系统上安装 Docker。 |
| Windows 10 Home | 在您安装和使用 Docker 之前,必须安装 WSL2。要安装 WSL2,请按照以下说明操作:docs.microsoft.com/en-us/windows/wsl/install-win10 安装 WSL2 后,您现在可以按照以下说明安装 Docker:docs.docker.com/docker-for-windows/install-windows-home/ 您还可以使用虚拟机在 Windows 10 Home 版上运行 Docker,如以下侧边栏中所述。 |
3.7.2 检查您的 Docker 安装
一旦您安装了 Docker,您可以使用终端通过打印版本来检查它是否正常:
docker --version
如果您安装的版本与我安装的版本相同(截至本文写作时),输出将如下所示:
Docker version 19.03.12, build 48a66213fe
如果您使用的是 Docker 的较新版本,请不要担心。它很可能与旧版本向后兼容。
在虚拟机(VM)下运行 Docker
您可能已经注意到,第三章的仓库(github.com/bootstrapping-microservices/chapter-3)包含一个 Vagrantfile。这是一个 Vagrant 脚本,它会启动一个预先配置的、自动安装了 Docker 的 Ubuntu Linux 虚拟机(VM)。要使用它,您必须首先安装 Vagrant 和 VirtualBox。
这是一种方便的方式来启动一个即时且可丢弃的开发环境。嗯,它并不完全即时,但通过调用vagrant up来构建用于开发的 VM 比手动创建要快得多。我说它是可丢弃的,因为调用vagrant destroy会删除 VM,并将您的开发工作站保持在一个干净的状态。这使得 Vagrant 成为尝试新软件(如 Docker)的好方法,而不会使您的电脑变得杂乱。
书中其他章节的每个代码仓库也包含一个 Vagrantfile。如果您想按照这种方式跟随示例,您可以方便地创建一个 VM 来尝试这本书中的示例。要了解更多关于 Vagrant 的信息,请参阅附录 A 或访问 Vagrant 网站:
3.8 打包我们的微服务
现在我们已经安装了 Docker,我们可以开始考虑使用它来打包我们的微服务以进行部署。最终,我们希望将微服务部署到生产环境中。但首先,我们需要将所有内容打包并准备好发货。我们将按照以下步骤打包我们的微服务:
-
为我们的微服务创建 Dockerfile
-
将我们的微服务打包成 Docker 镜像
-
通过作为容器启动已发布的镜像来测试它
3.8.1 创建 Dockerfile
对于我们想要创建的每个 Docker 镜像,我们必须创建一个 Dockerfile。Dockerfile是 Docker 创建的镜像的规范。我喜欢将 Dockerfile 想象成一个包含构建镜像指令的脚本文件。您可以在图 3.5 中看到这一点的说明。
Dockerfile 中的行定义了我们的微服务、其依赖项以及任何支持资产。Dockerfile 中的不同行会导致不同的文件被复制到镜像中。对于 Dockerfile,我们将添加复制我们的 Node.js 项目和安装我们的 npm 依赖项的指令。
还请注意图 3.5 中,我们将一个示例视频复制到了我们的镜像中。将视频烘焙到镜像中并不是我们在最终生产版本中想要做的事情,但在本例中它是有用的——我们还没有其他方式来存储这个视频。

图 3.5 Dockerfile 是一个脚本,它指定了如何构建我们的 Docker 镜像。
只有一个视频会让视频流应用显得相当无聊,但解决这个问题将留到第四章。现在,这实际上是一个很好的例子,表明我们不仅可以包含代码到我们的镜像中。包含其他类型的资产对 Docker 来说没有任何问题!
列表 3.1 显示了我们的视频流微服务的 Dockerfile。它并不复杂,是 Node.js 应用程序 Dockerfile 的一个好例子。请阅读并尝试想象每一行是如何添加到最终镜像中的。
列表 3.1 为我们的视频流微服务(chapter-3/example-1/Dockerfile)的 Dockerfile
FROM node:12.18.1-alpine ①
WORKDIR /usr/src/app ②
COPY package*.json ./ ③
RUN npm install --only=production ④
COPY ./src ./src ⑤
COPY ./videos ./videos ⑥
CMD npm start ⑦
① 为我们的新镜像设置基础镜像。这允许我们基于现有镜像生成新镜像。
② 设置镜像中的目录。其他路径相对于此。
③ 将 Node.js 的 package.json 文件复制到镜像中
④ 使用 npm 仅安装生产依赖项
⑤ 复制我们的微服务的源代码
⑥ 复制我们的示例视频
⑦ 使用“npm start”约定启动微服务(参见上一章)
在列表 3.1 中,第一行包含了FROM指令。这指定了我们新镜像的基础镜像。通过说我们的基础镜像为 node:12.18.1-alpine,我们是在声明我们的衍生镜像应包含 Node.js 版本 12.18.1。(如果你想知道 alpine 是什么意思,请参见以下边栏。)
如果你正在使用除 JavaScript 和 Node.js 之外的语言或框架,那么你将选择不同的基础镜像。选择一个适合你自己的技术栈的镜像。
能够选择基础镜像非常实用。我们可以选择使用 Docker Hub 上可用的众多公共镜像之一(hub.docker.com),或者甚至创建我们自己的自定义基础镜像。这意味着我们可以重用现有镜像,到本书结束时,我们还将看到几个重用第三方镜像的示例。
在列表 3.1 中还有各种包含COPY指令的行。这些行将文件复制到我们的镜像中。你可以看到 package.json、我们的代码和示例视频都被复制到了镜像中。
RUN指令也值得注意。你可以在构建过程中在镜像内运行软件,以更改镜像、安装依赖项和执行其他设置任务。在这个例子中,我们使用RUN来安装我们的 npm 依赖项并将它们嵌入到镜像中。
列表 3.1 中最后且最重要的一行是CMD指令。这个指令设置在容器实例化时调用的命令。这就是我们告诉它使用我们在第二章中添加到 package.json 文件的 npm start 脚本来运行我们的 Node.js 应用程序的方式。关于这一点,可以重新阅读 2.6.7 节。
Alpine 与非 Alpine:第一部分
当你在镜像的名称中看到“alpine”(例如,node:12.18.1-alpine)时,这表明该镜像基于 Alpine Linux。Alpine 是一个轻量级的 Linux 发行版,只包含最基本的东西,因此它比常规发行版小得多。由于尺寸小,Alpine 镜像非常适合生产,因为它能更好地利用你的基础设施和云资源。
3.8.2 打包和检查我们的 Docker 镜像
现在我们已经创建了 Dockerfile,我们可以将我们的微服务打包成一个可运行的镜像。我们将使用docker build命令来构建这个镜像。它以输入我们的 Dockerfile,其中包含构建镜像的指令。图 3.6 显示了这一过程。

图 3.6 docker build命令根据我们的 Dockerfile 生成 Docker 镜像。
注意:在我们可以将微服务部署到生产之前,我们必须能够将其打包成 Docker 镜像。
现在到了有趣的部分。是时候从我们的微服务中创建一个镜像了。为了跟上进度,你需要一个像列表 3.1 中显示的 Dockerfile,以及一个 Node.js 项目。你可以自己创建一个,或者使用 GitHub 上第三章代码仓库中的 example-1(见 3.1 节)。
当你准备好了,打开一个终端并切换到 chapter-3/example-1 目录(或者你存放代码和 Dockerfile 的任何目录)。现在按照以下方式调用docker build:

当你运行这段代码时,你会看到基础镜像的各个部分正在下载。这个下载只会在第一次发生;随后,你已经在你的工作站上缓存了基础镜像。它不会再次下载(至少在我们稍后在 3.9.3 节中删除所有本地镜像之前不会下载)。一旦完成,你应该在输出的末尾看到类似以下内容:
Successfully built 9c475d6b1dc8
Successfully tagged video-streaming:latest
这表明镜像已成功构建。它还提供了你镜像的唯一 ID,并显示了为它设置的标签。
注意:当你自己调用此命令时,你会看到不同的输出,因为分配给你的镜像的 ID 将不同于分配给我的镜像的 ID。
因为它是一个唯一的 ID,所以它将随着你创建的每个新图像而不同。如果你想的话,你可以记下这个 ID,并在未来的 Docker 命令中使用它来引用图像。然而,你实际上并不需要这样做,因为我们已经用有意义的名称(video-streaming)标记了它。我们可以使用这个名称而不是 ID。
注意输出中版本被自动设置为latest,因为我们没有为它指定任何内容。在第七章中,我们将自动设置这个版本作为我们持续交付过程的一部分。这将区分我们随着代码迭代更新和构建新图像而产生的每个新版本。以下是一些其他需要注意的点:
-
The
-targument 允许我们标记或命名我们的图像。 你会想要这样做;否则,你将不得不通过其唯一的 ID 来引用你的图像。它是一长串难看的数字(正如你在前面的输出中看到的),所以这不是最佳选择。 -
The
--fileargument 指定要使用的 Dockerfile 的名称。 从技术上讲,这是不必要的,因为默认情况下它总是命名为 Dockerfile。我明确地包括这一点是为了让你知道,我们将在第五章中利用它。在第五章中,我们将分离我们的 Dockerfile,以便在开发和生产中有不同的版本。 -
不要忘记句号! 很容易忽略。它告诉
build命令针对当前目录操作。这意味着 Dockerfile 中的任何指令都是相对于当前工作目录的。更改此目录使得将 Dockerfile 存储在项目资产的不同目录中成为可能。这在某些时候可能很有用,但不是我们现在需要的特性。
这是构建你自己的图像时docker build命令的一般格式:
docker build -t <your-name-for-the-image> --file <path-to-your-Dockerfile>
➥ <path-to-project>
你可以将你的微服务的特定名称作为图像名称、其 Dockerfile 的路径以及其项目文件夹的路径插入其中。构建我们的图像后,我们现在应该检查它以确保一切正常。我们可以使用以下命令列出我们的本地图像:
docker image list
这列出了我们本地工作站上的图像。如果上一节中的docker build命令成功完成,我们现在可以看到至少列出两个图像:
REPOSITORY TAG IMAGE ID CREATED SIZE
video-streaming latest 9c475d6b1dc8 33 seconds ago 74.3MB
node 12.18.1-alpine 072459fe4d8a 6 months ago 70.7MB
如果你已经使用 Docker 在本地创建其他图像,或者如果你已经探索了 Docker Hub 上许多公开可用的图像,你可能会在这个列表中看到其他图像(参见标题为“探索其他容器”的侧边栏)。
注意前面的输出中的列。在REPOSITORY列下,你可以看到video-streaming和node,其中video-streaming是我们刚刚创建的微服务的图像,而node是我们第 3.1 列表中引用的基础图像。
TAG是下一列,通常显示镜像的版本号。因为我们没有为我们的视频流镜像指定版本,所以它自动分配了版本latest。
下一个列是IMAGE ID,显示了每个镜像的唯一 ID。请注意,我们的视频流图像的 ID 与build命令的输出中的 ID 相同。再次强调,您镜像的唯一 ID 可能与这里看到的不同。输出中的其他列包括CREATED,它告诉您镜像是在何时创建的,以及SIZE,它显示了镜像的大小。
Alpine 与非 Alpine:第二部分
在本节中,您可以在docker image list的输出中看到我们的视频流图像大小为 74.3 MB。这个大小是由于我们选择了 Alpine 图像作为我们的基础镜像。
想知道如果我们使用非 Alpine 镜像的大小是多少吗?嗯,非 Alpine 镜像的重量达到了惊人的 902 MB。这是大小的 10 倍以上!您可以清楚地看到为什么我们想在生产中使用 Alpine 镜像。
3.8.3 在容器中启动我们的微服务
在我们发布新创建的 Docker 镜像之前,我们应该在我们的开发工作站上对其进行测试运行,以确保一切正常工作。一旦我们将微服务打包成 Docker 镜像,我们就可以使用docker run命令将其实例化为容器,如图 3.7 所示。这将在我们的开发工作站上创建一个我们的视频流微服务的实例,然后我们可以使用网页浏览器对其进行测试。

图 3.7 docker run命令生成了我们的微服务在容器中运行的实例。
当您准备好时,打开一个终端并调用以下命令来从镜像实例化您的微服务:

作为输出,您应该看到打印出的容器的唯一 ID。以下是我调用命令时的输出:
460a199466896e02dd1ed601f9f6b132dd9ad9b42bbd3df351460e5eeacbe6ce
看到这样的输出意味着您的微服务已成功启动。当您运行此命令时,您将看到不同的输出。这是因为您的容器将有一个与我不同的唯一 ID。您仍然会看到一个很长的一串数字,但再次强调,它将是不同的。您需要这个 ID 来调用与容器相关的未来的 Docker 命令。
不要担心试图记住它(除非您有 photographic memory),因为我们可以很容易地按需回忆起这个和其他容器的细节,正如您很快就会看到的。下面列出了更多需要注意的点。
-
-d参数使我们的容器以分离模式运行**。这意味着它在后台运行,我们无法直接看到其日志。如果我们省略了它,我们的容器将在前台运行,我们可以直接看到其输出;尽管如此,它也会占用我们的终端。 -
-p参数将主机操作系统和我们的容器之间的端口绑定。这就像端口转发一样,发送到我们开发工作站上端口 3000 的网络流量将被转发到容器内的端口 3000。我们这样设置是因为我们最初将微服务硬编码为监听端口 3000。数字 3000 本身在这里并不重要。我们可以使用几乎任何数字,但根据惯例,在开发/测试单个 HTTP 服务器时通常使用 3000。
-
最后一个参数,
video-streaming,是我们给我们的图像取的名字。这是我们指定哪个图像(我们可能有多个)将被实例化的方式。这与我们在 3.8.2 节中使用的docker build命令和-t参数所指定的图像名称相关。
在这一点上常见的错误是当我们使用的端口(例如,端口 3000)已经被另一个应用程序分配时。如果发生这种情况,您需要关闭该应用程序,或者如果您不能这样做,您将不得不选择一个不同于 3000 的端口。您可以通过使用我们在第二章 2.6.6 节中使用的 PORT 环境变量来完成此操作。以下是docker run命令的一般格式:
docker run -d p <host-port>:<container-port> <image-name>
您可以使用此方法通过插入您创建的每个图像的特定名称来启动其他微服务。
检查容器
我们现在有一个正在运行的容器,但让我们检查一下它是否处于正常工作状态。要显示您拥有的容器,请调用此命令:
docker container list
这里是输出的一部分:
CONTAINER ID IMAGE STATUS PORTS
460a19946689 video-streaming Up 20 seconds 0.0.0.0:3000->3000/tcp
您的输出将不同于显示的输出,因为为了使其适应,我移除了COMMAND、CREATED和NAMES列。但您可以直接运行该命令以查看这些信息。
注意CONTAINER ID列。这显示了容器的唯一 ID。它是从上一节中docker run命令输出的较长 ID 的简化版本。两者都是您容器的唯一 ID,如您一会儿将看到的,我们将使用 ID 在运行 Docker 命令时识别容器。
检查您的微服务
我们已成功从我们的图像中实例化了一个容器,并检查了它是否正在运行。但我们如何知道容器内的微服务是否功能正常?它可能会抛出各种错误,而我们目前还不知道。让我们检查微服务的输出并看看它告诉我们什么:
docker logs 460a19946689
哇,等等!您不能只是调用那个命令并使用我的容器的唯一 ID。记住,您的工站上创建的容器的 ID 将是不同的。如果您这样调用,将会得到一个错误。所以请注意您自己的容器 ID,如前节所示,并像这样调用命令,插入您自己的容器 ID:
docker logs <container-id>
现在您应该看到微服务的输出。如果您从第三章代码库中的 example-1 运行代码,您应该看到类似以下内容:
Microservice listening on port 3000, point your browser at
➥ http://localhost:3000/video
成功!我们构建了一个镜像。我们将其实例化为一个容器,并确认我们的微服务正在运行。现在让我们在网页浏览器中测试一下。打开你的浏览器,将其指向 http://localhost:3000/video。你应该能看到流媒体视频,结果应该和我们在第二章中测试的相同。
为什么这能工作?这是因为我们使用了docker run命令中的-p参数,将我们的工作站上的端口 3000(假设这个端口还没有被分配)转发到容器中的端口 3000。我们的微服务正在监听端口 3000,并做出了响应!
显然,我们还可以做更多的事情来测试我们的代码。但我们将把那留到以后。在第八章*中,我们将探讨如何将自动化的代码驱动测试应用到我们的微服务中。然后在第十章中,我们将看到如何监控我们的微服务,当发现问题时要如何调试它们,以及我们可以用于构建容错系统的技术。但现在,我们已经准备好发布我们的镜像了!
探索其他容器
你知道你可以轻松地使用docker run命令运行任何公共镜像吗?本书后面我们将使用的两个镜像分别是 mongodb 和 rabbitmq。试着运行这些镜像,以便在 localhost:27017 上获得一个即时可用的数据库。例如
docker run -p 27017:27107 mongo:latest
在线有许多公共镜像可供使用,你不需要账户就可以访问这些镜像。在 Docker Hub 上搜索以找到更多hub.docker.com。
3.9 发布我们的微服务
我们现在几乎已经准备好将我们的第一个微服务用于生产部署。我们已经将其打包成一个 Docker 镜像,但目前这个镜像仅存在于我们的开发工作站上。这对我们的测试和实验来说很好,但我们仍然需要将我们的镜像发布到某个地方,以便我们以后可以将其部署到我们的 Kubernetes 集群中。图 3.8 展示了我们现在如何将我们的镜像发布到云中托管的私有容器注册库。

图 3.8 将我们的 Docker 镜像发布到云中的私有容器注册库
我们将按照以下步骤发布我们的微服务:
-
我们在 Microsoft Azure 上创建了自己的私有容器注册库。我们只需要在第一次发布镜像时做这件事。以后,当我们发布镜像的新版本和其他微服务的镜像时,我们将简单地重用这个相同的注册库。
-
在发布之前,我们必须使用
docker login命令对注册库进行身份验证。 -
我们使用
docker push命令将我们的镜像上传到注册库。(这是实际发布我们的微服务的步骤。) -
我们再次使用
docker run来检查我们是否可以从发布的镜像启动我们的微服务。
3.9.1 创建私有容器注册库
创建私有容器注册库实际上非常简单。我们将在 Microsoft Azure 上创建我们的注册库,但所有主要的云服务提供商都支持这项功能。为什么选择发布到私有注册库?在这本书中,我们正在学习如何为私有公司构建专有应用,因此将镜像私下发布而不是使用像 Docker Hub 这样的公共注册库是有意义的。
我使用 Azure 来写这本书,因为我发现它是最简单的云平台之一,并且它是学习如何构建云原生应用的绝佳起点。Azure 为新注册用户提供了一些优惠,包括第一个月免费信用额度。这为您提供了尝试本书中展示的云基础设施的机会,而且无需付费。
确保您稍后销毁所有资源,以免不必要地支付费用。顺便说一句,这也是使用 Azure 的另一个原因:微软已经使查找和销毁云资源变得容易,这样我们就不会忘记某些事情,最终为未使用的基础设施付费。现在,我们将手动创建我们的容器注册库。但在第六章,我们将回到这个话题,学习如何使用代码创建它。
打开您的浏览器并加载 Azure 网站:azure.microsoft.com。按照步骤进行注册。注册后,您应该能够登录到 Azure 门户:portal.azure.com。
一旦进入 Azure 门户,您应该在左侧菜单中看到“创建资源”选项。点击此选项,然后在搜索输入框中输入container registry并按 Enter。您将看到如图 3.9 所示的匹配选项。点击“Container Registry by Microsoft”选项。

![图 3.9 在 Azure 门户中创建新的私有容器注册库]
现在,您应该看到一个页面,它解释了更多关于 Microsoft 容器注册库的信息。如果您愿意,在点击创建按钮之前先阅读一下。
接下来,我们填写一些关于我们正在创建的注册库的详细信息。图 3.10 显示,我们首先需要提供一个名称。名称很重要,因为它创建了一个我们将用于以后与注册库通信的 URL。我为我的注册库选择的名称是 bmdk1,这导致容器注册库的 URL 如此:bmdk1.azurecr.io**.

![图 3.10 为我们的新私有容器注册库填写详细信息]
因为为注册库选择的名称会生成 URL,所以它必须是全局唯一的。这意味着您不能选择别人已经使用的名称——选择您自己的唯一名称。您应该注意 URL,因为您很快将需要它来在您的注册库上执行 Docker 命令。
在点击“创建”之前,我们需要选择或创建一个资源组。正如其名称所暗示的,Azure 中的资源组允许将云资源收集到组中以方便管理。图 3.11 显示,我正在创建一个新的资源组来包含我称为 bmdk1 的新注册表。要创建一个新的资源组,请点击“创建新”,输入一个名称,然后点击“确定”。

图 3.11 创建新的资源组以包含私有容器注册表
这个名字不重要。我们可以使用之前相同的名字,或者我们可以使用我们喜欢的任何其他名字。它不需要与容器注册表有相同的名字,也不需要是全球唯一的。只需确保您给它一个对您有意义的名字,这样当您稍后再次看到它时,您会想起它的用途。
现在,点击“审查 + 创建”按钮。在下一页,点击“创建”以创建您的注册表。
要跟进我们的注册表创建,我们需要在 Azure 门户中查看通知。点击通知图标以打开通知侧边栏并监视部署进度。这可能需要一些时间,但完成时,我们将在侧边栏中看到“部署成功”通知,如图 3.12 所示。

图 3.12 我们新容器注册表的部署已成功!
从“部署成功”通知中,我们可以点击“转到资源”来查看新注册表的详细信息。否则,如果我们稍后需要再次找到我们的注册表,请点击左侧菜单中的“所有资源”。图 3.13 显示,这将列出我们所有的资源(如果您创建了其他资源),以及我们的新容器注册表。

图 3.13 您可以在“所有资源”列表中找到您的容器注册表。在这个阶段,我们只有一个资源,即注册表本身。
接下来,在列表中点击您的容器注册表以深入查看其详细信息,然后在左侧菜单中点击“访问密钥”。您可以在图 3.14 中看到它的样子。注意,在这里我们可以看到注册表的 URL。
注意:启用“管理员用户”选项非常重要。我们需要启用此选项才能在推送和拉取镜像时对注册表进行认证。
现在,请注意您的注册表的用户名和密码(您只需要第一个密码)。不要麻烦记录图 3.14 中看到的那些。这些是我注册表的详细信息,并且在你阅读这段文字时它将不存在。请确保使用您自己的注册表的详细信息!

图 3.14 查看我们新的私有容器注册表的认证详情
就这些了!如果您遵循了这些说明,现在您已经有了自己的私有容器注册表。您可以将镜像推送到注册表,然后从这里,您可以将其部署到生产环境中。所以让我们发布我们的第一个镜像吧!
公共与私有
对于这本书,我们只对发布私有 Docker 镜像感兴趣。但您可能也想知道,您还可以发布公共镜像。
例如,假设您创建了一个开源微服务。为它创建一个 Docker 镜像,然后将其公开发布到 Docker Hub。这可以帮助您的用户快速运行它!
要发布到 Docker Hub,您必须在 hub.docker.com 注册。然后您可以使用 docker push 命令将您的镜像推送到 Docker Hub。
Docker Hub 还允许您发布私有镜像。尽管要发布多个这样的镜像,您需要升级到付费账户。
3.9.2 将我们的微服务推送到注册库
现在我们有了私有容器注册库,我们有一个地方可以发布我们的第一个微服务。我们将通过调用如图 3.15 所示的 docker push 命令来发布我们的镜像。

图 3.15 docker push 命令将我们的 Docker 镜像上传到我们的私有容器注册库。
使用注册库进行身份验证
在我们可以向我们的注册库推送之前,我们必须首先登录。我们启用了身份验证,因为我们不希望任何人都能将镜像发布到我们的注册库。
在上一节中,您创建了您的私有容器注册库,并记下了其详细信息。要与注册库通信,您必须知道其 URL。要推送和拉取镜像,您需要用户名和密码。如果您记不住这些,请参阅第 3.9.1 节,在 Azure 门户中找到您的注册库并回忆这些详细信息。要进行身份验证,我们将调用 docker login 命令:

我本可以向您展示我使用的完整命令,包括我的注册库的 URL、用户名和密码。但这不会适合页面!而且,这也不会对您有帮助,因为在这个时候,您必须使用您自己的注册库的详细信息。当您调用 docker login 时,请确保使用您自己的 URL、用户名和密码。在通过 docker login 进行身份验证后,您现在可以调用针对您的注册库的其他 Docker 命令。
标记我们的镜像
在我们可以将我们的镜像发布到注册库之前,我们必须告诉 Docker 镜像被推送到哪里。我们通过使用 docker tag 命令对镜像进行标记来实现,如下所示:

当然,您不能直接输入该命令。您必须使用您自己的注册库的 URL!docker tag 命令具有以下通用格式:
docker tag <existing-image> <registry-url>/<image-name>:<version>
我们设置了现有镜像的名称以进行标记,然后是应用于它的新的标记。在这种情况下,我们之所以进行标记,仅仅是因为我们想要将镜像推送到我们的注册库。因此,我们在应用的标记中包括了注册库的 URL。
我们可以通过调用docker image list来检查我们的新标签是否已应用。在应用新标签后尝试这样做。您应该在表格的新标签部分看到一个新条目。请注意,Docker 并没有创建一个新的图像;它只是给现有的图像添加了一个新的标签。我们可以通过检查图像的唯一 ID 来确认这一点,我们看到它对于标记的两个版本都是相同的。
将我们的图像推送到注册表
最后,我们已准备好将我们的图像发布到注册表。为此,我们将调用docker push命令:

再次提醒,请确保在这里使用您自己的注册表的 URL;否则,这个命令对您将不起作用。以下是docker push命令的一般格式:
docker push <registry-url>/<image-name>:<version>
docker push命令后面的命令部分用于标识要推送的图像。这也是标识要推送到的注册表的部分。
如果您认为这有点尴尬,那么我会同意您的看法。在我看来,应该有一个一步到位的过程来将现有图像推送到注册表,而不必先进行标记。但这并不是这样,这就是它的操作方式。在开始图像上传后,请耐心等待其完成。
检查我们的图像是否已成功推送到注册表
在我们将图像推送到注册表后,我们现在想检查它是否已成功到达。我们如何知道它是成功的?第一个线索在输出中。它应该会说推送是成功的,我们可以相信这是正确的。但无论如何,让我们回到 Azure 门户中的注册表,看看现在看起来如何。
在 Azure 门户中,导航到所有资源,找到您的注册表,然后点击它。从左侧菜单中选择仓库。如图 3.16 所示,您应该能够在仓库列表中看到您的视频流图像。如果您查看仓库内部(如图 3.16 右侧所示),您会看到这里有一个版本列表。目前只有一个版本(标记为latest),但在将来,在您推送更新到这个图像之后,您可以返回这里并看到列出的其他版本。

图 3.16 通过 Azure 门户查看推送到容器注册表的图像
您甚至可以通过最新的标签进一步深入查看图像的详细信息,包括其文件清单。我鼓励您更多地探索这个界面,看看您能发现关于您刚刚发布的图像的哪些信息。
3.9.3 从注册表启动我们的微服务
恭喜您,您刚刚成功将您的第一个图像发布到了您自己的私有注册表。我们现在可以将这个图像部署到我们的生产环境中,尽管我们无法这样做,因为我们还没有构建我们的 Kubernetes 集群。我们将在第六章中构建它。但在那之前,我们还有更多的工作要做,还有更多的事情要学习。
在继续之前,我们应该确认我们的发布镜像是否正常工作。我的意思是,我们应该能够直接从云端的仓库实例化镜像作为容器。仅仅因为我们还没有生产环境并不意味着我们不能在我们的开发工作站上模拟部署。这并不困难,实际上这与我们在本章中学到的并没有什么不同。
从镜像运行容器基本上是相同的,无论这个镜像是我们本地构建的还是远程仓库中可用的。我们将回到docker run命令来测试我们发布的镜像,如图 3.17 所示。

图 3.17 我们可以通过在我们的开发工作站上运行它来测试我们的发布镜像;在这种情况下,docker run命令必须首先从仓库拉取镜像。
清理我们的混乱
在我们能够从仓库测试我们的镜像之前,有一件事阻碍了我们。我们必须首先删除我们镜像的本地版本。我们必须这样做;否则,当我们调用docker run时,它将从我们已有的本地镜像版本启动容器。这不是我们想要的!
相反,我们想要测试我们是否可以从远程仓库中拉取镜像。如果我们已经有一个本地缓存的镜像版本,它就不需要拉取远程版本。这也是我们学习如何删除本地容器和镜像的好借口。
容器不会自行消失。当我们为长期运行的服务器创建容器时,容器通常会保留在那里!当我们完成时,我们需要关闭它们,以免它们继续消耗我们的系统资源。
注意:在我们能够删除镜像之前,我们必须首先删除从它们实例化的任何容器。尝试删除有运行中容器的镜像将导致错误。
我们将在终端中调用docker ps。它就像docker container list,但它显示了运行中和已停止的容器。如果你在容器列表中看到了你的视频流微服务,那就是你想要删除的。记下它的容器 ID。你可能会记得,我自己的容器 ID 是 460a19946689。当然,你的将不同,所以不要期望在你的容器列表中看到那个特定的 ID。我用以下命令删除了我的容器:
docker kill 460a19946689
docker rm 460a19946689
只需记住使用你的容器 ID。以下是一般格式:
docker kill <your-container-id>
docker rm <your-container-id>
在删除容器后,我们可以再次调用docker ps并检查容器是否不再在列表中。在删除任何容器后,我们现在可以继续删除镜像。
调用docker image list。我们可以看到列表中至少有三个镜像。有 Node.js 的基础镜像和我们的视频流微服务的两个标记版本。我们只需要删除我们的微服务的镜像。没有必要删除 Node.js 的基础镜像,因为这对这次测试运行来说并不重要。
注意,我们的镜像的两个标记版本具有相同的镜像 ID,实际上这只是多次引用的同一镜像。我们可以通过使用带有--force参数的docker rmi命令来删除这两个镜像:
docker rmi 9c475d6b1dc8 --force
当然,你需要用你特定的镜像 ID(你可以从docker image list的输出中找到)来运行这个命令。一般格式是
docker rmi <your-image-id> --force
我们在这里使用--force是因为,否则,我们会因为错误消息Image is referenced in multiple repositories而停止。这是因为我们有多个标记版本的我们的镜像。我们可以使用--force来确保这些都被删除。
删除镜像后,再次调用docker image list以检查这是否正确执行,并且我们的镜像不再在列表中。看到列表中的 Node.js 基础镜像是可以的,因为在这个测试运行中不需要删除它。
直接从注册库运行容器
在清理了本地容器和镜像之后,我们现在可以直接从远程注册库中的镜像实例化一个新的容器。我们再次使用docker run,如下所示:
docker run -d -p 3000:3000 bmdk1.azurecr.io/video-streaming:latest
和往常一样,你必须使用你自己的注册库的 URL。这里是一般格式:
docker run -d -p <host-port>:<container-port> <registry-url>/<image-name>:
➥ <version>
这次当我们调用docker run时,我们使用了与第 3.8.3 节中相同的所有参数。这里有-d用于分离模式,-p用于绑定端口。我们在这里唯一改变的是用来识别镜像的标记。在这种情况下,标记也标识了拉取镜像的远程注册库。
当你在终端中调用docker run时,给它一些时间下载。它首先必须拉取你的镜像。你可能已经本地缓存了 Node.js 基础镜像(除非你决定在上一个部分中删除它),在这种情况下,这不会花费很长时间。
当这个过程完成后,你应该有一个正在运行的容器。但这次,它的镜像已经从云中的私有容器注册库中按需拉取了。当docker run命令完成后,你应该看到打印出的容器 ID。我们也可以使用第 3.8.3 节中概述的步骤来检查容器是否正在运行。或者,我们可以直接通过将我们的网络浏览器指向 http://localhost:3000/video 来测试它。
3.10 Docker 回顾
哇!真是一次旅行。Docker 看起来很简单,直到你试图在单章中解释它!我们刚才做了什么?
-
我们为我们的微服务创建了一个 Dockerfile,它指导 Docker 如何构建它的镜像。
-
我们调用了
docker build来将我们的微服务打包成镜像。 -
在我们在 Azure 上创建我们的私有容器仓库之后,我们随后调用了
docker tag、docker login和docker push来发布我们的图像。 -
我们使用
docker run完成了我们发布的图像的测试运行。
我们拼凑的完整管道如图 3.18 所示。仔细查看此图,并享受你迄今为止所学到的内容。

图 3.18 完整的 Docker 构建管道图,显示了构建、推送和运行在过程中的位置。
在继续之前,让我们快速回顾一下我们在本章中添加到工具包中的命令。表 3.3 显示了这些命令。
表 3.3 Docker 命令概览
| 命令 | 描述 |
|---|---|
docker --version |
检查 Docker 是否已安装并打印版本号 |
docker container list |
列出正在运行的容器 |
docker ps |
列出所有容器(正在运行和已停止的)。 |
docker image list |
列出本地图像 |
docker build -t <tag> --file➥ <docker-file``> . |
根据当前目录中 docker-file 中的说明从资产构建图像。-t 参数使用你指定的名称标记图像。 |
docker run -d -p <host-➥ port>:<container-port>➥ <tag> |
从图像实例化一个容器。如果图像在本地不可用,可以从远程仓库拉取(假设标记指定了仓库的 URL)。-d 参数以分离模式运行容器,因此它不会绑定到终端,你将看不到输出。省略此参数可以直接看到输出,但这也锁定你的终端。-p 参数允许你将主机上的端口绑定到容器中的端口。 |
docker logs <container-id> |
从特定的容器检索输出。你需要这个输出,以便在分离模式下运行容器时查看输出。 |
docker login <url>➥ --username <username>➥ --password <password> |
使用你的私有 Docker 仓库进行身份验证,以便你可以运行针对它的其他命令。 |
docker tag <existing-tag>➥ <new-tag> |
向现有图像添加新标记。要将图像推送到你的私有容器仓库,你必须首先使用你的仓库的 URL 标记它。 |
docker push <tag> |
将适当标记的图像推送到你的私有 Docker 仓库。图像应该使用你的仓库的 URL 进行标记。 |
docker kill <container-id> |
在本地停止特定的容器。 |
docker rm <container-id> |
在本地删除特定的容器(必须先停止)。 |
docker rmi <image-id>➥ --force |
在本地删除特定的图像(必须先删除任何容器)。--force 参数即使图像被标记多次也会删除图像。 |
3.11 继续学习
本章进展迅速。目标是给你启动应用程序所需的最小内容,但还有更多关于 Docker 的内容可以学习。以下是一些其他书籍的参考,这些书籍将帮助你更深入地了解 Docker:
-
《一个月午餐学会 Docker》由 Elton Stoneman 著(Manning, 2020)
-
《实践中的 Docker》由 Aidan Hobson Sayers 和 Ian Miell 著(Manning, 2016)
-
《Docker 实战》由 Jeff Nickoloff 著(Manning, 2016)
Docker 也有良好的在线文档。值得浏览一下
在本章中,我们探讨了如何使用 Docker 构建和发布单个微服务。随着我们推出更多微服务并创建我们的应用程序,我们将在未来的章节中继续利用这些技能。在下一章中,我们将扩展到多个微服务,并学习如何在我们的开发工作站上轻松运行多个基于 Docker 的微服务。
摘要
-
我们了解了 Docker 镜像和容器以及它们与虚拟机的关系。
-
您已将 Docker 安装到您的开发环境中并检查了版本号
-
我们创建了一个 Dockerfile,并使用
docker build命令将我们的微服务打包到 Docker 镜像中。 -
我们使用
docker run命令在 Docker 容器中实例化了我们的微服务。 -
您在云中创建了自己的私有 Docker 注册库。
-
您已将微服务发布到您的 Docker 注册库。
-
在使用
docker run命令从注册库中发布的镜像实例化微服务之前,您清理了所有本地容器和镜像。
4 微服务的数据管理
本章涵盖
-
使用 Docker Compose 在开发中构建和运行微服务应用程序
-
将文件存储添加到你的应用程序中
-
将数据库添加到你的应用程序中
在构建任何应用程序时,通常,我们需要处理数据或文件,有时两者都需要。微服务也不例外。我们需要一个数据库来存储由应用程序生成和更新的动态数据,我们还需要一个地方来存储由应用程序提供或上传到它的资产。
在本章中,我们将文件存储和数据库添加到我们的 FlixTube 示例应用程序中。首先,我们将添加文件存储,这样 FlixTube 就有了一个存储视频的位置。我们希望在应用程序中为流媒体和视频存储划分不同的责任区域。这意味着我们需要为应用程序添加另一个微服务,在本章中,我们确实会创建我们的第二个微服务。
然后,我们将添加数据库。在这个时候,我们添加数据库是为了有一个记录每个视频路径的地方,但这实际上只是将数据库引入位置的借口。因为一旦我们有了它,我们就可以轻松地使用它来存储我们视频的所有元数据,并为所有微服务的持续数据存储需求提供服务。
通过添加数据库服务器和第二个微服务到我们的应用程序中,我们迈出了重要的一步。在第二章,我们构建了我们的第一个微服务;在第三章,我们使用 Docker 在容器中实例化了我们的第一个微服务。在本章中,我们将应用程序扩展以托管多个容器,为此,我们需要一个新的工具!
4.1 新工具
本章介绍了为微服务存储数据的两种方式:文件存储和数据库。通常有很多人不同的方法来做这件事,也有很多不同的工具我们可以选择。你为每个项目选择的工具将是那些最适合特定项目、你的团队、你的公司以及你的客户的工具。
对于本书中的任何示例,我需要做出选择,所以从本章开始,我们将使用 MongoDB 作为我们的数据库,并使用 Azure Storage 作为我们的文件存储。我们还将升级我们的开发环境以同时运行多个容器。我们可以像上一章学到的使用 Docker 的build和run命令来做这件事。但那样的话,我们就必须为每个容器重复运行命令。
当只处理少量容器时,这并不是大问题,但这并不适用于更大的应用程序。想象一下,仅用这种方式构建和运行 10 个微服务!因此,我们需要一种更好的方式来管理多个微服务。为此,本章介绍了 Docker Compose。表 4.1 列出了本章我们将学习的新工具。
表 4.1 第四章介绍的工具
| 工具 | 版本 | 目的 |
|---|---|---|
| Docker Compose | 1.26.2 | Docker Compose 允许我们同时配置、构建、运行和管理多个容器。 |
| Azure 存储 | SDK 版本 2.10.3 | Azure 存储是一个云存储文件的服务。我们可以通过 Azure 门户、通过 API 或从命令行管理资产。我们将通过 Azure 门户上传一个视频,然后使用 Node.js Azure 存储 SDK 读取它。 |
| MongoDB | 4.2.8 | MongoDB 是一种流行的 NoSQL 类型的数据库。它轻量级,易于设置和使用,对微服务来说很方便。 |
4.2 获取代码
要跟随本章的内容,您需要下载代码或克隆存储库。
-
您可以在以下位置下载代码的 zip 文件:
-
您可以使用 Git 克隆代码,如下所示:
git clone https://github.com/bootstrapping-microservices/chapter-4.git
关于安装和使用 Git 的帮助,请参阅第二章。如果您在代码方面遇到问题,请在 GitHub 的存储库中记录一个问题。
4.3 使用 Docker Compose 开发微服务
在上一章的结尾,我们在我们的开发工作站(或个人电脑)上创建了一个在容器中运行的单一微服务。我们能够使用我们的网络浏览器对其进行测试。图 4.1 说明了我们当前的情况。

图 4.1 我们的单一微服务在我们的开发工作站上运行 Docker,这是我们第二章中创建的。
然而,一个微服务应用程序,如果它只包含一个单一微服务,那么它就不是一个微服务应用程序!是时候扩展我们的应用程序并添加更多容器了。在本章中,我们将使用 Docker Compose 来将我们的应用程序迁移到多个微服务。
在本章中,我们将扩展到多个容器,因为我们想添加一个数据库(这是一个容器),我们还希望添加一个新的微服务来处理我们的文件存储(这也是另一个容器)。所以,鉴于我们从一个容器开始(我们的视频流微服务),到本章结束时,我们将拥有三个容器,如图 4.2 所示。

图 4.2 我们将应用程序扩展到多个容器。
为了构建、运行和管理我们不断增长的应用程序,我们可以通过多次运行各种 Docker 命令来完成任务(为每个镜像或容器重复)。但在开发过程中,这很快就会变得繁琐,因为在我们工作日中,我们需要多次停止和重新启动我们的应用程序。而且情况只会变得更糟!随着我们的应用程序继续增长,我们将向其中添加更多的容器。我们需要一个更好的工具。
4.3.1 为什么使用 Docker Compose?
在开发过程中管理多个容器可能会非常繁琐;在第六章中,您将看到我们将如何使用 Kubernetes 来管理生产环境中的容器。然而,Kubernetes 是一个庞大且复杂的系统,设计用于在多台计算机上运行(您至少需要一个主节点和一个节点)。在开发工作站上“模拟”Kubernetes 并不容易。您可以使用 Minikube 来完成这项工作,这就像是一个简化的 Kubernetes 版本。但有一个更简单的方法,您可能甚至已经安装了它——Docker Compose。
为什么选择 Docker Compose?与 Docker 允许我们构建、运行和管理单个微服务的方式相同,Docker Compose 为我们提供了一个方便的方式来在开发中构建、运行和管理多个微服务。
Docker Compose 是 Docker 开发者开发的另一个工具,它建立在 Docker 之上,以便更轻松地管理多容器应用程序。在开发和测试期间,我们必须频繁地启动和重启我们的整个应用程序。并且在每次小的开发增量之后,我们必须测试我们的代码更改。我们可以通过前面章节中介绍的方法来完成这项工作:
-
打开多个终端(每个微服务一个),然后使用 Node.js 或我们使用的任何技术栈(如第二章所述)分别运行每个微服务
-
使用 Docker 分别构建和运行每个容器(如第三章所述)
这些方法中的每一个都是我们在构建微服务应用程序的过程中重要的里程碑,而且我们确实会在与单个微服务一起工作时经常回到这些方法。但是,当涉及到与整个微服务应用程序一起工作时,这些方法的效果就不那么明显了。
使用这些方法来管理我们不断增长的应用程序意味着我们花费越来越多的时间在管理运行中的应用程序上。这以开发时间为代价。这减缓了我们的迭代进度,降低了我们的生产力,并最终,耗尽了我们的动力。
我们需要一个更有效的方法来管理我们的应用程序在开发过程中的状态。这就是 Docker Compose 的用武之地。Docker Compose 是一个用 Python 编写的开源工具,您可以在以下位置找到代码:github.com/docker/compose。
4.3.2 安装 Docker Compose
好消息是,您可能已经在您的开发工作站上安装了 Docker Compose。如果您跟随第三章并安装了 Docker,那么您可能已经安装了 Docker Compose,因为它包含在 Windows 和 MacOS 的标准安装程序中。如果您在 Linux 上工作,您可能需要单独安装 Docker Compose。要检查您是否已经安装了它,请打开一个终端并按照以下方式打印版本号:
docker-compose --version
如果您已经安装了它,您将看到打印出的版本号。这是我写作时运行的版本输出:
docker-compose version 1.26.2, build eefe0d31
注意:如果您运行的版本比这个版本晚,这是可以的,因为,它很可能与旧版本兼容。
如果你发现你没有安装 Docker Compose,那么你应该调用 docker --version 命令来再次确认你是否已安装了基本的 Docker 工具。如果你还没有安装,请返回到第三章的 3.7.1 节来安装它。
你可能已经安装了 Docker,但还没有安装 Docker Compose。这种情况可能发生在你正在使用 Linux 并且遵循了 Docker 的命令行安装说明时。如果你需要安装 Docker Compose 作为 Docker 的补充,请参阅 Docker 网站上的 Docker Compose 安装说明,网址如下:
docs.docker.com/compose/install/
按照那里的说明操作。完成后,使用 docker-compose --version 命令来检查它是否已准备好使用。
4.3.3 创建我们的 Docker Compose 文件
Docker Compose 围绕 Docker Compose 文件. 我喜欢将其视为一个用于自动构建微服务应用的脚本文件。
定义 Docker Compose 文件 是一个脚本,它指定了如何从多个 Docker 容器中组合一个应用程序。
回想一下我们在第 3.8 节中创建的 Dockerfile。那是一个构建单个镜像的脚本。Docker Compose 文件将其扩展,并允许我们从一组 Dockerfile 中编排整个应用程序的创建。Docker Compose 读取 Docker Compose 文件,并生成一个运行中的应用程序,如图 4.3 所示。

图 4.3 Docker Compose 文件就像是一个用于构建和启动微服务应用的脚本。
在我们学习如何使用 Docker Compose 创建由多个容器组成的应用程序之前,让我们保持简单,创建一个仅包含单个容器的应用程序。我们将进行一些实验,这样你就可以熟悉 Docker Compose。之后,我们将添加更多的容器。
我们学习 Docker Compose 的第一步是将第二章中的视频流微服务与它配合使用。本章的下一个示例是第三章代码库中的 example-1 的直接后续。你可以从该示例开始,并根据需要对其进行更新,或者你可以跟随第四章代码库中预先准备好的示例。
我们需要做的第一件事是将我们的微服务的 Dockerfile 和代码移动到一个新的子目录中。在这个例子中,我们将其命名为 video-streaming,以匹配微服务的名称。我们这样做的原因是,我们现在正在构建一个很快就会包含多个微服务的应用程序。因此,我们必须将每个微服务放入它自己的单独子目录中。我们的约定是,每个子目录都以其微服务的名称命名。
现在让我们创建我们的 Docker Compose 文件。实际上,这个文件叫做 docker-compose.yaml。因为它不属于任何单个微服务,所以它位于我们的微服务应用程序的根目录中。列表 4.1 显示了我们的第一个 Docker Compose 文件。你可以自己输入这段代码,或者直接从第四章代码仓库中的 example-1 目录加载到 Visual Studio (VS) Code 中。
列表 4.1:我们的微服务的 Docker Compose 文件(第四章/example-1/docker-compose.yml)
version: '3' ①
services: ②
video-streaming: ③
image: video-streaming ④
build: ⑤
context: ./video-streaming ⑥
dockerfile: Dockerfile ⑤⑦
container_name: video-streaming ⑧
ports: ⑨
- "4000:80" ⑩
environment: ⑪
- PORT=80 ⑫
restart: "no" ⑬⑭
① 使用 Docker Compose 文件格式的第 3 版
② 在“services”字段下嵌套我们的容器
③ 配置我们的视频流微服务
④ 设置镜像名称
⑤ 设置构建镜像所需的参数
⑥ 设置微服务的目录
⑦ 设置构建镜像的 Dockerfile
⑧ 命名实例化的容器
⑨ 指定端口映射。这就像我们在上一章中使用 Docker 时的“-p”参数。
⑩ 将微服务中的端口 80 映射到主机操作系统的端口 4000
⑪ 设置配置容器输入的环境变量
⑫ 设置微服务 HTTP 服务器使用的端口
⑬ 配置我们的视频流微服务
⑭ 如果微服务崩溃,不要自动重启它。
列表 4.1 是一个 Docker Compose 文件,它创建了一个容器:我们的视频流微服务。注意build部分。这里的字段设置了包含微服务项目和 Dockerfile 的子目录及其名称。这就是 Docker Compose 如何找到构建我们视频流微服务镜像所需的信息。
还要注意,Dockerfile 是这个文件的默认名称。我们本来可以省略这个名称,但我明确指定了它,因为在下一章中,我们将分离我们的 Dockerfile。这样,我们可以为开发和生产使用不同的版本。还配置了各种选项(见代码注释),你可能还记得第二章的内容:
-
设置镜像名称为 video-streaming
-
绑定端口
-
设置环境变量以配置微服务
在列表 4.1 中,我们为我们的容器从 4000 开始设置端口号。一旦我们有了多个容器,它们的端口号将是 4000、4001、4002 等等。这样做只是为了确保多个微服务的端口号不会冲突。
注意:端口号的选择是任意的,对于你自己的应用程序,如果你喜欢,可以使用不同的端口号集合。
你可能想知道为什么我们在列表 4.1 中将restart选项设置为no。在开发工作中,我们不希望微服务在崩溃时自动重启,如果那样做了,我们很容易错过问题!
相反,如果它们崩溃,我们希望它们保持这种状态,这样我们就会注意到问题。这与我们通常希望在生产中让微服务工作的方式相反。我们将在第十章中看到如何让 Kubernetes 自动重启崩溃的生产微服务。
尽管我们的第一个 Docker Compose 文件很简单,但它已经非常有用了。这个 Docker Compose 文件只创建了一个容器,但它包含了我们构建和运行微服务所需的所有信息。记录这些配置细节已经让事情变得容易一些。否则,每次我们调用 Docker 的 build 和 run 命令时,我们都需要在终端中输入这些配置。即使在这个早期阶段,我们也能看到 Docker Compose 如何改进我们的开发流程。
YAML
你可能已经注意到 Docker Compose 文件是一个 YAML 格式的文件。根据维基百科,YAML 是“YAML Ain’t Markup Language”的递归缩写。
YAML 虽然实际上不是一种标记语言,但最好将其描述为一种数据格式或配置语言。YAML 的目的是与 JSON 类似,但语言本身的结构是为了更易于人类阅读。
这也是为什么你会看到 Docker Compose 和 Kubernetes 等工具使用 YAML 的原因。这些是设计给人编辑的配置文件,同时仍然易于机器读取。
4.3.4 启动我们的微服务应用程序
到目前为止,我们已经创建了一个 Docker Compose 文件来构建和运行第二章的视频流微服务。我们重用了整个项目,包括第三章的 Dockerfile。我们现在将测试我们所做的工作。
在本节中,我们使用 Docker Compose 启动单个服务。这还没有给我们带来比仅使用 Docker 更多的优势,但请耐心等待。这只是一个起点,很快我们就会扩展我们的 Docker Compose 文件以包括多个容器。我们将使用我们刚刚创建的 Docker Compose 文件,以便使用 Docker Compose 启动我们的应用程序。
打开终端并切换到包含你的 Docker Compose 文件的目录。如果你正在跟随 GitHub 上第四章代码仓库中的代码,那么你应该切换到 chapter-4/example-1 目录。现在调用 Docker Compose 的 up 命令:
docker-compose up --build
up 命令使 Docker Compose 启动我们的微服务应用程序。--build 参数使 Docker Compose 在从这些镜像实例化容器之前构建我们的每个镜像。
从技术上讲,在这个阶段,--build 参数不是必需的,因为第一次调用 up 命令时,它无论如何都会构建你的镜像。在其他时候(没有 --build 参数),up 命令只是从之前构建的镜像启动我们的容器。这意味着如果你在微服务中更改了一些代码并再次调用 up 命令,它不会包含你的更改除非你使用 --build 参数。不幸的是,这使得意外遗漏你试图测试的代码更改变得非常容易。
当这种情况发生时,而你并没有意识到它,你最终会浪费时间去测试那些甚至不存在的更改。我不喜欢浪费时间;这就是为什么我每次运行 up 命令时都会使用 --build 参数。这意味着我不用去想它。我知道我的代码更改总是会传递到正在运行的应用中。
当你调用 up 命令时,你会看到你的基础镜像的各个层正在下载。之后,你将开始看到来自你的视频流微服务(现在应该很熟悉)的输出。它看起来可能像以下这样:
video-streaming |
video-streaming | > example-1@1.0.0 start /usr/src/app
video-streaming | > node ./src/index.js
video-streaming |
video-streaming | Microservice online
你可以在输出的左侧看到它显示了容器的名称。这就是识别输出来自我们的视频流微服务的原因。目前容器的名称并不重要,因为在这个阶段,我们只是在应用中运行单个容器——所有的输出都来自这个容器。
现在我们已经运行了我们的微服务,我们可以测试一下一切是否正常。将你的浏览器指向 http://localhost:4000/video 来观看你从之前章节中应该很熟悉的视频。
仅使用单个微服务,这并不算是一个真正的微服务应用。但现在我们已经配置好使用 Docker Compose,我们可以轻松地向我们的应用中添加新的容器。但在我们这样做之前,让我们花点时间来学习更多关于如何使用 Docker Compose 来管理我们的应用。
尽管我们还没有扩展到多个容器,但你可能已经意识到 Docker Compose 已经为我们提供了一个更高效的流程来处理单个容器。使用 up 命令可以让我们免于调用单独的 Docker build 和 run 命令。
这只是目前节省的一小部分时间,但正如你很快就会看到的,Docker Compose 的 up 命令可以扩展到许多容器。你可以想象当你有,比如说,10 个微服务,你可以使用单个 up 命令一次性构建和运行所有这些服务时,它能节省多少时间!这只是一个命令(up 命令)而不是 20 个命令(10 个 build 命令和 10 个 run 命令)。
Docker Compose 的 up 命令可能是你在本书中将要学习到的最重要的命令!你将在开发和测试你的应用时一次又一次地调用它,我会确保你不会忘记它!
4.3.5 与应用一起工作
在启动你的应用后,Docker Compose 会继续在终端中打印输出,直到它运行结束。这会锁定你的终端,所以我们现在除了观看输出外,无法对它做任何事情。我们可以使用 up 命令的 -d 参数以分离模式运行,就像我们在第三章中使用的 Docker run 命令一样。但使用 -d 参数会隐藏应用的输出。我们不希望这样,因为能够查看实时输出对于理解正在发生的事情是有用的。
注意:当然,你可以使用 Docker Compose 的 logs 命令恢复输出。不过,我倾向于不使用 -d 参数,因为我喜欢将输出清晰地显示在前面,以便实时查看发生了什么。
即使我们的终端被 Docker Compose 锁定,我们也可以始终简单地打开一个新的终端,并使用它来调用其他命令。现在让我们试试这个。打开一个新的终端,将目录切换到 Docker Compose 文件所在的目录,并调用以下命令:
docker-compose ps
ps 命令会显示我们正在运行的容器列表。因为我们只有一个微服务在我们的应用程序中运行,你应该会看到如下输出:
Name Command State Ports
-------------------------------------------------------------------------
video-streaming docker-entrypoint.sh ... Up .0.0.0:4000->80/tcp
在这一点上再次指出,Docker Compose 仅仅是普通 Docker 命令的一个层。这意味着我们所有的普通 Docker 命令都可以正常工作。例如,你可以尝试使用 docker ps 来获取容器列表,或者使用 docker push 将镜像上传到你的私有 Docker 仓库。
Docker 命令(如 docker ps)的输出可能与 docker-compose ps 的输出不同。这是因为 Docker 命令与你的开发工作站上的所有镜像和容器相关,而 Docker Compose 命令只与你在 Docker Compose 文件中指定的镜像和容器相关。
在这种意义上,我们像使用作用域机制一样使用 Docker Compose。它限制了命令,使得这些命令只适用于当前项目中的镜像和容器。本质上,它将这些命令的作用域限制在当前工作目录。这是 Docker Compose 的另一个有用特性。
更具体地说,docker-compose ps 只会显示我们 Docker Compose 文件中列出的容器,而 docker ps 则会显示我们开发工作站上的所有容器。如果你运行了 docker ps 命令并发现显示的容器数量比 docker-compose ps 多,那是因为你之前可能已经在你的电脑上创建了其他容器,可能是在跟随第三章的内容时。
在官方文档中还有许多其他的 Docker Compose 命令供你探索。请参阅本章末尾的链接。
4.3.6 关闭应用程序
你可以通过两种方式停止你的应用程序。如果你在上一节中打开了第二个终端,你可以使用它来调用 stop 命令:
docker-compose stop
另一种停止应用程序的方法是在最初调用 up 命令的终端中按 Ctrl-C。然而,这种方法存在一些问题。
第一个问题是你必须小心只按一次 Ctrl-C。如果你只按一次,那么应用程序将优雅地停止并耐心等待所有容器停止。但如果你像我一样(没有耐心),那么你可能会反复按 Ctrl-C,直到进程完成并返回你的终端。与你在交通十字路口猛烈地按人行横道按钮不同,这实际上有效。但遗憾的是,它会中断关闭操作,并且可能会使一些或所有容器处于运行状态。
第二个问题是停止应用程序不会删除容器。相反,它会将这些容器保留在 停止 状态,以便您可以检查它们。这是一个调试崩溃容器的便捷方法!我们将在第十章中更多地讨论容器调试。不过,现在,我们能够删除我们的容器并将我们的开发工作站恢复到干净状态更为有用。为此,我们可以使用 down 命令:
docker-compose down
我实际上认为我们最好总是使用 down 命令。尽管需要 Ctrl-C 来解锁我们的终端,但它不可靠,而 down 命令使得 stop 命令变得多余。
小贴士:在按下 Ctrl-C 后养成使用 down 命令的习惯。
我们可以组合使用 up 和 down 命令,以便在需要将更新的代码或依赖项放入应用程序时轻松重启我们的应用程序。我们可以将这些命令链接如下:
docker-compose down && docker-compose up --build
如果你开始厌倦所有这些复杂的命令,那么,我在听你说。你可能想花些时间为你最常用的命令创建 shell 脚本。请参阅以下侧边栏中的示例。
我们现在为 Docker Compose 建立了一些良好的基础,这将为我们开发微服务应用程序的测试提供良好的服务。我们将在第五章和第八章中学习更多关于使用 Docker Compose 的内容。
Shell 脚本
在日常的开发工作中,你可能会发现输入一些这些命令变得很繁琐。例如,输入 docker-compose up --build 很快就会变得无聊,所以我通常将它封装在一个名为 up.sh 的 shell 脚本中。
通常,当我编写这样的长命令时,我会创建易于运行的 shell 脚本;至少当我每天需要多次运行命令时,我会这样做。我使用的其他 shell 脚本包括
-
down.sh 用于
docker-compose down -
reboot.sh 用于
docker-compose down && docker-compose up --build
我们将在第七章中更多地讨论 shell 脚本。
4.3.7 我们能否在生产中使用 Docker Compose?
在这一点上,我们可能需要暂停一下,考虑为什么我们使用 Docker Compose 进行开发,而不是用于生产。Docker Compose 看起来是定义微服务应用程序的一个很好的方法,那么为什么我们不能在生产中使用它?为什么我们选择使用 Kubernetes 而不是它?
为生产自行构建 Docker Compose 堆栈在开始时很容易,但很难扩展。你可以部署到 Docker Swarm,但这样就会将你锁定在该特定平台上。Kubernetes 是一个健壮且灵活的平台,用于在生产中运行微服务。它得到了所有主要云供应商的支持,但它也是独立的,因此它不会将你锁定。
事实上,我们可以使用 Docker Compose 进行生产。以下至少有两种实现方式:
-
在云中创建一个虚拟机(VM),安装 Docker 和 Docker Compose。然后,将你的应用程序复制到 VM 中,并使用 Docker Compose 启动它。
-
使用由 Docker 制作者提供的 Docker Swarm 托管服务来部署由 Docker Compose 文件定义的应用程序。
这两种选项在短期内可能是可取的,尤其是如果你不想费心学习 Kubernetes。但从长远来看,它们都不是理想的。
选项 1 是垂直可扩展的,但不是水平可扩展的,这非常有限。(如果你还没有听说过水平和垂直扩展的概念,请不要担心。我将在第十一章中解释这些概念,在那里我们将学习如何扩展我们的应用程序。)
选项 2 可能是一个不错的选择,但不幸的是,它会将我们锁定在 Docker 的这项付费服务中。Kubernetes 的一个优点是它独立于任何特定的云供应商,同时,所有云供应商都支持它。这意味着我们不会被锁定!
尽管本书提供了使用 Microsoft Azure 的示例,但你确实可以在 AWS 和 Google Cloud 上运行 Kubernetes,更不用说其他供应商了。所以,你关于 Kubernetes 学到的任何东西都是可转移的知识,你基于 Kubernetes 构建的任何应用程序通常可以在云供应商之间通用。我尽可能地保持供应商中立,这就是为什么我更喜欢 Kubernetes 用于生产。
我们将在第六章中学习更多关于 Kubernetes 的知识,但到目前为止,我想解释为什么 Docker Compose 是开发的最佳选择,但可能不是生产的最佳选择。当然,你选择的策略取决于你的情况、你的项目和你的公司。请不要将此视为圣旨!
4.4 为我们的应用程序添加文件存储
现在我们正在使用 Docker Compose,我们可以轻松地运行多个容器。这为我们提供了进入本章真正主题——数据管理的工具。
我们希望向我们的应用程序添加文件存储和数据库。我们添加文件存储是为了有一个位置来存储应用程序使用的视频。一种常见的方法是使用大型云供应商提供的存储解决方案。因为我们在本书中使用 Azure,所以我们将使用 Azure 存储作为我们的存储提供者。
注意:许多应用程序,包括我们的示例应用程序 FlixTube,都需要存储文件。有各种方法可以做到这一点,但最常见的方法之一是使用外部云存储,例如 Azure 存储、AWS S3 或 Google Cloud 存储。
我们可以直接将视频流微服务连接到存储提供商来添加云存储。我们不会这样做。相反,我们将采用良好的设计原则,即关注点分离和单一责任原则;我们将创建一个新的微服务,其目的是成为我们的文件存储提供商的抽象。图 4.4 说明了在我们向应用程序添加新的视频存储微服务后,应用程序将看起来是什么样子。

图 4.4 我们向应用程序添加第二个微服务和外部云存储。
图 4.4 显示了视频存储微服务将作为视频流微服务和外部云存储之间的中介。在本节的结尾,我们将更多地讨论这些微服务分离背后的原因。现在,只需满足于这个借口:这是介绍我们的第二个微服务的充分理由,因此,我们将正式运行一个微服务应用程序(尽管是一个小的应用程序)。
4.4.1 使用 Azure 存储
Azure 存储是微软提供的一种云存储服务。我们将使用它来为我们的应用程序添加存储功能。您应该已经从第三章的工作中拥有一个 Azure 账户,在本节中,我们将返回 Azure,创建一个存储账户,并上传我们的测试视频。然后我们将创建一个新的微服务,其目的是从存储中检索视频。
定义 Azure 存储 是一种微软 Azure 服务,用于在云中托管私有或公共文件。您将文件上传到 Azure 存储,然后可以通过 Azure 存储 API 访问这些文件。
虽然我们可以在 Azure 存储上托管私有和公共文件,但我们将使用私有选项。我们不希望任何人都能随意从存储中下载我们的视频。相反,我们希望他们通过前端进行操作。我们为新的微服务编写的代码通过 Azure 进行身份验证,并使用官方的 Azure Storage SDK for JavaScript(通过 npm 提供)检索视频。
为什么选择 Azure 存储?
我们有很多文件存储选项,为什么选择 Azure 存储?事实是,我们同样可以轻松地使用 AWS S3 或 Google Cloud Storage。对于本书中的我们的目的来说,这并没有太大的区别。我们编写的代码当然会有所不同,因为如果我们使用不同的云服务提供商,我们就必须使用不同的存储 API。
注意:本章的示例展示了使用 Azure 的外部云存储。在这种情况下,Azure 并没有什么特别之处。使用不同的 API,代码看起来会有所不同,但微服务的结构基本上是相同的。
对于我们来说,使用 Azure 很方便,因为您在上一个章节中已经注册了它。然而,没有必要被锁定在 Azure 上。
我们正在实施的架构的一个优点是,我们可以轻松地替换我们的 Azure 存储微服务,并用替代品替换它。我们甚至可以在我们的应用程序在生产中运行时这样做!从这个意义上说,您可以将这个视频存储微服务视为热插拔。
创建 Azure 存储帐户
在我们将测试视频放入存储之前,我们必须创建一个 Azure 存储帐户。为此,您需要登录到 Azure 门户portal.azure.com/,就像在第三章中做的那样。然后在左侧菜单中,点击创建资源并搜索“存储帐户”,如图 4.5 所示。

图 4.5 在 Azure 门户中创建新的存储帐户
点击 Microsoft 的存储帐户选项,然后点击创建。您现在可以填写您新存储帐户的详细信息,如图 4.6 所示。
您需要选择一个资源组。为此,您可以使用第三章中创建的资源组,或者您可以点击“创建新”来创建一个新的资源组。然后您需要为您的存储帐户选择一个名称。

图 4.6 填写新存储帐户的详细信息
其他设置可以保留默认值。填写完详细信息后,点击“审查 + 创建”。如果详细信息通过验证,您就可以点击“创建”来创建存储帐户。如果它们没有通过验证,那么您需要按照指示修复问题。
现在,等待直到您收到通知,表示您的存储帐户已部署。在那个时刻,您可以在通知中点击“转到资源”,或者您可以在全局列表中找到您的资源,就像在第三章中做的那样。
一旦您在 Azure 门户中打开存储帐户,点击左侧菜单中的访问密钥。在这里,您将看到与图 4.7 所示类似的存储帐户访问密钥。这些是您需要与存储帐户进行身份验证的详细信息。记下您的存储帐户名称和一个密钥。您只需要一个密钥的值。您不需要连接字符串。
注意,图 4.7 显示了来自我的帐户的密钥。您需要查找您自己帐户的密钥。这些密钥当然与我的密钥不同,而且到您阅读这篇文章的时候,我的密钥可能已经不再有效了。

图 4.7 查看我们新存储帐户的认证详情
将您的视频上传到 Azure 存储
在我们的存储帐户创建完成后,我们现在可以上传我们的测试视频。在 Azure 门户中,当您的存储帐户打开时,点击左侧菜单中的容器。您应该会看到一个如图 4.8 所示的消息,表示您还没有任何容器。
顺便说一句,为了避免混淆,我需要说明,我们在这里讨论的容器与我们运行在微服务应用程序中的容器不是同一个。Azure 存储中的容器就像一个目录;它是一个存储文件的地点。

图 4.8 导航到容器并创建我们的视频容器
在工具栏中点击 + 容器按钮以创建你的第一个容器。现在为你的容器输入一个名称。在这个阶段你可以给它起任何名字,但为了与即将出现的示例代码兼容,让我们称它为 videos。在这里你还可以选择访问级别,但我们将保持默认设置,即仅私有访问。接下来,点击确定以创建容器。
现在你应该能在列表中看到视频容器。点击它以深入查看。在查看你新容器的内容时,你会看到一个如图 4.9 所示的消息。如果你想知道什么是 blob,它只是一个文件,我们目前还没有任何这样的文件。现在让我们上传一个。
在工具栏中点击上传按钮以上传你的视频文件,并从你的磁盘上选择一个文件进行上传。你可以使用第二章或第三章代码库中包含的测试视频;否则,使用你自己的选择。

图 4.9 深入视频容器并点击上传以上传视频文件
视频上传后,它将如图 4.10 所示出现在列表中。

图 4.10 视频上传后,你将在视频容器下看到它
创建一个读取 Azure 存储的微服务
我们现在已经在 Azure 存储中上传了一个测试视频,因此是时候创建我们的新视频存储微服务了。这是我们第二个官方微服务,它将是一个 REST API,用于从我们的存储提供商检索视频。
注意 我们可以直接将我们的视频流微服务与云存储集成,但我们将把这个连接抽象化,通过另一个微服务来实现。这使得以后替换存储机制变得非常简单,并为我们的应用程序支持多个存储提供商铺平道路。
我们需要做的第一件事是为我们的第二个微服务创建一个新的目录。你应该创建一个新的子目录,或者只需将 chapter-4 代码库中的 example-2 加载到 VS Code 中。我们将为新微服务的子目录命名为 azure-storage. 我们特意这样命名新项目,以表明其目的与 Azure 存储相关。如果我们要添加不同的存储提供商,我们将使用不同的名称(例如,aws-storage 或 google-storage)。
简要说明:如果你在考虑将此处展示的代码移植到 AWS 或 GCP,将 Azure 存储微服务转换为其他提供商并不是一个简单的任务。与 Azure 相比,与 AWS 和 GCP 存储接口的 API 将相当不同,你需要单独阅读它们的文档来了解如何使用这些 API。确保你在尝试转换为任何其他提供商之前,已经在本章中完成了对 Azure 存储微服务的学习。
现在打开一个终端并切换到 azure-storage 目录。如果你是从头创建新的微服务,你需要创建一个新的 package.json 并安装 express 包,就像我们在第二章中所做的那样。然后你需要像这样安装 azure-storage 包:
npm install --save azure-storage
如果你正在跟随第四章代码仓库中的 example-2,你需要的一切已经准备好了:
-
包文件
-
代码
-
Dockerfile
要在 Node.js 下直接运行新微服务,你首先需要切换到 azure-storage 目录并安装依赖项:
npm install
列表 4.2 展示了我们新微服务的代码。在我们运行此代码之前,让我们先阅读并理解它在做什么。
列表 4.2 从 Azure 存储检索视频的微服务(chapter-4/example-2/azure-storage/src/index.js)
const express = require("express");
const azure = require('azure-storage'); ①
const app = express();
const PORT = process.env.PORT;
const STORAGE_ACCOUNT_NAME =
➥ process.env.STORAGE_ACCOUNT_NAME; ②
const STORAGE_ACCESS_KEY =
➥ process.env.STORAGE_ACCESS_KEY; ③
function createBlobService() { ④
const blobService = azure.createBlobService(STORAGE_ACCOUNT_NAME,
➥ STORAGE_ACCESS_KEY);
return blobService;
}
app.get("/video", (req, res) => { ⑤
const videoPath = req.query.path; ⑥
const blobService = createBlobService(); ⑦
const containerName = "videos"; ⑧
blobService.getBlobProperties(containerName,
➥ videoPath, (err, properties) => { ⑨
if (err) {
// ... error handling ...
res.sendStatus(500);
return;
}
res.writeHead(200, { ⑩
"Content-Length": properties.contentLength,
"Content-Type": "video/mp4",
});
blobService.getBlobToStream(containerName,
➥ videoPath, res, err => { ⑪
if (err) {
// ... error handling ...
res.sendStatus(500);
return;
}
});
});
});
app.listen(PORT, () => {
console.log(`Microservice online`);
});
① 加载 azure-storage 包,以便我们的代码可以与 Azure Storage API 交互
② 从环境变量中获取存储账户的名称
③ 从环境变量中获取访问键的名称
④ 连接到 azure-storage API 的辅助函数
⑤ 用于从 Azure 存储检索视频的 HTTP GET 路由
⑥ 指定存储中视频的路径作为 HTTP 查询参数
⑦ 连接到 azure-storage API
⑧ 固定编码的容器名称。稍后我们可以根据某些目的(例如,通过用户 ID,以便我们可以为每个用户分别保存视频)来更改此名称。
⑨ 从 Azure 存储检索视频的属性
⑩ 将内容长度和 MIME 类型写入 HTTP 响应头
⑪ 从 Azure 存储流式传输视频到 HTTP 响应
在列表 4.2 中,我们使用azure-storage,这是通过 npm 安装的官方 Azure Storage SDK。我们同样使用 Express 创建了一个 HTTP 服务器,就像我们在第二章中所做的那样。
有两个新的环境变量用于配置此微服务;STORAGE_ACCOUNT_NAME 和 STORAGE_ACCESS_KEY 设置了我们的 Azure 存储账户的认证详情。请注意,你必须将这些环境变量设置为来自你自己的存储账户的认证详情。你将在下一节中这样做。认证详情用于辅助函数createBlobService以创建我们需要访问存储 SDK 的 API 对象。
列表 4.2 中最重要的是 HTTP GET 路由/video,通过它可以从存储中检索视频。此路由从 Azure 存储流式传输视频到 HTTP 响应。
独立测试我们的新微服务
在我们尝试将此微服务集成到我们的应用程序之前,最好先独立测试它。在这种情况下,我们可以先集成它,然后再进行测试。当我们的应用程序如此之小的时候,这样工作是可以行的。然而,随着我们的应用程序变得更大、更复杂,集成测试就变得更加困难。
单独测试微服务效果更好,因为我们可以快速启动或重新加载单个微服务。但对于整个应用程序来说,这样做并不容易。因此,让我们养成在集成测试整个应用程序之前先单独测试我们的微服务的习惯。
在运行(并测试)新微服务之前,我们需要设置环境变量来配置它。我们将从终端进行此操作。在 MacOS 和 Linux 上,我们这样做:
export PORT=3000
export STORAGE_ACCOUNT_NAME=<the name of your storage account>
export STORAGE_ACCESS_KEY=<the access key for your storage account>
在 Windows 上,我们这样做:
set PORT=3000
set STORAGE_ACCESS_KEY=<the name of your storage account>
set STORAGE_ACCESS_KEY=<the access key for your storage account>
注意,你必须插入你之前创建的存储账户的名称和密钥。当运行微服务时,我们可以选择以生产模式或开发模式运行,正如我们在第二章中讨论的那样。我们可以像这样以生产模式运行:
npm start
或者,我们可以以开发模式运行它,使用 nodemon 进行实时重新加载,如下所示:
npm run start:dev
实时重新加载对于快速开发来说非常重要,因为我们可以在代码中做出更改,并让微服务自动重启。在下一章中,你将学习如何将实时重新加载扩展到整个微服务应用程序。现在,我们将满足于在开发和测试单个微服务时使用它。
当你的微服务正在运行时,你现在可以打开你的浏览器并导航到 http://localhost:3000/video?path=SampleVideo_1280x720_1mb.mp4。如果你使用了不同的视频名称,你需要调整此 URL 中的视频名称以适应。你现在应该看到熟悉的视频正在播放,但这次是从你的 Azure 存储账户流式传输的。
我们将在第八章中更多地讨论微服务的测试。不过,现在让我们继续前进,将我们的新微服务集成到应用程序中。
4.4.2 更新视频流微服务
将新微服务集成到我们的应用程序中的第一步是更新我们的视频流微服务。作为提醒,我们在第三章结束时结束了一个从文件系统加载测试视频的视频流微服务。现在,我们将更新该微服务,使其委托视频加载到我们新的 Azure 存储微服务。
在这里,我们更新我们的视频流微服务,将存储委托给另一个微服务。我们正在分离关注点,这样视频流微服务就只负责将视频流式传输给我们的用户,并且它不需要知道存储处理的细节。
列表 4.3 展示了我们将对视频流微服务所做的更改。阅读列表中的代码,以了解我们是如何将视频的 HTTP 请求转发到新的视频存储微服务的。
列表 4.3 更新的视频流微服务(第四章/示例 2/video-streaming/src/index.js)
const express = require("express");
const http = require("http"); ①
const app = express();
const PORT = process.env.PORT;
const VIDEO_STORAGE_HOST =
➥ process.env.VIDEO_STORAGE_HOST; ②
const VIDEO_STORAGE_PORT = ②
➥ parseInt(process.env.VIDEO_STORAGE_PORT); ②
app.get("/video", (req, res) => {
const forwardRequest = http.request( ③
{
host: VIDEO_STORAGE_HOST, ④
port: VIDEO_STORAGE_PORT, ④
path:'/video?path=
➥ SampleVideo_1280x720_1mb.mp4', ⑤
method: 'GET', ⑥
headers: req.headers ⑦
},
forwardResponse => { ⑧
res.writeHeader(forwardResponse.statusCode,
➥ forwardResponse.headers); ⑨
forwardResponse.pipe(res); ⑩
}
);
req.pipe(forwardRequest); ⑪
});
app.listen(PORT, () => {
console.log(`Microservice online`);
});
① 加载(内置)http 库以便我们可以转发 HTTP 请求
② 配置连接到视频存储微服务
③ 将 HTTP GET 请求转发到视频存储微服务的视频路由
④ 设置我们要转发的目标主机和端口
⑤ 设置我们要转发的路由
⑥ 将请求作为 HTTP GET 请求转发
⑦ 原样转发 HTTP 头部信息
⑧ 获取转发请求的响应
⑨ 返回转发请求的状态码和头部信息
⑩ 使用 Node.js 流管道响应流
⑪ 使用 Node.js 流管道请求流
在列表 4.3 中,我们使用 Node.js 内置的 http 库将一个微服务的 HTTP 请求转发到另一个微服务。然后返回的响应被流式传输到客户端。这种工作方式可能难以理解,但现在不必过于担心。在下一章中,我们将更深入地探讨这一点,因为微服务之间的通信非常重要,值得单独成章。
注意,我们目前硬编码了存储中视频的路径。这只是一个过渡步骤,我们很快就会解决这个问题。但为了使这段代码在同时工作,你必须已经将测试视频上传到这个路径。如果你上传了不同的视频,你应该相应地更改代码。
在更新我们的视频流微服务后,我们应该独立对其进行测试。鉴于它依赖于视频存储微服务,这有点困难。如果我们有模拟依赖项的工具和技术,我们就可以这样做。
模拟是测试中使用的技巧,其中我们用假或模拟的替代品替换依赖项。我们目前还没有这些技术,但这是我们在第八章中将要探讨的内容,你将在第九章中看到一个模拟微服务的示例。现在,让我们继续前进并完成集成。然后我们可以检查应用程序是否按预期工作。
4.4.3 将我们的新微服务添加到 Docker Compose 文件中
我们已经为此做了很多工作。我们创建了一个 Azure 存储账户,并上传了我们的测试视频。然后我们创建了第二个微服务,即 Azure 存储微服务,它是一个抽象我们的存储提供程序的 REST API。之后,我们更新了视频流微服务,使其不再像第二章和第三章那样从文件系统加载视频,而是现在通过视频存储微服务检索视频。
注意,Docker Compose 文件的优点在于它使得定义和管理一系列容器变得更加容易。这是一种管理微服务应用程序的便捷方式!
为了将新的微服务集成到我们的应用程序中并进行测试,我们现在必须将其添加到我们的 Docker Compose 文件的新部分中。您可以在图 4.11 中看到它的样子,该图显示了在添加第二个微服务和数据库服务器后 Docker Compose 文件将看起来如何。您可以看到左侧的 Docker Compose 文件有三个部分,对应右侧的三个容器。

图 4.11 我们的 Docker Compose 文件中的每个部分定义了一个独立的容器。
您可以将 Docker Compose 文件视为一种 聚合 Dockerfile,我们用它来描述和管理多个容器。它之所以是聚合的,是因为我们用它将每个微服务的多个 Dockerfile 连接起来。
列表 4.4 显示了我们的更新后的 Docker Compose 文件,其中添加了 Azure 存储微服务。在我们进行测试之前,请确保将 STORAGE_NAME 和 STORAGE_ACCESS_KEY 的值设置为之前从您自己的 Azure 存储账户中记录的值。
列表 4.4 向我们的 Docker Compose 文件中添加新的微服务(第四章/示例 2/docker-compose.yaml)
version: '3'
services:
azure-storage: ①
image: azure-storage ②
build:
context: ./azure-storage
dockerfile: Dockerfile
container_name: video-storage ③
ports:
- "4000:80"
environment:
- PORT=80
- STORAGE_ACCOUNT_NAME=<your storage account> ④
- STORAGE_ACCESS_KEY=<your storage access key> ④
restart: "no"
video-streaming:
image: video-streaming
build:
context: ./video-streaming
dockerfile: Dockerfile
container_name: video-streaming
ports:
- "4001:80"
environment:
- PORT=80
- VIDEO_STORAGE_HOST=video-storage ⑤⑥
- VIDEO_STORAGE_PORT=80 ⑥
restart: "no"
① 将新的微服务添加到我们的应用程序中
② 设置图像名称
③ 容器的名称将这些两个微服务连接起来!
④ 配置微服务以连接到我们的 Azure 存储账户。请确保在此处添加您自己的详细信息。
⑤ 容器的名称将这些两个微服务连接起来!
⑥ 配置微服务以连接到视频存储微服务
在这一点上,你可能心中会有一些疑问:为什么容器名称设置为 video-storage 而不是 azure-storage?我们称微服务为 azure-storage,但容器名称却是 video-storage;这是为什么?这是一个故意的抽象。这是我们的设计的一部分,视频流微服务并不关心它从哪里获取视频!它对视频存储在 Azure 的事实不感兴趣。从它的角度来看,这些视频同样可以存储在任何其他地方,比如 AWS S3 或 Google Cloud Storage。
通过将我们的容器命名为 video-storage,我们现在能够使用一个与底层存储提供者无关的名称将我们的微服务连接到它。这是一种良好的应用程序结构实践。我们为自己提供了灵活性,以便将来能够替换 azure-storage 并用 aws-storage 或 google-storage 替换它。而且我们可以这样做而不中断视频流微服务。从它的角度来看,什么都没有改变。这种在未来进行更改而不产生连锁反应的自由很重要,这也表明我们正在充分利用我们的微服务架构。
4.4.4 测试更新后的应用程序
我们已经更新了我们的 Docker Compose 文件,以包含我们的两个微服务。现在我们终于准备好启动我们的应用程序,并使用我们的附加微服务进行测试。为此,我们像以前一样运行我们的应用程序:
docker-compose up --build
现在的区别是我们启动了两个容器,而不仅仅是单个容器。你可以在下面的示例中看到输出示例:
video-streaming | > example-1@1.0.0 start /usr/src/app
video-streaming | > node ./src/index.js
video-streaming |
video-storage |
video-storage | > example-1@1.0.0 start /usr/src/app
video-storage | > node ./src/index.js
video-storage |
video-streaming | Forwarding video requests to video-storage:80.
video-streaming | Microservice online.
video-storage | Serving videos from...
video-storage | Microservice online.
注意输出中每个容器的名称都打印在左侧。这是来自所有容器的日志聚合流。左侧的名称允许我们区分每个微服务的输出。
注意:我们使用单个命令启动多个容器来运行我们的应用程序,这样我们就可以使用多个微服务来测试我们的应用程序。
现在我们已经添加了第二个微服务,这是我们看到 Docker Compose 真正价值的开始。我们可以在以下两种方式中启动应用程序而不使用 Docker Compose:
-
打开两个终端,并直接使用 Node.js 在一个终端中运行视频流微服务,在另一个终端中运行 Azure 存储微服务。 这意味着两个终端和两个命令来运行我们的应用程序。
-
使用 Docker 运行两个容器。 在这种情况下,我们必须为每个微服务分别运行
docker build和docker run。这意味着一个终端和四个命令。
没有人愿意整天重复输入命令。相反,Docker Compose 允许我们使用单个命令启动我们的应用程序,并且这可以扩展到任意数量的容器。
想象一下将来;我们已经用多达 10 个微服务构建了我们的应用程序。如果没有 Docker Compose,你至少需要输入 20 个命令来构建和启动你的应用程序。有了 Docker Compose,我们可以用单个命令构建和运行我们的 10 个微服务应用程序!无论我们需要多少个容器,它仍然是单个命令。
到目前为止,我们有两次测试的机会。至少,我们必须测试视频流微服务,因为目前这是我们唯一的面向客户的端点。为此,打开浏览器并导航到 http://localhost:4001/video。
再次,你会看到熟悉的测试视频。测试视频流微服务实际上测试了两个微服务,因为视频流微服务依赖于视频存储微服务。这两个微服务同时被测试。我们可以在这里停止,但也可以独立测试视频存储微服务。
如果你回顾一下列表 4.4,你会看到我们已经将其端口绑定到了 4000。我们可以将浏览器导航到该端口,并直接从视频存储微服务中查看视频流。然而,视频存储微服务期望我们告诉它视频所在的位置。我们通过 URL 来完成这个操作。让我们将浏览器导航到 http://localhost:4000/video?path=SampleVideo_1280x720_1mb.mp4 并测试视频存储微服务。
注意,像这样从外部测试内部微服务只可能在开发中实现。一旦我们将这个微服务移到生产环境,它的 REST API 只在 Kubernetes 集群内部可用。在这种情况下,我们将使其私有,因为我们不希望外部世界直接访问我们的视频存储。这是微服务的一个安全特性!我们可以控制哪些微服务暴露给外部世界,我们可以利用这一点来限制外部人士直接访问的应用程序部分。我们将在第十一章中更多地讨论安全性。
好吧,这就是我们做到的。我们给我们的应用程序添加了外部文件存储,在这个过程中,我们将它扩展到了两个微服务。然而,在我们自我祝贺之前,让我们考虑一些设计理论。
4.4.5 云存储与集群存储的比较
到目前为止,如果你对 Kubernetes 有所了解,你可能会想知道为什么我们没有使用 Kubernetes 卷来存储文件,而不是使用云存储。这是一个重要的设计决策,而且,这又是一种取决于你的项目、你的业务和你的客户需求的事情。
我们使用云存储而不是集群存储,因为它简单,我们在开发中运行时它有效,它便宜,而且有人为我们管理。这些都是云存储的好处,也是为什么许多公司普遍使用它的原因。此外,我们还没有学习关于 Kubernetes 的任何知识,所以我们肯定不能在本书的这个阶段使用 Kubernetes 卷。然而,还有一个重要的原因,为什么我通常选择使用云存储而不是集群存储。
我们可以将我们应用程序的文件和数据存储在 Kubernetes 集群中,但我更喜欢我的生产集群是无状态的。这意味着我可以随意销毁和重建集群,而不会丢失数据。稍后,这将使我们能够为我们的生产部署使用蓝绿部署,我们将在第十一章中讨论这一点。这使得构建与旧版本并行运行的新和更新实例变得容易。
为了将我们的客户升级到新版本,我们然后可以切换 DNS 记录,这样主机名现在就指向了新实例。这为我们提供了低风险的方式来对我们的应用程序进行重大升级。它低风险并不是因为不会出现问题,而是因为如果出现问题,我们可以快速将 DNS 切换回旧实例,这样我们的客户就可以(几乎)立即恢复到之前的(并且可能是有效的)版本。
4.4.6 我们取得了什么成果?
恭喜!我们现在有一个小型的微服务应用程序在运行!这是一个大事件。使用 Docker Compose,我们创建了一个支架,我们可以轻松地添加新的微服务并扩展我们的应用程序。花点时间给自己鼓掌。这是一个重要的里程碑!
我们取得了什么成果?我们向应用程序添加了文件存储功能。我们的微服务现在具有将文件存储在外部云存储中的能力,这为我们的应用程序提供了一个托管视频的地方。
我们还添加了第二个微服务。由于 Docker Compose 已经到位,我们现在可以通过向其中添加新的容器来继续扩展我们的应用程序。当我们向应用程序添加数据库服务器时,我们将在稍后再次利用这种能力。
我们添加了第二个微服务,作为我们的存储提供者的抽象。这是一个有利的决策设计。现在我们可以用不同的存储提供者替换和替换我们的视频存储微服务,而对应用程序的影响最小。我们甚至可以在应用程序在生产环境中运行时这样做!未来甚至可能需要同时运行多个存储微服务。如果适合我们的产品,我们可以将其升级以支持 Azure 存储、AWS S3 和 Google Cloud Storage!
存储工作细节已被限制在视频存储微服务的内部。这意味着我们可以独立于应用程序的其他部分更改这些细节,而不会引起连锁问题。这种保护可能现在看起来是多余的,但随着我们应用程序的增长,它变得更为重要。
注意:最终,我们的应用程序将成为许多微服务之间通信的蜘蛛网。一个微服务的更改有可能在应用程序中引起指数级的问题连锁。仔细构建微服务之间的接口以最小化它们的耦合,有助于我们充分利用微服务架构。
将我们的微服务分离,即所谓的关注点分离(在第一章中提到),很重要——每个微服务应该负责其自己的独立责任区域。我们也在遵循单一责任原则(也在第一章中提到),即每个微服务应该负责一件事情。我们的微服务现在负责它们自己的责任区域:
-
视频流微服务负责将视频流式传输到用户。
-
视频存储微服务负责在存储中定位视频并检索这些视频。
以这种方式分离微服务有助于确保每个微服务都小、简单且易于管理。
4.5 向我们的应用程序添加数据库
数据管理的另一半与数据库有关。大多数应用程序需要某种类型的数据库来存储它们的动态数据,FlixTube 也不例外。
我们首先需要为每个视频存储元数据。我们将通过存储每个视频的路径来开始使用我们的数据库。这将解决我们之前遇到的问题,即在我们的视频流微服务中有一个硬编码的视频文件路径。
注意 实际上,几乎所有应用程序都需要某种类型的数据库来存储应用程序将要更新的数据。
图 4.12 显示了添加数据库后我们的应用程序将看起来是什么样子。除了我们两个微服务的两个容器外,我们还将有一个容器来托管 MongoDB 数据库。您可以在图中看到,只有视频流微服务连接到数据库;视频存储微服务不需要数据库。

图 4.12 将数据库添加到我们的应用程序中
4.5.1 为什么选择 MongoDB?
MongoDB 是所谓 NoSQL 数据库中最受欢迎的一种。使用 Docker 允许我们几乎拥有一个“即时数据库”。我们只需要指定数据库镜像的名称,Docker 就会从 DockerHub 中拉取它并在我们的开发工作站上实例化它。
注意 MongoDB 易于使用,提供了一种灵活的数据库,可以存储无模式的结构化数据,并且具有丰富的查询 API。
但是,我们可以轻松启动许多不同的数据库,为什么选择 MongoDB 呢?根据我的经验,即使是手动下载和安装 MongoDB 也比旧的和更传统的数据库要容易;现在有了 Docker,这甚至更容易。像任何数据库一样,我们可以使用 MongoDB 来存储丰富的结构化数据。MongoDB 也以其高性能和极高的可扩展性而闻名。
我处理很多不可预测的数据,很难预测接下来会抛给我什么。我喜欢 MongoDB 不强迫我定义固定模式的事实!尽管如此,如果您使用像 Mongoose (www.npmjs.com/package/mongoose) 这样的对象关系映射(ORM)库,当然也可以使用 MongoDB 定义模式。
MongoDB 也易于在许多不同的编程语言中进行查询和更新。它得到了良好的支持,拥有优秀的文档,并且有大量的示例在流传。MongoDB 是开源的。您可以在以下位置找到代码:github.com/mongodb/mongo。
4.5.2 在开发中添加数据库服务器
我们打算使用 Docker Compose 在开发中的应用程序中添加一个数据库,就像我们在本章前面添加视频存储微服务时所做的那样。我们将为我们的应用程序添加一个新容器来托管单个数据库服务器。我们只需要一个服务器,但可以在该服务器上托管多个数据库。这意味着我们为未来做好了准备,可以轻松地随着我们向应用程序添加更多微服务而创建更多数据库。
将数据库服务器添加到 Docker Compose 文件中
要将数据库服务器添加到我们的应用程序中,我们必须更新我们的 Docker Compose 文件。Docker Compose 使得将数据库添加到我们的应用程序变得容易。我们只需在 Docker Compose 文件中添加几行,以指定数据库的公共 Docker 镜像并设置一些配置。Abracadabra,即时数据库!
列表 4.5 显示了更新的 Docker Compose 文件。我们在文件顶部添加了一个名为db(代表数据库)的新部分。这个容器的配置与我们之前添加的微服务的配置不同。这是因为现在我们不需要为新容器构建镜像。相反,我们使用从 Docker Hub 公开发布的mongo镜像。
列表 4.5 添加 MongoDB 数据库(chapter-4/example-3/docker-compose.yaml)
version: '3'
services:
db: ①
image: mongo:4.2.8 ②
container_name: db ③
ports:
- "4000:27017" ④
restart: always ⑤
azure-storage:
image: azure-storage
build:
context: ./azure-storage
dockerfile: Dockerfile
container_name: video-storage
ports:
- "4001:80"
environment:
- PORT=80
- STORAGE_ACCOUNT_NAME=<your Azure storage account name here>
- STORAGE_ACCESS_KEY=<your Azure storage account key here>
restart: "no"
video-streaming:
image: video-streaming
build:
context: ./video-streaming
dockerfile: Dockerfile
container_name: video-streaming
ports:
- "4002:3000"
environment:
- PORT=80
- DBHOST=mongodb://db:27017 ⑥
- DBNAME=video-streaming ⑦
- VIDEO_STORAGE_HOST=video-storage
- VIDEO_STORAGE_PORT=80
restart: "no"
① 将 MongoDB 数据库服务器添加到我们的微服务应用程序中
② 设置镜像名称和版本。这是一个从 Docker Hub 检索的公共 MongoDB 镜像。
③ 设置在应用程序中实例化的容器名称。我们的微服务使用此名称连接到数据库。
④ 将 MongoDB 标准端口 27017 映射到我们的主机 OS 上的 4000。我们可以使用端口 4000 与主机上的数据库交互和检查。
⑤ 设置重启策略为始终。如果 MongoDB 崩溃(这几乎从未发生),它会自动重启。
⑥ 配置微服务以连接到数据库
⑦ 设置微服务用于其数据库的名称
在我们的更新后的应用程序中,视频流微服务将连接到数据库。请注意,我们现在有了新的环境变量,DBHOST 和 DBNAME,它们配置了微服务与其数据库的连接。
还值得注意在 db 容器的配置中,我们如何映射容器的端口。在这里,我们将标准的 MongoDB 端口 27017 映射到 4000。这意味着什么?在 Docker 运行时,其他容器可以使用 27017 访问数据库。这是 MongoDB 的传统端口,所以我们将坚持使用它。
在我们的主机操作系统(OS)上,我们将端口映射到 4000。这是一个任意的选择。我们可以给它任何数字,包括 27017。我更喜欢不使用标准的 MongoDB 端口,因为这可能会与我们在主机 OS 上可能运行的 MongoDB 实例冲突。
这是一个良好的设置。我们的应用程序可以通过标准端口与 MongoDB 交互,但我们可以使用工具(正如我们很快将看到的)直接从我们的开发工作站查询和编辑我们的数据库。这对于开发来说非常好,因为它使我们能够直接与数据库交互和查询。
更新视频流微服务以使用数据库
我们在我们的 Docker Compose 文件中添加了环境变量,以便将我们的视频流微服务连接到其数据库。现在我们需要更新这个微服务的代码,以便使用这些环境变量建立数据库连接。
列表 4.6 显示了更新后的视频流微服务的代码,允许它查询和读取数据库中的数据。浏览此代码,注意它与之前的版本有何不同。
列表 4.6 更新微服务以使用数据库(chapter-4/example-3/video-streaming/src/index.js)
const express = require("express");
const http = require("http");
const mongodb = require("mongodb"); ①
const app = express();
const PORT = process.env.PORT;
const VIDEO_STORAGE_HOST = process.env.VIDEO_STORAGE_HOST;
const VIDEO_STORAGE_PORT = parseInt(process.env.VIDEO_STORAGE_PORT);
const DBHOST = process.env.DBHOST; ②
const DBNAME = process.env.DBNAME; ③
function main() { ④
return mongodb.MongoClient.connect(DBHOST) ⑤
.then(client => {
const db = client.db(DBNAME); ⑥
const videosCollection =
➥ db.collection("videos"); ⑦
app.get("/video", (req, res) => {
const videoId =
➥ new mongodb.ObjectID(req.query.id); ⑧
videosCollection
➥ .findOne({ _id: videoId }) ⑨
.then(videoRecord => {
if (!videoRecord) {
res.sendStatus(404); ⑩
return;
}
const forwardRequest = http.request(
{
host: VIDEO_STORAGE_HOST,
port: VIDEO_STORAGE_PORT,
path:`/video?path=${videoRecord
➥ .videoPath}`, ⑪
method: 'GET',
headers: req.headers
},
forwardResponse => {
res.writeHeader(forwardResponse.statusCode,
➥ forwardResponse.headers);
forwardResponse.pipe(res);
}
);
req.pipe(forwardRequest);
})
.catch(err => {
console.error("Database query failed.");
console.error(err && err.stack || err);
res.sendStatus(500);
});
});
app.listen(port, () => {
console.log(`Microservice online.`);
});
});
} ④
main() ⑫
.then(() => console.log("Microservice online."))
.catch(err => {
console.error("Microservice failed to start.");
console.error(err && err.stack || err);
});
① 加载 MongoDB 库,以便微服务可以连接到其数据库
② 指定要连接的数据库服务器
③ 设置此微服务用于其数据库的名称
④ 将此微服务的主体包装在 main 函数中。这是此微服务的主要入口点。你能看出我是一名恢复中的 C++程序员吗?
⑤ 连接到数据库服务器
⑥ 获取此微服务使用的数据库
⑦ 获取存储每个视频元数据的视频集合
⑧ 通过 HTTP 查询参数指定视频 ID。这是一个 MongoDB 文档 ID。
⑨ 通过请求的 ID 查询数据库以找到单个视频
⑩ 视频未找到!以 HTTP 404 错误代码响应
⑪ 在将 HTTP 请求转发到视频存储微服务时,将视频的 ID 映射到视频的位置
⑫ 启动微服务
列表 4.6 通过视频 ID 查询其数据库以检索存储中视频的位置。然后它将此位置传递给视频存储微服务以检索存储在那里的视频。这里的其他代码应该很熟悉。我们正在将视频的 HTTP 请求转发到视频存储微服务。
对视频流微服务的这次更新移除了硬编码的视频路径。相反,我们现在通过数据库 ID 来引用视频。我们本可以不使用 ID 来解决这个问题。我们可以简单地通过存储中的路径来引用视频。但正如你可能怀疑的那样,这并不是一个好主意。让我们考虑一下原因。
如果我们用路径来标识我们的视频,那么在将来如果我们决定想要重新结构化我们的存储文件系统时,将视频移动到不同的位置就会变得困难。这个问题之所以存在,是因为各种其他数据库和记录都需要引用我们的视频。这包括一个元数据数据库,用于记录有关视频的信息,例如其类型。我们稍后还想要一个数据库来记录每个视频的推荐和观看情况。
每个数据库都必须有一种方式来引用视频。如果我们只为每个视频记录 ID,我们就有更多的自由来独立更改我们的存储,而不会导致任何糟糕的问题在我们的微服务和数据库中蔓延。
这也使得事情变得稍微简单一些,因为视频的位置可能是一个很长的路径,而像这样的内部细节通常我们不希望泄露到我们的应用程序中。为什么?暴露这样的细节,暗示内部结构,可能会给潜在的攻击者带来优势。最好是将这类信息保密。
在我们的数据库中加载一些测试数据
我们已经在 Docker Compose 文件中添加了一个数据库,并且我们已经更新了视频流微服务以使用该数据库。我们几乎准备好测试我们的更改了!
为了测试我们的更新代码,我们现在必须将一些测试数据加载到我们的数据库中。稍后,我们将为我们的用户提供上传他们自己的视频并使用相关细节填充数据库的方法,但我们的应用程序中还没有实现这一功能。
我们可以通过用某种模拟的数据库版本替换它来测试我们的代码。我指的是模拟数据库。(我们之前在本章中已经讨论过模拟。)我们还可以通过使用数据库固定数据来实现这一点,这是一种仅用于测试而加载到我们的数据库中的测试数据。
我们有各种方法可以将数据加载到我们的数据库中。最简单的方法是使用 Robo 3T(之前称为 Robomongo)。这是一个用于处理 MongoDB 的出色 UI 工具。我自己一直在使用它,如果你读过我的第一本书《使用 JavaScript 数据整理》(Manning,2018),你应该已经知道了。它适用于 Windows、MacOS 和 Linux。
有关 Robo 3T 的下载和安装说明,请参阅robomongo.org/。Robo 3T 允许您查看数据库中的集合和文档。您可以轻松创建数据库、集合和数据记录。
但在我们能够使用 Robo 3T 将示例数据加载到我们的数据库之前,我们首先必须确保我们的数据库正在运行。我们可以通过启动我们的应用程序来实现这一点。如果你还没有这样做,请打开一个终端并启动你的应用程序:
docker-compose up --build
注意:您应该从列表 4.5 中的更新 Docker Compose 文件相同的目录运行此命令。您可以在第四章代码存储库的 example-3 子目录中找到此文件。
启动我们的应用程序后,我们现在有一个 MongoDB 数据库服务器在容器中运行。由于我们将标准 MongoDB 端口 27017 映射到我们的开发工作站上的端口 4000,我们现在可以通过连接到 localhost:4000 来从 Robo 3T 访问数据库。
列表 4.7 显示了我们将使用 Robo 3T 添加到数据库中的测试数据。这是一个位于 example-3 目录下的单个 JSON 文档,适合使用 Robo 3T 进行复制粘贴插入。
要使用 Robo 3T 加载数据,请打开该应用程序,创建一个名为 video-streaming 的新数据库,创建一个名为 videos 的集合,然后在该集合中插入一个文档。为了我们的目的,使用此列表中的内容。
列表 4.7 使用 Robo 3T 加载数据记录(第四章/示例 3/db-fixture/videos.json)
{ ①
"_id" : { "$oid": "5d9e690ad76fe06a3d7ae416" }, ①②
"videoPath" : "SampleVideo_1280x720_1mb.mp4" ①③
} ①
① 要加载到数据库中的数据记录
② 将视频的 ID 设置为 MongoDB 文档的 ID 的特殊语法
③ 设置视频的位置
我们将在第八章中回到模拟和数据库固定数据。现在,让我们看看如何测试我们的应用程序。
测试我们的更新应用程序
在这个阶段,如果您愿意,可以先直接在 Node.js 下测试微服务。在将微服务集成之前独立测试它们总是一个好主意。如果您自己组合代码并在 Node.js 下直接测试,别忘了从 npm 安装 mongodb 驱动程序包:
npm install --save mongodb
没有必要逐个向您介绍每个新微服务的单独测试。为了提高效率,我们将跳过这一步骤,直接在 Docker Compose 中的应用中运行我们的集成代码。
您应该已经从上一节运行了应用程序。我们之所以需要它,是为了数据库,以便我们可以加载测试数据。如果应用程序没有运行,现在就启动它:
docker-compose up --build
我们现在可以用通常的方式用网络浏览器测试应用程序。不过,这次我们必须提供我们想要观看的视频的 ID。我们在测试数据中指定的 ID 是一长串数字,这就是我们现在必须添加到我们的 URL 中,以测试更新的应用程序。打开您的浏览器并导航到这个链接:
http://localhost:4002/video?id=5d9e690ad76fe06a3d7ae416
如果您更改了测试数据中的 ID,您也需要更新此 URL 中的 ID。现在您应该已经非常熟悉这个视频了!
4.5.3 在生产中添加数据库服务器
到目前为止,我们只讨论了将数据库服务器添加到我们应用程序的开发版本的情况。这目前足够好了,因为我们还没有学习如何将应用程序部署到生产环境;这将在第六章和第七章中介绍。不过,我们现在可以简要考虑我们可能如何为生产环境部署数据库服务器。
Docker Compose 使得将数据库服务器添加到我们的开发应用中变得容易,但对于生产环境呢?对于生产环境,我建议使用 Kubernetes 集群外部的数据库。这保持了集群无状态,正如我们在 4.4.5 节中讨论的,这意味着我们可以在不危及数据的情况下随时拆解和重建我们的集群。
一旦我们构建了生产 Kubernetes 集群,我们可以轻松地将 MongoDB 数据库部署到生产环境中,其方式与我们刚刚使用 Docker Compose 所做的方式类似。实际上,这就是我们在第七章将要做的,因为这是我们最容易将数据库服务器部署到生产环境中的方法。
尽管如此,我建议您将数据库与集群分开。您可以在单独的虚拟机上运行它,或者可以使用外部托管数据库。这样做的原因是为了保持生产集群无状态。
使用托管数据库的另一个优点是安全性。数据库提供商为我们处理维护工作;它负责保护并备份我们的数据!如果我们为一家大公司工作,我们的公司可能会内部管理这项工作。但如果我们为一家小公司或初创公司工作,我们需要尽可能多的帮助。
4.5.4 每个微服务或每个应用的数据库?
到目前为止,我们已经在数据库服务器上创建了一个数据库。但现在我们已经准备好创建更多的数据库。
你可能已经注意到,我们给数据库命名为 video-streaming,以与使用它的微服务相呼应!这暗示了我们将在整本书中遵循的一条规则:每个微服务都应该有自己的数据库。我们这样做是因为我们希望将数据封装在微服务中,就像在面向对象编程中将数据封装在对象中一样。
我们应该为每个微服务或每个应用使用一个数据库吗?当然,目标是每个微服务只有一个数据库。您的数据库可以托管在单个服务器上,但请确保每个微服务都有自己的数据库。如果您共享数据库或将数据库作为微服务之间的集成点,您将邀请架构和可扩展性问题。
我们限制了除直接封装它的代码之外的所有数据。这有助于我们随着时间的推移安全地演变数据结构,因为对它的更改可以隐藏在微服务中。这是另一种技术,如果我们精心设计 REST API,就可以避免将中断和问题从微服务传播到应用程序的其他部分。在设计这些 REST API 时应用的谨慎等于我们应用程序的更好设计。
你可能会认为在微服务之间共享数据库是它们共享数据的好方法。但将数据库用作微服务之间的集成点或接口是一个糟糕的想法,因为它会使应用程序更加脆弱且扩展性更差。
在某个时候,你可能会发现自己想要为了性能或其他原因而共享一个数据库。毕竟,有时为了实现困难的目标,规则有时不得不被打破。仔细考虑你为什么要这样做,以及这是否真正必要。将这种反模式引入我们的应用程序不是我们应该盲目做的事情。我们将在第十一章中更多地讨论数据库和可扩展性。
4.5.5 我们取得了什么成果?
我们已经向我们的应用程序添加了一个数据库。现在我们有两种不同的方法来管理应用程序的数据:我们可以在外部云存储中存储文件,也可以在数据库中存储数据。我们很好地使用了 Docker Compose 来运行由多个容器组成的应用程序,并将我们的应用程序升级为两个微服务和数据库。
我们将我们的存储提供商隐藏在视频存储微服务之后。它的任务是检索存储中的视频。我们实施的抽象允许我们轻松地在不影响应用程序的情况下更改存储提供商。
我们创建了一个数据库服务器,并为我们的视频流微服务添加了一个数据库。我们遵循每个微服务应该有自己的数据库的规则,在未来,我们可以轻松地向我们的服务器添加更多数据库,并继续满足这一规则。
我们还简要地看到了一个微服务如何与另一个微服务通信。视频流微服务接收到的 HTTP GET 请求被转发到视频存储微服务。这是第一个也是最简单的通信形式,一个微服务可以使用它来请求或委托任务给另一个微服务。在下一章中,我们将更深入地探讨这一点以及其他微服务之间的通信方法。此外,我们将进一步扩展我们的 Docker Compose 技能,并学习如何将自动实时重新加载应用到我们的整个微服务应用程序中。
4.6 Docker Compose 回顾
在本章中,我们已经看到了 Docker Compose 的价值日益增加,我们使用它来帮助我们管理开发工作站上不断增长的应用程序的复杂性。即使只运行一个容器,它也是有用的,因为它允许我们捕获和记录配置细节。在那个早期阶段,它神奇地将两个命令合并成了一个。
随着我们通过本章的进展,我们向我们的应用程序中添加了两个额外的容器,Docker Compose 的价值变得更加明显。我们可以向我们的应用程序添加任意数量的容器,我们可以记录它们的全部配置细节,无论我们有多少个容器,我们都可以使用单个命令将它们作为一个聚合实体来管理。
图 4.13 显示了在 Docker Compose 下运行的应用程序简单生命周期。我们使用 up 命令启动我们的应用程序及其所有微服务。我们使用 down 命令销毁我们的应用程序,并将我们的开发工作站恢复到干净状态。

图 4.13 使用 Docker Compose 时你的微服务应用程序的生命周期
在你完成本章之前,快速浏览表 4.2 以回顾你学到的 Docker Compose 命令。在这个页面上放置书签,以便当你需要帮助使用 Docker Compose 时可以快速返回此处。
表 4.2 Docker Compose 命令回顾
| 命令 | 描述 |
|---|---|
docker-compose --version |
检查 Docker Compose 是否已安装并打印版本号 |
docker-compose up --build |
在当前工作目录中构建并实例化由 Docker Compose 文件(docker-compose .yaml)定义的由多个容器组成的应用程序 |
docker-compose ps |
列出由 Docker Compose 文件指定的应用程序中的运行中的容器 |
docker-compose stop |
停止应用程序中的所有容器,但保留已停止的容器以供检查 |
docker-compose down |
停止并销毁应用程序,使开发工作站处于干净状态 |
4.7 继续你的学习
本章简要介绍了两个重要主题。我们向应用程序添加了一个新的微服务,并将其连接到我们的 Azure 存储帐户。我们还向应用程序添加了一个 MongoDB 数据库。Azure 和 MongoDB 都是各自拥有广阔世界的技术,因此现在我将给你一些参考资料,以便你更深入地了解这些领域:
-
《Azure in Action》由 Brian H. Prince 和 Chris Hay 著(Manning, 2010)
-
《一个月午餐时间学会 Azure》由 Iain Foulds 著(Manning, 2018)
-
《MongoDB in Action》,第 2 版,由 Kyle Banker、Peter Bakkum 等人著(Manning, 2016)
要了解更多关于 Docker Compose 的信息,请在线阅读文档:
在本章中,我们使用 Docker Compose 将服务扩展到了多个微服务。我们还为我们的应用程序添加了数据管理功能。在下一章中,我们将更详细地学习如何使我们的微服务相互通信。我们还将提高使用 Docker Compose 的技能,并学习如何扩展 实时重新加载 以使其在整个应用程序中工作。
摘要
-
我们创建了一个 Docker Compose 文件,该文件指定了应用程序中的微服务。
-
你学习了如何使用 Docker Compose 命令
up和down来运行你的微服务开发应用程序。 -
你学习了如何创建 Azure 存储帐户并将视频上传到其中。
-
我们向应用程序添加了第二个微服务,用于从 Azure 存储中检索视频。
-
我们修改了我们的视频流微服务,使其将请求转发到新的视频存储微服务。
-
我们在应用程序中包含了一个 MongoDB 数据库来存储有关视频的信息。
-
我们修改了我们的视频流微服务,使其使用数据库来确定视频的位置。
5 微服务之间的通信
本章涵盖
-
在应用级别使用实时重载以实现更快的迭代
-
使用 HTTP 请求在微服务之间发送直接消息
-
使用 RabbitMQ 在微服务之间发送间接消息
-
在使用直接和间接消息之间进行选择
一个微服务应用由许多微服务组成,每个微服务负责其自己的领域。因为每个微服务本身都很小、简单,并且不做很多事情,所以我们的微服务必须协作以创建实现应用功能集所需复杂行为。为了协作,我们的微服务需要通信方式。如果它们不能相互交谈,那么它们将无法协调它们的活动,也不会取得很大成就。
在本章中,我们探讨了微服务之间可以采用的不同通信方式,以便它们能够协作并满足应用的高级需求。在这个过程中,我们还将回顾 Docker 和 Docker Compose,以设置整个应用的实时重载。向前推进,这是必要的,这样我们就不需要不断地重建和重新启动我们的应用,随着代码的更新。
在前面的章节中,我们已经看到 HTTP 请求是微服务之间通信的一种方式。在本章中,我们将扩展使用 HTTP 请求进行直接消息传递,同时我们还将探讨使用 RabbitMQ 进行间接消息传递。在整个章节中,你将学习如何根据具体情况决定使用哪种类型的消息传递。
5.1 新的和熟悉的工具
本章介绍了 RabbitMQ 软件用于消息队列。这将帮助我们解耦我们的微服务。我们将使用 npm 包 amqplib 将我们的微服务连接到 RabbitMQ,以便它们可以发送和接收消息。我们还将回顾一些熟悉工具,并更详细地探讨我们如何使用 HTTP 请求发送消息,以及如何升级我们的开发环境以支持应用级别的实时重载。
表 5.1 第五章中的新和熟悉工具
| 工具 | 版本 | 目的 |
|---|---|---|
| Docker Compose | 1.26.2 | Docker Compose 允许你同时配置、构建、运行和管理多个容器。 |
| HTTP | 1.1 | 超文本传输协议(HTTP)用于从一个微服务向另一个微服务发送直接(或同步)消息。 |
| RabbitMQ | 3.8.5 | RabbitMQ 是我们将用于从一个微服务向另一个微服务发送间接(或异步)消息的消息队列软件。 |
| amqplib | 0.5.6 | 这个 npm 包允许我们配置 RabbitMQ,并从 JavaScript 发送和接收消息。 |
5.2 获取代码
要跟随本章内容,你需要下载代码或克隆存储库。
-
从这里下载代码的 zip 文件:
-
你可以使用 Git 如下克隆代码:
git clone https://github.com/bootstrapping-microservices/chapter-5.git
关于安装和使用 Git 的帮助,请参阅第二章。如果您对代码有问题,请在 GitHub 仓库中记录一个问题。
5.3 让我们的微服务开始对话
在本书的这一部分,我们有一个包含两个微服务的应用程序:视频流和视频存储。在上一章中,我们添加了数据存储功能;视频流微服务有一个数据库,视频存储微服务使用外部云存储来存储视频文件。图 5.1 显示了我们的应用程序现在的样子。

图 5.1 我们在上一个章节中完成了两个微服务和数据库在开发工作站上运行在 Docker Compose 下的工作。在那个章节中,我们还添加了连接到 Azure 云存储以存储我们的视频。
一个微服务应用程序只能由协作以提供应用程序功能的微服务构建而成。如果我们有无法通信的微服务,我们的应用程序将无法做很多事情!因此,微服务之间的通信是构建微服务的重要组成部分,我们必须有可用的通信技术。
实际上,如果没有在第四章中像我们那样使用 HTTP 请求来在视频流和视频存储微服务之间进行通信,我们就不会走到这一步。我们在那里只是略过了这一点,但实际上它非常重要。没有它,我们就会在第一个障碍上跌倒:将我们的应用程序的流媒体和存储功能分离出来。
注意:我们的微服务必须协同工作以实现应用程序的功能,因此它们能够进行通信以协作至关重要。
在本章中,我们向我们的应用程序添加了第三个微服务:历史微服务。添加这个新微服务的目的是为了演示微服务之间的通信。您可以在图 5.2 中看到视频流微服务是如何向历史微服务发送消息流的。
图 5.2 从概念上展示了本章结束时我们的应用程序将是什么样子,但它并没有展示我们将要添加的完整技术细节。为了获得完整的图景,我们需要了解我们可以使用的各种通信风格以及支撑这些技术的技术。在此之前,让我们更好地理解历史微服务。

图 5.2 在本章中,我们通过添加一个新的微服务来扩展我们的应用程序,并探索微服务之间的通信方法。
5.4 介绍历史微服务
在本章中,我们使用历史微服务作为示例,说明微服务如何相互发送和接收消息。实际上,这个新微服务在 FlixTube 中确实有一个合适的位置,正如其名称所暗示的,它记录了我们的用户的观看历史。
我们的应用程序可以通过多种方式利用这个历史记录。首先,我们的用户可能想查看自己的历史记录以记住他们以前观看的视频。他们可能希望在以后继续观看视频,或者我们可以用它来为其他用户提供推荐。
为了使本章的示例简单,我们将从上一章中删除视频存储微服务,这简化了视频流微服务。实际上,在本章的起点,我们将回到视频流微服务的早期版本,其中示例视频已经嵌入到其 Docker 镜像中。我们将像第三章之后那样使用视频流微服务。这种简化只是为了我们熟悉通信技术。在本章之后,我们将恢复视频存储微服务,并将视频流微服务恢复到其原来的状态。
我们将在微服务之间传输的消息是已查看的消息。这就是视频流微服务如何通知历史微服务用户已经观看了一个视频。图 5.3 展示了历史微服务正在做什么。它从视频流微服务接收一系列消息,并将它们记录在其自己的数据库中。

图 5.3 作为探索通信方法的一种方式,我们将让视频流微服务向历史微服务发送已查看的消息以记录我们的用户观看历史。
我们还没有讨论我们可以使用的消息风格——这将在不久的将来讨论。目前,请知道我们有多项技术可以用来发送已查看的消息。在本章中,我们将探讨我们的选项,我们可以在以后决定哪个最适合这种特定情况。不过,在之前,让我们升级我们的开发环境以加快开发周期。
5.5 快速迭代的实时重载
在 2.4 节中,我们讨论了我们的开发哲学以及为什么小而快的增量对于紧密的反馈循环和保持快速的开发节奏至关重要。在第二章中,当我们直接在 Node.js 下运行我们的第一个微服务时,我们能够使用 npm 包 nodemon 来使我们的微服务实时重载。这意味着当我们对其代码进行更改时,我们的微服务会自动重新加载。在应用层面,拥有高效的实时重载机制比在微服务层面更为重要。这是因为构建和启动整个应用程序比每个单独的微服务要慢得多。
在第三章中,我们使用了 Docker,并开始将我们的微服务代码“烘焙”到 Docker 镜像中。Docker 是我们打包、发布和部署微服务的极其有用的方式。这就是我们使用它的原因,尽管我们还没有看到这个谜题的部署部分。然而,为了看到部署的实际操作,我们需要一个生产环境(将在第六章中介绍),到第七章,我们将看到我们的 Docker 镜像被部署到生产环境中。
在第四章中,我们使用 Docker Compose 在我们的开发环境中作为一种方便的方式来构建和管理我们不断增长的应用程序。这一切都很好,但不幸的是,在从直接使用 Node.js 过渡到在 Docker 容器中运行我们的微服务时,我们失去了自动重新加载代码的能力。
由于我们将代码烘焙到我们的 Docker 镜像中,我们无法在之后更改它!这对于生产来说很棒,因为出于安全原因,我们真的不希望任何中间人能够篡改这段代码。现在的问题是,在开发过程中,我们不希望不断地重建我们的镜像和重新启动应用程序以包含更新的代码。这样做非常慢。而且,对于重复的重建和重启,时间真的会累积起来,尤其是随着我们的应用程序规模的扩大。
注意:无法快速更新运行中的应用程序代码对我们的开发过程来说是一件糟糕的事情,这可能会极大地消耗我们的生产力。我们将尽早解决这个问题,并找到一种方法来恢复我们的实时重新加载功能。
在本节中,我们将升级我们的 Docker Compose 文件,以支持在开发工作站和我们的容器之间共享代码。图 5.4 展示了新历史微服务的源代码目录是如何从我们的开发工作站共享到微服务的容器中的。
再次强调,我们将使用 nodemon 来完成这项工作,并且我们将将其应用于所有我们的微服务。它会在代码更改时自动重启每个微服务。这种配置可能看起来很繁琐,但将其正确设置是非常有价值的,因为它将对我们的开发速度产生重大影响!

图 5.4 为了在更大范围内启用实时重新加载,我们在开发工作站和容器之间同步我们的代码,以便代码的更改自动传播到容器中。
5.5.1 为历史微服务创建存根
我们只为新的历史微服务创建实时重新加载配置,但之后,我们需要将此相同的配置应用到每个微服务上。这样,我们的应用程序中的所有微服务都支持实时重新加载。
在我们开始之前,阅读列表 5.1,熟悉新生的历史微服务。目前它还没有做任何事情。它只是一个占位符,等待添加功能。一旦我们为这个微服务实现了实时重新加载,我们就能使用 Docker Compose 启动我们的应用程序。然后,我们将对新的微服务进行实时更新和增量更改,而无需重新启动应用程序。
列表 5.1 历史微服务的占位符(第五章/示例 1/历史/src/index.js)
const express = require("express");
function setupHandlers(app) {
①
}
function startHttpServer() {
return new Promise(resolve => {
const app = express();
setupHandlers(app);
const port = process.env.PORT &&
➥ parseInt(process.env.PORT) || 3000;
app.listen(port, () => {
resolve();
});
});
}
function main() {
console.log("Hello world!");
return startHttpServer();
}
main()
.then(() => console.log("Microservice online."))
.catch(err => {
console.error("Microservice failed to start.");
console.error(err && err.stack || err);
});
① 这是一个占位符微服务。稍后,我们将在这里添加 HTTP 路由和消息处理器!
5.5.2 为实时重新加载增强微服务
对于我们的微服务的基本代码,除了我们在第二章中学到的之外,我们不需要做任何其他的事情,包括我们设置第一个微服务和安装 nodemon 以实现实时重新加载。每个微服务都需要像这样安装 nodemon:
npm install --save-dev nodemon
npm 包 nodemon 是我们用来监控代码并在代码更改时自动重启微服务的工具。微服务的 package.json 文件中包含了一个名为 start:dev 的 npm 脚本,这是我们在第二章中开始使用的约定。你可以在列表 5.2 中看到它的样子。
列表 5.2 使用 nodemon 设置 package.json 以实现实时重新加载(第五章/示例 1/历史/package.json)
{
"name": "history",
"version": "1.0.0",
"description": "",
"main": "./src/index.js",
"scripts": {
"start": "node ./src/index.js",
"start:dev":
➥ "nodemon --legacy-watch ./src/index.js" ①
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"express": "⁴.17.1"
},
"devDependencies": {
"nodemon": "¹.19.1"
}
}
① 使用 nodemon 为这个微服务启用实时重新加载。当代码更改时,nodemon 会自动重启微服务。
在 start:dev npm 脚本设置到位后,我们可以这样运行我们的微服务:
npm run start:dev
这将像这样调用 nodemon 来监控我们的微服务:
nodemon --legacy-watch ./src/index.js.
显然,你总是可以键入完整的 nodemon 命令,但使用npm run start:dev更短,并且对于我们的所有微服务都是一样的,前提是我们将这个约定应用到每个微服务上。如果你刚刚启动了历史微服务,现在使用 Ctrl-C 退出。不久,我们将再次使用 Docker Compose 运行我们的整个应用程序。
你可能想知道为什么我在 nodemon 中使用了--legacy-watch参数。我使用这个参数是因为我经常在 Linux 虚拟机(VM)下运行 Docker 和 Docker Compose。这是在 Windows Home PC 上使用 Linux 的一种方便方式。(在 WSL2 之前,Windows Home 无法直接运行 Docker。)
--legacy-watch参数禁用了文件系统监控,并使用频繁轮询机制来监控代码更改。如果你在虚拟机上开发,你需要这个参数,因为实时重新加载所需的自动文件监控无法从宿主操作系统传递更改。
如果你不是在虚拟机(VM)下进行开发,你可以安全地移除--legacy-watch参数,你的实时重新加载将会有更好的性能。你可以在附录 A 中了解更多关于在开发中使用虚拟机的内容。
5.5.3 为开发和生产分割 Dockerfile
在第二章,我们讨论了能够在开发模式或生产模式下运行我们的微服务。我们做出这种区分是为了能够针对开发和生产的不同需求分别进行优化。在本节中,您将看到这种分离开始实现。
注意:在此阶段,我们将为我们的开发和生产模式创建单独的 Dockerfile。在每种情况下,我们的需求都不同。对于开发,我们优先考虑快速迭代。对于生产,我们优先考虑性能和安全。
对于所有微服务,从现在起,我们将创建不止一个,而是两个 Dockerfile。我们现在需要一个用于开发,另一个用于生产。我们将开发版本的 Dockerfile 命名为 Dockerfile-dev,生产版本的 Dockerfile 命名为 Dockerfile-prod。
这些名称的选择是为了避免混淆。在软件开发中,命名非常重要,我们应该努力选择清晰的名字以帮助避免歧义。我们现在将 Dockefiles 分开,以便在开发中启用实时重载。这不是我们希望在生产中启用的事情!
列表 5.3 显示了新历史微服务的生产 Dockerfile。这里没有什么新内容,因为这基本上是一个相当标准的 Node.js Dockerfile。它与我们在第三章中创建的 Dockerfile 相似。
列表 5.3 创建生产 Dockerfile(chapter-5/example-1/history/Dockerfile-prod)
FROM node:12.18.1-alpine ①
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production ②
COPY ./src ./src ③
CMD npm start ④
① 我们使用 alpine Linux 镜像用于生产,因为它使我们的微服务镜像变得很小。
② 仅安装生产依赖项
③ 将源代码复制到镜像中。我们可以说代码被“烘焙”到镜像中了。
④ 以生产模式启动微服务
在本章中,我们实际上不会使用生产 Dockerfile,但在第七章,当我们部署到生产环境时,我们肯定会需要这些。将开发和生产 Dockerfile 并排维护是一个好主意,这样开发版本就不会比生产版本领先太多。
列表 5.4 显示了历史微服务的开发 Dockerfile。阅读它,并将其与列表 5.3 中的生产 Dockerfile 进行比较。自己注意开发与生产之间的差异。
列表 5.4 创建开发 Dockerfile(chapter-5/example-1/history/Dockerfile-dev)
FROM node:12.18.1-alpine ①
WORKDIR /usr/src/app
COPY package*.json ./ ②
CMD npm config set cache-min 9999999 && \ ③
npm install && \ ④
npm run start:dev ⑤
① 我们可以选择在这里使用非 alpine 版本的 Linux,而不是 alpine 版本。非 alpine 发行版更大,但它有更多在开发期间有用的调试工具。
② 将 package.json 文件复制到镜像中。请注意,我们并没有将代码复制到镜像中。
③ 启用 npm 安装的缓存,使得后续的 npm 安装更快
④ 容器启动时执行 npm install。这意味着我们可以利用 npm 缓存,使得在容器启动时安装比在构建过程中安装要快得多。
⑤ 以开发模式启动微服务,使用 nodemon 进行实时重载
你是否注意到了两个不同的 Dockerfile 之间的差异?在列表 5.3 中,我们只安装了生产依赖项,而在列表 5.4 中,我们安装了所有依赖项,包括我们的开发依赖项。但你是否注意到了最重要的变化?在列表 5.3 中,我们使用COPY指令将我们的代码烘焙到生产 Docker 镜像中:
COPY ./src ./src
那个命令将我们的代码复制到镜像中。在 Dockerfile 的开发版本中最有趣的是缺少的部分。你会注意到,在列表 5.4 中,我们的代码没有COPY指令(尽管有 package.json 的一个),因此我们排除了我们的代码从开发 Docker 镜像中!如果我们把我们的代码烘焙到镜像中,那么我们就不容易稍后更改它。如果我们不能更改我们的代码,那么我们就不能使用实时重新加载。
但如果我们没有将代码复制到我们的开发镜像中,那么它将如何进入容器呢?我们将在下一节中找到这个问题的答案。现在,我们还需要看看开发 Dockerfile 和产品 Dockerfile 之间还有一个重大差异。
注意指定如何在容器内启动我们的微服务的CMD指令。在产品 Dockerfile 中,我们简单地使用第二章中描述的npm start约定启动微服务:
CMD npm start
开发 Dockerfile 中的CMD指令不同,并且做了更多的工作:
CMD npm config set cache-min 9999999 && \
npm install && \
npm run start:dev
这个命令通过反斜杠(\)行续字符在三条线上分开。第一行配置了 npm 缓存,第二行安装了 npm,第三行启动了微服务。
在产品 Dockerfile 中,我们在 Docker 构建过程中调用npm install,这意味着我们的依赖项被烘焙到镜像中,正如在生产中应该的那样。然而,在开发版本中,我们在容器启动时执行npm install。开发中这种差异的原因是为了在后续重建中提供更好的性能。
npm install可能需要很长时间。当我们容器启动时执行它,我们能够在主机操作系统上缓存 npm 包。这就是为什么我们在第一行配置了缓存。以这种方式缓存我们的 npm 包使得后续的 npm 安装变得更快,这反过来又使得容器启动更快。我们将在下一节中了解更多关于它是如何工作的信息。
开发 Dockerfile 中CMD指令的第三行实际上是启动微服务。它调用npm script start:dev以启用实时重新加载来以开发模式启动我们的微服务。
5.5.4 更新 Docker Compose 文件以实现实时重新加载
使我们的应用程序级实时重新加载工作完成的最后部分是对我们的 Docker Compose 文件进行一些必要的更改,以在主机操作系统和容器之间共享我们的代码和 npm 缓存。在本节中,我们使用 Docker 卷在开发工作站和容器之间共享文件系统。这意味着我们可以在 Visual Studio (VS) Code 中编辑代码,并且更改几乎立即在我们的在 Docker Compose 下运行的微服务中可见。
列表 5.5 是从 example-1 Docker Compose 文件中提取的,展示了我们新历史微服务的配置。这与我们在第四章中创建的 Docker Compose 文件类似,但有一些差异和新增内容。
列表 5.5 更新 Docker Compose 文件以实现实时重新加载(第五章/example-1/docker-compose.yaml 的提取)
version: '3'
services:
# ... other services defined here ...
history: ①
image: history
build:
context: ./history
dockerfile: Dockerfile-dev ②
container_name: history
volumes: ③
- /tmp/history/npm-cache:/root/.npm:z ④
- ./history/src:/usr/src/app/src:z ⑤
ports:
- "4002:80"
environment:
- PORT=80
- NODE_ENV=development
restart: "no"
① 定义了我们的新历史微服务的容器
② 使用 Dockerfile 的开发版本
③ 定义了主机操作系统和容器之间共享的卷
④ 从主机共享 npm 缓存到容器中。这正是允许 npm 模块被缓存,使得后续安装更快的原因。
⑤ 直接从主机共享源代码到容器。你可以在你的开发工作站上(例如,使用 VS Code)进行更改,这些更改将自动在容器内可见。
列表 5.5 中新的第一点是,我们现在使用Dockerfile-dev,这是我们的 Dockerfile 的开发版本。我在第四章提到,我们可以省略dockerfile字段,并且它将默认为Dockerfile。在第四章中,我们没有将其设置为默认值;相反,我们明确将其设置为Dockerfile。我指出,我们将在不久的将来明确设置它。好吧,现在我们就到了这里,我们明确将其设置为Dockerfile-dev以使用我们的 Dockerfile 的开发版本。
接下来新的内容是添加了volumes字段,我们在其中创建了一些 Docker 卷来连接我们的开发工作站上的文件系统与容器的文件系统。这直接将我们的源代码链接到容器中。这也是我们没有直接将代码烘焙到镜像中的原因。
为了共享代码,我们使用一个 Docker 卷。另一个卷创建了一个用于 npm 缓存的共享目录。这允许在容器中安装的 npm 包在主机操作系统上被缓存,这样,如果我们销毁并重新创建容器,后续的 npm 安装会更快,因为我们已经保留了容器外的缓存。
如果你对列表 5.5 中卷配置中使用的z标志感到好奇,这仅仅是指示 Docker 该卷是要共享的(可能是在多个容器之间)。如果你愿意,你可以在这里了解更多信息:
docs.docker.com/storage/bind-mounts/
这已经有很多东西要吸收了,到目前为止,这只是为了历史微服务!我们需要对所有微服务进行这些更改。幸运的是,我们只需使用相同的模式并将其应用于每个微服务,如下所示:
-
为每个微服务安装 nodemon。
-
更新 package.json 并实现 start:dev 脚本来使用 nodemon 启动微服务(如列表 5.3 所示)。
-
创建我们的 Dockerfile 的开发和生成版本。开发 Dockerfile 不应该将代码复制到镜像中(如列表 5.4 所示)。
-
在容器启动时执行
npm install;仅限开发,不用于生产(这是为了性能,如列表 5.4 所示)。 -
更新 Docker Compose 文件,使其使用开发 Dockerfile(如列表 5.5 所示)。
-
在 Docker Compose 文件中添加 Docker 卷,以便将源代码和 npm 缓存共享到容器中(如列表 5.5 所示)。
我已经为第五章仓库中的所有示例都做了这件事,所以你不必担心。但你应该至少启动 example-1,然后对历史微服务进行一些代码更改,这样你就可以看到实时重新加载的效果!所以现在让我们来做这件事。
5.5.5 尝试实时重新加载
看代码列表已经够多了!现在是时候看到实时重新加载的效果,这样你才能真正欣赏它的有用性。打开一个终端,切换到 chapter-5 代码仓库下的 example-1 子目录。然后使用 Docker Compose 启动应用程序:
docker-compose up --build
这个例子包含了简化的视频流微服务和新的存根历史微服务。检查 Docker Compose 的输出。你应该会看到存根历史微服务启动时打印出“Hello world!”。为了测试实时重新加载,我们将更改历史微服务打印的消息:
-
在 VS Code 中打开 example-1 目录。
-
找到并打开历史微服务的 index.js 文件。
-
搜索打印“Hello world!”消息的代码行,并将此代码行更改为打印“Hello computer!”。
-
保存 index.js 文件,然后切换回 Docker Compose 输出。
如果你切换得足够快,你会看到历史微服务正在重新加载并打印你的更新消息。如果你切换得不够快,你应该看到这已经发生了。当你这样做的时候,请注意视频流微服务并没有重新加载。那是因为我们没有更改它的代码。只有历史微服务被更新了,所以只有它重新加载了。
这就是实时重新加载的承诺。我们可以快速迭代更新我们的代码并快速直接地获得反馈。我们不必等待构建和启动整个应用程序。相反,我们可以为需要更新的每个微服务热加载代码。
那么,如果我们代码中引入了错误会发生什么?当微服务带有错误重新加载时,我们会看到什么?错误会在 Docker Compose 输出中显示。然后我们可以纠正错误并保存代码文件。微服务会自动重新加载,并且假设我们的更改实际上修复了错误,我们应该会看到更新后的微服务的干净输出。
到目前为止,我实际上建议你尝试破坏历史微服务,看看会发生什么。继续。打开它的 index.js 文件,输入一些肯定会导致它崩溃的随机胡言乱语。保存文件,然后切换回 Docker Compose 输出以查看结果。
问问自己错误信息是什么意思,我做了什么导致了它?现在我听到你说,“但是 Ash,我们希望保持代码的正常工作,那么我们为什么要尝试破坏它?”
实际上,在受控和安全的环境中练习破坏和修复代码是很好的。这样,当遇到野外真实问题时,你会更有经验,并且对错误信息和如何处理这些问题会有更好的理解。现在花点时间破坏代码;造成问题,并在过程中找点乐趣。
强制容器重新启动
有时我们可能想要强制重新加载一个没有更改的微服务。比如说,微服务已经挂起或崩溃,现在卡住了。使用我们的实时重新加载系统,我们可以通过更改代码来简单地使容器重新启动,例如,添加一些空白字符,然后保存文件。
实际上,我们甚至不需要走那么远。我们只需在 VS Code 中保存文件就足够了,这样就可以使容器重新启动。我们不需要做出更改!
如果你可以从终端访问touch命令,你也可以像以下这样从命令行触发历史微服务的实时重新加载:
cd chapter-5/example-1
touch history/src/index.js
如果你还没有为特定容器设置实时重新加载(你实际上只需要为代码频繁更改的微服务使用实时重新加载),那么你可以使用 Docker Compose 的restart命令来使容器重新启动;例如,要强制历史微服务重新启动,请输入
docker-compose restart history
5.5.6 在开发中测试生产模式
到目前为止,在本章中,我们已经将我们的 Dockerfile 拆分为单独的文件,以便我们可以为开发模式和生产模式拥有不同的版本,但我们还没有使用生产 Dockerfile。这将在第七章中改变,当我们部署到生产环境时。现在,只需注意,我们不会像在开发期间那样频繁地在生产模式下测试我们的应用程序。
在开发过程中,我们将不断进行小的增量代码更改,然后测试我们的应用程序是否仍然正常工作。尽管我们并不像开发版本那样频繁地使用我们的生产 Dockerfile,但我们仍然应该与开发版本一起更新这些文件。我们还应该定期在生产环境中进行测试,尽管不如开发期间频繁。
例如,你可能每几分钟就在开发模式下进行一次测试,因为你正在修改代码。你仍然想测试生产模式,但也许,你只有在积累了大量代码更改之后,每隔几个小时才进行一次。主要观点是,在你将这些部署到生产之前,你也需要测试你的生产 Dockerfile。你不想无意中积累隐藏的问题,这些问题只有在部署到生产后才会暴露出来。
你可以通过在开发工作站上定期以生产模式进行测试来轻松且主动地解决这个问题。通常,我会做的是有两个单独的 Docker Compose 文件:一个用于开发,另一个用于生产。
当你调用 Docker Compose 时,可以使用 -f 参数来指定 Docker Compose 文件。例如,如果你想在你开发工作站上以生产模式运行你的应用程序,你可能想创建一个单独的 生产 版本的 Docker Compose 文件并像这样运行它:
docker-compose -f docker-compose-prod.yml up --build
你可以有一个单一的 Docker Compose 文件,该文件由环境变量参数化,但通常我会为测试开发和生产保留不同的版本。这是因为我喜欢让我的生产 Docker Compose 文件尽可能地模仿真实的生产环境。此外,通常我的开发版本会用模拟版本替换各种微服务,以便更容易和更快地进行测试。
我们将在第九章中讨论模拟微服务。在第八章中,我们将介绍自动化测试,这是可以增强你生产力的另一个方面。
5.5.7 我们取得了什么成果?
在第 5.5 节中,我们为我们的微服务配置了实时重新加载。我们从历史微服务开始,并将相同的模式应用到视频流微服务上。从现在起,我们将为此使用所有我们的微服务。
我们这样做是因为构建和启动我们的应用程序需要花费相当多的时间。我们不希望每次更改代码时都构建和重新启动我们的应用程序。相反,我们希望能够快速更改代码以进行实验和快速迭代,并让应用程序自动更新自己。现在,我们可以编辑代码,我们的微服务将自动重启。这就是为什么这被称为 实时重新加载-- 它在你编码时自动重新加载。
这使得工作流程非常高效和有效。我们现在可以持续演进我们的微服务应用程序,同时接收持续的反馈流。浏览 example-1 中的代码,确保你理解实时重新加载配置是如何在整个应用程序中应用的。
5.6 微服务通信方法
在升级我们的开发环境以支持应用程序范围实时重载的间歇之后,现在让我们回到本章的主题:探索微服务之间的通信机制。但在我们深入研究通信技术之前,我们将从对微服务使用的两种通信风格的高级概述开始:直接消息和间接消息,也通常称为 同步 和 异步 通信。
我更喜欢使用“直接消息”和“间接消息”这样的术语,而不是“同步消息”和“异步消息”,因为“同步”和“异步”这两个词在常规计算机编程中具有不同的含义。此外,异步编程的概念,尤其是,可能很难学习,并且让许多有志于编程的人感到寒心。不要担心;我们将避免使用“异步”这个词。
5.6.1 直接消息
直接 消息 简单来说就是指一个微服务直接向另一个微服务发送消息,然后立即接收直接响应。当希望一个微服务直接向特定微服务发送消息并立即在其中调用动作或任务时,会使用直接消息。
直接消息也用于在另一个微服务中触发直接动作。我们还可以用它来在多个微服务之间按顺序执行一系列严格的行为。你可以将其视为向另一个微服务发送命令或指示(例如,这样做 或 那样做 然后告诉我你是否成功)。
接收微服务不能忽略或避免传入的消息。如果它这样做,发送者将直接从响应中得知。图 5.5 展示了视频流微服务如何将查看的消息直接发送到历史微服务,该服务提供直接且即时的响应。
在某些用例中,通常需要使用直接消息。它的主要缺点是它要求通信两端两个微服务紧密耦合。我们通常更愿意避免微服务之间的紧密耦合,因此我们将频繁使用间接消息而不是直接消息。

图 5.5 通过名称显式发送直接消息到历史微服务,并立即处理。
5.6.2 间接消息
间接 消息 在通信过程的两端之间引入了一个中介。我们在微服务之间添加了一个中间人。因此,通信的双方实际上不必相互了解。这种通信方式导致我们的微服务之间的耦合更加松散。这意味着两件事:
-
消息通过中介发送,这样消息的发送者和接收者都不知道涉及了哪个其他微服务。 对于发送者来说,甚至不知道是否有其他微服务会接收这条消息!
-
因为接收者不知道哪个微服务发送了消息,所以它不能发送直接的回复。 这意味着这种通信方式不能应用于需要直接响应以确认成功或失败的情况。
当发送微服务不在乎是否采取了后续操作时,我们应该使用间接消息。我们还可以用它向整个应用程序广播消息(例如,通知其他微服务可能感兴趣的重要事件)。
注意:我们使用间接消息来宣布不需要直接响应的重要事件。这种消息方式比直接消息提供了更灵活的通信结构,并且减少了我们的微服务之间的耦合。
图 5.6 展示了视频流微服务(在左侧)如何通过消息队列(中介)向历史微服务(在右侧)发送间接消息。请注意,视频流微服务和历史微服务之间没有直接连接。这就是为什么我们可以说它们是 松散耦合 的。

图 5.6 一个间接消息不会明确发送给微服务;相反,消息被放置在队列中,可以在稍后处理。
间接消息可以帮助我们构建灵活的消息架构来解决许多复杂的通信问题。不幸的是,这种灵活性带来了更高的复杂性。随着你的应用程序的增长,你会发现更难精确地映射通信路径,因为这些路径不是直接的,因此也不那么明显。在了解了直接和间接消息的概述之后,我们可以一头扎进尝试每种通信方法。
5.7 使用 HTTP 进行直接消息传递
在上一章中,我们使用 HTTP 进行数据检索,从存储中检索了我们的流媒体视频。在本章中,我们使用 HTTP 达到不同的目的:从一个微服务向另一个微服务发送直接消息。
注意:使用 HTTP 请求发送的消息会有直接的响应。我们可以立即知道消息的处理是成功还是失败。
具体来说,在本节中,我们将使用 HTTP POST 请求直接从视频流微服务向历史微服务发送消息。图 5.7 展示了这一过程。

图 5.7 HTTP POST 请求通过名称显式地针对另一个微服务。
5.7.1 为什么使用 HTTP?
超文本传输协议(HTTP)是万维网的语言和基础,在创建 网络服务 时是事实上的标准。它被每个人所理解,并且是我们可以依赖的东西。
HTTP 已经被广泛用于创建表示状态转移(REST)API,我们不需要太费脑筋去想为什么我们应该使用它。它是为此类事情而设计的,并且被我们愿意与之合作的每一种编程语言所支持。我们还可以轻松访问与之相关的海量学习资源,而且讽刺的是,这些信息很可能会通过互联网世界万维网底层的 HTTP 协议传递给我们。
5.7.2 直接针对特定微服务发送消息
在我们向微服务发送消息之前,我们需要一种方法来定位它。与 HTTP 伴随的是另一种互联网协议,称为 域名系统(DNS)。这为我们提供了一种简单且几乎自动的方法,可以通过微服务的名称将消息直接发送到微服务。
关于微服务通信的一个关键问题是,我们如何将消息定向到另一个微服务?对这个问题的最简单答案是无处不在的 DNS,它将主机名转换为 IP 地址。这在使用 Docker Compose(容器名称是主机名)时自动工作,并且不需要太多努力就可以在我们的生产 Kubernetes 集群中使其工作。
图 5.8 显示了我们可以向特定的主机名发送 HTTP POST 消息。在发送 HTTP 请求时,会自动进行 DNS 查找,并将我们的主机名转换为微服务的互联网协议(IP)地址。

图 5.8 显示了 HTTP 请求是如何通过 DNS 查找将目标微服务的主机名转换为互联网协议(IP)地址的。
IP 地址是一串数字,代表我们微服务的唯一互联网位置。请注意,仅仅因为它是 IP 地址,并不意味着我们正在谈论公共互联网。在这种情况下,IP 地址实际上代表一个位于私有网络中的私有服务器,无论是在我们的开发工作站上运行 Docker 运行时,还是在我们的生产 Kubernetes 集群内部运行。这是我们需要在发送 HTTP 请求时指向接收者的消息的 IP 地址,并且当我们发起请求时,DNS 会自动且几乎神奇地在幕后工作。
正如我们所做的那样,使用 Docker 和 Docker Compose 进行开发意味着 DNS 会自动工作,我们可以依赖它。当我们部署到我们的生产 Kubernetes 集群时,我们将有一些额外的工作来使我们的微服务可以通过 DNS 访问,但我们将在这第七章中讨论这个问题。
5.7.3 使用 HTTP POST 发送消息
消息传递方程有两个方面:一个微服务发送消息,另一个接收消息。在本节中,我们将探讨如何使用 HTTP POST 请求发送消息。
在 4.4.2 节中,我们查看了一个从微服务转发到另一个微服务的 HTTP GET 请求。我们当时使用内置的 Node.js http 库来做这件事。我们将再次使用这个库从一个微服务向另一个微服务发送请求。
列表 5.6 是从示例 2 视频流微服务的更新后的 index.js 文件中摘取的,展示了如何发送 HTTP POST 消息。它实现了一个新的函数 sendViewedMessage,当用户开始观看视频时,该函数会将查看的消息发送到历史微服务。
列表 5.6 使用 HTTP POST 发送直接消息(摘自第五章/示例 2/video-streaming/index.js)
function sendViewedMessage(videoPath) { ①
const postOptions = { ②
method: "POST", ③
headers: {
"Content-Type": "application/json", ④
},
};
const requestBody = { ⑤
videoPath: videoPath
};
const req = http.request( ⑥
"http://history/viewed", ⑦
postOptions
);
req.on("close", () => {
... ⑧
});
req.on("error", (err) => {
... ⑨
});
req.write(JSON.stringify(requestBody)); ⑩
req.end(); ⑪
}
① 一个辅助函数,用于将查看的消息发送到历史微服务。
② 配置 HTTP 请求的选项。
③ 设置 HTTP 方法为 POST。
④ 设置 HTTP 请求主体的内容类型。
⑤ HTTP 请求的主体定义了消息负载;这是我们随消息发送的数据。
⑥ 将 HTTP 请求发送到历史微服务。
⑦ 设置 HTTP 请求的 URL,该 URL 识别历史微服务和查看消息。
⑧ 当请求完成时调用此函数。
⑨ 处理可能发生的任何错误。
⑩ 将主体写入请求。
⑪ 完成请求。
我们调用 http.request 函数来创建 HTTP POST 请求。我们使用 URL http://history/viewed 将请求定向到历史微服务。这个 URL 结合了主机名(在这种情况下是 history)和路由(在这种情况下是 viewed)。正是这种组合识别了目标微服务和发送给它的消息。
分别处理请求的成功和失败回调函数。正是在这里,我们可以检测到错误并采取后续的补救措施。否则,如果请求成功,我们可能希望调用后续操作。
5.7.4 使用 HTTP POST 接收消息
在等式的另一边,我们通过在接收微服务中创建 Express 路由处理程序来接收 HTTP POST 消息。列表 5.7 展示了历史微服务的 index.js 文件摘录,展示了这一点。
更新的 setupHandlers 函数为 viewed 路由添加了一个 HTTP POST 处理程序以接收传入的消息。在这个列表中,我们只是将接收到的消息存储在数据库中,以保留查看历史记录。
列表 5.7 使用 HTTP POST 接收直接消息(摘自第五章/示例 2/history/index.js)
function setupHandlers(app, db) {
const videosCollection = db.collection("videos");
app.post("/viewed", (req, res) => { ①
const videoPath = req.body.videoPath; ②
videosCollection
➥ .insertOne({ videoPath: videoPath }) ③
.then(() => {
console.log(`Added video ${videoPath} to history.`);
res.sendStatus(200);
})
.catch(err => {
console.error(`Error adding video ${videoPath}
➥ to history.`);
console.error(err && err.stack || err);
res.sendStatus(500);
});
});
}
① 处理通过 HTTP POST 请求接收到的查看消息。
② 从 HTTP 请求的 JSON 主体中提取数据。
③ 在数据库中记录视图。
你是否注意到了在 HTTP POST 处理程序中我们是如何通过 req.body 访问请求体的?我们将请求体作为消息 负载 处理。由于我们使用了 Express 的 body-parser 中间件,body 变量被自动解析为 JSON 格式,安装方式如下:
npm install --save body-parser
如果你对如何将 body-parser 中间件添加到 Express 感兴趣,请查看代码文件 chapter-5/example-2/history/index.js。
5.7.5 测试更新后的应用程序
现在是时候测试我们最新的代码,亲自看看这种消息操作是如何进行的了。打开一个终端,切换到 example-2 目录,并按常规方式启动应用程序:
docker-compose up --build
如果你收到任何关于容器已创建的错误,可能是因为你留下了上一个示例在运行。在从每个示例继续之前,务必使用
docker-compose down
等待微服务上线,然后将浏览器指向 http://localhost:4001/video。测试视频将开始播放。
切换回终端以查看 Docker Compose 输出。你应该会看到确认视频流微服务发送了已查看消息的输出,随后是一些显示历史微服务接收了消息的文本。
到目前为止,我们可以直接检查以确保“查看”操作已存储在数据库中。你需要安装数据库查看器。如果你已经从第四章安装了 Robo 3T,你可以使用它。
将你的数据库查看器连接到数据库(在 localhost:4000 上连接,这是 Docker Compose 文件中配置的端口),然后查看历史数据库的视频集合,确认每次刷新浏览器时都会创建一个新的记录。检查数据库是测试代码最终结果的一种实用方法。
5.7.6 直接消息的顺序
直接消息的一个潜在好处是能够拥有一个控制器微服务,它可以协调多个其他微服务之间的复杂行为序列。因为直接消息有直接响应,这允许单个微服务协调或编排多个其他微服务的活动。
这种类型的消息被称为 同步通信 的原因是我们可以像图 5.9 所示的那样协调消息。在图中,微服务 A 正在协调其他微服务的活动。

图 5.9 直接消息允许一个控制器微服务(此处为微服务 A)在多个其他微服务之间编排复杂行为。
注意 直接消息可以用来以明确的方式或定义良好的顺序协调行为。
使用直接消息,很容易跟踪代码并理解消息的顺序。你很快就会看到追踪间接消息的顺序并不那么容易。
5.7.7 我们取得了什么成果?
在 5.7 节中,我们探讨了使用 HTTP POST 请求直接从微服务向微服务发送已查看消息。这被称为 直接消息,因为我们可以通过名称直接将这些消息发送到特定的微服务。我们还可以立即知道消息是否成功处理或失败。
最好将此类消息视为命令或行动呼吁,而不是通知。由于直接消息的同步性质,我们可以对多个协调消息进行排序。当我们想要一个控制器微服务来协调其他微服务的复杂行为时,这很有用。
尽管直接消息可能很有用,有时也是必要的,但它们也有一些主要的缺点。首先,我们一次只能针对一个其他微服务。因此,当我们希望一条消息被多个接收者接收时,直接消息并不容易工作。
此外,直接消息是微服务之间高度耦合的点。有时高度耦合是必要的,但我们更喜欢尽可能避免它。从控制器微服务集中编排多个微服务的能力可能看起来像是一个优势,而且确实可以使了解应用程序中正在发生的事情变得更容易。
但最大的问题是,这可能会为可能是一个庞大且复杂的操作创建一个单点故障。如果在编排过程中控制微服务崩溃了会发生什么?我们的应用程序现在可能处于不一致的状态,并且可能已经丢失了数据。直接消息引起的问题可以通过间接消息来解决,这就是我们现在转向 RabbitMQ 的原因。
5.8 使用 RabbitMQ 进行间接消息传递
现在我们已经掌握了使用 HTTP POST 请求进行直接消息的方法,是时候看看间接消息了,这可以帮助我们解耦我们的微服务。一方面,它可能会使我们的应用程序架构更难以理解。另一方面,它对安全性、可扩展性、可扩展性、可靠性和性能有许多积极的副作用。
注意:RabbitMQ 允许我们解耦消息发送者与消息接收者。发送者不知道是否还有其他微服务将处理一条消息。
图 5.10 展示了在添加 RabbitMQ 服务器后我们应用程序的结构。视频流微服务不再直接耦合到历史微服务。相反,它将查看的消息发布到消息队列。然后,历史微服务在其自己的时间从队列中拉取消息。

图 5.10 使用 RabbitMQ 通过消息队列间接向其他微服务发送消息
5.8.1 为什么选择 RabbitMQ?
RabbitMQ 是一种广为人知且成熟的用于消息队列的软件。许多公司都在普遍使用它,而且它也是我用于间接消息的首选解决方案。RabbitMQ 稳定且成熟。它是在十年前开发的,并且除了其他协议外,它还实现了高级消息队列协议(AMQP),这是一种消息代理通信的开放标准。
注意:RabbitMQ 以微服务之间的间接通信而闻名,并允许实现复杂和灵活的消息架构。
RabbitMQ 为所有流行的编程语言都提供了库,所以无论你的技术栈如何,你都不会有任何问题使用它。我们正在使用 Node.js,所以我们将使用 npm 注册表上可用的 amqplib 库。RabbitMQ 是开源的,并且相对容易入门。你可以在以下链接找到服务器的代码:
github.com/rabbitmq/rabbitmq-server
5.8.2 间接针对微服务发送消息
使用间接消息,我们并不是直接针对任何特定的微服务,但我们仍然需要将我们的消息指向某个东西。而那个东西将是一个 RabbitMQ 服务器。在那个服务器中,要么是一个命名队列,要么是一个消息交换。队列和交换的组合为我们提供了在结构化我们的消息架构方面的很大灵活性。
注意:消息发送者使用 DNS 解析 RabbitMQ 服务器的 IP 地址。然后它与它通信,在特定的命名队列或交换上发布消息。接收者也使用 DNS 定位 RabbitMQ 服务器并与它通信,从队列中检索消息。在任何时候,发送者和接收者都没有直接通信。
要向队列或交换发布消息,我们必须首先将 RabbitMQ 服务器添加到我们的应用程序中。然后我们可以使用 AMQP 代码库(称为 amqplib)来发送和接收消息。
在底层,DNS 将 RabbitMQ 主机名解析为 IP 地址。现在,我们不再像通过 HTTP POST 请求发送消息时那样将消息直接指向特定的微服务,而是将这些消息指向通过 DNS 定位的 RabbitMQ 服务器上的特定队列或交换。

图 5.11 通过将消息推入 RabbitMQ 队列来发送消息。
间接消息的传输分为两部分进行,所以我将使用两个图来解释它。我们首先考虑使用队列,然后我们再看看使用交换。图 5.11 显示了视频流微服务pushing其消息到viewed队列。然后在图 5.12 中,我们可以看到历史微服务pulling队列中的消息。

图 5.12 通过从 RabbitMQ 队列中拉取消息来接收消息。
我在这里使用了pushing和pulling这两个动词,因为这是一种很好的方式来可视化这个交易。在之前的 HTTP POST 中,我们可以想象视频流微服务正在将其消息推送到历史微服务,而历史微服务在这方面没有选择。消息被强制推送到历史微服务,而不考虑它实际上是否有处理它的能力。
使用间接消息,历史微服务得到了更多的控制。现在,当它准备好这样做时,它会从队列中拉取消息。当它不堪重负,没有能力接受新消息时,它可以自由地忽略这些消息,让它们在队列中堆积,直到它能够处理它们。
5.8.3 创建 RabbitMQ 服务器
让我们在我们的应用程序中添加一个 RabbitMQ 服务器。信不信由你,RabbitMQ 是用 Erlang 语言编写的。曾经有一段时间设置它可能很困难,但现在不再是了!如今,这已经变得非常简单,多亏了我们已经学到的 Docker 和 Docker Compose 技能。
列表 5.8 是 example-3 Docker Compose 文件的摘录,展示了如何将 RabbitMQ 服务器添加到我们的应用程序中。这是从 Docker Hub 上的镜像实例化容器的另一个例子,就像我们在第四章为 MongoDB 数据库所做的那样。
列表 5.8 向 Docker Compose 文件中添加 RabbitMQ 服务器(摘自第五章的 example-3/docker-compose.yaml)
version: '3'
services:
# ... other services defined here ...
rabbit: ①
image: rabbitmq:3.8.1-management ②
container_name: rabbit ③
ports: ④
- "5672:5672" ④
- "15672:15672" ④
expose: ⑤
- "5672" ⑤
- "15672" ⑤
restart: always ⑥
# ... more services defined here ...
① 定义了托管我们的 RabbitMQ 服务器的容器
② 我们使用 RabbitMQ 镜像的管理版本。这为我们提供了 RabbitMQ 控制台。
③ 设置了容器的名称。这是我们用来连接到 RabbitMQ 服务器的名称。
④ 配置了从主机操作系统到容器的端口映射
⑤ 配置了哪些容器端口被暴露。这些是连接到服务器和查看控制台的标准 RabbitMQ 端口。
⑥ 如果 RabbitMQ 服务器出现故障,这将使其自动重启。
5.8.4 探索 RabbitMQ 控制台
你可能已经注意到了列表 5.8 中 RabbitMQ 端口的配置。5672 端口是我们很快将用于通过 amqplib 通过 RabbitMQ 发送和接收消息的端口号。我们将使用 15672 端口访问 RabbitMQ 管理控制台。
注意 RabbitMQ 的控制台是了解 RabbitMQ 的工作方式以及更好地理解在应用程序中传递的消息的绝佳方式。
我们从名为 rabbitmq:3.8.1-management 的镜像启动了 RabbitMQ 服务器,因为这个镜像自带内置的管理控制台。控制台在图 5.13 中展示,它作为探索我们应用程序中消息流的一种图形方式。现在让我们看看它。自己启动应用程序,以便你可以尝试一下!
打开一个终端并切换到 example-3 目录。以正常方式启动应用程序(如果其他方式不起作用,我将会确保你记住这个命令!):
docker-compose up --build

图 5.13 RabbitMQ 管理控制台
除了数据库和你的微服务的输出之外,你还应该看到来自你的 RabbitMQ 服务器的输出流。给它一些时间启动,然后用你的网络浏览器指向 http://localhost:15672/。你可以使用默认用户名 guest 和默认密码 guest 登录。
你现在应该能看到 RabbitMQ 控制台。但与图 5.13 不同,你目前还看不到任何队列或交换机。我是在创建了查看队列之后截图的。我们将在稍后触发队列的创建,然后你可以回到控制台看看它的样子。
RabbitMQ 仪表板是一个有用的调试工具。我相信,能够可视化正在发生的事情总是比仅仅假设我们知道正在发生的事情要好。仪表板是那些优秀的可视化工具之一,它使我们的应用程序实际上在做什么一目了然!
你可能会注意到我们不需要包含 RabbitMQ 仪表板。我们可以使用 rabbitmq:3.8.1 的镜像,这是一个不包含仪表板的镜像。如果你正在构建一个精简的生产应用程序或者你有特定的安全顾虑,这可能是你的首选。但通常,我更喜欢在生产环境中保留仪表板(当然是在私有网络之后),因为这些工具对我们理解生产环境中的情况非常有价值。
5.8.5 将我们的微服务连接到消息队列
在 RabbitMQ 服务器就绪后,我们现在可以更新我们的微服务以连接到它。如果你从头开始编写代码,你必须首先将 amqplib npm 包安装到每个需要连接到 RabbitMQ 的微服务中:
npm install --save amqplib
如果你直接在 Node.js 下运行 example-3 的代码,你必须首先安装所有依赖项:
npm install
下一个列表是历史微服务的 index.js 文件的摘录。它展示了我们如何连接到 RabbitMQ 服务器。
列表 5.9 连接到 RabbitMQ 服务器(摘自第五章/示例-3/history/index.js)
// ... other package imports here ...
const amqp = require("amqplib"); ①
const RABBIT = process.env.RABBIT; ②
// ... code omitted here ...
function connectRabbit() { ③
return amqp.connect(RABBIT) ④
.then(messagingConnection => {
return messagingConnection
➥ .createChannel(); ⑤
});
}
// ... code omitted here ...
function main() {
return connectDb() ⑥
.then(db => {
return connectRabbit() ⑦
.then(messageChannel => {
return startHttpServer(db,
➥ messageChannel); ⑧
});
});
}
main()
.then(() => console.log("Microservice online."))
.catch(err => {
console.error("Microservice failed to start.");
console.error(err && err.stack || err);
});
① 导入 amqplib 库。这是与 RabbitMQ 服务器通信的 API。
② 获取连接到 RabbitMQ 的 URI
③ 一个用于创建连接的辅助函数
④ 连接到 RabbitMQ 服务器
⑤ 创建 RabbitMQ 消息通道
⑥ 连接到数据库
⑦ 连接到 RabbitMQ 服务器
⑧ 启动 HTTP 服务器
列表 5.9 和列表 5.10(紧随其后)最重要的部分是如何通过 RABBIT 环境变量配置到 RabbitMQ 服务器的连接。列表 5.10 是 example-3 Docker Compose 文件的摘录。它设置了 RABBIT 环境变量,包括用户名(guest)、密码(也是 guest)、服务器的主机名(rabbit)和连接的端口号(5672)。
列表 5.10 配置历史微服务(摘自第五章/示例-3/docker-compose.yaml)
version: '3'
services:
# ... other services defined here ...
history:
image: history
build:
context: ./history
dockerfile: Dockerfile-dev
container_name: history
volumes:
- /tmp/history/npm-cache:/root/.npm:z
- ./history/src:/usr/src/app/src:z
ports:
- "4002:80"
environment:
- PORT=80
- RABBIT=amqp://guest:guest@rabbit:5672 ①
- DBHOST=mongodb://db:27017
- DBNAME=history
- NODE_ENV=development
depends_on:
- db
- rabbit ②
restart: "no"
① 设置连接到 RabbitMQ 的 URL
② 历史微服务现在依赖于我们在列表 5.8 中定义的 rabbit 容器。
这个谜题还有一个部分你可能直到尝试启动这个版本的应用程序时才意识到。RabbitMQ 服务器相当重量级,启动并准备好接受连接需要时间。另一方面,我们的微服务轻量级,只需片刻即可准备就绪。
当我们的微服务尝试连接到 RabbitMQ 但 RabbitMQ 尚未准备好时会发生什么?它会出错并终止!我们现在遇到了问题,因为我们应用程序中有启动依赖项需要按特定顺序解决。
要成为一个容错且表现良好的微服务,它实际上应该在尝试连接之前等待 RabbitMQ 服务器准备好。更好的是,如果 RabbitMQ 突然关闭(例如,因为我们正在升级它),我们希望我们的微服务能够处理断开连接并在尽可能快的时间内自动重新连接。我们希望它能这样工作,但这更复杂。目前,我们将使用一个简单的解决方案来解决这个问题。在第十章中,我们将学习一种更复杂的方式来处理这个问题。
解决这个问题的最简单方法是什么?我们将在 Dockerfile 中添加一个额外的命令,以延迟我们的微服务直到 RabbitMQ 服务器准备好。我们将使用通过 npm 安装的方便的wait-port命令:
npm install --save wait-port
列表 5.11 显示了带有wait-port命令的历史微服务更新后的 Dockerfile。我们使用这个命令来延迟微服务的启动,直到 RabbitMQ 启动后。
列表 5.11 历史微服务的更新后的 Dockerfile,它等待 RabbitMQ(第五章/示例 3/历史/Dockerfile-dev)
FROM node:10.15.2-alpine
WORKDIR /usr/src/app
COPY package*.json ./
CMD npm config set cache-min 9999999 && \
npm install && \
npx wait-port rabbit:5672 && \ ①
npm run start:dev ②
① 使用 npx 调用本地安装的wait-port命令,等待在主机名为 rabbit 的服务器上端口 5672 接受连接
② 在wait-port完成后,启动历史微服务
同时,我们应该更新 Dockerfile 的生产版本。在我们工作时,保持两个版本同步是好事。
使用wait-port是在我们首次开始构建微服务应用程序时快速启动的一种简单而有效的方法。然而,它并不非常健壮。启动顺序问题并不是唯一的问题。我们通常希望我们的微服务具有容错能力,能够应对其他服务器和微服务的不可避免的中断。我们将在第十章中回到这个问题。
在这一点上,你可能想知道为什么在第四章开始使用 MongoDB 数据库时我们没有遇到这个启动顺序问题?当然,数据库也需要时间来启动,但我们不需要在连接到它之前等待它准备好。
好吧,这完全是 MongoDB 库中良好的软件工程的结果。它已经为自动重新连接进行了编程,所以感谢 MongoDB 工程师为你付出了这么多的努力。这应该让你有所思考。当编写代码库时,花点时间考虑我们的用户视角,将转化为他们更好的体验。
5.8.6 单接收者间接消息
我们可以通过多种方式配置 RabbitMQ 中的消息路由来实现各种消息架构。我们将专注于两种简单的配置,这些配置将处理你在构建应用程序时面临的大多数通信问题。
第一个是单接收者消息的设置,我们将使用它来创建一对一,但仍然间接的消息通道,在微服务之间。尽管在这个配置中,您允许有多个发送者和接收者参与,但您可以保证每个单独的消息只由一个微服务接收。这对于将工作分配给一组微服务时非常出色,但这项工作应由第一个能够处理它的微服务来处理。
注意 单接收者消息是 一对一 的:一条消息从一个微服务发送,只被另一个微服务接收。这是一种确保在您的应用程序中特定工作只执行一次的绝佳方式。
接收单接收者消息
让我们在历史微服务中添加代码,以便它可以接收单接收者消息。我们已经在 5.8.5 节中添加了连接到我们的 RabbitMQ 服务器的代码。一旦连接,我们现在可以 断言 一个消息队列并开始从该队列拉取消息。注意我在这里使用的新术语。
我说的是“断言”一个消息队列,而不是“创建”一个消息队列。区别在于多个微服务可以断言一个队列,所以它就像检查队列是否存在,然后只有在它不存在时才创建它。这意味着队列只创建一次,并在所有参与的微服务之间共享。不要将这与其他在编程中常用的一种断言概念混淆——这两个是两个不同的概念。
列表 5.12 是历史微服务的 index.js 的摘录,它断言了已查看队列并调用 consume 以开始接收消息。这就是它!实际上,接收 RabbitMQ 消息所需的代码并不多。
列表 5.12 从 RabbitMQ 队列中消费已查看消息(摘自 chapter-5/example-3/history/index.js)
// ... code omitted here ...
function setupHandlers(app, db, messageChannel) {
const videosCollection = db.collection("videos");
function consumeViewedMessage(msg) { ①
const parsedMsg = JSON
➥ .parse(msg.content.toString()); ②
return videosCollection.insertOne({ videoPath: parsedMsg.videoPath })③
.then(() => { ④
messageChannel.ack(msg); ⑤
});
};
return messageChannel.assertQueue("viewed", {}) ⑥
.then(() => {
return messageChannel.consume("viewed",
➥ consumeViewedMessage); ⑦
});
}
// ... code omitted here ...
① 一个处理传入消息的函数
② 将 JSON 消息解析为 JavaScript 对象
③ 在历史数据库中记录查看
④ 如果没有错误……
⑤ ……确认消息。
⑥ 断言我们有一个已查看队列
⑦ 开始从已查看队列接收消息
列表 5.12 中的代码仅因我们希望以 JSON 格式发送消息而略显复杂,但 RabbitMQ 并没有原生支持 JSON。因此,我们必须手动解析传入的消息有效负载。
RabbitMQ 实际上对消息有效负载的格式是中立的,从它的角度来看,一条消息只是一个二进制数据块。这在性能关键的情况下可能很有用,我们可能会用更有效的二进制格式来替换 JSON。
发送单接收者消息
使用 RabbitMQ 发送简单消息甚至比接收消息更容易。列表 5.13 是视频流微服务的 index.js 文件的摘录。假设我们已经添加了类似列表 5.9 中的代码并将此微服务连接到 RabbitMQ 服务器。我们现在通过指定队列名称(viewed)和提供消息负载来调用 publish。
列表 5.13 将观看消息发布到 RabbitMQ 队列(摘自第五章/示例 3/video-streaming/index.js)
// ... code omitted here ...
function sendViewedMessage(messageChannel,
➥ videoPath) { ①
const msg = { videoPath: videoPath }; ②
const jsonMsg = JSON.stringify(msg); ③
messageChannel.publish("", "viewed",
➥ Buffer.from(jsonMsg)); ④
}
// ... code omitted here ...
① 发送观看消息的辅助函数
② 定义消息负载。这是我们随消息发送的数据。
③ 将消息转换为 JSON 格式
④ 将消息发布到观看队列
再次强调,列表 5.13 只稍微复杂一些,因为我们必须在发送消息之前手动 字符串化(或序列化)我们的消息负载为 JSON。除此之外,它相当直接。现在我们有了视频流微服务,每当用户观看视频时,就会发布观看消息。
测试单收件人消息
我们已经准备好进行另一次测试运行。我们有一个 RabbitMQ 服务器。视频流微服务正在发送观看消息,而历史微服务正在接收它。如果您还没有这样做,请启动示例-3 应用程序:
docker-compose up --build
等待数据库和 RabbitMQ 启动以及微服务建立连接。现在将您的网络浏览器指向 http://localhost:4001/video。检查输出以确认消息已发送并接收。您可以使用 Robo3T 检查历史微服务是否在其数据库中为观看创建了新的记录。
5.8.7 多收件人消息
发送单收件人消息是 RabbitMQ 的第一个常见用例。它也是最简单的,这就是为什么我们从它开始。潜在地更有用得多的是多收件人(或广播式)消息。简单来说,一个微服务发送消息,但许多其他微服务可以接收它。
我们使用这种类型的消息用于 通知(例如,指示应用程序中发生重要事件的邮件,如视频被观看的事件)。这是多个其他微服务都希望了解的消息。
注意 多收件人消息是 一点对多:消息只从一个微服务发送,但可能被许多其他微服务接收。这是在您的应用程序中发布通知的绝佳方式。
要使这适用于 RabbitMQ,我们现在必须使用一个消息交换。图 5.14 显示了视频流微服务将其消息发布到观看交换。从交换中,消息被路由到多个匿名队列,以便多个微服务同时处理。
当你查看图 5.14 时,你可能会想知道推荐微服务是从哪里来的?不,你没有错过任何东西!我实际上在你没注意的时候悄悄地引入了一个新的微服务。我不得不这样做;否则,我没有办法向你展示这些广播式消息是如何工作的。

图 5.14 向多个收件人广播要处理的消息
推荐微服务将在以后向我们的用户推荐观看的视频。它现在出现在这里只是为了让我们看到多收件人消息的实际操作。
接收多收件人消息
接收多收件人消息与接收单收件人消息没有太大区别。以下是从历史微服务的 index.js 文件中摘取的代码。
列表 5.14 从 RabbitMQ 交换机中消费已查看的消息(摘自第五章/示例 4/history/index.js)
// ... code omitted here ...
function setupHandlers(app, db, messageChannel) {
const videosCollection = db.collection("videos");
function consumeViewedMessage(msg) { ①
const parsedMsg = JSON.parse(msg
➥ .content.toString()); ②
return videosCollection.insertOne({ videoPath: parsedMsg
➥ .videoPath }) ③
.then(() => { ④
messageChannel.ack(msg); ⑤
});
};
return messageChannel
➥ .assertExchange("viewed", "fanout") ⑥
.then(() => {
return messageChannel
➥ .assertQueue("", { exclusive: true }); ⑦
})
.then(response => {
const queueName = response.queue; ⑧
return messageChannel
➥ .bindQueue(queueName, "viewed", "") ⑨
.then(() => {
return messageChannel
➥ .consume(queueName, consumeViewedMessage); ⑩
});
});
}
// ... code omitted here ...
① 一个处理传入消息的函数
② 将 JSON 消息解析为 JavaScript 对象
③ 记录历史数据库中的视图
④ 如果没有错误……
⑤ ... 确认消息。
⑥ 断言我们有一个已查看的交换机
⑦ 创建一个匿名队列。选项 exclusive 设置为 true,这样当微服务从队列断开连接时,队列将被自动释放(否则,我们的应用程序将出现内存泄漏)。
⑧ 为匿名队列分配一个自动生成的唯一标识符作为其名称
⑨ 将队列绑定到交换机
⑩ 开始从绑定到已查看交换机的匿名队列接收消息
列表 5.14 与列表 5.12 的区别在于我们现在断言的是已查看的交换机(又是那个断言术语),而不是已查看的队列。之后,我们断言一个匿名队列。通过创建一个无名的队列,我们得到一个只为这个微服务创建的独特队列。已查看的交换机在所有微服务之间共享,但匿名队列仅由这个微服务拥有。这个细节是这个工作的重要部分。
在创建无命名的队列时,我们将得到 RabbitMQ 生成的随机名称。RabbitMQ 分配给我们的队列的名称只重要,因为我们现在必须将队列绑定到已查看交换机。这种绑定将交换机和队列连接起来,使得 RabbitMQ 在交换机上发布的消息被路由到队列。
每个其他想要接收已查看消息的微服务(例如,我悄悄引入的推荐微服务)都会创建自己的无命名的队列来绑定到已查看交换机。我们可以有任意数量的其他微服务绑定到已查看交换机,并且当消息发布到交换机时,这些微服务都会在自己的匿名队列上接收到消息的副本。
发送多收件人消息
再次强调,发送多收件人消息与发送单收件人消息类似。列表 5.15 是视频流微服务的 index.js 文件的摘录。我在这个摘录中包含了更多代码,因为了解在这种情况下与 RabbitMQ 服务的连接方式很重要。它之所以不同,是因为微服务启动时我们断言存在查看交换。
在启动时执行此操作意味着我们可以依赖交换在整个微服务生命周期中的存在。在列表中,我们仍然使用 publish 函数发送消息,但现在我们指定消息是发布到查看交换而不是查看队列。
列表 5.15 将查看消息发布到 RabbitMQ 交换(来自第四章示例 3/video-streaming/index.js 的摘录)
// ... code omitted here ...
function connectRabbit() {
return amqp.connect(RABBIT)
.then(connection => {
console.log("Connected to RabbitMQ.");
return connection.createChannel()
.then(messageChannel => {
return messageChannel.assertExchange(
➥ "viewed", "fanout") ①
.then(() => {
return messageChannel;
});
});
});
}
function sendViewedMessage(
➥ messageChannel, videoPath) { ②
const msg = { videoPath: videoPath }; ③
const jsonMsg = JSON.stringify(msg); ④
messageChannel
➥ .publish("viewed", "", Buffer.from(jsonMsg)); ⑤
}
// ... code omitted here ...
function main() {
return connectRabbit() ⑥
.then(messageChannel => {
return startHttpServer(messageChannel); ⑦
});
}
// ... code omitted here ...
① 断言我们有一个查看交换
② 一个发送查看消息的辅助函数
③ 定义消息的有效负载
④ 将消息转换为 JSON 格式
⑤ 将消息发布到查看交换
⑥ 连接到 RabbitMQ 服务器
⑦ 启动 HTTP 服务器
测试多收件人消息
让我们测试我们的更新代码。正是为了这个测试,我将推荐微服务添加到我们的应用程序中。这个新微服务实际上只是一个存根;它除了打印接收到的消息外,什么都不做。这足以表明多个微服务可以处理这些消息。打开终端,切换到 example-4 目录,并执行常规操作:
docker-compose up --build
当你在网页浏览器中访问 http://localhost:4001/video 时,你应该会看到消息被打印到控制台,以显示历史微服务和推荐微服务都在接收查看的消息。
这之所以可行,是因为我们有一个绑定到两个队列的交换:我们为每个接收微服务有一个队列。仅使用单个队列无法实现这种行为。当我们向单个共享队列发布消息时,接收微服务会竞争成为第一个拉取并处理该消息的服务。你可以把这看作是一种负载均衡。这有时是一种有用的技术,但广播式消息通常更有用。
5.8.8 间接消息的排序
间接消息有许多积极的好处,但这些都可能使理解和控制我们应用程序的行为变得更加困难。对于间接消息,没有直接响应的方式,并且从发送者的角度来看,接收者可能根本不存在!发送者无法知道是否有一个接收者在等待接收其消息。
注意:由于间接消息没有“中央控制”,因此这些允许实现更加灵活、可扩展和可演化的消息架构。每个独立的微服务负责如何响应传入的消息,并且可以生成许多其他响应消息。
与直接消息不同,间接消息没有单个微服务负责协调其他微服务。这并不一定是个坏事情。考虑一下,有一个单一的控制器微服务意味着我们有一个单点故障,这无疑是件坏事。如果控制微服务在复杂协调过程中崩溃,会发生什么?正在进行的任何操作都将丢失!这就是直接消息可能产生的可怕副作用。
有时直接消息很有用,但一般来说,间接消息允许构建更复杂和更具弹性的行为网络。我们可能难以理解它在复杂性中的整体结构,但至少我们知道它是可靠的!这是因为没有单个点可以失败,微服务之间的连接是通过可靠和容错的队列(好吧,RabbitMQ 可能会失败,但它比我们自己的微服务失败的可能性要小得多)实现的。
任何特定的微服务都可能失败,但即使它在处理消息时失败,我们也知道消息不会丢失。因为当微服务崩溃时,消息不会被确认,所以这些消息最终会被发送到另一个微服务进行处理。正是这些小技术的总和,帮助我们构建了一个坚固可靠且可靠的微服务应用程序。请查看图 5.15,以更直观地了解间接消息如何在您的应用程序中按顺序排列成动态的消息流。

图 5.15 间接消息允许更自由和灵活地编排微服务,从而产生涌现行为。
5.8.9 我们取得了什么成果?
在本节中,我们学习了如何使用 RabbitMQ 在我们的微服务之间发送间接消息。首先,我们尝试发送单收件人消息。然后,我们改为多收件人消息,以便我们可以广播全应用的消息。
注意:使用间接的多收件人消息似乎是处理查看消息的正确方式,因此我们的微服务耦合度更低。这是一个很好的成果。
我们本可以提前规划并直接转向间接广播风格的短信,但这是经验的好处。现在我们已经考虑了所有选项,您有了这样的经验,并且更有能力根据您向应用程序添加更多消息的情况,自行决定需要哪种风格的消息。
5.9 微服务通信回顾
现在,您手头上有两种不同的消息风格可供使用,以使您的微服务相互通信。您已经学会了如何使用 HTTP 请求发送直接消息,以及如何使用 RabbitMQ 发送间接消息。使用 RabbitMQ,您可以发送单收件人和多收件人(或广播)消息。
我们有一个灵活的消息结构,未来可以扩展。稍后,我们将向这个应用添加更多微服务,每个微服务可能或可能不会关心查看的消息。但那些关心的可以简单地处理它,而无需我们修改消息的原始发送者。
我们讨论了为什么你可能想要选择一种消息风格而不是另一种风格的各种原因。为了您的方便,这些信息已在表 5.2 中总结。当您在特定情况下决定需要哪种消息风格时,可以参考此表。
表 5.2 何时使用每种通信类型
| 情况 | 使用什么 |
|---|---|
| 我需要通过名称将消息直接发送到特定的微服务。 | 直接消息:HTTP |
| 我需要确认消息处理成功或失败。 | 直接消息:HTTP |
| 我需要在第一个消息完成后对后续消息进行排序。 | 直接消息:HTTP |
| 我希望一个微服务能够协调其他微服务的活动。 | 直接消息:HTTP |
| 我需要将一条消息广播到整个应用中,以通知零个或多个微服务系统中的事件(我不关心消息是否被处理)。 | 间接消息:RabbitMQ |
| 我想要解耦发送者和接收者(这样它们可以更容易地独立更改和演进)。 | 间接消息:RabbitMQ |
| 我希望发送者和接收者的性能是独立的(发送者可以发送任意数量的消息,接收者将按自己的时间处理这些消息)。 | 间接消息:RabbitMQ |
| 我要确保如果消息处理失败,它将自动稍后重试,直到成功(这样就不会因为间歇性故障而丢失消息)。 | 间接消息:RabbitMQ |
| 我需要将消息的处理负载均衡到一组工人中的一个。 | 要么 HTTP 要么 RabbitMQ |
| 我需要将消息的处理分配给多个可以并行操作的工人。 | 间接消息:RabbitMQ |
5.10 继续你的学习
本章带您游览了我们可以让我们的微服务进行通信的各种方式。我们使用了 HTTP 进行直接消息,RabbitMQ 进行间接消息。像往常一样,我们只是简要地触及了这些主题,还有很多可以学习。以下是一些学习更多知识的优秀资源:
-
《API 设计模式》 由 JJ Geewax 著(Manning,预计 2021 年春季)
-
《Web API 设计》 由 Arnaud Lauret 著(Manning,2019 年)
-
《深入 RabbitMQ》 由 Gavin M Roy 著(Manning,2017 年)
-
《RabbitMQ in Action》由 Alvaro Videla 和 Jason J.W. Williams 著(Manning,2012 年)
要了解更多关于 amqplib 包的信息,请在此处阅读文档:
要了解更多关于 wait-port 命令的信息,请参阅:
我们已经走了很长的路。在构建我们的第一个微服务后,我们迅速扩展到开发多个通信微服务。每个微服务都可以有自己的数据库和/或文件存储。我们现在使用实时重新加载来高效地在我们编码时重新加载整个应用程序。
接下来是什么?我们有一个初出茅庐的应用程序。它目前还做不了很多事情,但这并不是避免将其部署到生产环境的原因。将我们的应用程序部署到生产环境可能是一项艰巨的任务,最好在应用程序小而简单时完成。因此,无需多言,在第六章和第七章中,我们将把我们的应用程序部署到生产环境!
摘要
-
我们可以使用 Docker 卷在开发工作站和应用程序中的容器之间共享代码。
-
使用 nodemon 进行实时重新加载意味着我们可以更新我们的代码,并且应用程序中相关的微服务可以自动重新加载,而无需重新构建和重启整个应用程序。
-
微服务之间的通信有两种风格:直接和间接。
-
当我们想要明确地序列化消息流或仔细编排其他微服务的行为时,直接或同步消息最为有用。
-
使用直接消息,我们可以立即知道消息处理是否成功或失败。
-
间接或异步消息帮助我们解耦微服务,这有助于促进灵活和可扩展的应用程序的开发。
-
使用间接消息,我们可以在整个应用程序中广播一条消息,以通知其他微服务系统中的重要事件。
-
HTTP POST 请求用于在微服务之间发送直接消息。
-
RabbitMQ 是一种用于消息队列的软件。我们可以用它来在微服务之间发送间接消息。
-
尽管我们使用了 wait-port npm 包在微服务连接到它之前等待 RabbitMQ 服务器准备好,但在第十章中,我们将学习一种更好的等待当前不可用的其他服务的方法。
-
决定使用 HTTP 还是 RabbitMQ 取决于具体情况的需求。请参考第 5.9 节中的表 5.2,以帮助您根据需求决定使用哪种方式。
6 创建您的生产环境
本章涵盖
-
为您的应用程序构建生产基础设施
-
使用 Terraform 脚本创建基础设施
-
创建一个 Kubernetes 集群以托管微服务
-
与您的 Kubernetes 集群交互
最后,我们来到了这本书中最激动人心的章节!接下来的两个章节也可能是迄今为止最难的,但请务必跟随示例进行学习。这样你才能学到最多,并获得将自己的应用程序投入生产的真实经验。
在本章和下一章中,我们将创建一个 Kubernetes 集群并将容器部署到其中:一个 MongoDB 数据库,一个 RabbitMQ 服务器,当然还有我们的视频流微服务。在下一章中,我们还将构建一个持续交付(CD)管道,该管道会自动将我们的更新代码部署到生产环境中。这两个章节中的示例旨在让您逐步跟随构建我们的基础设施和应用。这紧密地模拟了原型化新基础设施的真实过程,并且接近我在自己开发中真正做的事情。
这些章节是我们进行生产部署所需的两部分。在第六章,也就是本章,我们将构建我们的生产基础设施。到本章结束时,我们将拥有一个全新的容器注册库和一个空白的 Kubernetes 集群,它们都准备好了,等待托管我们的应用程序。在第七章,我们将继续努力,学习如何通过自动化部署管道部署我们的微服务。让我们开始吧!
6.1 新工具
本章介绍了两个新工具:Kubernetes 和 Terraform。这两个工具如此重要,以至于它们成为了本书的标题!我们将使用 Terraform 为我们微服务应用程序创建基础设施,包括我们的 Kubernetes 集群。
表 6.1 第六章中的新工具
| 工具 | 版本 | 目的 |
|---|---|---|
| Kubernetes | 1.18.8 | Kubernetes 是我们用于在生产中托管微服务的计算平台。 |
| Terraform | 0.12.29 | Terraform 允许我们脚本化创建云资源和应用程序基础设施。 |
| Kubectl | 1.18.6 | Kubectl 是与 Kubernetes 集群交互的命令行工具。 |
| Azure CLI | 2.9.1 | 我们将使用 Azure 命令行工具对 Azure 进行身份验证,并允许 Terraform 访问我们的 Azure 账户。此工具通常用于管理 Azure 账户和云资源。 |
6.2 获取代码
要跟随本章,您需要下载代码或克隆存储库。
-
从这里下载代码的 zip 文件:
-
您可以使用 Git 克隆代码如下:
git clone https://github.com/bootstrapping-microservices/chapter-6.git
如需安装和使用 Git 的帮助,请参阅第二章。如果您在代码方面遇到问题,请在 GitHub 的存储库中记录一个问题。
6.3 进入生产
那一天已经到来。我们将我们的应用程序部署到生产环境。虽然看起来将这个小型应用程序部署到生产环境可能还为时尚早,但实际上,在正常开发情况下,我确实主张尽可能早地将应用程序部署到生产环境。也许不会像现在这样早,但我确实认为当你的应用程序仍然很小时进入生产环境是一个好主意。那为什么?
进入生产意味着将我们的应用程序放置在客户可以看到并使用它的位置。将我们的产品展示给用户是获取反馈、适应他们的需求并构建有价值功能的关键。如果我们不进入生产,我们就不会得到那些反馈。拥有允许快速可靠更新的部署管道是我们进行实验和找出如何制作出优秀产品的最佳工具。
此外,当我们的应用程序仍然很小的时候,这是构建 CD 管道并进入生产阶段的最佳时机。当我们的应用程序很小的时候,部署起来更容易。但随着我们的应用程序变得越来越大,这项任务会变得越来越困难。
图 6.1 展示了本章我们将做什么。我们将在我们的开发工作站(或个人电脑)上使用 Terraform 在云中构建基础设施。我们将使用代码创建我们的容器注册表和 Kubernetes 集群。然后,在第七章中,我们将学习如何通过持续交付(CD)自动化这一切。但就目前而言,我们将学习通过手动调用 Terraform 来演进我们的开发基础设施。

图 6.1 使用 Terraform 原型化我们的基础设施
6.4 基础设施即代码
基础设施即代码是我们将在本章中使用的技术,以将我们的应用程序部署到生产环境。它被称为基础设施即代码,因为与手动创建基础设施(例如,通过 GUI,就像我们在第三章中为我们的私有容器注册表所做的那样)不同,我们将编写创建我们基础设施的代码。
不仅此代码将描述我们的基础设施,我们还将执行它以实际创建我们的基础设施。使用代码创建基础设施意味着我们可以可靠地、重复地按需创建和重新创建我们的基础设施,并且可以像我们喜欢的那样频繁地这样做。
代码不仅描述了我们的基础设施,还构建了它,这使得它成为一种可执行文档。它是对我们希望基础设施看起来怎样的声明,并且与正常的(即不可执行的)文档不同,这是一种永远不会过时的文档形式。
通过基础设施即代码,创建和更新我们的基础设施变成了一种编码任务。最佳的基础设施即代码形式使用声明性语言而不是过程性语言。这意味着它描述了基础设施的配置和布局,而不是构建它的逐步指令。我们更喜欢声明性格式,因为我们可以让我们的工具承担繁重的工作,并且这些工具可以找出更改我们基础设施的最佳方式。
图 6.2 阐述了基础设施即代码的概念。我们的基础设施代码存储在代码仓库中,例如 Git。从那里,我们执行它以创建、配置和维护基于云的基础设施。

图 6.2 基础设施即代码使用可执行代码来创建基础设施。
基础设施即代码之所以重要,不仅是因为我们可以使用经过良好测试的代码来可靠地重复创建我们的基础设施。它还重要,因为它允许我们自动化基础设施的创建和维护。因此,它是持续交付的关键推动者,我们将在下一章中看到,我们将构建我们的自动化部署管道。
6.5 在 Kubernetes 上托管微服务
到下一章结束时,我们将在我们的应用程序的生产环境中运行多个容器。这些容器托管在云中运行的 Kubernetes 的管理版本上(你可以将其视为 Kubernetes 作为 服务 的形式)。
注意 Kubernetes 是一个用于管理基于容器的应用程序的计算平台。它最初由谷歌创建,但现在由云原生计算基金会管理,该基金会拥有巨大的行业支持,并负责许多其他有趣的项目。
Kubernetes 通常被称为容器编排平台。这告诉我们所有需要知道的信息。Kubernetes 可以管理和自动化容器的部署和扩展。Kubernetes 是我们微服务应用程序的生产骨干。我喜欢将其视为 微服务平台。
6.5.1 为什么选择 Kubernetes?
使用 Kubernetes 有许多原因。最简单的原因是避免供应商锁定。所有主要的云供应商都提供自己的容器编排服务,这些服务本身很好。但每个供应商也提供了一种管理的 Kubernetes 服务,所以为什么要在可以使用 Kubernetes 的情况下使用专有服务呢?使用 Kubernetes 意味着我们的应用程序可以迁移到任何云供应商。
我认为学习 Kubernetes(至少是基础知识)是值得的,因为这种知识是可以迁移的。尽管,在这本书中,我们在微软 Azure 上托管我们的 Kubernetes 集群,但你可以将 Kubernetes 技能随身携带,并在你最喜欢的云平台上使用。
Kubernetes 以其复杂性而闻名。当然,如果你想在自己的硬件上安装它,或者你想深入研究并成为专家,它确实很复杂。幸运的是,对于其他人来说,在我们的首选云平台上构建托管的 Kubernetes 集群要容易得多,至少在 Azure 上,我们可以在几个点击中通过 GUI 创建它。不过,我们不会通过 GUI 手动创建我们的基础设施。相反,我们将通过 Terraform 代码创建我们的集群。
Kubernetes 是从 Google 的丰富经验中诞生的,然后它被转交给社区。这意味着你可以分叉代码并自己为 Kubernetes 做贡献——前提是你有意愿陷入那个特定的兔子洞!
Kubernetes 允许我们构建以多种方式可扩展的应用程序。这一点我们将在第十章和第十一章中详细讨论。然而,在本章和下一章中,我们将学习绝对的基础知识。这足以构建一个生产集群,我们可以将我们的小型微服务应用程序部署到该集群。
最重要的是,Kubernetes 有一个可自动化的 API。这将使我们能够在第七章中构建我们的自动化部署管道。Kubernetes 正在成为微服务行业的标准,我预计它将继续朝这个方向发展。它得到了很好的支持,拥有一个庞大的社区和工具生态系统。
对我来说,Kubernetes 是一个通用的计算平台。它得到了所有主要云服务提供商的支持。无论我们最终在哪里,我们都可以带着 Kubernetes 一起走。Kubernetes 是开源的,你可以在以下位置找到代码:
github.com/kubernetes/kubernetes
6.5.2 Kubernetes 是如何工作的?
Kubernetes 集群由多台计算机组成。每台计算机被称为一个节点。每个节点实际上是一个虚拟机(VM)。我们可以根据需要向我们的集群添加任意数量的虚拟机,以控制应用程序可用的计算能力。每个节点还可以托管多个 Pod。Pod 是 Kubernetes 中计算的基本单元。
图 6.3 展示了节点和 Pod 的排列示例。图中所示的集群有三个节点(由三个虚拟机供电)。然而,在本章中我们创建的集群只有一个节点。这是因为我们的简单应用程序不需要太多的计算能力。这也意味着我们不会为超过我们实际需要的虚拟机付费。不过,扩展到更多节点很容易,我们将在第十一章中看到一个真实示例。

图 6.3 Kubernetes 集群的结构
如图 6.4 所示,每个 Pod 实际上可以托管多个容器。这可以成为许多有趣架构模式的基础(例如,代理和身份验证中广为人知的边车模式)。
然而,在这本书中,我们尽量保持简单。每个 pod 只会托管一个容器或微服务. 即使图 6.4 显示了一个包含多个容器的 pod,但对于本书的目的,你可以将 pod 视为一个容器或微服务,如果这对你来说是一种有用的简化。

图 6.4 Kubernetes pod 的结构
6.6 使用 Azure CLI
在我们开始使用 Terraform 之前,我们需要确保 Azure CLI 正在运行。这是让我们能够认证 Terraform 的最简单方式,以便它能够访问我们的 Azure 账户,并授予它代表我们在 Azure 上创建基础设施的权限。同时,拥有 Azure CLI 也非常方便,因为它是一种与我们的 Azure 账户交互和管理云资源的有用方式。
6.6.1 安装 Azure CLI
你可以在此处找到安装 Azure CLI 的说明:
docs.microsoft.com/en-us/cli/azure/install-azure-cli
选择你的平台并按照说明进行安装。安装 Azure CLI 后,你可以使用以下命令从终端测试它:
az --version
在撰写本文时,我正在使用版本 2.9.1。未来的版本应该具有向后兼容性。
Azure CLI 已在 Vagrant 虚拟机中预安装
如果你正在使用第六章代码库中包含的 Vagrant 虚拟机,你将发现所有需要的工具都已经安装好了。这包括 Azure CLI。
如果你正在使用 Linux,请查看第六章代码库中的 shell 脚本 scripts/provision-dev-vm.sh。这个 shell 脚本在虚拟机中安装工具,以便你可以将其作为在 Linux 计算机上安装 Azure CLI 的示例。
有关使用 Vagrant 的更多信息,请参阅附录 A。
6.6.2 使用 Azure 进行认证
我们安装 Azure CLI 的主要原因仅仅是用于与我们的 Azure 账户进行认证。我们可以通过在终端运行以下命令来完成:
az login
运行此命令会打开浏览器,以便你可以登录到你的 Azure 账户。如果浏览器没有自动打开,你需要手动检查输出,打开 URL,然后输入代码。命令的输出提供了以下说明:
To sign in, use a web browser to open the page https://microsoft.com/devicelogin
➥ and enter the code XXXXXX to authenticate.
输入代码后,点击“下一步”。现在使用你的用户名和密码登录到你的 Azure 账户。登录后,你将在浏览器中看到如下消息:
You have signed in to the Microsoft Azure Cross-platform Command Line
➥ Interface application on your device. You may now close this window.
现在,你可以关闭浏览器并返回终端。az login 命令完成并显示一个以 JSON 格式化的你的 Azure 订阅列表。如果你只为这本书刚刚注册了 Azure,你应该只看到一个订阅。如果你已经使用 Azure 进行工作,你可能会看到多个订阅的列表。
认证信息被保存在本地,从现在开始,你可以发出针对你的 Azure 账户的其他命令,而无需每次都登录。我们可以使用以下命令测试我们正在使用的 Azure 订阅:
az account show
此命令的输出显示了当前默认订阅。我们可以使用此命令查看所有订阅的列表:
az account list
输出是一个以 JSON 格式显示的订阅列表。每个订阅都有一个 id 字段,它是订阅的唯一标识符。您还会注意到,当前默认订阅通过将其 isDefault 字段设置为 true 来标记。列表中的任何其他订阅的此字段都设置为 false。
在这一点上,您应该验证您正在使用正确的订阅来跟随本书中的示例。例如,如果您有权访问雇主的订阅,您可能不应该使用这些订阅进行自己的学习和实验(或者至少,先与您的老板确认)。如果您需要更改当前的工作订阅,可以使用以下命令设置新的默认值:
az account set --subscription=<subscription-id>
将 <subscription-id> 替换为您想要设置为默认的订阅的 ID。更改默认订阅后,请再次使用此命令进行双重检查:
az account show
这只是为了确保我们正在使用自己的订阅来跟随示例。我们不希望不小心使用我们雇主的 Azure 订阅。
6.6.3 哪个版本的 Kubernetes?
让我们用 Azure CLI 做一些实际操作,以了解它如何有用。在本章的末尾,我们将创建我们的托管 Kubernetes 集群。提前了解我们创建它的位置可用的 Kubernetes 版本将很有帮助。
要通过 Azure CLI 与 Azure Kubernetes 服务交互,我们将使用 aks 子命令。以下是一个列出西 US 区域 Kubernetes 版本的示例:
az aks get-versions --location westus
输出是一个以 JSON 格式显示的列表,其中显示了该位置可用的 Kubernetes 版本。如果使用如下所示的 表格样式 输出,输出对我们来说将更容易阅读:
az aks get-versions --location westus --output table
此时,您应该看到类似以下内容:
KubernetesVersion Upgrades
------------------- --------------------------------
1.18.4(preview) None available
1.18.2(preview) 1.18.4(preview)
1.17.7 1.18.2(preview), 1.18.4(preview)
1.16.10 1.17.7
1.15.12 1.16.10
1.15.11 1.15.12, 1.16.10
从此列表中,您应选择最新的稳定(非预览)版本的 Kubernetes。在撰写本文时,这是 1.18.8 版本。但到您阅读本文时,可能会有更新的版本。完全有可能 1.18.8 版本已经过期(不再通过 Azure 提供)。务必选择当前可用的版本号!
记下版本号。我们很快就需要它来创建我们的集群。如果您想评估最新发布版本,可以选择 Kubernetes 的预览版本。但通常对于生产使用,我们更愿意使用最新的稳定版本。
6.6.4 我们取得了什么成果?
我们已安装 Azure 命令行工具(Azure CLI)。这是一个从终端与我们的 Azure 账户交互的有用工具。我们用它来验证我们的 Azure 账户。
注意 Terraform 需要使用 Azure 进行身份验证,以便它能够代表我们创建基础设施。
作为实际示例,我们使用 Azure CLI 查询我们选择位置可用的 Kubernetes 版本。我们记录了 Kubernetes 的最新版本号,稍后我们将使用它来创建我们的集群。
6.7 使用 Terraform 创建基础设施
现在我们正来到一个点,我们将真正开始创建我们的基础设施!我们可以手动构建我们的基础设施,无论是使用云供应商的 GUI(例如 Azure 门户)还是通过命令行(例如 Azure CLI)。然而,在这本书中,我们将使用代码以自动化的方式构建我们的基础设施。
从现在开始,我们将使用 基础设施即代码来自动化基础设施创建过程,从而使其可靠且可重复。自动化使我们能够在不增加手动工作负载的情况下扩展我们的应用程序。我们将使用 Terraform 来完成这项工作,这是一个执行 HashiCorp 配置语言 (HCL) 代码的惊人灵活的工具。
HCL 是我们定义基础设施的声明性配置语言。使用 Terraform 执行此代码实际上在云中创建我们的基础设施。
注意:在未来,我将简单地将 HCL 称为 Terraform 代码。
Terraform 通过插件提供者支持多个云供应商,如图 6.5 所示。在本章的示例中,我们使用 Terraform 在 Microsoft Azure 上创建基础设施。
如果学习 HCL 似乎有任何令人畏惧的地方,请记住这一点:HCL 实际上就像 YAML 或 JSON 一样,但它是一种不同的格式。HashiCorp 创建 HCL 以使其成为一个可读性强的配置格式,同时也可以机器翻译成 YAML 和 JSON。将其视为 YAML 或 JSON,但结构上更便于人类阅读。

图 6.5 使用 Terraform 与各种云供应商构建基础设施
6.7.1 为什么选择 Terraform?
Terraform 是一种用于配置云应用程序基础设施的工具和语言。Terraform 使配置云基础设施变得可靠且可重复。它非常灵活,因为其功能可以通过插件提供者扩展。这就是它支持多个云供应商的原因!Terraform 已经为 Azure、AWS 和 Google Cloud 实现了强大的提供者。
就像 Kubernetes 一样,我们还将学习可转移的技能,这些技能可以用于所有主要的云供应商。无论我们使用哪种云,我们都可以利用 Terraform 来构建我们的基础设施。我们甚至可以创建自己的提供者,并将 Terraform 扩展到它尚未支持的平台。为了巩固这一点,Terraform 支持 Kubernetes,我们还将使用它来将容器部署到我们的集群中。
Terraform 几乎可以完成我们创建应用程序自动化部署管道所需的所有工作。它是一个用于脚本化基础设施的全能工具,因为即使对于它无法完成的事情,我们也可以自己填补空白。在下一章中,你将看到一种简单的方法,我们可以用它来扩展 Terraform 的功能,以覆盖它目前还无法处理的领域。
对我来说,Terraform 好像是一种通用配置语言。这是我们用来创建所有基础设施的一种语言。Terraform 是开源的,你可以在以下位置找到代码:
github.com/hashicorp/terraform
6.7.2 安装 Terraform
安装 Terraform 只是一个下载适用于您操作系统的二进制可执行文件并将其移动到包含在您的系统 PATH 环境变量中的目录的过程。从这里下载 Terraform 的最新版本:
www.terraform.io/downloads.html
安装 Terraform 后,使用以下命令从您的终端测试它:
terraform --version
在撰写本文时,我正在使用版本 0.12.29。未来的版本应该具有向后兼容性。
Terraform 在 Vagrant VM 中预先安装
如果你正在使用第六章代码库中包含的 Vagrant VM,你会发现你需要的所有工具,包括 Terraform,都已经安装好了。
如果你正在使用 Linux 进行工作,请查看第六章代码库中的 shell 脚本 scripts/provision-dev-vm.sh。这个 shell 脚本会在 Vagrant VM 中安装工具,并包含如何在 Linux 计算机上安装 Terraform 的示例。
6.7.3 Terraform 项目设置
在我们开始使用 Terraform 之前,让我们熟悉一下 Terraform 项目的外观。图 6.6 展示了一个完整的 Terraform 项目。这就是第七章中的 example-3 的样子。你现在不必查看第七章,甚至不必现在就打开那个特定的代码示例。我们只是在图 6.6 中查看该项目的结构,以便熟悉 Terraform 项目的外观。
正如你在图 6.6 中所见,一个 Terraform 项目由多个 Terraform 代码文件组成;这些文件以 .tf 扩展名结尾。这些文件包含 Terraform 代码,当由 Terraform 执行时,会为我们的应用程序创建基础设施。
这里有很多文件。那是因为我们正在查看下一章的更复杂示例。不要担心!很快,我们将从本章(第六章)的 example-1 开始,它要简单得多。
你应该能够阅读图 6.6 中的文件名,并了解它们的作用。这是因为我已经使用了一种命名约定,其中每个脚本文件都根据它创建的基础设施部分命名。当你阅读图 6.6 中的文件名(或第六章和第七章中的任何项目)时,你应该这样阅读:resource-group.tf 负责创建 Azure 资源组;database.tf 负责部署数据库;等等。
现在我们来试试这个。阅读图 6.6 中的文件名,并尝试猜测每个文件的作用。大多数情况下,应该是显而易见的;尽管如此,有几份文件超出了命名约定。如果你无法全部弄清楚,不要担心;所有内容将在本章和下一章中解释。

图 6.6 更完整 Terraform 项目的结构(我们已跳转到第七章的示例-3)。
注意图 6.6 中,我们将视频流微服务的代码与 Terraform 代码文件放在同一位置。它位于同一代码仓库中的 video-streaming 子目录下。这应该解释了为什么我们将 Terraform 代码文件存储在 scripts 子目录下。这样,我们的基础设施代码与微服务代码保持分离。
本示例项目的结构和文件名不是由 Terraform 决定的。这仅仅是我们将在这些示例中使用的约定。对于你自己的项目,可能存在不同的结构会更好,所以请自由实验,找到最适合你自己的项目的最佳结构。
为了在学习过程中保持简单,并且因为这是启动一个新微服务项目的好方法,我们将把我们的基础设施和微服务代码放在同一个位置。你可能已经意识到,这种类似单体项目的结构在最初使用微服务时消除了一些好处。现在不必过于担心这一点。只需知道,这种简单的结构仅适用于我们新应用的早期阶段。随着我们应用的增长,我们需要将其转换为更可扩展的结构,但这将在第十一章中讨论。目前,让我们坚持这种简单的项目结构。
6.8 为您的应用程序创建 Azure 资源组
在查看下一章示例-3 的高级项目结构之后,现在让我们降低复杂性,看看本章的更简单的示例-1。我们需要从某个地方开始我们的 Terraform 之旅,我们的起点应该是简单的。示例-1 就是这样,它包含了我们可以从中开始创建部署管道的最简单 Terraform 代码。
我们首先要做的是创建一个 Azure 资源组,将本章中我们将构建的所有其他 Azure 资源组合在一起。在第三章中,我们通过 Azure Portal GUI 手动创建了一个资源组。现在,我们再次创建一个资源组,但这次我们不是手动创建。我们将通过代码使用 Terraform 来构建它。
图 6.7 展示了本节我们将要做什么。Example-1 包含两个 Terraform 代码文件:providers.tf 和 resource-group.tf。脚本文件 resource-group.tf 是实际创建资源组的文件。另一个文件 providers.tf 包含 Terraform 提供者插件的配置。
我们将使用 terraform apply 命令来执行我们的 Terraform 代码。图 6.7 显示了我们的代码文件是如何输入到 Terraform 中,它执行我们的代码并在 Azure 中创建一个 FlixTube 资源组(如图 6.7 右侧所示)。

图 6.7 使用 Terraform 创建 Azure 资源组
文件 providers.tf 从技术上讲不是这个过程所必需的。我们可以删除它,这个示例仍然可以工作。然而,拥有这个文件是有用的,因为这是我们放置我们用于配置提供者的代码的地方。稍后,我们将更详细地讨论 providers.tf。
6.8.1 使用 Terraform 的进化式架构
Terraform 是一种以迭代方式构建我们基础设施的工具——我们称之为 进化式架构。在本章中,您可以亲自体验这种构建基础设施的迭代方法。
到目前为止,我们将开始编写 Terraform 代码。您可以选择如何跟随本章和下一章中的示例进行学习:
-
从 example-1 开始,然后随着您阅读本章和下一章,迭代地更新您的示例项目以演进您的基础设施
-
为每个示例从头开始,并在本章和下一章中为每个示例构建新的基础设施
第六章和第七章中的所有示例都可以独立运行,因此您可以从任何示例开始轻松地启动您的基础设施,只需跳转到任何示例并调用该代码的 Terraform 即可。然而,最适合您跟随的方式,最接近“真实”开发的方式,是迭代地更新您的代码,以逐步的方式演进您的基础设施(这是提到的第一个选项)。要以此方式跟随,您应该为您的演进项目创建一个单独的工作目录,例如:
mkdir working
然后将 example-1 代码复制到其中:
cp -r chapter-6/example-1/* working
现在,您可以跟随第六章和第七章中的示例。每次您到达一个新的示例时,就像这样将新代码复制到您的工作项目中:
cp -r chapter-6/example-2/* working
要在 Windows 上执行这些命令,您应该考虑安装 Git for Windows。它包含许多编译在 Windows 下工作的 Linux 命令。以下是链接:
或者,在 Windows 上,您可以使用 WSL2 或在 Vagrant VM 下运行的 Linux。有关更多详细信息,请参阅第三章和附录 A。
第六章和第七章中的示例代码旨在以这种方式使用,因此模拟了基础设施开发的实际迭代过程。图 6.8 展示了此过程。注意我们如何在我们编辑基础设施代码时使用多个 terraform apply 命令的迭代。通过这种方式,我们逐步添加和更新我们不断增长的基础设施。
小贴士 使用 Git 来跟踪您复制到工作项目中的更新代码是最佳选择。
在从 example-1 复制代码后,创建一个新的 Git 代码仓库并提交代码。然后,在您将每个新示例复制到工作项目后,您可以使用 git diff 来理解正在进行的新的更改。在每个迭代中,提交更新后的代码并继续下一个示例。

图 6.8 Terraform 基础设施迭代进化
这可能看起来像是一项大量工作。我理解您很忙,可能没有时间使用进化方法跟随。如果是这样,您可以自由地直接跳转到本章中您可能想运行的任何示例。这些示例都是设计为独立运行的,因此您可以按照您喜欢的任何方式跟随。
我要说的是,如果您确实想跟随每个示例,那么迭代进化方法实际上更有效。这是因为迭代过程中的每个步骤,Terraform 只会创建那些尚未存在的资源。
如果您单独运行每个示例,您将最终运行 Terraform 来为每个示例创建完整的基础设施。这是最不有效的方法。从头开始创建 Kubernetes 集群很慢,但更新现有的 Kubernetes 集群要快得多!跟随进化方法实际上可以为您节省一些时间!
6.8.2 编写基础设施创建脚本
列表 6.1 展示了我们的第一个 Terraform 代码文件。这已经非常简单了。通过使用 Azure 提供程序,我们只需在 Terraform 代码中声明三行即可创建 Azure 资源组。
列表 6.1 创建 Azure 资源组(第六章/example-1/scripts/resource-group.tf)
resource "azurerm_resource_group" "flixtube" { ①
name = "flixtube" ②
location = "West US" ③
}
① 声明一个 Azure 资源组。此资源组将包含我们创建的所有资源,因此它是我们新基础设施的基本起点。
② 设置资源组的名称
③ 设置资源组创建的位置(数据中心)
通过 Terraform 代码,我们正在定义我们的基础设施组件。在列表 6.1 中,我们定义了我们的基础设施的第一部分。我们声明了一个名为 flixtube 的 Azure 资源组,其类型为 azurerm_resource_group. 这是一种来自 Azure 提供程序的 Terraform 资源类型,它使我们能够在 Azure 上创建资源组。很快,我们将运行 Terraform,它将在我们的 Azure 账户中创建这个资源组,正如我们配置的那样。
6.8.3 初始化 Terraform
我们在创建我们的基础设施方面已经迈出了第一步。我们编写了一个简单的脚本,用于创建 Azure 资源组。但在调用 Terraform 并执行此脚本之前,我们必须首先初始化 Terraform。
当我们初始化 Terraform 时,它会下载我们脚本所需的提供程序插件。此时,我们只需要 Azure 提供程序。要初始化 Terraform,首先将目录更改为 Terraform 代码的位置:
cd chapter-6/example-1/scripts
现在运行 terraform init 命令:
terraform init
您应该会看到一些输出,表明 Azure 提供程序插件已下载;例如,
Initializing the backend...
Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "azurerm" (hashicorp/azurerm) 1.43.0...
Terraform has been successfully initialized!
一旦完成,我们现在就可以执行我们的 Terraform 代码了。在执行该目录中的任何 Terraform 代码之前,我们必须为每个 Terraform 项目至少运行一次 terraform init 命令。您还必须为每个新提供的程序至少运行一次。每次调用 terraform init 时,它只会下载尚未缓存的提供程序。
如果您按照 6.8.1 节中提出的进化方式跟随第六章和第七章的示例,那么您只需要为包含新提供程序的每个示例进行初始化。否则,如果您独立运行每个示例,您只需记住为每个示例运行 terraform init 即可。
如果您忘记运行 terraform init,请不要担心,它不会给您带来任何问题。当您忘记时,Terraform 会提醒您需要先做这件事。
6.8.4 Terraform 初始化的副产品
在 Terraform 初始化后,我们现在可以检查 init 命令在脚本子目录中创建或下载的文件。浏览脚本目录,看看你能找到什么。图 6.9 展示了运行 terraform init 后的 example-1 项目。

图 6.9 展示了运行 terraform init 时下载或创建的文件。
您会注意到创建了一个隐藏的子目录 .terraform,其中包含了一些文件。这是 Terraform 存储已下载的提供程序插件的地方。这些文件被缓存在这里,以便每次调用 Terraform 时可以重复使用。
6.8.5 修复提供程序版本号
在我们第一次调用 Terraform 之前,让我们谈谈我们项目中那个其他文件。列表 6.2 展示了 providers.tf 的代码。这是我们定义和配置所有 Terraform 提供程序插件的文件。
列表 6.2 配置 Terraform 供应商插件(第六章/示例 1/脚本/providers.tf)
provider "azurerm" { ①
version = "1.38.0" ②
}
① 设置供应商插件的名称
② 设置要下载和使用的供应商插件的版本
从技术上讲,我们目前不需要这个文件,也不需要提前声明我们的供应商。我们可以简单地调用 terraform init,它足够智能,能够找出我们需要哪些供应商,并为我们下载它们。
当开始一个新项目时,这很方便。我们可以简单地开始编写 Terraform 代码来创建我们的云基础设施,而且我们不需要提前声明我们的供应商。然后,当我们调用 terraform init 时,它会下载我们需要的供应商的最新版本。Terraform 会列出它下载的插件的版本,就像以下从早期输出中的摘录:
- Downloading plugin for provider "azurerm" (hashicorp/azurerm) 1.43.0...
这是一种很好的入门方式,但它可能会导致未来出现意外的麻烦。例如,将来,你可能会发现自己无意中升级到了一个与最初使用的版本不 100% 兼容的新版本的 Azure 供应商(是的,这发生在我身上)。结果,你的 Terraform 代码可能会以难以预测或理解的方式中断。
幸运的是,我们可以通过将版本固定为我们已测试并信任的版本来预先解决这个问题。你可以通过检查 terraform init 的输出来查看任何供应商的当前版本,然后将此版本号硬编码到你的 providers.tf 文件中(如列表 6.2 所示)。
最终,我们的 Terraform 代码必须在我们的自动化 CD 管道中完全无人值守地运行(我们将在第七章中创建它)。我们的代码必须是坚不可摧的,修复我们的版本号使我们的 Terraform 代码更加可靠。这意味着在未来,我们不会暴露在依赖项在我们不知情的情况下被更改的风险中。
我们还可以将 providers.tf 用作配置其他供应商参数的地方。我们将在下一章中看到一个例子。
6.8.6 构建你的基础设施
在初始化我们的 Terraform 项目后,我们就可以调用 terraform apply 命令来执行我们的 Terraform 代码并构建我们基础设施的第一迭代。如果你需要,可以参考图 6.8 来了解 apply 命令的图形表示。从调用 init 命令的同一目录中运行此命令:
terraform apply
apply 命令会收集并执行我们所有的 Terraform 代码文件。(到目前为止我们只有两个代码文件,但很快我们会更多。)当你调用 apply 命令时,你会看到如下输出:
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# azurerm_resource_group.flixtube will be created
+ resource "azurerm_resource_group" "flixtube" {
+ id = (known after apply)
+ location = "westus"
+ name = "flixtube"
+ tags = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value:
此输出描述了我们对基础设施的计划更新。Terraform 正在告诉我们它即将做出的更改。(你还可以使用 terraform plan 命令单独获取此输出。)
Terraform 现在正在等待我们批准计划,然后才会继续并实际上对我们的基础设施进行更新。此时检查输出并确保即将到来的更改是 OK 的,符合我们的预期是个好主意。一旦对计划满意,输入yes并按 Enter 键允许 Terraform 继续。
Terraform 现在正在创建我们请求的基础设施。在这种情况下,在我们首次调用 Terraform 时,flixtube 资源组被创建在我们的 Azure 账户中。这应该会很快发生(因为目前它仍然是一个小脚本,并没有做太多)。然后你会看到一条成功消息,如下所示:
azurerm_resource_group.flixtube: Creating...
azurerm_resource_group.flixtube: Creation complete after 5s [id=/subscriptions/219aac63-3a60-4051-983b-45649c150e0e/resourceGroups/flixtube]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
输出给出了一个快速总结,说明了添加了什么、更改了什么、删除了什么。在这种情况下,它确认了我们已经知道的事情,即我们创建了一个云资源,我们的 Azure 资源组。
现在,让我们手动检查更改看起来像什么。打开你的网络浏览器,导航到 Azure 门户portal.azure.com/。你可以亲自检查,确认确实在你的 Azure 账户中创建了一个 Azure 资源组。在门户中,点击资源组并验证 flixtube 资源组现在是否在列表中。这就是你的第一个 Terraform 代码刚刚创建的内容!
当然,你不必总是通过手动检查 Azure 门户来确认每个资源是否已创建。我们在这里这样做只是为了让你能够理解刚才发生的事情。
6.8.7 理解 Terraform 状态
在这个阶段,在我们项目中对terraform apply进行首次调用后,Terraform 将生成其状态文件terraform.tfstate。你应该能在与你的 Terraform 代码文件相同的目录中看到这个文件。
理解 Terraform 的持久状态管理很重要;尽管大多数时候我们不会关心状态文件中的内容。但了解为什么它存在以及如何处理它是很好的。
让我们看一下我们的 Terraform 状态文件,看看在我们创建了第一件基础设施之后它看起来像什么。这是一个查看状态文件的好时机:因为此时它仍然很小,很容易理解。调用cat命令来显示状态文件:
cat terraform.tfstate
你的输出将类似于以下内容:
{
"version": 4,
"terraform_version": "0.12.29",
"serial": 1,
"lineage": "dc5cb51c-1ab4-02a5-2271-199538b7655a",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "azurerm_resource_group",
"name": "flixtube",
"provider": "provider.azurerm",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "/subscriptions/219aac63-3a60-4051-983b-
➥ 45649c150e0e/resourceGroups/flixtube",
"location": "westus",
"name": "flixtube",
"tags": {}
},
"private": "bnVsbA=="
}
]
}
]
}
你可以看到,我们的 Terraform 状态文件在resources字段中有一个条目。我们刚刚创建的资源组的详细信息都记录在这个状态文件中。
我们第一次调用terraform apply时,会生成状态文件。随后的terraform apply调用将使用状态文件作为输入。Terraform 加载状态文件,然后从实时基础设施中刷新它。图 6.10 显示了连续调用 Terraform 是如何通过实时基础设施和状态文件连接起来的。
你可能想知道此时状态文件的确切作用是什么?如果我们的基础设施定义在我们的 Terraform 代码中,并且 Terraform 可以直接从实际基础设施中知道当前状态,为什么它必须将状态保存在单独的文件中?有两个要点需要考虑,以理解为什么状态文件是必要的:
-
这个 Terraform 项目并不拥有你 Azure 账户中的所有基础设施。
-
当我们更改我们的 Terraform 代码(以更改我们的基础设施)时,它与实际基础设施就不再同步。(我们依赖 Terraform 更改实际基础设施,使其看起来像我们声明的基础设施。)

图 6.10 理解 Terraform 状态对于使用 Terraform 至关重要。
让我们考虑第一个点。Azure 订阅可能被多个项目共享。该账户中的基础设施可能是由其他 Terraform 项目创建的,甚至可能是由完全不同的方式(例如,在 Azure 门户中手动创建或使用 Azure CLI 工具)创建的。
随着你在这本书中的示例,你很可能有一个整个 Azure 订阅都为此而设。但如果你是在管理多个项目的公司工作,或者你自己正在管理多个项目,情况就不同了。在这种情况下,Azure 订阅将在项目之间共享,订阅包含多套基础设施。
我要说明的是,Terraform不能,实际上也不假设它拥有它被允许访问的 Azure 账户中的所有内容。这意味着 Terraform 不能简单地读取实际基础设施并假设它拥有所有内容。它只能假设拥有在基础设施代码中声明或记录在状态文件中的基础设施。Terraform 首先做的事情是加载你的代码和状态文件。这就是它知道它拥有哪套基础设施。
尽管如此,Terraform 总是希望保持最新状态,因此加载状态文件后,它会直接从实际基础设施刷新状态。这允许 Terraform 处理实际状态已从先前记录的状态发生变化时的配置漂移(例如,因为有人手动调整了它)。
你可以看到这可能会如何影响性能。Terraform 只查询它负责的实时基础设施的部分;那些因为它记录的状态而知道的部分。相反,如果它查询所有实时基础设施,这可能是一个昂贵且耗时的操作,具体取决于我们 Azure 账户中存在的总基础设施量。
现在,让我们考虑提到的第二点。当我们更改我们的 Terraform 代码(以更改我们的基础设施)时,它与我们的实际基础设施就不再同步。这是因为我们通过代码的更改来引导基础设施的变更。这就是为什么我们称之为基础设施即代码。
我们可以通过修改我们的代码来添加、更新和删除基础设施。Terraform 如何知道发生了什么变化?Terraform 将其记录的状态与我们的代码中的内容进行比较。然后,Terraform 自动确定需要更新我们的基础设施的确切更改集。当你仔细思考时,你会发现 Terraform 是多么聪明,它能为您做多少工作。
现在您可能已经了解得比您想要的更多关于 Terraform 状态的信息,但说实话,在我们能够正确地在我们的 CD 流水线中实施它之前,我们有一个很好的理解是非常重要的。我们将在下一章中讨论这一点。随着您在本章和下一章的示例中前进,请随时再次查看状态文件,以查看它是如何增长和变化的。
6.8.8 销毁和重新创建您的基础设施
我们已经启动了我们的基础设施!目前它还不算多,但这是一个良好的开始。在我们继续演进我们的基础设施之前,让我们花些时间来尝试销毁和重建它。
我们选择这个时刻进行实验的原因是,当我们的基础设施规模较小时,进行这种实验更有效率。在本章结束时,我们将添加一个 Kubernetes 集群,这将需要更多的时间来销毁和重建。
更不用说最终,您仍然需要清理这些 Azure 资源。您不希望最终为此付费(除非,当然,您正在开发一个真实的产品)。运行此基础设施需要花费金钱;尽管如此,我希望您是从 Azure 的免费信用额度开始的。但不要让它运行超过您需要的时长!
现在,请使用 Terraform 的 destroy 命令像这样销毁您当前的基础设施:
terraform destroy
您的输出将类似于以下内容:
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
- destroy
Terraform will perform the following actions:
# azurerm_resource_group.flixtube will be destroyed
- resource "azurerm_resource_group" "flixtube" {
- id = "/subscriptions/219aac63-3a60-4051-983b-
➥ 45649c150e0e/resourceGroups/flixtube" -> null
- location = "westus" -> null
- name = "flixtube" -> null
- tags = {} -> null
}
Plan: 0 to add, 0 to change, 1 to destroy.
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value:
就像 apply 命令一样,destroy 会显示其计划。这些都是它将做出的更改。要继续,我们必须输入 yes 并按 Enter 键。Terraform 完成工作并显示摘要:
azurerm_resource_group.flixtube: Destroying... [id=/...
azurerm_resource_group.flixtube: Still destroying... [id=/subscriptions/219aac63-3a60-4051-983b-45649c150e0e/resourceGroups/flixtube, 10s elapsed]
[id=/subscriptions/219aac63-3a60-4051-983b-45649c150e0e/resourceGroups/flixtube, 50s elapsed]
azurerm_resource_group.flixtube: Destruction complete after 54s
Destroy complete! Resources: 1 destroyed.
当您完成书中的每个示例后,您应该调用 destroy 来清理您创建的基础设施。如果您正在使用第 6.8.1 节中描述的迭代方法演进您的基础设施,并且一直做到第七章的结尾,那么您不需要再次调用 destroy,直到书的结尾。
您也可以通过 Azure 门户或 Azure CLI 工具手动删除 Azure 资源。但使用 destroy 命令来做这件事更简单。这也意味着您不会意外地删除其他基础设施,比如说,如果您与其他项目共享 Azure 订阅的话。
在您使用 terraform destroy 进行练习运行后,重新构建您的基础设施变得简单。为此,我们只需再次调用 terraform apply:
terraform apply
你可以尽可能多地练习这个过程。销毁和重建你的基础设施的过程有助于你理解你实际上是在用可执行代码管理基础设施!你可以随意销毁和创建你的基础设施,无需任何手动操作。在这个早期阶段,这看起来可能不多,但随着你的基础设施和应用程序变得更大、更复杂,其重要性会逐渐增加。
实际上,你可能已经意识到我们可以使用我们的 Terraform 代码来创建我们基础设施的多个副本!在第十一章中,我们将学习如何参数化我们的 Terraform 代码,以创建用于开发、测试和生产的独立实例。如果这还不能让你兴奋,我不知道还有什么能让你兴奋。
6.8.9 我们取得了什么成果?
现在我们已经安装了 Terraform,并且我们已经构建了我们初生的基础设施。Terraform 是我们用于 基础设施即代码 的工具。这是一种技术,我们将基础设施配置存储为可执行代码(例如,在 Terraform 代码文件中),我们可以使用它来创建、管理和销毁我们的基础设施。
我们还创建了我们的第一个 Terraform 代码文件,并使用 terraform init 初始化了我们的项目。然后我们调用了 terraform apply 来创建一个 Azure 资源组。我们还学习了如何使用 terraform destroy 后跟 terraform apply 来销毁和重新创建我们的基础设施。
6.9 创建你的容器注册库
我们基础设施的下一步是创建一个私有容器注册库。我们将在下一章中使用这个注册库来发布我们的微服务的 Docker 镜像。
如果你记得在第三章,我们学习了如何构建和发布 Docker 镜像。在那个章节中,我们通过 Azure 门户中的 GUI 手动创建了一个容器注册库。现在,我们已经对 Terraform 有了一个基本的了解,我们将重新访问那个领域,并使用代码创建我们的注册库。
6.9.1 我们基础设施的持续进化
我们现在正在将第六章代码库中的 example-2 移动到下一步。在这个阶段,如果你是以迭代的方式工作,并从 example-1 继续前进,你应该将 example-2 的代码复制到你在 6.8.1 节中创建的工作目录中:
cp -r chapter-6/example-2/* working
如果你不是将每个示例作为一个独立的项目来处理,你应该销毁从 example-1 创建的基础设施:
cd chapter-6/example-1/scripts
terraform destroy
在销毁 example-1 基础设施之后,你可以移动到 example-2 并初始化它:
cd chapter-6/example-2/scripts
terraform init
6.9.2 创建容器注册库
列表 6.3 展示了创建我们容器注册库的最新 Terraform 代码文件。要让这段代码工作,你需要更改注册库的名称。这是因为 Azure 容器注册库的名称必须是唯一的。它不会允许你使用我选择的相同名称(flixtube)。
如果你正在跟随,现在进入 container-registry.tf。将注册库的名称更改为其他名称。
列表 6.3 创建我们的私有容器注册库(摘自 chapter-6/example-2/scripts/container-registry.tf)
resource "azurerm_container_registry"
➥ "container_registry" { ①
name = "flixtube" ②
resource_group_name = azurerm_resource_group
➥ .flixtube.name ③
location = "westus" ④
admin_enabled = true ⑤
sku = "Basic" ⑥
}
... code omitted here ...
① 声明容器注册库资源
② 设置容器注册库的名称。这必须是唯一的,所以你必须将其更改为其他内容。
③ 设置资源组的名称并创建到另一个资源的链接
④ 设置容器注册库的位置
⑤ 启用管理员账户,以便我们可以远程对注册库进行身份验证
⑥ 使用基本 SKU 成本更低,也更简单,因为存储是自动管理的。
注意:如果您有所疑问,SKU(库存单位)或库存保持单元是产品的不同版本。在这里这意味着我们正在使用容器注册库的基本版本。
注意resource_group_name的值是如何从另一个文件(我们在图 6.1 中查看的resource-group.tf文件)中定义的资源属性中设置的。这两个资源现在通过 Terraform 的资源图相互链接。这就是 Terraform 管理资源之间依赖关系的方式。这是 Terraform 知道它应该按什么顺序执行我们的脚本文件的方式。
Terraform 必须在将其他资源(如新的容器注册库)填充到我们的 Azure 账户之前创建资源组。让我们调用apply命令来添加这个新的基础设施部分:
terraform apply -auto-approve
注意这次我们使用了-auto-approve参数。这意味着我们不必每次都输入yes来批准更改。在我们原型化我们的基础设施时,这很方便,但在下一章,当我们创建我们的持续交付(CD)管道时,这变得至关重要。到那时,我们将需要以自动和无人值守的方式调用 Terraform。那里将没有人来进行批准!正因为如此,我们现在开始使用-auto-approve以非交互式模式运行 Terraform。
现在我们开始创建更复杂的基础设施,所以你可能需要比上次等待更长的时间。一旦完成,你将看到与之前类似的输出;Terraform 正在向我们展示我们的基础设施中发生了什么变化。然而,在末尾,你将看到一些新的输出。这为我们提供了与我们的新容器注册库进行身份验证所需的详细信息。我们将在下一节中了解更多关于这个新输出的信息。
Outputs:
registry_hostname = flixtube.azurecr.io
registry_pw = +2kGfgth0beCHPh+VIf9fqJhAf7zEqX6
registry_un = flixtube
6.9.3 Terraform 输出
Terraform(或其底层的插件提供者)通常会生成我们需要了解的配置信息。在上一节中,我们创建了新的容器注册库。在列表 6.3 中,你看到我们为注册库启用了管理员用户。这允许我们进行身份验证并与我们的注册库(推送和拉取 Docker 镜像)进行交互。
注意:启用管理员用户会导致 Terraform 生成用户名和密码。我们需要注意这些细节,以便我们可以在以后使用这些信息登录到我们的注册库。
我们可以使用 Terraform 输出 从我们的 Terraform 代码中提取生成的配置细节。在列表 6.4 中,你可以看到声明了多个输出。这导致在执行此代码时,这些值将在终端中显示。我们还会在多个其他代码文件中使用输出,所以请在未来的代码列表中寻找这些输出。
列表 6.4 Terraform 输出(章节-6/示例-2/脚本/container-registry.tf 的摘录)
... code omitted here ...
output "registry_hostname" { ①
value = azurerm_container_registry.
➥ container_registry.login_server ②
}
output "registry_un" { ①
value = azurerm_container_registry.
➥ container_registry.admin_username ②
}
output "registry_pw" { ①
value = azurerm_container_registry.
➥ container_registry.admin_password ②
}
① 创建一个输出
② 设置要输出的值
6.9.4 我们取得了什么成果?
我们通过创建容器注册库继续演变我们的基础设施。这是我们在下一章中为我们的微服务发布 Docker 镜像时需要的东西。
在本节中,我们添加了一个新的 Terraform 代码文件并执行了它。这在我们 Azure 账户中创建了一个新的容器注册库。最后,我们学习了如何使用 Terraform 输出显示有关创建的资源和管理基础设施的相关信息。
6.10 重构以共享配置数据
你可能已经注意到在最近的代码列表中,我们开始重复某些配置值从文件到文件。当需要更改这些值时,这可能会成为一个问题。理想情况下,我们希望能够在一个地方更改重要的值,并且这些值可以在所有我们的 Terraform 代码文件之间共享。我们可以通过使用 Terraform 变量 来实现这一点,因此现在我们将重构我们的代码以共享配置数据。
6.10.1 继续我们基础设施的演变
我们现在转向章节 6 代码库中的示例-3。在此阶段,如果你以迭代方式工作并从早期示例继续,你现在可以将示例-4 代码复制到你在 6.8.1 中创建的早期工作目录中:
cp -r chapter-6/example-3/* working
否则,你可以直接跳转到示例-3 并在章节-6/example-3/scripts 目录中运行 terraform init。如果你这样做,别忘了首先销毁为早期示例创建的任何基础设施。
6.10.2 介绍 Terraform 变量
章节第 6 代码库中的示例-3 是对示例-2 的重构,修改为在代码文件之间共享配置值,添加了一个名为 variables.tf 的新文件。列表 6.5 显示了新的代码文件。
在列表中,你可以看到 Terraform 全局变量是如何定义我们一些最重要的配置值的。我们为我们的应用程序名称(flixtube)、数据中心位置(西 US)等定义了变量。
列表 6.5 设置 Terraform 全局变量(章节-6/示例-3/脚本/variables.tf)
variable "app_name" { ①
default = "flixtube"
}
variable location { ①
default = "West US"
}
① 为我们在多个 Terraform 代码文件中使用的全局变量设置默认值
到目前为止,如果你一直在跟随,你应该编辑 variables.tf 并为你的应用程序设置一个 唯一的 名称。在列表 6.5 中,名称被设置为 flixtube。将从这个变量设置各种 Azure 资源名称,其中一些将需要为这个项目的你的版本唯一(例如,你的容器注册表的名称)。
列表 6.6 和 6.7 展示了我们如何使用我们的新变量。你可以看到,我们的资源组名称和我们的容器注册表名称都是从 app_name 变量的值设置的。我们还可以从 location 变量设置这些资源的地理位置。
列表 6.6 使用变量配置资源组(第六章/示例 3/脚本/resource-group.tf)
resource "azurerm_resource_group" "flixtube" {
name = var.app_name ①
location = var.location ②
}
① 从 app_name 变量设置资源组的名称
② 从位置变量设置位置
列表 6.7 使用变量配置容器注册表(摘自第六章/示例 3/脚本/container-registry.tf)
resource "azurerm_container_registry" "container_registry" {
name = var.app_name ①
resource_group_name = azurerm_resource_group.flixtube.name
location = var.location ②
admin_enabled = true
sku = "Basic"
}
... code omitted here ...
① 从 app_name 变量设置容器注册表的名称
② 从位置变量设置位置
我们已经重构了我们的 Terraform 代码,并使用 Terraform 变量在我们的代码文件之间共享了一些相关的配置值。我们现在有一个方便的地方可以更改这些值。例如,假设我们想更改应用程序的位置。我们可以简单地通过更改 variables.tf 中的 location 变量来实现这一点。
6.11 创建我们的 Kubernetes 集群
现在我们来到了我们最关键的基础设施部分。我们需要一个平台来托管我们的微服务在生产环境中,为此,我们将使用 Terraform 在我们的 Azure 账户中创建一个 Kubernetes 集群。
6.11.1 脚本化创建你的集群
继续使用示例-3,现在让我们看看创建我们的 Kubernetes 集群的代码。列表 6.8 是一个新的 Terraform 代码文件,它定义了我们的集群配置。
我们在这里继续使用我们的 Terraform 变量,并且其中一些字段你可能已经熟悉。例如 name、location 和 resource_group_name 字段不需要新的解释。然而,还有一些字段将完全陌生。
列表 6.8 创建我们的 Kubernetes 集群(第六章/示例 3/脚本/kubernetes-cluster.tf)
resource "azurerm_kubernetes_cluster" "cluster" { ①
name = var.app_name
location = var.location
resource_group_name = azurerm_resource_group.flixtube.name
dns_prefix = var.app_name
kubernetes_version = "1.18.8" ②
linux_profile { ③
admin_username = var.admin_username
ssh_key {
key_data = "${trimspace(tls_private_key.key.public_key_openssh)}
➥ ${var.admin_username}@azure.com"
}
} ③
default_node_pool { ④
name = "default"
node_count = 1
vm_size = "Standard_B2ms"
} ④
service_principal { ⑤
client_id = var.client_id
client_secret = var.client_secret
} ⑤
}
output "cluster_client_key" {
value = azurerm_kubernetes_cluster.cluster.kube_config[0].client_key
}
output "cluster_client_certificate" {
value = azurerm_kubernetes_cluster.cluster.kube_config[0]
➥ .client_certificate
}
output "cluster_cluster_ca_certificate" {
value = azurerm_kubernetes_cluster.cluster.kube_config[0]
➥ .cluster_ca_certificate
}
output "cluster_cluster_username" {
value = azurerm_kubernetes_cluster.cluster.kube_config[0].username
}
output "cluster_cluster_password" {
value = azurerm_kubernetes_cluster.cluster.kube_config[0].password
}
output "cluster_kube_config" {
value = azurerm_kubernetes_cluster.cluster.kube_config_raw
}
output "cluster_host" {
value = azurerm_kubernetes_cluster.cluster.kube_config[0].host
}
① 声明我们的 Kubernetes 集群的资源
② 指定我们正在使用的 Kubernetes 版本。当你运行此代码时,这个版本可能已不再在 Azure 上可用。请参阅第 6.6.3 节了解如何选择版本号。
③ 设置我们集群的认证详情
④ 配置我们集群的节点
⑤ 配置认证详情以允许集群与 Azure 交互
注意在列表 6.8 中我们如何指定要使用的 Kubernetes 版本。如果你还记得,我们是在 6.6.3 节中决定的。然后我们提供了一个 SSH 密钥,我们可以使用它来与我们的集群交互。我们正在链接到在private-key.tf文件中定义的tls_private_key资源。在这个代码文件中,我们使用不同的 Terraform 提供程序来生成 SSH 密钥。你还没有在代码列表中看到这一点。但如果你好奇并想了解 SSH 密钥是如何生成的,请自己查看文件example-3/scripts/private-key.tf。
列表 6.8 也是我们定义集群节点和虚拟机大小的位置。注意,我们正在仅使用单个节点来构建我们的集群。虽然我们可以轻松地添加更多节点,但我们将把这一点留到第十一章。现在,我们必须专注于服务主体的配置。以下是从列表 6.8 中重复的内容,我们将 Azure 认证详情链接到我们的集群配置中:
service_principal {
client_id = var.client_id
client_secret = var.client_secret
}
服务主体是 Azure 的认证机制。它允许我们的集群与 Azure 进行认证,以便它可以创建 Azure 负载均衡器作为面向客户的微服务(例如,我们的前端网关)的外部端点。
我们使用了两个新的 Terraform 变量,client_id和client_secret,这些变量定义在最新的variables.tf版本中。我们没有为这些变量提供默认值。这是因为这些变量包含敏感的认证信息,出于安全原因,我们更愿意不在代码库中包含它们的值。
6.11.2 使用 Azure 进行集群认证
我们目前还不能创建我们的集群。如果我们现在调用apply命令,Terraform 将会要求我们提供client_id和client_secret变量的值(因为我们没有在代码中为这些变量提供默认值)。
这些变量为我们 Kubernetes 集群提供了我们的 Azure 订阅的认证详情。为了满足这些变量,我们首先必须在 Azure 中创建一个服务主体。这就像是一个单独的访问账户,允许我们的集群代表我们与 Azure 交互。
完全在 Terraform 中创建服务主体是可能的,如果我们能这样做将是理想的。毕竟,服务主体只是我们基础设施的另一个方面,我们更愿意让我们的整个基础设施都由 Terraform 创建。
不幸的是,在撰写本文时,这并不稳定。尽管 Azure 提供程序确实支持创建服务主体,但出于某种原因,它没有正确处理创建的时间。服务主体需要时间才能在 Azure 中传播,而且没有方法可以延迟集群的创建直到服务主体就绪。如果我们尝试在 Terraform 中这样做,我们最终会在服务主体存在之前创建集群。这会导致错误,因为集群的创建需要服务主体已经存在。
由于 Azure 提供者存在此问题(可能在你阅读本文时已修复),我们无法从 Terraform 中可靠地创建服务主体。相反,我们将求助于使用 Azure CLI 工具来完成此任务。这实际上是可以接受的,因为我们只需要创建一次服务主体,之后我们将简单地继续使用它。在您能够创建服务主体之前,您必须知道您的 Azure 订阅 ID,您可以使用以下命令来完成:
az account show
从 id 字段中提取值,并使用它来创建您的服务主体:
az ad sp create-for-rbac --role="Contributor"
➥-scopes="/subscriptions/<subscription-id>"
当您运行该命令时,应将 <subscription-id> 替换为您自己的订阅 ID。命令的输出将类似于以下内容:
{
"appId": "a2016492-068c-4f37-a32b-6e6196b65488",
"displayName": "flixtube",
"name": "http://flixtube",
"password": "deb781f5-29e7-42c7-bed8-80781411973a",
"tenant": "f88afda7-7b7b-4fb6-a093-6b254e780c4c"
}
我们为我们的集群创建了一个服务主体,用于与 Azure 进行身份验证。记下您自己的 appId 和 password 字段值(这些将不同于我的值!)。您稍后需要这些值来输入 Terraform 变量的 client_id 和 client_secret。
6.11.3 构建您的集群
我们现在可以运行最新的 Terraform 代码来创建我们的 Kubernetes 集群。调用 apply 命令:
terraform apply -auto-approve
Terraform 会提示您输入没有值的变量。第一个是 client_id:
var.client_id
Enter a value:
在这里,您应输入您服务主体的 appId 值。然后 Terraform 会提示您输入 client_secret:
var.client_secret
Enter a value:
现在输入您服务主体的 password 值。Terraform 现在创建您的 Kubernetes 集群。这可能需要一些时间;您可能想喝杯咖啡。
注意:如果您遇到我使用的版本号(1.18.8)的问题,这可能是由于该版本在 Azure 上不再可用。请参阅 6.6.3 节以获取如何选择可用版本的说明。
最后,您会看到大量的 Terraform 输出,这些输出提供了您新集群的配置和身份验证细节。请注意以下值。这些是我们与新的 Kubernetes 集群接口所需的凭证。
-
cluster_client_certificate -
cluster_client_key -
cluster_cluster_ca_certificate
6.11.4 我们取得了什么成就?
干得好!我们刚刚创建了一个 Kubernetes 集群。如果您之前认为 Kubernetes 很复杂,您可能会对它的简单性感到惊讶!
这是通往生产之路上的一个重大成就。再次强调,我们继续演进我们的架构,将 Kubernetes 集群添加到现有的基础设施中。在这个过程中,我们进行了一些重构,并使用 Terraform 变量在各个 Terraform 代码文件之间共享重要值。
我们还创建了一个服务主体,用于与 Azure 进行身份验证。Kubernetes 集群在需要创建 Azure 负载均衡器时使用它。我们将在下一章中讨论这些内容。
6.12 与 Kubernetes 交互
现在我们有了 Kubernetes 集群,我们如何与之交互呢?图 6.11 展示了我们可用的交互方法。简要来说,这些方法是
-
Kubernetes 命令行工具,Kubectl
-
Terraform,本书中我们使用的主要方法
-
Kubernetes 仪表板
在本章中,我们使用 Terraform 创建了一个 Kubernetes 集群。在下一章中,我们将在此基础上扩展,学习如何与集群交互以部署容器。我们再次将使用 Terraform。
在本书中,我们与 Kubernetes 交互的主要方式是使用 Terraform。但了解其他交互方法对我们来说也很有用,这样我们就可以测试我们刚刚创建的集群。我们将通过使用 Kubectl 和 Kubernetes 仪表板与我们的集群交互来结束本章。

图 6.11 与 Kubernetes 交互的方法
6.12.1 Kubernetes 认证
在与你的 Kubernetes 集群交互之前,我们首先必须对其进行认证。在第 6.11.3 节中,你注意到了 Terraform 的以下输出。这些是你用于与集群认证所需的凭据。
-
cluster_client_certificate -
cluster_client_key -
cluster_cluster_ca_certificate
到目前为止,你可以尝试手动设置你的认证细节。为此,你需要在你的主目录下创建 .kube/config 文件,然后输入你的 Kubernetes 凭据。不幸的是,这个设置不是一项简单的练习!但幸运的是,我们正在使用 Azure 和 Azure CLI 工具来自动化这个设置,以下是一个命令:
az aks get-credentials --resource-group flixtube --name flixtube
当你调用 aks get-credentials 时,请确保将两个 flixtube 实例替换为你自己的应用程序名称。这是你在第 6.10 节中为 app_name 变量设置的名称。根据以下模板调用命令:
az aks get-credentials --resource-group <your-app-name>
➥-name <your-app-name>
运行此命令后,Azure CLI 工具将创建你的 Kubectl 配置文件。你可以使用以下命令查看它:
cat ~/.kube/config
你可以在此处了解更多关于手动设置 Kubectl 配置文件的信息:
6.12.2 Kubernetes 命令行界面
在配置就绪后,我们现在可以使用 Kubernetes 命令行(Kubectl)与我们的集群交互。
安装 Kubernetes 命令行
安装 Kubectl 的说明可以在此处找到:
kubernetes.io/docs/tasks/tools/install-kubectl/
安装只是下载适合你操作系统的正确二进制可执行文件并将其添加到系统路径中。当你安装了 Kubectl 后,你可以使用以下命令测试它:
kubectl version
这显示了你的本地计算机上的 Kubectl 和你的 Kubernetes 集群版本号,可能看起来像这样:
Client Version: version.Info{Major:"1", Minor:"19",
GitVersion:"v1.19.3",
GitCommit:"1e11e4a2108024935ecfcb2912226cedeafd99df",
GitTreeState:"clean", BuildDate:"2020-10-14T12:50:19Z",
GoVersion:"go1.15.2", Compiler:"gc", Platform:"windows/amd64"}
version.Info{Major:"1", Minor:"18", GitVersion:"v1.18.8",
GitCommit:"73ec19bdfc6008cd3ce6de96c663f70a69e2b8fc",
GitTreeState:"clean", BuildDate:"2020-09-17T04:17:08Z",
GoVersion:"go1.13.15", Compiler:"gc", Platform:"linux/amd64"}
这有点难以阅读!但如果你从 Client Version 开始扫描,你会找到 GitVersion,它显示了 Kubectl 的版本。你可以看到我正在使用 1.19.3 版本。然后你可以从 Server Version 开始扫描,找到 GitVersion,它显示了 Kubernetes 的版本。你可以看到我的集群正在使用 Kubernetes 的 1.18.8 版本。
使用 Kubectl
Kubectl 是与 Kubernetes 交互的官方和主要方法。任何可以用 Kubernetes 做的事情都可以从 Kubectl 中完成——配置、容器部署,甚至是监控实时应用程序。
在这本书中,我们主要通过 Terraform 代码来控制 Kubernetes。这是一种更高级、更易于表达的方式来与 Kubernetes 交互。而且,我们还可以保持一个简单的部署管道,并将所有基础设施和部署代码都放在 Terraform 中。但在现实世界的生产系统中,这并不总是可能的;尽管如此,我们能够在本书中的简单示例中实现这一点。
我们应该学习 Kubectl 的基础知识,因为它是 Kubernetes 的官方接口,也是 Terraform Kubernetes 提供商的基础。我们至少需要了解它,因为它是调试我们的 Kubernetes 集群的最佳方式,这一点我们将在第十章中探讨。考虑到这一点,让我们使用以下命令测试到我们的 Kubernetes 集群的认证连接:
kubectl get nodes
get nodes 命令显示了为我们集群提供动力的节点列表。我们创建了一个包含单个节点的集群,所以输出将会非常短;类似于以下内容:
NAME STATUS ROLES AGE VERSION
aks-default-42625609-vmss000000 Ready agent 21m v1.15.7
到此为止。我们将在接下来的章节中返回 Kubectl 并学习更多命令。如果你愿意,你可以在以下位置继续学习和实验 Kubectl:
kubernetes.io/docs/reference/kubectl/overview/
6.12.3 Kubernetes 仪表板
Kubectl 是与 Kubernetes 交互的一种方式。另一种是通过 Kubernetes 仪表板,一旦我们设置了 Kubectl 并进行了认证,我们就可以使用它来访问仪表板。
安装 Kubernetes 仪表板
Kubernetes 仪表板默认未安装。尽管如此,我们可以使用以下命令轻松安装它:
kubectl apply -f
➥ https://raw.githubusercontent.com/kubernetes/dashboard/v2.0.4/aio/deploy/
➥ recommended.yaml
连接到 Kubernetes 仪表板
我们无法直接连接到 Kubernetes 仪表板。它简单地没有向公众开放。然而,鉴于我们已经通过 Kubectl 认证来连接到我们的集群,我们可以使用 Kubectl 创建一个代理,允许我们从我们的开发工作站访问仪表板:
kubectl proxy
如果你在一个 Vagrant 虚拟机中运行代理,并想从你的主机操作系统访问它,你需要更改它的绑定 IP 地址,使其外部可访问:
kubectl proxy --address=0.0.0.0
代理允许我们使用完整的 Kubernetes REST API,该 API 可在 http://localhost:8001 访问。你可以在浏览器中打开这个 URL,查看它返回的内容。
如果你想探索 Kubernetes HTTP API,你可以使用 curl 命令、Postman 或 Visual Studio Code REST 客户端来做到这一点。你可以在这里了解更多关于代理命令的信息:
kubernetes.io/docs/reference/generated/kubectl/kubectl-commands#proxy
现在有了 REST API,我们可以通过代理访问仪表板。在你的网络浏览器中打开这个相当长且不自然的 URL:
打开仪表板后,我们来到其身份验证屏幕。我们可以通过选择如图 6.12 所示的 Kubeconfig 文件进行身份验证。这个配置文件就是我们之前在第 6.12.1 节中查看过的,你可以在你的家目录中找到它,路径为~/.kube/config。
注意:如果你在一个 Vagrant 虚拟机下工作,你必须将此文件从虚拟机复制到你的主机操作系统,这样你才能选择它并使用仪表板进行身份验证。
要了解更多关于安装和连接到 Kubernetes 仪表板的信息,请阅读此网页上的信息:
kubernetes.io/docs/tasks/access-application-cluster/web-ui-dashboard/

图 6.12 Kubernetes 仪表板的身份验证
探索 Kubernetes 仪表板
在浏览器中打开 Kubernetes 仪表板后,我们可以使用 GUI 图形化地检查我们的集群。这是了解我们的集群和学习 Kubernetes 的绝佳方式!我鼓励你在继续之前花些时间自己探索这个仪表板。当你完成下一章的学习时,你将能够返回到这个仪表板,并看到我们即将部署的容器。
图 6.13 显示了仪表板的概览页面。这就是你开始探索的地方。仪表板是首先需要访问的地方,尤其是在集群或其托管的任何容器出现问题时,尤其是在早期。在第十章中,我们将了解更多关于仪表板如何帮助调试的信息。

图 6.13 Kubernetes 仪表板
6.12.4 我们取得了什么成果?
我们已经将 Kubernetes 集群上线,并准备好开始部署我们的微服务。在这本书中,我们主要使用 Terraform 代码与我们的集群交互,但我们刚刚学习了两种其他与之交互的方式:Kubernetes CLI 和 Kubernetes 仪表板。
-
Kubernetes CLI(也称为 kubectl)用于从终端配置和查询我们的集群。 它为我们提供了更低级别的 API 用于管理和调试。
-
Kubernetes 仪表板是我们与集群交互的图形化 GUI。 它允许我们直观地探索 Kubernetes 可用的各种资源。除了是一个很好的调试工具外,它也是一个很好的教育工具,有助于更好地理解 Kubernetes。
在下一章中,我们将继续学习如何通过 Terraform 代码与 Kubernetes 交互。
6.13 Terraform 复习
这又是一个重要的章节!我相信现在回想起来,Docker 章节看起来要简单得多。
为了复习,Terraform 是一个用于创建和配置基于云的基础设施的通用工具。到目前为止,我们已经用它来创建我们的微服务应用程序的全部基础设施(除了 Azure 服务主体)。在继续之前,让我们回顾一下我们添加到工具箱中的 Terraform 命令。
表 6.2 Terraform 命令回顾
| 命令 | 描述 |
|---|---|
terraform init |
初始化 Terraform 项目并下载提供者插件 |
terraform➥ apply -auto-approve |
在工作目录中执行 Terraform 代码文件,以增量方式应用更改到我们的基础设施 |
terraform destroy |
销毁由 Terraform 项目创建的所有基础设施 |
6.14 继续学习
在本章中,我们学习了如何基于 Kubernetes 创建生产环境。为了构建我们的生产环境,我们使用了 Terraform 和基础设施即代码的技术。我们将在下一章继续学习更多关于 Terraform 和 Kubernetes 的内容。
Kubernetes 本身是一个深奥且复杂的技术,绝对是我们在这本书中讨论的最复杂的技术。你可能会花上几个月的时间与之打交道,才能彻底了解它!在这本书中,我们只是触及了表面,但我们覆盖了足够的内容,以便将我们的应用程序部署到生产环境中。要深入了解这些主题,我推荐以下书籍:
-
核心 Kubernetes 由 Jay Vyas 和 Chris Love 合著(Manning,预计 2021 年夏季)
-
Kubernetes 实战 由 Marko Lukša 编著(Manning,2017 年)
-
Terraform 实战 由 Scott Winkler 编著(Manning,预计 2021 年春季)
-
GitOps 和 Kubernetes 由 Billy Yuen、Alexander Matyushentsev 等人合著(Manning,预计 2021 年春季)
-
Kubernetes 快速入门 由 William Denniss 编著(Manning,预计 2021 年夏季)
-
一个月午餐学会 Kubernetes 由 Elton Stoneman 编著(Manning,预计 2021 年 2 月)
你可以通过阅读 Kubernetes 文档来了解更多关于 Kubernetes 的信息:
Terraform 的文档可在以下位置找到:
要了解 Azure CLI 工具还能做什么,请在此处阅读文档:
你可以在此处了解更多关于 Azure 上托管 Kubernetes 服务的信息:
摘要
-
基础设施即代码是一种技术,我们将基础设施配置存储为代码。编辑和执行该代码是我们更新基础设施的方式。
-
我们使用 Terraform 从代码中编写创建云资源和应用程序基础设施的脚本。
-
在使用之前,必须初始化 Terraform,并且我们应该修复我们的提供者版本号以避免不愉快的惊喜。
-
Terraform 状态维护了我们创建的系统的记录,并使对系统的未来修改更加高效。
-
我们在 Azure 上为我们的应用程序创建了生产基础设施:一个容器注册库和一个 Kubernetes 集群。
-
我们可以使用 Terraform 输出来找出所创建基础设施的关键细节。
-
Terraform 资源图确保由 Terraform 创建的资源按正确的顺序创建。
-
Kubernetes 是一个支持多个云供应商的云计算平台。
-
Kubernetes 拥有一个可自动化的 API。这允许我们构建持续交付管道(更多内容将在下一章中介绍)。
-
我们不仅使用 Terraform,还使用 Kubernetes CLI(kubectl)和 Kubernetes 仪表板与我们的集群进行交互。
7 实现持续交付
本章涵盖
-
将容器部署到您的 Kubernetes 集群
-
使用 Terraform 配置 Kubernetes
-
为您的应用程序创建自动部署管道
-
使用 Bitbucket Pipelines 进行持续交付
在本章中,我们将我们的微服务应用程序的早期版本部署到生产环境中。在前一章中刚刚创建了一个空的 Kubernetes 集群,我们现在准备好向其部署容器。
首先,我们将部署我们的 MongoDB 数据库和 RabbitMQ 服务器。然后,我们将部署我们的第一个微服务到 Kubernetes 集群:我们在第二章中创建的视频流微服务(从那时起您已经走了很长的路)。
在学习如何使用 Terraform 将容器部署到 Kubernetes 之后,我们将通过自动化的持续交付(CD)管道来总结我们的部署过程。到那时,通过将代码更改推送到我们的托管代码存储库来实现基础设施和应用程序的更新。令人兴奋的时刻!
如果您觉得本章难以理解,请不要担心。本章和前一章可能是本书中最难的两章,所以请坚持下去!跟随示例是获得经验的最佳方式。在本章结束时,您的应用程序将在生产中上线,您需要亲自体验这种感觉!
7.1 新的和熟悉的工具
本章重新审视了 Kubernetes 和 Terraform。但现在,我们将使用 Terraform 将容器和微服务部署到我们的 Kubernetes 集群。我们还介绍了 Bitbucket Pipelines,这是我们用于为微服务应用程序创建 CD 管道的托管服务。
表 7.1 第七章中的新和熟悉工具
| 工具 | 版本 | 用途 |
|---|---|---|
| Kubernetes | 1.18.6 | Kubernetes 是我们将用于在生产中托管微服务的计算平台。 |
| Terraform | 0.12.29 | Terraform 允许我们编写脚本以创建云资源和应用程序基础设施。 |
| Bitbucket Pipelines | N/A | 我们将用于持续交付(CD)以自动化应用程序部署的 Atlassian 提供的托管服务。 |
7.2 获取代码
要跟随本章,您需要下载代码或克隆存储库。
-
从这里下载代码的 zip 文件:
-
您可以使用 Git 如下克隆代码:
git clone https://github.com/bootstrapping-microservices/chapter-7.git
关于安装和使用 Git 的帮助,请参阅第二章。如果您在代码方面遇到问题,请在 GitHub 存储库中记录问题。
7.3 持续演进我们的基础设施
在前一章中,您有选择跟随示例的机会。它是这样的:
-
在遵循第六章和第七章的示例的同时,迭代地演进我们的基础设施。 我们从第六章的/example-1 开始,然后逐步复制每个新示例中的代码。
-
为每个示例构建新的基础设施。 第六章和第七章中的示例也可以独立运行,因此我们可以通过跳入任何示例并调用 Terraform 来轻松地从任何点开始我们的基础设施。
你可能选择了使用迭代和演进的方法(第一个选项)。如果是这样,你可以在本章继续这样做,其中示例被设计为直接从上一章继续。继续像在第六章中那样,将每个新示例中的文件复制到你的工作目录中。如果你选择单独运行每个示例或直接跳转到你感兴趣的特定示例(第二个选项),你也可以在本章继续这样做。
无论你选择哪种工作方式,请确保将 variables.tf 文件顶部的app_name变量的值更改为仅对你而言独特的名称。这必须是唯一的!如果你将你的应用程序命名为 FlixTube,它将与我自己或其他本书读者的资源重叠。
图 7.1 展示了本章我们将要实现的内容。我们将创建一个自动部署管道来部署一个 MongoDB 数据库服务器,部署一个 RabbitMQ 服务器,最重要的是,部署我们的第一个微服务。

图 7.1 本章构建的持续交付(CD)管道和应用程序
7.4 持续交付(CD)
持续交付(CD)是软件开发中的一种技术,我们频繁地将更新的代码自动部署到生产(或测试)环境中。这是我们应用程序的一个重要方面,因为这是我们可靠且频繁地将功能交付给客户手中的方式。从客户那里获得反馈对于构建相关产品至关重要。CD 使我们能够快速且安全地将代码更改部署到生产环境中,并促进快速的开发节奏。
到目前为止,我们已经设法将整个部署过程脚本化。对于更复杂的生产系统来说,这并不总是如此,但对于我们的示例应用程序来说足够了,并且有助于保持事情简单。它还使我们能够更容易地在 CD 管道中实例化我们的部署过程。
图 7.2 展示了到本章结束时我们的 CD 管道将看起来是什么样子。将代码更改推送到我们的 Bitbucket 代码仓库将启动自动部署。这执行我们的 Terraform 代码,并更新托管在 Kubernetes 集群中的应用程序。

图 7.2 使用基础设施即代码,我们可以使用代码来创建我们的基础设施。
我们编写的 Terraform 代码必须尽可能简单且无懈可击。这是因为,最终,在托管 CD 管道中运行代码的调试会更加困难!这就是为什么我们的部署代码应该是简单的,具有最少的移动部件,并且经过良好的测试。
就像任何其他代码一样,在代码部署到生产环境之前,我们将在我们的开发工作站(或个人电脑)上测试我们的部署代码。本章(以及上一章)的大部分内容都包括在接近持续集成(CD)之前,原型设计和测试我们的部署管道。
如果 CD 似乎很复杂或困难,请让我向你保证,它并不复杂。实际上,持续交付(Continuous Delivery)不过是能够在云中托管一个自动调用的 shell 脚本的能力。我们的部署 shell 脚本将在我们向代码仓库推送代码更改时自动调用。
注意:如果你能编写 shell 脚本(这并不困难),那么你可以构建一个 CD 管道。如前所述,CD 并不难;尽管如此,你在 shell 脚本中放入的内容可能就是难点。
对于本章的示例,我们将创建一个部署 shell 脚本,并从中调用 Terraform 来进行部署。在我们着手创建部署 shell 脚本并将其移动到我们的 CD 管道之前,我们将首先学习如何使用 Terraform 将容器部署到 Kubernetes。一旦我们完成了这一步,设置我们的 CD 管道将会相对容易。
7.5 使用 Terraform 部署容器
在上一章中,我们学习了如何使用 Terraform 创建基础设施。我们创建了一个私有容器注册库和一个 Kubernetes 集群。在本章中,我们再次使用 Terraform,但这次是为了将容器部署到我们的集群。在我们能够做到这一点之前,我们必须首先为 Terraform 配置 Kubernetes 提供程序。
7.5.1 配置 Kubernetes 提供程序
我们现在正在将第七章代码库中的 example-1 作为示例。根据你在第六章中的工作方式(参见 6.8.1 节),你可以更新你的工作项目以包含新代码,或者只为本章从头开始使用 example-1。以下列表显示了我们将添加到 providers.tf 中的新代码,以配置 Kubernetes 提供程序。
列表 7.1 设置 Kubernetes 提供程序(摘自 chapter-7/example-1/scripts/providers.tf)
... code omitted ...
provider "kubernetes" { ①
version = "1.10.0" ②
host = azurerm_kubernetes_cluster
➥ .cluster.kube_config[0].host ③
client_certificate = base64decode(azurerm_kubernetes_cluster
➥ .cluster.kube_config[0].client_certificate) ③
client_key = base64decode(azurerm_kubernetes_cluster
➥ .cluster.kube_config[0].client_key)
cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster
➥ .cluster.kube_config[0]
➥ .cluster_ca_certificate) ③
}
① 配置 Kubernetes 提供程序
② 修复提供程序的版本号
③ 设置 Kubernetes 集群的连接和身份验证细节
你可能想知道,这就是我们配置 Kubernetes 集群连接和身份验证细节的地方。在这个代码文件中,这些值会自动从创建我们集群的其他 Terraform 代码文件中提取(参见 kubernetes-cluster.tf)。
您也可以用您单独创建的集群的详细信息替换这些值。例如,您可能已经在 Azure 门户 GUI 或使用 Azure CLI 工具手动创建了您的集群。您也可能使用一个完全独立的 Terraform 项目来创建您的集群。(我们将在第十一章中讨论以这种方式分离我们的代码。)无论如何,只要您有您集群的连接详细信息(就像我们在第 6.11.3 节中注意到的那些),您就可以在这里使用它们来连接到它。
如果您正在跟随,您现在需要调用 terraform init。无论您是在演进您的工作项目还是从 example-1 开始全新,您都需要这样做。我们向我们的 Terraform 项目添加了一个新的提供者,而 init 命令就是下载其插件的命令。
7.5.2 部署我们的数据库
我们将部署到 Kubernetes 的第一个容器是为我们的 MongoDB 数据库服务器。最终,这将是我们为每个微服务托管单独数据库的地方。
列表 7.2 显示了将数据库服务器部署到我们的集群的 Terraform 代码。此脚本创建了一个 Kubernetes 部署,从公共 Docker 映像实例化 MongoDB 容器。然后创建了一个 Kubernetes 服务,通过 DNS 使部署对其他容器可访问。这就是其他容器如何连接到我们的数据库。您可以在 Kubernetes 文档中了解更多关于 Kubernetes 概念,如部署和服务,kubernetes.io/docs/concepts/。
列表 7.2 部署您的数据库(第七章/示例-1/脚本/database.tf)
resource "kubernetes_deployment" "database" { ①
metadata { ②
name = "database" ③
labels = {
pod = "database" ④
}
}
spec {
replicas = 1 ⑤
selector { ⑥
match_labels = {
pod = "database"
}
} ⑥
template { ⑦
metadata { ⑧
labels = {
pod = "database" ⑨
}
}
spec {
container { ⑩
image = "mongo:4.2.8" ⑪
name = "database" ⑫
port {
container_port = 27017 ⑬
}
}
}
}
}
}
resource "kubernetes_service" "database" { ⑭
metadata {
name = "database" ⑮
}
spec {
selector = {
pod = kubernetes_deployment.database
➥ .metadata[0].labels.pod ⑯
}
port {
port = 27017 ⑰
}
type = "LoadBalancer" ⑱
}
}
① 声明一个 Kubernetes 部署用于我们的 MongoDB 数据库服务器,导致 MongoDB 容器在我们的 Kubernetes 集群中实例化
② 为部署设置元数据
③ 设置部署的名称
④ 标记部署
⑤ 为此部署中的 pod 设置要创建的副本(或副本)数量
⑥ 通过匹配标签将部署附加到其 pod
⑦ 此部署创建的 pod 的模板
⑧ 为每个 pod 设置元数据
⑨ 标记 pod
⑩ 指定在 pod 中实例化的单个容器的详细信息
⑪ 设置实例化容器的映像
⑫ 设置容器的名称
⑬ 显示要公开的容器端口;在这种情况下,MongoDB 的默认端口(可选,主要用于信息)
⑭ 声明一个 Kubernetes 服务,创建一个 DNS 记录,以便数据库可以被集群内的其他容器访问
⑮ 设置服务的名称
⑯ 通过匹配标签将服务附加到部署
⑰ 设置此服务公开的端口
⑱ 使用 Azure 负载均衡器将服务暴露给外部世界。这为数据库分配了一个外部可访问的 IP 地址。我们可以使用它来测试我们的部署。
您可以看到在列表 7.2 的末尾,我们将 Azure 负载均衡器附加到 Kubernetes 服务上,以便将我们的数据库公开给外界。负载均衡器的真正用途是我们将在第十章和第十一章中讨论的,但在这里,我们只是用它来从我们的开发工作站访问集群中的数据库。这是如果我们把容器隐藏在集群内部,我们就无法做到的事情。
从安全角度来看,以这种方式公开我们的数据库是绝对不允许的。这意味着任何人(包括那些有恶意意图的人)都可以更改我们的数据库。请放心,这种情况只是暂时的!我们只是在基础设施的早期阶段为了测试(我们将在下一刻进行)而公开了我们的数据库。测试后,我们将将其锁定,使其只能从集群内部访问。
7.5.3 准备持续交付
我们正在开发工作站上原型化和测试我们的部署代码。在这个过程中,我们将准备在 CD 管道中运行此代码。这意味着我们需要我们的代码以完全自动化的方式运行,因此我们现在应该消除任何人工交互。
如果您回想起第 6.11.2 节,我们创建了一个 Azure 服务主体,允许我们的 Kubernetes 集群通过 Azure 进行身份验证。它需要这样做,以便它可以为我们的服务(如列表 7.2 中所示)创建负载均衡器。然后,在第 6.11.3 节中,当调用terraform apply时,我们手动为client_id和client_secret变量提供了值。为此,我们输入了从服务主体的appId和password字段中的值。
手动输入这样的值在 CD 管道中不起作用。这必须自动化!同时,在我们原型化 Terraform 代码时,不得不不断输入这些值也相当烦人。
现在,我们将通过命令行参数设置这些值。首先,您必须记住您的服务主体的详细信息。如果您在第 6.11.2 节中创建的服务主体仍然有效,并且您已经记下了这些详细信息,您可以重用它。否则,请再次按照第 6.11.2 节中的说明创建或更新您的服务主体。然后注意appId和password字段。现在,在设置client_id和client_secret为命令行参数的情况下,调用terraform apply示例-1:
terraform apply -var="client_id=a2016492-068c-4f37-a32b-6e6196b65488"
➥ -var="client_secret=deb781f5-29e7-42c7-bed8-80781411973a" -auto-approve
只需记住将client_id和client_secret的值替换为您自己的服务主体的值,根据以下模板:
terraform apply -var="client_id=<your-client-id>"
➥ -var="client_secret=<your-client-secret>" -auto-approve
您可以看到我们继续使用我们在第 6.9.2 节开始使用的-auto-approve命令行选项。设置变量并启用自动批准将我们的 Terraform 项目置于完全自动模式。无需人工干预,这意味着我们将在 CD 管道中完全无人值守地执行此代码。
7.5.4 测试新的数据库服务器
在上一节中调用 terraform apply 之后,你现在应该在你的 Kubernetes 集群中运行一个数据库服务器。为了测试我们的数据库是否启动,我们已将其附加了一个 Azure 负载均衡器(如列表 7.2 所示)。这是一个临时措施,以便我们可以使我们的数据库对外可访问并测试它以确保其正常工作。
查找外部 IP 地址
要找出分配给数据库服务的那个外部 IP 地址,我们可以使用 Kubernetes CLI 工具(Kubectl)或我们之前设置的 Kubernetes 仪表板(第 6.12 节)。我们可以使用 Kubectl 列出我们集群中的服务:
kubectl get services
如果你在使用 Kubectl 和连接到你的集群时遇到麻烦,请参阅第 6.12.1 节。输出看起来可能像这样:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
database LoadBalancer 10.0.226.64 168.62.216.232 27017:30390/TCP
kubernetes ClusterIP 10.0.0.1 <none> 443/TCP
挑选出数据库服务(例如,我们在列表 7.2 中为其命名的服务)并注意 EXTERNAL-IP 列中的 IP 地址。你还可以在 PORT(S) 列中看到 MongoDB 默认端口 27017。这是从集群外部访问我们的数据库的 IP 地址和端口号。或者,我们可以打开 Kubernetes 仪表板(如第 6.12.3 节所述)并导航到服务部分以找到这些详细信息。
测试连接
现在使用 Robo 3T(如第 4.5.2 节中所述)或另一个数据库查看器,使用你为其记下的外部 IP 地址连接到你的数据库服务器。确保你使用端口号 27017 进行连接。如果一切顺利,你应该能够连接到你的数据库并查看其默认内容(然而,我们还没有添加任何特定内容)。
7.5.5 部署和测试 RabbitMQ
与我们的数据库服务器类似,但配置略有不同,我们现在转向 example-2。在那里,我们将 RabbitMQ 服务器部署到我们的 Kubernetes 集群。
列表 7.3 与列表 7.2 类似。它创建了一个 Kubernetes 部署,在容器中实例化 RabbitMQ 服务器。它创建了一个 Kubernetes 服务,使容器在集群内部可通过 DNS 访问。同样,我们将 Azure 负载均衡器附加到 Kubernetes 服务上,以便我们可以从集群外部访问它进行测试。然后,我们可以使用 RabbitMQ 仪表板来检查 RabbitMQ 是否正常工作。
列表 7.3 部署你的 RabbitMQ 服务器(chapter-7/example-2/scripts/rabbit.tf)
resource "kubernetes_deployment" "rabbit" { ①
metadata {
name = "rabbit"
labels = {
pod = "rabbit"
}
}
spec {
replicas = 1
selector {
match_labels = {
pod = "rabbit"
}
}
template {
metadata {
labels = {
pod = "rabbit"
}
}
spec {
container {
image = "rabbitmq:3.8.5-management" ②
name = "rabbit"
port {
container_port = 5672
}
}
}
}
}
}
resource "kubernetes_service" "rabbit" { ③
metadata {
name = "rabbit"
}
spec {
selector = {
pod = kubernetes_deployment.rabbit.metadata[0].labels.pod
}
port {
port = 5672
}
}
}
resource "kubernetes_service" "rabbit_dashboard" { ③
metadata {
name = "rabbit-dashboard"
}
spec {
selector = {
pod = kubernetes_deployment.rabbit.metadata[0].labels.pod
}
port {
port = 15672
}
type = "LoadBalancer" ④
}
}
① 声明一个 Kubernetes 部署以部署我们的 RabbitMQ 服务器。这就是在 Kubernetes 集群中实例化 RabbitMQ 容器的方式。
② 从公共 RabbitMQ 镜像实例化容器
③ 声明一个 Kubernetes 服务,创建一个 DNS 记录,以便 RabbitMQ 服务器可以被集群内的其他容器访问
④ 为服务创建一个 Azure 负载均衡器,为仪表板分配一个外部可访问的 IP 地址。我们可以使用它来测试部署。
现在运行 terraform apply(使用与第 7.5.3 节中相同的命令行参数)。然后将 RabbitMQ 部署到你的 Kubernetes 集群。
再次,我们已经配置了我们的服务(暴露 RabbitMQ 仪表盘的服务)可以通过 Azure 负载均衡器外部访问。现在我们可以使用 Kubectl 或 Kubernetes 仪表盘来找到已分配的外部 IP 地址。回顾第 7.5.4 节以记住我们是如何为我们的数据库做这件事的。注意外部 IP 地址,并使用您的浏览器打开 RabbitMQ 管理仪表盘。您可以使用默认用户名 guest 和默认密码 guest 登录。
例如,如果你的 RabbitMQ 仪表盘的 IP 地址是 40.112.161.104,那么你应该将你的浏览器指向 http://40.112.161.104:15672/。仪表盘的端口号是 15672。然而,你自己的服务的 IP 地址 将不同于我的。确保你将其替换为你分配给你的 RabbitMQ 实例的 IP 地址。
7.5.6 加强我们的安全性
我们已经通过外部端点测试了我们的 MongoDB 和 RabbitMQ 服务器。将这些服务器暴露给世界就像是在寻找麻烦!现在我们已经测试过了,让我们移除外部访问并加强我们的安全性。这就像从我们的脚本中移除以下行一样简单:
type = "LoadBalancer"
这正是我们从 example-3 开始要做的。当你用 example-3 代码更新你的工作项目并接下来调用 terraform apply 时,MongoDB 和 RabbitMQ 的外部访问将被移除,从而加强我们应用程序的安全性。
7.5.7 我们取得了什么成果?
在上一章创建我们的 Kubernetes 集群之后,在这一章中,我们现在开始用容器填充它。在为 Terraform 设置 Kubernetes 提供者之后,我们创建了新的 Terraform 脚本来部署 MongoDB 数据库和 RabbitMQ 服务器。
我们为了测试暂时将服务器暴露给外界。测试完成后,我们通过移除那些外部 IP 地址来加强我们的安全性——出于安全考虑,我们不希望外界能够访问我们的内部基础设施。
7.6 使用 Terraform 部署我们的第一个微服务
我们已经将一些公开可用的 Docker 镜像部署到了我们的集群中(MongoDB 和 RabbitMQ)。现在,让我们继续到 example-3 并将我们的第一个微服务部署到集群中。
虽然我们仍然从 Docker 镜像部署容器,但这次镜像是由我们自己的私有代码构建的。在我们能够从它部署容器之前,我们必须能够构建一个镜像并将其(就像我们在第三章中练习的那样)发布到我们在上一章 6.9 节中创建的私有容器注册库。
7.6.1 使用局部变量计算配置
为了使事情变得更容易,并使我们的 Terraform 代码更加紧凑,我们将使用 Terraform 局部变量 来在我们的最新代码文件 video-streaming.tf 中组合和共享一些常见的配置值。这个新文件负责构建、发布和部署我们的视频流微服务。
下一个列表是从新的代码文件中摘录的。它显示了我们将用于脚本其余部分的多个本地变量的声明。
列表 7.4 使用本地变量进行配置(摘自第七章/示例 3/脚本/video-streaming.tf)
locals {
service_name = "video-streaming" ①
login_server = azurerm_container_registry.container_registry
➥ .login_server ②
username = azurerm_container_registry ②
➥ .container_registry.admin_username ②
password = azurerm_container_registry ②
➥ .container_registry.admin_password ②
image_tag = "${local.login_server}/${local.service_name}:${
➥ var.app_version}" ③
}
# ... code omitted here ...
① 设置我们在这段代码文件中使用的部署服务的名称
② 设置我们私有容器注册库的连接详情。这些详情是从创建注册库的 Terraform 文件中提取的。
③ 组合视频流微服务 Docker 镜像的标签
注意image_tag本地变量是如何由多个其他变量组成的,特别是app_version变量,我们使用它来为每个连续的镜像添加一个新的版本号。image_tag变量也是我们标记图像以发布到容器注册库的方式。
7.6.2 构建和发布 Docker 镜像
现在,让我们看看构建并发布我们视频流微服务 Docker 镜像的 Terraform 代码。这段代码有三个任务:
-
构建镜像
-
登录到容器注册库
-
将镜像推送到容器注册库
你在第三章学习了如何做所有这些事情。在这里,我们将使用 Terraform 来自动化这个过程。
在列表 7.5 中,我们继续查看 video-streaming.tf 中的代码。虽然有一个 Docker 提供程序可以与 Terraform 一起使用,但不幸的是,它没有执行我们需要的任务的能力。这就是为什么我们使用 Terraform 的万能null_resource及其local-exec功能来调用我们需要的 Docker 命令。
我们可以使用null_resource来创建没有特定资源类型的 Terraform 资源。我们使用local-exec在本地计算机上调用命令。如果这个列表看起来像一个大杂烩,特别是使用timestamp来强制构建 Docker 镜像,那么确实是因为这是一个大杂烩!
我们使用这个作为权宜之计来简化事情,并保持我们的整个部署过程在 Terraform 中。从长远来看,我们不希望在生产环境中使用这样的代码。最终,我们将从这个权宜之计迁移出去,并实施一个更干净的解决方案。但你必须等到第十一章才能看到那是什么样子。
列表 7.5 构建和发布 Docker 镜像(摘自第七章/示例 3/脚本/video-streaming.tf)
# ... code omitted here ...
resource "null_resource" "docker_build" { ①
triggers = {
always_run = timestamp() ②
}
provisioner "local-exec" { ③
command = "docker build -t ${local.image_tag} --file
➥ ../${local.service_name}/Dockerfile-prod
➥ ../${local.service_name}"
} ③
}
resource "null_resource" "docker_login" { ①
depends_on = [ null_resource.docker_build ] ④
triggers = {
always_run = timestamp() ②
}
provisioner "local-exec" { ⑤
command = "docker login ${local.login_server}
➥-username ${local.username}
➥-password ${local.password}" ⑥
}
}
resource "null_resource" "docker_push" { ①
depends_on = [ null_resource.docker_login ] ④
triggers = {
always_run = timestamp() ②
}
provisioner "local-exec" { ⑦
command = "docker push ${local.image_tag}"
}
}
# ... code omitted here ...
① 使用 Terraform 的 null_resource 声明我们的 Docker 命令
② 强制我们的命令始终被调用
③ 构建我们的 Docker 镜像
④ 设置对前一个命令的依赖,因为我们不能在构建镜像之后发布我们的镜像
⑤ 调用一个命令来对我们的容器注册库进行认证
⑥ 使用我们的注册库进行认证
⑦ 将镜像推送到我们的注册库
我必须承认,我们在列表 7.5 中对 null_resource 的使用(有些人可能会说是滥用)并不理想。如果 Docker 提供商直接支持构建和推送操作(为什么它不支持?),我们就不需要这段丑陋的代码了!不幸的是,目前就是这样,但也许在将来,会有一个更优雅的解决方案。在第十一章中,我们将讨论当我们从 mono-repo(单个代码仓库)结构转向 multi-repo(多个代码仓库)结构时如何解决这个问题。
7.6.3 使用容器注册库进行认证
在构建和发布我们的微服务的 Docker 镜像之后,我们必须现在授予集群从容器注册库拉取镜像的权限。您可以在列表 7.6 中看到这是如何完成的,我们继续查看 video-streaming.tf。在列表中,我们创建了一个 Kubernetes 机密来包含我们的 Docker 凭据。这是在集群中存储敏感数据的一种安全方式。
列表 7.6 使用容器注册库进行认证(摘自第七章示例 3 脚本/video-streaming.tf)
# ... code omitted here ...
locals { ①
dockercreds = { ②
auths = {
"${local.login_server}" = {
auth = base64encode("${local.username}:${local.password}")
}
}
} ②
}
resource "kubernetes_secret" "docker_credentials" { ③
metadata {
name = "docker-credentials"
}
data = {
".dockerconfigjson" =
➥ jsonencode(local.dockercreds) ④
}
type = "kubernetes.io/dockerconfigjson" ⑤
}
# ... code omitted here ...
① 定义更多局部变量以供此代码文件使用
② 创建一个包含我们的容器注册库认证详情的变量
③ 声明一个 Kubernetes 机密以安全地存储我们的认证凭据
④ 设置机密的数据
⑤ 设置机密的类型
再次,我们编写的代码感觉相当尴尬。如果 Docker 提供商有一个更优雅的方式来表达这一点,那会很好,也许在将来,他们会提供这样的解决方案。
7.6.4 部署视频流微服务
现在我们有了构建和发布我们的视频流微服务的 Terraform 代码。我们还拥有包含我们的注册库凭据的 Kubernetes 机密。现在我们可以编写部署我们的微服务的代码了。
列表 7.7 展示了 video-streaming.tf 中的剩余代码。文件的其他部分与我们在列表 7.2 和 7.3 中看到的代码类似,这些代码部署了 MongoDB 和 RabbitMQ 的容器。我们有一个 Kubernetes 部署,它实例化了一个用于我们的视频流微服务的容器,以及一个 Kubernetes 服务,使其在集群内部可通过 DNS 访问。此外,我们再次将 Azure 负载均衡器附加到 Kubernetes 服务上,以便使其外部可访问,这样我们就可以从我们的开发工作站测试集群中的微服务。
这里的主要区别是,我们的微服务的镜像私有,并从我们自己的私有容器注册库中拉取,而 MongoDB 和 RabbitMQ 是公共镜像。为了便于这样做,我们添加了一个显式的依赖项(使用列表中可以看到的 depends_on)。这个依赖项导致我们的 Docker 镜像在创建 Kubernetes 部署之前被构建和发布。此外,请注意,注册库凭据是通过 image_pull_secrets 提供的。
列表 7.7 部署视频流微服务(摘自第七章示例 3 脚本/video-streaming.tf)
# ... code omitted here ...
resource "kubernetes_deployment"
➥ "service_deployment" { ①
depends_on = [ null_resource.docker_push ] ②
metadata {
name = local.service_name ③
labels = {
pod = local.service_name ③
}
}
spec {
replicas = 1
selector {
match_labels = {
pod = local.service_name ③
}
}
template {
metadata {
labels = {
pod = local.service_name
}
}
spec {
container {
image = local.image_tag ④
name = local.service_name
env {
name = "PORT"
value = "80"
}
}
image_pull_secrets { ⑤
name = ⑤
➥ kubernetes_secret.docker_credentials.metadata[0].name ⑤
} ⑤
}
}
}
}
resource "kubernetes_service" "service" { ⑥
metadata {
name = local.service_name
}
spec {
selector = {
pod = kubernetes_deployment.service_deployment.metadata[0].labels.pod
}
port {
port = 80
}
type = "LoadBalancer" ⑦
}
}
① 声明了一个 Kubernetes 部署以部署我们的视频流微服务。这将在我们的 Kubernetes 集群中实例化微服务的容器。
② 创建了一个依赖项,导致我们的 Docker 镜像在容器部署之前构建和发布
③ 使用局部变量在此代码文件中共享配置
④ 图片是从我们的私有容器注册库中拉取的。
⑤ 为我们的容器注册库指定了身份验证凭据,以便 Kubernetes 可以拉取镜像
⑥ 声明了一个 Kubernetes 服务,创建一个 DNS 记录以在集群内使微服务可访问
⑦ 使用 Azure 负载均衡器为该服务创建一个外部 IP 地址。我们可以使用这个地址来测试已部署的微服务是否正常工作。
在 7.7 列表的末尾,你可以看到我们已经将 Azure 负载均衡器附加到 Kubernetes 服务上以创建外部端点。我们为了测试目的暂时将一个容器暴露给外部世界。这允许我们从网页浏览器中检查我们的视频流微服务以验证其功能。请放心,最终的 FlixTube 示例应用程序已限制外部端点!你将不得不等到第九章才能看到这一点。
7.6.5 测试你的微服务
让我们把这个微服务部署起来吧!调用terraform apply并将视频流微服务部署到你的 Kubernetes 集群中:
terraform apply -var="app_version=1" -var="client_id=<your-client-id>"
➥ -var="client_secret=<your-client-secret>" -auto-approve
注意,我们现在正在设置app_version变量。我们最初将其设置为版本 1,并在发布微服务的新版本镜像时递增它。请记住用 6.11.2 和 7.5.3 节中所示的自定义服务主体的值替换client_id和client_secret的值。
完成此操作后,查找视频流微服务的外部 IP 地址,就像在 7.5.4 节中为数据库服务所做的那样。现在打开你的网络浏览器并导航到该 IP 地址的/video 路由。例如,如果你的 IP 地址是 40.112.161.104,那么将你的浏览器指向 http://40.112.161.104/video。只需记住使用你自己的外部 IP 地址。你现在应该能在浏览器中看到熟悉的视频播放。
7.6.6 我们取得了什么成果?
在部署 MongoDB 和 RabbitMQ 的容器之后,我们现在已经打包、发布并部署了我们的第一个微服务!在这个过程中,我们学习了如何使用 Terraform 局部变量来组合和共享配置细节,以便在多个地方使用。这使我们免去了多次输入这些细节的麻烦,并在以后需要更改这些配置时将变得方便。
我们使用 Docker 构建和发布了微服务的镜像。从集群到容器注册库的身份验证(以拉取镜像)有点棘手,但我们创建了一个 Kubernetes 密钥来处理这个问题。
最终,我们部署并测试了我们的视频流微服务,并完成了我们的部署流程的原型设计。现在,是时候通过创建我们的 CD 管道来自动化部署流程了。
7.7 使用 Bitbucket Pipelines 进行持续交付
到目前为止,在第六章和第七章的这一部分,我们手动调用了 Terraform 来执行我们的基础设施代码,并为我们的应用程序构建基础设施。这样做是原型化我们的部署代码的正常流程的一部分。
就像任何编码任务一样,在我们可以在生产环境中运行代码之前,我们需要在本地开发和测试我们的代码。在这种情况下,这尤其重要,因为这段代码运行在托管服务中,调试那里发生的问题可能相当困难。我们希望这段代码在我们离开开发工作站之前尽可能无懈可击。
在本地运行 Terraform 也是学习和理解基础设施即代码的最佳方式。但使用 Terraform 和基础设施即代码的整个目的在于自动化我们的部署流程。我们不希望每次对基础设施或微服务的更改都手动调用 Terraform。我们希望频繁地部署更改,并希望它是自动化和简化的,这样我们就可以把大部分时间花在构建功能上,而不是部署我们的软件。此外,自动化还可以大大减少人为错误的可能性。
现在,我们将使用 Bitbucket Pipelines 创建我们的 CD 管道。这是 Atlassian 提供的一项托管服务,我们将用它以自动化的方式运行我们的部署流程。它使我们的部署过程变得像代码推送一样简单。将代码更改推送到我们的托管代码仓库将自动调用我们的部署管道。
7.7.1 为什么选择 Bitbucket Pipelines?
有许多好的托管服务用于 CD,它们都相当相似。如果你学会了其中一个,你会发现其他的并没有太大的不同。
Bitbucket Pipelines 很方便,因为它包含在 Atlassian 的 Bitbucket 中,这样我们就可以将代码和 CD 管道放在一起。你无法抱怨价格!Atlassian 提供了一个很好的入门级服务,包括免费的私有仓库和每月有限的免费 构建分钟数,可以用于 CD 管道。这为我们提供了足够的空间,可以完全免费托管一个小型构建管道。
注意:我们使用哪种托管服务实际上并不重要。例如,GitHub 和 GitLab 都提供类似的服务,配置方式也类似。Bitbucket Pipelines 出现在这本书中,是因为我在生产环境中目前使用的是它,尽管我过去使用过 GitLab,而且我继续在大多数开源编码中使用 GitHub。
我喜欢将 CD 简单地视为在云中自动运行 shell 脚本的一种方式。当然,这是一个简化,但它可能有助于你理解 CD 并不特别复杂或神秘。为我们的部署创建 shell 脚本也是有用的,因为我们可以在本地轻松测试它。
7.7.2 将示例代码导入 Bitbucket
要使用 Bitbucket Pipelines,我们首先必须将我们的代码提交到 Bitbucket 代码仓库。我们现在将转到代码示例-4。这与示例-3 相同,但它包括了我们需要为我们的 CD 管道添加的额外配置。
此外,在这个时候,你应该使用 terraform destroy(如果你还没有这样做)来销毁你之前创建的基础设施。你之前创建的基础设施是我们的 原型 基础设施。从现在开始,我们将使用我们的 CD 管道来创建 生产 基础设施,我们不希望它们重叠。
在 Bitbucket 上注册
首先,你需要注册一个 Bitbucket 账户,假设你还没有。登录或注册请点击此处:bitbucket.org。
创建托管代码仓库
当你登录到你的账户时,点击按钮创建一个新的代码仓库。为你的新仓库选择一个名称并填写详细信息(如图 7.3 所示)。

图 7.3 创建新的 Bitbucket 代码仓库
在创建你的代码仓库后,你会看到一个类似于图 7.4 的页面。这里提供了将代码放入仓库的说明。这些只是标准的 Git 命令,但很方便的是,这些命令给出了我们导入代码所需的精确命令。

图 7.4 Bitbucket 提供的将你的代码放入新仓库的说明
创建本地仓库
在将示例-4 代码导入我们的新仓库之前,让我们先创建一个全新的副本,这样我们就不至于弄乱第七章的仓库:
cp -r chapter-7/example-4 bootstrapping_ch7_example4
进入新目录并初始化一个新的空 Git 仓库:
cd bootstrapping_ch7_example4
git init
现在将所有文件提交到新仓库:
git add .
git commit -m "First commit"
推送到托管仓库
现在我们可以遵循 Bitbucket 提供的说明(如图 7.4 所示)。给出的命令将因我们拥有不同的 Bitbucket 账户而不同。第一个命令是将远程仓库作为本地仓库的源。例如
git remote add origin
➥ git@bitbucket.org:ashleydavis75/bootstrapping_ch7_example4.git
记得用你自己的详细信息替换远程仓库的 URL。以下是你应该使用的模板:
git remote add origin git@bitbucket.org:<your-user-name>/<your-repo-name>.git
现在将你的代码推送到你的托管仓库:
git push -u origin master
在这个阶段,你需要输入你的凭证。为了避免每次推送时都这样做,我建议你设置一个 SSH 密钥,这在每个平台上都相对容易完成,请按照 Bitbucket 支持的以下说明操作:

图 7.5 导航到你的仓库的管道页面
在未来,你可以使用简短的命令来推送代码更改:
git push
启用 Bitbucket Pipelines
我们已经创建了托管代码仓库并将示例代码推送到它。现在,我们可以为这个仓库启用 Bitbucket Pipelines。导航到管道页面(如图 7.5 所示)。
示例代码包含一个 bitbucket-pipelines.yaml 配置文件。Bitbucket 会检测到这个文件,现在你可以点击启用按钮(如图 7.6 所示),这将启用此代码仓库的 Bitbucket Pipelines。(注意,你可能需要向下滚动以便可以看到启用按钮。)

图 7.6 点击启用按钮以启用 Bitbucket Pipelines
7.7.3 创建部署 shell 脚本
大多数 CD 管道都可以简化为一个由环境变量提供的输入的 shell 脚本。采用这种方法有两个原因:
-
我们的部署管道在 CD 提供者之间具有一定的可移植性。
-
我们可以很容易地通过执行 shell 脚本在本地测试我们的部署管道。
创建部署 shell 脚本给我们一些自由。这很好,但这对我们部署管道的本地测试也是必不可少的。在我们尝试在云中无人执行之前,我们需要确保这段代码是坚不可摧的。
我们在本章的大部分时间里都在原型设计和测试我们的部署代码,所以我们已经非常确信它的工作。我们只需要将其封装在一个 shell 脚本中。example-4 中包含的 shell 脚本如下所示。它很简单,这是因为我们能够将我们的部署代码完全保持在 Terraform 中。我们的 shell 脚本只是调用 Terraform。
列表 7.8 部署的 shell 脚本(chapter-7/example-4/scripts/deploy.sh)
set -u ①
: "$VERSION"
: "$ARM_CLIENT_ID"
: "$ARM_CLIENT_SECRET"
: "$ARM_TENANT_ID"
: "$ARM_SUBSCRIPTION_ID" ①
cd ./scripts ②
export KUBERNETES_SERVICE_HOST="" ③
terraform init ④
terraform apply -auto-approve \ ⑤
-var "app_version=$VERSION" \ ⑥
-var "client_id=$ARM_CLIENT_ID" \
-var "client_secret=$ARM_CLIENT_SECRET" ⑥
① 检查是否提供了预期的输入环境变量
② 从包含我们的部署脚本的目录中调用 Terraform
③ Kubernetes 提供者的一个问题的工作方案(解释如下)
④ 初始化 Terraform 并下载提供者
⑤ 调用 Terraform apply
⑥ 从环境变量设置 Terraform 变量
在这个脚本中有一个有趣的事情需要注意,那就是它是如何检查输入环境变量的。这些是我们应用程序的当前版本号(用于标记我们的 Docker 镜像)和我们的 Azure 账户的认证详情。我们很快就会看到这些变量从哪里来。
你可能想知道为什么我们要设置 KUBERNETES_SERVICE_HOST 环境变量。这是在尝试在 Kubernetes pod 内使用 Terraform 的 Kubernetes 提供者时出现问题的解决方案。(我刚刚短路了你的大脑吗?)看起来 Bitbucket Pipelines 在 Bitbucket 自己的 Kubernetes 集群内的一个 pod 中悄悄地运行我们的 CD 管道。这很有意义,也是 Kubernetes 的一个很棒的使用案例。由此产生的问题不应该发生,但它确实发生了。我们清空 KUBERNETES_SERVICE_HOST 环境变量来欺骗 Kubernetes 提供者,以避免这个问题。
我们不应该关心这类事情,但有时在使用早期工具(Terraform 是在 1.0 版本之前),我们有时不得不应对 Terraform 或其提供者插件中的异常问题。可能在你阅读这段文字的时候,问题已经被修正,这个解决方案也就不再需要了。如果你好奇并想了解更多,你可以在这里阅读相关信息:
github.com/terraform-providers/terraform-provider-kubernetes/issues/679
7.7.4 管理 Terraform 状态
现在我们回到管理 Terraform 状态这个棘手的问题。回想一下前一章的 6.8.7 节。你会记得 Terraform 有一个状态文件,这样它就能记住它创建的基础设施。
现在的问题是,我们如何在我们的 CD 流水线中持久化状态文件?CD 流水线的特性是每次调用都在一个新的容器实例中发生。这就是为什么我们必须在我们的部署 shell 脚本中对每一次部署调用 terraform init。
我们如何管理 Terraform 状态文件?我们必须在 CD 流水线的实例之间持久化它,这样 Terraform 的后续调用就能记住之前创建的内容。这样它就不会盲目地尝试重新创建它已经创建的基础设施。Terraform 有一个解决方案。我们可以提供外部存储,Terraform 可以在其中存储其状态文件。这使得状态文件可以单独存储在 CD 流水线之外。
以下列表显示了我们的 Azure 后端配置。通过这个配置,我们设置了一个 Azure 存储帐户作为存储 Terraform 状态的后端。
列表 7.9 配置后端存储(第七章示例 4 的 scripts/backend.tf)
terraform {
backend "azurerm" { ①
resource_group_name = "terraform" ②
storage_account_name = "terraform" ②
container_name = "terraform" ②
key = "terraform.tfstate" ③
} ①
}
① 为 Azure 存储后端设置配置
② 为 Terraform 设置资源组、存储帐户和容器的名称
③ 指定存储 blob 的名称,其中存储 Terraform 状态
在 Terraform 在你的 CD 流水线中工作之前,我们必须为它创建一个单独的 Azure 存储帐户。首先,选择一个用于你的存储帐户的名称。正如你在列表 7.9 中看到的那样,我使用了 terraform 这个名称。不幸的是,你不能使用这个名字,因为存储帐户的名称必须是全局唯一的(这有点令人烦恼)。
在你的浏览器中打开 Azure 门户,并在具有相同名称的资源组下创建一个新的存储帐户。(有关创建存储帐户的复习,请参阅第四章的 4.4.1 节。)
在你的新存储帐户中,也创建一个具有相同名称的容器(一个存储容器,而不是 Docker 容器)。你可以为这个容器使用任何你想要的名称(尽管,存储帐户的名称有一些限制)。我使用了 terraform 这个名称作为资源组、存储帐户和容器名称。我使用这个名称只是为了表明这些都是在我们的 CD 管道中运行的 Terraform 的纯使用。这个新的存储帐户在基础设施或应用程序中未作他用。
一旦创建了存储帐户和容器,编辑 backend.tf(如列表 7.9 所示)并将每个 terraform 实例替换为你所选的名称。在这个阶段,你可以将这些更改提交到你的本地代码仓库,但不要将它们推送到 Bitbucket!在我们 CD 管道工作之前,我们还有一些配置要做。
7.7.5 Bitbucket Pipelines 脚本
我们 CD 管道的最后一部分是 bitbucket-pipelines.yaml 配置文件。这个 YAML 文件是我们用来配置我们的 CD 管道的。要使用 Bitbucket Pipelines,你必须在这个代码仓库的根目录中拥有此文件。没有它,它根本不起作用。
列表 7.10 显示了 example-4 中的 bitbucket-pipelines.yaml。你可以看到我们只是将此文件用作列表 7.8 中 shell 脚本的包装器。当然,这不仅仅是这样,但也没有多少!为我们的 Bitbucket 代码仓库启用 Bitbucket Pipelines 后,此 YAML 文件现在在代码推送到托管代码仓库时调用我们的部署 shell 脚本。
列表 7.10 配置我们的 Bitbucket Pipelines 自动部署管道(chapter-7/example-4/bitbucket-pipelines.yaml)
image: hashicorp/terraform:0.12.6 ①
pipelines:
default: ②
- step: ③
name: Build and deploy
services:
- docker ④
script:
- export VERSION=$BITBUCKET_BUILD_NUMBER ⑤
- chmod +x ./scripts/deploy.sh ⑥
- ./scripts/deploy.sh ⑦
① 为 CD 管道中运行的容器设置基本镜像。我们使用 Terraform 基本镜像,以便在我们的 CD 管道中访问 Terraform 工具。
② 配置我们仓库的默认管道。我们也可以为每个分支有单独的管道。
③ 在我们的部署管道中定义一个步骤(这里,只有一个步骤)
④ 启用 Docker 服务。这让我们可以在 Terraform 代码中使用 Docker。
⑤ 将 VERSION 环境变量设置为 Bitbucket 构建号
⑥ 确保我们的部署脚本可执行
⑦ 执行我们的部署脚本。这是我们部署管道的核心。
注意列表 7.10 中的第一行如何设置 CD 管道容器的基镜像。每次我们的 CD 管道被触发时,Bitbucket 都会从这个镜像实例化一个新的容器。我们自动可以访问 Terraform 工具,因为它在基镜像中预先安装。如果我们不使用 Terraform,我们可以选择不同的基镜像。查看列表 7.10 中的以下代码行:
export VERSION=$BITBUCKET_BUILD_NUMBER
这是将环境变量传递到我们的部署 shell 脚本的一种方法,在这里我们已设置了应用程序的版本号。我们是从 Bitbucket Pipeline 的BITBUCKET_BUILD_NUMBER环境变量中设置这个版本的,这个变量简单地计算了 CD 管道被触发的次数。这是为 CD 管道内创建的镜像生成版本号的一种方便方法。Bitbucket Pipelines 还提供了许多其他内置环境变量,这些变量可能很有用。有关更多信息,请参阅
confluence.atlassian.com/bitbucket/variables-in-pipelines-794502608.html
7.7.6 配置环境变量
在上一节中,你看到了如何将环境变量输入到我们的部署 shell 脚本中的一个示例。我们还有其他一些尚未提供的环境变量。
我们可以在bitbucket-pipelines.yaml中设置这些变量,就像在 7.10 列表中设置VERSION环境变量一样,但对于我们的 Azure 凭证,我们不应该这样做。这些信息是敏感的,出于安全考虑,我们不希望将这些信息包含在我们的代码仓库中。相反,我们将通过 Bitbucket GUI 将这些变量配置为仓库变量。导航到代码仓库设置选项下的“仓库变量”页面(如图 7.7 所示)。

图 7.7 导航到代码仓库的仓库变量
现在创建仓库变量,并按照图 7.8 所示输入它们的值。你可以自由地选择“受保护”选项,为这些敏感值提供额外的安全层。这些值作为环境变量传递到我们的 CD 管道中。
在创建这些变量时,你必须输入你自己的 Azure 账户和 Azure 服务主体的身份验证凭据。ARM_CLIENT_ID和ARM_CLIENT_SECRET的值是你创建服务主体时从appId和password字段中获取的。ARM_TENANT_ID和ARM_SUBSCRIPTION_ID的值是你 Azure 账户的详情,你可以使用az account show命令查看这些详情,就像我们在 6.11.2 节中做的那样。
这些身份验证详情必须设置在某处。如果你还记得,在 6.6.2 节中,我们曾在我们的开发工作站上使用az login命令来与 Azure 进行身份验证。这个命令在我们的 CD 管道中不起作用,因为不可能有人工交互。为了允许 Terraform 使用 Azure 身份验证来构建和更新我们的基础设施,我们必须将这些身份验证详情传递到我们的管道中。

图 7.8 创建仓库变量以指定 Azure 身份验证凭据
7.7.7 测试你的部署管道
好的,我们终于准备好测试我们的 CD 管道了!我们已经创建了一个 Bitbucket 代码仓库,并将 example-4 代码的副本推送到它。我们已经为该仓库启用了并配置了 Bitbucket Pipelines,我们有一个 CD 管道;我们只需要触发它!
我们可以通过简单地推送代码来触发管道。你可能已经有了一个未推送的代码更改准备就绪。如果你一直跟着做,你已经在 7.7.4 节中更早的时候提交了 backend.tf 的更改。现在就推这些更改吧:
git push
如果你之前推送了这些代码更改,那并不是什么大问题。现在进行一个简单的更改(例如,在某个代码文件中添加一个空行)然后提交并推送到触发 CD 管道。在测试和调试你的 CD 管道时,你可以重复这个过程任意多次。
你可以在 Bitbucket 仓库的管道页面下监控管道调用(如图 7.9 所示)。你在这个例子中可以看到,我的第一次管道调用失败了,而第二次调用刚刚开始(用“挂起”一词表示)。

图 7.9 从管道页面监控管道调用
你可以深入到任何管道调用中,以了解发生了什么。当然,你将来需要这样做,以便在它失败后找出出了什么问题。
例如,我点击进入我的第二次调用(成功完成的那个)。你可以在图 7.10 中看到它的样子。你会注意到,部署 shell 脚本的完整输出在这里显示。现在你应该很熟悉了,因为它与我们之前在本地测试 Terraform 代码时看到的相同输出。

图 7.10 查看成功的 CD 管道调用
7.7.8 调试你的部署管道
现在我们有一个 CD 管道。改变我们的基础设施和部署我们的应用程序现在与改变我们的代码同义:我们称之为*基础设施即代码**。
你现在可能有一个新的问题:我如何调试我的部署管道?你可能在我展示的图 7.9 中的失败管道后对此感到好奇。或者,可能是因为你可能已经有过自己的失败管道!
第一步是在你自己的开发工作站上彻底测试你部署代码的任何更改。然后,我们可以在我们的部署代码到达 CD 管道之前捕捉到许多问题。
当你遇到失败的管道时,你需要深入调查它,阅读输出,并尝试理解问题。理解问题是难点;一旦我们知道问题是什么,修复问题通常很容易。
你可以在图 7.11 中看到一个问题的例子,当我深入挖掘以找出我的第一个管道调用失败的原因时。我阅读了这里的错误信息,并意识到它失败是因为我没有提供所需的环境变量。我在配置存储库变量之前调用了这个管道,正如我们在 7.7.6 节中所做的那样。这是一个容易犯的错误,如果你在推动我们在 7.7.4 节中做出的更改时过于冲动,你可能已经犯了这个错误。

图 7.11 查看失败的 CD 管道和读取错误信息
当调试你的管道时,你应该注意你可以使用任何常见的命令来帮助你。例如cd、ls、pwd和cat等命令可以用来在 CD 管道的容器内导航和检查文件系统。这为你提供了多种理解那里发生的事情的方法。希望这个虽小但非常有价值的小贴士能帮助你未来的 CD 管道调试!
7.7.9 我们取得了什么成果?
你刚刚创建了你的第一个 CD 管道,并使用它来部署生产基础设施和容器。这是真正的进步!
有许多 CD 服务,但我们使用了 Atlassian 的 Bitbucket Pipelines。我们在 Bitbucket 上创建了一个存储库,并从 example-4 导入代码。然后我们创建了一个 shell 脚本来封装我们的部署。
虽然 CD 有很多供应商,但构建 CD 管道通常不会比调用一个 shell 脚本多多少。这很有用,因为 shell 脚本是我们可以在尝试在我们的 CD 管道中运行它之前在本地测试的东西。我们创建的部署 shell 脚本很简单。这是因为我们的整个部署过程都在 Terraform 代码中,所以我们只是调用了 Terraform 来创建我们的基础设施并部署我们的容器。
7.8 继续学习
在本章中,我们将我们在上一章中创建的 Kubernetes 集群部署了我们的初出茅庐的微服务应用程序。在未来的章节中,我们将继续完善 FlixTube 应用程序,使其成为一个完整的应用程序。
总是会有比我们能在这里涵盖的更多的东西要学习。随着我们深入开发,你无疑在工作过程中需要进一步深入。以下是一些将帮助你做到这一点的书籍:
-
《核心 Kubernetes》 by Jay Vyas and Chris Love (Manning, 预计 2021 年夏季)
-
《Kubernetes 实战》 by Marko Lukša (Manning, 2017)
-
《Terraform 实战》 by Scott Winkler (Manning, 预计 2021 年春季)
-
《GitOps 和 Kubernetes》 by Billy Yuen, Alexander Matyushentsev, et. al. (Manning, 预计 2021 年春季)
-
《Kubernetes 快速入门》 by William Denniss (Manning, 预计 2021 年夏季)
-
《一个月午餐学会 Kubernetes》 by Elton Stoneman (Manning, 预计 2021 年 2 月)
要了解 Atlassian Bitbucket,请访问 Bitbucket 网站:
在以下位置查看 Bitbucket 概述:
要了解更多关于使用 Bitbucket Pipelines 的 CD 信息,请参阅
在 Kubernetes 文档中了解更多关于 Kubernetes 概念的信息
摘要
-
持续交付(CD)是一种技术,其中生产基础设施和软件在代码更改时持续更新。
-
我们使用 Terraform 脚本化配置和部署多个容器到我们的 Kubernetes 集群。
-
我们部署了一个 MongoDB 数据库服务器和一个 RabbitMQ 服务器,供我们的微服务使用。
-
我们使用 Terraform 构建和发布了我们的第一个微服务(视频流微服务)的 Docker 镜像,然后将其部署到我们的 Kubernetes 集群。
-
我们创建了一个 shell 脚本来封装我们的部署管道。
-
我们将代码移至私有 Bitbucket 代码仓库,并配置 Bitbucket Pipelines 为我们的应用程序创建 CD 管道。
-
我们的 Bitbucket Pipelines 文件很简单;它只调用了我们的部署 shell 脚本。
-
拥有 shell 脚本很重要,因为它允许我们在开发工作站上测试和调试我们的部署管道。在 CD 管道中进行测试和调试更困难。
-
我们学习了如何使用 Bitbucket 界面将环境变量配置为部署管道的输入。
-
我们学习了如何使用 Azure 存储持久化 Terraform 状态。
8 微服务的自动化测试
本章涵盖
-
微服务的自动化测试
-
使用 Jest 进行单元测试和集成测试
-
使用 Cypress 进行端到端测试
-
将自动化测试添加到你的持续交付管道中
到目前为止,在构建微服务的过程中,我们一直是手动测试我们的代码。然而,在这一章中,我们将提高一个档次,学习如何将自动化测试应用到我们的微服务中。
到目前为止,我们主要是通过运行我们的代码并视觉检查输出来进行测试。在各个章节中,我们使用了我们的网络浏览器、命令行输出或本地文件系统的变化来检查代码的结果。在其他章节中,我们使用了更具体的工具,如第三章中的 Robo3T 或第五章中的 RabbitMQ 仪表板。
手动测试的方法多种多样。我想让你知道手动测试是完全可以接受的,并且是完全有效的。你应该从手动测试开始,并继续进行,直到你足够熟悉,可以使用自动化测试,并且你的产品足够了解,值得进行这样的投资。我可以推荐一些手动测试工具,比如 Postman 或 Visual Studio Code 的 REST 客户端。这些工具将帮助你手动测试你的 REST API。
然而,在某个时候,手动测试会变得繁琐且耗时。你将想要转向自动化测试。当然,自动化测试在软件开发领域通常是很有用的,但在微服务中,随着我们应用的增长,它变得至关重要。对于小型团队来说,这也同样重要,因为在某个时候,手动测试的负担会变得如此沉重,以至于你将只会进行测试。当有如此容易获取的优秀测试工具时,你没有理由承担沉重的测试负担!
将本章视为一次针对微服务测试领域的导游。我们将从测试的介绍开始,然后我们将查看单元测试、集成测试和端到端测试的更高级示例。
自动化测试是一个高级主题。我把它包含在这本书中,因为我相信它对于微服务的扩展确实是必不可少的。如果你之前没有进行过自动化测试,你可能会觉得这一章有点令人不知所措。希望不是这样,但如果你觉得难以理解,请随意跳过这一章,稍后再回来。只需知道自动化测试很重要,尽管在早期你可能不需要它,但最终你肯定会需要它。
8.1 新工具
作为现代开发者,我们被丰富的免费、易于获取且易于学习的测试工具所宠溺。在本章中,我们将学习使用两个流行且重要的测试工具进行自动化测试。我们将使用 Jest 和 Cypress 来测试我们的微服务,以确保它们的健壮性。
Jest 是一个用于测试 JavaScript 代码的工具;Cypress 是我们将用于端到端测试的工具。Jest 和 Cypress 都是用 JavaScript 编写的。如果你用不同于 JavaScript 的语言构建微服务,那么你可能不会选择 Jest。相反,你会选择最适合你特定语言的测试工具。
Cypress 是一个无论你使用什么后端语言都非常适合测试网页的出色工具。如果你不使用 JavaScript 作为你的微服务语言,Cypress 仍然是一个很好的端到端测试选择。
在本章的末尾,我们将学习如何将自动化测试添加到我们在上一章中开始的持续交付 (CD) 管道中。这意味着我们的测试将在我们将代码更改推送到托管代码仓库时自动调用。这很重要,因为它使得测试过程成为生产前的检查点。有问题的代码或失败的测试将自动停止部署,并在这些问题自动检测到时提醒我们。
表 8.1 第八章中的新工具
| 工具 | 版本 | 目的 |
|---|---|---|
| Jest | 26.2.2 | Jest 是一个用于自动化测试 JavaScript 代码的工具。 |
| Cypress | 4.12.1 | Cypress 是一个用于网页自动化测试的工具。 |
8.2 获取代码
要跟随本章内容,你需要下载代码或克隆仓库。
-
从这里下载代码的 zip 文件:
-
你可以使用 Git 如下克隆代码:
git clone https://github.com/bootstrapping-microservices/chapter-8.git
关于安装和使用 Git 的帮助,请参阅第二章。如果你遇到代码问题,请在 GitHub 仓库中记录一个问题。
8.3 微服务测试
就像我们编写的任何代码一样,微服务需要经过良好的测试,这样我们才能知道代码是健壮的、难以破坏的,并且可以优雅地处理问题。测试让我们对我们的代码在正常和意外情况下都能正常工作感到安心。
有效的测试尽可能模拟生产环境。这包括环境、代码配置以及我们使用的测试数据。使用 Docker 和 Docker Compose 允许我们配置测试环境,使其类似于生产环境。
这使得“在我的电脑上运行正常”这个理由在现代开发中对于有问题的代码来说变得不那么有用。通常,当你在正确配置的 Docker 环境中运行时,你可以相当确信它将在生产环境中运行。为我们的代码提供一个稳定的运行环境是可靠测试的关键因素。
手动测试是一个好的起点,并且是一项值得培养的技能。但到了某个阶段,自动化测试对于扩展我们的应用程序是必要的。随着微服务数量的增长,我们将越来越多地依赖自动化来保持应用程序的运行并帮助我们保持快速的开发节奏。在前一章中,我们创建了我们的持续交付(CD)管道来自动化部署。现在,让我们将注意力转向将自动化测试上线。
8.4 自动化测试
简单来说,自动化测试是代码驱动测试。我们编写代码来执行我们的代码并验证其正确性。这听起来像是循环逻辑,但我们在一次迭代后就打破了它。我们拥有应用程序代码或被测试的代码,然后我们拥有测试代码(或只是测试)。
通常,测试代码直接调用被测试的代码,但它也可以通过 HTTP 请求或 RabbitMQ 消息间接调用。然后,测试代码验证结果是否正确,无论是通过检查输出还是检查行为。
在本章中,你将学习一些自动化测试技术。你将能够反复应用这些技术,为你的应用程序创建一个全面的测试套件。
微服务测试可以在多个级别上应用。我们可以测试单个函数,可以测试整个微服务,可以一起测试微服务组,或者可以测试整个应用程序(直到应用程序变得太大;关于这一点稍后会有更多介绍)。这些测试级别与以下三种类型的自动化测试相关:
-
单元测试 —测试隔离的代码和单个函数
-
集成测试 —测试整个微服务
-
端到端测试 —测试微服务组以及/或整个应用程序(包括前端)
你可能之前听说过这些测试类型。如果没有,不要担心,因为我们将逐一查看每种。
图 8.1 显示了一个被称为测试金字塔的图表。它将自动化测试类型相互关联,并给你一个关于在你的测试套件中应该有多少每种类型测试的想法。

图 8.1 测试金字塔显示了我们应该拥有的每种类型测试的相对数量。
单元测试运行速度快,因此你可以拥有很多这样的测试。因此,它们是测试金字塔的基础。集成测试和端到端测试位于金字塔的更高位置。这些类型的测试运行速度较慢,因此你无法拥有那么多。(随着我们向上移动金字塔,减少的区域表示我们将越来越少地使用这些类型的测试。)这意味着我们应该比单元测试更少地拥有集成测试,比集成测试更少地拥有端到端测试。
图 8.2 展示了 FlixTube 简化版本的端到端测试的样子。在那张图中,我首先展示了端到端测试,因为它是最像手动测试的测试类型;也就是说,我们以类似于客户使用它的方式测试整个应用程序。
端到端测试是最容易理解的测试类型,尽管实际上它相当复杂,我们直到本章快结束时才能触及它。端到端测试与手动测试最为接近,因为我们必须加载整个应用程序来测试它,就像我们手动测试时做的那样。图 8.2 显示了针对在 Docker Compose 上运行的我们整个应用程序的简化版本运行 Cypress 测试。

图 8.2 使用 Cypress 对 FlixTube 简化版本进行端到端测试
自动化测试与持续交付(CD)结合就像是一个早期预警系统。当警报响起时,我们可以感到庆幸,因为它给了我们停止问题进入生产并可能影响我们的客户的机会。自动化测试(就像自动化部署一样)最好在项目早期开始,因为试图将自动化测试附加到一个遗留应用程序(一个未设计为可测试的应用程序)可能非常困难。
但不要在开发生命周期的早期就开始自动化测试。这是一个平衡行为。当开始一个新产品时,你应该首先从原型设计阶段开始,然后再添加自动化测试。原型设计允许你在做出承诺之前对你的产品进行实验。如果你还不确定你的产品是什么(例如,你仍在实验)或者如果你仍在尝试验证你的商业模式,那么你可能想要推迟自动化测试,并继续进行更长时间的手动测试。
为测试构建基础设施是对你产品的重大投资。为了本章的目的,让我们假设我们已经准备好为 FlixTube 做出自动化测试的承诺。
注意:自动化测试的真正回报是它能让你摆脱无数小时的常规测试,更不用说它还能阻止可能已经进入生产并造成混乱的损坏代码的部署。
尽管自动化测试非常神奇,但它并不是万能的!它不能替代由真实人类进行的良好探索性测试(例如,手动测试)。这仍然需要发生,因为这是发现开发团队甚至无法想象的错误唯一的方式。
自动化测试不仅仅是证明你的代码能正常工作。它还作为一个无价的沟通工具,一种可执行文档,展示了代码应该如何使用。它还为你提供了一个安全的框架,可以在其中重构和重构你的应用程序。这让你可以持续地向更简单、更优雅的架构迈进。现在,让我们逐一分析每种测试类型,并查看应用于元数据微服务和 FlixTube 应用程序的测试示例。
8.5 使用 Jest 进行测试
测试是一个非常大的主题,所以让我们先看看一些简单的例子,这些例子与微服务没有直接关系。在本节中,我们将查看的代码通常适用于测试 JavaScript 代码,无论这些代码是在前端、后端,还是在移动或桌面应用程序中。
如果你已经能够使用 Jest 编写自动化测试,并且理解模拟,那么你可以自由地跳过本节,直接进入 8.6 节。在那个部分,我们将开始将自动化测试与微服务联系起来。
对于本节,假设我们正在为我们的微服务创建一个 JavaScript 数学库。我们将使用 Jest 进行测试。那是一个 JavaScript 测试工具和框架。图 8.3 给出了我们将如何使用它的想法。

图 8.3 使用 Jest 进行自动化测试
在图中,左侧是 math.test.js。这是一个包含我们将对其运行测试的测试的文件。右侧是 math.js。这是一个包含我们数学库代码的文件。当我们运行 Jest 时,它会加载我们的测试代码,然后反过来运行我们正在测试的代码。从我们的测试中,我们可以直接调用我们的代码来测试它,并在结果中验证一切是否如预期进行。
8.5.1 为什么选择 Jest?
Jest 可以说是最流行的 JavaScript 测试工具和框架。它易于设置,配置简单,非常适合初学者。它运行速度快,可以并行运行测试。Jest 还提供了出色的实时重新加载支持;你可以在监视模式下运行它,在你编码时它会自动重新加载。
Jest 是由 Facebook 创建的,所以你知道它背后有强大的支持。但它也有大量的追随者和许多来自 Facebook 以外的贡献者。API 非常全面,支持多种测试风格,并提供了多种验证测试和创建模拟的方法。Jest 在创建模拟对象方面也有出色的支持。
本章中还有一些其他非常棒的功能,我们甚至不会在这里提及。(在章节末尾,你可以找到一个链接,了解更多关于 Jest 的信息。)Jest 是一个开源且免费使用的工具。你可以在以下链接找到代码:
8.5.2 设置 Jest
我们将首先查看第八章代码仓库中的 example-1。这个例子足够小,如果你愿意,可以直接输入。如果你不想这样做,你可以从 GitHub 获取代码来跟随学习。
你可以亲自运行这些测试,并对它们进行修改以查看会发生什么。Example-1 已经在 package.json 中包含了 Jest,所以我们将简单地安装项目的依赖项:
cd chapter-8/example-1
npm install
你可以这样将 Jest 安装到新的 Node.js 项目中:
npm install --save-dev jest
我们使用 --save-dev 参数将 Jest 保存为 package.json 中的开发依赖。Jest 是我们只在开发或测试环境中使用的东西,所以我们将其保存为开发依赖,以便从我们的生产环境中排除。
如果你查看 package.json 文件,你会看到我已经安装了 Jest 版本 26.2.2。当你将来安装 Jest 时,你会看到更新的版本。这里学到的许多内容仍然有效,因为 Jest 是稳定的(它已经更新到 26 版本了!)
以下列表显示了 example-1 的 Jest 配置。这实际上是 Jest 生成的默认配置。我没有做任何修改,除了移除注释。
列表 8.1 Jest 的配置文件(第八章/example-1/jest.config.js)
module.exports = {
clearMocks: true, ①
testEnvironment: "node", ②
};
① 自动在每次测试之间清除模拟(我很快会解释模拟)
② 这是测试 Node.js 的环境。
当开始一个新项目时,创建你自己的 Jest 配置文件如下:
npx jest --init
当你初始化 Jest 配置时,它会问你几个问题。如果你接受所有默认设置,那么你的配置文件将类似于列表 8.1。我只将 clearMocks 改为 true(默认为 false),以帮助防止测试相互干扰。
只是为了提醒你,npx 是 Node.js 中的一个命令,它允许我们以命令行应用程序的方式运行 npm 模块。有许多可安装的 npm 模块以这种方式工作,包括 Jest。你可能还记得我们在第五章中使用 npx 的 wait-port 命令。
当你生成如列表所示的配置文件时,你会看到它包含许多被注释掉的选项。阅读生成的配置文件是了解 Jest 所能实现功能的好方法。因为在这个例子中并不需要,所以我移除了注释以获得一个最小化的配置。
图 8.4 展示了安装了 Jest 的 example-1 Node.js 项目的结构。你可以看到熟悉的 package.json 和 package-lock.json 文件,这些文件在我们第二章中学到的每个 Node.js 项目中都有。至于 Jest,请注意,该项目包含 Jest 配置文件(内容如列表 8.1 所示)以及我们的代码和测试文件。我们的数学库代码在 math.js 中,测试代码在 math.test.js 中。与任何其他 npm 模块一样,Jest 本身安装在 node_modules 目录下。

图 8.4 安装了 Jest 的相当典型的 Node.js 项目的结构
注意测试文件是以它所测试的代码命名的。在创建 math.test.js 时,我们只是简单地将.test.js 添加到我们库的名称中。这种命名约定是 Jest 定位我们的测试代码的方式。Jest 自动加载名称中包含.test的代码。这是 Jest 的默认约定,但如果我们想有不同的约定,我们可以进行配置。
注意测试文件(math.test.js)是如何紧挨着代码文件(math.js)放在同一目录下的。这是另一个约定,而且相当常见。我们本可以将这两个文件放在项目目录结构中的任何地方,这不会有多大区别。另一个常见的约定是将所有测试与应用程序代码分离,并位于紧挨着或位于 src 子目录下的 test 或 tests 子目录中。
你可能已经注意到,Jest 配置文件实际上是一个 JavaScript 文件本身。这意味着你可以在配置中使用 JavaScript 代码。实际上,JavaScript 和 Node.js 工具拥有可执行配置文件是很常见的,我认为 JavaScript 可以用作其自身的配置语言是非常酷的。
8.5.3 要测试的数学库
现在想象一下,我们已经向我们的新数学库添加了第一个函数。下面的列表显示了square函数。这是一个简单的函数,它接受一个数字并返回该数字的平方。
列表 8.2 我们新数学库的起点(chapter-8/example-1/src/math.js)
function square(n) { ①
return n * n; ①
} ①
... ②
module.exports = {
square, ③
... ④
};
① 一个简单的 JavaScript 函数计算一个数字的平方。这是我们将要测试的代码。
② 你可以在开发过程中在此处为你的数学库添加更多函数。
③ 导出“square”函数,以便我们可以在代码模块中使用它。这也是我们从测试代码中访问它的方式。
④ 随着你将更多函数添加到你的数学库中,这里将导出其他函数。
在未来,我们将向 math.js 添加更多函数。但到目前为止,我们会保持它简短,这样它就可以作为一个简单的自动化测试演示。
8.5.4 你的第一个 Jest 测试
square函数是一个简单的函数,具有简单的结果,而更复杂的函数总是依赖于像这样的简单函数。为了确保复杂函数正常工作,我们必须首先测试简单函数。是的,尽管这个函数很简单,我们仍然想测试它。
当然,这是 JavaScript。我们可以很容易地使用 Node.js REPL 手动测试这个函数。但将其纳入自动化测试中几乎同样容易,这(结合对许多其他函数的许多其他测试)可以在未来为我们节省时间。不用说,我在这里演示测试,所以仅为此目的,让我们编写我们的第一个自动化测试。
列表 8.3 显示了测试我们初生的数学库的代码。describe函数定义了一个名为square函数的测试套件。test函数定义了我们第一个测试,名为can square two。
列表 8.3 使用 Jest 的第一个测试(chapter-8/example-1/src/math.test.js)
const { square } = require("./math"); ①
describe("square function", () => { ②
test("can square two", () => { ③
const result = square(2); ④
expect(result).toBe(4); ⑤
}); ③
}); ②
① 导入我们正在测试的代码
② 创建一个名为“square function”的测试套件
③ 创建一个名为“can square two”的测试
④ 调用“square”函数并捕获结果
⑤ 设置一个期望,结果将是 4。如果期望没有得到满足,测试将失败。
我们将这个测试套件命名为它所测试的函数。你可以想象在将来,我们可能会在这个文件中为我们的数学库中的其他函数有其他测试套件(你很快就会看到更多这方面的示例)。
在列表 8.3 中,我们从 math.js 文件中导入了我们的 square 函数。然后,在我们的测试 can square two 中,我们用数字 2 作为输入调用它。你可以看到测试的名称暗示了测试实际上在做什么。
注意:一个好的测试名称可以让你立即了解正在测试的内容。
我们随后使用 expect 和 toBe 函数来验证 square 函数的结果是否为数字 4。可以将各种函数组合链接到 expect 函数上(更多示例请参阅 Jest 文档jestjs.io/docs/en/expect,它提供了丰富的语法来描述正在测试的代码的预期输出)。
8.5.5 运行你的第一个测试
现在我们已经查看要测试的代码和测试本身,我们准备运行 Jest,看看成功的测试运行看起来像什么(相信我,我已经知道这段代码是有效的)。在 example-1 目录的终端中,按照以下方式运行测试:
npx jest
你可以在图 8.5 中看到成功测试运行的输出。我们有一个测试和一个测试套件,两者都成功完成。

图 8.5 使用 Jest 成功测试运行的输出
8.5.6 使用 Jest 进行实时重新加载
实时重新加载对于开发者生产力至关重要,尤其是在测试时。在编码和编写测试时,你可以按照以下方式以实时重新加载模式运行 Jest:
npx jest --watchAll
该命令适用于所有项目,在代码更改时运行所有测试。如果你使用 Git,你还可以使用此命令:
npx jest --watch
第二个版本性能更好,因为它使用 Git 来知道哪些文件已更改(而不是盲目地运行所有测试)。这是一种很好的工作方式。更改一些代码,测试就会自动运行,并显示是否有什么被破坏了!
8.5.7 解释测试失败
当我们的测试通过时,一切都很顺利,但如果我们代码中出现问题,测试失败时怎么办?不要等到你意外破坏了代码才知道!
让我们试试看。这就像改变我们代码的行为一样简单。例如,尝试将 square 函数更改为返回错误的结果:
function square(n) {
return n & n;
}
注意我如何将乘法运算符替换为二进制 AND 运算符。让我们看看我们的测试对此有何看法。
你可以在图 8.6 中看到现在失败的测试输出。当一个测试失败时,Jest 会以非零退出码结束。这表明发生了失败。我们将在我们的 CD 管道中利用这一点,以防止在测试失败的情况下进行部署。

图 8.6 Jest 中失败的测试输出
这个测试失败是因为我们更改了代码的预期行为。我们故意破坏了自己的代码以查看结果,但你也可以想象在我们的常规开发过程中一个简单的打字错误是如何在生产代码中引起这个问题的。如果你没有设置自动测试,这个问题很容易在手动测试中遗漏,后来被客户发现。这至少是尴尬的,但根据实际错误的性质,它可能会对我们的业务造成真正的问题。
当然,这里的意图不仅仅是测试square函数。仅此本身不会有效。我们需要的是让大量代码被这样的测试覆盖。
一大批测试为我们提供了一个自动验证系统,我们可以运行它来毫无疑问地证明我们的代码按预期工作。更重要的是,它证明我们的代码在未来的演变中仍然按预期工作。值得注意的是,你可以在任何你想要的地方通过抛出异常来模拟失败的代码,如下所示:
throw new Error("This shouldn't happen.");
面对错误时无所畏惧的最佳方式是残忍地尝试在自己的代码中引发这些错误。一旦你看到了所有的错误,恐惧就会消失,你可以专注于理解和解决问题。为了确保我们的应用程序能够优雅地处理问题,在代码中模拟或引发问题被称为混沌工程(有关更多信息的参考,请查看第十章末尾)。
8.5.8 使用 npm 调用 Jest
在第二章中,我们介绍了向 package.json 文件添加 npm 脚本的思路,以便我们可以使用常规的 npm 命令,如npm start. 在第二章中,我们还配置了 start 脚本。这里我们也将为 test 脚本做同样的事情。一旦我们为 package.json 配置了这些,我们就可以通过输入以下命令来运行我们的测试套件:
npm test
这个约定意味着我们可以轻松地为任何 Node.js 项目运行测试。我们不需要知道项目是否正在使用 Jest 或其他测试工具!实际上,你将在本章后面看到我们如何使用相同的命令运行 Cypress 测试。以下列表显示了我们的 package.json 文件,其中包含一个用于运行 Jest 测试的测试脚本。
列表 8.4 包含运行 Jest 的 npm 脚本的 package.json(chapter-8/example-1/package.json)
{
"name": "example-1",
"version": "1.0.0",
"scripts": {
"test": "jest", ①
"test:watch": "jest --watchAll" ②
},
"devDependencies": {
"jest": "²⁵.4.0" ③
},
"dependencies": {
④
}
}
① 通过调用“npm test”运行 Jest 的设置
② 为在实时重新加载模式下运行 Jest 进行设置
③ 将 Jest 作为开发依赖项安装
④ 这个项目目前还没有任何生产依赖。
注意,在列表 8.4 中,还有一个名为 test:watch 的 npm 脚本。它被配置为我们可以以这种方式运行我们的测试,在实时重新加载模式下:
npm run test:watch
测试:watch 脚本是我的个人约定——它不是 npm 标准。我使用它,以便无论我使用哪种测试工具,我都能轻松记住如何启用实时重载来运行我的测试。
8.5.9 填充您的测试套件
到目前为止,我们只看到了一个测试,但我也想给你展示一下,随着测试套件的扩展,它看起来会是什么样子。列表 8.5 展示了在添加第二个测试后 math.test.js 的样子。(示例-1 实际上不包含这个新测试,但你可以自由添加它并对其进行实验。)
列表 8.5 添加下一个测试(对 chapter-8/example-1/src/math.test.js 的添加)
const { square } = require("./math");
describe("square function", () => {
test("can square two", () => {
... ①
});
test("can square zero", () => { ②
const result = square(0);
expect(result).toBe(0);
}); ②
... ③
});
... ④
① 为了简洁起见省略了之前的测试
② 创建测试“can square zero”
③ 在您的“square”函数测试套件中添加更多测试
④ 在此处为数学库添加更多测试套件
如列表 8.5 所示,我们可以通过在测试套件的 describe 函数内部添加更多 test 函数的实例来向我们的 square 函数测试套件添加更多测试。
新的测试,can square zero,是一个边缘情况的例子。我们不需要为平方正数添加更多测试;can square two 就足以涵盖所有正数情况,因此我们可以将其重命名为 can square positive number。然后,为了完成对 square 函数的这个小测试套件的补充,你可能还需要添加一个名为 can square negative number 的测试。如果你愿意继续工作,我会把这个留给你。
随着我们开发数学库,我们将添加更多数学函数和更多测试套件。例如,我们将添加 squareRoot 和 average 函数及其测试套件“平方根函数”和“平均函数”。记住,我们命名了测试文件为 math.test.js,这个名字足够通用,我们可以使用 describe 函数向其中添加新的测试套件。
我们也可以为每个测试套件创建单独的 JavaScript 代码文件,例如,square.test.js、square-root.test.js 和 average.test.js。请注意,这些文件都是以 *.test.js 结尾,这样 Jest 可以自动找到它们。随着我们未来添加新的库,我们将添加新的测试文件,数量根据需要而定,以包含我们创建的所有测试。
你可以以任何你想要的方式组织你的测试。这意味着你可以按你喜欢的方式命名它们,并将它们跨文件组织以适应你的需求。然而,当你在公司工作时,你将被期望遵循他们现有的风格和约定。无论你遵循哪种约定,我都代表全世界的开发者请求(请使用 有意义的名称 为你的测试命名。名称应该使测试的目的易于理解。非常感谢。)
8.5.10 使用 Jest 进行模拟
JavaScript 是创建模拟的绝佳语言!JavaScript 的动态特性使得创建自动化测试变得特别容易。但模拟是什么?
模拟的定义是:在我们代码中用虚假或模拟的版本替换真实依赖项。
我们可以替换的依赖项可以是函数、对象,甚至是整个代码模块。在 JavaScript 中,创建函数和组合新的对象和数据结构作为模拟是非常容易的。
我们为什么要这样做?模拟的目的是隔离我们正在测试的代码。隔离特定的代码部分使我们能够只测试那部分代码,而无需测试其他内容。隔离对于单元测试和测试驱动开发非常重要。
模拟不仅有助于隔离我们正在测试的代码,而且还可以完全消除那些会使测试变慢的代码和过程。例如,我们可以消除数据库查询、网络事务和文件系统操作。这些都是与我们要测试的代码相比可能需要花费大量时间的事情。
在第 8.6 节中,我们将学习单元测试,并看到一个真实的模拟示例,但让我们首先通过检查一个简单的例子来理解模拟。假设我们不是在square函数中使用乘法运算符,而是使用以下multiply函数:
function square(n) {
return multiply(n, n);
}
你可能会问,既然已经有了完美的运算符,为什么还要用函数来做乘法呢?这是一个很好的问题。嗯,我在这里引入multiply函数主要是为了提供一个简单的例子来解释模拟(mocking)。但如果你想,我也可以编造一个很好的理由来说明为什么我们需要这个函数!
就让我们说,我们希望我们的数学库能够与抽象数据类型一起工作。而不是使用普通数字,我们希望它能够处理向量(数字数组),在这种情况下,multiply函数可能是一个非常复杂的函数,它可以在图形处理单元(GPU)上并行进行计算。
现在我们需要隔离square函数中的代码(从理论上讲可能并不多),我们需要模拟multiply函数。这意味着我们必须用另一个函数来替换它——一个我们可以控制的函数。我们可以通过一种原始的依赖注入(Dependency Injection,DI)形式来实现这一点。DI 是一种技术,我们将依赖项注入到我们的代码中,而不是硬编码它们。我们控制依赖项是什么,这对于隔离代码进行单元测试很有用。在这种情况下,我们将multiply函数注入到square函数中,如下所示:
function square(n, multiply) {
return multiply(n, n);
}
这之所以可行,是因为在 JavaScript 中,函数是一等公民,它们可以像任何其他值或对象一样传递。现在让我们从我们的测试中利用这一点。当我们调用square函数时,我们将传递我们的模拟版本multiply:
test("can square two", () => {
const mockMultiply = (n1, n2) => { ①
expect(n1).toBe(2); ②
expect(n2).toBe(2); ②
return 4; ③
}; ①
const result = square(2, mockMultiply); ④
expect(result).toBe(4); ⑤
});
① 创建“multiply”函数的模拟版本
② 期望“square”函数将正确的输入传递给“multiply”函数
③ 将模拟函数硬编码为返回 4
④ 将模拟函数传递给“square”函数而不是真实的“multiply”函数
⑤ 期望得到硬编码的值 4
你现在可能想知道,所有这些有什么意义?鉴于我们的模拟函数返回一个硬编码的值 4,我们在这里实际上测试了什么?你可以这样理解:“我们正在测试 square 函数是否以输入 2 和 2 调用 multiply 函数,并且从 multiply 收到的结果是 square 函数返回的值。”
你可能会注意到,我们刚刚实现了 square 函数,对其进行了测试,并证明了它的工作——而 multiply 函数的真实版本甚至还没有存在!这是测试驱动开发(TDD)的超级能力之一。TDD 允许我们可靠地测试代码的不完整版本。如果这还不能让你印象深刻,我不知道还有什么能做到了!
要使此代码真正工作,我们仍然需要实现 multiply 函数。这反过来又可以对其应用自动化测试。
好吧,这是一个疯狂编造的例子,但我们需要一种方法来介绍模拟的概念。像我现在展示的那样,在如此细粒度级别实现依赖注入(DI)是非常罕见的。不过,很快你将看到一个更现实的例子,它使用模拟替换了整个代码模块。
8.5.11 我们取得了什么成果?
我们已经看到了使用 Jest 进行测试的简单示例以及如何使用模拟来隔离我们正在测试的代码。让我们以使用 Jest 进行测试的一般方法来结束本节。
使用 Jest 创建测试
-
为你正在测试的代码创建一个文件(例如,math.js)。
-
为你的测试创建一个与 Jest 命名约定匹配的文件(例如,math.test.js)。
-
将函数和代码模块导入测试文件中。
-
将整个测试套件包裹在
describe函数的调用中,并为其提供一个描述性的名称。 -
使用对
test函数的调用添加每个测试,并为每个测试提供一个描述性的名称。 -
使用
npx jest或npm test(如果已在 package.json 中配置)运行你的测试。
测试套件的模板
... ②
describe("the name the test suite", () => { ③
... ④
test("test 1", () => { ⑤
... ⑥
});
... ⑦
test("test N", () => { ⑤
... ⑥
});
});
... ⑧
① 导入函数和代码模块放在这里。
② 在此文件中测试之间共享的全局模拟代码放在这里。
③ 调用定义测试套件的“describe”函数放在这里。
④ 在此测试套件中测试之间共享的模拟代码放在这里。
⑤ 调用定义每个测试的“test”函数
⑥ 测试代码放在这里。
⑦ 可以在这里添加更多测试。
⑧ 可以在这里添加更多测试套件。
测试的模板
test("the name of the test", () => { ①
... ②
});
① 调用定义每个测试的“test”函数。为每个测试使用一个有意义的名称!
② 仅在此测试中使用的模拟代码放在这里。
③ 调用要测试的函数并记录任何结果
④ 检查结果和模拟并声明测试的期望
8.6 单元测试
微服务单元测试与其他类型的单元测试工作方式相同。我们的目标是独立于其他代码,单独测试单个 单元 代码。什么是单元?通常,每个测试都会对一个函数或单个函数的一个方面进行测试。
单元测试中重要的是隔离。当我们测试隔离的代码时,我们集中测试精力在那一小块代码上。例如,我们希望测试我们的元数据微服务的代码,但我们不关心测试 Express 库或 MongoDB 库的代码。那些是我们假设已经测试过的依赖项。相反,我们只想测试我们创建的代码。为了专注于我们自己的代码,我们必须消除所有其他代码。
通过模拟依赖项来实现代码的隔离。对于我们元数据微服务来说,这意味着我们将用我们可以控制和操纵的假实例替换真实的 Express 和 MongoDB 库。
隔离是使单元测试运行快速的原因。集成和端到端测试不隔离代码。在那些类型的测试中,我们锻炼的是代码模块的集成,而不是隔离的代码片段。
当运行单元测试时,我们不会启动真实的 HTTP 服务器或连接到真实的数据库。这类事情使得单元测试运行得很快,这也是为什么这些是测试金字塔(图 8.1)的基础。我们可以承担有成百上千个单元测试,而且我们不需要等待很长时间才能完成我们的单元测试套件。
我们将使用 Jest 来执行我们的单元测试。图 8.7 显示了我们将如何使用它。我们的测试代码来自 index.test.js(在左侧),由 Jest 加载。我们要测试的代码,即我们的元数据微服务的代码(在右侧),由我们的测试代码加载。
我们将模拟 Express 和 MongoDB 而不是使用真实的东西。测试代码“启动”我们的微服务。我之所以说“启动”,是因为我们不会以通常的方式启动它。与正常执行不同,Express 被模拟,所以我们不会启动一个真实的 HTTP 服务器。同样,MongoDB 也被模拟,所以我们不会连接到真实的数据库。

图 8.7 使用 Jest 对元数据微服务进行单元测试
8.6.1 元数据微服务
现在,我们将转向第八章代码库中的 example-2。要跟上,你需要安装依赖项:
cd chapter-8/example2
npm install
列表 8.6 展示了我们将要测试的代码。这是一个初生的微服务,它将成为 FlixTube 的元数据微服务。这是一个 REST API,其目的是收集、存储、搜索和管理与每个视频相关的元数据。列表中的基本设置与第二章中的第一个微服务并没有太大的不同。
列表 8.6 单元测试的元数据微服务(第八章/example-2/src/index.js)
const express = require("express");
const mongodb = require("mongodb");
function connectDb(dbhost, dbname) {
return mongodb.MongoClient.connect(dbhost, {
useUnifiedTopology: true
})
.then(client => {
const db = client.db(dbname);
return {
db: db,
close: () => {
return client.close();
},
};
});
}
function setupHandlers(microservice) {
const videosCollection = microservice.db.collection("videos");
microservice.app.get("/videos", (req, res) => { ①
return videosCollection.find() ②
.toArray()
.then(videos => {
res.json({ ③
videos: videos
});
})
.catch(err => {
... error reporting omitted ...
res.sendStatus(500);
});
});
... ④
}
function startHttpServer(dbConn) { ⑤
return new Promise(resolve => { ⑥
const app = express();
const microservice = { ⑦
app: app,
db: dbConn.db,
}
setupHandlers(microservice);
const port = process.env
➥ .PORT && parseInt(process.env.PORT) || 3000;
const server = app.listen(port, () => {
microservice.close = () => { ⑧
return new Promise(resolve => {
server.close(() => { ⑨
resolve();
});
})
.then(() => {
return dbConn.close(); ⑩
});
};
resolve(microservice);
});
});
}
function startMicroservice(dbhost, dbname) { ⑪
return connectDb(dbhost, dbname)
.then(dbConn => {
return startHttpServer(dbConn);
});
}
function main() { ⑫
... error checking for environment variables omitted ...
const DBNAME = process.env.DBNAME;
return startMicroservice(DBHOST, DBNAME);
}
if (require.main === module) { ⑬
main() ⑭
.then(() => console.log("Microservice online."))
.catch(err => {
console.error("Microservice failed to start.");
console.error(err && err.stack || err);
});
}
else { ⑮
module.exports = { ⑯
startMicroservice,
};
}
① 处理对/videos 路由的请求
② 返回一个 promise,以便我们可以在测试中等待结果
③ 从数据库检索记录
④ 其他处理程序可以稍后添加到这里。
⑤ 启动 Express HTTP 服务器
⑥ 使用 promise 包装,以便我们可以在服务器启动时得到通知
⑦ 创建一个代表我们的微服务的对象
⑧ 创建一个可以用来关闭微服务的函数
⑨ 关闭 Express 服务器
⑩ 关闭数据库
⑪ 一个新的辅助函数,用于收集微服务启动时执行的代码
⑫ 微服务的主要入口点
⑬ 如果此脚本是主模块,则正常启动微服务
⑭ 启动微服务
⑮ 否则,在测试中运行微服务
⑯ 导出启动微服务的函数,以便我们可以在测试中调用它
列表 8.6 使用 Express 库启动 HTTP 服务器,并使用 MongoDB 库连接到 MongoDB 数据库。我们为 HTTP GET /videos 路由添加了一个单独的处理函数。此路由从数据库检索视频元数据数组。
我们在这里测试的代码将通过调用函数startMicroservice来执行。这是我们添加到微服务中的一个新函数,以帮助使其更容易进行测试。调用startMicroservice返回一个表示微服务的 JavaScript 对象。我们目前还没有存储返回的对象。我们不需要它来进行单元测试,但当我们进行集成测试时,我们将需要它。
我们对微服务的结构进行了此更改,旨在为测试而设计,我们经常会发现自己这样做,调整代码以便更容易进行测试。请注意,我们不仅限于调用startMicroservice。实际上,我们可以从我们的任何代码模块中调用任何导出的函数。记住这一点,因为这就是单元测试真正关注的内容:单独测试每个函数。现在,让我们创建一些测试来确认我们的微服务已启动,并且/video 路由可以检索到预期的数据。
8.6.2 使用 Jest 创建单元测试
在我们可以对代码进行单元测试之前,我们需要能够为依赖项创建模拟。对于此示例,我们的依赖项是 Express 和 MongoDB。在其他情况下,你将会有不同的依赖项,例如用于与 RabbitMQ 交互的 amqp 库。
列表 8.7 显示了测试的代码。此文件定义了一个名为metadata microservice的单个测试套件,其中包含三个测试。我们将文件命名为 index.test.js,以表明它测试的是主源文件 index.js 中的代码。随着你继续开发你的微服务,你最终会有更多这样的文件,包含测试以覆盖你微服务中的所有代码。
测试套件的第一部分致力于设置 Express 和 MongoDB 库的模拟。注意使用jest.fn创建模拟函数,我们可以使用这些函数来检测函数是否被调用,以及如果被调用,传递给它的参数是什么。接下来,注意使用jest.doMock,它允许我们模拟整个 Node.js 模块。这些工具功能强大,允许我们替换 Express 和 MongoDB,而无需调整我们正在测试的代码。
列表 8.7 中的第一个测试检查 HTTP 服务器是否已在端口 3000 上启动。第二个测试检查是否已注册了/videos 路由的处理程序。第三个测试直接调用/videos 路由处理程序函数,并检查它是否从数据库中检索到所需的数据。
这个例子实际上相当高级,但我想要直接切入正题,并展示一些与微服务相关的单元测试。如果你觉得这段代码难以理解,不必过于担心。只需尝试阅读它,抓住其要点,并理解哪些部分是用于模拟,哪些部分是用于测试。
列表 8.7:使用 Jest 测试元数据微服务(chapter-8/example-2/src/index.test.js)
describe("metadata microservice", () => { ①
const mockListenFn = jest.fn(
➥ (port, callback) => callback()); ②
const mockGetFn = jest.fn(); ③
jest.doMock("express", () => { ④
return () => { ⑤
return { ⑥
listen: mockListenFn,
get: mockGetFn,
};
};
});
const mockVideosCollection = {}; ⑦
const mockDb = { ⑧
collection: () => {
return mockVideosCollection;
}
};
const mockMongoClient = { ⑨
db: () => {
return mockDb;
}
};
jest.doMock("mongodb", () => { ⑩
return {
MongoClient: { ⑪
connect: async () => { ⑫
return mockMongoClient;
}
}
};
});
const { startMicroservice } =
➥ require("./index"); ⑬
test("microservice starts web server
➥ on startup", async () => { ⑭
await startMicroservice(); ⑮
expect(mockListenFn.mock
➥ .calls.length).toEqual(1); ⑯
expect(mockListenFn.mock
➥ .calls[0][0]).toEqual(3000); ⑰
});
test("/videos route is handled", async () => { ⑱
await startMicroservice(); ⑲
expect(mockGetFn).toHaveBeenCalled();
const videosRoute = mockGetFn.mock.calls[0][0];
expect(videosRoute).toEqual("/videos"); ⑳
});
test("/videos route retrieves data via
➥ videos collection", async () => { ㉑
await startMicroservice(); ㉒
const mockRequest = {}; ㉓
const mockJsonFn = jest.fn();
const mockResponse = {
json: mockJsonFn
}; ㉓
const mockRecord1 = {}; ㉔
const mockRecord2 = {};
mockVideosCollection.find = () => {
return {
toArray: async () => { ㉕
return [ mockRecord1, mockRecord2 ];
}
};
}; ㉖
const videosRouteHandler =
➥ mockGetFn.mock.calls[0][1]; ㉗
await videosRouteHandler(mockRequest,
➥ mockResponse); ㉘
expect(mockJsonFn.mock
➥ .calls.length).toEqual(1); ㉙
expect(mockJsonFn.mock.calls[0][0]).toEqual({
videos: [ mockRecord1, mockRecord2 ], ㉚
});
});
... ㉛
});
① 定义“metadata 微服务”的测试套件
② 创建一个模拟的“listen”函数
③ 创建一个模拟的“get”函数
④ 创建 Express 库的模拟
⑤ Express 库是一个工厂函数,用于创建 Express 应用对象。
⑥ 返回一个模拟的 Express 应用对象
⑦ MongoDB 视频集合的模拟
⑧ MongoDB 数据库的模拟
⑨ MongoDB 客户端对象的模拟
⑩ 创建 MongoDB 模块的模拟
⑪ MongoClient 的模拟
⑫ connect 函数的模拟
⑬ 导入我们正在测试的代码
⑭ 测试微服务是否正确启动 HTTP 服务器
⑮ 调用测试下的代码
⑯ 期望只调用一次“listen”函数
⑰ 期望将端口 3000 传递给“listen”
⑱ 测试/videos 路由是否由 HTTP 服务器处理
⑲ 期望 Express 的“get”函数已被调用
⑳ 期望“get”的参数是/videos
㉑ 测试/videos 路由从数据库中的视频集合检索数据
㉒ 调用测试下的代码
㉓ 模拟传递给 Express 路由处理程序的 Express“request”和“response”对象
㉔ 模拟“find”函数返回一些模拟数据库记录
㉕ 模拟 MongoDB 库的结构
㉖ 模拟“find”函数返回一些模拟数据库记录
㉗ 提取/videos 路由的“handler”函数
㉘ 调用“handler”函数
㉙ 期望调用“json”函数
㉚ 期望模拟记录已从数据库中检索
㉛ 更多测试在这里!
你可能已经注意到在列表 8.7 中我使用了async和await关键字。我在日常编码中经常使用这些关键字,但在这本书中还没有使用过。我在这里引入这些关键字的原因是它们非常适合 Jest 测试,并且使异步代码的阅读变得更加容易。
你可能想知道jest变量实际上是从哪里来的,因为在列表 8.7 中没有require语句来导入它!这是标准的 JavaScript,通常这会是个问题,但这段代码是在 Jest 环境下运行的。Jest 会自动为我们导入jest变量。它真是太好了,为我们节省了一行代码。
列表 8.7 的开头大部分内容都是用来创建替换 Express 和 MongoDB 的模拟。我们使用了jest.fn和jest.doMock来创建模拟。Jest 有许多其他用于模拟和指定测试期望的有用函数。请参阅本章末尾的参考资料以了解更多信息。
我们用新的 JavaScript 对象替换了 Express 和 MongoDB,因此为我们正在测试的代码的依赖项提供了自己的实现。当代码调用这些函数时,它调用的是替换版本,而不是来自真实 Express 和 MongoDB 库的常规版本。
如果我们没有替换 Express 和 MongoDB,那么调用startMicroservice将会启动真实的 HTTP 服务器并连接到真实的数据库。这种正常操作正是我们在单元测试时想要避免的!这是使自动化测试运行缓慢的那种类型的事情。现在这看起来可能并没有太大的区别,因为目前我们只是在谈论一个非常小的测试数量。但是当你运行 100 个甚至 1000 个测试时,你肯定会看到很大的不同。
8.6.3 运行测试
在编写代码和测试之后,我们就可以运行 Jest 了。在 example-1 目录的终端中,按照以下方式运行测试:
npx jest
或者运行
npm test
输出应该显示一个通过的一组测试,包含三个通过测试。
8.6.4 我们取得了什么成果?
我们已经学习了使用 Jest 进行单元测试的基础知识。我们模拟了 Express 和 MongoDB 库,并测试了我们的微服务可以启动,以及它的/videos 路由可以从数据库中检索记录。
这可能看起来并不多,但你可以继续创建这样的测试来覆盖你所有微服务中的代码。你可能甚至想尝试测试驱动开发(TDD),也称为测试先行开发,在这种方法中,你会在编写实际要测试的代码之前先编写测试代码。
这是一个强大的技术,可以帮助你实现 100%的测试覆盖率,这是一个在没有 TDD 的情况下可能很难实现的成就。如果你愿意,在本章末尾,你会找到更多关于这种以测试为中心的开发方法的参考资料。
8.7 集成测试
测试金字塔(图 8.1)的下一步是集成测试。它被称为集成测试,因为,与单元测试中独立测试代码模块不同,现在的重点是测试代码模块以集成方式共同工作。当涉及到微服务时,集成测试通常意味着我们正在测试整个微服务,包括它所依赖的所有代码模块和代码库。
如果单元测试足以解决我们所有的问题那将很理想。单元测试是有效的,因为单元测试运行得非常快。单元测试的速度意味着我们更有可能频繁运行这些测试,从而快速发现问题。不幸的是,尽管如此,许多问题仍然可能隐藏在代码模块之间的集成中,这些问题是单元测试无法检测到的。
从某种意义上说,集成测试实际上比单元测试更容易,因为我们不需要担心模拟。事实上,如果你觉得模拟太难,你可能会发现从集成测试开始要容易得多。早些时候,在单元测试时,我们模拟了 Express 和 MongoDB 的依赖项。在集成测试中,我们不会这样做。相反,我们将允许我们正在测试的微服务启动一个真实的 HTTP 服务器并连接到一个真实的数据库。
尽管编写集成测试比编写单元测试更容易,但集成测试的设置却更困难。使用真实的 HTTP 服务器限制了我们的测试并行化,因为我们一次只能在端口 3000(或者实际上任何其他端口)上运行一个 HTTP 服务器。使用真实的 MongoDB 数据库意味着我们需要有一个数据库可供我们正在测试的代码使用。
此外,启动 HTTP 服务器和连接到数据库是耗时的。这就是为什么与单元测试相比,集成测试特别慢的原因。综合考虑,如果你现在相信实际上单元测试比集成测试更容易,我也不会感到惊讶!
注意:使用正确的测试组合是一种平衡行为,我们确实需要集成测试,因为这是发现集成代码中问题的唯一方法。
通常,当我们对微服务运行集成测试时,我们会通过其官方 HTTP 接口与其交互,而不是直接调用其函数,就像我们在单元测试中所做的那样。根据微服务的实现方式,我们可以有其他与之交互的方法。例如,如果微服务使用 RabbitMQ,我们也可以通过发送消息与之交互。
图 8.8 展示了在本节中我们将如何进行集成测试。再次强调,我们仍然使用 Jest 来测试我们的元数据微服务,但这次,我们不会使用 Jest 的模拟功能。我们不会直接调用微服务中的代码来测试它,而是发送 HTTP 请求并检查返回的响应。

图 8.8 使用 Jest 对微服务进行集成测试
8.7.1 要测试的代码
现在,我们可以转向第八章代码库中的 example-3。你可以继续跟随并运行这些测试。我们将要测试的代码与 example-2 中的代码相同;没有变化,所以如果你想复习那段代码,可以查看列表 8.6。
8.7.2 运行 MongoDB 数据库
在进行集成测试时,我们不会用模拟版本替换我们的数据库。相反,我们需要一个真实的数据库,并且我们需要能够加载真实的测试数据。
要运行 example-3 的集成测试,你需要一个正在运行的 MongoDB 数据库。下载和安装 MongoDB 并不是特别困难。如果你还没有这样做,你可以在你的开发工作站上安装它。请按照以下说明进行操作:
docs.mongodb.com/manual/installation/
作为一种替代方案,我在 example-3 中包含了一个 Docker Compose 文件,该文件在 Docker 容器中启动 MongoDB。你可以这样启动它:
cd example-2
docker-compose up
8.7.3 加载数据库固定数据
当数据库运行起来后,我们现在需要一种按需加载数据库固定数据的方法。一个 数据库固定数据 是一组固定的测试数据,我们可以将其加载到我们的数据库中进行测试。它被称为固定数据,因为我们用它来用已知或特定的数据集初始化我们的数据库。
使用 Jest 做这件事尤其简单,因为我们可以直接通过常规的 MongoDB Node.js 库创建一个 JavaScript 辅助函数,将数据直接加载到我们的数据库中。MongoDB 已经包含在 example-3 的 package.json 文件中,你可以像这样安装 example-2 的所有依赖项:
npm install
可以按照以下方式在新的项目中安装 MongoDB:
npm install --save mongodb
注意,我们将使用 --save 参数而不是 --save-dev,因为 MongoDB 实际上在我们的生产微服务中使用了,而不仅仅是测试代码。尽管我们用它进行测试,但我们仍然需要将其作为生产依赖项而不是开发依赖项安装。
列表 8.8 展示了一个我们可以用来加载测试数据的简单函数。我们可以从我们的测试代码中调用这个函数,你很快就会看到一个例子。我们只需要指定要加载的集合名称和数据记录。
在列表 8.8 中,注意我们是如何通过 microservice 对象的 db 字段(如列表 8.6 中所示,该对象被保存在一个变量中)访问微服务的数据库。这样做可以避免多次连接到数据库。我们不需要这样做,因为微服务已经建立了连接,我们可以重用它。
列表 8.8 加载数据库固定数据的辅助函数(摘自 chapter-8/example-3/src/index.test.js)
// ...
async function
➥ loadDatabaseFixture(collectionName, records) { ①
await microservice.db.dropDatabase(); ②
const collection = microservice.db.collection(collectionName);
await collection.insertMany(records); ③
}
// ...
① 一个用于加载数据库固定数据的辅助函数
② 重置数据库(不要在生产环境中尝试!)
③ 将测试数据(我们的数据库固定数据)插入到数据库中
我们最初选择使用 MongoDB 的一个原因是因为它使得加载测试数据变得非常容易。当然,你可以用任何数据库做这样的事情。只是有些数据库,比如传统的 SQL 数据库,处理起来可能更困难。
列表 8.8 中的辅助函数允许我们将测试数据内联存储在我们的测试代码中,并在需要时将其加载到我们的数据库中。这非常方便,但也可以将我们的测试数据存储在独立的数据文件中。这样可能会更容易组织。在接下来的端到端测试部分,您将看到一种不同的加载数据库固定数据的方法。
8.7.4 使用 Jest 创建集成测试
使用 Jest 创建集成测试与创建单元测试非常相似。因为我们没有进行任何模拟,这实际上大大简化了我们的测试代码。
我们不会直接在我们的微服务中调用代码,而是使用 HTTP 请求来触发我们想要测试的代码。为了发送 HTTP 请求,我们可以使用我们在第五章中使用的 Node.js 低级 http 库,或者通过 npm 安装的库。在这种情况下,我们将使用 Axios 库,这是一个更现代的库,它直接支持async/await,因此它与 Jest 对异步编码的支持非常契合。
Example-3 已经将 Axios 添加到 package.json 文件中。如果您已安装了 example-3 的所有依赖项,那么您已经拥有它。否则,您可以在新项目中像这样安装 Axios:
npm install --save-dev axios
我们在这里使用--save-dev参数,因为在这种情况下,我们将在测试中使用 Axios。因此,它可以是开发依赖项。如果您计划在生产代码中使用 Axios,请确保使用--save而不是--save-dev将其安装为常规依赖项。
列表 8.9 展示了我们的集成测试代码。这与我们的单元测试代码类似,但不同的是,我们不是模拟依赖项并直接调用要测试的代码,而是将元数据微服务作为真实的 HTTP 服务器启动。然后我们使用 Axios 向其发送 HTTP 请求。
请注意,不要在生产数据库上运行列表 8.9!首先加载数据库固定数据的函数会删除整个数据库。请确保您只对测试数据库运行此操作!并且始终备份您的生产数据库,以防万一!
列表 8.9 使用 Jest 对元数据微服务进行集成测试的代码(chapter-8/example-3/src/index.test.js)
const axios = require("axios");
const mongodb = require("mongodb");
describe("metadata microservice", () => {
const BASE_URL = "http://localhost:3000";
const DBHOST = "mongodb://localhost:27017"; ①
const DBNAME = "testdb";
const { startMicroservice } = require("./index");
let microservice;
beforeAll(async () => {
microservice =
➥ await startMicroservice(DBHOST, DBNAME); ②
});
afterAll(async () => {
await microservice.close(); ③
});
function httpGet(route) {
const url = `${BASE_URL}${route}`;
return axios.get(url);
}
async function
➥ loadDatabaseFixture(collectionName, records) { ④
await microservice.db.dropDatabase();
const collection = microservice.db
➥ .collection(collectionName);
await collection.insertMany(records);
} ④
test("/videos route retrieves data via
➥ videos collection", async () => { ⑤
const id1 = new mongodb.ObjectId(); ⑥
const id2 = new mongodb.ObjectId();
const videoPath1 = "my-video-1.mp4";
const videoPath2 = "my-video-2.mp4";
const testVideos = [
{
_id: id1,
videoPath: videoPath1
},
{
_id: id2,
videoPath: videoPath2
},
]; ⑥
await
➥ loadDatabaseFixture("videos", testVideos); ⑦
const response = await httpGet("/videos"); ⑧
expect(response.status).toEqual(200);
const videos = response.data.videos; ⑨
expect(videos.length).toEqual(2);
expect(videos[0]._id).toEqual(id1.toString());
expect(videos[0].videoPath).toEqual(videoPath1);
expect(videos[1]._id).toEqual(id2.toString());
expect(videos[1].videoPath).toEqual(videoPath2); ⑨
});
... ⑩
});
① 为我们的数据库服务器设置基本 URL
② 启动微服务,包括 HTTP 服务器和数据库连接
③ 关闭微服务
④ 加载测试数据(数据库固定数据)到我们的数据库中的辅助函数。我们在列表 8.8 中定义了这个函数。
⑤ 测试通过向/videos 路由发送 HTTP 请求可以检索视频列表
⑥ 创建测试数据以加载到数据库中
⑦ 将数据库固定数据加载到数据库的视频集合中
⑧ 向我们正在测试的路由发送 HTTP 请求
⑨ 期望接收到的数据与我们的测试数据匹配
⑩ 更多测试在这里进行!
在列表 8.9 中,只有一个测试,但随着我们开发微服务,我们可以轻松地添加更多。这一次,我们通过其正常的 HTTP 接口进行测试。这次,微服务使用的是真实数据库而不是模拟。
我们并不是在测试 HTTP 服务器是否启动,就像我们在单元测试中做的那样。那时测试起来很容易,因为我们已经模拟了 Express 库。现在,尽管如此,我们并没有模拟任何东西,所以很难明确确认 HTTP 服务器是否正确启动。尽管如此,我们可以看到我们正在向微服务发送 HTTP 请求,这隐含地测试了我们的 HTTP 服务器是否正常工作。
注意在列表 8.9 中我们如何使用 Jest 的beforeAll函数在测试前启动我们的微服务,然后使用afterAll函数关闭微服务。看看我们是如何保存microservice对象的引用。这意味着我们可以在完成后访问其数据库连接并关闭微服务。关闭我们的微服务是我们之前从未考虑过的事情,但在这里它很重要,因为这可能不是唯一的测试套件,我们不希望这个微服务运行时间比必要的更长。
你可能已经意识到,随着我们向这个测试套件添加更多测试,我们将对同一个微服务运行多个测试。以这种方式在多个测试中共享微服务并不是理想的,因为它使得知道每个测试是否独立于其他测试变得困难。但这种方式比分别为每个测试启动和停止微服务要快得多。我们可以这样做以提高测试套件的可靠性,但你会等待更长的时间才能完成它!
8.7.5 运行测试
使用 Jest 运行集成测试与运行单元测试相同。输入
npx jest
或者,因为我们已经在 package.json 中进行了配置,所以可以输入
npm test
尝试亲自运行这个集成测试。同时,尝试修改代码以破坏测试,就像我们之前在单元测试中做的那样。
8.7.6 我们取得了什么成果?
在本节中,我们学习了使用 Jest 运行集成测试的基础。它与单元测试非常相似,但我们省略了模拟。因此,我们运行了与依赖项集成的代码。
当进行集成测试时,我们并不是试图隔离被测试的代码(单元测试的目的就是如此),我们也不是试图模拟任何依赖(这有助于实现隔离)。相反,我们旨在测试代码的集成状态!也就是说,我们是在与其他代码结合的情况下测试它:其他模块中的代码和外部库中的代码。
在某种程度上,集成测试比单元测试更容易,因为我们没有隔离和模拟的担忧。创建集成测试也可能比编写单元测试更有效地利用我们的时间。这是因为集成测试通常会覆盖更多的代码,因此你需要花费更少的时间来编写测试。
集成测试的大问题是它们比单元测试慢。这就是为什么它们在测试金字塔中位置更高的原因。考虑一下我们在本章中已经看到的单元和集成测试。它们基本上测试了相同的内容。但在集成测试的情况下,我们启动了一个真实的 HTTP 服务器,连接到一个真实的数据库。这使得集成测试的执行速度比单元测试慢得多。
8.8 端到端测试
现在我们迈上测试金字塔的最终一步(图 8.1)。我们来到了端到端测试。这与集成测试类似,但现在我们的目标是测试整个应用程序,或者至少是其简化版本。在这里,我们希望测试我们的应用程序的完整性,并尽可能接近其在生产环境中的表现。
端到端测试可能是最简单的测试形式。我们不需要像单元测试那样进行模拟。然而,我们需要数据库固定数据,以便我们可以加载真实的测试数据。
传统上,对分布式应用程序进行端到端测试可能会有所困难。这是因为启动所有服务需要大量的努力。幸运的是,我们现在有了 Docker Compose 的帮助,我们在第四章和第五章中学习了它,并自那时起用它来开发我们的应用程序。现在我们将使用 Docker Compose 作为启动我们的微服务应用程序进行自动化端到端测试的便捷方式。
到目前为止,我们正在放弃 Jest,转向 Cypress,这是一个用于加载和测试网页的测试工具。Cypress 功能强大,具有许多特性。在这里,你将只学习基础知识,但这足以让你开始并了解它能做什么。我们将使用 Cypress 通过网关微服务提供的前端来运行针对我们应用程序的测试。你可以在图 8.9 中看到这看起来是什么样子。

图 8.9 使用 Cypress 和 Docker Compose 进行端到端测试的整个应用程序
运行端到端测试需要我们启动整个应用程序,并在浏览器中进行测试。这使得端到端测试成为所有测试类型中最慢的一种,也是它们位于测试金字塔顶端的原因。
话虽如此,拥有少量端到端测试应该是您测试策略的重要组成部分。端到端测试覆盖了大量的内容,尽管这些测试可能需要花费大量时间运行,但它们提供了很高的性价比。此外,这种测试通过前端来测试您的应用程序,而前端恰好是客户的角度。不用说,这确实是我们可以测试应用程序最重要的视角,也是我们将端到端测试的价值看得如此之高的主要原因。
我们现在转向第八章的最后一个示例——example-4。Example-4 包含一个 docker-compose.yaml 文件,它启动了 FlixTube 的简化版本。
8.8.1 为什么选择 Cypress?
Cypress 简直是一个测试网页的绝佳全能工具。它是一个视觉解决方案,拥有出色的用户界面;我们实际上可以观察它测试我们应用程序前端的过程。您可以在图 8.10 中看到它的样子,但要真正理解它的强大之处,您必须亲自尝试。
Cypress 默认使用 Chrome 来运行测试,但它也会自动检测我们工作站上的其他浏览器。我们可以轻松地在这些浏览器之间切换,以进行跨浏览器测试。

图 8.10 Cypress UI(用户界面)
Cypress 拥有出色的用户界面,但它也可以在 无头 模式下从终端运行,这意味着用户界面是隐藏的。在开发过程中,我们将花费大量时间使用 Cypress UI 来直观测试我们的前端。最终,我们将以无头模式运行它,以便它能够融入我们的 CD 管道。
在无头模式下运行时,我们无法直接看到 Cypress 与我们的前端交互,但 Cypress 有一个超级酷的功能:它会记录测试运行的录像。这个功能在自动化测试中尤为有用。当 Cypress 测试失败时,您可以从您的 CD 服务器中提取失败的测试录像,以便您可以看到发生了什么!
当运行 Cypress UI 时,它自动支持实时重新加载。您需要做的就是更新您的代码和测试,Cypress 将自动重新加载并再次运行您的测试。像所有伟大的现代工具一样,Cypress 是开源的。您可以在 GitHub 上找到它的代码,链接如下:
Cypress 还拥有其他令人印象深刻的出色功能。您可以在本章末尾找到参考资料,以便继续学习更多关于 Cypress 的知识。
然而,Cypress 并非全是优点,如果我不指出它存在的一个主要问题,那将是我失职。Cypress 基于 Electron 框架。这意味着它体积庞大,下载/安装可能相当慢。这也意味着在您的持续集成/持续部署(CD)管道中使其高效运行可能很困难,尽管仍然可行。
对于本书和 FlixTube,我们将 Cypress 和我们的端到端测试集成到单个代码仓库中。然而,对于未来的真实项目,您可能希望将 Cypress 测试分离到单独的测试仓库中。尽管通常将测试与被测试的代码放在一起是件好事,但鉴于 Cypress(它体积庞大),将这些放在单独的仓库中是有意义的。
8.8.2 安装 Cypress
Example-4 已经在 package.json 中添加了 Cypress。您可以使用以下方式安装依赖项:
cd chapter-8/example-4
npm install
您可以这样将 Cypress 安装到新项目中:
npm install --save-dev cypress
因为 Cypress,就像 Jest 一样,仅是用于测试的工具,所以我们将使用 --save-dev 来将其保存为开发依赖项。正如之前提到的,Cypress 是一个庞大的工具,安装可能需要一些时间。现在可能是一个喝咖啡的好时机!
你可以在图 8.11 中看到安装了 Cypress 的 example-4 项目的结构。这与我们在前几章中处理的其他项目结构类似。我们有一个 docker-compose.yaml 文件来构建和运行我们的应用程序,并且我们在子目录中有微服务的代码。

图 8.11 安装了 Cypress 的 Example-4 项目结构
图 8.11 中你看到的一些结构是在你第一次在新项目中启动 Cypress 时自动为你创建的。特别是,Cypress 在 cypress/integration/examples 子目录下创建了多个示例测试文件。我没有将这些示例包含在第八章的代码仓库中,但你可以通过在新项目中安装 Cypress 并运行它来轻松生成这些文件。你应该尝试这样做,因为浏览这些示例测试是了解 Cypress 功能的绝佳方式。
列表 8.10 展示了 Cypress 配置文件。这是一个 JSON 文件,在其中我们可以设置 Cypress 的配置选项。对于本例,我们只需要两个选项。第一个是 baseUrl,在这里我们设置要测试的网页的基础 URL。我们将本地运行它(通过使用 Docker Compose 启动我们的应用程序),因此主机名为 localhost。
运行我们的网关的容器被配置为在端口 4000 上提供前端服务,这使得基础 URL 为 http://localhost:4000。在 Cypress 配置文件中设置基础 URL 是可选的。但这样做很有用,因为我们只需更改配置文件中的那一行就可以轻松地将整个 Cypress 测试套件重定向到新位置。
列表 8.10 Cypress 的配置文件(第八章/example-4/cypress.json)
{
"baseUrl": "http://localhost:4000", ①
"dbFixturesUrl": "http://localhost:9000" ②
}
① 设置我们将用于运行测试的基础 URL
② 设置数据库固定值 REST API 的 URL
列表 8.10 中我们设置的另一个字段不是标准的 Cypress 配置选项。我们需要一种方法将数据库固定值加载到我们的数据库中。由于我将很快解释的原因,我们将使用一个单独的 REST API 来完成这个任务。配置文件中的 dbFixturesUrl 字段设置了该 REST API 的基础 URL。
8.8.3 启动 Cypress UI
现在我们已经准备好启动 Cypress 并运行一些测试了。在你的终端中,从第八章代码仓库中的 example-4 运行以下命令:
npx cypress open
这将打开 Cypress UI 的第一层,并显示你的 Cypress 测试套件列表。Example-4 只包含一个测试套件。双击名为 front-end.spec.js 的测试套件以打开 Cypress UI 的下一层。
你现在看到的是一个针对 FlixTube UI 运行的单个测试。测试会自动运行,但在这个阶段,测试应该会失败,因为我们还没有启动我们的应用程序。
8.8.4 设置数据库固定值
在我们开始应用程序之前,我们必须能够加载数据库固定数据。在之前使用 Jest 时,我们能够直接从测试代码中将数据加载到数据库中。我们不能直接从 Cypress 中这样做,因为它是运行在浏览器中的(Cypress 是基于 Chromium 渲染引擎的 Electron 应用程序,是 Chrome 浏览器的基础),并且常规的 MongoDB npm 库在那里不起作用。我们需要一个不同的解决方案来加载数据库固定数据。
要将测试数据加载到我们的数据库中,我们将使用一个单独的 REST API 来管理我们的数据库。这意味着我们可以通过 HTTP 请求来加载和卸载数据库固定数据。我们已经在使用 Docker Compose,因此将额外的容器添加到我们的应用程序中并不困难。图 8.12 显示了包括新的数据库固定数据 REST API 在内的应用程序结构。
创建这样的 REST API 需要做很多工作。然而,我已经有一个过去用于测试项目的。我在 example-4 项目中包含了它的代码副本(在 example-4/db-fixtures-rest-api 下找到)。你还可以在 GitHub 上找到代码的独立副本:
github.com/ashleydavis/db-fixture-rest-api

图 8.12 在运行 Cypress 测试之前使用数据库固定数据 REST API 为数据库播种测试数据
我们不会在本书中介绍数据库固定数据 REST API 的内部结构。我们必须在某处划线,但请随意查看你自己的代码。阅读他人的代码是一种宝贵的经验。请放心,你在这里不会找到任何特别新的东西;毕竟,它只是一个基于 Express 的 Node.js REST API,这与你在本书中已经看到的微服务类似。
列表 8.11 是 example-4 docker-compose.yaml 文件的摘录。它显示我们将数据库固定数据 REST API 集成到我们的应用程序中的方式与任何其他微服务相同。
列表 8.11 使用 Docker Compose 加载 db 固定数据 REST API(摘自第八章示例 3 的 docker-compose.yaml)
version: '3'
services:
db: ①
image: mongo:4.2.0
container_name: db
ports:
- "27017:27017"
expose:
- "27017"
restart: always ①
db-fixture-rest-api: ②
image: db-fixture-rest-api
build:
context: ./db-fixture-rest-api
dockerfile: Dockerfile
container_name: db-fixture-rest-api
ports:
- "9000:80"
environment:
- PORT=80
- DBHOST=mongodb://db:27017
- FIXTURES_DIR=fixtures
volumes:
- ./fixtures:/usr/src/app/fixtures:z
depends_on:
- db
restart: always ②
... ③
① 配置 MongoDB 数据库服务器
② 配置数据库固定数据 REST API
③ 网关和元数据微服务在此定义。
列表 8.11 将数据库固定数据 REST API 添加到我们的应用程序中,但我们仍然需要一个方法从我们的 Cypress 测试中与之通信。为此,我们将创建一个 Cypress 扩展,我们可以在测试中使用它来加载数据库固定数据。
列表 8.12 是一段代码片段,展示了我们如何向 Cypress 添加新命令。这是一个展示如何扩展 Cypress 来执行新功能的例子。这个特定的命令被称为 loadFixture,我们将在 Cypress 测试中使用它来加载数据库固定数据。
列表 8.12 在 Cypress 下加载数据库固定数据(摘自第八章示例 3 的 cypress/support/commands.js)
Cypress.Commands.add('loadFixture',
➥ (databaseName, fixtureName) => { ①
cy.unloadFixture(databaseName, fixtureName); ②
const dbFixturesUrl =
➥ Cypress.config("dbFixturesUrl"); ③
const route = "/load-fixture?db=" + databaseName +
➥ "&fix=" + fixtureName;
cy.request("GET", dbFixturesUrl + route) ④
.then(response => {
expect(response.status).to.eql(200); ⑤
});
});
① 定义一个 Cypress 命令(Cypress 的扩展)通过新的 REST API 加载数据库固定数据
② 通过调用另一个辅助函数从上一个测试中卸载数据(测试数据)
③ 从 Cypress 配置文件中读取 REST API 的 URL
④ 向 REST API 发送 HTTP GET 请求以加载数据库固定数据
⑤ 预期固定数据已成功加载(否则测试失败)
loadFixture 命令向数据库固定数据 REST API 发送 HTTP GET 请求,并使其从文件(在这种情况下,example-4/fixtures/two-videos/videos.js)中加载数据库固定数据。稍后,您将看到我们如何在测试代码中调用此命令。
8.8.5 启动您的应用程序
我们已经安装并准备好了 Cypress,并且我们有能力加载数据库固定数据。在我们能够测试我们的应用程序之前,我们必须启动它!
列表 8.11 是 example-4 的 Docker Compose 文件的摘录。完整的文件包含了一个带有网关和元数据微服务的 FlixTube 简化版本的配置。这远非完整的应用程序,但足以让我们编写一个测试来确认视频列表是从数据库中检索并在前端显示的。
在这个例子中,我将 FlixTube 简化,以便将其作为本章的一个简单示例。然而,一般来说,了解我们总是有选择缩减应用程序以使其更容易测试的选项是很好的。随着我们的应用程序越来越大,最终它将变得太大,无法使用端到端测试在单个计算机上进行测试。在这种情况下,我们被迫将应用程序分割成更小的可测试单元。现在,让我们使用我们的老朋友 Docker Compose 启动应用程序:
docker-compose up --build
8.8.6 使用 Cypress 创建端到端测试
使用 Cypress 编写端到端测试与使用 Jest 编写测试略有不同。在列表 8.13 中,我们使用由 describe 和 it 函数组成的类似整体结构,而不是 Jest 中的 describe 和 test(我们使用了那些)。describe 和 it 来自 Mocha 测试风格。
Mocha 是一个流行的 JavaScript 测试框架,您可能已经听说过它。Cypress 正是基于 Mocha,这就是为什么这些测试看起来是这样的。实际上,Jest 也支持 describe 和 it 格式,所以如果您愿意,您可以使用相同的格式使用这两个测试工具。
列表 8.13 使用 Cypress 对 FlixTube 进行端到端测试(来自第八章的 example-4/cypress/integration/front-end.spec.js)
describe("flixtube front end", () => { ①
it("can list videos", () => { ②
cy.loadFixture("metadata", "two-videos"); ③
cy.visit("/"); ④
cy.get("#video-list").find("div").
➥ should("have.length", 2); ⑤
cy.get("#video-list div:nth-child(1) a") ⑥
.should("have.text", "SampleVideo_1280x720_1mb.mp4")
.should("have.attr", "href",
➥ "/video?id=5ea234a1c34230004592eb32"); ⑥
cy.get("#video-list div:nth-child(2) a") ⑦
.should("have.text", "Another video.mp4")
.should("have.attr", "href",
➥ "/video?id=5ea234a5c34230004592eb33"); ⑦
});
});
① 定义测试套件
② 测试我们能否在 FlixTube UI 中加载视频列表
③ 将名为 two-videos 的固定数据加载到元数据数据库的视频集合中
④ 让 Cypress 访问 FlixTube 的主页
⑤ 验证第一段视频的详细信息
⑥ 检查两个视频(从数据库固定数据加载的视频)是否在 UI 中显示
⑦ 验证第二段视频的详细信息
我们测试中的第一行代码是对我们的 loadFixtures 命令的调用,通过 Cypress 的 cy 对象访问。Cypress 接口还包含许多其他用于加载、交互和测试网页的功能。
在下一行,我们调用 cy.visit。这是你需要了解的 Cypress 中最重要的事情。这个函数是 Cypress 访问网页的方式。所有其他 Cypress 命令都是相对于访问的页面操作的。
我们在这里正在访问网页上的/(根)路由。请注意,这相对于我们在 Cypress 配置文件中之前指定的基本 URL。这会将 Cypress 浏览器导航到 FlixTube 主页。
接下来,我们使用 cy.get 从浏览器的 DOM 层级中获取一个元素并对它进行测试。它检查我们在视频列表中有两个视频,然后检查每个视频的名称和链接。我们知道这些视频应该在前端显示,因为我们在这个测试的第一行用两个视频的数据库固定文件初始化了我们的元数据微服务的数据库。这个数据库固定文件将测试数据(你可以在 example-4/fixtures/two-videos/videos.js 中看到)加载到数据库中,包含这两个视频的所有详细信息。
如果你已经打开了 Cypress,那么你可能已经运行了这个测试。结果应该看起来像图 8.10。你可能需要在上一节启动应用程序后刷新 Cypress UI 来运行测试。在这个时候,你可以尝试破坏这段代码并观察测试失败,就像我们之前在单元和集成测试中做的那样。
这里有一个例子。打开文件 example-4/gateway/src/views/video-list.hbs。这是为 FlixTube 主页渲染的 HTML(以 Handlebars 模板格式)。尝试更改这个 HTML,以便在列表中的每个视频上显示不同的内容。一旦破坏了测试,你会看到红色表示失败,而不是图 8.10 中显示的绿色成功。
只要注意,你永远不要在生产数据库上运行这个测试。加载数据库固定文件会清除相关的数据库集合,你永远不希望丢失生产数据。实际上,你也不应该在生产环境中这样做,因为你在生产环境中永远不会运行数据库固定文件的 REST API!这给了我们加载数据库固定文件的能力,但我们只需要在开发和测试环境中使用它。
注意:在生产环境中运行数据库固定文件的 REST API 也会使外部访问你的数据库。这是一个灾难性的配方,所以请务必不要在生产环境中实例化它。
你可以用 Cypress 做的事情还有很多!这包括点击按钮
cy.get(".some-button").click();
以及在输入字段中输入值:
cy.get(".some-input").type("Hello world");
Cypress 还提供了模拟你的后端 REST API 的功能。你可以使用这个功能来隔离用户界面进行测试!这允许你进行一种针对用户界面的 TDD(测试驱动开发),坦白说,我觉得这非常令人惊讶。本章末尾有参考资料,你可以继续学习关于 Cypress 的知识。
8.8.7 使用 npm 调用 Cypress
现在,我们可以设置使用 npm 调用我们的 Cypress 测试,就像我们使用 Jest 一样。Example-4 是一个独立于其他示例的项目,我们使用不同的测试工具(Cypress 而不是 Jest)。尽管如此,我们希望能够像这样使用传统的 npm test 脚本来运行 Cypress:
npm test
列表 8.14 展示了在 package.json 中的设置,以便实现这一功能。我们已配置测试脚本以调用 cypress run,这将以无头模式执行 Cypress。这允许我们从终端运行 Cypress 测试,就像我们之前使用 Jest 测试那样。
列表 8.14 包含 npm 脚本的 package.json 以运行 Cypress(章节-8/示例-4/package.json)
{
"name": "example-4",
"version": "1.0.0",
"scripts": {
"test:watch": "cypress open", ①
"test": "cypress run" ②
},
"dependencies": {},
"devDependencies": {
"cypress": "⁴.4.1",
"mongodb": "³.5.6"
}
}
① 调用命令“npm run cypress”打开 Cypress UI
② 使用“npm test”命令以无头模式运行 Cypress,从而完全从命令行运行测试
我们设置的另一个脚本是为 npm run test:watch 调用 cypress open,这将打开 Cypress UI。我喜欢这种配置,因为我感觉运行 Cypress UI(它会自动进行实时重载)在功能上与在 Jest 的实时重载模式下运行 Jest 类似(使用 Jest 的 --watch/--watchAll 参数)。当我需要这个功能时,我只需调用 npm run test:watch,而无需考虑我是在 Jest 项目中还是在 Cypress 项目中。我只是得到了我预期的结果。
8.8.8 我们取得了什么成果?
我们几乎完成了对测试领域的探索。我们看到了单元测试、集成测试,现在还有端到端测试。
我们已经了解了测试的相对性能:集成测试比单元测试慢,端到端测试比集成测试慢。我们还看到了每个单元测试仅覆盖一小部分隔离代码的情况。集成和端到端测试可以非常有效,因为这些测试覆盖了更多的代码,但测试数量较少。
现在的问题是,你应该有多少种类型的测试?这个答案并不是一成不变的。
但我可以说的是,你可以,也许应该,拥有 100 个或 1,000 个单元测试。你需要更少的集成测试和非常少的端到端测试。很难说具体数量,因为这实际上取决于你愿意等待测试完成的时间长度。如果你愿意等待一整夜或整个周末来让测试套件完成,那么你可能也能承担起拥有 100 个或 1,000 个端到端测试的成本。
尽管如此,作为开发者,我们渴望快速和全面的反馈。在这方面,单元测试是无与伦比的。如果你可以通过许多非常快速的单元测试实现大量的代码覆盖率,那么这就是你应该拥有的!这是因为这就是开发者会在他们工作日的每一刻编码时使用的。如果你的测试套件运行缓慢,开发者往往不会使用它,也不会更新它。这对任何人来说都不是好事。
最终,事情并非非黑即白。不同类型的测试之间甚至没有明确的区分。单元测试在哪里结束,集成测试又从哪里开始?这并不明确。所有测试都落在光谱上,这是一个有着许多灰度的光谱。
8.9 持续集成(CD)管道中的自动化测试
我们有一套自动化测试。现在我们来到了自动化测试的真正目的:让它自动运行!
要真正实现自动化,我们的测试需要直接在我们的托管代码仓库上运行。当开发者将代码更改推送到代码仓库时,我们希望自动运行测试套件来检查代码的健康状况。为了实现这一点,我们必须将测试添加到我们的 CD 管道中,这样它们就会在生产部署之前成为一个自动检查点。如果测试通过,我们的代码就会部署到生产环境。如果它们失败,我们的代码将不会被部署。就是这样简单。图 8.13 说明了这种情况。

图 8.13 持续集成(CD)管道中的自动化测试
我们之前花时间讨论 npm test 脚本在 package.json 中的配置,原因在于这是我们如何将自动化测试集成到我们的 CD 管道中的方式。正如我们在上一章所学,CD 管道可以简单到只是运行一个 shell 脚本(尽管一些供应商提供了花哨的图形用户界面)。添加我们的自动化测试很容易。假设我们的 npm test 脚本已经配置好,我们只需从我们的部署 shell 脚本中调用以下命令即可:
npm test
作为例子,让我们考虑将自动化测试添加到第七章的 example-4 中。列表 8.15 显示了配置 Bitbucket Pipelines 的 YAML 文件。这与第七章中使用的相同,但现在我们在调用部署脚本之前先调用 npm test。
如果自动化测试失败,也就是说,如果 npm test 返回非零退出码,Jest 和 Cypress 都会在任何测试失败时这样做,那么管道本身就会失败并终止。因此,失败的测试会阻止代码部署到生产环境。
列表 8.15 在持续集成(CD)管道中运行测试(第七章示例-4/bitbucket-pipelines.yaml 的更新)
image: hashicorp/terraform:0.12.6
pipelines:
default:
- step:
name: Build and deploy
services:
- docker
script:
- export VERSION=$BITBUCKET_BUILD_NUMBER
- cd video-streaming && npm install
➥ && npm test ①
- chmod +x ./scripts/deploy.sh
- ./scripts/deploy.sh
① 运行第七章中视频流微服务的测试
在我们的持续集成(CD)管道中运行 Jest 相对简单。列表 8.15 中的 npm install 命令会安装它。
运行 Cypress 更为复杂。因为 Cypress 非常庞大,您需要在 CD 服务器上配置缓存,以便每次流水线调用时 Cypress 都不会重新下载。这有点过于复杂,并且特定于您的 CD 提供商,因此我们在这本书中不涉及它。
我们还需要在部署脚本中添加一个命令,在运行 Cypress 测试之前启动我们的应用程序。这使得事情变得更加复杂,并且更加高级。虽然这超出了本书的范围,但您确实值得深入研究,以便在您的部署管道中自动运行端到端测试。
8.10 测试概览
在结束本章之前,这里快速回顾一下 Jest 和 Cypress,以及我们如何使用这些工具来运行测试。
表 8.2 测试命令概览
| 命令 | 描述 |
|---|---|
npx jest --init |
初始化 Jest 配置文件。 |
npx jest |
在 Jest 下运行测试。 |
npx jest --watch |
启用实时重新加载功能运行测试,当代码发生变化时重新运行测试。它使用 Git 来确定哪些文件已更改。 |
npx jest --watchAll |
与上述类似,但它监视所有文件的变化,而不仅仅是 Git 报告已更改的文件。 |
npx cypress open |
打开 Cypress UI,以便您可以运行测试。实时重新加载默认启用;您可以更新代码,测试将自动重新运行。 |
npx cypress run |
在无头模式下运行 Cypress 测试。这允许您从命令行(或 CD 流水线)进行 Cypress 测试,而无需显示用户界面。 |
npm test |
npm 运行测试的脚本约定。根据您在 package.json 文件中的配置,运行 Jest 或 Cypress(甚至两者都运行)。这是您应在 CD 流水线中运行的命令以执行测试套件。 |
npm run test:watch |
这是我在实时重新加载模式下运行测试的个人约定。您需要配置 package.json 文件中的此脚本才能使用它。 |
8.11 继续你的学习
在本章中,我们学习了自动化测试的基础知识。这里的内容足以启动您自己的测试计划,但测试是一个如此庞大的主题,并且本身就是一种专业。要进一步探索这个主题,请参考以下书籍:
-
《单元测试原则、实践和模式》,作者:弗拉基米尔·科里科夫(Vladimir Khorikov)(Manning, 2020)
-
《单元测试的艺术》(第 2 版),作者:罗伊·奥斯霍夫(Roy Osherove)(Manning, 2013)
-
《测试 Java 微服务》,作者:亚历克斯·索托·布埃诺(Alex Soto Bueno)、安迪·冈布雷希特(Andy Gumbrecht)和贾森·波特(Jason Porter)(Manning, 2018)
-
《使用 Mountebank 测试微服务》,作者:布兰登·拜尔斯(Brandon Byars)(Manning, 2018)
还可参见 Elyse Kolker Gordon 著的 《探索 JavaScript 测试》(Manning, 2019),这是一本关于测试的免费章节集合,来自 Manning 出版的其他书籍:
要了解更多关于 Jest 的信息,请参阅 Jest 网页和此处提供的 《入门》 指南:
要了解更多关于 Cypress 的信息,请查看以下 Cypress 网页和文档:
-
docs.cypress.io/guides/getting-started/installing-cypress.html -
docs.cypress.io/guides/core-concepts/introduction-to-cypress.html
摘要
-
自动化测试对于扩展到大量微服务至关重要。
-
你学习了单元测试、集成测试和端到端测试如何在测试金字塔中相互配合。
-
我们使用 Jest 创建并执行了单元测试和集成测试。
-
我们使用 Docker Compose 和 Cypress 创建了端到端测试。
-
你学习了如何使用数据库固定值来为集成和端到端测试填充测试数据。
-
你学习了如何将自动化测试整合到你的持续交付(CD)管道中。
9 探索 FlixTube
本章涵盖
-
回顾你已学到的工具
-
理解 FlixTube 的布局、结构和主要代码路径
-
在开发环境中构建、运行和测试 FlixTube
-
使用 Terraform 将 FlixTube 部署到生产环境
-
构建 FlixTube 的持续交付管道
通过第九章的旅程是一条漫长的道路。在这个过程中,我们使用了众多工具来构建微服务,测试它们并将它们部署到生产环境中。在本章中,我们将看到我们辛勤工作的成果,即 FlixTube 示例应用的完整版本。
通过本章,我们将了解 FlixTube 作为一个整体是如何工作的,并遇到一些新的微服务。我们将复习和巩固我们的技能,并在一个完整但相对简单的微服务应用中展示这些技能。
我们将首先在开发环境中构建和运行 FlixTube。接下来,我们将运行第八章中的测试。最终,我们将 FlixTube 部署到我们的生产 Kubernetes 集群,并为其创建一个持续交付(CD)管道。
9.1 没有新工具!
恭喜!你已经学会了所有开始构建微服务应用所需的主要工具。当然,还有更深层次的知识需要掌握。还有许多其他有用的工具你可以学习,未来也会有新的工具出现。
但是,就本书的目的而言,我们已经学到了构建基于微服务产品的最小工具集。随着你深入到持续的开发中,你会发现一些特定于你项目的难题,你需要更深入地研究这些工具。你需要学习更深入的 Docker、Kubernetes 和 Terraform 知识。然而,目前我们工具箱中的工具已经足够我们完成 FlixTube 的第一个版本。所以,让我们开始吧。
9.2 获取代码
要跟随本章,你需要下载代码或克隆仓库。
-
从这里下载代码的 zip 文件:
-
你可以使用 Git 克隆代码,如下所示:
git clone https://github.com/bootstrapping-microservices/chapter-9.git
如需安装和使用 Git 的帮助,请参阅第二章。如果你在代码上遇到问题,请在 GitHub 的仓库中记录一个问题。
9.3 回顾基本技能
在我们完成整个 FlixTube 示例的过程中,我们将练习我们学到的构建、运行、测试和部署微服务的基本技能。当你看到这样的列表时,你会意识到我们已经覆盖了多少内容!
-
使用 Node.js 运行微服务(来自第二章)
-
使用 Docker 打包和发布我们的微服务(来自第三章和第六章)
-
使用 Docker Compose 在开发环境中构建和运行我们的应用(来自第四章和第五章)
-
使用数据库存储和检索数据(来自第四章)
-
使用外部文件存储存储和检索文件(来自第四章)
-
使用 HTTP 请求和 RabbitMQ 消息在微服务之间进行通信(来自第五章)
-
使用 Jest 测试单个微服务(来自第八章)
-
使用 Cypress 测试整个应用程序(来自第八章)
-
使用 Terraform 将应用程序部署到 Kubernetes 集群(来自第六章和第七章)
-
使用 Bitbucket Pipelines 创建 CD 管道(第七章)
图 9.1 展示了我们将回顾的技能及其在事物方案中的上下文。为了充分利用本章内容,请跟随示例。您应该自己运行 FlixTube,以便研究它并理解其工作原理。为了测试和加深您的理解,您应该尝试进行自己的更改。实践是巩固这些技能的最佳方式。

图 9.1 本章回顾的基本技能
9.4 FlixTube 概述
本章的代码仅包含一个示例:完整的 FlixTube 项目。您可以在第九章代码仓库的 example-1 子目录中找到它。让我们先从其结构的一个鸟瞰图开始。图 9.2 展示了 FlixTube 的最新版本。

图 9.2 完成的 FlixTube 示例应用程序概述
9.4.1 FlixTube 微服务
您已经了解图 9.2 中显示的一些微服务。例如
-
视频流(首次在第二章中遇到)
-
视频存储(来自第四章)
-
历史(来自第五章)
-
元数据(来自第八章)
还有一些您尚未见过的新的微服务:网关和视频上传。表 9.1 列出了这些微服务的每个目的。
表 9.1 FlixTube 微服务
| 微服务 | 目的 |
|---|---|
| 网关 | 应用程序的入口点。服务于前端并提供 REST API。 |
| 视频流 | 从存储流式传输视频供用户观看。 |
| 历史 | 记录用户的观看历史。 |
| 元数据 | 记录每个视频的详细信息及其元数据。 |
| 视频上传 | 协调视频上传到存储。 |
| 视频存储 | 负责从外部云存储中存储和检索视频。 |
9.4.2 微服务项目结构
在我们查看整个应用程序的项目结构之前,让我们首先回顾单个 Node.js 微服务的结构。打开第九章代码仓库中 example-1 目录下的元数据目录,以跟随示例。
以元数据微服务为例,图 9.3 描述了其项目的布局。这是一个典型的 Node.js 项目,FlixTube 的所有微服务几乎都有这种相同的结构。

图 9.3 Node.js 微服务项目结构(元数据微服务)
9.4.3 FlixTube 项目结构
现在,让我们看看整个 FlixTube 项目的结构。图 9.4 展示了其布局,包括每个微服务的子目录。打开第九章代码仓库中的 example-1 目录,亲自查看一下。
为了简单起见,FlixTube 是在一个代码仓库中构建的。使用单个仓库是学习微服务开发的绝佳方式(它使你的任务变得更简单),而且一般来说,即使你成为微服务的专家,使用单个仓库也是启动新的微服务应用程序的一种简单方便的方式。当然,使用单个仓库也是我与你分享示例代码的一种方便方式。
说了这么多,生产环境中的微服务通常永远不会包含在单个代码仓库中。使用单个仓库会消除使用微服务的最大优势:它们可以独立部署。将所有微服务放在单个仓库中意味着它们将一起部署(除非你有非常聪明的 CD 流水线)。

图 9.4 整个 FlixTube 项目的结构
在现实情况下,微服务几乎总是被分割成独立的仓库,通常每个微服务都有一个独立的仓库。为了简单和方便起见,我们暂时将 FlixTube 放在一个仓库中。在第十一章中,我们将讨论单仓库与多仓库的对比,以及如何通过将 FlixTube 分割到独立的代码仓库中来推进它。
9.5 在开发中运行 FlixTube
我们的第一步是将 FlixTube 运行在我们的开发工作站(或个人电脑)上。图 9.5 展示了它在开发中的样子。请注意,我们已经用它的模拟版本替换了视频存储微服务。我们很快会讨论为什么以及如何这样做。

图 9.5 FlixTube 在开发中的样子
9.5.1 启动微服务
在启动整个应用程序之前,回顾一下我们如何启动单个微服务是有意义的。当开发新的微服务或专注于现有微服务时,我们经常需要在该微服务之外的应用程序上下文中单独运行该微服务。
我们使用 Node.js 作为我们的微服务,这意味着我们的微服务将直接在我们的开发工作站上的 Node.js 下运行。如果你在第二章和第八章中一直跟随,那么你已经有 Node.js 安装了。如果没有,请回到第二章 2.5.4 节获取说明。在运行 Node.js 项目之前,你必须首先安装依赖项,如下所示:
npm install
运行 Node.js 项目时,请使用 npm start 脚本约定:
npm start
这将调用在项目的 package.json 文件中指定的命令行。FlixTube 中的所有微服务都遵循这个常见的 Node.js 约定。这意味着你知道如何启动 FlixTube 中的任何微服务,以独立运行在 生产模式 下。
在持续开发期间,更合适的是以开发模式运行微服务。这启用了实时重新加载(首次在第 2.6.8 节中介绍),这样我们就可以编辑我们的代码,并且微服务会自动重新启动以包含更改。我们使用 start:dev 脚本来以开发模式运行 FlixTube 的任何微服务(我的个人约定):
npm run start:dev
(您可以在生产模式、开发模式和实时重新加载中进行进一步修改。要了解更多信息,请返回第二章的 2.6.7 和 2.6.8 节。)
您可能已经注意到,FlixTube 的大多数微服务现在都有依赖项,这使得它们单独启动变得更加困难。其中大部分需要数据库或 RabbitMQ 服务器。其中一些需要两者。我们可以通过以下任何一种方式来处理这个问题:
-
在您的开发工作站上安装 MongoDB 和 RabbitMQ。 这在短期内可能很烦人,但从长远来看非常有用。
-
使用 Docker 或 Docker Compose 实例化 MongoDB 和 RabbitMQ 服务器容器。 这是一种方便、有效且简单的方法。
-
模拟 MongoDB、RabbitMQ 和其他依赖项的库。 这与我们在第八章中做的是类似的。您可能想为您的自动化测试做这件事。
9.5.2 启动应用程序
现在,让我们使用 Docker Compose 启动整个 FlixTube 应用程序,这是我们首次在第四章中遇到的有用工具,并且自那时起一直在使用。在日常产品开发中,我们经常构建和重新启动我们的应用程序,而 Docker Compose 使这变得更加简单。我们经常抽出时间专注于单个微服务,但我们在微服务组件演变的同时,仍然经常想要测试我们的更大应用程序。
如果您跟随了第四章、第五章和第八章的内容,您已经安装了 Docker Compose。如果没有,请参考第 4.3.2 节并安装它,以便您可以继续操作。现在您应该打开 VS Code 中的第九章的 example-1 子目录,以查看代码。
列表 9.1 让我们回忆起 Docker Compose 文件(docker-compose.yaml)的样子。FlixTube 版本的此文件是本书中最大的,因此列表 9.1 为了简洁而进行了缩写。此文件中的大多数条目都是相似的,因此可以安全地省略。如果您看过一个,基本上就看过它们了。
列表 9.1 启动 FlixTube 开发环境的 Docker Compose 文件(从第九章的 chapter-9/example-1/docker-compose.yaml 缩写)
version: '3'
services:
db: ①
image: mongo:4.2.0
container_name: db
# ... code omitted for brevity ...
rabbit: ②
image: rabbitmq:3.8.1-management
container_name: rabbit
# ... code omitted ...
db-fixture-rest-api: ③
image: db-fixture-rest-api
build:
context: ./db-fixture-rest-api
dockerfile: Dockerfile
container_name: db-fixture-rest-api
# ... code omitted ...
video-streaming: ④
image: video-streaming
build:
context: ./video-streaming
dockerfile: Dockerfile-dev
container_name: video-streaming
# ... code omitted ...
# ... other microservices omitted ⑤
① 启动 MongoDB 数据库的容器
② 启动 RabbitMQ 服务器的容器
③ 启动用于加载数据库固定数据的 REST API
④ 构建并启动视频流微服务
⑤ 其他所有 FlixTube 微服务都在这里。
大多数 FlixTube 微服务在列表 9.1 中被省略了,但你可以看到的是我们熟悉的老朋友,视频流微服务。还包括我们数据库的设置(在第四章中介绍),RabbitMQ(在第五章中介绍),以及我们将用于自动化测试的数据库固定 REST API(在第八章中介绍)。现在使用 Docker Compose 构建并启动 FlixTube:
cd example-1
docker-compose up --build
构建和启动需要一些时间,尤其是如果你之前没有这样做过。Docker 需要下载和缓存基础镜像。
现在,随着 FlixTube 应用程序的运行,打开你的浏览器并导航到 http://localhost:4000 以查看 FlixTube 的主页。你会发现 FlixTube 有一个闪亮的新用户界面(UI)!我们很快会更多地讨论这一点。现在,花些时间探索 FlixTube 的 UI:
-
导航到上传页面。
-
上传视频。
-
导航回主页以查看列表中的上传视频。
-
点击视频播放它。
当你完成开发后,别忘了关闭 FlixTube,以免它在你的开发工作站上继续消耗资源。你可以通过在 Docker Compose 运行的终端中按 Ctrl-C 来做到这一点,然后调用
docker-compose down
9.6 开发中测试 FlixTube
测试对于开发实践至关重要。我们可以也应该进行手动测试,但自动化测试在效率、可靠性和可重复性方面无与伦比。
在第八章中,我们探讨了使用 Jest 和 Cypress 的多种测试方法。我们在这里将再次回顾这些方法。我们在那一章中查看的各种测试在本章 9 的代码库中重复出现。我们现在将对完成的 FlixTube 示例运行这些测试。
当然,任何真正的应用程序都将比我们这里运行的几个测试有更多的测试。这只是一个演示,我并没有追求接近完整的测试覆盖率。在接下来的章节中,尝试自己运行这些测试。
9.6.1 使用 Jest 测试微服务
FlixTube 中的元数据微服务包括第八章中的 Jest 单元测试。在运行测试之前,你需要安装依赖项:
cd chapter-9/example-1/metadata
npm install
现在按照以下标准 npm test脚本来运行测试:
npm test
这将在第八章中配置的元数据微服务的 package.json 文件中执行相关的命令行。图 9.6 显示了成功测试运行的结果。

图 9.6 使用 Jest 对元数据微服务进行自动测试的成功运行
你也可以在实时重新加载模式下运行测试,这意味着你可以编辑你的代码,测试将自动重新启动。我们使用另一个名为test:watch的 npm 脚本来做到这一点(我的个人约定):
npm run test:watch
要更详细地复习 Jest,请回到第 8.5 节。要回顾 npm 和实时重新加载的 Jest 设置,请参阅第 8.5.8 节。
9.6.2 使用 Cypress 测试应用程序
我们也可以从第八章开始运行 Cypress 端到端测试,针对 FlixTube 应用程序。在第八章中,我们针对 FlixTube 的简化版本运行了这个测试。然而,这次我们将针对完整的应用程序运行它。要运行这个测试,你需要为 FlixTube 项目安装依赖项:
cd chapter-9/example-1
npm install
如果您还没有这样做,请确保实际启动应用程序:
docker-compose up --build
现在,运行常规的 npm test 脚本,在这个例子中,它被配置为调用 Cypress:
npm test
这将在终端中以无头模式运行 Cypress。在开发过程中,我们希望启动 Cypress UI,如图 9.7 所示。在这种情况下,我们将使用我们配置的 test:watch 脚本来启动 Cypress UI:
npm run test:watch

图 9.7 使用 Cypress 对 FlixTube UI 的自动化测试成功运行
在 Cypress UI 运行的情况下,我们可以对影响我们前端代码的代码进行更改,并以非常直观的方式看到结果。要更详细地修订 Cypress,请返回到第 8.8 节。要回顾 Cypress 的 npm 设置,请转到第 8.8.7 节。
9.7 深入了解 FlixTube
到目前为止,你应该从高层次上理解了 FlixTube。你知道微服务以及每个微服务的目的。你知道如何在你的开发工作站上构建、运行和测试应用程序。在我们将 FlixTube 部署到生产之前,让我们首先了解一些其更深入细节。在本节中,我们将查看 FlixTube 的各个方面:
-
数据库固定值
-
模拟存储微服务
-
网关
-
FlixTube UI
-
视频流
-
视频上传
9.7.1 数据库固定值
我们在第八章中首先讨论了数据库固定值,在那里我们使用这些固定值在运行自动化测试之前将真实数据集加载到数据库中。我们看到了数据库固定值在自动化测试中的应用,但它们对手动测试甚至产品演示也非常有用。能够启动应用程序并展示包含真实数据的完整应用程序是非常有用的!
当使用 Jest 进行单元测试时,我们不需要任何数据,因为我们模拟了 MongoDB 数据库库,并能够通过数据库库的模拟版本提供的假数据替换真实数据。当使用 Jest 进行集成测试时,我们能够在测试代码中直接使用 MongoDB 库与我们的 MongoDB 数据库进行交互。这意味着我们可以在测试代码中内联测试数据,但不必为它创建单独的数据文件。
当使用 Cypress 进行端到端测试时,我们必须找到不同的解决方案。因为 Cypress 测试在浏览器中运行(Cypress 是基于 Electron 构建的,而 Electron 是基于 Chrome 的),所以我们无法访问 MongoDB 库(它只在 Node.js 下运行)。在这种情况下,我们无法直接操作我们的 MongoDB 数据库。
为了解决这个问题,我创建了 数据库固定配置 REST API。这是一个看起来与其他你在本书中看到的任何其他微服务相似的 REST API。我们不会直接查看其代码,但如果你想自己查看,你会发现它已经相当熟悉了。REST API 的代码包含在第八章代码存储库中,并复制到第九章代码存储库,以便我们在对 FlixTube 运行测试时可以使用它。此外,你可以在 GitHub 上找到它的原始源代码:github.com/ashleydavis/db-fixture-rest-api。你可以在列表 9.1 中找到 REST API 容器的设置。
要了解数据库固定配置的外观,请参阅列表 9.2。一般来说,我们的数据库固定配置存储在 chapter-9/example-1 的 fixtures 子目录下。
FlixTube 只有一个数据库固定配置文件在 videos.js 文件中(如列表 9.2 所示)。文件名表示数据将要存储的数据库集合。从这个固定配置文件中获取的数据将被加载到视频集合中。
包含该文件的目录表示固定配置的名称。在这种情况下,目录的名称是 two-videos,因此数据库固定配置的名称是 two-videos。我之所以给固定配置起这个名字,是因为它的目的是将两个视频的元数据加载到我们的数据库中。一般来说,我们应该给我们的数据库固定配置起有意义的名字,这样我们就可以轻松记住它们的目的。
每个数据库固定配置可以由多个文件组成。尽管在这里我们只为我们的双视频固定配置文件只有一个文件,但它可以有更多这样的文件来设置数据库中其他集合的内容。
列表 9.2 FlixTube 的一个示例数据库固定配置(chapter-9/example-1/fixtures/two-videos/videos.js)
const mongodb = require("mongodb"); ①
module.exports = [ ②
{
_id:
➥ mongodb.ObjectId("5ea234a1c34230004592eb32"), ③
name: "SampleVideo_1280x720_1mb.mp4" ④
},
{
_id:
➥ mongodb.ObjectId("5ea234a5c34230004592eb33"), ③
name: "Another video.mp4" ④
}
];
① 导入 MongoDB 库以便我们可以创建数据库 ID
② 将插入到元数据库视频集合中的数据导出
③ 为新记录创建数据库 ID
④ 设置视频的文件名
如果你之前在 9.6.2 节中运行了 Cypress 测试,那么你已经使用了这个数据库固定配置!请注意,列表 9.2 中显示的固定配置实际上是一个 JavaScript 文件。我们可以使用 JSON 格式或 JavaScript 来创建这些数据库固定配置。JSON 适用于静态数据,但 JavaScript 是生成动态数据的一个很好的选择。这为我们生成测试数据提供了很大的灵活性。在列表 9.2 中,看看我们如何使用 MongoDB 库为我们的测试数据生成数据库 ID。
9.7.2 模拟存储
为了开发期间的方便,我们将视频存储微服务的 Azure 版本替换为模拟版本。这与我们在第 8.5.10 节中使用的模拟类似。不同之处在于,我们不是用模拟版本替换函数、对象和库,而是现在用一个假版本替换整个微服务。图 9.8 显示了当 Azure 存储被模拟存储微服务替换时 FlixTube 的样子。
我们的模拟存储微服务并不是完全的虚构!它仍然执行存储的任务,但不是使用云存储,而是在本地文件系统中存储视频。我们这样做的主要原因不仅仅是测试;它还为了方便和性能,能够将整个应用程序限制在我们的开发工作站上。

图 9.8 在开发期间用模拟微服务替换云存储,以实现更方便和高效的使用
在开发期间运行时,我们更喜欢消除外部依赖,如连接到云存储。在这种情况下,将存储限制在本地文件系统使开发设置更加简单。性能得到提升,因为视频是本地存储的,而不是发送到云端。除了这个变化之外,FlixTube 仍然按正常工作,其他微服务并不知道 Azure 存储微服务已被踢出并替换为模拟版本。
能够用更简单的模拟版本替换复杂微服务不仅方便,而且在未来的某个时刻可能也是必要的。目前,FlixTube 是一个小型应用程序,但你可以想象,随着它成长为注定要成为世界主导的流媒体服务,它将变得太大,无法在单台计算机上运行。
到那时,我们需要使出浑身解数来使其适应。这包括删除我们不需要的微服务;例如,如果你不需要测试它,你可以从 Docker Compose 文件中移除历史微服务。
注意:移除或替换大型复杂微服务——甚至可能是整个微服务组——是减少我们应用程序大小的重要技术,以便它可以在单台计算机上运行并在开发期间运行。
列表 9.3 展示了 FlixTube 的 Docker Compose 文件中我们的模拟存储微服务的配置。它看起来与 Azure 存储微服务的配置相似。不同的一点是,存储子目录是在主机操作系统和容器之间共享的。这是上传视频存储的目录。以这种方式共享意味着我们可以在主机操作系统上自行检查上传的视频,以测试微服务是否正常运行。
列表 9.3 Docker Compose 文件中的模拟存储微服务设置(摘自第九章/示例-1/docker-compose.yaml)
video-storage: ①
image: mock-storage
build:
context: ./mock-storage ②
dockerfile: Dockerfile-dev
container_name: video-storage
volumes:
- /tmp/mock-storage/npm-cache:/root/.npm:z
- ./mock-storage/src:/usr/src/app/src:z
- ./mock-storage/storage:
➥ /usr/src/app/storage:z ③
ports:
- "4005:80"
environment:
- PORT=80
restart: "no"
① 将 DNS 名称设置为 video-storage。(其他微服务不知道 Azure 存储微服务已被模拟版本替换。)
② 我们不是从 azure-storage 子目录构建容器,而是从 mock-storage 子目录构建模拟版本。
③ 在主机操作系统和容器之间共享存储目录,并将视频存储在此目录中。您可以从主机检查它们,以确保模拟存储微服务正常工作。
能够用模拟替换微服务是开发中的一个很好的选项。它可以帮助简化开发,但有时我们需要关注微服务的实际版本;我们需要测试它而不是模拟版本。在这些时候,我们可以在 Docker Compose 文件中简单地用实际版本替换模拟版本。如果您愿意,您可以亲自尝试一下。
列表 9.4 显示了实际存储微服务的已注释配置。只需取消注释此配置,然后注释掉模拟版本的配置。现在重新构建并重启您的应用程序。您现在可以在开发中测试实际存储微服务了!
列表 9.4 实际存储微服务已注释(摘自第九章示例 1 的 docker-compose.yaml)
# video-storage: ①
# image: azure-storage ①
# build: ①
# context: ./azure-storage ①
# dockerfile: Dockerfile-dev ①
# container_name: video-storage ①
# ... code omitted for brevity ... ①
① 取消注释此行以在开发期间将 Azure 存储微服务包含在应用程序中。为了使此操作生效,您必须先注释掉模拟存储微服务(如列表 9.3 所示),实际上用实际版本替换它。
列表 9.5 显示了模拟存储微服务的代码。模拟版本用使用本地文件系统的版本替换了实际存储微服务的 /video 和 /upload 路由。模拟微服务是一个即插即用的替代品,因为它的 REST API 符合实际微服务的接口。
列表 9.5 模拟存储微服务(摘自第九章示例 1 的 mock-storage/src/index.js)
const express = require("express");
const fs = require("fs");
const path = require("path");
const app = express();
const storagePath =
➥ path.join(__dirname, "../storage"); ①
app.get("/video", (req, res) => { ②
const videoId = req.query.id;
const localFilePath = path.join(storagePath, videoId);
res.sendFile(localFilePath); ③
});
app.post("/upload", (req, res) => { ④
const videoId = req.headers.id;
const localFilePath = path.join(storagePath, videoId);
const fileWriteStream =
➥ fs.createWriteStream(localFilePath);
req.pipe(fileWriteStream) ⑤
.on("error", err => {
console.error("Upload failed.");
console.error(err && err.stack || err);
})
.on("finish", () => {
res.sendStatus(200);
}); ⑤
});
const port = process.env.PORT && parseInt(process.env.PORT) || 3000;
app.listen(port, () => {
console.log(`Microservice online`);
});
① 设置在本地文件系统中存储视频的路径
② HTTP GET 路由处理程序,从存储中流式传输视频
③ 将本地文件直接作为响应发送给 HTTP 请求
④ HTTP POST 路由处理程序,将视频上传到存储
⑤ 将传入的 HTTP 请求体(上传的文件)流式传输到本地文件
9.7.3 网关
FlixTube 有一个单独的网关微服务。它被称为 网关,因为它充当用户进入应用程序的网关。对于 FlixTube 的当前版本,这是整个应用程序的唯一入口点。网关提供了前端 UI,使用户能够通过他们的网络浏览器与 FlixTube 交互。它还提供了一个 REST API,以便前端可以与后端交互。
FlixTube 目前还不支持任何类型的身份验证,但未来我们可能会升级网关以验证我们的用户。FlixTube 用户必须在网关允许他们与后端交互之前进行登录。
图 9.9 展示了 FlixTube 多个网关的潜在未来。这说明了被称为“前端的后端”的已知模式。每个前端都有自己的网关。有一个网关供网络浏览器访问;另一个网关供移动应用访问;还有一个网关供 FlixTube 管理门户使用。

图 9.9 多个网关的 FlixTube 将看起来如何
如果可能的话,我们希望保持简单,并只支持单个网关。在多个类型的客户端之间共享网关是完全可行的。但如果我们发现前端有不同的需求(例如,网络和移动之间的不同认证形式或网络和管理门户之间的不同安全考虑),那么“前端的后端”模式可以提供帮助。
如果我们扩展到拥有多个网关,那么我们就会希望使用单独的主机名或子域名来访问它们。例如,浏览器的主要网关可以使用 flixtube.com,移动网关使用 mobile.flixtube.com,而管理门户使用 admin.flixtube.com。为了将域名分配给您的应用程序,您需要使用 DNS 提供商购买域名,并将每个域名配置为指向特定网关微服务的 IP 地址。
将 HTTP 请求转发到集群是网关微服务的主要任务之一。我们将在接下来的章节中看到这方面的代码示例。更高级的网关(FlixTube 目前还没有这么高级)将具有 REST API 路由,这些路由会向多个内部微服务发出请求。然后它将多个响应整合成一个返回给前端的单一响应。
例如,想象一个 REST API,它检索单个用户的历史记录。这可能需要向用户账户微服务(FlixTube 目前还没有这个)和历史微服务发送 HTTP 请求,然后在整合响应并发送到前端之前。在这个理论示例中,网关合并了这两个 HTTP 请求的响应。
9.7.4 用户界面(UI)
如果您还没有机会探索 FlixTube 的用户界面,现在就去做吧。按照 9.5.2 节中的讨论构建并启动应用程序,然后使用您的网络浏览器导航到 http://localhost:4000。
图 9.10 展示了 FlixTube(视频列表)在已上传一些视频后的主页。我们可以点击列表中的任何视频来观看它。我们可以在顶部的导航栏中点击“视频”、“上传”和“历史”,在主要页面之间切换。
FlixTube 是作为一个传统的服务器端渲染网页实现的,而不是作为在浏览器中渲染的现代单页应用程序(SPA)。如果 FlixTube 是一个真正的商业应用程序,它很可能会使用 React、Angular 或 Vue 编码为一个 SPA。
FlixTube 使用 Express 和 Handlebars 模板引擎通过服务器端渲染,前端使用纯 JavaScript。FlixTube 前端是普通的 HTML、CSS 和 JavaScript,没有任何花哨的现代框架。
为什么不使用流行的现代 SPA 框架之一呢?嗯,简单的原因是这超出了本书的范围。这本书不是关于 UI 的,这就是为什么前端尽可能简单。(此外,我不想选择任何一方,挑起 SPA 框架信徒之间的战争,但所有酷炫的孩子都在用 React,对吧?)。

图 9.10 FlixTube UI 的主页显示了已上传的视频列表。
列表 9.6 是网关微服务主代码文件的摘录。它显示了渲染主页的 HTTP GET 路由。主页显示了上传的视频列表。此路由处理程序首先从元数据微服务请求数据。然后我们使用视频列表模板渲染网页,并将视频列表作为模板的数据输入。
列表 9.6 渲染视频列表网页的网关代码(摘自第九章/示例-1/网关/src/index.js)
app.get("/", (req, res) => { ①
http.request( ②
{
host: `metadata`,
path: `/videos`,
method: `GET`,
},
(response) => {
let data = "";
response.on("data", chunk => {
data += chunk;
});
response.on("end", () => {
res.render("video-list", {
➥ videos: JSON.parse(data).videos }); ③
});
response.on("error", err => {
console.error("Failed to get video list.");
console.error(err || `Status code:
➥ {response.statusCode}`);
res.sendStatus(500);
});
}
).end(); ②
});
① 声明一个 HTTP GET 路由处理程序,用于检索主网页并显示上传的视频列表
② 向元数据服务发送 HTTP 请求以获取视频列表
③ 使用视频列表模板(列表 9.8 显示了模板)渲染网页。我们将视频数组作为渲染模板的数据传递。
列表 9.6 中制作 HTTP 请求的代码使用的是内置的 Node.js http.request函数,因此相当冗长。在看到第八章中的 Axios 之后,你可能会想知道为什么我没有使用它。
Axios 是一个出色的现代库,我强烈推荐它!它使用简单,相当灵活,并且与 Java-Script 中的新async和await关键字配合得很好。它没有在本章中使用的原因是,作为 Node.js 流控制内置库更容易,但我们还没有使用它,但很快你将看到这方面的例子。
我没有为 FlixTube 使用 JavaScript 框架,但我确实使用了 CSS 框架(Tailwind CSS)。这样我就可以制作一个漂亮的 UI,而无需与 CSS 的细节纠缠。
列表 9.7 显示了 FlixTube 的主页。这是一个包含在 Handlebars 模板中的 HTML 文档。Handlebars 是一个简单而强大的模板库,我们可以用它根据数据生成网页。如果你回顾列表 9.6,你会看到视频列表作为模板数据传递。现在在列表 9.7 中,你可以看到我们正在从这个模板数据生成一系列 HTML div元素。
当网络浏览器请求主页时,网关微服务会从元数据微服务请求数据。从这些数据中,它将渲染 HTML 以在用户的网络浏览器中显示。
列表 9.7 视频列表网页的 Handlebars 模板(chapter-9/example-1/gateway/src/views/video-list.hbs)
<!doctype html> ①
<html lang="en">
<head>
<meta charset="utf-8">
<title>FlixTube: Home</title>
<link rel="stylesheet"
➥ href="css/tailwind.min.css"> ②
<link rel="stylesheet" href="css/app.css"> ③
</head>
<body>
<div class="flex flex-col">
<div class="border-b-2 bg-gray-100"> ④
<div class="nav flex flex-row items-center mt-1 p-2">
<div class="text-xl font-bold">
FlixTube
</div>
<div class="ml-16 border-b-2 border-blue-600">
<a href="/">Videos</a>
</div>
<div class="ml-4">
<a href="/upload">Upload</a>
</div>
<div class="ml-4">
<a href="/history">History</a>
</div>
</div>
</div> ④
<div class="m-4"> ⑤
<h1>Videos</h1>
<div id="video-list" class="m-4"> ⑥
{{#if videos}} ⑦
{{#each videos}} ⑦
<div class="mt-1"> ⑧
<a href="/video?id={{this._id}}">
➥ {{this.name}}</a> ⑨
</div> ⑧
{{/each}}
{{else}}
No videos uploaded yet. ⑩
{{/if}}
</div>
</div>
</div>
</body>
</html>
① 一个 HTML5 网页
② 包含 Tailwind CSS。使用 CSS 框架使 CSS 处理起来容易得多!
③ 包含 FlixTube 特定的 CSS
④ 在网页顶部渲染导航栏
⑤ 网页的主要内容
⑥ 视频列表的容器
⑦ 从数据渲染模板的 Handlebars 语法
⑧ 该元素为每个视频重复渲染。
⑨ 从模板数据渲染视频链接
⑩ 在上传视频前显示消息
9.7.5 视频流
FlixTube 的核心是视频流。我们早在第二章就讨论了这一点,并且它贯穿了整本书。现在,是时候看看在完成的 FlixTube 示例应用程序中视频流是如何工作的了。其中一些内容将是复习,但重要的是现在我们已经有了网关微服务和 UI,要看到它在更大的上下文中是如何工作的。
图 9.11 说明了流媒体视频的路径,从左侧的外部云存储开始,到右侧在网页浏览器中向用户显示结束。流媒体视频在其到达用户的过程中通过了三个微服务。现在,让我们通过代码跟随这段旅程。

图 9.11 FlixTube 中流媒体视频的路径
列表 9.8 是一个摘录,展示了在 Azure 版本的视频存储微服务中流媒体视频旅程的起始点。HTTP GET /video 路由从 Azure 存储检索视频并将其流式传输到 HTTP 响应。目前如何工作的细节并不重要,但如果你想了解,请参阅第 4.4.1 节。
列表 9.8 从 Azure 存储流式传输视频(第九章/example-1/azure-storage/src/index.js 的摘录)
app.get("/video", (req, res) => { ①
const videoId = req.query.id; ②
const blobService = createBlobService();
streamVideoFromAzure(blobService, videoId, res) ③
.catch(err => { ④
// ... error reporting omitted ...
res.sendStatus(500);
}); ④
});
① HTTP GET 路由处理程序从视频存储微服务检索流媒体视频。
② 输入要检索的视频 ID 作为 HTTP 查询参数
③ 从 Azure 存储将视频流式传输到 HTTP 响应
④ 处理可能发生的任何错误
继续我们的视频流微服务之旅,列表 9.9 是一个摘录,展示了如何使用 Node.js 流将 HTTP GET /video 路由的流媒体视频从视频存储管道传输到其自己的 HTTP 响应。
视频流微服务还有另一个任务。它向应用程序中的其他微服务广播“已观看视频”消息。这种事件驱动编程意味着我们可以在以后决定让其他微服务响应事件,而无需更新视频流微服务的代码。
如您从第五章第 5.8 节所知,是历史微服务接收到这条消息并使用它来记录用户的观看历史。这种间接消息的使用方式使得视频流和历史微服务之间保持良好的解耦。这也突出了微服务应用程序之所以如此灵活和可扩展的原因之一。
列表 9.9 通过视频流微服务转发流式视频(摘自第九章/示例-1/video-streaming/src/index.js)
app.get("/video", (req, res) => { ①
const videoId = req.query.id;
const forwardRequest = http.request( ②
{
host: `video-storage`,
path: `/video?id=${videoId}`,
method: 'GET',
headers: req.headers,
},
forwardResponse => {
res.writeHeader(forwardResponse.statusCode,
➥ forwardResponse.headers);
forwardResponse.pipe(res); ③
}
);
req.pipe(forwardRequest); ②
broadcastViewedMessage(messageChannel, videoId); ④
});
① 定义一个 HTTP GET 路由处理程序,从视频流微服务检索流式视频
② 将 HTTP GET 请求转发到视频存储微服务
③ 将视频存储微服务的响应(使用 Node.js 流)管道传输到该请求的响应中
④ 向其他微服务广播观看视频的消息,以便它们知道用户正在观看视频
我们的视频流之旅继续到网关微服务,这是 UI 之前的最后一站。列表 9.10 中的 HTTP GET /video 路由将流式视频从视频流微服务管道传输到其自己的 HTTP 响应。这是视频离开集群的地方,因此将视频传送到前端。
列表 9.10 通过网关微服务转发流式视频(摘自第九章/示例-1/gateway/src/index.js)
app.get("/api/video", (req, res) => { ①
const forwardRequest = http.request( ②
{
host: `video-streaming`,
path: `/video?id=${req.query.id}`,
method: 'GET',
},
forwardResponse => {
res.writeHeader(forwardResponse.statusCode,
➥ forwardResponse.headers);
forwardResponse.pipe(res); ③
}
);
req.pipe(forwardRequest); ②
});
① 定义一个 HTTP GET 路由处理程序,从网关微服务检索流式视频
② 将 HTTP GET 请求转发到视频流微服务
③ 将视频流微服务的响应(使用 Node.js 流)管道传输到该请求的响应中
我们的视频流之旅在 UI 中结束。您可以在列表 9.11 中看到 HTML video元素。source元素及其src字段触发对网关的 HTTP GET 请求,这触发了对视频流的请求,进而触发了对视频存储的请求。然后,流式视频通过视频存储、视频流、网关,最终通过用户浏览器中的video元素显示给用户。
列表 9.11 使用 HTML 视频元素在前端播放视频(摘自第九章/示例-1/gateway/src/views/play-video.hbs)
<video controls autoplay muted> ①
<source src={{video.url}} type="video/mp4"> ②
Your browser does not support the video tag.
</video>
① 使用 HTML 视频元素在前端显示流式视频
② 在网关微服务中链接到/api/video 路由以检索用于在视频元素中显示的流式视频
9.7.6 视频上传
视频流只是 FlixTube 方程的一侧。另一侧是视频上传,这是我们最初如何将视频添加到 FlixTube 的方式。视频上传在书中尚未介绍,尽管它与视频流的工作方式相似,所以您不会遇到任何麻烦。
图 9.12 展示了通过应用程序上传视频的路径。用户通过 FlixTube 前端选择视频文件并上传。上传的视频到达网关微服务的集群,然后通过视频上传微服务转发到视频存储微服务。在那里,它被安全地存储在外部云存储中。我们再次将这段旅程通过代码来追踪。

图 9.12 通过 FlixTube 上传视频的路径。
图 9.13 是 FlixTube 上传网页的截图。如果你在 9.5.2 节中跟随了操作,你将已经看到这个并尝试上传一个视频。用户点击选择文件并选择要上传的文件。一旦上传完成,UI 将更新(如图 9.13 所示),以提供上传无错误完成的反馈。如果发生错误,将显示错误。

图 9.13 FlixTube 上传视频的用户界面
列表 9.12 是前端代码片段,用于将视频上传到后端。这是使用 fetch 函数通过 HTTP POST 请求上传视频。此时,你可能正确地想到为什么我们还要使用另一个 HTTP 请求库?
嗯,通常情况下,我们会在前端使用像 Axios 这样的库。然而,这是一个没有构建过程的纯 JavaScript 网页。这使得安装像 Axios 这样的 npm 包并在我们的前端 JavaScript 代码中使用它变得相当困难;我们没有将其捆绑到前端的方法。
剩下的最简单的方法是使用浏览器自带的功能来发起 HTTP 请求。我们可以使用古老的 XMLHttpRequest 来做这件事,但这有点复杂。相反,我们将使用更现代的 fetch 函数,它也更容易使用。不幸的是,fetch 并未在旧版网络浏览器中实现,这可能会影响我们的用户群。幸运的是,我们在这里只是用它来替代无法使用 Axios 的情况。
列表 9.12 使用 fetch 在前端代码中上传视频(摘自 chapter-9/example-1/gateway/public/js/upload.js)
fetch("/api/upload", { ①
body: file, ②
method: "POST", ③
headers: { ④
"File-Name": file.name,
"Content-Type": file.type,
}, ④
})
.then(() => {
// ... Update the UI after the upload ... ⑤
})
.catch((err) => {
// ... Handle the upload error ... ⑥
});
① 使用浏览器的“fetch”函数向 /api/video 路径发起 HTTP 请求
② 将要上传的文件设置为 HTTP 请求的主体
③ 设置 HTTP 方法为 POST
④ 在请求头中存储文件名和 MIME 类型
⑤ 请求成功后执行
⑥ 如果请求失败则执行
在从网络浏览器上传后,HTTP POST 请求到达网关,在那里它被以下列表中显示的 /api/upload 路由处理。在这里,我们看到请求被转发到视频上传微服务。
列表 9.13 网关微服务将 HTTP POST 请求转发到视频上传微服务(摘自 chapter-9/example-1/gateway/src/index.js)
app.post("/api/upload", (req, res) => { ①
const forwardRequest = http.request( ②
{
host: `video-upload`,
path: `/upload`,
method: 'POST',
headers: req.headers,
},
forwardResponse => {
res.writeHeader(forwardResponse.statusCode,
➥ forwardResponse.headers);
forwardResponse.pipe(res); ③
}
);
req.pipe(forwardRequest); ④
});
① 定义一个 HTTP POST 路由处理程序,将视频上传到网关微服务
② 将请求转发到视频上传微服务
③ 将视频上传微服务的响应(使用 Node.js 流)通过管道传输到当前请求的响应
④ 将请求本身(请求体是视频)通过管道传输到另一个请求
列表 9.14 展示了视频上传微服务如何处理传入的视频。在此阶段,我们通过创建 MongoDB 的 ObjectId 类的实例为视频创建一个唯一 ID。然后,请求被转发到视频存储微服务。
上传成功后,消息“视频已上传”被广播出去,以便让其他微服务知道系统中有了新的视频。元数据微服务处理此消息并在其数据库中记录新的视频。
列表 9.14 通过 HTTP POST 处理视频上传(摘自 chapter-9/example-1/video-upload/src/index.js)
app.post("/upload", (req, res) => { ①
const fileName = req.headers["file-name"]; ②
const videoId = new mongodb.ObjectId(); ③
const newHeaders = Object.assign({}, req.headers,
➥ { id: videoId }); ④
streamToHttpPost(req, `video-storage`,
➥ `/upload`, newHeaders) ⑤
.then(() => {
res.sendStatus(200); ⑥
})
.then(() => {
// Broadcast message to the world.
broadcastVideoUploadedMessage( ⑦
/* params omitted */
);
})
.catch(err => {
console.error(`Failed to capture uploaded file ${fileName}.`);
console.error(err);
console.error(err.stack);
});
});
① 定义一个 HTTP POST 路由处理程序,用于将视频上传到视频上传微服务
② 从请求头中提取原始文件名
③ 为新视频创建一个唯一 ID
④ 将视频 ID 添加到头信息中
⑤ 将 HTTP 请求转发到视频存储微服务
⑥ 成功捕获视频以供视频存储微服务使用。这是最重要的事情;我们不能丢失用户数据!
⑦ 广播视频上传消息,以便其他微服务知道已上传新的视频。
最后,上传的视频到达视频存储微服务,您可以在列表 9.15 中看到。从这里开始,视频被保存到 Azure 存储。一旦整个链路完成,我们就成功保存了用户上传的视频副本。如果您想深入了解文件如何添加到 Azure 存储,请将视频存储微服务的完整 index.js 文件加载到 VS Code 中。
列表 9.15 从 HTTP POST 流式传输视频到 Azure 存储(摘自 chapter-9/example-1/azure-storage/src/index.js)
app.post("/upload", (req, res) => { ①
const videoId = req.headers.id; ②
const mimeType = req.headers["content-type"]; ②
const blobService = createBlobService();
uploadStreamToAzure(req, mimeType,
➥ videoId, blobService) ③
.then(() => {
res.sendStatus(200); ④
})
.catch(err => {
// ... error reporting omitted ...
res.sendStatus(500); ⑤
});
});
① HTTP POST 路由处理程序,用于将视频上传到 Azure 存储
② 从请求头中提取视频详情
③ 将视频从 HTTP 请求流式传输到 Azure 存储
④ 表示上传成功
⑤ 表示上传失败
9.8 使用 Terraform 手动部署 FlixTube 到生产环境
如果您已经在开发环境中运行了 FlixTube,那么这是一个巨大的进步!为了使 FlixTube 可供公众使用,我们现在必须使用与第六章和第七章中相同的技术和工具将其部署到生产环境。
本章接下来的两个部分最具挑战性,但如果您能跟上并完成这些任务,这将是一次极好的体验。如果在任何时候感觉太难,请随时回顾第六章和第七章,那里有更详细的说明。
最终,我们希望为 FlixTube 有一个 CD 管道,每当我们将更新后的代码推送到我们的托管代码仓库时,它会自动部署到生产环境。不过,在我们达到那个目标之前,我们必须首先手动部署 FlixTube。原因如下:
-
在最初开发部署脚本时,你会逐步进行。 随着你对部署脚本的演变,你需要一种方法来测试这些脚本,获取反馈,并修复问题。
-
在未来,如果你在你的 CD 管道中发现问题,你需要有在开发中运行部署脚本的能力。这是必要的,这样你就可以找出并修复问题。
图 9.14 突出了我们将要做什么。我们将使用 Terraform 在云中创建我们的基础设施。然后,我们将使用 Docker 打包和发布我们的镜像,并使用 Terraform 将容器部署到我们的 Kubernetes 集群。

图 9.14 使用 Terraform 从开发工作站手动部署 FlixTube 到生产
9.8.1 Terraform 脚本结构
图 9.15 显示了 FlixTube 脚本目录的布局。你将在这里识别出一些来自第六章和第七章的代码文件,但也有一些是新的。
显著的是,我们现在看到了第一次使用 Terraform 模块。在modules/microservice目录中的main.tf文件是一个可重用的 Terraform 代码模块。我们可以使用它来部署我们所有的微服务,而无需重复此代码。我们稍后将查看此模块的代码。

图 9.15 脚本子目录包含将 FlixTube 部署到生产的 Terraform 脚本
9.8.2 前提条件
要部署 FlixTube,你需要安装一些工具。如果你在第六章和第七章中跟随了操作,你已经有这些工具了;如果没有,你现在可以安装这些工具。首先,你需要 Azure CLI 工具。检查它是否已安装,方法如下:
az --version
如果你还没有 Azure CLI 工具,请按照以下安装说明进行操作:
docs.microsoft.com/en-us/cli/azure/install-azure-cli
你还需要安装 Terraform。检查你是否已经安装了它,方法如下:
terraform --version
否则,从以下链接安装最新版本:
www.terraform.io/downloads.html
9.8.3 Azure 身份验证
在将基础设施部署到 Azure 之前,你首先需要使用你的账户进行身份验证。如果你在第六章和第七章中跟随了操作,你已经完成了这项操作。如果没有,请参阅第 6.6.2 节以获取详细说明。使用 Azure CLI 工具,通过输入以下命令来检查你当前正在使用哪个账户:
az account show
复制输出,因为你很快就需要从id字段(你的 Azure 订阅 ID)和tenantID字段中获取值。
注意:确保你使用的是正确的账户!如果你不小心将基础设施部署到工作账户,可能会很尴尬。
你还需要创建一个服务主体,你的 Kubernetes 集群将使用它来与你的 Azure 账户交互(例如,当它为你创建 Azure 负载均衡器时)。如果你已经在第六章中有一个服务主体,你现在可以重用它;否则,创建一个新的,如下所示:
az ad sp create-for-rbac --role="Contributor"
➥-scopes="/subscriptions/<subscription-id>"
一定要将 <subscription-id> 替换为你刚才记下的实际订阅 ID。然后复制输出结果,你很快就会需要它。你需要的是来自 appId(在我们的 Terraform 脚本中我们称之为 client_id)和 password(我们称之为 client_secret)字段中的值。有关创建服务主体的更详细说明,请参阅第 6.11.2 节。
9.8.4 配置存储
要将 FlixTube 部署到生产环境,你还需要一个 Azure 存储账户。Azure 存储微服务使用此账户来存储和检索视频。如果你在第四章中跟随操作,你已经有一个账户,并且如果你愿意可以重用它。否则,按照第 4.4.1 节中的说明创建一个存储账户。
你需要在你的存储账户中创建一个视频容器。请注意,这并不是一个 Docker 容器;这是 Azure 存储中的容器概念,一个可以存储任意文件的容器。记下你的存储账户名称和访问密钥。你很快就会需要这些信息。
9.8.5 部署应用程序
现在是时候调用 Terraform 来评估我们的脚本并将 FlixTube 部署到生产环境了。首先,我们必须初始化 Terraform。为此,初始化 Terraform 并安装我们需要的各种提供者:
cd chapter-9/example-1/scripts
terraform init
现在调用 Terraform 来部署我们的基础设施:
terraform apply
在开始之前,你必须为以下输入变量提供值:
-
app_version—第一次你可以直接输入 1。在随后的terraform apply调用中,你应该增加这个数字。 -
client_id—你在第 9.8.3 节中记下的 Azure 服务主体的 ID。 -
client_secret—你的服务主体的密码。 -
storage_account_name—你在第 9.8.4 节中记下的用于存储视频的 Azure 存储账户名称。 -
storage_access_key—你的存储账户的访问密钥。
部署 FlixTube 需要一些时间。你可以随意泡一杯茶(或者几杯)。
9.8.6 检查是否工作
要检查 FlixTube 是否已部署并正常工作,我们可以将其前端加载到我们的网页浏览器中。为此,我们必须知道它的 IP 地址。就像我们在第七章的各个部分中所做的那样,我们可以使用 Kubernetes CLI 工具来完成这个任务:
kubectl get services
要记住如何安装和使用 Kubectl,请回顾第 6.12 节。你可以看到 Kubectl 显示在图 9.16 中的表格输出。在网关容器的 EXTERNAL-IP 列中找到 IP 地址。将 IP 地址复制到你的网页浏览器中。

图 9.16 使用 Kubernetes 命令行工具获取网关的 IP 地址,以便我们可以在浏览器中测试 FlixTube
你不需要使用任何特定的端口号。我们使用 4000 端口来访问 FlixTube 的开发版本,但生产中的 FlixTube 配置为使用端口 80。这是 HTTP 的默认端口(因为它默认,所以我们不需要指定它)。
你可能已经注意到我们在这里使用的是 HTTP 协议。这就是为什么浏览器会在 Fixtube 的 IP 地址旁边显示“不安全”。出于安全考虑,我们实际上应该使用(就像所有现代 Web 服务器一样)HTTP 的安全版本,称为 HTTPS。这代表安全超文本传输协议,我们将在第十一章再次提到。如果一切按计划进行,你现在应该能够导航到 FlixTube UI 来上传和播放视频。
在这个阶段,你可以随意尝试使用 FlixTube 和 Terraform。你可以修改 FlixTube 或 Terraform 代码,然后使用terraform apply应用你的更改。你可以这样做多少次就做多少次。
9.8.7 拆卸
当你完成 FlixTube 后,确保清理所有内容。在云中运行此基础设施将花费你金钱。如果你刚刚创建了 Azure 账户,你将使用它提供的免费信用额度来尝试它,但让我们不要浪费它。当你完成使用后,销毁你的基础设施:
terraform destroy
9.8.8 Terraform 模块
第九章代码库中的大部分 Terraform 代码与第六章和第七章中已经看到的代码相同。然而,这里有一个需要进一步解释的新内容。
记得部署微服务的 Terraform 代码吗?如果你需要复习,可以快速浏览第 7.6 节。我们可以通过简单地重复相同的代码来应对 FlixTube 中的每个微服务。但大部分代码是相同的,所以这不是一种高效的工作方式。为了应对这种情况,我们将引入 Terraform 的一个更高级的功能:Terraform 模块。
Terraform 模块允许我们编写可重用的代码模块,我们可以通过提供不同的输入变量在不同的环境中使用它们。列表 9.16 显示了用于部署 FlixTube 六个微服务的 Terraform 模块。这看起来或多或少就像任何其他 Terraform 代码文件。
列表从 Kubernetes 部署开始,该部署将微服务实例化到我们的 Kubernetes 集群中。它以 Kubernetes 服务结束,该服务通过 DNS 使微服务在集群内可访问。请注意,Kubernetes 服务的type字段是参数化的,这样我们就可以为微服务启用或禁用 Azure 负载均衡器。你将在下一章中了解更多关于负载均衡器可以做什么的信息。现在,这是为了我们可以分配一个 IP 地址给网关微服务,并使其对外界可访问。(这就是我们的客户将如何与我们应用程序交互的方式。)
列表 9.16 中的 Terraform 代码没有特别特殊的地方,除了它位于 modules/microservice 子目录中。请注意列表开头定义的多个输入变量。这些是正常的 Terraform 变量,但在这个例子中,这些是允许我们定制模块行为的输入。例如,我们可以通过service_name变量设置微服务的名称。我们还必须传递托管微服务镜像的容器注册库的详细信息。另一个重要的变量是env。它允许我们为每个微服务单独配置环境变量集。
列表 9.16 一个可重用的 Terraform 模块将微服务部署到 Kubernetes(摘自第九章/示例 1/脚本/modules/microservice/main.tf)
variable "app_version" {} ①
variable "service_name" {}
variable "dns_name" {
default = ""
}
variable "login_server" {}
variable "username" {}
variable "password" {}
variable "service_type" {
default = "ClusterIP"
}
variable "session_affinity" {
default = ""
}
variable "env" {
default = {}
type = map(string)
} ①
locals { ②
image_tag = "${var.login_server}/${var.service_name}:${var.app_version}"②
} ②
# ... much code omitted for brevity ...
resource "kubernetes_deployment"
➥ "service_deployment" { ③
depends_on = [ null_resource.docker_push ]
metadata {
name = var.service_name ④
labels = {
pod = var.service_name
}
}
spec {
replicas = 1
selector {
match_labels = {
pod = var.service_name
}
}
template {
metadata {
labels = {
pod = var.service_name
}
}
spec {
container {
image = local.image_tag
name = var.service_name ④
env {
name = "PORT"
value = "80"
}
dynamic "env" { ⑤
for_each = var.env
content {
name = env.key
value = env.value
}
} ⑤
}
image_pull_secrets {
name =
➥ kubernetes_secret.docker_credentials.metadata[0].name
}
}
}
resource "kubernetes_service" "service" { ⑥
metadata {
name = var.dns_name != ""
➥ ? var.dns_name : var.service_name ⑦
}
spec {
selector = {
pod = kubernetes_deployment.service_deployment.metadata[0].labels.pod
}
session_affinity = var.session_affinity ⑦
port {
port = 80
target_port = 80
}
type = var.service_type ⑧
① 定义此 Terraform 模块的输入变量
② 定义在此模块中使用的局部变量
③ 将容器部署到我们的 Kubernetes 集群
④ 使用变量来为每个微服务定制此模块的配置
⑤ 使用变量来设置针对每个微服务特定的环境变量
⑥ 部署一个服务,使容器可以通过 DNS 从其他容器访问
⑦ 使用变量来为每个微服务定制此模块的配置
⑧ 使用变量来为每个微服务定制此模块的配置
列表 9.17 展示了我们如何使用 Terraform 模块来部署我们的微服务。这里只展示了网关微服务。其他服务被省略,因为它们看起来几乎相同,尽管它们的环境变量配置可能有所不同。在下面的列表中,请注意模块是如何导入的,其源文件是如何指定的,以及环境变量是如何配置的。
列表 9.17 Terraform 微服务模块将网关微服务部署到 Kubernetes(摘自第九章/示例 1/脚本/microservices.tf)
locals { ①
login_server = azurerm_container_registry.container_registry.login_server①
username = azurerm_container_registry.container_registry.admin_username ①
password = azurerm_container_registry.container_registry.admin_password ①
rabbit = "amqp://guest:guest@rabbit:5672" ①
database = "mongodb://db:27017" ①
} ①
module "gateway-microservice" { ②
②
source ="./modules/microservice" ②③
②
service_name = "gateway" ②④
service_type = "LoadBalancer" ②④
session_affinity = "ClientIP" ②④
login_server = local.login_server ②④
username = local.username ②④
password = local.password ②④
app_version = var.app_version ②④
②
env = { ②⑤
RABBIT: local.rabbit ②⑤
} ②⑤
} ②
# ... all other microservices omitted for brevity ...
① 设置用于整个脚本的局部变量
② 导入微服务 Terraform 模块(来自列表 9.16),以部署我们的网关微服务
③ 指定从子目录./modules/microservice 加载的模块的来源,其中包含 main.tf 文件(来自列表 9.16)
④ 将输入变量设置为配置网关微服务的微服务模块
⑤ 配置针对单个微服务的特定环境变量
Terraform 模块是 Terraform 的高级功能之一,而且 Terraform 中还有更多内容等待您去探索。请参阅本章末尾,以获取深入了解 Terraform 的参考信息。
9.9 持续交付到生产环境
在手动将 FlixTube 部署到生产环境之后,我们现在可以上线持续交付(CD)管道。
您可以跟随操作,但这可能比上一节更具挑战性,尤其是如果出现问题的话!您可能需要回到手动部署(我们在 9.7 节中刚刚做过的)来找出问题。
就像在第七章中做的那样,我们将使用 Bitbucket Pipelines 创建我们的 CD 管道。对于您来说,将其转移到任何其他 CD 平台应该相当容易。正如我在第七章中所说的,CD 管道实际上只是一个被美化的 shell 脚本,即使某些提供商也提供了花哨的 UI。
将您的部署 shell 脚本从一个提供商迁移到另一个提供商并不困难。然而,Bitbucket 因其提供免费层而具有很高的性价比。在尝试将其迁移到不同的持续交付(CD)提供商之前,遵循这里的说明来练习使其工作是有价值的。图 9.17 展示了 FlixTube 的 CD 管道结构。

图 9.17 FlixTube 的持续交付(CD)管道
9.9.1 前提条件
您需要一个 Bitbucket 账户。如果您在第七章中跟随了操作,您已经有了这个账户。否则,请前往bitbucket.org注册一个免费账户。
9.9.2 设置您的代码存储库
下一步是将 FlixTube 的代码导入到 Bitbucket 代码存储库中。将第九章代码存储库中 example-1 子目录的全部内容复制到一个新位置。在这里创建一个新的 Git 仓库,然后将代码推送到您的托管 Bitbucket 仓库。接下来,为存储库启用 Bitbucket Pipelines。请参阅 7.7.2 节以获取有关 Bitbucket 存储库设置的详细说明。
现在,配置您的环境变量以用于存储库。您需要添加与 Azure 身份验证相关的变量,就像在 7.7.6 节中所做的那样。出于安全原因,我们将敏感配置细节作为存储库变量存储,而不是将这些存储在代码中。就像在第七章中做的那样,添加 ARM_CLIENT_ID、ARM_CLIENT_SECRET、ARM_TENANT_ID 和 ARM_SUBSCRIPTION_ID 的变量。您在 9.8.3 节中已经记录了这些变量的值。
此外,我们还需要添加一些新变量来验证视频存储微服务对存储视频的 Azure 存储账户的访问权限。为此,也添加 STORAGE_ACCOUNT_NAME 和 STORAGE_ACCESS_KEY 的变量,并将这些值设置为在 9.8.4 节中记录的值。
9.9.3 准备后端
在您的 CD 管道首次调用之前,您需要配置后端,以便 Terraform 的状态文件在后续调用之间持久化。关于 Terraform 状态的复习,请参阅 6.8.7 节和 7.7.4 节。
为 Terraform 创建一个不同的 Azure 存储容器。您可以使用在第七章中创建的容器,或者创建一个新的容器。不要重复使用视频容器!这样会使用同一个容器进行不同的目的,这最终会使理解并推理您的应用程序变得更加困难。
Terraform 脚本 backend.tf 已经配置为将 Terraform 状态存储在我们的 Azure 存储账户中。您只需取消注释该文件中的代码(您在手动部署 FlixTube 时注释了它)。请确保设置详细信息以匹配您自己的存储账户和容器。
列表 9.18 显示了在取消注释代码后的 backend.tf。请确保将资源组和存储账户重命名为您自己的 Azure 账户中存在的名称。您还需要创建一个名为terraform的 Azure 存储容器。Terraform 将在此容器下以terraform.tfstate的名称持久化其状态。
列表 9.18 Terraform 后端配置(第九章/示例-1/脚本/backend.tf)
terraform {
backend "azurerm" {
resource_group_name =
➥ "<your-resource-group>" ①
storage_account_name =
➥ "<your-storage-account>" ②
container_name = "terraform" ③
key = "terraform.tfstate" ④
}
}
① 设置包含存储账户的资源组名称。将其重命名为您 Azure 账户中存在的资源组。
② 设置存储 Terraform 状态的存储账户名称。将其重命名为您 Azure 账户中存在的存储账户。
③ 存储 Terraform 状态的容器名称。无需重命名,但请确保该容器存在于您的 Azure 账户中。
④ 存储 Terraform 状态的文件名。这可以设置为任何名称,但我们使用 Terraform 状态文件的默认名称,因为这更有意义,我们也容易记住它的含义。
9.9.4 部署 shell 脚本
如前所述,shell 脚本通常是任何持续交付(CD)管道的核心。列表 9.19 是 FlixTube 的部署 shell 脚本。请注意,它与第七章中的部署脚本几乎没有区别。在列表 9.19 中,为了简洁起见,省略了一些代码,并且有几个额外的环境变量被传递给 Terraform。
我们的大部分部署代码都在 Terraform 代码中,这就是为什么这个 shell 脚本如此之小。如果我们想在这里直接做更多的事情,比如构建和发布 Docker 镜像,我们完全可以做到。但至少对于 FlixTube 来说,我们已经成功地将整个部署包含在 Terraform 代码中。要更详细地修订部署 shell 脚本,请回顾第 7.7.3 节。
列表 9.19 使用 Terraform 进行部署的 shell 脚本(第九章/示例-1/脚本/deploy.sh 摘录)
cd ./scripts ①
terraform init ②
terraform apply -auto-approve \ ③
-var "app_version=$VERSION" \ ④
-var "client_id=$ARM_CLIENT_ID" \ ④
-var "client_secret=$ARM_CLIENT_SECRET" \ ④
-var "storage_account_name=$STORAGE_ACCOUNT_NAME" \ ④
-var "storage_access_key=$STORAGE_ACCESS_KEY" ④
① 修改包含我们的 Terraform 脚本的目录
② 调用 Terraform 初始化
③ 启用自动批准后调用 Terraform apply,然后运行我们的 Terraform 脚本并部署我们的基础设施和微服务
④ 通过环境变量传递给我们的 Terraform 脚本
9.9.5 FlixTube 的 CD 配置
CD 管道拼图中最后一块是配置文件。对于 Bitbucket Pipelines,这是一个放置在你代码仓库根目录的 YAML 文件。它被称为 bitbucket-pipelines.yaml。一些其他 CD 提供商使用类似的 YAML 格式。
列表 9.20 显示了 FlixTube 的简单 CD 管道配置。这是因为我们在这里真正做的只是调用我们在列表 9.19 中看到的部署 shell 脚本。有关 Bitbucket Pipelines 配置的更多详细信息,请参阅第 7.7.5 节。
列表 9.20 Bitbucket Pipelines 的 CD 配置(chapter-9/example-1/bitbucket-pipelines.yaml)
image: hashicorp/terraform:0.12.6
pipelines:
default:
- step:
name: Build and deploy
services:
- docker
script:
- export VERSION=$BITBUCKET_BUILD_NUMBER
- chmod +x ./scripts/deploy.sh
- ./scripts/deploy.sh ①
① 调用我们的部署 shell 脚本
9.9.6 测试持续交付(CD)管道
现在我们准备测试我们的 CD 管道。假设你的仓库已配置并且你已启用 Bitbucket Pipelines(见第 9.9.2 节),我们需要推送代码更新。你还可以在 Bitbucket Pipelines 仪表板中手动触发 CD 管道,但通常触发部署的方式是更改一些代码并将更改推送到我们的托管仓库。让我们测试一下它是否工作。
尝试推送一个代码更改——只需要进行一个小改动即可。也许更改一下 UI 中的文本?然后保存文件,提交更改,并将其推送到 Bitbucket。然后你可以在 Bitbucket Pipelines 仪表板中观察管道被触发。
注意:第一次调用管道时,由于部署了你的基础设施和微服务的第一个实例,所以会花费一些时间。
一旦准备就绪,你就可以再次使用 kubectl get services(如第 9.8.6 节中所述)来获取网关的 IP 地址,然后在你的网页浏览器中加载并进行一些测试。现在你已经为持续部署做好了准备!你推送到 Bitbucket 的任何代码更改都将自动部署到生产环境。
9.9.7 添加自动化测试
你可以使用 CD 管道执行的最后一步是添加自动化测试。第九章的示例代码包括一些你可能已经在第 9.6 节中尝试过的自动化测试。将自动化测试添加到你的应用程序就像将正确的命令放在正确的位置一样简单。
这是因为我们遵循了约定。这意味着我们只需要知道一个命令,那就是 npm test。我们不需要记住我们是否在使用 Jest、Cypress 或其他 JavaScript 测试框架。无论我们使用什么,我们只需要确保 npm test 的脚本配置正确,以便调用它。
至于调用此命令的正确位置,这更困难,因为我们有一个完整的应用程序在一个仓库中,并且配置了一个 CD 管道。我们可以从我们的部署 shell 脚本(列表 9.21)或直接从我们的 Bitbucket Pipelines 配置文件(列表 9.22)中调用 npm test。列表 9.21 和 9.22 显示了这两种方法可能适用于元数据微服务。
如你所想,这仅仅是冰山一角。随着我们为其他微服务构建更多的自动化测试,我们不得不为每个微服务重复调用 npm test。这看起来并不优雅,但当我们过渡到多仓库可扩展部署架构时,这个问题将会得到解决。只需耐心等待第十一章的更多内容。
列表 9.21 将自动化测试添加到部署 shell 脚本中
set -e ①
cd ./metadata ②
npm install ③
npm test ④
cd .. ⑤
cd ./scripts
terraform init
terraform apply -auto-approve \
-var "app_version=$VERSION" \
-var "client_id=$ARM_CLIENT_ID" \
-var "client_secret=$ARM_CLIENT_SECRET"
① 导致 shell 脚本中后续失败的命令使整个脚本失败
② 切换到元数据微服务目录
③ 安装依赖项(这会安装 Jest)
④ 运行测试。如果失败,shell 会以错误代码终止。这反过来又会导致 CD 管道因错误而终止。
⑤ 切换回主项目目录
列表 9.22 将自动化测试直接添加到 CD 配置文件中
image: hashicorp/terraform:0.12.6
pipelines:
default:
- step:
name: Build and deploy
services:
- docker
script:
- cd metadata && npm install && npm test ①
- export VERSION=$BITBUCKET_BUILD_NUMBER
- chmod +x ./scripts/deploy.sh
- ./scripts/deploy.sh
① 直接从 Bitbucket Pipelines 配置文件中调用我们的自动化测试。如果测试失败,CD 管道会因错误而终止。
我们甚至可以直接从我们的 Terraform 代码中调用我们的自动化测试。但是,可以说,我们已经用构建和发布 Docker 镜像做了太多的事情,在 Terraform 中。我们已经将其用途扩展到了其原始的云基础设施供应目的之外,尽管这是启动我们的微服务应用程序的一种方便方式。在第十一章中,我们将讨论如何随着 FlixTube 的扩展来重构我们的开发和部署流程。
如第八章所述,在 CD 管道中运行 Cypress 实质上是一样的,尽管有一些额外的困难。我们再次将 npm test 调用,但这次配置为调用 Cypress 而不是 Jest。
Cypress 的问题在于它很大!每次管道被调用时都将其安装到我们的 CD 管道中是缓慢且低效的(尤其是如果你按分钟支付管道执行时间的话)。如果你想在 CD 管道中使用 Cypress,那么你将不得不学习如何使用你的 CD 提供者的缓存设施。但恐怕这本书已经太长了,所以你将不得不自己解决这个问题。你可以在 Bitbucket Pipelines 中了解更多关于缓存的信息:
support.atlassian.com/bitbucket-cloud/docs/cache-dependencies/
9.10 回顾
恭喜!如果你跟随着本章的内容,你现在已经在生产环境中运行了 FlixTube,并且已经准备好继续演进 FlixTube。你可以进行代码更改,在开发环境中测试它们,然后使用 CD 部署更新到生产环境。使用表 9.2 来回顾本章中使用的命令。
表 9.2 第九章命令回顾(续)
| 命令 | 描述 |
|---|---|
npm start |
不论主脚本文件命名如何或它期望的命令行参数如何,都是启动 Node.js 应用程序的传统 npm 脚本。通常,这会在 package.json 文件中转换为node index.js,但这完全取决于项目的作者以及他们如何设置。好事是,无论特定项目结构如何,您只需记住npm start即可。 |
npm run start:dev |
我启动 Node.js 项目开发时的个人约定。我将此添加到 package.json 中的脚本中,通常它会运行类似 Nodemon 的东西,以便在您工作时实时重新加载您的代码。 |
docker-compose up➥ --build |
根据当前工作目录中定义的 Docker Compose 文件(docker-compose.yaml)构建和实例化由多个容器组成的应用程序 |
docker-compose➥ down |
停止并销毁应用程序,使开发工作站保持干净状态 |
npm test |
运行测试的 npm 脚本约定。这可以运行 Jest 或 Cypress(甚至两者都可以),具体取决于您如何配置 package.json 文件。这是您应在 CD 管道中运行的命令,以执行测试套件。 |
npm run test:watch |
这是我在实时重新加载模式下运行测试的个人约定。您需要配置 package.json 文件中的此脚本才能使用它。 |
terraform init |
初始化 Terraform 项目并下载提供者插件 |
terraform apply |
在工作目录中执行 Terraform 脚本,以增量方式对我们的基础设施应用更改 |
terraform destroy |
销毁由 Terraform 项目创建的所有基础设施 |
9.11 FlixTube 的未来
FlixTube 接下来该往哪里发展?这需要您来想象!在第十一章中,我们将讨论 FlixTube 未来的技术方面:
-
我们如何扩大规模以适应不断增长的用户群体?
-
随着应用程序的增长和开发团队规模的增加,我们如何扩大我们的开发和部署流程?
目前,只需想象一下您将来希望添加到 FlixTube 中的微服务类型。图 9.18 为您提供了关于它随着增长可能看起来怎样的灵感。
9.12 继续你的学习
在本章中,我们研究了 FlixTube 示例应用程序的结构和布局。我们在开发环境中构建、运行和测试了它。然后我们通过其 CD 管道将其部署到生产环境。
您已经运行了 FlixTube,接下来该做什么呢?阅读任何书籍都只能让您走这么远。保留这些技能的关键是练习,练习,然后再练习。尝试对代码进行实验。尝试添加功能。尝试添加新的微服务。尝试破坏 FlixTube 以查看会发生什么。练习开发艺术是让您达到下一个层次的关键。

图 9.18 FlixTube 未来可能的样子
开发并非没有挑战。事实上,它是一连串不断的问题和解决方案的永无止境的过山车。当你遇到任何工具或技术的难题时,回到这本书中相应的章节进行回顾。你可能会找到你需要的答案。否则,你可能需要深入研究并探索其他资源。
本书最后几章包含的指导将帮助您在微服务未来的开发道路上导航。每章末尾的参考文献(包括本章)将帮助您继续您的学习之旅。但请记住,您成功的关键和保持这些技能的关键是持续的练习。
要了解 UI 开发,请参阅以下书籍:
-
《Angular 实战》 by Jeremy Wilken (Manning, 2018)
-
《使用 Mongo, Express, Angular 和 Node 实现 MEAN》,第 2 版,by Simon D. Holmes and Clive Harber (Manning, 2019)
-
《微前端实战》 by Michael Geers (Manning, 2020)
要了解更多关于使用微服务进行开发的信息,请参阅以下书籍:
-
《微服务实战》 by Morgan Bruce, Paulo A. Pereira (Manning, 2018)
-
《微服务模式》 by Chris Richardson (Manning, 2018)
-
《微服务之道》 by Richard Rodger (Manning, 2017)
-
《.NET Core 微服务》,第 2 版,by Christian Horsdal Gammelgaard (Manning, 2020)
-
《使用 Python 开发微服务 API》 by José Haro Peralta (Manning, est, Spring 2021)
要深入了解 Terraform,请参阅以下书籍:
- 《Terraform 实战》 by Scott Winkler (Manning, est Spring, 2021)
摘要
-
我们了解了 FlixTube 的整体工作原理,并在过程中遇到了一些新的微服务。
-
我们回顾了构建、运行、测试和部署微服务所需的一些基本工具。
-
我们在 FlixTube 应用程序的生产模式和开发模式下分别运行了一个单独的微服务,这使我们可以实现实时重载,然后再启动整个应用程序。
-
我们使用 Jest 和 Cypress 测试了 FlixTube。
-
为了使 FlixTube 可供公众使用,我们将其部署到生产环境,并上线了持续交付(CD)管道。
10 健康的微服务
本章涵盖
-
确保您的微服务保持健康的技术
-
微服务的日志记录和监控
-
微服务调试
-
可靠性和容错模式
错误会发生。代码有错误。硬件、软件和网络是不可靠的。所有类型的应用程序都会发生故障,而不仅仅是微服务。但微服务应用程序更加复杂,因此随着我们应用程序的增长,问题可能会变得相当严重。我们维护的微服务越多,在特定时间某些微服务出现行为不当的可能性就越大!
我们无法完全避免问题。这些问题是由人为错误或不可靠的基础设施引起的无关紧要。问题是肯定会发生的。但仅仅因为问题无法总是避免,并不意味着我们不应该尝试减轻这些问题。一个精心设计的应用程序会预见并考虑到问题,即使某些问题的具体性质无法预见。
随着我们的应用程序变得更加复杂,我们需要技术来应对问题并保持微服务的健康。我们的行业已经发展了许多“最佳”实践和模式来处理问题。在本章中,我们将介绍其中一些最有用的。遵循这些指导原则将使您的应用程序运行更加顺畅,更加可靠,从而在问题发生时减少压力,更容易从问题中恢复。
本章并不立即实用;GitHub 中没有示例代码,您也不能直接跟随。将其视为一个技术工具箱,您可以在未来尝试,随着您继续前进并继续开发自己的微服务应用程序。
10.1 维护健康的微服务
一个健康的微服务应用程序由健康的微服务组成。一个健康的微服务是指没有遇到问题,如错误、CPU 过载或内存耗尽。为了了解我们应用程序的健康状况,我们需要
-
监控我们的微服务以了解它们的当前状态
-
当问题发生时采取行动以保护我们的客户
-
在出现问题时进行调试并应用修复
以 FlixTube 的元数据微服务为例,图 10.1 展示了生产中健康微服务的架构。请注意,该微服务有多个副本,并且使用负载均衡器在微服务的实例之间均匀分配请求。如果任何单个微服务失效,副本可以替代,直到失败的实例重新启动。

图 10.1 生产中健康微服务的架构
这种冗余确保了微服务和应用程序的持续可靠性。在本章中,我们将学习在 Kubernetes 上复制微服务以及其他促进容错和从错误中恢复的技术。
即使没有停机的大幅影响,微服务也可能出现问题。我们如何知道微服务中发生了什么?它不必是一个黑盒。我们需要某种类型的日志聚合服务(如图 10.1 所示)来以我们可以理解的方式组合所有展示的微服务的日志。
我们可以做什么来确保我们的微服务保持健康?首先,类似于真正的医疗专业人士,我们必须知道如何测量体温。我们有多种技术可供我们诊断微服务的状态和行为。表 10.1 列出了本章我们将学习的主要技巧来测量微服务的体温。
表 10.1 监控微服务状态的技巧
| 技巧 | 描述 |
|---|---|
| 日志记录 | 将有关我们微服务行为的信息输出,以显示正在发生的事情以及何时发生。 |
| 错误处理 | 制定管理错误的策略,以记录失败的内容和失败的时间。 |
| 聚合 | 将所有微服务的相关信息合并到单个流中,这样我们就不必在微服务之间搜索所需的信息。 |
| 自动健康检查 | 配置 Kubernetes 以自动发现我们的微服务中的问题。 |
当出现问题时会发生什么?我们如何修复它?应对已经发生的问题需要调试。在本章中,我们将学习我们可以使用的技巧来找到问题的原因,以便我们可以修复它。
10.2 监控您的微服务
将我们的应用程序部署到生产环境只是第一步。之后,我们需要持续了解我们的应用程序是否在运行,尤其是在代码的新更新推出之后。
我们必须了解我们的应用程序正在做什么,否则我们无法知道里面发生了什么,除非我们知道它们,否则我们无法修复问题。在本节中,我们将探讨一些监控我们微服务行为的技巧:
-
记录日志
-
错误处理
-
日志聚合
-
自动健康检查
10.2.1 开发中的登录
将日志记录到控制台是我们理解微服务持续行为的最低级工具。通过日志记录,我们输出一个文本流,显示应用程序内部发生的重要事件、活动和操作。
来自应用程序的日志流可以被视为应用程序的历史记录,显示了在其整个生命周期中发生的所有相关事件。我们可以在开发和生产中使用控制台日志。图 10.2 展示了它在开发中的工作方式。

图 10.2 开发中的控制台日志
每个微服务,就像每个进程一样,都有两个用于日志记录的输出流:
-
标准输出
-
标准错误
在 JavaScript 中,我们像这样将日志输出到标准输出通道:
console.log("Useful information goes here");
我们像这样将错误输出到标准错误通道:
console.error("Useful information goes here");
注意:如果你使用的是除 JavaScript 之外的语言,那么它将有自己的函数来输出到标准输出和标准错误。
这就是我们输出到控制台所需的所有内容。我们实际上不需要复杂的日志系统。现代日志聚合系统通常会自动收集从容器中流出的标准输出和标准错误。我们很快就会看到它是如何工作的。
应该记录什么?
由于日志记录必须由开发者显式添加,并且始终是可选的,我们该如何选择要记录的内容呢?以下是一些示例:
-
应该记录什么:
-
应用程序中的相关事件及其细节
-
重要操作的成功/失败
-
-
不应该记录的内容:
-
可以从其他来源轻易确定的事情
-
任何秘密或敏感信息
-
有关你用户的任何个人详细信息
-
如果你发现自己被过多的日志细节淹没,你可以自由地进入并删除没有用的日志。对于每个控制台日志,你只需要问自己一个问题:没有这个细节我能活下去吗?如果你不需要它,就删除它。
虽然如此,但一般来说,更多的日志比更少的日志好。在生产环境中进行调试时,你需要尽可能多的帮助来了解问题发生的原因。通过日志回溯是理解导致问题的事件序列的重要步骤。
在问题发生之后,你将无法添加更多的日志!好吧,如果你能够隔离并重现问题,那么你可以这样做,但这本身可能很困难。更多的日志记录更好,因为当你遇到问题时,你希望拥有尽可能多的信息来帮助你解决问题。
10.2.2 错误处理
错误会发生。我们的用户会受到影响。这是计算机编程的基本法则!以下是一些错误的示例:
-
运行时错误(抛出异常导致我们的微服务崩溃)
-
输入错误数据(来自故障传感器或数据输入中的人为错误)
-
以意外组合或方式使用的代码
-
第三方依赖项失败(例如 RabbitMQ)
-
外部依赖项失败(例如 Azure 存储)
我们如何处理错误很重要。我们必须计划优雅地处理和恢复错误,以最大限度地减少对我们用户和业务的损害。当错误发生时会发生什么?我们的应用程序将如何处理这些问题?我们必须思考这些问题,并为我们的应用程序制定一个错误处理策略。
在我们的 JavaScript 代码中,我们通常会预测错误,并在我们的代码中使用异常、回调或承诺来处理这些错误。在这些情况下,我们通常知道该怎么做。我们可以重试失败的操作,或者如果可能,如果没有明显的自动纠正措施,我们可能需要向用户报告错误或通知我们的运维人员。
有时我们可以预见错误,有时则不能。我们可能会错过错误,因为我们不知道错误可能会发生,或者因为某些类型的错误(例如,硬盘故障)发生的频率如此之低,以至于不值得专门处理这些错误。为了安全起见,我们必须考虑到我们甚至无法想象的错误!
我们需要的是一种处理意外错误的一般策略。对于任何进程,包括单个微服务,这归结为两个主要选项:中断 和重启 或 恢复操作。您可以在图 10.3 中看到这些错误处理策略的说明。

图 10.3 处理意外错误的策略
中断和重启
中断和重启策略拦截意外错误,并通过重启进程来响应。使用此策略的最简单方法是忽略我们不关心的任何错误。任何我们没有在代码中使用 try/catch 语句明确处理的异常都会导致进程被中断。
这是最简单的错误处理策略,因为它实际上意味着什么都不做。只需允许意外错误发生,并让 Node.js 响应地中断我们的程序。当生产级微服务被中断时,我们将依赖 Kubernetes 自动为我们重启它,这是它的默认行为。(在 Kubernetes 中,这种行为也是可配置的。)
恢复操作
恢复操作策略拦截意外错误,并通过允许进程继续来响应。我们可以在 Node.js 中通过在 process 对象上处理 uncaughtException 事件来实现这一点:
process.on("uncaughtException", err => {
console.error("Uncaught exception:");
console.error(err && err.stack || err);
});
如果我们像这样处理事件,我们就明确控制了意外错误。在这种情况下,Node.js 不会采取默认的中断进程的操作。它只是简单地继续尽可能好地运行,我们必须希望错误没有使进程处于不良状态。
将错误打印到标准错误通道意味着它可以被我们的生产日志系统捕获,我们将在不久后讨论。然后,可以将此错误报告给我们的运维团队,而无需被忽视。
中断和重启:版本 2
现在我们已经了解了如何在 Node.js 中处理未捕获的异常,我们可以实现一个更好的版本的中断和重启策略:
process.on("uncaughtException", err => {
console.error("Uncaught exception:");
console.error(err && err.stack || err);
process.exit(1);
});
在此代码中,我们明确控制了意外错误的处理器。与之前一样,我们打印错误以便我们的运维团队能够注意到。接下来,我们通过调用 process.exit 明确终止程序。
我们向 exit 函数传递一个非零退出码。这是一个标准约定,表示进程因错误而终止。我们可以在这里使用不同的非零错误码(任何正数)来指示不同类型的错误。
我应该使用哪种错误处理策略?
关于是否重启,这是一个问题。许多开发者坚信应该终止并重启,在大多数情况下,简单地让我们的进程崩溃是一个好主意。因为尝试在崩溃后恢复微服务可能会使其处于损坏状态而无法正常工作。
使用 终止并重启,我们可以监控崩溃,以了解哪些微服务出现了需要解决的问题。如果你结合良好的错误报告,这是一个好的通用策略,你可以默认应用。
然而,有时我们可能需要使用 恢复操作 策略。对于某些微服务(例如处理客户数据的微服务),我们必须仔细考虑终止进程的后果。
以 FlixTube 的视频上传微服务为例,这个微服务在任何时候都可以被终止吗?在任何时刻,它可能正在接受来自多个用户的多个视频上传。终止这个微服务,可能会丢失用户上传,这是可以接受的吗?我会说不可以,但如果你是这个微服务,你可能有不同的看法,这也可以。没有一种正确的方式来处理这个问题。
注意:在决定使用哪种策略时,最好默认使用 终止并重启,但偶尔 恢复操作 可能会更合适。
10.2.3 使用 Docker Compose 进行日志记录
当在开发中使用 Docker Compose 时,我们可以在终端窗口中看到所有微服务的日志输出合并为一个流。Docker 自动收集日志并将其汇总成一个单一的流,如图 10.4 所示。显然,这有助于我们全面了解应用在任何给定时间正在做什么。

图 10.4 使用 Docker Compose 时,Docker 将所有微服务的日志汇总成一个单一的流。
将日志重定向到文件
这里有一个我发现非常实用的技巧。当我们运行 Docker Compose 时,我们可以重定向其输出并将其捕获到日志文件中。tee 命令意味着我们可以在终端显示输出同时将其保存到文件中。

现在我们可以将日志文件(在这个例子中是 debug.log)加载到 VS code 中,并随意浏览它。我们可以搜索特定的文本字符串。例如,如果我们正在尝试找到数据库的问题,我们可能会搜索包含“database”一词的日志。
我甚至喜欢在我的日志中放入特殊的代码(字符序列),以区分微服务特定子系统的日志。这使得搜索或过滤你感兴趣的日志类型变得更容易。
10.2.4 Kubernetes 中的基本日志记录
当在 Docker Compose 下开发中运行微服务时,我们在本地开发工作站上运行应用程序。这使得我们很容易看到应用程序的日志并理解代码中的情况。
从在 Kubernetes 上远程运行的生产微服务中检索日志要困难得多。要查看日志,我们必须能够从集群中提取它并将其拉回到我们的开发工作站进行分析。
假设我们能够验证我们的 Kubernetes 集群,使用 Kubectl 或 Kubernetes 仪表板分别检索单个微服务的日志相对容易。回顾第 6.12 节以提醒自己如何进行验证并开始使用这些工具。
Kubectl
我们在第六章中首次遇到 Kubectl,但现在我们将再次使用它来从 Kubernetes 上运行的特定容器中获取日志。假设我们正在运行第九章末尾的 FlixTube(如果您愿意,您可以这样做并跟随操作)。想象一下,我们想要从我们的元数据微服务的一个实例中获取日志。
由于我们可能有多个元数据微服务的实例(我们目前还没有,但本章后面我们将讨论创建副本),我们需要确定 Kubernetes 分配给我们所感兴趣的特定微服务的唯一名称。
我们实际上在这里寻找的是 pod 的名称。您可能还记得第六章中提到的 Kubernetes pod 是包含我们的容器的东西。一个 pod 实际上可以运行多个容器,尽管对于 FlixTube 来说,我们目前每个 pod 只运行一个容器。在按照第 6.12.1 节所述验证 Kubectl 之后,现在使用 get pods 命令查看集群中所有 pod 的完整列表,如下所示:

在列表中向下扫描以找到元数据微服务的 pod 名称,并找到其唯一名称。在这种情况下,名称是 metadata-55bb6bdf58-7pjn2。现在我们可以使用 logs 命令来检索元数据微服务的日志。在这种情况下,没有太多可看的内容,但知道如何做是有帮助的。

只需记得将 pod 的名称替换为集群中实际微服务的名称。唯一名称是由 Kubernetes 生成的,因此您的元数据微服务的名称不会与我的版本生成的名称相同。以下是命令的一般模板:
kubectl logs <pod-name>
只需插入您想要检索日志的特定 pod 名称。
Kubernetes Dashboard
在您的集群中查看单个容器的日志的另一种方法是使用 Kubernetes 仪表板。这是一种视觉方式来检查和探索您的集群,您甚至可以对其进行修改(尽管,我不建议手动调整生产集群!)。
我们在第六章首次遇到 Kubernetes 仪表板。如果你还没有这样做,你可以按照第 6.12 节的说明来安装、验证和连接到你的仪表板。一旦连接,你可以快速钻取到任何 pod 以查看其日志。图 10.5、10.6 和 10.7 显示了此过程。注意在图 10.5 和 10.6 中,还有其他有用的信息可以帮助我们了解微服务的状态。

图 10.5 显示我们集群中所有 pod 的 Kubernetes 仪表板。

图 10.6 查看包含我们的元数据微服务的 pod 的详细信息

图 10.7 查看元数据微服务的日志
10.2.5 为 Kubernetes 手动实现日志聚合
通过追踪如前几节所示每个单独微服务的日志,我们可以走很长的路来找到问题。我建议你在可行的情况下尽可能这样做,因为为 Kubernetes 实现日志聚合是一个困难的任务。
最终,随着你的应用程序的增长,你可能会厌倦为每个微服务分别追踪日志。不幸的是,Kubernetes 没有内置从集群容器中聚合日志的方法。我真心希望 Kubernetes 的开发者在未来能提供一个简单的解决方案;如果有一个简单的方法可以启用来自集群的单个日志流,我们可以用它来监控整个应用程序的行为,那将是非常好的。
然而,也有企业解决方案,我们将在下一节中探讨其中之一。企业解决方案可能很重且成本高昂,而且这些解决方案并不一定使事情变得更容易。这些解决方案的设置和配置也可能相当困难且耗时。如果你在寻找一个更轻量级的解决方案,你可以根据图 10.8 所示自行构建 Kubernetes 聚合系统。

图 10.8 Kubernetes 日志聚合的滚动
图 10.8 中显示的聚合服务是一个轻量级的微服务,它运行在每个 Kubernetes 节点上。实现这个服务的难点在于你必须将其部署为DaemonSet。这是一种 Kubernetes 部署类型,它在集群中的每个节点上运行一个容器。为什么我们需要这样做呢?这是因为我们需要访问每个节点的文件系统,其中存储着日志文件。Kubernetes 会自动记录每个容器的标准输出和标准错误到日志文件中,但这些文件仅限于节点内部访问。
聚合服务将节点上运行的容器中的所有日志转发到外部的 收集服务。收集服务是一个轻量级的微服务。它的唯一任务是接收通过 HTTP 请求传入的日志,并将这些日志存储在其数据库中。然后,通过基于 Web 的仪表板将日志数据库显示给我们的开发人员和运维人员。
注意,收集服务本身位于集群之外。我们本可以将它放在集群内部,但那样的话,集群的问题(正是我们试图检测的问题)可能会妨碍我们收集日志的能力。当你的日志收集器托管在出现问题的集群内部时,调试集群中的问题可能会很困难。
这种手工打造的日志系统在应用程序的早期阶段实际上工作得相当不错。实现这个系统是一个很好的学习经历,但只有当你想深入了解 Kubernetes 的内部工作原理时才这样做。想了解更多信息并尝试自己构建这个系统,请阅读我关于 Kubernetes 日志聚合的博客文章:
www.the-data-wrangler.com/kubernetes-log-aggregation/
10.2.6 企业级日志、监控和警报
对于大规模企业级微服务监控的一个常见解决方案是 Fluentd、Elasticsearch 和 Kibana 的组合。其他专门用于监控指标的选择是 Prometheus 和 Grafana。这些都是用于监控和警报的专业企业级可扩展解决方案。但它们可能很重,资源密集,所以不要急于将这些解决方案应用到你的应用程序中。
我们在这里不会深入探讨这些技术细节,因为这超出了本书的范围。现在,对每种技术有一个简要的了解就足够了。
Fluentd
Fluentd 是一个用 Ruby 编写的开源日志和数据收集服务。你可以在你的集群中实例化一个 Fluentd 容器,以便将你的日志转发到外部的日志收集器。
Fluentd 是灵活的,可以通过其许多插件进行扩展。其中一个插件允许我们将日志转发到 Elasticsearch。想了解更多关于 Fluentd 的信息,请访问以下网站:
Elasticsearch
Elasticsearch 是一个用 Java 编写的开源搜索引擎。你可以使用 Elasticsearch 来存储和检索日志、指标和其他有用的数据。更多关于 Elasticsearch 的信息,请访问他们的网站:
Kibana
Kibana 是所有这些选项中最有趣的一个。它是一个基于 Elasticsearch 的开源可视化仪表板。
Kibana 允许我们查看、搜索和可视化我们的日志和其他指标。您可以使用 Kibana 创建出色的自定义仪表板。图 10.9 展示了一个包含 Kubernetes 集群指标的仪表板示例。

图 10.9 演示 Kibana 仪表板截图,包含 Kubernetes 集群的指标
Kibana 的好处之一,它可以真正救命,是您可以配置它以在集群中出现问题时代自动提醒您。您指定触发警报的条件以及采取的操作。
Kibana 的付费版本还支持电子邮件通知和一些其他选项,包括触发 webhook 以调用所需的任何自定义响应。有关 Kibana 的更多信息,请从以下网站了解:
https://www.elastic.co/what-is/kibana
您可以在以下位置找到 Kibana 演示仪表板:
您可以在此浏览支持的提醒:
www.elastic.co/guide/en/kibana/master/action-types.html
Prometheus
Prometheus 是一个开源的监控系统和时序数据库。与 Kubernetes 一起,Prometheus 是云原生计算基金会(CNCF)的一个毕业项目,这使得它与一些非常受尊敬的公司并列。
我们可以配置 Prometheus 定期从我们的微服务中抓取指标,并在出现问题时自动提醒我们。有关 Prometheus 的更多信息,请在此处了解:
Grafana
虽然 Prometheus 在数据收集、查询和警报方面很出色,但在可视化方面就不那么好了。我们可以用 Prometheus 创建简单的图表,但它相当有限。
幸运的是,Grafana 允许我们创建视觉和交互式仪表板,并且连接到 Prometheus 非常容易。有关 Grafana 的更多信息,请在此处了解:
10.2.7 使用 Kubernetes 健康检查自动重启
Kubernetes 具有自动健康检查的出色功能,允许我们自动检测和重启不健康的微服务。您可能不需要这个特定功能,因为 Kubernetes 已经将不健康的微服务定义为崩溃或退出的微服务。默认情况下,Kubernetes 会自动重启行为不当的容器。
如果我们对默认设置不满意,Kubernetes 允许我们根据具体情况创建自己的“不健康”定义。我们可以为每个微服务定义一个就绪探测和存活探测,以便 Kubernetes 查询微服务的健康状况。就绪探测显示微服务是否已启动并准备好开始接受请求。存活探测随后显示微服务是否仍然存活并且仍在接受请求。两者都在图 10.10 中展示。

图 10.10 将自动 Kubernetes 健康检查应用于元数据微服务
我们可以使用这两个 Kubernetes 功能优雅地解决我们在第五章首次将历史微服务连接到我们的 RabbitMQ 服务器时发现的问题(5.8.5 节)。问题是历史微服务(或任何连接到上游依赖的微服务)必须在连接并使用它之前等待其依赖项(在这种情况下,是 RabbitMQ)启动。
如果微服务尝试过早地连接,它将简单地抛出一个异常,这可能导致进程终止。如果我们能让历史微服务安静地等待直到 RabbitMQ 可用会更好。这就是为什么我们在第五章中使用了 wait-port npm 模块,但这只是一个笨拙的解决方案。然而,使用 Kubernetes,我们现在有了优雅解决问题的工具。
如上所述的问题仅在微服务应用首次启动时真正发生。一旦您的生产应用正在运行,并且 RabbitMQ 服务器已经启动,您就可以轻松且安全地引入依赖 RabbitMQ 的新微服务,而无需它们等待。但不要认为这不是一个问题,因为这个问题还有另一面:
-
当 RabbitMQ 崩溃并被 Kubernetes 自动重启时会发生什么?
-
如果我们想暂时关闭 RabbitMQ 进行升级或维护,会发生什么情况?
在这两种情况下,RabbitMQ 都会离线,这会中断所有依赖它的微服务的连接。对于这些微服务来说,默认操作(除非我们特别处理)是抛出一个未处理的异常,这很可能会导致微服务终止。现在,任何依赖 RabbitMQ 的微服务在 RabbitMQ 离线时都会不断崩溃和重启。
这也适用于除了 RabbitMQ 之外的其他系统依赖。一般来说,我们希望能够将任何服务离线,并让下游服务安静地等待该服务再次可用。当服务上线时,下游服务可以恢复正常操作。
我们现在可以使用就绪和存活探针来解决这些问题。以下列表显示了第九章中 Terraform 代码的更新,该代码定义了微服务的就绪和存活探针。
列表 10.1 为微服务实现 Kubernetes 就绪和存活探针(第九章的更新 - chapter-9/example-1/scripts/modules/microservice/main.tf)
container {
image = local.image_tag
name = var.service_name
env {
name = "PORT"
value = "80"
}
dynamic "env" {
for_each = var.env
content {
name = env.key
value = env.value
}
}
readiness_probe { ①
http_get {
path = "/ready" ②
port = 80
}
} ①
liveness_probe { ③
http_get {
path = "/alive" ④
port = 80
}
} ③
}
① 为微服务定义就绪探针
② Kubernetes 向 /ready 路由发送 HTTP 请求以确定微服务是否准备好接受请求。
③ 为微服务定义存活探针
④ Kubernetes 向 /alive 路由发送 HTTP 请求以确定微服务是否仍在接受请求。
如果你想亲自尝试列表 10.1 中的代码,你可以将更新输入到文件 chapter-9/example-1/scripts/modules/microservice/main.tf 中的代码。然后你需要运行terraform apply来将更改应用到你在第九章中部署的 FlixTube 的现有版本。如果你没有这样做,或者如果你已经关闭了你的 FlixTube 生产版本,运行terraform apply将部署 FlixTube 的新实例。
为了进行这个更改,我们还需要为所有我们的微服务添加针对/ready 和/alive 的 HTTP GET 路由处理程序。但这些路由应该做什么呢?
在最简单的情况下,我们只需返回 HTTP 状态码 200 以表示成功。这足以通过这两个探测,并让 Kubernetes 知道微服务既就绪又活跃。在某些情况下(例如,对于历史微服务),我们还可以添加额外的代码来自定义就绪和活跃的定义。在依赖于 RabbitMQ 的任何微服务中,我们都会添加代码来
-
当 RabbitMQ 可用时,仅返回状态码 200 的/ready 路由。 这告诉 Kubernetes 微服务已进入其就绪状态。
-
当 RabbitMQ 不可用时,返回错误代码的/alive 路由。 这会导致微服务重启,但由于/ready 路由,新的微服务(直到 RabbitMQ 重新上线)不会处于就绪状态。
这种策略解决了两个问题。首先,如果我们没有使用就绪和存活探测,我们的历史微服务将在 RabbitMQ 故障时不断启动、崩溃和重启。这种不断的重启并不是我们资源的高效利用,而且它将生成大量的错误日志,我们需要分析这些日志(以防其中隐藏着真正的问题!)。
第二,我们可以在微服务中显式处理这个问题,通过检测 RabbitMQ 何时断开连接,然后不断轮询以查看我们是否可以重新连接。这将使微服务免于不断崩溃和重启,但这需要在我们的微服务中编写更复杂的代码来处理与 RabbitMQ 的断开和重新连接。我们不需要编写这样的复杂代码,因为这就是探测为我们所做的事情。要了解更多关于 Pod 生命周期和不同类型的探测的信息,请参阅 Kubernetes 文档:
kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/
10.2.8 微服务间的跟踪
我还有一件关于日志和微服务的事情要告诉你。能够通过你的集群关联请求字符串是非常有用的。我们通过生成一个唯一的关联 ID(CID)来实现这一点,我们可以将其附加到我们的请求上,以便将它们相互关联。
您可以在图 10.11 中看到这是如何工作的。当一个 HTTP 请求首先到达我们的网关微服务时,会生成一个唯一的 CID 并附加到请求上。随着请求通过系统(无论是通过 HTTP 请求还是 RabbitMQ 消息)转发,ID 保持附加状态,我们可以使用它来追踪相关请求通过我们应用程序的路径。
CID 关联了请求完整链的所有日志、错误、指标和其他信息。当监控或探索我们应用程序的行为时,这些信息非常有用。如果您没有这些信息,那么请求如何以及在哪里深入到我们的应用程序中就不是很明显了。

图 10.11 使用关联 ID (CID) 通过您的集群关联请求字符串
我们可以使用 npm 上的 uuid 库创建唯一的 ID。安装后,我们可以创建这样的唯一 ID:
const { v4: uuid } = require("uuid");
const cid = uuid();
然后,我们可以将唯一的 ID 附加到转发的 HTTP 请求的头部(使用 http.request 或 Axios 都很容易做到),或者我们可以将 ID 添加到 RabbitMQ 消息的有效负载中。要严肃地追踪您的请求,您将需要获取 Zipkin。这是一个允许您在应用程序中可视追踪请求的工具。您可以在网上了解更多关于 Zipkin 的信息:
Zipkin 的代码可以在 GitHub 上找到:
10.3 调试微服务
在设置某种形式的监控后,我们可以看到我们应用程序的日志和指标。我们使用这些信息来了解其当前状态和历史行为。当出现问题时,手头上有这类信息非常有用。
一旦问题变得明显,我们现在必须戴上侦探帽。我们需要分析我们所拥有的信息,以了解什么出了问题。然后,我们将线索追踪回根本原因,以找出为什么它发生了。在这个过程中,我们将进行实验,以进一步缩小问题根源。
通常,我们无法修复问题,直到我们确定了原因。当然,有时我们可以在不知道原因的情况下随机找到解决方案。但无论如何,能够确定根本原因总是明智的。这样,我们可以确保所谓的修复实际上解决了问题,而不仅仅是掩盖了它。
调试是追踪问题根源并随后应用适当修复的过程的名称。调试微服务类似于调试任何其他类型的应用程序;它是一种既包含艺术又包含科学的问题解决形式。
然而,由于应用程序的分布式特性,调试微服务更为困难。在单个进程中定位问题本身就很难,但在由许多相互作用的进程组成的应用程序中找到问题则更加麻烦。
正如你可能已经怀疑的那样,寻找问题的根源实际上是调试中最困难的部分。这就像在干草堆里寻找传说中的针。如果你有任何关于在哪里寻找问题的线索,你就有更大的机会快速找到它。这就是为什么熟悉特定代码库的开发者比不太熟悉的人更快地找到其中的错误。
在找到问题的根源之后,我们现在必须修复它。幸运的是,修复一个错误通常(但不总是)比最初找到它要快得多。
10.3.1 调试过程
在理想的世界里,我们会在开发和测试期间找到并修复所有问题。事实上,如果你有彻底的测试实践和/或全面的自动化测试套件,你将在生产之前发现许多错误。如果可能的话,这是最好的方法,因为与在生产环境中(可能分布在数据中心的多台服务器上)相比,在开发环境中(在你的开发工作站上)调试要容易得多。要调试任何代码,我们可以遵循以下过程:
-
收集证据
-
减轻客户影响
-
隔离问题
-
重新复现问题
-
解决问题
-
反思
正如任何既是艺术又是科学的东西一样,这实际上不是一个严格定义的过程。有时,我们必须以不可预测的方式在这些步骤中追踪一个迭代的路径。不过,为了解释的目的,让我们假设我们可以通过以直接线性方式通过这些步骤来解决我们的问题。
收集证据
调试过程的开始总是尽可能地收集有关问题的证据。这包括任何可以帮助我们更快地找到错误真正位置的东西。如果我们从问题实际发生的地方开始调试,我们就可以更快地缩小范围。我们需要尽可能快地了解尽可能多的关于问题的信息。这就像
-
日志和错误报告
-
系统中相关请求路径的跟踪(如第 10.2.8 节所述)
-
用户提交的错误报告
-
来自 Kubernetes CLI 工具或仪表板的信
-
任何可能发生的崩溃的调用栈
-
受影响的代码版本或分支
-
最近部署的代码或微服务
我们必须立即编译这些信息的原因是,通常,为了我们客户的利益,我们接下来必须做的事情是尽可能快地让问题消失。
减轻客户影响
在尝试解决问题或找到问题的原因之前,我们必须确保它不会对我们的客户产生不利影响。如果我们的客户受到负面影响,那么我们必须立即采取行动纠正这种情况。
在这一点上,我们不在乎问题是由什么引起的,或者它的真正长期解决方案可能是什么。我们只需要以最快的方式恢复客户所依赖的功能。他们会感激我们立即采取行动找到一种解决方案,让他们能够继续使用我们的应用程序。我们可以通过多种方式做到这一点:
-
如果问题来自最近的代码更新,则撤销该更新并将代码重新部署到生产环境中。由于我们知道更新的微服务导致了问题,因此使用微服务通常更容易这样做,我们可以轻松地撤销单个微服务并将其恢复到之前工作的版本,比如容器注册库中的一个更早的镜像。
-
如果问题来自客户不需要的新的或更新的功能,我们可以禁用该单个功能以恢复应用程序的工作状态。
-
如果问题来自一个不是急需的微服务,我们可以暂时将该微服务停用。
我无法过分强调这一步骤的重要性!解决问题可能需要几个小时或几天(在最坏的情况下甚至可能需要几周)。我们无法提前知道需要多长时间,也不能期望我们的客户等待。更有可能的是,他们会转向我们的竞争对手之一。
更糟糕的是,在压力下解决问题(因为我们的客户在等待我们)会非常紧张,并导致决策不佳。我们在压力下实施的任何修复都可能引入更多的错误,这只会使问题更加复杂。
为了我们客户和我们自己,我们必须暂时忽略这个问题,找到最快的方法将我们的应用程序恢复到工作状态(如图 10.12 所示)。这样做可以减轻压力,让我们的客户能够不间断地继续使用,同时也为我们解决问题争取了时间。

图 10.12 在发现问题后,通过立即回滚到先前的有效版本来减轻对我们客户的危害
重新复制问题
在确保应用程序再次为我们的客户正常工作后,我们现在可以继续寻找问题的原因并解决问题。为此,我们必须能够重新复制问题。除非我们能够肯定且一致地重复问题,否则我们永远无法确定我们已经修复了它。我们的目标是创建一个测试用例来演示这个错误。这是一个我们可以遵循的文档化步骤序列,以可靠地引起错误显示出来。
理想情况下,我们希望在开发工作站上重新复制这个错误。这使我们可以更容易地运行实验来追踪错误。然而,有些问题非常复杂,我们无法在开发环境中轻松地复制这些问题,特别是当你的应用程序变得非常大(例如,它有多个微服务)以至于不再适合整个地放在一台计算机上。
在这种情况下,我们必须在测试环境中重现问题。这是一个类似生产环境的环境,但纯粹用于测试(它不面向客户)。尽管在测试环境中(与生产环境中的调试类似)调试仍然可能很困难,但最终,我们仍然希望在开发环境中重现问题。
在测试环境中,我们可以进行实验,进一步了解哪些应用程序组件参与了问题,然后安全地移除那些没有贡献的组件。通过排除法,我们可以将应用程序缩减到足够小,以便在开发环境中运行。在这个时候,我们可以从测试环境转移到我们的开发工作站。我们将在第十一章中更多地讨论创建测试环境。
如果我们在进行自动化测试,这就是我们应该编写一个自动化测试来检查 bug 是否被修复的时刻。当然,这个测试最初会失败——这正是它的目的。我们稍后会用它作为一个可靠的方式来知道问题已经被修复。
编写自动化测试也确保了我们能够反复重现问题。每次运行这个测试时,它都应该失败,从而确认我们确实找到了一种可靠的重现 bug 的方法。
隔离问题
一旦我们在开发环境中重现了问题,我们现在开始隔离问题的过程。我们反复进行实验,逐步缩小应用程序的范围,直到我们缩小了范围并确定了 bug 的确切来源。
我们实际上是在逐步缩小问题可能隐藏的空间,直到问题的根源变得明显。我们正在使用一种类似于图 10.13 所示的分而治之的过程。
顺便说一句,微服务非常适合这个。我们的应用程序已经很好地分解成易于分离的组件。这使得将我们的应用程序拆分成各个部分变得容易得多。一般来说,只需从 Docker Compose 文件中注释掉单个微服务即可将其从应用程序中移除!随着你移除每个微服务,问自己一个问题:你还能重现问题吗?
-
是的。太好了。你已经通过减少一个微服务来缩小了问题域。
-
不。太好了。你可能刚刚将那个微服务牵涉到问题中。
无论哪种方式,你都是在通过迭代的方式接近问题的根源。

图 10.13 逐步缩小问题空间,直到我们隔离出 bug 的确切来源
有时我们很快就能确定问题的根源。在其他时候,调试可能是一个痛苦缓慢、耗时且令人沮丧的过程。这很大程度上取决于我们的总体经验水平、我们对代码库的熟悉程度、我们是否以前见过这种类型的问题,以及特定问题的复杂性。
注意:调试最糟糕的情况需要毅力、耐心和承诺。不要害怕寻求帮助。没有什么比陷入一个无法解决的问题更糟糕的了。
如果你知道从哪里开始寻找问题,那么你已经取得了巨大的优势。你也可能能够对导致问题的原因做出有根据的猜测。如果这奏效了,你完全有理由跳过这个过程的大部分内容,并立即将注意力集中在问题的原因上。然而,如果你不知道该往哪里看,或者你的猜测是错误的,你将不得不更加科学地进行调试,并应用这个完整的过程。
修复问题
你已经确定了问题的根本原因。现在你只需要修复它!
幸运的是,解决问题比最初找到问题要容易得多。通常,识别出有问题的代码就足以让你想象出解决方案。有时,这会更困难,你可能需要投入一些创造性思维来想出解决方案。但最困难的部分肯定已经过去了。你已经找到了针尖上的麦芒,现在你可以找到最好的方法来移除它。
如果你正在进行自动化测试,并且已经编写了重现问题的失败的测试用例,那么你有一个方便且可靠的标尺来告诉你何时修复了错误。即使修复变得困难,至少你有一种方法可以确信问题已经解决。当你迭代和实验以找到解决方案时,这是一件很有用的事情。
反思
每当我们解决问题时,我们都应该暂停片刻,反思如何防止问题在未来再次发生,或者如何更快地发现和修复问题。反思对我们个人和团队来说都很重要,以不断改进我们的开发过程。
我们可能已经编写了一个自动化测试,可以防止未来再次出现这个问题。但仍然,我们需要更多。我们应该寻求实践和习惯来帮助我们消除,而不仅仅是这个具体的问题,而是所有这类或这类的问题。
我们花在反思上的时间和我们投入在升级我们的开发过程中的时间在很大程度上取决于问题本身及其严重性。我们应该提出像以下问题:
-
这种问题未来可能再次发生,以至于我们应该主动减轻其影响吗?
-
这个问题的后果严重到足以让我们主动减轻其影响吗?
回答这些问题有助于我们了解在将来应对这类问题需要投入多少努力。
10.3.2 调试生产级微服务
有时候我们无法避免这种情况;我们实际上必须在生产中调试我们的微服务。如果我们无法在测试或开发中重现问题,那么我们唯一的选择就是进一步了解生产中正在发生的问题。
如果我们需要进行比日志记录所能提供的更深入的检查,我们可以使用 Kubernetes CLI 工具(kubectl)在任何容器(至少是安装了 shell 的任何容器)中打开一个终端。一旦您知道了 Pod 的名称(请参阅第 10.2.4 节),例如包含元数据微服务的 Pod,我们就可以像这样打开一个 shell 到它:
kubectl exec --stdin --tty metadata-55bb6bdf58-7pjn2 -- sh
您可能已经注意到,在图 10.6 中,您还可以使用 Kubernetes 仪表板打开一个 Pod 的终端。现在我们可以在生产微服务内部调用 shell 命令。
如您可能已经感受到的,我们正处于极其危险的领域。当您身处这样的微服务内部时,潜在的损害很大,任何错误都可能导致问题变得更加严重!不要随意在生产微服务上执行 shell 命令,如果您确实这样做,请不要更改任何内容。有更好的、更安全的方法来诊断问题!
这只有在它影响我们的客户时才重要。如果您在自己的私有集群或测试环境中调试微服务,那么您不会影响任何客户;因此,您可以随意推动、刺激和探索您的微服务——这是很好的学习经验!只是不要在生产微服务上这样做。
10.4 可靠性和恢复
我们无法避免问题,但我们可以以多种方式在我们的应用程序中处理这些问题,以在面临故障时保持服务。在我们的应用程序在生产中,我们期望它以一定的可靠性运行,并且我们可以采用许多策略来构建健壮和可靠的系统。本节概述了一些实践和技术,可以帮助我们构建容错系统,这些系统能够快速从故障中恢复。
10.4.1 实践防御性编程
第一步是带着防御性编程的心态进行编码。以这种方式工作时,我们期望会发生错误,即使我们无法预知这些错误可能是什么。我们始终应该期待以下情况:
-
我们代码可能会接收到不良的输入。
-
我们代码中包含尚未显现的 bug。
-
我们所依赖的东西(例如,RabbitMQ)并不总是 100%可靠的,偶尔也会出现他们自己的问题。
当我们采取防御性思维时,我们会自动开始寻找让我们的代码在意外情况下表现得更加优雅的方法。容错始于编码层面。它始于每个微服务内部。
10.4.2 实践防御性测试
如你所知,测试在构建弹性可靠系统中起着巨大的作用。我们在第八章中介绍了测试,所以在这里我想说的只是,测试“正常”代码路径是不够的。我们还应该测试我们创建的软件可以处理错误。这是防御编程的下一步。
我们应该编写测试来积极攻击我们的代码。这有助于我们识别需要更多关注的脆弱代码。我们需要确保我们的代码可以优雅地恢复,报告错误,并处理不寻常的情况。
10.4.3 保护你的数据
所有应用程序都处理用户数据,我们必须采取必要的步骤来保护我们的数据,以防发生故障。当意外故障发生时,我们需要有信心,我们最重要的数据没有被损坏或丢失。错误会发生;我们数据的丢失不应该发生。
并非所有数据都同等重要。在我们系统中生成(因此可以重新生成)的数据不如从我们的客户那里捕获的数据重要。尽管所有数据都很重要,但我们必须最重视保护源数据。
保护数据的第一步显然是备份。备份应该是自动化的。大多数云服务提供商都提供了你可以启用的此类功能。
注意:不要忘记练习恢复你的备份!如果无法恢复备份,备份将完全无用。
至少现在,如果最坏的情况发生,我们可以从备份中恢复丢失或损坏的数据。在业界,我们有一句话:我们的数据不存在,除非它至少存在于三个地方。以下是一些我们可以遵循的其他保护数据的指南:
-
一旦数据被捕获,就安全地记录数据
-
永远不要编写覆盖源数据的代码
-
永远不要编写删除源数据的代码
捕获我们数据的代码是我们应用程序中最重要的代码之一,我们应该以适当的尊重来对待它。它应该经过极其严格的测试。它还应该是最小化和尽可能简单的,因为简单的代码留给错误和安全问题隐藏的空间更少。
我们永远不应该覆盖或删除源数据的原因是,该代码中的错误可以轻易地损坏或破坏数据。我们知道错误会发生,对吧?我们处于防御心态,因此我们预计会发生不可预见的问题。要了解更多关于使用和保护数据的信息,请参阅我的书籍《使用 JavaScript 进行数据整理》(Manning,2018 年)。
10.4.4 复制和冗余
解决微服务失败的最佳方式是通过冗余。我们通过在负载均衡器后面拥有多个(通常至少三个)每个微服务的实例来实现这一点,如图 10.14 所示。负载均衡器是一种服务,它将传入的请求分配到多个微服务中,以便“负载”在它们之间均匀分布。
如果任何微服务发生故障,负载均衡器会立即将传入请求重定向到其他实例。在此期间,Kubernetes 会重启失败的实例。这种冗余意味着即使在间歇性故障的情况下,我们也能保持连续的服务水平。

图 10.14 负载均衡器将传入请求分配到多个冗余的微服务实例。
冗余是通过复制实现的。我们也会使用复制来提高性能,但我们会留到第十一章再讨论。
虽然我们的系统可以处理故障,但这并不意味着我们应该容忍这些故障。所有故障都应该被记录下来,并在之后进行调查。我们可以使用第 10.3 节中的调试过程来找到并修复故障的原因。
在 Kubernetes 中实现复制
到目前为止,我们为 FlixTube 部署的所有微服务(在第七章和第九章中)都只有一个实例。当创建一个用于学习的应用程序(就像我们用 FlixTube 做的那样)或者你处于开发自己的微服务应用程序的初期阶段时,这是完全可以接受的。但这并不像它本可以做到的那样具有容错性。
这很容易解决,因为 Kubernetes 让我们很容易创建副本。令人惊讶的是,这和更改我们已编写的 Terraform 代码中的一个字段的值一样简单——这就是基础设施即代码的力量。
我们可以通过设置 Kubernetes 部署中 replicas 属性的值来轻松更改副本的数量。您可以在列表 10.2 中看到这个例子,这是对第九章中 Terraform 代码的更新。
副本数量已从 1 更新到 3。我们可以通过运行 terraform apply 来应用这个更改。一旦完成,我们的所有微服务都将拥有三个冗余实例。通过这个小小的改变,我们极大地提高了应用程序的可靠性和容错性!
我们副本的负载均衡器是由列表 10.2 末尾定义的 Kubernetes 服务创建的。在处理第七章和第九章时,我们始终为我们的微服务提供了一个负载均衡器,但它只将负载分配给单个微服务!通过列表中我们做出的更改,现在负载正在三个实例之间分配给每个微服务。
列表 10.2 在 Kubernetes 上创建负载均衡的微服务副本(对第九章示例 1 的脚本 modules/microservice/main.tf 的更新)
resource "kubernetes_deployment"
➥ "service_deployment" { ①
depends_on = [ null_resource.docker_push ]
metadata {
name = var.service_name
labels = {
pod = var.service_name
}
}
spec {
replicas = 3 ②
selector {
match_labels = {
pod = var.service_name
}
}
template {
metadata {
labels = {
pod = var.service_name
}
}
spec {
container {
image = local.image_tag
name = var.service_name
env {
name = "PORT"
value = "80"
}
}
}
}
}
}
resource "kubernetes_service" "service" {
metadata {
name = var.dns_name
}
spec {
selector = {
pod = kubernetes_deployment.service_deployment
➥ .metadata[0].labels.pod
}
session_affinity = var.session_affinity
port {
port = 80
target_port = 80
}
type = var.service_type
}
}
① 配置每个微服务的 Kubernetes 部署
② 将副本数量设置为 3。再次运行“terraform apply”将创建每个微服务的三个副本。
10.4.5 故障隔离和优雅降级
微服务真正擅长的一件事是故障隔离。然而,我们必须小心一些,以便能够利用这一点。我们的目标是使集群内部的问题得到隔离,以便它们对用户的影响最小化。
在适当机制到位的情况下,我们的应用程序可以优雅地处理故障,并防止这些故障在前端表现为问题。我们需要的工具包括超时、重试、断路器和防波堤,这些将在以下章节中描述。
例如,让我们考虑视频上传微服务。想象一下,它出了问题,不再可用。在这个时候,我们正在努力纠正这种情况,并迅速将其恢复到工作状态。与此同时,我们的客户希望继续使用我们的产品。如果我们没有预防机制,错误可能会一直传播到前端,导致我们的服务中断,严重扰乱我们的客户。
相反,我们应该实施保护措施,防止这种对用户群体的全面破坏。这如图 10.15 所示。图的上半部分显示了错误一直传播到用户并给他们带来问题。图 10.15 的下半部分显示了它应该如何工作:网关阻止错误传播,从而在集群内限制故障。
然后,我们可以通过向用户显示错误消息来处理这种情况,说明视频上传功能目前不可用。视频上传可能已损坏,但我们的用户可以继续使用应用程序的其他部分。
这就是微服务带来的巨大好处。如果我们使用单体应用,并且其中一个组件(例如,视频上传组件)损坏,通常会导致整个单体崩溃,让我们的客户一无所有。然而,使用微服务,故障可以被隔离,整个应用程序可以继续运行,尽管是降级状态。
这种故障隔离的想法通常被称为防波堤模式,之所以这样命名,是因为它在概念上与大型船舶中实际使用的防波堤相似。当船舶发生泄漏时,是防波堤阻止泄漏逃逸到其他舱室,并最终导致船舶沉没。这是现实世界中的故障隔离,你可以看到它如何与微服务应用程序相似。

图 10.15 集群内隔离故障于用户
10.4.6 容错简单技术
这里有一些简单的技术,您可以立即开始使用,以在您自己的微服务应用程序中实现容错和故障隔离。
超时
在这本书中,我们使用了内置的 Node.js http.request函数和 Axios 代码库来在微服务之间进行内部 HTTP 请求。我们控制自己的微服务,大多数时候我们知道它们会快速响应集群内部的请求。然而,有时问题会显现出来,一个内部微服务停止响应。
在未来,我们还想向外部服务发起请求。想象一下,我们将 FlixTube 与 Dropbox 集成,作为导入新视频的手段。当我们向类似 Dropbox 这样的外部服务发起请求时,我们无法控制这些服务对请求的响应速度。这样的外部服务偶尔会因维护而关闭,因此,外部服务如 Dropbox 间歇性地停止响应我们的请求是完全可能的。
我们必须考虑如何处理不响应的服务请求。如果请求在短时间内无法完成,我们希望在经过一定最大时间后将其终止。如果我们不这样做,请求可能需要很长时间(如果有的话)才能完成。我们实在无法让我们的客户等待那么久!我们宁愿快速终止请求,并告诉客户出了些问题,而不是让他们无限期地等待。
我们可以使用 超时 来处理这个问题。超时是在请求自动因错误代码被终止之前可以经过的最大时间。为我们的请求设置超时允许我们控制我们的应用程序对失败的反应速度。快速失败是我们想要的,因为另一种选择是缓慢失败,如果某件事要失败,我们希望尽可能快地处理它,以免浪费客户的时间。
使用 Axios 设置超时
阅读 Axios 文档告诉我,默认超时是无限大!这意味着默认情况下,Axios 请求可以无限期地进行而不会被终止。我们绝对需要为任何使用 Axios 发起的请求设置超时。
你可以为每个请求设置超时,但这需要重复的努力。幸运的是,使用 Axios,我们可以为所有请求设置默认超时,如下面的列表所示。
列表 10.3 使用 Axios 设置 HTTP 请求的默认超时时间
const axios = require("axios");
axios.defaults.timeout = 2500; ①
① 将请求的默认超时设置为 2500 毫秒或 2.5 秒
重试
我们知道 HTTP 请求有时会失败。我们无法控制外部服务,也无法看到那些服务的代码。对我们来说,很难确定这些服务的可靠性,即使是可靠性最高的服务也可能会有间歇性故障。

图 10.16 重试 HTTP 请求直到成功
处理这个问题的一个简单方法是在多次尝试中简单地重试操作,并希望它在后续尝试中成功。这如图 10.16 所示。在这个例子中,你可以想象 FlixTube 的视频存储微服务请求从 Azure 存储检索视频。由于不可确定的原因,此类请求偶尔会失败。在图 10.16 中,连续两次请求因间歇性连接错误而失败,但第三次请求成功。
假设网络是可靠的,这是分布式计算中的一个谬误,我们必须采取措施来减轻请求失败的影响。在 JavaScript 中的实现并不特别困难。在列表 10.4 中,你可以看到我已在多个项目中使用的一个retry函数的实现。retry函数包装了其他异步操作,如 HTTP 请求,这样就可以尝试多次。
列表 10.4 还包括了一个有用的sleep函数,用于在尝试之间创建暂停。立即再次尝试请求是没有意义的。如果我们做得太快,它很可能会再次失败。在这种情况下,我们在再次尝试之前给它一些时间。
列表 10.5 展示了如何调用retry函数,展示了它如何包装 HTTP GET 请求。在这个例子中,我们允许请求重试三次,每次请求之间暂停 5 毫秒。
列表 10.4 JavaScript 中重试函数的实现
async function sleep(timeMS) { ①
return new Promise((resolve, reject) => { ②
setTimeout( ③
() => { resolve(); }, ④
timeMS ⑤
);
}); ②
}
async function retry(operation, maxAttempts,
➥ waitTimeMS) { ⑥
while (maxAttempts-- > 0) { ⑦
try {
const result = await operation(); ⑧
return result; ⑨
}
catch (err) { ⑩
lastError = err; ⑪
if (maxAttempts >= 1) {
await sleep(waitTimeMS); ⑫
}
}
}
throw lastError; ⑬
}
① 定义了一个“sleep”函数,可用于在重试之间暂停
② 在 Promise 中包装对“setTimeout”的调用,以便我们等待暂停完成
③ 在经过一段时间后调用回调
④ 回调解决 Promise。
⑤ 设置暂停的持续时间
⑥ 定义了一个“重试”函数,我们可以用它来对任何异步操作进行多次尝试
⑦ 循环直到达到最大重试次数
⑧ 尝试实际异步操作
⑨ 操作成功!这会跳出循环并返回异步操作的结果。
⑩ 处理异步操作抛出的任何错误
⑪ 记录最近尝试中的错误
⑫ 在下一次尝试之前暂停片刻(只要我们不是最后一次尝试)
⑬ 抛出最后一次尝试的错误。我们已经用完了重试次数,所以我们必须让错误冒泡到调用者。
列表 10.5 使用重试函数(示例)
await retry( ①
() => axios.get("https://something/something”), ②
3, ③
5 ④
);
① 调用我们的“retry”函数
② 要重试的操作;在这个例子中,它是一个使用 Axios 的 HTTP GET 请求。
③ 将最大尝试次数设置为三次
④ 将重试之间的时间设置为五毫秒
10.4.7 容错的高级技术
我们已经看到了一些提高我们应用程序可靠性和弹性的简单技术。当然,还有许多其他更高级的技术我们可以部署来提高容错性和从失败中恢复。
我们几乎超出了本书的范围,但我仍然想与你分享一些更高级技术的简要概述。这些技术在你为应用程序构建更健壮的架构时将非常有用。
工作队列
工作队列是许多应用程序架构中找到的一种微服务。这与我们在 RabbitMQ 中看到的消息队列是不同的事物。它很相似,但它的复杂度更高。
我们使用工作队列来管理重量级处理任务。让我们想象一下 FlixTube 未来版本的工作方式。我们可以这样说,每个视频在上传后都需要大量的处理。例如,我们希望从视频中提取缩略图。或者,也许我们希望将视频转换为较低分辨率,以便在移动设备上更好地播放性能。这些是在视频上传后应该发生的任务类型。
现在想象一下,1,000 个用户几乎同时上传了视频。我们还没有任何弹性扩展(我们将在第十一章中讨论)。那么,我们如何管理这么多视频同时进入 FlixTube 产生的巨大处理工作负载呢?这就是工作队列的作用。您可以在图 10.17 中看到其工作原理的示意图。

图 10.17 一个工作队列微服务管理着视频缩略图生成作业的队列。
工作队列将需要执行的任务序列记录到数据库中。这使得它对故障具有弹性。整个应用程序可能会崩溃并重新启动,但只要数据库存活,我们就可以重新加载工作队列并继续处理之前中断的地方。个别任务也可能失败;例如,执行处理的微服务崩溃了,但由于失败的任务没有被标记为完成,它们会自然地在稍后再次尝试。
工作队列还允许控制此处理性能。我们不必一次性将应用程序性能最大化来处理 1,000 个上传的视频,我们可以将负载分散,以便在更长的时间段内进行调度。它也可以在非高峰时段进行调度。这意味着我们不必为可能需要的额外计算能力付费,如果我们想一次性在大量爆发中进行处理的话。
电路断路器
电路断路器类似于超时的更高级版本。它有一些内置的智能来理解何时出现问题,以便更智能地处理这些问题。图 10.18 说明了电路断路器的工作原理。

图 10.18 电路断路器工作原理示意图
在正常情况下,电路断路器的状态设置为开启,并允许 HTTP 请求像往常一样通过(1)。如果在某个时刻对特定资源的请求失败(2),电路断路器将切换到关闭状态(3)。在关闭状态下,电路断路器总是立即拒绝新的请求。
将其视为一个“超级”超时。电路断路器知道上游系统目前正在失败,因此它甚至懒得检查。它立即拒绝传入的请求!
快速失败是我们使用超时的原因。快速失败比缓慢失败要好。电路断路器通过已经知道我们正在失败,因此,它不仅可以更快地失败,而且可以立即失败。
定期地,在它自己的时间(你可以配置延迟),断路器会检查上游服务是否已恢复正常操作。当这种情况发生时,断路器会切换回开启状态(4)。未来的入站请求现在可以正常通过。实现断路器比实现超时或重试要困难得多,但值得记住以备将来使用,特别是如果你发现自己需要更复杂的技术。
10.5 继续你的学习
你现在工具箱里有很多技术来保持你的微服务健康和可靠!要了解更多关于构建可靠微服务的信息,Manning 出版了一些关于微服务稳定性的免费章节,这些章节来自 Morgan Bruce 和 Paulo A. Pereira 的书籍 Microservices in Action(2018 年)以及 Richard Rodger 的 The Tao of Microservices(2017 年)。你可以在以下链接找到这些内容:
此外,还有一本关于如何对应用程序进行崩溃测试的精彩书籍:
- Chaos Engineering by Mikolaj Pawlikowski (Manning, 2020)
要了解更多关于生产中的日志记录和监控的信息,请阅读以下书籍:
-
Unified Logging with Fluentd by Phil Wilkins (Manning, est Summer 2020)
-
Elasticsearch in Action by Radu Gheorghe, Matthew Lee Hinman, and Roy Russo (Manning, 2015)
摘要
-
Logging and error handling outputs key information used to understand the behavior and state of our microservices.
-
监控是确定我们的微服务健康状况和检测问题的关键。
-
Aggregation combines the output of all our microservices into a single easily accessible stream of information.
-
Kubernetes 健康检查可以用来自动检测我们的微服务中的问题。
-
当检测到问题时,我们必须通过调试过程来找出问题的原因,并确定适当的修复方案。
-
确保我们的微服务可靠性和容错性的技术有很多,包括副本和负载均衡、自动重启、超时、重试、作业队列和断路器。
11 条通往可扩展性的路径
本章涵盖
-
将微服务扩展到更大的开发团队
-
将微服务扩展以满足不断增长的需求
-
理解基本的安全问题
-
将单体转换为微服务的策略
-
在预算内使用微服务的技巧
我们整本书都在致力于构建生产级的微服务应用程序,那么接下来呢?是时候看看微服务在未来能为我们提供什么了。
在整本书中,我们采取了许多捷径,帮助我们快速、低成本地开始使用微服务。这些捷径使学习微服务和启动我们初出茅庐的应用程序变得更加简单。尽管 FlixTube 是一个使用相对简单的过程构建的简单应用程序,但我们仍在使用微服务,这是一种提供许多通往未来可扩展性的路径的架构。
在本章中,我们讨论如何管理不断增长的微服务应用程序。我们如何扩大到更大的开发团队?我们如何扩大以满足不断增长的客户需求?我们还需要讨论基本的安全问题以及它们如何与微服务相关。然后,我们将简要介绍将现有单体应用程序转换为微服务所需的内容。
我们将通过重申可以使启动微服务应用程序更简单、更轻松、更便宜的技术来结束本书。这是对小团队、初创公司或独立开发者启动自己的微服务应用程序同时仍拥有充满扩展可能性的未来的实用建议!
11.1 我们的未来是可扩展的
微服务为我们提供了许多通往可扩展产品的路径。在本章中,我们将探讨我们必须采取哪些措施来扩展我们的应用程序和工作流程,以便围绕不断增长的应用程序扩大我们的开发团队。我们将通过探讨如何扩展应用程序的性能以实现更大的容量和吞吐量来跟进。
你可能现在还不需要这些技术;只有当你的应用程序足够大,需要扩大你的开发团队时,或者当你的客户群增加,你需要为了更好的性能而进行扩展时,你才需要这些技术。
我们正在进入一个非常高级的领域,本章主要为你提供未来如何扩展应用程序的方法的初步了解。这仅仅是冰山一角;但足以让你对未来的道路有所认识。
本章我们将解决的问题都是好问题。如果你到了必须扩展的地步,那是一件好事。这意味着你的业务是成功的。这意味着你有一个不断增长的客户群。在这个时候,你可以非常高兴地选择微服务架构,因为它使扩展变得更加直接。
本章的目的不是实际操作。将其视为一些洞察,了解您的微服务之旅未来可能走向何方。话虽如此,许多这些技术相当容易尝试,但在尝试的过程中,您可能会犯错误并无意中破坏您的应用程序集群。
不要在生产基础设施上尝试任何这些,这些基础设施是现有员工或客户所依赖的。但请随意回到第九章,并遵循那里的说明来启动 FlixTube 的新生产实例。您可以使用它进行实验。这为您提供了一个风险免费的方式来尝试本章中听起来有趣的内容。
11.2 扩展开发流程
首先,让我们解决扩展我们的开发流程的问题。到目前为止,在这本书中,我们已经从单个开发者在小微服务应用程序上工作的角度体验了开发流程和生产工作流程。现在,让我们将我们的关注点提升到团队的水平。我们迄今为止使用的简单流程实际上对于一个小团队来说可以起到一定作用:
-
在单一代码库上工作的开发者,在他们的开发工作站上编写和测试代码
-
开发者将代码更改推送到托管代码仓库,这会触发持续交付(CD)管道将应用程序部署到生产环境
这个简单的流程是开始构建新应用程序并快速推进的绝佳方式。但我们的初步开发流程存在以下问题:
-
我们不希望代码直接从开发者流向客户。 我们希望我们的开发者能够在类似生产环境的环境中测试他们的代码,但我们希望“正在进行的工作”能够从客户那里缓冲,以确保在将其施加于他们之前它能够良好运行。
-
我们不希望开发者相互干扰。 随着我们开发团队的扩大,在单一代码库中工作的开发者将更频繁地相互干扰(例如,导致合并冲突和破坏构建)。
-
我们的单一代码仓库和 CD 管道不可扩展。 为了管理我们不断增长的应用程序的复杂性,我们必须将其拆分,即使应用程序可能变得极其复杂,每个单独的微服务仍然保持小型、简单且易于管理。
为了构建一个可扩展的开发流程,扩展到多个团队,并充分利用微服务,我们必须进行一些重组。
11.2.1 多个团队
随着我们应用的发展,我们将添加更多的微服务来实现功能并扩展应用程序的能力。随着工作量的增长,我们也需要扩大团队来处理它。在某个时候,当我们的单个团队变得太大时,我们需要将其拆分为多个团队。这使我们的团队保持小型化,并使我们能够从小型团队带来的沟通和组织优势中受益。

图 11.1 当启动一个新应用程序时,它应该足够小,以至于一个团队可以自己管理所有微服务。
基于微服务的应用程序提供了自然缝隙,可以用来分割应用程序,供多个团队进行开发。图 11.1 展示了我们在开发早期阶段,使用简单开发过程时的团队结构。

图 11.2 随着我们的应用程序不断增长,开发工作可以分割,以便不同的团队管理独立的微服务或微服务组。
图 11.2 展示了我们在增长并分割成不同团队后的结构可能看起来是什么样子。我们将应用程序分割,使每个团队负责一组不同的微服务,且没有重叠。这有助于阻止团队之间相互干扰。现在,我们可以通过在微服务边界上分割应用程序来将我们的团队规模扩大到任何我们想要的规模。
每个团队拥有一个或多个微服务,通常,他们负责自己的微服务——从编码、测试,到生产。团队通常负责其微服务的运营需求,保持这些微服务在线、健康和高效。
当然,有许多方法可以实现这一点,任何两家公司的团队结构和开发过程在细节上都会有所不同。但是,这种组织自给自足团队的方法是可扩展的。这意味着我们可以在一个巨大的应用程序周围发展一个庞大的公司,同时仍然有一个有效的开发过程。
11.2.2 独立微服务
到目前为止,我们开发的 FlixTube 应用程序生活在单个代码仓库中,并有一个单一的 CD 流水线。您可以在图 11.3 中看到这是怎样的情况。
在处理任何新的微服务项目时,使用所谓的单(单体)仓库(单代码仓库)是一个很好的开始方式。它使启动过程更简单、更容易,我们将花费更少的时间来创建和维护我们的开发基础设施(支持我们的开发过程的基础设施)。

图 11.3 当启动一个新的微服务应用程序时,对于整个应用程序来说,拥有一个单一的代码仓库和一个单一的 CD 流水线会更简单。
拥有一个单代码仓库和单一的 CD 流水线在开始时会使事情变得简单,但不幸的是,它消除了使用微服务的主要好处。拥有单个仓库和 CD 流水线意味着我们必须同步发布所有微服务!实际上,我们没有能力独立发布微服务的更新!这意味着每次部署,我们都可能破坏整个应用程序!这种情况并不比单体架构更好!我们的部署过程是单体的!
你可能还记得,在第一章的开头,我们实际上将微服务定义为一个拥有自己独立部署计划的独立软件过程(参见 1.5 节)。我们还没有真正实现这一点,为了从使用微服务中获得最大好处,我们真的需要使它们具有独立的部署能力。这种样子在图 11.4 中有说明。
拥有单独的代码仓库和多个 CD 管道允许我们对部署有更细粒度的控制。如果我们能够逐个独立更新我们的微服务,那么我们的部署风险就会大大降低。我们不必每次部署都冒着破坏整个应用的风险,我们只冒着破坏单个微服务的风险。

图 11.4 随着我们应用的增长,我们需要将我们的微服务拆分到单独的代码仓库和 CD 管道中,以获得独立部署的微服务带来的好处。
如果将转换到多个仓库和 CD 管道看作是一项巨大的工作量并且增加了太多的额外复杂性,我完全理解你的感受!事实上,我会争辩说,这一件事是导致通常归因于微服务的感知复杂性的主要原因。
在这一点上,我想再次强调,拥有一个单仓库和单一持续交付(CD)管道仍然是开始任何新的微服务应用的不错方式。它使得在开发的早期阶段事情保持简单。一旦你转换到多个代码仓库和多个 CD 管道,管理和维护就会变得更加复杂,至少在应用层面是这样。这仅仅是随着发展而来的。
尽管我们的整体应用最终会变得极其复杂(这对现代企业应用来说是不可避免的),如果我们能够将我们的视角降低到单个微服务,画面看起来就完全不同了。事情突然看起来简单多了。因为复杂性是逐渐增加的,所以它更容易管理。而且,通过关注单个微服务(它们是简单的)而不是关注整个应用(注定是复杂的),应用的整体复杂性影响就会小得多。
这正是解决微服务应用复杂性的关键。单个微服务是一个微小且易于理解的程序,拥有较小的代码库。它有一个相对简单的部署过程。每个微服务都很简单,易于管理,尽管它们一起构建出强大而复杂的程序。从复杂的应用程序到简单的微服务的这种视角转变对于管理复杂性非常重要。
将我们的开发过程分割成微服务大小的块增加了一些额外的复杂性,但与我们的应用程序最终可能变得多么复杂相比,这微不足道。通过将我们的关注点从整个应用程序的复杂性转向单个微服务,我们实际上已经让我们的应用程序能够扩展到真正巨大的规模,即使每个微服务仍然像以前一样简单易用。
然而,不要过于热衷于将这种变更应用于可独立部署的微服务。如果你过早地做出这种变更,可能会发现你正在为过渡的成本买单,而此时还太早从中获益。你不想在能够利用好处之前就支付成本。
良好的软件开发完全是关于做出良好的权衡。只要这对你有意义,就坚持使用单仓库和单个 CD 管道。但请注意,这并不是应该的方式。随着你的应用程序变得更加复杂,以及随着你团队的扩大,这种简单的方法最终会崩溃。最终会有一个时刻,分割我们的部署管道对于扩展规模是必要的,同时保持一个高效的开发过程。
11.2.3 分割代码仓库
我们的首要任务是分割我们的单仓库(monorepo)成多个代码仓库,以便为每个微服务拥有一个明确且独立的仓库。每个新的仓库将包含单个微服务的代码以及部署到生产环境的代码。
我们还需要一个独立的代码仓库来存放创建我们基础设施的 Terraform 代码。这是创建我们的容器注册库和 Kubernetes 集群的代码。这段代码不属于任何特定的微服务,因此它需要一个自己的代码仓库。
图 11.5 说明了我们如何将第九章的 FlixTube 项目拆分成多个代码仓库。为了构建每个新的仓库,我们调用git init来创建一个空仓库,然后将代码复制到新仓库并提交。否则,我们可能需要采取额外的步骤来保留我们的现有版本历史(见下文边栏)。

图 11.5 当我们分割我们的仓库时,每个微服务的子目录成为它自己的独立 Git 仓库。
保留版本历史
在从旧仓库创建新的代码仓库时,我们可以使用带有--subdirectory-filter参数的git filter-branch命令来保存我们的现有版本历史。要这样做,请参阅 Git 文档以获取详细信息:
git-scm.com/docs/git-filter-branch
你也可以在网上搜索“filter-branch”的例子——有很多!
11.2.4 分割持续交付(CD)管道
将我们的 monorepo 拆分开来相对容易。同时,我们还必须拆分我们的单体 CD 流水线,这是一个更困难的任务。我们现在需要为每个微服务创建一个单独的部署流水线。
幸运的是,我们可以创建一个单独的微服务部署流水线,然后为每个微服务重复使用它(如果需要,对每个微服务的定制需求进行少量修改)。我们的每个微服务的 CD 流水线将独立部署单个微服务,当更新后的代码推送到微服务的托管代码仓库时,会自动触发。图 11.6 说明了这个过程。

图 11.6 将代码推送到微服务的托管代码仓库会触发 CD 流水线,部署单个微服务。
单个微服务的 Terraform 部署代码类似于我们在第九章中看到的代码;实际上,它是该代码的简化版本。我们可以使用类似于我们在第六章和第七章中使用的迭代过程来开发这个 Terraform 代码。一旦我们确信部署流水线对单个微服务有效,我们就可以将其复制到每个微服务的代码仓库中——每个微服务的代码通过名称参数化。
每个代码仓库都需要启用并配置其流水线。如果您像我们在第七章和第九章中做的那样使用 Bitbucket Pipelines 进行持续交付(CD),您可以为每个仓库启用它,如 7.7.2 节中所示。然后,您必须为每个仓库添加一个单独的配置文件,如图 11.7 所示。

图 11.7 每个微服务的代码仓库都有自己的持续交付(CD)配置。
从 Terraform 中提取 Docker 构建
在这一点上,我们可以回顾我们如何进行 Docker 构建。我们现在能够从 Terraform 代码中提取这些。如果您还记得第七章,我们通过在 Terraform 中执行整个部署过程来简化了事情。
实际上,这并不完全正确;我当时没有提到,但由于我们最初在 Terraform 中创建所有基础设施,所以这样做是必要的解决方案。这包括创建我们的容器注册库。如果我们尝试从 Terraform 中提取 Docker,我们会发现第一次部署时,我们无法将镜像推送到容器注册库,因为它尚未创建!
这种“进退两难”的情况并没有因为 Terraform 的 Docker 提供者不支持构建和发布 Docker 镜像(真的,它应该升级以正确支持这一点)而得到改善。这就是我们被迫在 Terraform 中使用local-exec和null_resource作为一种丑陋的解决方案的原因(有关提醒,请参阅 7.6.2 节)。
然而,现在情况不同了。我们已经将 Terraform 代码分离到多个仓库中,并且我们的基础设施代码已经与微服务代码分离。在创建基础设施和部署微服务之间,我们可以通过在持续交付(CD)管道中直接调用 Docker(使用我们在第三章中学到的build和push命令)来简单地构建和发布 Docker 镜像。
我们可以创建一个单独的配置,然后将其作为每个微服务的模板重复使用。我们的“单个微服务”配置(Bitbucket Pipelines)在列表 11.1 中展示。在这个部署过程中,我们有两个步骤。第一步直接使用 Docker 构建和发布微服务的镜像。第二步使用 Terraform 将微服务部署到我们的 Kubernetes 集群。
列表 11.1 单个微服务的 Bitbucket Pipelines 配置文件(bitbucket-pipelines.yaml)
image: hashicorp/terraform:0.12.29 ①
pipelines:
default:
- step: ②
name: Build microservice
services:
- docker ③
script:
- export NAME=$BITBUCKET_REPO_SLUG ④
- export VERSION=$BITBUCKET_BUILD_NUMBER ⑤
- export IMAGE=
➥ $DOCKER_REGISTRY/$NAME:$VERSION ⑥
- docker build -t $IMAGE
➥-file ./Dockerfile-prod . ⑦
- docker login $DOCKER_REGISTRY --username $DOCKER_UN
➥-password $DOCKER_PW ⑧
- docker push $IMAGE ⑨
- step: ⑩
name: Deploy to cluster
deployment: production ⑪
script:
- export NAME=$BITBUCKET_REPO_SLUG ⑫
- export VERSION=$BITBUCKET_BUILD_NUMBER ⑫
- export IMAGE=$DOCKER_REGISTRY/ ⑫
➥ $NAME:$VERSION ⑫
- chmod +x ./scripts/deploy.sh
- ./scripts/deploy.sh ⑬
① 将基础镜像设置为 Terraform,这样我们就可以在 CD 管道中使用 Terraform
② 第 1 步:构建并发布微服务的 Docker 微服务
③ 启用在 CD 管道中使用 Docker
④ 使用代码仓库的名称作为微服务的名称
⑤ 使用构建号作为 Docker 镜像的版本号
⑥ 从微服务的名称和版本中组合镜像名称
⑦ 构建 Docker 镜像的生产版本
⑧ 登录到我们的私有容器注册库
⑨ 将新的 Docker 镜像推送到容器注册库
⑩ 第 2 步:将更新的微服务部署到 Kubernetes 集群
⑪ 跟踪当前部署到生产环境的内容(你可以在 Bitbucket Pipelines 仪表板中查看已部署的内容)。
⑫ 环境变量必须复制到每个步骤。
⑬ 执行部署 shell 脚本,并使用 Terraform 部署微服务
11.2.5 元仓库
使用独立的代码仓库让你感到沮丧吗?你是否怀念通过单个代码仓库管理应用程序的简单日子?好吧,这里有一些好消息。
我们可以创建一个元仓库,将所有独立的仓库组合成一个单一的聚合代码仓库。你可以将元仓库视为一种虚拟代码仓库。这意味着我们可以在不牺牲独立仓库的灵活性和独立性的情况下,找回一些单仓库的简单性和便利性。要创建元仓库,我们需要 meta 工具,可在以下位置获取:
通过创建一个包含多个独立仓库列表的.meta 配置文件来配置元仓库。请参见图 11.8,了解.meta 文件在 FlixTube 项目中的位置示例。列表 11.2 显示了该文件的结构。

图 11.8 .meta 配置文件将独立的仓库组合成一个元仓库。
列表 11.2 配置 FlixTube 的 meta 代码仓库 (.meta)
{
"projects": { ①
"gateway": "git@bitbucket.org:bootstrappingmicroservices/gateway.git", ①
"azure-storage": "git@bitbucket.org:bootstrappingmicroservices/ ①
➥ azure-storage.git", ①
"video-streaming": "git@bitbucket.org:bootstrappingmicroservices/ ①
➥ video-streaming.git", ①
"video-upload": "git@bitbucket.org:bootstrappingmicroservices/ ①
➥ video-upload.git", ①
"history": "git@bitbucket.org:bootstrappingmicroservices/history.git" ①
"metadata": "git@bitbucket.org:bootstrappingmicroservices/ ①
➥ metadata.git", ①
} ①
}L
① 列出构成此 meta-repo 的独立代码仓库
注意:在列表中,代码仓库链接指向 Bitbucket 代码仓库,但它们同样可以指向 GitHub 仓库或链接到我们托管代码仓库的任何其他地方。
使用 meta 允许我们运行影响整个仓库集合的单个 Git 命令。例如,假设我们想一次性拉取 FlixTube 项目下所有微服务的代码更改。我们可以使用 meta 通过单个命令来完成:
meta git pull
我们仍在使用独立的代码仓库,但 meta 允许我们同时对多个代码仓库执行命令,这样感觉就像我们又回到了使用单仓库的工作方式。
Meta 为我们提供了很多额外的灵活性。我们可以用它来创建自己的自定义微服务集合。作为一个大型团队的开发者,你可以创建一个仅针对你通常工作的微服务集合的 meta-repo。其他开发者可以拥有他们自己的独立 meta-repos。你可能甚至喜欢创建多个 meta-repos,这样你可以轻松地在不同的微服务集合之间切换,这取决于你目前正在做什么。
作为团队领导,你可以为应用程序的不同配置创建独立的 meta-repos,每个配置都有自己的 Docker Compose 文件。这使得团队成员克隆完整的微服务集合的代码变得容易。然后他们可以使用 Docker Compose 启动该应用程序配置。这是为团队成员提供一个“即时”且可管理的开发环境的一种极好方式!
11.2.6 创建多个环境
随着我们为应用程序获得客户,确保他们免受持续进行的“工作进行中”的问题的影响或保护他们免受部分完成或仅部分测试的新功能变得非常重要。开发团队需要一个类似生产环境的环境来测试他们的代码,在将其展示给客户之前。
每个开发者必须在他们的开发工作站上测试他们的代码,但这还不够。他们还必须在代码与其他开发者的更改集成后测试他们的代码。为了使其尽可能“真实”,这种测试应该在类似生产的环境中完成——只是不是我们的客户正在使用的那一个!
我们需要一个工作流程,让我们的开发者在从开发工作站,通过集成环境,进入测试环境,最终,一旦所有测试都通过,进入面向客户的环境之前,将他们的更改传递。尽管没有两家公司的流程完全相同,但你可以在图 11.9 中看到典型工作流程的样子。

图 11.9 在进入生产之前,通过开发和测试环境推进代码更改
设置多个环境实际上很简单,我们已经在第九章中展示的现有 Terraform 代码中拥有了大部分所需内容。我们已经在代码中通过 app_name 变量进行了参数化,我们使用这个变量根据我们为其指定的名称创建单独的应用程序资源(回顾第 6.10 节,我们首次添加了这个变量)。
我们现在可以在调用 Terraform 创建 FlixTube 的不同实例(用于测试和生产)时使用 app_name(从命令行设置)。我们只需为每个实例提供不同的名称。例如,我们可以将 app_name 设置为 flixtube-development、flixtube-test 或 flixtube-production 以创建我们各自的环境。
尽管如此,我们还可以在列表 11.3 中进行改进。通过引入一个名为 environment 的新变量,我们可以使创建新环境变得更加简单。然后我们将 app_name 转换为一个依赖于 environment 值的计算本地变量。
列表 11.3 在 Terraform 中设置 app_name 本地变量为环境名称(对第九章示例-1/scripts/variables.tf 的更新)
variable "environment" {} ①
locals {
app_name = "flixtube-${var.environment}" ②
}
① 添加一个新的 Terraform 变量,指定当前环境。在通过命令行运行 Terraform 时,我们需要提供此变量,例如将其设置为开发、测试或生产。
② 创建一个名为“app_name”的本地变量,为每个环境构建应用程序的不同版本(例如,flixtube-development、flixtube-test 或 flixtube-production)。
引入这个新变量(environment)允许我们从命令行设置当前环境。列表 11.4 展示了如何从另一个名为 ENVIRONMENT 的变量中输入值。
我们可以使用相同的 Terraform 项目创建尽可能多的单独环境,所有这些环境都托管在同一个云账户中,但通过名称区分(例如,flixtube-development、flixtube-test 或 flixtube-production)。您可以使用此功能创建如图 11.9 所示的工作流程或更复杂的工作流程,具体取决于您的需求。
列表 11.4 更新后的部署脚本以设置环境(对第九章示例-1/scripts/deploy.sh 的更新)
cd ./scripts
terraform init
terraform apply -auto-approve \
-var "app_version=$VERSION" \
-var "client_id=$ARM_CLIENT_ID" \
-var "client_secret=$ARM_CLIENT_SECRET" \
-var "environment=$ENVIRONMENT" \ ①
-var "storage_account_name=$STORAGE_ACCOUNT_NAME" \
-var "storage_access_key=$STORAGE_ACCESS_KEY" \
① 通过环境名称参数化我们的 Terraform 代码。我们通过操作系统环境变量传递正在部署的环境的名称。
11.2.7 生产工作流程
我们现在可以创建多个环境,并使用它们来构建测试工作流程,以保护我们的客户免受损坏代码的影响。剩下的问题是,我们如何触发特定环境的部署?这比你想象的要简单。
我们可以在代码仓库中使用单独的分支来针对不同的环境进行部署。图 11.10 展示了这样一个示例设置。这是一个相当简单的分支策略,但在实际中还有更复杂的版本。

图 11.10 开发、测试和生产分支的代码自动部署到相应的环境。
我们的开发团队在开发分支上工作。当他们向该分支推送代码时,会触发一个 CD 流水线,将其部署到开发环境。这允许我们的整个团队在一个类似生产的环境中频繁地集成和测试他们的更改。
开发者应该多久推送一次代码更改?尽可能频繁!每天至少一次,如果可能的话,每天多次。代码合并之间的时间越短,由于冲突更改和不良集成引起的错误就越少。这就是持续集成背后的理念,这是一个支撑持续交付(CD)的重要实践。
不太频繁(比如每周一次),我们会从开发分支合并到测试分支。这会触发测试环境的部署。从开发到测试的代码合并频率较低,这给我们时间来测试、修复问题,并在将代码交给客户之前稳定代码。
最后,当测试分支中的代码准备就绪(比如每 1-2 周一次),我们就将其合并到生产分支。这样会将更新的微服务部署到生产环境,以便我们的客户能够使用我们添加的任何新特性和错误修复。
这个工作流程可以应用于是否包含自动化测试。它为测试提供了充足的空间,并允许管理者有意识地决定是否部署到生产环境。当然,自动化测试使这一切变得更好,并且更具可扩展性!如果在工作流程的任何点上自动化测试失败,则部署将自动不允许。当出现此类问题时,团队必须共同努力纠正情况。良好的自动化测试的加入意味着我们可以安全地提高我们的部署频率,达到许多现代公司每天部署到生产环境的程度。
如果我们使用 Bitbucket Pipelines,我们可以轻松地为每个分支配置单独的 CD 流水线,如列表 11.5 所示。这个版本的 bitbucket-pipelines.yaml 配置文件为每个分支包含单独的部分。每个部分基本上是相同的,但我们可以通过不同的环境变量来配置用于部署每个分支的生产环境。
列表 11.5 为每个分支配置单独的 CD 流水线(bitbucket-pipelines.yaml)
image: hashicorp/terraform:0.12.6
pipelines:
branches: ①
development: ②
- step:
name: Build microservice
script:
# ... Commands to build and publish the microservice ...
- step: ③
name: Deploy cluster
script:
# ... Commands to deploy the microservice to the
dev environment ...
test: ④
- step:
name: Build microservice
script:
# ... Commands to build and publish the microservice ...
- step: ⑤
name: Deploy cluster
script:
# ... Commands to deploy the microservice to the
test environment ...
production: ⑥
- step:
name: Build microservice
script:
# ... Commands to build and publish the microservice ...
- step: ⑦
name: Deploy cluster
script:
# ... Commands to deploy the microservice to the
prod environment ...
① 为 Git 仓库中的每个分支创建单独的 CD 流水线
② 配置开发分支的流水线
③ 部署到开发环境
④ 配置测试分支的流水线
⑤ 部署到测试环境
⑥ 配置生产分支的流水线
⑦ 部署到生产环境
在实施这种多分支/多环境策略时需要注意的一点是,每个环境都需要其自己的独立 Terraform 状态。我们首先在第 7.7.4 节中配置了 Terraform 后端用于 CD。当时,我们在 Terraform 文件 backend.tf 中硬编码了与 Azure 存储的连接。现在我们必须改变这一点,以便我们可以从命令行设置存储配置。然后我们将为每个独立的环境进行更改。
首先,我们必须从我们的后端配置中移除key字段,如下所示。这个值需要根据环境的不同而变化,我们将将其设置为命令行参数而不是硬编码。
列表 11.6 配置 Terraform 状态的后端存储以支持多个环境(对第九章/example-1/scripts/backend.tf 的更新)
terraform {
backend "azurerm" { ①
①
resource_group_name = "terraform" ①
storage_account_name = "terraform" ①②
container_name = "terraform" ①
①
} ①
}
① 配置后端与第七章中相同,但我们移除了“key”字段,我们现在为每个环境单独设置它。
② 您必须为您的存储账户选择一个不同的名称。这是一个全局唯一的名称,因此您无法选择 terraform。
现在,我们可以从命令行配置 Terraform 后端的key字段,如列表 11.7 所示。本质上,我们做的是告诉 Terraform 将其状态配置存储在文件中,该文件名根据当前部署的环境而不同。例如,它可以被称为跟踪开发环境状态的 terraform-development.tfstate 文件,以及其他两个环境的 terraform-test.tfstate 和 terraform-production.tfstate。
列表 11.7 更新后的部署脚本,根据环境设置后端配置(对第九章/example-1/scripts/deploy.sh 的更新)
cd ./scripts
terraform init \
-backend-config=
➥ "key=terraform-${ENVIRONMENT}.tfstate" ①
terraform apply -auto-approve \
-var "app_version=$VERSION" \
-var "client_id=$ARM_CLIENT_ID" \
-var "client_secret=$ARM_CLIENT_SECRET" \
-var "environment=$ENVIRONMENT" \
-var "storage_account_name=$STORAGE_ACCOUNT_NAME" \
-var "storage_access_key=$STORAGE_ACCESS_KEY" \
① 初始化当前部署环境的后端配置中的“key”字段
11.3 性能缩放
我们不仅可以将微服务应用程序扩展到更大的开发团队,还可以为了更好的性能而扩展它们。我们的应用程序然后可以具有更高的容量,并可以处理更大的工作负载。
使用微服务使我们能够对我们的应用程序性能进行细粒度控制。我们可以轻松地测量微服务的性能(例如,参见图 11.11),以找到表现不佳、过度工作或在高需求时段过载的微服务。
然而,如果使用单体,我们对性能的控制将有限。我们可以垂直扩展单体,但这基本上就是全部了。水平扩展单体要困难得多。我们根本无法独立扩展单体中的任何“部分”。这是一个糟糕的情况,因为可能只有单体的一小部分导致了性能问题。然而,我们可能不得不垂直扩展整个单体来修复它!垂直扩展大型单体可能是一个昂贵的提议。
相反,在微服务中,我们有多种扩展选项。我们可以独立微调系统小部分的性能,以消除瓶颈并获得正确的性能结果组合。我们可以用许多高级方法来解决性能问题,但在这个部分,我们将概述以下(相对)简单的扩展我们的微服务应用程序的技术:
-
在整个集群中进行纵向扩展
-
在整个集群中进行横向扩展
-
横向扩展单个微服务
-
弹性扩展整个集群
-
弹性扩展单个微服务
-
扩展数据库
扩展通常需要对我们的集群进行风险配置更改。不要尝试直接对客户或员工依赖的生产集群进行任何这些更改。在本节的末尾,我们将简要介绍蓝绿部署,这是一种帮助我们以更少的风险管理大型基础设施更改的技术。

图 11.11 在 Kubernetes 仪表板中查看微服务的 CPU 和内存使用情况
11.3.1 纵向扩展集群
随着我们的应用程序的增长,我们可能会达到一个点,我们的集群通常没有足够的计算、内存或存储来运行我们的应用程序。随着我们添加新的微服务(或复制现有的微服务以实现冗余),我们最终会耗尽集群中的节点。(我们可以在 Azure 门户或 Kubernetes 仪表板中监控这一点。)在这个时候,我们必须增加集群可用的总资源量。在 Kubernetes 集群上扩展微服务时,我们可以同样容易地使用纵向或横向扩展。
图 11.12 显示了 Kubernetes 的纵向扩展看起来是什么样子。我们通过增加节点池中虚拟机(VM)的大小来扩展我们的集群。我们可能从三个小型的虚拟机开始,然后增加它们的大小,现在我们有三个大型的虚拟机。我们没有改变虚拟机的数量;我们只是增加了它们的大小。

图 11.12 通过增加虚拟机(VM)的大小来纵向扩展您的集群
在列表 11.8 中,我们将vm_size字段从Standard_B2ms更改为Standard_B4ms。这升级了我们 Kubernetes 节点池中每个虚拟机的大小。现在我们有了四个 CPU(每个虚拟机一个),内存和硬盘也增加了。您可以在这里自己比较 Azure 虚拟机大小:
我们在集群中仍然只有一个虚拟机,但我们已经增加了它的大小。扩展我们的集群就像代码更改一样简单。我们再次看到了 基础设施即代码 的力量,这是一种技术,我们将基础设施配置存储为代码,并通过提交触发我们的 CD 管道的代码更改来更改我们的基础设施。
列表 11.8 使用 Terraform 垂直扩展集群(更新至第九章示例 1 的 scripts/kubernetes-cluster.tf)
default_node_pool {
name = "default"
node_count = 1
vm_size = "Standard_B4ms" ①
}
① 为集群中的每个节点设置更大的虚拟机
11.3.2 水平扩展集群
除了垂直扩展我们的集群外,我们还可以水平扩展它。我们的虚拟机可以保持相同的大小,但我们只是简单地添加更多。通过向我们的集群添加更多虚拟机,我们将应用程序的负载分散到更多的计算机上。
图 11.13 展示了我们可以如何将我们的集群从三个虚拟机扩展到六个。每个虚拟机的大小保持不变,但通过拥有更多的虚拟机,我们获得了更多的计算能力。

图 11.13 通过增加虚拟机数量水平扩展您的集群
列表 11.9 展示了我们需要进行的代码更改,以向我们的节点池添加更多虚拟机。在列表 11.8 中,我们将 node_count 设置为 1,但在这里我们将其更改为 6!请注意,我们已经将 vm_size 字段恢复到较小的 Standard_B2ms 大小。`
在这个例子中,我们增加了虚拟机的数量,但没有增加其大小;尽管如此,我们也没有阻止自己同时增加虚拟机的数量和大小。不过,通常我们可能更倾向于水平扩展,因为它比垂直扩展更经济。这是因为使用许多较小的虚拟机比使用较少但更大、价格更高的虚拟机更便宜。
列表 11.9 使用 Terraform 水平扩展集群(更新至第九章示例 1 的 scripts/kubernetes-cluster.tf)
default_node_pool {
name = "default"
node_count = 6 ①
vm_size = "Standard_B2ms"
}
① 将节点池的大小增加到 6。现在集群由六个虚拟机供电!
11.3.3 水平扩展单个微服务
假设我们的集群已经扩展到足够的大小,可以以良好的性能托管所有微服务,那么当单个微服务过载时我们该怎么办?(这可以在 Kubernetes 仪表板中监控。)
答案是,对于任何成为性能瓶颈的微服务,我们可以通过水平扩展它来在其多个实例之间分配其负载。这如图 11.14 所示。我们实际上为这个特定的微服务提供了更多的计算、内存和存储,以便它能够处理更大的工作负载。

图 11.14 通过复制微服务来水平扩展
同样,我们可以使用代码来做出这种更改。实际上,我们在第十章的列表 10.2 中已经做了这个操作。代码片段在此列表 11.10 中再次重复。
我们将设置 replicas 字段为 3。在第十章中,我们为了冗余进行了这个更改。拥有多个实例意味着当任何单个实例失败时,其他实例可以暂时接管其负载,直到它重新启动。这里我们对 replicas 字段进行相同的更改,但这次是为了性能原因。我们通常需要出于这两个原因进行此更改。我们希望拥有冗余和良好的性能,而这通过在必要时创建我们的微服务的副本来解决。
列表 11.10 使用 Terraform 横向扩展微服务(第九章示例-1/scripts/modules/microservice/main.tf 的更新)
\spec {
replicas = 3 ①
selector {
match_labels = {
pod = var.service_name
}
}
template {
metadata {
labels = {
pod = var.service_name
}
}
spec {
container {
image = local.image_tag
name = var.service_name
env {
name = "PORT"
value = "80"
}
}
}
}
}
① 将微服务的副本数量设置为 3。我们现在可以在这三个微服务实例之间均匀分配负载。
11.3.4 集群的弹性扩展
进入更高级的领域,我们现在可以思考 弹性扩展,这是一种自动和动态调整我们的集群以满足不同需求水平的技术。在需求低峰期,Kubernetes 可以自动释放不需要的资源。在需求高峰期,它可以分配新的资源以满足增加的负载。这可以带来实质性的成本节约,因为在任何给定时刻,我们只为当时处理应用程序负载所需的资源付费。
我们可以在集群级别使用弹性扩展来自动扩展我们的集群,当它接近资源限制时。这同样只是一个代码更改。列表 11.11 展示了如何启用 Kubernetes 自动扩展器并设置节点池的最小和最大大小。
您可以将 Terraform 代码在 scripts/kubernetes-cluster.tf(第九章中的 example-1)中从列表 11.11 中的代码更新为启用 FlixTube Kubernetes 集群的横向扩展。扩展默认情况下是启用的,但我们有很多方法可以自定义它。在 Terraform 文档中搜索“auto_scaler_profile”以了解更多信息:
www.terraform.io/docs/providers/azurerm/r/kubernetes_cluster.html
列表 11.11 使用 Terraform 启用集群的弹性扩展(第九章示例-1/scripts/kubernetes-cluster.tf 的更新)
default_node_pool {
name = "default"
vm_size = "Standard_B2ms"
enable_auto_scaling = true ①
min_count = 3 ②
max_count = 20 ③
}
① 启用 Kubernetes 集群自动扩展
② 将最小节点数量设置为 3。这个集群从三个虚拟机开始。
③ 将最大节点数量设置为 20。这个集群可以自动扩展到 20 个虚拟机以满足需求。
11.3.5 单个微服务的弹性扩展
我们还可以在单个微服务级别启用弹性扩展。列表 11.12 是一段 Terraform 代码示例,可以为微服务提供“爆发”能力。微服务的副本数量会根据微服务的不同工作量动态扩展和收缩(活动爆发)。
您可以将列表 11.12 中的代码添加到第九章 example-1 中的 scripts/modules/microservice/main.tf 文件 Terraform 代码的末尾。然后,为了为 FlixTube 微服务启用弹性扩展,调用terraform apply。默认情况下,扩展会生效,但可以根据需要自定义以使用其他指标。有关更多信息,请参阅 Terraform 文档:
www.terraform.io/docs/providers/kubernetes/r/horizontal_pod_autoscaler.html
要了解有关 Kubernetes 中 Pod 自动扩展的更多信息,请参阅以下 Kubernetes 文档:
kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/
列表 11.12 使用 Terraform 为微服务启用弹性扩展(第九章/example-1/scripts/modules/microservice/main.tf 的补充)
resource "kubernetes_horizontal_pod_autoscaler" "service_autoscaler" {
metadata {
name = var.service_name
}
spec {
min_replicas = 3 ①
max_replicas = 20 ①
scale_target_ref {
kind = "Deployment"
name = var.service_name
}
}
}
① 设置此微服务的实例范围。它从 3 个实例开始,可以扩展到 20 个实例以满足不同的需求水平。
11.3.6 扩展数据库
我们将要探讨的最后一种扩展方式是扩展我们的数据库。回顾第四章,你可能还记得我们讨论了这样一个规则:每个微服务都应该有自己的数据库(参见 4.5.4 节)。
在微服务之间共享数据库存在多个问题;其中一个是它严重限制了我们的可扩展性。考虑图 11.15 中描述的情况。我们有多达多个微服务共享一个数据库。这是一个未来的可扩展性噩梦!
这些微服务不是独立的。共享数据库是这些服务之间固定的集成点,并且可能成为严重的性能瓶颈。如果微服务共享数据,这些服务将紧密耦合。这严重限制了我们在未来进行重构和重构的能力。通过共享数据库,我们阻碍了自己未来解决性能问题的能力。
这种场景可能会完全破坏我们费尽心思实现的“简单”扩展。如果我们想以这种方式构建我们的应用程序,我们不妨根本不使用微服务!

图 11.15 为什么我们不共享微服务之间的数据库(除非可能是同一微服务的副本)
相反,我们的应用程序应该看起来像图 11.16 所示。每个微服务都有自己的独立数据库。这些微服务是独立的,这意味着如果需要,我们可以轻松地应用水平扩展。

图 11.16 每个独立的微服务都应该有自己的数据库。
到目前为止,我想明确指出,仅仅因为我们必须拥有独立的数据库,并不意味着我们也需要独立的数据库服务器。管理数据库服务器是有成本的,我们通常希望将这种成本降至最低。正如图 11.17 所示,拥有一个包含我们独立数据库的单个数据库服务器可以使我们开始使用微服务更加简单和便宜。

图 11.17 在共享数据库服务器上运行独立的数据库是完全可行的(这是开始的最简单方式)。
在未来,如果我们发现任何特定数据库的工作负载增长过大,我们可以轻松创建一个新的数据库服务器,并将该数据库移动到其中,如图 11.18 所示。当需要时,我们可以为需要额外计算、内存或存储的任何数据库创建专用服务器。

图 11.18 随着你的应用程序变大,你可以通过将大型数据库拆分到它们自己的独立数据库服务器上来进行扩展。
需要一个更具可扩展性的数据库吗?在这本书中,我们使用了 MongoDB,它提供了一个数据库分片功能(如图 11.19 所示)。这允许我们将单个大型数据库分布在多个虚拟机上。你可能永远不需要这种级别的可扩展性。它仅适用于极大型数据库,但了解我们拥有这种选项是很好的,以防万一需要。

图 11.19 对于极大型数据库,我们可能需要 MongoDB 的分片功能,以将单个大型数据库分布在多个虚拟机上。
11.3.7 管理基础设施更改
对基础设施进行更改是一项风险业务,需要得到良好的管理。如果你在刚刚读到的任何扩展技术中犯了一个错误,你可能会使整个集群崩溃。我们最好不要对面向客户的基础设施进行这类更改,因此在本节中,我介绍了一种将此类风险更改与客户保持距离的技术。
这种技术被称为蓝绿部署。我们创建了两个生产环境,并将它们标记为蓝色和绿色。我们能够轻松做到这一点,因为在 11.2.6 节中,我们已经参数化了我们的 Terraform 代码,以创建不同名称区分的环境。
我们创建的第一个环境被标记为蓝色环境。我们的客户通过我们的域名(例如,www.company.com)使用我们的应用程序。然后,我们通过 DNS 记录将他们路由到蓝色环境。现在,为了保护我们的客户,我们宁愿不对蓝色环境进行任何风险性的更改(尽管对个别微服务的常规和频繁更新是可以接受的,因为这不会对基础设施造成任何影响)。
为了进行任何有风险的或实验性的更改(例如尝试扩展),我们创建了一个全新的生产基础设施,我们将其标记为绿色环境。我们的开发人员现在在绿色环境中工作,所以他们所做的任何工作都与客户使用的蓝色环境分开。这如图 11.20 所示。

图 11.20 客户使用蓝色环境,而开发人员和测试人员使用绿色环境。
一旦绿色环境的工作完成,经过测试,并且已知工作良好,我们只需将 DNS 记录从蓝色切换到绿色即可。我们的客户现在可以使用绿色环境,我们的开发人员和测试人员可以切换到与蓝色环境一起工作。这如图 11.21 所示。
如果发现新的绿色环境有任何问题,我们可以简单地翻转 DNS 开关回到蓝色环境,为我们的客户恢复工作功能。在未来,我们可以继续在蓝色和绿色环境之间切换,从而保护我们的客户免受我们基础设施可能存在的风险更改的影响。

图 11.21 当绿色环境准备就绪并经过测试后,客户将切换到它。开发人员和测试人员随后切换到蓝色环境并继续工作。当蓝色环境准备就绪并经过测试后,客户再次切换,循环继续。
11.4 安全
我们在书中多个地方简要地讨论了安全问题。但我们并没有真正公正地对待它,因为安全非常重要——甚至在开发的早期阶段也是如此。如此重要,以至于安全真的值得有一本自己的书。
好吧,幸运的是,有一本关于微服务安全的优秀书籍:Prabath Siriwardena 和 Nuwan Dias 合著的《Microservices Security in Action》(Manning,2020 年)。不过,现在让我们先了解一些基础知识。
每个应用程序都需要一定级别的安全。即使你的数据不是敏感的,你也不想任何人能够欺诈性地修改它。即使你的系统不是关键的,你也不想攻击者破坏你的系统和流程。
我们必须有效地使用安全技术,如身份验证、授权和加密,以减轻对我们应用程序或数据的恶意使用。我们可能还必须根据我们特定地区的法规,对我们的数据进行结构化以保护客户的隐私和匿名性。尽管如此,FlixTube 还没有这些,尽管我们已经对以下方面采取了一些措施:
-
*唯一暴露给外部世界(因此,暴露给攻击)的微服务是网关微服务。这是设计使然!我们的内部微服务不能从集群外部直接访问。
-
尽管最初,我们为了早期实验将我们的 RabbitMQ 服务器和 MongoDB 数据库暴露给世界,但我们很快关闭了这些服务。 我们这样做是为了防止直接外部访问这些关键资源。这很重要!除非您 100%确信这些资源受到攻击保护,否则不要将此类关键资源暴露给外界。
在未来,我们希望至少升级 FlixTube 以下安全特性:
-
网关处的认证系统。
-
使用 HTTPS 与我们的客户建立连接。这将加密他们的通信,使用像 Cloudflare 这样的外部服务意味着你可以快速上线。
当然,任何给定应用程序所需的安全级别的重要性仅与我们要保护的系统和数据有关。我们添加到 FlixTube 的安全性将远低于银行应用程序或政府网站所需的安全性。
安全性必须来自组织的两端。您的公司应该有符合领域和客户要求的安全政策和策略。然后,您和每一位开发人员都有责任思考并按照公司的标准实施安全。我们应该编写简单但安全的代码。并且与防御性编程(见第 10.4.2 节)一样,在安全性方面,我们应该采取防御性的心态。
首先,当编写代码和构建微服务时,我们应该问自己,有人会如何攻击这个系统?这使我们的思维处于主动应对安全问题的状态,在攻击发生之前,这可以产生最大的影响。
11.4.1 信任模型
FlixTube 的需求足够简单,我们可以采用内部信任模型,也称为“信任网络”(如图 11.22 所示)。在这个模型中,我们在系统的入口点(网关微服务)进行所有认证。集群内的微服务相互信任,并依赖底层网络的安全性来保护它们免受外部攻击。
内部信任模型是一种简单的方式来开始使用微服务。在安全性方面,简单往往比复杂更好,因为简单提供了更少的安全问题隐藏的地方。在引入更复杂的安全性时,我们必须小心,因为任何增加的复杂性实际上都可能引入安全漏洞。

图 11.22 内部信任模型。在网关处应用认证。内部微服务相互信任,无需认证即可通信。
如果您的安全需求高于 FlixTube,那么内部信任模型可能不够。如果您有多个集群,并且需要跨集群通信的微服务,这也将是情况。
您应该考虑的一个更安全的模型被称为“零信任”或“无信任”(如图 11.23 所示)。在零信任模型中,所有微服务之间的连接——无论是内部还是外部——都需要进行身份验证。微服务之间不会自动信任彼此。我们假设任何特定的微服务都可能被劫持或受到损害,尤其是如果该微服务托管在其他集群中。

图 11.23 无信任模型。所有连接,无论是内部还是外部,都需要进行身份验证。此模型支持连接到外部微服务。
11.4.2 敏感配置
任何应用程序都有需要保护的敏感配置数据。您可能记得在第七章中,我们将 Azure 凭据存储在 Bitbucket 仓库变量中(第 7.7.6 节)。在本章开头(第 11.2.3 节)将基础设施和微服务的部署代码拆分出来后,我们也需要一个地方来存储私有容器注册表的凭据。
在我们构建应用程序的过程中,我们还需要存储其他密码、令牌和 API 密钥,这些都需要安全地存储。我们可以在代码中存储任何这些敏感信息,这当然很方便。但这意味着任何拥有或能够访问我们的代码的人也将能够访问到可以轻松用于破坏或关闭我们的应用程序的操作信息。
Bitbucket 仓库或账户变量(或类似项,取决于您的持续交付提供者)是存储此类信息的良好方式。然而,您可能更喜欢一个不依赖于您的源代码控制或持续交付提供者的解决方案。在这种情况下,Kubernetes 为秘密配置提供了自己的存储解决方案。您可以在此处了解相关信息:
如果这不符合您的需求,还有各种其他产品可以帮助您。例如,您可能想了解更多关于 Vault 的信息,这是 Hashicorp(Terraform 的开发者)的另一个开源产品。更多信息请访问
11.5 微服务重构
在第一章(第 1.1 节)中,我承诺在学习如何从头开始构建微服务应用程序之后,我们最终会回来讨论如何将现有的单体应用程序重构为微服务。对于任何给定的单体,我们将如何转换的细节会有所不同。我们可以用很多种方式来做这件事,但在这个部分,我将向您提供一些基本策略和战术,供任何人使用。
基本思想与任何开发过程相同。正如在第二章(第 2.4 节)中介绍的那样,它全部关于迭代、小而简单的更改,并在进行过程中保持代码的正常工作(如图 11.24 所示)。
单体应用的转换是一项巨大的工作(取决于单体的大小和复杂性),一次性的“大爆炸”转换不太可能成功。到达另一边的唯一安全方式是通过小而可控的工作块,并在过程中进行极其彻底的测试。
我们也不能停止对产品的开发。我们仍然有责任添加业务请求的功能和修复错误。同样重要的是,我们必须保持产品正常运行;我们只是不能让问题积累。

图 11.24 将单体重构为微服务只能通过一系列小而经过良好测试的迭代步骤来完成。
你真的需要微服务吗?
在你开始将单体应用转换为微服务之前,你真的需要问自己:微服务真的有必要吗? 转换到微服务可能是一个漫长而困难的过程。它将引入显著的复杂性,并将考验你开发团队的耐心和决心。
-
这个转换真的值得付出的代价吗?
-
你真的需要扩展吗?
-
你真的需要微服务的灵活性吗?
这些都是重要的问题。确保你有好的答案。
规划你的转换并让每个人都参与
你不能在黑暗中简单地走向微服务!为了最大限度地提高成功的可能性,你需要一个关于你的产品到达时将是什么样子的文档化愿景。
使用领域驱动设计(DDD)来将你的业务建模为微服务(参见本章末尾的书籍参考)。追求简单的架构。为眼前的未来而不是遥远的、不确定的未来做计划。从你的架构愿景倒退到现在。这是你必须做出的变化序列以转换为微服务。这不需要详细规划,但你确实需要有一个大致的方向。
我们需要有一个关于我们正在构建的愿景,一个关于我们如何到达那里的想法,以及为什么这很重要的理解。计划总是会发生变化的。正如他们所说,“战斗计划在与敌人的接触中永远不会存活”(转述自赫尔穆特·冯·毛奇,老将军)。但这并不意味着我们不应该计划!相反,我们应该计划让变化在过程中自然发生,因为我们更多地了解我们的应用程序应该如何构建。我们还应该回顾和修订我们的计划,更新它,以便在遵循计划的过程中保持相关性。
转换计划应该与团队(或代表的一部分)一起创建,因为实施这个转换将是一个共享且困难的练习。你需要让每个人都参与其中。
只制定计划是不够的。现在你必须将其传达给更广泛的公司。确保开发者知道对他们有什么期望。与其他业务部门沟通,用对他们有意义的语言描述,让他们知道为什么这件事正在进行以及它带来的价值。每个人,绝对每个人,都必须理解这个操作的高风险!
了解你的遗留代码
在转换前后,你应该投入大量时间了解你的单体。创建测试计划。进行实验。了解其故障模式。对它在转换的每一步中哪些部分可能会突破有一个概念。
提升你的自动化
良好的自动化对任何微服务项目至关重要。在转换前后,你应该持续投资并改进你的自动化。如果你还没有掌握你的基础设施和自动化,你需要立即开始工作(甚至在开始转换之前!)你可能会发现,改变公司对自动化的思维方式实际上是这个过程中最难的部分。
你需要可靠且快速的自动化部署(第六章和第七章)。你转换的任何功能都应该已经具有自动化测试,或者你在将功能转换为微服务的同时实现良好的自动化测试(第八章)。
使用微服务,你无法摆脱自动化。如果你无法承担投资自动化的费用,你可能也无法承担转换为 微服务 *的费用。
构建你的微服务平台
在转换开始之前,你需要一个平台,你可以在这个平台上托管新创建的微服务。你需要一个生产环境来托管微服务,因为这些是从你的单体中增量提取的(如图 11.25 所示)。
在这本书中,你有了构建这样一个平台的秘方。根据第六章和第七章创建私有容器注册库,并创建你的 Kubernetes 集群。在创建你的第一个微服务之后,现在为你的团队创建一个共享模板:一个空白微服务,可以作为其他每个微服务的起点。如果你有不同的微服务类型,创建多个模板,每个类型一个。
创建你的自动化测试流程,并使其易于开发者使用。创建文档、示例和教程,以便你的开发者可以快速了解如何创建和部署新的微服务到你的平台。

图 11.25 你的单体的小块可以增量提取并移动到你的 Kubernetes 集群中。
沿着自然的缝隙雕刻
现在寻找你单体中与你的架构愿景中的微服务相匹配的现有组件。这些为从单体中逐块提取组件到微服务提供了巨大的机会,如图 11.26 所示。
如果你很难找到自然的缝隙,你的工作将会更加困难。如果你的单体应用是一个巨大的泥球或充满了意大利面代码,你可能必须首先重构或在进行提取时重构。无论如何,这将会很棘手。为了安全起见,你的重构应该由自动化测试支持。事情可能会变得混乱——做好准备。

图 11.26 单体应用通常会有自然的缝隙。利用这些缝隙来识别可以逐步提取为微服务的单个组件。
提取变化最频繁的部分
在决定将哪些组件转换为微服务时,优先考虑那些变化最多的组件。将这些部分从单体应用中早期提取出来作为微服务,会带来即时的实际效益,你将立即感受到其影响。这种早期的性价比应该会显著提高你的开发速度。它将降低你的部署风险,并有助于你说服他人转换正在进行得很好。
并且重复……
通过反复将小块内容提取到微服务中并边走边测试,我们将安全地将我们的单体应用转换为基于微服务的应用(图 11.27)。这不会容易。可能需要很长时间(取决于单体应用的大小和复杂性,可能是数年)。但这是可行的!我们只需要一点一点地持续努力,直到工作完成。

图 11.27 逐步将单体应用的小块内容提取为微服务,始终进行测试并保持其运行。最终,你的应用将被分解为微服务。
不必追求完美
当我们确立我们的架构愿景时,我们追求的是我所说的开发者的微服务乌托邦。这是一个我们都希望居住的地方——如果我们能够的话。然而,你必须意识到,我们并不是真正追求一个完美的微服务应用实例。当然,那会很棒。但说实话,可能没有必要完全达到那里。
达到完美的投资回报率会逐渐减少,而且很少值得尝试一直推进到那里。此外,由于没有人会完全同意这意味着什么,所以达到完美是不可能的。但仍然有可能朝着这个大致方向前进,并在过程中使事物变得更好。
在我们通往微服务的旅程中的每一步都应该被选择,以便对我们的客户、我们的应用、我们的开发过程或我们的业务产生积极影响。如果我们发现继续转换不再产生价值,我们必须停止并重新评估我们所做的事情。
也许我们正在走错路?或者,也许我们已经提取了所有可能的价值,继续前进不会再继续改善事情。这可能会让我们得到一个部分转换的单体应用,但那又如何?对你有用的就是好的。我们都在为我们的业务寻求好的结果,而且我们不应该因为实现这些结果所付出的代价而感到尴尬,无论它看起来如何。如果它能完成任务,那就完成了。这就是故事的全部。
可能性的光谱
如图 11.28 所示,在单体和开发者的微服务乌托邦之间有一个无限的可能性光谱。谁能说你的应用程序在这个连续体上属于哪里?当然不是我。只有你能决定这一点。

图 11.28 微服务转换的时间线。在早期,你将获得很高的投资回报率(ROI)。然而,随着你继续前进,你将获得递减的投资回报率,而且可能没有必要完全过渡到开发者的微服务乌托邦。
11.6 在预算内使用微服务
分布式架构一直是交付复杂应用的有用且强大的方式。由于当前云技术、现代工具和自动化的结合,微服务现在很受欢迎。这使得微服务比以往任何时候都更容易实现且成本效益更高。
但微服务应用仍然是一个复杂的构建任务。尽管每个单独的微服务都很简单,但你可能会在应用的整体复杂性上遇到困难,尤其是如果你是一个小团队、独立开发者或精益创业公司。
在这本书的整个过程中,我们学习了各种技巧和技术,使学习微服务和开始使用它们变得更加容易。这些技巧在未来如果你需要时将继续帮助你。我再次以更简洁的形式呈现这些见解。
-
教育自己使用现代工具,并充分利用这些工具! 自己开发工具是耗时、困难且会分散你应做的事情:向客户交付功能。
-
从单个代码仓库和单个持续交付(CD)管道开始。 之后,当你分离出多个代码仓库时,创建一个或多个元仓库将这些仓库重新组合在一起(如第 11.2.5 节所述)。
-
使用单个数据库服务器 来托管每个微服务的一个数据库。
-
使用单个虚拟机创建一个 Kubernetes 集群。 为每个微服务创建一个实例(没有副本)。一开始,你可能不需要冗余或性能。这有助于降低成本。
-
使用外部文件存储和外部数据库服务器,使你的集群实际上是无状态的。 这降低了你在集群上实验的风险。你可能会破坏你的集群,但你不会丢失你的数据。它还支持之前介绍的蓝绿部署技术(第 11.3.7 节)。
-
使用 Docker Compose 在你的工作站上模拟你的应用程序进行开发和测试。 使用实时重载进行快速的开发迭代。
-
*在早期,你可能不需要自动化测试,但对于构建一个可维护的微服务应用程序来说,它是必不可少的。然而,对于初创公司来说,构建一个最小可行产品(MVP)时,你可能不需要它。在产品的生命周期中,这还为时尚早,不能对基础设施做出如此大的承诺。我们必须在投资更先进的基础设施之前证明我们的产品!
-
你可能没有自动化测试,但你仍然需要测试! 设置高效且可靠的手动测试。你需要一个脚本来快速启动你的应用程序,从无到有,在短时间内进入可测试状态。你可以使用 Docker Compose 和数据库固定值来实现这一点。
-
Docker 使得将第三方镜像部署到集群中运行的容器变得容易。 这就是我们如何在第五章中部署 RabbitMQ 的方式。你可以在 DockerHub 上找到许多其他有用的镜像:
hub.docker.com/. -
尽早投资于你的自动化,特别是通过自动化部署的持续交付。 你将每天都会依赖它,所以请确保它运行良好。
11.7 从简单的起点……
只看我们共同走过的路!我们从一个单一的微服务开始。然后我们学习了如何使用 Docker 打包和发布它。我们学习了如何在我们的开发工作站上使用 Docker Compose 开发和测试多个微服务。最终,我们在云上创建了一个生产环境,并使用 Terraform 将我们的基于微服务的应用程序部署到其中。
复杂性管理是现代发展的核心。这就是为什么我们投入时间学习高级架构模式,如微服务。
这真是一次伟大的旅程!但我很遗憾地说,我们在一起的时间已经结束了。当然,你的旅程将继续,我祝愿你在使用微服务构建自己的复杂应用程序时一切顺利。

图 11.29 从单个微服务到在生产中运行的多个微服务的旅程
11.8 继续你的学习
最后一次,让我们通过一些书籍的引用来结束这一章,这些书籍将帮助你学习更多,并将你的理解和知识向前推进。要了解更多关于领域驱动设计(DDD)的信息,请阅读关于它的原始书籍:
- 《领域驱动设计》 由 Eric Evans(Addison-Wesley,2004)
如果你没有太多时间,你可以在免费的电子书《领域驱动设计快速入门》中找到一个很好的总结,由 Abel Avram 和 Floyd Marinescu(InfoQ,2018)编写,可在以下链接找到:
为了更好地理解微服务的安全性,请阅读
- 《微服务实战安全》 由 Prabath Siriwardena 和 Nuwan Dias 著(Manning, 2020)
要了解更多关于使用微服务进行开发的细节理论,请选择以下任何一本书籍:
-
《微服务之道》 由 Richard Rodger 著(Manning, 2017)
-
《微服务模式》 由 Chris Richardson 著(Manning, 2018)
-
《微服务实战》 由 Morgan Bruce 和 Paulo A. Pereira 著(Manning, 2018)
-
《.NET Core 微服务》 第 2 版,由 Christian Horsdal Gammelgaard 著(Manning, 2020)
-
《使用 Python 开发微服务 API》 由 José Haro Peralta 著(Manning, 2020)
摘要
-
为了从微服务中获得最大利益,我们必须分离我们的代码仓库和持续交付(CD)管道。这确保了每个微服务都可以独立部署。它还允许不同的团队对不同的微服务负责。
-
使用元仓库,在我们分离代码仓库之后,我们可以恢复一些单仓库(单一仓库)的便利性。
-
拥有独立的 CD 管道意味着我们将拥有可扩展的部署管道。
-
我们可以通过参数化我们的 Terraform 部署代码来创建多个环境(例如,开发、测试和生产)。
-
我们可以在代码仓库中为每个分支(例如,开发、测试和生产)配置独立的 CD 管道。将代码推送到分支会触发管道并部署到相应的环境。
-
为了提高性能,我们有多种选择,包括
-
我们可以垂直和水平地扩展我们的集群。
-
我们可以水平扩展我们的微服务。
-
我们可以为特定的微服务预留专用计算资源。
-
我们可以利用弹性扩展在需求高峰时自动扩展我们的集群和微服务。
-
-
我们应该为每个微服务保留单个数据库,这样我们就有选项来扩展我们的数据存储。
-
蓝绿部署在交替环境中切换客户,是一种管理潜在风险基础设施升级的安全方式。
-
由于微服务应用程序可能有许多网关,因此微服务的安全性与其他应用程序一样重要,甚至更为重要。
-
我们可以采用诸如身份验证和授权等安全技术来保护我们系统的访问权限。
-
我们可以采用完整性保护技术来保护我们的数据,并为我们的客户确保隐私和机密性。
-
从单体架构重构到微服务只能通过一系列小而经过良好测试的步骤来完成。
-
在我们刚开始时,有许多方法可以使微服务更加经济实惠且不那么复杂。这使得微服务成为初创公司、小型团队和独立开发者一个有效且高效的起点。
附录 A. 使用 Vagrant 创建开发环境
Vagrant 是一个允许我们通过脚本创建虚拟机(VM)的工具。它通常用于创建在本地计算机上运行的 VM(而不是在云中运行的 VM)。
Vagrant 是创建基于 Linux 的开发环境的好方法。它也适用于实验新的软件(例如 Docker 和 Terraform)。你可以使用它,这样就不会让你的常规开发计算机因为新软件而变得杂乱无章。
我直到最近还在日常开发中广泛使用 Vagrant。它作为一个方便的方法,在运行 Windows 10 Home 的计算机上安装 Docker 非常有用。现在,我使用 WSL2(Windows Subsystem for Linux 2)。Docker for Windows 与之集成(详见第三章),因此我不再需要 Vagrant 来运行 Docker。
但 Vagrant 仍然适用于构建用于开发、测试和实验的临时环境。你可以在 GitHub 上找到一个示例 Vagrant 设置:
示例 Vagrant 设置会自动安装 Docker、Docker Compose 和 Terraform,为你提供一个“即时”的开发环境,你可以使用这个环境来实验本书附带代码示例(见附录 A.6)。请注意,本书各章节的示例代码库都包含一个预配置的 Vagrant 脚本,你可以使用它来运行该章节的代码。
A.1 安装 VirtualBox
在使用 Vagrant 之前,你必须安装 VirtualBox。这是在您的普通计算机(主机)内实际运行 VM 的软件。您可以从 VirtualBox 下载页面下载它:
下载并安装适合您主机操作系统的包。按照 VirtualBox 网页上的说明操作。
注意:Vagrant 支持其他 VM 提供商,如 VMWare,但我推荐使用 VirtualBox,因为它免费且易于设置。
A.2 安装 Vagrant
现在安装 Vagrant。这是 VirtualBox 之上的一个脚本层,允许你通过代码(实际上是 Ruby 代码)管理 VM 的设置。您可以从 Vagrant 下载页面下载它:
下载并安装适合您主机操作系统的包。按照 Vagrant 网页上的说明操作。
A.3 创建你的虚拟机(VM)
在安装了 VirtualBox 和 Vagrant 之后,你现在可以创建你的 VM。首先,你必须决定使用哪个操作系统。如果你已经有一个生产系统,请选择相同的操作系统。如果没有,请选择一个长期支持(LTS)版本,这将长期保持稳定。你可以在本网页上搜索操作系统:
我非常喜欢 Ubuntu Linux,所以在这个例子中,我们将使用 Ubuntu 20.04 LTS。我们将安装的box的 Vagrant 名称是ubuntu/xenial64。
在创建 Vagrant box 之前,打开命令行并创建一个目录来存储它。切换到该目录,然后按照以下方式调用vagrant init命令:
vagrant init ubuntu/xenial64
这将在当前目录中创建一个基本的 Vagrantfile。编辑此文件以更改虚拟机的配置和设置。你可以在这里了解更多关于 Vagrant 配置的信息:
现在启动你的虚拟机:
vagrant up
确保你在包含 Vagrantfile 的同一目录中运行此命令。这可能需要一些时间,尤其是如果你还没有在本地缓存操作系统的镜像。请给它足够的时间完成。一旦完成,你将有一个全新的 Ubuntu 虚拟机可以工作。
A.4 连接到你的虚拟机
在虚拟机启动后,你可以这样连接到它:
vagrant ssh
Vagrant 会自动创建一个 SSH 密钥并为你管理连接。你现在有了进入虚拟机的命令行 shell。在这个 shell 中调用的任何命令都会在虚拟机内部执行。
A.5 在虚拟机中安装软件
在你的虚拟机运行并使用vagrant ssh连接后,你现在可能想要安装一些软件。在你的新虚拟机中要做的第一件事是更新操作系统。在 Ubuntu 中,你可以使用以下命令:
sudo apt-get update
你现在可以通过遵循软件供应商的说明来安装所需的任何软件。要安装 Docker,请参阅
要安装 Docker Compose,请参阅
要安装 Terraform,你只需下载它,解压它,并将可执行文件添加到你的路径中。你可以从这里下载 Terraform:
示例 Vagrant 设置(下一节)会自动安装所有这些工具。这为你提供了一个“即时”的开发环境,你可以用它来实验这本书附带的代码示例。
A.6 使用示例设置
你可以从 GitHub 上的示例设置启动虚拟机
使用 Git 克隆仓库:
git clone https://github.com/bootstrapping-microservices/example-vagrant-vm
然后切换到那个仓库目录:
cd example-vagrant-vm
现在启动虚拟机:
vagrant up
将命令行 shell 连接到虚拟机:
vagrant ssh
这个 Vagrant 脚本运行一个 shell 脚本,该脚本会自动安装 Docker、Docker Compose 和 Terraform!一旦虚拟机启动并连接,你就可以使用所有这些工具。
A.7 关闭虚拟机
在你完全完成虚拟机后,你可以使用以下命令将其销毁:
vagrant destroy
如果你只是暂时完成机器,并希望以后再次使用它,可以使用以下命令挂起它:
vagrant suspend
悬挂的机器可以通过调用 vagrant up 命令在任何时候恢复。记住,当您不使用这些虚拟机时,请销毁或挂起它们,否则它们将无端消耗您宝贵的系统资源。
附录 B. 微服务引导速查表
本附录总结了您在本书中学到的最有用的命令。
B.1 Node.js 命令
请参阅第二章了解如何安装和使用 Node.js 创建微服务。
表 B.1 Node.js 命令(续)
| 命令 | 描述 |
|---|---|
node --version |
检查 Node.js 是否已安装并打印版本号。 |
npm init -y |
创建默认的 Node.js 项目。这会为我们的 package.json 创建一个占位符,该文件跟踪 Node.js 项目的元数据和依赖项。 |
npm install --save➥ <package-name> |
安装 npm 包。在 npm 上还有许多其他包可用。您可以通过插入特定的包名称来安装任何包。 |
npm install |
安装 Node.js 项目的所有依赖项。这包括所有之前记录在 package.json 中的包。 |
node <script-file> |
运行 Node.js 脚本文件。我们只需调用 node 命令,并将脚本文件名作为参数传递。如果您想将其命名为 main.js 或 server.js,也可以,但最好遵守约定,只将其命名为 index.js。 |
npm start |
无论是主脚本文件叫什么名字,还是它期望的命令行参数是什么,npm start 都是启动 Node.js 应用的传统 npm 脚本。通常这只是在 package.json 文件中转换为 node index.js,但它完全取决于项目的作者以及他们如何设置。好处是,无论特定项目结构如何,您只需记住 npm start。 |
npm run start:dev |
我启动开发中 Node.js 项目的个人约定。我将此添加到 package.json 中的脚本中,通常它运行类似 nodemon 的东西,以便在您与代码一起工作时启用实时重载。 |
B.2 Docker 命令
请参阅第三章了解如何使用 Docker 打包、发布和运行微服务。
表 B.2 Docker 命令(续)
| 命令 | 描述 |
|---|---|
docker --version |
检查 Docker 是否已安装并打印版本号。 |
docker container list |
列出正在运行的容器。 |
docker ps |
列出所有容器(正在运行和已停止的)。 |
docker image list |
列出本地镜像。 |
docker build -t <tag> --file➥ <docker-file> . |
根据当前目录中 docker-file 中的说明从资产构建一个镜像。-t 参数使用您指定的名称标记镜像。 |
docker run -d -p➥ <host-port>:<container-port>➥ <tag> |
从镜像实例化一个容器。如果镜像在本地不可用,您可以从远程仓库拉取它(假设标签指定了仓库的 URL)。-d 参数以分离模式运行容器;它不会绑定到终端,您将看不到输出。省略此参数可以直接看到输出,但请注意,这也会锁定您的终端。-p 参数允许您将主机上的端口绑定到容器中的端口。 |
docker logs <container-id> |
从特定容器检索输出。您需要发出此命令才能在分离模式下运行容器时看到输出。 |
docker login <url>➥ --username <username>➥ --password <password> |
使用您的私有 Docker 仓库进行身份验证,以便您可以对其运行其他命令。 |
docker tag <existing-tag>➥ <new-tag> |
为现有镜像添加新标签。要将镜像推送到您的私有容器仓库,您必须首先使用您仓库的 URL 标记它。 |
docker push <tag> |
将带有适当标签的镜像推送到您的私有 Docker 仓库。镜像应带有您仓库的 URL。 |
docker kill <container-id> |
在本地停止特定容器。 |
docker rm <container-id> |
在本地删除特定容器(必须先停止)。 |
docker rmi <image-id>➥ --force |
在本地删除特定镜像(必须先删除任何容器)。--force 参数即使在镜像被多次标记的情况下也会删除镜像。 |
B.4 Docker Compose 命令
参见第四章和第五章,了解如何使用 Docker Compose 在您的开发工作站(或个人电脑)上模拟微服务应用程序进行开发和测试。
表 B.3 Docker Compose 命令
| 命令 | 描述 |
|---|---|
docker-compose --version |
检查 Docker Compose 是否已安装并打印版本号。 |
docker-compose up --build |
根据当前工作目录中定义的 Docker Compose 文件(docker-compose .yaml)构建并实例化由多个容器组成的应用程序。 |
docker-compose ps |
列出由 Docker Compose 文件指定的应用程序中的运行容器。 |
docker-compose stop |
停止应用程序中的所有容器,但保留已停止的容器以供检查。 |
docker-compose down |
停止并销毁应用程序,使开发工作站处于干净状态。 |
Terraform 命令
参见第六章和第七章,了解如何通过 Terraform 实现基础设施即代码,以创建 Kubernetes 集群并将您的微服务部署到其中。
表 B.4 Terraform 命令
| 命令 | 描述 |
|---|---|
terraform init |
初始化 Terraform 项目并下载提供者插件。 |
terraform apply➥ -auto-approve |
在工作目录中执行 Terraform 代码文件,以增量方式应用更改到您的基础设施。 |
terraform destroy |
销毁 Terraform 项目创建的所有基础设施。 |
B.5 测试命令
查阅第八章了解使用 Jest 和 Cypress 对微服务进行自动化测试的内容。
表 B.5 测试命令
| 命令 | 描述 |
|---|---|
npx jest --init |
初始化 Jest 配置文件。 |
npx jest |
在 Jest 下运行测试 |
npx jest --watch |
在启用实时重新加载的情况下运行测试,当代码更改时重新运行测试。Jest 使用 Git 来了解哪些文件已更改,应予以考虑。 |
npx jest --watchAll |
如前所述,但它监视所有文件以更改,而不仅仅是 Git 报告已更改的文件。 |
npx cypress open |
打开 Cypress UI,以便您可以运行测试。实时重新加载默认启用。您可以更新您的代码,测试将自动重新运行。 |
npx cypress run |
在无头模式下运行 Cypress 测试。这允许您从命令行(或 CD 管道)测试 Cypress,而无需显示 UI。 |
npm test |
运行测试的 npm 脚本约定。这可以运行 Jest 或 Cypress(甚至两者都可以),具体取决于您如何配置您的 package.json 文件。这是您应该在您的持续集成(CD)管道中运行的命令,以执行测试套件。 |
npm run test:watch |
这是我在实时重新加载模式下运行测试的个人约定。您需要配置此脚本在您的 package.json 文件中才能使用它。 |


浙公网安备 33010602011771号