Kurs NestJS · Moduł 9: Deployment i infrastruktura

Blue-Green Deployment i Zero-Downtime - sztuka bezprzerwowej zmiany warty

9 min czytania
W tej lekcji6

Mistrzu strategii! Senator Cicero powiada: „Imperium nigdy nie śpi, a drogi muszą być zawsze otwarte”. Tymczasem każde wdrożenie oznacza pół minuty błędów 502, bo stary proces już zgasł, a nowy jeszcze nie wstał. Rzymskie legiony zmieniały wartę bez pozostawiania murów bez obrony - aplikacje też muszą aktualizować się bez przerw w działaniu.

Blue-Green Deployment to technika, w której utrzymujemy dwa identyczne środowiska produkcyjne (blue i green). W danym momencie tylko jedno obsługuje ruch, a drugie przyjmuje nową wersję. Przełączenie następuje natychmiast, bez przestojów.

Koncepcja Blue-Green Deployment

Wyobraź sobie dwie bramy miasta. Blue jest otwarta i obsługuje podróżnych, a w green strażnicy montują nowe fortyfikacje. Gdy green jest gotowa, ruch przechodzi na nią. Konfigurację obu slotów opisują dwa interfejsy:

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 wskazuje bramę z ruchem, a przełączenie to zmiana celu w load balancerze. Rollback polega na przełączeniu z powrotem, bo stara wersja wciąż działa. Ceną są podwójne zasoby, a długie połączenia, np. WebSocket, trzeba świadomie przenieść.

Rolling Updates

Rolling update stopniowo zastępuje stare instancje nowymi, jak zmiana warty centurionów, dzięki czemu część instancji zawsze obsługuje ruch:

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      `Rozpoczynam rolling update do wersji ${newVersion}`
22    );
23    this.logger.log(
24      `Instancje: ${instances.length}, max niedostępnych: ${maxUnavailable}`
25    );
26
27    // Aktualizuj instancje partiami - cała partia naraz
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      // Nieudana instancja zatrzymuje cały rollout
35      if (results.includes(false)) {
36        throw new Error(`Rolling update do wersji ${newVersion} zatrzymany`);
37      }
38    }
39  }

Promise.all aktualizuje całą partię naraz. Pierwsza wersja miała pętlę z await wewnątrz partii, więc mimo parametru maxUnavailable zawsze wyłączała jedną instancję. Nieudana instancja zatrzymuje teraz cały rollout, zamiast psuć kolejne - w teście z sześcioma instancjami dwie ostatnie zostały nietknięte.

Każda instancja przechodzi pięć kroków w stałej kolejności:

1  private async updateInstance(instance: Instance, newVersion: string): Promise<boolean> {
2    instance.status = 'updating';
3    this.logger.log(`Aktualizuję instancję ${instance.id}...`);
4
5    // 1. Wyłącz instancję z load balancera
6    await this.removeFromLoadBalancer(instance);
7
8    // 2. Poczekaj aż bieżące requesty się zakończą
9    await this.drainConnections(instance);
10
11    // 3. Zaktualizuj do nowej wersji
12    await this.deployNewVersion(instance, newVersion);
13
14    // 4. Sprawdź health check
15    const healthy = await this.waitForHealthy(instance);
16
17    if (healthy) {
18      instance.status = 'running';
19      instance.version = newVersion;
20      // 5. Dodaj instancję z powrotem do load balancera
21      await this.addToLoadBalancer(instance);
22      this.logger.log(`Instancja ${instance.id} zaktualizowana`);
23      return true;
24    }
25
26    instance.status = 'failed';
27    this.logger.error(
28      `Instancja ${instance.id} - rollback!`
29    );
30    await this.rollback(instance);
31    return false;
32  }

Instancja znika z load balancera, oddaje trwające żądania, dostaje nową wersję, przechodzi health check i dopiero wtedy wraca do puli.

Metody pomocnicze to szkielet do podłączenia pod Twoją infrastrukturę:

1  private async removeFromLoadBalancer(instance: Instance) {
2    // Usuń instancję z puli load balancera
3  }
4
5  private async drainConnections(instance: Instance) {
6    // Poczekaj aż aktywne połączenia się zakończą (graceful)
7  }
8
9  private async deployNewVersion(
10    instance: Instance, version: string
11  ) {
12    // Wdrożenie nowej wersji na instancji
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        // Sprawdź endpoint /health/ready nowej wersji
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        // Instancja jeszcze nie odpowiada
26      }
27      await new Promise(r => setTimeout(r, 2000)); // przerwa po każdej próbie
28    }
29    return false;
30  }
31
32  private async addToLoadBalancer(instance: Instance) {
33    // Dodaj instancję z powrotem do puli
34  }
35
36  private async rollback(instance: Instance) {
37    // Przywróć poprzednią wersję i dodaj instancję z powrotem do puli
38  }
39}

waitForHealthy() odpytuje /health/ready z limitem czasu i czeka po każdej próbie. Pierwsza wersja czekała tylko po wyjątku, więc dziesięć nieudanych odpowiedzi mijało w ułamku sekundy.

Health Check-based Deployment

Health checki to zwiadowcy Imperium - sprawdzają, czy nowa fortyfikacja jest gotowa, zanim otworzymy bramy:

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 - czy aplikacja żyje?
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 - czy gotowa na ruch?
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 - czy aplikacja wystartowała poprawnie?
41  @Get('startup')
42  @HealthCheck()
43  checkStartup() {
44    return this.health.check([
45      () => this.mongoose.pingCheck('mongodb'),
46    ]);
47  }
48}

Liveness mówi, czy proces żyje, readiness - czy może przyjmować ruch, a startup - czy aplikacja poprawnie wstała. TerminusModule.forRoot({ gracefulShutdownTimeoutMs: 10000 }) domyka zero-downtime: po SIGTERM readiness zwraca 503 ze statusem shutting_down, a aplikacja jeszcze przez 10 sekund obsługuje ruch. W teście zwykłe endpointy odpowiadały w tym czasie 200, więc Kubernetes zdąży zabrać pod z puli.

Migracje bazy danych - zero-downtime

Migracje to najtrudniejszy element zero-downtime deployment, bo stary i nowy kod współistnieją z tą samą bazą. Zasada: migracja musi być kompatybilna wstecz. ALTER TABLE legions RENAME COLUMN name TO legion_name powoduje downtime, bo stary kod szuka name i pada. Bezpieczna zmiana nazwy pola to trzy wdrożenia. Pierwsze dodaje nowe pole:

1// Migracja 1 (wdrożenie 1): dodaj nowe pole 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  // Stary kod dalej czyta 'name' - działa!
10}

Aktualizacja z potokiem kopiuje wartość name do legion_name w każdym dokumencie MongoDB, a stary kod niczego nie zauważa.

Drugie wdrożenie zmienia kod:

1// Kod v2 (wdrożenie 2): zapisuj do obu pól
2async updateLegion(id: string, name: string) {
3  await this.model.updateOne(
4    { _id: id },
5    { $set: { name, legion_name: name } }
6  );
7}
8
9// Czytaj z nowego pola
10async getLegion(id: string) {
11  const doc = await this.model.findById(id);
12  return doc?.legion_name ?? doc?.name; // Fallback
13}

Kod v2 pisze do obu pól i czyta nowe, z powrotem do starego dla dokumentów, których migracja nie objęła.

Trzecie sprząta:

1// Migracja 3 (po pełnym wdrożeniu v2): usuń stare pole
2import { Db } from 'mongodb';
3
4export async function up(db: Db) {
5  // Najpierw uzupełnij dokumenty zapisane przez stary kod w trakcie wdrożenia
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}

Najpierw uzupełniamy dokumenty zapisane przez stary kod już po pierwszej migracji. Pierwsza wersja od razu usuwała name i w teście dokument dodany w trakcie wdrożenia stracił nazwę.

Feature Flags

Feature flagi to tajne rozkazy cesarza - włączają i wyłączają funkcje bez wdrażania nowego kodu:

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    // Flaga całkowicie wyłączona
24    if (!flag.enabled) return false;
25
26    // Użytkownik na liście dozwolonych (np. testerzy)
27    if (userId && flag.allowedUsers.includes(userId)) {
28      return true;
29    }
30
31    // Stopniowe wdrażanie (procentowy rollout)
32    if (flag.rolloutPercentage < 100) {
33      // Nazwa flagi w hashu - każda flaga losuje innych użytkowników
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}

setFlag() dodałem, bo wcześniej mapy nic nie wypełniało. Nazwa flagi w hashu sprawia, że każda flaga wybiera innych użytkowników: bez niej w teście ci sami 20% użytkowników trafiało do każdego rolloutu. W produkcji flagi trzyma się w bazie albo w usłudze, np. Unleash.

Kontroler pyta serwis przy każdym żądaniu:

1// Użycie w kontrolerze
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}

Metody rankingów pominąłem, a req.user ustawia strażnik JWT.

PM2 Cluster Mode

PM2 w trybie cluster to jak rozmieszczenie wielu garnizonów - wykorzystuje wszystkie rdzenie CPU, restartuje procesy i przeładowuje je bez przestoju:

1// ecosystem.config.cjs - konfiguracja PM2 (.cjs, bo projekt NestJS 12 to moduł ES)
2module.exports = {
3  apps: [{
4    name: 'imperium-api',
5    script: 'dist/main.js',
6    instances: 'max',       // Użyj wszystkich rdzeni CPU
7    exec_mode: 'cluster',   // Tryb klastrowy
8    autorestart: true,
9    watch: false,
10    max_memory_restart: '500M',
11
12    // Zero-downtime reload
13    wait_ready: true,        // Czekaj na process.send('ready')
14    listen_timeout: 10000,   // Max czas na gotowość
15    kill_timeout: 5000,      // Czas na graceful shutdown
16
17    env_production: {
18      NODE_ENV: 'production',
19      PORT: 3000,
20    },
21  }],
22};

Plik ma rozszerzenie .cjs, bo module.exports nie zadziała w projekcie z "type": "module". wait_ready każe czekać na sygnał gotowości (domyślnie 3 s, tu listen_timeout 10 s), a kill_timeout zastępuje domyślne 1,6 s na zamknięcie.

Sygnał gotowości wysyła main.ts:

1// W main.ts - sygnalizacja gotowości
2async function bootstrap() {
3  const app = await NestFactory.create(AppModule);
4  app.enableShutdownHooks(); // PM2 zatrzymuje proces sygnałem SIGINT
5  await app.listen(3000);
6
7  // Powiadom PM2 że aplikacja jest gotowa
8  if (process.send) {
9    process.send('ready');
10  }
11}

PM2 zatrzymuje procesy sygnałem SIGINT, który enableShutdownHooks() zamienia w porządny odwrót. process.send istnieje tylko wtedy, gdy proces ma kanał IPC do PM2.

Komendy PM2 dla zero-downtime:

1# Start z pliku konfiguracyjnego (.cjs trzeba podać wprost)
2pm2 start ecosystem.config.cjs --env production
3
4# Reload bez downtime (graceful)
5pm2 reload imperium-api
6
7# Status instancji
8pm2 status
9
10# Monitorowanie w czasie rzeczywistym
11pm2 monit

pm2 reload w trybie cluster restartuje procesy po kolei, więc zawsze któryś obsługuje ruch.

Blue-green, rolling updates i feature flagi to potężne narzędzia w arsenale każdego architekta Imperium. Polecam Ci zaczynać od rolling update z dobrymi sondami, a blue-green zostawić dla systemów, w których rollback musi być natychmiastowy.

Pamiętaj: wartę zmienia się tak, żeby mur ani przez chwilę nie stał pusty.

Kod do tej lekcji: src/blue-green-deployment.ts
1// Blue-Green Deployment i Zero-Downtime
2// Strategie wdrazania bez przestojow
3
4// 1. Konfiguracja 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: Sprawdz MongoDB, pamiec, dysk
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: Implementuj canary release
48      // Uzyj 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: Zaimplementuj pelny serwis rolling update
66// TODO: Dodaj strategie migracji bazy danych (3 kroki)
67// TODO: Skonfiguruj PM2 z graceful shutdown
68

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 polega na:

Zadania praktyczne w grze

  • Edytor kodu

    Stwórz FeatureFlagService z metodą isEnabled obsługującą rolloutPercentage oraz interfejs DeploymentSlot dla blue-green

  • Układanie w pionie

    Uporządkuj kroki rolling update dla pojedynczej instancji:

  • Edytor kodu

    Stwórz plik .github/workflows/deploy.yml z jobami: test, build, deploy uruchamianymi na push do main

  • Klikanie w kolejności

    Ułóż poprawną kolejność kroków w kompletnym CI/CD pipeline:

  • Układanie w pionie

    Ułóż poprawny dekorator health check w kontrolerze NestJS:

  • Edytor kodu

    Stwórz konfigurację z Joi schema walidującą DATABASE_URL, PORT, NODE_ENV i JWT_SECRET

  • Klikanie w kolejności

    Ułóż middleware bezpieczeństwa w zalecanej kolejności dodawania do main.ts:

Przydatne artykuły