
Nodejs 云函数冷启动时间的优化 (硬核)
本文不涉及自定义镜像的部署方式
前言
冷启动(Cold Start) 一直是 Serverless 的一个缺点。
它往往出现在这样一个场景:
当一个云函数已经不存在空闲的可提供服务的容器时。再有额外的请求传入,函数本身就不得不去启动一个新的容器来处理这次的请求。由于启动新容器会有延迟,这就导致了这个请求需要更多时间来响应,用户就会感觉我们应用程序似乎 "变慢" 了。
这对于用户的体验来说是个大问题,需要尽可能的去减短这个时间。
我们知道当容器从 "冷态" 开始启动时,函数需要:
- 从外部持久化存储中获取代码包
- 启动容器
- 在内存中加载程序包代码
- 运行函数进行处理
其中,在第一步拉取代码时,代码体积越小,拉取代码速度越快,冷启动时间自然就变短了。这块是我们开发者能够间接控制的。
而对我们 nodejs 开发者来说,在项目里,往往占据巨大体积的,不是我们自己写的代码,而是在 node_modules 中依赖各种包。
像传统的npm包安装方式,在 开发者本地 或者在 云端 安装依赖,都会附带过多的 "无用" 垃圾文件,白白占据了大量的空间。我们要想办法来解决这个问题,在保证项目稳定性的同时,减小包的体积来优化冷启动时间。
本地安装依赖
1. 运行时依赖的筛选
本地作为开发环境,开发者往往会把 devDependencies,dependencies 都给安装进来。
而 devDependencies 往往是 eslint, webpack 这类和真正的服务端运行时无关的包。要是把它们也部署到函数中,不论是直接压缩上传代码包,还是做成 层函数(layer) 去绑定,都是在浪费代码包体积,因为那一部分代码,在运行时永远不会被调用。
怎么办呢?
yarn install --production 算一个"不充分"的解决方案,这个指令作用是:只安装 dependencies 里的包。这也要求开发者,安装npm包时,对所需的环境做准确的划分。
注:这个指令在我们开发时候,往往是无用的,举个例子: 我们通常会把
typescript安装到devDependencies里 要是只安装dependencies,那我们连tsc都做不到了。
2. 和操作系统或指令集绑定的第三方包
操作系统大体上分为 darwin , linux, win32 ,mas 这几类。
而 cpu 指令集,常见的也有 arm64 , x64, armv7l ,ia32 等等。
node_modules 里面,往往也会存在 cpp,rust,python 这类非 js代码,有些包的作者也往往会在 npm hook 里,去检测当前系统的发行版本,根据它,再去远程下载 对应平台对应指令集 的二进制包。
这里我举个例子,来说明这个问题的危害。
我们在 win10 上开发,下载了 win32-x64 的二进制包,本地运行非常的正常,做成 layer 层函数,再部署到 SCF 上,结果就挂了。
这是因为 SCF 函数运行环境,需要的是 linux-x64 的包,但运行时从 layer 里读到的是 win32-x64 的二进制包,平台不符合,自然就挂了。
如何规避这个问题呢?
本地开发使用 docker + scf镜像 ,尽力来仿造 scf 运行环境。
当然,更好的方案还是直接在云端进行开发,或者云端映射到本地进行开发。这样在保证运行环境的绝对准确的同时,也能感知到其他云服务的存在,更便于开发调试。
云端安装依赖
云端安装依赖实际上是 SCF 提供的功能,它只需要一个installDependency 配置项 并上传 package.json,就可以在云端自己安装依赖并做成代码包,省去了本地上传压缩 node_modules 的麻烦。
云端安装依赖 可以规避 本地安装依赖 中 操作系统或指令集绑定的第三方包 这个问题,毕竟依赖都是在云函数环境下现装的。
node_modules 的处理
我们前端开发者对 webpack,rollup 这类打包工具可谓是非常熟悉了。当然它们这些工具,除了可以打包 Web应用,也可以去打包 Nodejs。我们的重点目标,主要是 node_modules 里依赖的第三方模块。
将代码打包成单文件,优化nodejs模块加载,减少读磁盘的次数,这也能在一定程度上减少 nodejs 应用启动时间。
注:
builtin-modules不打包
以 esbuild 为例
通过上述的思路写出 esbuild 打包的配置:
/**
* @type {import('esbuild').BuildOptions}
*/
const config = {
entryPoints: ['./src/index.js'],
bundle: true,
platform: 'node',
target: ['node12'], // scf runtime node version
outfile: path.resolve(__dirname, 'dist', 'index.js'),
sourcemap: isDev, // 调试用
minify: isProd, // 压缩代码
external: [] // 跳过打包
}
await esbuild.build(config)
这样它便会去分析我们的js代码依赖来抽取代码,只不过有时会遇到非 js 依赖,打包工具分析不出来,例如这种 fs 读取文件的,也算一种依赖:
// dist/index.js
var trie = new UnicodeTrie(fs.readFileSync(__dirname + "/data.trie"));
这时候为了能够顺利的把函数跑起来,最简单的方案还是 copy: 哪里缺,哪里找。
然而这种解决方案有一个巨大的问题:
打包成单文件,会导致原先的目录结构被抹平。这样就容易出现多个 非js 文件重名,导致相互覆盖。
举个例子,unicode-properties 和 fontkit 都用 fs 依赖了它们各自目录下的 data.trie 文件,但是它们依赖的 data.trie 内容不同,这也会导致 函数挂掉。
这种情况就可以使用 external 来规避:即把能打包的打包了,不能打包的就不打包。
部署的时候,再把没打包的 external 们,单独抽离出来,做成独立的 dist/package.json, 再在云端安装依赖,我们的云函数就能够直接运行了。
总结
代码包体积小了之后,大大提升了函数部署到 腾讯云 SCF 平台的速度(避免了压缩上传 node_modules)
同时通过分析依赖,构建产出,并压缩代码的方式,将一些本要在云端 拉取解压近 300多M 的代码包,减小到 8M 左右,有效的减少了函数获取代码包的时间,从而整体优化了函数的冷启动时间。