- 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 을 날리기보단 이를 활용해 헬스체크를 하는 것이 맞는 방법일 것 같습니다.
'Backend > NestJS' 카테고리의 다른 글
NestJS - 대충 서비스 만들어보기 (17) (0) | 2023.07.15 |
---|---|
NestJS - 대충 서비스 만들어보기 (16) (0) | 2023.07.08 |
NestJS - 대충 서비스 만들어보기 (14) (0) | 2023.06.17 |
NestJS - 대충 서비스 만들어보기 (13) (0) | 2023.06.12 |
NestJS - 대충 서비스 만들어보기 (12) (0) | 2023.05.27 |