[Spring Boot] 무중단 서비스

2020. 9. 23. 12:50Spring/Spring Boot

1. 무중단 서비스

1) 무중단 서비스

서비스를 배포하게 될 경우, 새로운 JAR가 실행되기 전까지 기존 JAR를 종료 시키기 때문에 서비스가 중단이 된다. 서비스가 중단되지 않게 배포를 하기 위한 방법으로는 L4 스위치를 이용한 배포 방식, AWS에서 블루 그린 무중단 배포 방식, 도커를 이용한 웹 서비스 무중단 배포 방식이 있다. 또한 아파치를 자리를 이은 가장 저렴하고 쉬운 NginX를 이용하여 무중단 배포도 가능하다.

2) NginX 무중단 서비스 구조

NginX가 가진 여러 기능 중 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 리버스 프록시가 존재한다. 리버스 프록시 서버(NginX)는 요청을 전달하고 실제 요청에 대한 처리는 뒷단의 웹 애플리케이션 서버들이 처리를 하게 된다. 배포를 위한 추가 EC2 인스턴스가 불필요하며 클라우드 인프라가 구축되어 있지 않아도 사용할 수 있는 범용적인 방법이다.

 

구조는 하나의 EC2에 NginX 1대와 스프링 부트 JAR를 2대 사용하는 구성이다. NginX는 80, 443 포트를 할당하고 첫 번째 스프링 부트는 8081, 두 번째 스프링 부트는 8082 포트로 실행한다.

 

사용자는 서비스 주소로 접속하고 NginX는 사용자의 요청을 현재 연결되어있는 첫 번째 스프링 부트에 요청을 전달한다. 두 번째 스프링 부트는 NginX와 연결된 상태가 아니기에 요청받지 않는다. 그리고 만약 신규 배포가 필요해지면 연결되지 않은 두 번째 스프링 부트로 배포한다. 배포 과정에서 NginX는 첫 번째 스프링 부트를 연결하기에 서비스가 중단되지 않는다. 이 후, 신규 배포가 완료된 두 번째 스프링 부트에 Reload하여 NginX가 8082번 포트를 바라보도록 연결한다.

NginX 무중단 서비스

 

전체 시스템 구조

3) 리다이렉션 URI 설정

8080포트가 아닌 80 포트로 주소가 변경되기 때문에 구글과 네이버 로그인에도 변경된 주소를 등록한다.

리다이렉션 URI 설정

4) NginX 설치 및 설정

// NginX 설치
sudo yum install nginx

// NginX 구동
sudo service nginx start

NginX 설치

NginX의 설치 완료 후 스프링 부트와 연동하면 된다.

sudo vim /etc/nginx/nginx.conf
어노테이션 및 코드 설명
proxy_proxy_pass http://localhost:8080; NginX 요청이 들어오면 8080 포트로 전달한다.
proxy_set_header 실제 요청 데이터를 헤더의 각 항목에 할당한다.
...

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

...

NginX 설정

5) ProfileController.java

배포 시, 8081 포트를 사용하는지, 8082 포트를 사용하는지 판단하기 위한 API를 생성한다.

어노테이션 및 코드 설명
env.getActiveProfiles() 현재 실행 중인 ActiveProfile을 모두 가져온다. real, oauth, real-db 등이 활성화 되어있다면 3개가 모두 담겨있다. real, real1, real2는 모두 배포에 사용될 profile이기에 이중 하나라도 있을 경우 반환하도록 한다.

※ Enviroment는 환경 설정 파일에 존재하는 설정 파일들에 접근할 수 있는 객체이다.

package com.springbook.biz.web;

import lombok.RequiredArgsConstructor;
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;

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

ProfileController.java

6) 테스트 코드

스프링 환경이 필요하지 않기때문에 @SpringBootTest 어노테이션이 필요없다. ProfileController나 Enviroment 모두 Java 클래스이기 때문에 쉽게 테스트가 가능하다. Enviroment는 인터페이스이기에 스프링에서 지원해주는 가짜 구현체인 MockEnviroment를 사용해서 테스트하면된다.

package com.springbook.biz.web;

import org.junit.Test;
import org.springframework.mock.env.MockEnvironment;

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

public class ProfileControllerUnitTest {

    @Test
    public void real_profile_find() {
        // 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);
    }
    
    // real profile이 없을 경우 첫 번째를 조회
    @Test
    public void real_profile_findIndexFirst() {
        // 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);
    }

    // active profile이 없을 경우 default 조회
    @Test
    public void active_profile_findIndexDefault() {
        // given
        String expectedProfile = "default";
        MockEnvironment env = new MockEnvironment();
        
        ProfileController controller = new ProfileController(env);

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

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

ProfileControllerUnitTest.java

7) SecurityConfig.java

'/profile' 요청은 인증 없이도 호출이 될 수 있도록 SecurityConfig 클래스에 코드를 추가해준다.

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

SecurityConfig.java

8) 테스트 코드

수정한 SecurityConfig 내용이 잘 반영이 되었는지 확인하기 위해 @SpringBootTest를 사용하는 테스트 클래스를 추가로 생성해준다.

package com.springbook.biz.web;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

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_with_no_authorization() throws Exception {
        String expected = "default";

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

ProfileControllerTest.java

9) real1, real2 profile

EC2 환경에서 실행되는 real은 Travis CI 배포 자동화를 위한 Profile이다. 무중단 배포를 위한 Profile 2개를 resources 디렉토리 하위에 추가해준다.

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

10) NginX 설정 수정

배포할 때마다 NginX의 프록시 설정이 순식간에 교체된다. 프록시 설정이 교체될 수 있도록 설정을 추가한다. NginX 설정이 모여있는 '/etc/nginx/conf.d/'에 service-url.inc 파일을 생성한다.

sudo vim /etc/nginx/conf.d/service-url.inc
set $service_url http://127.0.0.1:8080;

service-url.inc 파일을 생성한 뒤, 이 파일이 NginX가 사용할 수 있도록 설정하고 재시작 한다.

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

location / {
    proxy_pass $service_url;
    ...
}

11) 배포 스크립트

기존에 제작한 프로젝트와 겹치지 않도록, 무중단 배포만을 위한 새로운 step3 폴더를 생성한다. 

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

새로운 디렉토리에 스크립트를 이용하여 배포하기 때문에 appspecs.yml 파일을 수정해준다.

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

permissions:
  - object: /
    pattern: "**"
    owner: ec2-user
    group: ec2-user

hooks:
  AfterInstall:
    - location: stop.sh
      timeout: 60
      runas: ec2-user
  ApplicationStart:
    - location: start.sh
      timeout: 60
      runas: ec2-user
  ValidateService:
    - location: health.sh
      timeout: 60
      runas: ec2-user

1. stop.sh

기존 NginX에 연결되어 있지 않지만, 실행 중이던 스프링 부트 종료의 목적을 지닌 스크립트이다.

어노테이션 및 코드 설명
ABSDIR=$(dirname $ABSPATH) 현재 stop.sh가 속해 있는 경로를 찾고 이를 이용하여 profile.sh 경로를 찾는다.
source=${ABSDIR}/profile.sh import 구문으로 stop.sh에서 profile.sh에 정의된 함수를 사용할 수 있도록 한다.
#!/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 -9 $IDLE_PID"
  kill -9 ${$IDLE_PID}
  sleep 5
fi

2. start.sh

배포할 신규 버전 스프링 부트 프로젝트를 stop.sh로 종료한 'profile'로 실행한다. 전체적인 코드는 deploy.sh와 유사하나, IDLE_PROFILE로 properties 파일을 가져오고 active profile을 지정한다.

#!/usr/bin/env bash

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

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

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,/home/ec2-user/app/application-oauth.properties,/home/ec2-user/app/application-real-db.properties,classpath:/application-$IDLE_PROFILE.properties \
        -Dspring.profiles.active=$IDLE_PROFILE \
        $JAR_NAME > $REPOSITORY/nohup.out 2>&1 &

3. health.sh

'start.sh'로 실행시킨 프로젝트가 정상적으로 실행되었는지 확인한다. NginX가 연결되지 않은 포트로 스프링 부트가 잘 수행되었는지를 확인하고, 정상일 경우 프록시 설정을 변경한다.

#!/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 "> NginX에 연결하지 않고 배포를 종료합니다."
    exit 1
  fi

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

4. switch.sh

NginX가 바라보는 스프링 부트를 최신 버전으로 변경한다.

어노테이션 및 코드 설명
echo "> set \$service_url http://127.0.0.1:${IDLE_PORT};" 하나의 문장을 만들어 파이프라인으로 넘겨주기 위해 echo를 사용한다. NginX가 변경할 프록시 주소를 생성한다. 쌍따옴표를 사용해야 $service_url을 그대로 인식하고 변수를 찾을 수 있다.
sudo tee /etc/nginx/conf.d/service_url.inc 앞에서 넘겨준 문장을 service-url.inc에 덮어쓴다.
#!/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 "> NginX Reload"
  sudo service nginx reload

}

5. profile.sh

앞선 4개의 스크립트 파일에서 공용으로 사용할 'profile'과 포트 확인 로직이 담겨있다.

어노테이션 및 코드 설명
$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile) 현재 NginX가 바라보고 있는 스프링 부트가 정상적으로 수행 중인지 확인한다. 응답값을 HttpStatus로 받는다. 정상일 경우 200, 오류가 발생할 경우 real2를 현재 Profile로 사용한다.
IDLE_PROFILE NginX와 연결되지 않은 Profile이다. 스프링 부트 프로젝트를 이 Profile로 연결하기 위해 반환한다.
echo "${IDLE_PROFILE}" bash 에는 값을 반환하는 기능이 없기에 echo로 결과를 출력하여 출력값을 잡아($(find_idle_profile)) 사용한다. 그렇기 때문에 중간에 echo를 사용해선 안된다.
#!/usr/bin/env bash

# 쉬고 있는 profile 찾기
function find_idle_profile() {

  RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)

  if [ ${RESPONSE_CODE} -ge 400 ] # 400 보다 클 경우 에러
  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

}

9) 구현 화면

배포를 확인하기 전, 잦은 배포로 인한 JAR 파일명이 겹칠 수 있기 때문에 자동으로 버전값이 변경되도록 build.gradle 파일을 수정한다.

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

처음으로 배포했을 경우, 'ps -ef | grep java'명령어를 통해 Java 애플리케이션 실행여부를 확인할 때와, 두 번째로 배포했을 경우의 실행여부의 차이를 확인할 수 있다.

처음 배포했을 경우 (1)
처음 배포했을 경우 (2)
두 번째로 배포했을 경우 (1)
두 번째로 배포했을 경우 (2)


[참고] 프링 부트와 AWS로 혼자 구현하는 웹 서비스

728x90