무중단 배포 방식에는 몇 가지가 있습니다.
우리가 진행할 방법은 엔진엑스(Nginx)를 이용한 무중단 배포 입니다.
엔진엑스는 lgor Sysoev라는 러시아 개발자 동시접속 처리에 특화된 웹 서버 프로그램이다. Apache보다 동작이 단순하고, 전달자 역할만 하기 때문에 동시 접속 처리에 특화되어 있다.
엔진엑스 1대와 스프링 부트 Jar를 2대 사용
무중단 배포 전체 구조
EC2 엔진엑스 설치
sudo yum install nginx
엔진엑스 실행
sudo service nginx start
엔진엑스의 포트번호는 기본적으로 80입니다. EC2 인바운드 보안그룹 추가 설정을 합니다.
80번 포트로 접속
엔진엑스가 현재 실행 중인 스프링 부트 프로젝트를 바라 볼 수 있도록 프록시 설정을 하겠스빈다.
엔진엑스 설정 파일
sudo vim /etc/nginx/nginx.conf
엔진엑스 설정 추가
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
- 실제 요청 데이터를 header의 각 항목에 할당합니다.
- proxy_set_header X-Real-IP $remote_addr 요청자의 ip를 저장합니다.
엔진엑스 재시작
sudo service nginx restart
스프링 부트 연동완료
무중단 배포 스크립트 작업 전 API를 하나 추가하겠습니다. 이 API는 이후 배포 시에 8081을 쓸지, 8082를 쓸지 판단하는 기준이 됩니다.
profile API 추가
package com.swchoi.webservice.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); } }
코드설명
- env.getActiveProfiles()
- 현재 실행 중인 ActiveProfile을 모두 가져옵니다.
- 즉, real, oauth,real-db등이 활성화되어 있다면(active) 3개가 모두 담겨 있습니다.
ProfileControllerTest 코드 작성
package com.swchoi.webservice.springboot.web; import org.junit.Test; import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; public class ProfileControllerTest { @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); } }
SecurityConfig 클래스 추가
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
SecurityConfig 변경 확인 테스트 추가
package com.swchoi.webservice.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.mock.env.MockEnvironment; import org.springframework.test.context.junit4.SpringRunner; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @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); }
git push 배포 확인
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라는 파일을 생성합니다.
service-url.inc
sudo vim /etc/nginx/conf.d/service-url.inc
코드입력
set $service_url http://127.0.0.1:8080;
nginx.conf
sudo vim /etc/nginx/nginx.conf
service_url 추가
nginx 재기동
sudo service nginx restart
디렉토리 생성
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
무중단 배포를 진행할 스크립트들은 총 5개입니다.
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 파일이 복사된 이후부터 차례로 앞선 스크립트들이 실행된다고 보면 된다.
profile.sh
#!/usr/bin/env bash # 쉬고 있는 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 CUREENT_PROFILE=real2 else CUREENT_PROFILE=$(curl -s http://localhost/profile) fi if [ $(CUREENT_PROFILE) == real1 ] then IDLE_PROFILE=real2 else IDLE_PROFILE=real1 fi echo "${IDLE_PROFILE}" } # 쉬고 있는 profile의 port 찾기 function find_idel_port() { IDLE_PROFILE=$(find_idle_profile) if [ ${IDLE_PROFILE} == real1 ] then echo "8081" else echo "8082" fi }
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
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=springboot-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 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 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 }
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
ec2-user 1626 1 1 20:22 ? 00:00:17 java -jar -Dspring.config.location=classpath:/application.properties,classpath:/application-real1.properties,/home/ec2-user/app/application-oauth.properties,/home/ec2-user/app/application-real-db.properties -Dspring.profiles.active=real1 /home/ec2-user/app/step3/springboot-webservice-1.0.1-SNAPSHOT-20200330112116.jar ec2-user 1809 1 5 20:35 ? 00:00:15 java -jar -Dspring.config.location=classpath:/application.properties,classpath:/application-real2.properties,/home/ec2-user/app/application-oauth.properties,/home/ec2-user/app/application-real-db.properties -Dspring.profiles.active=real2 /home/ec2-user/app/step3/springboot-webservice-1.0.1-SNAPSHOT-20200330113405.jar