Kurs NestJS · Moduł 9: Deployment i infrastruktura
Blue-Green Deployment i Zero-Downtime - sztuka bezprzerwowej zmiany warty
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 monitpm2 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
68Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
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: