此页面需要javascript支持,请在浏览器中启用javascript

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

node_modules
rollup
esbuild
serverless
阅读 

Image

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

本篇文章,不涉及自定义镜像的部署方式

冷启动

我们知道, 在 serverless 场景下,函数的冷启动时间, 是和上传代码包的体积大小相关的。代码体积越小,拉取代码速度越快,冷启动时间自然就短了。

对我们 nodejs 开发者来说,在工程里,往往占据巨大体积的,不是我们自己写的代码,而是在 node_modules 中依赖各种包。尤其是某些npm包作者,不会正确使用 .npmignore , .gitignorepackage.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 frameworktencent-scf 组件,部署的时候,上传代码排除 node_modules, 我们再把 serverless.yml 中的 installDependency 配置项开启, 在线安装依赖就起作用了。

不过目前也存在一些问题 ,比如:

  • installDependency 指令不够细 , 不知道是 npm or yarn,也不知道会不会使用到 package-lock.json or yarn.lock

  • npm 注册源不能切换

  • 安装好后,目前也是直接放到代码中去,没有打成层函数。

不过 在线安装依赖 可以规避上述 本地安装依赖操作系统或指令集绑定的第三方包 这个问题,毕竟依赖都是在云函数环境下现装的。

打包服务端 node_modules

我们前端对 webpack , rollup ,vite ,parcel 这类打包工具非常熟悉了。当然它们这些工具,除了可以打包 Web 前端应用,当然也可以去打包 nodejs 服务端。

在打包阶段,处理 js 我们也有很多的选择,比如 typescript,babel,esbuild,@swc/core, 它们之间并不是互斥的关系。

我们的重点打包的目标,主要是 node_modules 里依赖的第三方模块,对他们进行 tree sharking,这个机制可以保证只有用到的代码才会被打包。

同时将代码打包成单文件,减少 nodejs 模块加载,从而减少读磁盘的次数,这也能减少 nodejs 应用启动时间。

这里我用 esbuildrollup 对服务端 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-propertiesfontkit 同时都会去,读取当前所在目录下的 data.trie 文件,这样相互的覆盖就出现了大问题, 假设它们依赖的 data.trie 不同,就会导致这两个包,只有一个能顺利运行。

这种情况,可以使用复原 node_modules 路径,再加上 replace fs 读取的路径来解决,这里受限于篇幅原因不在叙述。

当然esbuild external 也能解决这个问题。

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.jsdist/package.json 部署上云 ,再开启在线安装依赖 installDependency 配置项, 我们的 serverless function 就直接能跑了。

后记

代码包小了后,发布到 Serverless 平台的速度很快(避免了压缩上传 node_modules

打包服务端 node_modules 也很简单,也有很多的措施来规避过程中可能出现的问题,推荐每一位 nodejs 开发者都去尝试一下。

细心的同学,可能发现,笔者并没有使用 webpack 来打包 nodejs

那是因为珠玉在前,在Serverles环境下已经有非常好的 webpack 打包方案了:

那就是 Malagu ,它是一个 Serverless First 的应用框架,我们使用它编写的应用, 在部署时自然而然的,就被转变成最小化可运行的代码。

这显然在 serverless 场景是极其有利的,推荐大家使用它,并学习一下它源码里的 webpack 打包方案。

附录

Malagu源码

内建模块builtin-modules (fs,http,os这类的)

rollup-plugin-node-polyfills

© 2021 icebreaker 苏ICP备19002675号-2
version:1.2.2