본문 바로가기
개발/스프링

[혼자 구현하는 웹서비스] 10. 24시간 365일 중단 없는 서비스를 만들자

by 카펀 2022. 2. 26.

*이 글은 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스' (프리렉, 이동욱 저) 를 공부하며 내용을 정리한 글입니다.

*내용을 따라가며 쓴 글이라 책과 흐름이 겹칠 수 있으나, 최대한 내용을 이해한 후 저의 글로 옮겼습니다.

*이 글은 9-1. 코드가 푸시되면 자동으로 배포해 보자 - Travis CI 배포 자동화 (2)에서 이어집니다.

 

0. 개요

1. 무중단 배포 소개

2. Nginx 설치와 Spring Boot 연동하기

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

4. 무중단 배포 테스트

 

0. 개요

앞선 글의 마지막에서 언급한 바 있듯, 우리의 프로젝트의 남은 문제점은 배포 시 서비스가 잠시 중단된다는 점이다.

jar 파일을 이용해 배포를 진행하기 이전에, 기존에 열려 있던 서비스를 종료하고, 배포가 마치면 다시 서비스가 오픈된다.

심지어 우리 서비스가 배포 이후 문제점이 발견되거나 장애가 발생한다면? 해당 이슈 대응을 위해 다시 배포를 하는 동안, 서비스는 또 다시 중단될 수 밖에 없다.

하지만 실제 서비스는 이런 경우가 잘 없다. 카카오톡이 중간중간 배포 때문에 서비스가 끊긴다거나, 구글이 정기적인 배포를 진행하느라 접속이 막히는 경우는 들어본 적이 없다.

 

이를 위한 방법이 무중단 배포이다.

말 그대로 서비스가 중단되는 일 없이 배포가 진행된다는 뜻으로, 실제 서비스 운영 시에는 필수적인 내용이라고 할 수 있겠다.

 

1. 무중단 배포 소개

앞서 무중단 배포의 필요성에 대해 언급하였다.

이러한 무중단 배포는 여러 가지 방법이 있다. 예시를 들자면,

  • AWS의 Blue-Green 무중단 배포 (유료)
  • Docker를 이용한 웹서비스 무중단 배포
  • L4 스위치를 이용한 무중단 배포 (고가의 장비 필요)
  • Nginx를 이용한 무중단 배포

이 중 Nginx를 사용한 무중단 배포를 해 보려고 한다. Nginx (엔진엑스)는 오픈소스 소프트웨어이며, 위의 언급한 방법 중 가장 저렴하고 간단하다.

Nginx의 여러 기능 중 '리버스 프록시' 를 사용하려고 한다. 리버스 프록시란, Nginx가 외부 (사용자)의 요청을 받아, 백엔드 서버로 요청을 전달하는 행위를 뜻한다.

 

기존에 사용하던 EC2에 그대로 사용할 수 있다.

방법은 아래와 같다.

 

기존에는 jar 1개를 두고 배포를 했지만, 이제는 2개를 둔다.

먼저, 사용자 (웹 브라우저)는 포트 80 (http) 혹은 443 (https)으로 접속한다 (Nginx).

맨 처음 배포를 할 때는 Nginx가 1번 jar (8081번, 편의상 1번으로 호칭)를 가리킨다.

이후 새로운 배포가 진행되는 동안, Nginx는 1번을 여전히 가리킨다. 2번 (8082)은 배포 중이다.

2번이 정상적으로 배포되었음을 확인하면, Nginx는 이제 2번을 가리킨다.

 

위 과정을 반복하면 우리가 생각했던 무중단 배포가 이루어진다.

 

2. Nginx 설치와 Spring Boot 연동하기

기존에 사용하던 EC2에 Nginx를 사용하려면, Nginx를 설치해야 한다.

 

EC2를 열고 아래 명령어를 입력하여 Nginx를 설치한다.

 

sudo amazon-linux-extras install nginx1

 

이후 성공적으로 설치되었다면 아래 명령어를 통해 Nginx의 버전을 확인할 수 있다.

 

nginx -v

잘 설치되었음을 확인할 수 있다.

설치된 nginx를 실행해 주자.

 

sudo service nginx start

 

다음으로, Nginx의 포트 번호를 보안 그룹에 추가해 주어야 한다.

앞서 언급하였듯 Nginx의 기본 포트 번호는 80이다.

 

AWS 콘솔을 켜고, EC2 -> 보안 그룹 -> 생성했던 EC2 보안 그룹 선택 -> 인바운드 편집 으로 들어가서, 추가해 주도록 하자.

 

위와 같이 추가해 주면 된다.

보안 그룹 규칙 ID는 저장하고 나면 생긴다.

 

다음으로, 기존 리다이렉션 주소를 수정해 주어야 한다.

무슨 말이냐 하면, 기존엔 우리가 8080 포트를 통해 바로 Spring Boot로 접근을 했었다.

하지만 이제는 먼저 Nginx로 접속을 해야 하므로, 포트 80을 접속하도록 수정해 주어야 한다.

 

OAuth2 로그인을 위해 구글과 네이버에 정보를 등록했었다.

여기서의 경우와 마찬가지로 수정해 주면 된다.

주소 뒤의 :8080을 제거하고 저장해 주자.

 

 

이후 기존의 우리의 웹 서비스 주소에 접속해 보자.

방금 저장할 때와 같이, 이번에는 뒤의 :8080 을 제거하고 접속한다.

 

위와 같은 Nginx 화면을 볼 수 있다.

아직 Nginx가 Spring Boot를 가리키고 있지 않은 상태이다.

 

Nginx의 설정을 조정해 주자.

EC2에서 Nginx 설정 파일을 열어 본다.

 

sudo vim /etc/nginx/nginx.conf

 

내용 중 server 내의 location / 내용을 아래와 같이 수정해 준다.

 

저장하고 나온 후, nginx를 재시작 해 준다.

 

sudo service nginx restart

 

이후 아까와 같은 주소에 접속해 보자.

 

페이지를 잘 가리키는 것을 확인할 수 있다.

Nginx가 Spring Boot 프로젝트를 프록시 하고 있다.

 

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

이제 무중단 배포를 위한 스크립트를 작성해야 한다.

그 전에, API를 먼저 하나 작성하도록 하자. 다음 배포 시의 포트 번호를 8081, 8082 중 어떤 것을 사용할지 판단하는 기준이 된다.

 

package org.example.springboot.web;

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

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

 

먼저 테스트 코드 ProfileControllerUnitTest.java 를 작성했다.

이어서 실제 controller를 작성한다.

 

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

 

위 코드는 Spring 환경이 필요하지는 않으므로, @SpringBootTest 어노테이션은 추가하지 않았다.

 

다음으로, 이 /profile이 인증 없이도 호출될 수 있도록 SecurityConfig 클라스에 제외 코드를 추가해 준다.

config/auth/SecurityConfig.java에 추가해 주면 된다.

 

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

잘 못 찾겠으면 위의 사진을 참고하자.

 

이 설정이 잘 되었는지도 테스트를 해 봐야 한다.

ProfileControllerTest.java를 하나 새로 추가하고 아래와 같이 작성한다.

 

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

이대로 테스트를 돌려 보면, 테스트가 실패한다.

expected: "deafult", actual: "oauth" 라고 한다.

 

test의 application.properties를 열고, spring.profiles.include=oauth 를 주석 처리 해 주자.

저 내용이 있으면 profiles에 oauth가 추가되어 위와 같은 테스트 결과를 얻게 된다.

 

여기까지 완료했다면 GitHub에 push하여 배포해 본다.

잘 진행되었다면, 배포 후 웹 브라우저에서 url/profile 로 접속하여 아래와 같은 화면이 나올 것이다.

 

이어서 진행해 보자.

현재 EC2 환경에서 실행되는 profile은 real밖에 없다.

무중단 배포를 위한 profile 2개를 새로 생성해 주자.

 

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

 

 

application-real1.properties, application-real2.properties 두 개를 생성한다.

내용은 같지만, 차이점은 포트 번호 (server.port)가 있다.

 

이어서 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;

 

저장하고 나온 후 (:wq), Nginx가 이 파일을 읽을 수 있도록 해 주어야 한다.

아래 명령어를 통해 nginx.conf 파일을 열자.

 

sudo vim /etc/nginx/nginx.conf

 

location / 부분을 찾아서, 아래와 같이 수정한다.

 

 

include를 통해 방금 작성한 파일을 포함시켜 주고, proxy_pass가 변수명 service_url을 참고하도록 한다.

마찬가지로 저장하고 나온 후, Nginx를 재시작해 주자.

 

다음 순서는 배포 스크립트 작성이다.

먼저 step3의 디렉토리를 만들어 주자.

 

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

 

디렉토리가 바뀌었으니 appspec.yml 역시 마찬가지로 수정해 준다.

 

destination을 step2에서 step3으로 바꾸어 주면 된다.

 

다음으로, 우리가 새로 작성할 스크립트 파일을 appspec.yml에서 사용하도록 적어 준다.

우리가 새로 작성할 스크립트 파일은 총 5개다.

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

appspec.yml에는 위와 같이 추가해 주면 된다.

 

이후 scripts 디렉토리 아래에 아래 내용을 추가해 주자.

 

profile.sh

더보기
#!/usr/bin/env bash

# 쉬고 있는 profile 찾기: real1이 사용 중이면 real2가 쉬고 있고, 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
}

 

stop.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_PORT} ]
then
  echo "> 현재 구동중인 어플리케이션이 없으므로 종료하지 않습니다."
else
  echo "> kill -15 $IDLE_PID"
  kill 15 ${IDLE_PID}
  sleep 5
fi

 

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=springboot-web-practise

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

 

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

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

 

switch.sh

더보기
#!/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
}

 

4. 무중단 배포 테스트

앞서 과정까지 마쳤다면, 무중단 배포를 테스트할 준비가 거의 다 되었다.

 

한 가지 불편한 점을 수정하려고 한다.

잦은 배포로 jar 파일명이 겹칠 수 있고, 직접 수정하기도 번거로운 일이므로 이를 자동화 해 보자.

 

build.gradle을 열고 아래와 같은 내용을 추가한다.

 

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

 

이후 GitHub에 push한다.

 

웹 사이트가 잘 뜨는 것을 볼 수 있다.

 

CodeDeploy의 로그를 보면 8081 포트로 접속한 것으로 보인다.

 

Spring Boot 로그는 다음 명령어로 확인할 수 있다.

 

vim ~/app/step3/nohup.out

 

한번 더 배포하고, 하는 동안 서비스가 중단되는지 확인해 보자.

 

아래 명령어로 현재 Java 어플리케이션 실행 여부를 확인해 보자.

 

ps -ef | grep java

 

2개의 어플리케이션이 실행되고 있음을 확인할 수 있다.

 

 

지금까지 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스'를 따라 하며 웹 서비스 제작, AWS 설정, 배포 자동화, 무중단 배포까지 구현해 보았다.

댓글