현재 상황 - 무중단 배포를 적용한 이유
현재 구조에서는 재배포할 때 기존의 docker container를 제거하고 새로운 container를 실행하는 과정에서 잠깐의 서버 다운타임이 발생한다. 개발할 당시엔 길어야 5초 정도 서버가 다운되는 것이라 큰 상관이 없지 않을까 생각했지만, 실제 서비스 환경에서는 잠깐이라도 서버가 다운되면 프론트엔드 요청을 처리할 수 없는 문제가 발생한다.
이를 방지하기 위해 무중단 배포
(Zero Downtime Deployment)를 적용하여, 배포 중에도 트래픽이 끊기지 않고 지속적으로 서비스를 제공할 수 있도록 개선하였다.
어떻게 해결할 수 있나?
1. 롤링 배포(Roling Deployment)
롤링 배포는 서버를 한 대씩 순차적으로 업데이트하는 방법이다.
롤링 배포를 적용하려면 여러 개의 인스턴스를 운영하거나 추가적인 로드밸런서 설정을 해야한다. 현재는 단일 인스턴스에서 서비스를 운영하고 있기 때문에 롤링 배포는 적용이 어렵다 .
2. 카나리 배포(Canary Deployment)
카나리 배포는 새로운 버전(신버전)을 일부 사용자(소수의 트래픽)에게 먼저 배포한 후, 점진적으로 트래픽을 늘려가면서 전체 배포를 진행하는 방식이다.
기존 서버에 영향을 주지 않고 새로운 버전을 테스트할 수 있기 때문에 A/B 테스트나 특정 지표를 통계적으로 확인할 때 유용하다. 하지만 현재 환경에서는 단일 인스턴스에서 트래픽을 조절하기 어렵고, A/B 테스트와 같은 검증 과정이 필요하지 않기 때문에 카나리 배포는 적합하지 않다.
3. 블루/그린 배포(Blue/Green Deployment)
미리 새로운 버전(그린) 서버를 준비한 후, 기존 버전(블루)에서 트래픽을 신버전으로 전환한 뒤 구버전을 폐기하는 방식이다.
일반적으로 이 방식을 구현하려면 기존 서버와 동일한 사양의 추가적인 서버가 필요하지만, 단일 인스턴스 내에서 자원을 분할하고 Nginx를 활용하여 트래픽을 전환하는 방식으로도 블루그린 배포를 구현할 수 있다. 이러한 점을 고려했을 때 현재 운영 방식에서 가장 적합한 배포 방법이다.
블루그린 배포
1. workflow 수정
- 기존
name: Java CI with Gradle
on:
push:
branches: [ "dev" ]
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: create application.yml
run: |
mkdir ./src/main/resources
cd ./src/main/resources
touch ./application.yml
echo "${{ secrets.APPLICATION_YML }}" >> ./application.yml
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle Wrapper
run: ./gradlew -x test build
- name: docker image build
run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/{도커이미지이름} .
- name: docker login
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Docker Hub push
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/{도커이미지이름}
- name: Deploy to EC2 via SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
script: |
docker stop cocos-container || true
docker rm cocos-container || true
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/{도커이미지이름}
docker run -d -p 8080:8080 --name cocos-container \
${{ secrets.DOCKERHUB_USERNAME }}/{도커이미지이름}
- 변경
- docker build 시에 —no-cache 옵션을 추가하였다. 이전 버전이 빌드될 때의 캐시로 인해 오류가 나는 상황을 방지하기 위함이다. (배포 방식 변경과는 상관없이 오류를 방지하기 위해 추가하였다.)
- script 부분의 Docker container 실행 부분을 shell 파일로 분리하였다. 해당 부분이 길어지고, 인스턴스 내부에서 상호작용하는 부분이 있기 때문에 파일을 분리하였다.
name: Java CI with Gradle
on:
push:
branches: [ "dev" ]
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: create application.yml
run: |
mkdir ./src/main/resources
cd ./src/main/resources
touch ./application.yml
echo "${{ secrets.APPLICATION_YML }}" >> ./application.yml
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle Wrapper
run: ./gradlew -x test build
- name: docker image build
run: docker build --no-cache -t ${{ secrets.DOCKERHUB_USERNAME }}/{도커이미지이름} . # --no-cache 옵션 추가 (배포 방식과는 연관 X)
- name: docker login
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Docker Hub push
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/{도커이미지이름}
- name: Deploy to EC2 via SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
script: | # script에서 docker container 실행하던 부분을 deploy.sh로 옮기고 deploy.sh을 실행
echo "Starting deployment..."
chmod +x /home/ubuntu/deploy.sh
/home/ubuntu/deploy.sh
2. shell 파일을 통한 docker container 실행
#!/bin/bash
BLUE_PORT=8081
GREEN_PORT=8082
DEPLOY_URL="http://localhost" #내부에 떴는지 테스트
HEALTH_CHECK_PATH="/api/dev/test/health-check"
CURRENT_CONTAINER=$(docker ps --filter "name=cocos-" --format "{{.Names}}")
if [[ -z "$CURRENT_CONTAINER" ]]; then
echo "No active container found. Deploying BLUE instance."
NEXT_PORT=$BLUE_PORT
NEXT_NAME="cocos-blue"
else
CURRENT_PORT=$(docker ps --filter "name=cocos-" --format "{{.Ports}}" | awk -F'->' '{print $1}' | awk -F':' '{print $2}')
if [[ "$CURRENT_PORT" -eq "$BLUE_PORT" ]]; then
NEXT_PORT=$GREEN_PORT
NEXT_NAME="cocos-green"
else
NEXT_PORT=$BLUE_PORT
NEXT_NAME="cocos-blue"
fi
fi
HEALTH_CHECK_URL="${DEPLOY_URL}:${NEXT_PORT}${HEALTH_CHECK_PATH}"
echo "Pulling latest Docker image..."
docker pull {도커계정명}/{도커이미지명}
echo "Running new container on port $NEXT_PORT..."
docker run -d --restart unless-stopped -p $NEXT_PORT:8080 --name $NEXT_NAME {도커계정명}/{도커이미지명}
echo "Waiting for the new container to be healthy..."
for i in {1..45}; do
sleep 2
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$HEALTH_CHECK_URL")
if [[ "$STATUS" == "200" ]]; then
echo "New container is healthy!"
break
fi
if [[ "$i" == "45" ]]; then
echo "Health check failed after multiple attempts. Rolling back..."
docker stop $NEXT_NAME
docker rm $NEXT_NAME
exit 1
fi
done
echo "Updating Nginx..."
if [[ "$NEXT_PORT" -eq "$BLUE_PORT" ]]; then
sudo sed -i 's|proxy_pass http://localhost:[0-9]*;|proxy_pass http://localhost:8081;|' /etc/nginx/sites-enabled/default # 설정하고 있는 파일 경로를 넣어야 함
else
sudo sed -i 's|proxy_pass http://localhost:[0-9]*;|proxy_pass http://localhost:8082;|' /etc/nginx/sites-enabled/default
fi
sudo systemctl reload nginx
echo "Stopping old container..."
docker stop "$CURRENT_CONTAINER" --time=5
docker rm "$CURRENT_CONTAINER"
3. nginx 설정 변경
- nginx 파일에 proxy_pass 설정을 하고 있는 파일을 찾아야 한다.
- 보통은 /etc/nginx/nginx.conf 파일에 server 블록을 추가하면 된다.
- /etc/nginx/sites-enabled/default 파일에서 관리하고 있는 경우도 있기 때문에 해당 파일을 수정해야 할 수도 있다.
cat /etc/nginx/sites-enabled/default | grep "proxy_pass”
를 통해 확인 필요
- 위 파일에 proxy_pass 블록이 이미 있는 경우 추가할 필요 없다.
- (deploy.sh 파일에서 찾아서 바꾸는 코드만 추가하면 된다)
...
http {
...
upstream backend {
server localhost:8081 # 포트 번호가 shell 파일에서 변경한 포트로 업데이트되어야 함
}
server {
listen 80;
server_name {도메인 이름}; # 도메인 이름 (실제 서비스 도메인으로 변경)
location / {
proxy_pass http://backend; # proxy 부분 있는지 확인
}
}
}
- nginx 설정 변경 후 반드시 다음 명령어를 통해 문법에 어긋나지 않는지 확인해야 한다.
- sudo nginx -t
- nginx 설정 변경 시 반드시 다음 명령어를 통해 변경 사항을 적용해야 한다.
- sudo systemctl reload nginx
4. test
- docker ps -a 로 포트 설정값으로 돌고 있는지 확인
- 내부 url로 접속해서 접속되는지 확인
- curl -I http://localhost:8081/api/dev/test/health-check
- nginx에서 올바른 port로 proxy하고 있는지 확인
- curl -I https://배포url/api/dev/test/health-check