멘지의 기록장

[SpringBoot] Docker + Nginx + Github Action을 사용하여 무중단배포 CI/CD 구축하기 본문

SpringBoot

[SpringBoot] Docker + Nginx + Github Action을 사용하여 무중단배포 CI/CD 구축하기

멘지 2024. 7. 30. 01:08

프로젝트를 진행하면서 적용했던 Docker와 Github Action, Nginx를 사용한 무중단 배포를 하는 방법을 작성해보려고 합니다.  


무중단 배포

무중단 배포는 서비스의 중단 없이 새로운 버전의 소프트웨어를 배포하는 것을 말합니다.

 

새로운 버전의 소프트웨어를 배포하기 위해선

1. 기존 서비스 종료

2. 새로운 서비스 시작

 

이와 같은 2가지 과정이 필요합니다.

 

기존 서비스를 종료하여 시스템을 사용할 수 없는 시간이 생기게 되면 이 시간을 다운타임이라고 합니다.

이러한 다운타임을 해결하기 위해 무중단 배포를 사용합니다.

 

무중단 배포 전략에는 3가지 전략이 있습니다. Rolling, Canary, Blue/Green 중 Blue/Green 방식을 사용하였습니다.


Blue/Green

Blue/Green 방식은 트래픽을 한번에 바꾸는 방법입니다. 일반적으로 현재 운영중인 서버를 Blue라고 하고, 새롭게 배포할 서버를 Green이라고 합니다.

트래픽을 한번에 바꾸기 때문에 Rolling, Canary와는 달리 유저마다 다른 버전의 서버를 사용하지 않게 하여 호환성 문제가 발생하지 않습니다. 다만, 실제 운영에 필요한 서버 리소스가 2배 필요하다는 단점이 있습니다.

 

이를 해결하기 위해 Docker를 사용하였습니다.

 

Docker를 사용하면 하나의 EC2에 포트를 다르게 하여 스프링 서버를 띄울 수 있기 때문에 여러 EC2를 사용하지 않고도 Blue/Green 방식을 도입할 수 있어 선택하였습니다.


무중단 배포 CI/CD 구축

Docker 설치

우선 관리자 권한으로 필요한 영역으로 들어간 후 패키지를 업데이트 합니다.

sudo su
sudo apt update

 

이후 https 관련 패키지를 설치합니다.

sudo apt install apt-transport-https ca-certificates curl software-properties-common

 

이후 docker repository 접근을 위한 gpg 키를 설정합니다.

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

 

다음으로 docker repository를 등록합니다.

sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable"

 

다시 업데이트를 진행합니다.

sudo apt update

 

다음으로 도커를 설치한 후 아래와 같은 화면이 나오는지 확인합니다.

sudo apt install docker-ce
docker --version

 

 

⚡참고로 sudo 명령어 없이 docker 명령어를 사용하려면!

우선 현재 사용자를 docker group에 포함시킵니다.

sudo usermod -aG docker ${USER}

 

이후 터미널을 재시작 후 아래 명령어를 실행시켜 docker가 포함되어 있는지 확인하면 됩니다.

id -nG


Swap Space 설정

우선 swap 메모리를 생성합니다.

sudo dd if=/dev/zero of=/swapfile bs=128M count=16
// of : swapfile 경로
// bs : Block 사이즈
// count : Block 갯수

 

다음으로 swapfile 읽기 쓰기 권한을 업데이트 합니다.

sudo chmod 600 /swapfile

 

이후 리눅스 swap 영역을 설정합니다.

sudo mkswap /swapfile

 

다음으로는 swap 메모리를 활성화 시킨 후, 절차가 성공했는지 확인합니다.

sudo swapon /swapfile
sudo swapon -s

 

부팅 시 swap 메모리를 활성화 시키기 위해 아래의 명령어를 실행시키면 완성입니다.

// 파일 열기
sudo vi /etc/fstab

// 파일 가장 마지막에 추가 후 :wq로 저장하고 종료
/swapfile swap swap defaults 0 0

Docker Compose 및 Nginx 설치

아래 2개의 명령어를 차례대로 실행하여 docker compose를 설치합니다.

curl \
    -L "https://github.com/docker/compose/releases/download/1.26.2/docker-compose-$(uname -s)-$(uname -m)" \
    -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose

 

Docker의 설치가 완료된 후 아래의 명령어로 Nginx를 설치합니다. (Docker image로 자동 세팅)

docker pull nginx

 

설치가 완료되면 Nginx를 실행시킵니다.

docker container run --name nginxserver -d -p 80:80 -p 443:443 nginx

 

Nginx를 실행시킨 후 아래의 코드를 사용해 Nginx에 접속합니다.

docker exec -it nginxserver bash

 

 

이후 Blue 또는 Green 컨테이너의 Nginx를 설정해주기 위해 conf.d 설정파일이 있는 디렉토리로 이동합니다.

cd /etc/nginx/conf.d

 

위의 경로에 있는 default.conf 파일을 수정하기 전,

Nginx 컨테이너에는 vim editor가 없기 때문에 먼저 설치를 해줍니다.

apt update
apt upgrade
apt install vim

 

설치가 완료되면 default.conf 파일을 수정하기 위해 아래 명령어를 실행합니다.

vim default.conf

 

기본 default.conf 파일은 아래와 같습니다.

 

우선 upstream 변수를 설정해줍니다. 

upstream 변수는 server 설정에서 Nginx가 받아들인 요청을 어떤 서버로 흘려보내 줄 것인지(load balancing) 결정할 때 사용됩니다.

EC2에서 설정한 Private IP 주소 뒤에 8080 포트로 온 요청을 blue 서버로, 8081 포트로 온 요청을 green 서버로 보내주기 위해 아래와 같이 설정하였습니다. 

(탄력적 IP가 아닌, Private IP를 사용하는 이유는 EC2 내부에서 IP를 찾아가야 하기 때문입니다.)

 

include /etc/nginx/conf.d/service-env.inc 에는 Nignx에서 사용할 환경 변수(service_url)를 넣을 예정입니다.

 

location / {} 의 의미는 'http://탄력적 IP/'로 오는 모든 요청을 받아오겠다는 것입니다.

 

proxy_pass http://$service_url

현재 위치에서 요청을 전달한 upstream 서버를 지정하겠다는 의미입니다.

 

service_url에는 blue 또는 green이 들어가게 됩니다.(실제 요청이 전달될 서버의 주소)

blue가 들어간다면 ⏩ upstream blue를 찾아서 실행

green이 들어간다면 ⏩ upstream green을 찾아서 실행

 

이제는 service_url을 넣을 파일인 service-env.inc를 만듭니다.

vim service-env.inc

파일의 내용
set #service_url green;

 

위의 모든 작업은 마치게 되면 exit을 통해 처음 화면으로 돌아갑니다.

이후 docker-compose.yml 파일을 생성해줍니다.

vim docker-compose.yml

 

아래와 같이 docker-compose.yml 파일에 작성합니다.

컨테이너를 실행할 때 도커 이미지와 컨테이너 이름, 외부포트 내부포트를 지정해줍니다.

version: '3.8'

services:
  blue:
    image: Docker_계정명/프로젝트명:prod
    container_name: blue
    ports:
      - "8080:8080"
    environment:
      - ENV=blue
  green:
    image: Docker_계정명/프로젝트명:prod
    container_name: green
    ports:
      - "8081:8080"
    environment:
      - ENV=green

Github Action 설정

EC2에서 설정해야하는 것은 모두 끝났고 이제 Github Action을 실행시키기 위한 yml 파일을 생성합니다.

 

main 브랜치에 push가 되는 경우에 실행하도록 설정하였습니다.

Github Secrets에 넣어놓은 application-prod.yml 파일 내용을 가져와 'src/main/resources' 디렉토리를 생성한 후 yml 파일을 만들어 넣어줍니다.

 

이후 Docker image를 생성한 후 Docker hub에 push 해줍니다.

 

health check를 통해 현재 실행되고 있는 docker 컨테이너의 이름을 확인하고,

CURRENT_UPSTREAM(제거할 Docker 컨테이너 이름)과 TARGET_UPSTREAM(앞으로 실행할 컨테이너 이름)을 설정합니다.

 

이후 위에서 설정했던 docker compose를 실행시킨 후 nginx를 통해 upstream을 변경 후 재실행합니다.

 

마지막으로 현재 도커 컨테이너를 stop 및 remove 함으로써 마무리가 됩니다.

name: Java CI with Gradle

on:
  push:
    branches: [ "main" ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      APPLICATION_PROD: ${{ secrets.APPLICATION_PROD }}

    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'corretto'

      # 설정 파일 생성
      - name: application yml setting
        run: |
          mkdir -p ./src/main/resources/
          echo ${{ env.APPLICATION_PROD }} | base64 --decode > ./src/main/resources/application-prod.yml

      # Gradle 실행권한 부여
      - name: Grant execute permission to gradlew
        run: chmod +x ./gradlew

      # Spring boot application 빌드
      - name: Build with gradle
        run: ./gradlew clean build

      # DockerHub에 로그인
      - name: Login to DockerHub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_PASSWORD }}

      # Docker 이미지 build 후 DockerHub에 push
      - name: Build Docker
        run: docker build --platform linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:prod .
      - name: Push Docker
        run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:prod

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Set target IP
        run: |
          STATUS=$(curl -o /dev/null -w "%{http_code}" "http://${{ secrets.SERVER_DOMAIN }}/global/health-check")
          echo $STATUS
          if [ $STATUS = 200 ]; then
            CURRENT_UPSTREAM=$(curl -s "http://${{ secrets.SERVER_DOMAIN }}/global/health-check")
          else
            CURRENT_UPSTREAM=green
          fi
          echo CURRENT_UPSTREAM=$CURRENT_UPSTREAM >> $GITHUB_ENV
          if [ $CURRENT_UPSTREAM = blue ]; then
            echo "CURRENT_PORT=8080" >> $GITHUB_ENV
            echo "STOPPED_PORT=8081" >> $GITHUB_ENV
            echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV
          else
            echo "CURRENT_PORT=8081" >> $GITHUB_ENV
            echo "STOPPED_PORT=8080" >> $GITHUB_ENV
            echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV
          fi

      - name: Docker compose
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.SERVER_DOMAIN }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script_stop: true
          script: |
            sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:prod
            sudo docker-compose -f docker-compose.yml up -d ${{ env.TARGET_UPSTREAM }}

      - name: Check deploy server URL
        uses: jtalk/url-health-check-action@v4
        with:
          url: http://${{ secrets.LIVE_SERVER_IP }}:${{ env.STOPPED_PORT }}/global/health-check
          max-attempts: 5
          retry-delay: 10s

      - name: Change nginx upstream
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.SERVER_DOMAIN }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script_stop: true
          script: |
            sudo docker exec -i nginxserver bash -c 'echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > /etc/nginx/conf.d/service-env.inc && nginx -s reload' 

      - name: Stop current server
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.SERVER_DOMAIN }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script_stop: true   # 실행 중 문제가 생기면 더 진행 X
          script: |
            sudo docker stop ${{ env.CURRENT_UPSTREAM }}
            sudo docker rm ${{ env.CURRENT_UPSTREAM }}

 

프로젝트에 있는 Dockerfile도 아래와 같이 설정해줍니다.

FROM amazoncorretto:17.0.7-alpine
COPY build/libs/server-0.0.1-SNAPSHOT.jar 프로젝트명.jar

ENV TZ Asia/Seoul
ARG ENV

ENTRYPOINT ["java", "-jar","-Dspring.profiles.active=prod", "-Dserver.env=${ENV}", "프로젝트명.jar"]

 

Github Secrets에 넣은 값들입니다


EC2 서버 동작 확인

green 서버가 잘 떠 있는 것을 확인할 수 있습니다.


 

무중단배포와 Github Actions를 사용하기 전, 수동배포를 하며 많은 불편함이 있었고,

또한, 서버가 내려가 있는 동안 프론트가 요청을 보내지 못하는 등의 문제점이 있었기 때문에

 

무중단 배포를 통해 유저와 프론트가 서비스를 지속적으로 사용할 수 있다는 점과, 배포 자동화를 통해 반복적이고 불필요한 행동을 줄일 수 있다는 것이 얼마나 많은 도움이 되는지 알 수 있었습니다.

 

'SpringBoot' 카테고리의 다른 글

No Offset을 적용한 무한 페이징 구현하기 (성능 비교)  (1) 2024.12.20
N+1 문제 해결 방법  (0) 2024.07.31
스프링 오답노트  (2) 2024.04.05
@Valid를 이용한 유효성 검증  (0) 2024.03.23
Lock의 종류  (0) 2024.03.23