Spring Boot 애플리케이션을 Docker를 통해 Github Action CI/CD를 적용해 보자

박준수·2024년 3월 7일
0

이것저것

목록 보기
8/9
post-thumbnail

🔍CI/CD의 개념

CI(Continuous Integration) 지속적 통합

CI가 없을 시 : 개발자들은 일명 머지데이라는 날을 통해 모든 분기 소스코드를 병합해야 했다.

  • 굉장히 많은 수작업이 동반되었고 결과적으로 많은 리소스를 낭비하게 됨
  • 개발자들이 코드를 병합할 때 개발자의 실수로 에러가 발생하는 코드를 병합하는 과정이 반복이 된다면, 에러가 발생했을 때 어느 부분에서 발생했는지 디버깅하기 굉장히 어려움

CI : 어플리케이션의 새로운 코드 변경 사항이 정기적으로 빌드 및 테스트 되어 공유 레포지토리에 통합하는 것을 의미

  • CI를 활용한다면 버그를 신속하게 찾아 해결하고,소프트웨어의 품질을 개선하고,새로운 업데이트의 검증 및 릴리즈의 시간을 단축시킬 수 있다.

CD (Continuous Delivery & Continuous Deployment) 지속적 제공 & 지속적 배포

CD가 없을 시 : 서버의 규모가 커져 수십, 수백대로 늘어났다고 가정을 해보았을 때 모든 서버에 일일이 접속해 배포 스크립트를 수동으로 실행시켜야 한다.

CD : 지속적인 서비스 제공 혹은 지속적인 배포를 의미

  • Continuous Delivery는 공유 레포지토리로 자동으로 Release 하는 것
  • Continuous Deployment는 Production 레벨까지 자동으로 deploy 하는 것

CI/CD의 필요성과 각각 무엇을 의미하는 것인지 알아보았다. Github Action을 이용한 CI/CD를 적용하는 방법을 찾아보니 대표적으로 S3 + AWS CodeDeploy를 이용한 방법과 Docker를 이용한 방법이 있었다. 내가 이번에 선택한 방법은 Docker인데, 그 이유는 CodeDeploy의 디버깅 어려움 + AWS EC2를 옮기거나 확장할 때 각 서버별 환경 설정을 맞춰주기가 번거롭다는 게시글 을 보았기 때문이다.

🌊CI/CD Flow

  1. 개발자가 코드 병합을 요청(push or Pull Request)하면 Github Action에서 CI를 진행
    • Build후 Test를 진행
  2. CI 완료 후 CD 진행
    • Docker Image 생성
    • Docker Hub에 생성된 Image Push
    • EC2 서버에 접근
    • 서버 안에서 Docker Image Pull
    • docker run으로 실행

🌈CI/CD 적용 과정

그럼 이제 본격적으로 내가 적용한 CI/CD 과정을 나열해 보겠다.

Spring Boot 프로젝트 생성

  • SpringFramework 3.1.1, JAVA 17, AWS RDS(Mysql 8.0.35)를 기준으로 하였다.
    • src/resources 하위 디렉토리로 서브 모듈을 설정하였는데, application.yml 같은 경우 중요한 키 값들이 담겨져 있기 때문에 private Repository로 하여 관리하였다.

다음과 같이 배포, 로컬, 테스트.yml을 만들었고 application.yml을 간단히 작성하였다.

이때 build.gradle에서 submodule인 application-config를 읽어오기 위해 다음과 같이 설정을 해야했다.

Dockerfile 작성

  • Dockerfile을 간단하게 작성할 수 도 있는데, 공식문서를 확인해보니 도커 이미지를 줄이는 향상된 Dockerfile을 작성하는 방법에 대해 나와있다. Spring Boot Layer Index 이 부분을 확인해 보면 된다.

최적화된 Docker 이미지를 더 쉽게 생성할 수 있도록 Spring Boot는 jar에 레이어 인덱스 파일을 추가하는 기능을 지원합니다. 이 파일은 레이어 목록과 그 안에 포함되어야 하는 jar의 부분을 제공합니다. 인덱스의 레이어 목록은 Docker/OCI 이미지에 레이어를 추가해야 하는 순서에 따라 정렬됩니다. 기본적으로 다음과 같은 레이어가 지원됩니다.
이 계층화는 애플리케이션 빌드 간에 변경될 가능성에 따라 코드를 분리하도록 설계되었습니다. 라이브러리 코드는 빌드 간에 변경될 가능성이 적으므로 자체 레이어에 배치하여 툴링이 캐시에서 레이어를 재사용할 수 있도록 합니다. 애플리케이션 코드는 빌드 간에 변경될 가능성이 더 높으므로 별도의 레이어에 격리됩니다.

즉 jar 파일은 여러 계층으로 분리되어 있는데, 애플리케이션 빌드 간에 변경될 가능성에 따라 애플리케이션의 일부를 분리하여 Docker 이미지 레이어를 더욱 효율적으로 만들 수 있다는 것이다.

FROM bellsoft/liberica-openjdk-alpine:17 as build
WORKDIR /workspace/app

# Copy the built JAR file
COPY build/libs/*.jar .

# Unpack the built application
RUN mkdir -p target/extracted
RUN java -Djarmode=layertools -jar *.jar extract --destination target/extracted

FROM bellsoft/liberica-openjdk-alpine:17
VOLUME /tmp
ARG EXTRACTED=/workspace/app/target/extracted

# Copy over the unpacked application
COPY --from=build ${EXTRACTED}/dependencies/ ./
COPY --from=build ${EXTRACTED}/spring-boot-loader/ ./
COPY --from=build ${EXTRACTED}/snapshot-dependencies/ ./
COPY --from=build ${EXTRACTED}/application/ ./

ENTRYPOINT ["java","-Dspring.profiles.active=dev","org.springframework.boot.loader.JarLauncher"]

  • 우선 빌드를 할 때 bellsoft/liberica-openjdk-alpine:17을 사용하였다. Spring Quickstart Guide에서 추천한 JDK이기 때문이다.
jar {
    enabled = false
}
  • build/libs/*.jar을 복사하는데, 이때 build.gradle에 enabled = false를 해야지 XXX.SNAPSHOT.jar 하나만 생성이 된다. 없을 경우 XXX.SNAPSHOT-plain.jar도 함께 생겨 에러가 발생할 수 있다.
  • 이후 target/extracted 디렉토리를 만들고, RUN java -Djarmode=layertools -jar *.jar extract --destination target/extracted 이 명령어를 실행 해주어야 한다. jar 파일을 레이어 도구로 실행하고 jar파일에서 필요한 파일을 추출하여 target/extracted 디렉토리에 저장되게 한다.
  • 내 spring 버전은 3.1.1 이지만 3.2.3일 때 org.springframework.boot.loader.JarLauncher가 아닌 org.springframework.boot.loader.launch.JarLauncher로 해야했다.
  • 필요한 파일을 빌드하고 ENTRYPOINT로 application-dev.yml을 이용하여 애플리케이션을 실행시키게 한다.

  • 향상된 도커파일로 작성을 했을 경우 도커 이미지는 단 154.11MB밖에 나오지 않았다.

github action workflow 작성

  • Github repository - Actions - Publish Java Package with Gradle을 선택하여 여기서 수정을 하였다.
  • 그렇다면 오른쪽 상단에 Marketplace가 나오는데 여기서 검색을 하여 스크립트를 작성하면 된다.
    ssh-action, gradle-build-action, publish-unit-test-result-action... 이렇게 공식 문서가 있으니 에러가 발생했을 때 github를 찾아 보면 좋을 것 같다.
name: SpringBoot Application CI-CD with Gradle

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

permissions:
  contents: read
  checks: write
  pull-requests: write

jobs:
  spring-boot-ci-cd:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/gradle-build-action@v3
      - name: Build with Gradle
        run: ./gradlew clean build -x test
      - name: Test with Gradle
        run: SPRING_PROFILES_ACTIVE=[test] ./gradlew test

      - name: Publish Test Results
        uses: EnricoMi/publish-unit-test-result-action@v2.15.1
        if: always()
        with:
          files: '**/build/test-results/test/TEST-*.xml'

      - name: Set Version
        id: set_current_date
        run: echo "version=$(date '+%Y-%m-%d-%H-%M-%S')" >> $GITHUB_OUTPUT

      - name: Docker Image Build
        run: |
          docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/springboot:${{ steps.set_current_date.outputs.version }}  .
          docker tag ${{ secrets.DOCKERHUB_USERNAME }}/springboot:${{ steps.set_current_date.outputs.version }} ${{ secrets.DOCKERHUB_USERNAME }}/springboot:latest

      - name: Docker Login
        uses: docker/login-action@v3.0.0
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_PASSWORD }}

      - name: Docker Hub Push
        run: |
          docker push ${{ secrets.DOCKERHUB_USERNAME }}/springboot:${{ steps.set_current_date.outputs.version }}
          docker push ${{ secrets.DOCKERHUB_USERNAME }}/springboot:latest

      - name: Docker Deploy In EC2
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.AWS_EC2_HOST }}
          username: ec2-user
          key: ${{ secrets.AWS_EC2_PRIVATE_KEY }}
          script: |
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/springboot
            docker stop
            docker run --name springboot --rm -d -p 8080:8080 ${{ secrets.DOCKERHUB_USERNAME }}/springboot
            docker system prune -f
  • 내가 작성한 CI/CD Flow이다.
  • workflow가 잘 실행되는지 확인하기 위해 main 브랜치에 push와 pull_request가 될 때 실행이 되도록 하였다.
  • CI를 할 때 빌드를 테스트를 따로 두어 진행하였다.
  • Publish Test Results는 테스트 오류가 발생했을 때 자세하게 에러 로그를 보여줄 수 있다. 따라서 여기 까지가 CI인 것이다.
  • 도커로 CI/CD를 구성했을 때, 도커 허브에 이미지를 올려 버전관리를 할 수 있는 장점이 있다. 따라서 나는 이미지 태그를 현재 시간으로 잡아 버전관리를 했다.
  • 도커 로그인을 하고 hub에 push를 한 다음, EC2에서 도커 허브의 이미지를 pull 받아 실행을 시킨다. 이때 최신 이미지를 pull 받기 위해, Docker Image Build에서springboot:latest도 만들어서 push를 한 것이다. 기존의 컨테이너는 중지하고 사용하지 않는 데이터는 삭제하는 명령어도 추가하였다.

  • 그러면 이렇게 도커 허브에서 이미지 관리를 할 수 있다.

  • 초록색 체크 표시가 나왔으므로 성공이다~!

💥진행중 트러블 슈팅

Build With Gradle 에러


Local에서는 test가 잘 진행되었는데 github action에서는 Build With Gradle에서 자꾸 에러가 발생하였다. 찾아보니 테스트를 실행할 때 데이터베이스와 연결이 안되서 발생한다고 하는데 application-test.yml에서 처음에는 RDS(mysql) 주소로 연결을 했다. 하지만 문제는 마찬가지였다.(왜 그런건진 잘 모르겠다ㅠㅠ) 그런데 보통 test db를 설정할 때에는 인메모리 모드 H2 데이터베이스(테스트 코드가 실행될 때 설정한 데이터베이스가 메모리에 올라가고, 테스트가 종료되면 데이터베이스가 메모리에서 내려가게 된다.)를 사용하는 경우도 많아서 build.gradle에 runtimeOnly 'com.h2database:h2' 을 추가하고 다음과 같이 작성을 하였더니 해결하였다.

Docker Deploy In EC2 에러

Docker Deploy In EC2 부분에서 key: ${{ secrets.AWS_EC2_PRIVATE_KEY }} 에는 EC2생성할 때 받은 .pem 키 값을 넣어야 한다. 키는
-----BEGIN RSA PRIVATE KEY-----
~XXXXX~
-----END RSA PRIVATE KEY-----
이렇게 구성이 되어있는데, 안에 있는 값만 넣지 말고 --- 이것 부터 끝까지 다 넣어야 한다.

🫧느낀점

예전부터 github action가지고 CI/CD를 해보고 싶었는데, 계속 미루고 미루었었다... 참여중인 '글또'커뮤니티에서 같은 조 백엔드 개발자 분이 Jenkins에서 Github Action으로 이전하여 CI/CD를 구축한 글을 보고 이번 기회에 나도 한번 해봐야겠다는 생각으로 해보았다. 향상된 도커 이미지도 작성해보고, 혼자 구축해보고 트러블 슈팅도 해보니 한 층 레벨업이 된 기분이다~

  • 잘못된 정보는 알려주시면 감사하겠습니다.

참고

spring-boot-docker docs
Spring Layering Docker Images docs
github action workflow docs
docker image tag docs
github action + docker ci/cd 예시1
github action + docker ci/cd 예시2
H2 DB 타입
CI-CD란 무엇인가
CI-CD 유튜브1
CI-CD 유튜브2

profile
방구석개발자

0개의 댓글