如何用Node.js编写一个闪电式的图像服务器

·  阅读 596

前段时间,我从一个曾经合作过的客户那里接到了一个很好的任务,他们要求为其高流量的报纸网站和移动应用程序提供一个图像服务器。该服务需要实时和按需地裁剪、调整大小和重新压缩JPG、PNG和GIF。我将描述我是如何做出最快的图像服务器之一的,以及我是如何使用一个 "慢 "的Node.js服务器实现的。

我将描述我遇到的一些问题和我必须做出的设计决定,但让我们先从系统的总体概述开始。

报纸编辑会使用他们的内容管理系统将源图片上传到一个S3桶。这些图片有时大到14MB,有时他们甚至会尝试将PDF文件作为图片上传,所以不能保证这些文件是可用的格式。

报纸的网站和移动应用程序以HTTP请求的形式向图像服务器请求原始文件的缩放和裁剪版本。然后图像服务器从S3下载源文件,确定文件类型,裁剪和调整大小,并按照规格返回。很简单,对吗?当然,我们可以水平地增加服务器的数量,鲍勃就会成为我们的叔叔。嗯,这是我的想法,但这是错的。我们没有得到足够好的性能来实时调整图像大小。我不得不改变我的方法,并将进入一个全新的性能优化的世界。

我一直在比较NPM上的各种图像调整库,并对它们进行微观测试。它们中的大多数使用某种形式的优化C(++)代码来调整图像大小。从理论上讲,它们的速度很快,但我们无法用这些库获得任何合理的性能。

第一个问题是,Node.js为每一个请求启动一个新的图像大小的进程需要太长的时间。最天真的实现使用ImageMagick的命令行版本,这是一个流行的快速图像处理库。启动该进程,向其提供数据并等待其返回需要相当长的时间。使用Node.js FFI(外国函数接口)的包做得更好一些,但FFI的开销仍然相当大。

第二个问题是,从S3下载一个图像需要相对较长的时间。这是第二个需要解决的瓶颈。

最后,我们必须尽快知道图片的类型和原始尺寸。含有无效的裁剪值或不支持的图像的请求必须被丢弃,这样才能释放内存。我们不得不在下载文件时从二进制文件头中读取图像元数据。

在与Dario Mannu的合作中,我们意识到最好的方法是采用流式管道来下载和处理图像,同时缓存输入和输出,即修改后的图像。

当你的服务器应用程序的请求和响应大小相对较小时,像Express框架那样的基于中间件的流水线是一个很好的解决方案。但当应用程序必须处理可能有14MB大的图像时,基于中间件的系统可能会出现问题。应用程序只需要图像的前几百KB来提取元数据,但是在一个基于承诺的系统中,我们将不得不等到图像完全下载完毕后才能检查元数据。这不是我们想要的,我们希望在收到第一个字节时就开始处理,当我们发现问题时,我们需要快速失败,中止任何进一步的数据流,并向客户端发送一个相关的状态代码。对于一个200状态代码的结果,我们也希望尽快将数据推送给客户端,而不是一次性写完整个结果。

Node.js流是为这种数据管道设计的。它允许你在数据到来时立即开始读取,当应用程序无法快速处理时,将提供背压以优化内存使用。流可以被分叉,或者在不同的消费者之间共享,当我们想让一个操作失败时,可以提前结束。

数据流管线的结果如下:

  • 收到带有转换参数的图像请求。
  • 在中间件中对请求进行消毒,然后进入路由处理程序
  • 加载源图像
  • 从内存缓存中获取数据流,或者从S3中请求图片作为数据流
  • 如果图像不存在,则将其导入内存缓存
  • 在Javascript中从图像头读取初始二进制数据,以找出图像类型和尺寸。
  • 将图像数据输送到ImageMagick服务工作池中
  • 将图像导入前台缓存
  • 将图像输送到响应中,并输送到客户端。

我将更详细地介绍这些步骤,并解释我们为使这个应用程序相当特别而进行的详细程度。

第一个步骤很简单,我们在中间件中检查请求以验证参数。处理程序使用s3.getObject('objectName') 从S3检索图像。这个方法返回一个流,我们可以用管道或从那里读取。在下面的代码中,是最初的几个步骤。如果AWS返回一个错误,我们在错误处理中间件中处理它。当我们得到AWS的头信息时,那么我们就为我们对客户端的响应设置它:

var stream = s3.getObject(req.params.id)
复制代码

这一部分相当简单,但下一步将变得有点复杂。我们从请求对象中解析参数。选项是裁剪、调整大小和质量。

我们要检查这些选项对我们正在处理的图像是否有效。我们不想把图像放大,也不支持从高于图像宽度的X位置开始的裁剪,比如说:

stream.pipe(imageSizeChecker(imageOptions))
复制代码

我们用管道将我们的图像字节输送到使用image-size的image-size检查库中,我们不会等到内存中拥有完整的图像,而是通过使用部分图像缓冲区逐步进行。大多数图像格式都有一个带有元数据的二进制头。

流管线允许更快的数据传输和更低的内存使用率

我们决定不使用大量的弹性计算单元。通过弹性设置,较小的服务器在负载变化时被添加和删除。问题是,弹性设置将需要一个单独的缓存解决方案,如Redis。在我们的案例中,这是不可行的,因为真正的问题是数据量,而不是外部服务的速度。对于一个弹性系统来说,内存缓存并不是一个可行的解决方案,较小的服务器意味着用于缓存的内存较少,缓存失误较多。明显的解决方案是使用更少但更大的服务器,拥有更多的内存和更多的处理器能力。

作为一个例子,我们可以使用一个拥有8GB内存的AWS实例。我们会把4GB的内存留作原始图像的缓存。如果图像平均为4MB,这将允许我们在LRU缓存中缓存1000张图像。在实践中,这几乎可以完全消除任何多余的s3请求。但是,如果两个请求同时出现在一个相同的图片上,我听到你在想什么?当一个请求正在进行时,我们在缓存中保存一个对S3流的引用,这样第二个请求就可以很容易地分叉并处理该流。是的,流对于这个用例来说是很了不起的。

所以,回到图像调整和裁剪的问题上。没有一个好的现成的Node.js插件来调整图片的大小。基于从一个不同的Node.js包中得到的灵感,我们建立了一个长期运行的C进程池来做这些艰苦的工作。每个Node.js服务器我们都可以启动多个C进程来进行图像处理。经过几次反复的基准测试,我们得到了我们所使用的特定服务器实例的理想池大小。

图像处理库使用ImageMagick C API来缩放、裁剪和压缩图像。这是我的第一个C语言项目,如果这个库的文档再好一点,这个项目可以更快地交付。

我们的C语言程序不断地运行,监听stdin上的图像数据和处理指令。调整后的图像被返回到stdout并由Node.js进程处理。我不会对这个应用程序的C语言编程做更多的介绍,我认为这已经超出了本文的范围。我只想说,这个进程的开销非常低,而且多个工作者服务可以并行工作。

这个图像服务器确实在CDN后面运行,但我们认为为调整后的图像添加第二个内存缓存是值得的。并非每个客户端都会直接通过CDN进行通信,例如内部请求。

最终的产品

最终的产品是一个能够在短时间内处理大量请求的服务器,例如,当一个新的移动报纸捆绑在一起时。该服务器经常能够在500毫秒内按要求调整JPEG图像的大小。当然,具体时间取决于输入图像的复杂性和大小,其中复杂的图像需要更多的时间来编码。

一些例子

一张尺寸为3300x1900的复杂纹理图像在650ms内调整为200px宽

同样的图像调整到1250px宽只需要590ms。

一个没有太多纹理的简单图像可以更快调整大小。

一张没有很多纹理的1600x900的图片可以在115ms内调整到800px。

任何后续的请求都直接来自缓存,可以在35ms左右得到服务。

该服务可以改变压缩的质量,裁剪和调整图像的大小。当大小比例合适时,它将重新取样以获得更快的结果,因为没有必要重新压缩。C代码是高度优化的,只有在使用GPU重新压缩的情况下才能得到改善,这将极大地改变服务器的要求。

AWS的顾问已经调查了他们是否能提供更快或更有成本效益的解决方案,但截至目前,这个图像服务器在几年后仍在使用,这是我相当自豪的事情。

如果你一路走到这里,那么我想感谢你的坚持不懈。这是我所做过的最有趣的项目之一,也是最成功的项目之一。如果我现在做这个项目,也许我会采取不同的方法,但是这个项目满足了我当时的所有要求,而且它仍然在被使用,这就是证明。

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改