BOOK 2 - 스프링부트와 AWS로 혼자 구현하는 웹 서비스(9)
스프링부트와 AWS로 혼자 구현하는 웹 서비스 - 9
무중단 배포
- 현재는 프로젝트 코드를 수정 후 다시 배포를 진행하게 되면 새로운 jar를 실행하기 전에 기존 jar를 종료하므로 서비스가 일시적으로 중단된다.
- 배포를 할 때마다 서비스가 종료되는 것은 위험하고 사용성에도 좋지 않으므로 Nginx를 이용한 무중단 배포를 구현하도록 한다.
Nginx
-
Nginx는 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어이다.
-
reverse proxy: 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위- reverse proxy server는 요청을 전달하고, 실제 요청에 대한 처리는 뒷단의 WAS가 처리한다.
-
하나의 EC2 서버에 Nginx 1대와 스트링부트 Jar 2대를 사용하여 무중단 배포 구조를 만들 수 있다.
- 엔진엑스는 80(http), 443(https) 포트를 할당한다.
- 스프링부트는 8081, 8082 포트를 할당하여 각각 실행한다.
- 위 그림에서 8082 포트의 스프링부트는 엔진엑스와 연결된 상태가 아니므로 요청을 받지 못한다.
- 다음 버전의 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 8082 포트로 배포한다.
- 배포하는 동안에도 엔진엑스는 8081 포트와 연결되어 요청을 보내고 있으므로 서비스가 중단되지 않는다.
- 8082 포트의 스프링 부트가 정상적으로 배포되고 구동되는지 확인되면 엔진엑스가 8082 포트의 스프링부트와 연결되도록 한다.
- nginx reload는 0.1초 이내에 완료된다.
Nginx 설치
-
EC2 인스턴스에 접속 후 다음 명령어로 엔진엑스를 설치 후 실행한다.
sudo yum install nginxsudo service nginx start
Nginx와 SpringBoot 연동
보안 그룹 추가
- 엔진엑스의 포트번호는 기본적으로 80이다.
- AWS EC2 보안그룹에 80 포트를 추가한다.
리다이렉션 URI 추가
- 네이버, 구글 OAuth 콘솔에서 리다이렉션 URI를 수정한다.
- EC2의 도메인으로 접근하되, 8080 포트를 제거하고 접근하도록 8080 포트번호를 제거해준다.
기존의 EC2 도메인 주소를 입력하여 브라우저에서 접속하면 다음과 같은 엔진엑스 페이지를 볼 수 있다.

proxy 설정
-
엔진엑스가 현재 실행 중인 스프링 부트 프로젝트를 바라볼 수 있도록 프록시 설정을 해야 한다.
-
설정은 엔진엑스 설정 파일을 통해 한다.
sudo vim /etc/nginx/nginx.conf-
server항목의location부분의 설정을 추가한다.
proxy_pass: nginx로 요청이 오면http://localhost:8080으로 전달한다.proxy_set_header: 실제 요청 데이터를 header의 각 항목에 할당한다.
-
-
설정 파일 수정 후 엔진엑스를 재시작한다.
sudo service nginx restart
SpringBoot 프로젝트에 profile 추가
Profile API 생성
- 배포시에 8081 포트를 쓸지, 8082 포트를 쓸지 판단하는 기준이 되는 API를 추가한다.
ProfileController
@RequiredArgsConstructor
@RestController
public class ProfileController {
private final Environment env;
@GetMapping("/profile")
public String profile() {
List<String> profiles = Arrays.asList(env.getActiveProfiles());
List<String> prodProfiles = Arrays.asList("production", "production1", "production2");
String defaultProfile = profiles.isEmpty()? "default" : profiles.get(0);
return profiles.stream()
.filter(prodProfiles::contains)
.findAny()
.orElse(defaultProfile);
}
}
env.getActiveProfiles()- 현재 실행 중인 ActiveProfile을 모두 가져온다.
- 즉,
production,oauth,prod-db등이 활성화 되어 있다면 3개 모두 리스트에 담겨 있게 된다. production,production1,production2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록 한다.
ProfileControllerTest
public class ProfileControllerTest {
@Test
public void get_production_profile() {
// given
String expectedProfile = "production";
MockEnvironment env = new MockEnvironment();
env.addActiveProfile(expectedProfile);
env.addActiveProfile("oauth");
env.addActiveProfile("prod-db");
ProfileController controller = new ProfileController(env);
// when
String profile = controller.profile();
// then
assertThat(profile).isEqualTo(expectedProfile);
}
@Test
public void get_first_one_when_theres_no_production_profile() {
// given
String expectedProfile = "oauth";
MockEnvironment env = new MockEnvironment();
env.addActiveProfile(expectedProfile);
env.addActiveProfile("prod-db");
ProfileController controller = new ProfileController(env);
// when
String profile = controller.profile();
// then
assertThat(profile).isEqualTo(expectedProfile);
}
@Test
public void get_default_when_theres_no_active_profile() {
// 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("/", "/images/**", "/profile").permitAll()
- 해당 변경 사항을 github에 push 하고 배포된 이후에
/profile주소로 API를 요청하면 “production” 결과값이 나온다.
production1 , production2 프로필 생성
application-production1.properties
server.port=8081
spring.profiles.include=oauth,prod-db
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
spring.session.store-type=jdbc
application-production2.properties
server.port=8082
spring.profiles.include=oauth,prod-db
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
spring.session.store-type=jdbc
- 두 파일의 내용은 모두 동일하지만
server.port의 값만 다르다.
Nginx 설정
-
배포 시마다 엔진엑스의 프록시 설정이 교체될 수 있도록 설정을 추가한다.
-
엔진엑스의 설정이 모여있는
/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.conflocation /부분을 다음과 같이 변경한다.
include /etc/nginx/conf.d/service-url.inc; location / { proxy_pass $service_url; ... }- 설정 변경 후 엔진엑스를 재시작한다.
-
배포 스크립트
-
EC2에 배포 스크립트를 저장할
step3디렉토리를 생성한다.mkdir ~/app/step3 && mkdir ~/app/step3/zip -
CodeDeploy도
step3디렉토리를 사용하도록appspec.yml파일을 수정한다.version: 0.0 os: linux files: - source: / destination: /home/ec2-user/app/step3/zip/ overwrite: yes ... - 무중단 배포를 위한 스크립트는 다음과 같이 5개가 있다.
stop.sh: 기존 nginx에 연결되어 있진 않지만 실행중이던 스프링 부트 종료start.sh: 배포할 신규 버전 스프링 부트 프로젝트를stop.sh로 종료한 profile로 실행health.sh:start.sh로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크switch.sh: nginx가 바라보는 스프링 부트를 최신 버전으로 변경profile.sh: 앞선 4개 스크립트 파일에서 공용으로 사용할 profile과 포트 체크 로직
-
appspec.yml에서 위의 스크립트를 사용하도록 설정한다.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
profile.sh
#! /usr/bin/env bash
# 쉬고 있는 profile 찾기 : production1이 사용 중이면 production2가 쉬고 있고, 반대면 production1이 쉬고 있음
function find_idle_profile() {
RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)
if [ ${RESPONSE_CODE} -ge 400 ]
then
CURRENT_PROFILE=production2
else
CURRENT_PROFILE=$(curl -s http://localhost/profile)
fi
if [ ${CURRENT_PROFILE} == production1 ]
then
IDLE_PROFILE=production2
else
IDLE_PROFILE=production1
fi
echo "${IDLE_PROFILE}"
}
# 쉬고 있는 profile의 port 찾기
function find_idle_port() {
IDLE_PROFILE=$(find_idle_profile)
if [ ${IDLE_PROFILE} == production1 ]
then
echo "8081"
else
echo "8082"
fi
}
$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)- 현재 nginx가 바라보고 있는 스프링부트가 정상적으로 수행 중인지 확인하기 위해 curl 요청을 보낸다.
- 응답값을 HttpStatus로 받는다.
- 정상이면 200, 오류가 발생한다면 400~503 사이로 응답값이 발생하므로 400 이상은 모두 예외로 보고
production2를 현재 프로필로 사용한다.
IDLE_PROFILE- 현재 nginx와 연결되지 않은 profile이다.
- 스프링부트 프로젝트를 이 프로필로 실행시키기 위해 반환한다.
echo "${IDLE_PROFILE}"- bash script는 값을 반환하는 기능이 없기 때문에
echo로 결과값을 출력한 후 클라이언트에서 그 값을 잡아서 사용하도록 한다. echo는 제일 마지막 줄에 선언해야 한다.
- bash script는 값을 반환하는 기능이 없기 때문에
stop.sh
#! /usr/bin/env bash
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
IDLE_PORT=$(find_idle_port)
echo "> Check Application PID Running on $IDLE_PORT"
IDLE_PID=$(lsof -ti tcp:${IDLE_PORT})
if [ -z ${IDLE_PID} ]
then
echo "> There's no running application."
else
echo "> Kill $IDLE_PID"
kill -15 ${IDLE_PID}
sleep 5
fi
ABSDIR=$(dirname $ABSPATH)- 현재
stop.sh파일이 속해있는 경로를 찾는다. profile.sh의 경로를 찾기 위해 사용된다.
- 현재
source ${ABSDIR}/profile.sh: 일종의 import 구문으로,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=all-review
echo "> Copy Build File"
cp $REPOSITORY/zip/*.jar $REPOSITORY/
echo "> New Application Deployment"
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1 | awk -F'/' '{print $6}')
echo "> Jar Name: $JAR_NAME"
chmod +x $JAR_NAME
IDLE_PROFILE=$(find_idle_profile)
echo "> Execute $JAR_NAME by $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-prod-db.properties \-Dspring.profiles.active=$IDLE_PROFILE \/home/ec2-user/app/step3/$JAR_NAME > /home/ec2-user/app/step3/nohup.out 2>&1 &
deploy.sh와 내용은 거의 유사하고,IDLE_PROFILE을 통해 properties 파일을 가져오고 active 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"
sleep 10
for RETRY_COUNT in {1..10}
do
RESPONSE=$(curl -s http://localhost:${IDLE_PORT}/profile)
UP_COUNT=$(echo ${RESPONSE} | grep 'production' | wc -l)
if [ ${UP_COUNT} -ge 1 ]
then
echo "> Health Check Success"
switch_proxy
break
else
echo "> Health Check does not response or not running"
echo "> Health Check: ${RESPONSE}"
fi
if [ ${RETRY_COUNT} -eq 10 ]
then
echo "> Health Check Fail"
echo "> Terminate deployment without connecting Nginx"
exit 1
fi
echo "> Retry Health Check"
sleep 10
done
- Nginx와 연결되지 않은 포트에서 새로운 스프링부트 프로젝트가 잘 구동되는지 체크한다.
- 잘 구동되고 있으면(
RESPONSE값에 “production” 문자열이 있으면) nginx 프록시 설정을 변경한다. - 10번의 재시도 후에도 새로운 어플리케이션이 제대로 구동되지 않았다면 엔진엑스에 연결하지 않고 배포를 종료한다.
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 "> Switch Port: $IDLE_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
}
echo "set \$service_url http://127.0.0.1:${IDLE_PORT};"- 하나의 문장을 만들어 파이프라인으로 넘겨주기 위해
echo를 사용한다. - nginx가 switch할 프록시 주소를 생성한다.
- 하나의 문장을 만들어 파이프라인으로 넘겨주기 위해
| sudo tee /etc/nginx/conf.d/service-url.inc- 앞에서 파이프라인으로 넘겨준 문장을
service-url.inc파일에 덮어쓴다.
- 앞에서 파이프라인으로 넘겨준 문장을
sudo service nginx reload- nginx 설정을 다시 불러온다.
restart는 잠시 끊기는 현상이 있지만,reload는 끊김 없이 다시 불러온다는 점에서 차이가 있다.- 중요한 설정들은
reload로 반영되지 않으므로restart를 사용해야 한다.
무중단 배포 로그 확인
-
CodeDeploy 로그는 다음 명령어로 확인할 수 있다.
tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log
-
nohup.out파일로 application 로그도 확인할 수 있다.tail -f ~/app/step3/nohup.out
- 스프링부트 실행 로그에서 8081 포트로 서버가 구동중인 것을 확인할 수 있다.
-
자바 어플리케이션 실행 여부 확인
ps -ef | grep java