본문 바로가기

Backend/NestJS

NestJS - 대충 서비스 만들어보기 (15)

- Health Check

 

서비스를 운영하다보면 현재 서비스 상태가 정상적으로 서빙할 수 있는 상태인지, 그렇지 않은지 체크가 필요합니다.

서비스가 건강한 상태인지를 체크하는 장치를 우리는 일반적으로 헬스체크 라고 부릅니다.

 

팀에서 운영하는 서버들에도 이 부분은 아직 좀 미흡한 점들이 있습니다.

서비스가 건강한 상태인지를 체크하려면 사용하는 RDB, ElastiCache 와의 커넥션 상태를 비롯해 장비의 인메모리, 디스크 상태등을

확인해야 하지만 어떤 서버들에 대해선 /health path 만 따놓고 그냥 200 OK 응답을 반환하도록 되어있는 경우도 있습니다.

 

현 팀에선 어디든 그렇겠지만 RDB 와 Redis 의 커넥션 상태가 제일 중요한데, 이러한 DB 들과의 통신이 되지 않는 상황에선 앱이

죽어버릴거고 그러면 /health 에 대한 응답도 되지 않으니 헬스체크가 실패하지 않을까 라는 생각에 의해 그대로 둔 것도 있습니다.

또는 일정 시간동안은 remote DB 에 붙지 못하더라도 local redis 또는 인메모리에 의해 처리되는 상황을 기대하기도 하구요.

 

여하튼 이런것은 팀 내에서 정책 또는 전략적으로 선택할 수 있는 방향일뿐이고, 어찌됐건 헬스체크는 필요합니다.

서비스에 대한 헬스체크가 100% 를 유지하지 못하는 경우엔 즉각 알람을 발생시켜 현재 서비스의 상태를 전파하기도하고,

이런 헬스체크 path 를 통해 로드밸런서에서 타겟 그룹을 관리하고 로드밸런싱 또는 페일오버 등을 수행해주기도 하니까요.

 

- Terminus

 

NestJS 는 헬스체크 라이브러리인 Terminus(@nestjs/terminus) 를 제공합니다.

 

  • HttpHealthIndicator
  • TypeOrmHealthIndicator
  • MongooseHealthIndicator
  • SequelizeHealthIndicator
  • MikroOrmHealthIndicator
  • PrismaHealthIndicator
  • MicroserviceHealthIndicator
  • GRPCHealthIndicator
  • MemoryHealthIndicator
  • DiskHealthIndicator

이미 제공하는 상태 표시기 들입니다. 많기도 하네요. 대체로 HTTP, ORM, Memory, Disk 등에 대한건 이미 제공하는 모양새입니다.

작성해뒀던 서버에선 TypeORM 을 사용하고 있으니 TypeOrmHealthIndicator 를 한번 연동해보겠습니다.

 

> npm install @nestjs/terminus

 

먼저 @nestjs/terminus 라이브러리를 설치해줍니다.

 

> nest g controller health-check

 

그리고 nest-cli 를 사용해 health-check controller 를 생성했습니다. 여기에 내용을 채워야합니다.

 

...
import { TerminusModule } from '@nestjs/terminus';
import { HealthCheckController } from './health-check/health-check.controller';

@Module({
  imports: [
    ...,
    TerminusModule,
  ],
  controllers: [HealthCheckController],
  ...
})
export class AppModule {}

 

nest-cli 에 의해 HealthCheckController 는 이미 추가되었을거구요. Terminus 는 위와 같이 AppModule 에 추가합니다.

 

import { Controller, Get } from '@nestjs/common';
import {
  HealthCheckService,
  HealthCheck,
  TypeOrmHealthIndicator,
} from '@nestjs/terminus';

@Controller('health-check')
export class HealthCheckController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([() => this.db.pingCheck('database')]);
  }
}

 

생성된 HealthCheckController 은 위와 같이 작성해줍니다. 다른 부분들은 특별할 것이 없고, @nestjs/terminus 에서 제공하는 TypeORMIndicator 의 pingCheck 메서드를 사용해 헬스체크를 합니다.

TypeORMIndicator 는 단순히 DB 가 잘 살아있는지를 SELECT 1 (오라클이라면 SELECT 1 FROM DUAL) 쿼리로 확인하기 때문에

실제 리소스에 부담은 없습니다. 해당 쿼리가 타임아웃으로 실패하면 DB 가 살아있지 않다고 판단하는 상태 표시기입니다.

 

HealthCheck 데코레이터도 추가된 것을 볼 수 있는데 저 데코레이터 자체가 특별한 기능을 지금은 하지 않습니다.

이 데코레이터는 이 라우터가 헬스체크 용이라는 것을 표시함으로써 만약 swagger 가 연동되어있다면 자동으로 문서화해주고, 헬스체크 요청의 응답에 대해선 캐싱을 하지 않는 정도의 기능을 갖고 있습니다.

 

이제 http://localhost:3000/health-check 경로로 GET 요청을 보내보도록 하겠습니다.

 

 

현재는 MySQL 이 정상 동작 중이니 헬스체크에도 별다른 특이사항은 없습니다. 응답에서 보이는 up 상태값은 정상 동작하는 상태를
뜻합니다. 프로메테우스를 연동했을 때 서비스들의 상태를 나타내는 지표에서도 많이 봤던 up 지표네요.

 

이제 의도적으로 MySQL 을 죽이고, 다시 헬스체크를 시도해보겠습니다. 저는 docker 를 사용해 MySQL container 를 띄웠으니
이 컨테이너를 pause 하고 다시 헬스체크를 시도해봤습니다.

 

 

아까 설명드린것처럼 상태를 체크하는 간단한 쿼리를 날렸으나 해당 쿼리가 타임아웃으로 실패해 헬스체크에도 실패한 결과를 볼 수
있습니다.

 

- Custom HealthIndicator

 

서비스의 헬스체크를 하기 위해선 NestJS 에서 제공하는 상태 표시기만으로 부족할지도 모릅니다.

이럴 경우엔 HealthIndicator 를 상속받는 커스텀 상태 표시기를 직접 만들 수 있습니다.

역시나 지금의 서버에선 필요하진 않지만 현재의 팀에선 ElastiCache 나 Local redis container 를 자주 사용하므로,
Redis 의 상태 표시기를 만들어보도록 하겠습니다.

 

> npm install ioredis
> mkdir src/redis

 

먼저 ioredis 라이브러리를 설치하고, 따로 모듈화 하기 위해 src 하위에 redis 디렉토리를 생성했습니다.

 

import { Module, forwardRef } from '@nestjs/common';
import IORedis from 'ioredis';

export const LOCAL_REDIS_CLIENT = Symbol('LOCAL_REDIS_CLIENT');

@Module({
  providers: [
    {
      provide: LOCAL_REDIS_CLIENT,
      useFactory: () => new IORedis(),
    },
  ],
  exports: [LOCAL_REDIS_CLIENT],
})
export class RedisClientModule {}

 

src/redis 하위에 redis-client.module.ts 파일을 먼저 작성해주었습니다.

ioredis 로 local redis 커넥션을 생성하고, 이를 export 하는 간단한 모듈입니다.

 

import { Injectable, Inject } from '@nestjs/common';
import {
  HealthIndicator,
  HealthIndicatorResult,
  HealthCheckError,
} from '@nestjs/terminus';
import { Redis } from 'ioredis';
import { LOCAL_REDIS_CLIENT } from './redis-client.module';

@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
  constructor(@Inject(LOCAL_REDIS_CLIENT) private localRedisClient: Redis) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    const isHealthy = await this.localRedisClient.ping().catch(() => false);
    const result = this.getStatus(key, !!isHealthy);

    if (isHealthy) {
      return result;
    }

    throw new HealthCheckError('Local Redis PING failed', result);
  }
}

 

src/redis 하위에 redisHealthIndicator.ts 파일로 커스텀하게 생성한 상태 표시기입니다.

@nestjs/terminus 가 제공하는 HealthIndicator 를 상속하는 클래스를 만들었고, 위의 모듈에서 생성했던 localRedisClient 를

주입받아 ping 쿼리를 수행한 후 해당 결과를 헬스체크 판별 값으로 사용하는 코드입니다.

local redis 에 ping 쿼리를 날려 성공하면 해당 값을 반환하고, 실패하면 HealthCheckError 를 뱉습니다.

 

ping 에 catch 를 붙여둔 것은 redis 의 경우 커넥션 정상 유무와 상관없이 커맨드에 실패할 경우 해당 커맨드를 default 로 20번 재시도합니다. 그 안에서도 결과를 얻지 못하면 Reached the max retries per request limit 와 같은 에러가 던져지죠.

따라서 ping 커맨드에 대한 catch 가 없으면 헬스체크 요청이 InternalServerError 를 던지기 때문에 의도적으로 catch 를 통해 던져진 에러를 잡아 isHealthy 값을 false 로 할당한 것입니다.

 

위 코드 중 getStatus 는 HealthIndicator 추상 클래스가 갖고 있는 메서드입니다.

이 메서드는 HealthIndicatorResult 를 돌려주며, 상태를 나타내는 key(string) 와 측정 결과인 isHealthy(boolean) 을 넘겨주면 됩니다. 결과에 포함시킬 data 를 세번째인자로 optioanl 하게 넘길 수 있는데 저는 필요없으니 생략했습니다.

 

...
import { RedisClientModule } from './redis/redis-client.module';
import { RedisHealthIndicator } from './redis/redisHealthIndicator';

@Module({
  imports: [
    ...,
    TerminusModule,
    RedisClientModule,
  ],
  controllers: [HealthCheckController],
  providers: [..., RedisHealthIndicator],
})
export class AppModule {}

 

AppModule 에 작성한 RedisClientModule 과 RedisHealthIndicator 를 추가해줍니다. 원래라면 RedisHealthIndicator 를 ClientModule 에서 해결했어야하지만 일단 쉽게 갔습니다.

 

import { Controller, Get } from '@nestjs/common';
import {
  HealthCheckService,
  HealthCheck,
  TypeOrmHealthIndicator,
} from '@nestjs/terminus';
import { RedisHealthIndicator } from '../redis/redisHealthIndicator';

@Controller('health-check')
export class HealthCheckController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private localRedisHealthIndicator: RedisHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.localRedisHealthIndicator.isHealthy('local-redis'),
    ]);
  }
}

 

기존의 HealthCheckController 에 작성한 RedisHealthIndicator 를 추가했습니다. 이제 헬스체크 요청에 대한 응답을 살펴보죠.

 

 

redis 서비스가 정상 동작하고 있는 상태에선 역시나 local-redis 상태 또한 up 으로 표시됩니다.

저는 제 맥에서 brew 를 통해 redis 서비스를 띄운 상태이므로, brew services stop redis 명령어로 redis 서비스를 내린 후 다시

헬스체크 요청을 해보겠습니다.

 

 

redis 서비스가 내려간 상태에서 local-redis 의 상태가 down 으로 바뀐 것을 볼 수 있습니다.

당연히 헬스체크 status 는 전체 중 하나라도 실패하면 안될테니 error 로 표시되구요.

 

 

let isHealthy = false;
const localRedisClient = new IORedis();

localRedisClient.on('connect', () => {
  isHealthy = true;
});

localRedisClient.on('error', err => {
  isHealthy = false;
});

 

사실 레디스를 제가 작성한 것처럼 체크하는 것은 좀 맞지 않는 방법일 수 있습니다. 레디스의 경우엔 맺어진 커넥션에서 에러가 발생할 경우 이벤트 핸들러를 통해 핸들링이 가능합니다. 위 콘솔 로그의 경우엔 아무런 처리를 하지 않아서 Unhandled 로 남는 것이구요.

바로 위처럼 이벤트 핸들러를 붙일 수 있는데 굳이 ping 을 날리기보단 이를 활용해 헬스체크를 하는 것이 맞는 방법일 것 같습니다.