CI CD

Blue-Green 배포 전략 실전예제 (Circle CI + Docker + Nginx + Spring Boot)

주코식딩 2023. 8. 6. 02:11

이 포스팅은 nginx를 불필요하게 프로젝트마다 설치한다.
개선된 deploy.sh 는 이곳에 있다.

 

이전 포스팅 에서 말한 제로타임배포를 구현하기 위해 가장 유명한 Blue-Green전략을 사용하기로 했다.

개념은 나보다 잘 설명할 사람이 많으니 직접 찾아보시길..

 

이번 프로젝트?는 가능한 많은 사람이 간편하게 사용하는 것을 목표로 만들었다.

Repository가 바뀔때 마다 설정해야하는 값들이나 변경가능성이 있는 변수들 모두 쪼개서 환경변수로 관리했다.

 

config.yml을 작성하는 것 보다 각종 파일들을 동적으로 만들거나 target port 관리 nignx설정 등에 시간을 많이 들였다.

 

 

현재 방식은 nginx를 불필요하게 프로젝트마다 설치한다.
개선된 deploy.sh 는 이곳에 있다.


파일 구성도

내가 만들 config.yml을 이용하려면 필요한 파일 구성도이다.

 

.circleci

 - config.yml

ci-cd

 - docker

   - spring.Dockerfile

 - sh

   - deploy.sh

   - nginx_start_and_change_port.sh

   - create_main_docker_compose.sh

   - create_nginx_docker_compose.sh

   - create_default_conf.sh


config.yml

ssh접속시 '-o ConnectTimeout=30' 옵션을 넣어주지 않으면 무한히 접속을 시도하기에 추가했습니다. 

 

참고로 description은 아무런 동작도 하지 않습니다. 

주석보다 이쁜데 에러는 안나길래 그냥 내버려 뒀습니다.

version: 2.1
workflows:
  build:
    jobs:
      - main:
          context: # 공통적으로 사용되는 환경변수를 가져옴
            - server
          filters:
            branches: # Push시 트리거 작동을 막기 위해선 Project Settings > Advanced 에서 Only build pull requests 활성화
              only:
                - main


jobs:
  main:
    machine: true
    resource_class:<리소스 클래스명>
    steps:
      - run:
          name: 환경변수 설정
          description: |
            각 레포마다 해당 부분만 세팅하면 됩니다.
            Blue-Green 배포 방식과 nignx를 사용해서 제로타임배포를 진행합니다.
            NGINX_PORT부분이 도메인과 연결되어 외부와 소통할 port입니다.
            
          command: |
            #      어플리케이션 정보
            echo 'export JAVA_VERSION=17' >> $BASH_ENV 
            echo 'export BLUE_PORT=000' >> $BASH_ENV
            echo 'export GREEN_PORT=000' >> $BASH_ENV
            echo 'export NGINX_PORT=000' >> $BASH_ENV
            
            #      포트 변경 후 몇 초뒤에 서버를 내릴지 결정
            echo 'export DOWN_DELTA_TIME=120' >> $BASH_ENV
            #      서버가 올라간 뒤 상태코드 200을 받을 경로
            echo 'export HEALTH_CHECK_PATH=login' >> $BASH_ENV

            #      도커 이미지명 (대문자가 허용되지 않습니다.)
            echo 'export DOCKER_IMAGE="'${CIRCLE_PROJECT_REPONAME}'"' >> $BASH_ENV
            
            #      서버 내에서 사용할 폴더명 및 경로
            echo 'export DIR_ROOT=/home/circleci' >> $BASH_ENV  # 마지막에 '/' 제외 필수!!!
            echo 'export FOLDER_NAME="'${CIRCLE_PROJECT_REPONAME}'"' >> $BASH_ENV
            echo 'export DIR_PROJECT=${DIR_ROOT}/${FOLDER_NAME}' >> $BASH_ENV
            
            #      어플리케이션을 실행할 서버 정보
            echo 'export PROD_SERVER_HOST="'${REMOTE_HOST_223}'"' >> $BASH_ENV
            echo 'export PROD_SERVER_USERNAME="'${REMOTE_USERNAME_223}'"' >> $BASH_ENV
            echo 'export PROD_SERVER_ADDR=${PROD_SERVER_USERNAME}@${PROD_SERVER_HOST}' >> $BASH_ENV
            echo 'export PROD_SERVER_PASSWORD="'${REMOTE_PASSWORD_223}'"' >> $BASH_ENV # 해당 변수가 할당되지 않았다면 key_pair를 사용합니다.(공백으로 세팅해주세요.)
            
            source $BASH_ENV

      - run:
          name: 환경변수 확인
          command: |
            echo "JAVA_VERSION: ${JAVA_VERSION}"
            echo "BLUE_PORT: ${BLUE_PORT}"
            echo "GREEN_PORT: ${GREEN_PORT}"
            echo "NGINX_PORT: ${NGINX_PORT}"
            echo "DOWN_DELTA_TIME: ${DOWN_DELTA_TIME}"
            echo "HEALTH_CHECK_PATH: ${HEALTH_CHECK_PATH}"
            echo "DOCKER_IMAGE: ${DOCKER_IMAGE}"
            echo "FOLDER_NAME: ${FOLDER_NAME}"
            echo "DIR_ROOT: ${DIR_ROOT}"
            echo "DIR_PROJECT: ${DIR_PROJECT}"
            echo "PROD_SERVER_HOST: ${PROD_SERVER_HOST}"
            echo "PROD_SERVER_USERNAME: ${PROD_SERVER_USERNAME}"
            echo "PROD_SERVER_ADDR: ${PROD_SERVER_ADDR}"
            echo "PROD_SERVER_PASSWORD: ${PROD_SERVER_PASSWORD}"

      - add_ssh_keys:
          fingerprints:
            - "xxxxxxxx"
      - run:
          name: Install Dependencies
          description: |
            프로젝트에 필요한 의존성 설치
            JAVA의 경우에는 버전별로 설치되어야 하므로, 설치되어있는지 확인 후 설치
          command: |
            if [ ! -z "$PROD_SERVER_PASSWORD" ]; then
              sudo apt-get install -y sshpass
            fi
            
            # Check the output of "java -version"
            JAVA_CMD=$(java -version 2>&1 | head -n 1)
            JAVA_VERSION_INSTALLED=$(echo $JAVA_CMD | sed -n ';s/.* version "\(.*\)\.\(.*\)\..*".*/\1\2/p;')
            
            # Check if the desired version is installed
            if [ "$JAVA_VERSION_INSTALLED" != "$JAVA_VERSION" ]; then
            sudo apt-get install -y openjdk-"${JAVA_VERSION}"-jdk
            JAVA_PATH=$(update-alternatives --list java | grep "java-${JAVA_VERSION}" | head -n 1)
            JAVA_HOME=$(dirname $(dirname $JAVA_PATH))
            echo "JAVA_HOME=\"$JAVA_HOME\"" | sudo tee -a /etc/environment
            source /etc/environment
            fi

      - run:
          name: known_hosts에 접속하려는 서버정보 추가
          description: |
            ssh 첫 접속시 yes/no 물어보는 것을 방지하기 위해 known_hosts에 추가
          command: |
            RSA_KEY=$(ssh-keyscan -t rsa -H ${PROD_SERVER_HOST})
            if ! grep -q "$(echo ${RSA_KEY} | awk '{print $NF}')" ~/.ssh/known_hosts; then
            echo ${RSA_KEY} >> ~/.ssh/known_hosts
            fi

      - run:
          name: 배포 서버에 프로젝트 폴더 생성
          description: |
            서버에 프로젝트를 배포할 디렉토리 생성
          command: |
            if [ -z "$PROD_SERVER_PASSWORD" ]; then
              SUDO_COMMAND="sudo"
            else
              SUDO_COMMAND="echo '${PROD_SERVER_PASSWORD}' | sudo -S "
            fi
            
            SSH_COMMAND="\
            (if [ ! -d '${DIR_PROJECT}' ]; then
            ${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}
            ${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}
            fi &&
            if [ ! -d '${DIR_PROJECT}/nginx' ]; then
            ${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}/nginx
            ${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}/nginx
            fi &&
            if [ ! -d '${DIR_PROJECT}/nginx/conf.d' ]; then
            ${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}/nginx/conf.d
            ${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}/nginx/conf.d
            fi &&
            if [ ! -d '${DIR_PROJECT}/sh' ]; then
            ${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}/sh
            ${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}/sh
            fi &&
            if [ ! -d '${DIR_PROJECT}/docker' ]; then
            ${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}/docker
            ${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}/docker
            fi) || exit 1"
            
            if [ -z "$PROD_SERVER_PASSWORD" ]; then
              ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}"
            else
              sshpass -p $PROD_SERVER_PASSWORD ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}"
            fi

      - run:
          name: 배포할 Color 확인
          command: |
            SSH_COMMAND="if docker ps --format '{{.Names}}' | \
            grep -q "${DOCKER_IMAGE}-blue"; then \
            echo 'green'; \
            else \
            echo 'blue'; \
            fi"
            
            if [ -z "$PROD_SERVER_PASSWORD" ]; then
              echo 'export TARGET_COLOR='$(ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}") >> $BASH_ENV
            else
              echo 'export TARGET_COLOR='$(sshpass -p $PROD_SERVER_PASSWORD ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}") >> $BASH_ENV
            fi
            
            source $BASH_ENV
            echo ${TARGET_COLOR}

      - checkout  # git에서 코드를 가져옴

      - run:
          name: 프로젝트 빌드
          command: |
            chmod +x ./gradlew
            ./gradlew build
    #  command: |
    #    chmod +x ./mvnw
    #    ./mvnw clean install

      - run:
          name: Docker 빌드 후 푸쉬
          description: |
            spring.Dockerfile을 이용하여 이미지를 빌드하고, Docker Hub에 푸시
            로그인은 docker-compose에 작성 후 머신 재시작시 자동으로 로그인
            Docker Hub ID : rmodev
          command: |
            echo "-----------"
            sudo docker build -t $DOCKERHUB_USERNAME/${DOCKER_IMAGE}:${TARGET_COLOR} -f ./ci-cd/docker/spring.Dockerfile .
            echo "-----------"
            sudo docker push $DOCKERHUB_USERNAME/${DOCKER_IMAGE}:${TARGET_COLOR}
            echo "-----------"
            echo ${TARGET_COLOR}
            echo "-----------"


      - run:
          name: 배포 서버로 파일 전송
          description: |
            프로젝트 파일들을 서버로 전송
          command: |
            if [ -z ${PROD_SERVER_PASSWORD} ]; then
              scp -o ConnectTimeout=30 -r ./ci-cd/* ${PROD_SERVER_ADDR}:${DIR_PROJECT}
            else
              sshpass -p ${PROD_SERVER_PASSWORD} scp -o ConnectTimeout=30 -r ./ci-cd/* ${PROD_SERVER_ADDR}:${DIR_PROJECT}
            fi



      - run:
          name: CD 시작
          description: |
            서버에서 deploy.sh 실행
            deploy.sh 에서 다른 모든 sh파일을 실행시킵니다.
          command: |
            SSH_COMMAND="\
            cd ${DIR_PROJECT}
            echo ${DOCKER_IMAGE}
            echo ${TARGET_COLOR}
            bash ./sh/deploy.sh ${DOCKER_IMAGE} ${TARGET_COLOR} ${BLUE_PORT} ${GREEN_PORT} ${NGINX_PORT} ${DIR_PROJECT} ${DOWN_DELTA_TIME} ${HEALTH_CHECK_PATH} ${PROD_SERVER_HOST}|| exit 1" 
            
            if [ -z "$PROD_SERVER_PASSWORD" ]; then
              ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}"
            else
              sshpass -p $PROD_SERVER_PASSWORD ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}"
            fi

 


deploy.sh

target_color를 입력받아서 해당 color를 올린 뒤 기존 live color를 내린뒤 최종적으로 실행되고 있는 port를 nginx.sh파일에 입력하는 sh파일이다.

 

처리 중인 작업이 있을 수 있으니 서버를 바로 down시키지 않고 변수로 받은 값만큼 딜레이를 준 뒤 서버를 내리게 만들었다.

딜레이가 클 수록 중복 실행될 가능성도 고려해서 pid를 저장시켜 종료시키게끔 구성했다.

 

모든 과정을 자동화 하기 위해 docker-compose파일 또한 이 곳에서 직접 만든다.

매번 파일을 재생성한다는 단점이 있기는 하지만 큰 자원이 소모된다고 생각되지 않는다.

#!/bin/bash

REPO_NAME=$1
TARGET_COLOR=$2
BLUE_PORT=$3
GREEN_PORT=$4
NGINX_PORT=$5
DIR_PROJECT=$6
DOWN_DELTA_TIME=$7
HEALTH_CHECK_PATH=$8
PROD_SERVER_HOST=$9

echo "===================="
# 실행중인 백그라운드 종료
if [ -f "$DIR_PROJECT/sh/down_live_after_delay.pid" ]; then
    OLD_PID=$(cat "$DIR_PROJECT/sh/down_live_after_delay.pid")
    if ps -p "$OLD_PID" > /dev/null; then
        echo "Terminating existing background process..."
        kill -9 "$OLD_PID"
    fi
fi


echo "===================="
if [ -z "$REPO_NAME" ] || [ -z "$TARGET_COLOR" ] || [ -z "$BLUE_PORT" ] || [ -z "$GREEN_PORT" ] || [ -z "$NGINX_PORT" ] || [ -z "$DIR_PROJECT" ] || [ -z "$DOWN_DELTA_TIME" ]; then
    echo "One or more variables are empty."
    exit 1
fi

echo "REPO_NAME: $REPO_NAME"
echo "TARGET_COLOR: $TARGET_COLOR"
echo "BLUE_PORT: $BLUE_PORT"
echo "GREEN_PORT: $GREEN_PORT"
echo "NGINX_PORT: $NGINX_PORT"
echo "DIR_PROJECT: $DIR_PROJECT"
echo "DOWN_DELTA_TIME: $DOWN_DELTA_TIME"


echo "===================="
if [ "$TARGET_COLOR" == "blue" ]
then
  LIVE_PORT=$GREEN_PORT
  LIVE_COLOR="green"
  TARGET_PORT=$BLUE_PORT
  TARGET_COLOR="blue"
else
  LIVE_PORT=$BLUE_PORT
  LIVE_COLOR="blue"
  TARGET_PORT=$GREEN_PORT
  TARGET_COLOR="green"
fi

cd "$DIR_PROJECT" || exit 1

echo "===================="
echo "create-docker-compose.sh"
sh "$DIR_PROJECT/sh/create_main_docker_compose.sh" "$REPO_NAME" "$BLUE_PORT" "$GREEN_PORT" "$DIR_PROJECT"


echo "===================="
echo "now state"
Target=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$TARGET_PORT/$HEALTH_CHECK_PATH")
Live=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$LIVE_PORT/$HEALTH_CHECK_PATH")

echo "API Target: $Target"
echo "API Live: $Live"


# Deploy the new version to the new environment
echo "===================="
echo "docker compose up"
docker-compose pull $TARGET_COLOR
docker-compose -f "$DIR_PROJECT/docker-compose.yml" up -d $TARGET_COLOR
Target=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$TARGET_PORT"/$HEALTH_CHECK_PATH)
Live=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$LIVE_PORT/$HEALTH_CHECK_PATH")

echo "API Target: $Target"
echo "API Live: $Live"



echo "===================="
run_tests() {
  # shellcheck disable=SC2034
  for i in {1..10}; do
    Target=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$TARGET_PORT/$HEALTH_CHECK_PATH")
    Live=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$LIVE_PORT/$HEALTH_CHECK_PATH")

    echo "API Target: $Target"
    echo "API Live: $Live"

    if [ "$Target" -eq 200 ]; then
      return 0
    fi
    echo "API is not ready yet cnt: [$i/10]]"
    # Wait for 6 seconds before the next try
    sleep 6
  done

  echo "API has not become ready within 60 seconds"
  # If the API did not respond with 200 OK within 60 seconds, return a failure code
  return 1
}

if run_tests; then
  # If Lives passed, switch the traffic to the new environment
  echo "Success!! Change Target: $TARGET_COLOR"
  NGINX_TARGET_PORT=$TARGET_PORT
  IS_SUCCESS=0

else
  # If Lives failed, stop the new environment
  echo "Fail.. Change LIVE: $LIVE_COLOR"
  NGINX_TARGET_PORT=$LIVE_PORT
  IS_SUCCESS=1
fi


echo "===================="
sh "$DIR_PROJECT/sh/nginx_start_and_change_port.sh" "$NGINX_TARGET_PORT" "$REPO_NAME-nginx" "$DIR_PROJECT" "$NGINX_PORT"


echo "===================="
down_live_after_delay() {
  sleep "$DOWN_DELTA_TIME"
  echo "Down Live after delay: $LIVE_COLOR"
  docker stop "$REPO_NAME-$LIVE_COLOR"
}


if [ $IS_SUCCESS ]; then
  if docker ps --format '{{.Names}}' |  grep "$REPO_NAME-$LIVE_COLOR"; then

    nohup down_live_after_delay &

    # 백그라운드 작업의 PID를 파일에 저장
    echo $! > "$DIR_PROJECT/sh/down_live_after_delay.pid"
    echo "Down Live after delay: $LIVE_COLOR, PID: $!, $DOWN_DELTA_TIME"
  fi
else
  if docker ps --format '{{.Names}}' |  grep "$REPO_NAME-$TARGET_COLOR"; then
    echo "Down Target: $TARGET_COLOR"
    docker stop "$REPO_NAME-$TARGET_COLOR"
  fi
fi
echo "===================="

create_main_docker_compose.sh

실행될 서비스의 docker-compose 파일이다.

docker-compose 파일을 분리하거나 image 태그를 매번 다르게 설정하는등 많은 시도 끝에 성공한 방법이다.

 

신기하게도 다른 방법 사용시 docker-compose up시 live와 target 컨테이너가 모두 down 된 후에 target만 올라온다.

 

그 외에 현재는 network를 따로 분리 해두었는데 nignx와 모두 같은 network를 사용하게 해서 어플리케이션의 port를 컨테이너 외부로 빼지 않아도 되는 방법이 있을것 같다.

#!/bin/bash

REPO_NAME=$1
BLUE_PORT=$2
GREEN_PORT=$3
DIR_PROJECT=$4

cat << EOF > "$DIR_PROJECT/docker-compose.yml"
version: '3.3'

services:
  blue:
    image: rmodev/${REPO_NAME}:blue
    container_name: ${REPO_NAME}-blue
    networks:
      - ${REPO_NAME}-blue-network
    ports:
      - "${BLUE_PORT}:${BLUE_PORT}"
    volumes:
      - ${DIR_PROJECT}/logs:/logs
    environment:
      - TZ=Asia/Seoul
  green:
    image: rmodev/${REPO_NAME}:green
    container_name: ${REPO_NAME}-green
    networks:
      - ${REPO_NAME}-green-network
    ports:
      - "${GREEN_PORT}:${BLUE_PORT}"
    volumes:
      - ${DIR_PROJECT}/logs:/logs
    environment:
      - TZ=Asia/Seoul

networks:
  ${REPO_NAME}-blue-network:
    driver: bridge
  ${REPO_NAME}-green-network:
    driver: bridge
EOF

nginx_start_and_change_port.sh

단순히 volume으로 conf 파일을 연결해서 port를 target으로 바꾸는 로직이다.

 

최초 실행시 생길 많은 변수를 고려해서 작성했다.

#!/bin/bash

# Port to assign
TARGET_PORT=$1
NGINX_CONTAINER=$2
DIR_PROJECT=$3
NGINX_PORT=$4
# Path to Nginx configuration file
NGINX_CONF=$DIR_PROJECT/nginx/conf.d/default.conf

cat "$NGINX_CONF"

cd "$DIR_PROJECT" || exit 1
if [ ! -f "$NGINX_CONF" ]; then
  echo "create_default_conf.sh"
  sh "$DIR_PROJECT/sh/create_default_conf.sh" "$TARGET_PORT" "$DIR_PROJECT"
fi
echo "create_nginx_docker_compose.sh"
sh "$DIR_PROJECT/sh/create_nginx_docker_compose.sh" "$NGINX_CONTAINER" "$DIR_PROJECT" "$NGINX_PORT"

# Use sed to replace the port
sed -i "s/proxy_pass http:\/\/host.docker.internal:[0-9]*;/proxy_pass http:\/\/host.docker.internal:$TARGET_PORT;/" "$NGINX_CONF"

cat "$NGINX_CONF"

# Check if Nginx container is running
if docker ps --filter "status=running" --filter "name=$NGINX_CONTAINER" | grep -qw "$NGINX_CONTAINER"; then
  echo "Nginx container is already running"
elif docker ps --filter "name=$NGINX_CONTAINER" | grep -qw "$NGINX_CONTAINER"; then
  echo "Nginx container already exists, but is not running"
  docker-compose -f "$DIR_PROJECT/nginx/docker-compose.nginx.yml" down
  docker-compose -f "$DIR_PROJECT/nginx/docker-compose.nginx.yml" up -d
else
  docker-compose -f "$DIR_PROJECT/nginx/docker-compose.nginx.yml" up -d
fi


# Check if the container is running
for i in {1..10}
do
  if docker ps --filter "status=running" --filter "name=$NGINX_CONTAINER" | grep -qw "$NGINX_CONTAINER"; then
    echo "Nginx container is ready"
    break
  fi
  if [ "$i" -eq 10 ]; then
    echo "Failed to start Nginx container"
    exit 1
  fi
  echo "Waiting for Nginx container to be ready ($i/10)"
  sleep 1
done


# Reload Nginx to apply the changes
docker exec -i "$NGINX_CONTAINER" nginx -s reload

create_default_conf.sh

이곳에 사용한 host.docker.internal 은 도커 컨테이너 내부에서 외부의 port에 접근하는 방식이다.

다만 이 방식은 window와 mac에서만 동작하므로 docker-compose 파일에서 추가 작업이 필요하다.

#!/bin/bash

TARGET_PORT=$1
DIR_PROJECT=$2

cat << EOF > "$DIR_PROJECT/nginx/conf.d/default.conf"
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location / {
        proxy_pass http://host.docker.internal:$TARGET_PORT;
    }
}
EOF

create_nginx_docker_compose.sh

extra_host에 아래 파일처럼 작성하면 window나 mac과 동일하게 동작한다.

#!/bin/bash

NGINX_CONTAINER=$1
DIR_PROJECT=$2
NGINX_PORT=$3

cat << EOF > "$DIR_PROJECT/nginx/docker-compose.nginx.yml"
version: '3.8'
services:
  nginx:
    image: nginx:1.25.1
    container_name: $NGINX_CONTAINER
    restart: unless-stopped
    ports:
      - "$NGINX_PORT:80"
    volumes:
      - $DIR_PROJECT/nginx/conf.d:/etc/nginx/conf.d
    extra_hosts:
      - "host.docker.internal:host-gateway"
EOF