Kurs NestJS · Moduł 8: Cache i wydajność

Load Balancing - rozłożenie obciążenia legionu

10 min czytania
W tej lekcji5

Dowódco legionów! Konsul Caesar.js zauważył, że jeden fort wykonuje całą pracę, podczas gdy inne stoją bezczynnie w garnizonie. Gdy nadchodzi fala meldunków, ten jeden fort pada, a razem z nim cała aplikacja. Czas nauczyć się load balancingu - sztuki rozkładania obciążenia między wszystkie oddziały armii.

Czym jest load balancing w świecie legionariuszy?

Wyobraź sobie armię rzymskich legionów:

  • jeden fort obsługuje wszystkich klientów = przeciążenie,
  • oddziały stoją bezczynnie = marnotrawstwo zasobów,
  • nierównomierne obciążenie = niektóre forty padają pod ciężarem zadań,
  • brak redundancji = gdy główny fort upadnie, cała armia staje.

Load balancer to mądry legat, który rozdziela zadania między forty. W praktyce tę rolę pełni zwykle gotowy serwer przed aplikacją, jak Nginx, HAProxy albo balancer chmury, który często przejmuje też HTTPS i kompresję odpowiedzi. Gdy nikt przed aplikacją nie kompresuje, w NestJS robi to middleware app.use(compression()), zmniejszając rozmiar wysyłanych odpowiedzi HTTP - wrócimy do niego w lekcji o optymalizacji odpowiedzi. Poniżej budujemy balancer w samym NestJS, żeby zobaczyć, jak działają algorytmy.

Strona instancji: endpoint /health

Balancer musi wiedzieć, które forty żyją. Każda instancja wystawia więc prosty endpoint zdrowia:

1// health.controller.ts - na każdej instancji
2import { Controller, Get } from '@nestjs/common';
3
4@Controller('health')
5export class HealthController {
6  @Get()
7  check() {
8    const memory = process.memoryUsage();
9    return {
10      status: 'ok',
11      uptime: Math.round(process.uptime()),
12      heapUsedMB: Math.round(memory.heapUsed / 1024 / 1024),
13      rssMB: Math.round(memory.rss / 1024 / 1024),
14    };
15  }
16}

process.uptime() podaje sekundy od startu procesu, a process.memoryUsage() zużycie pamięci. Czas odpowiedzi mierzy sam balancer: jeśli /health odpowiada wolno albo wcale, fort wypada z rotacji.

Application Load Balancing

Serwis balancera trzyma listę serwerów opisanych interfejsem ServerInstance: adres, waga, bieżące obciążenie i stan zdrowia:

1// load-balancer.service.ts
2import { Injectable, OnModuleInit } from '@nestjs/common';
3import { ConfigService } from '@nestjs/config';
4import { Cron } from '@nestjs/schedule';
5
6interface ServerInstance {
7  id: string;
8  host: string;
9  port: number;
10  weight: number;
11  currentLoad: number;
12  isHealthy: boolean;
13  lastHealthCheck: Date;
14}
15
16@Injectable()
17export class LoadBalancerService implements OnModuleInit {
18  private servers: ServerInstance[] = [];
19  private currentIndex = 0;
20
21  constructor(private configService: ConfigService) {
22    this.initializeServers();
23  }
24
25  async onModuleInit() {
26    await this.startHealthChecks(); // pierwszy przegląd od razu po starcie
27  }
28
29  private initializeServers(): void {
30    // LEGION_SERVERS to JSON z listą serwerów, np. [{"host":"...","port":3000,"weight":10}]
31    const fromEnv = this.configService.get<string>('LEGION_SERVERS');
32    const serverConfigs = fromEnv ? JSON.parse(fromEnv) : [
33      { host: 'cohort-1.legion.local', port: 3000, weight: 10 },
34      { host: 'cohort-2.legion.local', port: 3000, weight: 15 },
35      { host: 'cohort-3.legion.local', port: 3000, weight: 8 },
36    ];
37
38    this.servers = serverConfigs.map((config, index) => ({
39      id: `cohort-${index + 1}`,
40      ...config,
41      currentLoad: 0,
42      isHealthy: true,
43      lastHealthCheck: new Date(),
44    }));
45  }

Listę czytamy ze zmiennej LEGION_SERVERS, a zmienne środowiskowe są tekstem, więc najpierw JSON.parse(). Pierwszy przegląd zdrowia rusza w onModuleInit(); pierwotna wersja odpalała go w konstruktorze, który nie może na nic czekać.

Dwa najprostsze algorytmy wyboru:

1  // Round Robin - kolejno każdy fort
2  getNextServerRoundRobin(): ServerInstance | null {
3    const healthyServers = this.servers.filter(s => s.isHealthy);
4    if (healthyServers.length === 0) return null;
5
6    const server = healthyServers[this.currentIndex % healthyServers.length];
7    this.currentIndex++;
8
9    console.log(`Wybrano fort: ${server.id} (Round Robin)`);
10    return server;
11  }
12
13  // Weighted random - losowanie z wagami: silniejsza kohorta częściej
14  getNextServerWeighted(): ServerInstance | null {
15    const healthyServers = this.servers.filter(s => s.isHealthy);
16    if (healthyServers.length === 0) return null;
17
18    const totalWeight = healthyServers.reduce((sum, s) => sum + s.weight, 0);
19    let randomWeight = Math.random() * totalWeight;
20
21    for (const server of healthyServers) {
22      randomWeight -= server.weight;
23      if (randomWeight <= 0) {
24        console.log(`Wybrano fort: ${server.id} (Weighted)`);
25        return server;
26      }
27    }
28
29    return healthyServers[0];
30  }

Round Robin bierze forty po kolei. Druga metoda to losowanie z wagami, a nie „Weighted Round Robin”, jak twierdził stary komentarz: w teście na 2500 losowań wagi 10 i 15 dały 986 i 1514 wyborów.

Dwa kolejne patrzą na obciążenie:

1  // Least Connections - najmniej obciążony fort
2  getNextServerLeastConnections(): ServerInstance | null {
3    const healthyServers = this.servers.filter(s => s.isHealthy);
4    if (healthyServers.length === 0) return null;
5
6    const leastLoaded = healthyServers.reduce((min, server) =>
7      server.currentLoad < min.currentLoad ? server : min
8    );
9
10    console.log(`Wybrano fort: ${leastLoaded.id} (Least Connections: ${leastLoaded.currentLoad})`);
11    return leastLoaded;
12  }
13
14  // Resource-based - na podstawie zasobów systemowych
15  async getNextServerResourceBased(): Promise<ServerInstance | null> {
16    const healthyServers = this.servers.filter(s => s.isHealthy);
17    if (healthyServers.length === 0) return null;
18
19    // Pobierz metryki zasobów dla każdej kohorty
20    const serversWithMetrics = await Promise.all(
21      healthyServers.map(async (server) => {
22        const metrics = await this.getServerMetrics(server);
23        return {
24          ...server,
25          cpuUsage: metrics.cpu,
26          memoryUsage: metrics.memory,
27          responseTime: metrics.avgResponseTime,
28          score: this.calculateServerScore(metrics),
29        };
30      })
31    );
32
33    // Wybierz fort z najniższym score (najmniej obciążony)
34    const bestServer = serversWithMetrics.reduce((best, server) =>
35      server.score < best.score ? server : best
36    );
37
38    console.log(`Wybrano fort: ${bestServer.id} (Resource-based, score: ${bestServer.score})`);
39    return bestServer;
40  }

Least Connections wybiera fort z najmniejszą liczbą trwających żądań. Wariant zasobowy pyta każdy serwer o metryki przy każdym żądaniu, co kosztuje - w produkcji metryki odświeża się w tle i trzyma w pamięci.

Punktacja, liczniki i pobieranie metryk:

1  private calculateServerScore(metrics: any): number {
2    // Prosta formuła: wyższa wartość = gorszy serwer
3    return (
4      metrics.cpu * 0.4 + // 40% wagi dla CPU
5      metrics.memory * 0.3 + // 30% wagi dla pamięci
6      metrics.avgResponseTime * 0.2 + // 20% wagi dla czasu odpowiedzi
7      metrics.activeConnections * 0.1 // 10% wagi dla połączeń
8    );
9  }
10
11  async incrementLoad(serverId: string): Promise<void> {
12    const server = this.servers.find(s => s.id === serverId);
13    if (server) {
14      server.currentLoad++;
15    }
16  }
17
18  async decrementLoad(serverId: string): Promise<void> {
19    const server = this.servers.find(s => s.id === serverId);
20    if (server && server.currentLoad > 0) {
21      server.currentLoad--;
22    }
23  }
24
25  private async getServerMetrics(server: ServerInstance): Promise<any> {
26    try {
27      // HTTP call do endpointu z metrykami, najwyżej 2 sekundy
28      const response = await fetch(`http://${server.host}:${server.port}/health/metrics`, {
29        signal: AbortSignal.timeout(2000),
30      });
31      return await response.json();
32    } catch (error) {
33      // Brak odpowiedzi = najgorsze możliwe metryki, fort wybierzemy na końcu
34      return {
35        cpu: 100,
36        memory: 100,
37        avgResponseTime: 10000,
38        activeConnections: 1000,
39      };
40    }
41  }

Wynik miesza procenty z milisekundami, więc czas odpowiedzi łatwo zdominuje resztę - przed ważeniem warto sprowadzić metryki do wspólnej skali. Przy braku odpowiedzi zwracamy najgorsze możliwe metryki; pierwsza wersja losowała je, więc martwy fort mógł wygrać. AbortSignal.timeout(2000) przerywa zbyt długie wywołanie, bo natywny fetch z Node.js nie zna opcji timeout.

Przegląd zdrowia powtarza się co 30 sekund:

1  @Cron('*/30 * * * * *') // Co 30 sekund
2  private async startHealthChecks(): Promise<void> {
3    for (const server of this.servers) {
4      try {
5        const response = await fetch(`http://${server.host}:${server.port}/health`, {
6          signal: AbortSignal.timeout(5000),
7        });
8
9        server.isHealthy = response.ok;
10        server.lastHealthCheck = new Date();
11
12        if (!server.isHealthy) {
13          console.warn(`Kohorta ${server.id} nie odpowiada!`);
14        }
15      } catch (error) {
16        server.isHealthy = false;
17        server.lastHealthCheck = new Date();
18        console.error(`Kohorta ${server.id} nieosiągalna:`, error.message);
19      }
20    }
21  }
22
23  getLegionStatus() {
24    return {
25      totalServers: this.servers.length,
26      healthyServers: this.servers.filter(s => s.isHealthy).length,
27      totalLoad: this.servers.reduce((sum, s) => sum + s.currentLoad, 0),
28      servers: this.servers.map(s => ({
29        id: s.id,
30        host: s.host,
31        isHealthy: s.isHealthy,
32        currentLoad: s.currentLoad,
33        weight: s.weight,
34        lastHealthCheck: s.lastHealthCheck,
35      })),
36    };
37  }
38}

Dekorator @Cron pochodzi z @nestjs/schedule i działa dopiero po zaimportowaniu ScheduleModule.forRoot(). Wzorzec ma sześć pól, pierwsze to sekundy, więc */30 * * * * * znaczy co 30 sekund.

Proxy Load Balancer Implementation

Kontroler przyjmuje każde żądanie pod /api/proxy i przekazuje je do wybranego fortu przez http-proxy-middleware:

1// proxy-load-balancer.controller.ts
2import { All, Controller, Req, Res } from '@nestjs/common';
3import type { Request, Response } from 'express';
4import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware';
5import { LoadBalancerService } from './load-balancer.service';
6
7@Controller('/api/proxy')
8export class ProxyLoadBalancerController {
9  constructor(private loadBalancer: LoadBalancerService) {}
10
11  @All('*path')
12  async proxyRequest(@Req() req: Request, @Res() res: Response): Promise<void> {
13    // Wybierz serwer za pomocą load balancera
14    const targetServer = await this.loadBalancer.getNextServerResourceBased();
15
16    if (!targetServer) {
17      res.status(503).json({
18        error: 'Wszystkie kohorty legionu są niedostępne!',
19        code: 'LEGION_UNAVAILABLE'
20      });
21      return;
22    }
23
24    // Zwiększ licznik obciążenia
25    await this.loadBalancer.incrementLoad(targetServer.id);
26
27    // Utwórz proxy do wybranego serwera
28    const proxy = createProxyMiddleware<Request, Response>({
29      target: `http://${targetServer.host}:${targetServer.port}`,
30      changeOrigin: true,
31      pathRewrite: {
32        '^/api/proxy': '', // Usuń prefix
33      },
34      on: {
35        proxyReq: (proxyReq, req) => {
36          // Dodaj informacje o load balancerze
37          proxyReq.setHeader('X-Proxy-Server', targetServer.id);
38          proxyReq.setHeader('X-Load-Balancer', 'legionariusze-legion-lb');
39          console.log(`Przekierowanie ${req.method} ${req.url} do ${targetServer.id}`);
40          // NestJS przeczytał już body - odtwórz je na końcu, po nagłówkach
41          fixRequestBody(proxyReq, req);
42        },
43        proxyRes: async (proxyRes) => {
44          // Zmniejsz licznik obciążenia po zakończeniu
45          await this.loadBalancer.decrementLoad(targetServer.id);
46          console.log(`Odpowiedź z ${targetServer.id}: ${proxyRes.statusCode}`);
47        },
48        error: async (err) => {
49          await this.loadBalancer.decrementLoad(targetServer.id);
50          console.error(`Błąd proxy do ${targetServer.id}:`, err.message);
51
52          if (!res.headersSent) {
53            res.status(502).json({
54              error: 'Błąd komunikacji z fortem',
55              server: targetServer.id,
56              details: err.message
57            });
58          }
59        },
60      },
61    });
62
63    proxy(req, res);
64  }
65}

Kod jest dostosowany do http-proxy-middleware 4: zdarzenia podaje się w obiekcie on, a stare opcje onProxyReq i spółka już nie działają. fixRequestBody jest konieczne, bo NestJS przeczytał już ciało żądania - bez niego POST w teście wisiał bez odpowiedzi. Woła się je na końcu, bo zapisuje ciało, a po nim nagłówków ustawić nie można. '*path' to nazwany wildcard wymagany od Express 5, a typy Express importujemy przez import type, czego TypeScript wymaga przy włączonych isolatedModules i emitDecoratorMetadata.

Tworzenie nowego proxy przy każdym żądaniu jest rozrzutne; opcja router pozwala jednemu proxy wybierać cel dla każdego żądania.

Circuit Breaker Pattern

Każdy serwer dostaje własny bezpiecznik, trzymany w mapie:

1// circuit-breaker.service.ts
2@Injectable()
3export class CircuitBreakerService {
4  private breakers = new Map<string, CircuitBreaker>();
5
6  getOrCreateBreaker(serverId: string): CircuitBreaker {
7    if (!this.breakers.has(serverId)) {
8      this.breakers.set(serverId, new CircuitBreaker({
9        serverId,
10        failureThreshold: 5, // 5 błędów
11        recoveryTimeout: 30000, // 30 sekund
12        monitoringPeriod: 10000, // 10 sekund
13      }));
14    }
15    return this.breakers.get(serverId)!;
16  }
17
18  async executeWithBreaker<T>(
19    serverId: string,
20    operation: () => Promise<T>
21  ): Promise<T> {
22    const breaker = this.getOrCreateBreaker(serverId);
23    return await breaker.execute(operation);
24  }
25
26  getBreakerStatus(serverId: string) {
27    const breaker = this.breakers.get(serverId);
28    return breaker ? breaker.getStatus() : null;
29  }
30
31  getAllBreakersStatus() {
32    const status = {};
33    this.breakers.forEach((breaker, serverId) => {
34      status[serverId] = breaker.getStatus();
35    });
36    return status;
37  }
38}

getOrCreateBreaker() zakłada bezpiecznik przy pierwszym użyciu, a executeWithBreaker() przepuszcza przez niego operację, więc awaria jednego fortu nie odcina pozostałych. Sam bezpiecznik liczy błędy danego serwera:

1class CircuitBreaker {
2  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
3  private failures = 0;
4  private lastFailureTime?: Date;
5  private successCount = 0;
6
7  constructor(private config: {
8    serverId: string;
9    failureThreshold: number;
10    recoveryTimeout: number;
11    monitoringPeriod: number;
12  }) {}
13
14  async execute<T>(operation: () => Promise<T>): Promise<T> {
15    if (this.state === 'OPEN') {
16      if (this.shouldAttemptReset()) {
17        this.state = 'HALF_OPEN';
18        console.log(`Circuit breaker dla ${this.config.serverId}: próba resetu`);
19      } else {
20        throw new Error(`Circuit breaker OPEN dla serwera ${this.config.serverId}`);
21      }
22    }
23
24    try {
25      const result = await operation();
26      this.onSuccess();
27      return result;
28    } catch (error) {
29      this.onFailure();
30      throw error;
31    }
32  }
33
34  private onSuccess(): void {
35    this.failures = 0;
36    this.successCount++;
37
38    if (this.state === 'HALF_OPEN') {
39      this.state = 'CLOSED';
40      console.log(`Circuit breaker dla ${this.config.serverId}: przywrócony`);
41    }
42  }
43
44  private onFailure(): void {
45    this.failures++;
46    this.lastFailureTime = new Date();
47
48    if (this.failures >= this.config.failureThreshold) {
49      this.state = 'OPEN';
50      console.log(`Circuit breaker dla ${this.config.serverId}: OTWORZONY`);
51    }
52  }
53
54  private shouldAttemptReset(): boolean {
55    if (!this.lastFailureTime) return false;
56
57    const timeSinceLastFailure = Date.now() - this.lastFailureTime.getTime();
58    return timeSinceLastFailure >= this.config.recoveryTimeout;
59  }
60
61  getStatus() {
62    return {
63      serverId: this.config.serverId,
64      state: this.state,
65      failures: this.failures,
66      successCount: this.successCount,
67      lastFailureTime: this.lastFailureTime,
68    };
69  }
70}

Po pięciu błędach z rzędu obwód się otwiera, a sukces zeruje licznik. monitoringPeriod czeka na okno czasowe, którego ta wersja jeszcze nie liczy.

Polecam Ci podział ról: ruchem niech steruje Nginx albo balancer chmury, a aplikacja niech uczciwie odpowiada na /health. W następnej lekcji zajrzymy do magazynów pamięci.

Pamiętaj: dobry legat nie wysyła wszystkich meldunków do jednego fortu - rozkłada je według sił i omija forty, które milczą.

Kod do tej lekcji: src/load-balancing.ts
1// Load Balancing - Rozlozenie Obciazenia Legionu
2
3// 1. Nginx jako load balancer (konfiguracja)
4const nginxConfig = `
5upstream roman_legion {
6    # Round Robin (domyslny) - po kolei
7    server app1:3000;
8    server app2:3000;
9    server app3:3000;
10
11    # Least Connections - do najmniej obciazzonego
12    # least_conn;
13
14    # IP Hash - ten sam klient -> ten sam serwer
15    # ip_hash;
16
17    # Weighted - serwery o roznej mocy
18    # server app1:3000 weight=3;
19    # server app2:3000 weight=2;
20    # server app3:3000 weight=1;
21}
22
23server {
24    listen 80;
25    server_name roman-empire.com;
26
27    location / {
28        proxy_pass http://roman_legion;
29        proxy_set_header Host \$host;
30        proxy_set_header X-Real-IP \$remote_addr;
31    }
32}
33`;
34
35// 2. PM2 - cluster mode w Node.js
36const pm2Config = {
37  apps: [{
38    name: 'roman-empire-api',
39    script: 'dist/main.js',
40    instances: 'max',    // Uzyj wszystkich CPU
41    exec_mode: 'cluster',
42    env_production: {
43      NODE_ENV: 'production',
44      PORT: 3000,
45    },
46  }],
47};
48
49// 3. Kubernetes Horizontal Pod Autoscaler
50const hpaYaml = `
51apiVersion: autoscaling/v2
52kind: HorizontalPodAutoscaler
53metadata:
54  name: roman-api-hpa
55spec:
56  scaleTargetRef:
57    apiVersion: apps/v1
58    kind: Deployment
59    name: roman-api
60  minReplicas: 2
61  maxReplicas: 10
62  metrics:
63  - type: Resource
64    resource:
65      name: cpu
66      target:
67        type: Utilization
68        averageUtilization: 70
69`;
70
71// 4. Health Check dla Load Balancera
72import { Controller, Get } from '@nestjs/common';
73
74@Controller('health')
75export class HealthController {
76  @Get()
77  check() {
78    return {
79      status: 'healthy',
80      uptime: process.uptime(),
81      pid: process.pid,
82      memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
83    };
84  }
85
86  @Get('ready')
87  ready() {
88    return { status: 'ready' };
89  }
90}
91

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. Moduł compression w NestJS (app.use(compression())) powoduje:

Zadania praktyczne w grze

  • Edytor kodu

    Stwórz /health endpoint z informacją o uptime, memory i response time

Przydatne artykuły