Passer au contenu

Serverless

L’informatique sans serveur est un modèle d’exécution de cloud computing dans lequel le fournisseur de cloud alloue des ressources de machine à la demande, prenant soin des serveurs pour le compte de ses clients. Lorsqu’une application n’est pas utilisée, aucune ressource de calcul n’est allouée à l’application. La tarification est basée sur la quantité réelle de ressources consommées par une application (source).

Avec une architecture sans serveur, vous vous concentrez purement sur les fonctions individuelles dans le code de votre application. Des services tels que AWS Lambda, Google Cloud Functions et Microsoft Azure Functions prennent en charge toute la gestion du matériel physique, du système d’exploitation de la machine virtuelle et des logiciels de serveur web.

Cold start

Un « cold start » est la première fois que votre code a été exécuté depuis un certain temps. Selon le fournisseur de cloud que vous utilisez, cela peut impliquer plusieurs opérations différentes, de l’importation du code et de l’initialisation de l’environnement d’exécution jusqu’à l’exécution de votre code. Ce processus ajoute une latence significative en fonction de plusieurs facteurs, tels que le langage et le nombre de packages requis par votre application.

Il est important de savoir que même s’il y a des aspects qui échappent à notre contrôle, il y a encore beaucoup de choses que nous pouvons faire de notre côté pour rendre cela aussi court que possible.

Bien que vous puissiez penser à Nest comme un cadre complet conçu pour être utilisé dans des applications d’entreprise complexes, il est également adapté à des applications beaucoup “plus simples” (ou scripts). Par exemple, grâce à la fonctionnalité des applications autonomes, vous pouvez tirer parti du système de DI de Nest dans des travailleurs simples, des tâches CRON, des CLIs ou des fonctions sans serveur.

Benchmarks

Pour mieux comprendre quel est le coût d’utilisation de Nest ou d’autres bibliothèques bien connues (comme express) dans le contexte des fonctions sans serveur, comparons combien de temps nécessite l’exécution de Node pour les scripts suivants :

Exemple d'utilisation d'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();
Exemple d'utilisation de Nest (avec @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();
Exemple d'application autonome avec Nest (sans serveur 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();
Exemple de script Node.js brut
async function bootstrap() {
console.log('Hello world!');
}
bootstrap();

Pour tous ces scripts, nous avons utilisé le compilateur tsc (TypeScript) et donc le code reste non empaqueté (webpack n’est pas utilisé).

ExécutionTemps
Express0.0079s (7.9ms)
Nest avec @nestjs/platform-express0.1974s (197.4ms)
Nest (application autonome)0.1117s (111.7ms)
Script Node.js brut0.0071s (7.1ms)

Maintenant, répétions tous les benchmarks mais cette fois, en utilisant webpack (si vous avez Nest CLI installé, vous pouvez exécuter nest build --webpack) pour empaquer notre application en un seul fichier JavaScript exécutable. Cependant, au lieu d’utiliser la configuration par défaut que fournis Nest CLI, nous allons nous assurer de regrouper toutes les dépendances (node_modules) ensemble, comme suit :

webpack.config.js
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;
},
}),
],
};
};

Avec cette configuration, nous avons obtenu les résultats suivants :

ExécutionTemps
Express0.0068s (6.8ms)
Nest avec @nestjs/platform-express0.0815s (81.5ms)
Nest (application autonome)0.0319s (31.9ms)
Script Node.js brut0.0066s (6.6ms)

Comme vous pouvez le voir, la façon dont vous compilez (et si vous regroupez ou non votre code) est cruciale et a un impact significatif sur le temps de démarrage global. Avec webpack, vous pouvez réduire le temps de démarrage d’une application Nest autonome (projet de démarrage avec un module, un contrôleur et un service) à environ ~32ms en moyenne, et à environ ~81.5ms pour une application NestJS basée sur HTTP classique.

Pour des applications Nest plus compliquées, par exemple avec 10 ressources (générées par le schéma nest g resource = 10 modules, 10 contrôleurs, 10 services, 20 classes DTO, 50 points de terminaison HTTP + AppModule), le temps de démarrage global sur un MacBook Pro Mid 2014, 2.5 GHz Quad-Core Intel Core i7, 16 Go 1600 MHz DDR3, SSD est d’environ 0.1298s (129.8ms). Exécuter une application monolithique en tant que fonction sans serveur n’a généralement pas beaucoup de sens, réfléchissez donc à ce benchmark davantage comme un exemple de la façon dont le temps de démarrage pourrait potentiellement augmenter au fur et à mesure que votre application se développe.

Optimisations d’exécution

Jusqu’à présent, nous avons couvert les optimisations au moment de la compilation. Celles-ci sont indépendantes de la façon dont vous définissez les fournisseurs et chargez les modules Nest dans votre application, et cela joue un rôle essentiel à mesure que votre application grandit.

Par exemple, imaginez avoir une connexion de base de données définie comme un fournisseur asynchrone. Les fournisseurs asynchrones sont conçus pour retarder le démarrage de l’application jusqu’à ce qu’une ou plusieurs tâches asynchrones soient terminées. Cela signifie que, si votre fonction sans serveur nécessite en moyenne 2s pour se connecter à la base de données (au démarrage), votre point de terminaison aura besoin d’au moins deux secondes supplémentaires (car il doit attendre l’établissement de la connexion) pour renvoyer une réponse (lorsqu’il s’agit d’un cold start et que votre application n’était pas déjà en cours d’exécution).

Comme vous pouvez le voir, la façon dont vous structurez vos fournisseurs est quelque peu différente dans un environnement sans serveur où le temps de démarrage est important. Un autre bon exemple est si vous utilisez Redis pour la mise en cache, mais uniquement dans certains scénarios. Peut-être, dans ce cas, vous ne devriez pas définir une connexion Redis comme un fournisseur asynchrone, car cela ralentirait le temps de démarrage, même si ce n’est pas requis pour cette invocation spécifique de fonction.

De plus, vous pourriez parfois charger de manière paresseuse des modules entiers, en utilisant la classe LazyModuleLoader, comme décrit dans ce chapitre. La mise en cache est un excellent exemple ici aussi. Imaginez que votre application dispose d’un CacheModule qui se connecte en interne à Redis et exporte également le CacheService pour interagir avec le stockage Redis. Si vous n’en avez pas besoin pour toutes les invocations de fonction potentielles, vous pouvez simplement le charger à la demande, paresseusement. De cette manière, vous obtiendrez un temps de démarrage plus rapide (lors d’un cold start) pour toutes les invocations qui ne nécessitent pas de mise en cache.

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

Un autre excellent exemple est un webhook ou un travailleur, qui en fonction de certaines conditions spécifiques (par exemple, arguments d’entrée), peut effectuer différentes opérations. Dans ce cas, vous pourriez spécifier une condition au sein de votre gestionnaire de route qui charge paresseusement un module approprié pour l’invocation spécifique de fonction, et charger chaque autre module de manière paresseuse.

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);
// ...
}

Exemple d’intégration

La façon dont le fichier d’entrée de votre application (typiquement le fichier main.ts) doit apparaître dépend de plusieurs facteurs et donc il n’y a pas de modèle unique qui fonctionne pour tous les scénarios. Par exemple, le fichier d’initialisation requis pour démarrer votre fonction sans serveur varie selon les fournisseurs de cloud (AWS, Azure, GCP, etc.). De plus, en fonction de si vous souhaitez exécuter une application HTTP typique avec plusieurs routes/points de terminaison ou simplement fournir une seule route (ou exécuter une portion spécifique de code), le code de votre application sera différent (par exemple, pour l’approche d’endpoint par fonction, vous pourriez utiliser NestFactory.createApplicationContext au lieu de démarrer le serveur HTTP, de configurer des middleware, etc.).

Juste à titre d’illustration, nous allons intégrer Nest (en utilisant @nestjs/platform-express et donc faire fonctionner tout le routeur HTTP fonctionnel) avec le framework Serverless (dans ce cas, ciblant AWS Lambda). Comme nous l’avons mentionné précédemment, votre code sera différent selon le fournisseur de cloud que vous choisissez, et de nombreux autres facteurs.

Tout d’abord, installons les packages requis :

Fenêtre de terminal
$ npm i @codegenie/serverless-express aws-lambda
$ npm i -D @types/aws-lambda serverless-offline

Une fois le processus d’installation terminé, créons le fichier serverless.yml pour configurer le framework 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+}'

Avec cela en place, nous pouvons maintenant naviguer vers le fichier main.ts et mettre à jour notre code de démarrage avec le code de démarrage requis :

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

Ensuite, ouvrez le fichier tsconfig.json et assurez-vous d’activer l’option esModuleInterop pour que le package @codegenie/serverless-express se charge correctement.

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

Maintenant, nous pouvons construire notre application (avec nest build ou tsc) et utiliser CLI serverless pour démarrer notre fonction lambda localement :

Fenêtre de terminal
$ npm run build
$ npx serverless offline

Une fois que l’application est en cours d’exécution, ouvrez votre navigateur et accédez à http://localhost:3000/dev/[ANY_ROUTE] (où [ANY_ROUTE] est n’importe quel point de terminaison inscrit dans votre application).

Dans les sections ci-dessus, nous avons montré que l’utilisation de webpack et le regroupement de votre application peuvent avoir un impact significatif sur le temps de démarrage global. Cependant, pour que cela fonctionne avec notre exemple, il y a quelques configurations supplémentaires que vous devez ajouter dans votre fichier webpack.config.js. En général, pour s’assurer que notre fonction handler sera détectée, nous devons changer la propriété output.libraryTarget en commonjs2.

return {
...options,
externals: [],
output: {
...options.output,
libraryTarget: 'commonjs2',
},
// ... le reste de la configuration
};

Avec cela en place, vous pouvez maintenant utiliser $ nest build --webpack pour compiler le code de votre fonction (et ensuite $ npx serverless offline pour le tester).

Il est également recommandé (mais non requis car cela ralentira votre processus de construction) d’installer le package terser-webpack-plugin et de remplacer sa configuration pour conserver les noms de classe intacts lors de la minification de votre build de production. Ne pas le faire peut entraîner un comportement incorrect lors de l’utilisation de class-validator dans votre application.

const TerserPlugin = require('terser-webpack-plugin');
return {
...options,
externals: [],
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
keep_classnames: true,
},
}),
],
},
output: {
...options.output,
libraryTarget: 'commonjs2',
},
// ... le reste de la configuration
};

Utilisation de la fonctionnalité d’application autonome

Alternativement, si vous souhaitez garder votre fonction très légère et que vous n’avez besoin d’aucune fonctionnalité liée à HTTP (routage, mais aussi gardes, intercepteurs, pipes, etc.), vous pouvez utiliser NestFactory.createApplicationContext (comme mentionné précédemment) au lieu d’exécuter l’ensemble du serveur HTTP (et express en arrière-plan), comme suit :

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

Vous pourriez également passer l’objet event à , disons, un fournisseur EventsService qui pourrait le traiter et renvoyer une valeur correspondante (en fonction de la valeur d’entrée et de votre logique métier).

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