微信号:frontshow

介绍:InfoQ大前端技术社群:囊括前端、移动、Node全栈一线技术,紧跟业界发展步伐。

如何在Node.js中优化服务器端渲染?

2018-07-22 17:43 无明 译
作者|Ben Hughes
译者|无明

在 Airbnb,我们花了数年时间将所有前端代码迁移到 React 架构,Ruby on Rails 在 Web 应用中所占的比例每天都在减少。实际上,我们很快会转向另一个新的服务,即通过 Node.js 提供完整的服务器端渲染页面。这个服务将为 Airbnb 的所有产品渲染大部分 HTML。这个渲染引擎不同于其他后端服务,因为它不是用 Ruby 或 Java 开发的,但它也不同于常见的 I/O 密集型 Node.js 服务。

一说起 Node.js,你可能就开始畅想着高度异步化的应用程序,可以同时处理成千上万个连接。你的服务从各处拉取数据,以迅雷不及掩耳之势处理好它们,然后返回给客户端。你可能正在处理一大堆 WebSocket 连接,你对自己的轻量级并发模型充满自信,认为它非常适合完成这些任务。

但服务器端渲染(SSR)却打破了你对这种美好愿景的假设,因为它是计算密集型的。Node.js 中的用户代码运行在单个线程上,因此可以并发执行计算操作(与 I/O 操作相反),但不能并行执行它们。Node.js 可以并行处理大量的异步 I/O,但在计算方面却受到了限制。随着计算部分所占比例的增加,开始出现 CPU 争用,并发请求将对延迟产生越来越大的影响。

以 Promise.all([fn1,fn2]) 为例,如果 fn1 或 fn2 是属于 I/O 密集型的 promise,就可以实现这样的并行执行:

如果 fn1 和 fn2 是计算密集型的,它们将像这样执行:

一个操作必须等待另一个操作完成后才能运行,因为只有一个执行线程。

在进行服务器端渲染时,当服务器进程需要处理多个并发请求,就会出现这种情况。正在处理中的请求将导致其他请求延迟:

在实际当中,请求通常由许多不同的异步阶段组成,尽管仍然以计算为主。这可能导致更糟糕的交叉。如果我们的请求包含一个像 renderPromise().then(out => formatResponsePromise(out)).then(body => res.send(body)) 这样的链,那么请求的交叉可能是这样的:

在这种情况下,两个请求都需要两倍的时间才能处理完成。随着并发的增加,这个问题将变得更加严重。

SSR 的一个目标是能够在客户端和服务器上使用相同或类似的代码。这两种环境之间存在一个巨大的差异,客户端上下文本质上是单租户的,而服务器上下文却是多租户的。在客户端可以正常运行的东西,比如单例或全局状态,到了服务器端就会导致 bug、数据泄漏和各种混乱。

这两个问题都与并发有关。在负载水平较低时,或在开发环境当中,一切都正常。

这与 Node 应用程序的情况完全不同。我们之所以使用 JavaScript 运行时,是因为它提供的库支持和对浏览器的支持,而不是因为它的并发模型。上述的示例表明,异步并发模型所带来的成本已经超出了它所能带来的好处。

从 Hypernova 中学到的教训

我们的新渲染服务 Hyperloop 将成为 Airbnb 用户的主要交互服务。因此,它的可靠性和性能对用户体验来说至关重要。随着逐渐在生产环境中使用新服务,我们将参考从早期 SSR 服务 Hypernova 中吸取到的教训。

Hypernova 的工作方式与新服务不同。它是一个纯粹的渲染器,Rails 单体应用 Monorail 会调用它,它返回渲染组件的 HTML 片段。在大多数情况下,“片段”是整个页面的一部分,Rails 只提供外部布局。页面上的各个部分可以使用 ERB 拼接在一起。但是,不管是哪一种情况,Hypernova 都不获取数据,数据由 Rails 提供。

也就是说,在计算方面,Hyperloop 和 Hypernova 具有类似的操作特性,而 Hypernova 提供了良好的测试基础,可以帮助我们理解生产环境中的页面内容是如何进行替换的。

用户请求进入我们的 Rails 主应用程序 Monorail,它为需要进行渲染的 React 组件组装 props,并向 Hypernova 发送带有这些 props 和组件名称的请求。Hypernova 使用收到的 props 来渲染组件,生成 HTML 并返回给 Monorail,Monorail 将 HTML 片段嵌入到页面模板中,并将所有内容发送给客户端。

如果 Hypernova 渲染失败(由于错误或超时),就将组件及 props 嵌入页面,或许它们可以成功地在客户端渲染。因此,我们认为 Hypernova 是一个可选的依赖项,我们能够容忍一些超时和失败。我根据 SLA p95 来设置超时时间,不出所料,我们的超时基线略低于 5%。

在高峰流量负载期间进行部署时,我们可以看到从 Monorail 到 Hypernova 最多有 40%的请求超时。我们可以从 Hypernova 中看到 BadRequestError:aborted 的错误率峰值。

部署超时峰值示例(红线)

我们把这些超时和错误归因于缓慢的启动时间,如 GC 启动初始化、缺少 JIT、填充缓存等等。新发布的 React 或 Node 有望提供足够的性能改进,以缓解启动缓慢的问题。

我怀疑这可能是由于不良的负载均衡或部署期间的容量问题造成的。当我们在同一个进程上同时运行多个计算请求时,我们看到了延迟的增加。我添加了一个中间件来记录进程同时处理的请求数。

我们将启动延迟归咎于并发请求等待 CPU。从我们的性能指标来看,我们无法区分用于等待执行的时间与用于实际处理请求的时间。这也意味着并发性带来的延迟与新代码或新特性带来的延迟是相同的——这些实际上都会增加单个请求的处理成本。

很明显,我们不能将 BadRequestError:Request aborted 错误归咎于启动延迟。这个错误来自消息解析器,特别在服务器完全读取请求消息体之前,客户端中止了请求。客户端关闭了连接,我们无法拿到处理请求所需的宝贵数据。发生这种情况的可能性更大,比如:我们开始处理请求,然后事件循环被另一个请求渲染阻塞,当回到之前被中断的地方继续处理时,发现客户端已经消失了。Hypernova 的请求消息体也很大,平均有几百千字节,这样只会让事情变得更糟。

我们决定使用两个现有的组件来解决这个问题:反向代理(Nginx)和负载均衡器(HAProxy)。

反向代理和负载均衡

为了充分利用 Hypernova 实例上的多核 CPU,我们在单个实例上运行多个 Hypernova 进程。因为这些是独立的进程,所以能够并行处理并发请求。

问题是每个 Node 进程将在整个请求时间内被占用,包括从客户端读取请求消息体。虽然我们可以在单个进程中并行读取多个请求,但在渲染时,这会导致计算操作交叉。因此,Node 进程的使用情况取决于客户端和网络的速度。

解决办法是使用缓冲反向代理来处理与客户端的通信。为此,我们使用了 Nginx。Nginx 将客户端的请求读入缓冲区,并在完全读取后将完整请求传给 Node 服务器。这个传输过程是在本地机器上进行的,使用了回送或 unix 域套接字,这比机器之间的通信更快、更可靠。

通过使用 Nginx 来处理读取请求,我们能够实现更高的 Node 进程利用率。

我们还使用 Nginx 来处理一部分请求,不需要将它们发送给 Node.js 进程。我们的服务发现和路由层通过 /ping 低成本请求来检查主机之间的连接性。在 Nginx 中处理这些可以降低 Node.js 进程的吞吐量。

接下来是负载均衡。我们需要明智地决定哪些 Node.js 进程应该接收哪些请求。cluster 模块通过 round-robin 算法来分配请求,当请求延迟的变化很小时,这种方式是很好的,例如:

但是当有不同类型的请求需要花费不同的处理时间时,它就不那么好用了。后面的请求必须等待前面的请求全部完成,即使有另一个进程可以处理它们。

更好的分发模型应该像这样:

因为这可以最大限度地减少等待时间,并可以更快地返回响应。

这可以通过将请求放进队列中并只将请求分配给空闲的进程来实现。为此,我们使用了 HAProxy。

当我们在 Hypernova 中实现了这些,就完全消除了部署时的超时峰值以及 BadRequestError 错误。并发请求也是造成延迟的主要因素,随着新方案的实施,延迟也降低了。在使用相同的超时配置的情况下,超时率基线从 5%变为 2%。部署期间的 40%失败也降低到了 2%,这是一个重大的胜利。现在,用户看到空白页的几率已经很低了。未来,部署稳定性对于我们的新渲染器来说至关重要,因为新渲染器没有 Hypernova 的回滚机制。

细节和配置

要搭建起整个环境,需要对 Nginx、HAProxy 和 Node 应用程序进行配置。我们提供了一个带有 Nginx 和 HAProxy 配置的 Node 示例应用程序(https://github.com/schleyfox/example-Node-ops)。

Nginx 遵循标准的配置,服务器监听 9000 端口,负责将请求转发给监听 9001 端口的 HAProxy(我们使用了 Unix 域套接字)。它还拦截 /ping 端点,用于检查机器间的连接性。与标准的 Nginx 配置不同的是,我们将 worker_processes 减少为 1,因为单个 Nginx 进程足以使我们的单个 HAProxy 进程和 Node 应用程序饱和。我们使用了大型的请求和响应缓冲区,因为 Hypernova 组件的 props 可能非常大(数百千字节)。你应该根据自己的请求 / 响应大小调整缓冲区大小。

Node 的 cluster 模块负责处理负载均衡和进程生成。要使用 HAProxy 作为负载均衡,我们必须为 cluster 的流程管理部分创建替代品。这就像 pool-hall(https://github.com/airbnb/pool-hall)一样,它比 cluster 更擅长于维护工作进程池,但它与负载均衡没有关系。这个示例应用程序(https://github.com/schleyfox/example-node-ops/blob/master/index.js#L18)演示了如何使用 pool-hall 启动四个 worker 进程,每个进程监听不同的端口。

HAProxy 配置了一个监听 9001 端口的代理,该代理将流量路由给监听 9002 到 9005 端口的四个 worker 进程。最重要的是将每个 worker 的 maxconn 设置为 1,也就是将 worker 每次处理的请求限定为 1。

HAProxy 会跟踪它与每个 worker 之间的连接数,最大只能达到 maxconn 配置的值。路由被设置为 static-rr(固定的 round robin),因此每个 worker 会依次获得请求。基于这些配置,路由会跳过当前达到请求限制的 worker。如果没有可用的 worker,请求就被放入队列,并被分派给最先可用的 worker。这就是我们想要的行为。

深入 HAProxy

HAProxy 上有很多配置正如我们预期的那样,如果它们没有按照我们预期的方式处理并发请求限制或队列,那对我们就没有太大用处。了解如何处理(或不处理)各种类型的故障对我们来说也很重要的。我们需要对使用这个方案作为 cluster 模块的替代品有足够的信心。为此,我们进行了一系列测试。

在进行测试时,我们一般会使用 ab(Apache Benchmark)在各种并发级别上发送 10,000 个请求。

ab -l -c <CONCURRENCY> -n 10000 http://<HOSTNAME>:9000/render

我们在示例应用程序中使用了 15 个 worker 而不是 4 个,为了避免基准测试与被测系统之间相互干扰,我们在单独的实例上运行 ab。我们分别在低负载(并发为 5)、高负载(并发为 13)和排队负载(并发为 20)的情况下运行测试。

第一组测试都是正常的操作,而第二组测试是在重启所有进程的情况下进行的。在进行最后一组测试时,我随机停止了一部分进程。

另外,应用程序代码中的无限循环一直是个问题,因此,在测试中我们通过一个无限循环向端点发送请求。

在正常情况下,maxconn 1 可以完全按照预期工作,也就是说每个进程一次只处理一个请求。我们没有在后端配置 HTTP 或 TCP 健康检查,因为这样会导致更多的混乱。健康检查似乎不受 maxconn 的限制,尽管我还没有在代码中证实这一点。我们预期的行为是一个进程要么能够提供服务,要么无法监听端口并立即抛出连接异常。我们发现健康检查对于我们的场景来说不是很有用。

我们需要处理连接错误,为此,我们设置了 redispatch,并把 retries 设置为 3,这样收到连接错误的请求可以重新连接到后端的另一个实例。

由于我们使用的是本地网络,所以连接超时对我们来说不是特别有用。我们最初期望通过设置一个较低的连接超时来防止 worker 陷入无限循环。我们将超时设置为 100 毫秒,但我们惊讶地发现,请求在 10 秒后才超时(这是客户端与服务器之间的超时时间)。

我们在测试过程中发现了另一个有趣的结果,客户端 / 服务器超时会导致一些非预期的行为。当请求被发送给一个进程并导致进程进入无限循环时,后端的连接计数被设置为 1。因为 maxconn 为 1,所以其他请求就无法被分配给这个进程。在客户端 / 服务器超时后,连接计数会减少到 0。而当客户端由于超时或其他原因关闭连接时,连接计数不会生效,但路由仍然可以继续工作。打开 abortonclose 可以让连接计数在客户端关闭后立即减少。因此,最好的做法是为这些超时设置一个较高的值并关闭 abortonclose。我们可以在客户端或 Nginx 端设置更严格的超时时间。

我们还发现在高负载时会出现一个非常诡异的情况。如果 worker 在服务器具有稳定队列的情况下崩溃(这应该是非常罕见的),后端会尝试处理请求,但由于没有进程在监听,将无法建立连接。然后,HAProxy 会将请求发给另一个后端,这样很快就会把重试次数用完并导致请求失败,因为连接错误远比渲染 HTML 发生得更快。该进程将重复尝试处理其余的请求,直到请求队列变空为止。这种情况很糟糕,不过也很罕见。在我们的案例中,我们的服务发现健康检查程序会发现这些问题,并快速将整个实例标记为不健康,不用于处理新的请求。虽然这样不是很好,但可以最大限度地降低风险。在未来,我们将通过更深入的 HAProxy 集成来处理这个问题,让管理进程监控其他进程的退出,并通过 HAProxy 数据套接字将其标记为 MAINT。

另外有一个值得注意的变化,在设置了 Node 的 server.close 后,服务器将等待当前请求完成,但 HAProxy 队列中的任何请求都将失败,因为服务器不会等待尚未收到的请求。在大多数情况下,确保实例停止接收请求与启动服务器重启进程之间有足够的衔接时间就可以解决这个问题。

我们还发现,如果设置了 balance first,也就是将大多数流量按顺序重定向给第一个可用的 worker,相比使用 balance static-rr,延迟将减少 15%。这种效果在部署之后会持续数小时,应该不仅仅是因为服务器预热的关系。在较长时间(12 小时)后,性能开始下降,可能是热进程的内存泄漏导致。由于冷进程极冷,因此应对流量高峰的弹性能力也较差。对此,我们还不知道该作何解释。

最后,Node 的 server.maxConnections 配置似乎会有所帮助,但我们发现它并没有真正提供太多好处,并且有时还会导致错误。这个配置可以防止服务器在达到限制后关闭新句柄来接收超过 maxConnections 数量的连接。这个检查被用在 JavaScript 中,所以它无法应对无限循环的情况。我们还看到在正常操作下它会造成连接错误。我们怀疑这只是一个时间问题或 HAProxy 和 Node 之间关于连接何时开始和结束存在分歧。

  英文原文

https://medium.com/airbnb-engineering/operationalizing-node-js-for-server-side-rendering-c5ba718acfc9

课程推荐




 
前端之巅 更多文章 前端周报:Udacity弃用RN,微信小程序将支持npm、分包和可视化编程 Swift并发编程的10大陷阱 ESLint的NPM账户遭黑客攻击,可能窃取用户NPM访问令牌 Kotlin生态调查结果出炉:超过6成的开发者用过Kotlin了 微信小程序的下一步:支持NPM、小程序云、可视化编程、支持分包
猜您喜欢 每周阅读清单:书单,学英语,Balabala 豹变 | 国内某大型酒店集团 Kubernetes 落地实录 如何看待 GitHub 项目求 Star 行为? 今晚7点不见不散:直播,PHP5面向对象编程入门第二讲 微信公众号推荐:那些科技大佬(二)