본문 바로가기
CI & CD

[Nginx] 24시간 365일 중단 없는 서비스 만들기 3 - 무중단 배포 스크립트 만들기

by 박성민 2021. 7. 20.

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

profile API 추가

ProfileController를 만들어 다음과 같이 간단한 API 코드를 추가합니다.

package com.usedcar.admin.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()); # 1
        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. env.getActiveProfiles()
    • 현재 실행 중인 ActiveProfile을 모두 가져옵니다.
    • 즉, real, oauth, real-db 등이 활성화되어 있다면(active) 3개가 모두 담겨 있습니다.
    • 여기서 real, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록 합니다.
    • 실제로 이번 무중단 배포에서는 real1과 real2만 사용되지만, step2를 다시 사용해 볼 수도 있으니 real도 남겨둡니다.

이 코드가 잘 작동하는지 테스트 코드를 작성해 보겠습니다.
해당 컨트롤러는 특별히 스프링 환경이 필요하지 않습니다.
그래서 @SpringBootTest 없이 테스트 코드를 작성합니다.

package com.usedcar.admin.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이_조회된다() throws Exception {
        // 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 active_profile이_없으면_default가_조회된다() throws Exception {
        // given
        String expectedProfile = "default";
        MockEnvironment env = new MockEnvironment();
        ProfileController controller = new ProfileController(env);

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

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

}

ProfileController나 Environment 모두 자바 클래스(인터페이스)이기 때문에 쉽게 테스트할 수 있습니다.
Environment는 인터페이스라 가짜 구현체인 MockEnvironment(스프링에서 제공)를 사용해서 테스트하면 됩니다.

이렇게 해보면 생성자 DI가 얼마나 유용한지 알 수 있습니다.
만약 Environment를 @Autowired로 DI 받았다면 이런 테스트 코드를 작성하지 못했습니다.
앞의 테스트가 성공적으로 다 통과했다면 컨트롤러에 로직에 대한 이슈는 없습니다.

그리고 이 /profile이 인증 없이도 호출될 수 있게 SecurityConfig 클래스에 제외 코드를 추가합니다.

.antMatchers("/", "/css/**", "/images/**",
"/js/**", "/h2-console/**", "/profile").permitAll()
  1. permitAll 마지막에 "/profile"이 추가됩니다.

그리고 SecurityConfig 설정이 잘 되었는지도 테스트 코드로 검증합니다.
이 검증은 스프링 시큐리티 설정을 불러와야 하니 @SpringBootTest를 사용하는 테스트 클래스(ProfileControllerTest)를 하나 더 추가합니다.

package com.usedcar.admin.web;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
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.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import static org.junit.Assert.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class ProfileControllerTest {

    @Autowired
    private MockMvc mvc;

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

        mvc.perform(get("/profile"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string(expected));
    }

}

여기까지 모든 테스트가 성공했다면 깃허브로 푸시하여 배포 합니다.
배포가 끝나면 브라우저에서 /profile로 접속해서 profile이 잘 나오는지 확인합니다.
1

real1, real2, profile 생성

현재 EC2 환경에서 실행되는 profile은 real밖에 없습니다.
해당 profile은 Travis CI 배포 자동화를 위한 profile이니 무중단 배포를 위한 profile 2개(real1, real2)를 src/main/resources 아래에 추가합니다.

application-real1.properties

server.port=8081

spring.profiles.include=oauth, real-db

# 쿼리 로그 세팅
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb

# 세션 저장소 jdbc 설정
spring.session.store-type=jdbc
spring.session.jdbc.initialize-schema=always

# UTF-8 세팅
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.force=true

application-real2.properties

server.port=8082

spring.profiles.include=oauth, real-db

# 쿼리 로그 세팅
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb

# 세션 저장소 jdbc 설정
spring.session.store-type=jdbc
spring.session.jdbc.initialize-schema=always

# UTF-8 세팅
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.force=true

2개의 profile은 real profile과 크게 다른 점은 없지만, 한 가지가 다릅니다.

server.port가 8080이 아닌 8081/8082로 되어 있습니다.
이 부분만 주의해서 생성하고 생성된 후에는 깃허브로 푸시하면서 마무리합니다.

엔진엑스 설정 수정

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

엔진엑스 설정이 모여있는 /etc/nginx/conf.d/ 에 servie-url-inc라는 파일을 하나 생성합니다.

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

그리고 다음 코드를 입력합니다.

set $service_url http://127.0.0.1:8080;

저장하고 종료한 뒤(:wq) 해당 파일은 엔진엑스가 사용할 수 있게 설정합니다.
다음과 같이 nginx.conf 파일을 열겠습니다.

sudo vim /etc/nginx/nginx.conf

location / 부분을 찾아 다음과 같이 변경합니다.

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

location / {
        proxy_pass $service-url;

2

저장하고 종료한 뒤(:wq) 재시작합니다.

sudo service nginx restart

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

배포 스크립트들 작성

먼저 step2와 중복되지 않기 위해 EC2에 step3 디렉토리를 생성합니다.

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

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

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 디렉토리에 추가합니다.

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

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

    echo "${IDLE_PROFILE}" # 3
}

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

    if [ ${IDLE_PROFILE} == real1 ]
    then
      echo "8081"
    else
      echo "8082"
    fi
}
  1. $(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)
    • 현재 엔진엑스가 바라보고 있는 스프링부트가 정상적으로 수행 중인지 확인합니다.
    • 응답값을 HttpStatus로 받습니다.
    • 정상이면 200, 오류가 발생한다면 400~503 사이로 발생하니 400 이상은 모두 예외로 보고 real2를 현재 profile로 사용합니다.
  2. IDLE_PROFILE
    • 엔진엑스와 연결되지 않은 profile 입니다.
    • 스프링 부트 프로젝트를 이 profile로 연결하기 위해 반환합니다.
  3. echo "${IDLE_PROFILE}"
    • bash라는 스크립트는 값을 반환하는 기능이 없습니다.
    • 그래서 제일 마지막 줄에 echo로 결과를 출력한 후, 클라이언트에서 그 값을 잡아서($(find_idle_profile)) 사용합니다.
    • 중간에 echo를 사용해선 안 됩니다.

stop.sh

#!/usr/bin/env bash

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

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
  1. ABSDIR=$(dirname $ABSPATH)
    • 현재 stop.sh가 속해 있는 경로를 찾습니다.
    • 하단의 코드와 같이 profile.sh의 경로를 찾기 위해 사용됩니다.
  2. source ${ABSDIR}/profile.sh
    • 자바로 보면 일종의 import 구문입니다.
    • 해당 코드로 인해 stop.sh에서도 profile.sh의 여러 function을 사용할 수 있게 됩니다.

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=used-car-admin

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-oauth.properties,/home/ec2-user/app/application-real-db.properties \
    -Dspring.profiles.active=$IDLE_PROFILE \
    $JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
  1. 기본적인 스크립트는 step2의 deploy.sh와 유사합니다.
  2. 다른 점이라면 IDLE_PROFILE을 통해 properties 파일을 가져오고(application-$IDLE_PROFILE.properties), active profile을 지정하는 것(-Dspring.profiles.active=$IDLE_PROFILE) 뿐입니다.
  3. 여기서도 IDLE_PROFILE을 사용하니 profile.sh을 가져와야 합니다.

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
  1. 엔진엑스와 연결되지 않은 포트로 스프링 부트가 잘 수행되는지 체크합니다.
  2. 잘 떴는지 확인되어야 엔진엑스 프록시 설정을 변경(switch_proxy)합니다.
  3. 엔진엑스 프록시 설정 변경은 switch.sh에서 수행합니다.

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 "> 엔진엑스 Reload"
    sudo service nginx reload
}
  1. echo "set \$service_url http://127.0.0.1:${IDLE_PORT};"
    • 하나의 문장을 만들어 파이프라인(|)으로 넘겨주기 위해 echo를 사용합니다.
    • 엔진엑스가 변경할 프록시 주소를 생성합니다.
    • 쌍따옴표(")를 사용해야 합니다.
    • 사용하지 않으면 $service_url을 그대로 인식하지 못하고 변수를 찾게 됩니다.
  2. | sudo tee /etc/nginx/conf.d/service-url.inc
    • 앞에서 넘겨준 문장을 service-url.inc에 덮어씁니다.
  3. sudo service nginx reload
    • 엔진엑스 설정을 다시 불러옵니다.
    • restart와는 다릅니다.
    • restart는 잠시 끊기는 현상이 있지만, reload는 끊김 없이 다시 불러옵니다.
    • 다만, 중요한 설정들은 반영되지 않으므로 restart를 사용해야 합니다.
    • 여기선 외부의 설정 파일인 service-url을 다시 불러오는 거라 reload로 가능합니다.

참고

댓글