어디까지 갈 수 있을까?

NGINX 무중단 배포 본문

책/스프링부트와 AWS로 혼자 구현하는 웹 서비스

NGINX 무중단 배포

_Min 2021. 4. 30. 18:35

지금까지의 서비스는 새로운 Jar가 실행되기 전까진 기존 Jar를 종료시켜 놓기 때문에 서비스가 중단된다.

이를 해결하기 위해 우리는 엔진엑스를 이용해 '무중단 배포' 를 해볼 것이다

엔진엑스의 기능 중 리버스 프록시는 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위를 말한다

 

무중단 배포의 운영과정을 요약하자면,

일단 포트를 두 개 둔다.

예를 들어 현재 엔진엑스가 8081포트와 연결돼있으면

8082 포트에서 신규 배포 진행 후 엔진엑스가 바라보는 포트를 8082 포트로 바꾸고,

또 신규 배포가 필요하면 8081 포트에서 신규 배포 진행 후 엔진엑스가 바라보는 포트는 8081로 바꾸면 된다.

사용자는 엔진엑스가 바라보는 포트의 프로그램을 사용하게 된다

 

 

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

EC2 엔진엑스 설치
sudo amazon-linux-extras install -y nginx1

엔진엑스 실행
sudo service nginx start

No package nginx available.
Error: Nothing to do 오류 발생
=> Amazon Linux 2에서는 yum을 통한 nginx 설치가 지원되지 않음

sudo yum install nginx 가 아니라
sudo amazon-linux-extras install -y nginx1 를 사용하자


*참고
nginx 패키지 이름 확인
yum list installed nginx

nginx 삭제
sudo yum remove 패키지이름

nginx 버전 확인
nginx -v
보안 그룹 추가

엔진엑스의 기본 포트번호는 80이니
EC2->보안그룹->EC2보안그룹선택->인바운드 편집으로 80번 포트를 보안그룹에 추가한다

리다이렉션 주소 추가

8080이 아닌 80포트로 주소가 변경되니 구글과 네이버에서도 변경된 주소를 등록해야 한다
기존에 등록된 리다이렉션 주소에서 8080부분을 제거하고 추가 등록

80번 포트는 기본적으로 도메인에서 포트번호가 제거된 상태이다

엔진엑스와 스프링 부트 연동

엔진엑스가 현재 실행 중인 스프링 부트 프로젝트를 바라볼 수 있도록 프록시 설정을 하자

엔진엑스 설정 파일
sudo vim /etc/nginx/nginx.conf

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;

proxy_pass
엔진엑스로 요청이 오면 http://localhost:8080로 전달

proxy_set_header XXX
실제 요청 데이터를 header의 각 항목에 할당합니다.
proxy_set_header X-Real-IP $remote_addr 요청자의 ip를 저장합니다.

엔진엑스 재시작
sudo service nginx restart

 

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

 

profile API 추가

배포 시에 8081을 쓸지, 8082를 쓸 지 판단하는 기준이 되는 API 추가

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

env.getActiveProfiles()

현재 실행 중인 ActiveProfile을 모두 가져옵니다.

즉, real, oauth,real-db등이 활성화되어 있다면(active) 3개가 모두 담겨 있습니다.

 

 

이 코드가 잘 작동하는지 테스트 코드 작성

해당 컨트롤러는 특별히 스프링 환경이 필요하지 않으므로 @SpringBootTest 없이 테스트 코드 작성

 

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이_조회된다() {
        //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);
    }

}

 

/profile이 인증 없이도 호출될 수 있게 SecurityConfig 클래스에 제외 코드 추가

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

 

SecurityConfig 설정이 잘 됐는지 테스트 코드로 검증

스프링 시큐리티 설정을 불러와야 하니 @SpringBootTest를 사용하는 테스트 클래스 추가

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은_인증없이_호출된다() 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에 접속해서 profile이 잘 나오는지 확인

 

java.util.zip.ZipException: invalid code lengths set 및 org/springframework/web/servlet/ModelAndViewDefiningException 발생

 

deploy.sh에서 현재 실행 중인 Spring boot 프로세스 아이디를 가져오지 못해 같은 웹서버 중복 실행한 게 원인

 

vim ~/app/step1/deploy.sh 수정

CURRENT_PID=$(ps -ef | grep ${PROJECT_NAME} | grep jar | awk '{print $1}') 로 변경


~/app/step1/deploy.sh 로 deploy.sh 재실행 후 curl localhost:8080 명령에서 html 코드 뜨면 성공

 

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.MySQL5InnoDBDialect
spring.session.store-type=jdbc


application-real2.properties

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

푸시
엔진엑스 설정이 모여있는 /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; 

sudo vim /etc/nginx/nginx.conf

service_url 추가


nginx 재시작
sudo service nginx restart

 

배포 스크립트들 작성

 

step2와 중복되지 않게 step3 디렉토리 생성
mkdir ~/app/step3 && mkdir ~/app/step3/zip

 

appspec.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 # 엔진엑스와 연결되어 있지 않은 Port로 새 버전의 스프링 부트를 시작합니다.

      timeout: 60
      runas: ec2-user
  ValidateService:
    - location: health.sh # 새 스프링 부트가 정상적으로 실행됐는지 확인합니다.
      timeout: 60
      runas: ec2-user

Jar 파일이 복사된 이후부터 차례로 hooks의 스크립트들이 실행된다

 

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

 

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)

    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
}

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

현재 엔진엑스가 바라보고 있는 스프링 부트가 정상적으로 수행 중인지 확인

응답값을 HttpStatus로 받습니다.

정상이면 200, 오류가 발생한다면 400~503 사이로 발생하니 400 이상은 모두 예외로 보고 real2를 현재 profile로 사용

 

IDLE_PROFILE

엔진엑스와 연결되지 않은 profile

스프링 부트 프로젝트를 이 profile로 연결하기 위해 반환

 

echo "${IDLE_PROFILE}"

bash라는 스크립트는 값을 반환하는 기능이 없습니다.

그래서 제일 마지막 줄에 echo로 결과를 출력 후, 클라이언트에서 그값을 잡아서 $(find_idle_profile) 사용합니다.

중간에 echo를 사용해선 안됩니다.

 

 

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

ABSDIR=$(dirname $ABSPATH)

현재 stop.sh가 속해 있는 경로 찾기

 

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=spring_web_service

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 &

prfile.sh를 통해 IDLE_PROFILE를 가져온다.

 

 

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_proxy)

엔진엑스 프록시 설정 변경은 switch.sh에서 수행

 

swtich.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
}

echo "set $service_url http://127.0.0.1:${IDLE_PORT};"

하나의 문장을 만들어 파이프라인(|)으로 넘겨주기 위해 echo를 사용

엔진엑스가 변경할 프록시 주소를 생성

 

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

앞에서 넘겨준 문장을 service-url.inc에 덮어 씁니다.

 

sudo service nginx reload

엔진엑스 설정을 다시 불러온다.

restart는 잠시 끊기는 현상이 있지만, reload는 끊김 없이 다시 불러온다.

 

 

무중단 배포 테스트

잦은 배포로 Jar 파일명이 겹칠 수 있다.

매번 버전을 올리는 것이 귀찮으므로 자동으로 버전값이 변경될 수 있도록 설정

 

build.gradle

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

푸시

 

 

CodeDeply 로그 확인

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

 

스프링 부트 로그
vim ~/app/step3/nohup.out

 

자바 애플리케이션 실행 여부
ps -ef | grep java

 

2개가 실행되고 있음을 알 수 있다

 

 

github.com/JMine97/spring_web_service

728x90
Comments