Đóng gói Spring Boot thành Docker image gọn nhẹ

1. Tổng quan

Đóng gói Spring Boot vào Docker rất dễ. Đóng gói sao cho image nhỏ, build nhanh, chạy an toàn thì cần thêm vài bước. Với Dockerfile “ba dòng” quen thuộc, image thường nặng vài trăm MB, mỗi lần sửa một dòng code lại phải đẩy lại toàn bộ file JAR, và process chạy dưới quyền root.

Bài này đi từ Dockerfile ngây thơ đến một Dockerfile multi-stage, dùng layered jar và JRE tối giản. Sau đó bạn sẽ thấy cách dùng Buildpacks làm phương án thay thế, và viết một test bằng Testcontainers để kiểm tra image vừa build.

2. Ứng dụng mẫu

Ta dùng một service nhỏ tên order-service, có một REST endpoint và Actuator để làm health check.

<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"));
    }
}

Lệnh ./mvnw package sinh ra file target/order-service-1.0.0.jar. Đây là một “fat jar” chứa cả code của bạn lẫn toàn bộ thư viện phụ thuộc.

3. Dockerfile ngây thơ và vấn đề của nó

Phiên bản mà nhiều người viết đầu tiên trông như sau:

FROM eclipse-temurin:21
COPY target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Dockerfile này chạy được, nhưng có bốn vấn đề:

  • Base image quá lớn. eclipse-temurin:21 chứa cả JDK: compiler, jshell, javadoc… Lúc chạy bạn không cần tới chúng.
  • Một layer cho tất cả. Fat jar khoảng 20–30 MB, trong đó code của bạn chỉ chiếm vài trăm KB. Sửa một dòng code là layer 30 MB đó thay đổi, phải build và push lại.
  • Phụ thuộc vào máy build. Image cần file JAR đã build sẵn trên máy. JDK và phiên bản Maven trên mỗi máy có thể khác nhau.
  • Chạy dưới quyền root. Nếu ứng dụng bị khai thác, kẻ tấn công có quyền root trong container.

Kiểm tra kích thước:

docker build -t order-service:naive .
docker images order-service
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   naive   3f1c9a7e2b10   ~480MB

Con số cụ thể tùy phiên bản base image, nhưng gần nửa GB cho một service “hello world” là quá nhiều.

4. Multi-stage build với layered jar

4.1. Layered jar là gì

Từ Spring Boot 2.3, fat jar được chia sẵn thành các layer theo tần suất thay đổi:

Layer Nội dung Tần suất thay đổi
dependencies Thư viện có version release Hiếm
spring-boot-loader Class loader của Spring Boot Rất hiếm
snapshot-dependencies Thư viện -SNAPSHOT Thỉnh thoảng
application Code và resource của bạn Thường xuyên

Bạn copy mỗi phần vào image bằng một lệnh COPY riêng, theo thứ tự từ ít thay đổi đến hay thay đổi. Docker sẽ cache các layer phía trên. Khi bạn chỉ sửa code, chỉ layer application bị build lại và push lại.

Từ Spring Boot 3.3, bạn tách layer bằng 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

Thư mục application chứa file order-service-1.0.0.jar đã được “làm mỏng”. File này tham chiếu tới thư mục lib/ nằm trong layer dependencies. Sau khi gộp các layer lại, bạn chỉ cần chạy java -jar như bình thường.

Lưu ý: Với Spring Boot 3.2 trở về trước, cú pháp là java -Djarmode=layertools -jar app.jar extract. Entrypoint khi đó là org.springframework.boot.loader.launch.JarLauncher thay vì -jar.

4.2. Dockerfile hoàn chỉnh

# ---------- Stage 1: build bằng 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: tách layer ----------
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: runtime ----------
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"]

Giải thích từng phần:

  • Stage build: COPY pom.xml rồi chạy dependency:go-offline trước khi copy src. Khi bạn chỉ sửa code, Docker dùng lại layer chứa các dependency đã tải. Bạn không phải tải lại cả thế giới từ Maven Central.
  • Stage extract: tách JAR thành bốn thư mục layer. Stage này chỉ là trung gian và không xuất hiện trong image cuối.
  • Stage runtime: dùng 21-jre-alpine, chỉ có JRE trên nền Alpine Linux.
  • Bốn lệnh COPY: mỗi lệnh tạo một Docker layer, theo thứ tự ít thay đổi → hay thay đổi. Vì file JAR “mỏng” (app.jar) nằm trong application và thư mục lib/ nằm trong dependencies, sau khi gộp lại /app có cấu trúc app.jar + lib/.
  • USER spring:spring: process Java không còn chạy dưới quyền root.
  • JAVA_TOOL_OPTIONS: JVM đọc biến này tự động. MaxRAMPercentage=75.0 cho heap dùng tối đa 75% memory limit của container. ExitOnOutOfMemoryError khiến JVM thoát hẳn khi hết bộ nhớ, để orchestrator khởi động lại container thay vì để nó “sống dở chết dở”.
  • HEALTHCHECK: Alpine có sẵn wget của BusyBox, nên bạn không phải cài thêm curl.
  • ENTRYPOINT dạng exec (mảng JSON): java là PID 1 và nhận được tín hiệu SIGTERM khi container dừng. Nhờ đó Spring Boot có thể graceful shutdown.

4.3. Đừng quên .dockerignore

Lệnh build gửi toàn bộ thư mục hiện tại (build context) lên Docker daemon. Hãy loại bỏ những thứ không cần:

target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
Dockerfile

Không có file này, thư mục target/ hay .git/ có thể làm context phình lên hàng trăm MB. Mỗi lần build sẽ chậm đi rõ rệt.

4.4. Kết quả

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/ ./

Image nhỏ đi hơn một nửa. Quan trọng hơn, layer application chỉ còn vài chục KB. Khi bạn sửa OrderController và build lại, chỉ layer đó thay đổi. Lần push lên registry chỉ mất vài giây.

5. Phương án thay thế: Cloud Native Buildpacks

Nếu bạn không muốn tự duy trì Dockerfile, plugin Maven của Spring Boot có thể build image trực tiếp bằng Buildpacks:

./mvnw spring-boot:build-image \
  -Dspring-boot.build-image.imageName=shop/order-service:1.0.0

Bạn cũng có thể cấu hình cố định trong 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 tự làm những gì ta đã làm tay ở trên: tách layer, chọn JRE, chạy dưới user non-root, tính toán memory cho JVM. Bạn không cần Dockerfile, chỉ cần Docker daemon đang chạy.

Đổi lại, bạn ít quyền kiểm soát hơn. Base image do buildpack quyết định, lần build đầu khá chậm vì phải tải builder image, và việc tùy biến sâu khó hơn so với sửa một Dockerfile. Nhóm muốn chuẩn hóa nhanh thì nên dùng Buildpacks. Nhóm cần kiểm soát chi tiết, ví dụ dùng base image nội bộ đã được duyệt bảo mật, thì nên dùng Dockerfile.

6. Kiểm tra image bằng Testcontainers

Một image “gọn” mà không khởi động được thì vô nghĩa. Bạn có thể viết test build image từ chính Dockerfile, chạy container và kiểm tra nó hoạt động đúng.

<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 đã quản lý version cho cả hai thư viện, nên bạn không cần khai báo <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");
    }
}

Test này làm những việc sau:

  • ImageFromDockerfile build image từ Dockerfile ở thư mục gốc dự án. Build context là thư mục chứa Dockerfile. Tham số false giữ lại image sau khi test để lần sau build nhanh hơn nhờ cache.
  • Wait.forHttp("/actuator/health") chờ đến khi ứng dụng thực sự sẵn sàng, chứ không chỉ chờ container khởi động.
  • Test đầu tiên gọi endpoint qua cổng được map ngẫu nhiên trên host và kiểm tra JSON trả về.
  • Test thứ hai chạy id -u bên trong container. Nếu ai đó vô tình xóa dòng USER spring:spring, kết quả sẽ là 0 và test fail ngay.

Đặt tên lớp với hậu tố IT và chạy bằng maven-failsafe-plugin. Test này build cả image nên mất vài chục giây, không phù hợp để chạy trong vòng unit test thường xuyên.

7. Lỗi hay gặp và khi nào không nên áp dụng

  • Dùng ENTRYPOINT java -jar app.jar dạng shell. Khi đó /bin/sh là PID 1 và không chuyển tiếp SIGTERM cho JVM. Container bị kill cứng sau thời gian chờ, mất graceful shutdown. Luôn dùng dạng mảng JSON.
  • Đặt -Xmx cố định. Nếu bạn đặt -Xmx2g nhưng giới hạn container là 1 GB, container sẽ bị OOM-killed mà không có stack trace. Hãy dùng MaxRAMPercentage để heap co giãn theo limit.
  • Alpine và thư viện native. Alpine dùng musl thay vì glibc. Một số thư viện có code native (Netty native transport, một số driver, thư viện xử lý ảnh) có thể lỗi hoặc chạy chậm. Khi đó hãy đổi sang eclipse-temurin:21-jre-jammy hoặc -noble. Image lớn hơn vài chục MB nhưng ổn định hơn.
  • COPY layer sai thứ tự. Nếu copy application trước dependencies, mọi thay đổi code đều làm vô hiệu cache của layer dependency. Bạn mất toàn bộ lợi ích của layered jar.
  • Dùng tag latest cho base image. Build hôm nay và tuần sau có thể ra hai image khác nhau. Hãy ghim tag cụ thể như 21.0.5_11-jre-alpine, hoặc ghim digest nếu cần build tái lập tuyệt đối.

Lưu ý: Nếu cần image nhỏ hơn nữa, bạn có thể dùng jdeps và jlink để tạo JRE chỉ chứa các module ứng dụng cần, hoặc biên dịch native bằng GraalVM. Cả hai cách giảm kích thước đáng kể, nhưng làm build phức tạp hơn nhiều và dễ vỡ khi có reflection. Chỉ nên áp dụng khi kích thước image hoặc thời gian khởi động thực sự là vấn đề, ví dụ với serverless hoặc scale-to-zero.

8. Kết luận

Trong bài này, bạn đã biến một Dockerfile ba dòng thành một Dockerfile multi-stage gồm các bước sau:

  • build trong container để không phụ thuộc máy build;
  • tách layered jar để chỉ layer chứa code thay đổi sau mỗi lần sửa;
  • dùng JRE Alpine để giảm kích thước image;
  • chạy dưới user non-root, có health check và cấu hình JVM theo memory limit của container.

Bạn cũng đã thấy Buildpacks là lựa chọn thay thế không cần Dockerfile. Cuối cùng, test Testcontainers giúp đảm bảo image vẫn khởi động được và không chạy dưới quyền root sau mỗi lần thay đổi.

spring-bootdockerjavadevopscontainer