Spring graceful shutdown (+ async, virtual thread)
배포나 scale-in 등의 이유로 Spring 서버 종료 시, 해당 서버가 처리하고 있는 HTTP 요청은 어떻게 될까?
요청 처리를 중단하고 서버가 종료되기 보다는 요청 처리를 다 마친 뒤에 서버가 종료되는 것이 좋지 않을까?
Spring에서는 프로세스 종료 시 처리하고 있는 요청을 끝까지 처리할 수 있도록 graceful shutdown 기능을 제공한다.
이번 포스팅에서는 Spring의 graceful shutdown 기능과 이를 비동기 작업에서 적용하는 방법에 대해서 알아보도록 하겠다.
추가로 virtual thread 설정 시 TaskExecutor를 커스터마이징 해야하는 이유를 알아보도록 하겠다.
graceful shutdown 설정
graceful shutdown 설정을 하기 위해서 server.shutdown property를 graceful로 설정만 해주면 된다.
(SpringBoot 3.4 버전 부터는 default로 graceful이 설정되어 있다 - 참고: release note
# application.yml
server:
shutdown: graceful
아래와 같이 처리에 10초가 걸리는 API를 생성한 뒤
@RestController
class ApiController {
private val logger = LoggerFactory.getLogger(this::class.java)
@GetMapping("/sync")
fun sync(): String {
this.logger.info("Sync job is starting...")
Thread.sleep(10_000)
this.logger.info("Sync job has finished!")
return "OK"
}
}
이 API로 요청을 보낸 직후에 프로세스 종료 signal을 보내면 해당 요청에 대한 작업 처리가 완료된 이후에 서버가 종료된다.
- IntelliJ의 경우 애플리케이션 종료 시 2(SIGINT) signal을 보낸다.
- Docker의 경우 container stop 시 15(SIGTERM) signal을 보낸다. (참고)
INFO 71591 --- [omcat-handler-0] ApiController : Sync job is starting...
INFO 71591 --- [ionShutdownHook] GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
INFO 71591 --- [omcat-handler-0] ApiController : Sync job has finished!
INFO 71591 --- [tomcat-shutdown] GracefulShutdown : Graceful shutdown complete
(참고로 9(SIGKILL) signal은 프로세스가 바로 종료되기 때문에 graceful 설정이 무효화된다.)
만약 요청 처리가 너무 길어지게 되면 언제까지고 기다릴 수 없기 때문에 종료 대기 timeout 시간을 지정할 수도 있다.
spring.lifecycle.timeout-per-shutdown-phase property에 원하는 timeout 시간을 정해주면 된다.
(default는 30초)
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30000 # 30 seconds
graceful shutdown 설정이 된 상태에서 요청을 처리하고 있는 중에도 지정한 timeout 시간을 넘어가게되면 서버는 종료된다.
async task를 모두 처리 한 후에 서버 종료 시키기
Spring에서는 @Async 어노테이션을 통해 비동기로 작업을 실행시킬 수 있다.
앞서 HTTP 요청이 모두 처리된 뒤 서버 프로세스가 종료되는 것과 같이, 비동기 작업 또한 모두 완료 된 뒤에 서버 프로세스가 종료되는 것이 좋을 것이다.
(server.shutdown property는 Tomcat과 같은 웹 서버의 설정이기 때문에 비동기 작업에 대해서는 적용되지 않는다)
virtual thread를 설정하지 않는 한 기본적으로 Spring 서버는 비동기 작업을 관리하고 실행하기 위해 ThreadPoolTaskExecutor 를 사용하는데,
비동기 작업이 실행 중에 프로세스 종료 signal을 받아도 실행 중인 비동기 작업이 모두 완료될 때 까지 서버 프로세스는 종료되지 않는다.
(하지만 spring.lifecycle.timeout-per-shutdown-phase 에 명시된 timeout 시간이 지나면 서버가 종료된다)
하지만 ThreadPoolTaskExecutor 의 Task Queue에 쌓인 작업들은 모두 완료되지 못한 채 서버 프로세스는 종료된다.
Task Queue에 쌓인 비동기 작업도 모두 완료한 뒤에 서버 프로세스가 종료되도록 하려면 spring.task.execution.shutdown.await-termination property를 true로 설정하면 된다.
spring.task.execution.shutdown.await-termination-period property는 TaskExecutor가 모든 비동기 작업을 처리하기까지 대기하는 timeout 설정이다.
# application.yml
spring:
task:
execution:
thread-name-prefix: async-task-
shutdown:
await-termination: true
await-termination-period: 30000 # 30 seconds
virtual thread 사용 시 custom TaskExecutor
SpringBoot는 3.2 버전부터 virtual thread 설정을 지원한다. (참고 - release note)
spring.threads.virtual.enabled property를 true로 설정하면 Spring에서 사용하는 웹서버와 TaskExcutor가 virtual thread를 사용한다.
여기서 중요하게 알아야하는 것은 비동기 작업을 처리하는 TaskExcutor가 기존 ThreadPoolTaskExecutor 에서 SimpleAsyncTaskExecutor 로 바뀐다는 점이다.
ThreadPoolTaskExecutor 와 SimpleAsyncTaskExecutor 는 상속관계의 차이가 있고
ThreadPoolTaskExecutor 의 경우 thread lifecycle 관리를 하는 반면,
SimpleAsyncTaskExecutor 의 경우 관리를 못한다는 차이가 있다.
이러한 차이점은 release note 에서도 설명하고 있으며
When virtual threads are enabled, the applicationTaskExecutor bean will be a SimpleAsyncTaskExecutor configured to use virtual threads.
...
Other spring.task.execution.* properties are ignored as they are specific to a pool-based executor.
관련 Issue의 코멘트에서도 자세히 설명해준다.
SimpleAsyncTaskScheduler with its SmartLifecycle implementation does not manage the lifecycle of handed-off tasks on (virtual) execution threads, it just stops/restarts its internal scheduler thread.
실질적으로 SimpleAsyncTaskExecutor 이 어떤 문제를 일으킬 수 있는지 확인해보자.
Spring virtual thread 설정을 하고
# application.yml
spring:
threads:
virtual:
enabled: true
아래와 같이 처리에 5초가 걸리는 비동기 작업을 실행하는 API를 생성한 뒤
@Service
class AsyncService {
private val logger = LoggerFactory.getLogger(this::class.java)
@Async
fun asyncJob() {
this.logger.info("Async job is starting...")
Thread.sleep(5_000)
this.logger.info("Async job has finished!")
}
}
@RestController
class ApiController(
private val asyncService: AsyncService,
private val taskExecutor: TaskExecutor,
) {
private val logger = LoggerFactory.getLogger(this::class.java)
@GetMapping("/async")
fun async(): String {
this.asyncService.asyncJob()
return "OK"
}
}
비동기 작업을 실행하는 API에 요청을 보낸 후 비동기 작업이 완료되기 전에 프로세스 종료 signal을 보내면
실행 중인 비동기 작업에 대해 java.lang.InterruptedException이 발생하면서 서버가 바로 종료된다.
비동기 작업을 처리하기 위해 virtual thread를 사용하면서도 모든 비동기 작업을 완료시킨 뒤에 서버가 종료시키기 위해서는
SimpleAsyncTaskExecutor 를 그대로 사용할 순 없고 custom TaskExecutor를 만들어 bean으로 등록해서 사용할 필요가 있다.
아래는 virtual thread를 사용하는 ThreadPoolTaskExecutor 를 bean으로 등록하는 예제이다.
@EnableAsync
@Configuration
class AsyncConfiguration(
@Value("\${spring.task.execution.thread-name-prefix}") private val taskThreadNamePrefix: String,
@Value("\${spring.task.execution.shutdown.await-termination}") private val taskAwaitTermination: Boolean,
@Value("\${spring.task.execution.shutdown.await-termination-period}") private val taskAwaitTerminationMillis: Long,
) {
private val logger = LoggerFactory.getLogger(this::class.java)
@Bean
@ConditionalOnThreading(Threading.VIRTUAL) // when `spring.threads.virtual.enabled` is `true`
fun taskExecutor(): TaskExecutor {
this.logger.info("Virtual thread is enabled. Register custom TaskExecutor")
return ThreadPoolTaskExecutor().apply {
this.setVirtualThreads(true)
this.setThreadNamePrefix(taskThreadNamePrefix)
this.setWaitForTasksToCompleteOnShutdown(taskAwaitTermination)
this.setAwaitTerminationMillis(taskAwaitTerminationMillis)
// not to reuse virtual thread (it spawns new thread every time)
this.keepAliveSeconds = 0
this.queueCapacity = 0
this.corePoolSize = 0
// to propagate MDC context
this.setTaskDecorator { runnable ->
val contextMap = MDC.getCopyOfContextMap()
Runnable {
if (contextMap != null) {
MDC.setContextMap(contextMap)
}
runnable.run()
}
}
}
}
}
- virtual thread를 생성하도록 virtual thread factory를 등록하는 ThreadPoolTaskExecutor.setVirtualThreads(true) 설정
- virtual thread는 pooling 하는 것을 지양하기 때문에 (참고 - Java 21 Core Libraries document of Oracle)
virtual thread를 pooling 하지 않으면서 비동기 작업을 실행 마다 virtual thread를 생성하여 처리하도록
keepAliveSeconds, queueCapacity, corePoolSize를 0으로 설정해두었다.
위에서 등록한 ThreadPoolTaskExecutor을 사용하기 위해 application property를 아래와 같이 설정할 수 있다.
# application.yml
spring:
threads:
virtual:
enabled: true
task:
execution:
thread-name-prefix: async-task-
shutdown:
await-termination: true
await-termination-period: 30000 # 30 seconds
위와 같이 설정을 하면 비동기 작업 실행 마다 새로운 virtual thread가 생성되어 작업을 처리할 수 있다.
혹은 제한없이 virtual thread를 생성하는 것을 막기 위해서 maxPoolSize와 queueCapacity를 조절할 수 있다.
@EnableAsync
@Configuration
class AsyncConfiguration(
...
) {
@Bean
@ConditionalOnThreading(Threading.VIRTUAL) // when `spring.threads.virtual.enabled` is `true`
fun taskExecutor(): TaskExecutor {
return ThreadPoolTaskExecutor().apply {
...
// not to reuse virtual thread (it spawns new thread every time)
this.keepAliveSeconds = 0
this.queueCapacity = 100 // <= maxPoolSize 만큼 작업이 실행 중인 경우에 최대 100개 까지 Task Queue에 작업 추가
this.corePoolSize = 0
this.maxPoolSize = 100 // <= 최대 100개의 쓰레드가 동시에 실행
...
}
}
}