nginx 활용한 무중단 배포1

박시시·2023년 3월 19일
0

PROJECT

목록 보기
1/2

무중단 배포란 말 그대로 중단없는 배포를 뜻한다. 아래 코드를 봐보자.

deploy.sh

#!/bin/bash

REPOSITORY=/home/ubuntu/deploy

cd $REPOSITORY

JAR_NAME=$(ls $REPOSITORY/ | grep '.jar' | tail -n 1)
JAR_PATH=$REPOSITORY/$JAR_NAME

CURRENT_PID=$(pgrep -f *.jar)

if [ -z $CURRENT_PID ]
then
  echo "> Nothing to end."
else
  echo "> kill -9 $CURRENT_PID"
  kill -15 $CURRENT_PID
  sleep 5
fi

echo "> $JAR_PATH deploy"
nohup java -jar $JAR_PATH --spring.profiles.active=prod > /dev/null 2> /dev/null < /dev/null &

간략히 코드 설명을 해보자면,
현재 운영중인 was를 찾아내 종료하고(kill -15 $CURRENT_PID) nohup 명령어를 통해 jar 파일을 실행시키고 있다.

이 배포 코드의 문제점은 확연하다. 바로 was를 내리고 다시 올리는 동안에 downtime이 발생한다는 것이다.
이러한 중단 배포 방식은 결국 고객에게 부정적인 경험을 제공하게 될 수 밖에 없다. 예를 들어 고객이 결제를 하고 있는 도중 서버가 중단된다거나 무언가를 작성 중에 서버가 내려간다면 고객은 좋지 않은 경험을 하게 된다.
그렇기에 무중단 배포 방식을 구현하여 안정적인 배포 체계를 갖춰야 고객에게 더욱 양질의 서비스를 제공할 수 있게 된다.

무중단 배포 전략

무중단 배포를 위해서는 로드밸런서를 통해 연결된 2개 이상의 인스턴스가 필요하다. 배포 과정에서 이 로드밸런서의 트래픽을 제어하여 중단없이 서비스를 제공하게 된다.


(무중단 배포 기본 구성, 출처: https://www.samsungsds.com/kr/insights/1256264_4627.html)

(현재 진행중인 프로젝트에서는 인스턴스 2개가 아닌 하나의 인스턴스에 각자 다른 포트로 was를 띄우는 식으로 구성하였다)

이러한 무중단 배포 전략에는 크게 3가지가 있다. 롤링 배포, 블루/그린 배포, 카나리 배포가 그것이다.

간단히 정리한 내용을 아래와 같이 공유한다.

  1. 롤링 업데이트
  • 가동중인 서버(인스턴스)를 여러개 갖춘 환경에서 서버를 정해진 수만큼 구버전에서 새버전으로 점진적으로 교체해가는 전략
  • 장점: 추가적인 인스턴스가 필요 없다. 그렇기에 서버수 제약이 있을 경우 유용하다.
  • 단점: 새 버전 배포 중 트래픽을 받아줄 인스턴스 수가 잠시 감소되어 트래픽이 몰릴 수 있다. 배포 진행시에 구버전, 신버전이 공존하여 호환성 문제 발생할 수도 있다.
  1. 블루/그린 배포
  • 운영 중인 구버전과 비슷한 환경으로 신버전 인스턴스 구성후 모든 트래픽을 한 번에 신버전 쪽으로 전환하는 방식
  • 장점: 롤백이 비교적 용이하다. 어느시점이든 하나의 버전만 운영되기에 서비스 버전 호환성 문제 없다. 기존 운영환경에 영향 주지 않고 새 버전 테스트가 가능하다.
  • 단점: 시스템 자원이 두 배로 필요하다. 즉 비용과 관련된 부분 고려해야 한다.
  1. 카나리 배포
  • 일부 트래픽을 새 버전으로 분산하여 문제가 있는지 등을 확인한다. 없다면 트래픽을 단계적으로 전환한다.
  • 장점: 문제 상황을 빠르게 감지할 수 있다. A/B 테스트에 활용할 수도 있다. 성능 모니터링에 유용하다.
  • 단점: 높은 수준의 네트워크 트래픽 제어가 필요하므로 구현하기 힘들 수 있다.

블루/그린을 활용한 무중단 배포

먼저 블루/그린을 통해 구현한 무중단 배포 방식을 간단히 설명 뒤, 조금 더 개선된 버전을 소개하는 식으로 글을 이어나가겠다.

구조는 간단하다. 8080, 8081 포트로 was를 띄워 놓고 nginx를 통해 트래픽을 was 중 하나로 흘려보낸다. 처음에는 8080으로 흘려보낸다고 하자.

이제 새로운 버전을 배포한다 해보자. 현재 8081 포트가 idle 상태이므로 8081 포트로 새 버전의 애플리케이션을 배포한다.

이제 nginx의 설정을 변경하여 현재 idle 상태인 8081 포트로 트래픽이 흘러가도록 바꿔준다.

8080 포트의 애플리케이션(blue)에서 8081 포트의 새 버전 애플리케이션(green)으로 트래픽이 흘러가게 됨으로써 중단없는 배포가 가능하게 되었다.

코드 살펴보기

코드에 대해 간략하게만 설명해두었다. 자세한 코드 설명은 향로님의 블로그를 참고바란다.

배포와 관련된 코드와 스크립트를 살펴보자.

먼저 idle profile(각 포트에 맞는 프로필을 따로 설정했다), idle port를 찾는 function들이 있는 스크립트는 아래와 같다.

# profile.sh


#!/bin/bash

# 현재 profile 찾기
function find_current_profile()
{
		RESPONSE_CODE=$(curl -o /dev/null -w "%{http_code}" http://127.0.0.1/profile)

    if [ ${RESPONSE_CODE} -ge 400 ] # 400 보다 크면 (40x/50x 에러 모두 포함)
    then
        CURRENT_PROFILE=prod2
    else
        CURRENT_PROFILE=$(curl -s http://127.0.0.1/profile)
    fi

		echo "${CURRENT_PROFILE}"
}

# 현재 port 찾기
function find_current_port()
{
    CURRENT_PROFILE=$(find_current_profile)

    if [ ${CURRENT_PROFILE} == prod1 ]
    then
      echo "8080"
    else
      echo "8081"
    fi
}

# 현재 사용하지 않는 idle profile 찾기
function find_idle_profile()
{
    CURRENT_PROFILE=$(find_current_profile)

    if [ ${CURRENT_PROFILE} == prod1 ]
    then
      IDLE_PROFILE=prod2
    else
      IDLE_PROFILE=prod1
    fi

    echo "${IDLE_PROFILE}"
}

# idle profile의 port 찾기
function find_idle_port()
{
    IDLE_PROFILE=$(find_idle_profile)

    if [ ${IDLE_PROFILE} == prod1 ]
    then
      echo "8080"
    else
      echo "8081"
    fi
}

코드는 간단하다. http://127.0.0.1/profile 로 요청하여 리턴된 값에 따라 idle인지 현재 사용중인지를 판단하게 된다.

GET /pofile 코드는 아래와 같다.

@GetMapping("/profile")
public String getProfile() {
    return Arrays.stream(env.getActiveProfiles())
        .findFirst()
        .orElse("");
}

이제 위 function들을 활용하여 작성한 배포 스크립트를 살펴보자.

# deploy.sh

#!/bin/bash

ABSPATH=$(readlink -f $0)

# ABSDIR : 현재 deploy.sh 파일 위치의 경로
ABSDIR=$(dirname $ABSPATH)

# import profile.sh, switch.sh
source ${ABSDIR}/profile.sh
source ${ABSDIR}/switch.sh

REPOSITORY=/home/ubuntu/deploy

cd $REPOSITORY

JAR_NAME=$(ls $REPOSITORY/ | grep '.jar' | tail -n 1)
JAR_PATH=$REPOSITORY/$JAR_NAME

IDLE_PROFILE=$(find_idle_profile)
IDLE_PORT=$(find_idle_port)
CURRENT_PORT=$(find_current_port)

echo "> $IDLE_PORT 에서 구동중인 애플리케이션 pid 확인"
IDLE_PID=$(lsof -ti tcp:${IDLE_PORT})

if [ -z $IDLE_PID ]
then
  echo "> 구동중인 애플리케이션이 없습니다."
else
  echo "> kill -15 IDLE_PID"
  kill -15 $IDLE_PID
  sleep 5
fi

echo "> $JAR_NAME 를 profile=$IDLE_PROFILE 로 실행"
nohup java -javaagent:/home/ubuntu/scouter/agent.java/scouter.agent.jar \
  -Dscouter.config=/home/ubuntu/scouter/agent.java/conf/scouter.conf \		# scouter apm 관련 부분이다.
  -jar $JAR_PATH --spring.profiles.active=$IDLE_PROFILE --logging.file.path=/home/ubuntu/log/ \
  --logging.level.org.hibernate.SQL=DEBUG >> /home/ubuntu/log/deploy.log 2>/home/ubuntu/log/error.log &


echo "> $IDLE_PROFILE 10초 후 Health check 시작"
echo "> curl -s http://127.0.0.1:$IDLE_PORT/health "
sleep 10

for retry_count in {1..10}
do
  response=$(curl -s http://127.0.0.1:$IDLE_PORT/health)
  up_count=$(echo $response | grep 'healthy' | wc -l)

  if [ $up_count -ge 1 ]
  then # $up_count >= 1 ("healthy" 문자열이 있는지 검증)
      echo "> Health check 성공"
      echo "> 현재의 idle port로 트래픽 전환"
      switch_proxy # 헬스체크가 성공한다면 nginx 설정을 변경해주는 switch_proxy 메서드를 실행시킨다.
      break
  else
      echo "> Health check의 응답을 알 수 없거나 혹은 status가 healthy가 아닙니다."
      echo "> Health check: ${response}"
  fi

  if [ $retry_count -eq 10 ]
  then
    echo "> Health check 실패. "
    echo "> Nginx에 연결하지 않고 배포를 종료합니다."
    exit 1
  fi

  echo "> Health check 연결 실패. 재시도..."
  sleep 10
done

# 기존 포트로 띄운 애플리케이션 종료
CURRENT_PID=$(lsof -ti tcp:${CURRENT_PORT} # 여기서의 CURRENT_PORT는 switch_proxy를 하기 전의 current port이다.

if [ -z ${CURRENT_PID} ]
then
  echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
else
  echo "> kill -15 $CURRENT_PID"
  kill -15 ${CURRENT_PID}
  sleep 5
fi

코드의 흐름은 다음과 같다.
1. idle port에서 구동 중인 애플리케이션을 확인한다. 앞선 설명에서 가정했듯 8081 port가 현재 idle port이다.
2. idle port(여기선 8081)로 띄워져있는 애플리케이션이 있다면 종료시켜준다(현재는 prod1의 프로필로 8080에 앱이 떠있는 상태다. 그러므로 GET /profile api의 응답은 prod1일 것이며 idle port는 8081이 된다).
3. idle port로 새로운 버전의 애플리케이션을 구동시킨다.
4. 새로 올라간 애플리케이션의 health check를 한다.
5. health check에 성공한다면 switch_proxy 메서드를 실행시킨다.
6. 기존 port(8080)에 올라간 애플리케이션(blue)를 종료시킨다.

가장 중요한 switch_proxy 관련된 코드들을 살펴보자.

먼저 nginx.conf 파일은 아래와 같다.

user www-data;
worker_processes auto;
pid /run/nginx.pid;

events {
	worker_connections 768;
}

http {
  access_log /var/log/nginx/access.log;
  error_log /var/log/nginx/error.log;

  gzip on;

  server {
		include /service_url.inc
  	
  	location / {
      proxy_pass http://$service_url;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
    }
  }
} 

중요한 부분은 proxy_pass http://$service_url; 여기다. /etc/nginx/conf.d/service-url.inc 이 경로에 아래와 같은 파일을 만들어두었다.

# service-url.inc

set $service_url http://127.0.0.1:8080;

이제 / 해당 경로로 들어온 트래픽은 $service_url 변수에 등록해둔 ip+port로 흘러가게 된다.

switch_proxy function은 이 service-url.inc 파일을 덮어쓰며 실행시키는 역할을 한다. 스크립트는 아래와 같다.

# switch.sh

#!/bin/bash

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh

function switch_proxy() {
    IDLE_PORT=$(find_idle_port)

    echo "> 전환할 Port: $IDLE_PORT"
    echo "> Port 전환"

    # | sudo tee ~ : 앞에서 넘긴 문장을 service-url.inc에 덮어씀
    echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc

    echo "> nignx Reload"
    sudo service nginx reload
}

마지막으로 nginx reload를 해주며 변경된 설정을 적용시켜준다. 이제 기존 8080으로 흐르던 트래픽은 8081로 전환되었다.
기존 8080포트의 was를 내려줌으로써 무중단 배포가 마무리 되었다.

한계점

일단 가장 걸리는 부분은 그린 서버로의 트래픽 전환 후 기존 블루 서버를 셧다운시키는 지점이다.
블루/그린은 롤백에 용이한 배포 전략이다. 그린 서버를 배포하고 트래픽 전환 전에 헬스체크를 통해 문제가 있을 시 트래픽 전환을 안하는 것도 롤백의 일종이겠지만, 트래픽 전환 후 나중에 문제가 생겨 급히 롤백할 경우도 발생할 것이다. 실무에서는 이 점을 보완하고자 블루 서버(블루가 여러개 띄워져있을때) 전체를 다 끄는 것이 아닌 일부 서버를 남겨둔다고 들었다(확실한 정보는 아니니 그런 경우가 있을 수도 있다 정도로만 알고 넘어가자..)
셧다운의 경우도, 반드시 graceful하게 셧다운 시켜야 한다. 12시 정각에 그린 서버로 트래픽 전환 후 블루 서버를 셧다운 시킨다 가정해보자. 11:59:59에 10분이 걸리는 작업(물론 프로덕션 환경에서 이렇게 오래 걸리는 작업은 없겠지만)에 대한 요청이 블루 서버로 들어왔다 하자. 이 경우 블루 서버를 그냥 셧다운 시킨다면 해당 작업을 요청한 클라이언트는 의문도 모른채 자신의 작업이 중단되는 경험을 하게 될 것이다.
물론 이러한 것을 고려하여 pid를 죽일 때 kill -15 ${CURRENT_PID} 의 방식으로 꺼주도록 했다. -15 옵션을 준다면 진행중인 작업을 마친 후에 종료가 된다.

nginx를 reload 하는 점도 걸린다. 배포시마다 설정 변경을 해가면는 것이 맞는가. reload하는데에 장애가 발생할 경우는 어떻게 대처할 것인가. reload에 예상보다 더 많은 시간이 소요된다면 그 사이 중단이 될 가능성도 있는 것이 아닐까

-> nginx reload는 설정만 재적용하는 것이기 때문에 서버 중단 없이 바로 적용이 된다고 한다.
하지만 그럼에도 nginx 배포시마다 설정 변경을 한다면 배포 과정이 nginx에 너무 의존하게 된다고 생각했다.

더불어 현재의 로드밸런싱 기능은 완벽하지 않다. 헬스체크 기반의 로드밸런싱이 되고 있지 않아서이다. 헬스체크를 통해 up인 곳으로 트래픽을 흘려보내고 down인 곳에는 흘려보내지 않도록 함으로써 고가용성을 확보할 수 있다. 그리고 이를 이용해 배포시 블루 서버를 down으로 변경하고 그린 서버를 up으로 해둠으로써 nginx 설정변경 없이 무중단 배포를 구현할 수 있을 거라 생각한다.

참조

https://jojoldu.tistory.com/267
https://stalker5217.netlify.app/devops/github-action-aws-ci-cd-4/
https://www.samsungsds.com/kr/insights/1256264_4627.html
https://tecoble.techcourse.co.kr/post/2022-11-01-blue-green-deployment/

0개의 댓글