生产最佳实践:性能和可靠性

概述

本文讨论部署到生产中的 Express 应用程序的性能和可靠性最佳实践。

这个主题显然属于 “devops” 世界,跨越传统的开发和运营。 因此,信息分为两部分:

在代码 {#in-code} 中要做的事情

以下是您可以在代码中执行的一些操作以提高应用程序的性能:

使用 gzip 压缩

Gzip 压缩可以大大减小响应主体的大小,从而提高 Web 应用程序的速度。 在您的 Express 应用程序中使用 compression 中间件进行 gzip 压缩。 例如:

const compression = require('compression')
const express = require('express')
const app = express()
app.use(compression())

对于生产中的高流量网站,实施压缩的最佳方法是在反向代理级别实施压缩(参见 使用反向代理)。 在那种情况下,您不需要使用压缩中间件。 有关在 Nginx 中启用 gzip 压缩的详细信息,请参阅 Nginx 文档中的 模块 ngx_http_gzip_module

不要使用同步函数

同步函数和方法会占用正在执行的进程,直到它们返回。 对同步函数的单个调用可能会在几微秒或几毫秒内返回,但在高流量网站中,这些调用加起来会降低应用程序的性能。 避免在生产中使用它们。

尽管 Node 和许多模块提供了它们功能的同步和异步版本,但在生产中始终使用异步版本。 唯一可以证明同步功能合理的时间是在初始启动时。

如果您使用的是 Node.js 4.0+ 或 io.js 2.1.0+,您可以使用 --trace-sync-io 命令行标志在您的应用程序使用同步 API 时打印警告和堆栈跟踪。 当然,您不想在生产中使用它,而是为了确保您的代码已准备好投入生产。 有关详细信息,请参阅 node 命令行选项文档

正确记录日志

一般来说,从您的应用程序登录有两个原因: 用于调试和记录应用程序活动(基本上,其他一切)。 使用 console.log()console.error() 将日志消息打印到终端是开发中的常见做法。 但是当目标是终端或文件时 这些函数是同步的,所以它们不适合生产,除非你将输出通过管道传递给另一个程序。

用于调试

如果您出于调试目的进行日志记录,那么不要使用 console.log(),而是使用像 debug 这样的特殊调试模块。 此模块使您能够使用 DEBUG 环境变量来控制将哪些调试消息发送到 console.error()(如果有)。 为了让您的应用程序完全异步,您仍然希望将 console.error() 通过管道传递给另一个程序。 但是,你真的不会在生产中调试,是吗?

对于应用活动

如果您正在记录应用程序活动(例如,跟踪流量或 API 调用),请使用 WinstonBunyan 等日志库,而不是使用 console.log()。 有关这两个库的详细比较,请参阅 StrongLoop 博文 比较 Winston 和 Bunyan Node.js 日志记录

妥善处理异常

Node 应用程序在遇到未捕获的异常时崩溃。 不处理异常并采取适当的措施将使您的 Express 应用程序崩溃并离线。 如果您遵循下面 确保您的应用自动重启 中的建议,那么您的应用将从崩溃中恢复。 幸运的是,Express 应用程序的启动时间通常很短。 然而,您首先要避免崩溃,为此,您需要正确处理异常。

为确保处理所有异常,请使用以下技术:

在深入这些主题之前,您应该对 Node/Express 错误处理有一个基本的了解: 使用错误优先回调,并在中间件中传播错误。 Node 使用 “错误优先回调” 约定从异步函数返回错误,其中回调函数的第一个参数是错误对象,后面是后续参数中的结果数据。 要指示没有错误,请将 null 作为第一个参数传递。 回调函数必须相应地遵循错误优先回调约定才能有意义地处理错误。 在 Express 中,最佳实践是使用 next() 函数通过中间件链传播错误。

有关错误处理基础知识的更多信息,请参阅:

不该做什么

你不应该做的一件事是监听 uncaughtException 事件,当异常冒泡回到事件循环时发出。 为 uncaughtException 添加事件监听器将更改遇到异常的进程的默认行为; 尽管出现异常,该过程仍将继续运行。 这听起来像是防止应用程序崩溃的好方法,但在未捕获的异常后继续运行应用程序是一种危险的做法,不推荐这样做,因为进程的状态变得不可靠且不可预测。

此外,使用 uncaughtException 被官方认可为 crude。 所以听 uncaughtException 只是个坏主意。 这就是为什么我们推荐诸如多个进程和主管之类的东西: 崩溃并重新启动通常是从错误中恢复的最可靠方法。

我们也不建议使用 domains。 它通常不能解决问题,是一个已弃用的模块。

使用 try-catch

Try-catch 是一种 JavaScript 语言构造,可用于捕获同步代码中的异常。 例如,使用 try-catch 来处理 JSON 解析错误,如下所示。

使用 JSHintJSLint 之类的工具来帮助您查找隐式异常,例如 未定义变量的引用错误

下面是使用 try-catch 处理潜在进程崩溃异常的示例。 这个中间件函数接受一个名为 “params” 的查询字段参数,它是一个 JSON 对象。

app.get('/search', (req, res) => {
  // Simulating async operation
  setImmediate(() => {
    const jsonStr = req.query.params
    try {
      const jsonObj = JSON.parse(jsonStr)
      res.send('Success')
    } catch (e) {
      res.status(400).send('Invalid JSON string')
    }
  })
})

但是,try-catch 仅适用于同步代码。 因为 Node 平台主要是异步的(特别是在生产环境中),try-catch 不会捕获很多异常。

使用 promise

Promises 将处理使用 then() 的异步代码块中的任何异常(显式和隐式)。 只需将 .catch(next) 添加到promise链的末尾。 例如:

app.get('/', (req, res, next) => {
  // do some sync stuff
  queryDb()
    .then((data) => makeCsv(data)) // handle data
    .then((csv) => { /* handle csv */ })
    .catch(next)
})

app.use((err, req, res, next) => {
  // handle error
})

现在所有异步和同步错误都会传播到错误中间件。

但是,有两个注意事项:

  1. 您所有的异步代码都必须返回promise(触发器除外)。 如果特定库不返回promise,请使用像 Bluebird.promisifyAll() 这样的辅助函数转换基础对象。
  2. 事件触发器(如流)仍然会导致未捕获的异常。 因此,请确保您正确处理了错误事件; 例如:
const wrap = fn => (...args) => fn(...args).catch(args[2])

app.get('/', wrap(async (req, res, next) => {
  const company = await getCompanyById(req.query.id)
  const stream = getLogoStreamById(company.id)
  stream.on('error', next).pipe(res)
}))

wrap() 函数是一个包装器,它捕获被拒绝的promise并以错误作为第一个参数调用 next()。 详见 Asynchronous Error Handling in Express with Promises, Generators and ES7

有关使用promise进行错误处理的更多信息,请参阅 使用 Q 在 Node.js 中实现 promise - 回调的替代方案

在您的环境/设置中要做的事情

以下是您可以在系统环境中执行的一些操作,以提高应用程序的性能:

将 NODE_ENV 设置为 “production”

NODE_ENV 环境变量指定应用程序运行的环境(通常是开发或生产)。 要提高性能,您可以做的最简单的事情之一就是将 NODE_ENV 设置为 “production.”

将 NODE_ENV 设置为 “production” 使得 Express:

测试表明 只要这样做就可以将应用程序性能提高三倍!

如果需要编写特定于环境的代码,可以使用 process.env.NODE_ENV 检查 NODE_ENV 的值。 请注意,检查任何环境变量的值都会导致性能下降,因此应谨慎进行。

在开发中,您通常在交互式 shell 中设置环境变量,例如使用 export.bash_profile 文件。 但一般来说,您不应该在生产服务器上这样做; 相反,请使用操作系统的初始化系统(systemd 或 Upstart)。 下一节将提供有关一般使用 init 系统的更多详细信息,但设置 NODE_ENV 对性能非常重要(而且很容易做到),因此在此处突出显示。

对于 Upstart,在您的工作文件中使用 env 关键字。 例如:

# /etc/init/env.conf
 env NODE_ENV=production

有关详细信息,请参阅 Upstart 简介、食谱和最佳实践

对于 systemd,请在单元文件中使用 Environment 指令。 例如:

# /etc/systemd/system/myservice.service
Environment=NODE_ENV=production

有关详细信息,请参阅 在 systemd 单元中使用环境变量

确保您的应用自动重启

在生产环境中,您永远不希望您的应用程序处于离线状态。 这意味着您需要确保它在应用程序崩溃和服务器本身崩溃时都重新启动。 尽管您希望这两种情况都不会发生,但实际上您必须通过以下方式考虑这两种可能性:

如果遇到未捕获的异常,Node 应用程序会崩溃。 您需要做的最重要的事情是确保您的应用经过良好测试并处理所有异常(有关详细信息,请参阅 正确处理异常)。 但作为故障保险,建立一个机制来确保当你的应用程序崩溃时,它会自动重启。

使用进程管理器

在开发中,您只需从命令行使用 node server.js 或类似的东西启动您的应用程序。 但是在生产中这样做是灾难的根源。 如果应用程序崩溃,它将处于离线状态,直到您重新启动它。 为确保您的应用程序在崩溃时重新启动,请使用进程管理器。 进程管理器是应用程序的 “container”,可促进部署、提供高可用性并使您能够在运行时管理应用程序。

除了在应用程序崩溃时重新启动应用程序之外,进程管理器还可以让您:

最流行的 Node 进程管理器如下:

有关三个流程管理器的逐项比较,请参阅 http://strong-pm.io/compare/。 有关这三者的更详细介绍,请参阅 Express 应用程序的进程管理器

使用这些进程管理器中的任何一个都足以让您的应用程序保持正常运行,即使它有时会崩溃。

然而,StrongLoop PM 有很多专门针对生产部署的特性。 您可以使用它和相关的 StrongLoop 工具来:

如下所述,当您使用 init 系统将 StrongLoop PM 安装为操作系统服务时,它会在系统重新启动时自动重新启动。 因此,它将使您的应用程序进程和集群永远保持活动状态。

使用初始化系统

下一层可靠性是确保您的应用程序在服务器重新启动时重新启动。 由于各种原因,系统仍可能出现故障。 为确保您的应用程序在服务器崩溃时重新启动,请使用操作系统内置的初始化系统。 目前使用的两个主要初始化系统是 systemdUpstart

有两种方法可以通过 Express 应用程序使用初始化系统:

systemd

Systemd 是一个 Linux 系统和服务管理器。 大多数主要的 Linux 发行版都采用 systemd 作为它们的默认初始化系统。

systemd 服务配置文件称为单元文件,文件名以 .service 结尾。 这是一个示例单元文件,用于直接管理 Node 应用程序。 为您的系统和应用替换 <angle brackets> 中包含的值:

[Unit]
Description=<Awesome Express App>

[Service]
Type=simple
ExecStart=/usr/local/bin/node </projects/myapp/index.js>
WorkingDirectory=</projects/myapp>

User=nobody
Group=nogroup

<a id="environment-variables"></a>

# 环境变量:
Environment=NODE_ENV=production

<a id="allow-many-incoming-connections"></a>

# 允许许多传入连接
LimitNOFILE=infinity

<a id="allow-core-dumps-for-debugging"></a>

# 允许核心转储进行调试
LimitCORE=infinity

StandardInput=null
StandardOutput=syslog
StandardError=syslog
Restart=always

[Install]
WantedBy=multi-user.target

有关 systemd 的更多信息,请参阅 systemd 参考手册

StrongLoop PM 作为 systemd 服务

您可以轻松地将 StrongLoop Process Manager 安装为 systemd 服务。 完成后,当服务器重新启动时,它会自动重新启动 StrongLoop PM,然后它将重新启动它管理的所有应用程序。

将 StrongLoop PM 安装为系统服务:

$ sudo sl-pm-install --systemd

然后启动服务:

$ sudo /usr/bin/systemctl start strong-pm

有关详细信息,请参阅 设置生产主机(StrongLoop 文档)

Upstart

Upstart 是许多 Linux 发行版上可用的系统工具,用于在系统启动期间启动任务和服务,在关闭期间停止它们,并监督它们。 您可以将您的 Express 应用程序或进程管理器配置为服务,然后 Upstart 会在它崩溃时自动重启它。

Upstart 服务在作业配置文件(也称为 “job”)中定义,文件名以 .conf 结尾。 以下示例显示如何为名为 “myapp” 的应用程序创建名为 “myapp” 的作业,主文件位于 /projects/myapp/index.js

使用以下内容创建一个名为 myapp.conf at /etc/init/ 的文件(将粗体文本替换为您的系统和应用程序的值):

<a id="when-to-start-the-process"></a>

# 何时开始进程
start on runlevel [2345]

<a id="when-to-stop-the-process"></a>

# 何时停止进程
stop on runlevel [016]

<a id="increase-file-descriptor-limit-to-be-able-to-handle-more-requests"></a>

# 增加文件描述符限制以能够处理更多请求
limit nofile 50000 50000

<a id="use-production-mode"></a>

# 使用生产模式
env NODE_ENV=production

<a id="run-as-www-data"></a>

# 作为 www-data 运行
setuid www-data
setgid www-data

<a id="run-from-inside-the-app-dir"></a>

# 从应用程序目录中运行
chdir /projects/myapp

<a id="the-process-to-start"></a>

# 开始的进程
exec /usr/local/bin/node /projects/myapp/index.js

<a id="restart-the-process-if-it-is-down"></a>

# 如果它关闭,请重新启动该进程
respawn

<a id="limit-restart-attempt-to-10-times-within-10-seconds"></a>

# 将重启尝试限制为 10 秒内 10 次
respawn limit 10 10

注意: 此脚本需要 Upstart 1.4 或更新版本,受 Ubuntu 12.04-14.10 支持。

由于作业配置为在系统启动时运行,因此您的应用程序将与操作系统一起启动,并在应用程序崩溃或系统停机时自动重新启动。

除了自动重启应用程序之外,Upstart 还允许您使用以下命令:

有关 Upstart 的更多信息,请参阅 Upstart 简介、食谱和最佳实践

StrongLoop PM 作为 Upstart 服务

您可以轻松地将 StrongLoop Process Manager 安装为 Upstart 服务。 完成后,当服务器重新启动时,它会自动重新启动 StrongLoop PM,然后它将重新启动它管理的所有应用程序。

要将 StrongLoop PM 安装为 Upstart 1.4 服务:

$ sudo sl-pm-install

然后运行服务:

$ sudo /sbin/initctl start strong-pm

注意: 在不支持 Upstart 1.4 的系统上,命令略有不同。 有关详细信息,请参阅 设置生产主机(StrongLoop 文档)

在集群中运行您的应用

在多核系统中,您可以通过启动进程集群将 Node 应用程序的性能提高很多倍。 集群运行应用程序的多个实例,理想情况下每个 CPU 内核一个实例,从而在实例之间分配负载和任务。

使用集群 API 在应用程序实例之间进行平衡

重要: 由于应用程序实例作为单独的进程运行,因此它们不共享相同的内存空间。 也就是说,对象对于应用程序的每个实例都是本地的。 因此,您无法在应用程序代码中维护状态。 但是,您可以使用像 Redis 这样的内存数据存储来存储与会话相关的数据和状态。 这个警告基本上适用于所有形式的水平扩展,无论是多进程集群还是多物理服务器。

在集群应用程序中,工作进程可以单独崩溃而不影响其余进程。 除了性能优势之外,故障隔离是运行应用进程集群的另一个原因。 每当工作进程崩溃时,始终确保记录事件并使用 cluster.fork() 生成新进程。

使用 Node 的集群模块

Node 的 cluster module 使集群成为可能。 这使主进程能够生成工作进程并在工作进程之间分配传入连接。 但是,与其直接使用该模块,不如使用自动为您完成的众多工具之一要好得多; 例如 node-pmcluster-service

使用 StrongLoop PM

如果将应用程序部署到 StrongLoop Process Manager (PM),则无需修改应用程序代码即可利用集群。

当 StrongLoop Process Manager (PM) 运行一个应用程序时,它会自动在一个集群中运行它,该集群的工作线程数等于系统上的 CPU 内核数。 您可以使用 slc 命令行工具手动更改集群中的工作进程数,而无需停止应用程序。

例如,假设您已将应用程序部署到 prod.foo.com 并且 StrongLoop PM 正在监听端口 8701(默认),然后使用 slc 将集群大小设置为 8:

$ slc ctl -C http://prod.foo.com:8701 set-size my-app 8

有关使用 StrongLoop PM 进行集群的更多信息,请参阅 StrongLoop 文档中的 集群

使用 PM2

如果您使用 PM2 部署您的应用程序,那么您可以在不修改您的应用程序代码的情况下利用集群。 你应该首先确保你的 应用程序是无状态的,这意味着没有本地数据存储在进程中(例如会话、websocket 连接等)。

当使用 PM2 运行应用程序时,您可以启用 集群模式 以在具有您选择的多个实例的集群中运行它,例如匹配机器上可用 CPU 的数量。 您可以使用 pm2 命令行工具手动更改集群中的进程数,而无需停止应用程序。

要启用集群模式,请像这样启动您的应用程序:

<a id="start-4-worker-processes"></a>

# 启动 4 个工作进程
$ pm2 start npm --name my-app -i 4 -- start
<a id="auto-detect-number-of-available-cpus-and-start-that-many-worker-processes"></a>

# 自动检测可用 CPU 的数量并启动多个工作进程
$ pm2 start npm --name my-app -i max -- start

这也可以在 PM2 进程文件(ecosystem.config.js 或类似文件)中配置,方法是将 exec_mode 设置为 cluster,将 instances 设置为要启动的工作进程数。

运行后,应用程序可以像这样缩放:

<a id="add-3-more-workers"></a>

# 增加 3 个工作线程
$ pm2 scale my-app +3
<a id="scale-to-a-specific-number-of-workers"></a>

# 扩展到特定数量的工作线程
$ pm2 scale my-app 2

有关使用 PM2 进行集群的更多信息,请参阅 PM2 文档中的 集群模式

缓存请求结果

另一个提高生产性能的策略是缓存请求的结果,这样你的应用程序就不会重复操作来重复服务同一个请求。

使用像 VarnishNginx(另请参阅 Nginx 缓存)这样的缓存服务器可以大大提高应用程序的速度和性能。

使用负载均衡器

无论应用程序如何优化,单个实例只能处理有限的负载和流量。 扩展应用程序的一种方法是运行它的多个实例并通过负载均衡器分配流量。 设置负载均衡器可以提高应用程序的性能和速度,并使其能够比单个实例扩展更多。

负载均衡器通常是一个反向代理,用于协调进出多个应用程序实例和服务器的流量。 您可以使用 NginxHAProxy 轻松地为您的应用设置负载均衡器。

使用负载平衡,您可能必须确保与特定会话 ID 关联的请求连接到发起它们的进程。 这被称为会话亲和性或粘性会话,并且可以通过上面的建议解决,即使用 Redis 等数据存储来存储会话数据(取决于您的应用程序)。 有关讨论,请参阅 使用多个 node

使用反向代理

反向代理位于 Web 应用程序前面,除了将请求定向到应用程序之外,还对请求执行支持操作。 它可以处理错误页面、压缩、缓存、服务文件和负载平衡等。

将不需要了解应用程序状态的任务移交给反向代理可以释放 Express 来执行专门的应用程序任务。 出于这个原因,建议在生产环境中像 NginxHAProxy 这样的反向代理后面运行 Express。