BOOK 2 - 스프링부트와 AWS로 혼자 구현하는 웹 서비스(9)

스프링부트와 AWS로 혼자 구현하는 웹 서비스 - 9

무중단 배포

  • 현재는 프로젝트 코드를 수정 후 다시 배포를 진행하게 되면 새로운 jar를 실행하기 전에 기존 jar를 종료하므로 서비스가 일시적으로 중단된다.
  • 배포를 할 때마다 서비스가 종료되는 것은 위험하고 사용성에도 좋지 않으므로 Nginx를 이용한 무중단 배포를 구현하도록 한다.

Nginx

  • Nginx는 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어이다.

  • reverse proxy : 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위

    • reverse proxy server는 요청을 전달하고, 실제 요청에 대한 처리는 뒷단의 WAS가 처리한다.
  • 하나의 EC2 서버에 Nginx 1대와 스트링부트 Jar 2대를 사용하여 무중단 배포 구조를 만들 수 있다.

    • 엔진엑스는 80(http), 443(https) 포트를 할당한다.
    • 스프링부트는 8081, 8082 포트를 할당하여 각각 실행한다.

    구조1

    • 위 그림에서 8082 포트의 스프링부트는 엔진엑스와 연결된 상태가 아니므로 요청을 받지 못한다.
    • 다음 버전의 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 8082 포트로 배포한다.
      • 배포하는 동안에도 엔진엑스는 8081 포트와 연결되어 요청을 보내고 있으므로 서비스가 중단되지 않는다.
      • 8082 포트의 스프링 부트가 정상적으로 배포되고 구동되는지 확인되면 엔진엑스가 8082 포트의 스프링부트와 연결되도록 한다.
      • nginx reload는 0.1초 이내에 완료된다.

Nginx 설치

  • EC2 인스턴스에 접속 후 다음 명령어로 엔진엑스를 설치 후 실행한다.

    sudo yum install nginx
    
    sudo service nginx start
    

Nginx와 SpringBoot 연동

보안 그룹 추가

  • 엔진엑스의 포트번호는 기본적으로 80이다.
  • AWS EC2 보안그룹에 80 포트를 추가한다.

리다이렉션 URI 추가

  • 네이버, 구글 OAuth 콘솔에서 리다이렉션 URI를 수정한다.
  • EC2의 도메인으로 접근하되, 8080 포트를 제거하고 접근하도록 8080 포트번호를 제거해준다.

기존의 EC2 도메인 주소를 입력하여 브라우저에서 접속하면 다음과 같은 엔진엑스 페이지를 볼 수 있다.

image

proxy 설정

  • 엔진엑스가 현재 실행 중인 스프링 부트 프로젝트를 바라볼 수 있도록 프록시 설정을 해야 한다.

  • 설정은 엔진엑스 설정 파일을 통해 한다.

    sudo vim /etc/nginx/nginx.conf
    
    • server 항목의 location 부분의 설정을 추가한다.

      image

      • proxy_pass : nginx로 요청이 오면 http://localhost:8080 으로 전달한다.
      • proxy_set_header : 실제 요청 데이터를 header의 각 항목에 할당한다.
  • 설정 파일 수정 후 엔진엑스를 재시작한다.

    sudo service nginx restart
    

SpringBoot 프로젝트에 profile 추가

Profile API 생성

  • 배포시에 8081 포트를 쓸지, 8082 포트를 쓸지 판단하는 기준이 되는 API를 추가한다.
ProfileController
@RequiredArgsConstructor
@RestController
public class ProfileController {

    private final Environment env;

    @GetMapping("/profile")
    public String profile() {
        List<String> profiles = Arrays.asList(env.getActiveProfiles());
        List<String> prodProfiles = Arrays.asList("production", "production1", "production2");

        String defaultProfile = profiles.isEmpty()? "default" : profiles.get(0);

        return profiles.stream()
          .filter(prodProfiles::contains)
          .findAny()
          .orElse(defaultProfile);
    }
}
  • env.getActiveProfiles()
    • 현재 실행 중인 ActiveProfile을 모두 가져온다.
    • 즉, production , oauth , prod-db 등이 활성화 되어 있다면 3개 모두 리스트에 담겨 있게 된다.
    • production , production1 , production2 는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록 한다.
ProfileControllerTest
public class ProfileControllerTest {

    @Test
    public void get_production_profile() {
        // given
        String expectedProfile = "production";
        MockEnvironment env = new MockEnvironment();
        env.addActiveProfile(expectedProfile);
        env.addActiveProfile("oauth");
        env.addActiveProfile("prod-db");

        ProfileController controller = new ProfileController(env);

        // when
        String profile = controller.profile();

        // then
        assertThat(profile).isEqualTo(expectedProfile);
    }

    @Test
    public void get_first_one_when_theres_no_production_profile() {
        // given
        String expectedProfile = "oauth";
        MockEnvironment env = new MockEnvironment();
        env.addActiveProfile(expectedProfile);
        env.addActiveProfile("prod-db");

        ProfileController controller = new ProfileController(env);

        // when
        String profile = controller.profile();

        // then
        assertThat(profile).isEqualTo(expectedProfile);
    }

    @Test
    public void get_default_when_theres_no_active_profile() {
        // 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("/", "/images/**", "/profile").permitAll()
  • 해당 변경 사항을 github에 push 하고 배포된 이후에 /profile 주소로 API를 요청하면 “production” 결과값이 나온다.

production1 , production2 프로필 생성

application-production1.properties
server.port=8081
spring.profiles.include=oauth,prod-db
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
spring.session.store-type=jdbc
application-production2.properties
server.port=8082
spring.profiles.include=oauth,prod-db
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
spring.session.store-type=jdbc
  • 두 파일의 내용은 모두 동일하지만 server.port 의 값만 다르다.

Nginx 설정

  • 배포 시마다 엔진엑스의 프록시 설정이 교체될 수 있도록 설정을 추가한다.

  • 엔진엑스의 설정이 모여있는 /etc/nginx/conf.dservice-url.inc 라는 파일을 생성한다.

    sudo vim /etc/nginx/conf.d/service-url.inc
    
    • 해당 파일에 다음 내용을 추가한다.

      set $service_url http://127.0.0.1:8080;
      
    • 해당 파일을 엔진엑스가 사용할 수 있도록 설정한다.

      sudo vim /etc/nginx/nginx.conf
      
      • location / 부분을 다음과 같이 변경한다.
      include /etc/nginx/conf.d/service-url.inc;
          
      location / {
      	proxy_pass $service_url;
      	...
      }
      
      • 설정 변경 후 엔진엑스를 재시작한다.

배포 스크립트

  • EC2에 배포 스크립트를 저장할 step3 디렉토리를 생성한다.

    mkdir ~/app/step3 && mkdir ~/app/step3/zip
    
  • CodeDeploy도 step3 디렉토리를 사용하도록 appspec.yml 파일을 수정한다.

    version: 0.0
    os: linux
    files:
      - source: /
        destination: /home/ec2-user/app/step3/zip/
        overwrite: yes
    ...
    
  • 무중단 배포를 위한 스크립트는 다음과 같이 5개가 있다.
    • stop.sh : 기존 nginx에 연결되어 있진 않지만 실행중이던 스프링 부트 종료
    • start.sh : 배포할 신규 버전 스프링 부트 프로젝트를 stop.sh 로 종료한 profile로 실행
    • health.sh : start.sh 로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크
    • switch.sh : nginx가 바라보는 스프링 부트를 최신 버전으로 변경
    • profile.sh : 앞선 4개 스크립트 파일에서 공용으로 사용할 profile과 포트 체크 로직
  • appspec.yml 에서 위의 스크립트를 사용하도록 설정한다.

    hooks:
      AfterInstall:
        - location: stop.sh
          timeout: 60
          runas: ec2-user
      ApplicationStart:
        - location: start.sh
          timeout: 60
          runas: ec2-user
      ValidateService:
        - location: health.sh
          timeout: 60
          runas: ec2-user
    

profile.sh

#! /usr/bin/env bash

# 쉬고 있는 profile 찾기 : production1이 사용 중이면 production2가 쉬고 있고, 반대면 production1이 쉬고 있음
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=production2
    else
      CURRENT_PROFILE=$(curl -s http://localhost/profile)
    fi

    if [ ${CURRENT_PROFILE} == production1 ]
    then
      IDLE_PROFILE=production2
    else
      IDLE_PROFILE=production1
    fi

    echo "${IDLE_PROFILE}"
}

# 쉬고 있는 profile의 port 찾기
function find_idle_port() {
    IDLE_PROFILE=$(find_idle_profile)

    if [ ${IDLE_PROFILE} == production1 ]
    then
      echo "8081"
    else
      echo "8082"
    fi
}
  • $(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)
    • 현재 nginx가 바라보고 있는 스프링부트가 정상적으로 수행 중인지 확인하기 위해 curl 요청을 보낸다.
    • 응답값을 HttpStatus로 받는다.
    • 정상이면 200, 오류가 발생한다면 400~503 사이로 응답값이 발생하므로 400 이상은 모두 예외로 보고 production2 를 현재 프로필로 사용한다.
  • IDLE_PROFILE
    • 현재 nginx와 연결되지 않은 profile이다.
    • 스프링부트 프로젝트를 이 프로필로 실행시키기 위해 반환한다.
  • echo "${IDLE_PROFILE}"
    • bash script는 값을 반환하는 기능이 없기 때문에 echo 로 결과값을 출력한 후 클라이언트에서 그 값을 잡아서 사용하도록 한다.
    • echo 는 제일 마지막 줄에 선언해야 한다.

stop.sh

#! /usr/bin/env bash

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh

IDLE_PORT=$(find_idle_port)

echo "> Check Application PID Running on $IDLE_PORT"
IDLE_PID=$(lsof -ti tcp:${IDLE_PORT})

if [ -z ${IDLE_PID} ]
then
  echo "> There's no running application."
else
  echo "> Kill $IDLE_PID"
  kill -15 ${IDLE_PID}
  sleep 5
fi 
  • ABSDIR=$(dirname $ABSPATH)
    • 현재 stop.sh 파일이 속해있는 경로를 찾는다.
    • profile.sh 의 경로를 찾기 위해 사용된다.
  • source ${ABSDIR}/profile.sh : 일종의 import 구문으로, 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/step3
PROJECT_NAME=all-review

echo "> Copy Build File"
cp $REPOSITORY/zip/*.jar $REPOSITORY/

echo "> New Application Deployment"
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1 | awk -F'/' '{print $6}')

echo "> Jar Name: $JAR_NAME"

chmod +x $JAR_NAME

IDLE_PROFILE=$(find_idle_profile)

echo "> Execute $JAR_NAME by $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-prod-db.properties \-Dspring.profiles.active=$IDLE_PROFILE \/home/ec2-user/app/step3/$JAR_NAME > /home/ec2-user/app/step3/nohup.out 2>&1 &
  • deploy.sh 와 내용은 거의 유사하고, IDLE_PROFILE 을 통해 properties 파일을 가져오고 active profile을 지정하는 것이 다르다.

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"
sleep 10

for RETRY_COUNT in {1..10}
do
  RESPONSE=$(curl -s http://localhost:${IDLE_PORT}/profile)
  UP_COUNT=$(echo ${RESPONSE} | grep 'production' | wc -l)

  if [ ${UP_COUNT} -ge 1 ]
  then
    echo "> Health Check Success"
    switch_proxy
    break
  else
    echo "> Health Check does not response or not running"
    echo "> Health Check: ${RESPONSE}"
  fi

  if [ ${RETRY_COUNT} -eq 10 ]
  then
    echo "> Health Check Fail"
    echo "> Terminate deployment without connecting Nginx"
    exit 1
  fi 
  
  echo "> Retry Health Check"
  sleep 10
done
  • Nginx와 연결되지 않은 포트에서 새로운 스프링부트 프로젝트가 잘 구동되는지 체크한다.
  • 잘 구동되고 있으면(RESPONSE 값에 “production” 문자열이 있으면) nginx 프록시 설정을 변경한다.
  • 10번의 재시도 후에도 새로운 어플리케이션이 제대로 구동되지 않았다면 엔진엑스에 연결하지 않고 배포를 종료한다.

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 "> Switch Port: $IDLE_PORT"
  echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc
  
  echo "> Nginx Reload"
  sudo service nginx reload
}
  • echo "set \$service_url http://127.0.0.1:${IDLE_PORT};"
    • 하나의 문장을 만들어 파이프라인으로 넘겨주기 위해 echo 를 사용한다.
    • nginx가 switch할 프록시 주소를 생성한다.
  • | sudo tee /etc/nginx/conf.d/service-url.inc
    • 앞에서 파이프라인으로 넘겨준 문장을 service-url.inc 파일에 덮어쓴다.
  • sudo service nginx reload
    • nginx 설정을 다시 불러온다.
    • restart 는 잠시 끊기는 현상이 있지만, reload 는 끊김 없이 다시 불러온다는 점에서 차이가 있다.
    • 중요한 설정들은 reload 로 반영되지 않으므로 restart 를 사용해야 한다.

무중단 배포 로그 확인

  • CodeDeploy 로그는 다음 명령어로 확인할 수 있다.

    tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log
    

    image

  • nohup.out 파일로 application 로그도 확인할 수 있다.

    tail -f ~/app/step3/nohup.out
    

    image

    • 스프링부트 실행 로그에서 8081 포트로 서버가 구동중인 것을 확인할 수 있다.
  • 자바 어플리케이션 실행 여부 확인

    ps -ef | grep java