본문 바로가기
Clone Coding/스프링 부트와 AWS

[Nginx] 무중단 배포

by 연로그 2021. 3. 22.
반응형

무중단 배포 방식

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

 

본 프로젝트에서는 저렴하고 쉬운 NGINX를 사용해서 무중단 배포가 가능하게 하겠다.

기존에 쓰던 EC2 인스턴스에 적용하면 되고, 개인이나 사내 서버 등 다양하게 사용될 수 있다.

(우리 회사에서도 일부 서비스에선 Apache가 아닌 Nginx 사용하기도 한다.)

 

NGINX 동작 구조

  1. 사용자는 서비스 주소로 접속 (http의 경우 80 포트, https의 경우 443 포트)
  2. Nginx는 사용자 요청을 받아 현재 연결된 Spring boot로 요청 전달
  3. 두 번째 Spring boot는 연결되어 있지 않아 요청받지 못한다.

 

신규 배포가 필요한 경우

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

 

기존 프로젝트+Nginx 의 구조

 

이제 본격적으로 Nginx를 설치하고 프로젝트와 연동해보자.

 


Nginx와 AWS EC2 연동

 

1. Nginx 설치

$ sudo yum install nginx
$ sudo service nginx start

 

2. 보안 그룹 추가

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

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

 

3. 리다이렉션 주소 추가

포트가 8080이 아닌 80으로 변경되니 구글과 네이버 로그인에서도 변경된 주소를 등록해야 한다.

변경 방법: yeonyeon.tistory.com/69?category=920206

 

잘 연동 되었다면 ec2 도메인을 이용해 접속했을 때 다음과 같이 뜰 것이다.

 

 


Nginx와 Spring boot 연동

 

1. nginx.conf 수정

$ 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 저장

 

2. nginx를 재시작

재시작 후에 도메인에 다시 들어가면 nginx 페이지가 아닌 우리가 만든 스프링 페이지가 만든 것이 보인다.

$ sudo service nginx restart

 


무중단 배포 스크립트

 

1. profile API 추가

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

 

ProfileController.java 생성

@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 등이 활성화되어 있다면 3개가 모두 담겨 있다.
  • real, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환한다.
  • 이번 무중단 배포에서는 real1과 real2에 대해서만 사용하지만, 혹시나 나중에라도 step2를 다시 이용해보고 싶다면 real도 남겨두자.

 

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

 

SecurityConfig.class 수정

위의 테스트 코드에서 /profile이 인증 없이 호출될 수 있도록 SecurityConfig 클래스에 제외 코드도 덧붙인다.

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

 

위 파일들을 깃허브에 push한 뒤, 도메인/profiles에 접속하면 아래와 같이 뜬다.

 

2. real1, real2 profile 생성

/src/main/resources 폴더에 application-real1.properties, application-real2.properties 파일을 생성한다.

applicaiton-real2는 real1과 내용이 같되, 포트 번호만 8082로 생성한 뒤 push 한다.

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

 

3. 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

 

브라우저에서 도메인을 입력해 페이지가 잘 호출되는지 확인한다.

확인되면 엔진엑스 설정이 무사히 된 것이다.

 

4. 배포 스크립트들 작성

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

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

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

 

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로 대체함

 

5. 무중단 배포 테스트

잦은 배포로 인해 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 실행 여부

java application 실행 결과

 


이것으로 "스프링 부트와 AWS로 혼자 구현하는 웹 서비스" 책이 종료되었다.

마음만 먹으면 1~2주 안에도 가능할 것 같은데 인수인계 받으면서 띄엄띄엄 하려니 대충 두 달 정도 걸린 것 같다.

EC2 인스턴스 생성만 해본거로 아 AWS 대충은 알지 이렇게 생각했는데 정말 아는게 없었다...

그래도 서버 관리하는게 화딱지 나긴 해도 제일 재밌다.

AWS 하다보니 또 Spring이 가물가물해져서... 이후 공부는 Spring에 더 초점을 두고 공부해야할 것 같다

가능하다면 JPA에 대한 공부도 하고싶은데 아직은 이른 얘기다.


해당 게시글은 [ 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 / 이동욱 ] 책을 따라한 것을 정리하기 위한 게시글입니다. 요약, 생략한 부분이 많으니 보다 자세한 설명은 책 구매를 권장합니다.

반응형