Cours NestJS · Module 9: Déploiement et infrastructure

Déploiement Blue-Green et zéro interruption - l'art du changement de garde sans faille

11 min de lecture
Dans cette leçon6

Maître stratège ! Le sénateur Cicero dit : « L'Empire ne dort jamais, et les routes doivent toujours rester ouvertes. » Pourtant, chaque déploiement signifie une demi-minute d'erreurs 502, car l'ancien processus s'est déjà éteint et le nouveau n'est pas encore debout. Les légions romaines changeaient la garde sans laisser les murs sans défense : les applications doivent elles aussi se mettre à jour sans interruption.

Le Blue-Green Deployment est une technique dans laquelle on maintient deux environnements de production identiques (blue et green). À un moment donné, un seul sert le trafic, et l'autre reçoit la nouvelle version. La bascule se fait instantanément, sans interruption.

Le concept du Blue-Green Deployment

Imagine deux portes de la ville. Blue est ouverte et sert les voyageurs, tandis que dans green les gardes installent de nouvelles fortifications. Quand green est prête, le trafic passe sur elle. La configuration des deux slots est décrite par deux interfaces :

1// src/deployment/blue-green.config.ts
2interface DeploymentSlot {
3  name: 'blue' | 'green';
4  port: number;
5  version: string;
6  status: 'active' | 'standby' | 'deploying';
7  healthCheckUrl: string;
8  startedAt: Date;
9}
10
11interface BlueGreenConfig {
12  activeSlot: 'blue' | 'green';
13  slots: {
14    blue: DeploymentSlot;
15    green: DeploymentSlot;
16  };
17  loadBalancerUrl: string;
18  healthCheckInterval: number; // ms
19  healthCheckRetries: number;
20  rollbackTimeout: number;    // ms
21}
22
23const deploymentConfig: BlueGreenConfig = {
24  activeSlot: 'blue',
25  slots: {
26    blue: {
27      name: 'blue',
28      port: 3001,
29      version: '2.3.0',
30      status: 'active',
31      healthCheckUrl: 'http://blue.imperium.internal:3001/health',
32      startedAt: new Date(),
33    },
34    green: {
35      name: 'green',
36      port: 3002,
37      version: '2.4.0',
38      status: 'standby',
39      healthCheckUrl: 'http://green.imperium.internal:3002/health',
40      startedAt: new Date(),
41    },
42  },
43  loadBalancerUrl: 'http://lb.imperium.internal',
44  healthCheckInterval: 5000,
45  healthCheckRetries: 3,
46  rollbackTimeout: 30000,
47};

activeSlot indique la porte qui reçoit le trafic, et la bascule est un changement de cible dans le load balancer. Un rollback consiste à basculer de nouveau, car l'ancienne version tourne toujours. Le prix, ce sont des ressources doublées, et les connexions longues, comme les WebSocket, doivent être déplacées en connaissance de cause.

Rolling Updates

Un rolling update remplace progressivement les anciennes instances par des nouvelles, comme des centurions qui changent la garde, si bien qu'une partie des instances sert toujours le trafic :

1// src/deployment/rolling-update.service.ts
2import { Injectable, Logger } from '@nestjs/common';
3
4interface Instance {
5  id: string;
6  version: string;
7  status: 'running' | 'updating' | 'ready' | 'failed';
8  port: number;
9}
10
11@Injectable()
12export class RollingUpdateService {
13  private readonly logger = new Logger(RollingUpdateService.name);
14
15  async performRollingUpdate(
16    instances: Instance[],
17    newVersion: string,
18    maxUnavailable: number = 1,
19  ): Promise<void> {
20    this.logger.log(
21      `Je lance le rolling update vers la version ${newVersion}`
22    );
23    this.logger.log(
24      `Instances : ${instances.length}, max indisponibles : ${maxUnavailable}`
25    );
26
27    // Mettre à jour les instances par lots - tout le lot à la fois
28    for (let i = 0; i < instances.length; i += maxUnavailable) {
29      const batch = instances.slice(i, i + maxUnavailable);
30      const results = await Promise.all(
31        batch.map((instance) => this.updateInstance(instance, newVersion)),
32      );
33
34      // Une instance en échec arrête tout le déploiement
35      if (results.includes(false)) {
36        throw new Error(`Rolling update vers la version ${newVersion} arrêté`);
37      }
38    }
39  }

Promise.all met à jour tout le lot à la fois. La première version avait une boucle avec await à l'intérieur du lot, donc malgré le paramètre maxUnavailable elle n'arrêtait jamais qu'une instance à la fois. Une instance en échec arrête maintenant tout le déploiement au lieu d'abîmer les suivantes : dans un test avec six instances, les deux dernières sont restées intactes.

Chaque instance passe par cinq étapes dans un ordre fixe :

1  private async updateInstance(instance: Instance, newVersion: string): Promise<boolean> {
2    instance.status = 'updating';
3    this.logger.log(`Mise à jour de l'instance ${instance.id}...`);
4
5    // 1. Retirer l'instance du load balancer
6    await this.removeFromLoadBalancer(instance);
7
8    // 2. Attendre la fin des requêtes en cours
9    await this.drainConnections(instance);
10
11    // 3. Passer à la nouvelle version
12    await this.deployNewVersion(instance, newVersion);
13
14    // 4. Vérifier le health check
15    const healthy = await this.waitForHealthy(instance);
16
17    if (healthy) {
18      instance.status = 'running';
19      instance.version = newVersion;
20      // 5. Rajouter l'instance au load balancer
21      await this.addToLoadBalancer(instance);
22      this.logger.log(`Instance ${instance.id} mise à jour`);
23      return true;
24    }
25
26    instance.status = 'failed';
27    this.logger.error(
28      `Instance ${instance.id} - rollback !`
29    );
30    await this.rollback(instance);
31    return false;
32  }

L'instance quitte le load balancer, termine les requêtes en cours, reçoit la nouvelle version, passe le health check et ne revient qu'ensuite dans le pool.

Les méthodes utilitaires sont un squelette à brancher sur ton infrastructure :

1  private async removeFromLoadBalancer(instance: Instance) {
2    // Retirer l'instance du pool du load balancer
3  }
4
5  private async drainConnections(instance: Instance) {
6    // Attendre la fin des connexions actives (graceful)
7  }
8
9  private async deployNewVersion(
10    instance: Instance, version: string
11  ) {
12    // Déployer la nouvelle version sur l'instance
13  }
14
15  private async waitForHealthy(instance: Instance): Promise<boolean> {
16    const maxRetries = 10;
17    for (let i = 0; i < maxRetries; i++) {
18      try {
19        // Vérifier l'endpoint /health/ready de la nouvelle version
20        const response = await fetch(`http://localhost:${instance.port}/health/ready`, {
21          signal: AbortSignal.timeout(2000),
22        });
23        if (response.ok) return true;
24      } catch {
25        // L'instance ne répond pas encore
26      }
27      await new Promise(r => setTimeout(r, 2000)); // pause après chaque tentative
28    }
29    return false;
30  }
31
32  private async addToLoadBalancer(instance: Instance) {
33    // Rajouter l'instance au pool
34  }
35
36  private async rollback(instance: Instance) {
37    // Restaurer la version précédente et rajouter l'instance au pool
38  }
39}

waitForHealthy() interroge /health/ready avec un délai maximal et attend après chaque tentative. La première version n'attendait qu'après une exception, donc dix réponses en échec défilaient en une fraction de seconde.

Health Check-based Deployment

Les health checks sont les éclaireurs de l'Empire : ils vérifient qu'une nouvelle fortification est prête avant qu'on ouvre les portes :

1// src/health/deployment-health.controller.ts
2import { Controller, Get } from '@nestjs/common';
3import {
4  HealthCheck, HealthCheckService,
5  MongooseHealthIndicator, MemoryHealthIndicator,
6  DiskHealthIndicator,
7} from '@nestjs/terminus';
8
9@Controller('health')
10export class DeploymentHealthController {
11  constructor(
12    private health: HealthCheckService,
13    private mongoose: MongooseHealthIndicator,
14    private memory: MemoryHealthIndicator,
15    private disk: DiskHealthIndicator,
16  ) {}
17
18  // Liveness - l'application est-elle vivante ?
19  @Get('live')
20  @HealthCheck()
21  checkLiveness() {
22    return this.health.check([
23      () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024),
24    ]);
25  }
26
27  // Readiness - est-elle prête pour le trafic ?
28  @Get('ready')
29  @HealthCheck()
30  checkReadiness() {
31    return this.health.check([
32      () => this.mongoose.pingCheck('mongodb'),
33      () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024),
34      () => this.disk.checkStorage('disk', {
35        thresholdPercent: 0.9, path: '/',
36      }),
37    ]);
38  }
39
40  // Startup - l'application a-t-elle bien démarré ?
41  @Get('startup')
42  @HealthCheck()
43  checkStartup() {
44    return this.health.check([
45      () => this.mongoose.pingCheck('mongodb'),
46    ]);
47  }
48}

Liveness dit si le processus est vivant, readiness s'il peut recevoir du trafic, et startup si l'application a bien démarré. TerminusModule.forRoot({ gracefulShutdownTimeoutMs: 10000 }) complète le zéro interruption : après SIGTERM, la readiness renvoie 503 avec le statut shutting_down, tandis que l'application sert encore le trafic pendant 10 secondes. Dans le test, les endpoints ordinaires répondaient 200 pendant ce temps, donc Kubernetes a le temps de retirer le pod du pool.

Migrations de base de données - zéro interruption

Les migrations sont la partie la plus difficile d'un déploiement sans interruption, car l'ancien et le nouveau code cohabitent avec la même base. La règle : une migration doit être rétrocompatible. ALTER TABLE legions RENAME COLUMN name TO legion_name provoque une interruption, car l'ancien code cherche name et plante. Renommer un champ en toute sécurité demande trois déploiements. Le premier ajoute le nouveau champ :

1// Migration 1 (déploiement 1) : ajouter le nouveau champ legion_name
2import { Db } from 'mongodb';
3
4export async function up(db: Db) {
5  await db.collection('legions').updateMany(
6    {},
7    [{ $set: { legion_name: '$name' } }]
8  );
9  // L'ancien code lit toujours 'name' - ça marche !
10}

La mise à jour avec pipeline copie la valeur de name dans legion_name dans chaque document MongoDB, et l'ancien code ne remarque rien.

Le deuxième déploiement modifie le code :

1// Code v2 (déploiement 2) : écrire dans les deux champs
2async updateLegion(id: string, name: string) {
3  await this.model.updateOne(
4    { _id: id },
5    { $set: { name, legion_name: name } }
6  );
7}
8
9// Lire depuis le nouveau champ
10async getLegion(id: string) {
11  const doc = await this.model.findById(id);
12  return doc?.legion_name ?? doc?.name; // Fallback
13}

Le code v2 écrit dans les deux champs et lit le nouveau, en se rabattant sur l'ancien pour les documents que la migration n'a pas couverts.

Le troisième fait le ménage :

1// Migration 3 (après le déploiement complet de la v2) : supprimer l'ancien champ
2import { Db } from 'mongodb';
3
4export async function up(db: Db) {
5  // Compléter d'abord les documents écrits par l'ancien code pendant le déploiement
6  await db.collection('legions').updateMany(
7    { legion_name: { $exists: false } },
8    [{ $set: { legion_name: '$name' } }]
9  );
10
11  await db.collection('legions').updateMany(
12    {},
13    { $unset: { name: '' } }
14  );
15}

On complète d'abord les documents écrits par l'ancien code après la première migration. La première version supprimait name tout de suite, et dans le test un document ajouté pendant le déploiement a perdu son nom.

Feature Flags

Les feature flags sont les ordres secrets de l'empereur : ils activent et désactivent des fonctionnalités sans déployer de nouveau code :

1// src/features/feature-flag.service.ts
2import { Injectable } from '@nestjs/common';
3
4interface FeatureFlag {
5  name: string;
6  enabled: boolean;
7  rolloutPercentage: number; // 0-100
8  allowedUsers: string[];
9}
10
11@Injectable()
12export class FeatureFlagService {
13  private flags: Map<string, FeatureFlag> = new Map();
14
15  setFlag(flag: FeatureFlag) {
16    this.flags.set(flag.name, flag);
17  }
18
19  isEnabled(flagName: string, userId?: string): boolean {
20    const flag = this.flags.get(flagName);
21    if (!flag) return false;
22
23    // Flag complètement désactivé
24    if (!flag.enabled) return false;
25
26    // Utilisateur sur la liste autorisée (par ex. des testeurs)
27    if (userId && flag.allowedUsers.includes(userId)) {
28      return true;
29    }
30
31    // Déploiement progressif (rollout en pourcentage)
32    if (flag.rolloutPercentage < 100) {
33      // Nom du flag dans le hash - chaque flag choisit des utilisateurs différents
34      const hash = this.hashUserId(`${flagName}:${userId || 'anonymous'}`);
35      return (hash % 100) < flag.rolloutPercentage;
36    }
37
38    return true;
39  }
40
41  private hashUserId(userId: string): number {
42    let hash = 0;
43    for (let i = 0; i < userId.length; i++) {
44      hash = ((hash << 5) - hash) + userId.charCodeAt(i);
45      hash |= 0;
46    }
47    return Math.abs(hash);
48  }
49}

J'ai ajouté setFlag(), car rien ne remplissait la map auparavant. Le nom du flag dans le hash fait que chaque flag choisit des utilisateurs différents : sans lui, dans le test, les mêmes 20 % d'utilisateurs se retrouvaient dans chaque rollout. En production, les flags se gardent dans une base ou dans un service comme Unleash.

Le contrôleur interroge le service à chaque requête :

1// Utilisation dans un contrôleur
2@Controller('legions')
3class LegionsController {
4  constructor(private features: FeatureFlagService) {}
5
6  @Get()
7  async getLegions(@Req() req) {
8    if (this.features.isEnabled('new-ranking-system', req.user?.id)) {
9      return this.getNewRanking();
10    }
11    return this.getOldRanking();
12  }
13}

J'ai omis les méthodes de classement, et req.user est défini par le guard JWT.

PM2 Cluster Mode

PM2 en mode cluster, c'est comme déployer plusieurs garnisons : il utilise tous les cœurs du CPU, redémarre les processus et les recharge sans interruption :

1// ecosystem.config.cjs - configuration de PM2 (.cjs, car un projet NestJS 12 est un module ES)
2module.exports = {
3  apps: [{
4    name: 'imperium-api',
5    script: 'dist/main.js',
6    instances: 'max',       // Utiliser tous les cœurs du CPU
7    exec_mode: 'cluster',   // Mode cluster
8    autorestart: true,
9    watch: false,
10    max_memory_restart: '500M',
11
12    // Zero-downtime reload
13    wait_ready: true,        // Attendre process.send('ready')
14    listen_timeout: 10000,   // Temps maximal pour être prêt
15    kill_timeout: 5000,      // Temps pour l'arrêt propre (graceful shutdown)
16
17    env_production: {
18      NODE_ENV: 'production',
19      PORT: 3000,
20    },
21  }],
22};

Le fichier a l'extension .cjs, car module.exports ne fonctionne pas dans un projet avec "type": "module". wait_ready fait attendre PM2 le signal de disponibilité (3 s par défaut, ici listen_timeout vaut 10 s), et kill_timeout remplace les 1,6 s par défaut pour l'arrêt.

Le signal de disponibilité est envoyé par main.ts :

1// Dans main.ts - signaler que l'application est prête
2async function bootstrap() {
3  const app = await NestFactory.create(AppModule);
4  app.enableShutdownHooks(); // PM2 arrête le processus avec le signal SIGINT
5  await app.listen(3000);
6
7  // Prévenir PM2 que l'application est prête
8  if (process.send) {
9    process.send('ready');
10  }
11}

PM2 arrête les processus avec SIGINT, que enableShutdownHooks() transforme en retraite ordonnée. process.send n'existe que si le processus dispose d'un canal IPC vers PM2.

Les commandes PM2 pour le zéro interruption :

1# Démarrer depuis le fichier de configuration (un fichier .cjs doit être indiqué explicitement)
2pm2 start ecosystem.config.cjs --env production
3
4# Reload sans interruption (graceful)
5pm2 reload imperium-api
6
7# État des instances
8pm2 status
9
10# Surveillance en temps réel
11pm2 monit

En mode cluster, pm2 reload redémarre les processus un par un, donc l'un d'eux sert toujours le trafic.

Le blue-green, les rolling updates et les feature flags sont des outils puissants dans l'arsenal de chaque architecte de l'Empire. Je te recommande de commencer par des rolling updates avec de bonnes sondes et de garder le blue-green pour les systèmes où le rollback doit être instantané.

Retiens : on change la garde de façon à ce que le mur ne reste jamais vide, pas même un instant.

Code de cette leçon : src/blue-green-deployment.ts
1// Déploiement Blue-Green et zéro interruption
2// Stratégies de déploiement sans interruption de service
3
4// 1. Configuration Blue-Green
5interface DeploymentSlot {
6  name: 'blue' | 'green';
7  port: number;
8  version: string;
9  status: 'active' | 'standby' | 'deploying';
10}
11
12const slots: Record<string, DeploymentSlot> = {
13  blue: {
14    name: 'blue',
15    port: 3001,
16    version: '2.3.0',
17    status: 'active',
18  },
19  green: {
20    name: 'green',
21    port: 3002,
22    version: '2.4.0',
23    status: 'standby',
24  },
25};
26
27// 2. Health Check Controller
28// @Controller('health')
29// class HealthController {
30//   @Get('live')
31//   liveness() { return { status: 'ok' }; }
32//
33//   @Get('ready')
34//   readiness() {
35//     // TODO: Check MongoDB, memory, disk
36//   }
37// }
38
39// 3. Feature Flag Service
40class FeatureFlagService {
41  private flags = new Map<string, { enabled: boolean; rollout: number }>();
42
43  isEnabled(flag: string, userId?: string): boolean {
44    const f = this.flags.get(flag);
45    if (!f || !f.enabled) return false;
46    if (f.rollout < 100 && userId) {
47      // TODO : Implémenter le canary release
48      // Use hash userId % 100 < rollout
49    }
50    return true;
51  }
52}
53
54// 4. PM2 Cluster Config
55// ecosystem.config.js:
56// {
57//   name: 'imperium-api',
58//   script: 'dist/main.js',
59//   instances: 'max',
60//   exec_mode: 'cluster',
61//   wait_ready: true,
62//   listen_timeout: 10000,
63// }
64
65// TODO : Implémenter un rolling update complet du service
66// TODO : Ajouter une stratégie de migration de base de données (3 étapes)
67// TODO : Configurer PM2 avec graceful shutdown
68

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 déploiement Blue-Green consiste à :

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un FeatureFlagService avec une méthode isEnabled prenant en charge rolloutPercentage et une configuration de déploiement blue-green

  • Tri vertical

    Ordonne les étapes d'une mise à jour progressive pour une seule instance :

  • Éditeur de code

    Crée un fichier .github/workflows/deploy.yml avec les jobs : test, build, deploy déclenchés au push sur main

  • Clique dans l'ordre

    Ordonne correctement les étapes d'un pipeline CI/CD complet :

  • Tri vertical

    Ordonne correctement le décorateur de health check dans un contrôleur NestJS :

  • Éditeur de code

    Crée une configuration avec un schéma Joi validant DATABASE_URL, PORT, NODE_ENV et JWT_SECRET

  • Clique dans l'ordre

    Ordonne les middlewares de sécurité dans l'ordre recommandé d'ajout à main.ts :