kimyu0218
  • [etc] graceful shutdown으로 사용자 경험 저하 방지하기 (with docker & nest)
    2024년 01월 15일 18시 26분 08초에 업로드 된 글입니다.
    작성자: @kimyu0218
    UX는 프론트엔드 담당이라고? 백엔드도 할 수 있어 😎

    새로운 어플리케이션이 출시되는 등의 이유로 서버를 종료하는 상황을 가정해보자. 사용자의 요청을 처리하는 도중에 서버를 종료하면 어떻게 될까? 사용자는 서버로부터 유의미한 응답을 받을 수 없기 때문에 불편함을 느낄 것이다. 지금부터 모든 요청을 처리한 후에 종료하는 방법을 알아보자!

    graceful shutdown

    난 사실 우아한 종료가 아닌, 우하하 종료를 구현했다 🤪

    그레이스풀 종료는 서버가 모든 요청에 대해 응답하고, 더 이상 처리해야 하는 작업이 없을 때 종료하는 것을 의미한다.

    혹시 SIGKILL을 보내고 있진 않나요?

    그레이스풀 종료를 구현하기 위해서는 몇 가지 유의사항이 있다. 우선, 프로세스를 강제로 죽이면 안된다. (처음엔 SIGKILL을 보내고 있으면서 그레이스풀 종료를 구현했다고 생각했다. 발표 자료 준비하면서 잘못 구현한 거 깨닫고 수정했다. 하지만 고쳤죠??)

     

    어플리케이션이 처리할 수 있는 시그널을 전달하자

    *시그널 : 프로세스에게 특정 이벤트가 발생했다는 것을 알려준다.

    종료 시그널을 이용하여 어플리케이션을 안전하게 종료해야 한다. 근데 어떤 종료 시그널을 이용해야 할까?
     
    Termination Signals

    SIGTERM vs. SIGKILL
    • SIGTERM : 프로세스에게 정중하게 종료할 것을 요청한다. 따라서 프로세스가 해당 신호를 무시할 수 있으며 적절하게 처리할 수도 있다.
    • SIGKILL : 프로세스에게 즉시 종료할 것을 요청한다. 프로세스는 해당 신호를 무시할 수 없고, 즉시 종료하기 때문에 시그널을 핸들링 할 수 없다.

    SIGKILL은 프로세스가 갑자기 종료되므로 어떠한 정리 작업도 수행하지 못한다. 따라서 데이터가 손실될 수 있고, 사용자의 요청도 마무리하지 못한다. 하지만 SIGTERM을 이용하면 프로세스가 해당 시그널을 핸들링 할 수 있기 때문에 자원을 해제하는 등 그레이스풀 종료를 수행할 수 있다.


    도커 컨테이너에 SIGTERM 전달하기

    도커 컨테이너에서 돌아가는 어플리케이션에 SIGTERM을 전달해보자. 우선 컨테이너를 종료시키는 도커 CLI를 정리해봤다.

    docker stop [CONTAINER_ID] # 일반 종료
    docker kill [CONTAINER_ID] # 강제 종료

    docker stop

    컨테이너의 메인 프로세스는 SIGTERM 시그널을 받는다. 만약 grace period가 지나고도 컨테이너가 종료되지 않으면 SIGKILL을 보내 강제로 컨테이너를 종료한다. (grace period의 기본값은 10초다!!)

    docker kill

    컨테이너의 메인 프로세스는 SIGKILL 시그널을 받고 곧바로 컨테이너를 종료한다.
     


    사용자 요청 멈춰! 어플리케이션 종료를 준비한다

    컨테이너는 종료 시그널을 수신한 후에 어플리케이션을 종료해야 한다.

    process.on('SIGTERM', async () => { // SIGTERM 시그널 리스닝
      logger.log('🖐️ Received SIGTERM signal. Start Graceful Shutdown...');
      /*
        1. 사용자 요청 그만 받기
        2. 모든 작업 완료 시, 어플리케이션 종료하기
      */
      process.exit(0);
    });

     

    nest의 라이프사이클

    먼저 nest의 라이프사이클부터 알아보자. 어플리케이션의 라이프사이클은 크게 초기화, 실행, 종료로 나뉜다. 이러한 라이프사이클을 이용하여 모듈과 서비스를 초기화하고, 종료 시그널을 수신했을 때 그레이스풀 종료를 수행할 수 있다.

    위 이미지는 종료 시그널을 받았을 때 수행되는 라이프사이클 훅 메서드다.

     


    사용자 요청 받지 않습니다

    어플리케이션을 안전하게 종료하기 위해서는 http 서버를 닫아 더 이상 사용자 요청을 받지 않아야 한다. (사용자 요청을 계속 받으면 종료 못한다!! 언제까지 기다려요~)

    process.on('SIGTERM', async () => { // SIGTERM 시그널 리스닝
      logger.log('🖐️ Received SIGTERM signal. Start Graceful Shutdown...');
      await server.close(); // http server 종료
      /* TODO : 모든 작업 완료 시, 어플리케이션 종료하기 */
      process.exit(0);
    });

    자, 이제 자원 정리를 시작하지

    지금부터 라이프사이클을 이용하여 자원을 해제할 것이다. 아래는 이번 포스팅에서 다룰 자원 해제 목록이다.

    정리할 자원 목록
    • 로그 파일 닫기
    • 데이터베이스 연결 해제
    *app.close() : `onModuleDestroy()`와 `onApplicationShutdown()` 훅을 트리거한다.

     
    먼저, 셧다운 훅을 활성화하기 위해 `enableShutdownHooks()`를 호출한다. (셧다운 훅 리스너는 시스템 자원을 소비하기 때문에 기본적으로 활성화 하지 않는다.)

    app.enableShutdownHooks();

    로그 파일 닫기

    현재 winton 로거를 이용하여 로그를 파일로 남기고 있다. 활성화된 transport에 모든 로그 메시지를 전달하고 안전하게 종료해보자. winston 로거의 close()

    ...
    @Injectable()
    export class LoggerService implements OnApplicationShutdown {
      constructor(@Inject('WINSTON') private readonly logger: Logger) {}
    
      async onApplicationShutdown(signal?: string): Promise<void> { // app.close() 호출 후에 트리거
        this.logger.close(); // winston 로거 graceful shutdown
      }
      ...
    }

    데이터베이스 연결 해제하기

    이번에는 데이터베이스 연결을 해제할 것이다. 로거와 마찬가지로 `OnApplicationShutdown`을 implements 하고 연결을 해제하는 함수만 작성하면 된다!
    typeorm의 DataSource.close()

    ...
    @Module({
      imports: [
        TypeOrmModule.forRootAsync({ ... }),
      ],
    })
    export class DatabaseModule implements OnApplicationShutdown {
      constructor(private readonly dataSource: DataSource) {}
    
      async onApplicationShutdown(signal?: string): Promise<void> { // app.close() 호출 후에 트리거
        await this.dataSource.destroy(); // 데이터베이스 연결 해제
      }
    }

    `server.close()` 이후에 `app.close()`를 요청하면 자동으로 `onApplicationShutdown()`이 실행된다. 해당 작업이 끝난 후 `process.exit(0)`을 호출하여 정상적으로 종료시킨다.

    process.on('SIGTERM', async () => { // SIGTERM 시그널 리스닝
      logger.log('🖐️ Received SIGTERM signal. Start Graceful Shutdown...');
      await server.close(); // http server 종료
      await app.close(); // 셧다운 훅 트리거
      process.exit(0); // 어플리케이션 종료
    });

    참고자료

    댓글