그라운드 플립은 현재 운영용 서버와 개발용 서버가 나뉘어져 있다.
출시한 지금은 프로덕션 서버에 조금 더 신경쓰고 있지만, 아직 개발용 서버는 팀원들의 지인을 비롯한 약 30명 가량의 테스터들이 사용하고 있다.
여기서 문제는, 개발용 서버는 팀원들이 이슈를 처리할 때마다 CI/CD 스크립트가 꽤나 빈번하게 실행되어 약 1분 간의 다운타임이 발생한다는 것이다. 물론 1분이 일상적인 상황이라면 그렇게 긴 시간은 아니다.
그렇지만, 프론트 측에서 매일 자정에 보내는 걸음수 저장 요청 등 매우 중요한 요청이 올 때 서버가 다운되어 있다면? 그리고 만약 그게 프로덕션 환경이라면? 분명 더 나은 방법을 찾아야 할 것이다.
따라서 다운타임을 최소화하기 위해 무중단 배포를 도입하기로 결정하였다.
또한 구현 방식으로는 Rolling, Canary, Blue/Green 중 Blue/Green을 택하였다.
이유는 다음과 같다.
일정 시간동안 리소스를 두 배 소모하는 단점을 가지고 있지만, 현재 그라운드 플립은 그렇게 무거운 앱이 아니며, t3a.small을 기준으로 컨테이너를 두 개 실행해도 실행이 되는 순간에만 CPU 사용량을 약 40% 웃돈다.
현재 개발팀이 적은 인원으로 구성되어 있으며, 아직 구현 방식의 복잡도를 높일 만큼 트래픽을 점진적으로 옮기는 것에 대한 이점이 크지 않다.
그렇게 내가 생각한 배포 시나리오는 아래 그림과 같다.
우선 8082 포트로 새로운 버전의 스프링을 실행한다.
이후 8081 포트의 스프링을 종료한다.
무중단 배포라는 단어를 여러 곳에서 들어보긴 했지만, 막상 구현을 고민하다보니 생각보다 간단하게 구현될 것 같았다.
다른 블로그 글들을 보니, 대부분 도커 컴포즈를 사용하여 구현했지만, 우리 배포 구조도를 봤을 때 기존의 deploy.sh
스크립트를 조금 수정해 충분히 구현할 것으로 보였다.
// 기존의 deploy.sh
#!/usr/bin/env bash
APP_NAME="ground_flip"
REPOSITORY=/home/ubuntu/ground_flip
echo "> Check the currently running container"
CONTAINER_ID=$(docker ps -aqf "name=$APP_NAME")
pwd
ls
if [ -z "$CONTAINER_ID" ];
then
echo "> No such container is running."
else
echo "> Stop and remove container: $CONTAINER_ID"
docker stop "$CONTAINER_ID"
docker rm "$CONTAINER_ID"
fi
echo "> Remove previous Docker image"
docker rmi "$APP_NAME"
echo "> Build Docker image"
docker build -t "$APP_NAME" "$REPOSITORY"
echo "> Run the Docker container"
docker run -d -p 8080:8080 --env-file /home/ubuntu/ground_flip/.env -e TZ=Asia/Seoul -v /home/ubuntu/logs:/logs --name "$APP_NAME" "$APP_NAME"
우선 EC2에 Nginx를 설치해준다.
$ sudo apt install nginx
그리고 /etc/nginx/nginx.conf 파일을 아래와 같이 수정해준다.
events {}
http {
server {
listen 8080;
server_name localhost;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
8080 포트로 들어오는 트래픽을 8081 포트로 라우팅한다.
그리고 가장 중요한 deploy.sh를 수정해준다.
#!/usr/bin/env bash
APP_NAME="ground_flip"
REPOSITORY=/home/ubuntu/ground_flip
# 기존 도커 이미지를 삭제한다.
echo "> Remove previous Docker image"
docker rmi "$APP_NAME"
# 새로운 도커 이미지를 빌드한다.
echo "> Build Docker image"
docker build -t "$APP_NAME" "$REPOSITORY"
TARGET_PORT=0
# 컨테이너의 이름에 "ground_flip"이 포함된 컨테이너의 포트를 특정한다.
CURRENT_PORT=$(sudo docker ps --filter "name=$APP_NAME" --format "{{.Ports}}" | cut -d: -f2 | cut -d- -f1)
echo "> CURRENT_PORT = $CURRENT_PORT"
# 해당 컨테이너의 포트에 따라 다음 컨테이너를 실행할 포트를 결정한다.
if [ "$CURRENT_PORT" == "8081" ]; then
TARGET_PORT=8082
else
TARGET_PORT=8081
fi
echo "> TARGET_PORT = $TARGET_PORT"
NEW_CONTAINER_NAME="$APP_NAME-$TARGET_PORT"
OLD_CONTAINER_NAME="$APP_NAME-$CURRENT_PORT"
# 새로운 포트로 컨테이너를 실행한다.
echo "> Run the Docker container on port $TARGET_PORT"
sudo docker run -d -p $TARGET_PORT:8080 --env-file /home/ubuntu/ground_flip/.env -e TZ=Asia/Seoul -v /home/ubuntu/logs:/logs --name "$NEW_CONTAINER_NAME" "$APP_NAME"
// Nginx가 새로운 컨테이너의 포트를 바라보게끔 설정 파일을 수정한다.
echo "> Update NGINX configuration to route traffic to the new container"
NGINX_CONF="/etc/nginx/nginx.conf"
// nginx.conf 파일의 내부를 직접 수정한다.
sudo sed -i "s/$CURRENT_PORT/$TARGET_PORT/g" "$NGINX_CONF"
// 변경사항을 적용하여 nginx를 Reload한다.
echo "> Reload NGINX to apply the new configuration"
sudo nginx -s reload
// 기존 컨테이너를 종료한다.
sudo docker rm -f $OLD_CONTAINER_NAME
echo "> Deployment to port $TARGET_PORT completed successfully."
하지만 이렇게 스크립트를 수정하고 실행해도 다운타임이 발생했다. 원인은 생각보다 쉽게 파악할 수 있었는데, 바로 '스프링이 실행되는 시간'이 문제였다.
새로운 인스턴스가 아직 실행되어 요청을 받을 수 없는 상태이지만, 기존 인스턴스가 종료된 것이었다.
따라서 로드밸런서에서 사용하려고 만든 health check용 API에 localhost로 curl 요청을 보내 켜지는 동안 기존의 컨테이너를 유지하게끔 수정했다.
#!/usr/bin/env bash
APP_NAME="ground_flip"
REPOSITORY=/home/ubuntu/ground_flip
# 기존 도커 이미지를 삭제한다.
echo "> Remove previous Docker image"
docker rmi "$APP_NAME"
# 새로운 도커 이미지를 빌드한다.
echo "> Build Docker image"
docker build -t "$APP_NAME" "$REPOSITORY"
TARGET_PORT=0
# 컨테이너의 이름에 "ground_flip"이 포함된 컨테이너의 포트를 특정한다.
CURRENT_PORT=$(sudo docker ps --filter "name=$APP_NAME" --format "{{.Ports}}" | cut -d: -f2 | cut -d- -f1)
echo "> CURRENT_PORT = $CURRENT_PORT"
# 해당 컨테이너의 포트에 따라 다음 컨테이너를 실행할 포트를 결정한다.
if [ "$CURRENT_PORT" == "8081" ]; then
TARGET_PORT=8082
else
TARGET_PORT=8081
fi
echo "> TARGET_PORT = $TARGET_PORT"
NEW_CONTAINER_NAME="$APP_NAME-$TARGET_PORT"
OLD_CONTAINER_NAME="$APP_NAME-$CURRENT_PORT"
# 새로운 포트로 컨테이너를 실행한다.
echo "> Run the Docker container on port $TARGET_PORT"
sudo docker run -d -p $TARGET_PORT:8080 --env-file /home/ubuntu/ground_flip/.env -e TZ=Asia/Seoul -v /home/ubuntu/logs:/logs --name "$NEW_CONTAINER_NAME" "$APP_NAME"
# 10초 간격으로 최대 10번 실행한다.
for cnt in {1..10}
do
echo "check server start.."
RESPONSE=$(curl -s http://127.0.0.1:${TARGET_PORT}/check)
if echo "$RESPONSE" | grep -q "success"; then
break
else
echo "server not start.."
fi
echo "wait 10 seconds"
sleep 10
done
// Nginx가 새로운 컨테이너의 포트를 바라보게끔 설정 파일을 수정한다.
echo "> Update NGINX configuration to route traffic to the new container"
NGINX_CONF="/etc/nginx/nginx.conf"
// nginx.conf 파일의 내부를 직접 수정한다.
sudo sed -i "s/$CURRENT_PORT/$TARGET_PORT/g" "$NGINX_CONF"
// 변경사항을 적용하여 nginx를 Reload한다.
echo "> Reload NGINX to apply the new configuration"
sudo nginx -s reload
// 기존 컨테이너를 종료한다.
sudo docker rm -f $OLD_CONTAINER_NAME
echo "> Deployment to port $TARGET_PORT completed successfully."
헬스체크를 거쳐 무중단 배포를 성공한 것을 볼 수 있다.