무중단 배포 구현

dawn·2021년 6월 1일
0

인프런

목록 보기
8/11

무중단 배포

무중단 배포 방식에는 몇 가지가 있다.

  • AWS에서 블루 그린 무중단 배포
  • 도커를 이용한 웹서비스 무중단 배포
    이 외에도 L4 스위치를 이용한 무중단 배포 방법도 있지만 비싸다..
    책에서 진행할 방법은 엔진엑스(Nginx)를 이용한 무중단 배포이다. 엔진엑스는 웹 서버, 리버스 포록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 들을 위한 오픈소스 소프트웨어이다.
    리버스 프록시를 통해 무중단 배포환경을 구축해 볼것이다.
    +리버스 프록시는 서버가 여러개 있을때 분산되도록 하는것
    구조는 하나의 EC2 혹은 리눅스 서버에 엔진엑스 1대와 스프링 부트 Jar 2대를 사용하는 것이다.
  • 엔진엑스는 80(http), 443(https) 포트를 할당
  • 스프링 부트1은 8081포트로 실행
  • 스프링 부트2는 8082포트로 실행

운영과정은 다음과 같다.

  • 사용자는 서비스 주소로 접속(80 혹은 443 포트)
  • 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트1로 요청을 전달
  • 스프링 부트2는 엔진엑스와 연결된 상태가 아니니 요청받지 못함
  • 만약 1.1버전으로 신규 배포가 필요하다면, 엔진엑스와 연결되지 않은 스프링 부트2로 배포
  • 배포가 끝나면 스프링 부트2가 정상적으로 구동중인지 확인
  • nginx reload 명령어를 통해 스프링부트1 대신 스프링부트2를 바라보도록 한다.

1. 엔진엑스 설치와 스프링 부트 연동하기

1-1. EC2에 엔진엑스 설치

sudo amazon-linux-extras install nginx1

1-2. 엔진엑스 실행

sudo service nginx start

1-3. 엔진엑스의 포트번호를 보안그룹 추가

[EC2 -> 보안그룹 -> EC2 보안 그룹 선택 -> 인바운드 편집]

추가하고 나서 :8080을 빼고 EC2 주소로 들어가면 다음과 같은 페이지가 나온다.

1-4. 엔진엑스와 스프링 부트 연동

엔진엑스가 현재 실행 중인 스프링 부트 프로젝트를 바라볼 수 있도록 프록시 설정을 하자.
sudo vim /etc/nginx/nginx.conf실행해서 수정하고 추가

    server {
        listen       80 default_server;
        listen       [::]:80 default_server;
        server_name  localhost;
        root         /usr/share/nginx/html;

        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;

        location / {
                proxy_pass http://localhost:8080;
                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;
        }

2. 무중단 배포 스크립트 만들기

2-1. API 추가

무중단 배포 스크립트 작업 전에 API를 하나 추가하자. 이 API는 배포 시에 8081을 쓸지, 8082를 쓸지 판단하는 기준이 된다.

package hello.hellospring.controller;

import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;
import java.util.List;


@RestController
public class ProfileController {
    private final Environment env;

    public ProfileController(Environment env) {
        this.env = env;
    }

    @GetMapping("/profile")
    public String profile() {
        List<String> profiles = Arrays.asList(env.getActiveProfiles());
        List<String> realProfiles = Arrays.asList("real1", "real2");
        String defaultProfile = profiles.isEmpty()? "default" : profiles.get(0);

        return profiles.stream()
                .filter(realProfiles::contains)
                .findAny()
                .orElse(defaultProfile);
    }
}
  • env.getActiceProfiles()
    - 현재 실행중인 ActiveProfile을 모두 가져온다.

작성이 끝났다면 테스트 고드를 작성하자. 해당 컨트롤러는 특별히 스프링 환경이 필요하지 않다.

package hello.hellospring.controller;

import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;

class ProfileControllerTest {

    @Test
    public void active_profile이_없으면_default가_조회된다() {
        //given
        String expectedProfile = "default";
        MockEnvironment env = new MockEnvironment();
        ProfileController controller = new ProfileController(env);

        //when
        String profile = controller.profile();

        //then
        assertThat(profile).isEqualTo(expectedProfile);
    }
}

2-2. real1, real2 profile 생성

무중단 배포를 위한 profile 2개를 src/main/resource 아래에 추가한다.

# application-real1.properties
server.port=8081
spring.jpa.show_sql=true
spring.jpa.hibernate.ddl-auto=create-drop
spring.profiles.include=real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect
spring.session.store-type=jdbc
# application-real2.properties
server.port=8082
spring.jpa.hibernate.ddl-auto=create-drop
spring.profiles.include=real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.session.store-type=jdbc

3. 엔진엑스 설정 수정

무중단 배포의 핵심은 엔진엑스 설정이다. 배포 때마다 엔진엑스의 프록시 설정(스프링 부트로 요청을 흘려보내는)이 순식간에 교체된다. 이제 프록시 설정이 교체될 수 있도록 설정을 추가하자.

sudo vim /etc/nginx/conf.d/service-url.inc파일안에 set $service_url http://127.0.0.1:8080; 추가

저장하고 종료한 뒤 해당 파일을 엔진엑스가 사용할 수 있게 설정한다.
sudo vim /etc/nginx/nginx.conf파일을 열고 location /부분을 찾아 다음과 같이 변경한다.

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

location / {
         proxy_pass $service_url;
         proxy_set_header X-Real-IP $remote_addr;

저장하고 종료한뒤 nginx를 재시작 한다.
sudo service nginx restart

다시 브라우저에서 정상적으로 호출되는지 확인한다. 확인되었다면 엔진엑스 설정까지 잘 된 것이다.


4. 배포 스크립트들 작성

먼저 step2와 중복되지 않기 위해 EC2에 step3 디렉토리를 생성한다.
mkdir ~/app/step3 && mkdir ~/app/step3/zip

무중단 배포는 앞으로 step3 를사용하겠다. 그래서 appspec.yml 역시 step3로 배포되도록 수정한다.

#appspec.yml
version: 0.0
os: linux
files:
  - source:  /
    destination: /home/ec2-user/app/step3/zip/
    overwrite: yes

무중단 배포를 진행할 스크립트들은 총 5개이다.

  • stop.sh: 기존 엔진엑스에 연결되어 있지 않지만, 실행 중이던 스프링 부트 종료
  • start.sh: 배포할 신규 버전 스프링 부트 프로젝트를 stop.sh로 종료한 'profile'로 실행
  • health.sh: 'start.sh'로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크
  • switch.sh: 엔진엑스가 바라보는 스프링 부트를 최신 버전으로 변경
  • profile.sh: 앞선 4개 스크립트 파일에서 공용으로 사용할 'profile'과 포트 체크 로직

appspec.yml에 앞선 스크립트를 사용하도록 설정한다.

hooks:
  AfterInstall:
    - location: stop.sh # 엔진엑스와 연결되어 있지 않은 스프링 부트를 종료합니다.
      timeout: 60
      runas: ec2-user
  ApplicationStart:
    - location: start.sh # 엔진엑스와 연결되어 있지 않은 Port로 새 버전의 스프링 부트를 시작합니다.
      timeout: 60
      runas: ec2-user
  ValidateService:
    - location: health.sh # 새 스프링 부트가 정상적으로 실행됐는지 확인 합니다.
      timeout: 60
      runas: ec2-user

Jar 파일이 복사된 이후부터 차례로 앞선 스크립트들이 실행된다고 보면된다. 이제는 각각의 스크립트들을 작성해보자. 이 스크립트들은 scripts디렉토리에 추가한다.

#!/usr/bin/env bash

# profile.sh
# bash는 return value가 안되니 *제일 마지막줄에 echo로 해서 결과 출력*후, 클라이언트에서 값을 사용한다

# 쉬고 있는 profile 찾기: real1이 사용중이면 real2가 쉬고 있고, 반대면 real1이 쉬고 있음
function find_idle_profile()
{
    RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)

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

    if [ ${CURRENT_PROFILE} == real1 ]
    then
      IDLE_PROFILE=real2
    else
      IDLE_PROFILE=real1
    fi

    echo "${IDLE_PROFILE}"
}

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

    if [ ${IDLE_PROFILE} == real1 ]
    then
      echo "8081"
    else
      echo "8082"
    fi
}
#!/usr/bin/env bash

# health.sh

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

IDLE_PORT=$(find_idle_port)

echo "> Health Check Start!"
echo "> IDLE_PORT: $IDLE_PORT"
echo "> curl -s http://localhost:$IDLE_PORT/profile "
sleep 10

for RETRY_COUNT in {1..10}
do
  RESPONSE=$(curl -s http://localhost:${IDLE_PORT}/profile)
  UP_COUNT=$(echo ${RESPONSE} | grep 'real' | wc -l)

  if [ ${UP_COUNT} -ge 1 ]
  then # $up_count >= 1 ("real" 문자열이 있는지 검증)
      echo "> Health check 성공"
      switch_proxy
      break
  else
      echo "> Health check의 응답을 알 수 없거나 혹은 실행 상태가 아닙니다."
      echo "> Health check: ${RESPONSE}"
  fi

  if [ ${RETRY_COUNT} -eq 10 ]
  then
    echo "> Health check 실패. "
    echo "> 엔진엑스에 연결하지 않고 배포를 종료합니다."
    exit 1
  fi

  echo "> Health check 연결 실패. 재시도..."
  sleep 10
done
#!/usr/bin/env bash

# start.sh

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

REPOSITORY=/home/ec2-user/app/step3
PROJECT_NAME=hello-spring
echo "> Build 파일 복사"
echo "> cp $REPOSITORY/zip/*.jar $REPOSITORY/"

cp $REPOSITORY/zip/*.jar $REPOSITORY/

echo "> 새 어플리케이션 배포"
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1)

echo "> JAR Name: $JAR_NAME"

echo "> $JAR_NAME 에 실행권한 추가"

chmod +x $JAR_NAME

echo "> $JAR_NAME 실행"

IDLE_PROFILE=$(find_idle_profile)

echo "> $JAR_NAME 를 profile=$IDLE_PROFILE 로 실행합니다."
nohup java -jar \
    -Dspring.config.location=classpath:/application.properties,classpath:/application-$IDLE_PROFILE.properties,/home/ec2-user/app/application-real-db.properties \
    -Dspring.profiles.active=$IDLE_PROFILE \
    $JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
#!/usr/bin/env bash

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

IDLE_PORT=$(find_idle_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
#!/usr/bin/env 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 전환"
    echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc

    echo "> 엔진엑스 Reload"
    sudo service nginx reload
}

다 작성하고 나서 깃허브에 push해서 잘 작동하면 무중단 배포가 끝났다.

+무중단배포를 하기 위한 sh파일에서 #!는 이 파일을 실행시키기 위한 프로그램의 경로를 말한다.
+#!/usr/bin/env bash 는 환경변수에서 지정한 언어의 경로를 찾아 실행?? https://blog.gaerae.com/2015/10/what-is-the-preferred-bash-shebang.html 참고


이번장은 스크립트가 많아서 이해를 거의 못했다. 쉘 스크립트 공부해야 할거같다.


profile
안녕하세요

0개의 댓글