本篇文章,不涉及自定义镜像的部署方式
我们知道,在 serverless 场景下,函数的冷启动时间,是和上传代码包的体积大小相关的。代码体积越小,拉取代码速度越快,冷启动时间自然就短了。
对我们 nodejs
开发者来说,在工程里,往往占据巨大体积的,不是我们自己写的代码,而是在 node_modules
中依赖各种包。尤其是某些npm
包作者,不会正确使用 .npmignore
, .gitignore
和 package.json
中的 files
字段,发布的包令人感到酸爽的(笑~)
像传统的 在本地 或者 在线 安装依赖,都会在 node_modules
中产生过多的无用垃圾文件,白白占据了大量的空间。对我们开发者而言,就要想办法去解决这个问题,以减小运行时代码包的大小。
本地作为开发环境,开发者往往会把 devDependencies
,dependencies
都给安装进来。
而 devDependencies
往往是 eslint
, webpack
这类的包,和真正的服务端运行时无关。
要是把它们也部署上 serverless
平台,不论是直接压缩上传代码包,还是做成 layer层函数
去绑定,都是在浪费代码包体积,因为那一部分代码,在运行时永远不会被调用。
yarn install --production
算一个解决方案,这个指令作用是:只安装 dependencies
里的包。
当然这也要求开发者,安装npm
包时,对所需的环境做准确的划分。
注:这个指令在我们开发时候,往往是无用的,举个例子: 我们通常会把
typescript
安装到devDependencies
里 要是只安装dependencies
,那我们连tsc
都做不到了。
我们知道,操作系统大体上分为 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
注册源不能切换
安装好后,目前也是直接放到代码中去,没有打成层函数。
不过 在线安装依赖 可以规避上述 本地安装依赖
中 操作系统或指令集绑定的第三方包
这个问题,毕竟依赖都是在云函数环境下现装的。
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 读取的路径来解决,这里受限于篇幅原因不在叙述。
当然
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.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
这类的)