개발 ━━━━━/Dev

[Nginx] Blue / Green 배포 전략으로 Spring 프로젝트 EC2 에 무중단 배포하기

GukJang 2024. 2. 20. 23:43
반응형

무중단 배포

현재 연습하고 있는 프로젝트는

 

사진과 같은 단순 배포 방식으로 프로젝트가 배포 되고 있어

업데이트 사항이 있을 때 서버가 항상 종료가 되었다가 새로 시작하는 방식으로 운영되고 있었다.

 

종료가 된 상태에선 서비스 이용을 못할 뿐더러 새롭게 배포된 프로젝트에 문제가 있을시 수습되기 전까진 서비스 이용을 또 못하고 있는 상황이 연출되어 무중단 배포를 적용시켜보기로 했다.

 

무중단 배포 전략

1. Rolling Update (롤링 배포)

(출처 : https://loosie.tistory.com/781)

롤링 배포는 사용 중인 인스턴스 내에서 새 버전의 프로젝트를 점진적으로 교체하는 방식

서비스 중인 인스턴스 하나를 로드밸런서에 라우팅하지 않도록 한 뒤, 새 버전을 적용하여 라우팅하도록 한다.

새 버전을 배포할 때 인스턴스가 감소를 하므로 트래픽이 몰려 과부하가 발생할 수 있고 배포가 진행되는 동안 신, 구버전이 공존하기 때문에 호환성 문제가 발생할 수 있다.

 

Load Balancer (로드밸런서) 란?
클라이언트와 서버 그룹 사이에 위치해 서버에 가해지는 트래픽을 여러 대의 서버에 고르게 분배하여 특정 서버의 부하를 덜어주는 역할을 하며 Scale up, Scale out 방식 중 한 가지를 사용해 해결한다.

Scale up - 기존 서버의 성능을 향상시키는 방법
Scale out - 트래픽이나 작업을 여러 대의 컴퓨터나 서버에 분산시켜 처리하는 방법

 

2. Blue-Green Deployment

(출처 : https://loosie.tistory.com/781)

블루를 구버전, 그린을 신버전으로 지칭하고 구버전과 동일하게 신버전의 인스턴스를 구성한 후, 로드밸런서를 통해 신버전으로 모든 트래픽을 전환하는 배포 방식

신버전의 인스턴스를 구성하기 때문에 신버전을 미리 테스트할 수 있다는 장점이 있고, 서버 롤백이 용이하지만 시스템 자원이 두 배로 필요하고 신버전에 대한 테스트가 먼저 실행되어야 한다.

 

3. Canary Deployment

(출처 : https://loosie.tistory.com/781)

신, 구버전이 동시에 가동시키며 신버전은 일부 사용자에게만 서비스 되고 정상 동작됨을 확인하면 점차 신버전의 제공 범위를 늘려가는 방식

블루-그린 배포 방식처럼 실제 운영 환경에서 미리 테스트해볼 수 있지만 롤링 배포처럼 신, 구버전이 동시에 운영되기 때문에 버전 관리가 필요하다.

 

배포 전략 선택

현재 연습 중인 프로젝트는

단일 서버이고 실제 이용자 수는 필자밖에 없기 때문에 블루-그린 배포 방식을 채택하였고

추가적인 서버 리소스 문제는 현재 프로젝트의 규모가 작기 때문에 아직 고려 대상이 아니라고 생각한다.

 

구현

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

https://cookiee.tistory.com/690

반응형