0%

Verdaccio 性能优化:代理分流

前言

这里的 Verdaccio 是指用于搭建轻量级 npm 私有仓库的开源解决方案,以下简称 npm 私服。

前段时间写了一点分流相关的优化思路,但那是以节省资源开销为主、不冲破原有结构的微调,从结果上看,甚至不是合格的优化。

随着用户(请求)数量的上升,服务响应速度和效率其实才是最要紧的问题,节省资源终究不能改善这一点。因此我决定实施上次浮现在脑中的想法,将内外网的 npm 包流量彻底分流。

关于 Cluster 模式的说明

再次解释,Verdaccio 官方文档明确表示不能支持(PM2)Cluster 模式。另外,其云存储方案是可以支持多进程多节点部署的,但只提供了 google cloud、aws s3 storage 的插件。

不过在此基础上,只要拥有自己的云存储服务,就能使用或设计一套新的存储插件,进而支持多进程架构。此方案一定可行,只是相比本篇的做法,需要的成本更高一些。

俗话说得好,没有一个中间层解决不了的问题,而在 Verdaccio 的场景下,这种做法又是相当地迅速和高效。

原理

npm 安装机制

如果不了解 npm 官方客户端的安装机制,稍后可以阅读阮一峰的博客[[http://www.ruanyifeng.com/blog/2016/01/npm-install.html][《npm 模块安装机制简介》]],少部分知识已经不适用于当前版本了,不过最重要的是能理解 npm 下载流程。

其中我们需要知道,npm 包下载前,客户端会向上游服务器查询包信息,以及获取压缩包的下载地址 url,并将此 url 存放在 package-lock.json 文件中。以后每次执行下载,都会优先使用 package-lock.json 中的地址。

npm 下载最长请求路径

为了方便理解 Verdaccio 所处的位置,我来绘制一下 npm 包下载时从客户端到 Verdaccio 再到上游的最长请求路径简图,并忽略中间的安全验证环节,如下所示。

npm 请求路径

接口转发

有了代理层,就可以忽略 Verdaccio 内部的各种逻辑,不受技术栈的约束,编写少量的代码,便能完成主要接口的分流。

首要的接口是 /:package/:version? ,释放私服最大的查询压力,原因可以看这里的解释

次要的接口是 /:package/-/:filename ,也就是实际的下载接口。并且其中还涉及另一个极为有利的优化。

尽管 Verdaccio 是转发上游的资源,它也会将下载 url 变更为自己的服务域名。因此不论依赖是否私有,记录到 package-lock.json 中的地址都是 Verdaccio 的地址。

但经过代理层的分流,此后经过更新的 package-lock.json 将保留原汁原味的下载地址,此后下载压缩包的请求再也不会发到私服。

综上所述,我们可以将私服超过 99.99% 的流量转移到代理或上游服务。

条件

接下来,我们来确定分流口径,自然是判断一个 package 是否是私服私有,因此需要 Verdaccio 提供接口,获取私有包的列表。

Verdaccio 有一个 /-/verdaccio/packages 接口用来获取所有私有包的信息,但这个包主要用于 Web 页面,包含大量我们不需要的信息,甚至简单一点,只要提供私有 npm 包的包名就能满足筛选条件。

因此,可以改良 /-/verdaccio/packages,例如新增一个专门获取包名列表的接口,并增加内存缓存。

Verdaccio 版本不同时,做法也有很大差异,相信这里的处理不是问题,只要认真阅读上述接口就能获取思路了。

PS:还是补充一点代码吧,早期版本 Verdaccio 只需要这样改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Get name list of all visible package
* @route /-/verdaccio/names
*/
route.get('/names', async function(req, res, next) {
// 此处 cache 作为缓存,在有新的私有 npm 包发布时刷新即可
let names = cache.get('packageNames');
if (!names) {
try {
names = await storage.localStorage.localList.get();
} catch(err) {
return next(err);
}
cache.set('packageNames', names);
}
next(names);
})

最新的 names 要使用回调的方式取值,伪代码:

1
2
3
const names = await new Promise((resolve, reject) =>
storage.localStorage.storagePlugin.get((err, list) =>
err ? reject(err): resolve(list)))

实现方式

客户端

客户端也能承担分流的任务,即像 cnpm 一样包装一层自己的 npm cli 工具,但分流的逻辑要简单许多,只需检查要安装的包是否属于私有,然后分为两批安装。

缺陷是推行难度和速度都不理想,于是这里只是顺便提一下。

服务端

到这一步,技术选型已经无所谓了,自然可以 nginx + lua,简单一点就继续使用 Node.js 实现。

由于其他原因,我用 express 做了实现,贴一点转发逻辑,大家就自由发挥吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const request = require('request');
const rp = require('request-promise-native');

const publicRegistry = 'http://registry.npm.taobao.org';
const privateRegistry = 'http://npm.private.com';

const sec = 1000;
const min = 60 * sec;

const privateListCache = [];

/**
* 检查并更新私服包名列表的缓存
* 缓存可以基于 redis 或内存,注意控制好更新节奏
*/
async function checkPrivateCache() {}

/**
* npm package 请求分流
* @route /:packages/:version? 版本检查
* @route /:packages/-/:filename 下载
*/
async function packages(req, res, next) {
console.log(req.url)
await checkPrivateCache();
// 请求默认转发至 taobao
let baseUrl = publicRegistry;
if (privateListCache.length && privateListCache.includes(req.params.package)) {
// 转发私服的请求
baseUrl = privateRegistry;
}

const options = {
uri: baseUrl + req.url,
timeout: 2 * min
};
try {
request(options).on('error', next).pipe(res)
} catch(err) {
next(err);
}
}

/**
* 其他请求原样转发私服
* @route /*
*/
function all(req, res, next) {
// 清除 headers 的 host
const headers = Object.assign({}, req.headers, { host: undefined })
const options = {
uri: privateRegistry + req.url,
method: req.method,
timeout: 2 * min,
headers
}
try {
req.pipe(request(options).on('error', next)).pipe(res);
} catch (err) {
next(err)
}
}

结果

在同样的测试条件下,私服的 /:package/:version? 接口平均响应耗时从 4s 降至 400 ms,可以明显感觉到速度的提升,并且可以通过不断扩展代理层优化处理效率。作为轻量级的私服解决方案,已经可以续命很久了。

后续

这个系列就此结束了吗?当然没有,cluster 的坑还没填呢!也确实可能会鸽掉…

因为支持 cluster 需要较深入的二次开发,也有新的中间件引入,相比目前的成本要高出不少。并且 Verdaccio 新旧版本的逻辑存在一定差异,我在老版本中已经解决了此问题,但新版可能又要另一套实现。

所以,等我读完 Verdaccio 最新的代码再说吧~