Chapter 10
24시간 365일 중단없는 서비스를 만들자
무중단 배포 소개
- 무중단 배포 : 서비스를 정지하지 않고, 배포할 수 있는 방법
대표적인 방법 : 엔진엑스, 도커, AWS에서 블루-그린
엔진엑스 설치와 스프링 부트 연동하기
- 엔진엑스 설치
- EC2에 엔진엑스 설치
sudo yum install nginx
- 엔진엑스 실행
- 보안 그룹 추가
엔진엑스의 포트번호는 기본적으로 80
EC2->보안 그룹->EC2 보안그룹 선택->인바운드 편집으로 이동해 변경
- 리다이렉션 주소 추가
구글과 네이버 로그인에도 변경된 주소를 등록 -> 기존의 리디렉션 주소에 8080 제거해 추가 등록
추가한 이후, EC2의 도메인으로 접근하되 8080 포트를 제거하고 접근해보기
- 엔진엑스와 스프링 부트 연동
엔진엑스가 현재 실행중인 스프링 부트 프로젝트를 바라볼 수 있도록 프록시 설정을 하기
먼저, 엔진엑스의 설정파일 열기
sudo vim /etc/nginx/nginx.conf
내용 중에서 server 아래의 location / 부분을 찾아 아래와 같이 추가
수정 이후, 엔진엑스를 아래 명령어로 재시작
sudo service nginx restart
이제 아까의 url로 이동하여 새로고침을 해보자.
무중단 배포 스크립트 만들기
- profile API 추가(배포시 8081, 8082를 쓸지의 기준이 됨)
ProfileController 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
package com.qwon.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);
}
}
|
테스트 코드 작성
ProfileControllerUnitTest 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
package com.qwon.springboot.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이_조회된다(){
//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);
}
}
|
이 /profile이인증 없이도 호출될 수 있게 SecurityConfig 클래스에 제외코드 추가
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
테스트 코드로 검증
ProfileControllerTest 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
package com.qwon.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.test.context.junit4.SpringRunner;
import static org.assertj.core.api.Assertions.assertThat;
@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로 접속해 확인해보자.
- real1, real2 profile 생성
현재 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.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 파일 생성
sudo vim /etc/nginx/conf.d/service-url.inc
파일 이동 후, 아래 내용 입력
해당 파일을 엔진엑스가 사용할 수 있도록 설정하기 위해 nginx.conf 파일 열기
sudo vim /etc/nginx/nginx.conf
저장 종료(:wq)이후 재시작
sudo service nginx restart
- 배포 스크립트들 작성
EC2에 step3 디렉토리 생성
mkdir ~/app/step3 && mkdir ~/app/step3/zip
무중단 배포는 앞으로 step3만 사용하려고 함
appspec.yml를 step3로 변경
무중단 배포를 진행할 스크립트
- stop.sh : 기존 엔진엑스에 연결x, 실행 중이던 스프링 부트 종료
- start.sh : 배포할 신규 버전을 stop.sh로 종료한 'profile'로 실행
- health.sh : 'start.sh'로 실행한 프로젝트가 정상적으로 실행됬는지 체크
- switch.sh : 엔진엑스가 바라보는 스프링 부트를 최신버전으로 변경
- profile.sh : 앞선 4개의 파일에서 공용으로 사용할 'profile'과 포트 체크 로직
appspec.yml 스크립트 수정
- profile.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
#! /usr/bin/env bash
function find_idle_profile()
{
RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}"
http://localhost/profile)
if [ ${RESPONSE_CODE} -ge 400 ]
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
}
|
- stop.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
#!/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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
#!/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 &
|
- health.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
#!/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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
#!/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 버전 변경
- CodeDeploy 로그로 확인
tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log
스프링 부트 로그로 확인하고 싶으면 아래 명령어
vim ~/app/step3/nohup.out
만약 2번 배포를 진행한 뒤에 다음과 같이 자바 애플리케이션 실행 여부를 확인
ps -ef | grep java
이제 이 시스템은 마스터 브랜치에 푸시에 발생하면 자동으로 서버 배포가 진행되고, 서버 중단 역시 전혀 없는 시스템이 되었다.
'언어 > SpringBoot' 카테고리의 다른 글
[JPA] 영속성 컨텍스트(EntityManager)와 엔티티의 생명주기 (0) | 2021.09.15 |
---|---|
[JPA] 다양한 연관관계 매핑 - 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M) (0) | 2021.08.17 |
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 9장 (0) | 2020.10.26 |
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 8장 (0) | 2020.10.23 |
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 7장 (0) | 2020.10.21 |