열렬히.뛰기

10. 무중단 배포

웹 프로그래밍 > Spring > 스프링부트 공부 (1) > 10. 무중단 배포

  • Travis CI를 활용하여 배포 자동화 환경을 구축
  • 그러나, 배포하는 동안 App이 종료된다는 문제를 가지고 있었음.
    • 새로운 Jar가 실행되기 전까지는 기존 Jar를 종료시켜 놓기 때문에 서비스가 중단.
  • 반면, 24시간 서비스들은 배포 동안 서비스가 정지되지는 않음.
    • 이를 적용하는 것이 이번 장의 목표

10.1 무중단 배포 소개

무중단 배포란?

서비스를 정지하지 않고 배포할 수 있는 방법

종류

  1. AWS에서 블루 그린을 이용한 무중단 배포
  2. 도커를 이용한 웹서비스 무중단 배포

여기서는 엔진엑스(nginx)를 이용한 무중단 배포를 시행.

그 중에서도 리버스 프록시를 사용.

리버스 프록시

엔진엑스가 외부의 요청을 받아 백앤드 서버로 요청을 전달하는 행위.

리버스 프록시 서버(엔진엑스)는 요청을 전달하고, 실제 요청에 대한 처리는 뒷단의 웹앱 서버가 처리

구조

하나의 EC2 리눅스 서버에 엔진엑스 1대와 스프링부트 Jar 2대를 사용

  • 엔진엑스 → 80, 443 포트 할당
  • 스프링부트1 → 8081 포트
  • 스프링부트2 → 8082 포트

운영과정

  1. 사용자는 서비스 주소로 접속
  2. 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청을 전달
  3. 스프링 부트2는 엔진엑스와 연결된 상태가 아니니 요청 받지 못함.

신규 배포가 필요하면, 스프링부트2로 배포.

  1. 배포하는 동안에도 서비스는 중단되지 않음. (엔진엑스 - 스프링부트1)
  2. 스프링부트2가 구동중인지 확인
    • 정상구동 시 nginx reload 를 통해 8081 대신 8082로 연결
    • nginx reload 는 0.1초 이내에 완료.

이 과정을 왔다갔다 하며 새 버전 배포.

10.2 엔진엑스 설치와 스프링 부트 연동

EC2에 nginx 설치

nginx 설치

sudo yum install nginx

nginx 실행

sudo yum nginx start

다음과 같은 메세지 확인.

Starting nginx: [  OK  ]

보안 그룹 추가

  • EC2의 보안 그룹에 포트번호 추가
  • Nginx의 포트번호는 80

리다이렉션 그룹 추가

  • 구글과 네이버에도 변경된 주소 등록.
  • 기존 리디렉션 주소에서 8080 제거 후 추가 등록

추가한 후 EC2의 도메인으로 접근하되, 8080 포트를 제거하고 접근하기.

엔진엑스 웹페이지가 뜨면 정상.

nginx와 스프링부트 연동

nginx 설정 파일 열기

sudo vim /etc/nginx/nginx.conf

server 아래 부분 location / 부분을 찾아 다음을 추가

location / {
			proxy_set_header X_Real_IP $remotd_addr;
			proxy_set_header X_Forwarded_For $proxy_add_x_forwarded_for;
			proxy_set_header Host @http_host;
}

수정이 끝나면 :wq 명령어로 저장 및 종료.

엔진엑스 재시작

sudo service nginx restart

화면을 새로고침.

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

profile API 추가

배포 시 8081을 쓸지, 8082를 쓸지 판단하는 기준이 된다.

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

@RequiredArgsConstructor
@RestController
public class ProfileController {
    private final Environment env;

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

        return profiles.stream()
                .filter(realProfiles::contains)
                .findAny()
                .orElse(defaultProfile);
    }

테스트 코드 1 작성

java
import static org.assertj.core.api.Assertions.assertThat;

public class ProfileControllerUnitTest {

    @Test
    public void real_profile이_조회된다() {
        //given
        String expectedProfile = "real";
        MockEnvironment env = new MockEnvironment();
        env.addActiveProfile(expectedProfile);
        env.addActiveProfile("oauth");
        env.addActiveProfile("real-db");

        ProfileController controller = new ProfileController(env);

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

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

    @Test
    public void real_profile이_없으면_첫번째가_조회된다() {
        //given
        String expectedProfile = "oauth";
        MockEnvironment env = new MockEnvironment();

        env.addActiveProfile(expectedProfile);
        env.addActiveProfile("real-db");

        ProfileController controller = new ProfileController(env);

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

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

    @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);
    }
}

SecurityConfig 클래스에 제외 코드 추가

java
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile")
.permitAll()

profileControllerTest 테스트 추가

java
import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProfileControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void profile은_인증없이_호출된다() throws Exception {
        String expected = "default";

        ResponseEntity<String> response = restTemplate.getForEntity("/profile", String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo(expected);
    }
}

이후 깃허브로 푸시후 배포.

배포 후 브라우저에서 /profile 로 배포 후 확인.

real1, real2 profile 생성

무중단 배포를 위한 추가 프로파일 생성.

src/main/resources 아래에 추가.

application-real1.properties

java
server.port=8081
spring.profiles.include=oauth,real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.session.store-type=jdbc

application-real2.properties

java
server.port=8082
spring.profiles.include=oauth,real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

역시 깃허브로 푸시 후 마무리.

nginx 설정 수정

/etc/nginx/conf.dservice-url.inc 파일 추가.

sudo vim /etc/nginx/conf.d/service-url.inc

그리고 다음 코드 입력

java
set $service_url http://127.0.0.1:8081;

저장 & 종료.

이후 다음과 같이 nginx.conf 파일 열기.

sudo vim /etc/nginx/nginx.conf

location / 부분을 찾아 다음 코드를 추가

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

location / {
				proxy_pass $service_url;
}

저장 & 종료. 이후 재시작(= sudo service nginx restart)

배포 스크립트들 작성

먼저 step2와 중복되지 않도록 step3 작성

mkdir ~/app/stpe3 && mkdir ~/app/step3/zip

무중단 배포는 step3 활용. 즉, appspec.yml 역시 step3로 배포.

스크립트는 총 5개.

  • stop.sh : 실행중이던 스프링부트 종료
  • start.sh : 배포할 신규 버전 스프링부트 프로젝트를 profile으로 실행
  • health.sh : 정상적으로 실행되었는지 체크
  • switch.sh : 엔진엑스가 바라보는 스프링부트를 최신버전으로 변경
  • profile.sh : 앞선 4개 스크립트 파일에서 공용으로 사용할 프로파일과 포트 체크 로직
profile.sh 만들기
#!/usr/bin/env bash

# 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) #01

    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 #02
    else
      IDLE_PROFILE=real1
    fi

    echo "${IDLE_PROFILE}" #03
}

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

    if [ ${IDLE_PROFILE} == real1 ]
    then
      echo "8081"
    else
      echo "8082"
    fi
}
stop.sh 만들기

start.sh 만들기
#!/usr/bin/env bash

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

REPOSITORY=/home/ec2-user/app/step3

PROJECT_NAME=freelec-springboot2-webservice

echo "> Build 파일 복사"
echo "> cp $REPOSITORY/zip/*.jar $REPOSITORY"

cp $
health.sh 만들기
#!/usr/bin/env bash

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
switch.sh 만들기
java
#!/usr/bin/env bash

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

function switch_proxy() {
	IDLE_PORT=$(find_port)
	
	echo "> 전환할 포트: $IDLE_PORT"
	echo "> 포트 전환"
	echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" |
sudo tee /etc/nginx/conf.d/service-url.inc

	echo "> nginx reload"
	sudo service nginx reload
}

10.4 무중단 배포 테스트

배포 테스트 전, 자동 버전값이 변경될 수 있도록 조치.

build.gradle 설정 추가

version '1.0.1-SNAPSHOT-'+new Date().format("yyyyMMddHHmmss")

여기까지 구성한 후 최종 코드를 깃허브로 푸시.

배포가 자동으로 진행되면 CodeDeploy 로그로 잘 진행되는지 확인.

tail -f /opt/codedeploy-agent/deployment-root/deployment-
logs/codedeploy-agent-deployments.log

스프링부트 로그도 보고 싶다면 다음 명령어로 확인.

vim ~/app/step3/nohup.out

2번 배포 진행 뒤 다음과 같이 자바 App 실행 여부 확인.

ps -ef | grep java