24시간 365일 중단 없는 서비스 만들기

김주영·2022년 7월 23일
0

[본 글은 프로젝트 과정을 기록할 목적으로 작성되었으며 아래 교재에 기반하여 작성됨]

🌱 무중단 배포 소개


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

이외에도 L4 스위치를 이용한 무중단 배포 방법도 있지만, L4가 워낙 고가의 장비이다 보니 대형 인터넷 기업 외에는 쓸 일이 거의 없다.

이번 챕터에서 진행할 방법은 엔진엑스(Nginx)를 이용한 무중단 배포다. 엔진엑스는 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어다.

엔진엑스가 가지고 있는 여러 기능 중 리버스 프록시가 있다. 리버스 프록시란 엔진엑스가 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위를 말한다. 즉, 요청을 받아 백엔드로 전달하는 중개자(프록시) 역할을 하는 것이다. 리버스 프록시 서버(엔진엑스)는 요청을 전달하고, 실제 요청에 대한 처리는 뒷단의 웹 애플리케이션 서버들이 처리한다.

이 리버스 프록시를 통해 무중단 배포 환경을 구축해 볼 예정이다. 엔진엑스를 이용하는 이유는 가장 저렴하고 쉽기 때문이다.

기존에 쓰던 EC2에 그대로 적용하면 되므로 배포를 위해 AWS EC2 인스턴스가 하나 더 필요하지 않다. 추가로 이 방식은 꼭 AWS와 같은 클라우드 인프라가 구축되어 있지 않아도 사용할 수 있는 범용적인 방법이다. 즉, 개인 서버 혹은 사내 서버에서도 동일한 방식으로 구축할 수 있으므로 사용처가 많다.

구조는 하나의 EC2 혹은 리눅스 서버에 엔진엑스 1대와 스프링 부트 Jar를 2대를 사용하는 것이다.

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

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

📝 운영 과정

  1. 사용자는 서비스 주소로 접속(80 or 443 port)

  2. 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청을 전달 (스프링 부트1 즉, 8081 포트로 요청을 전달한다고 가정)

  3. 스프링 부트2는 엔진엑스와 연결된 상태가 아니니 요청을 받지 못한다.

1.1 버전으로 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 스프링 부트2(8082 포트)로 배포한다.

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

📝 운영 과정

  1. 배포하는 동안에도 서비스는 중단되지 않는다.
    (엔진엑스는 스프링 부트1을 바라보기 때문)

  2. 배포가 끝나고 정상적으로 스프링 부트2가 구동 중인지 확인한다.

  3. 스프링 부트2가 정상 구동 중이면 nginx reload 명령어를 통해 8081 대신 8082를 바라보도록 한다.

  4. nginx reload는 0.1초 이내에 완료된다.

이후 1.2 버전 배포가 필요하면 이번에는 스프링 부트1로 배포한다.

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

📝 운영 과정

  1. 현재는 엔진엑스와 연결된 것이 스프링 부트2이다.

  2. 스프링 부트1의 배포가 끝났다면 엔진엑스가 스프링 부트1을 바라보도록 변경하고 nginx reload를 실행한다.

  3. 이후 요청부터는 엔진엑스가 스프링 부트 1로 요청을 전달한다.

✔ 무중단 배포 전체 시스템 구조

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


🌿 EC2에 nginx 설치

sudo yum install nginx (Amazon Linux1)

Amazon Linux2에서는 해당 명령어가 아닌 다음 명령어로 설치하라는 메시지가 나온다.

🔧 설치가 완료되었으면 다음 명령어로 엔진엑스를 실행

sudo service nginx [-y] start

다음 명령어를 통해 nginx의 상태를 보면 잘 실행되었음을 알 수 있다.

🌿 보안 그룹 추가

nginx의 포트번호를 보안 그룹에 추가하겠다. 엔진엑스의 포트번호는 기본적으로 80이다. 해당 포트가 EC2의 보안 그룹에 없으므로 변경하도록 하자

🔧 EC2의 보안 그룹의 인바운드 규칙에서 80 port를 열어준다.

🌿 리다이렉션 주소 추가

8080이 아닌 80포트로 주소가 변경되니 구글과 네이버 로그인에도 변경된 주소를 등록해야만 한다. 기존에 등록된 리디렉션 주소에서 8080 부분을 제거하여 추가 등록한다.

🔧 https://console.cloud.google.com/ 이동 후 [API 및 서비스 ➡ 사용자 인증 정보 ➡ 클라이언트 ID 클릭]

🔧 https://developers.naver.com/apps/#/myapps 이동 후 본인의 프로젝트로 이동하여 [API 설정 ➡ PC 웹]

Callback URL만 추가하면 된다.

🔧 추가한 후에는 EC2의 도메인으로 접근하되, 8080 포트를 제거하고 접근해 본다. 즉, 포트번호 없이 도메인만 입력해서 브라우저에서 접속한다.

🌿 nginx와 스프링 부트 연동

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

🔧 엔진엑스 설정 파일 open

sudo vim /etc/nginx/nginx.conf

🔧 설정 내용 중 server 아래의 location / 부분을 찾아서 다음과 같이 추가한다.

📢 proxy_pass

엔진엑스로 요청이 오면 http://localhost:8080로 전달한다.

📢 proxy_set_header XXX

실제 요청 데이터를 header의 각 항목에 할당한다.

ex) proxy_set_header X-Real_IP $remote_addr : Request Header의 X-Real-IP에 요청자의 IP를 저장한다.

🔧 저장 후 엔진엑스 재시작

sudo service nginx restart

nginx가 스프링 부트 프로젝트를 프록시하는 것이 확인된다!

🌱 무중단 배포 스크립트 만들기


무중단 배포 작업 전에 이후 배포 시 8081과 8082 포트 선택 기준이 되는 API를 추가하도록 한다.

🌿 ProfileController

package com.bbs.projects.bulletinboard.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; //active 상태 profile 집합

    @GetMapping("/profile")
    public String profile() {
    	//active 프로파일 추출
        List<String> profiles = Arrays.asList(env.getActiveProfiles());
        //배포용 profiles
        List<String> realProfiles = Arrays.asList("real", "real1", "real2");
        //default profile 지정
        String defaultProfile = profiles.isEmpty() ? "default" : profiles.get(0);

		//가장 먼저 찾은 "real"이 포함된 profile 반환
        return profiles.stream()
                .filter(realProfiles::contains)
                .findAny()
                .orElse(defaultProfile); //없을 경우, default profile 반환
    }
}

📢 profile

Spring Boot의 profile이란 Bean들의 묶음으로서 실 서버, 테스트 서버, 개발 서버 등 각각의 환경을 말한다.

ref : https://dbjh.tistory.com/31

📢 Environment

활성화된 profile 집합을 반환한다.

📢 env.getActiveProfiles()

현재 실행 중인 ActiveProfile을 모두 가져온다. 즉, real, oauth, real-db 등이 활성화되어 있다면(active) 3개가 모두 담겨 있다. 여기서 real, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록 한다. 실제로 무중단 배포에서는 real1과 real2만 사용되지만, step2를 다시 사용해볼 수도 있으니 real도 남겨둔다.

무중단 배포 전에는 real 환경에서 배포했다. real1과 real2는 nginx가 바라보는 profile과 새로운 배포용 profile로 나뉘게 되는 식으로 무중단 배포를 구현한다.

해당 코드가 잘 작동하는지 테스트 코드를 작성해보록 하겠다. 해당 컨트롤러는 특별히 스프링 환경이 필요하지는 않다. 그래서 @SpringBootTest 없이 테스트 코드를 작성한다.

🌿 ProfileControllerUnitTest

package com.bbs.projects.bulletinboard.web;

import org.junit.Test;
import org.springframework.mock.env.MockEnvironment;

import static org.assertj.core.api.Assertions.*;

public class ProfileControllerUnitTest {

    @Test
    public void read_real_profile() {

        //given
        String expectedProfile = "real";
        MockEnvironment env = new MockEnvironment(); //Environment 가짜 구현체
        //profile 추가
        env.addActiveProfile(expectedProfile);
        env.addActiveProfile("oauth");
        env.addActiveProfile("real-db");

        ProfileController controller = new ProfileController(env);

        //when
        String profile = controller.profile(); //필터링된 profile 추출

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

    }

    @Test
    public void read_first_if_not_real_profile() {
        //given
        String expectedProfile = "oauth";
        MockEnvironment env = new MockEnvironment(); //Environment 가짜 구현체

        //profile 추가
        env.addActiveProfile(expectedProfile);
        env.addActiveProfile("real-db");

        ProfileController controller = new ProfileController(env);

        //when
        String profile = controller.profile(); //필터링된 profile 추출

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

    @Test
    public void read_default_if_not_active_profile() {
        //given
        String expectedProfile = "default";
        MockEnvironment env = new MockEnvironment(); //Environment 가짜 구현체
        ProfileController controller = new ProfileController(env);

        //when
        String profile = controller.profile(); //필터링된 profile 추출
        
        //then
        assertThat(profile).isEqualTo(expectedProfile);

    }

}

📢 MockEnvironment

Environment는 인터페이스이기 때문에 테스트를 위해 구현체가 필요하다. 이를 위해 가짜 구현체인 MockEnvironment를 사용했다.

📝 (정리) 필터링 규칙

테스트 1.

  • real, oauth, real-db 프로파일이 모두 profiles에 담긴 상황에서 필터링 규칙대로 real 프로파일을 최우선으로 반환하는지 테스트한 것이다.

테스트 2.

  • real 프로파일 없이 oauth, real-db가 있는 상황에서 profiles.get(0) 명령대로 가장 먼저 추가된 profile이 추출되는지 테스트한 것이다.

테스트 3.

  • defaultProfile을 저장한 것처럼 활성화된 프로파일이 없을 경우 "default"라고 저장된 프로파일이 추출되는지 테스트한 것이다.

🔧 /profile이 인증 없이도 호출될 수 있게 SecurityConfig 클래스에 제외 코드를 추가한다.

.antMatchers("/", "/css/**", "/images/**", "/js/**", 
"/h2-console/**", "/profile").permitAll()

🌿 ProfileControllerTest

🔧 SecurityConfig 설정 테스트를 위해 @SpringBootTest를 사용하는 테스트 클래스를 하나 더 추가한다.

package com.bbs.projects.bulletinboard.web;

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.mock.env.MockEnvironment;
import org.springframework.test.context.junit4.SpringRunner;

import static org.assertj.core.api.Assertions.*;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProfileControllerTest {

    @LocalServerPort
    private int port;

    //필드 주입
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Test
    public void call_profile_without_certification() throws Exception{

        String expected = "default";
        //http 헤더 접근을 위해 ResponseEntity를 받음
        ResponseEntity<String> response = restTemplate.getForEntity("/profile", String.class);
        //HTTP 헤더 상태 검증
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        //HTTP 바디에 "default"가 있는지 검증
        assertThat(response.getBody()).isEqualTo(expected);

    }
    
}

SecurityConfig 설정에 "/profile" 을 포함했기 때문에 인증 없이 통과되었다면 profile의 기본 값인 "default"가 있어야 한다. 이를 검증하기 위해서는 HTTP 헤더&바디 접근이 필요하므로 TestRestTemplate를 주입받았다.

참고로 test 디렉토리의 application.properties에서 spring.profiles.include를 제거해야 한다. 그렇지 않으면 지정한 값이 default 대신 나오게 되므로 오류가 발생한다.

🔧 여기까지 모든 테스트가 성공했다면 깃허브로 푸시

참고로 putty를 통해 nginx를 실행하지 않으면 80 port가 아닌 8080 포트로 실행된다.

브라우저 테스트까지 정상적으로 마무리되었다! 👍

🌿 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

2개의 프로파일은 server.port가 8080이 아닌 8081과 8082로 되어 있다. 이 부분만 주의해서 생성하면 된다.

🔧 깃허브로 푸시

🌿 nginx 설정 수정

무중단 배포의 핵심은 엔진엑스 설정이다. 배포 때마다 엔진엑스의 프록시 설정(스프링 부트로 요청을 흘려보내는)이 순식간에 교체된다. 여기서 프록시 설정이 교체될 수 있도록 설정을 추가하겠다.

🔧 엔진엑스 설정이 모여있는 /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;

📢 service_url이라는 변수를 선언하여 오른쪽 url을 값으로 지정했다.

🔧 해당 파일을 엔진엑스가 사용할 수 있도록 설정

sudo vim /etc/nginx/nginx.conf

🔧 location / 부분을 찾아 다음과 같이 변경

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

nginx에게 해당 파일의 위치를 알림

📢 location /

특정 URL을 처리하는 방법을 정의

📢 proxy_pass $service_url;

현재 서버에 /로 시작하는 path로 접근하면 $service_url로 리다이렉션

🔧 저장한 뒤 재시작

sudo service nginx restart

EC2와 브라우저 모두 정상 작동했다.

🌿 배포 스크립트들 작성

🔧 먼저 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 : 기존 nginx에 연결되어 있진 않지만, 실행 중이던 스프링 부트 종료

  • start.sh : 배포할 신규 버전 스프링 부트 프로젝트를 stop.sh로 종료한 'profile'로 실행

  • health.sh : 'start.sh'로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크

  • switch.sh : nginx가 바라보는 스프링 부트를 최신 버전으로 변경

  • profile.sh : 앞선 4개 스크립트 파일에서 공용으로 사용할 'profile'과 포트 체크 로직

🔧 appspec.yml에 앞선 스크립트를 사용하도록 설정

...
hooks:
  AfterInstall:
    - location: stop.sh # nginx와 연결되어 있지 않은 스프링 부트를 종료
      timeout: 60
      runas: ec2-user
  ApplicationStart:
    - location: start.sh # nginx와 연결되어 있지 않은 Port로 새 버전의 스프링 부트를 시작
      timeout: 60
      runas: ec2-user
  ValidateService:
    - location: health.sh # 새 스프링 부트가 정상적으로 실행됐는지 확인
      timeout: 60
      runas: ec2-user

📢 AfterInstall

애플리케이션 구성, 파일 권한 변경

📢 ApplicationStart

중지된 서비스를 다시 시작

📢 ValidateService

배포가 성공적으로 완료되었는지 확인, 마지막 배포 수명 주기

수명 주기 이벤트 Hooks 목록
ref : https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/reference-appspec-file-structure-hooks.html#appspec-hooks-server

Jar 파일이 복사된 이후부터 차례로 앞선 스크립트들이 실행된다고 보면 된다.

🔧 이제 각 스크립트 파일들을 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 # 에러 발생 시, real2를 현재 profile로 보고
    # 에러가 없으면 요청 결과를 현재 profile로 한다.
    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
}

📢 $(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)

현재 엔진엑스가 바라보고 있는 스프링 부트가 정상적으로 수행 중인지 확인한다. 여기서 -s는 progress나 error 정보를 제외한다는 것이고, -o는 stdout이 아닌 파일로 출력한다는 옵션이다.

응답값은 HttpStatus로 받고, 정상이면 200, 오류가 발생한다면 400-503 사이로 발생하니 400 이상은 모두 예외로 보고 real2를 현재 profile로 사용한다.

http://localhost/profile로 요청하고 %{http_code}로 받은 응답 코드를 /dev/null에 출력한다.

📢 IDLE_PROFILE

nginx와 연결되지 않은 profile이다. 스프링 부트 프로젝트를 이 profile로 연결하기 위해 반환한다.

📢 echo "${IDLE_PROFILE}"

bash라는 스크립트는 값을 반환하는 기능이 없다. 그래서 제일 마지막 줄에 echo로 결과를 출력 후, 클라이언트에서 그 값을 잡아서 사용한다.

ex) $(find_idle_profile)

여기서 중간에 echo를 사용해선 안된다.

🌳 stop.sh

#!/usr/bin/env bash

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH) # stop.sh 경로
source ${ABSDIR}/profile.sh # import profile.sh

IDLE_PORT=$(find_idle_port) # idle port 값을 잡아온다.

echo "> $IDLE_PORT 에서 구동 중인 애플리케이션 pid 확인"
IDLE_PID=$(lsof -ti tcp:${IDLE_PORT}) # nginx와 연결되지 않은 포트를 사용하는 프로세스의 pid 추출

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

java로 보면 일종의 import 구문이다. 해당 코드로 인해 stop.sh에서도 profile.sh의 여러 function을 사용할 수 있게 된다.

현재 엔진엑스와 연결되어 있지 않은 Port 값을 갖고 와서 해당 Port를 사용하고 있는 프로세스를 IDLE_PID에 넣었다. 옵션은 -t(자세한 정보를 제외하고 pid만 출력), -i(모든 네트워크 포트 표시) 등을 사용했다.

ref : https://www.lesstif.com/system-admin/lsof-20776078.html

🌳 start.sh

#!/usr/bin/env bash

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH) # start.sh 경로
source ${ABSDIR}/profile.sh # import profile.sh

REPOSITORY=/home/ec2-user/app/step3 #step3 환경으로 변경
PROJECT_NAME=bulletinboard-webservice-2022

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 &

📢 -Dspring.config.location=
classpath:/application.properties,
classpath:/application-$IDLE_PROFILE.properties,
...

-Dspring.profiles.active=$IDLE_PROFILE

기존에 application-real.properties는 무중단 배포가 구현되지 않아 oauth, real-db를 제외하고 활성 상태에서 real 프로파일만 존재한다.

무중단 배포는 real1과 real2로 운영되고, 이 중 profile.sh 로직 결과인 IDLE_PROFILE을 갖고 와서 항상 엔진엑스와 연결되어 있지 않은 프로세스를 활성 상태로 만든다.

📝 classpath에 포함될 수 있는 profiles

  • application.properties

  • application-real1.properties

  • application-real2.properties

  • application-oauth.properties

  • application-real-db.properties

🌳 health.sh

#!/usr/bin/env bash

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH) # health.sh 경로
source ${ABSDIR}/profile.sh # import profile.sh
source ${ABSDIR}/switch.sh # import switch.sh

IDLE_PORT=$(find_idle_port) # 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) # "real"이 포함된 라인 수
  
  if [ ${UP_COUNT} -ge 1 ] # "real"이 존재한다면
  then # $up_count >= 1 ("real" 문자열이 있는지 검증)
    echo "> Health check 성공"
    switch_proxy # IDLE_PORT 로 포트 변경
    break
  else
    echo "> Health check의 응답을 알 수 없거나 혹은 실행 상태가 아닙니다."
    echo "> Health check: ${RESPONSE}"
  fi
  
  if [ ${RETRY_COUNT} -eq 10 ] # RETRY_COUNT 내에서 성공하지 못한 경우
  then
    echo "> Health check 실패"
    echo "> 엔진엑스에 연결하지 않고 배포를 종료합니다."
    exit 1
  fi
  
  echo "> Health check 연결 실패. 재시도..."
  sleep 10
done

📢 RESPONSE=$(curl -s http://localhost:${IDLE_PORT}/profile)

해당 URL로 요청 후 응답을 RESPONSE에 받는다.

📢 UP_COUNT=$(echo ${RESPONSE} | grep 'real' | wc -l)

wc는 파일 안의 단어 갯수 혹은 라인 수를 출력하는 명령어다. 옵션을 -l로 주었으므로 라인 수를 말한다. 즉, 서버 요청에 대한 응답 내용 중 "real"이라는 단어가 포함된 라인 수를 UP_COUNT로 한다.

start.sh로 실행한 프로세스가 잘 실행되었는지 확인하기 위해 서버에 IDLE 포트로 요청을 보내고 그 응답에 real 문자열이 있는지 확인한다. 해당 문자열을 찾으면 성공 메시지와 함께 프록시 설정을 변경하고 마무리한다.

🌳 switch.sh

#!/usr/bin/env bash

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH) # switch.sh 경로
source ${ABSDIR}/profile.sh # import profile.sh

function switch_proxy() {
    IDLE_PORT=$(find_idle_port) # idle port 값을 잡아온다.

    echo "> 전환할 Port: $IDLE_PORT"
    echo "> Port 전환"
    # /etc/nginx/conf.d/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"
    sudo service nginx reload # service-url.inc를 다시 불러옴
}

📢 echo "set \$service_url http://127.0.0.1:${IDLE_PORT};"

하나의 문장을 만들어 파이프라인(|)으로 넘겨주기 위해 echo를 사용한다. 앞서 가져온 IDLE_PORT를 갖고 엔진엑스가 변경할 프록시 주소를 생성한다.

📢 | sudo tee /etc/nginx/conf.d/service_url.inc

앞에서 넘겨준 문장을 service-url.inc에 덮어쓴다.

📢 sudo service nginx reload

엔진엑스 설정을 다시 불러온다. reload는 restart와는 다르다. restart는 잠시 끊기는 현상이 있지만, reload는 끊김 없이 다시 불러온다. 다만, 중요한 설정들은 반영되지 않으므로 restart를 사용해야 한다. 여기선 외부의 설정 파일인 service-url을 다시 불러오는 거라 reload로 가능하다.

🌱 무중단 배포 테스트


배포 테스트를 하기 전, 잦은 배포로 Jar 파일명이 겹칠 수 있는 문제를 해결하겠다. 매번 버전을 올리는 것이 귀찮으므로 자동으로 버전값이 변경될 수 있도록 조치하겠다.

🌿 build.gradle

version '1.0.4-SNAPSHOT'+new Date().format("yyyyMMddHHmmss")

new Date()로 빌드할 때마다 그 시간이 버전에 추가되도록 구성한다.

🔧 최종 코드를 깃허브로 푸시

🔧 배포가 자동으로 진행되면 CodeDeploy 로그로 잘 진행되는지 확인

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

🔧 스프링 부트 로그도 보고 싶다면 다음 명령어로 확인할 수 있다.

vim ~/app/step3/nohup.out

한 번 더 배포하면 그때는 real2로 배포된다. 이 과정에서 브라우저 새로고침을 해보면 전혀 중단 없는 것을 확인할 수 있다.

🔧 2번 배포를 진행한 뒤에 다음과 같이 자바 애플리케이션 실행 여부를 확인한다.

ps -ef | grep java

이제 이 시스템은 마스터 브랜치에 푸시가 발생하면 자동으로 서버 배포가 진행되고, 서버 중단 역시 전혀 없는 시스템이 되었다! 😊😊

이것으로 스프링 부트와 AWS로 구현하는 간단한 웹 게시판을 완성했다. 아직 부족한 부분이 많지만 웹 서비스의 중요한 기반이 되는 DB, 서버, 시큐리티, 클라우드 등을 간단하게라도 다뤄볼 수 있는 기회가 되어 굉장히 뜻깊은 경험을 했다고 생각한다.

마지막으로 저를 포함하여 웹 개발자를 희망하는 많은 사람들에게 큰 도움을 주신 "스프링 부트와 AWS로 혼자 구현하는 웹 서비스" 저자 이동욱 선생님께 깊은 감사의 인사를 드립니다. 🙏

0개의 댓글