September 22, 2020
서비스가 중단된다. 반면 24시간 서비스하는 네이버나 카카오톡 같은 경우 배포하는 동안 서비스가 정지되지는 않는다.예전에는 배포가 엄청나게 큰 일이었기 때문에 개발자 모두가 고생했다. 심지어 배포 후 치명적인 문제가 발견되었을 때 서비스를 정지해야만 가능한 경우라면 롤백조차 어려우므로 개발자들이 정말 많이 비효율적으로 일했다. 서비스 입장에서도 배포만 했다 하면 서비스가 정지돼야 하니 곤란한 상황이 많았다. 그래서 서비스를 정지하지 않고, 배포할 수 있는 방법들을 찾기 시작했고 이를 무중단 배포라고 한다. 몇 가지 방법이 있다.
이외에도 L4 스위치를 이용한 무중단 배포 방법도 있지만, L4가 워낙 고가의 장비이다 보니 대형 인터넷 기업 외에는 쓸 일이 거의 없다. 여기서는 엔진엑스를 이용한 무중단 배포이다. 엔진엑스는 웹서버, 리버스, 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어다. 이전에 아파치(Apache)가 대세였전 자리를 완전히 빼앗은 가장 유명한 웹서버이자 오픈소스이다. 고성능 웹서버이기 때문에 대부분 서비스들이 현재는 엔진엑스를 사용하고 있다. 엔진엑스가 가지고 있는 여러 기능 중 리버스 프록시가 있다. 리버스 프록시란 엔진엑스가 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위를 얘기한다. 리버스 프록시 서버(엔진엑스)는 요청을 전달하고, 실제 요청에 대한 처리는 뒷단의 웹 애플리케이션 서버들이 처리한다. 여기서는 이 리버스 프록시를 통해서 무중단 배포 환경을 구축할 예정. 엔진엑스를 이용한 무중단 배포를 하는 이유는 간단하다. 가장 저렴하고 쉽기 때문.
기존에 쓰던 EC2에 그대로 적용하면 되므로 배포를 위해 AWS EC2 인스턴스가 하나 더 필요하진 않다. 추가로 이 방식은 꼭 AWS와 같은 클라우드 인프라가 구축되어 있지 않아도 사용할 수 있는 범용적 방법. 즉, 개인 서버 혹은 사내 서버에서도 동일한 방식으로 구축할 수 있으므로 사용처가 많다.
구조는 간단하다. 하나의 EC2 혹은 리눅스 서버에 엔진엑스 1대와 스프링 부트 Jar를 2대를 사용하는 것.
엔진엑스 무중단 배포 1의 운영 과정은 다음과 같다.
1.1 버전으로 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 스프링부트2(8082포트)로 배포한다.
엔진엑스 무중단 배포2의 운영 과정은 다음과 같다.
엔진엑스 무중단 배포3의 운영 과정은 다음과 같다.
이렇게 구성되면 전체 시스템 구조는 다음과 같다.
기존 구조에서 EC2 내부의 구조만 변경된 것이다.
엔진엑스 설치
sudo yum install nginxsudo service nginx startStarting nginx : [Ok]보안 그룹 추가
80이다. 해당 포트 번호가 보안 그룹에 없으니 EC -> 보안 그룹 -> EC2 보안 그룹 선택 -> 인바운드 편집 으로 차레로 이동해서 변경한다.리다이렉션 주소 추가
8080포트를 제거하고 접근해본다. 즉, 포트번호 없이 도메인만 입력해서 브라우저에서 접속해본다. 그럼 엔진엑스 웹페이지를 볼 수 있다.엔진엑스와 스프링 부트 연동
sudo vim /etc/nginx/nginx.conflocation / {
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
:wq 명령어로 저장하고 종료해서, 엔진엑스를 재작.sudo service nginx restart프록시하는 것이 확인된다. 본격적으로 무중단 배포 작업을 진행하겠다.profile API 추가
@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()추가한 코드가 잘 작성되는지 테스트 코드 작성. 해당 컨트롤러는 특별히 스프링 환경이 필요하지는 않다. 그래서 @SpringBootTest없이 테스트 코드를 작성한다
@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);
}
}자바 클래스(인터페이스)이기 때문에 쉽게 테스트 가능. Environment는 인터페이스라 가짜 구현체인 MockEnvironment(스프링에서 제공) 를 사용해서 테스트하면 된다.만약 Environment를 @Autowired로 DI 받았다면 이런 테스트 코드를 작성하지 못했을 것이다. 항상 스프링 테스트를 해야만 했을 것. 앞의 테스트가 성공적으로 다 통과했다면 컨트롤러 로직에 대한 이슈는 없다는 것을 의미한다.그리고 위의 /profile이 인증 없이도 호출될 수 있게 SecurityConfig 클래스에 제외 코드를 추가한다.
.antMatchers("/", "/css/**". "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()그리고 SecurityConfig 설정이 잘 되었는지도 테스트 코드로 검증한다. 이 검증은 스프링 시큐리티 설정을 불러와야 하니 @SpringBootTest를 사용하는 테스트 클래스ProfileControllerTest를 하나 더 추가한다.
@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이 잘 나오는지 확인한다.
http://ec2-3-35-70-236.ap-northeast-2.compute.amazonaws.com/profile 로 접속real1, real2 profile 생성
현재 EC2 환경에서 실행되는 profile은 real밖에 없다. 해당 profile은 Travis CI 배포 자동화를 위한 profile 이니 무중단 배포를 위한 profile 2개(real1, real2)를 src/main/resources 아래에 추가한다.
server.port=8081
spring.profiles.include=oauth,real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.session.store-type=jdbcserver.port=8082
spring.profiles.include=oauth,real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.session.store-type=jdbc엔진엑스 설정 수정
엔진엑스 설정이다. 배포 때마다 엔진엑스의 프록시 설정(스프링 부트로 요청을 흘려보내는) 이 순식간에 교체된다. 여기서 프록시 설정이 교체될 수 있도록 설정을 추가한다.service.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.conflocation / 부분을 찾아서 다음과 같이 변경한다.
include /etc/nginx/conf.d/service-url.inc;
location / {
proxy_pass $service_url;
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;
}저장하고 종료한 뒤(:wq) 재시작한다.
sudo service nginx restart다시 브라우저에서 정상적으로 호출되는지 확인. 확인되었다면 엔진엑스 설정까지 잘 된 것이다.
http://ec2-3-35-70-236.ap-northeast-2.compute.amazonaws.com/profile 로 접속
배포 스크립트들 작성
mkdir ~/app/step3 && mkdir ~/app/step3/zipversion: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/app/step3/zip/
overwrite: yeshooks:
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 #!/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
}{http_code}" http://localhost/profile)IDLE_PROFILE#!/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
fiABSDIR=$(dirname $ABSPATH)source ${ABSDIR}/profile.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 &#!/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#!/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};"|sudo tee /etc/nginx/conf.d/service-url.incsudo service nginx reloadbuild.grade
version '1.0.1-SNAPSHOT-'+new Date().format("yyyyMMddHHmmss")tail -f /opt/codeDeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log...(중략)...
[stdout]> Health check의 응답을 알 수 없거나 혹은 실행상태가 아닙니다.
[stdout]> Health check:
[stdout]> Health check 연결 실패. 재시도...
[stdout]> Health check 성공
[stdout]> 전환할 Port: 8081
...(중략)...
[stdout]Reloading nginx: [ OK ]vim ~/app/step3/nohup.out그럼 스프링 부트 실행 로그를 직접 볼 수 있다. 한 번 더 배포하면 그때는 real2로 배포된다. 이 과정에서 브라우저 새로고침을 해보면 전혀 중단 없는 것을 확인할 수 있다. 2번 배포를 진행한 뒤에 다음과 같이 자바 어플리케이션 실행 여부를 확인한다
ps -ef | grep java다음과 같이 2개의 애플리케이션이 실행되고 있음을 알 수 있다.
java -jar -Dspring.config.location=...-Dspring.profiles.active-real1 /home/ec2-user/app/step3/~~/jar
java -jar -Dspring.config.location=...-Dspring.profiles.active-real2 /home/ec2-user/app/step3/~~/jar이제 이 시스템은 마스터 브랜치에 푸시가 발생하면 자동으로 서버 배포가 진행되고, 서버 중단 역시 전혀 없는 시스템이 되었다. 어떤 것이든 실제로 구축해 보는 것이 가장 빨리 익힌다.
참고 : 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 이동욱