跳转至

无服务器

无服务器计算是一种云计算执行模型,在该模型中,云提供器按需分配机器资源,代表客户管理服务器。 当应用不被使用时,该应用将没有计算资源分配给该应用。 定价是基于应用程序实际消耗的资源数量(source)。

在一个 无服务器架构 中,你只关注应用程序代码中的单个函数。 AWS Lambda、谷歌云函数和微软 Azure 函数等服务负责所有物理硬件、虚拟机操作系统和 web 服务器软件管理。

Hint

本章不讨论无服务器功能的优缺点,也不深入讨论任何云提供商的细节。

冷启动

冷启动是代码在一段时间内第一次执行。 根据您使用的云提供商的不同,它可能跨越几个不同的操作,从下载代码和引导运行时到最终运行您的代码。 这个过程会增加大量的延迟,这取决于几个因素,语言,应用程序需要的包的数量,等等。

冷启动很重要,虽然有些事情是我们无法控制的,但我们仍有很多事情可以做,以使它尽可能短。

虽然你可以将 Nest 视为一个成熟的框架,专为复杂的企业应用而设计, 它也 适用于“更简单”的应用程序 (或脚本)。 例如,使用独立应用程序特性,你可以在 simple workers, CRON jobs, CLIs, or serverless functions 中利用 Nest 的 DI 系统。

基准

为了更好地理解在无服务器函数的环境中使用 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 (with @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(3000);
}
bootstrap();

// #3 Nest as a Standalone application (no HTTP server)
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 Raw Node.js script
async function bootstrap() {
  console.log('Hello world!');
}
bootstrap();

For all these scripts, we used the tsc (TypeScript) compiler and so the code remains unbundled (webpack isn't used).

Express 0.0079s (7.9ms)
Nest with @nestjs/platform-express 0.1974s (197.4ms)
Nest (standalone application) 0.1117s (111.7ms)
Raw Node.js script 0.0071s (7.1ms)

!!! info Note Machine: MacBook Pro Mid 2014, 2.5 GHz Quad-Core Intel Core i7, 16 GB 1600 MHz DDR3, SSD.

Now, let's repeat all benchmarks but this time, using webpack (if you have Nest CLI installed, you can run nest build --webpack) to bundle our application into a single executable JavaScript file. However, instead of using the default webpack configuration that Nest CLI ships with, we'll make sure to bundle all dependencies (node_modules) together, as follows:

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;
        },
      }),
    ],
  };
};

Hint

To instruct Nest CLI to use this configuration, create a new webpack.config.js file in the root directory of your project.

With this configuration, we received the following results:

Express 0.0068s (6.8ms)
Nest with @nestjs/platform-express 0.0815s (81.5ms)
Nest (standalone application) 0.0319s (31.9ms)
Raw Node.js script 0.0066s (6.6ms)

Note

Machine: MacBook Pro Mid 2014, 2.5 GHz Quad-Core Intel Core i7, 16 GB 1600 MHz DDR3, SSD.

Info

You could optimize it even further by applying additional code minification & optimization techniques (using webpack plugins, etc.).

As you can see, the way you compile (and whether you bundle your code) is crucial and has a significant impact on the overall startup time. With webpack, you can get the bootstrap time of a standalone Nest application (starter project with one module, controller, and service) down to ~32ms on average, and down to ~81.5ms for a regular HTTP, express-based NestJS app.

For more complicated Nest applications, for example, with 10 resources (generated through $ nest g resource schematic = 10 modules, 10 controllers, 10 services, 20 DTO classes, 50 HTTP endpoints + AppModule), the overall startup on MacBook Pro Mid 2014, 2.5 GHz Quad-Core Intel Core i7, 16 GB 1600 MHz DDR3, SSD is approximately 0.1298s (129.8ms). Running a monolithic application as a serverless function typically doesn't make too much sense anyway, so think of this benchmark more as an example of how the bootstrap time may potentially increase as your application grows.

运行时优化

Thus far we covered compile-time optimizations. These are unrelated to the way you define providers and load Nest modules in your application, and that plays an essential role as your application gets bigger.

For example, imagine having a database connection defined as an asynchronous provider. Async providers are designed to delay the application start until one or more asynchronous tasks are completed. That means, if your serverless function on average requires 2s to connect to the database (on bootstrap), your endpoint will need at least two extra seconds (because it must wait till the connection is established) to send a response back (when it's a cold start and your application wasn't running already).

As you can see, the way you structure your providers is somewhat different in a serverless environment where bootstrap time is important. Another good example is if you use Redis for caching, but only in certain scenarios. Perhaps, in this case, you should not define a Redis connection as an async provider, as it would slow down the bootstrap time, even if it's not required for this specific function invocation.

Also, sometimes you could lazy-load entire modules, using the LazyModuleLoader class, as described in this chapter. Caching is a great example here too. Imagine that your application has, let's say, CacheModule which internally connects to Redis and also, exports the CacheService to interact with the Redis storage. If you don't need it for all potential function invocations, you can just load it on-demand, lazily. This way you'll get a faster startup time (when a cold start occurs) for all invocations that don't require caching.

1
2
3
4
5
6
7
8
9
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);
}

Another great example is a webhook or worker, which depending on some specific conditions (e.g., input arguments), may perform different operations. In such a case, you could specify a condition inside your route handler that lazily loads an appropriate module for the specific function invocation, and just load every other module lazily.

1
2
3
4
5
6
7
8
9
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);
  // ...
}

示例集成

The way your application's entry file (typically main.ts file) is supposed to look like depends on several factors and so there's no single template that just works for every scenario. For example, the initialization file required to spin up your serverless function varies by cloud providers (AWS, Azure, GCP, etc.). Also, depending on whether you want to run a typical HTTP application with multiple routes/endpoints or just provide a single route (or execute a specific portion of code), your application's code will look different (for example, for the endpoint-per-function approach you could use the NestFactory.createApplicationContext instead of booting the HTTP server, setting up middleware, etc.).

Just for illustration purposes, we'll integrate Nest (using @nestjs/platform-express and so spinning up the whole, fully functional HTTP router) with the Serverless framework (in this case, targetting AWS Lambda). As we've mentioned earlier, your code will differ depending on the cloud provider you choose, and many other factors.

First, let's install the required packages:

$ npm i @vendia/serverless-express aws-lambda
$ npm i @types/aws-lambda serverless-offline

Hint

To speed up development cycles, we install the serverless-offline plugin which emulates AWS λ and API Gateway.

Once the installation process is complete, let's create the serverless.yml file to configure the Serverless framework:

service:
  name: serverless-example

plugins:
  - serverless-offline

provider:
  name: aws
  runtime: nodejs12.x

functions:
  main:
    handler: dist/main.handler
    events:
      - http:
          method: ANY
          path: /
      - http:
          method: ANY
          path: '{proxy+}'

Hint

To learn more about the Serverless framework, visit the official documentation.

With this place, we can now navigate to the main.ts file and update our bootstrap code with the required boilerplate:

import { NestFactory } from '@nestjs/core';
import serverlessExpress from '@vendia/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);
};

Hint

For creating multiple serverless functions and sharing common modules between them, we recommend using the CLI Monorepo mode.

Warning

If you use @nestjs/swagger package, there are a few additional steps required to make it work properly in the context of serverless function.

Check out this article for more information.

Next, open up the tsconfig.json file and make sure to enable the esModuleInterop option to make the @vendia/serverless-express package load properly.

1
2
3
4
5
6
{
  "compilerOptions": {
    ...
    "esModuleInterop": true
  }
}

Now we can build our application (with nest build or tsc) and use the serverless CLI to start our lambda function locally:

$ npm run build
$ npx serverless offline

Once the application is running, open your browser and navigate to http://localhost:3000/dev/[ANY_ROUTE] (where [ANY_ROUTE] is any endpoint registered in your application).

In the sections above, we've shown that using webpack and bundling your app can have significant impact on the overall bootstrap time. However, to make it work with our example, there's one additional configuration you must add in your webpack.config.js file. Generally, to make sure our handler function will be picked up, we must change the output.libraryTarget property to commonjs2.

return {
  ...options,
  externals: [],
  output: {
    ...options.output,
    libraryTarget: 'commonjs2',
  },
  // ...
the rest of the configuration
};

With this in place, you can now use $ nest build --webpack to compile your function's code (and then $ npx serverless offline to test it).

使用独立应用程序特性

Alternatively, if you want to keep your function very lightweight and you don't need any HTTP-related features (routing, but also guards, interceptors, pipes, etc.), you can just use NestFactory.createApplicationContext (as mentioned earlier) instead of running the entire HTTP server (and express under the hood), as follows:

import { HttpStatus } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Callback, Context, Handler } from 'aws-lambda';
import { AppModule } from './app.module';
import { AppService } from './app.service';

export const handler: Handler = async (
  event: any,
  context: Context,
  callback: Callback,
) => {
  const appContext = await NestFactory.createApplicationContext(AppModule);
  const appService = appContext.get(AppService);

  return {
    body: appService.getHello(),
    statusCode: HttpStatus.OK,
  };
};

Hint

Be aware that NestFactory.createApplicationContext does not wrap controller methods with enhancers (guard, interceptors, etc.).

For this, you must use the NestFactory.create method.

You could also pass the event object down to, let's say, EventsService provider that could process it and return a corresponding value (depending on the input value and your business logic).

1
2
3
4
5
6
7
8
9
export const handler: Handler = async (
  event: any,
  context: Context,
  callback: Callback,
) => {
  const appContext = await NestFactory.createApplicationContext(AppModule);
  const eventsService = appContext.get(EventsService);
  return eventsService.process(event);
};