NestJS Terminus 헬스체크

Terminus란?

@nestjs/terminus는 NestJS 애플리케이션의 헬스체크 엔드포인트를 구축하는 공식 모듈입니다. Kubernetes의 livenessProbe, readinessProbe, startupProbe와 직접 연동되어 오케스트레이터가 애플리케이션 상태를 정확히 판단하고 트래픽을 제어할 수 있게 합니다.

설치 및 기본 설정

npm install @nestjs/terminus
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheck, HealthCheckService, HttpHealthIndicator,
  TypeOrmHealthIndicator, MemoryHealthIndicator, DiskHealthIndicator,
} from '@nestjs/terminus';

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

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      // DB 연결 확인
      () => this.db.pingCheck('database'),
      // 외부 서비스 응답 확인
      () => this.http.pingCheck('auth-service', 'https://auth.example.com/health'),
      // 힙 메모리 300MB 이하
      () => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
      // RSS 메모리 500MB 이하
      () => this.memory.checkRSS('memory_rss', 500 * 1024 * 1024),
      // 디스크 사용률 90% 이하
      () => this.disk.checkStorage('disk', { thresholdPercent: 0.9, path: '/' }),
    ]);
  }
}

응답 예시:

{
  "status": "ok",
  "info": {
    "database": { "status": "up" },
    "auth-service": { "status": "up" },
    "memory_heap": { "status": "up" },
    "disk": { "status": "up" }
  },
  "details": { ... }
}

Liveness vs Readiness vs Startup 분리

Kubernetes의 세 가지 프로브는 각각 다른 목적을 가집니다. 하나의 /health로 통합하면 안 됩니다.

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

  // Liveness — "프로세스가 살아있는가?"
  // 실패 시: Pod 재시작
  // 외부 의존성 체크 금지! (DB/Redis 장애로 Pod 재시작 연쇄 방지)
  @Get('liveness')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 500 * 1024 * 1024),
      () => this.memory.checkRSS('memory_rss', 800 * 1024 * 1024),
    ]);
  }

  // Readiness — "트래픽을 받을 수 있는가?"
  // 실패 시: Service 엔드포인트에서 제거 (트래픽 차단)
  // 외부 의존성 체크 포함
  @Get('readiness')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 3000 }),
      () => this.redis.isHealthy('redis'),
      () => this.http.pingCheck('auth-service', 'https://auth.example.com/health', {
        timeout: 3000,
      }),
    ]);
  }

  // Startup — "초기화가 완료되었는가?"
  // 실패 시: 다른 프로브 시작 안 함
  // 느린 초기화(캐시 워밍, 마이그레이션 등)에 사용
  @Get('startup')
  @HealthCheck()
  startup() {
    return this.health.check([
      () => this.db.pingCheck('database'),
    ]);
  }
}

Kubernetes 매니페스트

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: api
          livenessProbe:
            httpGet:
              path: /health/liveness
              port: 3000
            initialDelaySeconds: 0
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/readiness
              port: 3000
            initialDelaySeconds: 0
            periodSeconds: 5
            failureThreshold: 3
          startupProbe:
            httpGet:
              path: /health/startup
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 30  # 최대 150초 대기

커스텀 Health Indicator

내장 인디케이터 외에 비즈니스 로직에 맞는 커스텀 인디케이터를 작성할 수 있습니다.

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

@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
  constructor(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;

      if (latency > 100) {
        // 느린 응답 — degraded 상태 표시
        return this.getStatus(key, true, { latency: `${latency}ms`, status: 'degraded' });
      }

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

// 큐 처리 상태 인디케이터
@Injectable()
export class QueueHealthIndicator extends HealthIndicator {
  constructor(@InjectQueue('email') private emailQueue: Queue) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    const waiting = await this.emailQueue.getWaitingCount();
    const failed = await this.emailQueue.getFailedCount();

    const isHealthy = waiting < 10000 && failed < 500;

    const result = this.getStatus(key, isHealthy, {
      waiting,
      failed,
      ...(waiting >= 10000 && { warning: 'Queue backlog too large' }),
    });

    if (!isHealthy) {
      throw new HealthCheckError('Queue unhealthy', result);
    }
    return result;
  }
}

Graceful Shutdown 연동

헬스체크와 Graceful Shutdown을 연동하면 배포 시 무중단을 보장합니다.

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

  get shuttingDown() {
    return this.isShuttingDown;
  }

  async onApplicationShutdown(signal: string) {
    this.isShuttingDown = true;
    console.log(`Received ${signal}, starting graceful shutdown...`);
    
    // readiness 실패 → K8s가 트래픽 차단
    // 기존 요청 처리 대기
    await new Promise(resolve => setTimeout(resolve, 10000));
  }
}

// Readiness에 shutdown 상태 반영
@Injectable()
export class ShutdownHealthIndicator extends HealthIndicator {
  constructor(private readonly shutdown: GracefulShutdownService) {
    super();
  }

  isHealthy(key: string): HealthIndicatorResult {
    if (this.shutdown.shuttingDown) {
      throw new HealthCheckError(
        'App is shutting down',
        this.getStatus(key, false),
      );
    }
    return this.getStatus(key, true);
  }
}

// main.ts
app.enableShutdownHooks();

MikroORM / Prisma 인디케이터

// MikroORM 헬스체크
@Injectable()
export class MikroOrmHealthIndicator extends HealthIndicator {
  constructor(private readonly orm: MikroORM) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      await this.orm.em.getConnection().execute('SELECT 1');
      return this.getStatus(key, true);
    } catch (error) {
      throw new HealthCheckError('DB failed', this.getStatus(key, false));
    }
  }
}

// Prisma 헬스체크
@Injectable()
export class PrismaHealthIndicator extends HealthIndicator {
  constructor(private readonly prisma: PrismaService) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      await this.prisma.$queryRaw`SELECT 1`;
      return this.getStatus(key, true);
    } catch (error) {
      throw new HealthCheckError('Prisma failed', this.getStatus(key, false));
    }
  }
}

보안 — 헬스체크 엔드포인트 보호

// 내부 네트워크에서만 상세 정보 노출
@Controller('health')
export class HealthController {
  @Get()
  @HealthCheck()
  check(@Req() req: Request) {
    const isInternal = req.ip?.startsWith('10.') || req.ip === '::1';

    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.redis.isHealthy('redis'),
    ]).then(result => {
      if (!isInternal) {
        // 외부 요청에는 status만 노출
        return { status: result.status };
      }
      return result;  // 내부 요청에는 전체 상세 정보
    });
  }
}

운영 팁

  • Liveness에 외부 의존성 넣지 말 것: DB 장애 시 모든 Pod가 재시작되는 연쇄 장애 발생
  • Readiness 타임아웃: 외부 서비스 체크 시 반드시 timeout 설정 (기본값이 너무 길 수 있음)
  • Startup Probe 활용: 초기화가 느린 앱에서 failureThreshold × periodSeconds로 충분한 시간 확보
  • 메트릭 연동: 헬스체크 결과를 구조화 로깅으로 기록하고 Prometheus로 수집
  • 캐시 워밍: Startup Probe에서 캐시 로드 완료를 확인해야 cold start 트래픽 방지

정리

NestJS Terminus는 Kubernetes 프로브와 직접 연동되는 헬스체크 인프라입니다. Liveness(프로세스 생존), Readiness(트래픽 수용), Startup(초기화 완료)을 분리 설계하고, 커스텀 인디케이터로 비즈니스 상태까지 모니터링하면 운영 안정성을 크게 높일 수 있습니다. Graceful Shutdown과 연동하면 배포 시 무중단 서비스를 보장합니다.

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