무중단 배포
현재 연습하고 있는 프로젝트는
사진과 같은 단순 배포 방식으로 프로젝트가 배포 되고 있어
업데이트 사항이 있을 때 서버가 항상 종료가 되었다가 새로 시작하는 방식으로 운영되고 있었다.
종료가 된 상태에선 서비스 이용을 못할 뿐더러 새롭게 배포된 프로젝트에 문제가 있을시 수습되기 전까진 서비스 이용을 또 못하고 있는 상황이 연출되어 무중단 배포를 적용시켜보기로 했다.
무중단 배포 전략
1. Rolling Update (롤링 배포)
롤링 배포는 사용 중인 인스턴스 내에서 새 버전의 프로젝트를 점진적으로 교체하는 방식
서비스 중인 인스턴스 하나를 로드밸런서에 라우팅하지 않도록 한 뒤, 새 버전을 적용하여 라우팅하도록 한다.
새 버전을 배포할 때 인스턴스가 감소를 하므로 트래픽이 몰려 과부하가 발생할 수 있고 배포가 진행되는 동안 신, 구버전이 공존하기 때문에 호환성 문제가 발생할 수 있다.
Load Balancer (로드밸런서) 란?
클라이언트와 서버 그룹 사이에 위치해 서버에 가해지는 트래픽을 여러 대의 서버에 고르게 분배하여 특정 서버의 부하를 덜어주는 역할을 하며 Scale up, Scale out 방식 중 한 가지를 사용해 해결한다.
Scale up - 기존 서버의 성능을 향상시키는 방법
Scale out - 트래픽이나 작업을 여러 대의 컴퓨터나 서버에 분산시켜 처리하는 방법
2. Blue-Green Deployment
블루를 구버전, 그린을 신버전으로 지칭하고 구버전과 동일하게 신버전의 인스턴스를 구성한 후, 로드밸런서를 통해 신버전으로 모든 트래픽을 전환하는 배포 방식
신버전의 인스턴스를 구성하기 때문에 신버전을 미리 테스트할 수 있다는 장점이 있고, 서버 롤백이 용이하지만 시스템 자원이 두 배로 필요하고 신버전에 대한 테스트가 먼저 실행되어야 한다.
3. Canary Deployment
신, 구버전이 동시에 가동시키며 신버전은 일부 사용자에게만 서비스 되고 정상 동작됨을 확인하면 점차 신버전의 제공 범위를 늘려가는 방식
블루-그린 배포 방식처럼 실제 운영 환경에서 미리 테스트해볼 수 있지만 롤링 배포처럼 신, 구버전이 동시에 운영되기 때문에 버전 관리가 필요하다.
배포 전략 선택
현재 연습 중인 프로젝트는
단일 서버이고 실제 이용자 수는 필자밖에 없기 때문에 블루-그린 배포 방식을 채택하였고
추가적인 서버 리소스 문제는 현재 프로젝트의 규모가 작기 때문에 아직 고려 대상이 아니라고 생각한다.
구현
Github Actions 에서 Spring 프로젝트를 이미지화하여 Docker hub 에 push 후 EC2 에서 스크립트를 실행하여 Docker Compose 로 서비스들을 실행시키고 있다.
사진과 같은 아키텍쳐로 구현할 예정이다.
Blue, Green 구별
Github Actions - deploy.yml
블루-그린 배포 방식을 어떻게 구분을 할까 고민을 해보았는데
프로젝트 이미지에 :blue, :green 으로 태그를 붙여 구분을 하였고
Github Actions deploy.yml 상에서는 변수를 지정하고 값을 저장해둬도 다음 배포가 실행되면 변수가 초기화되기 때문에
Docker Hub 에 올라간 각 blue, green 태그 이미지의 last updated 시간을 curl 명령어로 긁어와 가장 최근에 올라간 태그가 blue 면 배포시 green 으로 태그를 생성하고, green 이면 blue 로 생성하게끔 구현해보았다.
# 도커 이미지 태그 확인 및 지정
- name: Fetch the latest tag from Docker Hub and Determine New Tag
run: |
TOKEN=$(curl -s -H "Content-Type: application/json" -X POST -d '{"username": "${{ secrets.DOCKER_ID }}", "password": "${{ secrets.DOCKER_PASSWORD }}"}' https://hub.docker.com/v2/users/login/ | jq -r '.token')
echo "TOKEN=$TOKEN" >> $GITHUB_ENV
LAST_UPDATED_BLUE=$(curl -s -H "Authorization: JWT $TOKEN" https://hub.docker.com/v2/repositories/${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_REPO }}/tags/ | jq -r '.results[] | select(.name=="blue") | .last_updated')
LAST_UPDATED_GREEN=$(curl -s -H "Authorization: JWT $TOKEN" https://hub.docker.com/v2/repositories/${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_REPO }}/tags/ | jq -r '.results[] | select(.name=="green") | .last_updated')
echo "LAST_UPDATED_BLUE=$LAST_UPDATED_BLUE" >> $GITHUB_ENV
echo "LAST_UPDATED_GREEN=$LAST_UPDATED_GREEN" >> $GITHUB_ENV
LAST_UPDATED_BLUE=$(date -d "$LAST_UPDATED_BLUE" +%s)
LAST_UPDATED_GREEN=$(date -d "$LAST_UPDATED_GREEN" +%s)
echo "LAST_UPDATED_BLUE=$LAST_UPDATED_BLUE" >> $GITHUB_ENV
echo "LAST_UPDATED_GREEN=$LAST_UPDATED_GREEN" >> $GITHUB_ENV
if [ "$LAST_UPDATED_BLUE" -gt "$LAST_UPDATED_GREEN" ]; then
NEW_TAG="green"
else
NEW_TAG="blue"
fi
echo "New tag will be $NEW_TAG"
echo "NEW_TAG=$NEW_TAG" >> $GITHUB_ENV
echo "TOKEN=" >> $GITHUB_ENV
# 도커 빌드 & 이미지 push
- name: Docker build & Push
run: |
docker login -u ${{ secrets.DOCKER_ID }} -p ${{ secrets.DOCKER_PASSWORD }}
docker buildx create --name mybuilder --use
docker buildx inspect --bootstrap
docker buildx build --platform linux/amd64,linux/arm/v7 -t ${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_REPO }}:"$NEW_TAG" --push .
프로젝트가 올라가는 Repository 를 private 로 설정해두어서 인증 토큰을 발급 받은 후, 해당 토큰을 갖고 이미지 태그를 조회 해야한다.
Github Actions 상에서 변수를 $GITHUB_ENV 에 저장을 하면, 다른 run 단에서도 사용이 가능하다.
Docker hub 에 까지 성공적으로 업로드가 되면
# 프로젝트 실행
- name: Run New Project
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SSH_IP_ADDRESS }}
username: ${{ secrets.SSH_USERNAME }}
port: ${{ secrets.SSH_PORT }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
source scripts/set_env.sh
source scripts/update_process.sh
EC2 내에서 update_process.sh 을 실행하도록 해놓았다.
update_process.sh
#!/bin/bash
echo "***** Executing update_process.sh *****"
# .env 파일 로드
echo "Loading .env... [1/6]"
ENV_FILE="$HOME/.env"
if [ -f "$ENV_FILE" ]; then
export $(cat "$ENV_FILE" | xargs)
echo "$ENV_FILE exported"
else
echo "Cannot find $ENV_FILE"
exit 1
fi
# 실행되고 있는 이미지 태그 조회
IS_BLUE=$(docker ps | grep ${SPRING_CONTAINER_NAME}-blue)
MAX_RETRIES=20
# health check
check_service() {
local RETRIES=0
local URL=$1
while [ $RETRIES -lt $MAX_RETRIES ]; do
echo "Checking service at $URL... (attempt: $((RETRIES+1)))"
sleep 3
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_BLUE" ];then
echo "Running lastest server... [2/5]"
docker-compose -f docker-compose.yml pull spring-blue
docker-compose -f docker-compose.yml up -d spring-blue
for i in {30..1}; do
echo -ne "Waiting for $i seconds...\r"
sleep 1
done
echo "Starting health check... [3/5]"
if ! check_service "$SERVER_IP_BLUE"; then
docker compose stop spring-blue
docker compose rm -f spring-blue
echo "Failed to switching server"
else
echo "Reloading Nginx... [4/5]"
sudo cp /home/$SSH_USERNAME/nginx/conf.d/nginx_blue.conf /home/$SSH_USERNAME/nginx/conf.d/default.conf
docker exec $NGINX_CONTAINER_NAME nginx -s reload
echo "Shutting down the previous server... [5/5]"
docker compose stop spring-green
docker compose rm -f spring-green
echo "Service port successfully had switched green -> blue"
fi
else
echo "Running lastest server... [2/5]"
docker-compose -f docker-compose.yml pull spring-green
docker-compose -f docker-compose.yml up -d spring-green
for i in {30..1}; do
echo -ne "Waiting for $i seconds...\r"
sleep 1
done
echo "Starting health check... [3/5]"
if ! check_service "$SERVER_IP_GREEN"; then
docker compose stop spring-green
docker compose rm -f spring-green
else
echo "Reloading Nginx... [4/5]"
sudo cp /home/$SSH_USERNAME/nginx/conf.d/nginx_green.conf /home/$SSH_USERNAME/nginx/conf.d/default.conf
docker exec $NGINX_CONTAINER_NAME nginx -s reload
echo "Shutting down the previous server... [5/5]"
docker compose stop spring-blue
docker compose rm -f spring-blue
echo "Service port successfully had switched blue -> green"
fi
fi
echo "***** update_process.sh Ended *****"
IS_BLUE 변수에 현재 실행 중인 컨테이너의 태그를 구별하여 0 또는 1 값을 준다.
IS_BLUE 가 0이면 블루를, 1이면 그린 컨테이너를 띄워야하므로 -Z 옵션을 주어 구별을 하였고
새 버전을 docker-compose up 하고 30초 뒤 health check 를 한 뒤 정상적으로 작동을 하면
새 버전에 해당하는 conf 파일를 복사하여 nginx reload 후 그 전 버전의 컨테이너를 삭제하는 것으로 구현을 하였다.
가독성을 위해 블루, 그린의 경우를 둘다 써놓았지만 new 와 previous 같은 변수등을 사용해 조건문을 없앨 수도 있을 것 같다.
Nginx conf 파일
~/nginx/conf.d/nginx-blue.conf
server {
listen 80;
listen [::]:80;
server_name ;
include $HOME/nginx/conf.d/service-url-blue.inc
location /api {
proxy_pass $service_url;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Real_IP $remote_addr;
proxy_redirect off;
}
}
~/nginx/conf.d/nginx-green.conf
server {
listen 80;
listen [::]:80;
server_name ;
include $HOME/nginx/conf.d/service-url-green.inc
location /api {
proxy_pass $service_url;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Real_IP $remote_addr;
proxy_redirect off;
}
}
blue, green 따로 만들어주고
include 에 나와있는 경로에 각 service-url-*.inc 에 서비스 주소와 해당하는 포트 번호를 기재해둔다.
(blue 는 8081, green 은 8082 로 설정해두었다.)
set $service_url 사이트주소:포트번호;
이렇게 4개의 파일을 만들어두면 update_process.sh 에서 태그에 맞게 nginx-blue.conf 나 nginx-green.conf 가 default.conf 로 복사가 되고 이 default.conf 를 nginx 의 default.conf 에 마운트 되게끔 nginx reload 를 해주는 것이다.
default.conf 를 마운트 하는 부분은 docker-compose.yml 에서 설정을 해준다.
docker-compose.yml
version: "3.1"
services:
nginx:
image: ${DOCKER_USERNAME}/${NGINX_CONTAINER_NAME}:latest
container_name: ${NGINX_CONTAINER_NAME}
restart: always
env_file:
- .env
ports:
- 80:80
- 443:443
volumes:
- /home/${SSH_USERNAME}/nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf
networks:
- network-reuse
depends_on:
- spring-blue
- spring-green
spring-blue:
image: ${DOCKER_USERNAME}/${SPRING_CONTAINER_NAME}:blue
container_name: ${SPRING_CONTAINER_NAME}-blue
restart: always
env_file:
- .env
ports:
- 8081:8080
networks:
- network-reuse
spring-green:
image: ${DOCKER_USERNAME}/${SPRING_CONTAINER_NAME}:green
container_name: ${SPRING_CONTAINER_NAME}-green
restart: always
env_file:
- .env
ports:
- 8082:8080
networks:
- network-reuse
networks:
network-reuse:
external: true
결과
위 과정대로 실행을 해주면 github actions 탭에서 결과를 확인할 수 있다.
마지막으로 올린 태그가 blue 였어서 이번에 올라갈 태그를 green 으로 설정해주는 job
이미지 빌드 후 push 까지 완료되면
EC2 상에서 update_process.sh 의 로직을 거쳐 green 태그 이미지를 받아오고 실행한다.
컨테이너가 실행되면 health check 진행 후 성공적이면 nginx 파일을 해당 태그에 맞는 파일로 reload 된 후 이전에 실행되고 있던 blue 프로세스를 종료한다.
(Service port successfully had switched blue -> green 이어야 하는데 echo 문을 반대로 적었었다...)
이전 서버 프로세스까지 종료 되면
이렇게 정상적으로 실행되고 있는 것을 확인할 수 있다.
참고
https://loosie.tistory.com/781
https://velog.io/@hooni_/Blue-Green-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC
'개발 ━━━━━ > Dev' 카테고리의 다른 글
[AWS S3] S3 데이터 계정간 이동 및 복원하기 (0) | 2024.06.08 |
---|---|
[Docker] Docker Volume 을 이용한 MySQL 데이터 백업 (0) | 2024.01.11 |
[Docker] MySQL 컨테이너를 이미지화 하여 Docker Hub 에 push 하기 (0) | 2023.12.04 |
Github Actions 를 이용하여 AWS EC2 에 SpringBoot 애플리케이션 CI/CD 구축 (0) | 2023.09.27 |
Github Actions (0) | 2023.09.25 |