一种秒级安装 npm 的方式

2,149 阅读9分钟

编者按:npm 作为目前排名第一的包生态,包含了数百万的包,生态繁荣的背后却是 npm 的安装速度越来越长。本文是蚂蚁集团 npm 工程师零弌给大家带来的“如何实现一种秒级安装 npm 的方式”,欢迎享用。

我是零弌,来自支付宝体验技术部雨燕团队,资深 Node.js 开发者,Rust 爱好者。Chair/Egg.js 核心开发者,tegg 作者。目前负责 tnpm(阿里/蚂蚁内部 npm 客户端) 的 工作。

npm 作为目前排名第一的包生态,包含了数百万的包,生态繁荣的背后却是 npm 的安装速度越来越长。将会为大家带来一个分享,如何实现一种秒级安装 npm 的方式。

本次分享将会分为三个内容。

  • 性能对比:通过一个 benchmark 来为大家直观的展示一下我们的安装性能。
  • 优化方式:将会带领大家走过我们的优化路程,期望为大家带来一些启发。
  • 发展路径:介绍目前我们的实现到了哪一步,将会发展的终态是什么。

性能对比

benchmark 使用内部的 npm 包 @alipay/smallfish。这个包包含了 2k 多个依赖,安装过程中,通过网络传输了 38 MB 的数据。虽然 38 MB 看起来很小,解压后会占用了 400 MB 以上的空间。

测试的机器使用了 M1 Mac mini,规格为 8C16G,网络环境为千兆有线网络。测试环境没有 NPM 缓存(包括元数据和 tar 包)。访问的 registry 为内部私有源,保障了响应延迟。

先看一下 npm 的数据,npm 的安装耗时为 1 分 07 秒。yarn 做了一定的优化,速度提升到了 36 秒。PNPM 和 CNPM 采用了类似软链的优化方式,耗时下降到了 19 秒。npm rapid 最快,仅使用了 6 秒就完成了安装。对于 NPM,性能提升了近 10 倍。

优化方式

在开始优化之前,我们需要先看到 npm 的性能瓶颈在哪里,主要分为三个部分。

  • HTTP 请求:安装一个包需要执行两个 HTTP 请求,一个请求会去 registry 获取包所有的版本,另一个请求会去下载包特定版本的 tgz 文件。
  • IO 操作:下载下来的 tgz 文件,会经过 ungzip 和 untar 两步,untar 会产生大量的小文件写入。除此之外,npm 安装过程中还会针对 bin 文件进行换行符修复、chmod、软链等操作。
  • 磁盘占用:smallfish 安装完成之后,将会占用 445 MB 的空间,并且每有一个项目安装都会多占用这么多空间。

在本次分享中,将会介绍实施优化的三个核心手段。

  • 通过聚合的方式来减少 HTTP 请求量。
  • 通过添加中间层的方式来减少文件写入量,大幅提升安装性能。
  • 通过缓存的方式,来减少磁盘占用,解决 npm 黑洞问题。

聚合

使用 npmgraph.an 去生成 smallfish 依赖图,这图看起来如同一个星云般复杂,依赖数量极为惊人,因此生成依赖树的过程很慢。

同时我们去观察依赖树的生成过程,间接依赖的请求还依赖直接依赖,导致请求过程会有一个递归过程。进一步限制了依赖树生成的时间。

我们通过将项目的 package.json 发送到服务端,在服务端运行 @npmcli/arborist 来生成依赖树。将 arborist 访问 registry 的 HTTP 接口,直接劫持到我们的 registry 服务。通过内存缓存/分布式缓存/DB 来加速依赖树的生成过程。

我们通过聚合的方式,将 2 千多个 HTTP 请求聚合为 1 个,使得依赖树的生成时间从 15 秒下降到了 3 秒,减少了 80% 的时间。

中间层

通过使用中间层减少 IO 的方式,我们将会带 npm 安装速度起飞。

首先使用 strace 我们可以看到在安装过程中,npm 调用了 20 万次 write syscall。平均每次调用时间为 76 μs,共计使用了 15 秒以上。20 万是我们需要解决的数字。

为什么会有这么大量的写入,这里有两个原因,一个是依赖太多,npm 作为最庞大的生态,依赖多是一个没法解决的问题。

cnpm/pnpm 通过软链的方式来避免重复依赖的写入,降低了一部分的写入量。但是现在的依赖越来越复杂,以往有效的优化措施已经没这么有用了。我们需要更有效的手段。

往更深层去走,为什么 2 千个依赖可能膨胀为 20 万次。smallfish 这个 npm 包,包含了 6 个文件,5 个目录,1 个 bin。需要调用 6 次 fs.writeFile, 5 次 fs.mkdir, 1 次 fs.symlink, 1 次 chmod。

光 JS 的 writeFile 就要调用 6 次,底层的 write syscall 会更多。这个 API 是最需要优化的。

再往深处走,我们看一下 tar 包的结构。tar 包由一个个 entry 组成,每个 entry 都包含了文件的基本信息,比如权限、大小、创建时间、文件名,以及最重要的文件体。那文件是不是可以直接通过 tar 来访问?

另外,tar 有一个重要的特性,append。tar 可以很简单的在尾部添加文件。因此我们可以将两个 tar 合并在一起。写入两个包的流程将会变成:

  1. fs.createFile:创建出公共的文件
  2. fs.appendFile:将第一个包写入
  3. fs.appendFile:将第二个包写入

从 26 次 IO 减少到了 3 次 IO。

但是,现在还有一个最重要的问题没有解决。虽然现在下载安装飞快,但是装完的东西没法用。tar 完全不是 JS 文件,没法直接用 JS 去 require,没法在 shell 里操作,更没法在 IDE 里编辑。一切习惯都被打破了。接下来我们就需要解决 tar 的读写问题。

首先,我们需要找到一个合适的分层去解决这个问题。通过 JS 去 require 一个文件的过程是这样的。

  1. 通过 fs.readFile这个 API 去发起一个文件读请求。

  2. JS 方法构造出 libuv 中的 uv_req_t 数据结果

  3. libuv 会调用 libc 中的 read方法。

  4. read方法会发起系统调用,来访问内核中的文件系统去读取文件。

社区中也有类似采用包的形式来存储 npm 包,PnP 采用了 zip 包的形式保存。通过劫持 node 的 require 方法来实现 zip 包的读取,通过开发 IDE 插件的方式来支持在 IDE 中读取。但是社区中有大量的实现是通过 fsAPI 去遍历 node_modules 目录,开发者也会使用 shell 去进行一些依赖操作。对于现有的使用习惯破坏较大。

不管我们是在 JS API 还是 C API 劫持,都不太能保持现有的使用方式。

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

我们得看一下这个中间层加在哪里来解决。问题来到了文件系统。

FUSE 全称是 FileSystem In UserSpace。即我们可以在用户态程序来实现一个文件系统。如图所示,hello接管了 /tmp/fuse目录所有的文件系统操作,图中的 ls -l /tmp/fuse命令会使用 `access`, openDir等等文件系统操作,这些操作都会到 hello中来执行。hello可以控制目录下有哪些文件,文件内容是什么。

因此结合 tar 中的元数据,我们可以通过 FUSE 来实现一个文件系统,保持现有所有的使用习惯不变。

但是 tar 还是有一个不完美的地方,上文中有提到 tar 可以很简单的在尾部新增一个文件,但是 node_modules 中的文件也是可以被修改的,这个基于 tar就不是这么好做到的了。

目前读的问题解决了,还需要解决写的问题。

另外还要介绍的一个技术是 Overlay,Overlay 可以将多个文件系统组合在一起。比如 Lower 目录是只读的,Upper 目录是可读写的,我们可以将 Overlay 把 Upper 和 Lower 目录组合在一起,构造出一个可读写的目录。对于文件的修改会反映到 Upper 目录中,而不会影响到 Lower 目录。

至此,已经将 npm rapid 使用到的核心技术分享给了大家。基于这些技术,我们可以构造出 NPM FS,底层使用 tar 作为存储格式,支持高速写入,上层使用 Overlay 技术实现读写,保持现有使用习惯。

通过中间层技术,我们将底层文件由小文件变为 tar,使耗时从 30 秒下降到了 3 秒,时间上减少了 90%。

缓存

解决完安装速度之后,我们还要解决最终的磁盘空间问题,现在 npm 安装之后占用了太多空间,黑洞之称实至名归。

NPM 采用全局 tar 的缓存来加速下载过程,减少重复的下载。但是每次解压还是占用了太多时间。

pnpm 采用文件硬链的形式来减少写入量,但是硬链代表全局指向了同一个文件,比如两个项目依赖了同一个包,如果其中一个在 debug 进行了一些改动,会影响到另一个包,造成意料之外的影响。

上文提到的 Overlay 还有一个特性是 COW(Copy On Write),在修改底层文件的时候会将底层的文件拷贝到上层目录中。因此我们可以使用同一份缓存,来支持全局所有的项目。

通过基于 COW 缓存的方式,我们实现了全局一份并且项目间隔离的缓存。

发展路径

npm 点亮了 JS 生态。我们在这一生态中也回馈了 cnpm,做了国内的镜像站,加速了 npm 的安装过程。现在我们希望再一次回馈社区,让 npm 摆脱又慢又大之名,为生态注入更多的活力。

在这次技术探索中,首先我们研发出了 NPM FS 来加速安装过程,在这过程中,我们又意识到原来 tnpm 的软链结构虽然通过减少写入的方式提升了安装速度,但是软链也导致了很多社区包不兼容的问题。NPM FS 已经解决了写入的问题,那我们完全可以回归 npm 的目录结构,降低社区的兼容成本。

接下来一步,我们将会支持 npm workspace,让 npm rapid 模式的功能可以完全与 npm 对齐。期望 npm rapid 模式有一天可以合入 npm,让所有的 JS 开发者都能享受其速度。