GitHub Actions를 통한 CI/CD 파이프라인 구축기(Java + Spring + Gradle)

sinryuji·2024년 9월 18일
post-thumbnail

서론

웹 프로젝트를 진행하다가 어쩌다보니 팀에서 CI/CD 파이프라인 구축을 맡게 되었다. 이전에 GitHub Actions를 사용해 본 적은 있지만 간단하게 prettier와 lint를 돌려본게 전부였기에, Java와 Spring으로 개발 환경도 바뀐데다가 CD는 커녕 수동 배포조차 해본 경험이 없는 나에게는 완전히 맨 땅에 헤딩을 하며 진행해야 하는 과제였다.

물론 그만큼 그 과정에서 배운 것도 많기에, 내가 직접 CI/CD를 구축해나간 과정에 따라 어떻게 하면 Java, Spring, Gradle 환경에서 GitHun Actions를 통해 CI/CD를 구축하면 되는지를 히스토리와 함께 정리 해보고자 한다.

1. Dev Server CI/CD

우리는 CI/CD의 강점을 살리기 위해 개발 서버를 운영하기로 했다. 그렇게 하면 백엔드에서 개발된 기능들이 바로 바로 개발 서버에 통합, 배포 되기에 프론트 개발자들이 일일이 백엔드 코드를 최신화 할 필요도 없고 백엔드를 로컬에서 돌려가며 테스트할 필요가 없어진다. 특히나 프로덕션 환경에서의 테스트가 반드시 필요한 부분들(ex. OAuth, 푸쉬 알림 등)을 테스트 하기 위해서도 개발 서버는 반드시 필요한 상황이었다.(그런 점에서 단순히 개발 서버라기 보다는 개발 + 스테이징 서버의 개념이었지만)

그렇기에 나에게 주어진 첫번째 과제는 NHN Cloud에서 운영할 개발 서버의 CI/CD를 구축하는 것이었다! NHN Cloud를 사용했던 이유는 다 얘기하자면 길지만 가장 큰 이유는 가입시 주는 20만 크레딧이었다. 서버 운영비 20만원 개꿀...!

메인 서버의 CI/CD의 경우 버전 관리 및 배포 전략에 따라 개발 서버의 CI/CD와 비교하여 추가된 내용들이 조금 있어서 개발 서버와 메인 서버를 두 스텝으로 나눠서 설명하고자 한다.
참고로 메인 서버는 On-premise 환경에서, 개발 서버는 Cloud 환경에서 운영하였다. 실질적인 서버 세팅의 경우 두 환경에서 제법 차이가 나지만, CI/CD 관점에서는 SSH 접속 방법만을 제외하면 차이가 나지 않는다! 다만 메인 서버의 경우 우리가 나름대로 수립했던 버전 관리 및 배포 프로세스에 따라 추가된 CI/CD 과정이 있다. 메인 서버의 CI/CD의 경우 그 점에 초점을 맞춰 설명할 예정이다!

1-1. CI

CI는 Continuous Integration(지속적 통합)의 약자이다. 이는 이름 그대로 지속적으로 코드를 통합해가며 버그를 신속하게 찾아 소프트웨어 품질의 안정성을 보장하는 개발방법론으로써의 의미도 있다. 그러하여 CI에서 이루어져야 할 것은 기본적으로 빌드 성공 여부, 테스트 통과 여부(Prettier나 ESLint를 사용한다면 코드 컨벤션 체크까지 포함)가 있다.

이 글의 내용은 Java와 Spring 환경에서 GitHub Actions를 통한 CI/CD 구축에 대한 전반이기에 GitHub Actions의 개념과 문법에 대해 자세하게 다루지는 않는다! 해당 내용은 다른 블로그들에 양질의 글이 너무나도 많고, 공식 Document에도 정리가 정말 잘 되어있기에 참고하기를 바란다!

workflow의 작동 트리거

GitHub Actions에서 에서 가장 큰 작동 단위는 workflow이다. GitHub 레포지토리의 루트에 .github/workflows 디렉토리에 GitHub Actions 문법에 맞춰 yml 파일을 작성하면 파일 하나당 하나의 workflow 단위로 동작하게 된다.

on:
  push:
    branches: [ "dev", release-** ]
  pull_request:
    branches: [ "dev", release-** ]
    types: [ "opened", "reopened", "synchronize", "closed" ]

workflow가 언제 작동하냐를 정의하는게 on이다. 위 내용은 pushpull request에 대하여 이 workflow 작동을 한다는 의미이고 그 중에서 push는 dev 브랜치와 release-로 시작하는 브랜치에서 push가 이루어졌을 때, 그리고 pull request는 dev 브랜치와 release-로 시작하는 브랜치에 PR이 들어왔을 때와 그 PR이 각각 opened, reopend, synchronize, closed 되었을 때 작동을 한다는 의미이다. 여기서 synchronize는 이미 열린 PR에 새로운 커밋이 push 되었을 때를 의미한다.

workflow의 구성 단위

workflow는 이름 그대로 작업 흐름이라는 의미이다. 그런 workflow의 구성 단위이며 하나의 작업에 해당 하는게 job이다.

jobs:
  build:
    runs-on: ubuntu-latest

위 내용에서 build가 job에 해당한다.(이름은 정하기 나름이다. build여도 되고 job이 수행할 어떤 작업을 의미하는 무엇이여도 상관없다.) GitHub Actions는 각각의 job마다 독립된 runner에서 동작을 시킨다. 여기서 runner란 정의한 job을 수행할 virtual machine을 의미한다. 위 내용에서 runs-on은 runner를 동작시킬 환경, 즉 runner의 운영체제를 의미한다.(linux, mac, window 모두 가능하다)

job의 구성 단위

job은 또 다시 내부적으로 작업을 진행할 순서를 의미하는 step으로 구성된다. 참고로 job은 병렬적으로 수행되지만 step은 이름 그대로 직렬적으로 수행된다. 우리가 GitHub Actions으로 수행하고 싶은 어떠한 일련의 작업들은 모두 이 step에서 정의가 된다.

steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        submodules: true
        token: ${{secrets.TOKEN}}
    
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'

    - name: Setup Docker
      run: docker compose up -d

    - name: Grant execute permission
      run: chmod +x ./gradlew

    - name: Build with Gradle
      run: ./gradlew clean build -x test

    - name: Test with Gradle
      run: ./gradlew test

위 내용과 같이 step을 정의하면 된다. 하나씩 설명을 해보자면!

CI 수행 과정

앞서 CI에서는 기본적으로 빌드와 테스트를 수행해야 한다고 설명했다. 그러기 위해서는 일단 runner에 우리 프로젝트 코드를 가져와야한다. 다음 내용이 그 작업이다.

    - name: Checkout
      uses: actions/checkout@v4
      with:
        submodules: true
        token: ${{secrets.TOKEN}}
  • name
    • step의 이름이다. step에서 수행할 작업을 의미하는 어떤 것이라도 상관 없다.
  • uses
    • GitHub Actions는 action이라는 재사용 가능한 코드 단위를 제공한다. 이 action은 step의 집합일 수도 있고, Dockerfile을 실행 시킬 수도, JavaScript를 실행 시킬 수도 있다. 자신이 재사용하고 싶은 작업을 직접 만들어 재사용을 해도 좋지만 GitHub Actions Marketplace에 다른 유저들이 만들어 놓은 유용한 action들이 정말 많다. 여기에서 내가 필요한 작업을 수행하는 action이 있는지 찾아보고, 있다면 그 action을 활용하는게 좋다. 없다면 내가 직접 만들어서 마켓에 올려도 좋다!
      아무튼 위 내용은 checkout이라는 action을 사용해서 레포지토리의 코드를 runner로 땡겨오는 작업을 수행하는 것이다. gitcheckout의 의미 그대로이다.
  • with
    • action을 사용하는데 필요한 input 값들이다. 이 input 값은 당연히 action들마다 상이하므로 Marketplace에서 사용 방법을 참고하면 된다. 위 내용에서 submodule은 레포지토리의 서브모듈까지 checkout 해오겠다는 것이고, token은 코드를 떙겨오기위한 자격 증명에 필요한 input이다.
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'

위 step 또한 마찬가지로 Marketplace의 action을 사용했다. java-verseion으로 버전을 설정하고, distribution으로 배포판을 설정하면 된다.

    - name: Setup Docker
      run: docker compose up -d

자, 이제 코드도 땡겨왔고 자바도 설치를 했으니 본격적으로 빌드와 테스트를 수행을 할건데, 그 전에 테스트 코드 중 repostiry 테스트 코드도 존재하여 DB를 세팅해 주어야했다. 우리는 로컬에서 돌릴 DB를 docker compose로 설정을 해놓았기에 그 dokcer compose로 바로 DB를 올리도록 했다. (runner에 docker는 기본적으로 설치가 되어 있다.)

    - name: Grant execute permission
      run: chmod +x ./gradlew

그 이후 프로젝트를 빌드하기 위해 우선 gradlew의 실행 권한을 runner에게 준다. 이 때 gradlew이 뭔데? 라고 생각하시는 분들이 있을 수 있는데, 워낙 ide의 사용이 보편화가 되어있고 ide 내에서 run 버튼을 딸깍하면 너무나도 쉽게 프로젝트가 실행되기에 빌드와 그 과정에 대해 잘 모를 수 있다. 그래서 간략하게 설명을 하자면,

우리가 사용하는 언어는 고급 언어(C, Java 등)로 컴퓨터가 이해를 하지 못한다. 이 때 고급은 퀄리티로써 급이 높다의 의미가 아니라 컴퓨터 - 인간의 관계에서 인간에게 더 가까운 포지션에 있다는 의미이다. 반대로 인간은 이해하기 어렵지만 컴퓨터는 이해 할 수 있는 언어(어셈블리어, 기계어)를 저급 언어라 칭한다. 그래서 우리가 고급 언어로 작성한 코드를 컴퓨터가 이해 할 수 있도록 기계어로 번역해주는 과정을 컴파일(Complie)이라고, 컴파일을 통해 생성된 파일을 목적 파일(Object File)이라고 한다. 이후 이 목적 파일들을 링킹(Linking) 하여 실행 가능한 실행 파일로 만들고, 이 파일을 실행시켜 우리의 프로그램이 실행되는 것이다. 그리고 이러한 일련의 과정을 일컫는 용어가 빌드(Build)이다.

아무튼 그러하여 우리가 Java로 작성한 코드들이 잘 빌드 될 수 있도록 해주는 빌드 도구가 바로 Gradle이다. 여기서 빌드 환경에 종속 되지 않고 빌드를 해줄 수 있도록 해주는게 Gradle Wrapper이고 gradlew은 바로 이 Gradle Wrapper의 유닉스용 스크립트이다.

    - name: Build with Gradle
      run: ./gradlew clean build -x test

위 내용에서 clean 은 빌드를 수행하기 이전에 기존에 존재 했던 gradle을 통한 산출물들을 모두 정리하는 명령어이고, -x 옵션은 빌드를 하며 수행 할 task 중 제외할 task를 지정하는 옵션이다. 기본적으로 빌드를 할 때 테스트를 수행을 하는데, 나는 빌드와 테스트를 다른 스텝으로 나누고 싶어서 위와 같이 옵션을 주었다. 즉, 위의 스텝에선 테스트를 수행하지 않고 빌드가 잘 되는지만 체크를 한다.

    - name: Test with Gradle
      run: ./gradlew test

마지막으로 위 스텝에서 테스트를 수행하며 CI를 마무리한다.

1-2. CD

CD는 지속적 제공/배포(Continuous Delivery/Deployment)의 약자이다. 이는 CI 단계가 성공적으로 이루어지면 해당 변경 사항을 프로덕션 환경에 자동으로 배포하는 것을 의미한다.

출처: https://www.redhat.com/ko/topics/devops/what-is-ci-cd

CI가 없는 CD. 즉, 검증되지 않은 변경사항을 배포하는 것은 너무나도 위험하고 또, CD 없는 CI는 배포 과정에 수동적인 작업이 들어가야함으로 그 효과가 반감하게 된다. 그러므로 CI와 CD 이 두 과정은 매우 일반적으로 서로 연결되어 동작을 하게 되고 이렇게 연결된 일련의 과정을 CI/CD 파이프라인이라고 칭한다.

Deploy job

앞서 우리는 workflow의 작동 단위가 job 임을 살펴보았고 job은 병렬적으로, 그리고 독립적으로 동작한다는 사실까지 살펴보았다. 그런데 위에서 설명하였다시피, CD는 반드시 CI가 성공을 했을 경우에만 수행을 해야한다. 그러기 위해서 또 다른 job이 성공적으로 수행 되었을 경우에만 job을 실행하도록 해주는게 needs이다.

    if: (github.ref == 'refs/heads/dev' || contains(github.ref, 'release')) && (github.event.pull_request.merged == true || github.event_name == 'push')
    needs: build
    runs-on: ubuntu-latest

또한 해당 job이 특정 조건에서만 동작을 하도록 해주는 조건문, if도 존재한다. 나는 "workflow의 트리거가 된 브랜치가 'dev'이거나 혹은 브랜치 이름에 'release'를 포함하고, 트리거가 PR이 머지된 경우이거나 혹은 트리거가 push일 경우에만"으로 조건문을 작성해주었고, needs를 통해 CI가 성공을 해야만 job이 동작하도록 작성하였다.

위와 같이 조건문을 작성해준 이유는 우선 맨 위에 on을 통해 workflow 작동 트리거를 지정 해주긴 했지만 한번 더 검증 과정을 거치기 위함이 있었다. 그리고 CI는 opened, reopened, synchronize, closed 이 4가지 경우, 즉 PR이 열리거나 코드에 변경사항이 있을때마다 모두 동작을 해야하지만 CD는 코드 리뷰를 모두 통과하고 코드가 성공적으로 타겟 브랜치(dev or release)에 머지 되었을대만 이루어져야 하기 때문이다. 팀 내에서 기본적으로 코드를 올릴때 PR을 열고 2명의 코드 리뷰를 받도록 규칙을 정했지만 예외적(이라 쓰고 바쁘거나 귀찮을 때)인 상황에 강제로 push를 했을때도 배포가 이루어져야 하기에 push도 조건문에 포함해주었다.

Deploy with docker

  deploy-dev:
    if: (github.ref == 'refs/heads/dev' || contains(github.ref, 'release')) && (github.event.pull_request.merged == true || github.event_name == 'push')
    needs: build
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        submodules: true
        token: ${{secrets.TOKEN}}

    - name: Docker build & push
      run: |
        ./gradlew bootjar
        docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
        docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }} .
        docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}

    - name: Deploy
      uses: appleboy/ssh-action@v1.0.0
      with:
        host: ${{ secrets.DEV_HOST }}
        port: 22
        username: ${{ secrets.DEV_USERNAME }}
        key: ${{ secrets.DEV_KEY }}
        debug: true
        script: |
          docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}
          docker stop ${{ secrets.DOCKER_DEV_IMAGE }}
          docker rm ${{ secrets.DOCKER_DEV_IMAGE }}2
          docker run --name=${{ secrets.DOCKER_DEV_IMAGE }} -d -p 8080-8082:8080-8082 -p 80:8080 -v $HOME/logs:/logs ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}
          docker container prune -f
          docker image prune -f

앞서 설명 했다시피, job들은 모두 별도의 러너에서 동작한다. 그래서 앞서 CI에서와 마찬가지로 CD에서도 코드를 checkout 해와야한다. 그 부분이 name: Checkout에 해당하는 step의 내용이다. 이후 step들은 처음 보는 내용들이니 하나하나 자세히 설명하겠다.

Docker build & push

프로젝트를 jar 파일로 말고 다시 Docker Image로 빌드 한 후 Docker Hub로 푸쉬하는 내용이다. 이렇게 하는 이유는 Docker를 이용하는게 빌드 버전 및 파일 관리에 용이하기 때문이다.

  • ./gradlew bootjar
    • 앞서 설명한 gradlew을 통해 gradle로 프로젝트를 빌드한다.bootjar는 프로젝트를 실행 가능한 jar 파일로 빌드하는 명령어이다.
  • docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
    • Docker Hub에 푸쉬하기 위해 로그인을 하는 과정이다. 여기서 ${{ secrets.DOCKER_USERNAME }}는 깃허브의 secret이다. 이 Yaml 파일은 깃허브 레포지토리에 존재하기에 Public 레포지토리의 경우 모두에게 공개가 된다. 그렇기에 아이디, 비밀번호 같이 보안상 중요한 정보들의 경우 깃허브 secret을 이용하여 사용하는 것이 안전하다.
  • docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }} .
    • 푸쉬할 이미지를 빌드하는 과정이다. -f 옵션의 경우 Dockerfile의 경로를 입력하는 옵션이다. 옵션을 사용하지 않으면 정확히 Dockerfile이라는 이름의 파일을 찾지만 나의 경우엔 개발 서버와 프로덕션 서버의 Dockerfile을 분리해놓았기에 파일 이름을 인자로 주었다.
      FROM openjdk:11-jdk
      ARG JAR_FILE=build/libs/*.jar
      ARG LOG4J2_CONFIG_FILE=src/main/resources/log4j2/*.xml
      COPY ${JAR_FILE} app.jar
      COPY ${LOG4J2_CONFIG_FILE} resources/log4j2/log4j2.xml
      ENTRYPOINT ["java","-jar","Dspring.profiles.active=dev","/app.jar"]
      Dockerfile-dev의 내용은 위와 같다.
  • docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}
    • Docker Hub에 푸쉬한다.

Deploy

실질적으로 배포가 이루어지는 부분이다. ssh로 프로덕션 서버에 접속한 후 Docker Hub에 업로드한 이미지를 받아와 컨테이너를 실행시킨다.

  • uses: appleboy/ssh-action@v1.0.0
    • ssh 접속을 위한 Action이다. ssh 접속에 필요한 host, port, username, key 등이 필요하다. 앞서 설명했다시피 개발 서버의 경우에는 NHN Cloud를 이용하였는데 NHN Cloud에서는 pem 키를 통해 ssh에 접속하는 방식이라 pem 키를 secret으로 사용했다.
  • docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}
    • 앞서 build 과정에서 Docker Hub에 푸쉬했던 이미지를 다시 떙겨온다. 이 때, 아까 빌드한 이미지가 있는데? 라며 헷갈릴 수 있는데 앞서 빌드한 환경은 Github Actions의 Runner이고 지금 배포를 하는 환경은 NHN Cloud의 인스턴스이다.
  • docker stop ${{ secrets.DOCKER_DEV_IMAGE }}
    • 기존에 실행중이던 컨테이너를 종료한다.
  • docker rm ${{ secrets.DOCKER_DEV_IMAGE }}
    • 기존에 실행중이던 컨테이너를 삭제한다.
  • docker run --name=${{ secrets.DOCKER_DEV_IMAGE }} -d -p 8080-8082:8080-8082 -p 80:8080 -v $HOME/logs:/logs ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}
    • 컨테이너를 실행 시킨다. 각 옵션에 대해 간단히 설명하자면, --name은 컨테이너의 이름을 정하는 옵션이고 -ddemon의 약자로 백그라운드로 실행 시키는 옵션이다. -p는 포트포워딩을 하는 옵션, -v는 볼륨을 마운트하는 옵션이다.
  • docker container prune -f
    • 사용하지 않는 컨테이너를 일괄 삭제한다. 굳이 필요하진 않지만 그냥 정리 차원에서 넣어준 커맨드...
  • docker image prune -f
    • 사요하지 않는 이미지를 일괄 삭제한다.

이로써 개발 서버의 CI/CD에 대한 설명은 끝났다! 해당 yml 파일의 전문은 다음과 같다.

name: CI/CD

on:
  push:
    branches: [ "dev", release-** ]
  pull_request:
    branches: [ "dev", release-** ]
    types: [ "opened", "reopened", "synchronize", "closed" ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        submodules: true
        token: ${{secrets.TOKEN}}
    
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'

    - name: Setup Docker
      run: docker compose up -d

    - name: Grant execute permission
      run: chmod +x ./gradlew

    - name: Build with Gradle
      run: ./gradlew clean build -x test

    - name: Test with Gradle
      run: ./gradlew test
      
  deploy-dev:
    if: (github.ref == 'refs/heads/dev' || contains(github.ref, 'release')) && (github.event.pull_request.merged == true || github.event_name == 'push')
    needs: build
    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        submodules: true
        token: ${{secrets.TOKEN}}

    - name: Docker build & push
      run: |
        ./gradlew bootjar
        docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
        docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }} .
        docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}

    - name: Deploy
      uses: appleboy/ssh-action@v1.0.0
      with:
        host: ${{ secrets.DEV_HOST }}
        port: 22
        username: ${{ secrets.DEV_USERNAME }}
        key: ${{ secrets.DEV_KEY }}
        debug: true
        script: |
          docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}
          docker stop ${{ secrets.DOCKER_DEV_IMAGE }}
          docker rm ${{ secrets.DOCKER_DEV_IMAGE }}
          docker run --name=${{ secrets.DOCKER_DEV_IMAGE }} -d -p 8080-8082:8080-8082 -p 80:8080 -v $HOME/logs:/logs ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}
          docker container prune -f
          docker image prune -f

2. Prod Server CI/CD with 배포 전략

앞서 설명했다시피 CI/CD 관점에서 Cloud 환경과 On-premise의 환경이라고 해서 크게 다를 건 없다. 코드를 땡겨와 CI를 수행하면 되고, 프로덕션 환경에 빌드한 프로그램을 배포하면 된다.

다만, 메인 서버의 경우 좀 더 철저한 검증 뒤에 배포가 이루어져야 하고, 안정적인 서비스 운영을 위해 버전 관리 또한 필요하기에 dev CI/CD와 비교하여 추가 된 점들이 있다. dev CI/CD와 중복되는 내용들은 패스하고 그 내용들을 중점적으로 설명을 할 예정이다!

2-1. CI

메인 서버의 경우 부분별한 배포가 이루어지지 않도록 배포 조건이 훨씬 제한적이어야 한다. 그러하여 우리는 메인 서버의 배포는 오직 release-a.b.c(a, b, c는 숫자)와 같은 형태의 브랜치에서 Pull Request가 발생하고 이게 merge 되었을때만 배포가 이루어지도록 정했다. a.b.c는 배포 버전이고 CI에서는 이를 검증함과 동시에 CD에서 이 버전을 추출하여 활용할 것이다!

작동 트리거

on:
  pull_request:
    branches: [ "main" ]
    types: [ "closed" ]

그러하여 작동 트리거에도 차이가 있다. 특정 브랜치에서 merge가 되었을 경우에만 배포가 이루어져야 하므로 트리거를 위와 같이 설정했다. 다만 GitHub Actions에서 Pull Requestmerged 되었는지에 대한 types를 제공해주지는 않는다. 그래서 일단 main으로 들어가는 PR이 closed 되었을 경우만을 작동 트리거로 설정을 하고, 이후에 이게 merged가 되었는지를 검증 해 줄 것이다.

브랜치 이름 검증

jobs:
  validate-branch:
    if: startsWith(${{ github.head_ref }}, 'release-')
    runs-on: ubuntu-latest
    steps:
      - name: Validate branch
        run: |
          BRANCH_NAME=${{ github.head_ref }}
          if [[ ! "$BRANCH_NAME" =~ ^release-[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
            echo "$BRANCH_NAME does not match the pattern release-x.y.z"
            exit 1
          fi

배포 전략을 release-a.b.c와 같은 형태의 브랜치에서만 이루어지두록 정하였기 때문에 이를 검증하기 위한 CI 과정이다. if: startsWith(${{ github.head_ref }}, 'release-')를 통해 브랜치가 release-로 시작하는지 확인한다. github.head_refPR을 트리거로 Action이 동작 할 경우 브랜치 이름을 가져올 수 있는 변수이다.

이후 쉘 스크립트에서도 마찬가지로 브랜치 이름을 가져온 후, 정규표현식을 통해 release-3자리 숫자.3자리 숫자.3자리 숫자의 형식임을 확인한다. 만약 이 형식에 맞지 않으면 exit 1을 하게 되는데 리눅스에서 exit status가 0이 아닌 경우 모두 error status이다. job에서 이런 error status로 종료를 하게 되면 job이 실패를 했다고 간주를 한다. 다음 내용에 나오겠지만, 다음 job에서 해당 job을 needs로 걸어놓았기 때문에 이 job이 실패를 하면 다음 job으로 넘어가지 않는다!

브랜치 merge 여부 검증

  validate-merged:
    needs: validate-branch
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
      - name: Validate merged
        run: |
          echo 'The PR was merged'

브랜치 이름과 마찬가지로 릴리즈 브랜치가 merge 되었을 경우에만 배포를 진행하여야 하므로 이 역시 CI 단계에서 검증이 필요하다. 위 job이 그 내용이다. if: github.event.pull_request.merged == true를 보면 github.event.pull_request.merged의 true 여부를 체크하는데, 만약에 트리거가 된 PRmerge가 완료 되었다면 해당 변수 값이 true가 된다. 마찬가지로 다음 job에서 해당 job을 needs로 걸어놓았기 때문에 해당 if문을 통과하지 못하면 다음 job으로 넘어가지 않는다!

버전 관리의 필요성

앞서 설명했다시피 release-a.b.c의 형태의 브랜치에서 merge가 되었을때만 배포가 이루어지도록 했고 a.b.c3자리 숫자.3자리 숫자.3자리 숫자 형식의 버전이다. 여기서 a는 Major 업데이트, b는 Minor 업데이트, c는 버그 픽스에 해당하는 버전이다. 예를 들어, 맨 처음 1.0.0으로 버전을 올렸는데 여기서 여러 버그들을 수정하여 다시 배포를 하면 1.0.1이 되고, 1.0.7까지 버그 픽스가 이루어지다가 몇몇 작은 기능들이 추가 되었을 경우 1.1.0으로 버전을 올리는 식인 것이다.

이런식으로 버전 관리가 이루어져야 하는 이유는 소프트웨어를 릴리즈하는데 있어 고유한 식별자를 부여함이 크다. 이는 개발자들이 각 릴리즈의 의미를 쉽게 이해하고 공유할 수 있게 해주며, 운영을 하는 와중 심각한 결함을 발견하였을 때 롤백을 하기에 용이하다는 장점도 있다.

버전 추출

  extract-version:
    needs: validate-merged
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.extract-version.outputs.version }}
    steps:
      - name: extract version
        id: extract-version
        run: |
          BRANCH_NAME=${{ github.head_ref }}
          echo "version=$(echo $BRANCH_NAME | egrep -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}')" >> "$GITHUB_OUTPUT"

아무튼 그러한 이유들 때문에 각 릴리즈마다 버전을 부여하고 릴리즈 브랜치 이름에 버전을 포함하여 release-a.b.c의 형태로 정하게 된 것 이다. 그럼 이제 CI/CD 과정에서 이 버전을 활용하여 Docker 이미지도 빌드를 해야 하고, Github 상의 Release도 만들어야 한다. 그러기 위해 a.b.c에 해당하는 버전을 CI/CD 과정에서 계속 재사용 할 목적으로 브랜치 이름에서 추출하는 것이 해당 job의 역할이다.

job은 각자 독립된 환경에서 병렬적으로 수행된다고 했다. 그럼 모든 job에서 활용을 해야하는 값이 필요하다면 어떻게 해야할까? 글로벌 변수와 같이 말이다. 우리는 앞서 ${{ github.head_ref }}와 같이 Github Actions에서 제공해주는 변수나 ${{ secrets.DOCKER_USERNAME }}와 같이 Secret을 통해 저장해 놓은 변수를 활용해보았다. 하지만 이 녀석들은 Github Actions에서 제공 해주어 우리가 컨트롤 할 수 없거나, 정적으로 세팅해놓은 값이라는 단점이 있다.

Github Actions을 이를 위해 Output이라는 기능이 존재한다. 이는 Workflow 내에서 모두 활용이 가능한 변수를 만드는 기능으로 독립된 환경에서 구동되는 각 job이 모두 활용이 가능하다. 단, job은 병렬적으로 수행되므로 output을 만드는 job에 종속되지 않는 job에서는 사용이 불가능하다.

      - name: extract version
        id: extract-version
        run: |
          BRANCH_NAME=${{ github.head_ref }}
          echo "version=$(echo $BRANCH_NAME | egrep -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}')" >> "$GITHUB_OUTPUT"

해당 부분이 output을 설정하는 부분이다. echo "{name}={value}" >> $GITHUB_OUTPUT와 같은 형태로 설정을 한다. 위 코드에서 (echo $BRANCH_NAME | egrep -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}')"는 브랜치 이름에서 버전만 추출하는 부분이다. 즉, release-a.b.c에서 a.b.c만을 추출하여 version이라는 변수에 담는 것이다.

물론 여기까지는 Workflow에서 전역적으로 활용할 수 있는 Output이 아니라 현재 Job 내에서만 활용 할 수 있는 Output이다. 이를 다른 Job들에서도 활용을 하려면 다음과 같이 해주어아한다.

    outputs:
      version: ${{ steps.extract-version.outputs.version }}

앞서 설정한 Output은 steps.<STEP_ID>.outputs.<OUTPUT_NAME>의 형태로 이루어져있다. Output을 설정한 step의 ID가 extract-version이고, Output의 이름이 version이었기 때문에 steps.extract-version.outputs.version이 되는 것이다. 이렇게 되면 이제 needs.<JOB_NAME>.outputs.<OUTPUT_NAME>로 다른 Job에서 활용 할 수 있다!

2-2. CD

Build 과정은 dev 서버의 과정과 완전히 똑같다. Deploy 과정 또한 큰 차이 Docker 이미지를 빌드 할 때 앞서 추출했던 버전을 지정해주는 것을 제외하면 전부 일치한다!

GitHub를 보면 위 사진과 같이 Releases라는 릴리즈 버전을 관리 할 수 있는 탭이 존재한다. 각 릴리즈에 대한 설명도 적어 놓을 수 있어 유용한 것 같아 해당 릴리즈를 만드는 과정도 CD에 포함시켰다!

Deploy

      - name: Docker build & push
        run: |
          ./gradlew bootjar
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile-prod -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_PROD_IMAGE }}:${{ needs.extract-version.outputs.version }} .
          docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_PROD_IMAGE }}:${{ needs.extract-version.outputs.version }}

Docker 이미지를 빌드하고 푸쉬하는 과정이다. 차이점이라면 Dockerfile-prod로 빌드하는 점, :${{ needs.extract-version.outputs.version }}를 통해 뒷 부분에 추출한 버전을 지정해주는 점 밖에 없다.

      - name: Deploy
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.PROD_SERVER_HOST }}
          port: ${{ secrets.PROD_SERVER_PORT }}
          username: ${{ secrets.PROD_SERVER_USERNAME }}
          password: ${{ secrets.PROD_SERVER_PASSWORD }}
          debug: true
          script: |
            docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_PROD_IMAGE }}:${{ needs.extract-version.outputs.version }}
            docker stop ${{ secrets.CONTAINER_NAME }}
            docker rm ${{ secrets.CONTAINER_NAME }}
            docker run --name=${{ secrets.CONTAINER_NAME }} -d -p 8080-8082:8080-8082 -v $HOME/logs:/logs ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_PROD_IMAGE }}:${{ needs.extract-version.outputs.version }}
            docker container prune -f
            docker image prune -f

실질적으로 배포가 이루어지는 부분이다. 역시나 다른 점은 ssh 접속 방식과 버전 지정 밖에 없다. 배포 서버의 경우에는 아이디, 비밀번호로 ssh에 접속하도록 했고, 마찬가지로 ${{ needs.extract-version.outputs.version }}를 활용해서 버전을 지정하면 된다!

create release

  create-release:
    needs: [deploy, extract-version]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: create release
        uses: ncipollo/release-action@v1.13.0
        with: 
          tag: ${{ needs.extract-version.outputs.version }}
          name: ${{ needs.extract-version.outputs.version }}

GitHub의 Releases를 만드는 부분이다. 역시나 마켓 플레이스의 Action을 활용했고 배포가 성공적으로 이루어진 후 만들어야 하고, 추출한 버전을 활용해야 하므로 그 두 부분에 대해 needs가 걸려있다. tag가 릴리즈 버전이 들어가는 부분이고, 이름도 그냥 버전과 동일하게 지정해주었다.

이로써 메인 서버의 CI/CD에 대한 설명도 끝이 났다! 해당 yml 파일의 전문은 다음과 같다.

name: Main Server CI/CD

on:
  pull_request:
    branches: [ "main" ]
    types: [ "closed" ]

permissions:
  contents: read

jobs:
  validate-branch:
    if: startsWith(${{ github.head_ref }}, 'release-')
    runs-on: ubuntu-latest
    steps:
      - name: Validate branch
        run: |
          BRANCH_NAME=${{ github.head_ref }}
          if [[ ! "$BRANCH_NAME" =~ ^release-[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
            echo "$BRANCH_NAME does not match the pattern release-x.y.z"
            exit 1
          fi

  validate-merged:
    needs: validate-branch
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
      - name: Validate merged
        run: |
          echo 'The PR was merged'
  
  extract-version:
    needs: validate-merged
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.extract-version.outputs.version }}
    steps:     
      - name: extract version
        id: extract-version
        run: |
          BRANCH_NAME=${{ github.head_ref }}
          echo "version=$(echo $BRANCH_NAME | egrep -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}')" >> "$GITHUB_OUTPUT"
          
  build:
    needs: extract-version
    runs-on: ubuntu-latest
    steps:  
      - name: Checkout
        uses: actions/checkout@v4    
        with:
          submodules: true
          token: ${{ secrets.TOKEN }}
      
      - name: Setup JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'
  
      - name: Setup Docker
        run: docker compose up -d
  
      - name: Grant execute permission
        run: chmod +x ./gradlew
  
      - name: Build with Gradle
        run: ./gradlew clean build -x test
  
      - name: Test with Gradle
        run: ./gradlew test

  deploy:
    needs: [build, extract-version]
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: true
          token: ${{ secrets.TOKEN }}
  
      - name: Docker build & push
        run: |
          ./gradlew bootjar
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile-prod -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_PROD_IMAGE }}:${{ needs.extract-version.outputs.version }} .
          docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_PROD_IMAGE }}:${{ needs.extract-version.outputs.version }}
  
      - name: Deploy
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.PROD_SERVER_HOST }}
          port: ${{ secrets.PROD_SERVER_PORT }}
          username: ${{ secrets.PROD_SERVER_USERNAME }}
          password: ${{ secrets.PROD_SERVER_PASSWORD }}
          debug: true
          script: |
            docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_PROD_IMAGE }}:${{ needs.extract-version.outputs.version }}
            docker stop ${{ secrets.CONTAINER_NAME }}
            docker rm ${{ secrets.CONTAINER_NAME }}
            docker run --name=${{ secrets.CONTAINER_NAME }} -d -p 8080-8082:8080-8082 -v $HOME/logs:/logs ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_PROD_IMAGE }}:${{ needs.extract-version.outputs.version }}
            docker container prune -f
            docker image prune -f

  create-release:
    needs: [deploy, extract-version]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: create release
        uses: ncipollo/release-action@v1.13.0
        with: 
          tag: ${{ needs.extract-version.outputs.version }}
          name: ${{ needs.extract-version.outputs.version }}

후기

이전까지는 정말 인프라에 대한 지식이 전무했다. 전에 회사에서도 Jenkins를 이용한 CI/CD 파이프라인이 있었지만 당시에는 하나도 이해를 하지 못했고 그 필요성 또한 알지 못했다. 하지만 이번 기회에 서버 구축부터 CI/CD 파이프라인까지 완성을 하고 나니 어느정도 인프라에 대한 기초는 다지게 된 것 같다. 이 글에서 다룬 Github Actions 뿐만 아니라 Cloud 서비스에 대한 전반적인 사용 경험, 네트워크 지식, 리눅스/Docker 숙련도, SSL에 대한 이해 등 많은 것들을 배울 수 있었다.

백엔드 개발자라면 서버를 다룰 줄 알아야 한다. 인프라 전반에 대한 지식의 수준을 높이면 DevOps가 아니더라도 나의 가치를 올리는데 충분히 좋은 재료가 될 것이다. 아직 무중단 배포, kubernetes, MSA 등 배워야 할 것들이 많다. 인프라에 대한 입문을 마쳤으니 더욱 많은 것들을 공부해봐야겠다!

참고

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

profile
응애 개발자입니다.

0개의 댓글