Graceful shutdown
Graceful shutdown이란?
새로운 버전의 릴리즈를 배포할 때, 구버전의 서버를 내리고 새로운 버전의 서버를 다시 띄우게 된다. 구버전 서버를 내리는 과정에서 프로세스를 종료하게 되면 현재 요청을 받아 진행 중인 작업들이 모두 응답 없이 취소가 된다.
블루그린 배포나 카나리아 배포와 같은 무중단 배포 전략을 사용한다면 발생하지 않을 수 있지만, 그렇지 않다면 프로세스를 종료하는 과정에서 유실되는 요청들에 대한 대책이 필요하다.
이를 위한 전략이 Graceful shutdown으로,
1.
종료 요청을 받으면 이후에 들어오는 요청에 대해서는 거부하고
2.
이미 요청을 받아 처리 중인 요청들이 있다면 해당 요청들에 대한 응답이 나간 이후 서비스를 종료하는 것이다.
Spring에서는 SpringBoot 2.3부터 Graceful shutdown을 지원하여 사용할 수 있다.
시그널과 프로세스 종료 방식
Graceful shutdown이 적용하여 서비스를 종료하고 싶다면 프로세스 종료 방식에도 신경써야 한다.
$ 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
Plain Text
복사
리눅스의 프로세스 종료 명령어인 kill에는 위와 같이 많은 시그널을 통해 종료할 수 있다.
이 중 가장 많이 사용되는 SIGKILL, SIGTERM에 대해 알아보자면,
1.
SIGKILL(kill -9 {pid})
프로세스를 즉시 종료 시키는 시그널로, 해당 시그널을 받으면 프로세스가 종료 전에 수행되는 절차를 무시하고 즉시 종료한다. graceful shutdown이 적용되었더라도 SIGKILL 시그널을 받으면 무시되고 프로세스가 종료된다.
2.
SIGTERM(kill -15 {pid})
마찬가지로 프로세스를 종료시키는 시그널이지만, 프로세스를 종료 시키기 전에 종료 절차를 진행 후에 프로세스를 종료한다. 만약 종료 절차를 핸들링하는 로직이 없다면 바로 종료되고, graceful shutdown이 적용되어 있다면 진행 중인 요청을 전부 마무리하고 종료한다.
Graceful shutdown이 적용된 Spring에서는 SIGTERM 요청을 받게 되면, JVM은 등록되어 있는 shutdown hook 중 랜덤한 하나를 선택하여 실행한다. JVM은 종료 과정에서 계속해서 실행되고 있는 스레드가 있다면 중단하거나 인터럽트를 걸지 않는다. 만약 shutdown hook이나 finalize 메서드가 작업을 마치지 못하고 계속 실행된다면, JVM은 계속 대기 상태로 머물기 때문에 JVM을 강제 종료하는 수 밖에 없다. JVM을 강제 종료하면 다른 종료 절차를 아무 것도 수행하지 않고 종료된다.
Webflux를 사용하는 Spring Gateway에서 스레드 종료를 확인하는 방법
Spring Cloud Gateway는 일반적인 Spring과 다르게 thread per request 모델을 사용하지 않고, 비동기-논블로킹인 reactive stream 모델을 사용하여 요청을 처리한다. 때문에 요청을 처리 중인 스레드에서 블로킹되지 않고 다른 작업을 수행하여, 하나의 스레드가 여러 요청을 동시에 처리할 수 있다.
이러한 reactive stream 모델에서 어떻게 요청에 대한 응답이 완료되었는지 알 수 있을까?
reactive stream 모델에서는 각 요청을 Mono나 Flux로 래핑하여 데이터 스트림을 처리한다. reactive stream에서는 모든 작업을 이벤트로 취급하여 이벤트 핸들러를 통해 처리하고, 이벤트 루프를 통해 지속적으로 이벤트 큐를 확인한다. 그 과정에서 스트림이 완료되면 onComplete 시그널이 발생하고, 이를 통해 Gateway가 해당 요청이 완전히 처리되었다는 것을 확인한다.
이를 통해서 graceful shutdown이 적용되었을 때, 모든 요청에 대한 응답이 나갔는지를 알 수 있다.
Graceful shutdown 적용하기
적용 사유
Graceful shutdown이 보장되지 않아 처리 중이던 요청이 완료되지 않은 상태로 쿠버네틱스의 gateway Pod가 종료되는 이슈 발생
이로인해 트래픽 유실이 발생함
이를 해결하고자 애플리케이션에 Graceful shutdown 적용한다.
적용 방법 및 적용
Spring에서는 SpringBoot 2.3.0 이상 버전부터 공식적으로 graceful shutdown을 지원한다.
Spring 공식문서에 적혀있는 사용법은 다음과 같다.
server:
shutdown: graceful
Plain Text
복사
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
Plain Text
복사
공식 문서에 적혀 있는 대로 application.yml 파일에 다음의 설정을 추가하여 graceful shutdown과 timeout을 설정하였다.
동작 테스트하기
Graceful shutdown 테스트
graceful shutdown이 잘 반영되어 수행되는지 확인하기 위해 간단한 테스트를 추가해보았다.
@Slf4j
@Component
public class TestFilter extends AbstractGatewayFilterFactory<TestFilter.Config> {
public TestFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
try {
log.error("sleep!!!");
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return chain.filter(exchange);
}
}
}
Java
복사
이와 같이 AuthFilter가 호출되면 5초동안 sleep을 호출하는 로직을 추가한 후,
spring:
cloud:
gateway:
routes:
- id: test
uri: http://localhost:1111
predicates:
- Path=/test/**
filters:
- name: TestFilter
YAML
복사
test uri로 호출되면 AuthFilter가 수행되도록 설정하였다.
애플리케이션을 실행 시킨 후
위의 test 요청을 호출 후 바로 SIGTERM 신호를 보내면,
이와 같이 graceful shutdown이 동작 중이라는 메세지와 함께 5초 뒤 수행 중인 요청이 마무리되고 애플리케이션이 종료된다.
graceful shutdown이 수행된 이후 추가적인 요청을 보내보면,
이와 같이 연결이 거부되는 것을 확인할 수 있다.
Graceful shutdown Timeout 테스트
graceful shutdown timeout을 테스트 해보았다.
try {
log.error("sleep!!!");
Thread.sleep(60000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
Java
복사
필터의 로직 부분에서 응답 시간을 60초로 변경 후 위와 동일하게 동작해보면,
timeout에 맞춰 Graceful shutdown이 aborted 되었다는 것을 확인할 수 있다.
처음 이해하기에는 timeout 시간이 되면 수행 중인 요청이 있더라도, 요청을 강제로 끊고 종료할 것으로 예상했다.
하지만 위 동작 결과에는 시간이 나오지 않지만 종료된 시점은 timeout인 20초가 아니라, 요청에 대한 응답 완료 시간인 60초가 지나고 나서 애플리케이션이 종료되었다.
그 이유에 대해서는 명확하게 원인을 찾지는 못하였다. 다만 추정해보자면,
일단 출력되는 메세지에서 강제로 종료하겠다는 메세지가 아닌 graceful shutdown이 aborted 되었다는 메세지가 출력된다.
관련 코드를 보면 시간이 지나면 aborted에 의해 shutdown이 완료됨 상태로 바꾸면서 그 결과를 활성화된 요청이 있음으로 저장한다.
또한 애플리케이션 종료 코드를 보면, 137(SIGKILL, 강제종료)이 아닌 143(SIGTERM, 종료 조건에 따른 순차 종료)으로 되어있다.
마지막으로 공식문서에도 Kubernetes의 컨테이너가 grace period 이후 SIGKILL 시그널을 전송한다고 되어있고,
그에 맞춰 쿠버네티스 설정인 termiationGracePeriodSeconds를 변경하라고 되어있다.
이러한 사항들을 볼 때, 스프링에서는 graceful shutdown 중에 설정한 timeout이 되더라도 강제로 종료하지 않고 응답이 마무리 될 때까지 기다리는 것으로 추정된다.