
serverless 降低冷启动时间的探索 - 服务端打包 node_modules
本篇文章,不涉及自定义镜像的部署方式
冷启动
我们知道,在 serverless 场景下,函数的冷启动时间,是和上传代码包的体积大小相关的。代码体积越小,拉取代码速度越快,冷启动时间自然就短了。
对我们 nodejs 开发者来说,在工程里,往往占据巨大体积的,不是我们自己写的代码,而是在 node_modules 中依赖各种包。尤其是某些npm包作者,不会正确使用 .npmignore , .gitignore 和 package.json 中的 files 字段,发布的包令人感到酸爽的(笑~)
像传统的 在本地 或者 在线 安装依赖,都会在 node_modules 中产生过多的无用垃圾文件,白白占据了大量的空间。对我们开发者而言,就要想办法去解决这个问题,以减小运行时代码包的大小。
本地安装依赖的问题
1. 筛选运行时依赖问题
本地作为开发环境,开发者往往会把 devDependencies,dependencies 都给安装进来。
而 devDependencies 往往是 eslint, webpack 这类的包,和真正的服务端运行时无关。
要是把它们也部署上 serverless 平台,不论是直接压缩上传代码包,还是做成 layer层函数 去绑定,都是在浪费代码包体积,因为那一部分代码,在运行时永远不会被调用。
怎么办呢?
yarn install --production 算一个解决方案,这个指令作用是:只安装 dependencies 里的包。
当然这也要求开发者,安装npm包时,对所需的环境做准确的划分。
注:这个指令在我们开发时候,往往是无用的,举个例子: 我们通常会把
typescript安装到devDependencies里 要是只安装dependencies,那我们连tsc都做不到了。
2. 和操作系统或指令集绑定的第三方包
我们知道,操作系统大体上分为 darwin , linux, win32 ,mas 这几个。
而指令集,比较常用的也有 arm64 , x64, armv7l ,ia32 这几类。
而 node_modules里面,啥都能放,有些npm包作者,就会在里面放 cpp,rust,python代码做编译,有些包的作者会在 postinstall 这个 hook 里,检测 OS 的发行版本,根据它再去远程下载对应平台对应指令集的二进制包。
这里我继续举个例子,来说明这个问题的危害。
我们在 win10 上开发,下载了win32-x64的二进制包,本地跑跑都非常的正常,做成 layer层函数,再部署到 serverless 上,结果挂了,Why?
SCF 函数运行环境 需要的是 linux-x64 的包,但运行时从 layer 里读到的是 win32-x64 的二进制包,平台不符合,自然就挂了。
交了学费之后,本地开发就去使用 docker + scf 镜像,尽力的仿造scf运行环境,来避免这个问题,但是配置环境也是有一定成本的。
当然有更好的方案,比如直接在 Web IDE那里进行开发,或者线上远端映射到本地机器进行开发。
- 一个好处是,可预见性,运行环境的绝对准确,在里面开发能跑起来,那么
Serverless环境也必定能跑起来。 - 另外一个好处是,强服务的感知度,比如在代码运行时,我们可以进行调试,感受到
API网关,VPC私有网络, 挂载的CFS文件存储这类配套设施存在,这点在本地直接开发是无法做到的。
在线安装依赖的问题
怎么在线安装依赖?这个实际上是 云函数 的功能,我们使用 serverless framework 的 tencent-scf 组件,部署的时候,上传代码排除 node_modules, 我们再把 serverless.yml 中的 installDependency 配置项开启,在线安装依赖就起作用了。
不过目前也存在一些问题 ,比如:
installDependency指令不够细,不知道是npmoryarn, 也不知道会不会使用到package-lock.jsonoryarn.lock。npm注册源不能切换- 安装好后,目前也是直接放到代码中去,没有打成层函数。
不过 在线安装依赖 可以规避上述 本地安装依赖 中 操作系统或指令集绑定的第三方包 这个问题,毕竟依赖都是在云函数环境下现装的。
打包服务端 node_modules
我们前端对 webpack , rollup ,vite ,parcel 这类打包工具非常熟悉了。当然它们这些工具,除了可以打包 Web 前端应用,当然也可以去打包 nodejs 服务端。
在打包阶段,处理 js 我们也有很多的选择,比如 typescript,babel,esbuild,@swc/core, 它们之间并不是互斥的关系。
我们的重点打包的目标,主要是 node_modules 里依赖的第三方模块,对他们进行 tree sharking,这个机制可以保证只有用到的代码才会被打包。
同时将代码打包成单文件,减少 nodejs 模块加载,从而减少读磁盘的次数,这也能减少 nodejs 应用启动时间。
这里我用 esbuild 和 rollup 对服务端 node_modules 的模块进行解析,打包,压缩,来减少代码的体积。
builtin-modules不打包;打包之后,一个 nodejs 项目,压缩代码后,只变成了 2MB 大小,而原先光 node_modules 就要 140MB
esbuild 打包
我们可以很容易的配置出 esbuild 打包的配置,一个简单的例子:
/**
* @typedef {import('esbuild').BuildOptions} BuildOptions
* @type {BuildOptions}
*/
const config = {
entryPoints: ['./src/index.js'],
bundle: true,
platform: 'node',
target: ['node14'],
outfile: path.resolve(__dirname, 'dist', 'index.js'),
sourcemap: isDev, // 调试用
minify: isProd, // 压缩代码
external: []
}
await esbuild.build(config)
只不过我们遇到的是非 js 依赖,打包工具分析不出来,那就麻烦了。
比如这种fs读取文件的,也算一种依赖:
// dist/index.js
var trie = new UnicodeTrie(fs.readFileSync(__dirname + "/data.trie"));
这时候我们怎么做才能让我们打包后的应用,继续跑呢?最简单的方案:
await Promise.all([
fsp.copyFile(
'node_modules/unicode-properties/data.trie',
pathJoin('data.trie')
),
fsp.copyFile('node_modules/fontkit/indic.trie', pathJoin('indic.trie')),
fsp.copyFile('node_modules/fontkit/use.trie', pathJoin('use.trie'))
])
核心思想就是:哪里缺,哪里找。这种解决方案有一个巨大的问题, 打包成单文件,会导致原先的目录结构被抹平。这样就容易出现多个非 js 文件,重名,相互覆盖的问题。
就以这段代码为例,unicode-properties 和 fontkit 同时都会去,读取当前所在目录下的 data.trie 文件,这样相互的覆盖就出现了大问题,假设它们依赖的 data.trie 不同,就会导致这两个包,只有一个能顺利运行。
这种情况,可以使用复原 node_modules 路径,再加上 replace fs 读取的路径来解决,这里受限于篇幅原因不在叙述。
当然
esbuildexternal也能解决这个问题。
rollup 打包
我们可以很容易的配置出 rollup 打包的配置,一个简单的例子:
// config.js
const external = ['@pkg/no-need-to-bundle']
/** @type {import('rollup').InputOptions} */
const inputOptions = {
input: 'src/index.ts',
plugins: [
typescript(),
commonjs(),
nodeResolve({
preferBuiltins: true
}),
json(),
alias({
entries: [
{ find: '@', replacement: './src' },
{ find: '@@', replacement: '.' }
]
}),
// terser(), Prod add for 压缩代码
replace({
preventAssignment: true,
values: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
}
})
],
external
}
/** @type {import('rollup').OutputOptions} */
const outputOptions = {
file: 'dist/index.js',
format: 'cjs',
sourcemap: isDev // 调试用
}
/** @type {import('rollup').RollupOptions} */
const config = {
output: outputOptions,
...inputOptions
}
打包的过程:
// build.js
const fsp = require('fs').promises
const rollup = require('rollup')
const { inputOptions, outputOptions, external } = require('./config')
const pkg = require('../package.json')
async function build() {
const bundle = await rollup.rollup(inputOptions)
await bundle.write(outputOptions)
await bundle.close()
// 这种做法,只能处理直接依赖的第一级包
// 次级依赖的包,由于自己项目的 package.json 不存在直接依赖造成空缺
// 这种的解决优化方案,可以使用递归查找,更深度的找到依赖项
// 再把依赖项,直接从第三方的 npm 包的 package.json 提出
// 放到第一级依赖的方式来做。
await fsp.writeFile(
'dist/package.json',
JSON.stringify({
dependencies: external.reduce((acc, cur) => {
const v = pkg.dependencies[cur]
if (v) {
acc[cur] = v
}
return acc
}, {})
})
)
process.exit()
}
build()
这样做的思路很明确,把能打包的打包了,不能打包的不打包。
比如,我们可以把某类,二进制 npm 包,放入 external 中,再把 external 当做依赖项,写入新的 package.json 里。
打包的时候就不会去解析这个npm包,部署的时候,也只需要我们把 dist/index.js 和 dist/package.json 部署上云,再开启在线安装依赖 installDependency 配置项,我们的 serverless function 就直接能跑了。
后记
代码包小了后,发布到 Serverless 平台的速度很快(避免了压缩上传 node_modules)
打包服务端 node_modules 也很简单,也有很多的措施来规避过程中可能出现的问题,推荐每一位 nodejs 开发者都去尝试一下。
细心的同学,可能发现,笔者并没有使用 webpack 来打包 nodejs
那是因为珠玉在前,在Serverles环境下已经有非常好的 webpack 打包方案了:
那就是 Malagu ,它是一个 Serverless First 的应用框架,我们使用它编写的应用, 在部署时自然而然的,就被转变成最小化可运行的代码。
这显然在 serverless 场景是极其有利的,推荐大家使用它,并学习一下它源码里的 webpack 打包方案。
附录
内建模块builtin-modules (fs,http,os这类的)