SpringBoot Graceful-Shutdown 개념과 동작 원리

공병주(Chris)·2023년 11월 2일
4


과거 무중단배포를 구현했을 때, SpringBoot의 Graceful shutdown 기능을 활용했었는데요. 실제로 breakpoint를 찍으면서 어떻게 내부적으로 동작하는지 알아보려 합니다.

우선 Graceful Shutdown은 SpringBoot 2.3부터 사용할 수 있습니다.

Graceful shutdown?

기본 개념

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();
    }
}

테스트 시나리오는 아래와 같습니다.

  • 2번의 요청을 보낸다.
  • 2번째 요청을 보낸 후 15초가 지나기 전(요청이 완료되기 전)에 서버를 종료시킨다.
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

서버 종료 방식(linux kill)

위 설정만으로 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일텐데요.

두 옵션에는 차이가 있습니다.

  • -9(SIGKILL) : 프로세스를 즉시 종료. 따라서, 처리중이던 작업들의 유무에 관계 없이 즉시 종료됩니다.
  • -15(SIGTERM) : 프로세스를 정상적으로 종료시킵니다. 소프트웨어 프로세스에게 종료하라는 시그널을 준다고 생각하시면 됩니다.

따라서, 프로세스를 정상적으로 종료시켜줘야 Graceful-Shutdown을 사용할 수 있습니다.

kill -15 명령어가 실행된다면 Spring의 SpringApplicationShutdownHook 이라는 객체를 통해 Spring을 종료시키기 시작합니다.

동작 원리

Graceful Shutdown이 설정된 Tomcat

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()의 값을 확인해보시면 실제로 처리중인 요청의 개수가 반환됩니다.

위를 통해 완료되지 않은 요청들에 대해 대기하는 방식입니다.

Graceful shutdown의 timeout

하지만, 무한루프를 돌거나 데드락으로 인해 영영 응답하지 못하는 요청들에 대해서는 어떻게 해야할까요?

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으로 인해 정상적으로 종료되지 않은 것들에 대한 처리는?

처음엔, 사용자의 요청이 정상적으로 처리되지 않으면 어떻게 하지..? 라는 생각이 들었습니다. 하지만, 조금만 생각해보니 쓸데 없는 걱정일 수도 있겠다는 생각이 들었습니다.

timeout-per-shutdown-phase를 5s로 설정했다고 가정해보겠습니다. 과연 5s 이상 소요되는 요청이 존재할까요? 만약 있다면, 해당 API는 shutdown 중에만 문제가 되는 것이 아니라, 서버가 구동중인 상황에서도 문제일 것입니다.

timeout-per-shutdown-phase은 무한 루프에 빠졌거나, 데드락 등으로 인해 요청이 무한히 처리될 수 없을 때의 상황에 대비한 것이라 생각합니다.

3s로만 설정해도 정상적인 API들은 시간 내에 요청에 대한 응답이 완료될 것 입니다.

0개의 댓글