Kurs NestJS · Moduł 9: Deployment i infrastruktura

Scaling i Microservices - budowanie sieci legionów

5 min czytania
W tej lekcji3

Dowódco legionów! Jeden fort z jednym procesem Node.js obsługuje całą prowincję, a gdy ruch rośnie, procesor dobija do stu procent i fort pada. Konsul Caesar.js nauczy Cię, jak z jednego rzymskiego fortu zbudować sieć prowincji. Scaling i microservices to techniki budowania aplikacji, które rosną razem z ruchem i złożonością biznesową.

Horizontal vs Vertical Scaling

Skalowanie pionowe to większa maszyna: więcej rdzeni i pamięci dla tego samego serwera. Skalowanie poziome to więcej instancji za load balancerem. Node.js wykonuje JavaScript w jednym wątku, więc żeby wykorzystać wszystkie rdzenie jednej maszyny, moduł cluster uruchamia kilka procesów, które dzielą port:

1// cluster.mjs - kilka procesów Node.js na jednej maszynie
2import cluster from 'node:cluster';
3import { availableParallelism } from 'node:os';
4
5const numCPUs = availableParallelism();
6
7if (cluster.isPrimary) {
8  console.log(`Primary ${process.pid} is running`);
9
10  // Fork workers
11  for (let i = 0; i < numCPUs; i++) {
12    cluster.fork();
13  }
14
15  cluster.on('exit', (worker, code, signal) => {
16    console.log(`Worker ${worker.process.pid} died`);
17    cluster.fork(); // Restart worker
18  });
19} else {
20  // Workers can share any TCP port
21  import('./dist/main.js').then(() => {
22    console.log(`Worker ${process.pid} started`);
23  });
24}

cluster.isPrimary zastąpiło przestarzałe od Node.js 16 isMaster, a availableParallelism() to zalecany dziś sposób liczenia rdzeni. Proces główny rozdziela połączenia między workery po kolei: w teście sześć żądań trafiło do sześciu różnych procesów. Workery nie dzielą pamięci, więc sesje i cache muszą mieszkać poza procesem, np. w Redisie - dokumentacja Node.js wprost ostrzega przed trzymaniem ich w obiektach w pamięci.

W kontenerach polecam jeden proces na kontener i skalowanie liczby replik, a nie cluster w środku - orkiestrator lepiej pilnuje restartów niż pętla fork().

Microservices Architecture

Mikroserwisy dzielą aplikację na małe, niezależnie wdrażane usługi, jak legiony z własnymi dowódcami. Mają swoją cenę: każda granica to połączenie sieciowe, które może zawieść, i osobne wdrożenie do pilnowania, więc małemu zespołowi często lepiej służy dobrze podzielony monolit. W NestJS klienta innego mikroserwisu rejestruje ClientsModule:

1// app.module.ts - klienci mikroserwisów
2import { Module } from '@nestjs/common';
3import { ClientsModule, Transport } from '@nestjs/microservices';
4
5@Module({
6  imports: [
7    ClientsModule.register([
8      { name: 'TRIBUTE_SERVICE', transport: Transport.TCP, options: { host: 'tributes', port: 4001 } },
9      { name: 'LEGION_SERVICE', transport: Transport.TCP, options: { host: 'legions', port: 4002 } },
10    ]),
11  ],
12  controllers: [UserController],
13  providers: [UserService],
14})
15export class AppModule {}

Każdy klient ma nazwę, transport (tu TCP) i adres. Nazwa służy potem jako token wstrzykiwania:

1// User microservice - brama HTTP, która pyta inne mikroserwisy
2import { Controller, Get, Inject, Param } from '@nestjs/common';
3import { ClientProxy } from '@nestjs/microservices';
4import { firstValueFrom } from 'rxjs';
5
6@Controller('users')
7export class UserController {
8  constructor(
9    private userService: UserService,
10    @Inject('TRIBUTE_SERVICE') private tributeService: ClientProxy,
11    @Inject('LEGION_SERVICE') private legionService: ClientProxy,
12  ) {}
13
14  @Get(':id/tributes')
15  async getUserTributes(@Param('id') userId: string) {
16    const user = await this.userService.findById(userId);
17    const tributes = await firstValueFrom(
18      this.tributeService.send('get_user_tributes', { userId }),
19    );
20
21    return { user, tributes };
22  }
23}
24
25// Message patterns
26export const USER_PATTERNS = {
27  GET_USER: 'get_user',
28  CREATE_USER: 'create_user',
29  UPDATE_USER: 'update_user',
30};

ClientProxy.send() wysyła wiadomość i czeka na odpowiedź, zwracając Observable, który firstValueFrom() z RxJS zamienia w Promise. Pierwsza wersja używała .toPromise(), przestarzałego w RxJS 7, i nie wstrzykiwała userService. Kontroler nie jest w istocie mikroserwisem użytkowników, tylko bramą HTTP, która pyta inne usługi.

Po drugiej stronie odpowiada kontroler z @MessagePattern:

1// tribute.controller.ts - po stronie mikroserwisu tributów
2import { Controller } from '@nestjs/common';
3import { MessagePattern, Payload } from '@nestjs/microservices';
4
5@Controller()
6export class TributeController {
7  @MessagePattern('get_user_tributes')
8  getUserTributes(@Payload() data: { userId: string }) {
9    return [{ userId: data.userId, amount: 1000, province: 'Gallia' }];
10  }
11}

Wzorzec get_user_tributes musi się zgadzać po obu stronach. Taki serwis startuje przez NestFactory.createMicroservice() z tym samym transportem. send() to model zapytanie-odpowiedź; do powiadomień bez odpowiedzi służą emit() i @EventPattern.

Wdrażanie bez przestojów

Wiele instancji pozwala aktualizować aplikację bez przerwy. Blue-green deployment utrzymuje dwa identyczne środowiska i przełącza ruch ze starego na nowe - daje zero przestojów i natychmiastowy rollback przez przełączenie z powrotem, kosztem podwójnej infrastruktury. Wdrożenie bez przestojów przebiega tak:

  1. przygotuj nową wersję w równoległym środowisku,
  2. uruchom health checki i smoke testy,
  3. przełącz ruch na nową wersję,
  4. wyłącz starą wersję po weryfikacji.

Canary deployment stopniowo kieruje ruch do nowej wersji, zaczynając od małego procentu użytkowników:

  1. wdróż nową wersję dla małego procentu użytkowników,
  2. monitoruj metryki i błędy,
  3. stopniowo zwiększaj ruch,
  4. zrób pełny rollout albo rollback.

W Kubernetesie o tym, czy instancja dostaje ruch, decydują sondy:

1# deployment.yaml - fragment: strategia wdrożenia i sondy zdrowia
2spec:
3  replicas: 4
4  strategy:
5    type: RollingUpdate
6    rollingUpdate:
7      maxSurge: 1        # najwyżej 1 dodatkowy pod w trakcie wdrożenia
8      maxUnavailable: 0  # stary pod znika dopiero, gdy nowy jest gotowy
9  template:
10    spec:
11      containers:
12        - name: api
13          image: registry.imperium.rome/api:2.4.0
14          readinessProbe:        # czy przyjmować ruch?
15            httpGet:
16              path: /health/ready
17              port: 3000
18            initialDelaySeconds: 5
19            periodSeconds: 10
20            timeoutSeconds: 2
21            successThreshold: 2  # dwa udane sprawdzenia z rzędu
22            failureThreshold: 3  # trzy nieudane = pod wypada z ruchu
23          livenessProbe:         # czy proces żyje?
24            httpGet:
25              path: /health/live
26              port: 3000
27            periodSeconds: 10
28            failureThreshold: 3  # trzy nieudane = restart kontenera

Sonda readiness usuwa pod z ruchu, gdy nie jest gotowy, a liveness restartuje kontener, który przestał odpowiadać. successThreshold większy niż 1 wolno ustawić tylko dla readiness; domyślnie periodSeconds to 10, a failureThreshold - 3. maxUnavailable: 0 sprawia, że rolling update nigdy nie zmniejsza liczby gotowych podów.

W ostatniej lekcji modułu zbudujemy blue-green, rolling update i feature flagi w kodzie.

Pamiętaj: siła Imperium nie leżała w jednym wielkim forcie, tylko w sieci legionów, które mogły się wzajemnie zastąpić -, buduj aplikację tak samo.

Kod do tej lekcji: src/scaling.ts
1// Scaling i Microservices - Budowanie Legionu Kohort
2
3// 1. Vertical vs Horizontal Scaling
4// Vertical: wiekszy serwer (wiecej CPU/RAM)
5// Horizontal: wiecej instancji (load balancing)
6
7// 2. NestJS Microservices
8import { Controller } from '@nestjs/common';
9// import { MessagePattern, EventPattern } from '@nestjs/microservices';
10
11// Microservice - TributeService
12// @Controller()
13// class TributeController {
14//   // Request-Response pattern
15//   @MessagePattern({ cmd: 'get_tribute' })
16//   getTribute(data: { provinceId: string }) {
17//     return { province: data.provinceId, amount: 1000 };
18//   }
19//
20//   // Event pattern (fire and forget)
21//   @EventPattern('tribute_collected')
22//   handleTributeCollected(data: { province: string; amount: number }) {
23//     console.log(`Tribute collected from ${data.province}`);
24//   }
25// }
26
27// 3. Komunikacja miedzy serwisami
28// TCP Transport (domyslny):
29// const app = await NestFactory.createMicroservice(AppModule, {
30//   transport: Transport.TCP,
31//   options: { host: '0.0.0.0', port: 3001 },
32// });
33
34// Redis Transport:
35// const app = await NestFactory.createMicroservice(AppModule, {
36//   transport: Transport.REDIS,
37//   options: { host: 'localhost', port: 6379 },
38// });
39
40// 4. Docker Compose dla microservices
41const dockerCompose = `
42services:
43  api-gateway:
44    build: ./gateway
45    ports: ['3000:3000']
46    depends_on: [tribute-service, legion-service]
47
48  tribute-service:
49    build: ./tribute-service
50    ports: ['3001:3001']
51
52  legion-service:
53    build: ./legion-service
54    ports: ['3002:3002']
55
56  redis:
57    image: redis:7-alpine
58    ports: ['6379:6379']
59
60  mongodb:
61    image: mongo:7
62    ports: ['27017:27017']
63    volumes: [mongo-data:/data/db]
64
65volumes:
66  mongo-data:
67`;
68
69// 5. Wzorce architektury
70// - API Gateway: jeden punkt wejscia
71// - Service Discovery: automatyczne wykrywanie serwisow
72// - Circuit Breaker: ochrona przed kaskadowymi awariami
73// - Saga Pattern: transakcje rozproszone
74// - CQRS: oddzielenie odczytu od zapisu
75

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Blue-green deployment strategy oferuje:

  2. 2. Canary deployment polega na:

Zadania praktyczne w grze

  • Edytor kodu

    Napisz konfigurację deployment z readiness probe i liveness probe

  • Układanie w pionie

    Ułóż kroki procesu canary deployment:

  • Układanie w pionie

    Uporządkuj kroki zero-downtime deployment:

Przydatne artykuły