DevOps/AWS

[AWS] Nginx, SpringBoot, CodeDeploy, Travis CI를 이용한 무중단 배포 시스템 만들기

킵고잉 개발자 2022. 5. 29. 13:06

이전 글에서 다음과 같은 구조로 자동 배포 시스템을 구축했습니다.

 

Github에 push만 하면 자동으로 EC2에 새로운 버전의 jar가 배포되고 재 실행되어서 배포가 되었습니다. 하지만 위의 방식에는 단점이 존재합니다. 배포가 진행되는 도중에는 서버가 잠시 중단돼야 한다는 점입니다.

CodeDeploy가 EC2에 새로운 버전의 jar를 배포할 때, 기존의 실행 중인 jar를 kill 한 후에 새로운 버전의 jar를 실행시킬 것입니다. 이 과정에서 서버가 잠시 중단되어 사용자들은 서비스에 접속할 수 없게 됩니다. 현재 우리가 많이 사용하고 있는 서비스들을 보면 새로운 버전의 업데이트가 일어나더라도 서비스가 중단되거나 하지 않고 무중단으로 배포가 이루어집니다.

 

이번 글에서는 Nginx를 이용해서 EC2 내부에 포트를 나눠서 무중단 배포를 진행해보겠습니다.

 

목차

  • 무중단 배포 방식이란?
  • Nginx 무중단 배포 사용 포트
  • Nginx 무중단 배포 방식
  • Nginx와 AWS EC2 연동
  • Nginx와 Spring boot 연동

무중단 배포 방식 및 Nginx는 왜 사용하는가?

새로운 jar가 실행되기 전까지 기존 jar를 종료시켜 놓아야 해서 일정 시간 동안 서비스가 중단됩니다.

→ 배포하는 동안 서비스가 종료되지 않는 배포 방식을 무중단 배포 방식이라고 합니다.

여러 무중단 배포 방식

  • AWS에서 블루 그린(Blue-Green) 무중단 배포
  • 도커를 이용한 웹서비스 무중단 배포
  • L4 스위치를 이용한 무중단 배포
  • ...

 

Nginx는 고성능 웹서버입니다.
Nginx의 리버스 프록시 기능이 존재합니다.
리버스 프록시란 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 것을 의미합니다.
다른 배포 방식에 비해서 저렴하고 쉽습니다

배포를 위해 AWS EC2 인스턴스가 하나 더 필요하지 않고, 기존에 쓰던 EC2 인스턴스에 적용하면 됩니다.

 

Nginx 무중단 배포 사용 포트

  • 엔진엑스는 80(http), 443(https) 포트를 할당합니다.
  • 스프링 부트 1은 8081 포트로 실행합니다.
  • 스프링 부트 2은 8082 포트로 실행합니다.

 

Nginx 무중단 배포 방식

  1. 사용자는 서비스 주소로 접속 (http의 경우 80 포트, https의 경우 443 포트)
  2. Nginx는 사용자 요청을 받아 현재 연결된 Spring boot로 요청 전달
    1. Nginx의 리버스 프록시 기능 (외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위)
  3. 두 번째 Spring boot는 연결되어 있지 않아 요청받지 못한다.

 

신규 배포가 필요한 경우

  1. 연결되지 않은, 두 번째 Spring boot에 배포를 한다. (Nginx는 첫 번째 Spring boot와 연결된 상태라 서비스가 중단되지 않는다.)
  2. 배포 후에 정상적으로 두 번째 Spring boot가 구동 중인지 확인
  3. 2가 정상적이라면, nignx reload 명령어를 통해 Nginx 연결을 2와 연결

 

Nginx와 AWS EC2 연동

Nginx 설치하기

sudo yum install nginx → amazon linux 2에서는 안된다.

sudo amazon-linux-extras install nginx1
sudo service nginx start

 

2. 보안 그룹 추가

Nginx의 포트 번호를 보안 그룹에 추가해야 합니다. (기본적으로 80 포트 사용)

EC2 - 보안 그룹 - EC2 보안 그룹 선택 - 인바운드 편집에서 80번 포트에 대해 0.0.0.0/0, ::/0을 오픈합니다.

 

Nginx와 EC2 연동

Nginx 설정 파일 수정

$ sudo vim /etc/nginx/nginx.conf

 

nginx.conf 내용은 다음과 같이 수정하면 됩니다.

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

proxy_pass

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

proxy_set_header XXX

  • 실제 요청 데이터를 header의 각 항목에 할당
  • ex: proxy_set_header X-Real-IP $remote-addr: Request Header의 X-Real-IP에 요청자의 IP 저장

 

nginx를 재시작

재시작 후에 도메인에 다시 들어가면 nginx 페이지가 아닌 이전에 만든 API가 보일 겁니다.

$ sudo service nginx restart

 

Nginx와 Spring boot 연동

 

1. profile API 추가

profile API는 이후 배포 시 포트 8081번을 쓸지 8082번을 쓸지 판단하는 기준이 됩니다.

 

아래와 같이 real1은 8081, real2은 8082로 포트 번호를 세팅하겠습니다.

 

 

ProfileController.java 생성

위의 API에 대해서 설명하자면 8081로 요청을 보냈을 때 응답이 오면 real1을 String으로 응답하고, 8082로 요청을 보냈을 때 응답이 오면 real2를 String으로 응답하는 API입니다.

@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, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환한다.
  • 이번 무중단 배포에서는 real1과 real2에 대해서만 사용합니다.

ProfileControllerUnitTest.java 생성

Environment는 인터페이스라 스프링에서 제공하는 가짜 구현체인 MockEnvironment를 사용해서 테스트하면 됩니다.

(Environment를 @Autowired로 DI 받을 필요 없이 간편한 테스트 코드를 작성할 수 있다.)

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

 

2. Nginx 설정 수정

배포 때마다 nginx의 프록시 설정이 교체될 수 있도록 설정을 추가합니다.

service-url.inc 파일을 생성하고 텍스트 박스의 코드를 입력합니다.

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

 

위 파일을 nginx가 수정할 수 있도록 설정합니다.

$ sudo vim /etc/nginx/nginx.conf

 

파일을 저장한 뒤, nginx를 재시작합니다.

$ sudo service nginx restart

 

3. 배포 스크립트들 작성

먼저 step2와는 중복되지 않도록 step3 디렉토리를 새롭게 생성해주겠습니다.

앞으로 step3에서 무중단 배포를 진행할 것입니다.

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

 

현재 CI/CD 중 CD의 역할은 CodeDeploy로 하고 있습니다. CodeDeploy가 작동하는 Flow는 appspec.yml 파일에 작성을 하게 되는데요.
이 파일에 대해서도 한번 알아보겠습니다. 그런데 그전에 CodeDeploy가 배포를 진행하는 단계에 대해서 알아야 합니다.

 

CodeDeploy는 위의 단계를 거쳐서 배포를 하게 됩니다. 각 단계는 어떤 의미를 담고 있을까요?

  • BeforeInstall – 파일 암호화 해제 및 현재 버전의 백업 만들기와 같은 사전 설치 작업에 이 배포 수명 주기 이벤트를 사용할 수 있습니다.
  • Install – 이 배포 수명 주기 이벤트 중에 CodeDeploy 에이전트는 개정 파일을 임시 위치에서 최종 대상 폴더로 복사합니다. 이 이벤트는 CodeDeploy 에이전트에 예약되어 있으므로 스크립트 실행에 사용할 수 없습니다.
  • AfterInstall – 애플리케이션 구성 또는 파일 권한 변경과 같은 작업에 이 배포 수명 주기 이벤트를 사용할 수 있습니다.
  • ApplicationStart – 중지된 서비스를 다시 시작하려면 일반적으로 이 배포 수명 주기 이벤트를 사용합니다
  • ValidateService – 마지막 배포 수명 주기 이벤트입니다. 배포가 성공적으로 완료되었는지 확인하는 데 사용됩니다.

이번 글에서는 AfterInstall, ApplicationStart, ValidateService 단계에 스크립트 파일을 실행시키겠습니다.

 

appsepc.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
  • 배포 폴더의 위치 step2 -> step3 변경
  • AfterInstall에서 stop.sh 실행
  • ApplicationStrat에서 start.sh 실행
  • VallidateService에서 health.sh 실행

 

배포 스크립트 작성 (5개)

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

(위 스크립트들은 deploy.sh와 마찬가지로 scripts 폴더에 두면 된다.)

 

profile.sh

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)

  • 현재 Nginx가 바라보고 있는 스프링 부트가 정상 수행 중인지 체크
  • 응답값: HttpStatus
  • 정상: 200, 오류: 400 이상
  • 오류일 경우 real2를 현재 profile로 사용

 

IDLE_PROFILE

  • Nginx와 연결되지 않은 profile
  • 스프링 부트 프로젝트를 이 profile로 연결하기 위해 echo를 통해 결과 출력
  • echo를 통해 출력된 결과는 클라이언트에서 그 값을 잡아 $(find_idle_profile)을 사용한다.

 

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가 속해있는 경로
  • 이후에 profile.sh의 경로를 찾기 위해 사용

 

source ${ABSDIR}/profile.sh

  • JAVA의 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=freelec-springboot2-webservice

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 &

코드 설명 ▼

더보기

IDLE_PROFILE=$(find_idle_profile)

  • profile.sh에서 echo했던 결과를 받아온 것 (Nginx와 연결되지 않은 profile)

 

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
}

코드 설명 ▼

더보기

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

  • 하나의 문장을 만들어 파이프라인' | '으로 넘기기 위해 echo 사용
  • Nginx가 변경할 프록시 주소 생성
  • 쌍따옴표 이용해야 함' " '
  • 사용하지 않으면 $service_url을 그대로 인식하지 못하고 변수를 찾게 된다.

 

| sudo tee /ect/nginx/conf.d/service-url.inc

  • 앞에서 넘겨준 문장을 service-url.inc에 덮어쓰기

 

sudo service nginx reload

  • Nginx 설정 다시 불러오기
  • != restart
  • restart는 잠시 끊기는 현상이 있지만, reload는 없다. (대신 중요한 설정들은 반영 x)
  • 여기선 외부 설정 파일인 service-url을 다시 불러오는 거라 reload로 대체함

 

4. 무중단 배포 테스트

잦은 배포로 인해 jar 파일명이 겹쳐지면 백업 파일 남기기가 힘듭니다.

그렇다고 버전을 매번 수동으로 올리는 건 번거로우니 build.gradle에 아예 설정을 합니다.

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

 

이제 위 작업들이 모두 push가 됐다면 로그를 통해 잘 진행되는지 확인해보자.

$ vim /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log #CodeDeploy 로그
$ vim ~/app/step3/nohup.out #Spring boot 로그
$ ps -ef | grep java #java application 실행 여부

CodeDeploy 배포 성공 로그
CodeDeploy 실행 결과
현재 배포된 프로파일 조회