Skip to content

더 무중단스러운 배포 ‐ Graceful shutdown

Dohyeon Han edited this page Aug 29, 2023 · 13 revisions

Spring Boot graceful shutdown

프로젝트에서 무중단 배포를 구성했는데, 많은 데이터로 인해 캐시가 되지 않으면 10초까지도 걸릴 수 있는 API가 있었다. 그렇다면 API 요청 도중에 스프링 부트 컨테이너가 내려가면 어떻게 되는지 실험해봤다. graceful

먼저 해당 환경을 구성하기 위해 docker compose를 통해 nginx와 두 개의 Spring boot 서버를 띄어놓았다.

Dockerfile

FROM openjdk:11

WORKDIR /usr/src/app

ARG JAR_PATH=build/libs/youngcha-0.0.1-SNAPSHOT.jar

COPY $JAR_PATH app.jar

CMD ["java", "-Dspring.profiles.active=${SERVER_MODE}", "-jar", "app.jar"]

docker-compose.graceful.yml

version: "3.9"

services:
  spring1:
    container_name: spring1
    restart: always
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      SERVER_MODE: spring1
    ports:
      - "8081:8080"
    depends_on:
      - nginx
    networks:
      - local

  spring2:
    container_name: spring2
    restart: always
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      SERVER_MODE: spring2
    ports:
      - "8082:8080"
    depends_on:
      - nginx
    networks:
      - local

  nginx:
    container_name: nginx
    image: nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/conf.d/application.conf:/etc/nginx/conf.d/application.conf
      - ./nginx/conf.d/service-url.inc:/etc/nginx/conf.d/service-url.inc
    networks:
      - local

networks:
  local:
    driver: bridge

그리고 local 환경에서 사용할 nginx도 설정했다.

nginx/nginx.conf

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/application.conf;

    #security
    server_tokens off;
}

nginx/conf.d/application.conf

server {
    listen  80;
    server_name localhost; # 적용할 도메인

    include /etc/nginx/conf.d/service-url.inc;

    location / {
        resolver            127.0.0.11;
        proxy_http_version  1.1;
        proxy_pass          $service_url;
        proxy_set_header    X-Real-IP $remote_addr;
        proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

nginx/conf.d/service_url.inc

set $service_url http://host.docker.internal:8081;

test를 위해 프로필 확인 API와 요청에 10초가 걸리는 API를 만들었다.

@RestController
@RequiredArgsConstructor
public class ProfileController {

    private final Environment env;

    @GetMapping
    String profile() {
        String[] activeProfiles = env.getActiveProfiles();
        List<String> springProfiles = List.of("spring1", "spring2");
        return Arrays.stream(activeProfiles).filter(springProfiles::contains).findAny()
                .orElse("spring1");
    }

    @GetMapping("wait")
    String waiting() throws InterruptedException {

        System.out.println("waiting");
        final int TEN_SEC = 1000 * 10;
        Thread.sleep(TEN_SEC);
        return "success";
    }
}

먼저 요청 도중 컨테이너를 삭제하지 않고 nginx의 설정만 변경하는 상황을 구현하였다.

#! /bin/bash

# response.txt 파일을 빈 파일로 만들기
> response.txt

echo "현재 container : $(curl -s http://localhost/profile)"

# 백그라운드 실행
echo "10초 걸리는 요청"
(curl -s http://localhost/wait > response.txt) &

echo "nginx spring2(8082)로 연결 port 변경"
docker exec nginx bash -c "echo set '\$service_url http://host.docker.internal:8082;' | tee /etc/nginx/conf.d/service-url.inc"

echo "nginx 재시작"
docker exec -i nginx nginx -s reload

echo "현재 container : $(curl -s http://localhost/profile)"

wait

# 백그라운드로 실행한 결과가 response.txt에 적힌다.
echo $(cat response.txt)

해당 shell script를 실행하면 nginx의 port가 변경돼도 기존 요청은 정상적으로 처리되는 것을 볼 수 있다.

spring container에도 한 번의 요청만 온 것을 확인할 수 있다.

image

그럼 이젠 기존 배포 과정에서 했었던 container를 지우는 과정을 추가해보자

#! /bin/bash

...

echo "현재 container : $(curl -s http://localhost/profile)"

# 추가된 코드
echo "spring1 container 삭제"
docker compose -f docker-compose.graceful.yml rm -s -v -f spring1

wait

echo $(cat response.txt)

스크립트를 실행하면 오류가 있다는 것을 볼 수 있다.

response.txt에는 502 Bad Gateway가 찍혀있었다.

<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx</center>
</body>
</html>

nginx의 log에는 다음과 같이 적혀있다.

image

연결이 끊어졌다는 내용을 통해 해당 배포 과정에는 약간의 문제가 있다는 것을 알 수 있다.

이를 해결하는 가장 쉬운 방법은 기존의 컨테이너를 지우지 않는 것이다. 사실 이렇게 해도 서버의 메모리가 모자라지는 않기 때문에 크게 상관은 없다.

하지만, 좀 더 효율적인 서버를 만들고 싶었고 필요 없는 컨테이너가 돌아가는 꼴(?)을 보고 싶지 않기 때문에 안전하게 종료할 수 있는 방법을 찾아 보았고, graceful shutdown이라는 것을 알게 되었다.

Graceful Shutdown

리눅스에는 프로세스를 종료하는 명령어들이 있다.

SIGKILL : 프로세스 강제 종료

SIGTERM : 프로세스 정상 종료

SIGINT : 유저가 프로세스를 종료(ctrl + c)

여기서 SIGTERMSIGINT는 기존의 요청을 처리하고 프로세스를 종료하여 안정적인 종료가 가능하고, 이런 안정적인 프로세스 종료를 graceful shutdown이라고 한다.

Docker stop

docker에서 graceful shutdown 위한 방법으로 docker stop을 활용하면 된다.

docker stop 명령어는 컨테이너의 프로세스에 SIGTERM신호를 전달하여 graceful shutdown을 하도록 한다. 그러나 일정 시간 내에 프로세스가 종료되지 않으면 SIGKILL신호가 전달되어 강제 종료된다. 이 시간은 기본적으로 10초이며 --time옵션을 사용하여 변경할 수 있다.

기존에 docker compose rm -s -v -f [service]을 사용했던 이유는 해당 명령어를 통해 컨테이너를 빠르게 삭제할 수 있다고 생각했고 강제 종료에 따른 부작용을 알지 못했기 때문이었다.

#! /bin/bash

...

# 해당 코드 삭제
# echo "spring1 container 삭제"
# docker compose -f docker-compose.graceful.yml rm -s -v -f spring1

# 변경점
echo "spring1 container 삭제"
docker stop spring1 && docker rm spring1

wait

echo $(cat response.txt)

이렇게 바꾸면 바로 graceful shutdown이 적용될 줄 알았지만,

nginx에는 똑같은 로그가 적혀있었고 response.txt에는 똑같이 502 Bad Gateway가 적혀있었다.

image

문제는 Spring boot에 있었다. Spring boot는 기본적으로 SIGTERM명령을 받으면 모든 스레드를 즉시 종료시킨다.

Spring boot 2.3 이상부터는 yml 파일의 해당 설정을 통해 간단하게 graceful shutdown을 적용할 수 있다. 그 이하 버전에서는 종료 과정을 어플리케이션에서 구현해야 한다.

server:
  shutdown: graceful
# 기본 설정은 immediate

이제 shell script를 실행하면 원하는 결과를 얻을 수 있다.

Clone this wiki locally