Project/9uin

spring boot 사이드 프로젝트 : spring boot + github action을 통해 CI/CD구축

attlet 2023. 10. 29. 18:23

ci/cd란 continuous integration / continuout deploy(delivery) 의 줄임말로, 지속적 통합과 배포를 말한다. 스프링 예제를 하나라도 해보면 알겠지만 서버 코드를 업데이트 했다고 실행중인 애플리케이션에 바로 적용되지는 않고,  서버를 껐다 켜야 업데이트한 코드가 적용된다. 

 

개발서버와 배포 서버를 따로 둔 상황에서 업데이트 내용을 적용하려면 배포 서버에 직접 접속해서 git pull이나 docker pull같은 작업을 거친뒤 다시 서버를 껐다 켜야되는데 서버가 한 두대면 모를까 여러대면 이런건 고역이 따로 없다. ci/cd를 통해 이런 작업을 할 필요 없이, 트리거만 충족되면 배포 서버에도 업데이트한 내용이 바로 적용되고, 다시 배포를 알아서 시작해준다.

 

 

 

 

프로젝트 마무리 즈음, ci/cd를 구축해보고 싶다는 생각이 들었다. 문제는 aws를 학부생 때  헛짓거리 + 해킹 실수일지도..     를 당해서 해서 무료로 더 이상 사용할 수 없는 상황이라, 다른 클라우드 서비스를 찾아봤다.

 

 

그러던 중 gcp(구글 클라우드) 가 눈에 들어왔다.  구글 아이디도 건제하고 단 한번도 건드린적 없기에 무료로 사용할 수 있을 것이란 생각에, gcp를 통해 서버를 배포, ci/cd를 구축해보기로 했다. 

 

 

ci/cd 툴은 젠킨스와 github action중에 고민했다. 이 두 가지가 ci/cd를 공부할 때 가장 자주 등장했던 점 때문에 이 두 가지를 선택했다.

 

 

 

 

이미지 출처:https://k21academy.com/devops-foundation/github-actions-vs-jenkins/

 

 

 

 

젠킨스의 장점은 일단 자료가 많았다. 조사할 때 많은 자료를 참고할 수 있었다. 하지만 github action에 비해 상대적으로 복잡하고, 인스턴스를 하나 더 필요로 할 수 있다는 점이 문제였다. (물론, 도커 컨테이너를 이용하면 해결 가능한 부분이지만, 그럼 또 그 부분이 복잡하게 다가올 수 있었다.)

 

 

 

github action은 일단 상대적으로 단순했다. 그리고 내가 따로 빌드 서버 인스턴스를 마련하지 않아도 github가 azure를 할당해 거기서 빌드 작업을 진행했다. 

 

 

 

나는 이런 고민 끝에 github action을 선택했고 gcp 에 적용하기로 했다. 

 

 

 

CI/CD 프로세스 


 

몬가 못생기게 그렸다...

 

 

 

위와 같은 구조로 진행된다고 볼 수 있다. 그럼 github action을 어떻게 작성해야하는지 보도록 하자.

 

 

 

github action 


 

 

github action은 github의 서비스로,  빌드 -> 테스트 -> 배포 까지 과정, ci/cd 자동화를 편리하게 커스텀할 수 있게 해준다.  

 

github action에서 workflow 가 가장 높은 개념이다. 한 작업의 흐름이라 생각할 수 있는데, 이 workflow는 레포지토리에 yaml파일 형태로 작성할 수 있다. 또한 여러개 존재할 수 있다.

 

 

이 yaml파일을 작성하려면 다음과 같이 진행한다.

 

 

 

 

github repository에 가보면 action이라는 탭이 있다. 여기로 들어가보면..

 

 

 

 

 

이런식으로 나오는데, 검색창에 gradle을 검색하면 java with gradle이 바로 나온다. configure를 클릭한다.

 

 

 

 

그러자 알아서 gradle.yml 템플릿을 작성해준다. 이걸 조금만 커스텀하면 된다. 

 

 

 

 

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle

name: Java CI with Gradle

on:
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'
        
    - name: Copy application.yml into runner         #application.yml을 복사해서 러너로 가져옴.
      run: |
        touch ./server/9in/src/main/resources/application.yml
        echo "${{secrets.APPLICATION_YML}}" > ./server/9in/src/main/resources/application.yml
        
    - name: Grant Execute Permission For Gradlew & Build gradle
      run: |
        cd ./server/9in
        chmod +x gradlew
        ./gradlew build 
      
    # - name: Build with Gradle
    #   run: ./gradlew build 
    
    - name: Docker hub login
      uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKERHUB_ID }}
        password: ${{ secrets.DOCKERHUB_PASSWORD }}
        
        
    - name: Docker image build
      run: |
        cd ./server/9in
        docker build -t ${{secrets.DOCKERHUB_ID}}/9uin-server .
      

    - name: Docker Hub push
      run: docker push ${{secrets.DOCKERHUB_ID}}/9uin-server 
    
  deploy: 
  
    runs-on: ubuntu-latest
    permissions: write-all
    needs: build
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Copy docker-compose into instance   #내 인스턴스로 docker-compose.yml파일 복사.
      uses: appleboy/scp-action@master          #이미 존재하는 경우 덮어씀
      with: 
        host: ${{secrets.GCP_HOST}}
        username: ${{secrets.GCP_USERNAME}}
        key: ${{secrets.GCP_PRIVATEKEY}}
        source: "./server/9in/docker-compose.yml"
        target: "/home/${{secrets.GCP_USERNAME}}"
        strip_components: 3
        overwrite: true
        debug: true



    - name: Deploy to Instance
      uses: appleboy/ssh-action@master     #가장 최신 버젼 appleboy 사용
      with:
        host: ${{secrets.GCP_HOST}}
        username: ${{secrets.GCP_USERNAME}}
        key: ${{secrets.GCP_PRIVATEKEY}}  
        script: |
          sudo docker login -u ${{ secrets.DOCKERHUB_ID }} -p ${{ secrets.DOCKERHUB_PASSWORD }}
          sudo docker pull ${{secrets.DOCKERHUB_ID}}/9uin-server
          sudo docker rm -f $(docker ps -qa) 2>/dev/null || true
          cd /home/${{secrets.GCP_USERNAME}}
          sudo docker-compose up -d
          sudo docker image prune -f

 

 

 

가장 중요한 부분이다. 처음 접하는 분야라 공부하는데 시간이 좀 걸렸다.  좀 길어지니 나눠서 알아보자.

 

 

 

CI 과정


 

name: Java CI with Gradle

on:
  pull_request:
    branches: [ "main" ]

permissions:
  contents: read

 

 

on 을 통해 언제 이 gradle.yml이 동작하도록 할 지 설정한다. pull request가 main 브랜치에 발생할 때 로 설정한 것이다. permissions를 통해 workflow가 액세스할 권한을 설정한다. read를 통해 읽을 수 있도록 했다.

 

 

 

jobs:
  spring-build:

    runs-on: ubuntu-latest

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

    # - name: Change directory to gradlew
    #   run: cd ./server/9in/

    - name: Grant Execute Permission For Gradlew & Build gradle
      run: |
        cd ./server/9in
        chmod +x gradlew
        ./gradlew build

 

 

job은 github action에서의 단위로, 하나의 workflow에는 여러개의 job이 수행될 수 있다. 이 job에서 일련의 작업을 수행하는 것이다. 이 job은 spring-build라는 이름을 갖게 되고, ubuntu최신 버전 기반 러너에서 실행된다.  

 

-- 여기서 러너(runner)란 이 workflow가 실행될 인스턴스를 말하며 기본적으로 azure가 할당되지만, 우리가 직접 설정해줄 수도 있다.

 

 

 

steps은 job안에서 실행되는 단계의 단위이다. 즉 하나의 workflow는 하나 이상의 job이 있고, job안에는 하나 이상의 step이 존재한다. 

 

 

step에서 uses을 통해 사람들이 만들어 놓은 액션을 가져와 사용할 수 있다. actions/checkout@v3같은 부분이 바로 그것으로, 이 액션은 레포지토리 코드들을 전부 러너로 복사해서 가져가는 것을 의미한다. 

 

 

name은 한 step의 이름을 의미한다. 이 name을 기준으로 step들을 나눌 수 있다고 봐도 된다.  맨 처음 name 부분은 java를 셋업하는 step이다. 셋업이 필요한 이유는 프로젝트 빌드를 통해 jar파일을 생성하기 위함이다.

우리 프로젝트 자바 버전은 17을 사용하니 17로 작성했다. 

 

 

그 다음 step은 gradlew build를 통해 프로젝트를 빌드해서 jar파일을 생성하는 부분이다. 

 

 

 

 

  - name: Docker hub login
      uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKERHUB_ID }}
        password: ${{ secrets.DOCKERHUB_PASSWORD }}
        
        
    - name: Docker image build
      run: |
        cd ./server/9in
        docker build -t ${{secrets.DOCKERHUB_ID}}/9uin-server .
      

    - name: Docker Hub push
      run: docker push ${{secrets.DOCKERHUB_ID}}/9uin-server

 

그 다음 과정은 docker에 로그인하고, DockerFile를 이용해 이미지를 빌드하고, 마지막으로 그 이미지를 docker hub에 push하는 step들이다.  docker hub에 알아서 최신 프로젝트 코드 기반 이미지를 빌드하고 푸쉬하니 이 과정까지가 CI과정이라 할 수 있다. 여기서 secrets 부분은 github repository에서 설정하는 부분이다.

 

 

 

 

 

repository에서 settings -> secrets and variables탭에 actions탭에 들어간다.

 

 

 

 

 

그럼 이렇게 나오는데, new repository secret탭을 통해 보안에 민감한 정보들을 여기에 저장할 수 있다. 여기에 저장한 정보를 gradle.yml파일에서 읽어서 가져올 수 있다. 나는 docker hub 접속에 필요한 id , 비밀번호나 그 외에 key들을 저장했다. 

 

 

 

 

CD 과정


 

deploy: 
  
    runs-on: ubuntu-latest
    permissions: write-all
    needs: build
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Copy docker-compose into instance   #내 인스턴스로 docker-compose.yml파일 복사.
      uses: appleboy/scp-action@master          #이미 존재하는 경우 덮어씀
      with: 
        host: ${{secrets.GCP_HOST}}
        username: ${{secrets.GCP_USERNAME}}
        key: ${{secrets.GCP_PRIVATEKEY}}
        source: "./server/9in/docker-compose.yml"
        target: "/home/${{secrets.GCP_USERNAME}}"
        strip_components: 3
        overwrite: true
        debug: true

 

자동 배포를 위한 job의 시작이다. github runner에서 배포 중인 서버로 접속을 하는 appleboy라는 액션을 사용했다. scp-action은 appleboy 액션들 중 scp명령어를 사용하는 액션이다.

 

 

이 action을 통해 runner에 올라온 최신 docker-compose.yml파일을 복사해 배포중인 서버로 보낸다. 프로젝트 최신화를 위해서는 docker-compose.yml파일도 최신화돼야 하는데, 이 과정을 거쳐 최신화를 진행했다.

 

 

 - name: Deploy to Instance
      uses: appleboy/ssh-action@v1.0.0     #가장 최신 버젼 appleboy 사용
      with:
        host: ${{secrets.GCP_HOST}}
        username: ${{secrets.GCP_USERNAME}}
        key: ${{secrets.GCP_PRIVATEKEY}}  
        script: |
          sudo docker login -u ${{ secrets.DOCKERHUB_ID }} -p ${{ secrets.DOCKERHUB_PASSWORD }}
          sudo docker pull ${{secrets.DOCKERHUB_ID}}/9uin-server
          sudo docker rm -f $(docker ps -qa) 2>/dev/null || true
          cd side_project/server/9in/
          sudo docker-compose up -d
          sudo docker image prune -f

 

 

 

 

 appleboy/ssh-action을 통해 runner에서 배포 서버로 ssh 접속을 사용했다. host , username, key 등을 통해 gcp인스턴스에 접속해서, docker pull을 통해 최신 프로젝트 이미지를 가져와서 docker-compose up을 통해 컨테이너들을 새로 실행한다. 

 

 

docker rm과 image prune 을 통해 이전에 실행되던 컨테이너들을과 이미지들을 삭제하는 작업이 있다. 이것이 없으면 계속 예전 이미지와 컨테이너들이 쌓여 성능 저하가 유발될 것이다.

 

 

 

이렇게 gradle.yml을 작성했다. 한 번 제대로 적용되는 지 확인해보자.

 

 

 

프로젝트가 실행중인 gcp의 swagger에 접속해보면 이렇게 나온다. 

 

 

 

api들 중 일부인 게시글 관련 api 들이다. 

 

 

이제 로컬 인텔리제이에서 github에 코드를 새로 업데이트 해서 push 한 후 pull request를 만들도록 한다. pr이 merge될 때 ci/cd작업이 gradle.yml에 작성한 대로 일어나야 한다.

 

 

새로 업데이트한 코드는 이렇다.

 

 

boardController에 테스트용으로 추가한 get api이다. 이 코드를 내 브랜치에 push 한 뒤, pull request를 main으로 보내면 gradle.yml에 작성한 workflow가 동작하게 된다.

 

 

 

 

 

 

 

 

pull request를 main으로 보내자 action탭에서 이렇게 뜨는 것을 확인할 수 있다. 

 

 

 

 

직접 진행과정을 확인할 수 있다. 

 

 

 

 

 

기다리니 이렇게 성공 표시가 나왔다. (사실 이 과정까지 10번은 넘게 실패한 것 같다.... )

 

 

이제 다시 gcp 인스턴스에 swagger로 들어가보자.

 

 

 

 

 

이전에 없던 api인 test2233이 추가된 것을 볼 수 있다!! 이렇게 내가 인스턴스에 들어가 작업할 필요가 없어졌다. 

 

 

 

물론 중간에 서버가 잠시 꺼졌다가 켜지는 과정이 존재한다. 이런 과정도 서비스에서 없애는 것이 요구사항이 될 것이기에, nginx를 활용한 무중단 배포도 공부해봐야 겠다.