Web/CI, CD
무중단 배포
태윤2
2021. 8. 24. 00:10
- CI/CD를 구축 하고 자동 배포시 새로운 Jar가 실행 되기 전까지 기존 Jar를 종료 시키기 때문에 서비스가 중단된다.
무중단배포 방식
- AWS에서 블루 그린(Blue-Gren) 무중단 배포
- 도커를 이용한 웹서비스 무중단 배포
엔진엑스(Nginx)를 이용한 무중단 배포
- 엔진엑스란?
- 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어
- 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 리버스 프록시
- 리버스 프록시 서버(엔진엑스)는 요청을 전달하고 실제 요청에 대한 처리는 백단의 웹 애플리케이션 서버들이 처리
- 엔진엑스의 구조
- EC2 혹은 리눅스 서버에 엔진엑스 1대와 스프링 부트 Jar를 2대 사용
- 엔엑스는 80(http), 443(https) 포트를 할당
- 스프링 부트1은 8081포트로 실행
- 스프링 부트2는 8082포트로 실행
- EC2 혹은 리눅스 서버에 엔진엑스 1대와 스프링 부트 Jar를 2대 사용
- 운영과정
- 사용자는 서비스 주소로 접속(80 혹은 443포트)
- 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청
- 스프링부트1 즉, 8081 포트로 요청을 전달한다고 가정
- 스프링 부트2는 엔진엑스와 연결된 상태가 아니니 요청받지 못함
- 1.1 버전으로 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 스프링부트2(8082 포트)로 배포
- 배포하는 동안에도 서비스는 중단 되지 않음
- 엔진엑스는 스프링 부트1을 바라보기 때문
- 배포가 끝나고 정상적으로 스프링 부트2가 구동 중인지 확인
- 스프링 부트2가 정상 구동 중이면 nginx reload 명령어를 통해 8081 대신 8082를 바라보도록 함
- nginx reload는 0.1초 이내 완료
- 이후 1.2 버전 배포가 필요하면 스프링 부트1로 배포
- 현재는 엔진엑스와 연결된 것이 스프링부트2임
- 스프링 부트1의 배포가 끝났다면 엔진엑스가 스프링 부트1을 바라보도록 변경 nginx reload를 실행
- 이후 요청부터는 엔진엑스가 스프링 부트 1로 요청
- 전체적인 시스템 구조
엔진엑스 설치와 스프링 부트 연동하기
- EC2에 접속해 엔진엑스 설치
- Amazon Linux 2에 yum을 통한 nginx 설치를 지원하지 않음
- amazon-linux-extras list 로 nginx 찾기
amazon-linux-extras list | grep nginx
- nginx 설치
sudo amazon-linux-extras install -y nginx1
- nginx 설치 확인
nginx -v
- nginx 실행
sudo service nginx start
# Redirecting to /bin/systemctl start nginx.service
- 보안 그룹 추가
- EC2->보안그룹->EC2 보안 그룹 선택 -> 인바운드 편집
- 리다이렉션 주소 추가
- 8080이 아닌 80포트로 주소가 변경되니 구글과 네이버 로그인에도 변경된 주소를 등록
- 포트 번호 없이 도메인 입력시 Nginx 웹페이지를 확인 할 수 있음
- 엔진엑스와 스프링 부트 연동
- 엔진엑스 설정파일 열기
sudo vim /etc/nginx/nginx.conf
- proxy_set_header XXX
- 실제 요청 데이터를 header의 각 항목에 할당
- 예) proxy_set_header X-Real-IP $remote_addr: Request Header의 X-Real-Ip에 요청자의 IP를 저장
- 엔진엑스 설정 추가
- /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;
- nginx.conf 파일 열기
sudo vim /etc/nginx/nginx.conf
- location / 부분을 찾아 다음과 같이 추가
무중단 배포 스크립트 만들기
- 무중단 배포 스크립트 작업 전에 API를 하나 추가
- 배포 시 8081을 쓸지, 8082를 쓸지 판단하는 기준
package com.example.springbootwebservice.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개가 모두 담겨 있음
- 여기서 real, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록 함
- 실제로 이번 무중단 배포에서는 real1과 real2만 사용 되지만, step2를 다시 사용해 볼 수도 있으니 real도 남김
- 테스트 코드 작성(컨트롤러 필요 x -> 스프링 환경 필요 x)
ProfileControllerUnitTest
package com.example.springbootwebservice.web;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
public class ProfileControllerUnitTest {
@Test
public void real_profile_search() {
//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_or_real1_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_or_default() {
//given
String expectedProfile = "default";
MockEnvironment env = new MockEnvironment();
ProfileController controller = new ProfileController(env);
//when
String profile = controller.profile();
//then
assertThat(profile).isEqualTo(expectedProfile);
}
}
ProfileControllerTest
package com.example.springbootwebservice.web;
import org.assertj.core.api.Assertions;
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.test.context.junit.jupiter.SpringExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class ProfileControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Test
public void profile_noOAuth() throws Exception{
String expected = "default";
ResponseEntity<String> response = restTemplate.getForEntity("/profile", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo(expected);
}
}
Security.config "/profile" permitAll()에 추가
real1, real2 profile 생성
- 무중단 배포를 위한 profile 2개(real1, real2)를 src/main/resources 아래에 추가
spring:
profiles:
include:
- oauth
- real-db
jpa:
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
session:
store-type: jdbc
server:
port: 8081 // real 1
8082 // real 2
- real1, real2 파일은 port만 다르게 설정
배포 스크립트들 작성
- step3 디렉토리 생성
mkdir ~/app/step3/ && mkdir ~/app/step3/zip
- 무중단 배포는 step3를 사용 appsepc.yml 역시 step3로 배포되도록 수정
무중단 배포를 진행할 스크립트들은 총 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 # 엔진엑스와 연결되어 있지 않은 Port 로 새 버전의 스프링 부트를 시작
timeout: 60
runas: ec2-user
ValidateService:
- location: health.sh # 새 스프링 부트가 정상적으로 실행됐는지 확인
timeout: 60
runas: ec2-user
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
ehco "8082"
fi
}
- RESPONSE_CODE=$(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)
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)
- 현재 stop.sh가 속해있는 경로를 찾음
- 하단의 코드와 같이 profile.sh의 경로를 찾기 위해 사용
- source ${ABSDIR}/profile.sh
- 자바로 보면 일종의 import 구문
- 해당 코드로 인해 stop.sh에서도 profile.sh의 여러 function을 사용할 수 있게 됨
- start.sh
#!/usr/bin/env bash
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR/profile.sh}
REPOSITORY=/home/ec2-user/app/step2
PROJECT_NAME=yoon-springboot2-webservice
echo "> Build 파일 복사"
echo "> $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.yml,classpath:/application-$IDLE_PROFILE.yml,/home/ec2-user/app/application-oauth.yml,/home/ec2-user/app/application-real-db.yml \
-Dspring.profiles.active=$IDLE_PROFILE \
$JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
- 기본적인 스크립트는 step2와 유사
- 다른점은 IDLE_PROFILE을 이용해 yml 파일을 가져오고 active profile을 지정하는것
- IDLE_PROFILE을 사용하기 위해 profile.sh를 가져와야함
- 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_proxy)함
- 엔진엑스 설정 변경은 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 "> 엔진엑스 Reload"
sudo service nginx reload
}
- echo "set \$service_url http://127.0.0.1:${IDLE_PORT};"
- 하나의 문장을 만들어 파이프라인(|)으로 넘겨주기 위해 echo를 사용
- 엔진엑스가 변경할 프록시 주소를 생성
- 쌍따옴표 (")를 사용
- 사용하지 않으면 $service_url을 그대로 인식하지 못하고 변수를 찾게됨
- | sudo tee /etc/nginx/conf.d/service-url.inc
- 앞에서 념겨준 문장을 service-url.inc에 덮어씀
- sudo service nginx reload
- 엔진엑스 설정을 다시 불러옴
- restart와는 다름
- restart는 잠시 끊기는 현상이 있지만, reload는 끊김 없이 다시 불러옴
- 다만, 중요한 설정들은 반영되지 않으므로 restart를 사용해야 함
- 여기서 외부의 설정 파일인 service-url을 다시 불러오는거라 reload로 가능
무중단 배포 테스트
- 자동으로 버전값이 변경될 수 있게 조치
version = '1.0.1-SNAPSHOT-' + new Date().format("yyyyMMddHHmmss")
- build.gradle은 Groovy 기반의 빌드툴
- Groovy 언어의 문법을 사용할 수 있음
푸시후 CodeDeploy 로그 확인
tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log
- reference
스프링부트와 AWS로 구현하는 웹 서비스