어디까지 갈 수 있을까?
NGINX 무중단 배포 본문
지금까지의 서비스는 새로운 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; |
엔진엑스 재시작 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}') 로 변경
|
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개가 실행되고 있음을 알 수 있다
'책 > 스프링부트와 AWS로 혼자 구현하는 웹 서비스' 카테고리의 다른 글
EC2 서버에 프로젝트를 배포해보자 &코드가 푸시되면 자동으로 배포해 보자 (0) | 2021.04.12 |
---|---|
AWS 서버, 데이터베이스 환경을 만들어보자(EC2, RDS) (0) | 2021.04.08 |
스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (0) | 2021.03.25 |
[작성중] 프로젝트 구조 (0) | 2021.03.16 |
머스테치로 화면 구성하기 (0) | 2021.03.14 |