CI/CD 파이프라인을 설계하면서 “jar 빌드를 어디서 할 것인가?”를 고민해보신 적 있으신가요?

저는 Spring Boot 애플리케이션을 kubernetes에 배포하는 과정에서 CI 파이프라인이 느려지고 Docker 이미지 빌드 시간이 길어지는 문제를 마주했습니다.

CI 서버는 실행될 때마다 새로운 VM을 띄우기에 캐싱이 날아가고 Gradle 의존성이나 Docker 레이어 캐시가 전혀 재사용되지 않습니다.

이 문제에 대해 고민하다 보니 크게 두 가지 접근법이 있었습니다.

  1. CI에서 빌드 후 Dockerfile에 jar만 COPY 하는 방식
  2. 멀티스테이징 Dockerfile로 빌드와 실행을 모두 책임지게 하는 방식

두 방식은 얼핏 보면 비슷해 보이지만 실제로는 효율성과 캐싱 전략에서 차이를 만들어 냅니다.

이번 글에서는 두 접근 방식을 비교하고 캐싱 이슈와 효율성까지 짚어보겠습니다.


1. CI에서 빌드 후 Docker 이미지 생성

  • 흐름:
    1. Github Action CI 실행 시 VM 띄우기

    2. VM안에서 ./gradlew build 실행해 build/libs/*.jar 생성

    3. docker build 실행해 임시 도커 컨테이너에 jar를 COPY

    4. Docker 이미지 말기

      → VM에 jar 디렉토리가 먼저 생기고 → 이 파일을 Docker 컨테이너에 복사해서 사용합니다.

  • 코드
    • Dockerfile 코드
      # open jdk 17 버전의 환경 구성
      FROM openjdk:17-alpine
      
      # build/libs/*.jar → app.jar 로 복사
      ARG JAR_FILE=build/libs/*.jar
      COPY ${JAR_FILE} app.jar
      
      # 실행 시 Spring profiles 지정
      ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=dev, bucket, jwt, oauth", "/app.jar"]
    • CI 코드
      name: CI for Gateway Service
      
      on:
        push:
          branches: [ "main" ]
      
      jobs:
        build-and-deploy:
          runs-on: ubuntu-latest
      
          steps:
      	    # 1. 소스코드 체크아웃
            - name: Checkout code
              uses: actions/checkout@v3
              
            # 2. JDK 세팅
            - name: Set up JDK 17
              uses: actions/setup-java@v3
              with:
                java-version: '17'
                distribution: 'temurin'
      
            # 3. Gradle 캐시 (속도 최적화)
            - name: Cache Gradle packages
              uses: actions/cache@v3
              with:
                path: ~/.gradle/caches
                key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
                restore-keys: |
                  ${{ runner.os }}-gradle-
      
            # 4. Gradle 빌드 (JAR 생성)
            - name: Build with Gradle
              run: ./gradlew clean build -x test
      
            # 5. Docker Hub 로그인
            - name: Log in to Docker Hub
              uses: docker/login-action@v3
              with:
                username: ${{ secrets.{DOCKER_USERNAME} }}
                password: ${{ secrets.{DOCKER_PAT} }}
      
            # 6. Git Commit SHA를 태그로 사용
            - name: Set Image Version Tag
              id: vars
              run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
      
            # 7. Docker 이미지 빌드 및 푸시
            - name: Build and Push Docker Image
              uses: docker/build-push-action@v5
              with:
                context: .
                push: true
                tags: |
                  {dockerhub_repo}/{service_name}:${{ steps.vars.outputs.sha_short }}
                  {dockerhub_repo}/{service_name}:latest
            
            # 이번 블로그 주제는 빌드를 어디에서 하느냐이기 때문에 CD 부분은 생략했습니다.
           

2. 멀티스테이징 Dockerfile

  • 흐름:
    1. Github Action CI 실행 시 VM 띄우기

    2. docker build 실행

    3. Stage1 컨테이너에서 ./gradlew build 실행해 jar 생성

    4. Stage2에서 jar를 가져와 최종 Docker 이미지 말기

      → jar는 VM 디렉토리에 남지 않고 Docker 빌드 과정 안에서 곧바로 이미지로 포함됩니다.

  • 코드
    • Dockerfile
      # 1. Build stage - Gradle 빌드
      FROM eclipse-temurin:17-jdk-jammy AS build
      WORKDIR /workspace
      
      # Gradle wrapper & 빌드 스크립트 복사 (의존성 캐싱 목적)
      COPY gradlew ./
      COPY gradle/wrapper/gradle-wrapper.jar gradle/wrapper/
      COPY gradle/wrapper/gradle-wrapper.properties gradle/wrapper/
      COPY settings.gradle* build.gradle* ./
      RUN chmod +x gradlew
      RUN ./gradlew --no-daemon dependencies || true
      
      # 애플리케이션 소스 복사 후 빌드
      COPY src ./src
      RUN ./gradlew --no-daemon bootJar -x test
      
      # 2. Runtime stage - 실행 전용 이미지
      FROM eclipse-temurin:17-jre-jammy AS runtime
      
      # 비루트 실행 권장
      RUN useradd -ms /bin/bash spring
      USER spring:spring
      
      WORKDIR /app
      # 빌드 산출물(app.jar) 복사
      COPY --from=build /workspace/build/libs/*.jar /app/app.jar
      
      LABEL authors="wixdom"
      
      ENV SERVER_PORT=8000
      EXPOSE 8000
      
      # 실행 시 메모리/타임존 옵션 지정
      ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError -Duser.timezone=Asia/Seoul"
      ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Dserver.port=$SERVER_PORT -jar /app/app.jar"]
    • CI 코드
      name: CI for Gateway Service - Multi Stage
      
      on:
        push:
          branches:
            - main
      
      env:
        REPO: {repo_name}
        IMAGE_NAME: {service_name}
        IMAGE_TAG: ${{ github.ref_name }}-${{ github.run_number }}
      
      jobs:
        build-and-push:
          runs-on: ubuntu-latest
      
          steps:
            # 1. 소스코드 체크아웃
            - name: Checkout source code
              uses: actions/checkout@v3
      
            # 2. Docker Registry 로그인
            - name: Log in to Container Registry
              run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login {registry_url} -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin
      
            # 3. Docker 이미지 빌드 & 푸시 (멀티스테이징 Dockerfile 사용)
            - name: Build and Push Docker Image
              uses: docker/build-push-action@v5
              with:
                context: .
                file: ./Dockerfile
                push: true
                tags: |
                  {registry_url}/${{ env.REPO }}/${{ env.IMAGE_NAME }}:latest
                  {registry_url}/${{ env.REPO }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
      
            # 이번 블로그 주제는 빌드를 어디에서 하느냐이기 때문에 CD 부분은 생략했습니다.

3. 차이점 정리

  • CI 빌드 방식
    • VM → Docker COPY 과정 하나가 더 있음
    • 빌드 실패 시 로그 확인이 명확 (CI 레벨에서 에러 확인 가능)
    • jar가 CI 환경에 산출물로 남음
  • 멀티스테이징
    • 과정 단순 (빌드 → 이미지화)
    • 빌드/런타임 환경을 Dockerfile 안에서 일관되게 관리
    • jar가 VM에 남지 않고 이미지 안에서만 관리됨
구분CI 빌드 방식멀티스테이징 방식
jar 생성 위치Github Actions VM 디스크Docker build Stage 1 컨테이너 파일 시스템
이미지로 복사VM → 도커 컨테이너Stage 1 컨테이너 → Stage 2 이미지
VM에 jar이 남는지O (job 끝날 때까지)X
일관성VM 환경에 따라 달라짐Dockerfile 안에 빌드/런타임 환경 명시 → 일관적

4. 캐싱 문제

여기서 중요한 포인트는 캐싱입니다.

Github Actions에서 runs-on: ubuntu-latest 같은 hosted runner를 쓰면 CI VM은 매번 새로 뜨고 끝나면 삭제됩니다.

따라서 Docker 레이어 캐시나 Gradle 캐시가 모두 날아갑니다.

→ Dockerfile에서 아무리 레이어를 잘 쪼개도 캐싱 효과가 없다는 것이죠.

  • 이걸 해결 하려면?

    1. Self-hosted runner 사용

      내 서버/VM에서 러너를 띄우면 VM이 계속 살아남기 때문에 Docker로 레이어 캐시 유지가 가능합니다.

    2. Github Actions Cache 활용

      빌드 시작 시, cache-from을 통해 Github Actions의 원격 캐시에서 이전에 저장해둔 Docker레이어 캐시 정보를 가져와 로컬 캐시를 구성합니다. 캐시를 사용해 작업 후 만들어진 레이어들을 cache-to를 통해 Github Actions의 원격 캐시에 저장합니다.

      → actions/cache와 Docker 멀티스테이지 빌드의 장점을 모두 활용하는 방식

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: false
          tags: myimage:latest
          # cache-from과 cache-to를 설정
          cache-from: type=gha
          cache-to: type=gha,mode=max

5. 실험해보기

깃허브 액션 플로우를 직접 돌리기에 제약이 있어 로컬 환경에서 깃허브 액션을 시뮬레이션 할 수 있는 act를 활용하여 CI 테스트를 진행했습니다.

  • 로컬 환경에서 act 실행 코드
    # act 실행 (기본 build job)
    act -j build -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest
    
    # -j build: .github/workflows/ci.yml 안의 job 이름 (build 등) 지정
    # -P ubuntu-latest=...: act에서 사용할 Docker 이미지 지정
    
    # 빌드 시간 측정
    time act -j build -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest
    
    # 로컬에서 실행 전 파일 정리
    docker builder prune -a
    rm -rf ~/.act
  • 테스트 케이스 1: CI에서 빌드 후 Docker 이미지 생성
    • 1차 빌드 결과: 3:59.18

    • 2차 빌드 결과(로컬 캐싱): 1:37.52

    • 이미지도 잘 들어왔다!

  • 테스트 케이스 2: 멀티스테이징 Dockerfile
    • 1차 빌드 결과: 4:18.13

    • 2차 빌드 결과(로컬 캐싱): 11.123

    • 이미지도 잘 들어왔다!


6. 결론: 어떤 방식이 더 좋을까?

build-push-action를 사용하여 Docker 레이어 캐싱을 해둘 경우 원격 캐시 저장소에서 캐시를 불러와 시간을 단축할 수 있습니다.

여기서 시간 차이가 나는 부분은 Dockerfile의 내용에서 나타납니다.

CI에서 빌드할 경우 Docker 파일에는 이미지를 마는 코드만 작성되어 있고, Dockerfile에서 빌드할 경우 의존성부터 빌드, 이미지를 마는 코드가 모두 작성되어 있습니다. 그렇기 때문에 캐시를 가져올 수 있는 내용에서 차이가 나고 캐시를 불러왔을 때도 속도 차이가 발생합니다.

→ 두 방법 모두 캐싱이 가능하지만 방식에 차이가 있기에 멀티 스테이징 방법이 더 빠르며, 멀티 스테이징은 하나의 파일에서 일괄적 관리를 통한 단일 책임 관리가 가능합니다.

실제 프로덕트 환경에서는 또 다른 결과 도출될 수 있고, 더 효율적인 방법이 있을 수 있습니다. 정답은 없기에 상황에 맞는 전략을 선택하고 개선해나가는 게 좋을 것 같습니다.

profile
가천대학교에서 상호 성장하는 기술의 장을 만들어갑니다.

0개의 댓글