Kurs NestJS · Moduł 8: Cache i wydajność
Load Balancing - rozłożenie obciążenia legionu
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}
91Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
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