Spring Boot

[GitHub Actions + Spring Boot + Nginx + Slack Notification] 무중단 배포 CI/CD 구축하기

수수한개발자 2023. 11. 22.
728x90

 

우리가 로컬에서 개발하고 이제 실제 운영환경에 배포해야 할 상황이 온다면 어떻게 해야 할까?

일단 앱을 테스트하고 빌드하고 패키징 된 jar파일을 배포를 해야 된다.

이러한 과정을 파이프라인으로 관리하며 자동화할 수 있다면 얼마나 편할까 라는 생각에서 시작하여 글을 작성하게 되었습니다.

 

모든 코드는 GitHub에 있습니다.

 

1. CI/CD 란 무엇인가?

CI/CD는 앱 개발 과정에서 자동화를 도입하여 고객에게 앱을 자주 제공하는 방법이다.

주요 개념은 지속적인 통합, 지속적인 전달 및 지속적인 배포입니다.

CI/CD는 통합 및 테스트 단계부터 제공 및 배포에 이르기까지 앱 수명주기를 전반에 걸쳐 지속적인 자동화 및 모니터링을 합니다. 이러한 연결된 방식을 종합하면 CI/CD 파이프라인이라고도 합니다.

 

 

2. GitHub Action

깃허브에서 제공하는 CI/CD DevOps 파이프라인 자동화 플랫폼이다.

소프트웨어 개발 및 배포 과정 자동화를 목적으로 제공되는 서비스.

젠킨스와 고민을 했지만 젠킨스는 별도의 서버등 리소스가 더 들어가기 때문에 작은 프로젝트라면

깃허브와 정말 통합된 구성인 깃허브 액션을 사용하는 것이 더 좋아 보인다.

 

 

 

3. 무중단 배포

배포를 하게 되면 배포하는 시간 동안 애플리케이션은 종료가 됩니다.

잠깐이나마 새로운 서버를 실행하는 동안 서버가 내려가 있게 되고 사용자의 안 좋은 경험으로 이어질 수 있습니다.

또한 새로운 서버의 치명적인 오류가 있다면 롤백 또한 똑같은 상황이겠지요,,

무중단 배포에는 여러 가지 방법이 있지만 Nginx를 사용해 Blue-Green 배포 전략을 선택하였습니다.

Nginx를 사용하면 리버스 프락시를 사용할 수 있어서 Blue-Green 배포를 더욱 쉽게 구성할 수 있습니다.

 

 

3.1 무중단 배포 전체 구조

 

 

구조는 다음과 같습니다.

사용자가 깃허브 저장소에 푸시를 하거나 Pull Request를 올린다고 했을 때 깃허브 액션의 워크플로가 이벤트를 감지하고 수행하게 됩니다.

이때 각각의 일 단위(job)를 구성할 수 있으며 각 job마다 test 및 build 등등에 대한 알림 또한 보내게 됩니다.

빌드가 완료되면 ec2 서버에 접속합니다.

ec2에는 Nginx가 80 혹은 443 포트를 사용하여 스프링 부트 서버로 요청을 보내줍니다.

기존 버전은 8080으로 서버가 띄워져 있고 새로운 버전을 8081로 띄웁니다.

엔진엑스의 리버스 프락시를 8080 -> 8081로 할당합니다.

만약 새로운 서버에 문제가 생기면 기존 서버를 사용하게 놔두게 됩니다.

 

 

4. 무중단 배포 구축하기

글 작성 환경

  • 자바 17
  • 스프링 부트 3.1.3
  • 노드 21.1.0
  • EC2 프리티어 Ubuntu 22
  • Nginx 1.18.0

 

순서

  • EC2 설정
    • Java 17 설치
    • Nginx 설치
      • Nginx 설정
    • 쉘 스크립트 작성
      • deploy.sh 작성
      • switch.sh 작성
      • execute-deploy.sh 작성
  • GitHub Action 설정
    • yml 작성
    • secrets 사용 방법
    • slack 알림 사용 방법
  • 결과

 

EC2 설정

 

4-1. Java 17 설치하기

sudo apt install openjdk-17-jdk

 

 

Nginx 설치하기

4-2. Nginx 설치

sudo apt install nginx

 

 

4-2-1. Nginx 설정

기본적으로 Nginx를 설치하게 되면 /etc/nginx 경로에 파일들이 생기게 됩니다.

 

cd /etc/nginx
ls -al

 

여기서 nginx.conf 파일을 수정해줘야 합니다.

 

4-2-2. nginx.conf

sudo vi /etc/nginx/nginx.conf

 

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
	worker_connections 768;
	# multi_accept on;
}

http {

	##
	# Basic Settings
	##

	sendfile on;
	tcp_nopush on;
	types_hash_max_size 2048;

	include /etc/nginx/mime.types;
	default_type application/octet-stream;

	##
	# SSL Settings
	##

	ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
	ssl_prefer_server_ciphers on;


	access_log /var/log/nginx/access.log;
	error_log /var/log/nginx/error.log;

	##
	# Gzip Settings
	##

	gzip on;

	server {
    	listen       80;
    	server_name  localhost;

        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;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

 

4-2-3.  service-url.inc

vi /etc/nginx/conf.d/service-url.inc

 

set $service_url http://127.0.0.1:8080;

 

참고로 vi로 파일을 편집하실 때는 i (INSERT)를 누르고 수정한 후 수정이 완료되면 esc를 누르고 :wq! 를 쳐서 저장하고 나가면 됩니다.

 

4-3. 쉘 스크립트 작성

 

4-3-1. deploy.sh

deploy.sh는 깃허브 액션에서 빌드된 jar를 받은 후에 실행되는 쉘 스크립트로서

현재 Nginx와 연결되어 있는 포트를 확인한 뒤 다른 포트로 스프링 부트 서버를 실행하는 쉘 스크립트입니다.

#!/bin/bash
# (1) 스크립트에 필요한 변수 값 할당
BASE_PATH=/home/ubuntu/app/nonstop
BUILD_PATH=$(ls $BASE_PATH/base_jar/*.jar)
JAR_NAME=$(basename $BUILD_PATH)
echo "> build 파일명: $JAR_NAME"

# (2) 빌드 된 jar 파일 jar 디렉토리로 복사
echo "> build 파일 복사"
#DEPLOY_PATH=$BASE_PATH/jar/
DEPLOY_PATH=$BASE_PATH/deploy/jar/
cp $BUILD_PATH $DEPLOY_PATH

## (3) 현재 구동 중인 포트 확인
echo "> 현재 구동중인 포트 확인"
CURRENT_PORT=$(curl -s http://localhost/health | egrep -o "[0-9]+" | tail -1)
echo "> Current port of running WAS is ${CURRENT_PORT}."

# (4) Nginx에 연결되어 있지 않은 Port 찾기
if [ ${CURRENT_PORT} -eq 8080 ];
then
  IDLE_PORT=8081 # 현재포트가 8080이면 8081로 배포
elif [ ${CURRENT_PORT} -eq 8081 ];
then
  IDLE_PORT=8080 # 현재포트가 8081라면 8080로 배포
else
  echo "> 일치하는 Port가 없습니다. Port: $CURRENT_PORT"
  IDLE_PORT=8080
fi
echo "> Port를 할당합니다. IDLE_PORT: $IDLE_PORT"

## (5) 미연결된 jar로 신규 jar 심볼릭 링크
echo "> application.jar 교체"
IDLE_APPLICATION=board-$IDLE_PORT.jar
IDLE_APPLICATION_PATH=$DEPLOY_PATH$IDLE_APPLICATION
echo "> IDLE_APPLICATION_PATH: $IDLE_APPLICATION_PATH"

if test -f "$IDLE_APPLICATION_PATH";
then
    echo "> $IDLE_APPLICATION_PATH exists."
else
    echo "> $IDLE_APPLICATION_PATH does not exist."
fi

cp $DEPLOY_PATH$JAR_NAME $IDLE_APPLICATION_PATH

## (6) Nginx와 연결되지 않은 Profile을 종료
echo "> $IDLE_PORT 에서 구동중인 애플리케이션 pid 확인"
IDLE_PID=$(pgrep -f $IDLE_APPLICATION)
echo "> $IDLE_PID "

if [ -z $IDLE_PID ]
then
  echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
else
  echo "> kill -15 $IDLE_PID"
  kill -15 $IDLE_PID
  sleep 5
fi

### (7) (6)의 Profile로 Jar 실행
echo "> $IDLE_PORT 배포"
nohup java -jar -Dspring.profiles.active=dev -Dserver.port=$IDLE_PORT $IDLE_APPLICATION_PATH &

echo "> $IDLE_PORT 10초 후 Health check 시작"
echo "> curl -s http://localhost:$IDLE_PORT/management/health_check "
sleep 10

## (8) 아래 코드를 10회 반복 수행
# do : /health 요청 결과 저장
# if : "UP"이 문자열로 있는지 확인해서 있다면 for문 종료 없다면 메시지 출력후 아래 코드 실행
# else: 10회 다 실행될 동안 안됐다면 스크립트 종료
for retry_count in {1..10}
do
  response=$(curl -s http://localhost:$IDLE_PORT/management/health_check)
  up_count=$(echo $response | grep 'UP' | wc -l)

  if [ $up_count -ge 1 ]
  then # $up_count >= 1 ("UP" 문자열이 있는지 검증)
      echo "> Health check 성공"
      break
  else
      echo "> Health check의 응답을 알 수 없거나 혹은 status가 UP이 아닙니다."
      echo "> Health check: ${response}"
  fi

  if [ $retry_count -eq 10 ]
  then
    echo "> Health check 실패. "
    echo "> Nginx에 연결하지 않고 배포를 종료합니다."
    exit 1
  fi

  echo "> Health check 연결 실패. 재시도..."
  sleep 10
done

echo "> 스위칭"
sleep 10
/home/ubuntu/app/nonstop/deploy/scripts/switch.sh

 

 

4-3-2. switch.sh

switch.sh는 위에서 deploy.sh로 새로운 포트로 실행된 스프링 부트 서버로 Nginx 리버스 프락시를 연결해 주기 위한 쉘 스크립트입니다.

 

#!/bin/bash
# (1) 현재 구동중인 스프링부트 서버 포트 확인
echo "> 현재 구동중인 Port 확인"
CURRENT_PORT=$(curl -s http://localhost/health | egrep -o "[0-9]+" | tail -1)

# (2) (1)에서 확인한 포트 정보 확인
echo "> Nginx에 연결되어 있지 않은 Port 찾기"
if [ ${CURRENT_PORT} -eq 8080 ];
then
  IDLE_PORT=8081 # 현재포트가 8080이면 8081로 배포
elif [ ${CURRENT_PORT} -eq 8081 ];
then
  IDLE_PORT=8080 # 현재포트가 8081라면 8080로 배포
else
  echo "> 일치하는 Port가 없습니다. Port: $CURRENT_PORT"
  IDLE_PORT=8080
fi
echo "> Port를 할당합니다. IDLE_PORT: $IDLE_PORT"

# (3) 기존에 Nginx와 연결 되어 있는 포트 말고 새로운 포트 할당
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

# (4) 현재 연결 되어 있는 포트 확인
PROXY_PORT=$(curl -s http://localhost/health | egrep -o "[0-9]+" | tail -1)
echo "> Nginx Current Proxy Port: $PROXY_PORT"

# (5) 새로운 포트를 할당한 정보로 업데이트하기 위한 재시작
echo "> Nginx Reload"
sudo service nginx reload

 

 

4-3-3. execute-deploy.sh

execute-deploy.sh 는 deploy.sh를 실행시키기 위한 쉘 스크립트로써 배포를 진행할 때 이 스크립트를 실행하게 됩니다.

 

#!/bin/bash
/home/ubuntu/app/nonstop/deploy/scripts/deploy.sh > /dev/null 2> /dev/null < /dev/null &

 

 

깃허브 액션

 

깃허브 액션은 프로젝트 루트 디렉터리에 github/workflows라는 이름의 디렉터리 밑에 yml파일을 생성하면 된다.

 

4-4-1. CI-CD.yml

name: CI-CD (Java with Gradle)

# push, pull request 등 다양한 이벤트를 걸 수 있음. 현재 나는 수동으로 클릭하게 함
on: workflow_dispatch

# 전역에서 쓸 수 있는 환경 변수 등록
env:
  RESOURCE_PATH: ./src/main/resources/db.yml

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # checkout -> 현재 프로젝트를 받아옴.
      - uses: actions/checkout@v3
	  
      # DB의 민감한 설정 정보들을 동적으로 주입하기 위한 step
      - name: Set DB settings
        uses: microsoft/variable-substitution@v1
        with:
          files: ${{ env.RESOURCE_PATH }}
        env:
          spring.datasource.url: ${{ secrets.RDS_HOST }}
          spring.datasource.username: ${{ secrets.RDS_USERNAME }}
          spring.datasource.password: ${{ secrets.RDS_PASSWORD }}
		
      # gradle 정보를 캐시한다.
      - id: gradle-cache
        name: Cache Gradle
        uses: actions/cache@v3
        env:
          cache-gradle-name: cache-gradle-name
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-gradle-name }}-
            ${{ runner.os }}-build-
            ${{ runner.os }}-
	
      # Java17 
      - uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'
	  
      # gradle 세팅
      - name: Validate Gradle wrapper
        uses: gradle/wrapper-validation-action@v1

      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2
        with:
          gradle-version: '8.3'
	
     # gradle 빌드
      - name: Build with Gradle
        run: ./gradlew build
    
      # jar 파일을 깃허브 액션 아티팩트에 저장한다.
      - name: Archive build artifacts
        uses: actions/upload-artifact@v2
        with:
          name: java-build-artifacts
          path: build/libs
	
      # 배포에 대한 슬랙 알림
      - name: Notice when a build finishes
        if: always()
        uses: 8398a7/action-slack@v3.2.0
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        with:
          status: ${{ job.status }}
          fields: repo,message,workflow,job,ref,commit,author,action,eventName,took
          autohr_name: GitHub Action Slack

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      # jar 파일을 깃허브 액션 아티팩트에서 가져온다.
      - name: Download build artifacts
        uses: actions/download-artifact@v2
        with:
          name: java-build-artifacts
	 
      # jar 파일을 scp를 통해 원격 저장소에 보낸다.
      - name: copy file via ssh password
        uses: appleboy/scp-action@v0.1.4
        with:
          host: ${{ secrets.REMOTE_IP }}
          username: ${{ secrets.REMOTE_USER }}
          key: ${{ secrets.REMOTE_PRIVATE_KEY }}
          port: ${{ secrets.REMOTE_SSH_PORT }}
          source: "board.jar"
          target: ./app/nonstop/base_jar
	  
      # ssh를 통해 ec2에 접속하여 스크립트를 실행한다.
      - name: excuting remote ssh commands
        uses: appleboy/ssh-action@v0.1.6 # ssh 접속하는 오픈소스
        with:
          host: ${{ secrets.REMOTE_IP }} # 인스턴스 IP
          username: ${{ secrets.REMOTE_USER }} # 우분투 아이디
          key: ${{ secrets.REMOTE_PRIVATE_KEY }} # ec2 instance pem key
          port: ${{ secrets.REMOTE_SSH_PORT }} # 접속포트
          script: | # 실행할 스크립트
            /home/ubuntu/app/nonstop/deploy/scripts/execute-deploy.sh
      
      # 배포에 대한 슬랙 알림
      - name: Notice when a Deploy finishes
        if: always()
        uses: 8398a7/action-slack@v3.2.0
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        with:
          status: ${{ job.status }}
          fields: repo,message,workflow,job,ref,commit,author,action,eventName,took
          autohr_name: GitHub Action Slack

 

 

4-4-1. 문제점!!

나는 하나의 yml에 여러 profile을 관리하도록 설정하였다.

예시는 다음과 같다.

 

application.yml

spring:
  profiles:
    default: local
---

spring.config.activate.on-profile: local

override:
  value: 'from local'
---

spring.config.activate.on-profile: dev

override:
  value: 'from dev'

 

 

github-action.yml

env:
  RESOURCE_PATH: ./src/main/resources/application.yml

jobs:
  build:
    runs-on: ubuntu-latest
    steps:	  
      # DB의 민감한 설정 정보들을 동적으로 주입하기 위한 step
      - name: Settings
       	uses: microsoft/variable-substitution@v1
        with:
          files: ${{ env.RESOURCE_PATH }}
        env:
          override.value: ${{ secrets.Value }}

 

이렇게 작성 시 환경변수를 동적으로 주입할 때 깃허브액션에서 어느 override value에 값을 넣어야 하는지 인식하지 못하는(?) 현상이 발생한다. 그래서 job이 계속 실패하게 된다.

그래서 해결 방법은 스프링 부트 프로젝트에서 새로운 yml 파일을 만들어 import 해주고 각 환경마다 다른 정보를 주입해 주는 방법을 선택하였다.

 

 

4-4-2. sercrets 설정 방법

Settings -> Secrets and variables -> Actions -> New respository secret에서 설정 가능하다.

yml 파일에서 ${{ secrets.Name }} 으로 사용 가능하다.

 

 

4-4-3. 슬랙 알람 설정 방법

https://api.slack.com/apps

 

Slack API: Applications | Slack

Your Apps Don't see an app you're looking for? Sign in to another workspace.

api.slack.com

 

위의 주소로 들어간다.

 

 

1. Creta New App을 클릭한다.

 

 

 

2. From scratch를 선택

 

 

3. App Name과 어떤 슬랙 워크스페이스에서 사용할지 선택한다.

 

 

4. Incoming Webhooks를 선택한 뒤 Activate를 On으로 활성화한다.

Add New Webhook to Workspace를 선택하여 알림을 보낼 채널을 선택한다.

WebhookUrl을 복사하여 secrets로 등록하여 사용한다.

나는 cicd 채널로 알림을 발송하게 설정해 두었다.

 

 

 

결과

액션 중 실패 시 

 

 

 

 

깃허브 액션 페이지에서 현재 어디서 실패했는지 확인할 수 있고 슬랙 알림 또한 잘 동작한다.

실패 시 실패된 잡부터 다시 실행할 수 있는 기능을 제공해 준다.

 

 

성공 

 

 

 

 

배포가 잘 된 모습을 확인할 수 있다.

 

 

 

 

 

 

ref

https://docs.github.com/ko/actions

https://jojoldu.tistory.com/267

 

728x90

댓글