코딩하는 오징어
JVM의 종료와 Graceful Shutdown 본문
개발자는 어플리케이션을 개발 할 때 많은 것들을 고려한다. 코드를 작성하고 나서는 비즈니스 로직이 정확한 결과를 산출해내는지 검증하기 위해 테스트 코드를 작성하기도 하며, 성능 테스트를 통해 시스템에 병목 지점은 없는지 등을 확인한다. 어플리케이션이 정상적으로 시작되고 실행이 지속되는지는 매우 중요하다. 하지만 어플리케이션이 정상적으로 종료되는지도 굉장히 중요하다. 이번 글을 통해서 JVM 플랫폼 위에서 실행되는 어플리케이션이 정상적으로 종료되기 위한 여러 내용들을 소개하려고한다.
프로세스 종료
먼저, 프로세스를 종료시키기 위해 프로세스로 전달하는 시그널에 대해서 살펴보자. 시그널이 전달되면 시스템은 다음과 같이 동작한다.
- 시그널에대한 핸들러는 커널에 프로그래밍 되어 있으며 프로세스가 시그널을 받게 되면 시그널에 해당하는 bit를 마킹한다.
- 다음 명령어가 진행될 때 마킹된 bit를 확인 후 커널에게 제어를 넘긴다. (Context Switching 발생)
- 벡터를 통해 적절한 시그널 핸들러를 찾아 핸들러를 실행시킨다.
프로세스를 종료 시키는 다음과 같은 시그널들이 있다.
- SIGKILL
- SIGTERM
- SIGINT
- SIGQUIT
- SIGSTP
- SIGHUP
이 글에서는 SIGKILL, SIGTERM, SIGINT에 대해서만 알아보겠다. 다른 시그널에 대한 내용도 살펴보고 싶다면 다음 링크를 참조하면 된다.
http://programmergamer.blogspot.com/2013/05/clarification-on-sigint-sigterm-sigkill.html
SIGKILL은 프로세스를 즉시 종료 시킨다. 해당 시그널을 받으면 프로세스가 종료되기 전에 수행되어야 하는 종료 절차를 실행하지 않고 즉시 종료하므로 graceful shutdown을 위해서라면 해당 시그널을 통해 프로세스를 종료시키는 것은 피해야한다. kill 명령어의 옵션 인자로 -9를 주면 프로세스에 SIGKILL 시그널을 전달하게된다.
> kill -9 {PID}
SIGTERM 시그널도 프로세스를 종료시키지만 프로세스를 종료 시키기전에 해당 시그널을 핸들링 할 수 있다. 즉, 프로세스를 종료시키기 전에 종료 절차를 진행한 후에 프로세스를 종료 시킨다. 만약 프로세스가 해당 시그널을 핸들링하는 코드를 작성하지 않았다면 즉시 종료시킨다. graceful shutdown을 위해서라면 해당 시그널을 사용하면 된다. kill 명령어에 어떠한 옵션인자도 주어지지 않는다면 SIGTERM을 프로세스로 전달하게 된다.
> kill {PID}
SIGINT 시그널은 SIGTERM과 동일하지만 시그널을 보내기 위한 트리거를 키보드로 부터 받는다. CTRL + C를 입력하여 프로세스를 종료시킬때 SIGINT가 전달된다.
JVM 종료
JVM은 다음과 같은 경우에 정상적인 종료 절차를 밟게 된다.
- 데몬 스레드가 아닌 일반 스레드가 모두 종료되는 시점
- System.exit 메서드가 호출 될 경우
- 프로세스가 종료 시그널을 받게 된 경우
위와 같은 상황을 통해 JVM이 종료된다면 JVM은 가장 먼저 등록되어 있는 모든 shutdown-hook을 실행시킨다. shutdown-hook은 Runtime 클래스의 addShutdownHook(Thread hook) 메서드를 통해 등록 할 수 있다. 하나의 JVM에 여러 개의 shutdown-hook을 등록 할 수 있다. 다만, 두 개 이상의 shutdown-hook이 등록되어 있는 경우에는 random으로 실행된다. 등록된 shutdown-hook들은 종료 시점에 동시에 실행되므로 thread-safe하게 코드를 작성해야한다.
JVM에서 종료 절차가 시작됐는데 어플리케이션에서 사용하던 스레드가 계속해서 동작 중이라면 종료 절차가 진행되는 과정 내내 기존의 스레드도 계속해서 실행되기도 한다. JVM은 종료 과정에서 계속해서 실행되고 있는 어플리케이션 내부의 스레드에 대해 중단 절차를 진행하거나 인터럽트를 걸지 않는다. 계속해서 실행되던 스레드는 결국 종료 절차가 끝나는 시점에 강제로 종료된다. 만약 shutdown-hook이나 finalize 메서드가 작업을 마치지 못하고 계속해서 실행된다면 종료 절차가 멈추는 셈이며 JVM은 계속해서 대기 상태로 머무르기 때문에 결국 JVM을 강제로 종료하는 수밖에 없다. JVM을 강제로 종료시킬 때는 JVM이 스스로 종료되는 것 이외에 shutdown-hook을 실행하는 등의 어떤 작업도 하지 않는다. 따라서 위에 말했다시피 shutdown-hook은 dead lock이나 hang이 걸리지 않도록 안전하게 개발해야한다.
Spring Context는 종료 시점에 사용하던 bean들을 정리하는 등의 Context를 정리하는 코드를 shutdown-hook으로 추가한다. 다음 코드는 Spring에서 Runtime.addShutdownHook(Thread hook) 메서드를 사용하는 것을 보여준다.
public abstract class AbstractApplicationContext extends DefaultResourceLoader
implements ConfigurableApplicationContext {
...
@Override
public void registerShutdownHook() {
if (this.shutdownHook == null) {
// No shutdown hook registered yet.
this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
@Override
public void run() {
synchronized (startupShutdownMonitor) {
doClose();
}
}
};
Runtime.getRuntime().addShutdownHook(this.shutdownHook);
}
}
...
protected void doClose() {
// Check whether an actual close attempt is necessary...
if (this.active.get() && this.closed.compareAndSet(false, true)) {
if (logger.isDebugEnabled()) {
logger.debug("Closing " + this);
}
if (!NativeDetector.inNativeImage()) {
LiveBeansView.unregisterApplicationContext(this);
}
try {
// Publish shutdown event.
publishEvent(new ContextClosedEvent(this));
}
catch (Throwable ex) {
logger.warn("Exception thrown from ApplicationListener handling ContextClosedEvent", ex);
}
// Stop all Lifecycle beans, to avoid delays during individual destruction.
if (this.lifecycleProcessor != null) {
try {
this.lifecycleProcessor.onClose();
}
catch (Throwable ex) {
logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
}
}
// Destroy all cached singletons in the context's BeanFactory.
destroyBeans();
// Close the state of this context itself.
closeBeanFactory();
// Let subclasses do some final clean-up if they wish...
onClose();
// Reset local application listeners to pre-refresh state.
if (this.earlyApplicationListeners != null) {
this.applicationListeners.clear();
this.applicationListeners.addAll(this.earlyApplicationListeners);
}
// Switch to inactive.
this.active.set(false);
}
}
...
}
Graceful Shutdown
어플리케이션을 개발할 때 반드시 고려해야할 요소들이 몇 가지 있다. 사용할 리소스들을 초기화 화거나 리소스를 초기화하는데 오랜 시간이 걸린다면 Lazy Loading을 통해 어플리케이션이 구동되는데 걸리는 시간을 줄이기도 한다. 어떤 로그를 남길 것이고 로그를 남기는데 필요한 도구들도 고려해야한다. 무중단 서비스를 제공하면서 동시에 성능을 수평적으로 높이기 위한 scale out을 고려하여 어플리케이션을 stateless하게 개발하기도한다. 이외에도 많은 것들을 고려하지만 어플리케이션을 잘 종료하는 것도 고려해야한다. 개발자는 종종 어플리케이션의 종료에 대해서는 소홀하게 생각하게된다. 어플리케이션이 사용하던 자원들을 반납하고 현재 처리 중이던 task들을 정리해야한다. 그렇지 않으면 어플리케이션을 종료 해야하는 상황마다 버그가 발생할 수 있다. 어플리케이션의 타입이 Web Server Application이라면 어플리케이션이 종료 절차를 밟은 뒤 부터는 더 이상 요청을 받지 않아야 하며, 이미 받은 요청이 존재한다면 해당 요청들을 모두 처리한 후에 어플리케이션을 종료하여야한다. 그렇지 않으면 이미 요청을 한 클라이언트는 4-way hand shake 절차가 생략되어 Connection Reset 응답을 받음으로써 에러 응답을 받는 등의 문제가 발생 할 수 있다. 어플리케이션의 타입이 Batch Application이라면 처리중이던 Task를 모두 처리하고 종료하거나 현재 까지 처리된 지점에 save point를 만들어 다시 실행시켰을 때 save point 지점부터 다시 처리되도록 할 수 있다. (save point를 관리하기 힘들기 때문에 Task를 멱등성있게 개발하는 것이 더 좋은 대안 일 수 있다.) Kafka를 메시지 큐로 이용하는 Consumer라면 어플리케이션이 종료될 때 어디까지 메시지를 처리하였는지 offset을 commit하거나 별도로 저장 및 관리하여야 한다.
Spring Boot 2.3이상부터는 web server를 종료할 때 graceful shutdown 할지 즉시 종료시킬지 정하는 property가 있다. application.properties에 server.shutdown=graceful을 명시하면 application을 graceful하게 종료시킬 수 있다. Tomcat, Jetty, Undertow, Netty 네 가지 타입의 embedded servlet container는 graceful shutdown을 지원한다. application의 종료 절차가 시작되면 Tomcat, Jetty, Netty는 network layer에서 요청을 더 이상 받지 않도록 처리하며 Undertow인 경우에는 새로운 요청을 계속해서 받지만 즉시 503 (Service Unavailable)응답을 전달한다. default 값은 server.shutdown=immediate이다. server를 graceful하게 종료시킬 경우, 이미 진행 중인 요청을 처리하는데 데드락이 발생하여 어플리케이션이 종료되지 못하고 hang이 걸릴 수 있기 때문에 timeout을 설정해주는 것도 고려하여야한다. Spring Boot에서는 application.properties에 spring.lifecycle.timeout-per-shutdown-phase=1m을 명시하여 shutdown하는 데 필요한 시간을 설정할 수 있다. Spring Boot 2.3 이상 부터는 이런 메커니즘을 지원하지만 그렇지 않은 경우에는 어플리케이션이 안전하게 종료하는 방법을 찾아보아야한다.
위에서 말한 property를 설정한다면 Tomcat 기준으로 org.springframework.boot.web.embedded.tomcat 패키지에 있는 GracefulShutdown 클래스를 확인하면된다. 해당 클래스에는 shutDownGracefully(GracefulShutdownCallback callback) 메서드가 있다. 해당 메서드는 TomcatWebServer 클래스에서 호출하며 TomcatWebServer 클래스는 WebServerGracefulShutdownLifecycle 클래스에서 의존성을 갖고 있다. WebServerGracefulShutdownLifecycle는 ServletWebServerApplicationContext 클래스에서 singleton bean으로 등록되며 위에서 언급한 Spring Context가 종료될때 shutdown-hook으로 등록된 Thread에서 호출하는 AbstractApplicationContext 클래스의 doClose() 메서드가 호출되어 정리되는 bean들 중 하나이다. 즉, Spring Context가 JVM이 종료될 때 호출하는 shutdown-hook으로 등록한 Task들 중의 GracefulShutDown 클래스의 shutDownGracefully(GracefulShutdownCallback callback) 메서드도 포함된다. 상세 로직은 해당 클래스의 메서드들을 참고해보자.
장황한 글이 된 것 같아 이해하기 어려울 수 있지만 이 글을 통해 전달하고자 하는 부분은 개발자는 어플리케이션의 시작과 실행 중인 상황 뿐만아니라 종료되는 시점도 잘 고려해야한다는 것이다.
참고 자료
- 자바 병렬 프로그래밍 chapter 7.4
- https://www.baeldung.com/spring-boot-web-server-shutdown
'알쓸신잡' 카테고리의 다른 글
Gradle Dependency Configuration (0) | 2021.09.26 |
---|---|
우리는 테스트 코드를 왜 짜야할까?? (3) | 2020.11.28 |
Redirect 와 Forward (2) | 2020.04.27 |
OLTP와 OLAP (1) | 2020.01.05 |
데이터 모델링에서 정규화의 의의와 성능 논쟁 (0) | 2020.01.05 |