ecsimsw

무중단 배포 적용기 본문

무중단 배포 적용기

JinHwan Kim 2021. 8. 10. 17:29

도입 배경 

시작하기 앞서 기존의 배포 방식을 먼저 설명하려고 한다. 적용한 프로젝트의 배포 방식은 아래 그림처럼, 젠킨스가 프로젝트를 빌드, 전달하고 실행 스크립트에 의해 인스턴스 내부에서 실행하고 있다. 

 

 

그렇게 빌드된 jar 파일을 실행하는 과정은 단순히 실행 중인 Application 프로세스를 종료하고, 새로 빌드된 Application을 띄우는 것이었는데, 이런 기존 방식에 두 가지 문제가 있었다.

 

기존 방식의 실행 스크립트 확인하기

더보기
#!/bin/bash
echo "> now ing app pid find!"
CURRENT_PID=$(pgrep -f back-end)   # PID 구별 key로 back-end가 들어갔다. 본인 환경에 맞게 수정한다.
echo "$CURRENT_PID"
if [ -z $CURRENT_PID ]; then
        echo "> no ing app."
else
        echo "> kill -9 $CURRENT_PID"
        kill -9 $CURRENT_PID
        sleep 1
fi
echo "> new app deploy"
JAR_NAME=$(ls |grep 'back-end' | tail -n 1)
echo "> JAR Name: $JAR_NAME"

nohup java -jar -Dspring.profiles.active=prod -Duser.timezone=Asia/Seoul $JAR_NAME &
sleep 1

 

기존 배포 방식의 문제 1 : 중단 시간  

먼저 사용 중인 Application 를 내리고 새로운 Application이 요청 처리를 준비할 때 사이에 텀이 존재한다는 것이다. 이번 프로젝트의 경우  그 시간이 10초 정도 소요되었고, 그 10초 동안은 사용자의 요청을 처리할 수 없게 되는 것이다.

 

 

 

기존 배포 방식의 문제 2 : 실행에 문제가 생기는 경우

두 번째로 빌드 시가 아닌, 실행 시점에서 문제가 생기는 경우에 대한 대처가 없었다. 예를 들면 빌드 시점에서 확인되지 않고 잘 넘겼지만, 막상 인스턴스에 넘어와 Application을 실행하려고 하니 DB migration에 문제가 생기는 경우, 기존의 방식에서는 이미 사용 중인 Application을 내려버렸고, 이번 빌드 파일은 실행에서 터져 인스턴스 내부에는 Application이 떠있지 않은 상태가 돼버리는 것이다.

 

 

 

물론 이런 상황을 대비해, 새로운 Application 실행 이후 헬스 체크를 하여 실행에 문제가 없는지 확인하고 문제가 생기면 기존 빌드 파일을 다시 띄울 수 있긴 하겠지만, 당시 상황에서는 빌드 파일을 빌드 버전 별로 분리하지 않았고, 그저 n초간 헬스 체크 후 실패 / 성공을 확인하는 방식이었다. 

 

 

 

새로 적용한 방식

새로 적용한 실행 방식은 다음과 같다. 이런 시나리오로 새로 빌드된 Application을 실행할 경우 앞선 두가지 문제점을 모두 해결할 수 있다. 중단 시간은 사용자 접근 포트를 스위칭하는 시간 정도이고, 새로운 Application 정상 실행이 확인이 안 되는 경우 기존의 Application 포트로 사용자 요청이 들어오기 때문에 실행에 문제가 되어도 사용자 요청은 그대로 처리할 수 있다. 물론 jenkins의 실행 실패 알림을 받으면서 말이다.

 

 

1. Application이 사용하고 있는 포트를 확인한다.

Application이 사용하고 있는 포트를 확인하고 그것과 다른 포트로 새로운 버전의 Application을 띄우기 위해 Spring application 환경을 분리하여 포트를 지정했다. 실행 환경명과 port는 임의로 지정하면 되는데, 이 글에서는 was1, was2라는 이름을 사용했고, 각각 8081, 8082 포트를 사용하도록 하였다.

 

 

그리고 다음처럼 현재 동작하고 있는 실행 환경을 확인할 수 있는 핸들러를 작성하였다. 

@RestController
public class GlobalController {

    Environment environment;

    public GlobalController(Environment environment) {
        this.environment = environment;
    }

    @GetMapping("/profiles")
    public String profile() {
        return String.join(", ", environment.getActiveProfiles());
    }
}

 

"/profiles" 요청으로 "was1"과 같은 응답 결과를 받고, 그것으로 '현재 Application이 8081 포트를 사용 중이구나', '다음 버전은 8082로 띄워야 하는 구나'를 확인하는 것이다. 실행 스크립트에서 그것을 확인할 수 있다.

# find IDLE PROFILE

echo "> 현재 구동중인 profile 확인"
CURRENT_PROFILE=$(curl -k https://localhost/profiles)

if [ $CURRENT_PROFILE == was1 ]; then
  IDLE_PROFILE=was2
  IDLE_PORT=8082
elif [ $CURRENT_PROFILE == was2 ]; then
  IDLE_PROFILE=was1
  IDLE_PORT=8081
else
  IDLE_PROFILE=was1
  IDLE_PORT=8081
fi

현재 사용 중인 환경(CURRENT_PROFILE)를 확인하고 쉬고 있는 포트(IDLE_PORT)를 지정하는 것이다.

 

2. 다른 포트로 새로운 Application을 띄운다.

쉬고 있는 포트로 Application을 띄운다. IDLE_PORT를 먼저 점유하고 있는 프로세스를 죽이고, 새로 빌드된 jar 파일을 실행하는 것이다. 이때 아래 스크립트에서 JAR_NAME는 본인의 jar 파일명, 위치로 찾을 수 있도록 수정해야 한다. 

# find build file

echo "> 빌드 파일(jar) 확인"
JAR_NAME=$(ls |grep 'back-end' | tail -n 1) # 개인 jar 파일명, 위치로 수정

# deploy in IDLE_PORT

IDLE_PID=$(pgrep -f $IDLE_PROFILE)

echo "> $IDLE_PORT 가 사용 중인 PID 확인 :  $IDLE_PID"
if [ -z $IDLE_PID ]; then
  echo "> 현재 $IDLE_PORT 가 사용 중인 애플리케이션이 없으므로 종료하지 않습니다."
else
  echo "> kill -9 $IDLE_PID"
  kill -9 $IDLE_PID
  sleep 5
fi

echo "> $IDLE_PROFILE 를 $IDLE_PORT 포트로 실행합니다."
nohup java -jar -Dspring.profiles.active=$IDLE_PROFILE $JAR_NAME &

 

3. Application이 제대로 실행되었는지 확인한다.

새로운 버전의 Application이 요청 처리 준비가 되었는지 확인한다.

@RestController
public class GlobalController {

    @GetMapping("/healths")
    public String health() {
        return "UP";
    }
}

그 기준을 예시의 경우에는 해당 애플리케이션이 /healths 요청을 처리할 수 있는지, 응답하는지를 확인하는 방식으로 했는데, "/healths" 요청을 보냈을 때 "UP" 문자열로 응답이 되면 요청을 처리할 수 있는 Application으로 확인하는 것이다.

 

아래 실행 스크립트에서 현재는 'http://localhost:$IDLE_PORT'가 요청을 처리할 준비가 된 상태인지 모르는 상황에서, 1초마다 'http://localhost:$IDLE_PORT/healths'를 요청하고 실패 또는 성공 응답이 왔는지를 100초간 확인하고, 도중에 실패 응답이 왔거나, 100초 동안 아무 응답을 못 받는 경우를 실행 실패라고 생각하도록 했다. 100초 도중 실패, 성공 응답이 하나라도 왔다면 헬스 체크를 마치고 실행 결과를 응답한다.

 

# health checking

echo "> Health check 시작합니다."
echo "> curl -s http://localhost:$IDLE_PORT/healths"
sleep 1

for retry_count in {1..100}
do
  response=$(curl -s http://localhost:$IDLE_PORT/healths)
  up_count=$(echo $response | grep 'UP' | wc -l)

  if [ $up_count -ge 1 ]
  then
      echo "> Health check 성공"
      break
  else
      echo "> Health check: ${response}"
  fi

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

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

 

4. 사용자 접근 포트를 기존 port와 스위칭한다.

이는 NginX의 리버스 프록시를 이용했다. 아래는 TLS 설정을 제외한 NginX 설정이다. 80으로 들어오는 요청을 /etc/nginx/conf.d/service-url.inc의 $service_url 변수로 지정한 url path로 포워딩한다는 의미이다. (NginX는 도커로 띄웠다.)

# /etc/nginx/nginx.conf  

event {}

http {
    server {
        listen 80;

        include /etc/nginx/conf.d/service-url.inc;

        location / {
            proxy_pass $service_url;
        }
    }
}

 

/etc/nginx/conf.d/service-url.inc는 다음과 같다. 앞서 말한 대로 service_url 변수에 'http://172.17.0.1:8081'를 담고 있는 상황이다.

 

# /etc/nginx/conf.d/service-url.inc

set $service_url http://172.17.0.1:8081

 

path의 위치는 본인의 was path를 입력하면 되는데, 예시의 상황에서는 한 인스턴스 안에 NginX가 도커로 띄워져 있어 docker0 기본 ip 172.17.0.1을 path로 사용했다.

 

이렇게 Froxy 방향을 service_url 이라는 변수로 분리하여 이후 방향을 바꿔야 하는 경우에는 이 변수 값만 수정하면 되도록 하였다. 아래 스크립트를 실행하는 것으로, 'etc/nginx/conf.d/service-url.inc'의 $service_url 값을 http://172.17.0.1:8081 에서 http://172.17.0.1:8082로 변경할 수 있고, 그에 따라 nginx의 포워딩 위치가 바뀌는 것이다.

# /scripts/switch-serve.sh

echo "set \$service_url http://172.17.0.1:8082;" |tee /etc/nginx/conf.d/service-url.inc

 

그리고 이 swtich 방향(포트)이 8082로 딱 고정된게 아니라, switch-servie 호출 시 파라미터 값으로 포트를 결정할 수 있도록 하였다. 즉 switch-serve.sh 8081과 같은 꼴로 switch-server를 호출한다. 

# /scripts/switch-serve.sh

echo "set \$service_url http://172.17.0.1:$1;" |tee /etc/nginx/conf.d/service-url.inc

 

이후  nginx를 한번 리로드 하는 것으로 수정된 nginx.conf ( proxy_pass http://172.17.0.1:8081 -> http://172.17.0.1:8082)를 반영한다.

service nginx reload

 

이때 사용자의 서버 요청 패스가 바뀌는 것은 아니다. 내부 was 포트가 어떻게 되었고, nginx의 포워딩 방향이 바뀌었고와 상관없이, 사용자는 nginx의 위치로 동일하게 요청을 하고, nginx는 그것을 정의된 was에 전달하는 개념이다.

 

plus. 새로운 버전에서 버그가 발견된다면.

이런 시나리오로 배포하는 것은 무중단 배포만이 아니라, 버그에 대처하는 방법에도 큰 장점을 갖는다.

 

이전 버전의 Application 프로세스를 죽이지 않은 상태에서, 새로운 버전에 버그가 발견된다면 앞선 4번의 내용처럼, nginx의 포워딩 위치만 수정하는 것으로 빠르게 이전 버전의 Application으로 사용자 요청 처리를 돌릴 수 있다.

 

 

전체 실행 스크립트

전체 실행 스크립트는 다음과 같다. 만일 따라하게 된다면, 다음 사항을 본인 환경에 맞게 수정해야 한다. 

 

1. JAR_NAME : 빌드된 jar 파일의 위치를 찾는 방법 또는 파일의 이름을 특정할 수 있는 방법을 찾아야 한다.

2. WAS 서버 위치 : 현재 구동 중인 was의 위치를 확인해야 한다. 예시의 경우에는 실행 스크립트와 WAS가 같은 인스턴스에서 실행되어 localhost를 사용한 것이다.

3. JAR 파일 실행 방법 : 빌드된 jar 파일을 실행하는 방법을 본인 상황에 맞게 수정한다. application profile, JVM 설정, nohup 등의 옵션을 확인한다.

4. 헬스 체크 대기 시간 : 헬스 체크를 반복할 시간을 설정한다. 예시에서는 100초 동안 1초씩 헬스 체크를 하고 성공 여부를 확인하는데, 본인 상황에 맞게 수정한다.

5. nginx conf 수정 : nginx의 프록시 설정 수정, 리로드 하는 방법을 본인 상황에 맞게 수정해야 한다. 예시에서는 nginx를 docker로, proxy를 컨테이너 명으로 한 상황이다.

 

#!/bin/bash
  
# find build file

echo "> 빌드 파일(jar) 확인"
JAR_NAME=$(ls |grep 'back-end' | tail -n 1)  # (1)

# find IDLE PROFILE

echo "> 현재 구동중인 profile 확인"
CURRENT_PROFILE=$(curl -k https://localhost/profiles)  # (2)

if [ $CURRENT_PROFILE == was1 ]; then
  IDLE_PROFILE=was2
  IDLE_PORT=8082
elif [ $CURRENT_PROFILE == was2 ]; then
  IDLE_PROFILE=was1
  IDLE_PORT=8081
else
  IDLE_PROFILE=was1
  IDLE_PORT=8081
fi

# deploy in IDLE_PORT

IDLE_PID=$(pgrep -f $IDLE_PROFILE)

echo "> $IDLE_PORT 가 사용 중인 PID 확인 :  $IDLE_PID"
if [ -z $IDLE_PID ]; then
  echo "> 현재 $IDLE_PORT 가 사용 중인 애플리케이션이 없으므로 종료하지 않습니다."
else
  echo "> kill -9 $IDLE_PID"
  kill -9 $IDLE_PID
  sleep 5
fi

echo "> $IDLE_PROFILE 를 $IDLE_PORT 포트로 실행합니다."
nohup java -jar -Dspring.profiles.active=$IDLE_PROFILE $JAR_NAME & # (3)

# health checking

echo "> Health check 시작합니다."
echo "> curl -s http://localhost:$IDLE_PORT/healths"
sleep 1

for retry_count in {1..100} # (4)
do
  response=$(curl -s http://localhost:$IDLE_PORT/healths)
  up_count=$(echo $response | grep 'UP' | wc -l)

  if [ $up_count -ge 1 ]
  then
      echo "> Health check 성공"
      break
  else
      echo "> Health check: ${response}"
  fi

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

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

# nginx $service_url switching

docker exec -it proxy sh /scripts/switch-serve.sh ${IDLE_PORT}  # (5)
docker exec -it proxy service nginx reload

curl -k https://localhost/profiles
PROXY_PORT=$(curl -k https://localhost/profiles)
echo "> Nginx Current Proxy Port: $PROXY_PORT"
sleep 1

 

In-place deployment vs Blue-Green deployment

https://www.pluralsight.com/guides/aws-deployment-strategies

 

AWS Deployment Strategies | Pluralsight

AWS offers several deployment strategies with its services, including but not limited to Elastic Beanstalk, CodeDeploy, ECS, and EKS. The three most common deployment strategies you will encounter are in-place, blue/green, and rolling. These deployment str

www.pluralsight.com

 

 

 

 

 

Comments