NestJS Graceful Shutdown 심화

Graceful Shutdown이 중요한 이유

프로덕션에서 Pod이 종료될 때 진행 중인 요청이 중단되면 502 에러, 데이터 불일치, 메시지 유실이 발생합니다. NestJS의 enableShutdownHooks()만으로는 부족합니다. HTTP 서버의 연결 드레이닝, WebSocket 세션 정리, 큐 워커 중단, DB 커넥션 풀 해제까지 종료 순서를 정확히 제어해야 실제 무중단 배포가 가능합니다.

1. 종료 시그널과 NestJS 라이프사이클

시그널 발생 시점 NestJS 동작
SIGTERM K8s Pod 종료, docker stop onModuleDestroy → onApplicationShutdown
SIGINT Ctrl+C (로컬 개발) 동일
SIGKILL terminationGracePeriod 초과 즉시 종료 (핸들링 불가)
// main.ts — 기본 설정
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // 필수: Shutdown Hooks 활성화
  app.enableShutdownHooks();
  
  // 타임아웃이 있는 서버 설정
  const server = app.getHttpServer();
  server.keepAliveTimeout = 65000;     // ALB 기본 60s보다 길게
  server.headersTimeout = 66000;       // keepAliveTimeout + 1s
  
  await app.listen(3000);
}
bootstrap();

2. 종료 순서 설계

올바른 Graceful Shutdown의 핵심은 종료 순서입니다.

// K8s Pod 종료 시 실행 순서:
// 1. preStop hook 실행 (설정한 경우)
// 2. SIGTERM 수신
// 3. readinessProbe 실패 → Service에서 제거 (트래픽 차단)
// 4. 진행 중인 요청 완료 대기 (드레이닝)
// 5. WebSocket/SSE 연결 종료
// 6. 큐 워커 중단 (현재 작업 완료 후)
// 7. DB 커넥션 풀 해제
// 8. 종료 완료

// 문제: 3번과 2번이 동시에 발생 → 라우팅 지연으로 새 요청 유입 가능
// 해결: preStop에서 sleep으로 EndpointSlice 전파 대기

3. HTTP 서버 드레이닝 구현

// shutdown/graceful-shutdown.service.ts
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { Server } from 'http';

@Injectable()
export class GracefulShutdownService implements OnApplicationShutdown {

  private server: Server;
  private isShuttingDown = false;
  private activeConnections = new Set<any>();

  constructor(private httpAdapterHost: HttpAdapterHost) {}

  onModuleInit() {
    this.server = this.httpAdapterHost.httpAdapter.getHttpServer();

    // 모든 연결 추적
    this.server.on('connection', (socket) => {
      this.activeConnections.add(socket);
      socket.on('close', () => {
        this.activeConnections.delete(socket);
      });
    });
  }

  get shuttingDown(): boolean {
    return this.isShuttingDown;
  }

  async onApplicationShutdown(signal?: string) {
    console.log(`Shutdown signal received: ${signal}`);
    this.isShuttingDown = true;

    // 1단계: 새 연결 거부
    await new Promise<void>((resolve) => {
      this.server.close(() => {
        console.log('Server closed to new connections');
        resolve();
      });
    });

    // 2단계: 기존 연결에 keep-alive 비활성화
    for (const socket of this.activeConnections) {
      if (!socket.destroyed) {
        socket.end();
      }
    }

    // 3단계: 잔여 연결 강제 종료 대기 (최대 25초)
    const deadline = Date.now() + 25000;
    while (this.activeConnections.size > 0 && Date.now() < deadline) {
      await new Promise(r => setTimeout(r, 500));
    }

    // 타임아웃 후 강제 종료
    for (const socket of this.activeConnections) {
      socket.destroy();
    }

    console.log('All connections drained');
  }
}

4. 헬스체크 연동: Readiness 분리

// health/health.controller.ts
@Controller('health')
export class HealthController {

  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private redis: RedisHealthIndicator,
    private shutdownService: GracefulShutdownService,
  ) {}

  // Liveness: 프로세스 살아있는지만 확인
  @Get('liveness')
  liveness() {
    return { status: 'ok' };
  }

  // Readiness: 트래픽 수신 가능 여부
  @Get('readiness')
  async readiness() {
    // 종료 중이면 즉시 503 → K8s가 Service에서 제거
    if (this.shutdownService.shuttingDown) {
      throw new ServiceUnavailableException('Shutting down');
    }

    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.redis.pingCheck('redis'),
    ]);
  }
}

5. 리소스별 정리: onModuleDestroy 패턴

// BullMQ 워커: 현재 작업 완료 후 중단
@Injectable()
export class OrderWorker implements OnModuleDestroy {

  private worker: Worker;

  onModuleInit() {
    this.worker = new Worker('orders', async (job) => {
      await this.processOrder(job.data);
    }, {
      connection: this.redis,
      concurrency: 5,
    });
  }

  async onModuleDestroy() {
    // 새 작업 수신 중단, 진행 중인 작업은 완료 대기
    await this.worker.close();
    console.log('Order worker stopped gracefully');
  }
}

// WebSocket: 클라이언트에 종료 알림
@Injectable()
export class WsShutdownService implements OnApplicationShutdown {

  constructor(private wsGateway: ChatGateway) {}

  async onApplicationShutdown() {
    const server = this.wsGateway.server;
    
    // 모든 클라이언트에 종료 메시지 전송
    server.emit('server:shutdown', {
      message: 'Server is restarting, please reconnect',
      reconnectAfter: 5000,
    });

    // 연결 종료 대기
    await new Promise(r => setTimeout(r, 2000));
    server.disconnectSockets(true);
  }
}

// DB 커넥션 풀 해제
@Injectable()
export class DatabaseCleanup implements OnModuleDestroy {

  constructor(private dataSource: DataSource) {}

  async onModuleDestroy() {
    if (this.dataSource.isInitialized) {
      await this.dataSource.destroy();
      console.log('Database connections released');
    }
  }
}

// Redis 연결 해제
@Injectable()
export class RedisCleanup implements OnModuleDestroy {

  constructor(@InjectRedis() private redis: Redis) {}

  async onModuleDestroy() {
    await this.redis.quit();
    console.log('Redis connection closed');
  }
}

6. K8s Deployment 설정

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  replicas: 3
  strategy:
    rollingUpdate:
      maxUnavailable: 0     # 무중단: 항상 전체 replicas 유지
      maxSurge: 1
  template:
    spec:
      terminationGracePeriodSeconds: 60  # SIGKILL까지 60초 유예
      containers:
      - name: api
        image: api-server:latest
        ports:
        - containerPort: 3000
        
        lifecycle:
          preStop:
            exec:
              # EndpointSlice 전파 대기 (핵심!)
              # SIGTERM 전에 5초 대기 → 새 트래픽이 이 Pod에 도달 안 함
              command: ["sh", "-c", "sleep 5"]
        
        readinessProbe:
          httpGet:
            path: /health/readiness
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 5
          failureThreshold: 1   # 1번 실패로 즉시 제거
        
        livenessProbe:
          httpGet:
            path: /health/liveness
            port: 3000
          initialDelaySeconds: 15
          periodSeconds: 10
          failureThreshold: 3
        
        resources:
          requests:
            cpu: 250m
            memory: 256Mi
타이밍 이유
preStop sleep 5초 EndpointSlice 전파 + iptables 갱신 시간
서버 드레이닝 25초 진행 중인 요청 완료 대기
워커/DB 정리 ~20초 큐 작업 완료 + 커넥션 해제
terminationGracePeriod 60초 preStop + 드레이닝 + 정리 합계보다 여유있게

7. 종료 미들웨어: 새 요청 거부

// shutdown/shutdown.middleware.ts
@Injectable()
export class ShutdownMiddleware implements NestMiddleware {

  constructor(private shutdownService: GracefulShutdownService) {}

  use(req: Request, res: Response, next: NextFunction) {
    if (this.shutdownService.shuttingDown) {
      // Connection: close 헤더로 클라이언트에 재연결 유도
      res.set('Connection', 'close');
      res.status(503).json({
        statusCode: 503,
        message: 'Service is shutting down',
        retryAfter: 5,
      });
      return;
    }
    next();
  }
}

// app.module.ts
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(ShutdownMiddleware)
      .forRoutes('*');
  }
}

8. 테스트: 종료 시나리오 검증

describe('Graceful Shutdown', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = module.createNestApplication();
    app.enableShutdownHooks();
    await app.init();
  });

  it('종료 중 readiness가 503 반환', async () => {
    // 종료 시작
    const shutdownService = app.get(GracefulShutdownService);
    (shutdownService as any).isShuttingDown = true;

    await request(app.getHttpServer())
      .get('/health/readiness')
      .expect(503);
  });

  it('종료 중 새 요청이 503 반환', async () => {
    const shutdownService = app.get(GracefulShutdownService);
    (shutdownService as any).isShuttingDown = true;

    await request(app.getHttpServer())
      .get('/api/users')
      .expect(503)
      .expect(res => {
        expect(res.body.message).toBe('Service is shutting down');
        expect(res.headers.connection).toBe('close');
      });
  });

  it('진행 중인 요청은 완료됨', async () => {
    // 느린 엔드포인트 호출 시작
    const slowRequest = request(app.getHttpServer())
      .get('/api/slow-endpoint');  // 3초 걸리는 엔드포인트

    // 1초 후 종료 시작
    setTimeout(() => app.close(), 1000);

    // 요청이 정상 완료되어야 함
    const res = await slowRequest;
    expect(res.status).toBe(200);
  });
});

마무리

NestJS Graceful Shutdown의 핵심은 preStop sleep으로 EndpointSlice 전파를 대기하고, server.close()로 새 연결을 거부한 뒤, 진행 중인 요청과 큐 작업이 완료될 때까지 기다리는 것입니다. terminationGracePeriodSeconds는 모든 단계의 합계보다 여유 있게 설정해야 합니다. NestJS Terminus 헬스체크로 readiness를 정확히 제어하고, K8s Pod Probe 전략과 함께 무중단 배포 파이프라인을 완성하는 것을 권장합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux