Github Actions, Jib와 캐싱을 통해 CI 최적화하기

    CI/CD 파이프라인에서 도커 이미지 빌드는 종종 전체 배포 시간의 상당 부분을 차지합니다. 빠른 피드백을 원하는 개발자들에게 이러한 상황은 별로 달갑지않죠. 특히 규모가 큰 프로젝트나 잦은 배포가 필요한 환경에서는 이 비용을 최소화하고 싶을 겁니다. 이번 포스팅에서는 기존 CI/CD 파이프라인을 최적화하는 과정에서, 구글의 Jib를 적용하며 겪었던 고민과 문제 해결 과정을 공유하려고 합니다.

    도커 빌드의 문제점과 Jib 소개

    기존 워크플로우의 한계와 고민점

    기존에 사용하던 GitHub Actions 워크플로우는 다음과 같은 절차로 구성되어 있었습니다.

    1. Gradle로 Java 애플리케이션 빌드
    2. Docker를 사용해 이미지 빌드
    3. Docker Hub에 이미지 푸시
    4. 원격 서버에 배포

    이 과정에서 다음과 같은 고민들이 있었습니다.

    • 분리된 빌드 과정: Gradle 빌드와 Docker 빌드가 별도로 진행되어 전체 파이프라인이 복잡하고 비효율적으로 느껴졌습니다.
    • Docker 의존성: Docker 데몬이 필요한 방식이 GitHub Actions 환경에서 최적인지 고민했습니다. 특히 빌드 서버에 Docker가 반드시 설치되어야 한다는 제약이 불필요하게 느껴졌습니다.
    • 비효율적인 레이어 캐싱: 코드의 작은 부분만 변경해도 전체 이미지를 재빌드하는 경우가 많아 "어떻게 하면 변경된 부분만 효율적으로 빌드할 수 있을까?"라는 고민이 있었습니다.
    • 긴 빌드 시간: 전체 워크플로우가 평균 3분 이상 소요되어 개발 속도에 영향을 주었습니다. 매번 개발 흐름이 끊기는 듯한 느낌이 들어서, 개발 생산성이 크게 저하되는 것을 느꼈습니다.

     

    Jib란 무엇인가?

    Jib는 Google에서 개발한 Java 애플리케이션을 위한 컨테이너 이미지 빌더입니다. 다음과 같은 장점을 제공합니다.

    • Docker 없이 빌드: Docker 데몬이나 Docker CLI 없이도 컨테이너 이미지를 생성할 수 있음
    • 빠른 빌드: 애플리케이션 변경 사항만 증분 빌드하여 빌드 시간 단축
    • 최적화된 레이어 구성: 의존성, 리소스, 클래스 파일을 별도 레이어로 분리해 캐싱 최적화
    • 빌드와 푸시 통합: 이미지 빌드와 레지스트리 푸시를 한 번에 처리

    Jib를 CI 파이프라인에 통합하기

    기존 워크플로우

    먼저, 기존에 사용하던 GitHub Actions 워크플로우 파일을 살펴보겠습니다

    name: Docker Image CI
    
    on:
      push:
        branches: [ "main" ]
    
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v3
    
          - name: Set Java
            uses: actions/setup-java@v3
            with:
              java-version: '17'
              distribution: 'temurin'
    
          - name: Setup Gradle
            uses: gradle/gradle-build-action@v2
    
          - name: Grant execute permission for gradlew
            run: chmod +x gradlew
            working-directory: ./timepiece
    
          - name: Run build with Gradle Wrapper
            run: ./gradlew clean build
            working-directory: ./timepiece
    
          - name: Build the Docker image
            run: docker build . -t jnamu/timepiece-server:$(date +%s) -t jnamu/timepiece-server
            working-directory: ./timepiece
    
          # docker hub 로그인
          - name: Login docker hub
            uses: docker/login-action@v2
            with:
              username: ${{ secrets.DOCKERHUB_USERNAME }}
              password: ${{ secrets.DOCKERHUB_TOKEN }}
    
          # docker hub 퍼블리시
          - name: Publish to docker hub
            run: docker push --all-tags docker.io/jnamu/timepiece-server
    
          # 원격서버 배포 스크립트 실행
          - name: Deploy remote ssh commands using password
            uses: appleboy/ssh-action@master
            with:
              host: ${{ secrets.HOST }}
              username: ${{ secrets.USER }}
              port: ${{ secrets.PORT }}
              key: ${{ secrets.KEY }}
              script_stop: true
              script: |
                sh timepiece/deploy-timepiece-server.sh

     

    Jib 적용하기

    1. Gradle 설정 변경

    먼저 build.gradle 파일에 Jib 플러그인을 추가합니다

    plugins {
        // 기존 플러그인들
        id 'com.google.cloud.tools.jib' version '3.4.5'
    }
    
    jib {
        from {
            image = 'eclipse-temurin:17-jre'
        }
        to {
            image = 'jnamu/timepiece-server'
            tags = ['latest', "${project.version}"]
        }
    }
    

    2. GitHub Actions 워크플로우 변경

    다음으로 GitHub Actions 워크플로우 파일을 Jib를 사용하도록 변경합니다.

    name: Docker Image CI
    
    on:
      push:
        branches: [ "main" ]
    
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v3
    
          - name: Set Java
            uses: actions/setup-java@v3
            with:
              java-version: '17'
              distribution: 'temurin'
              
          - name: Setup Gradle
            uses: gradle/gradle-build-action@v2
    
          # docker hub 로그인
          - name: Login docker hub
            uses: docker/login-action@v2
            with:
              username: ${{ secrets.DOCKERHUB_USERNAME }}
              password: ${{ secrets.DOCKERHUB_TOKEN }}
    
          - name: Grant execute permission for gradlew
            run: chmod +x gradlew
            working-directory: ./timepiece
    
          # Jib로 빌드 및 푸시
          - name: Build and Push with Jib
            run: ./gradlew jib
            working-directory: ./timepiece
    
          # 원격서버 배포 스크립트 실행
          - name: Deploy remote server
            uses: appleboy/ssh-action@master
            with:
              host: ${{ secrets.HOST }}
              username: ${{ secrets.USER }}
              port: ${{ secrets.PORT }}
              key: ${{ secrets.KEY }}
              script_stop: true
              script: |
                sh timepiece/deploy-timepiece-server.sh

    주요 변경점:

    • Run build with Gradle Wrapper와 Docker 빌드/푸시 단계가 사라짐
    • 대신 ./gradlew jib 명령 하나로 빌드와 푸시를 한 번에 처리

     

    성능 개선: GitHub Actions 캐싱 적용하기

    Jib는 증분 빌드를 지원하지만, GitHub Actions의 Runner는 매 워크플로우마다 새로운 환경에서 실행됩니다. 그리고 이런 특성은 로컬 캐시의 활용을 제한합니다. 이 문제를 해결하기 위해 GitHub Actions의 캐싱 기능을 적용해보겠습니다.

    steps:
      # ... 기존 Java, Gradle 설정 ...
    
      # Cache Gradle dependencies
      - name: Setup Gradle Dependencies Cache
        uses: actions/cache@v3
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }}
          restore-keys: |
            ${{ runner.os }}-gradle-caches-
    
      # Cache Gradle Wrapper
      - name: Setup Gradle Wrapper Cache
        uses: actions/cache@v3
        with:
          path: ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-wrapper-
    
      # Use Gradle build action with caching enabled
      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2
        with:
          gradle-home-cache-cleanup: true
    
      # ... 이후 단계 계속 ...
    

    캐싱 메커니즘과 Jib의 연관성

    1. Gradle 의존성 캐시: ~/.gradle/caches 디렉토리를 캐싱하여 의존성 다운로드 시간을 줄임
    2. Gradle Wrapper 캐시: Gradle Wrapper 자체를 캐싱하여 초기화 시간 단축
    3. 빌드 상태 정보 유지: Jib의 증분 빌드에 필요한 이전 빌드 정보를 워크플로우 실행 간에 보존

    이 캐싱 메커니즘을 통해 GitHub Actions 환경에서도 Jib가 로컬에서 연속 실행되는 것처럼 최적화된 성능을 발휘할 수 있게 되었습니다.

     

    성능 비교 및 최적화 결과

    빌드 시간 비교

    캐싱 적용 후 워크플로우 실행 시간을 비교해보면 큰 차이가 나타납니다

    워크플로우 유형 평균 실행 시간
    기존 방식 (Docker) 3분 18초
    Jib 적용 (캐시 없음) 3분 정도
    Jib 적용 + 캐싱 46초

    특히 캐싱을 적용한 후에는 빌드 시간이 70~80% 감소하는 놀라운 결과를 보였습니다.

     

    이미지 크기 문제와 해결 과정

    Jib를 적용하면서 예상치 못한 문제가 발생했습니다. 기존 Dockerfile에서는 Alpine 베이스 이미지를 사용했었는데, Jib의 기본 설정으로 빌드했을 때 이미지 크기가 눈에 띄게 증가했습니다

    jib {
        from {
            image = 'eclipse-temurin:17-jre'  // 기본 설정
        }
        // ... 나머지 설정 ...
    }
    
    이미지 버전 크기
    기존 Docker 이미지 115.89 MB
    Jib (일반 JRE) 148.8 MB

    "빌드 시간은 개선되었지만 이미지 크기가 커진다면 실제로 이득일까?"

    이미지 크기가 증가하면 컨테이너 시작 시간이 늘어나고, 네트워크 전송 시간도 증가하며, 서버 스토리지 공간도 더 많이 차지하게 됩니다. 사실, 이 경우엔 빌드 시간 감소가 커서 훨씬 좋은 것 같긴 합니다만.. 이미지 크기가 커서 좋을 것은 없으니 다시 줄여보도록 하겠습니다.

    다시 돌아가서, Jib 설정에서 (from)베이스 이미지를 기존의 Alpine 이미지를 사용토록 변경합니다.

    jib {
        from {
            image = 'eclipse-temurin:17.0.11_9-jre-alpine'  // alpine 사용
        }
        // ... 나머지 설정 ...
    }

    결과적으로 이미지 크기를 기존 수준으로 줄이면서도 Jib의 빌드 속도 향상 이점을 유지할 수 있었습니다

    이미지 버전 크기
    기존 Docker 이미지 115.89 MB
    Jib (일반 JRE) 148.8 MB
    Jib (Alpine JRE) 115.65 MB

     

    결론 및 실무 적용 팁

    전체 과정에서 배운 점과 고민 해결

    이번 Jib 도입 과정에서 얻은 교훈들이 있습니다

    1. 도구 선택의 중요성: 초기에는 단순히 "Docker 빌드가 느리니 Jib를 쓰자"라는 단순한 생각으로 접근했지만, 실제로는 각 도구의 특성과 CI 환경과의 통합 방식까지 고려해야 함을 배웠습니다.
    2. 기본 설정의 함정: Jib 사용은 편리하지만 이미지 크기 증가라는 의도치 않은 결과를 가져왔습니다. 새로운 도구를 도입할 때 기본값을 그대로 수용하지 말고, 기존 시스템과 비교하며 세부 설정을 조정해야 한다는 교훈을 얻었습니다.
    3. CI 환경의 특성 이해: GitHub Actions의 Runner가 매번 새롭게 생성된다는 특성을 고려하지 않았던 것이 초기 시도에서 성능 향상이 제한적이었던 원인이었습니다. CI 환경의 특성을 이해하고 그에 맞는 전략(캐싱)을 적용하는 것의 중요성을 깨달았습니다.
    4. 점진적 개선의 가치: 한 번에 모든 것을 완벽하게 만들려 하기보다, 문제를 발견하고 해결해나가는 점진적 접근이 더 효과적이었습니다. 특히 실제 측정 한 데이터를 바탕으로 의사결정을 내리는 것이 중요했습니다.

    주요 이점 요약

    1. 빌드 시간 단축: 캐싱 적용 시 최대 80%까지 빌드 시간 감소 (3분 18초 → 46초)
    2. 워크플로우 간소화: 빌드와 푸시 과정이 통합되어 더 간결하고 유지보수가 쉬운 워크플로우
    3. Docker 의존성 제거: Docker 데몬 없이도 이미지 빌드 가능
    4. 자동화된 레이어 최적화: 효율적인 레이어 분리로 증분 업데이트 시 성능 향상

    실무 적용 시 고려사항

    1. 베이스 이미지 선택: Alpine 이미지는 크기가 작지만, 일부 환경에서 호환성 이슈가 있을 수 있음. 특히 네이티브 라이브러리를 사용하는 경우 주의가 필요합니다. (베이스 이미지는 프로젝트 환경에 맞춰 빌드해서 사용하는게 일반적일 것입니다.)
    2. 캐싱 전략: 프로젝트 규모에 따라 캐시 키와 캐시 정책 조정이 필요합니다. 너무 많은 캐시는 GitHub Actions 스토리지 한도에 영향을 줄 수 있습니다.
    3. CI 환경별 최적화: GitHub Actions 외의 CI 환경(Jenkins, GitLab CI 등과 같이 워커(에이전트)로 동작하는 경우)에서는 해당 환경에 적절한 캐싱 메커니즘을 찾아 적용해야 합니다. 
    4. 빌드 테스트: 실제 배포 전 별도 브랜치에서 충분한 테스트를 진행해야 합니다. 저희도 배포 전 분리된 브랜치에서 여러 차례 검증 후 적용했습니다.
    5. 본문에는 포함되지 않았지만 Github Actions에서 Jib의 캐시를 보존하는 방법도 있다고 합니다. 찾아보시면 도움이 될 것 같습니다.

    마치며

    이번 Jib 도입 과정은 단순한 도구 교체가 아닌, CI/CD 파이프라인 전체를 재고하고 최적화하는 여정이었습니다. 처음에는 "빌드가 느리니 개선하자"라는 단순한 목표로 시작했지만, 과정에서 여러 문제와 고민에 직면하고 이를 해결해나가며 더 깊은 이해와 인사이트를 얻을 수 있었습니다.

    특히 최근 마이크로서비스 환경에서 빌드 시간 단축은 개발자 경험과 전체 시스템 배포 효율성에 큰 영향을 줍니다. 3분이 46초로 줄어든 것은 단순한 숫자 이상의 의미가 있습니다. 개발-테스트-배포 사이클이 빨라지면서 전체 개발 속도가 향상되고, 작은 변경사항도 더 자주, 더 안전하게 배포할 수 있는 기반이 마련된 것입니다.

    여러분의 CI/CD 파이프라인에도 Jib를 적용해보시고, 빌드 시간 단축의 혜택을 경험해보시길 바랍니다.

    작업 PR

     

     

    추가로 알아볼 내용

    Jib의 레이어링 방식 및 증분 빌드 메커니즘

    레이어 최적화 원리

    • 레이어 분리: Jib는 애플리케이션을 여러 논리적 레이어로 분리합니다.
      • 의존성 레이어 (라이브러리)
      • 리소스 레이어 (정적 파일)
      • 클래스 레이어 (컴파일된 코드)
    • 변경 빈도 기반 레이어링: 변경 빈도가 낮은 것부터 높은 순으로 레이어를 배치
      • 기본 이미지(JRE) → 의존성 → 리소스 → 클래스
      • 결과적으로 자주 변경되는 코드만 새로운 레이어로 재빌드됨

    멀티 모듈 프로젝트에서의 Jib 적용 방법

    정확한 내용은 아래 example을 참조하시면 도움이 될 것 같습니다.

    1. 공통 설정 관리 

    // 루트 build.gradle
    subprojects {
      plugins {
        id 'com.google.cloud.tools.jib' apply false
      }
    }
    
    // 모듈별 build.gradle
    apply plugin: 'com.google.cloud.tools.jib'
    
    jib {
      from {
        image = rootProject.ext.baseImage
      }
      to {
        image = "${rootProject.ext.registryPrefix}/${project.name}"
        tags = ['latest', project.version]
      }
    }

    2. 모듈별 빌드 전략

    • 개별 모듈 빌드: ./gradlew :모듈명:jib
    • 전체 빌드: ./gradlew jib (각 모듈 별도 이미지)
    • 선택적 빌드: 특정 모듈만 빌드하는 커스텀 태스크 정의

    3. 공통 베이스 이미지 활용

    • 공통 의존성을 포함한 베이스 이미지 먼저 빌드
    • 각 모듈은 해당 베이스 이미지에서 시작하여 고유 코드만 추가
    jib {
      from {
        image = "${rootProject.ext.registryPrefix}/base-image:${project.version}"
      }
    }

    4. 모듈 간 의존성 처리

    • 모듈 간 의존성이 있는 경우 빌드 순서에 유의
    • Gradle 태스크 의존성을 통해, 항상 올바른 순서대로 빌드되록 보장해야함

    댓글