Skip to content

무중단 배포에 대해 알아보자!

김동철 edited this page Aug 29, 2023 · 27 revisions

무중단 배포란?

downtime

서비스가 운영 중일 때, 새로운 버전을 배포하기 위해서는 기존 서비스를 종료하고 새로운 서비스를 시작해야 합니다. 이 과정에서 다운타임(Downtime)이 필연적으로 발생하며, 해당 시간동안 사용자들은 서비스를 이용할 수 없게 됩니다. 이러한 다운타임을 해결해주는 방법이 바로 무중단 배포입니다.

즉, 서비스를 중단하지 않고 새로운 버전의 애플리케이션을 배포하는 방식을 무중단 배포라고 합니다.

  • 다운타임(Downtime): 시스템을 이용할 수 없는 시간을 일컫는다. 이용 불가능의 의미는 시스템이 오프라인이거나 사용할 수 없는 상황에 놓이는 상태를 가리킨다.

무중단 배포 전략

1. Rolling 배포

다운로드

  • 트래픽을 점진적으로 구 버전에서 신 버전으로 옮기는 방식입니다.

방법 1.

  1. 인스턴스를 하나 추가하여 새로운 버전의 애플리케이션을 실행한다
  2. 로드 밸런서에 추가된 인스턴스를 연결한다
  3. 구 버전의 인스턴스를 종료한다.
  4. 위의 과정을 모든 인스턴스에 대해 반복한다.
  • 서버 개수를 유연하게 조절할 수 있는 클라우드 환경에 적합한 방법입니다.

방법 2.

  1. 구 버전의 인스턴스를 로드 밸런서에서 해제하고
  2. 새로운 버전의 애플리케이션을 실행한 뒤
  3. 다시 로드 밸런서에 연결시키는 방법입니다.
  • 물리적인 서버로 서비스를 운영하는 온프레미스(On-premise) 환경에 적합한 방식입니다.
    • On-premise : 서버를 클라우드와 같은 '가상의 공간'이 아니라, 자체적으로 보유하고 있는 서버에 직접 설치하고 운영하는 방식

장점

  • Blue/Green 배포 방식에 비해, 많은 서버 자원을 확보하지 않아도 된다.
  • 점진적으로 새로운 버전이 사용자에게 출시되므로, 배포로 인한 위험성을 줄일 수 있다.

단점

  • 방법 2의 경우, 서비스 중인 인스턴스의 수가 줄어들면서 각각의 서버가 부담하는 트래픽의 양이 늘어날 수 있습니다. 따라서 전체 트래픽의 양과 단일 서버가 처리할 수 있는 트래픽의 양을 잘 고려하여 배포를 진행해야 합니다.
  • 또한, 구버전과 신버전의 어플리케이션이 동시에 서비스되기 때문에 호환성 문제가 발생할 수 있습니다.

2. Blue/Green 배포

다운로드 (1) 다운로드 (2)

  • 트래픽을 한번에 구버전에서 신버전으로 옮기는 전략입니다.

  • 현재 운영중인 서비스의 환경을 Blue라고 부르고, 새롭게 배포할 환경을 Green이라고 합니다.

  • Blue와 Green의 서버를 동시에 구성해둔 상태로, 배포 시점에 로드 밸런서로 트래픽을 Blue에서 Green으로 일제히 전환시킵니다.

장점

  • 롤링 배포와 카나리 배포와 달리, 전체 트래픽을 한번에 새로운 버전으로 옮기기 때문에 호환성 문제가 발생하지 않고 신속한 배포가 가능합니다.

단점

  • 실제 서버 운영에 필요한 리소스 대비 2배의 리소스를 확보해야 합니다. 따라서, 온프레미스(On-premise) 환경의 경우 비용 부담이 크다는 단점이 있습니다.

3. Canary 배포

다운로드 (3)

  • 서버 트래픽의 일부를 신 버전으로 분산하여 오류 여부를 확인한 뒤, 이상이 없으면 전체를 배포하는 전략입니다.

장점

  • 새로운 버전의 배포로 인한 위험을 최소화 할 수 있습니다.
  • A/B 테스트가 가능하며, 성능 모니터링에 유용합니다.

단점

  • 롤링 배포와 마찬가지로 신/구 버전의 애플리케이션이 동시에 존재하므로 호환성 문제가 발생할 수 있습니다.

프로젝트에 적용한 스크립트

deploy.sh

#! /bin/bash

DOCKER_COMPOSE_FILE=$1
DOCKER_USERNAME=$2
DOCKER_REPO=$3
ABS_PATH=$(readlink -f "$0")
ABS_DIR=$(dirname "$ABS_PATH")
source "$ABS_DIR"/profile.sh

IDLE_CONTAINER=$(find_idle_profile)

echo "> Nginx, Redis container 실행"
docker compose -f "$DOCKER_COMPOSE_FILE" up nginx redis -d --build

# $IDLE_CONTAINER의 컨테이너 ID를 찾고, 있다면 제거
if [ "$(docker ps -aqf name="^$IDLE_CONTAINER$")" ];
then
  echo "> $IDLE_CONTAINER container 제거"
  docker stop "$IDLE_CONTAINER" && docker rm "$IDLE_CONTAINER"
else
  echo "> 구동 중인 유휴 spring container가 없으므로 종료하지 않습니다."
fi

if [[ "$(docker images -q "$DOCKER_USERNAME"/"$DOCKER_REPO":latest 2> /dev/null)" != "" ]]; then
  echo "> latest image tag를 old로 변경"
  docker rmi "$DOCKER_USERNAME"/"$DOCKER_REPO":old
  docker tag "$DOCKER_USERNAME"/"$DOCKER_REPO":latest "$DOCKER_USERNAME"/"$DOCKER_REPO":old
  docker rmi "$DOCKER_USERNAME"/"$DOCKER_REPO":latest
fi

echo "> $IDLE_CONTAINER container 실행"
docker compose -f "$DOCKER_COMPOSE_FILE" up "$IDLE_CONTAINER" -d --build

health.sh

#! /bin/bash

DOCKER_COMPOSE_FILE=$1
DOCKER_USERNAME=$2
DOCKER_REPO=$3
ABS_PATH=$(readlink -f "$0")
ABS_DIR=$(dirname "$ABS_PATH")
source "$ABS_DIR"/[profile.sh](http://profile.sh/)
source "$ABS_DIR"/[switch.sh](http://switch.sh/)

IDLE_PORT=$(find_idle_port)

echo "> Health check 시작"
echo "> IDLE_CONTAINER: $IDLE_PORT"
echo "> curl -s http://localhost:$IDLE_PORT/profiles"
sleep 10

for RETRY_COUNT in $(seq 1 10)
do
  RESPONSE=$(curl -s http://localhost:"$IDLE_PORT"/profiles)
  UP_COUNT=$(echo "$RESPONSE" | grep -c "spring") # spring이 들어간 행의 개수

  if [ "$UP_COUNT" -ge 1 ]
  then
    echo "> Health check 성공"
    switch_proxy
    if [ "$?" -ge 1 ]
    then
      exit 1
    fi
    # 성공  break
    break
  else
    echo "> 응답 실패"
    echo "> Health check: $RESPONSE"
  fi

  if [ "$RETRY_COUNT" -eq 10 ]
  then
    echo "> Health 실패"
    echo "> Nginx에 연결하지 않고 배포를 종료합니다."
    echo "> 배포에 실패한 container 삭제"
    IDLE_CONTAINER=$(find_idle_profile)
    docker stop "$IDLE_CONTAINER" && docker rm "$IDLE_CONTAINER"
    echo "> 실패한 docker latest image 삭제"
    docker rmi "$DOCKER_USERNAME"/"$DOCKER_REPO":latest
    exit 1
  fi

  echo "> Health check 실패, 5  재시도..."
  sleep 5
done

profile.sh

#! /bin/bash

# nginx와 연결되지 않은 profile 찾기(활성화시킬 profile)
function find_idle_profile() {
  RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://api.youngcha.team/profiles)

  if [ "$RESPONSE_CODE" -ge 400 ]
  then
    # nginx와 연결된 컨테이너가 없으면 spring2가 연결되어 있다고 설정
    CURRENT_PROFILE=spring2
  else
    CURRENT_PROFILE=$(curl -s https://api.youngcha.team/profiles)
  fi

  # 구동할 profile 설정
  if [ "$CURRENT_PROFILE" == spring1 ]
  then
    IDLE_PROFILE=spring2
  else
    IDLE_PROFILE=spring1
  fi

  echo "$IDLE_PROFILE"
}

# 쉬고 있는 profile의 port 찾기
function find_idle_port(){
  IDLE_PROFILE=$(find_idle_profile)

  if [ "$IDLE_PROFILE" == spring1 ]
  then
    echo "8081"
  else
    echo "8082"
  fi
}

switch.sh

#! /bin/bash

ABS_PATH=$(readlink -f "$0")
ABS_DIR=$(dirname "$ABS_PATH")
source "$ABS_DIR"/profile.sh

function switch_proxy(){
  IDLE_CONTAINER=$(find_idle_profile)

  echo "> 전환할 컨테이너: $IDLE_CONTAINER"
  docker exec nginx /bin/sh -c "echo set '\$service_url http://$IDLE_CONTAINER:8080;' | tee /etc/nginx/conf.d/service-url.inc"

  echo "Nginx 재시작"
  docker exec -i nginx nginx -s reload

  for RETRY_COUNT in $(seq 1 5)
  do
    INACTIVE_CONTAINER=$(find_idle_profile)
    if [ "$IDLE_CONTAINER" != "$INACTIVE_CONTAINER" ]
    then
      echo "> Nginx에 연결되지 않은 container 삭제"
      docker stop "$INACTIVE_CONTAINER" && docker rm "$INACTIVE_CONTAINER"
      break
    fi
    echo "Switch delayed"
    sleep 0.2
    if [ "$RETRY_COUNT" -eq 5 ]
    then
      echo "Nginx 전환에 실패했습니다."
      exit 1
    fi
  done
}

출처

Clone this wiki locally