NestJS Health Check·Graceful Shutdown

NestJS Health Check란?

마이크로서비스와 Kubernetes 환경에서 Health Check는 선택이 아닌 필수다. 로드밸런서는 Health Check으로 트래픽을 라우팅하고, K8s는 Liveness/Readiness Probe로 Pod 재시작과 트래픽 제거를 결정한다. @nestjs/terminus는 DB, Redis, 디스크, 메모리 등 다양한 의존성의 상태를 체크하는 공식 모듈이다.

이 글에서는 Terminus Health Check 구성, 커스텀 인디케이터, K8s Probe 연동, Graceful Shutdown 구현, 그리고 무중단 배포 시 고려사항까지 다룬다.

@nestjs/terminus 기본 설정

// 설치
// npm install @nestjs/terminus @nestjs/axios

// health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { HealthController } from './health.controller';
import { RedisHealthIndicator } from './redis.health';

@Module({
  imports: [TerminusModule, HttpModule],
  controllers: [HealthController],
  providers: [RedisHealthIndicator],
})
export class HealthModule {}

// health.controller.ts
import {
  Controller, Get,
} from '@nestjs/common';
import {
  HealthCheck, HealthCheckService,
  TypeOrmHealthIndicator, HttpHealthIndicator,
  DiskHealthIndicator, MemoryHealthIndicator,
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    private readonly http: HttpHealthIndicator,
    private readonly disk: DiskHealthIndicator,
    private readonly memory: MemoryHealthIndicator,
    private readonly redis: RedisHealthIndicator,
  ) {}

  // K8s Readiness Probe: 트래픽 수신 가능 여부
  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.redis.isHealthy('redis'),
      () => this.http.pingCheck('auth-service',
        'http://auth-service:3000/health/live'),
    ]);
  }

  // K8s Liveness Probe: 프로세스 생존 여부
  @Get('live')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024), // 300MB
      () => this.memory.checkRSS('memory_rss', 500 * 1024 * 1024),  // 500MB
    ]);
  }

  // K8s Startup Probe: 초기화 완료 여부
  @Get('startup')
  @HealthCheck()
  startup() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 10000 }),
      () => this.redis.isHealthy('redis'),
    ]);
  }
}

응답 예시 (정상):

{
  "status": "ok",
  "info": {
    "database": { "status": "up" },
    "redis": { "status": "up" },
    "auth-service": { "status": "up" }
  },
  "error": {},
  "details": {
    "database": { "status": "up" },
    "redis": { "status": "up" },
    "auth-service": { "status": "up" }
  }
}
// HTTP 200

// 일부 실패 시:
// HTTP 503 + "status": "error"

커스텀 Health Indicator

Redis, Kafka, 외부 API 등 기본 제공되지 않는 의존성은 커스텀 인디케이터로 구현한다.

import {
  HealthIndicator, HealthIndicatorResult, HealthCheckError,
} from '@nestjs/terminus';
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
  constructor(@InjectRedis() private readonly redis: Redis) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      const start = Date.now();
      await this.redis.ping();
      const latency = Date.now() - start;

      return this.getStatus(key, true, { latency: `${latency}ms` });
    } catch (err) {
      throw new HealthCheckError(
        'Redis check failed',
        this.getStatus(key, false, { message: err.message }),
      );
    }
  }
}

// Kafka Health Indicator
@Injectable()
export class KafkaHealthIndicator extends HealthIndicator {
  constructor(private readonly kafka: KafkaService) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      const admin = this.kafka.admin();
      await admin.connect();
      const topics = await admin.listTopics();
      await admin.disconnect();

      return this.getStatus(key, true, { topics: topics.length });
    } catch (err) {
      throw new HealthCheckError(
        'Kafka check failed',
        this.getStatus(key, false, { message: err.message }),
      );
    }
  }
}

// 외부 API 의존성 체크 (타임아웃 포함)
@Injectable()
export class PaymentGatewayHealthIndicator extends HealthIndicator {
  constructor(private readonly httpService: HttpService) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      const { status } = await firstValueFrom(
        this.httpService.get('https://api.payment.com/health', {
          timeout: 3000,
        }),
      );
      const isUp = status === 200;
      return this.getStatus(key, isUp);
    } catch {
      throw new HealthCheckError(
        'Payment gateway unreachable',
        this.getStatus(key, false),
      );
    }
  }
}

K8s Probe 매핑

Probe 엔드포인트 체크 대상 실패 시 동작
Startup /health/startup DB, Redis 연결 Pod 재시작
Liveness /health/live 메모리, 프로세스 Pod 재시작
Readiness /health/ready DB, Redis, 외부 서비스 트래픽 제거 (재시작 X)
# K8s Deployment 매니페스트
spec:
  containers:
    - name: api
      image: myapp:latest
      ports:
        - containerPort: 3000
      startupProbe:
        httpGet:
          path: /health/startup
          port: 3000
        failureThreshold: 30
        periodSeconds: 2           # 최대 60초 대기
      livenessProbe:
        httpGet:
          path: /health/live
          port: 3000
        initialDelaySeconds: 0     # startupProbe 성공 후 시작
        periodSeconds: 10
        failureThreshold: 3
        timeoutSeconds: 3
      readinessProbe:
        httpGet:
          path: /health/ready
          port: 3000
        periodSeconds: 5
        failureThreshold: 2
        timeoutSeconds: 5

핵심 원칙: Liveness는 가볍게(메모리만), Readiness는 의존성 포함. Liveness에 DB 체크를 넣으면 DB 일시 장애 시 모든 Pod가 재시작되는 캐스케이딩 장애가 발생한다.

Graceful Shutdown: 안전한 종료

Pod 종료 시 진행 중인 요청을 완료하고, DB 커넥션을 정리하고, 큐 워커를 중지해야 한다. NestJS는 enableShutdownHooks()와 라이프사이클 훅으로 이를 지원한다.

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Shutdown Hooks 활성화
  app.enableShutdownHooks();

  await app.listen(3000);
}

// graceful-shutdown.service.ts
import {
  Injectable, OnModuleDestroy, OnApplicationShutdown,
  BeforeApplicationShutdown,
} from '@nestjs/common';

@Injectable()
export class GracefulShutdownService
  implements BeforeApplicationShutdown, OnApplicationShutdown, OnModuleDestroy
{
  private isShuttingDown = false;

  // 1단계: 새 요청 거부 시작
  onModuleDestroy() {
    this.isShuttingDown = true;
    console.log('[Shutdown] Module destroying — rejecting new requests');
  }

  // 2단계: 진행 중인 작업 완료 대기
  async beforeApplicationShutdown(signal?: string) {
    console.log(`[Shutdown] Signal: ${signal} — waiting for in-flight requests`);

    // K8s가 Endpoints에서 Pod를 제거할 시간 확보
    await new Promise(resolve => setTimeout(resolve, 5000));

    // 큐 워커 중지
    await this.bullQueue?.pause(true);  // 현재 작업 완료 후 중지

    console.log('[Shutdown] In-flight work completed');
  }

  // 3단계: 리소스 정리
  async onApplicationShutdown(signal?: string) {
    console.log(`[Shutdown] Cleaning up resources`);
    // DB 커넥션 풀, Redis, WebSocket 등 정리
    // NestJS가 자동으로 처리하지만, 커스텀 리소스는 여기서
  }

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

Shutdown 미들웨어: 새 요청 거부

// shutdown.middleware.ts
@Injectable()
export class ShutdownMiddleware implements NestMiddleware {
  constructor(private readonly shutdown: GracefulShutdownService) {}

  use(req: Request, res: Response, next: NextFunction) {
    if (this.shutdown.shuttingDown) {
      // 종료 중에는 503 반환 → K8s가 다른 Pod로 라우팅
      res.status(503).json({
        statusCode: 503,
        message: 'Server is shutting down',
      });
      return;
    }
    next();
  }
}

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

K8s 무중단 배포와 Shutdown 타이밍

Pod 종료 시 K8s의 이벤트 순서를 이해해야 무중단 배포를 달성할 수 있다:

# Pod 종료 타임라인
# t=0s  SIGTERM 수신 + Endpoints에서 Pod 제거 시작 (비동기!)
# t=0~5s  iptables/ipvs 업데이트 전파 중 (이 동안 트래픽 올 수 있음)
# t=5s  preStop hook 완료 → 앱 Shutdown 시작
# t=5~25s  진행 중인 요청 처리 완료
# t=30s  terminationGracePeriodSeconds 도달 → SIGKILL

# 해결: preStop hook으로 5초 대기
spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: api
      lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 5"]
            # 5초 동안 기존 요청 처리 + Endpoints 전파 대기
단계 시간 동작
SIGTERM 0s 종료 시그널 수신
preStop 0~5s sleep 5 (Endpoints 전파 대기)
beforeApplicationShutdown 5~10s 진행 중인 요청 완료, 큐 정지
onApplicationShutdown 10~15s DB/Redis 커넥션 종료
SIGKILL 30s 강제 종료 (여기까지 안 가야 정상)

Readiness 기반 트래픽 제어

// Readiness를 수동으로 제어하는 패턴
@Injectable()
export class ReadinessService {
  private ready = false;

  // 앱 초기화 완료 후 ready 전환
  async onApplicationBootstrap() {
    // 캐시 워밍업, 설정 로드 등
    await this.warmupCache();
    await this.loadFeatureFlags();
    this.ready = true;
  }

  // Shutdown 시 ready 해제 → K8s가 트래픽 제거
  onModuleDestroy() {
    this.ready = false;
  }

  isReady(): boolean {
    return this.ready;
  }
}

// Health Controller에서 활용
@Get('ready')
@HealthCheck()
readiness() {
  return this.health.check([
    () => {
      if (!this.readinessService.isReady()) {
        throw new HealthCheckError('App not ready',
          { app: { status: 'down' } });
      }
      return { app: { status: 'up' } };
    },
    () => this.db.pingCheck('database'),
  ]);
}

운영 체크리스트

  • Liveness ≠ Readiness: Liveness에 외부 의존성을 넣지 않는다. DB 장애 시 모든 Pod가 재시작되면 상황이 악화된다
  • 타임아웃 설정: Probe의 timeoutSeconds보다 Health Check 내부 타임아웃을 짧게 설정한다
  • preStop hook: 반드시 sleep 3~5로 Endpoints 전파 시간을 확보한다. 없으면 종료 중인 Pod로 트래픽이 유입된다
  • terminationGracePeriodSeconds: 앱의 최대 요청 처리 시간 + preStop 시간 + 여유분으로 설정한다
  • enableShutdownHooks: 반드시 호출해야 OnApplicationShutdown 등 라이프사이클 훅이 동작한다
  • 모니터링: Interceptor로 Health Check 응답 시간을 메트릭으로 노출하여 의존성 레이턴시 추이를 추적한다

NestJS Health Check과 Graceful Shutdown은 프로덕션 안정성의 기반이다. Terminus로 의존성 상태를 노출하고, 라이프사이클 훅으로 안전하게 종료하며, preStop hook으로 무중단 배포를 달성한다. 이 세 가지가 갖춰져야 비로소 Kubernetes 환경에서 신뢰할 수 있는 서비스를 운영할 수 있다.

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