무중단 배포와 자동화 배포
얼마전에 유튜브 영상을 보는데 CI/CD 라는 말이 오용되는 케이스가 있다는 이야기를 들었다. 최근 신입 개발자 이력서에 Jenkins 나 Github Actions 를 이용해서 자동으로 프로젝트를 배포하는 구조를 모두 CI/CD 라고 칭하는 경향이 있는데, 사실 자동화 배포 툴로 배포를 진행한다고 해도 프로젝트가 빌드되는 시간동안 배포가 중단된다면 이는 엄밀히 말해 중단없이 연속적으로 배포된다는 의미의 CD(Continuos Deploy) 라 할 수 없으며 단순히 자동화 배포라고 이야기 해야한다는 말이었다.
그렇게 보니 우리 프로젝트 또한 빌드 시간 동안 배포가 끊기게 되는 자동화 배포 구조로 동작하고 있었다.
최근에 내 이력서를 보고 공부가 웹어플리케이션 개발에 치중되어 웹 서버 / DB 쪽을 보완하면 좋겠다는 피드백을 들은터라, 나는 현재 적용된 배포 구조를 무중단 배포로 개선하고 이 과정을 통해 Nginx 도 공부하고자 하였다. (Nginx 는 리버스 프록시의 역할만 담당하고 있어서 결과적으로 크게 공부가 되지는 못했다. 더 배울 기회가 있으면 좋겠다.) 이번 글에서는 자동화 배포를 무중단 배포로 개선한 과정을 코드 위주가 아닌 서술 위주로 남기려고 한다. 만약에 코드를 참고할 목적으로 이 글을 읽는다면 인터넷에 잘 짜여진 코드들이 많으니 그걸 참고하시오. 내 코드는 직접 쓴 것이기 때문에 투박하고, 별로일 확률이 큼
1. 프로젝트 구조
프로젝트에 적용한 무중단 배포 구조
1) 무중단 배포 Flow
무중단 배포의 핵심은 새로 배포하는 WAS 서버가 빌드되는 동안 기존의 WAS 가 내려가지 않고 배포 상태를 유지하여야 한다는 것이다. 또한 배포 상태가 끊기지 않도록 신규 WAS 서버의 빌드가 끝나고 정상적으로 구동되는 시점 이후, 다시 말해 기존의 WAS 와 신규 WAS 양 쪽이 정상적으로 구동되는 상태에서 웹 서버는 리버스 프록시 대상 포트를 신규 WAS 서버로 바꾼다. 이러한 방식의 배포에서는 웹 서버를 리로드하는 짧은 순간에만 배포가 중단되므로, 중단 시간이 최소화 된다. (아마도 서버구조가 엄청 큰 경우에는 이러한 중단 또한 굉장히 부담일 수 있을 것 같다. 그래서 상위 프록시 레이어를 도입하여 트래픽을 점진적으로 우회하는 등의 다른 배포 전략들이 있다고 한다.)
당연한 얘기같지만 무중단 배포는 단일한 WAS 서버로는 구현이 불가능하며 2개 이상의 서버가 필요하다. 그래서 8080 포트 이외에 8081 포트에 추가적으로 배포를 진행하기로 하였다. 이후 개발용 서버도 하나 더 늘릴까 생각중이긴 한데 프리티어 수준에서 그걸 버틸 수 있을지가 의문이다.
*실험을 해봤는데, 총 메모리가 949MB 중
- 서버 1개 배포시 사용 메모리 528MB / Swap 메모리 344MB
- 서버 2개 배포 시 사용 메모리 692MB / Swap 메모리 407MB
- 서버 3개 배포 시 사용 메모리 753MB / Swap 메모리 624MB
를 사용한다. 숫자만 놓고 봐서는 2개 까지는 무난히 사용가능 할 것으로 보인다. 3개 구동시에는 서버가 다운되지 않기는 하지만 Swap 메모리가 급격하게 올라가는 것으로 보아 Swap 메모리 사용에 따른 오버헤드가 예상되기 때문에 서버는 2개로 제한을 두고 개발용 서버는 개발시에만 임시로 사용하기로 했다.
2) 서버 구성
기존에는 ELB를 통해 443 요청(Https 기본 포트) 을 서버 포트인 8080 으로 포워딩 받고있었다. 무중단 배포를 위해 이 요청을 다시 라우팅 해주기 위한 Nginx 를 Spring 서버 앞단에 두었다. 사실 EC2 내부에서 스크립트를 통해 ELB 의 포워딩 포트를 변경할 수 있다면 굳이 Nginx 를 붙일 필요가 없을지도 모르겠지만, 앞서 말했듯이 Nginx 도 학습 목표에 있기 때문에 이렇게 했다. 그리고 Nginx, Spring 서버를 도커 컨테이너 형태로 구동하였다.
3) 배포 과정
배포툴로 Jenkins 도 써볼까 고민했는데 기존 쓰던 Github Actions 를 계속 사용하기로 했다. 프리티어의 작은 메모리에서 서버를 2개 구동하는 것도 불안했는데 Jenkins 를 쓰면 빌드 과정까지 EC2 에서 하게되기 때문에 Jenkins 는 상당히 불안한 선택지였다. Github actions 같은 경우 따로 러너 환경을 제공해주기 때문에 이런 부담이 적다.
빌드 / 배포 과정은 바뀌었다. 기존에는 AWS 의 S3 에 서버를 업로드하고 다시 Code Deploy 로 내려받아 서버를 실행했는데, 이러한 복잡한 과정 대신 Github actions 내에서 빌드가 완료된 jar 파일로 Docker 이미지를 빌드하여 DockerHub 에 업로드한 뒤 이를 다시 EC2 에서 내려 받아서 구동하는 과정으로 변경했다. 흐름이 비슷한 것 처럼 보이지만 절차가 하나 줄기 때문에 절차적으로 더 단순할 뿐만 아니라 AWS 는 보안 자격 관련해서 더 만져야 할게 많다. 그리고 기존 빌드 과정은 AWS 의 리소스를 사용하게 되는데 프리티어에서는 이 또한 부담으로 작용했다.
사실 배포에서 가장 핵심적인 부분은 EC2 내부에서 docker 컨테이너 실행하고, NGiNX 의 포트를 이리저리 변경해주는 부분인데, 이건 별도의 배포 툴이 있는 게 아니라 모두 서버에 작성한 스크립트 파일(.sh) 을 실행시켜서 진행한다. (사실 다른 배포 툴이 있는지도 모르겠다. 아직 모르는게 너무 많아서...) 이건 아래에서 코드에 자세히 나와있다. blue-green / rolling / 개발용 서버 이렇게 세가지의 배포 방식을 선택할 수 있도록 작성했다.
4) 요청 과정
Https 요청(443) > ELB(443 : 9090) > Nginx(9090:8080/8081) > Spring WAS
+) Docker 역할
Docker 는 무중단 배포라는 구조에서는 순전히 선택사항이다. 도커는 쉽게 말해 가상화 컨테이너로 격리환경을 만들어주는 도구인데, 이번에 공부해보고 싶기도 했고 포트를 내가 원하는 대로 만들어줄 수 있다는 점에서 편하기도 하다. 그리고 Docker Hub 를 경유해서 배포하는 과정도 AWS 의 S3 와 Code Deploy 를 이용하는 것에 비해 더 간편했다.
2. 배포 코드 설명
1) Github Actions Workflow
기존 Workflow코드
name: CI/CD
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch: # 수동으로 CI/CD 수행
# 변수 설정
env:
AWS_REGION: ap-northeast-2
S3_BUCKET_NAME: naejango-github-actions-s3-bucket
CODE_DEPLOY_APPLICATION_NAME: my-codedeloy-app
CODE_DEPLOY_DEPLOYMENT_GROUP_NAME: my-codedeploy-group
SECURITY_GROUP_NAME: rds-security
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
# (1) Checkout
- name: Checkout
uses: actions/checkout@v3
with:
token: ${{ secrets.ACTIONS_TOKEN }}
submodules: true
# (2) JDK 11 셋팅
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
# (3) DB 셋팅
- name: Set up PostgreSQL DB
uses: harmon758/postgresql-action@v1
with:
postgresql version: '14'
# (4) 빌드 (RDS 접근 권한 설정)
# (4-1) AWS 권한 획득 (RDS 서버)
- name: Configure AWS RDS Credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.RDS_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.RDS_SECRET_KEY }}
aws-region: ap-northeast-2
# (4-2) 깃허브 액션 러너의 아이피 얻어오기
- name: Get Github action IP
id: ip
uses: haythem/public-ip@v1.2
# (4-3) RDS 보안 그룹에 깃허브 러너 IP 인바운드 규칙 추가
- name: Add Github Actions IP to Security group
run: |
aws ec2 authorize-security-group-ingress --group-name ${{ env.SECURITY_GROUP_NAME }} --protocol tcp --port 5432 --cidr ${{ steps.ip.outputs.ipv4 }}/32
# (4-4) 빌드 권한 획득
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# (4-5) 빌드
- name: Build with Gradle
uses: gradle/gradle-build-action@v2
with:
arguments: clean build
# (4-6) RDS 보안 그룹에서 깃허브 러너 IP 삭제
- name: Remove Github Actions IP from security group
run: |
aws ec2 revoke-security-group-ingress --group-name ${{ env.SECURITY_GROUP_NAME }} --protocol tcp --port 5432 --cidr ${{ steps.ip.outputs.ipv4 }}/32
# (5) AWS 권한 획득 (S3, EC2, CodeDeploy Full Access )
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
# (6) 빌드 결과물을 S3 버킷에 업로드
- name: Upload to AWS S3
run: |
aws deploy push \
--application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
--ignore-hidden-files \
--s3-location s3://$S3_BUCKET_NAME/$GITHUB_SHA.zip \
--source .
# (7) S3 버킷에 있는 파일을 대상으로 CodeDeploy 실행
- name: Deploy to AWS EC2 from S3
run: |
aws deploy create-deployment \
--application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
--deployment-config-name CodeDeployDefault.AllAtOnce \
--deployment-group-name ${{ env.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \
--s3-location bucket=$S3_BUCKET_NAME,key=$GITHUB_SHA.zip,bundleType=zip
변경 후 Workflow 코드
name: Deploy
on:
push:
branches: [ "main", "develop/*"]
pull_request:
branches: [ "main" ]
workflow_dispatch: # 수동으로 CI/CD 수행
# 변수 설정
env:
AWS_REGION: ap-northeast-2
S3_BUCKET_NAME: naejango-github-actions-s3-bucket
CODE_DEPLOY_APPLICATION_NAME: my-codedeloy-app
CODE_DEPLOY_DEPLOYMENT_GROUP_NAME: my-codedeploy-group
SECURITY_GROUP_NAME: rds-security
EC2_SECURITY_GROUP_NAME: ec2-security
BRANCH_NAME: ${{ github.ref }}
DEPLOY_STRATEGY: blue-green
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# (1) Checkout
- name: Checkout
uses: actions/checkout@v3
with:
token: ${{ secrets.ACTIONS_TOKEN }}
submodules: true
# (2) JDK 11 셋팅
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
# (3) DB 셋팅
- name: Set up Postgresql DB
uses: harmon758/postgresql-action@v1
with:
postgresql version: '14'
# (4) 빌드 (RDS 접근 권한 설정)
# (4-1) AWS 권한 획득 (RDS 서버)
- name: Configure AWS RDS Credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.RDS_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.RDS_SECRET_KEY }}
aws-region: ap-northeast-2
# (4-2) 깃허브 액션 러너의 아이피 얻어오기
- name: Get Github action IP
id: ip
uses: haythem/public-ip@v1.2
# (4-3) RDS 보안 그룹에 깃허브 러너 IP 인바운드 규칙 추가
- name: Add Github Actions IP to Security group
run: |
aws ec2 authorize-security-group-ingress --group-name ${{ env.SECURITY_GROUP_NAME }} --protocol tcp --port 5432 --cidr ${{ steps.ip.outputs.ipv4 }}/32
# (4-4) 빌드 권한 획득
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# (4-5) 빌드
- name: Build with Gradle
uses: gradle/gradle-build-action@v2
with:
arguments: clean build
# (4-6) RDS 보안 그룹에서 깃허브 러너 IP 삭제
- name: Remove Github Actions IP from security group
run: |
aws ec2 revoke-security-group-ingress --group-name ${{ env.SECURITY_GROUP_NAME }} --protocol tcp --port 5432 --cidr ${{ steps.ip.outputs.ipv4 }}/32
# (5) 무중단 배포
# (5-1) docker 이미지 build 및 push
- name: Docker build & push
run: |
branch_name=$(echo "${{ env.BRANCH_NAME }}" | awk -F'/' '{print $3}')
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build --no-cache -t ${{ secrets.DOCKER_USERNAME }}/naejango-server:$branch_name .
docker push ${{ secrets.DOCKER_USERNAME }}/naejango-server:$branch_name
# (5-2) AWS 권한 획득 (EC2 서버)
- name: AWS Authentication
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.EC2_USER_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.EC2_USER_SECRET_KEY }}
aws-region: ap-northeast-2
# (5-3) EC2 보안 그룹에 깃허브 러너 IP 인바운드 규칙 추가
- name: Add Github Actions IP to Security group
run: |
aws ec2 authorize-security-group-ingress --group-name ${{ env.EC2_SECURITY_GROUP_NAME }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
# (5-4) EC2 에 ssh 로 접속하여 배포 스크립트를 실행
- name: Deploy to EC2
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST_DNS }} # EC2 퍼블릭 IPv4 DNS
username: ec2-user
key: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
branch_name=$(echo "${{ env.BRANCH_NAME }}" | awk -F'/' '{print $3}')
if [ "$branch" == "develop" ]; then
sudo sh /home/ec2-user/cicd/deploy.sh $branch_name develop
else
sudo sh /home/ec2-user/cicd/deploy.sh $branch_name ${{ env.DEPLOY_STRATEGY }}
fi
# (5-5) EC2 보안 그룹에서 깃허브 러너 IP 규칙 삭제
- name: Remove Github Actions IP from security group
run: |
aws ec2 revoke-security-group-ingress --group-name ${{ env.EC2_SECURITY_GROUP_NAME }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
위에서 설명한대로 프로젝트를 빌드 후, 도커 이미지를 푸쉬하고 EC2 내부로 접속하여 스크립트 파일을 실행시키는 것이 전부이다. 여기서 눈여겨 볼만한 점은 branch 에 따라서 빌드 이미지 및 배포 전략을 달리한다는 점이다. tag 와 배포 전략을 sh 파일을 실행할때 인자로 넘겨준다.
2) 배포 스크립트 파일(.sh)
배포 전략별로 sh 파일을 나누어 놓아서 스크립트 파일이 총 7개가 됐다. 그 중에서 blue-green-deploy 만 보겠다.
#! /bin/bash
tag=$1
log_path=$2
image="utopiandrmer/naejango-server"
container_name="naejango-server"
upstream_server_file=/home/ec2-user/nginx/volume/conf.d/naejango_servers.conf
nginx_port=9090
port_statue
current_port
next_port
echo "-------------------------------------------------------------------------------------------------"
echo "-------------------------------------------------------------------------------------------------" >> $log_path/deploy.log
echo "[$(date '+%Y-%m-%d %H:%M:%S')] blue-green 무중단 배포를 시작합니다. "
echo "[$(date '+%Y-%m-%d %H:%M:%S')] blue-green 무중단 배포를 시작합니다. " >> $log_path/deploy.log
echo ""
echo "" >> $log_path/deploy.log
# 현재 포트 상태를 확인합니다.
if ss -tnl | grep -q ":8080" && ss -tnl | grep -q ":8081"; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 현재 8080, 8081 포트로 배포중입니다."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 현재 8080, 8081 포트로 배포중입니다." >> $log_path/deploy.log
port_status=both
elif ss -tnl | grep -q ":8080"; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 현재 8080 포트 배포중입니다."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 현재 8080 포트 배포중입니다." >> $log_path/deploy.log
current_port=8080
next_port=8081
elif ss -tnl | grep -q ":8081"; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 현재 8081 포트 배포중입니다."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 현재 8081 포트 배포중입니다." >> $log_path/deploy.log
current_port=8081
next_port=8080
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 현재 8080, 8081 모두 가용 상태입니다."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 현재 8080, 8081 모두 가용 상태입니다." >> $log_path/deploy.log
port_status=none
current_port=8081
next_port=8080
fi
# 양쪽의 포트에 모두 구동중인 경우 8080 포트의 엔진엑스 업스트림 서버를 끊고 종료 합니다.
if [ "$port_status" == "both" ]; then
next_port=8080
current_port=8081
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] \033[1m$next_port\033[0m 포트에 배포를 종료 합니다."
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] \033[1m$next_port\033[0m 포트에 배포를 종료 합니다." >> $log_path/deploy.log
echo "upstream naejango-server {" > "$upstream_server_file"
echo "server 43.202.25.203:$current_port;" >> $upstream_server_file
echo "}" >> $upstream_server_file
sudo docker exec nginx service nginx reload
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] \033[1m$next_port\033[0m 포트를 종료합니다."
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] \033[1m$next_port\033[0m 포트를 종료합니다." >> $log_path/deploy.log
sudo docker rm --force "$(sudo docker ps -a --format '{{.Names}}: {{.Ports}}' | grep ":$next_port->" | cut -d ':' -f 1)"
fi
# docker container 를 구동합니다
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] \033[1m$next_port\033[0m 포트 에 배포를 시작합니다."
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] \033[1m$next_port\033[0m 포트 에 배포를 시작합니다." >> $log_path/deploy.log
sudo docker run -d -e TZ="Asia/Seoul" -p $next_port:8080 -v $log_path:/log --name "$container_name-$next_port" "${image}:${tag}"
# 빌드가 잘 되었는지 healthcheck 를 통해 확인합니다.
timeout=60
counter=0
echo -n "$next_port healthcheck 중"
while [ $counter -lt $timeout ]; do
response_code=$(curl -sL -w "%{http_code}" -o /dev/null -m "$timeout" "http://localhost:${next_port}/healthcheck")
if [ "$response_code" -eq 200 ]; then
echo ""
break
else
echo -n "."
((counter++))
sleep 1
fi
done
if ! [ $response_code -eq 200 ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $next_port 포트의 healthcheck 에 실패하였습니다."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $next_port 포트의 healthcheck 에 실패하였습니다." >> $log_path/deploy.log
echo "\033[1m배포 결과:\033[0m\033[1;31m실패\033[0m"
echo "\033[1m배포 결과:\033[0m\033[1;31m실패\033[0m" >> $log_path/deploy.log
echo "-------------------------------------------------------------------------------------------------"
echo "-------------------------------------------------------------------------------------------------" >> $log_path/deploy.log
exit 1
else
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $next_port 포트 서버 구동 \033[1;32m성공\033[0m"
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $next_port 포트 서버 구동 \033[1;32m성공\033[0m" >> $log_path/deploy.log
fi
# 리버스 프록시 포트 변경을 위해 NGiNX 의 설정을 변경하고 NGiNX 를 재시작 합니다.
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] NGiNX 의 리버스 프록시 포트를\033[1m$current_port\033[0m 에서\033[1m$next_port\033[0m로 변경합니다."
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] NGiNX 의 리버스 프록시 포트를\033[1m$current_port\033[0m 에서\033[1m$next_port\033[0m로 변경합니다." >> $log_path/deploy.log
echo "upstream naejango-server {" > $upstream_server_file
echo "server 43.202.25.203:$next_port;" >> $upstream_server_file
echo "}" >> $upstream_server_file
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] NGiNX 서버를 재시작 합니다."
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] NGiNX 서버를 재시작 합니다." >> $log_path/deploy.log
sudo docker exec nginx service nginx reload
# 리버스 프록시가 잘 적용 되었는지 체크하고 잘 안되었을 경우 롤백합니다.
timeout=60
counter=0
while [ $counter -lt $timeout ]; do
response_code=$(curl -sL -w "%{http_code}" -o /dev/null -m "$timeout" "http://localhost:${nginx_port}/healthcheck")
if [ "$response_code" -eq 200 ]; then
break
else
((counter++))
sleep 1
fi
done
if ! [ "$response_code" -eq 200 ]; then
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] NGiNX - $nginx_port 포트의 healthcheck 에 실패하였습니다."
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] NGinX - $nginx_port 포트의 healthcheck 에 실패하였습니다." >> $log_path/deploy.log
echo "[$(date '+%Y-%m-%d %H:%M:%S')] NGinX $nginx_port 포트의 리버스 프록시 포트를 기존의 포트로 복구합니다."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] NGinX $nginx_port 포트의 리버스 프록시 포트를 기존의 포트로 복구합니다." >> $log_path/deploy.log
echo "upstream naejango-server {" > $upstream_server_file
echo "server 43.202.25.203:$current_port;" >> $upstream_server_file
echo "}" >> $upstream_server_file sudo sed -i "s/$next_port/$current_port/"
sudo docker exec nginx service nginx reload
echo "\033[1m배포 결과:\033[0m\033[1;31m실패\033[0m"
echo "\033[1m배포 결과:\033[0m\033[1;31m실패\033[0m" >> $log_path/deploy.log
echo "-------------------------------------------------------------------------------------------------"
echo "-------------------------------------------------------------------------------------------------" >> $log_path/deploy.log
exit 1
else
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $nginx_port 포트 healthcheck 완료, nginx 리버스 프록시 포트 변경 \033[1;32m성공\033[0m "
echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $nginx_port 포트 healthcheck 완료, nginx 리버스 프록시 포트 변경 \033[1;32m성공\033[0m " >> $log_path/deploy.log
fi
# 이전 포트를 삭제합니다.
if [ $current_port != "none" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $current_port 포트의 컨테이너를 삭제합니다."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $current_port 포트의 컨테이너를 삭제합니다." >> $log_path/deploy.log
sudo docker rm --force "$(sudo docker ps -a --format '{{.Names}}: {{.Ports}}' | grep ":$current_port->" | cut -d ':' -f 1)"
sudo docker rm --force "$(sudo docker ps -a --format '{{.Names}}: {{.Ports}}' | grep ":$8082->" | cut -d ':' -f 1)"
sudo docker image prune -f
fi
echo ""
echo "" >> $log_path/deploy.log
echo -e "\033[1m배포 내용: ${tag} 브랜치 blue-green 무중단 배포 || 배포 결과: \033[0m\033[1;32m성공\033[0m"
echo -e "\033[1m배포 내용: ${tag} 브랜치 blue-green 무중단 배포 || 배포 결과: \033[0m\033[1;32m성공\033[0m" >> $log_path/deploy.log
echo "-------------------------------------------------------------------------------------------------"
echo "-------------------------------------------------------------------------------------------------" >> $log_path/deploy.log
echo ""
echo "" >> $log_path/deploy.log
echo ""
echo "" >> $log_path/deploy.log
상당히 복잡한데 주석만 놓고보자면
# 현재 포트 상태를 확인합니다.
# 양쪽의 포트에 모두 구동중인 경우 8080 포트의 엔진엑스 업스트림 서버를 끊고 종료 합니다.
# docker container 를 구동합니다.
# 빌드가 잘 되었는지 healthcheck 를 통해 확인합니다.
# 리버스 프록시 포트 변경을 위해 Nginx 의 설정을 변경하고 Nginx 를 재시작 합니다.
# 리버스 프록시가 잘 적용 되었는지 체크하고 잘 안되었을 경우 롤백합니다.
# 이전 포트를 삭제합니다.
이런 흐름이다. (rolling 방식은 계속 Nginx 의 포트를 바꿔주어야 해서 좀 더 복잡하다.) Docker container 가 빌드를 하고나서 Nginx 의 포트를 바꿔주는, 일종의 동기적인 실행을 위해서 중간 중간 health 체크를 해준다. 헬스 체크가 되지 않으면 Nginx 의 포워딩 포트를 변경하지 않기 때문에 배포가 끊기지 않는다. 프로젝트 구동 시간이 대충 30초 정도가 소요되기 때문에 Time-out 을 60초로 잡았다.
3) Nginx 설정 파일
Nginx 는 아파치와 점유율 1등을 다툴 정도로 많이 쓰이는 웹 서버 엔진인데, 그만큼 많은 기능을 가지고 있을 거라고 생각이 된다. 이번에는 리버스 프록시 기능만이라도 온전하게 사용하고자 했다. Nginx 는 .conf 라는 확장자를 가진 설정 파일을 작성해서 서버 동작을 조작할 수 있다. 나는 아래와 같이 별도의 업스트림 구성 파일을 만들어서 이를 include 해서 사용한다.(스크립트로 조작하기 쉽게) 업스트림이라는 건 클라이언트의 요청을 넘겨줄 WAS 서버를 말하는데, 서버를 여러 개로 구성하게 되면 Nginx 가 지정된 알고리즘에 따라 로드 밸런싱을 해준다.
별도의 설정을 해주지 않으면 Nginx 는 CPU 스케줄링에서도 들었던 라운드 로빈 이라는 알고리즘을 사용한다고 한다. 라운드 로빈은 일정 간격을 두고 요청 대상을 스위칭을 해주는 방식인데 CPU 에서는 일정한 시간 간격으로 프로세스를 스위칭하는 것으로 들었는데, ChatGPT 말로는 Nginx 는 시간이 아닌 매 요청에 대해 순차적으로 스위칭을 해주는 방식이라고 한다. 실제로 로그를 보면 ELB 의 헬스체크 요청을 8080/8081 이 번갈아가면서 받고 있었다. 세부적인 알고리즘이 어떻든 모든 업스트림 서버에게 최대한 공평하게 부하를 나누어 준다고 생각하면 될 것 같다.
이외에는 가중치를 둔 라운드 로빈(요청을 가중치만큼 더 받는 알고리즘일 것으로 생각된다), 최소 연결 수 알고리즘(가장 연결 수가 적은 서버 우선) 등이 있고 캐시를 적극적으로 사용하기 위한 IP Hash 라는 알고리즘도 있었다. 그리고 각 알고리즘을 적절히 조합해서 사용하는 것도 가능하다고 한다.
Nginx 는 스프링 서버와 마찬가지로 도커 컨테이너에 담아서 구동시키는데, 사실 굳이 docker 에서 가동할 필요가 있나? 라는 생각이 들었다. 솔직히 볼륨 마운트 때문에 골머리를 썩을 때 이 생각이 제일 많이 했다. 볼륨 마운트가 어떻게 동작하는지는 잘 모르고, 공식 문서를 읽는 것은 또 너무 피곤해서 시행착오를 많이 했다. 볼륨 마운트를 위해서는 볼륨을 컨테이너 내부의 폴더와 동일하게 일치시켜줘야 한다. 아니면 오류가 뜬다... 그래서 설정파일을 더 늘리게 되면 이미지를 만들때 해당 설정파일도 같이 복사되게끔 이미지를 생성해 줘야한다.
default.conf 파일
include conf.d/naejango_servers.conf;
server {
listen 80;
listen [::]:80;
location / {
proxy_pass http://naejango-server;
proxy_set_header HOST $http_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;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
charset utf-8;
}
location /api/subscribe {
proxy_pass http://naejango-server;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_read_timeout 86400;
}
}
naejango_servers.conf 파일
upstream naejango-server {
# 여기에 여러개의 주소를 입력할 수 있다.
server xxx.xxx.xxx.xxx:8080;
}
보다시피 default.conf 는 naejango_servers.conf 에 의존하고 있다. 그냥 ngix 이미지를 빌드해버리면 volume 마운트가 되지 않는다 컨테이너 외부에도 위의 설정 파일을 만들어두고, 내부에도 똑같이 구성을 해놓아야 한다. 정말 귀찮기 짝이없다. 그냥 없으면 한쪽꺼를 다른쪽에 덮어씌운다든지 그러면 안되나? 내가 뭘 잘못하고 있는 것일지도 모른다는 생각도 좀 든다.
3. 결론
사실 무중단 배포의 원리는 굳이 설명하지 않아도 될 정도로 간단하다. 그런데 이걸 실제로 코드로 구현하는게 너무 힘들었다. 왜냐하면 대부분의 작업이 리눅스의 CLI 에서 이루어지고 vi 로 스크립트를 작성하는 작업이기 때문이다. 누가 vi 가 적응하면 편하다고 했는데 이번에 정말 많이 연습을 해서 속도가 꽤나 붙었는데도 편해지려면 아직 멀은 것 같다.
아래는 배포 로그 사진인데 굳이 이렇게 예쁘게 로그를 찍을 필요는 없었지만 그냥 vi 에디터 연습 겸, 또 디버그도 수월하게 할 겸 정리해봤다. 현재 프로젝트에는 blue-green 방식의 배포를 택하고 있어서 rolling 방식 배포는 수동으로 동작시켜봤다.
blue-green 배포 로그
개발 서버 배포 로그
rolling 방식 배포 로그
나중에 업스트림으로 2개 구동시 어떤 포트로 들어오는 지 확인하는 api 를 만들어서 직접 확인해보고 싶다. 그건 다음에...