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 nginx
sudo service nginx start
Starting nginx : [Ok]
보안 그룹 추가
80
이다. 해당 포트 번호가 보안 그룹에 없으니 EC
-> 보안 그룹
-> EC2 보안 그룹 선택
-> 인바운드 편집
으로 차레로 이동해서 변경한다.리다이렉션 주소 추가
8080포트를 제거하고
접근해본다. 즉, 포트번호 없이 도메인만 입력해서 브라우저에서 접속해본다. 그럼 엔진엑스 웹페이지
를 볼 수 있다.엔진엑스와 스프링 부트 연동
sudo vim /etc/nginx/nginx.conf
location / {
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=jdbc
server.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.conf
location / 부분을 찾아서 다음과 같이 변경한다.
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/zip
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/app/step3/zip/
overwrite: yes
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
#!/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
fi
ABSDIR=$(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.inc
sudo service nginx reload
build.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로 혼자 구현하는 웹 서비스 - 이동욱