Docker Multi-stage Builds, Layered JAR로 빌드 성능과 이미지 최적화하기
현재 동아리에서 Docker 컨테이너를 활용하여 여러 서비스를 운영하고 있으며, 모든 서비스는 Dockerfile을 통해 빌드 및 배포되고 있습니다. 그러나 Gradle 빌드와 Docker 이미지 빌드를 포함한 전체 소요 시간이 평균 5분에 달해, 라즈베리 파이 4 환경을 고려할 때에도 비효율적이라는 판단에 이르게 되었습니다.
이를 해결하기 위해 Docker Multi-stage Builds와 Layered JAR를 도입했습니다. Dockerfile 내에서 빌드 단계와 런타임 단계를 분리하고, JAR 파일을 계층화함으로써 빌드 속도를 최적화하는 동시에 최종 이미지 크기도 효율적으로 줄일 수 있었습니다.
본 글에서는 이러한 멀티 스테이지 빌드와 레이어드 JAR의 적용 과정을 공유하며, 동일한 문제를 겪고 있는 분들께 실질적인 도움을 드리고자 합니다.
0. 프로젝트 환경
- Raspberry Pi 4
- Ubuntu 24.04.1 LTS
- Docker 27.3.1
- Spring Boot 3.3.5
- Gradle 8.11.1
- JDK 21
1. 기존 빌드 방식의 한계
현재 Docker 빌드 프로세스는 Gradle을 사용해 JAR 파일을 생성하고, 이를 Dockerfile로 감싸 최종 이미지를 생성하는 방식으로 구성되어 있습니다. 그러나 이 방식은 다음과 같은 문제를 가지고 있습니다.
1. 빌드 시간의 비효율성
- Gradle 빌드 과정에는 소스 코드 컴파일, 테스트 실행, JAR 파일 생성 등이 포함되어 있어 상당한 시간이 소요됩니다.
- Docker 이미지 빌드 시 `COPY` 명령어와 이미지 계층 생성 과정에서 추가적인 시간 지연이 발생합니다.
2. 이미지 크기의 비효율성
- 단일 단계 빌드에서는 빌드에 사용된 모든 종속성과 런타임 종속성이 최종 이미지에 포함됩니다.
- 이로 인해 이미지 크기가 커져 저장 공간을 많이 차지하고, 이미지 전송 속도도 느려집니다.
3. 보안 취약성
- 애플리케이션 실행에 불필요한 빌드 도구나 종속성이 이미지에 포함되어 보안 위험이 증가할 가능성이 있습니다.
4. 비효율적인 캐싱
- Docker의 빌드 캐시 기능은 이전 단계에 변경 사항이 발생할 경우 이후 단계의 캐시를 무효화합니다.
- 이로 인해 전체 레이어가 재빌드되어 불필요한 리소스 소모가 발생합니다.
Dockerfile
# 단일 단계에서 빌드 및 실행
FROM gradle:8.11.1-jdk21 AS app
WORKDIR /app
# Gradle 파일 복사 및 의존성 설치
COPY build.gradle settings.gradle /app/
RUN gradle dependencies --stacktrace
# 소스 코드 복사 및 애플리케이션 빌드
COPY src /app/src
RUN gradle bootJar --no-daemon --stacktrace
# JAR 파일 실행 (Layered JAR 적용하지 않음)
ENTRYPOINT ["java", "-Dspring.profiles.active=stage", "-jar", "build/libs/*.jar"]
Jenkinsfile
def buildAndPushDockerImage() {
sh """
DOCKER_BUILDKIT=1 docker build -f ${env.DOCKERFILE_PATH} -t ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER} .
docker tag ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER} ${env.DOCKER_HUB_REPO}:${env.DEPLOY_CONTAINER}
docker push ${env.DOCKER_HUB_REPO}:${env.DEPLOY_CONTAINER}
docker logout
"""
}
2. Docker Multi-stage Builds를 통한 빌드, 이미지 최적화
Docker Multi-stage Builds는 빌드 환경과 런타임 환경을 분리하여 최적화된 Docker 이미지를 생성하는 빌드 방식입니다. 이를 통해 최종 이미지는 더 작고, 더 안전하며, 유지 관리하기 쉬운 형태로 개선됩니다. Multi-stage Builds는 여러 `FROM` 문을 사용해 각 단계의 역할을 분리하며, Docker의 자동 병렬 처리를 활용해 효율적으로 빌드 작업을 수행합니다. 멀티 스테이지 빌드의 주요 장점은 다음과 같습니다.
1. 더 작은 이미지 크기
- 빌드 환경과 런타임 환경을 분리하여 애플리케이션 실행에 필요한 최소한의 구성 요소만 포함할 수 있습니다.
- 이미지 크기가 작아져 전송 속도가 빨라지고, 저장 공간 요구 사항이 감소됩니다.
2. 보안 위험 감소
- 빌드 종속성(빌드 도구, 컴파일러 등)을 최종 이미지에서 제거하여 이미지의 공격 표면을 줄일 수 있습니다.
- 런타임 환경에만 필요한 구성 요소로 제한하여 보안 취약성의 위험을 최소화할 수 있습니다.
3. 유지 관리성 향상
- 빌드 단계와 런타임 단계를 분리함으로써 Dockerfile의 구성을 단순화할 수 있습니다.
- 각 단계가 독립적으로 설계되어 특정 단계의 수정이 다른 단계에 영향을 주지 않도록 할 수 있습니다.
4. 빌드 속도 향상
- Docker 빌드 캐시를 활용하여 변경된 레이어만 재빌드할 수 있습니다.
- 단계별 캐시를 효과적으로 재사용하여 전체 빌드 시간을 단축할 수 있습니다.
5. 병렬 작업 지원
- 각 빌드 단계를 병렬로 처리할 수 있어, 빌드 과정을 더 효율적으로 진행할 수 있습니다.
- 순차적으로 실행되던 기존 빌드 방식보다 시간 절약 효과가 뛰어납니다.
6. 유연성 제공
- 빌드 단계마다 최적화된 이미지를 지정할 수 있어 각 단계에 적합한 환경을 제공할 수 있습니다.
- 빌드 단계는 빌드 도구가 포함된 이미지를, 런타임 단계는 경량화된 이미지를 사용할 수 있습니다.
3. Spring 프로젝트에 멀티 스테이지 빌드 적용하기
1. 빌드 단계 정의
FROM gradle:8.11.1-jdk21 AS build
WORKDIR /app
`FROM gradle:8.11.1-jdk21`
Gradle 8.11.1 버전과 JDK 21을 포함한 공식 이미지를 사용하여 빌드 단계를 정의합니다. 프로젝트의 버전과 호환되지 않을 경우, Gradle 공식 Docker 이미지에서 적합한 이미지를 선택해 사용할 수 있습니다.
`AS build`
Dockerfile에서 `AS` 키워드를 사용해 단계명(스테이지명)을 지정합니다. 이를 통해 다른 단계에서 해당 단계를 `COPY --from=build`와 같은 형태로 참조할 수 있습니다. `AS`를 사용하지 않으면, Docker는 자동으로 0부터 시작하는 숫자 ID를 부여합니다.
`WORKDIR /app`
컨테이너 내부에서 작업 디렉토리를 `/app`으로 설정합니다. 이후 모든 명령은 이 디렉토리에서 실행됩니다. 작업 디렉토리를 명시적으로 설정하면, 경로 관리가 간편해지고 예기치 않은 경로 문제를 방지할 수 있습니다.
2. 의존성 캐싱 및 설치
COPY build.gradle settings.gradle /app/
RUN gradle dependencies --parallel --stacktrace
`COPY build.gradle settings.gradle /app/`
Gradle 설정 파일(`build.gradle`, `settings.gradle`)을 컨테이너의 `/app/` 디렉토리에 복사합니다. Docker는 이 레이어에 포함된 파일이 변경되지 않는 한 캐시를 재사용합니다. 이로 인해 Docker 빌드 시 매번 의존성을 다시 다운로드하지 않고, 이전에 설치된 의존성을 그대로 재사용하여 빌드 속도를 단축합니다.
`RUN gradle dependencies --parallel --stacktrace`
Gradle 의존성을 확인하고 필요한 패키지를 다운로드하여 Gradle 캐시에 저장합니다.
- `--parallel`: 의존성이 없는 작업을 병렬로 실행하여 처리 속도를 향상시킵니다.
- `--stacktrace`: 빌드 실패 시 상세한 오류 정보를 출력하여 디버깅을 용이하게 합니다.
3. 소스 코드 복사 및 애플리케이션 빌드
COPY src /app/src
RUN gradle build -x test --parallel --stacktrace
`COPY . /app/src`
프로젝트의 전체 소스 코드를 컨테이너의 `/app/src` 디렉토리에 복사합니다. 이 단계에서 소스 코드가 변경되면 Docker는 이후 레이어를 다시 빌드하게 됩니다.
`RUN gradle build -x test`
Gradle의 `build` 태스크를 실행하여 프로젝트를 빌드합니다. `-x test` 옵션을 사용하여 테스트 태스크를 제외하고 빌드를 수행합니다. 테스트를 생략하고 애플리케이션 빌드(컴파일, 패키징 등)에만 집중하여 빌드 시간을 단축시킵니다. 테스트를 통해 코드 품질을 검증해야 하는 경우에는 `-x test` 옵션을 제거하는 것을 권장합니다.
4. 런타임 단계 정의
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
`FROM eclipse-temurin:21-jre`
경량화된 JRE 이미지를 사용하여 런타임 환경을 구성합니다. JRE는 JDK보다 작고 애플리케이션 실행에 필요한 최소한의 환경만 포함하므로 컨테이너 크기를 줄이는 데 도움됩니다.
5. 빌드된 JAR 파일 복사
COPY --from=build /app/build/libs/clab.jar clab.jar
`COPY --from=build`
이전 `build` 단계에서 생성된 결과물을 현재 런타임 단계로 복사합니다. 멀티 스테이지 빌드의 기능 중 하나로, 빌드 단계에서 필요한 결과물만 가져올 수 있습니다.
`/app/build/libs/clab.jar`
빌드 단계에서 생성된 JAR 파일의 경로입니다.
`clab.jar`
런타임 단계에서 사용할 파일명을 `clab.jar`로 간단하게 지정합니다.
6. 런타임 설정
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=stage", "clab.jar"]
`EXPOSE 8080`
컨테이너가 외부와 통신할 수 있도록 8080 포트를 노출합니다.
`ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=stage", "clab.jar"]`
컨테이너가 실행될 때 Spring Boot 애플리케이션을 시작하는 명령어입니다. `stage`는 활성화할 Spring 프로파일을 의미합니다.
Dockerfile
# 1. 빌드 단계
FROM gradle:8.11.1-jdk21 AS build
WORKDIR /app
# Gradle 파일 복사 및 의존성 설치
COPY build.gradle settings.gradle /app/
RUN gradle dependencies --parallel --stacktrace
# 소스 코드 복사 및 애플리케이션 빌드
COPY src /app/src
RUN gradle build -x test --parallel --stacktrace
# 2. 런타임 단계
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
# 빌드 단계에서 생성된 JAR 파일 복사
COPY --from=build /app/build/libs/clab.jar clab.jar
# 런타임 설정
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=stage", "clab.jar"]
4. Layered JAR 적용하여 빌드 캐시 극대화하기
Layered JAR는 Java 9, Spring Boot 2.3.0부터 도입된 기능으로, 애플리케이션 JAR 파일을 여러 레이어로 나눠 구성합니다. 이를 통해 Docker와 같은 컨테이너 환경에서 빌드 캐시를 최대한 활용할 수 있습니다. 레이어를 구성하는 주요 목적은 애플리케이션의 변경 빈도에 따라 계층화하여, 변경되지 않은 레이어는 캐시를 재사용하고 변경된 레이어만 업데이트할 수 있도록 하는 것입니다. 이를 통해 Docker 이미지 빌드 속도를 크게 향상시킬 수 있습니다.
기본 레이어 구성 (Spring Boot 3.2.0 이상)
커스텀 레이어를 설정하지 않을 경우 Layered JAR는 기본적으로 다음의 네 가지 레이어로 구성됩니다.
/app
├── dependencies
│ ├── spring-boot-starter-web.jar
│ ├── spring-data-jpa.jar
│ └── (기타 변경 가능성이 낮은 런타임 의존성)
│
├── spring-boot-loader
│ ├── org/
│ │ └── springframework/
│ │ └── boot/
│ │ └── loader/
│ │ ├── launch/
│ │ │ ├── JarLauncher.class
│ │ │ └── (기타 실행 관련 클래스)
│ │ ├── ExecutableArchiveLauncher.class
│ │ └── (기타 로더 클래스)
│
├── snapshot-dependencies
│ ├── some-library-SNAPSHOT.jar
│ └── (기타 SNAPSHOT 버전 의존성)
│
├── application
│ ├── BOOT-INF/
│ │ ├── classes/
│ │ │ ├── com/
│ │ │ │ └── example/
│ │ │ │ └── Application.class
│ │ │ └── (기타 애플리케이션 클래스)
│ │ └── resources/
│ │ ├── application.properties
│ │ └── (기타 리소스 파일)
│
└── META-INF/
└── MANIFEST.MF
1. `dependencies`
- 변경 가능성이 낮은 런타임 의존성(JAR 파일)을 포함합니다.
- e.g. `spring-boot-starter-web`, `spring-data-jpa`
2. `spring-boot-loader`
- Spring Boot의 JAR 실행 로더 클래스를 포함합니다.
- e.g. `org.springframework.boot.loader.launch.JarLauncher` (Spring Boot 3.2.0 이상)
- e.g. `org.springframework.boot.loader.JarLauncher` (Spring Boot 3.2.0 미만)
3. `snapshot-dependencies`
- SNAPSHOT 버전의 의존성을 포함합니다.
- 자주 변경될 가능성이 있으므로 독립된 레이어로 관리됩니다.
4. `application`
- 애플리케이션의 소스 코드와 리소스 파일을 포함합니다.
- e.g. `src/main/java`, `src/main/resources`
Layered JAR 활성화 하기
Layered JAR를 활성화하려면 `build.gradle`에 다음 설정을 추가해야 합니다.
Gradle 6.0 이상
bootJar {
layered {
enabled = true
}
}
Gradle 5.x 이하
bootJar {
layered()
}
멀티 스테이지 빌드에 Layered JAR 적용하기
Dockerfile
# 1. 빌드 단계
FROM gradle:8.11.1-jdk21 AS build
WORKDIR /app
# Gradle 파일 복사 및 의존성 설치
COPY build.gradle settings.gradle /app/
RUN gradle dependencies --stacktrace
# 소스 코드 복사 및 빌드
COPY src /app/src
RUN gradle bootJar --no-daemon --build-cache --stacktrace
# JAR 파일에서 레이어 추출
RUN java -Djarmode=layertools -jar build/libs/*.jar extract \
&& ls -l /app \
&& ls -l /app/dependencies \
&& ls -l /app/spring-boot-loader \
&& ls -l /app/snapshot-dependencies \
&& ls -l /app/application
# 2. 런타임 단계
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
# 레이어별로 복사
COPY --from=build /app/dependencies/ ./
COPY --from=build /app/spring-boot-loader/ ./
COPY --from=build /app/snapshot-dependencies/ ./
COPY --from=build /app/application/ ./
# 애플리케이션 실행
ENTRYPOINT ["java", "-Dspring.profiles.active=stage", "org.springframework.boot.loader.launch.JarLauncher"]
변경 사항
1. 실행 가능한 JAR 파일 생성
RUN gradle bootJar --no-daemon --build-cache --stacktrace
Gradle의 `bootJar` 태스크를 실행하여 Spring Boot 애플리케이션의 실행 가능한 JAR 파일을 생성합니다. 생성된 JAR 파일은 기본적으로 `build/libs/` 디렉토리에 위치합니다.
- `--no-daemon`: Gradle 데몬을 비활성화하여 Gradle 명령을 실행할 때마다 새 프로세스를 시작하도록 합니다. 각 빌드가 독립적으로 실행되도록 설정하여 여러 빌드가 병렬로 실행될 경우 충돌이 발생하거나 메모리 사용량이 증가하는 것을 방지합니다.
- `--build-cache`: Gradle 빌드 캐시를 활성화하여 작업(Task) 결과를 저장하고 재사용합니다. 이전에 실행된 빌드 작업과 입력값이 동일한 경우, 작업 결과를 캐시에서 재사용하여 불필요한 재실행을 방지합니다.
- `--stacktrace`: 빌드 실패 시 자세한 오류 메시지를 출력하여 디버깅을 용이하게 합니다.
2. JAR 파일에서 레이어 추출
RUN java -Djarmode=layertools -jar build/libs/*.jar extract
생성된 JAR 파일의 내용을 레이어별로 추출합니다. 작업 디렉토리(`/app`)에 레이어별 디렉토리가 생성됩니다.
- `-Djarmode=layertools`: JAR 파일을 `layertools` 모드로 실행하여 내부 레이어를 추출할 수 있도록 합니다.
- `extract`: JAR 파일을 네 개의 레이어로 나눕니다.
3. 컨테이너에 레이어별로 복사
COPY --from=build /app/dependencies/ ./
COPY --from=build /app/spring-boot-loader/ ./
COPY --from=build /app/snapshot-dependencies/ ./
COPY --from=build /app/application/ ./
`COPY` 명령을 사용해 빌드 단계에서 추출된 4개의 레이어를 런타임 이미지에 복사합니다. Docker의 빌드 캐시를 효과적으로 활용하기 위해 변경 가능성이 낮은 순서로 복사합니다.
`dependencies` > `spring-boot-loader` > `snapshot-dependencies` > `application`
4. 애플리케이션 실행
ENTRYPOINT ["java", "-Dspring.profiles.active=stage", "org.springframework.boot.loader.launch.JarLauncher"]
컨테이너가 실행될 때 Spring Boot 애플리케이션을 시작하는 명령어를 정의합니다. Spring Boot 3.2.0 이전 버전에서는 `org.springframework.boot.loader.JarLauncher`를 지정해야 합니다.
5. 적용 전후 성능 분석
빌드 속도: 운영 서버에서 테스트하기에 무리가 있어 비슷한 라즈베리 파이 5 환경에서 버전 정보를 모두 통일한 상태로 진행했습니다.
- 적용 전: 2분 37초

- 적용 후: 2분 10초

빌드 속도가 2분 37초에서 2분 10초로 줄어들며, 17.19% 개선되었습니다.
이미지 크기
- 적용 전: 596.09 MB

- 적용 후: 187.27 MB

이미지 크기가 596.09 MB에서 187.27 MB로 줄어들며, 68.58% 개선되었습니다.
6. 마치며
베어메탈 환경에서 컨테이너 환경으로 전환하며 CD 파이프라인을 구축했던 기억이 납니다. 당시에는 Docker, Nginx, Jenkins 등 모든 것이 새롭고 복잡하게 느껴져 일주일 내내 어려움을 겪었었는데, 지금은 익숙해져서인지 문서도 쉽게 이해되고, 새로운 기술도 빠르게 습득하는 것 같습니다.
다만 이런 작업을 할 때마다 항상 아쉬움이 남습니다. 작업 당시에는 최선을 다했고 더는 개선할 부분이 없어보이지만, 시간이 지나면 부족하고 고쳐나가야 할 부분이 많이 보입니다. 그만큼 성장했다는 증거이기도 하지만, 처음에 왜 더 잘하지 못했을까 하는 아쉬움이 많이 남습니다.
멀티 스테이지 빌드와 레이어드 JAR 작업을 마치고 정신을 차려보니 3일이라는 시간이 흘러있네요. 많은 시간을 투자한 만큼 새로운 지식을 많이 습득했고, 특히 이미지 크기에서 큰 개선을 이뤄내어 매우 보람찬 시간이었습니다. 저의 경험이 비슷한 문제를 겪고 있는 분들께 실질적인 도움이 되었으면 좋겠습니다.