-
Notifications
You must be signed in to change notification settings - Fork 0
더 무중단스러운 배포 ‐ Graceful shutdown
프로젝트에서 무중단 배포를 구성했는데, 많은 데이터로 인해 캐시가 되지 않으면 10초까지도 걸릴 수 있는 API가 있었다. 그렇다면 API 요청 도중에 스프링 부트 컨테이너가 내려가면 어떻게 되는지 실험해봤다.
먼저 해당 환경을 구성하기 위해 docker compose를 통해 nginx와 두 개의 Spring boot 서버를 띄어놓았다.
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"]
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도 설정했다.
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;
}
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;
}
}
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에도 한 번의 요청만 온 것을 확인할 수 있다.
그럼 이젠 기존 배포 과정에서 했었던 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에는 다음과 같이 적혀있다.
연결이 끊어졌다는 내용을 통해 해당 배포 과정에는 약간의 문제가 있다는 것을 알 수 있다.
이를 해결하는 가장 쉬운 방법은 기존의 컨테이너를 지우지 않는 것이다. 사실 이렇게 해도 서버의 메모리가 모자라지는 않기 때문에 크게 상관은 없다.
하지만, 좀 더 효율적인 서버를 만들고 싶었고 필요 없는 컨테이너가 돌아가는 꼴(?)을 보고 싶지 않기 때문에 안전하게 종료할 수 있는 방법을 찾아 보았고, graceful shutdown
이라는 것을 알게 되었다.
리눅스에는 프로세스를 종료하는 명령어들이 있다.
SIGKILL : 프로세스 강제 종료
SIGTERM : 프로세스 정상 종료
SIGINT : 유저가 프로세스를 종료(ctrl + c)
여기서 SIGTERM
과 SIGINT
는 기존의 요청을 처리하고 프로세스를 종료하여 안정적인 종료가 가능하고, 이런 안정적인 프로세스 종료를 graceful shutdown
이라고 한다.
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
가 적혀있었다.
문제는 Spring boot에 있었다. Spring boot는 기본적으로 SIGTERM
명령을 받으면 모든 스레드를 즉시 종료시킨다.
Spring boot 2.3 이상부터는 yml 파일의 해당 설정을 통해 간단하게 graceful shutdown을 적용할 수 있다. 그 이하 버전에서는 종료 과정을 어플리케이션에서 구현해야 한다.
server:
shutdown: graceful
# 기본 설정은 immediate
이제 shell script를 실행하면 원하는 결과를 얻을 수 있다.
- FE - 나도 오픈소스 개발자? (NPM 배포기)
- FE - 합성 컴포넌트에 스토리북 한 스푼 🥄
- FE - Tailwind CSS 찐하게 사용해보기
- AOS - 안드로이드 네트워크 연결
- AOS - API 요청에 따른 동적 탭 생성
- AOS - 나도 오픈소스 개발자? (jitpack 배포기)
- AOS - 폭죽 애니메이션
- AOS - 가이드 모드 애니메이션
- AOS - 뷰모델과 애니메이션을 같이 사용했을때의 ISSUE
- BE - 무중단 배포에 대해 알아보자!
- BE - 더 무중단스러운 배포를 위한 graceful shutdown
- BE - 쿼리 최적화에 대해 알아보자!
- BE - 실전, 쿼리 가속도 업!