Nginx를 통한 무중단 배포 구현

bluewind8791·2022년 5월 6일
0

Server

목록 보기
4/6
post-thumbnail

전체 아키텍쳐는 위와 같습니다.

  1. 기존에는 Nginx가 8081포트로 실행하고 있는 스프링 프로젝트를 바라보고 있습니다.
  2. 그 상태에서 현재 Nginx가 바라보고 있지 않은 8082포트로 배포를 진행합니다.
  3. 배포가 끝나면 Nginx는 새롭게 배포된 8082포트를 바라봅니다.

Github Actions CI + CodeDeploy로 CI/CD 구현하기에 이어서 진행하겠습니다.

Nginx 설치

AWS EC2 인스턴스의 리눅스 환경에서 설치하도록 하겠습니다.

sudo amazon-linux-extras install nginx1

설치가 완료되면 sudo service nginx start 으로 서비스를 실행합니다.

이 명령어로 service nginx.status status 해당 서비스가 잘 실행중인지 알 수 있습니다.

그리고 http://(탄력적-ip):80 로 접속하면 아래와 같이 Nginx의 기본화면을 볼 수 있습니다.
(이때, Nginx 는 80포트를 이용하기 때문에 EC2 인스턴스 보안 그룹에서 80을 열어둬야 합니다.)


Nginx 설정

이제 본격적으로 설정에 들어가보겠습니다.
Nginx의 설정파일인 nginx.conf 파일에 진입합니다.

sudo vim /etc/nginx/nginx.conf

아래와 같이 수정해줍니다.

추가되는 코드는 아래와 같습니다.

include /etc/nginx/conf.d/service-url.inc;

location / {
		proxy_pass $service_url;
        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;
}

위에서 include 했던 파일을 생성하겠습니다.
아래의 명령어로 해당 디렉토리에 파일을 생성한 뒤 해당 코드를 넣고 저장한뒤 닫습니다.

sudo vim /etc/nginx/conf.d/service-url.inc

위의 설정을 완료하고 sudo service nginx restart 으로 Nginx를 재시작해줍니다.
이제 Nginx의 설정은 끝입니다!


profile 설정

위의 아키텍쳐를 보시면 8081포트와 8082포트를 넘나드는것을 볼 수 있습니다. 현재 웹 프로젝트가 어떤 포트를 사용하고 있는지 알 수 있게 스프링 프로젝트로 넘어가서 설정해 주도록 하겠습니다.

application 설정

먼저 application.yml 에서 다음의 코드를 추가합니다.

spring:
  profiles:
    group:
      set1: set1
      set2: set2

스프링의 profile 그룹을 두 개 생성하였습니다.
그리고 같은 디렉토리에 application-set1.ymlapplication-set2.yml 를 다음과 같이 생성합니다.

  • application-set1.yml
spring:
  config:
    activate:
      on-profile: set1
server:
  port: 8081
  • application-set2.yml
spring:
  config:
    activate:
      on-profile: set2
server:
  port: 8082

이렇게 하면 기존의 application.yml 에는 set1 set2의 profile 설정의 공통 설정이 들어가며, set1을 실행하면 8081 포트로, set2로 실행하면 8082 포트로 실행됩니다.

해당 profile로 실행하는 방법은 아래와 같습니다.

  1. Visual Studio Code 에서 실행
    프로젝트 최상단의 .vscode > launch.json 파일에서 아래의 부분을 수정합니다.
{
    "configurations": [
        {
            ...
            "args": [
                "--spring.profiles.active=set1"
            ]
        }
    ]
}
  1. jar 파일로 실행

java -jar -Dspring.profiles.active=set1 ./JAR파일이름.jar

ProfileController 생성

이제 어떤 profile로 실행되고 있는지 간단하게 확인할 수 있는 controller를 생성하겠습니다.

package com.example.demo.controller;

import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.core.env.Environment;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class ProfileController {

    private final Environment env;

    // 실행중인 프로젝트의 Profile이 뭔지 확인할 수 있는 API
    @GetMapping("/profile")
    public String getProfile() {
        List<String> profile = Arrays.asList(env.getActiveProfiles());
        List<String> realProfiles = Arrays.asList("set1", "set2");
        String defaultProfile = profile.isEmpty() ? "default" : profile.get(0);

        return profile.stream()
                .filter(realProfiles::contains)
                .findAny()
                .orElse(defaultProfile);
    }
}

위와 같이 설정하고 /profile 로 진입하면 사용중인 해당 profile이 뜨게됩니다.


스크립트 파일 설정

이제 스프링 프로젝트의 스크립트 파일과 appspec.yml 파일을 설정하겠습니다.
먼저 appspec.yml 파일을 설정하겠습니다.

version: 0.0
os: linux
files:
  - source:  /
    destination: /home/ec2-user/app/step3/zip/
    overwrite: yes

permissions:
  - object: /
    pattern: "**"
    owner: ec2-user
    group: ec2-user

hooks:
  AfterInstall:
    - location: scripts/stop.sh
      timeout: 60
      runas: ec2-user
  ApplicationStart:
    - location: scripts/start.sh
      timeout: 60
      runas: ec2-user
  ValidateService:
    - location: scripts/health.sh
      timeout: 60
      runas: ec2-user
  • AfterInstall : 배포가 끝나면 아래 명령어를 실행
  • ApplicationStart : ApplicationStart 단계에서 해당 파일을 실행
  • ValidateService : 배포가 성공적으로 완료되었는지 확인

스크립트 파일을 이제 하나씩 생성해 보도록 하겠습니다.

  • profile.sh
#!/usr/bin/env bash

# 쉬고 있는 profile 찾기
# set1이 사용중이면 set2가 쉬고 있으며, 반대편 set1이 쉬고 있다.
function find_idle_profile()
{
  RESPONSE_CODE=$(sudo curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)  

  if [ ${RESPONSE_CODE} -ge 400 ] # 400 보다 크면 (즉, 40x/50x 에러 모두 포함)
  then
    CURRENT_PROFILE=set2
  else
    CURRENT_PROFILE=$(sudo curl -s http://localhost/profile)
  fi

  if [ ${CURRENT_PROFILE} == set1 ]
  then
    IDLE_PROFILE=set2 # Nginx와 연결되지 않은 profile
  else
    IDLE_PROFILE=set1
  fi

  # bash script는 return 기능이 없기 떄문에,
  # echo를 통해서 출력하면 이 값을 클라이언트가 사용할 수 있습니다.
  echo "${IDLE_PROFILE}"
}

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

  if [ ${IDLE_PROFILE} == set1 ]
  then
    echo "8081" # 여기도 마찬가지로 return 기능의 느낌
  else
    echo "8082"
  fi
}
  • stop.sh
#!/usr/bin/env bash

# 기존 Eginx엔스에 연결되어 있지 않지만, 실행 중이던 스프링 부트 종료
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH) # 현재 stop.sh가 속해있는 경로
source ${ABSDIR}/profile.sh # 해당 코드로 profile.sh 내의 함수 사용

IDLE_PORT=$(find_idle_port)

echo ">>> $IDLE_PORT 에서 구동중인 애플리케이션 PID 확인"
IDLE_PID=$(sudo lsof -ti tcp:${IDLE_PORT})

if [ -z ${IDLE_PID} ]
then
  echo ">>> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
else
  # # Nginx에 연결되어 있지는 않지만 현재 실행 중인 jar 를 Kill 합니다.
  echo ">>> kill -15 $IDLE_PID"
  kill -15 ${IDLE_PID}
  sleep 5
fi
  • start.sh
#!/usr/bin/env bash

# 배포할 신규 버전 프로젝트를 stop.sh로 종료한 profile로 실행
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh # 해당 코드로 profile.sh 내의 함수 사용

REPOSITORY=/home/ec2-user/app/step3

echo ">>> Build 파일 복사"
echo ">>> cp $REPOSITORY/zip/build/libs/*.jar $REPOSITORY/"

cp $REPOSITORY/zip/build/libs/*.jar $REPOSITORY/

echo ">>> 새 어플리케이션 배포"
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1)    # jar 이름 꺼내오기

echo ">>> JAR Name: $JAR_NAME"
echo ">>> $JAR_NAME 에 실행 권한 추가"
chmod +x $JAR_NAME

echo ">>> $JAR_NAME 실행"
IDLE_PROFILE=$(find_idle_profile)

# 위에서 보았던 것처럼 $IDLE_PROFILE에는 set1 or set2가 반환되는데
# 반환되는 properties를 실행한다는 뜻.
echo ">>> $JAR_NAME 를 profile=$IDLE_PROFILE 로 실행합니다."

nohup java -jar -Dspring.profiles.active=$IDLE_PROFILE $JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
  • switch.sh
#!/usr/bin/env bash

# Ngnix가 바라보는 스프링 부트를 최신 버전으로 변경
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를 통해서 나온 결과를 | 파이프라인을 통해서 service-url.inc에 덮어쓸 수 있습니다.
    echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc 
    echo ">>> Reload Nginx"
    sudo service nginx reload # Nginx reload를 합니다.
}
  • health.sh
#!/usr/bin/env bash

# start.sh로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크
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 문 10번 돌기
for RETRY_COUNT in {1..10}
do
  # 현재 문제 없이 잘 실행되고 있는 요청을 보내봅니다.
  RESPONSE=$(curl -s http://localhost:${IDLE_PORT}/profile)
  # 해당 결과의 줄 수를 숫자로 리턴합니다.
  UP_COUNT=$(echo ${RESPONSE} | grep 'set' | wc -l)

  if [ ${UP_COUNT} -ge 1 ]
  then # $up_count >= 1 ("set" 문자열이 있는지 검증)
      echo ">>> Health check 성공"
      switch_proxy # switch.sh 실행
      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

무중단 배포 테스트

이제 배포 테스트를 해보겠습니다.

CodeDeploy Agent는 아래의 로그파일에 로그가 생성되는데 아래의 명령어로 배포 과정을 확인할 수 있습니다.

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

로그도 문제 없고 profile도 성공적으로 바뀐것을 알 수 있습니다!


참조

  • 이동욱 저자의 <스프링부트와 AWS로 혼자 구현하는 웹 서비스>
profile
NO EFFORT, NO RESULTS

0개의 댓글