
CI/CD 파이프라인을 설계하면서 “jar 빌드를 어디서 할 것인가?”를 고민해보신 적 있으신가요?
저는 Spring Boot 애플리케이션을 kubernetes에 배포하는 과정에서 CI 파이프라인이 느려지고 Docker 이미지 빌드 시간이 길어지는 문제를 마주했습니다.
CI 서버는 실행될 때마다 새로운 VM을 띄우기에 캐싱이 날아가고 Gradle 의존성이나 Docker 레이어 캐시가 전혀 재사용되지 않습니다.
이 문제에 대해 고민하다 보니 크게 두 가지 접근법이 있었습니다.
두 방식은 얼핏 보면 비슷해 보이지만 실제로는 효율성과 캐싱 전략에서 차이를 만들어 냅니다.
이번 글에서는 두 접근 방식을 비교하고 캐싱 이슈와 효율성까지 짚어보겠습니다.
Github Action CI 실행 시 VM 띄우기
VM안에서 ./gradlew build 실행해 build/libs/*.jar 생성
docker build 실행해 임시 도커 컨테이너에 jar를 COPY
Docker 이미지 말기
→ VM에 jar 디렉토리가 먼저 생기고 → 이 파일을 Docker 컨테이너에 복사해서 사용합니다.
# 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"]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 부분은 생략했습니다.
Github Action CI 실행 시 VM 띄우기
docker build 실행
Stage1 컨테이너에서 ./gradlew build 실행해 jar 생성
Stage2에서 jar를 가져와 최종 Docker 이미지 말기
→ jar는 VM 디렉토리에 남지 않고 Docker 빌드 과정 안에서 곧바로 이미지로 포함됩니다.
# 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"]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 부분은 생략했습니다.| 구분 | CI 빌드 방식 | 멀티스테이징 방식 |
|---|---|---|
| jar 생성 위치 | Github Actions VM 디스크 | Docker build Stage 1 컨테이너 파일 시스템 |
| 이미지로 복사 | VM → 도커 컨테이너 | Stage 1 컨테이너 → Stage 2 이미지 |
| VM에 jar이 남는지 | O (job 끝날 때까지) | X |
| 일관성 | VM 환경에 따라 달라짐 | Dockerfile 안에 빌드/런타임 환경 명시 → 일관적 |
여기서 중요한 포인트는 캐싱입니다.
Github Actions에서 runs-on: ubuntu-latest 같은 hosted runner를 쓰면 CI VM은 매번 새로 뜨고 끝나면 삭제됩니다.
따라서 Docker 레이어 캐시나 Gradle 캐시가 모두 날아갑니다.
→ Dockerfile에서 아무리 레이어를 잘 쪼개도 캐싱 효과가 없다는 것이죠.
이걸 해결 하려면?
Self-hosted runner 사용
내 서버/VM에서 러너를 띄우면 VM이 계속 살아남기 때문에 Docker로 레이어 캐시 유지가 가능합니다.
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
깃허브 액션 플로우를 직접 돌리기에 제약이 있어 로컬 환경에서 깃허브 액션을 시뮬레이션 할 수 있는 act를 활용하여 CI 테스트를 진행했습니다.
# 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차 빌드 결과: 3:59.18

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

이미지도 잘 들어왔다!

1차 빌드 결과: 4:18.13

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

이미지도 잘 들어왔다!
build-push-action를 사용하여 Docker 레이어 캐싱을 해둘 경우 원격 캐시 저장소에서 캐시를 불러와 시간을 단축할 수 있습니다.
여기서 시간 차이가 나는 부분은 Dockerfile의 내용에서 나타납니다.
CI에서 빌드할 경우 Docker 파일에는 이미지를 마는 코드만 작성되어 있고, Dockerfile에서 빌드할 경우 의존성부터 빌드, 이미지를 마는 코드가 모두 작성되어 있습니다. 그렇기 때문에 캐시를 가져올 수 있는 내용에서 차이가 나고 캐시를 불러왔을 때도 속도 차이가 발생합니다.
→ 두 방법 모두 캐싱이 가능하지만 방식에 차이가 있기에 멀티 스테이징 방법이 더 빠르며, 멀티 스테이징은 하나의 파일에서 일괄적 관리를 통한 단일 책임 관리가 가능합니다.
실제 프로덕트 환경에서는 또 다른 결과 도출될 수 있고, 더 효율적인 방법이 있을 수 있습니다. 정답은 없기에 상황에 맞는 전략을 선택하고 개선해나가는 게 좋을 것 같습니다.