Serverless computing (điện toán không máy chủ) là một mô hình thực thi điện toán đám mây, trong đó nhà cung cấp cloud cấp phát tài nguyên máy tính theo nhu cầu (on-demand), tự lo phần server thay cho khách hàng. Khi một app không được sử dụng, sẽ không có tài nguyên tính toán nào được cấp phát cho app đó. Chi phí được tính dựa trên lượng tài nguyên thực tế mà application tiêu thụ (nguồn).
Với kiến trúc serverless, bạn chỉ tập trung thuần túy vào các function riêng lẻ trong code application của mình. Các dịch vụ như AWS Lambda, Google Cloud Functions, và Microsoft Azure Functions lo hết phần phần cứng vật lý, hệ điều hành máy ảo, và quản lý phần mềm web server.
Gợi ý Chương này không đề cập đến ưu nhược điểm của serverless function, cũng không đi sâu vào chi tiết riêng của từng nhà cung cấp cloud.
Cold start (khởi động nguội)
Cold start là lần đầu tiên code của bạn được thực thi sau một khoảng thời gian không hoạt động. Tùy vào nhà cung cấp cloud bạn dùng, nó có thể bao gồm nhiều bước khác nhau, từ việc tải code, bootstrap runtime, cho đến khi thực sự chạy code của bạn. Quá trình này gây ra độ trễ đáng kể, tùy thuộc vào nhiều yếu tố như ngôn ngữ lập trình, số lượng package mà application của bạn cần, v.v.
Cold start là một vấn đề quan trọng, và mặc dù có những thứ nằm ngoài tầm kiểm soát của chúng ta, vẫn còn rất nhiều việc chúng ta có thể làm về phía mình để rút ngắn nó nhất có thể.
Mặc dù bạn có thể nghĩ Nest là một framework đầy đủ tính năng, được thiết kế để dùng trong các application doanh nghiệp phức tạp, nó cũng phù hợp với những application “đơn giản” hơn nhiều (hoặc script). Ví dụ, với tính năng Standalone applications, bạn có thể tận dụng hệ thống DI của Nest trong các worker đơn giản, CRON job, CLI, hoặc serverless function.
Benchmarks (đo hiệu năng)
Để hiểu rõ hơn chi phí khi dùng Nest hay các thư viện nổi tiếng khác (như express) trong bối cảnh serverless function, hãy so sánh thời gian Node runtime cần để chạy các script sau:
// #1 Express
import 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));
}
await bootstrap();
// #2 Nest (với @nestjs/platform-express)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { logger: ['error'] });
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
// #3 Nest dưới dạng Standalone application (không có HTTP server)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { AppService } from './app.service.js';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error'],
});
console.log(app.get(AppService).getHello());
}
await bootstrap();
// #4 Script Node.js thuần
async function bootstrap() {
console.log('Hello world!');
}
await bootstrap();
Với tất cả các script này, chúng ta dùng trình biên dịch tsc (TypeScript) nên code vẫn ở dạng chưa được bundle (không dùng webpack).
| Express | 0.0079s (7.9ms) |
Nest với @nestjs/platform-express |
0.1974s (197.4ms) |
| Nest (standalone application) | 0.1117s (111.7ms) |
| Script Node.js thuần | 0.0071s (7.1ms) |
Lưu ý Máy đo: MacBook Pro Mid 2014, 2.5 GHz Quad-Core Intel Core i7, 16 GB 1600 MHz DDR3, SSD.
Bây giờ, hãy lặp lại tất cả benchmark này nhưng lần này dùng webpack (nếu bạn đã cài Nest CLI, bạn có thể chạy nest build --webpack) để bundle application thành một file JavaScript thực thi duy nhất.
Tuy nhiên, thay vì dùng cấu hình webpack mặc định mà Nest CLI cung cấp sẵn, chúng ta sẽ đảm bảo bundle tất cả dependency (node_modules) lại cùng nhau, như sau:
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;
},
}),
],
};
};
Gợi ý Để chỉ định Nest CLI dùng cấu hình này, tạo một file webpack.config.js mới ở thư mục gốc của project.
Với cấu hình này, chúng ta nhận được kết quả sau:
| Express | 0.0068s (6.8ms) |
Nest với @nestjs/platform-express |
0.0815s (81.5ms) |
| Nest (standalone application) | 0.0319s (31.9ms) |
| Script Node.js thuần | 0.0066s (6.6ms) |
Lưu ý Máy đo: MacBook Pro Mid 2014, 2.5 GHz Quad-Core Intel Core i7, 16 GB 1600 MHz DDR3, SSD.
Gợi ý Bạn còn có thể tối ưu hơn nữa bằng cách áp dụng thêm các kỹ thuật minify & tối ưu code (dùng plugin webpack, v.v.).
Như bạn thấy, cách bạn biên dịch (và có bundle code hay không) đóng vai trò then chốt và có ảnh hưởng đáng kể đến tổng thời gian khởi động. Với webpack, bạn có thể đưa thời gian bootstrap của một Nest standalone application (starter project với một module, controller, và service) xuống còn ~32ms trung bình, và xuống ~81.5ms cho một app NestJS HTTP thông thường dựa trên express.
Với các Nest application phức tạp hơn, ví dụ với 10 resource (được sinh ra qua schematic $ nest g resource = 10 module, 10 controller, 10 service, 20 class DTO, 50 HTTP endpoint + AppModule), tổng thời gian khởi động trên MacBook Pro Mid 2014, 2.5 GHz Quad-Core Intel Core i7, 16 GB 1600 MHz DDR3, SSD là khoảng 0.1298s (129.8ms). Dù sao, việc chạy một application monolithic dưới dạng serverless function thường không hợp lý lắm, nên hãy xem benchmark này như một ví dụ cho thấy thời gian bootstrap có thể tăng lên thế nào khi application của bạn lớn dần.
Ghi chú người dịch Con số ~197ms cho Nest với Express nghe có vẻ chậm so với ~8ms của Express thuần, nhưng phần lớn đó là chi phí một lần cho việc Nest dùng reflect-metadata để scan decorator, dựng DI container và resolve toàn bộ cây phụ thuộc lúc bootstrap — không phải overhead trên từng request. Sau khi app đã “ấm” (warm), tốc độ xử lý request của Nest và Express gần như tương đương vì cùng dùng chung HTTP engine bên dưới.
Tối ưu runtime
Cho đến giờ chúng ta đã nói về tối ưu ở thời điểm biên dịch (compile-time). Những tối ưu này không liên quan đến cách bạn định nghĩa provider và load Nest module trong application, và điều đó lại đóng vai trò thiết yếu khi application của bạn lớn dần.
Ví dụ, hãy tưởng tượng bạn có một kết nối database được định nghĩa dưới dạng async provider. Async provider được thiết kế để trì hoãn việc khởi động application cho đến khi một hoặc nhiều tác vụ bất đồng bộ hoàn tất. Điều đó có nghĩa là, nếu serverless function của bạn trung bình cần 2s để kết nối tới database (lúc bootstrap), endpoint của bạn sẽ cần thêm ít nhất hai giây (vì nó phải chờ cho đến khi kết nối được thiết lập) để trả response về (khi đó là cold start và application của bạn chưa chạy sẵn).
Như bạn thấy, cách bạn cấu trúc provider có phần khác biệt trong một môi trường serverless, nơi thời gian bootstrap là quan trọng. Một ví dụ hay khác là nếu bạn dùng Redis để cache, nhưng chỉ trong một số tình huống nhất định. Có lẽ trong trường hợp này, bạn không nên định nghĩa kết nối Redis dưới dạng async provider, vì nó sẽ làm chậm thời gian bootstrap, ngay cả khi nó không cần thiết cho lần gọi function cụ thể này.
Ngoài ra, đôi khi bạn có thể lazy load toàn bộ module, dùng class LazyModuleLoader, như đã mô tả trong chương này. Caching cũng là một ví dụ tuyệt vời ở đây.
Hãy tưởng tượng application của bạn có, chẳng hạn, CacheModule — module này bên trong kết nối tới Redis, và cũng export CacheService để tương tác với storage Redis. Nếu bạn không cần nó cho tất cả các lần gọi function tiềm năng,
bạn có thể load nó theo yêu cầu, một cách lazy. Nhờ vậy bạn sẽ có thời gian khởi động nhanh hơn (khi xảy ra cold start) cho tất cả các lần gọi không cần dùng caching.
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);
}
Một ví dụ tuyệt vời khác là webhook hoặc worker, tùy vào một số điều kiện cụ thể (ví dụ: tham số đầu vào) mà có thể thực hiện các thao tác khác nhau. Trong trường hợp này, bạn có thể chỉ định một điều kiện bên trong route handler để lazy load module phù hợp cho lần gọi function cụ thể, và load mọi module khác theo kiểu lazy.
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);
// ...
}
Ghi chú người dịch Nếu đã dùng Golang, LazyModuleLoader ở đây có tinh thần khá giống việc bạn tự viết sync.Once để khởi tạo một dependency nặng (ví dụ kết nối Redis) chỉ khi nó thực sự được gọi đến lần đầu, thay vì khởi tạo hết mọi thứ ngay trong main(). Điểm khác biệt là Nest tích hợp việc này vào hệ thống DI, nên module lazy-load vẫn nhận đầy đủ phụ thuộc (dependency) đã được inject như module thường.
Ví dụ tích hợp
Cách mà entry file của application (thường là file main.ts) trông như thế nào phụ thuộc vào nhiều yếu tố, nên không có một template duy nhất hoạt động được cho mọi tình huống.
Ví dụ, file khởi tạo cần thiết để chạy serverless function của bạn thay đổi tùy theo nhà cung cấp cloud (AWS, Azure, GCP, v.v.).
Ngoài ra, tùy vào việc bạn muốn chạy một HTTP application thông thường với nhiều route/endpoint hay chỉ cung cấp một route duy nhất (hoặc thực thi một đoạn code cụ thể),
code của application bạn sẽ trông khác nhau (ví dụ, với cách tiếp cận endpoint-per-function, bạn có thể dùng NestFactory.createApplicationContext thay vì khởi động HTTP server, cấu hình middleware, v.v.).
Chỉ để minh họa, chúng ta sẽ tích hợp Nest (dùng @nestjs/platform-express, nên sẽ khởi động toàn bộ HTTP router đầy đủ chức năng)
với framework Serverless (trong trường hợp này, nhắm tới AWS Lambda). Như đã đề cập trước đó, code của bạn sẽ khác nhau tùy vào nhà cung cấp cloud bạn chọn, và nhiều yếu tố khác.
Trước tiên, hãy cài đặt các package cần thiết:
$ npm i @codegenie/serverless-express aws-lambda
$ npm i -D @types/aws-lambda serverless-offline
Gợi ý Để tăng tốc chu trình phát triển, chúng ta cài đặt plugin serverless-offline, plugin này giả lập AWS λ và API Gateway.
Sau khi cài đặt xong, hãy tạo file serverless.yml để cấu hình 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+}'
Gợi ý Để tìm hiểu thêm về framework Serverless, truy cập tài liệu chính thức.
Với cấu hình này, giờ chúng ta có thể chuyển sang file main.ts và cập nhật code bootstrap với đoạn boilerplate cần thiết:
import { NestFactory } from '@nestjs/core';
import serverlessExpress from '@codegenie/serverless-express';
import { Callback, Context, Handler } from 'aws-lambda';
import { AppModule } from './app.module.js';
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);
};
Gợi ý Để tạo nhiều serverless function và chia sẻ các module chung giữa chúng, chúng tôi khuyến nghị dùng chế độ CLI Monorepo.
Cảnh báo Nếu bạn dùng package @nestjs/swagger, việc phục vụ Swagger UI từ một serverless function cần thêm một bước. Đằng sau API Gateway, originalUrl của request có thể thiếu dấu / ở cuối mà Swagger UI mong đợi, gây ra vòng lặp redirect vô hạn. Hãy đăng ký một middleware để khôi phục nó trước khi gọi SwaggerModule.setup():
app.use((req, res, next) => {
if (req.originalUrl === '/swagger') {
req.originalUrl = '/swagger/';
}
next();
});
Tiếp theo, mở file tsconfig.json và đảm bảo bật tùy chọn esModuleInterop để package @codegenie/serverless-express load đúng cách.
{
"compilerOptions": {
...
"esModuleInterop": true
}
}
Bây giờ chúng ta có thể build application (bằng nest build hoặc tsc) và dùng CLI serverless để khởi động lambda function ở local:
$ npm run build
$ npx serverless offline
Sau khi application đã chạy, mở trình duyệt và truy cập http://localhost:3000/dev/[ANY_ROUTE] (trong đó [ANY_ROUTE] là bất kỳ endpoint nào đã đăng ký trong application của bạn).
Ở các phần trên, chúng ta đã cho thấy việc dùng webpack và bundle app có thể ảnh hưởng đáng kể đến tổng thời gian bootstrap.
Tuy nhiên, để nó hoạt động với ví dụ của chúng ta, có một vài cấu hình bổ sung bạn phải thêm vào file webpack.config.js. Nhìn chung,
để đảm bảo function handler của chúng ta được nhận diện, chúng ta phải đổi thuộc tính output.libraryTarget thành commonjs2.
return {
...options,
externals: [],
output: {
...options.output,
libraryTarget: 'commonjs2',
},
// ... phần còn lại của cấu hình
};
Với cấu hình này, giờ bạn có thể dùng $ nest build --webpack để biên dịch code của function (rồi dùng $ npx serverless offline để test).
Cũng nên (nhưng không bắt buộc vì nó sẽ làm chậm quá trình build) cài đặt package terser-webpack-plugin và ghi đè cấu hình của nó để giữ nguyên tên class khi minify bản build production. Nếu không làm vậy, có thể dẫn đến hành vi sai khi dùng class-validator trong application của bạn.
const TerserPlugin = require('terser-webpack-plugin');
return {
...options,
externals: [],
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
keep_classnames: true,
},
}),
],
},
output: {
...options.output,
libraryTarget: 'commonjs2',
},
// ... phần còn lại của cấu hình
};
Ghi chú người dịch Lý do phải giữ nguyên tên class (keep_classnames) khi minify: class-validator và một số cơ chế của Nest dựa vào reflect-metadata, vốn gắn metadata theo tham chiếu class/constructor thực tế. Nếu minifier đổi tên class (mangle) để rút gọn code, metadata này có thể không còn khớp với class đã bị đổi tên, dẫn đến validate sai hoặc DI resolve nhầm — một lỗi khó debug vì code vẫn chạy được, chỉ sai logic.
Dùng tính năng standalone application
Ngoài ra, nếu bạn muốn giữ function của mình thật nhẹ và không cần bất kỳ tính năng liên quan đến HTTP nào (routing, cũng như guard, interceptor, pipe, v.v.),
bạn có thể chỉ cần dùng NestFactory.createApplicationContext (như đã đề cập trước đó) thay vì chạy toàn bộ HTTP server (và express bên dưới), như sau:
import { HttpStatus } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Callback, Context, Handler } from 'aws-lambda';
import { AppModule } from './app.module.js';
import { AppService } from './app.service.js';
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,
};
};
Gợi ý Cần lưu ý rằng NestFactory.createApplicationContext không bọc các phương thức controller bằng enhancer (guard, interceptor, v.v.). Để làm được điều đó, bạn phải dùng phương thức NestFactory.create.
Bạn cũng có thể truyền đối tượng event xuống, chẳng hạn, provider EventsService để xử lý nó và trả về giá trị tương ứng (tùy vào giá trị đầu vào và logic nghiệp vụ của bạn).
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);
};
Ghi chú người dịch createApplicationContext giống với việc chỉ dựng lên IoC container (DI container) mà không kèm bất kỳ router HTTP nào — nếu bạn từng dùng Go và tự tay wiring dependency qua constructor (không có framework HTTP nào bọc ngoài), đây tương đương với việc bạn chỉ khởi tạo các struct/service và gọi thẳng method của chúng, bỏ qua hoàn toàn tầng transport.