Cours NestJS · Module 8: Cache et performances

Load Balancing - répartir la charge de la légion

11 min de lecture
Dans cette leçon5

Commandant des légions ! Le consul Caesar.js a remarqué qu'un seul fort fait tout le travail pendant que les autres restent inactifs dans leur garnison. Quand arrive une vague de rapports, ce fort tombe, et toute l'application tombe avec lui. Il est temps d'apprendre le load balancing, l'art de répartir la charge entre toutes les unités de l'armée.

Qu'est-ce que le load balancing dans le monde des légionnaires ?

Imagine une armée de légions romaines :

  • un seul fort sert tous les clients = surcharge,
  • des unités restent inactives = gaspillage de ressources,
  • une charge inégale = certains forts s'effondrent sous leurs tâches,
  • aucune redondance = quand le fort principal tombe, toute l'armée s'arrête.

Un load balancer est un légat avisé qui répartit les tâches entre les forts. En pratique, ce rôle revient le plus souvent à un serveur tout fait placé devant l'application, comme Nginx, HAProxy ou un load balancer du cloud, qui prend souvent aussi en charge le HTTPS et la compression des réponses. Quand personne ne compresse devant l'application, dans NestJS c'est le middleware app.use(compression()) qui s'en charge, en réduisant la taille des réponses HTTP envoyées ; nous y reviendrons dans la leçon sur l'optimisation des réponses. Ci-dessous, nous construisons un balancer dans NestJS lui-même pour voir comment fonctionnent les algorithmes.

Côté instance : l'endpoint /health

Le balancer doit savoir quels forts sont vivants. Chaque instance expose donc un endpoint de santé simple :

1// health.controller.ts - sur chaque instance
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() donne les secondes écoulées depuis le démarrage du processus, et process.memoryUsage() l'utilisation de la mémoire. Le temps de réponse est mesuré par le balancer lui-même : si /health répond lentement ou pas du tout, le fort sort de la rotation.

Application Load Balancing

Le service du balancer garde une liste de serveurs décrits par l'interface ServerInstance : adresse, poids, charge actuelle et état de santé :

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(); // premier contrôle juste après le démarrage
27  }
28
29  private initializeServers(): void {
30    // LEGION_SERVERS est un JSON avec la liste des serveurs, par ex. [{"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  }

On lit la liste depuis la variable LEGION_SERVERS, et les variables d'environnement sont du texte, donc JSON.parse() passe en premier. Le premier contrôle de santé démarre dans onModuleInit() ; la version d'origine le lançait dans le constructeur, qui ne peut rien attendre.

Les deux algorithmes de sélection les plus simples :

1  // Round Robin - chaque fort à tour de rôle
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(`Fort choisi : ${server.id} (Round Robin)`);
10    return server;
11  }
12
13  // Weighted random - un tirage pondéré : une cohorte plus forte est choisie plus souvent
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(`Fort choisi : ${server.id} (Weighted)`);
25        return server;
26      }
27    }
28
29    return healthyServers[0];
30  }

Round Robin prend les forts à tour de rôle. La deuxième méthode est un tirage pondéré, et non un « Weighted Round Robin » comme le prétendait l'ancien commentaire : dans un test de 2 500 tirages, les poids 10 et 15 ont donné 986 et 1 514 sélections.

Les deux suivants regardent la charge :

1  // Least Connections - le fort le moins chargé
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(`Fort choisi : ${leastLoaded.id} (Least Connections : ${leastLoaded.currentLoad})`);
11    return leastLoaded;
12  }
13
14  // Resource-based - selon les ressources système
15  async getNextServerResourceBased(): Promise<ServerInstance | null> {
16    const healthyServers = this.servers.filter(s => s.isHealthy);
17    if (healthyServers.length === 0) return null;
18
19    // Récupérer les métriques de ressources de chaque cohorte
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    // Choisir le fort au score le plus bas (le moins chargé)
34    const bestServer = serversWithMetrics.reduce((best, server) =>
35      server.score < best.score ? server : best
36    );
37
38    console.log(`Fort choisi : ${bestServer.id} (Resource-based, score : ${bestServer.score})`);
39    return bestServer;
40  }

Least Connections choisit le fort qui a le moins de requêtes en cours. La variante basée sur les ressources interroge chaque serveur sur ses métriques à chaque requête, ce qui coûte cher : en production, on rafraîchit les métriques en arrière-plan et on les garde en mémoire.

Le score, les compteurs et la récupération des métriques :

1  private calculateServerScore(metrics: any): number {
2    // Formule simple : valeur plus haute = serveur moins bon
3    return (
4      metrics.cpu * 0.4 + // 40 % de poids pour le CPU
5      metrics.memory * 0.3 + // 30 % de poids pour la mémoire
6      metrics.avgResponseTime * 0.2 + // 20 % de poids pour le temps de réponse
7      metrics.activeConnections * 0.1 // 10 % de poids pour les connexions
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      // Appel HTTP vers l'endpoint des métriques, 2 secondes au maximum
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      // Pas de réponse = les pires métriques possibles, ce fort sera choisi en dernier
34      return {
35        cpu: 100,
36        memory: 100,
37        avgResponseTime: 10000,
38        activeConnections: 1000,
39      };
40    }
41  }

Le score mélange des pourcentages et des millisecondes, donc le temps de réponse domine facilement le reste : mieux vaut ramener les métriques à une échelle commune avant de les pondérer. En l'absence de réponse, on renvoie les pires métriques possibles ; la première version les tirait au hasard, si bien qu'un fort mort pouvait gagner. AbortSignal.timeout(2000) interrompt un appel trop long, car le fetch natif de Node.js ne connaît pas d'option timeout.

Le contrôle de santé se répète toutes les 30 secondes :

1  @Cron('*/30 * * * * *') // Toutes les 30 secondes
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(`La cohorte ${server.id} ne répond pas !`);
14        }
15      } catch (error) {
16        server.isHealthy = false;
17        server.lastHealthCheck = new Date();
18        console.error(`Cohorte ${server.id} injoignable :`, 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}

Le décorateur @Cron vient de @nestjs/schedule et ne fonctionne qu'après l'import de ScheduleModule.forRoot(). Le motif a six champs et le premier correspond aux secondes, donc */30 * * * * * signifie toutes les 30 secondes.

Proxy Load Balancer Implementation

Le contrôleur accepte chaque requête sous /api/proxy et la transmet au fort choisi via 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    // Choisir un serveur avec le load balancer
14    const targetServer = await this.loadBalancer.getNextServerResourceBased();
15
16    if (!targetServer) {
17      res.status(503).json({
18        error: 'Toutes les cohortes de la légion sont indisponibles !',
19        code: 'LEGION_UNAVAILABLE'
20      });
21      return;
22    }
23
24    // Augmenter le compteur de charge
25    await this.loadBalancer.incrementLoad(targetServer.id);
26
27    // Créer un proxy vers le serveur choisi
28    const proxy = createProxyMiddleware<Request, Response>({
29      target: `http://${targetServer.host}:${targetServer.port}`,
30      changeOrigin: true,
31      pathRewrite: {
32        '^/api/proxy': '', // Retirer le préfixe
33      },
34      on: {
35        proxyReq: (proxyReq, req) => {
36          // Ajouter des informations sur le load balancer
37          proxyReq.setHeader('X-Proxy-Server', targetServer.id);
38          proxyReq.setHeader('X-Load-Balancer', 'legionariusze-legion-lb');
39          console.log(`Redirection ${req.method} ${req.url} vers ${targetServer.id}`);
40          // NestJS a déjà lu le body - le restaurer à la fin, après les en-têtes
41          fixRequestBody(proxyReq, req);
42        },
43        proxyRes: async (proxyRes) => {
44          // Diminuer le compteur de charge à la fin
45          await this.loadBalancer.decrementLoad(targetServer.id);
46          console.log(`Réponse de ${targetServer.id} : ${proxyRes.statusCode}`);
47        },
48        error: async (err) => {
49          await this.loadBalancer.decrementLoad(targetServer.id);
50          console.error(`Erreur de proxy vers ${targetServer.id} :`, err.message);
51
52          if (!res.headersSent) {
53            res.status(502).json({
54              error: 'Erreur de communication avec le fort',
55              server: targetServer.id,
56              details: err.message
57            });
58          }
59        },
60      },
61    });
62
63    proxy(req, res);
64  }
65}

Le code est adapté à http-proxy-middleware 4 : les événements se déclarent dans l'objet on, et les anciennes options onProxyReq et consorts ne fonctionnent plus. fixRequestBody est indispensable, car NestJS a déjà lu le corps de la requête : sans lui, le POST du test restait bloqué sans réponse. On l'appelle en dernier, car il écrit le corps, et après cela on ne peut plus définir d'en-têtes. '*path' est le wildcard nommé exigé depuis Express 5, et on importe les types d'Express avec import type, ce que TypeScript exige quand isolatedModules et emitDecoratorMetadata sont activés.

Créer un nouveau proxy à chaque requête est un gaspillage ; l'option router permet à un seul proxy de choisir la cible de chaque requête.

Circuit Breaker Pattern

Chaque serveur reçoit son propre disjoncteur, gardé dans une map :

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 erreurs
11        recoveryTimeout: 30000, // 30 secondes
12        monitoringPeriod: 10000, // 10 secondes
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() crée un disjoncteur à la première utilisation, et executeWithBreaker() fait passer l'opération par lui, donc la panne d'un fort ne coupe pas les autres. Le disjoncteur lui-même compte les erreurs d'un serveur donné :

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 pour ${this.config.serverId} : tentative de réinitialisation`);
19      } else {
20        throw new Error(`Circuit breaker OPEN pour le serveur ${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 pour ${this.config.serverId} : rétabli`);
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 pour ${this.config.serverId} : OUVERT`);
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}

Après cinq erreurs consécutives, le circuit s'ouvre, et un succès remet le compteur à zéro. monitoringPeriod attend une fenêtre de temps que cette version ne calcule pas encore.

Je te recommande une répartition des rôles : laisse Nginx ou un balancer du cloud diriger le trafic, et laisse l'application répondre honnêtement sur /health. Dans la prochaine leçon, nous jetterons un œil aux entrepôts de la mémoire.

Retiens : un bon légat n'envoie pas tous les rapports au même fort, il les répartit selon les forces et évite les forts qui restent muets.

Code de cette leçon : src/load-balancing.ts
1// Load Balancing - Répartition de la charge de la flotte
2
3// 1. Nginx as load balancer (configuration)
4const nginxConfig = `
5upstream roman_fleet {
6    # Round Robin (par défaut) - à tour de rôle
7    server app1:3000;
8    server app2:3000;
9    server app3:3000;
10
11    # Least Connections - vers le moins chargé
12    # least_conn;
13
14    # IP Hash - même client -> même serveur
15    # ip_hash;
16
17    # Weighted - serveurs de puissance différente
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_fleet;
29        proxy_set_header Host \$host;
30        proxy_set_header X-Real-IP \$remote_addr;
31    }
32}
33`;
34
35// 2. PM2 - cluster mode dans Node.js
36const pm2Config = {
37  apps: [{
38    name: 'roman-empire-api',
39    script: 'dist/main.js',
40    instances: 'max',    // Use all CPUs
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 pour le Load Balancer
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

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Le module de compression dans NestJS (app.use(compression())) provoque :

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un endpoint /health avec des informations d'uptime, de mémoire et de temps de réponse