과거 무중단배포를 구현했을 때, SpringBoot의 Graceful shutdown 기능을 활용했었는데요. 실제로 breakpoint를 찍으면서 어떻게 내부적으로 동작하는지 알아보려 합니다.
우선 Graceful Shutdown은 SpringBoot 2.3부터 사용할 수 있습니다.
Graceful Shutdown은 아래와 같은 개념입니다.
Graceful shutdown이 진행되면 더이상 요청은 거부
한다.처리 중인 요청이 있다면 마무리하고 server를 종료
시킨다.아래와 같이, 요청을 처리하는데에 15s가 소요되는 Controller를 선언했습니다.
@RestController
public class GracefulShutdownController {
private final Logger logger = LoggerFactory.getLogger(GracefulShutdownController.class);
@PostMapping("/graceful-test")
public ResponseEntity<Void> checkGracefulShutdown(@RequestBody GracefulShutDownRequest request) {
logger.info("요청 수신 seq : ({})", request.getSequence());
try { // 15초 소요되는 비즈니스 로직이 있다고 가정
Thread.sleep(15000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
logger.info("요청 처리 완료 seq : ({})", request.getSequence());
return ResponseEntity.noContent().build();
}
}
테스트 시나리오는 아래와 같습니다.
2023-11-02 15:00:57.126 [http-nio-8080-exec-2] INFO dandi.dandi.graceful.GracefulShutdownController - 요청 수신 seq : (1)
2023-11-02 15:01:12.129 [http-nio-8080-exec-2] INFO dandi.dandi.graceful.GracefulShutdownController - 요청 처리 완료 seq : (1)
2023-11-02 15:01:21.957 [http-nio-8080-exec-3] INFO dandi.dandi.graceful.GracefulShutdownController - 요청 수신 seq : (2)
// 서버 종료
2023-11-02 15:01:36.957 [http-nio-8080-exec-3] INFO dandi.dandi.graceful.GracefulShutdownController - 요청 처리 완료 seq : (2)
2023-11-02 15:01:36.988 [tomcat-shutdown] INFO org.springframework.boot.web.embedded.tomcat.GracefulShutdown - Graceful shutdown complete
2023-11-02 15:01:37.000 [SpringApplicationShutdownHook] INFO org.springframework.integration.endpoint.EventDrivenConsumer - Removing {logging-channel-adapter:_org.springframework.integration.errorLogger} as a subscriber to the 'errorChannel' channel
2023-11-02 15:01:37.000 [SpringApplicationShutdownHook] INFO org.springframework.integration.channel.PublishSubscribeChannel - Channel 'application.errorChannel' has 0 subscriber(s).
2023-11-02 15:01:37.001 [SpringApplicationShutdownHook] INFO org.springframework.integration.endpoint.EventDrivenConsumer - stopped bean '_org.springframework.integration.errorLogger'
2023-11-02 15:01:37.018 [SpringApplicationShutdownHook] INFO org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean - Closing JPA EntityManagerFactory for persistence unit 'default'
2023-11-02 15:01:37.020 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
2023-11-02 15:01:37.031 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.
위 로그에서 알 수 있듯이, 서버를 종료하려고 해도, 진행중이던 2번째 요청까지는 모두 수행하고 서버를 종료
하는 것을 확인할 수 있습니다.
이것이 바로 Graceful shutdown입니다.
그렇다면 Graceful shutdown은 어떻게 사용할 수 있을까요?
먼저, 아래와 같이 설정 파일에 서버를 shutdown하는 방식을 graceful로 설정
해야합니다.
server:
shutdown: graceful // 기본 값은 immediate
위 설정만으로 Graceful shutdown이 가능한 것이 아닙니다. 서버를 종료하는 방식
이 중요한데요.
Linux에서 process를 종료할 때, kill
명령어를 사용하실 겁니다. kill 명령어의 옵션은 다양합니다.
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
많이 사용하시는 옵션이 -9와 -15일텐데요.
두 옵션에는 차이가 있습니다.
따라서, 프로세스를 정상적으로 종료시켜줘야 Graceful-Shutdown을 사용할 수 있습니다.
kill -15 명령어가 실행된다면 Spring의 SpringApplicationShutdownHook
이라는 객체를 통해 Spring을 종료시키기 시작합니다.
server:
shutdown: graceful
package org.springframework.boot.web.embedded.tomcat;
public class TomcatWebServer implements WebServer {
private final GracefulShutdown gracefulShutdown;
// ...
public TomcatWebServer(Tomcat tomcat, boolean autoStart, Shutdown shutdown) {
Assert.notNull(tomcat, "Tomcat Server must not be null");
this.tomcat = tomcat;
this.autoStart = autoStart;
this.gracefulShutdown = (shutdown == Shutdown.GRACEFUL) ? new GracefulShutdown(tomcat) : null;
initialize();
}
먼저, SpringBoot가 구동될 때, shutdown 설정 값이 graceful이라면 아래와 같이 GracefulShutdown 객체를 생성해 TomcatWebServer에 할당합니다.
이렇게 생성된 Tomcat이 동작하다가, 종료된다면 shutDownGracefully이라는 메서드가 호출
됩니다.
package org.springframework.boot.web.embedded.tomcat;
public class TomcatWebServer implements WebServer {
private final GracefulShutdown gracefulShutdown;
// ...
@Override
public void shutDownGracefully(GracefulShutdownCallback callback) {
if (this.gracefulShutdown == null) {
callback.shutdownComplete(GracefulShutdownResult.IMMEDIATE);
return;
}
this.gracefulShutdown.shutDownGracefully(callback);
}
}
final class GracefulShutdown {
private static final Log logger = LogFactory.getLog(GracefulShutdown.class);
private final Tomcat tomcat;
private volatile boolean aborted = false;
// ...
void shutDownGracefully(GracefulShutdownCallback callback) {
logger.info("Commencing graceful shutdown. Waiting for active requests to complete");
new Thread(() -> doShutdown(callback), "tomcat-shutdown").start();
}
private void doShutdown(GracefulShutdownCallback callback) {
List<Connector> connectors = getConnectors();
connectors.forEach(this::close); // 새로운 요청을 받지 않기 위해 connector들을 close
try {
for (Container host : this.tomcat.getEngine().findChildren()) {
for (Container context : host.findChildren()) {
while (isActive(context)) {
if (this.aborted) {
logger.info("Graceful shutdown aborted with one or more requests still active");
callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE);
return;
}
Thread.sleep(50);
}
}
}
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
logger.info("Graceful shutdown complete");
callback.shutdownComplete(GracefulShutdownResult.IDLE);
}
private boolean isActive(Container context) {
try {
if (((StandardContext) context).getInProgressAsyncCount() > 0) {
return true;
}
for (Container wrapper : context.findChildren()) {
if (((StandardWrapper) wrapper).getCountAllocated() > 0) {
return true;
}
}
return false;
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
}
먼저, doShutdown 메서드에서 connector들을 닫음으로써 새로운 요청들을 받지 않도록
합니다.
doShutdown 메서드 내부 while문에서 isActive라는 메서드를 통해 현재 처리중인 요청이 있는지 확인
하고 있다면 루프에서 50ms씩 기다리면서 지속적으로 완료되지 않은 요청에 대한 확인을 합니다.
확인하는 자세한 방법에 대해서는 해당 글을 참고하시면 좋을 것 같습니다.
isActive 함수의 ((StandardWrapper) wrapper).getCountAllocated()
의 값을 확인해보시면 실제로 처리중인 요청의 개수가 반환됩니다.
위를 통해 완료되지 않은 요청들에 대해 대기하는 방식
입니다.
하지만, 무한루프를 돌거나 데드락으로 인해 영영 응답하지 못하는 요청들에 대해서는 어떻게 해야할까요?
spring:
lifecycle:
timeout-per-shutdown-phase: 5s
위와 같이 timeout-per-shutdown-phase 옵션을 주면 shutdown 최대 대기 시간을 지정 가능합니다.
final class GracefulShutdown {
// ...
void abort() {
this.aborted = true;
}
}
Graceful shutdown이 5초가 넘어간다면 GracefulShutdown 객체의 abort 메서드를 통해 Graceful shutdown aborted 값을 true로 변경
시키고, 아래 코드의 doShutdown 메서드의 while 문안에 aborted 조건문이 true가 되어 Graceful shutdown이 종료
됩니다.
final class GracefulShutdown {
// ...
void shutDownGracefully(GracefulShutdownCallback callback) {
logger.info("Commencing graceful shutdown. Waiting for active requests to complete");
new Thread(() -> doShutdown(callback), "tomcat-shutdown").start();
}
private void doShutdown(GracefulShutdownCallback callback) {
List<Connector> connectors = getConnectors();
connectors.forEach(this::close);
try {
for (Container host : this.tomcat.getEngine().findChildren()) {
for (Container context : host.findChildren()) {
while (isActive(context)) {
if (this.aborted) { // true로 변경되어서 Graceful Shutdown이 종료됨
logger.info("Graceful shutdown aborted with one or more requests still active");
callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE);
return;
}
Thread.sleep(50);
}
}
}
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
logger.info("Graceful shutdown complete");
callback.shutdownComplete(GracefulShutdownResult.IDLE);
}
처음엔, 사용자의 요청이 정상적으로 처리되지 않으면 어떻게 하지..? 라는 생각이 들었습니다. 하지만, 조금만 생각해보니 쓸데 없는 걱정일 수도 있겠다는 생각이 들었습니다.
timeout-per-shutdown-phase를 5s로 설정했다고 가정해보겠습니다. 과연 5s 이상 소요되는 요청이 존재할까요? 만약 있다면, 해당 API는 shutdown 중에만 문제가 되는 것이 아니라, 서버가 구동중인 상황에서도 문제일 것입니다.
timeout-per-shutdown-phase은 무한 루프에 빠졌거나, 데드락 등으로 인해 요청이 무한히 처리될 수 없을 때의 상황에 대비한 것이라 생각합니다.
3s로만 설정해도 정상적인 API들은 시간 내에 요청에 대한 응답이 완료될 것 입니다.