Đó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:21chứ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.JarLauncherthay 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.xmlrồi chạydependency:go-offlinetrước khi copysrc. 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 trongapplicationvà thư mụclib/nằm trongdependencies, sau khi gộp lại/appcó cấu trúcapp.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.0cho heap dùng tối đa 75% memory limit của container.ExitOnOutOfMemoryErrorkhiế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ẵnwgetcủa BusyBox, nên bạn không phải cài thêmcurl.ENTRYPOINTdạng exec (mảng JSON):javalà PID 1 và nhận được tín hiệuSIGTERMkhi 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:
ImageFromDockerfilebuild image từDockerfileở thư mục gốc dự án. Build context là thư mục chứa Dockerfile. Tham sốfalsegiữ 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 -ubên trong container. Nếu ai đó vô tình xóa dòngUSER spring:spring, kết quả sẽ là0và 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.jardạng shell. Khi đó/bin/shlà PID 1 và không chuyển tiếpSIGTERMcho 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
-Xmxcố định. Nếu bạn đặt-Xmx2gnhưng giới hạn container là 1 GB, container sẽ bị OOM-killed mà không có stack trace. Hãy dùngMaxRAMPercentageđể heap co giãn theo limit. - Alpine và thư viện native. Alpine dùng
muslthay 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 sangeclipse-temurin:21-jre-jammyhoặc-noble. Image lớn hơn vài chục MB nhưng ổn định hơn. COPYlayer sai thứ tự. Nếu copyapplicationtrướcdependencies, 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
latestcho 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
jdepsvà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.