Spring Boot를 경량 Docker 이미지로 패킹하기
1. 개요
Spring Boot를 Docker로 패킹하는 것은 매우 쉽습니다. 이미지가 작고, 빠르게 빌드되고, 안전하게 실행되도록 패킹하려면 몇 가지 추가 단계가 필요합니다. 익숙한 “3줄짜리 Dockerfile”로는 이미지가 보통 수백 MB이고, 코드를 한 줄이라도 수정할 때마다 전체 JAR 파일을 다시 푸시해야 하며, 프로세스가 root 권한으로 실행됩니다.
이 글은 naive Dockerfile에서 멀티스테이지 Dockerfile로 나아갑니다. 계층화된 jar와 최소 JRE를 사용합니다. 그 다음 대안으로 Buildpacks를 사용하는 방법을 살펴봅시다. 마지막으로 Testcontainers를 사용하여 빌드한 이미지를 검증하는 테스트를 작성합니다.
2. 샘플 애플리케이션
order-service라는 작은 서비스를 사용합니다. REST 엔드포인트와 헬스 체크를 위한 Actuator가 있습니다.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
@RestController
@RequestMapping("/orders")
class OrderController {
record Order(long id, String customer, BigDecimal total) {}
@GetMapping("/{id}")
Order find(@PathVariable long id) {
return new Order(id, "Nguyen Van A", new BigDecimal("250000"));
}
}
./mvnw package 명령이 파일을 생성합니다. target/order-service-1.0.0.jar. 이는 코드와 모든 종속성을 포함하는 “fat jar”입니다.
3. Naive Dockerfile과 그 문제점들
많은 사람들이 처음 작성하는 버전은 다음과 같습니다:
FROM eclipse-temurin:21
COPY target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
이 Dockerfile은 실행되지만 네 가지 문제가 있습니다:
- Base 이미지가 너무 큼.
eclipse-temurin:21은 컴파일러,jshell,javadoc등이 포함된 전체 JDK를 포함합니다. 런타임에는 이들이 필요 없습니다. - 모든 것을 위한 단 하나의 레이어. Fat jar는 약 20–30 MB입니다. 이 중 코드는 몇 백 KB에 불과합니다. 코드 한 줄을 수정하면 30 MB 레이어가 변경됩니다. 다시 빌드하고 푸시해야 합니다.
- 빌드 머신에 의존. 이미지는 머신에서 미리 빌드된 JAR 파일이 필요합니다. 각 머신의 JDK와 Maven 버전이 다를 수 있습니다.
- Root 권한으로 실행. 애플리케이션이 해킹되면 공격자는 컨테이너에서 root 권한을 가집니다.
크기 확인:
docker build -t order-service:naive .
docker images order-service
REPOSITORY TAG IMAGE ID SIZE
order-service naive 3f1c9a7e2b10 ~480MB
구체적인 숫자는 base 이미지 버전에 따라 다르지만 ‘hello world’ 서비스에 거의 500MB는 너무 많습니다.
4. 계층화된 jar를 사용한 멀티스테이지 빌드
4.1. 계층화된 jar란 무엇인가
Spring Boot 2.3부터 fat jar는 변경 빈도에 따라 레이어로 미리 분할됩니다:
| 레이어 | 내용 | 변경 빈도 |
|---|---|---|
dependencies |
릴리스 버전 라이브러리 | 드묾 |
spring-boot-loader |
Spring Boot 클래스 로더 | 매우 드묾 |
snapshot-dependencies |
-SNAPSHOT 라이브러리 |
때때로 |
application |
코드와 리소스 | 자주 |
각 부분을 별도의 COPY 명령으로 이미지에 복사합니다. 변경이 적은 것부터 자주 변경되는 것 순서로. Docker는 위의 레이어를 캐시합니다. 코드만 수정하면 application 레이어만 다시 빌드되고 푸시됩니다.
Spring Boot 3.3부터 jarmode=tools로 레이어를 분할합니다:
java -Djarmode=tools -jar target/order-service-1.0.0.jar extract --layers --destination extracted
ls extracted
application dependencies snapshot-dependencies spring-boot-loader
application 디렉토리에는 “축소”된 order-service-1.0.0.jar 파일이 포함됩니다. 이 파일은 dependencies 레이어의 lib/ 디렉토리를 참조합니다. 레이어를 다시 병합한 후에는 평소처럼 java -jar를 실행하면 됩니다.
주의: Spring Boot 3.2 이전의 경우 문법은
java -Djarmode=layertools -jar app.jar extract입니다. 이 경우 진입점은-jar대신org.springframework.boot.loader.launch.JarLauncher입니다.
4.2. 완전한 Dockerfile
# ---------- Stage 1: Maven으로 빌드 ----------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
# ---------- Stage 2: 레이어 분할 ----------
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# ---------- Stage 3: 런타임 ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app
COPY --from=extract /extract/extracted/dependencies/ ./
COPY --from=extract /extract/extracted/spring-boot-loader/ ./
COPY --from=extract /extract/extracted/snapshot-dependencies/ ./
COPY --from=extract /extract/extracted/application/ ./
USER spring:spring
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
각 부분 설명:
buildStage:src를 복사하기 전에COPY pom.xml을 실행하고dependency:go-offline을 실행합니다. 코드만 수정하면 Docker는 로드된 종속성을 포함하는 레이어를 다시 사용합니다. Maven Central에서 전체를 다시 로드할 필요가 없습니다.extractStage: JAR을 네 개의 레이어 디렉토리로 분할합니다. 이 단계는 중간 단계일 뿐 최종 이미지에는 나타나지 않습니다.- 런타임 Stage:
21-jre-alpine을 사용하여 Alpine Linux 기반의 JRE만 포함합니다. - 네 개의
COPY명령: 각 명령은 Docker 레이어를 만들고, 변경이 적은 것에서 자주 변경되는 것 순서입니다.application에는 “축소”된 JAR 파일(app.jar)이 있고dependencies에는lib/디렉토리가 있기 때문입니다. 병합 후/app은app.jar+lib/구조를 가집니다. USER spring:spring: Java 프로세스는 더 이상 root 권한으로 실행되지 않습니다.JAVA_TOOL_OPTIONS: JVM은 이 변수를 자동으로 읽습니다.MaxRAMPercentage=75.0은 힙이 컨테이너의 메모리 제한의 최대 75%를 사용하도록 합니다.ExitOnOutOfMemoryError는 메모리가 부족할 때 JVM이 완전히 종료되도록 합니다. 오케스트레이터가 컨테이너를 다시 시작하게 하기 위해서입니다.HEALTHCHECK: Alpine에는 BusyBox의wget이 미리 있으므로 추가curl을 설치할 필요가 없습니다.- 배열 JSON 형식
ENTRYPOINT:java는 PID 1이며 컨테이너가 중지될 때SIGTERM신호를 받습니다. 덕분에 Spring Boot는 graceful shutdown을 할 수 있습니다.
4.3. .dockerignore을 잊지 마세요
build 명령은 현재 디렉토리 전체(빌드 컨텍스트)를 Docker daemon으로 보냅니다. 필요 없는 것들을 제외하세요:
target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
Dockerfile
이 파일이 없으면 target/ 또는 .git/ 디렉토리가 컨텍스트를 수백 MB까지 부풀릴 수 있습니다. 매번 빌드가 눈에 띄게 느려질 것입니다.
4.4. 결과
docker build -t order-service:1.0.0 .
docker images order-service
docker history order-service:1.0.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -8
REPOSITORY TAG IMAGE ID SIZE
order-service 1.0.0 9b2e44d1c0af ~200MB
order-service naive 3f1c9a7e2b10 ~480MB
0B ENTRYPOINT ["java" "-jar" "app.jar"]
...
45kB COPY /extract/extracted/application/ ./
0B COPY /extract/extracted/snapshot-dependencies/ ./
290kB COPY /extract/extracted/spring-boot-loader/ ./
22MB COPY /extract/extracted/dependencies/ ./
이미지가 절반 이상 작아졌습니다. 더 중요한 것은 application 레이어가 단 몇십 KB입니다. OrderController를 수정하고 다시 빌드하면 해당 레이어만 변경됩니다. 레지스트리로 푸시하는 데 몇 초만 걸립니다.
5. 대안: Cloud Native Buildpacks
Dockerfile을 직접 유지하고 싶지 않으면 Spring Boot의 Maven 플러그인이 Buildpacks를 사용하여 이미지를 직접 빌드할 수 있습니다:
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=shop/order-service:1.0.0
pom.xml에서 설정을 고정할 수도 있습니다:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>shop/order-service:${project.version}</name>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
</env>
</image>
</configuration>
</plugin>
Buildpacks는 우리가 위에서 수동으로 한 것을 자동으로 합니다: 레이어를 분할하고, JRE를 선택하고, 루트가 아닌 사용자로 실행하며, JVM의 메모리를 계산합니다. Dockerfile이 필요 없으며 Docker daemon이 실행 중이기만 하면 됩니다.
대신 제어 권한이 줄어듭니다. Base 이미지는 buildpack이 결정합니다. 처음 빌드는 빌더 이미지를 로드해야 하기 때문에 꽤 느립니다. 깊은 사용자 정의는 Dockerfile을 수정하는 것보다 어렵습니다. 빠른 표준화를 원하는 팀은 Buildpacks를 사용해야 합니다. 자세한 제어가 필요한 팀, 예를 들어 보안 검토를 받은 내부 base 이미지를 사용하려는 팀은 Dockerfile을 사용해야 합니다.
6. Testcontainers로 이미지 검증
‘경량’ 이미지가 시작되지 않으면 의미가 없습니다. Dockerfile에서 이미지를 빌드하고 테스트를 작성할 수 있습니다. 컨테이너를 실행하고 올바르게 작동하는지 확인합니다.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
Spring Boot BOM이 두 라이브러리의 버전을 관리합니다. 따라서 <version>을 선언할 필요가 없습니다.
@Testcontainers
class OrderServiceImageIT {
static final ImageFromDockerfile IMAGE =
new ImageFromDockerfile("order-service-it", false)
.withDockerfile(Path.of("./Dockerfile"));
@Container
static final GenericContainer<?> APP = new GenericContainer<>(IMAGE)
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200))
.withStartupTimeout(Duration.ofMinutes(2));
@Test
void shouldServeOrdersAndReportHealthy() throws Exception {
String baseUrl = "http://" + APP.getHost() + ":" + APP.getMappedPort(8080);
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create(baseUrl + "/orders/42")).build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("\"id\":42", "Nguyen Van A");
}
@Test
void shouldNotRunAsRoot() throws Exception {
var result = APP.execInContainer("id", "-u");
assertThat(result.getStdout().trim()).isNotEqualTo("0");
}
}
이 테스트는 다음을 수행합니다:
ImageFromDockerfile프로젝트 루트 디렉토리의Dockerfile에서 이미지를 빌드합니다. 빌드 컨텍스트는 Dockerfile을 포함하는 디렉토리입니다.false매개변수는 테스트 후 이미지를 유지하므로 캐시 덕분에 다음 빌드가 더 빠릅니다.Wait.forHttp("/actuator/health")애플리케이션이 실제로 준비될 때까지 기다립니다. 컨테이너가 시작될 때까지만 기다리는 것이 아닙니다.- 첫 번째 테스트 호스트에서 임의로 매핑된 포트를 통해 엔드포인트를 호출합니다. 반환된 JSON을 확인합니다.
- 두 번째 테스트 컨테이너 내에서
id -u를 실행합니다. 누군가가 실수로USER spring:spring줄을 삭제하면 결과는0이 되고 테스트가 즉시 실패합니다.
IT 접미사로 클래스 이름을 지정하고 maven-failsafe-plugin으로 실행합니다. 이 테스트는 이미지를 빌드하므로 몇십 초가 걸립니다. 정기적인 단위 테스트에서 실행하기에는 적합하지 않습니다.
7. 흔한 실수와 언제 적용하면 안 되는지
- 셸 형식의
ENTRYPOINT java -jar app.jar사용. 이 경우/bin/sh는 PID 1이며 JVM에SIGTERM을 전달하지 않습니다. 대기 시간 후 컨테이너가 강제 종료되고 graceful shutdown이 손실됩니다. 항상 배열 JSON 형식을 사용하세요. - 고정
-Xmx설정.-Xmx2g를 설정했는데 컨테이너 제한이 1 GB이면 컨테이너가 OOM-killed됩니다. 스택 트레이스가 없습니다.MaxRAMPercentage를 사용하여 힙을 제한에 맞게 조정하세요. - Alpine과 네이티브 라이브러리. Alpine은
glibc대신musl을 사용합니다. 일부 라이브러리에는 네이티브 코드가 있습니다. Netty 네이티브 전송, 일부 드라이버, 이미지 처리 라이브러리 오류가 발생하거나 느리게 실행될 수 있습니다. 그 경우eclipse-temurin:21-jre-jammy또는-noble로 전환하세요. 이미지가 몇십 MB 더 크지만 더 안정적입니다. COPY레이어 순서 오류.dependencies전에application을 복사하면 모든 코드 변경이 종속성 레이어의 캐시를 무효화합니다. 계층화된 jar의 모든 이점을 잃게 됩니다.- Base 이미지에
latest태그 사용. 오늘 빌드와 다음 주 빌드가 다른 이미지를 만들 수 있습니다.21.0.5_11-jre-alpine과 같은 특정 태그를 사용하세요. 또는 절대 재현 빌드가 필요한 경우 digest를 고정하세요.
주의: 더 작은 이미지가 필요한 경우
jdeps및jlink를 사용하여 애플리케이션에 필요한 모듈만 포함하는 JRE를 만들 수 있습니다. 또는 GraalVM으로 네이티브로 컴파일합니다. 두 방법 모두 크기를 상당히 줄입니다. 하지만 빌드를 훨씬 더 복잡하게 만들고 리플렉션이 있을 때 깨지기 쉽습니다. 이미지 크기 또는 시작 시간이 실제로 문제가 되는 경우에만 적용해야 합니다. 예를 들어 서버리스 또는 스케일-투-제로의 경우입니다.
8. 결론
이 글에서는 세 줄의 Dockerfile을 멀티스테이지 Dockerfile로 변환했습니다. 다음 단계로 구성됩니다:
- 빌드 머신에 의존하지 않도록 컨테이너에서 빌드;
- 계층화된 jar를 분할하여 코드를 수정할 때마다 코드를 포함하는 레이어만 변경;
- Alpine JRE를 사용하여 이미지 크기 감소;
- 루트가 아닌 사용자로 실행하고, 헬스 체크를 포함하고, 컨테이너의 메모리 제한에 따라 JVM 구성.
또한 Buildpacks가 Dockerfile이 필요 없는 대안이라는 것을 보았습니다. 마지막으로 Testcontainers 테스트는 이미지가 여전히 시작될 수 있음을 보장합니다. 그리고 변경할 때마다 root 권한으로 실행되지 않습니다.