Skip to content

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 运行时执行下面几段脚本所需的时间:

typescript
// #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)。

时间
Express0.0079s(7.9ms)
Nest + @nestjs/platform-express0.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)也一并打包进去,配置如下:

javascript
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 文件。

使用这份配置后,我们得到如下结果:

时间
Express0.0068s(6.8ms)
Nest + @nestjs/platform-express0.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 存储交互。如果不是每次函数调用都需要缓存,那么你就可以只在需要时才懒加载它。这样一来,所有不依赖缓存的调用,在发生冷启动时都能获得更快的启动速度。

typescript
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。根据不同条件(例如输入参数),它们可能执行完全不同的逻辑。

在这种情况下,你可以在路由处理器中根据条件动态懒加载对应模块,而把其他模块都延迟到真正需要时再加载。

typescript
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。正如前面所说,具体代码会随着云厂商和部署方式不同而有所差异。

首先安装必要依赖:

bash
$ npm i @codegenie/serverless-express aws-lambda
$ npm i -D @types/aws-lambda serverless-offline

提示

为了加快开发迭代速度,这里安装了 serverless-offline 插件,它可以模拟 AWS Lambda 与 API Gateway。

安装完成后,创建 serverless.yml 文件来配置 Serverless 框架:

yaml
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,将启动代码改成如下模板:

typescript
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 能被正确加载:

json
{
  "compilerOptions": {
    ...
    "esModuleInterop": true
  }
}

现在就可以构建应用(使用 nest buildtsc),然后通过 serverless CLI 在本地启动 Lambda 函数:

bash
$ npm run build
$ npx serverless offline

基于 NestJS 官方文档翻译