다운타임 없이 서버 배포하기! (BlueGreen)

김재연·2025년 3월 2일
8
post-thumbnail
다운타임?

다운타임 - 위키백과, 우리 모두의 백과사전

위키 백과에서 설명하고 있는 다운타임의 설명은 이렇습니다.

정리할 필요도 없이 간략하지만, 그럼에도 불구하고 더 간략하게 정리하면, 서버 가용 불가 상태다운타임이라고 표현할 수 있습니다.

그러면 저와 같이 서비스를 개발하는 개발자에게 다운타임이 왜 중요할까요?

다운타임이 중요한 이유

웹 다운타임을 심각하게 생각해야 하는 4가지 이유 | CIO

제가 생각하고 있는 다운타임이 서비스 기업에게 주는 문제점을 정확히 짚어주고 있는 글이 있어 들고 와봤습니다.

  1. 이용자는 참을성이 없다
  2. 원인을 추적하기 어렵다
  3. 비싸다
  4. 기업 평판에 치명적이다

해당 아티클은 위와 같이 4가지 문제점을 지적해주고 있는데, 저에게 특히 와닿는 문제들은 1, 3번째 인 것 같습니다.

  • 이용자는 참을성이 없다.

아무래도 빠른 인터넷이 모두에게 익숙한 현대 세상에서 데이터 로딩 시간은 굉장히 중요합니다.

글에 따르면 아카마이 테크놀로지고메즈닷컴이 조사해본 결과 소비자의 47%가 2초의 로딩 시간을 예상하고 있으며, 3초 이상 걸리게 되면 40%는 해당 웹 사이트에서 이탈하게 된다고 합니다.

  • 비싸다.

60%의 기업이 다운타임 1분당 손실이 평균 1000달러라고 합니다.

아마존은 1초 딜레이가 연 매출 16억 달러 손실로 이어진다고도 합니다.

이러한 점들로 미루어 볼 수 있듯이, 서비스를 운영하고 있다면 다운타임은 꽤나 치명적입니다. 심지어 다른 것도 아닌 배포로 인한 다운타임이 1분이 넘는 시간이 걸린다면 이 부분은 분명히 개선할 포인트로 여겨지지 않을까요?

이를 해결하기 위해 우리는 다양한 무중단 배포 방식들에 대해 알아볼 필요가 있습니다.

무중단 배포란 무엇인가

무중단 배포란, 말 그대로 배포가 무중단으로 이루어지는 것을 의미합니다. 즉, 다운타임 없이 배포를 진행하는 것을 의미합니다.

무중단 배포 종류

무중단 배포로는 롤링, 카나리, 블루그린 방식등이 있습니다.

각각 하나씩 알아보죠.

  • 롤링

모든 무중단 배포 방식에는 로드 밸런싱이 필요합니다. Client의 Request를 어떤 서버에 꽂아줄 지 정해야 한다는 것입니다.

글의 막단에서, 이 부분을 다룰 예정인데, 저는 Nginx를 통해 이를 구현했습니다.

롤링 배포 방식은 그림에 보이는 것처럼, 점진적으로 업데이트를 진행하는 것을 볼 수 있습니다. (초록색이 업데이트)

해당 방식은, 로드 밸런싱을 통해 업데이트 이전 버전에서 업데이트 이후 버전으로 인스턴스를 점차적으로 업데이트 시킵니다.

  • 카나리

[잠깐과학]광부를 구한 생명의 지저귐 탄광의 카나리아 : 동아사이언스

이 글에서 보면, 카나리아라는 새는 유독 가스에 예민하기 때문에, 해당 새와 함께 갱도에 내려가 일반적으로 알아채기 힘든 유독가스의 위험으로부터 사람들을 구해줬죠.

이 원리와 굉장히 비슷한 방식의 무중단 배포 방식입니다.

일부 인스턴스만 업데이트를 진행하고 일부 유저들을 해당 인스턴스로 로드 밸런싱을 해주어, 업데이트 한 버전이 괜찮은지 확인을 하며 점차 점차 로드 밸런싱하는 유저의 수를 늘려가, 결국 모든 유저를 업데이트 한 인스턴스에 할당하는 방식입니다.

  • 블루그린 (제가 선택한 무중단 배포 방식)


블루 그린 배포 방식은 유저가 현재 사용하고 있는 인스턴스는 그대로 내비두고, 다른 인스턴스를 업데이트 해줍니다.

그리고 모든 업데이트가 진행이 되면, 모든 유저를 한번에 업데이트한 인스턴스로 로드 밸런싱해줍니다.

블루 그린을 선택한 이유

아무래도, 사이드 프로젝트에서 진행했던거라, 유저가 많지 않아, 서버를 여러대로 운영하며 부하에 따른 로드밸런싱을 진행하는 것이 필요하지 않았습니다.

또한, 이와 같은 맥락으로 서버에 하나의 어플리케이션만 떠있으면 됐기 떄문에, 순간적으로 최대 2개의 이미지만 뜨게 됩니다.

이러한 맥락으로 인해 자연스럽게 블루 그린 무중단 배포 방식으로 진행하게 되었습니다.

구현 방법

구현은 Bash Shell로 진행할 것입니다. sh script.sh 만 실행시키면 알아서 무중단 배포가 진행되도록 말이죠.

그러면 구현에 앞서, 순서를 정의하고 순차적으로 정의하고 들어가보겠습니다.

  1. 현재 떠 있는 컨테이너가 Blue or Green Container인지 확인합니다.
  2. 현재 떠 있는 컨테이너가 아닌 색깔의 Container를 띄웁니다. (업데이트된 이미지로)
    • 이미지는 Docker Hub를 사용하여 관리했습니다.
  3. 새로 띄운 Container가 잘 떴는지 Health Check를 진행해줍니다.
    • 새로 띄운 Container가 잘 뜨지 않았으면 배포 프로세스를 종료합니다.
  4. 새롭게 띄운 Container로 포워딩 해줍니다.
  5. 기존 컨테이너를 내리고 삭제합니다.

순서를 정의하고 보면 굉장히 간단한 것을 볼 수 있습니다.

이제 순차적으로 구현을 진행해보죠

  • 현재 떠 있는 컨테이너가 Blue or Green Container인지 확인합니다.
IS_BLUE=$(docker ps | grep wespot-blue)

다음과 같이, docker ps (현재 실행되고 있는 컨테이너 출력) 그리고 grep (출력된 메시지에서 원하는 키워드가 포함된 라인을 출력)을 파이프라인으로 이어서 진행하면 됩니다.

  • 현재 떠 있는 컨테이너가 아닌 색깔의 Container를 띄웁니다. (업데이트된 이미지로)
...이외 로직들

docker pull '도커 허브 저장소를 입력하셔야합니다. (공개된 글이기에 가립니다)'

if [ -z "$IS_BLUE" ];then
  echo "### GREEN => BLUE ###"

  echo "1. BLUE 컨테이너 실행"
  docker compose up -d wespot-blue
  
else
  echo "### BLUE => GREEN ###"

  echo "1. GREEN 컨테이너 실행"
  docker compose up -d wespot-green
fi

일단, 먼저 pull 을 통해서 CI/CD 파이프라인을 통해 업데이트된 따끈따근한 이미지를 Docker Hub에서 끌고옵니다.

그리고 docker compose를 통해 상황에 맞는 container를 띄웁니다.

services:
  redis:
    image: redis
    hostname: redis
    container_name: redis
    ports:
      - "6379:6379"
    networks:
      - my-network
    environment:
      TZ: "Asia/Seoul"

  wespot-green:
    image: "도커 허브 저장소를 입력하셔야해요."
    container_name: wespot-green
    ports:
      - "8081:8080"
    platform: linux/amd64
    extra_hosts:
      - "host.docker.internal:host-gateway"
    depends_on:
      - redis
    networks:
      - my-network
    environment:
      TZ: "Asia/Seoul"

  wespot-blue:
    image: "도커 허브 저장소를 입력하셔야해요."
    container_name: wespot-blue
    ports:
      - "8080:8080"  
    platform: linux/amd64
    extra_hosts:
      - "host.docker.internal:host-gateway"
    depends_on:
      - redis
    networks:
      - my-network
    environment:
      TZ: "Asia/Seoul"

networks:
  my-network:
    driver: bridge

도커 컴포즈도 굉장히 간단하게 짜주었습니다.

redis와 연결해주기 위해 network를 구성해주고, blue: 8080, green: 8081 포트로 매핑해주어 작성해주었습니다.

그리고 redis container에 depends하게 container가 뜰 수 있도록 해주었습니다. 그리고 docker compose는 자동적으로 네트워크를 생성해주긴 하지만, 명시적으로 network를 명시해주었습니다.

  • 새로 띄운 Container가 잘 떴는지 Health Check를 진행해줍니다.
MAX_RETRIES=30

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 "2. health check"
  if ! check_service "http://localhost:8080"; then
    echo "BLUE health check 가 실패했습니다."
    exit 1
  fi
  ...이외 로직들
else
  ...이외 로직들
  echo "2. health check"
  if ! check_service "http://localhost:8081"; then
    echo "GREEN health check 가 실패했습니다."
    exit 1
  fi
  ...이외 로직들
fi

실제 헬스 체크 로그

curl 명령어를 활용해 서버에서 정상적으로 응답이 내려오는지 확인하여, 만일 최대 RETRIES 횟수만큼 요청을 진행했음에도 불구하고 서버가 뜨지 않았다면 exit해주고, 그렇지 않다면 다음 스텝을 진행해줍니다.

  • 새롭게 띄운 Container로 포워딩 해줍니다.

Container가 잘 떴다면 포워딩을 진행해줍니다.

이 때 저는 Nginx의 포워딩을 활용하여 해결했습니다.

server {
        listen 443 ssl;
		
        ... 코드들

        location / {
                proxy_pass http://172.31.2.221:8080;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
}

		if ($host = (우리 서버 DNS 주소)) {
	        return 301 https://$host$request_uri;
	    }


        listen 80 ;
        listen [::]:80 ;

		... 코드들
}

443 포트로 요청이 들어왔을 때 포워딩을 진행해주는 코드이고 80으로 들어오더라도

if ($host = (우리 서버 DNS 주소)) {
	return 301 https://$host$request_uri;
}

해당 코드를 통해 리다이렉션을 진행해줍니다.

우리가 포워딩을 위해 집중해줘야 부분은

location / {
		proxy_pass http://172.31.2.221:8080;
		proxy_set_header Host $host;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

이 부분입니다.

이렇게 저 proxy_pass http://172.31.2.221:8080; 이 부분만 8081로 바꾸거나 8080으로 바꾸면 되는 것입니다.

그러면 이를 행하기 위해 8080으로 정의된 nginx 파일, 8081로 정의된 nginx 파일을 두고, 배포 과정중에 nginx 설정을 갈아끼워주기만 하면 되겠죠?

if [ -z "$IS_BLUE" ];then
  ...이외의 로직들
  echo "3. nginx 재실행"
  sudo cp /etc/nginx/sites-available/default-blue /etc/nginx/sites-available/default
  sudo nginx -s reload
  ...이외의 로직들
else
  ...이외의 로직들
  echo "3. nginx 재실행"
  sudo cp /etc/nginx/sites-available/default-green /etc/nginx/sites-available/default
  sudo nginx -s reload
  ...이외의 로직들
fi

이렇게 default-(green or blue) 파일을 미리 정의해두고, default 로 덮어씌워줍니다.

그리고서 nginx reload를 하게 되면 포워딩이 진행되는거죠.

  • 기존 컨테이너를 내리고 삭제합니다.
...이외의 로직들

if [ -z "$IS_BLUE" ];then
  ...이외의 로직들

  echo "4. GREEN 컨테이너 내리기"
  docker stop wespot-green

  echo "5. GREEN 컨테이너 삭제"
  docker rm -f wespot-green

else
  ...이외의 로직들

  echo "4. BLUE 컨테이너 내리기"
  docker stop wespot-blue

  echo "5. BLUE 컨테이너 삭제"
  docker rm -f wespot-blue
fi

echo "6. 중지된 모든 컨테이너 삭제"
docker container prune -f

echo "7. 배포 완료"

모든 과정을 진행했습니다.

가장 먼저는 포워딩까지 진행했으니 이제 사용하지 않는 컨테이너를 내려줍니다.

그리고 서버의 효율적인 자원사용을 위해 깔끔하게 잉여 데이터를 지워줍니다.

이렇게 하면 모든 과정은 마무리 되게 됩니다.

도식화

현재까지 설명드린 서버의 동작을 간단하게 도식화하면 다음과 같습니다.

결과

이러한 과정들을 통해서 무중단 배포를 구현하여, 사용자들에게 더욱이 양질의 경험을 제공할 수 있게 되었습니다! 짝짝

긴 글 읽어주셔서 감사하고 마지막으로 전체적인 코드들을 보여드리겠습니다. (이번 블로그 포스팅에서 다룬 부분들에 대해서만 코드 업로드 합니다.)

전체 코드
  • Nginx
server {
        listen 443 ssl;
		
        ... 코드들

        location / {
                proxy_pass http://172.31.2.221:8081;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
}

		if ($host = (우리 서버 DNS 주소)) {
	        return 301 https://$host$request_uri;
	    }


        listen 80 ;
        listen [::]:80 ;

		... 코드들
}
  • Docker Compose
services:
  redis:
    image: redis
    hostname: redis
    container_name: redis
    ports:
      - "6379:6379"
    networks:
      - my-network
    environment:
      TZ: "Asia/Seoul"

  wespot-green:
    image: "도커 허브 저장소를 입력하셔야해요."
    container_name: wespot-green
    ports:
      - "8081:8080"
    platform: linux/amd64
    extra_hosts:
      - "host.docker.internal:host-gateway"
    depends_on:
      - redis
    networks:
      - my-network
    environment:
      TZ: "Asia/Seoul"

  wespot-blue:
    image: "도커 허브 저장소를 입력하셔야해요."
    container_name: wespot-blue
    ports:
      - "8080:8080"  
    platform: linux/amd64
    extra_hosts:
      - "host.docker.internal:host-gateway"
    depends_on:
      - redis
    networks:
      - my-network
    environment:
      TZ: "Asia/Seoul"

networks:
  my-network:
    driver: bridge
  • 배포 스크립트
#!/bin/bash

IS_BLUE=$(docker ps | grep wespot-blue)
MAX_RETRIES=30

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
}

set -e
docker container prune -f
docker image prune -a -f
docker network prune -f
docker volume prune -f
docker builder prune -f
docker pull '도커 허브 저장소를 입력하셔야해요. (공개된 글이기에 가립니다)'

if [ -z "$IS_BLUE" ];then
  echo "### GREEN => BLUE ###"

  echo "1. BLUE 컨테이너 실행"
  docker compose up -d wespot-blue

  echo "2. health check"
  if ! check_service "http://localhost:8080"; then
    echo "BLUE health check 가 실패했습니다."
    exit 1
  fi

  echo "3. nginx 재실행"
  sudo cp /etc/nginx/sites-available/default-blue /etc/nginx/sites-available/default
  sudo nginx -s reload

  echo "4. GREEN 컨테이너 내리기"
  docker stop wespot-green

  echo "5. GREEN 컨테이너 삭제"
  docker rm -f wespot-green

else
  echo "### BLUE => GREEN ###"

  echo "1. GREEN 컨테이너 실행"
  docker compose up -d wespot-green

  echo "2. health check"
  if ! check_service "http://localhost:8081"; then
    echo "GREEN health check 가 실패했습니다."
    exit 1
  fi

  echo "3. nginx 재실행"
  sudo cp /etc/nginx/sites-available/default-green /etc/nginx/sites-available/default
  sudo nginx -s reload

  echo "4. BLUE 컨테이너 내리기"
  docker stop wespot-blue

  echo "5. BLUE 컨테이너 삭제"
  docker rm -f wespot-blue
fi

echo "6. 중지된 모든 컨테이너 삭제"
docker container prune -f

echo "7. 배포 완료"
profile
끊임없이 '성장'하는 개발자 김재연입니다.

4개의 댓글

comment-user-thumbnail
2025년 3월 4일

유익한 글 잘 읽었어요 :)

2개의 답글

관련 채용 정보

Powered by GraphCDN, the GraphQL CDN