Experience/항해99 18기

[챌린지 프로젝트 사전주차] CI-CD : Github actions을 사용해보자

chillmyh 2024. 1. 24. 03:34

0.  들어가기 전에!

  앞으로 다가올 실전프로젝트에 서비스팀과 챌린지팀으로 나눠진다는 소식이 있었다. 운이 좋게도 기술매니저님의 추천을 받아 챌린저팀에 들어올 수 있었고, 기존 서비스팀과는 다르게 대용량 트래픽 처리에 관점을 맞춘 프로젝트를 진행하게 되었다. 자바, 스프링도 항해를 통해서 처음 접하고 맛본건데 내가 이걸 할 수 있나..? 라는 생각이 들지만서도.. 감사한 기회라고 생각하고 도전에 임했다.

 기본적인 CRUD 기능을 구현하고 팀원별로 프론트파트 보강작업, Spring security, CI-CD 2명으로 나뉘어 작업을 진행하였다. 나와 도현님, 둘이서 CI-CD를 맡게되어 구글링과 gpt로 조사하고 레퍼런스들을 참고하며 적용해본 기록들을 늦게나마 남겨보려한다.


1.  왜? Why? Github actions?

기술적 의사결정 과정에서 고려되었던 기술 중 CI-CD 쪽에서 비교되었던 두 아키텍처는 Github Actions와 Jenkins이다. 두가지 후보군을 두고 여러 자료들을 참고해본 결과 크게는 아래와 같이 정리할 수 있었다.

  Github Actions Jenkins
레퍼런스 비교적 적음 많음
난이도 간단함 비교적 어려움
처리속도 비교적 느림 비교적 빠름

 

또한, 대규모 시스템엔 Jenkins가 더 적합하고 소규모 프로젝트에는 Github Action이 괜찮다는 의견이 많아 실전프로젝트 기간 들어가기 전 사전주차격인 지금에는 규모도 작고, ec2 인스턴스도 한개만 사용할거라 본 프로젝트에서는 Github Actions을 써보기로 결정했다!


2.  그래서 Github Actions이 뭔데? 

GitGub에서 제공하는 CI(Continuous Integration : 지속적 통합) / CD(Continuous Deployment : 지속적 배포)를 위한 비교적 최근에 추가된 기능이다. 비교적 간단하게 yml 파일을 작성하여 workflow를 만들어 특정 event가 발생하면 자동으로 세팅해놓은 CI-CD를 통해 자동배포가 가능하다.

 


GitHub를 사용하다보면 Repository에 Actions라는 탭을 본적이 있을것이다. 바로 요녀석이었다. 이녀석을 사용하기 위해서는 먼저 배포할 프로젝트에 디렉토리를 만들고 yml파일을 세팅해줘야한다. actions 탭에 들어가서 아래와 같이 Configure 버튼을 눌러서 편하게 바로 만들 수도 있다.

 

하지만 나는 직접 프로젝트에 디렉토리를 만들고 github-actions.yml 파일을 만들어줬다. 파일명은 마음대로 지어도 된다. 

우선 yml 파일을 작성해보기 전에 작동 흐름 이해를 위해 중요한 문법 몇개를 먼저 알아두면 좋다.

 

2-1) on : 트리거 설정

on:
  push:
    branches: [ "main", "be/dev" ]

 

우리가 CI-CD를 위해 열심히 github-actions.yml을 작성해놓아도 실제로 이 workflow가 동작하기 위해서 트리거가 되는 event 동작을 정의해줘야한다. 예를들면 특정 branch에 push, pull-request 를 하는 경우가 되겠다. 이런 트리거 역할을 지정해주는게 on 이다.

 때문에 위 코드는 '트리거역할 지정 : "main", "be/dev"에 push 됐을때' 정도로 이해하면 된다. push말고 pull-request도 event로 지정이 가능하다.

 나는 main, be/dev 브랜치 두개로 나누어 be/dev에 먼저 배포를 하며 테스트를 하다가 최종으로 main에 push를 하여 배포할 계획이므로 main과 be/dev브랜치를 트리거대상으로 설정하였다.

 

2-2) jobs

jobs:
  CI-CD:
    runs-on: ubuntu-latest
    steps:

 

...

 

docker-pull-and-run:
 runs-on: [self-hosted, dev]
 if: ${{ needs.CI-CD.result == 'success' }}
 needs: [ CI-CD ]
 steps:
   - name : 배포 스크립트 실행
     run: |
       sh /deploy.sh

 

하나의 작업단위라고 생각하면 된다. jobs에 하위 내용으로 CI-CD라는 작업단위가 오고 아래 steps 아래로 지정해준 name별로의 또 작업단위들이 순서대로 실행된다.

 본 프로젝트에 사용된 github-actions.yml 코드에서 jobs 하위 작업단위로는 CI-CD, docker-pull-and-run이 있다. 실제로 실행순서가 CI-CD 에 속해있는 작업들이 step 순서대로 실행되고, 모두 통과가 되면 다음으로 docker-pull-and-run의 작업들이 실행된다.

 참고로, docker-pull-and-run 쪽 코드를 보면 needs와 if 에 대한 문법이 있는데 간단히 설명하면 다음과 같다.

"CI-CD이 필요하다. CI-CD 작업 결과가 모두 성공하면 작업들을 수행한다"

 


3.  사용 예시를 보자!

아래는 실제로 챌린지 사전주차용 프로젝트에 사용한 github-actions.yml 내용이다.

 

# github repository actions 페이지에 나타날 이름
name: CI/CD using github actions & docker

# event trigger
# main이나 develop 브랜치에 push가 되었을 때 실행
on:
  push:
    branches: [ "main", "be/dev" ]

permissions:
  contents: read

jobs:
  CI-CD:
    runs-on: ubuntu-latest
    steps:

      # JDK setting - github actions에서 사용할 JDK 설정 (프로젝트나 AWS의 java 버전과 달라도 무방)
      - uses: actions/checkout@v3
      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      # gradle caching - 빌드 시간 향상
      - name: Gradle Caching
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      # 환경별 yml 파일 생성(1) - application.yml
      - name: make application.yml
        if: |
          contains(github.ref, 'main') ||
          contains(github.ref, 'be/dev')
        run: |
          cd ./src/main/resources
          touch ./application.yml
          echo "${{ secrets.YML }}" > ./application.yml
        shell: bash

      # 환경별 yml 파일 생성(2) - dev
      - name: make application-dev.yml
        if: contains(github.ref, 'be/dev')
        run: |
          cd ./src/main/resources
          touch ./application-dev.yml
          echo "${{ secrets.YML_DEV }}" > ./application-dev.yml
        shell: bash

      # 환경별 yml 파일 생성(3) - prod
      - name: make application-prod.yml
        if: contains(github.ref, 'main')
        run: |
          cd ./src/main/resources
          touch ./application-prod.yml
          echo "${{ secrets.YML_PROD }}" > ./application-prod.yml
        shell: bash

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

      # docker build & push to production
      - name: Docker build & push to prod
        if: contains(github.ref, 'main')
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -t ${{ secrets.DOCKER_USERNAME }}/deliveryrepository .
          docker push ${{ secrets.DOCKER_USERNAME }}/deliveryrepository

      # docker build & push to develop
      - name: Docker build & push to dev
        if: contains(github.ref, 'be/dev')
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -t ${{ secrets.DOCKER_USERNAME }}/deliveryrepository_dev .
          docker push ${{ secrets.DOCKER_USERNAME }}/deliveryrepository_dev

      ## deploy to production - 'main' branch 배포는 무중단 배포가 적용되지 않은 상태
      - name: Deploy to prod
        uses: appleboy/ssh-action@master
        id: deploy-prod
        if: contains(github.ref, 'main')
        with:
          host: ${{ secrets.HOST_PROD }} # EC2 퍼블릭 IPv4 DNS
          username: ubuntu
          key: ${{ secrets.PRIVATE_KEY }}
          envs: GITHUB_SHA
          script: |
            sudo docker ps
            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/deliveryrepository
            sudo docker run -d -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/deliveryrepository
            sudo docker image prune -f

# 무중단 배포가 적용된 상태 - 'be/dev' branch
  docker-pull-and-run:
   runs-on: [self-hosted, dev]
   if: ${{ needs.CI-CD.result == 'success' }}
   needs: [ CI-CD ]
   steps:
     - name : 배포 스크립트 실행
       run: |
         sh /deploy.sh

#      ## deploy to develop
#      - name: Deploy to dev
#        uses: appleboy/ssh-action@master
#        id: deploy-dev
#        if: contains(github.ref, 'be/dev')
#        with:
#          host: ${{ secrets.HOST_DEV }} # EC2 퍼블릭 IPv4 DNS
#          username: ${{ secrets.USERNAME }} # ubuntu
#          port: 22
#          key: ${{ secrets.PRIVATE_KEY }}
#          envs: GITHUB_SHA
#          script: |
#            sudo docker ps
#            docker stop $(docker ps -a -q)
#            docker rm $(docker ps -a -q)
#            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/deliveryrepository_dev
#            sudo docker run -d -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/deliveryrepository_dev
#            sudo docker image prune -f

 

 참고로, 맨 밑에 주석된 부분은 무중단배포를 도입하기 전 docker image를 hub에서 pull 받아오고 run 시켰던 배포부분이다. 무중단 배포가 필요없는 상황이라면 docker-pull-and-run부분을 주석하고 밑에 주석부분을 살리면 된다.

 

이제, 파트별로 뜯어서 살펴보자. docker에 대한 내용도 있지만 이런 흐름으로 작성되는 구나 정도를 파악하기 위해 보면 좋을 것 같다.

3-1) JDK 설정

# JDK setting - github actions에서 사용할 JDK 설정 (프로젝트나 AWS의 java 버전과 달라도 무방)
- uses: actions/checkout@v3
- name: Set up JDK 17
  uses: actions/setup-java@v3
  with:
    java-version: '17'
    distribution: 'temurin'

 

Github Actions에서 사용될 JDK를 설정한다. 프로젝트나 AWS의 java버전과는 달라도 무방하다. 하지만 터미널에 설정된 java 버전과 같아야한다!! 이 부분때문에 터미널에 자바 환경변수 설정이 되어있지 않아서 배포에 실패했었다.

 

3-2) gradle caching

# gradle caching - 빌드 시간 향상
- name: Gradle Caching
  uses: actions/cache@v3
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
      ${{ runner.os }}-gradle-

 

해당 코드는 없어도 무방하다. 하지만 gradle caching을 적용하면 빌드시간을 단축시켜주기때문에 사용하였다.

 

3-3) application.yml

# 환경별 yml 파일 생성(1) - application.yml
- name: make application.yml
  if: |
    contains(github.ref, 'main') ||
    contains(github.ref, 'be/dev')
  run: |
    cd ./src/main/resources
    touch ./application.yml
    echo "${{ secrets.YML }}" > ./application.yml
  shell: bash

# 환경별 yml 파일 생성(2) - dev
- name: make application-dev.yml
  if: contains(github.ref, 'be/dev')
  run: |
    cd ./src/main/resources
    touch ./application-dev.yml
    echo "${{ secrets.YML_DEV }}" > ./application-dev.yml
  shell: bash

# 환경별 yml 파일 생성(3) - prod
- name: make application-prod.yml
  if: contains(github.ref, 'main')
  run: |
    cd ./src/main/resources
    touch ./application-prod.yml
    echo "${{ secrets.YML_PROD }}" > ./application-prod.yml
  shell: bash

 

be/dev나 main에 push하여 테스트를 할때 yml 설정이 달라질 수 있으므로 배포에 필요한 yml 파일을 따로 설정해주었다. 보통 application.yml 파일은 .gitignore 에 등록하기 때문에 push를 해도 파일이 포함되지 않는다. 때문에 배포단계에서 필요한 yml 파일을 작성해주는 과정이다.

 

3-4) gradle build

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

 

Docker image로 만들기 전에 gradle build를 진행한다.

Docker 사용에 관련된 내용은 다음 글에 이어진다. 

 

3-5) Docker build & push

# docker build & push to develop
- name: Docker build & push to dev
  if: contains(github.ref, 'be/dev')
  run: |
    docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
    docker build -t ${{ secrets.DOCKER_USERNAME }}/deliveryrepository_dev .
    docker push ${{ secrets.DOCKER_USERNAME }}/deliveryrepository_dev

 

Docker image를 build하고 Docker hub에 push한다.

USERNAME, PASSWORD는 Docker hub의 id와 password이다.

 

3-6) Deploy

#      ## deploy to develop
#      - name: Deploy to dev
#        uses: appleboy/ssh-action@master
#        id: deploy-dev
#        if: contains(github.ref, 'be/dev')
#        with:
#          host: ${{ secrets.HOST_DEV }} # EC2 퍼블릭 IPv4 DNS
#          username: ${{ secrets.USERNAME }} # ubuntu
#          port: 22
#          key: ${{ secrets.PRIVATE_KEY }}
#          envs: GITHUB_SHA
#          script: |
#            sudo docker ps
#            docker stop $(docker ps -a -q)
#            docker rm $(docker ps -a -q)
#            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/deliveryrepository_dev
#            sudo docker run -d -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/deliveryrepository_dev
#            sudo docker image prune -f

 

이 부분은 현재 무중단배포를 구현해서 주석처리해놓고 사용하지 않는 부분이다.

하지만 무중단배포를 구현하지 않는다면 이런식으로 배포 자동화를 구현하면 된다.

주목해야될 곳은 script 부분이다. 흐름은 다음과 같다.

 

- sudo docker ps : actions 진행 중 현재 실행중인 docker 컨테이너를 보여준다. 없어도 되는 부분.

- docker stop ~ : 실행중인 docker 컨테이너를 중지한다.

- docker rm ~ : 중지된 컨테이너를 삭제한다.

- sudo docker pull ~ : docker hub에서 image를 pull 받아온다.

 - sudo docker run ~ : 8080:8080 포트로 도커 이미지를 실행한다.

- sudo docker image prune -f : 불필요한 이미지 리소스들을 삭제한다.

 

docker 관련 트러블 슈팅했던 내용은 다음에 다시 다루겠지만 추가로 적자면,
stop, rm 부분이 빠진채로 스크립트가 진행되면 image에 taging을 안해놨기 때문에, :latest image가 pull 되면서 기존에 남아있던 image와 태그가 일치해서 기존에 실행되고있는 image를 우선으로 남겨놓게된다. 따라서, pull 받은 새로운 image로 업데이트가 안된다.

stop, rm으로 돌아가고있던 docker 컨테이너를 중지시키고, 삭제해주자.

 

 

 

docker-pull-and-run:
 runs-on: [self-hosted, dev]
 if: ${{ needs.CI-CD.result == 'success' }}
 needs: [ CI-CD ]
 steps:
   - name : 배포 스크립트 실행
     run: |
       sh /deploy.sh

 

이 부분은 글이 길어지기 때문에 다음에 무중단배포를 다루는 글에서 자세히 설명하려한다.


 

4. 중간에 ${{ secrets. ~~~ }} 는 뭔데?

Github repository - Setting - Security - Secrets and variables - Actions 에서 특정 값들에 대해 Secret 처리가 가능하다.

New repository secret 버튼을 눌러 ${{ secrets.xxx }} 에서 xxx에 해당하는 name을 입력하고 해당하는 값을 적어주면 된다.

이 값은 한번 지정되면 볼 수 없고, 편집과 삭제만 가능하다.

 


 

5. 결론

이처럼 Github Actions를 활용하여 CI-CD 를 간단하게 yml 작성을 통하여 구현할 수 있다. 또한 자유롭게 커스텀이 가능한 부분이 큰 장점으로 다가왔다. 하지만 비교적 최근 기술이다보니 특정 아키텍처들과 연동하여 사용하는 레퍼런스들을 찾기가 어렵긴했다.

그래도 yml 작성을 해보고 직접 배포해보니 어느정도 감이 잡힐정도로 크게 어렵진 않았던 것 같다. 배포에 문제가 있었던 부분은 github actions보다는 aws라던지.. 다른 부분에서 트러블이 많았기 때문에 github actions자체는 쉽게 사용가능한 좋은 서비스인것 같다.

 

Reference
https://velog.io/@kimseungki94/Jenkins-vs-Github-Action-%EC%96%B4%EB%96%A4%EA%B1%B8-%EC%93%B0%EB%8A%94%EA%B2%8C-%EC%A2%8B%EC%9D%84%EA%B9%8C

 

Jenkins vs Github Action 어떤걸 쓰는게 좋을까?

개요 > IT-Hermes 프로젝트 개발 과정에서, 배포를 업데이트 하는 과정에 너무 오랜시간이 걸렸습니다. 특히 이번 프로젝트의 경우 주요 서버들이 너무 많았습니다. 프로젝트에 필요한 서버는 다음

velog.io

https://velog.io/@leeeeeyeon/Github-Actions%EA%B3%BC-Docker%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-CICD-%EA%B5%AC%EC%B6%95

 

Github Actions과 Docker을 활용한 CI/CD 구축

🍏 M1 맥북을 기준으로 작성하였습니다. 🍎

velog.io