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 指令不够细 , 不知道是 npm or yarn,也不知道会不会使用到 package-lock.json or yarn.lock。
npm 注册源不能切换
安装好后,目前也是直接放到代码中去,没有打成层函数。
不过 在线安装依赖 可以规避上述 本地安装依赖 中 操作系统或指令集绑定的第三方包 这个问题,毕竟依赖都是在云函数环境下现装的。
打包服务端
我们前端对 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 就要 140MBesbuild
我们可以很容易的配置出 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.jsvar 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 读取的路径来解决,这里受限于篇幅原因不在叙述。
当然esbuild external 也能解决这个问题。rollup
我们可以很容易的配置出 rollup 打包的配置, 一个简单的例子:
// config.jsconst 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.jsconst fsp = require('fs').promisesconst 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 打包方案。
附录
Malagu源码
内建模块builtin-modules (fs,这类的)
rollup-plugin-node-polyfills