Web/CI, CD

무중단 배포

태윤2 2021. 8. 24. 00:10
  • CI/CD를 구축 하고 자동 배포시 새로운 Jar가 실행 되기 전까지 기존 Jar를 종료 시키기 때문에 서비스가 중단된다.

무중단배포 방식

  • AWS에서 블루 그린(Blue-Gren) 무중단 배포
  • 도커를 이용한 웹서비스 무중단 배포

엔진엑스(Nginx)를 이용한 무중단 배포

  1. 엔진엑스란?
    • 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어
    • 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 리버스 프록시
      • 리버스 프록시 서버(엔진엑스)는 요청을 전달하고 실제 요청에 대한 처리는 백단의 웹 애플리케이션 서버들이 처리
  2. 엔진엑스의 구조
    • EC2 혹은 리눅스 서버에 엔진엑스 1대와 스프링 부트 Jar를 2대 사용
      • 엔엑스는 80(http), 443(https) 포트를 할당
      • 스프링 부트1은 8081포트로 실행
      • 스프링 부트2는 8082포트로 실행 

엔진엑스 무중단 배포의 구조

  • 운영과정
    1. 사용자는 서비스 주소로 접속(80 혹은 443포트)
    2. 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청
      • 스프링부트1 즉, 8081 포트로 요청을 전달한다고 가정
    3. 스프링 부트2는 엔진엑스와 연결된 상태가 아니니 요청받지 못함
  • 1.1 버전으로 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 스프링부트2(8082 포트)로 배포

  1. 배포하는 동안에도 서비스는 중단 되지 않음
    • 엔진엑스는 스프링 부트1을 바라보기 때문
  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에 접속해 엔진엑스 설치
  • 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

location 부분을 찾아서 추가

  1. 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);
    }

}
  1. 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
}
  1. RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)
    • 현재 엔진엑스가 바라보고 있는 스프링 부트가 정상적으로 수행 중인지 확인
    • 응답값을 HttpStatus로 받음
    • 정상이면 200, 오류가 발생한다면 400~503 사이로 발생 400 이상은 모두 예외로 보고 real2를 현재 profile로 사용
  2. IDLE_PROFILE
    • 엔진엑스와 연결되지 않은 profile
    • 스프링 부트 프로젝트를 이 profile로 연결하기 위해 반환
  3. 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로 구현하는 웹 서비스