배포 자동화 환경을 구축했지만, 배포하는 동안 애플리케이션이 종료된다는 문제가 있다.
새로운 Jar가 실행되기 전까지는 기존 Jar를 종료시켜 놓기 때문에 서비스가 중단된다.
무중단 배포 방식
- AWS에서 블루 그린(Blue-Green) 무중단 배포
- 도커를 이용한 웹서비스 무중단 배포
엔진엑스(NGinx)를 이용해 무중단 배포를 해보겠다.
엔진엑스
웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어
엔진엑스의 기능들 중 리버스 프록시가 있다.
리버스 프록시
엔진엑스가 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위
이 리버스 프록시를 통해 무중단 배포 환경을 구축할 것이다.
엔진엑스의 구조
하나의 EC2 혹은 리눅스 서버
엔진엑스 1대
스프링 부트 Jar 2대
- 엔진엑스는 80(Http), 443(https) 포트를 할당한다.
- 스프링부트1은 8081 포트로 실행한다.
- 스프링부트2는 8082 포트로 실행한다.
1. 사용자는 서비스 주소로 접속(80 또는 443 포트)
2. 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청을 전달
새로운 버전으로 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 스프링부트2(8082)로 배포
1. 배포하는 동안에도 서비스는 중단되지 않음(현재 엔진엑스는 스프링부트1(8081)을 바라봄)
2. 배포가 끝나고 정상적으로 스프링 부트2가 구동 중인지 확인함
3. 스프링 부트2가 정상 구동 중이면 nginx reload 명령어를 통해 8081 대신 8082를 바라보도록 한다.
4. nginx reload는 0.1초 이내에 완료됨
이후 1.2 버전 배포가 필요하면 이번에는 스프링 부트1로 배포한다.
1. 현재 엔진엑스와 연결된 것은 스프링 부트2이다.
2. 스프링 부트1의 배포가 끝났다면 엔진엑스가 스프링 부트1을 바라보도록 변경하고 nginx reload를 실행한다.
3. 이후 요청부터는 엔진엑스가 스프링 부트1로 전달한다.
엔진엑스 설치
설치
EC2 접속
설치
sudo amazon-linux-extras install nginx1
실행
sudo service nginx start
보안 그룹 추가
EC2
리다이렉션 주소 추가
8080이 아니라 80으로 변경했으니 구글과 네이버 로그인에도 변경된 주소를 등록해야 한다.
기존에 등록된 리디렉션 주소에서 8080 부분을 제거하여 추가 등록한다.
이제 EC2 도메인에서 8080 포트를 제거하고 접속해 보자
그러면 아래와 같은 화면이 나온다.
엔진엑스와 스프링 부트 연동
엔진엑스가 현재 실행 중인 스프링 부트 프로젝트를 바라볼 수 있도록 프록시 설정을 하자
엔진엑스 설정 파일을 연다.
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
- 실제 요청 데이터를 header의 각 항목에 할당한다.
- 예시) proxy_set_header X-Real_IP $remote_addr; 은 Request Header의 X-Real-IP에 요청자의 IP를 저장한다.
이제 엔진엑스를 재시작하자
sudo service nginx restart
이제 브라우저로 접속하면 스프링 부트 프로젝트를 프록시하는 것을 볼 수 있다.
무중단 배포 스크립트 만들기
우선 API를 하나 추가한다. 이것은 이후 배포 시 8081을 쓸지 8082를 쓸지 판단하는 기준이 된다.
profile API 추가
ProfileController를 만들어 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개가 모두 담겨있다.
- 여기서 real, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록 했다.
- 실제로 이번 무중단 배포에서는 real1과 real2만 사용되지만, step2를 다시 사용해 볼 수도 있으니 real도 남겨둔다.
이 코드에 대한 테스트코드를 작성한다.
이 컨트롤러는 스프링 환경이 필요하지 않으니 @SpringBootTest 없이 테스트 코드를 작성한다.
package com.ewok.study.springboot.web;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
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 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를 사용해서 테스트한다.
이 /profile이 인증 없이도 호출될 수 있게 SecurityConfig 클래스에 제외 코드를 추가한다.
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
SecurityConfig 설정 테스트 코드를 작성한다. 이제 스프링 시큐리티 설정을 불러와야 하니 @SpringBootTest를 사용한다.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.junit.jupiter.SpringExtension;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(SpringExtension.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);
}
@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 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로 접속해서 profile이 잘 나오는지 확인한다.
real1, real2 profile 생성
src/main/resources 아래에 prifle 2개 real1, real2를 추가한다.
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
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.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.session.store-type=jdbc
참고로 스프링부트 3.0 부터는 Spring Session 스토리지 타입을 명시적으로 구성하는 설정이 중단되었다고 한다.
따라서 스프링부트 3.0부터는 아래 코드를 사용할 수 없다.
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;
저장하고 종료(:wq)한 뒤 엔진엑스가 사용할 수 있게 설정한다.
sudo vim /etc/nginx/nginx.conf
location / 부분을 찾아 아래와 같이 변경한다.
저장하고 종료한 뒤 재시작한다.
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
timeout: 60
runas: ec2-user
ValidateService:
- location: health.sh
timeout: 60
runas: ec2-user
Jar 파일이 복사된 이후부터 차례대로 스크립트들이 실행된다
이제 각 스크립트들을 scripts 디렉토리에 추가한다.
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
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) # stop.sh의 절대경로
ABSDIR=$(dirname $ABSPATH) # stop.sh의 절대 경로에 해당하는 directory 이름
source ${ABSDIR}/profile.sh # directory명/profile.sh -> profile.sh의 경로를 찾음. java의 import 같은 기능을 함.
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.profile.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 "> Nginx에 연결하지 않고 배포를 종료합니다."
exit 1
fi
echo "> Health Check 연결 실패. 재시도..."
sleep 10
done
- 엔진엑스와 연결되지 않은 포트로 스프링 부트가 잘 수행되었는지 체크
- 잘 떴는지 확인되어야 엔진엑스 프록시 설정을 변경한다.
- 엔진엑스 프록시 설정 변경은 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 "> Nginx Reload"
sudo service nginx reload
}
echo "set \$service_url http://127.0.0.1:${IDLE_PORT};"
- 하나의 문장을 만들어 파이프라인으로 남겨주기 위해 echo를 사용
- 엔진엑스가 변경할 프록시 주소를 생성
- "를 사용해야 함
- 사용하지 않으면 $service_url을 그대로 인식하지 못하고 변수를 찾게 된다.
이제 무중단 배포를 진행하자
무중단 배포 테스트
잦은 배포로 Jar파일명이 겹칠 수 있으니, 자동으로 버전값이 변경될 수 있도록 하자
build.gradle
version '1.0.1-SNAPSHOT-'+new Date().format("yyyyMMddHHmmss")
build.gradle은 Groovy 기반의 빌드툴이다.
여기서는 new Date()로 빌드할 때마다 그 시간이 버전에 추가되도록 구성함
여기서 한 가지 수정사항이 있다. 이 사항을 반영하지 않으니 제대로 작동이 안 되었다.
https://jojoldu.tistory.com/539
(2020.12.16) 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 최신 코드로 변경하기
작년 11월 말에 스프링 부트와 AWS로 혼자 구현하는 웹 서비스를 출판 하였습니다. Spring Boot가 2.1 -> 2.4로, IntelliJ IDEA가 2019 -> 2020으로 오면서 너무 많은 변화가 있다보니, 집필할 때와 비교해 실습
jojoldu.tistory.com
4. spring.profile.include를 보면 변경사항이 있다. 우선 나는
application.properties
spring.profiles.include=oauth
#spring.profiles.group.local-real=local-real, oauth
spring.profiles.group.real=real, real-db, oauth
spring.profiles.group.real1=real1, real-db, oauth
spring.profiles.group.real2=real2, real-db, oauth
application-real1.properties
server.port=8081
spring.profiles.include=real1,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=real2,oauth,real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.session.store-type=jdbc
이렇게 수정하였다. 아래 블로그를 참고했다.
스프링 부트와 AWS로 혼자 구현하는 웹서비스 후기 (2/2)
약 2주간의 여정이 끝이났다.스프링 시큐리티까지는 버전 간 차이가 크지 않아 진도에 영향이 크지 않았지만,본격적으로 AWS를 시작하면서부터 버전 간 차이로 인해 많이 막히기 시작했다.해결
velog.io
지금까지 구성한 최종 코드를 깃허브로 푸시한다.
배포가 성공되면 CodeDeploy 로그로 잘 진행되는지 확인해 보자
tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log
스프링 부트 로그도 보고 싶다면
vim ~/app/step3/nohup.out
한번 더 배포하면 real2로 배포된다. 배포 중에 브라우저 새로고침을 해보면 중단이 없는 것을 확인할 수 있다.
2번 배포를 진행해 보고 자바 애플리케이션 실행 여부를 확인해 보자
ps -ef | grep java
'SpringBoot > AWS' 카테고리의 다른 글
무중단 배포를 진행하며 겪은 에러 (0) | 2023.03.19 |
---|---|
CodeDeploy 로그 확인 (0) | 2023.03.18 |
Github Actions 배포 자동화 (0) | 2023.03.18 |
스프링 부트에서 작성하는 테스트 코드 (0) | 2023.03.14 |
스프링 부트 시작하기 (0) | 2023.03.14 |