본문 바로가기
Web/SpringBoot

10. 24시간 365일 중단없는 서비스 구축

by SeleniumBindingProtein 2022. 3. 15.
728x90
반응형

1. 무중단 배포

  • 서비스를 정지하지 않고 배포하는 방식
    • AWS에서 블루그린 무중단배포
    • 도커를 이용한 웹서비스 무중단 배포
  • 두 가지 방식과 L4스위치를 이용한 무중단 배포가 있음
    • L4는 고가의 장비이기 때문에 대형 기업 이외에는 쓸 일이 거의 없고, 엔진엑스를 이용한 무중단 배포를 할 것이며, 웹서버, 리버스, 프록스, 캐싱, 로드밸런싱, 미디어 스트리밍등을 위한 오픈소스 소프트웨어임
    • 아파치가 대세였던 자리를 빼앗은 가장 유명한 웹서버이자 오픈소스
  • 리버스프록시
    • 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위
    • 엔진엑스를 이용하는 이유는 가장 저렴하고 쉽기 때문임
  • EC2 혹은 리눅스 서버에 엔진엑스 1대와 스프링부트 jar 를 2대 사용하는 것
    • 엔진엑스는 80(http), 443(http) 포트를 할당
    • 스프링부트 1은 8081포트로 실행
    • 스프링부트 2는 8082포트로 실행
  • 엔진엑스 무중단 배포 1 구조
    • 운영과정
      1. 사용자는 서비스 주소로 접속
      2. 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청 전달
      3. 스프링부트 2는 엔진엑스와 연결된 상태가 아니니 요청 받지 못함

 

 

  • 엔진엑스 무중단 배포 2 구조
    1. 배포하는 동안에도 서비스는 중단되지 않음
    2. 배포가 끝나고 정상적으로 스프링부트2가 구동중인지 확인
    3. 스프링부트 2가 정상 구동 중이면 명령어를 통해 8081대신에 8082를 바라보게 함
    4. 명령어는 0.1초 이내에 완료됨

  • 엔진엑스 무중단 배포 3 구조
    1. 현재는 엔진엑스와 연결된 것이 스프링부트
    2. 스프링부트1의 배포가 끝난다면 엔진엑스가 스프링부트1을 바라보도록 변경하고 명령어를 실행
    3. 이후 요청부터는 엔진엑스가  스프링부트1로 요청을 전달

  • 무중단 배포 전체 구조

 

2. 엔진엑스 설치와 스프링 부트 연동

  • 가장먼저 EC2에 엔진엑스 설치
sudo amazon-linux-extras install -y nginx1
  • 설치 후 엔진엑스 실행
sudo service nginx start
  • Start nginx: [ok]문구를 확인

 

  • 보안그룹 추가
    • 엔진 엑스의 포트번호를 보안 그룹에 추가하고, 엔진엑스의 포트번호는 기본적으로 80
    • EC2 - 보안그룹 - EC2 보안그룹 선택 - 인바운드 편집 으로 이동
    • 80번 포트에 nginx를 추가

 

  • 리다이렉션 주소 추가
    • 8080이 아닌 80포트로 주소가 변경됨에 따라서 구글과 네이버에도 변경된 주소를 등록
    • 기존에 등록했던 8080포트를 제거하여 추가 등록
    • EC2의 도메인으로 접근하되, 8080포트를 제거하고 접근
    • 포트없이 도메인만 입력해서 브라우저에서 접속하고, 그러면 엔진엑스 웹 페이지가 나옴

 

  • 엔진엑스와 스프링 부트 연동
  • 프록시설정
sudo vim /etc/nginx/nginx.conf
  • 설정 내용 중 server아래 location /부분을 찾아서 다음 코드 추가
location / {
	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의 각 항목에 할당

 

  • 수정이 끝났으면 :wq 명령어로 저장하고 종료, 엔진엑스 재시작
sudo service nginx restart
  • 브라우저로 접속해서 엔진엑스 시작페이지가 보이면 새로고침하고, 이전에 스프링부트 웹 프로젝트 화면이 출력됨

 

3. 무중단 배포 스크립트 생성

  • API를 추가
  • 이 API는 배포 시 8081를 쓸지, 8082를 쓸지 판단하는 기준
  • ProfileController를 만들어 다음과 같이 입력
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);
    }
}
  • 작성한 코드가 잘 동작하는지 테스트 코드를 작성하고 해당 컨트롤러는 특별히 스프링 환경이 필요하지 않음
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);
    }
}
  • profileController나 Enviroment 모두 자바 클래스이기 때문에 쉽게 테스트 할 수 있음
  • profile이 인증 없이 호출될 수 있도록 SecurityConfig클래스에 제외 코드를 추가
 .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
  • 시큐리티 설정도 잘 되었는지 테스트 코드로 검증함
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로 접속해서 profile이 잘 나오는지 확인함

 

4. real1,real2, profile 생성

  • EC2 환경에서 실행되는 profile은 real밖에 없음
  • 해당 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

 

5. 엔진엑스 설정

  • /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 / 부분을 찾아 다음과 같이 변경
location / {
	proxy_pass $service_url;
  • 저장 후 종료하고, 엔진엑스를 재시작하여 반영
sudo service nginx restart

 

6. 배포 스크립트 작성

  • 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
  • 무중단 배포 스크립트
    • 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

 

  • script 폴더에 나머지 파일을 생성함
  • profile.sh
#!/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
}
  • 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=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
#!/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
}

 

 

7. 무중단 배포 테스트

  • 배포 테스트를 하기 전, 한가지 추가작업을 하며, 잦은 배포를 하면 jar파일 명이 겹치게 됨.
  • 매번 버전을 올릴 수 없으므로 자동으로 버전 값이 변경되도록 수정함
  • build.gradle
version '1.0.1-snapshot -'+new Date().format("yyyyMMddHHmmss")
  • CodeDeploy 로그로 잘 진행되는지 확인
tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log
  • 스프링 부트 로그는 아래에서 확인
vim ~/app/step3/nohup.out
  • 자바 애플리케이션 실행 여부 확인
ps -ef | grep java
728x90
반응형

댓글