lambda nodejs 函数降低冷启动时间的最佳实践
前言
本文章的思路,继承发展自这两篇文章:
这里要感谢这
2
篇文章的作者:ice breaker
,2
年前就提供了这么优秀的思路和解决方案了,真是忍不住给他点赞呀。
首先在看这篇文章之前,我先必须给你介绍一个概念,就是 冷启动时间
。
什么是冷启动时间
这个特性各个服务商的 serverless
云函数都存在,这个和 函数容器的生命周期
息息相关。
以 Lambda
为例,Lambda 生命周期可以分为三个阶段:
- Init:在此阶段,Lambda 会尝试解冻之前的执行环境,若没有可解冻的环境,Lambda 会进行资源创建,下载函数代码,初始化扩展和 Runtime,然后开始运行初始化代码(主程序外的代码)。
- Invoke:在此阶段,Lambda 接收事件后开始执行函数。函数运行到完成后,Lambda 会等待下个事件的调用。
- Shutdown:如果 Lambda 函数在一段时间内没有接收任何调用,则会触发此阶段。在
Shutdown
阶段,Runtime 关闭,然后向每个扩展发送一个Shutdown
事件,最后删除环境。
当您在触发 Lambda 时,若当前没有处于激活阶段的 Lambda 可供调用,则 Lambda 会下载函数的代码并创建一个 Lambda 的执行环境。从事件触发到新的 Lambda 环境创建完成这个周期通常称为 “冷启动时间”。显然,这个时间肯定是越短越好的。
这里可以参考 AWS 这篇博客 以获取更多信息。
其中 AWS 提供的几种降低冷启动时间的方式有:
- 选择合适的编程语言 (我们大部分情况无法更换)
- 减小应用程序大小
- 预热 (定时触发器防止回收和预置并发,保留实例)
- JVM 分层编译 (java 特供)
其中可行性最高的方式,就是本篇文章要探讨的 减小应用程序大小
打包服务端 js
回到正题,为什么要去打包服务端 js
代码呢?用 layer
的方式不是蛮好吗?
这里必须要知道的一点是,函数冷启动的时间,是和整体运行以及其依赖的代码包大小,是息息相关的。
比如上篇文章中的示例,我们把 uuid
这个依赖给做成 layer
上传了上去,但是你有没有想过,既然 uuid
的所有实现都是 js
,为什么不把它整个源代码,打入我们的函数构建产物中呢?这样还省了依赖一个 layer
呢。
同样的道理,我们函数也可以把 express
,lodash
等等依赖,全部打入我们的函数包里去,以减小整体代码包的体积。
这就像我们在写前端项目那样,本质上也会把所有运行时代码,全部给 inline
打成一个一个 chunk
到各个 js
里面去,毕竟浏览器可没有什么 node_modules
的加载机制。你写 vue
还是写 react
都是直接加载它们inline
的整个代码的。
什么是 inline
这里给一个例子:原先你的ts
代码可能是这样写的:
import express from 'express'
然后经过 tsc
,产物变成了这样:
// commonjs format
const express = require('express')
而假如走 inline
那产物中就不会出现 express
,而是直接把 express
相关的代码全部给打了进来,效果类似于:
// 部分代码
var require_express = __commonJS({
"../../node_modules/.pnpm/[email protected]/node_modules/express/lib/express.js"(exports, module2) {
"use strict";
var bodyParser2 = require_body_parser();
var EventEmitter = require("events").EventEmitter;
var mixin = require_merge_descriptors();
var proto = require_application();
var Route = require_route();
var Router = require_router();
var req = require_request2();
var res = require_response2();
exports = module2.exports = createApplication;
function createApplication() {
var app2 = function(req2, res2, next) {
app2.handle(req2, res2, next);
};
mixin(app2, EventEmitter.prototype, false);
mixin(app2, proto, false);
app2.request = Object.create(req, {
app: { configurable: true, enumerable: true, writable: true, value: app2 }
});
app2.response = Object.create(res, {
app: { configurable: true, enumerable: true, writable: true, value: app2 }
});
app2.init();
return app2;
}
exports.application = proto;
exports.request = req;
exports.response = res;
exports.Route = Route;
exports.Router = Router;
exports.json = bodyParser2.json;
exports.query = require_query();
exports.raw = bodyParser2.raw;
exports.static = require_serve_static();
exports.text = bodyParser2.text;
exports.urlencoded = bodyParser2.urlencoded;
var removedMiddlewares = [
"bodyParser",
"compress",
"cookieSession",
"session",
"logger",
"cookieParser",
"favicon",
"responseTime",
"errorHandler",
"timeout",
"methodOverride",
"vhost",
"csrf",
"directory",
"limit",
"multipart",
"staticCache"
];
removedMiddlewares.forEach(function(name) {
Object.defineProperty(exports, name, {
get: function() {
throw new Error("Most middleware (like " + name + ") is no longer bundled with Express and must be installed separately. Please see https://github.com/senchalabs/connect#middleware.");
},
configurable: true
});
});
}
});
// ......
显然这种方式下,可以打出更小更单一的包,因为所有的碎片化的 js
依赖,都被打成到了单文件里面去了,减少了 io
的次数,而且还能够一起制定策略,如 split chunk
or compress
。
要实现这种效果,其实基本上所有流行的打包工具都内置了这个功能。
比如 webpack/esbuild
,当然 rollup
也有对应的插件支持,@rollup/plugin-node-resolve
就是它的实现方式之一。
其中笔者文章开头提到的 2
篇文章里的实现方式,就是基于 rollup
这个工具去实现的,在此不再叙述。在 2
年后的今天,也很高兴看到了更多,基于它们的开箱即用的工具,使得我们不需要安装大量的插件或者编写复杂的打包配置,就能实现同样的效果,让我们一起来看看吧。
进一步封装的打包工具
在过去,比较构建库比较流行 rollup
或者 esbuild
,不过现在有了基于它们更进一步的打包工具: unbuild
/ tsup
。
其中 unbuild
这个工具先跳过,我们会在 monorepo
章节中介绍它,这里我们主要来介绍 tsup
在函数打包中的用法。
tsup
由 esbuild
进一步封装而来。它太开箱即用了,甚至可以 0 config
。它本身的打包配置,主要是基于约定的:
比如它会默认去 inline
我们所有在运行时引用的,但是却是注册在 devDependencies
里的包
而 dependencies
里的包,则是被默认加入了 external
中,不进行 node resolve
这个约定实际上很简单却很实用,类似于这样 rollup
的配置:
// rollup.config.ts
import { readFileSync } from 'node:fs'
const pkg = JSON.parse(
readFileSync('./package.json', {
encoding: 'utf8'
})
)
const dependencies = pkg.dependencies as Record<string, string> | undefined
const config: RollupOptions = {
// ...
plugins: [
json(),
nodeResolve(),
commonjs(),
typescript()
],
external: [...(dependencies ? Object.keys(dependencies) : [])]
}
显然对比起来 tsup.config.ts
文件的配置就 简洁 多了,见下:
// tsup.config.ts
import { defineConfig } from 'tsup'
const isDev = process.env.NODE_ENV === 'development'
export default defineConfig({
entry: ['src/index.ts'],
splitting: false,
sourcemap: isDev,
clean: true,
// external: []
})
接着注册指令即可 package.json
里的 npm scripts
(这里我加了 cross-env
包和 NODE_ENV
环境变量是为了做更多,比如条件编译等等事情)
"scripts": {
"dev": "cross-env NODE_ENV=development tsup --watch",
"build": "cross-env NODE_ENV=production tsup",
},
这样执行这命令,你的函数就被打入 dist/inde.js
里了,赶紧去检查一下产物吧。
存在的弊端以及解决方案
上面说的这种打包方式其实存在一定的弊端:
- 首先,它改变了第三方依赖的目录结构
- 其次它只能处理一些
js
依赖,不使用特定的插件,常常会出现一些非js
依赖的缺失
这个问题的严重性会导致一系列的问题,比如某些包源代码里面是依赖文件目录的:
// node_modules/some-lib/dist/index.js
const defaultDbFile = path.resolve(__dirname, '../data/ip2region.xdb')
那这行代码被打入我们函数包就会有问题,因为目录结构被破坏了,这会导致第三方包调用出错。
目录结构的变化如下:
(打包前)本地可以运行的目录结构
- src
- index.ts
- node_modules
- some-lib
- dist
- index.js
- data
- ip2region.xdb
- dist
- some-lib
(打包后)
- dist
- index.js
- node_modules
- some-lib
- dist (用不到了)
- index.js
- data
- ip2region.xdb
- dist (用不到了)
- some-lib
注意此时 node_modules/some-lib/dist/index.js
里的代码inline
到了 dist/index.js
里去了。但是 defaultDbFile
的引用的路径却变化了,因为 __dirname
变化了,此时正确的路径实际上是 path.resolve(__dirname, '../node_modules/some-lib/data/ip2region.xdb')
那么如何解决呢?目前比较好的解决方案,是使用 external
的方式,不去主动 inline
那些可能会导致问题的包,并把那些包挑出来,做成 layer
再进行绑定。幸好这种包是小概率会遇到的,测试环境很容易发现问题。
Next Chapter
现在你已经学会了打包服务端代码的策略。
下一篇,《更灵活的 serverless framework 配置文件》中,将会详细介绍如何让你的部署配置文件变得灵活起来。
完整示例及文章仓库地址
https://github.com/sonofmagic/serverless-aws-cn-guide
如果你遇到什么问题,或者发现什么勘误,欢迎提 issue
给我