무중단 배포 스크립트 작업 전에 API를 하나 추가하겠습니다.
이 API는 이후 배포 시에 8081을 쓸지, 8082를 쓸지 판단하는 기준이 됩니다.
profile API 추가
ProfileController를 만들어 다음과 같이 간단한 API 코드를 추가합니다.
package com.usedcar.admin.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()); # 1
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개가 모두 담겨 있습니다.
- 여기서 real, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록 합니다.
- 실제로 이번 무중단 배포에서는 real1과 real2만 사용되지만, step2를 다시 사용해 볼 수도 있으니 real도 남겨둡니다.
이 코드가 잘 작동하는지 테스트 코드를 작성해 보겠습니다.
해당 컨트롤러는 특별히 스프링 환경이 필요하지 않습니다.
그래서 @SpringBootTest 없이 테스트 코드를 작성합니다.
package com.usedcar.admin.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이_조회된다() throws Exception {
// 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 active_profile이_없으면_default가_조회된다() throws Exception {
// given
String expectedProfile = "default";
MockEnvironment env = new MockEnvironment();
ProfileController controller = new ProfileController(env);
// when
String profile = controller.profile();
// then
assertThat(profile).isEqualTo(expectedProfile);
}
}
ProfileController나 Environment 모두 자바 클래스(인터페이스)이기 때문에 쉽게 테스트할 수 있습니다.
Environment는 인터페이스라 가짜 구현체인 MockEnvironment(스프링에서 제공)를 사용해서 테스트하면 됩니다.
이렇게 해보면 생성자 DI가 얼마나 유용한지 알 수 있습니다.
만약 Environment를 @Autowired로 DI 받았다면 이런 테스트 코드를 작성하지 못했습니다.
앞의 테스트가 성공적으로 다 통과했다면 컨트롤러에 로직에 대한 이슈는 없습니다.
그리고 이 /profile이 인증 없이도 호출될 수 있게 SecurityConfig 클래스에 제외 코드를 추가합니다.
.antMatchers("/", "/css/**", "/images/**",
"/js/**", "/h2-console/**", "/profile").permitAll()
- permitAll 마지막에 "/profile"이 추가됩니다.
그리고 SecurityConfig 설정이 잘 되었는지도 테스트 코드로 검증합니다.
이 검증은 스프링 시큐리티 설정을 불러와야 하니 @SpringBootTest를 사용하는 테스트 클래스(ProfileControllerTest)를 하나 더 추가합니다.
package com.usedcar.admin.web;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
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.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import static org.junit.Assert.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class ProfileControllerTest {
@Autowired
private MockMvc mvc;
@Test
public void profile은_인증없이_호출된다() throws Exception {
String expected = "default";
mvc.perform(get("/profile"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string(expected));
}
}
여기까지 모든 테스트가 성공했다면 깃허브로 푸시하여 배포 합니다.
배포가 끝나면 브라우저에서 /profile로 접속해서 profile이 잘 나오는지 확인합니다.
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.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
# 세션 저장소 jdbc 설정
spring.session.store-type=jdbc
spring.session.jdbc.initialize-schema=always
# UTF-8 세팅
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.force=true
application-real2.properties
server.port=8082
spring.profiles.include=oauth, real-db
# 쿼리 로그 세팅
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
# 세션 저장소 jdbc 설정
spring.session.store-type=jdbc
spring.session.jdbc.initialize-schema=always
# UTF-8 세팅
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.force=true
2개의 profile은 real profile과 크게 다른 점은 없지만, 한 가지가 다릅니다.
server.port가 8080이 아닌 8081/8082로 되어 있습니다.
이 부분만 주의해서 생성하고 생성된 후에는 깃허브로 푸시하면서 마무리합니다.
엔진엑스 설정 수정
무중단 배포의 핵심은 엔진엑스 설정입니다.
배포 때마다 엔진엑스의 프록시 설정(스프링 부트로 요청을 흘려보내는)이 순식간에 교체됩니다.
여기서 프록시 설정이 교체될 수 있도록 설정을 추가하겠습니다.
엔진엑스 설정이 모여있는 /etc/nginx/conf.d/ 에 servie-url-inc라는 파일을 하나 생성합니다.
sudo vim /etc/nginx/conf.d/service-url.inc
그리고 다음 코드를 입력합니다.
set $service_url http://127.0.0.1:8080;
저장하고 종료한 뒤(:wq) 해당 파일은 엔진엑스가 사용할 수 있게 설정합니다.
다음과 같이 nginx.conf 파일을 열겠습니다.
sudo vim /etc/nginx/nginx.conf
location / 부분을 찾아 다음과 같이 변경합니다.
include /etc/nginx/conf.d/service-url.inc;
location / {
proxy_pass $service-url;
저장하고 종료한 뒤(:wq) 재시작합니다.
sudo service nginx restart
다시 브라우저에서 정상적으로 호출되는지 확인합니다.
확인되었다면 엔진엑스 설정까지 잘 된 것입니다.
배포 스크립트들 작성
먼저 step2와 중복되지 않기 위해 EC2에 step3 디렉토리를 생성합니다.
mkdir ~/app/step3 && mkdir ~/app/step3/zip
무중단 배포는 앞으로 step3를 사용하겠습니다.
그래서 appspec.yml 역시 step3로 배포되도록 수정합니다.
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/app/step3/zip/
overwrite: yes
무중단 배포를 진행할 스크립트들은 총 5개 입니다.
- stop.sh
- 기존 엔진엑스에 연결되어 있진 않지만, 실행 중이던 스프링 부트 종료
- start.sh
- 배포할 신규 버전 스프링 부트 프로젝트를 stop.sh로 종료한 'profile'로 실행
- health.sh
- 'start.sh'로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크
- switch.sh
- 엔진엑스가 바라보는 스프링 부트를 최신 버전으로 변경
- profile.sh
- 앞선 4개 스크립트 파일에서 공용으로 사용할 'profile'과 포트 체크 로직
appspec.yml에 앞선 스크립트를 사용하도록 설정합니다.
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 파일이 복사된 이후부터 차례로 앞선 스크립트들이 실행된다고 보면 됩니다.
다음은 각 스크립트입니다.
이 스크립트들 역시 scripts 디렉토리에 추가합니다.
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) # 1
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 # 2
else
IDLE_PROFILE=real1
fi
echo "${IDLE_PROFILE}" # 3
}
# 쉬고 있는 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) # 1
source ${ABSDIR}/profile.sh # 2
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
- 자바로 보면 일종의 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=used-car-admin
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 &
- 기본적인 스크립트는 step2의 deploy.sh와 유사합니다.
- 다른 점이라면 IDLE_PROFILE을 통해 properties 파일을 가져오고(application-$IDLE_PROFILE.properties), active profile을 지정하는 것(-Dspring.profiles.active=$IDLE_PROFILE) 뿐입니다.
- 여기서도 IDLE_PROFILE을 사용하니 profile.sh을 가져와야 합니다.
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에서 수행합니다.
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를 사용합니다.
- 엔진엑스가 변경할 프록시 주소를 생성합니다.
- 쌍따옴표(")를 사용해야 합니다.
- 사용하지 않으면 $service_url을 그대로 인식하지 못하고 변수를 찾게 됩니다.
- | sudo tee /etc/nginx/conf.d/service-url.inc
- 앞에서 넘겨준 문장을 service-url.inc에 덮어씁니다.
- sudo service nginx reload
- 엔진엑스 설정을 다시 불러옵니다.
- restart와는 다릅니다.
- restart는 잠시 끊기는 현상이 있지만, reload는 끊김 없이 다시 불러옵니다.
- 다만, 중요한 설정들은 반영되지 않으므로 restart를 사용해야 합니다.
- 여기선 외부의 설정 파일인 service-url을 다시 불러오는 거라 reload로 가능합니다.
참고
'CI & CD' 카테고리의 다른 글
[Nginx] 24시간 365일 중단 없는 서비스 만들기 4 - 무중단 배포 테스트 (0) | 2021.07.20 |
---|---|
[Nginx] 24시간 365일 중단 없는 서비스 만들기 2 - 엔진엑스 설치와 스프링 부트 연동하기 (0) | 2021.07.19 |
[Nginx] 24시간 365일 중단 없는 서비스 만들기 1 - 무중단 배포 소개 (0) | 2021.07.19 |
[Travis CI] 코드가 푸시되면 자동으로 배포하기 5 - 배포 자동화 구성 (0) | 2021.07.18 |
[Travis CI] 코드가 푸시되면 자동으로 배포하기 4 - Travis CI와 AWS S3, CodeDeploy 연동하기 (0) | 2021.07.18 |
댓글