0. 들어가기 전에
무중단 배포의 필요성을 느끼게 되어서 도입하면서 생각한 점을 정리했습니다.
1. 왜 무중단 배포를 진행하는가?
사실 지금까지는 CI/CD가 되어있어서 무중단 배포를 할 필요를 못느꼈다. 하지만 이제 사용자를 모집하려고 하고 있고, 계속해서 개발할 기능들이 많이 있기 때문에 사용자 경험 때문에 무중단 배포가 필요했다.
2. 과정
바톤 프로젝트와 마찬가지로 블루 그린 배포를 해줄 예정이다. 순서는 다음과 같다.
- 포트가 다른 새로운 컨테이너를 띄운다.
- nginx를 사용해서 healthcheck를 한다.
- 정상적으로 생성이 되었다면
nginx reload -s
로 클라이언트 요청을 옮겨준다. - old 버전 컨테이너를 삭제한다.
3. 코드
먼저 CD 로직이 있는 github actions 설정파일 수정해줬다.
기존에는 단순히 새로운 이미지를 pull 받아서 새로운 컨테이너를 띄우는 형식이었는데 무중단 배포 스크립트 파일을 추가해서 실행하도록 변경했다.
사용했던 스크립트는 바톤에서 사용했던 스크립트에서 필요한 부분만 수정했다. 바톤과 달리 도커 이미지를 테그를 붙혀서 관리하고 있기 때문에 그 부분만 수정했다.
#!/bin/bash
IS_PROD1=$(docker ps | grep spring-itracker-8080)
DEFAULT_CONF=" /etc/nginx/nginx.conf"
MAX_RETRIES=20
check_service() {
local RETRIES=0
local URL=$1
while [ $RETRIES -lt $MAX_RETRIES ]; do
echo "Checking service at $URL... (attempt: $((RETRIES+1)))"
sleep 5
REQUEST=$(curl $URL)
if [ -n "$REQUEST" ]; then
echo "health check success"
return 0
fi
RETRIES=$((RETRIES+1))
done;
echo "Failed to check service after $MAX_RETRIES attempts."
return 1
}
if [ -z "$IS_PROD1" ];then
echo "새로운 container image 변수 설정"
export DOCKER_IMAGE=$(docker images --format "{{.Repository}}:{{.Tag}} {{.CreatedAt}}" | grep itracker | sort -rk2 | head -n 1 | awk '{print $1}')
echo "설정된 값: $DOCKER_IMAGE"
echo ""
echo "### PROD2 => PROD1 ###"
echo "1. PROD1 이미지 받기"
docker pull $DOCKER_IMAGE
echo "2. PROD1 컨테이너 실행"
docker run -p 8080:8080 --net service --name spring-itracker-8080 -d $DOCKER_IMAGE
echo "3. health check"
if ! check_service "http://127.0.0.1:8080"; then
echo "PROD1 health check 가 실패했습니다."
exit 1
fi
echo "4. nginx 재실행"
sudo cp /etc/nginx/nginx.prod1.conf /etc/nginx/nginx.conf
sudo nginx -s reload
echo "5. PROD2 컨테이너 내리기"
sudo docker stop spring-itracker-8081
sudo docker rm -f spring-itracker-8081
else
echo "새로운 container image 변수 설정"
export DOCKER_IMAGE=$(docker images --format "{{.Repository}}:{{.Tag}} {{.CreatedAt}}" | grep itracker | sort -rk2 | head -n 1 | awk '{print $1}')
echo "설정된 값: $DOCKER_IMAGE"
echo ""
echo "### PROD1 => PROD2 ###"
echo "1. PROD2 이미지 받기"
docker pull $DOCKER_IMAGE
echo "2. PROD2 컨테이너 실행"
docker run -p 8081:8080 --net service --name spring-itracker-8081 -d $DOCKER_IMAGE
echo "3. health check"
if ! check_service "http://127.0.0.1:8081"; then
echo "PROD2 health check 가 실패했습니다."
exit 1
fi
echo "4. nginx 재실행"
sudo cp /etc/nginx/nginx.prod2.conf /etc/nginx/nginx.conf
sudo nginx -s reload
echo "5. PROD1 컨테이너 내리기"
sudo docker stop spring-itracker-8080
sudo docker rm -f spring-itracker-8080
fi
4. 예상되는 문제
4.1 진행중이던 로직이 중단된다?!
진행중인 로직이 있는데 중단되면 어떻게 될까? 갑자기 500대 에러가 나면서 사용자 경험에 악영향을 끼칠것이다. 이를 막기위해 스프링은 graceful shutdown을 지원하고 있다.
server:
shutdown: graceful
위와 같은 설정을 설정파일에 추가하면 작업 도중에 서버가 꺼지는 문제를 예방할 수 있다.
단 graceful shutdown은 스프링이 SIGTERM(kill -15)
이나 SIGINT(kill -2)
일 때만 동작하므로 주의해서 사용한다.
여기서 궁금한점이 있다. 스프링은 어떻게 로직이 진행중
인 걸 알까?
외부 요청인 경우 톰캣에서 확인할 수 있는데, 톰캣의 GracefulShutdown
클래스에서 isActive(Container context)
메서드를 살펴보면 countAllocated
를 확인하는 절차가 있다.
이 값은 Atomic하게 선언되어있고 요청이 들어오고 나감에 따라 숫자가 변화하게 되어 남은 요청을 확인할 수 있다.
public class StandardWrapper extends ContainerBase implements ServletConfig, Wrapper, NotificationEmitter {
//...
protected final AtomicInteger countAllocated = new AtomicInteger(0);
//...
/**
* @return the number of active allocations of this servlet.
*/
public int getCountAllocated() {
return this.countAllocated.get();
}
}
그렇다면 요청이 완료되어서 다시 nginx로 요청을 보내려하는데, nginx가 종료가 되었다면? 이 경우도 예외가 발생한다.
이 문제는 위 스크립트 파일에서 nginx reload
가 해결하고 있다.nginx reload
는 기본적으로 설정 파일을 새로 고침한다.
새로운 설정 파일로 새로운 워커 프로세스를 만들어 새로운 요청들을 처리하게 된다.
이 때 기존에 있던 워커 프로세스들은 요청이 마무리될 때까지 살아있다가 프로세스들이 종료된다.
4.2 Async나 Schedule 도중에 중단된다?!
만약에 Async나 Schedule등이 동작을 해서 쓰레드는 동작하고 있지만 꺼지게 되면 어떻게 될까?
4.1 에서와 마찬가지로 옵션을 주면 되는데, TaskExecutor
나 TaskScheduler
의 빈을 등록할 때 아래와 같은 옵션을 건드리면 된다.
private boolean waitForTasksToCompleteOnShutdown = false;
private long awaitTerminationMillis = 0;
@Bean
fun taskExecutor(): TaskExecutor {
val taskExecutor = ThreadPoolTaskExecutor()
taskExecutor.setThreadNamePrefix("Async-itracker-")
taskExecutor.setWaitForTasksToCompleteOnShutdown(true)
taskExecutor.setAwaitTerminationSeconds(AWAIT_SECONDS)
return taskExecutor
}
@Bean
fun taskScheduler(): TaskScheduler {
val threadPoolTaskScheduler = ThreadPoolTaskScheduler()
threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true)
threadPoolTaskScheduler.setAwaitTerminationSeconds(AWAIT_SECONDS)
return threadPoolTaskScheduler
}
또한 비슷하게 ActiveCount를 확인하는 메서드가 존재하기 때문에 graceful shutdown에서 확인할 것 같다.
public class ThreadPoolTaskExecutor extends ExecutorConfigurationSupport
implements AsyncListenableTaskExecutor, SchedulingTaskExecutor {
/**
* Return the number of currently active threads.
* @see java.util.concurrent.ThreadPoolExecutor#getActiveCount()
*/
public int getActiveCount() {
if (this.threadPoolExecutor == null) {
// Not initialized yet: assume no active threads.
return 0;
}
return this.threadPoolExecutor.getActiveCount();
}
}
4.3 크롤링 driver 끊김 문제
위와 같은 설정을 했음에도 크롤링할 때 사용하는 셀레니움의 RemoteDriver는 바로 닫혀서 스케줄링이 실패했다.
이 문제를 자동으로 해결하는 방법도 있지만, 수동으로 크롤링하는 api가 존재하기 때문에 logging만 해서 문제가 생기면 api 요청을 하기로 했다.