Serverless
Serverless computing(无服务器计算)是一种云计算执行模型,在这种模型中,云服务商会按需分配机器资源,并代替客户管理服务器。当应用未被使用时,不会为其分配计算资源。计费则基于应用实际消耗的资源量(来源)。
在 serverless 架构 中,你关注的重点会完全落在应用代码中的各个函数本身。像 AWS Lambda、Google Cloud Functions 和 Microsoft Azure Functions 这类服务,会负责底层物理硬件、虚拟机操作系统以及 Web 服务器软件的管理。
提示
本章不会讨论 serverless 函数的优缺点,也不会深入特定云厂商的实现细节。
冷启动
冷启动(cold start)指的是你的代码在较长一段时间未执行之后,重新开始第一次执行时的过程。根据所使用的云服务商不同,它可能涉及多个阶段:从下载代码、初始化运行时,到最终真正执行你的代码。
这个过程会带来显著延迟,而延迟大小通常取决于多种因素,例如编程语言、应用所需的依赖包数量等。
冷启动很重要。虽然其中有些因素超出我们的控制范围,但从应用自身角度,仍然有许多优化空间,可以尽量缩短这段时间。
虽然你通常会把 Nest 看作一个面向复杂企业级应用的完整框架,但它同样适用于更简单的应用(甚至是脚本)。例如,借助独立应用特性,你可以在简单的 worker、CRON 任务、CLI,或 serverless 函数中复用 Nest 的依赖注入系统。
基准测试
为了更直观地了解在 serverless 函数场景下使用 Nest 或其他常见库(如 express)的启动成本,我们来比较 Node 运行时执行下面几段脚本所需的时间:
// #1 Express
import * as express from 'express';
async function bootstrap() {
const app = express();
app.get('/', (req, res) => res.send('Hello world!'));
await new Promise<void>((resolve) => app.listen(3000, resolve));
}
bootstrap();
// #2 Nest(使用 @nestjs/platform-express)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { logger: ['error'] });
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
// #3 Nest 作为独立应用(无 HTTP 服务器)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AppService } from './app.service';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error'],
});
console.log(app.get(AppService).getHello());
}
bootstrap();
// #4 原生 Node.js 脚本
async function bootstrap() {
console.log('Hello world!');
}
bootstrap();这些脚本全部使用 tsc(TypeScript)编译,因此代码未被打包(没有使用 webpack)。
| 时间 | |
|---|---|
| Express | 0.0079s(7.9ms) |
Nest + @nestjs/platform-express | 0.1974s(197.4ms) |
| Nest(独立应用) | 0.1117s(111.7ms) |
| 原生 Node.js 脚本 | 0.0071s(7.1ms) |
注意
测试机器:MacBook Pro Mid 2014,2.5 GHz 四核 Intel Core i7,16 GB 1600 MHz DDR3,SSD。
接下来我们重复测试,但这一次使用 webpack 将应用打成单个可执行 JavaScript 文件(若你已安装 Nest CLI,可以执行 nest build --webpack)。
不过,这里不会直接使用 Nest CLI 默认附带的 webpack 配置,而是显式把所有依赖(node_modules)也一并打包进去,配置如下:
module.exports = (options, webpack) => {
const lazyImports = [
'@nestjs/microservices/microservices-module',
'@nestjs/websockets/socket-module',
];
return {
...options,
externals: [],
plugins: [
...options.plugins,
new webpack.IgnorePlugin({
checkResource(resource) {
if (lazyImports.includes(resource)) {
try {
require.resolve(resource);
} catch (err) {
return true;
}
}
return false;
},
}),
],
};
};提示
要让 Nest CLI 使用这份配置,请在项目根目录下新建 webpack.config.js 文件。
使用这份配置后,我们得到如下结果:
| 时间 | |
|---|---|
| Express | 0.0068s(6.8ms) |
Nest + @nestjs/platform-express | 0.0815s(81.5ms) |
| Nest(独立应用) | 0.0319s(31.9ms) |
| 原生 Node.js 脚本 | 0.0066s(6.6ms) |
注意
测试机器:MacBook Pro Mid 2014,2.5 GHz 四核 Intel Core i7,16 GB 1600 MHz DDR3,SSD。
提示
你还可以通过进一步的代码压缩与优化手段(例如额外的 webpack 插件)继续缩短启动时间。
可以看到,应用的编译方式(以及是否打包)对整体启动时间至关重要。使用 webpack 后,一个标准的 Nest 独立应用(starter 项目,包含 1 个 module、1 个 controller、1 个 service)的平均启动时间可以降到约 32ms;而一个常规的、基于 Express 的 HTTP Nest 应用则可以降到约 81.5ms。
对于更复杂的 Nest 应用,例如包含 10 个资源(通过 $ nest g resource 生成,相当于 10 个模块、10 个控制器、10 个服务、20 个 DTO 类、50 个 HTTP 端点,再加上 AppModule),在上述同一台机器上的整体启动时间大约为 0.1298s(129.8ms)。当然,把一个大型单体应用整体作为 serverless 函数运行,通常本身并不太合理,因此这个数据更多只是用来说明:随着应用规模增长,启动时间可能如何上升。
运行时优化
前面讨论的都是编译时优化。这些优化与 provider 定义方式、模块加载方式无关,而后者在应用规模增大时同样至关重要。
例如,假设你的数据库连接是通过一个异步 provider建立的。异步 provider 的设计目标,就是在一个或多个异步任务完成之前延迟应用启动。
这意味着,如果你的 serverless 函数在冷启动时平均需要 2 秒才能连上数据库,那么某个端点要返回响应时,就至少要额外等待这 2 秒,因为它必须等连接建立完毕。
由此可见,在serverless 环境中,由于启动时间格外重要,你组织 provider 的方式往往会和传统常驻服务有所不同。
另一个常见例子是 Redis 缓存。如果你只在某些特定场景下才会使用 Redis,那么也许就不应该把 Redis 连接定义成异步 provider,因为那会拖慢所有函数调用的启动时间,即便这次调用其实根本不需要 Redis。
有时你甚至可以借助 LazyModuleLoader 类来按需延迟加载整个模块,这在懒加载模块章节中已有介绍。缓存依然是个很好的例子。
假设你的应用有一个 CacheModule,它内部会连接 Redis,并导出 CacheService 用于与 Redis 存储交互。如果不是每次函数调用都需要缓存,那么你就可以只在需要时才懒加载它。这样一来,所有不依赖缓存的调用,在发生冷启动时都能获得更快的启动速度。
if (request.method === RequestMethod[RequestMethod.GET]) {
const { CacheModule } = await import('./cache.module');
const moduleRef = await this.lazyModuleLoader.load(() => CacheModule);
const { CacheService } = await import('./cache.service');
const cacheService = moduleRef.get(CacheService);
return cacheService.get(ENDPOINT_KEY);
}另一个很典型的例子是 webhook 或 worker。根据不同条件(例如输入参数),它们可能执行完全不同的逻辑。
在这种情况下,你可以在路由处理器中根据条件动态懒加载对应模块,而把其他模块都延迟到真正需要时再加载。
if (workerType === WorkerType.A) {
const { WorkerAModule } = await import('./worker-a.module');
const moduleRef = await this.lazyModuleLoader.load(() => WorkerAModule);
// ...
} else if (workerType === WorkerType.B) {
const { WorkerBModule } = await import('./worker-b.module');
const moduleRef = await this.lazyModuleLoader.load(() => WorkerBModule);
// ...
}集成示例
应用入口文件(通常是 main.ts)应该如何编写,取决于多个因素,因此并不存在一个适用于所有场景的通用模板。
例如,不同云厂商(AWS、Azure、GCP 等)要求的 serverless 初始化文件就不相同。
另外,如果你想运行的是一个典型的多路由 / 多端点 HTTP 应用,还是仅仅提供一个单独路由(或执行一小段特定逻辑),你的应用代码结构也会不同。例如,对于“每个端点一个函数”的方式,你就可能使用 NestFactory.createApplicationContext(),而不是启动完整 HTTP 服务器、配置中间件等。
这里仅作为示例,演示如何将 Nest(使用 @nestjs/platform-express,也就是启动完整的 HTTP 路由系统)与 Serverless 框架集成,并部署到 AWS Lambda。正如前面所说,具体代码会随着云厂商和部署方式不同而有所差异。
首先安装必要依赖:
$ npm i @codegenie/serverless-express aws-lambda
$ npm i -D @types/aws-lambda serverless-offline提示
为了加快开发迭代速度,这里安装了 serverless-offline 插件,它可以模拟 AWS Lambda 与 API Gateway。
安装完成后,创建 serverless.yml 文件来配置 Serverless 框架:
service: serverless-example
plugins:
- serverless-offline
provider:
name: aws
runtime: nodejs14.x
functions:
main:
handler: dist/main.handler
events:
- http:
method: ANY
path: /
- http:
method: ANY
path: '{proxy+}'提示
如需进一步了解 Serverless 框架,请查阅其官方文档。
接下来打开 main.ts,将启动代码改成如下模板:
import { NestFactory } from '@nestjs/core';
import serverlessExpress from '@codegenie/serverless-express';
import { Callback, Context, Handler } from 'aws-lambda';
import { AppModule } from './app.module';
let server: Handler;
async function bootstrap(): Promise<Handler> {
const app = await NestFactory.create(AppModule);
await app.init();
const expressApp = app.getHttpAdapter().getInstance();
return serverlessExpress({ app: expressApp });
}
export const handler: Handler = async (
event: any,
context: Context,
callback: Callback,
) => {
server = server ?? (await bootstrap());
return server(event, context, callback);
};提示
如果你需要创建多个 serverless 函数,并在它们之间共享通用模块,我们推荐使用 CLI 的 Monorepo 模式。
警告
如果你使用 @nestjs/swagger,为了让它在 serverless 函数场景中正常工作,还需要额外做一些处理。详情请参考这个 issue 讨论。
然后打开 tsconfig.json,确认启用了 esModuleInterop,以确保 @codegenie/serverless-express 能被正确加载:
{
"compilerOptions": {
...
"esModuleInterop": true
}
}现在就可以构建应用(使用 nest build 或 tsc),然后通过 serverless CLI 在本地启动 Lambda 函数:
$ npm run build
$ npx serverless offline