Production best practices: performance and reliability
本文讨论部署到生产的 Express 应用程序的性能和可靠性最佳实践。
这个话题显然属于“devops”世界,涵盖传统的开发和运营。 因此,信息分为两部分:
Gzip 压缩可以大大减小响应主体的大小,从而提高 Web 应用程序的速度。 在您的 Express 应用程序中使用 compression 进行 gzip 压缩。 例如:
var compression = require('compression')
var express = require('express')
var app = express()
app.use(compression())
对于生产中的高流量网站,实施压缩的最佳方法是在反向代理级别实施它。 在这种情况下,您不需要使用 compression 中间件。 有关在 Nginx 中启用 gzip 压缩的详细信息,请参阅 Nginx 文档中的模块 ngx_http_gzip_module。
同步函数和方法会在它们返回之前阻塞正在执行的进程。 对同步函数的单个调用可能会在几微秒或几毫秒内返回,但是在高流量网站中,这些调用会累加并降低应用程序的性能。 避免在生产中使用它们。
尽管 Node 和许多模块提供了它们功能的同步和异步版本,但在生产中始终使用异步版本。 唯一可以证明同步功能合理的时间是在初始启动时。
如果您使用的是 Node.js 4.0+ 或 io.js 2.1.0+,您可以使用 --trace-sync-io 命令行标志在您的应用程序使用同步 API 时打印警告和堆栈跟踪。 当然,您不想在生产中使用它,而是要确保您的代码已准备好用于生产。
通常,从您的应用程序进行日志记录有两个原因:用于调试和记录应用程序活动(本质上是其他所有内容)。 使用 console.log() 或 console.error() 将日志消息打印到终端是开发中的常见做法。 但是当目标是终端或文件时,这些函数是同步的,因此它们不适合生产,除非您将输出通过管道传输到另一个程序。
如果您出于调试目的进行日志记录,那么不要使用 console.log(),而是使用像 debug 这样的特殊调试模块。 此模块使您能够使用 DEBUG 环境变量来控制将哪些调试消息发送到 console.error()(如果有)。 为了保持你的应用完全异步,你仍然希望通过管道将 console.error() 传递给另一个程序。
如果您要记录应用活动(例如,跟踪流量或 API 调用),请不要使用 console.log(),而是使用像 Winston 或 Bunyan 这样的日志库。 有关这两个库的详细比较,请参阅 StrongLoop 博客文章 Comparing Winston and Bunyan Node.js Logging.
Node 应用程序在遇到未捕获的异常时崩溃。 不处理异常并采取适当的措施将使您的 Express 应用程序崩溃并下线。 如果您遵循下面确保您的应用程序自动重新启动中的建议,那么您的应用程序将从崩溃中恢复。 幸运的是,Express 应用程序的启动时间通常很短。 尽管如此,您首先要避免崩溃,为此,您需要正确处理异常。
为确保您处理所有异常,请使用以下技术:
在深入研究这些主题之前,您应该对 Node/Express 错误处理有一个基本的了解:使用错误优先回调,以及在中间件中传播错误。 Node 使用“错误优先回调”约定从异步函数返回错误,其中回调函数的第一个参数是错误对象,后跟参数中的结果数据。 要指示没有错误,请将 null 作为第一个参数传递。 回调函数必须相应地遵循错误优先回调约定才能有意义地处理错误。 而在 Express 中,最佳实践是使用 next() 函数通过中间件链传播错误。
您不应该做的一件事是侦听 uncaughtException 事件,当异常冒泡一直返回到事件循环时会发出该事件。 为 uncaughtException 添加事件监听器将改变遇到异常的进程的默认行为; 尽管有异常,该进程仍将继续运行。 这听起来像是防止您的应用程序崩溃的好方法,但在未捕获的异常之后继续运行应用程序是一种危险的做法,不建议这样做,因为进程的状态变得不可靠且不可预测。
此外,使用 uncaughtException 被官方认为是粗暴的。所以监听 uncaughtException 只是一个坏主意。 这就是为什么我们推荐多个进程和主管之类的东西:崩溃和重新启动通常是从错误中恢复的最可靠方法。
我们也不建议使用 domains.它通常不能解决问题,并且是不推荐使用的模块。
Try-catch 是一种 JavaScript 语言结构,可用于捕获同步代码中的异常。 例如,使用 try-catch 来处理 JSON 解析错误,如下所示。
使用诸如 JSHint 或 JSLint 之类的工具来帮助您查找隐式异常,例如未定义变量上的引用错误。
以下是使用 try-catch 处理潜在进程崩溃异常的示例。 这个中间件函数接受一个名为“params”的查询字段参数,它是一个 JSON 对象。
app.get('/search', (req, res) => {
// Simulating async operation
setImmediate(() => {
var jsonStr = req.query.params
try {
var jsonObj = JSON.parse(jsonStr)
res.send('Success')
} catch (e) {
res.status(400).send('Invalid JSON string')
}
})
})
但是,try-catch 仅适用于同步代码。 因为 Node 平台主要是异步的(特别是在生产环境中),try-catch 不会捕获很多异常。
Promise 将处理使用 then() 的异步代码块中的任何异常(显式和隐式)。 只需将 .catch(next) 添加到承诺链的末尾即可。 例如:
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
})
现在所有异步和同步错误都会传播到错误中间件。
但是,有两个警告:
所有异步代码都必须返回承诺(发射器除外)。 如果特定库不返回承诺,请使用 Bluebird.promisifyAll() 等辅助函数转换基础对象。
事件发射器(如流)仍然会导致未捕获的异常。 因此,请确保正确处理错误事件; 例如:
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() 函数是一个包装器,它捕获被拒绝的承诺并调用 next() 并将错误作为第一个参数。
更多细节可以参考这篇博客:Asynchronous Error Handling in Express with Promises, Generators and ES7.
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
使用 systemd,在单元文件中使用 Environment 指令。 例如:
# /etc/systemd/system/myservice.service
Environment=NODE_ENV=production
在生产中,您永远不希望您的应用程序处于离线状态。 这意味着您需要确保它在应用程序崩溃和服务器本身崩溃时重新启动。 尽管您希望这两种情况都不会发生,但实际上您必须通过以下方式对这两种情况进行说明:
使用进程管理器在崩溃时重新启动应用程序(和节点)。
使用操作系统提供的 init 系统在操作系统崩溃时重新启动进程管理器。 也可以在没有进程管理器的情况下使用 init 系统。
如果遇到未捕获的异常,节点应用程序就会崩溃。 您需要做的最重要的事情是确保您的应用程序经过良好测试并处理所有异常。
但作为一种故障安全措施,应采用一种机制来确保当您的应用程序崩溃时,它会自动重新启动。
在开发中,您只需使用 node server.js 或类似的东西从命令行启动您的应用程序。 但是在生产中这样做会导致灾难。 如果应用程序崩溃,它将处于离线状态,直到您重新启动它。 为确保您的应用程序在崩溃时重新启动,请使用进程管理器。 流程管理器是应用程序的“容器”,可促进部署、提供高可用性并使您能够在运行时管理应用程序。
除了在应用程序崩溃时重新启动应用程序之外,进程管理器还可以让您:
深入了解运行时性能和资源消耗。
动态修改设置以提高性能。
控制集群(StrongLoop PM 和 pm2)。
下面是三个比较流行的进程管理器:
有关三个流程管理器的逐个功能比较,请参阅 http://strong-pm.io/compare/。
使用这些进程管理器中的任何一个都足以让您的应用程序保持正常运行,即使它不时崩溃。
下一层可靠性是确保您的应用程序在服务器重新启动时重新启动。 由于各种原因,系统仍可能出现故障。 为确保您的应用程序在服务器崩溃时重新启动,请使用操作系统内置的 init 系统。 目前使用的两个主要初始化系统是 systemd 和 Upstart。
有两种方法可以在 Express 应用程序中使用 init 系统:
在进程管理器中运行您的应用程序,并使用 init 系统将进程管理器安装为服务。 当应用程序崩溃时,进程管理器将重新启动您的应用程序,当操作系统重新启动时,init 系统将重新启动进程管理器。 这是推荐的方法。
直接使用 init 系统运行您的应用程序(和 Node)。 这有点简单,但您无法获得使用进程管理器的额外优势。
Systemd 是一个 Linux 系统和服务管理器。 大多数主要的 Linux 发行版都采用 systemd 作为它们的默认初始化系统。
systemd 服务配置文件称为单元文件,文件名以 .service 结尾。 这是一个用于直接管理 Node 应用程序的示例单元文件。 为您的系统和应用替换尖括号中的值:
[Unit]
Description=<Awesome Express App>
[Service]
Type=simple
ExecStart=/usr/local/bin/node </projects/myapp/index.js>
WorkingDirectory=</projects/myapp>
User=nobody
Group=nogroup
# Environment variables:
Environment=NODE_ENV=production
# Allow many incoming connections
LimitNOFILE=infinity
# Allow core dumps for debugging
LimitCORE=infinity
StandardInput=null
StandardOutput=syslog
StandardError=syslog
Restart=always
[Install]
WantedBy=multi-user.target
在多核系统中,您可以通过启动一组进程来将 Node 应用程序的性能提高许多倍。 一个集群运行应用程序的多个实例,理想情况下每个 CPU 内核上有一个实例,从而在实例之间分配负载和任务。
[图片]
重要提示:由于应用程序实例作为单独的进程运行,因此它们不共享相同的内存空间。 也就是说,对象对于应用程序的每个实例都是本地的。 因此,您无法在应用程序代码中维护状态。 但是,您可以使用像 Redis 这样的内存中数据存储来存储与会话相关的数据和状态。 这个警告基本上适用于所有形式的水平扩展,无论是多进程集群还是多物理服务器。
在集群应用程序中,工作进程可以单独崩溃而不影响其余进程。 除了性能优势之外,故障隔离是运行应用进程集群的另一个原因。 每当工作进程崩溃时,请始终确保记录该事件并使用 cluster.fork () 生成一个新进程。
如果使用 PM2 部署应用程序,则无需修改应用程序代码即可利用集群。 您应该首先确保您的应用程序是无状态的,这意味着没有本地数据存储在进程中(例如会话、websocket 连接等)。
当使用 PM2 运行应用程序时,您可以启用集群模式以在具有您选择的多个实例的集群中运行它,例如匹配机器上可用 CPU 的数量。 您可以使用 pm2 命令行工具手动更改集群中的进程数,而无需停止应用程序。
要启用集群模式,请像这样启动您的应用程序:
# Start 4 worker processes
$ pm2 start npm --name my-app -i 4 -- start
# Auto-detect number of available CPUs and start that many worker processes
$ pm2 start npm --name my-app -i max -- start
这也可以在 PM2 进程文件(ecosystem.config.js 或类似文件)中通过将 exec_mode 设置为 cluster 并将实例设置为要启动的工作程序数量来配置。
运行后,应用程序可以像这样缩放:
# Add 3 more workers
$ pm2 scale my-app +3
# Scale to a specific number of workers
$ pm2 scale my-app 2
在生产中提高性能的另一个策略是缓存请求的结果,这样您的应用程序就不会重复操作来重复处理相同的请求。
使用 Varnish 或 Nginx 等缓存服务器(另请参阅 Nginx 缓存)可以大大提高应用程序的速度和性能。
无论应用程序如何优化,单个实例只能处理有限的负载和流量。 扩展应用程序的一种方法是运行它的多个实例并通过负载均衡器分配流量。 设置负载均衡器可以提高应用程序的性能和速度,并使其能够比单个实例扩展更多。
负载均衡器通常是一个反向代理,用于协调进出多个应用程序实例和服务器的流量。 您可以使用 Nginx 或 HAProxy 轻松地为您的应用程序设置负载均衡器。
通过负载平衡,您可能必须确保与特定会话 ID 关联的请求连接到发起它们的进程。 这称为亲缘会话或粘性会话,可以通过上面的建议解决,使用诸如 Redis 之类的数据存储来存储会话数据(取决于您的应用程序)。
反向代理位于 Web 应用程序的前面,除了将请求定向到应用程序之外,还对请求执行支持操作。 它可以处理错误页面、压缩、缓存、提供文件和负载平衡等。
将不需要应用程序状态知识的任务移交给反向代理可以释放 Express 来执行专门的应用程序任务。 出于这个原因,建议在生产中使用反向代理(如 Nginx 或 HAProxy)运行 Express。