24시간 365일 중단없는 서비스를 만들자
※ 이번 포스트는 <스프링부트와 AWS로 혼자 구현하는 웹서비스> 책의 10장을 공부하면서 정리한 것입니다.
무중단 배포란
서비스를 정지하지 않고 배포하는 방법
무중단 배포의 방식에는 AWS Blue-Green을 이용하는 방법, 도커를 사용하는 방법 등 다양하지만 이번 포스트에서는 nginx를 활용하여 무중단 배포를 할 것이다.
NGINX
웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어
●리버스 프록시 : 외부의 요청을 받아 백앤드 서버로 요청을 전달하는 행위. nginx는 요청을 전달하고, 실제 요청에 대한 처리는 어플리케이션 서버가 처리한다.
NGINX 리버스 프록시를 활용한 springboot 프로젝트 무중단 배포
nginx 1대와 springboot jar 2대를 사용하며, 구조는 다음과 같다.

운영과정은 다음과 같다.
- 사용자는 서비스 주소로 접속한다.(80 혹은 443포트)
- nginx는 사용자의 요청을 받아 현재 연결된 스프링부트로 요청을 전달한다. (8081포트로 연결하며, 8082는 nginx와 연결된 상태가 아니므로 요청을 받지 못한다
- 1.1ver으로 신규 배포가 필요한 경우, 8072포트와 연결된 스프링부트로 배포한다.
- 배포가 끝나면 정상적으로 해당 스프링부트가 구동 중인지 확인한다.
- 정상 구동 중이라면, nginx reload 명령으로 8082포트를 바라보도록 한다.
이렇게 구성을 하면 새로운 버전을 배포하는 동안에도 서비스가 중단되지 않는다. 만약 다음에 1.2ver으로 새로 배포하게 된다면 그 때는 8081포트의 springboot에 배포한 후 nginx reload로 8081 포트를 연결해주면 된다.
배포 자동화, 무중단 배포까지 적용한 전체 시스템 구조는 다음과 같다

무중단 배포를 적용하는 과정
- EC2 인스턴스에 nginx를 설치한다
- EC2 인스턴스의 보안그룹에서 인바운드 편집으로 80번(nginx의 기본 포트번호) 포트를 보안그룹에 추가한다
- 포트가 8080이 아닌 80으로 변경되므로 소셜로그인을 설정한 사이트에서도 승인된 리디렉션 URI를 추가한다
- 포트 번호 없이 도메인만 입력하여 nginx 화면이 잘 뜨는지 확인한다
- 아래 sudo vim /etc/nginx/nginx.conf 명령으로 nginx 설정 파일로 들어가 location / 부분을 다음 코드 처럼 수정한다
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;
6. sudo service nginx restart 명령을 입력하고 본인의 사이트가 잘 뜨는지 확인한다
7. ProfileController를 만들어 아래의 코드를 추가한다
package com.sjiun.book.springboot.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());
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);
}
}
위 컨트롤러의 테스트 코드는 다음과 같다
package com.sjiun.book.springboot.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이_조회된다() {
//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);
}
}
8. /profile이 인증없이도 호출될 수 있도록 다음과 같이 SecurityConfig 클래스에 제외 코드를 추가한다
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
위의 SecurityConfig를 테스트할 수 있는 코드는 다음과 같다
package com.sjiun.book.springboot.web;
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);
}
}
9. 깃헙으로 푸시, 배포한다
10. 현재 EC2 환경에서 실행되는 profile은 real뿐이므로 무중단 배포를 위한 profile을 2개 추가한다
[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
11. sudo vim /etc/nginx/conf.d/service-url.inc 명령을 입력하고 다음 코드를 입력한다.
set $service_url http://127.0.0.1:8080;
12. sudo vim /etc/nginx/nginx.conf 명령을 입력하고 아래의 코드처럼 location / 부분을 추가한다
include /etc/nginx/conf.d/service-url.inc;
location / {
proxy_pass $service_url;
13. nginx를 재시작하고 브라우저에서 정상적으로 호출되는지 확인한다.
14. 배포스크립트를 작성한다. 세로운 디렉토리를 생성하고. appsep.yml을 다음과 같이 수정한다.
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/app/{생성한 디렉토리 이름}/zip/
overwrite: yes
permissions:
- object: /
pattern: "**"
owner: ec2-user
group: ec2-user
hooks:
AfterInstall:
- location: stop.sh # nginx와 연결되어 있지 않은 스프링 부트를 종료합니다.
timeout: 60
runas: ec2-user
ApplicationStart:
- location: start.sh # nginx와 연결되어 있지 않은 Port로 새 버전의 스프링 부트를 시작합니다.
timeout: 60
runas: ec2-user
ValidateService:
- location: health.sh # 새 springboot가 정상적으로 실행됐는지 확인 합니다.
timeout: 60
runas: ec2-user
15. scripts 디렉토리를 생성하여 아래의 스크립트들을 추가한다. 각 스크립트의 용도는 코드 앞부분에 적어 놓았다.
[profile.sh]
#!/usr/bin/env bash
# 이어서 작성할 스크립트 파일에서 공용으로 사용할 'profile'과 포트 체크
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
# 기존 nginx에 연결되어 있진 않지만, 실행 중이던 springboot 종료
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
[start.sh]
#!/usr/bin/env bash
# 배포할 신규 버전 프로젝트를 stop.sh로 종료한 'profile'로 실행
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 &
[health.sh]
#!/usr/bin/env bash
# start.sh로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크
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.sh]
#!/usr/bin/env bash
# nginx가 바라보는 springboot을 최신 버전으로 변경
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
}
16. 마지막으로 자동으로 버전값이 올라갈 수 있도록 build.gradle의 version을 다음과 같이 수정하고 push하면 된다!
version '1.0.4-SNAPSHOT-'+new Date().format("yyyyMMddHHmmss")