克隆和负载均衡

传统的多线程 Web 服务器通常仅在分配给一台机器的资源无法再升级时或者当这样做会比简单启动另一台机器涉及更高的成本时才进行水平扩展。

通过使用多线程,传统 Web 服务器可以充分利用服务器的所有处理能力,使用所有可用的处理器和内存。 相反,Node.js 应用程序是单线程的,与传统 Web 服务器相比,扩展速度通常要快得多。 即使在单机环境中,我们也需要找到 “扩展” 应用程序的方法,以便利用所有可用资源。

在 Node.js 中,垂直扩展(向单台机器添加更多资源)和水平扩展(向基础设施添加更多机器)几乎是相同的概念:事实上,两者都涉及类似的技术来利用所有可用的处理能力。

不要被愚弄,认为这是一个缺点。 相反,几乎被迫扩展会对应用程序的其他属性产生有益的影响,特别是可用性和容错性。 事实上,通过克隆扩展 Node.js 应用程序相对简单,即使不需要获取更多资源,也经常会实现它,只是为了获得冗余、容错设置。

这也促使开发人员从应用程序的早期阶段就考虑可扩展性,确保应用程序不依赖于任何无法在多个进程或机器之间共享的资源。 事实上,扩展应用程序的绝对先决条件是每个实例不必存储有关无法共享的资源(例如内存或磁盘)的公共信息。 例如,在 Web 服务器中,将会话数据存储在内存或磁盘上的做法不太适合扩展。 相反,使用共享数据库将确保每个实例都可以访问相同的会话信息,无论其部署在何处。

现在让我们介绍一下扩展 Node.js 应用程序的最基本机制:集群(cluster)模块。

集群模块

在 Node.js 中,在单个计算机上运行的不同实例之间分配应用程序负载的最简单模式是使用集群模块,该模块是核心库的一部分。 集群模块简化了同一应用程序新实例的分叉,并自动在它们之间分配传入连接,如图 12.2 所示:

image 2024 05 08 11 14 42 895
Figure 1. 图12.2:集群模块原理图

主进程负责生成许多进程(工作进程),每个进程代表我们想要扩展的应用程序的一个实例。 然后,每个传入连接都会分布在克隆的工作人员之间,从而将负载分散到它们之间。

由于每个工作进程都是一个独立的进程,因此您可以使用此方法来生成与系统中可用 CPU 数量一样多的工作进程。 通过这种方法,您可以轻松地让 Node.js 应用程序利用系统中所有可用的计算能力。

关于集群模块行为的注释

在大多数系统中,集群模块使用显式的循环负载平衡算法。 该算法在主进程内部使用,确保请求均匀分布在所有工作进程中。 循环调度在除 Windows 之外的所有平台上默认启用,并且可以通过设置变量 cluster.schedulingPolicy 并使用常量 cluster 来全局修改。 SCHED_RR(循环)或 cluster.SCHED_NONE(由操作系统处理)。

循环算法在轮流的基础上将负载均匀地分布在可用服务器上。 第一个请求将转发到第一台服务器,第二个请求将转发到列表中的下一个服务器,依此类推。 当到达列表末尾时,迭代再次从头开始。 在集群模块中,循环逻辑比传统实现更聪明一些。 事实上,它丰富了一些额外的行为,旨在避免给定工作进程超载。

当我们使用 cluster 模块时,工作进程中对 server.listen() 的每次调用都会委托给主进程。 这允许主进程接收所有传入消息并将它们分发到工作池。 对于大多数用例来说,cluster 模块使得这个委托过程非常简单,但是有一些边缘情况,在工作模块中调用 server.listen() 可能不会达到您的预期:

  • server.listen({fd}):如果工作线程使用特定文件描述符进行监听,例如通过调用 server.listen({fd: 17}),则此操作可能会产生意外结果。 文件描述符在进程级别映射,因此如果工作进程映射文件描述符,这将与主进程中的同一文件不匹配。 克服此限制的一种方法是在主进程中创建文件描述符,然后将其传递给工作进程。 这样,工作进程就可以使用主进程已知的描述符来调用 server.listen()。

  • server.listen(handle):在工作进程中显式使用句柄对象(FileHandle) 进行侦听将导致工作进程直接使用提供的句柄,而不是将操作委托给主进程。

  • server.listen(0):调用 server.listen(0) 通常会导致服务器侦听随机端口。 然而,在集群中,每个工作线程每次调用 server.listen(0) 时都会收到相同的 “随机” 端口。 换句话说,该端口仅在第一次时是随机的; 它将从第二次调用开始修复。 如果你想让每个 worker 监听不同的随机端口,你必须自己生成端口号。

构建一个简单的 HTTP 服务器

现在让我们开始研究一个示例。 让我们构建一个小型 HTTP 服务器,使用集群模块进行克隆和负载平衡。 首先,我们需要一个可以扩展的应用程序,对于这个例子,我们不需要太多,只需要一个非常基本的 HTTP 服务器。

因此,让我们创建一个名为 app.js 的文件,其中包含以下代码:

import {createServer} from 'http'

const {pid} = process
const server = createServer((req, res) => {
// simulates CPU intensive work
    let i = 1e7;
    while (i > 0) {
        i--
    }
    console.log(`Handling request from ${pid}`)
    res.end(`Hello from ${pid}\n`)
})
server.listen(8080, () => console.log(`Started at ${pid}`))

我们刚刚构建的 HTTP 服务器通过发回包含其进程标识符 (PID) 的消息来响应任何请求; 这对于识别应用程序的哪个实例正在处理请求非常有用。 在此版本的应用程序中,我们只有一个进程,因此您在响应和日志中看到的 PID 将始终相同。

此外,为了模拟一些实际的 CPU 工作,我们执行了 1000 万次空循环:如果没有这个,服务器负载几乎可以忽略不计,并且很难从我们将要运行的基准测试中得出结论。

我们在这里创建的应用程序模块只是通用 Web 服务器的简单抽象。 为了简单起见,我们没有使用 Express 或 Fastify 等 Web 框架,但请随意使用您选择的 Web 框架重写这些示例。

现在,您可以通过照常运行应用程序并使用浏览器或 curl 向 http://localhost:8080 发送请求来检查是否一切正常。

您还可以尝试测量服务器能够在一个进程上处理的每秒请求数。 为此,您可以使用网络基准测试工具,例如 autocannon (nodejsdp.link/autocannon):

npx autocannon -c 200 -d 10 http://localhost:8080

上述命令将在 10 秒内向服务器加载 200 个并发连接。 作为参考,我们在机器(使用 Node.js v14 的 2.5 GHz 四核 Intel Core i7)上得到的结果约为每秒 300 个事务。

请记住,我们将在本章中执行的负载测试故意简单且最少,仅供参考和学习目的。 他们的结果无法对我们正在分析的各种技术的性能提供 100% 准确的评估。 当您尝试优化实际生产应用程序时,请确保在每次更改后始终运行您自己的基准测试。 您可能会发现,在我们将在这里说明的不同技术中,对于您的特定应用程序,有些技术可能比其他技术更有效。

现在我们有了一个简单的测试 Web 应用程序和一些参考基准,我们准备尝试一些技术来提高应用程序的性能。

使用集群模块进行扩展

现在让我们更新 app.js 以使用 cluster 模块扩展我们的应用程序:

import {createServer} from 'http'
import {cpus} from 'os'
import cluster from 'cluster'

if (cluster.isMaster) { // (1)
    const availableCpus = cpus()
    console.log(`Clustering to ${availableCpus.length} processes`)
    availableCpus.forEach(() => cluster.fork())
} else { // (2)
    const {pid} = process
    const server = createServer((req, res) => {
        let i = 1e7;
        while (i > 0) {
            i--
        }
        console.log(`Handling request from ${pid}`)
        res.end(`Hello from ${pid}\n`)
    })
    server.listen(8080, () => console.log(`Started at ${pid}`))
}

正如我们所看到的,使用集群模块只需很少的努力。 让我们分析一下发生了什么:

  1. 当我们从命令行启动 app.js 时,我们实际上正在执行 master 进程。 在这种情况下,cluster.isMaster 变量设置为 true,我们需要做的唯一工作是使用 cluster.fork() 分叉当前进程。 在前面的示例中,我们启动与系统中逻辑 CPU 核心数量一样多的工作线程,以充分利用所有可用的处理能力。

  2. 当从 master 进程执行 cluster.fork() 时,当前模块(app.js)再次运行,但这次是在工作模式下(cluster.isWorker 设置为 true,而 cluster.isMaster 设置为 false)。 当应用程序作为工作线程运行时,它可以开始执行一些实际工作。 在这种情况下,它启动一个新的 HTTP 服务器。

    重要的是要记住,每个工作线程都是一个不同的 Node.js 进程,具有自己的事件循环、内存空间和加载的模块。

有趣的是,集群模块的使用基于重复模式,这使得运行应用程序的多个实例变得非常容易:

if (cluster.isMaster) {
    // fork()
} else {
    // do work
}

在底层, cluster.fork() 函数使用 child_process.fork() API,因此,我们在主节点和工作线程之间也有一个可用的通信通道。 可以从变量 cluster.workers 访问工作进程,因此向所有进程广播消息就像运行以下代码行一样简单:

Object.values(cluster.workers).forEach(worker =>
worker.send('Hello from the master'))

现在,让我们尝试在集群模式下运行我们的 HTTP 服务器。 如果我们的机器有多个核心,我们应该看到主进程一个接一个地启动许多工作进程。 例如,在具有四个逻辑核心的系统中,终端应如下所示:

Started 14107
Started 14099
Started 14102
Started 14101

如果我们现在尝试使用 URL http://localhost:8080 再次访问我们的服务器,我们应该注意到每个请求都会返回一条具有不同 PID 的消息,这意味着这些请求已由不同的工作人员处理,从而确认 负载正在它们之间分配。

现在,我们可以尝试再次对服务器进行负载测试:

npx autocannon -c 200 -d 10 http://localhost:8080

这样,我们应该能够发现通过跨多个进程扩展应用程序所获得的性能提升。 作为参考,在我们的机器中,我们看到性能提高了约 3.3 倍(1,000 事务/秒与 300 事务/秒)。

集群模块的弹性和可用性

因为 worker 都是独立的进程,所以它们可以根据程序的需要被杀死或重生,而不会影响其他 worker。 只要还有一些工作人员还活着,服务器就会继续接受连接。 如果没有工作人员处于活动状态,则现有连接将被丢弃,并且新连接将被拒绝。 Node.js 不会自动管理 worker 的数量; 但是,应用程序有责任根据自己的需要来管理工作池。

正如我们已经提到的,扩展应用程序还带来其他优势,特别是即使在出现故障或崩溃的情况下也能够维持一定水平的服务。 此属性也称为弹性,它有助于提高系统的可用性。

通过启动同一应用程序的多个实例,我们正在创建一个冗余系统,这意味着如果一个实例由于某种原因发生故障,我们仍然有其他实例准备好服务请求。 使用 cluster 模块可以非常简单地实现此模式。 让我们看看它是如何工作的!

让我们以上一节的代码为起点。 特别是,让我们修改 app.js 模块,使其在随机时间间隔后崩溃:

// ...
} else {
// Inside our worker block
    setTimeout(
        () => { throw new Error('Ooops') },
        Math.ceil(Math.random() * 3) * 1000
    )
// ...

进行此更改后,我们的服务器会在 1 到 3 秒之间的随机秒数后退出并出现错误。在现实生活中,这最终会导致我们的应用程序停止服务请求,除非我们使用某些外部工具来监控其 状态并自动重启。 但是,如果我们只有一个实例,则由于应用程序的启动时间而导致的重新启动之间可能存在不可忽略的延迟。 这意味着在重新启动期间,应用程序不可用。 相反,拥有多个实例将确保我们始终有一个备份进程来服务传入的请求,即使其中一个工作进程发生故障也是如此。

使用集群模块,我们所要做的就是在检测到某个工作程序因错误代码而终止时立即生成一个新工作程序。 让我们修改 app.js 以考虑到这一点:

// ...
if (cluster.isMaster) {
// ...
    cluster.on('exit', (worker, code) => {
        if (code !== 0 && !worker.exitedAfterDisconnect) {
            console.log(
                `Worker ${worker.process.pid} crashed. ` +
                'Starting a new worker'
            )
            cluster.fork()
        }
    })
} else {
// ...
}

在前面的代码中,一旦主进程收到 “退出” 事件,我们就会检查该进程是有意终止还是由于错误而终止。 我们通过检查状态代码和标记 worker.exitedAfterDisconnect 来做到这一点,该标记指示 worker 是否被 master 显式终止。 如果我们确认进程因错误而终止,我们就会启动一个新的工作线程。 有趣的是,当崩溃的工作线程被替换时,其他工作线程仍然可以处理请求,因此不会影响应用程序的可用性。

为了测试这个假设,我们可以尝试使用自动炮再次对我们的服务器施加压力。 当压力测试完成时,我们会注意到在输出的各种指标中,还有失败数量的指示。 在我们的例子中,它是这样的:

[...]
8k requests in 10.07s, 964 kB read
674 errors (7 timeouts)

这应该相当于大约 92% 的可用性。 请记住,此结果可能会有很大差异,因为它很大程度上取决于正在运行的实例的数量以及它们在测试期间崩溃的次数,但它应该为我们提供解决方案如何工作的良好指标。 前面的数字告诉我们,尽管我们的应用程序不断崩溃,但在 8,000 次点击中,我们只有 674 个失败的请求。

在我们刚刚构建的示例场景中,大多数失败的请求都是由崩溃期间已建立的连接中断引起的。 不幸的是,我们几乎无法阻止这些类型的故障,尤其是当应用程序因崩溃而终止时。 尽管如此,我们的解决方案被证明是有效的,而且对于经常崩溃的应用程序来说,它的可用性一点也不差!

零停机重启

当我们想要向生产服务器发布新版本时,Node.js 应用程序可能还需要重新启动。 因此,同样在这种情况下,拥有多个实例可以帮助维护应用程序的可用性。

当我们必须有意重新启动应用程序来更新它时,应用程序会在一个小窗口中重新启动并且无法处理请求。 如果我们要更新个人博客,这是可以接受的,但对于具有服务级别协议 (SLA) 的专业应用程序或作为持续交付过程的一部分经常更新的应用程序来说,这甚至不是一种选择。 解决方案是实现零停机重启,即更新应用程序的代码而不影响其可用性。

使用集群模块,这又是一项非常简单的任务:该模式涉及一次重新启动一个工作程序。 这样,剩余的工作人员可以继续操作和维护可用的应用程序的服务。

让我们将这个新功能添加到我们的集群服务器中。 我们所要做的就是添加一些由主进程执行的新代码:

import {once} from 'events'
// ...
if (cluster.isMaster) {
    // ...
    process.on('SIGUSR2', async () => { // (1)
        const workers = Object.values(cluster.workers)
        for (const worker of workers) { // (2)
            console.log(`Stopping worker: ${worker.process.pid}`)
            worker.disconnect() // (2)
            await once(worker, 'exit')
            if (!worker.exitedAfterDisconnect) continue
            const newWorker = cluster.fork() // (4)
            await once(newWorker, 'listening') // (5)
        }
    })
} else {
    // ...
}

前面的代码块是这样工作的:

  1. 收到 SIGUSR2 信号时会触发工作进程的重新启动。 请注意,我们使用异步函数来实现事件处理程序,因为我们需要在此处执行一些异步任务。

  2. 当收到 SIGUSR2 信号时,我们迭代 cluster.workers 对象的所有值。 每个元素都是一个工作对象,我们可以使用它与工作池中当前活动的给定工作人员进行交互。

  3. 我们为当前工作线程做的第一件事是调用 worker.disconnect(),它会优雅地停止工作线程。 这意味着如果工作人员当前正在处理请求,则不会突然中断; 相反,它将被完成。 仅当完成所有正在进行的请求后,工作人员才会退出。

  4. 当终止的进程退出时,我们可以生成一个新的 worker。

  5. 我们等待新工作程序准备就绪并侦听新连接,然后再继续重新启动下一个工作程序。

由于我们的程序使用 Unix 信号,因此它无法在 Windows 系统上正常运行(除非您使用适用于 Linux 的 Windows 子系统)。 信号是实现我们的解决方案的最简单的机制。 然而,这并不是唯一的一个。 事实上,其他方法包括监听来自套接字、管道或标准输入的命令。

现在,我们可以通过运行应用程序然后发送 SIGUSR2 信号来测试零停机重启。 不过,我们首先需要获取 master 进程的 PID。 以下命令可用于从所有正在运行的进程的列表中识别它:

ps -af

主进程应该是一组节点进程的父进程。 一旦我们得到了我们正在寻找的 PID,我们就可以向它发送信号:

kill -SIGUSR2 <PID>

现在,应用程序的输出应显示如下内容:

Restarting workers
Stopping worker: 19389
Started 19407
Stopping worker: 19390
Started 19409

我们可以尝试再次使用 autocannon 来验证在工作进程重新启动期间我们不会对应用程序的可用性产生任何重大影响。

pm2 (nodejsdp.link/pm2) 是一个基于集群的小型实用程序,它提供负载平衡、进程监控、零停机重启和其他好处。

处理状态通信

集群模块不能很好地处理有状态通信,其中应用程序状态不在各个实例之间共享。 这是因为属于同一有状态会话的不同请求可能由应用程序的不同实例处理。 这不仅是集群模块的问题,而且一般来说,它适用于任何类型的无状态负载平衡算法。 例如,考虑图 12.3 描述的情况:

image 2024 05 08 11 31 35 346
Figure 2. 图 12.3:负载均衡器后面有状态应用程序的问题示例

用户 John 最初向我们的应用程序发送请求以验证自己的身份,但操作结果注册在本地(例如在内存中),因此只有接收到身份验证请求的应用程序实例(实例 A)知道 John 已成功验证。 当 John 发送新请求时,负载均衡器可能会将其转发到应用程序的另一个实例,该实例实际上不拥有 John 的身份验证详细信息,因此拒绝执行该操作。 我们刚刚描述的应用程序无法按原样扩展,但幸运的是,我们可以应用两种简单的解决方案来解决此问题。

跨多个实例共享状态

我们必须使用有状态通信来扩展应用程序的第一个选择是在所有实例之间共享状态。

这可以通过共享数据存储轻松实现,例如 PostgreSQL (nodejsdp.link/postgresql)、MongoDB (nodejsdp.link/mongodb) 或 CouchDB (nodejsdp.link/couchdb) 等数据库,甚至 更好的是,我们可以使用内存存储,例如 Redis (nodejsdp.link/redis) 或 Memcached (nodejsdp.link/memcached)。

图 12.4 概述了这个简单而有效的解决方案:

image 2024 05 08 11 33 01 350
Figure 3. 图 12.4:使用共享数据存储的负载均衡器后面的应用程序

使用共享存储来存储通信状态的唯一缺点是应用此模式可能需要对代码库进行大量重构。 例如,我们可能正在使用一个将通信状态保存在内存中的现有库,因此我们必须弄清楚如何配置、替换或重新实现该库以使用共享存储。

在重构可能不可行的情况下,例如,由于需要进行太多更改或使应用程序更具可扩展性的严格时间限制,我们可以依靠侵入性较小的解决方案:粘性负载平衡(或粘性会话)。

粘性负载均衡

我们必须支持有状态通信的另一种选择是让负载均衡器始终将与会话关联的所有请求路由到应用程序的同一实例。 这种技术也称为粘性负载平衡。

图 12.5 说明了涉及此技术的简化场景:

image 2024 05 08 11 34 16 022
Figure 4. 图 12.5:说明粘性负载平衡如何工作的示例

从图 12.5 中我们可以看到,当负载均衡器收到与新会话关联的请求时,它会创建与负载均衡算法选择的一个特定实例的映射。 下次负载均衡器收到来自同一会话的请求时,它会绕过负载均衡算法,选择先前与该会话关联的应用程序实例。 我们刚刚描述的特定技术涉及检查与请求关联的会话 ID(通常由应用程序或负载均衡器本身包含在 cookie 中)。

将有状态连接关联到单个服务器的更简单的替代方法是使用执行请求的客户端的 IP 地址。 通常,IP 被提供给哈希函数,该函数生成表示指定接收请求的应用程序实例的 ID。 该技术的优点是不需要负载均衡器记住关联。 但是,它不适用于经常更改 IP 的设备,例如在不同网络上漫游时。

默认情况下,集群模块不支持粘性负载平衡,但可以使用名为 Sticky-session (nodejsdp.link/sticky-session) 的 npm 库添加它。

粘性负载平衡的一个大问题是,它抵消了冗余系统的大部分优势,其中应用程序的所有实例都是相同的,并且一个实例最终可以替换另一个停止工作的实例。 由于这些原因,建议始终尝试避免粘性负载平衡和构建在共享存储中维护会话状态的应用程序。 或者,在可行的情况下,您可以尝试构建根本不需要状态通信的应用程序; 例如,通过将状态包含在请求本身中。

对于需要粘性负载平衡的库的真实示例,我们可以提到 Socket.IO (nodejsdp.link/socket-io)。

使用反向代理进行扩展

集群模块虽然非常方便且易于使用,但并不是我们扩展 Node.js Web 应用程序的唯一选择。 传统技术通常是首选,因为它们在高可用性生产环境中提供更多控制和功能。

使用集群的替代方法是启动在不同端口或计算机上运行的同一应用程序的多个独立实例,然后使用反向代理(或网关)提供对这些实例的访问,在它们之间分配流量。 在此配置中,我们没有将请求分发给一组工作人员的主进程,而是一组在同一台计算机上运行(使用不同端口)或分散在网络内不同计算机上的不同进程。 为了向我们的应用程序提供单一访问点,我们可以使用反向代理,这是一种放置在客户端和应用程序实例之间的特殊设备或服务,它接受任何请求并将其转发到目标服务器,将结果返回到 客户就好像它本身就是起源一样。 在此场景中,反向代理还用作负载均衡器,在应用程序的实例之间分配请求。

有关反向代理和正向代理之间差异的清晰说明,您可以参阅 Apache HTTP 服务器文档,网址为 nodejsdp.link/forward-reverse。

图 12.6 显示了一个典型的多进程、多机器配置,其中反向代理充当前端的负载均衡器:

image 2024 05 08 11 36 54 725
Figure 5. 图 12.6:典型的多进程、多机器配置,其中反向代理充当负载均衡器

对于 Node.js 应用程序,选择此方法代替集群模块的原因有很多:

  • 反向代理可以将负载分布到多台机器上,而不仅仅是多个进程上。

  • 市场上最流行的反向代理支持开箱即用的粘性负载平衡。

  • 反向代理可以将请求路由到任何可用的服务器,无论其编程语言或平台如何。

  • 我们可以选择更强大的负载均衡算法。

  • 许多反向代理提供额外的强大功能,例如 URL 重写、缓存、SSL 终止点、安全功能(例如拒绝服务保护),甚至是成熟的 Web 服务器的功能,可用于 例如,提供静态文件。

也就是说,如果需要,集群模块还可以轻松地与反向代理结合使用,例如,通过使用集群在单台机器内垂直扩展,然后使用反向代理在不同节点之间水平扩展。

模式

使用反向代理在运行于不同端口或机器上的多个实例之间平衡应用程序的负载。

我们有很多选择来使用反向代理实现负载均衡器。 以下是最流行的解决方案列表:

  • Nginx (nodejsdp.link/nginx):这是一个基于非阻塞I/O 模型构建的Web 服务器、反向代理和负载均衡器。

  • HAProxy (nodejsdp.link/haproxy):这是一个用于TCP/HTTP 流量的快速负载均衡器。

  • 基于Node.js 的代理:有许多解决方案可直接在Node.js 中实现反向代理和负载均衡器。 这可能有优点也有缺点,我们稍后会看到。

  • 基于云的代理:在云计算时代,使用负载均衡器作为服务的情况并不罕见。 这很方便,因为它需要最少的维护,通常具有高度可扩展性,有时它可以支持动态配置以实现按需可扩展性。

在本章接下来的几节中,我们将使用 Nginx 分析示例配置。 稍后,我们将只使用 Node.js 来构建我们自己的负载均衡器!

使用 Nginx 进行负载平衡

为了让您了解反向代理的工作原理,我们现在将构建一个基于 Nginx 的可扩展架构,但首先我们需要安装它。 我们可以按照 nodejsdp.link/nginx-install 中的说明进行操作。

在最新的 Ubuntu 系统上,可以通过命令 sudo apt-get install nginx 快速安装 Nginx。 在 macOS 上,您可以使用 brew (nodejsdp.link/brew):brew install nginx。 请注意,对于以下示例,我们将使用撰写本文时可用的最新版本的 Nginx (1.17.10)。

由于我们不打算使用集群来启动服务器的多个实例,因此我们需要稍微修改应用程序的代码,以便我们可以使用命令行参数指定侦听端口。 这将使我们能够在不同的端口上启动多个实例。 让我们考虑一下示例应用程序 (app.js) 的主模块:

import {createServer} from 'http'

const {pid} = process
const server = createServer((req, res) => {
    let i = 1e7;
    while (i > 0) {
        i--
    }
    console.log(`Handling request from ${pid}`)
    res.end(`Hello from ${pid}\n`)
})

const port = Number.parseInt(
    process.env.PORT || process.argv[2]
) || 8080
server.listen(port, () => console.log(`Started at ${pid}`))

此版本与 Web 服务器的第一个版本之间的唯一区别在于,我们通过 PORT 环境变量或命令行参数来配置端口号。 这是必需的,因为我们希望能够启动服务器的多个实例并允许它们侦听不同的端口。

如果没有集群,我们将无法使用的另一个重要功能是在崩溃时自动重新启动。 幸运的是,通过使用专用的监督程序(即监视我们的应用程序并在必要时重新启动它的外部进程)可以轻松解决此问题。 以下是一些可能的选择:

  • 基于 Node.js 的监控程序,例如 forever (nodejsdp.link/forever) 或 pm2 (nodejsdp.link/pm2)

  • 基于操作系统的监控程序,例如 systemd (nodejsdp.link/systemd) 或runit (nodejsdp.link/runit)

  • 更高级的监控解决方案,例如 monit (nodejsdp.link/monit) 或 supervisord (nodejsdp.link/supervisord)

  • 基于容器的运行时,例如 Kubernetes (nodejsdp.link/kubernetes)、Nomad (nodejsdp.link/nomad) 或 Docker Swarm (nodejsdp.link/swarm)。

对于这个例子,我们将使用 forever,这是我们使用最简单、最直接的。 我们可以通过运行以下命令来全局安装它:

npm install forever -g

下一步是启动我们应用程序的四个实例,所有实例都在不同的端口上并由永远监督:

forever start app.js 8081
forever start app.js 8082
forever start app.js 8083
forever start app.js 8084

我们可以使用以下命令检查启动的进程列表:

forever list

您可以使用forever stopall 来停止之前使用forever 启动的所有Node.js 进程。 或者,您可以使用永久停止 <id> 来停止永久列表中显示的特定进程。

现在,是时候将 Nginx 服务器配置为负载均衡器了。

首先,我们需要在工作目录中创建一个最小的配置文件,我们将其命名为 nginx.conf。

请注意,由于 Nginx 允许您在同一服务器实例后面运行多个应用程序,因此更常见的是使用全局配置文件,在 Unix 系统中,该文件通常位于 /usr/local/nginx/conf、/etc/ 下 nginx 或 /usr/local/etc/nginx.conf 在这里,通过在工作文件夹中放置一个配置文件,我们采用了一种更简单的方法。 对于本演示来说,这是可以的,因为我们只想在本地运行一个应用程序,但我们建议您遵循生产部署的推荐最佳实践。

接下来,让我们编写 nginx.conf 文件并应用以下配置,这是为 Node.js 进程获得工作负载均衡器所需的最低配置:

daemon off; ## (1)
error_log /dev/stderr info; ## (2)

events { ## (3)
    worker_connections 2048;
}

http { ## (4)
    access_log /dev/stdout;
    upstream my-load-balanced-app {
        server 127.0.0.1:8081;
        server 127.0.0.1:8082;
        server 127.0.0.1:8083;
        server 127.0.0.1:8084;
    }
    server {
        listen 8080;
        location / {
            proxy_pass http://my-load-balanced-app;
        }
    }
}

让我们一起讨论这个配置:

  1. 声明 daemon off 允许我们使用当前非特权用户将 Nginx 作为独立进程运行,并保持该进程在当前终端的前台运行(这允许我们使用 Ctrl + 将其关闭) C)。

  2. 我们使用 error_log(以及后来在 http 块中的 access_log)将错误和访问日志分别流式传输到标准输出和标准错误,因此我们可以直接从终端实时读取日志。

  3. events 块允许我们配置 Nginx 如何管理网络连接。 在这里,我们将 Nginx 工作进程可以打开的最大并发连接数设置为 2048.

  4. http 块允许我们定义给定应用程序的配置。 在上游 my-load-balanced-app 部分中,我们定义用于处理网络请求的后端服务器列表。 在服务器部分,我们使用 listen 8080 来指示服务器监听 8080 端口,最后,我们指定 proxy_pass 指令,它实质上告诉 Nginx 将任何请求转发到我们之前定义的服务器组(my-load-balanced-app )。

就是这样! 现在,我们只需要使用我们的配置文件通过以下命令启动 Nginx:

nginx -c ${PWD}/nginx.conf

我们的系统现在应该已启动并运行,准备好接受请求并平衡 Node.js 应用程序的四个实例之间的流量。 只需将浏览器指向地址 http://localhost:8080 即可查看我们的 Nginx 服务器如何平衡流量。 您还可以再次尝试使用 autocannon 对该应用程序进行负载测试。 由于我们仍在一台本地计算机上运行所有进程,因此您的结果应该与使用集群模块方法对版本进行基准测试时获得的结果相差不大。

本示例演示了如何使用 Nginx 来负载均衡流量。 为了简单起见,我们将所有内容都保存在本地计算机上,但尽管如此,这仍然是一个很好的练习,可以让我们做好在多个远程服务器上部署应用程序的准备。 如果你想尝试这样做,你基本上必须遵循这个食谱:

  1. 配置 n 个运行 Node.js 应用程序的后端服务器(使用服务监视器(例如,forever 或使用 cluster 模块)运行多个实例)。

  2. 配置一台安装了 Nginx 的负载均衡器计算机以及将流量路由到 n 个后端服务器的所有必要配置。 每台服务器中的每个进程都应该使用网络中各个机器的正确地址列在 Nginx 配置文件的上游块中。

  3. 使用公共 IP 和可能的公共域名,使您的负载均衡器在 Internet 上公开可用。

  4. 尝试使用浏览器或 autocannon 等基准测试工具向负载均衡器的公共地址发送一些流量。

为简单起见,您可以通过云提供商管理界面旋转服务器并使用 SSH 登录这些服务器来手动执行所有这些步骤。 或者,您可以选择允许您通过将基础设施编写为代码来自动执行这些任务的工具,例如 Terraform (nodejsdp.link/terraform)、Ansible (nodejsdp.link/ansible) 和 Packer (nodejsdp.link/packer)。

在此示例中,我们使用了预定义数量的后端服务器。 在下一节中,我们将探索一种技术,该技术允许我们对一组动态后端服务器的流量进行负载平衡。

动态水平缩放

现代基于云的基础设施的一项重要优势是能够根据当前或预测的流量动态调整应用程序的容量。 这也称为动态缩放。 如果实施得当,这种做法可以极大地降低 IT 基础设施的成本,同时仍然保持应用程序的高可用性和响应能力。

这个想法很简单:如果我们的应用程序因流量峰值而导致性能下降,系统会自动生成新的服务器来应对增加的负载。 同样,如果我们发现分配的资源未得到充分利用,我们可以关闭一些服务器以降低运行基础设施的成本。 我们还可以决定根据时间表执行扩展操作; 例如,当我们知道流量会比较少时,我们可以在一天中的某些时段关闭一些服务器,并在高峰时段之前重新启动它们。 这些机制要求负载均衡器始终了解当前网络拓扑,随时了解哪台服务器处于运行状态。

使用服务注册表

解决此问题的常见模式是使用称为服务注册表的中央存储库,它跟踪正在运行的服务器及其提供的服务。

图 12.7 显示了一个多服务架构,前端有一个负载均衡器,使用服务注册表进行动态配置:

image 2024 05 08 11 48 58 202
Figure 6. 图 12.7:前端有负载均衡器的多服务架构,使用服务注册表动态配置

图 12.7 中的架构假设存在两个服务:API 和 WebApp。 每项服务可以有一个或多个实例,分布在多台服务器上。

当收到对 example.com 的请求时,负载均衡器会检查请求路径的前缀。 如果前缀为 /api,则请求将在 API 服务的可用实例之间进行负载平衡。 在图 12.7 中,我们有两个实例在服务器 api1.example.com 上运行,一个实例在服务器 api2.example.com 上运行。 对于所有其他路径前缀,请求在 WebApp 服务的可用实例之间进行负载平衡。 在该图中,我们只有一个 WebApp 实例,它运行在服务器 web1.example.com 上。 负载均衡器使用服务注册表获取服务器列表以及每台服务器上运行的服务实例。

为了完全自动化地工作,每个应用程序实例必须在上线时将自身注册到服务注册表,并在停止时取消注册。 这样,负载均衡器始终可以了解网络上可用的服务器和服务的最新视图。

模式(服务注册表)

使用中央存储库来存储系统中可用的服务器和服务的始终最新的视图。

虽然此模式对于负载平衡流量很有用,但它还有一个额外的好处,即能够将服务实例与其运行的服务器解耦。 我们可以将服务注册模式视为应用于网络服务的服务定位器设计模式的实现。

使用 http-proxy 和 Consul 实现动态负载均衡器

为了支持动态网络基础设施,我们可以使用反向代理,例如 Nginx 或 HAProxy:我们需要做的就是使用自动化服务更新其配置,然后强制负载均衡器选择更改。 对于 Nginx,可以使用以下命令行完成此操作:

nginx -s reload

使用基于云的解决方案可以实现相同的结果,但我们有第三种更熟悉的替代方案,它利用我们最喜欢的平台。

我们都知道 Node.js 是构建任何类型的网络应用程序的绝佳工具,正如我们在本书中所说,这正是其主要设计目标之一。 那么,为什么不只使用 Node.js 构建一个负载均衡器呢? 这将为我们提供更多的自由和能力,并允许我们直接在定制的负载均衡器中实现任何类型的模式或算法,包括我们现在要探索的:使用服务注册表的动态负载均衡。 此外,进行这项练习肯定会帮助我们更好地理解 Nginx 和 HAProxy 等生产级产品的实际工作原理。

在这个例子中,我们将使用 Consul (nodejsdp.link/consul) 作为服务注册中心来复制我们在图 12.7 中看到的多服务架构。 为此,我们将主要使用三个 npm 包:

  • http-proxy (nodejsdp.link/http-proxy):简化 Node.js 中反向代理/负载均衡器的创建

  • portfinder (nodejsdp.link/ portfinder):在系统中查找空闲端口

  • consul (nodejsdp.link/consul-lib):与 Consul 交互

让我们从实现我们的服务开始。 这些是简单的 HTTP 服务器,就像我们迄今为止用来测试集群和 Nginx 的服务器一样,但是这一次,我们希望每个服务器在启动时将自己注册到服务注册表中。

让我们看看它是什么样子的(文件 app.js):

import {createServer} from 'http'
import consul from 'consul'
import portfinder from 'portfinder'
import {nanoid} from 'nanoid'

const serviceType = process.argv[2]
const {pid} = process

async function main() {
    const consulClient = consul()
    const port = await portfinder.getPortPromise() // (1)
    const address = process.env.ADDRESS || 'localhost'
    const serviceId = nanoid()

    function registerService() { // (2)
        consulClient.agent.service.register({
            id: serviceId,
            name: serviceType,
            address,
            port,
            tags: [serviceType]
        }, () => {
            console.log(`${serviceType} registered successfully`)
        })
    }

    function unregisterService(err) { // (3)
        err && console.error(err)
        console.log(`deregistering ${serviceId}`)
        consulClient.agent.service.deregister(serviceId, () => {
            process.exit(err ? 1 : 0)
        })
    }

    process.on('exit', unregisterService) // (4)
    process.on('uncaughtException', unregisterService)
    process.on('SIGINT', unregisterService)
    const server = createServer((req, res) => { // (5)
        let i = 1e7;
        while (i > 0) {
            i--
        }
        console.log(`Handling request from ${pid}`)
        res.end(`${serviceType} response from ${pid}\n`)
    })
    server.listen(port, address, () => {
        registerService()
        console.log(`Started ${serviceType} at ${pid} on port ${port}`)
    })
}

main().catch((err) => {
    console.error(err)
    process.exit(1)
})

在上面的代码中,有一些地方值得我们关注:

  1. 首先,我们使用 portfinder.getPortPromise() 来发现系统中的一个空闲端口(默认情况下,portfinder 从端口 8000 开始搜索)。 我们还允许用户根据环境变量 ADDRESS 配置地址。 最后,我们使用 nanoid (nodejsdp.link/nanoid) 生成一个随机 ID 来识别该服务。

  2. 接下来,我们声明 registerService() 函数,该函数使用 consul 库在注册表中注册一个新服务。 服务定义需要几个属性:id(服务的唯一标识符)、name(标识服务的通用名称)、地址和端口(标识如何访问服务)以及 tags(可选的标签数组,用于标识服务)。 可用于过滤和分组服务)。 我们使用 serviceType(从命令行参数获取)来指定服务名称并添加标签。 这将使我们能够识别集群中可用的相同类型的所有服务。

  3. 此时,我们定义了一个名为 unregisterService() 的函数,它允许我们删除刚刚在 Consul 中注册的服务。

  4. 我们使用 unregisterService() 作为清理函数,以便当程序关闭时(无论是有意还是无意),服务都会从 Consul 中注销。

  5. 最后,我们在 portfinder 发现的端口和为当前服务配置的地址上启动我们的服务的 HTTP 服务器。 请注意,当服务器启动时,我们确保调用 registerService() 函数以确保服务已注册以供发现。

通过这个脚本,我们将能够启动和注册不同类型的应用程序。

现在,是时候实现负载均衡器了。 让我们通过创建一个名为 loadBalancer.js 的新模块来实现这一点:

import {createServer} from 'http'
import httpProxy from 'http-proxy'
import consul from 'consul'

const routing = [ // (1)
    {
        path: '/api',
        service: 'api-service',
        index: 0
    },
    {
        path: '/',
        service: 'webapp-service',
        index: 0
    }
]
const consulClient = consul() // (2)
const proxy = httpProxy.createProxyServer()
const server = createServer((req, res) => {
    const route = routing.find((route) => // (3)
        req.url.startsWith(route.path))
    consulClient.agent.service.list((err, services) => { // (4)
        const servers = !err && Object.values(services)
            .filter(service => service.Tags.includes(route.service))
        if (err || !servers.length) {
            res.writeHead(502)
            return res.end('Bad gateway')
        }
        route.index = (route.index + 1) % servers.length // (5)
        const server = servers[route.index]
        const target = `http://${server.Address}:${server.Port}`
        proxy.web(req, res, {target})
    })
})
server.listen(8080, () => {
    console.log('Load balancer started on port 8080')
})

这就是我们实现基于 Node.js 的负载均衡器的方式:

  1. 首先,我们定义负载均衡器路由。 路由数组中的每一项都包含用于处理到达映射路径的请求的服务。 索引属性将用于循环给定服务的请求。

  2. 我们需要实例化一个consul客户端,以便我们可以访问注册表。 接下来,我们实例化一个 http 代理服务器。

  3. 在服务器的请求处理程序中,我们要做的第一件事就是将 URL 与路由表进行匹配。 结果将是包含服务名称的描述符。

  4. 我们从 consul 获取实现所需服务的服务器列表。 如果此列表为空或检索它时出错,那么我们将向客户端返回错误。 我们使用 Tags 属性来过滤所有可用的服务,并找到实现当前服务类型的服务器的地址。

  5. 最后,我们可以将请求路由到目的地。 我们遵循循环方法更新 route.index 以指向列表中的下一个服务器。 然后,我们使用索引从列表中选择一个服务器,并将其连同请求 (req) 和响应 (res) 对象一起传递给 proxy.web()。 这将简单地将请求转发到我们选择的服务器。

现在很清楚仅使用 Node.js 和服务注册表实现负载均衡器是多么简单,以及这样做可以拥有多大的灵活性。

请注意,为了保持实现简单,我们故意省略了一些有趣的优化机会。 例如,在此实现中,我们询问 consul 以获取每个请求的注册服务列表。 这会增加显着的开销,特别是当我们的负载均衡器接收高频请求时。 缓存服务列表并定期(例如每 10 秒)刷新它会更有效。 另一种优化可能是使用集群模块来运行负载均衡器的多个实例,并将负载分布到机器中的所有可用核心上。

现在,我们应该准备好尝试我们的系统,但首先,让我们按照 nodejsdp.link/consulinstall 上的官方文档安装 Consul 服务器。

这允许我们使用这个简单的命令行在我们的开发机器上启动 Consul 服务注册表:

consul agent -dev

现在,我们准备启动负载均衡器(使用 forever 来确保应用程序在崩溃时重新启动):

forever start loadBalancer.js

现在,如果我们尝试访问负载均衡器公开的一些服务,我们会注意到它返回 HTTP 502 错误,因为我们尚未启动任何服务器。 自己尝试一下:

curl localhost:8080/api

前面的命令应返回以下输出:

Bad Gateway

如果我们生成一些服务实例,例如两个 api 服务和一个 webapp 服务,情况就会改变:

forever start --killSignal=SIGINT app.js api-service
forever start --killSignal=SIGINT app.js api-service
forever start --killSignal=SIGINT app.js webapp-service

现在,负载均衡器应该自动查看新服务器并开始在它们之间分配请求。 让我们用以下命令再试一次:

curl localhost:8080/api

前面的命令现在应该返回:

api-service response from 6972

通过再次运行此命令,我们现在应该收到来自另一台服务器的消息,确认请求在不同服务器之间均匀分布:

api-service response from 6979

如果您想查看永久管理的实例并停止其中一些实例,您可以使用永久列表和永久停止命令。 要停止所有正在运行的实例,您可以使用永久停止所有。 为什么不尝试停止 api 服务正在运行的实例之一,看看整个应用程序会发生什么情况?

这种模式的优势是立竿见影的。 我们现在可以按需或根据计划动态扩展我们的基础设施,并且我们的负载均衡器将根据新配置自动调整,无需任何额外的工作!

Consul 提供了一个方便的 Web UI,默认情况下可在 localhost:8500 上使用。 在玩这个示例时检查一下,看看服务在注册或取消注册时如何出现和消失。

Consul 还提供健康检查功能来监控注册服务。 该功能可以集成到我们的示例中,以使我们的基础设施对故障更具弹性。 事实上,如果服务不响应运行状况检查,它会自动从注册表中删除,因此它不会再接收流量。 如果您想知道如何实现此功能,可以在 nodejsdp.link/consul-checks 上查看 Checks 的官方文档。

现在我们知道如何使用负载均衡器和服务注册表执行动态负载均衡,我们准备探索一些有趣的替代方法,例如对等负载均衡。

点对点负载均衡

当我们想要将复杂的内部网络架构暴露给互联网等公共网络时,使用反向代理几乎是必需的。 它有助于隐藏复杂性,提供外部应用程序可以轻松使用和依赖的单一访问点。 但是,如果我们需要扩展仅供内部使用的服务,我们可以拥有更多的灵活性和控制力。

让我们想象一下,有一个服务(服务 A)依赖服务 B 来实现其功能。 服务 B 跨多台计算机扩展,并且仅在内部网络中可用。 到目前为止我们了解到,服务 A 将使用负载均衡器连接到服务 B,负载均衡器会将流量分配给所有实现服务 B 的服务器。

然而,还有一个替代方案。 我们可以从图中删除负载均衡器并直接分发来自客户端(服务 A)的请求,客户端现在直接负责在服务 B 的各个实例之间对其请求进行负载均衡。只有当服务器 A 了解详细信息时,这才有可能 关于暴露服务 B 的服务器,并且在内部网络中,这通常是已知信息。 通过这种方法,我们本质上是实现点对点负载平衡。

图 12.8 比较了我们刚刚描述的两种替代方案:

image 2024 05 08 12 09 41 720
Figure 7. 图 12.8:集中式负载平衡与点对点负载平衡

这是一种极其简单而有效的模式,可以实现真正的分布式通信,而不会出现瓶颈或单点故障。 除此之外,它还具有以下属性:

  • 通过删除网络节点来降低基础设施复杂性

  • 允许更快的通信,因为消息将通过更少的节点传输

  • 可以更好地扩展,因为性能不受负载均衡器可以处理的内容的限制

另一方面,通过删除负载均衡器,我们实际上暴露了其底层基础设施的复杂性。 此外,每个客户端都必须通过实施负载平衡算法变得更加聪明,并且可能还需要一种保持其基础设施最新知识的方法。

对等负载平衡是 ZeroMQ (nodejsdp.link/zeromq) 库中广泛使用的一种模式,我们将在下一章中使用它。

在下一节中,我们将展示一个在 HTTP 客户端中实现点对点负载平衡的示例。

实现可以平衡多个服务器之间的请求的 HTTP 客户端

我们已经知道如何仅使用 Node.js 实现负载均衡器并在可用服务器之间分发传入请求,因此在客户端实现相同的机制应该不会有太大不同。 事实上,我们所要做的就是包装客户端 API 并使用负载平衡机制对其进行增强。 看一下以下模块(balancedRequest.js):

import {request} from 'http'
import getStream from 'get-stream'

const servers = [
    {host: 'localhost', port: 8081},
    {host: 'localhost', port: 8082}
]
let i = 0

export function balancedRequest(options) {

    return new Promise((resolve) => {
        i = (i + 1) % servers.length
        options.hostname = servers[i].host
        options.port = servers[i].port
        request(options, (response) => {
            resolve(getStream(response))
        }).end()
    })
}

前面的代码非常简单,不需要太多解释。 我们包装了原始的 http.request API,以便它使用循环算法从可用服务器列表中选择的主机名和端口覆盖请求的主机名和端口。 请注意,为简单起见,我们使用模块 get-stream (nodejsdp.link/getstream) 将响应流 “累积” 到包含完整响应正文的缓冲区中。

然后可以无缝使用新包装的 API (client.js):

import {balancedRequest} from './balancedRequest.js'

async function main() {
    for (let i = 0; i < 10; i++) {
        const body = await balancedRequest({
            method: 'GET',
            path: '/'
        })
        console.log(`Request ${i} completed:`, body)
    }
}

main().catch((err) => {
    console.error(err)
    process.exit(1)
})

要运行前面的代码,我们必须启动提供的示例服务器的两个实例:

node app.js 8081
node app.js 8082

接下来是我们刚刚构建的客户端应用程序:

node client.js

我们应该注意到,每个请求都发送到不同的服务器,确认我们现在能够在没有专用负载均衡器的情况下平衡负载!

对我们之前创建的包装器的一个明显改进是将服务注册表直接集成到客户端并动态获取服务器列表。

在下一节中,我们将探讨容器和容器编排领域,并了解在这个特定上下文中,运行时如何承担许多可扩展性问题。

使用容器扩展应用程序

在本节中,我们将演示如何使用容器和容器编排平台(例如 Kubernetes)帮助我们编写更简单的 Node.js 应用程序,这些应用程序可以将大多数扩展问题(例如负载平衡、弹性扩展和高可用性)委托给底层 容器平台。

容器和容器编排平台构成了一个相当广泛的主题,很大程度上超出了本书的范围。 因此,在这里,我们的目标是仅提供一些基本示例,帮助您开始使用 Node.js 来使用此技术。 最终,我们的目标是鼓励您探索新的现代模式以运行和扩展 Node.js 应用程序。

什么是容器?

容器,特别是 Linux 容器,按照开放容器计划 (OCI) (nodejsdp.link/opencontainers) 的标准化,被定义为“一个标准的软件单元,它打包代码及其所有依赖项,以便应用程序快速可靠地运行” 从一种计算环境转移到另一种计算环境。”

换句话说,通过使用容器,您可以在不同的机器上无缝打包和运行应用程序,从桌面上的本地开发笔记本电脑到云中的生产服务器。

除了极其可移植之外,作为容器运行的应用程序还具有执行时开销非常小的优点。 事实上,容器的运行速度几乎与直接在操作系统上运行本机应用程序一样快。

简单来说,您可以将容器视为标准软件单元,允许您直接在 Linux 操作系统上定义和运行隔离的进程。

由于其可移植性和性能,与虚拟机相比,容器被认为是一个巨大的进步。

有不同的方法和工具可以为应用程序创建和运行符合 OCI 的容器。 其中最受欢迎的是 Docker (nodejsdp.link/docker)。

您可以按照官方文档中针对您的操作系统的说明在系统中安装 Docker:nodejsdp.link/docker-docs。

使用 Docker 创建并运行容器

让我们重写简单的 Web 服务器应用程序并进行一些小的更改 (app.js):

import {createServer} from 'http'
import {hostname} from 'os'

const version = 1
const server = createServer((req, res) => {
    let i = 1e7;
    while (i > 0) {
        i--
    }
    res.end(`Hello from ${hostname()} (v${version})`)
})
server.listen(8080)

与此 Web 服务器的早期版本相比,这里我们将计算机主机名和应用程序版本发送回用户。 如果您运行此服务器并发出请求,您应该返回如下内容:

Hello from my-amazing-laptop.local (v1)

让我们看看如何将此应用程序作为容器运行。 我们需要做的第一件事是为项目创建一个 package.json 文件:

{
    "name": "my-simple-app",
    "version": "1.0.0",
    "main": "app.js",
    "type": "module",
    "scripts": {
        "start": "node app.js"
    }
}

为了对我们的应用程序进行 docker 化,我们需要遵循两个步骤:

  • 构建容器映像

  • 从映像运行容器实例

要为我们的应用程序创建容器映像,我们必须定义一个 Dockerfile。 容器镜像(或 Docker 镜像)是实际的包,符合 OCI 标准。 它包含所有源代码和必要的依赖项,并描述了应用程序必须如何执行。 Dockerfile 是一个文件(实际上名为 Dockerfile),它定义了用于为应用程序构建容器映像的构建脚本。 因此,事不宜迟,让我们为我们的应用程序编写 Dockerfile:

FROM node:14-alpine
EXPOSE 8080
COPY app.js package.json /app/
WORKDIR /app
CMD ["npm", "start"]

我们的 Dockerfile 很短,但是这里有很多有趣的东西,所以让我们一一讨论它们:

  • FROM node:14-alpine 表示我们要使用的基础镜像。 基础镜像允许我们在现有镜像的 “之上” 构建。 在本例中,我们从已包含 Node.js 版本 14 的映像开始。 这意味着我们不必担心描述 Node.js 需要如何打包到容器镜像中。

  • EXPOSE 8080 通知 Docker 应用程序将侦听端口 8080 上的 TCP 连接。

  • COPY app.js package.json /app/ 将文件 app.js 和 package.json 复制到容器文件系统的 /app 文件夹中。 容器是隔离的,因此默认情况下它们不能与主机操作系统共享文件; 因此,我们需要将项目文件复制到容器中以便能够访问和执行它们。

  • WORKDIR /app 将容器的工作目录设置为 /app。

  • CMD ["npm", "start"] 指定当我们从映像运行容器时执行的启动应用程序的命令。 在这里,我们只是运行 npm start,它又将运行 node app.js,如 package.json 中指定的那样。 请记住,我们之所以能够在容器中运行 node 和 npm,只是因为这两个可执行文件可通过基本映像使用。

现在,我们可以使用 Dockerfile 通过以下命令构建容器映像:

docker build .

该命令将在当前工作目录中查找 Dockerfile 并执行它来构建我们的映像。

该命令的输出应该是这样的:

Sending build context to Docker daemon 7.168kB
Step 1/5 : FROM node:14-alpine
---> ea308280893e
Step 2/5 : EXPOSE 8080
---> Running in 61c34f4064ab
Removing intermediate container 61c34f4064ab
---> 6abfcdf0e750
Step 3/5 : COPY app.js package.json /app/
---> 9d498d7dbf8b
Step 4/5 : WORKDIR /app
---> Running in 70ea26158cbe
Removing intermediate container 70ea26158cbe
---> fc075a421b91
Step 5/5 : CMD ["npm", "start"]
---> Running in 3642a01224e8
Removing intermediate container 3642a01224e8
---> bb3bd34bac55
Successfully built bb3bd34bac55

请注意,如果您以前从未使用过 node:14-alpine 映像(或者最近擦除了 Docker 缓存),您还会看到一些额外的输出,指示该容器映像的下载。

最终的哈希值是我们的容器镜像的 ID。 我们可以使用它来通过以下命令运行容器的实例:

docker run -it -p 8080:8080 bb3bd34bac55

该命令本质上是告诉 Docker 以 “交互模式”(这意味着它不会进入后台)运行映像 bb3bd34bac55 中的应用程序,并且容器的端口 8080 将映射到主机的端口 8080(我们的操作 系统)。

现在,我们可以通过 localhost:8080 访问该应用程序。 因此,如果我们使用curl向Web服务器发送请求,我们应该得到类似于以下内容的响应:

Hello from f2ffa85c8ff8 (v1)

请注意,主机名现在不同了。 这是因为每个容器都在沙盒环境中运行,默认情况下,该环境无法访问底层操作系统中的大部分资源。

此时,您只需在容器运行的终端窗口中按 Ctrl + C 即可停止容器。

构建图像时,我们可以使用 -t 标志来标记生成的图像。 标签可以用作生成的哈希的更可预测的替代方案来识别和运行容器映像。 例如,如果我们想调用容器镜像 hello-web:v1,我们可以使用以下命令:

docker build -t hello-web:v1 .
docker run -it -p 8080:8080 hello-web:v1

使用标签时,您可能需要遵循图像名称:image-name:version。

什么是 Kubernetes?

我们刚刚使用容器运行了一个 Node.js 应用程序,万岁! 尽管这看起来是一项特别令人兴奋的成就,但我们仅仅触及了皮毛。 当构建更复杂的应用程序时,容器的真正威力就会显现出来。 例如,当构建由多个独立服务组成的应用程序时,需要跨多个云服务器进行部署和协调。 在这种情况下,仅靠 Docker 已经不够了。 我们需要一个更复杂的系统,使我们能够在云集群中的可用机器上编排所有正在运行的容器实例:我们需要一个容器编排工具。

容器编排工具有许多职责:

  • 它允许我们将多个云服务器(节点)加入到一个逻辑集群中,可以动态添加和删除节点,而不会影响每个节点中运行的服务的可用性。

  • 它确保没有停机时间。 如果容器实例停止或对运行状况检查无响应,它将自动重新启动。 此外,如果集群中的某个节点发生故障,该节点中运行的工作负载将自动迁移到另一个节点。

  • 提供实现服务发现和负载平衡的功能。

  • 提供对持久存储的协调访问,以便数据可以根据需要持久保存。

  • 自动推出和回滚应用程序,停机时间为零。

  • 敏感数据和配置管理系统的秘密存储。

最流行的容器编排系统之一是 Kubernetes(nodejsdp.link/kubernetes),最初由 Google 于 2014 年开源。Kubernetes 的名称源自希腊语 “κυβερνήτης”,意思是 “舵手” 或 “飞行员”,也有 “统治者” 的意思。或者更笼统地说,“指挥者”。 Kubernetes 融合了 Google 工程师在云中大规模运行工作负载的多年经验。

它的特点之一是声明性配置系统,允许您定义 “最终状态”,并让编排器找出达到所需状态所需的步骤顺序,而不会破坏集群上运行的服务的稳定性。 Kubernetes 配置的整个思想围绕着 “对象” 的概念。 对象是云部署中的一个元素,可以添加、删除它,也可以随着时间的推移更改其配置。 Kubernetes 对象的一些很好的例子是:

  • 容器化应用程序

  • 容器的资源(CPU 和内存分配、持久存储、对网络接口或 GPU 等设备的访问等)

  • 应用程序行为策略(重启策略、升级、容错)

Kubernetes 对象是一个一种 “意图记录”,这意味着一旦您在集群中创建一个对象,Kubernetes 将不断监视(并在需要时更改)对象的状态,以确保它符合定义的期望。

Kubernetes 集群通常通过名为 kubectl (nodejsdp.link/kubectl-install) 的命令行工具进行管理。

有多种方法可以创建用于开发、测试和生产目的的 Kubernetes 集群。 开始尝试 Kubernetes 的最简单方法是通过本地单节点集群,该集群可以通过名为 minikube 的工具轻松创建 (nodejsdp.link/minikube-install)。

确保在您的系统上安装 kubectl 和 minikube,因为我们将在下一节中在本地 Kubernetes 集群上部署示例容器化应用程序!

了解 Kubernetes 的另一个好方法是使用官方交互式教程 (nodejsdp.link/kubernetestutorials)。

在 Kubernetes 上部署和扩展应用程序

在本节中,我们将在本地 minikube 集群上运行简单的 Web 服务器应用程序。 因此,请确保正确安装并启动了 kubectl 和 minikube。

在 macOS 和 Linux 环境中,请确保运行 minikube start 和 eval $(minikube docker-env) 来初始化工作环境。 第二个命令确保当您在当前终端中使用 docker 和 kubectl 时,您将与本地 Minikube 集群进行交互。 如果您打开多个终端,您应该在每个终端上运行 eval $(minikube docker-env) 。 您还可以运行 minikube 仪表板来运行方便的 Web 仪表板,该仪表板允许您可视化集群中的所有对象并与之交互。

我们要做的第一件事是构建 Docker 镜像并给它一个有意义的名称:

docker build -t hello-web:v1 .

如果您已正确配置环境,则 hello-web 映像将可在本地 Kubernetes 集群中使用。

使用本地镜像足以进行本地开发。 当您准备好投入生产时,最好的选择是将映像发布到 Docker 容器注册表,例如 Docker Hub (nodejsdp.link/docker-hub)、Docker 注册表 (nodejsdp.link/docker-registry)、Google Cloud 容器注册表 (nodejsdp.link/gc-container-registry) 或 Amazon Elastic 容器注册表 (nodejsdp.link/ecr)。 将映像发布到容器注册表后,您可以轻松地将应用程序部署到不同的主机,而无需每次都重建相应的映像。

创建 Kubernetes 部署

现在,为了在 Minikube 集群中运行该容器的实例,我们必须使用以下命令创建一个部署(这是一个 Kubernetes 对象):

kubectl create deployment hello-web --image=hello-web:v1

这应该产生以下输出:

deployment.apps/hello-web created

该命令基本上是告诉 Kubernetes 将 hello-web:v1 容器的实例作为名为 hello-web 的应用程序运行。

您可以使用以下命令验证部署是否正在运行:

kubectl get deployments

这应该打印如下内容:

NAME      READY UP-TO-DATE AVAILABLE AGE
hello-web 1/1   1           1         7s

该表基本上表明我们的 hello-web 部署处于活动状态,并且为其分配了一个 Pod。 Pod 是 Kubernetes 中的基本单元,代表一组必须在同一 Kubernetes 节点中一起运行的容器。 同一 Pod 中的容器共享存储和网络等资源。 通常,一个 pod 只包含一个容器,但当这些容器运行紧密耦合的应用程序时,一个 pod 中存在多个容器的情况并不少见。

您可以使用以下命令列出集群中运行的所有 Pod:

kubectl get pods

这应该打印如下内容:

NAME                        READY STATUS  RESTARTS AGE
hello-web-65f47d9997-df7nr  1/1   Running 0        2m19s

现在,为了能够从本地计算机访问 Web 服务器,我们需要公开部署:

kubectl expose deployment hello-web --type=LoadBalancer --port=8080 minikube service hello-web

第一个命令告诉 Kubernetes 创建一个 LoadBalancer 对象,该对象公开 hello-web 应用程序的实例,连接到每个容器的端口 8080。

第二个命令是一个 minikube 辅助命令,它允许我们获取本地地址来访问负载均衡器。 此命令还将为您打开一个浏览器窗口,因此现在您应该在浏览器中看到容器响应,应如下所示:

Hello from hello-web-65f47d9997-df7nr (v1)

扩展 Kubernetes 部署

现在我们的应用程序正在运行并且可以访问,让我们实际开始尝试 Kubernetes 的一些功能。 例如,为什么不尝试通过运行五个实例而不是仅一个实例来扩展我们的应用程序呢? 这就像运行一样简单:

kubectl scale --replicas=5 deployment hello-web

现在,kubectl get 部署应该向我们显示以下状态:

NAME      READY UP-TO-DATE AVAILABLE AGE
hello-web 5/5    5          5        9m18s

kubectl get pods 应该生成如下内容:

NAME READY STATUS RESTARTS AGE
hello-web-65f47d9997-df7nr 1/1 Running 0 9m24s
hello-web-65f47d9997-g98jb 1/1 Running 0 14s
hello-web-65f47d9997-hbdkx 1/1 Running 0 14s
hello-web-65f47d9997-jnfd7 1/1 Running 0 14s
hello-web-65f47d9997-s54g6 1/1 Running 0 14s

如果您现在尝试访问负载均衡器,当流量分布在可用实例上时,您很可能会看到不同的主机名。 如果您在给应用程序施加压力的同时尝试访问负载均衡器(例如,通过针对负载均衡器 URL 运行 autocannon 负载测试),这一点应该会更加明显。

Kubernetes rollouts

现在,让我们尝试一下 Kubernetes 的另一个功能:推出。 如果我们想发布应用程序的新版本怎么办?

我们可以在 app.js 文件中设置 const version = 2 并创建一个新图像:

docker build -t hello-web:v2 .

此时,为了将所有正在运行的 pod 升级到这个新版本,我们必须运行以下命令:

kubectl set image deployment/hello-web hello-web=hello-web:v2 --record

该命令的输出应如下所示:

deployment.apps/hello-web image updated

如果一切按预期工作,您现在应该能够刷新浏览器页面并看到类似以下内容:

Hello from hello-web-567b986bfb-qjvfw (v2)

请注意那里的 v2 标志。

幕后发生的事情是,Kubernetes 开始通过一一替换容器来推出新版本的镜像。 当容器被替换时,正在运行的实例会正常停止。 这样,当前正在进行的请求可以在容器关闭之前完成。

我们的迷你 Kubernetes 教程到此结束。 这里的教训是,当使用像 Kubernetes 这样的容器编排器平台时,我们可以保持应用程序代码非常简单,因为我们不必考虑扩展到多个实例或处理软部署和应用程序重新启动等问题。 这是这种方法的主要优点。

当然,这种简单性并不是免费的。 这是通过学习和管理编排平台来付费的。 如果您在生产中运行小型应用程序,那么安装和管理 Kubernetes 等容器编排器平台的复杂性和成本可能不值得。 但是,如果您每天为数百万用户提供服务,那么构建和维护如此强大的基础设施肯定具有很大的价值。

另一个有趣的观察是,当在 Kubernetes 中运行容器时,容器通常被认为是 “一次性的”,这基本上意味着它们可以随时被终止并重新启动。 虽然这看起来像是一个不相关的细节,但您实际上应该考虑到这种行为,并尝试使您的应用程序尽可能保持无状态。 事实上,默认情况下,容器不会在本地文件系统中保留任何更改,因此每次您必须存储一些持久信息时,您都必须依赖外部存储机制,例如数据库或持久卷。

如果您想从前面示例中刚刚运行的容器中清理系统并停止 minikube,您可以使用以下命令来执行此操作:

kubectl scale --replicas=0 deployment hello-web
kubectl delete -n default service hello-web
minikube stop

在本章的下一部分和最后一部分中,我们将探索一些有趣的模式,将单体应用程序分解为一组解耦的微服务,如果您已经构建了单体应用程序并且现在遇到可扩展性问题,那么这一点至关重要。