Post

Spring Boot 애플리케이션에서 graceful shutdown은 어떻게 동작할까?

graceful shutdown이 적용되지 않은 상태에서 서비스를 운영 중이거나 생소하게 느끼시는 분들을 주변에서 많이 접하게 되는 것 같다. 마찬가지로 현재 재직 중인 곳에서도 일부 애플리케이션에는 적용되지 않은 상태였고 잘못 적용된 부분도 있어서 여러 가지 문제들을 겪고 있었다.

이를 계기로 graceful shutdown이 무엇이고 Spring Boot 애플리케이션에서는 어떻게 동작하는지 더 자세히 이해할 수 있었다. 서비스를 운영하면서 꼭 알아야 할 정말 중요한 개념이기 때문에 이해한 내용을 잘 정리해 보면 좋을 것 같다는 생각이 들어 간략하게 글을 작성해 본다.

사용된 개발 환경

  • spring boot 3.2.4
  • tomcat embed core 10.1.19

Body

graceful shutdown은 OS 또는 다른 소프트웨어가 프로세스를 제어된 방식으로 정상적이고 안전하게 종료시킨다는 의미이다. 반대되는 말로는 hard shutdown이 있는데 이는 모든 작업을 즉시 중단하고 프로세스를 종료시킨다는 의미이다.

유닉스와 리눅스에서는 시그널이라는 개념이 있다. 시그널은 소프트웨어 인터럽트라고도 불리며 IPC 기법 중 하나이다. 사용자 또는 커널이 프로세스에게 무언가를 통지하는 목적으로 사용되는데 만약 프로세스가 특정 이벤트 발생을 알리는 시그널을 받으면 자신의 시그널 핸들러를 실행시킨다. 대부분의 시그널 동작은 프로세스를 종료시키는 것과 연관되어 있는데 사용할 수 있는 시그널을 kill -l 명령어로 확인해 볼 수 있다.

프로세스 종료에 일반적으로 사용되는 시그널을 살펴보자. SIGTERM은 프로세스를 안전하게 정상 종료되도록 요청하며 프로세스는 요청을 catch할 수 있고 무시할 수 있다. catch할 수 있다는 의미는 종료 작업을 수행하는 시그널 핸들러를 사용할 수 있다는 의미이며 이를 시그널 후킹이라고 한다. SIGINT는 키보드 인터럽트(Ctrl + C)에서 발생하며 프로세스가 안전하게 정상 종료되도록 요청하는 부분에서 SIGTERM과 동일하다. SIGKILL은 프로세스를 강제 종료하며 시크널 후킹과 무시가 불가능하다. 자식 프로세스들이 종료되지 않은 상태로 종료되어 고아 프로세스가 생기는 문제가 발생할 수 있다.

sigterm-sigkill-toon.jpeg

kill 명령어에선 옵션을 생략하면 SIGTERM이 기본 옵션으로 사용되며 graceful shutdown을 수행한다. SIGKILL은 보통 응답하지 않는 프로세스를 강제 종료 시키는 경우에 사용되며 hard shutdown을 수행한다. 그렇다면 graceful shutdown은 왜 중요할까? hard shutdown을 하게 되면 처리 중인 작업이 중단되거나 사용했던 리소스가 잘 정리되지 않았을 때 생기는 문제가 발생할 수 있기 때문이다. 그래서 우리는 SIGTERM을 사용해서 프로세스를 안전하게 종료시켜야 하고 프로세스에서는 처리 중인 작업을 마치고 리소스가 잘 정리된 상태에서 종료될 수 있도록 시그널 핸들러를 구현해야한다.

Spring Boot 애플리케이션에서는 JVM shutdown hook을 사용해서 JVM이 종료될 때 정상 종료를 위한 작업을 수행한다. JVM은 마지막 비데몬 쓰레드가 종료되거나 System.exit 메소드가 호출될 때 또는 시그널과 같은 시스템 전반의 이벤트에 응답하여 종료된다. JVM shutdown hook은 Runtime.getRuntime().addShutdownHook 메소드로 작업을 수행하는 쓰레드를 등록할 수 있다. JVM이 종료 작업을 시작하면 등록된 JVM shutdown hook들은 지정되지 않은 순서로 시작되어 동시에 병렬적으로 수행된다. 따라서 순서를 보장해야 한다면 동일한 스레드에서 수행될 수 있도록 동일한 hook에서 순차적으로 처리해야 한다.

이 외에도 JVM shutdown hook을 사용할 때 인지해야 할 것들이 몇 가지 있다. shutdown이 시작되면 새로운 shutdown hook을 등록하거나 이전에 등록된 hook을 해제하는 것은 불가능하며 강제로 종료해야만 중지할 수 있다. shutdown hook은 JVM의 라이프 사이클에서 주의가 필요한 시간에 실행되므로 다른 서비스에 의존하는 경우 문제가 발생할 수 있기 때문에 thread-safe하고 데드락이 발생하지 않도록 방어적으로 작성되어야 한다. 또한 hook이 완전히 처리되지 않은 상태에서 종료될 수 있기 때문에 오래 걸리는 작업을 hook에서 처리하는 것은 적합하지 않으며 최대한 빨리 처리될 수 있도록 작성해야 한다. SIGKILL 또는 halt로 인해 JVM을 강제 종료하는 경우에는 hook이 실행되지 않으며 내부 데이터 구조를 손상시키거나 존재하지 않는 메모리에 접근하려고 시도하는 등의 네이티브 메소드에서 문제가 발생하면 JVM이 중단되어 hook의 실행을 보장할 수 없다.

spring-boot-application-run.png

Spring Boot 애플리케이션은 SpringApplication.run 메소드에 의해 실행된다. 메소드 내부로 들어가 보면 애플리케이션 컨텍스트를 생성하기 위해 생성 전 필요한 작업과 생성 후 필요한 작업(prepare, refresh, afterRefresh 등)을 하는 것을 볼 수 있다. shutdown hook은 여러 가지가 있을 수 있는데 앞으로는 헷갈리지 않게 Spring Boot 애플리케이션의 정상 종료 작업을 수행하는 shutdown hook을 spring shutdown hook이라고 부르겠다. spring shutdown hook은 refreshContext 단계에서 등록될 수 있고 prepareEnvironment 단계에서 등록될 수도 있다.

refresh-application-context.png

애플리케이션 컨텍스트 생성 후 컨텍스트를 refresh하기 전 spring shutdown hook을 등록한다.

register-application-context.png

애플리케이션을 실행하는 과정에서 이미 등록되었을 수도 있기 때문에 spring shutdown hook이 등록되지 않은 경우에만 등록한다.

on-application-environment-prepared-event.png application-environment-prepared-event-initialize.png

prepareEnvironment 메소드 내부에서는 애플리케이션 컨텍스트 생성에 필요한 환경이 준비되면 ApplicationEnvironmentPreparedEvent 이벤트를 발생시킨다. 해당 이벤트를 수신하는 리스너(LoggingApplicationListener) 중에서는 초기화 작업을 수행하고 shutdown hook을 등록하는 리스너가 있다.

register-shutdown-hook.png

마찬가지로 shutdown hook이 이미 등록되지 않은 경우에만 등록한다.

add-shutdown-hook-handler.png

shutdown hook을 등록하는 과정에서 spring shutdown hook이 등록되지 않았다면 등록하기 때문에 refreshContext 단계 이전에 이미 등록된 상태일 수도 있는 것이다.

spring-shutdown-hook-run.png

등록된 spring shutdown hook에서는 애플리케이션 컨텍스트를 종료하는 작업을 수행하는 것을 볼 수 있다.

application-context-close-and-wait.png application-context-close.png application-context-do-close.png

애플리케이션 컨텍스트를 종료하는 과정에서 애플리케이션이 정상적으로 종료될 수 있도록 여러 가지 작업을 수행한다. 애플리케이션 컨텍스트 종료 이벤트 발생과 LifecycleProcessor에 의한 Lifecycle 종료 작업 그리고 등록된 빈을 제거하는 부분을 기억해두면 이번 내용을 잘 이해하는 데 도움이 될 것 같다.

웹 애플리케이션에서는 여러 가지 방법으로 요청을 받을 수 있는데 대표적으로 HTTP 통신으로 요청이 인입되는 경우가 있다. 인입된 HTTP 요청들을 처리하는 도중에 애플리케이션을 종료한다면 처리 중인 요청이 중단될 수 있고 클라이언트로 응답을 보내지 못할 수도 있다. 그럼 HTTP 요청을 처리하는 도중에 종료되더라도 spring shutdown hook이 등록되었으니 모든 요청이 중단 없이 처리되어 응답되는 것을 보장할 수 있을까? spring shutdown hook은 등록되었지만 웹 서버를 graceful shutdown하는 작업은 별도의 설정 없이는 기본적으로 동작하지 않는다.

Spring Boot에서는 2.3 버전부터 내장 웹 서버의 graceful shutdown을 공식적으로 지원한다.

1
2
3
4
5
6
server:
  shutdown: graceful # default immediate

spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s # default 30s

애플리케이션의 옵션을 설정해서 graceful shutdown을 간단하게 활성화할 수 있다. graceful shutdown을 사용하더라도 정상 종료를 무기한으로 기다릴 수는 없기 때문에 적절한 timeout을 설정해야 한다. 만약 timeout이 너무 길게 설정된다면 종료 작업에 너무 긴 시간이 소요되거나 프로세스가 정상적으로 동작하지 않는 경우에 종료되지 않을 수 있다. timeout 값을 길게 설정할 필요가 있다면 문제가 발생하는 곳을 개선하는 방향이 더 바람직하다.

스프링에서는 빈의 라이프 사이클을 컨트롤하기 위해 Lifecycle, SmartLifecycle 인터페이스를 지원한다. Lifecycle 인터페이스는 시작과 종료 작업을 정의할 수 있지만 실행 시점에 시작 작업을 자동으로 호출하지는 않는다. 반면 Lifecycle, Phased 인터페이스를 상속받은 SmartLifecycle은 실행과 종료 시점에 시작과 종료 작업이 phase에 맞게 호출된다. 웹 서버의 시작과 종료 작업은 SmartLifecycle 인터페이스를 사용하여 동작하게 되며 graceful shutdown하는 작업도 동일한 방식으로 동작한다.

life-processor-stop-beans.png

Lifecycle 구현체 빈들을 종료하기 전 phase를 기준으로 역순 정렬하고 그룹화한다. LifeCycle의 시작 작업은 phase가 낮은 순에서 높은 순으로 수행되고 종료 작업은 높은 순에서 낮은 순으로 수행된다.

lifecycle-group-stop.png

Lifecycle 구현체 빈들은 종료 작업을 수행하고 지정된 시간만큼 처리가 종료될 때까지 대기하게 된다. 여기서 지정된 시간은 spring.lifecycle.timeout-per-shutdown-phase 옵션으로 설정한 값이 된다.

web-server-graceful-shutdown-lifecycle.png

Lifecycle 구현체 중 하나인 WebServerGracefulShutdownLifecycle에서 웹 서버의 graceful shutdown이 동작한다.

tomcat-web-server-constructor.png tomcat-web-server-shutdown-gracefully.png

graceful shutdown이 활성화된 경우에만 처리하고 그렇지 않은 경우는 즉시 종료한다. 여기서 활성화 여부는 server.shutdown 옵션으로 설정된 값이 된다.

graceful-shutdown-shutdown-gracefully.png

graceful shutdown 동작을 살펴보면 하나 이상의 요청이 활성화된 상태에서도 결국 종료될 수 있는 것을 볼 수 있다. 이는 위에서 언급한 대로 정상 종료를 무기한 기다릴 수 없기 때문이다.

graceful-shutdown-close.png

웹 애플리케이션으로 추가 요청이 들어오지 않도록 프로토콜과 서버 소켓을 중지한다.

graceful-shutdown-await-inactive-or-aborted.png

Container 구현체들이 가지는 계층 구조를 순회하면서 비활성화될 때까지 대기한다. 다만 aborted된 경우에는 종료되지 않아도 더 이상 대기하지 않게 된다. 여기서 계층구조는 StandardEngine(tomcat) → StandardHost(localhost) → StandardContext(TomcatEmbeddedContext) → StandardWrapper(DispatcherServlet)으로 구성된다.

graceful-shutdown-is-active.png

즉 DispatcherServlet에서 처리 중인 요청이 없는지 확인하고 더 이상 처리 중인 요청이 없으면 비활성화되었다고 판단한다.

web-server-start-stop-lifecycle.png

Lifecycle 구현체 중 하나인 WebServerStartStopLifecycle에서 웹 서버를 종료한다. WebServerStartStopLifecycle은 phase가 WebsServerGracefulShutdownLifecycle보다 높기 때문에 시작 작업은 먼저 동작하지만 종료 작업은 늦게 동작한다.

tomcat-web-server-stop.png

웹 서버가 종료되는 과정에서 graceful shutdown을 aborted한다. 만약 이때까지 DispatcherServlet에서 처리 중인 요청이 있다면 더 이상 대기하지 않고 종료되는 것이다.

만약 웹 서버에서 처리 중인 요청이 @Async 또는 java에서 지원하는 async api를 사용해서 비동기 요청을 처리하는 executor에 의존하는 경우는 shutdown이 어떻게 동작할까?

1
2
3
4
5
6
7
8
9
10
11
12
13
@Bean
public Executor executor() {
    ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();

    threadPoolTaskExecutor.setCorePoolSize(10);
    threadPoolTaskExecutor.setMaxPoolSize(10);
    threadPoolTaskExecutor.setWaitForTasksToCompleteOnShutdown(true);
    threadPoolTaskExecutor.setAwaitTerminationSeconds(30);
    threadPoolTaskExecutor.setThreadNamePrefix("EXECUTOR-");
    threadPoolTaskExecutor.initialize();

    return threadPoolTaskExecutor;
}

스프링에서는 ExecutorConfigurationSupport를 상속한 ThreadPoolTaskExecutor을 사용해서 executor를 정의해서 빈으로 등록하여 사용한다. ExecutorConfigurationSupport도 SmartLifecycle을 구현하고 있는데 phase 값이 Integer.MAX_VALUE으로 설정되어 있어 앞에서 언급한 WebServerStartStopLifecycle과 WebsServerGracefulShutdownLifecycle 보다 종료를 위한 작업이 먼저 시작된다. 이 작업이 먼저 시작된다면 웹 서버의 요청을 처리하는 워커 쓰레드가 작업을 마치기 전에 executor가 먼저 종료될 수도 있겠다는 생각이 들 수 있다.

executor-configuration-support-on-application-event.png

애플리케이션 컨텍스트 종료 작업을 시작하기 전 먼저 ContextClosedEvent 이벤트가 발생하게 되는데 executor의 설정 중 acceptTasksAfterContextClose(컨텍스트 종료 후에도 요청을 수용), waitForTasksToCompleteInShutdown(요청이 처리될 때까지 shutdown을 지연)이 모두 비활성화되지 않았다면 lateShutdown을 하게 된다.

executor-configuration-support-stop.png

lateShutdown이 활성화되면 Lifecycle 종료 작업에서 executor가 종료되지 않기 때문에 작업 순서가 앞서더라도 문제가 되지 않는다. 웹 서버의 graceful shutdown 작업이 먼저 수행된 후에 executor 종료 작업이 수행되기 때문에 요청 쓰레드가 executor에 의존하더라도 정상적으로 요청을 처리할 수 있게 된다. 그렇다면 executor는 언제 종료하게 될까?

executor-configuration-support-destroy.png

애플리케이션 컨텍스트 종료 작업에서 lifecycleProcessor에 의한 Lifecycle 종료 작업이 끝난 뒤에 빈을 제거하는 과정에서 destory 메소드가 호출되는 시점에 종료된다.

executor-configuration-support-shutdown.png

executor를 종료할 때 waitForTasksToCompleteOnShutdown 설정이 활성화되어 있지 않으면 처리 중인 요청이 중단될 수 있다. 마찬가지로 무기한으로 기다릴 수 없으므로 적절한 awaitTerminationSeconds 설정이 필요하다.

이 외에도 애플리케이션에서 카프카나 레디스 등의 서비스를 사용하고 있다면 정상 종료를 위해 필요한 작업이 더 존재할 수 있다. 본인이 개발하고 있는 프로젝트에서 Lifecycle, SmartLifeCycle 인터페이스의 구현체들을 살펴보면 Spring Boot에서 지원하는 구현체들을 확인해 볼 수 있고 시작과 종료 시 어떤 작업들을 하고 있는지 파악할 수 있다. 만약 필요하다면 직접 구현해서 사용해 볼 수도 있겠다.

웹 서버의 graceful shutdown을 지원하지 않는 2.3 버전 미만의 프로젝트에서도 비슷한 동작을 구현할 수 있는 예시 코드가 있으니 레거시 프로젝트를 운영 중이라면 참고해 봐도 좋고 직접 Lifecycle, SmartLifecycle 구현체를 구현해 보는 것도 좋을 것 같다.

운영 중인 서비스에서 배포 시에 처리 중인 작업이 중단되어 데이터의 정합성이 깨지거나 데이터와 이벤트가 유실되는 문제가 발생했다. 배포 대상 애플리케이션과 통신 중인 애플리케이션에서는 네트워크 연결이 비정상적으로 끊어지면서 read timeout이 발생하기도 했다.

웹 서버의 graceful shutdown을 공식적으로 지원하지 않는 2.3 버전 미만의 레거시 프로젝트들이 대부분이었기 때문에 직접 구현해야 하는 상황이었다. graceful shutdown을 적용해도 크게 효과를 볼 수 없었는데 그 이유는 처리 시간이 오래 걸리는 작업이 많았기 때문이다. 오래 걸리지만 중단되지 말아야 할 작업을 무기한 기다릴 수 없기에 아직도 일부 애플리케이션은 배포 시에 처리 중인 작업이 중단되는 문제를 겪고 있다. 결국 처리 시간이 오래 걸리는 문제를 해결해야 하는데 코드를 개선해서 해결되는 문제도 있지만 아키텍처를 개선해야 해결할 수 있는 문제도 있다.

복잡하게 얽혀있는 부분도 많고 보수적인 조직에서 속도를 얼마나 낼 수 있을지는 모르겠지만 서비스를 안정적으로 운영하기 위해서 반드시 개선해야 하는 부분이고 현재 조직에서 겪고 있는 대부분의 문제의 원인이기 때문에 지속적으로 해결해나갈 예정이다.

This post is licensed under CC BY 4.0 by the author.