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

Nodejs云函数冷启动时间的优化(硬核)

Nodejs
Serverless
云函数
冷启动
硬核?
共1685个字,阅读时间 8 分钟
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://icebreaker.top/articles/2021/11/5-serverless-node-bundler

bg

Nodejs 云函数冷启动时间的优化 (硬核)

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

前言

冷启动(Cold Start) 一直是 Serverless 的一个缺点。

它往往出现在这样一个场景:

当一个云函数已经不存在空闲的可提供服务的容器时。再有额外的请求传入,函数本身就不得不去启动一个新的容器来处理这次的请求。由于启动新容器会有延迟,这就导致了这个请求需要更多时间来响应,用户就会感觉我们应用程序似乎 "变慢" 了。

这对于用户的体验来说是个大问题,需要尽可能的去减短这个时间。

我们知道当容器从 "冷态" 开始启动时,函数需要:

  1. 从外部持久化存储中获取代码包
  2. 启动容器
  3. 在内存中加载程序包代码
  4. 运行函数进行处理

其中,在第一步拉取代码时,代码体积越小,拉取代码速度越快,冷启动时间自然就变短了。这块是我们开发者能够间接控制的。

而对我们 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-propertiesfontkit 都用 fs 依赖了它们各自目录下的 data.trie 文件,但是它们依赖的 data.trie 内容不同,这也会导致 函数挂掉。

这种情况就可以使用 external 来规避:即把能打包的打包了,不能打包的就不打包。

部署的时候,再把没打包的 external 们,单独抽离出来,做成独立的 dist/package.json, 再在云端安装依赖,我们的云函数就能够直接运行了。

总结

代码包体积小了之后,大大提升了函数部署到 腾讯云 SCF 平台的速度(避免了压缩上传 node_modules

同时通过分析依赖,构建产出,并压缩代码的方式,将一些本要在云端 拉取解压近 300多M 的代码包,减小到 8M 左右,有效的减少了函数获取代码包的时间,从而整体优化了函数的冷启动时间。

附录

内建模块 (builtin-modules)