본문 바로가기
Infra/CI&CD

Github Actions과 Docker를 이용한 CI/CD

by 구본식 2023. 7. 19.

본 게시물은 넥스터즈 23기 짠지 사이드 프로젝트에서 진행한 내용입니다.

자세한 내용: https://github.com/Nexters/zzanji-server

 

GitHub - Nexters/zzanji-server: 넥스터즈 23기 짠지 팀의 서버 저장소

넥스터즈 23기 짠지 팀의 서버 저장소. Contribute to Nexters/zzanji-server development by creating an account on GitHub.

github.com


들어가기 앞서

예전 사이드 프로젝트에서는 Jenkins, Docker를 이용해서 자동 배포 파이프라인을 구성했지만,

이번에는 새로운 툴을 사용해보고 싶어 Github Action를 이용하여 CI/CD 파이프라인을 구성해보았습니다.

(기본적으로 Docker를 이용했습니다. JIB와 같이 구글에서 자바 어플리케이션 전용 컨테이너를 생성해주고 배포해주는 쉬운 툴도 존재하던데, 다음에 기회가 되면 사용해봐야겠다.)

 

총 CI, CD로 나누어 파이프라인을 구성했습니다.
"develop, feat/*" 브랜치에 PR 혹은 Push 작업 발생 시, 지속적인 통합(CI)이 되도록 구성했고,
"main, develop" 브랜치에 Push 작업 발생 시, 자동 배포(지속적인 배포, CD)가 되도록 구성했습니다.

동작 원리

CI 과정

1. feat/*에 push or PR, develop에 PR 가 되었을 때 Workflow를 시작(Trigger)

2. Github Action에서 스프링 어플리케이션의 jar 파일 빋드(build + test) -> local 환경에서 진행

3. 결과 출력

 

CD 과정(자동 배포)

  1. main, develop 브랜치 Merge가 됬을 때 Workflow 시작
  2. Github Action에서 스프링 어플리케이션 jar 파일 빌드(build + test)
    • 실제 배포 환경(prod), 개발 환경(dev) 환경에서의 Test 진행
    • 본 프로젝트에서는 Rest docs를 사용하기 때문에 Test 과정이 필요
  3. 생성된 jar파일을 Dockerfile로 빌드(이미지 생성)
  4. Docker hub 로그인 및 이미지 push
  5. Github action에서 SSH 접속 방식으로 NCP 서버에 접근, Docker hub에서 이미지 pull, docker-compose 실행
  6. 배포 완료

CI.yml

자세한 설명은 생략하고, 주석을 통해 설명하겠습니다.

참고로, 짠지 사이드 프로젝트는 Spring boot 3.1.1 을 사용하고 있어, JDK 17을 설치했습니다.(Gradle 8.1.1)

name: CI

# feat/*, develop 브랜치에 push or pr 작업 요청시 작동
on:
  push:
    branches:
      - feat/*
  pull_request:
    branches:
      - develop
      - feat/*

permissions: write-all

# 실행할 작업들
jobs:
  CI:
    runs-on: ubuntu-latest
    #Build test를 위한 테스트 DB Setting
    services:
      mysql:
        image: mysql
        env:
          MYSQL_DATABASE: jjanji
          MYSQL_ROOT_PASSWORD: 1234
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

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

      # Gradle 권한 부여
      - name: Grant Execute permission for gradlew
        run: chmod +x gradlew
        shell: bash

      #Build & Test
      - name: Build With Gradle
        run: ./gradlew clean build
        shell: bash

      #테스트 결과 출력
      - name: Publish Unit Test Result
        uses: EnricoMi/publish-unit-test-result-action@v1
        if: ${{ always() }}
        with:
          files: build/test-results/**/*.xml

Dockerfile

FROM openjdk:17-alpine
EXPOSE 8080
ARG JAR_FILE=/build/libs/jjanji-0.0.1-SNAPSHOT.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=dev", "/app.jar"]

짠지 사이드 프로젝트는 Spring boot 3.1.1 을 사용하고 있어, JDK 17 이미지를 세팅해주었습니다.

jar파일이 생성되는  /build/libs 경로를 JAR_FILE에 초기화하고, app.jar 로 복사해주었습니다.

또한, 개발 서버 환경의 yml 파일이 사용될 수 있도록  profile 옵션을 주었습니다.

(현재는 개발 서버 환경에 CD를 진행하기 때문에 "-Dspring-profiles.active=dev"을 사용했습니다. 실제 배포 환경에서는 별도의 Dockerfile를 사용하고 "-Dspring-profiles.active=prod"만 차이가 있습니다.)

Docker-compose.yml

version: "3" #버전 지정

services: #컨테이너 설정
        web:
                container_name: zzanz-dev
                image: rnqhstlr2297/zzanz-dev
                restart: always
                ports:
                        - "8080:8080"

docker hub에서 pull할 이미지명을 지정해준다. "${도커 허브 계정명}/${도커 허브 레포지토리 명}"

요청이 들어오는 host의 포트와 도커 컨테이너의 포트를 매핑 시켜준다.

CD.yml

name: CD

# main, develop 브랜치에 push 작업 요청시 작동
on:
  push:
    branches:
      - main
      - develop

permissions:
  contents: read

# 실행할 작업들
jobs:
  CD:
    runs-on: ubuntu-latest
    steps:
      #JDK setting
      - name: Checkout
        uses: actions/checkout@v3
      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'adopt'

      #dev, prod 환경에 맞는 .yml 생성
      # create application-dev.yml
      - name: make application-dev.yml
        if: contains(github.ref, 'develop') # branch가 develop인 경우
        run: |
          cd ./src/main/resources
          touch ./application-dev.yml
          echo "${{ secrets.PROPERTIES_DEV }}" > ./application-dev.yml
        shell: bash

      # Gradle 권한 부여
      - name: Grant Execute permission for gradlew
        run: chmod +x gradlew
        shell: bash

      # develop build(with test)
      - name: Develop Build With Test
        if: contains(github.ref, 'develop')
        env:
          SPRING_PROFILES_ACTIVE: dev
        # 오류 발생시 오류 추적을 위해 --stacktrace 추가
        run: ./gradlew clean build --stacktrace
        shell: bash

      # docker build & push
       # 개발 서버용(develop) Docker build & push
      - name: Docker build & push to dev 
        if: contains(github.ref, 'develop')
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_REPO }}/zzanz-dev .
          docker push ${{ secrets.DOCKER_REPO }}/zzanz-dev

      # SSH 접속 및 배포
      # deploy to develop
      - name: Deploy to Develop
        uses: appleboy/ssh-action@master
        id: deploy-dev
        if: contains(github.ref, 'develop')
        with:
          host: ${{ secrets.DEV_SERVER_IP }}
          username: ${{ secrets.DEV_SERVER_USER }}
          password: ${{ secrets.DEV_SERVER_PASSWORD }}
          port: ${{ secrets.DEV_SERVER_PORT }}
          script: |
          	# 배포 서버 접속시 위치인 "/root" 위치에 docker-compose 파일 존재
            sudo docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
            sudo docker rm -f $(docker ps -q -a)
            sudo docker pull ${{ secrets.DOCKER_REPO }}/zzanz-dev
            docker-compose up -d
            docker image prune -f

이전 CI.yml 과 다르게 각 환경별(dev, prod) application.yml 파일을 생성하였고, 해당 환경의 yml파일을 사용해 build 및 테스트를 진행했습니다.

참고로, ${{ secrets.~ }} 로 지정한 시크릿 변수들은 아래와 같이 미리 정의해두었습니다.

결과

결과를 보시게 되면 정상적으로 동작하는 것을 볼 수 있습니다.

트러블 슈팅(삽질....)

앞서 말했듯이 짠지 사이드 프로젝트는 Rest docs를 사용합니다.

Github action을 사용해서 CI/CD 과정을 마치고 서버에 정상적으로 배포가 되었는데, Rest docs의 결과물인 index.html에 접속하지 못하는 문제가 발생했습니다.

서버에 접근하지 못하는 것이 아니라, 서버에 index.html이 없는것이 문제였습니다.(Local에서는 있었는데?......)

 

서버에 배포된 jar파일에 index.html이 있는지 확인해보았는데..어?...왜없지??....

GitHub Action CD.yml workflow에서 build시 제대로 test -> asciidoctor -> copyDocument -> bootJar 순으로 동작되고, 그 결과과 정상적인지 확인하기 위해서 build 시 --info 옵션을 통해 로그를 찍어보았습니다.

 

asciidoctor 동작시 아래와 같이 정상적으로 build.gradle 파일 설정 정보인  build/docs/asciidoc 하위 디렉토리 위치에 index.html이 복사된것을 로그로 볼 수 있었습니다.

그리고, bootJar 동작시 로그를 살펴보았는데, 앞서 생성한 경로에 html 파일을 찾지 못한다고 출력되었습니다. 왜?.....

아래 프로젝트  build.gradle 설정 정보에 따르면,

booJar시 build/docs/asciidoc 하위 디렉토리 위치의 html 파일을 static/docs 로의 copy 태스크로 넣어주고  jar 파일에 직접 문서 파일을 넣어주게 됩니다. (즉 build 패키지에 들어가게 된다.)

공식 문서에서 이렇게 나와있는데 왜?.....안돼????.....

공식문서

혹시나 Github Action에서 asciidoctor 플러그인이 생성한 HTML5 문서를 인식하지 못하나? 싶어서 아래와 같이 바꾸어보았습니다.

즉, asciidoctor 플러그인이 생성한 모든 변환 결과물을 복사하도록 바꾸었습니다.

그 결과.. 정상적으로 index.html 파일이 들어온것을 볼 수 있습니다. 

당연하지 asciidoctor 플러그인이 생성한 모든 파일을 가져오는 거니깐...!!!

근데 이유가 도대체 뭐야?....왜 HTML5 문서만 가져오는건 안되는거지?.... 아직 이유를 정확히 찾지 못했지만..일단 해결..

 

참고자료