Skip to main content
 Web开发网 » 站长学院 » 浏览器插件

serverless 降低冷启动时间的探索 - 服务端打包 node_modules

2021年11月03日5730百度已收录

serverless 降低冷启动时间的探索 - 服务端打包 node_modules  Serverless 第1张

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

评论列表暂无评论
发表评论
微信