Viết Dockerfile tốt: multi-stage build cho ứng dụng Spring Boot
1. Tổng quan
Một Dockerfile viết vội thường cho ra image nặng hàng trăm MB. Image đó chứa cả JDK, Maven, mã nguồn và thư mục .m2. Mỗi lần sửa một dòng code, Docker lại tải toàn bộ dependency từ đầu.
Bài này hướng dẫn bạn dùng multi-stage build để tách bước build khỏi bước chạy. Sau bài, bạn sẽ viết được một Dockerfile cho ứng dụng Spring Boot 3.x (Java 21) có image nhỏ, build lại nhanh nhờ cache, chạy test trong stage riêng và chạy process bằng user không phải root.
2. Vấn đề của Dockerfile một stage
Ta dùng một ứng dụng nhỏ có một endpoint trả về trạng thái đơn hàng:
package com.nonla.orders;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
@RestController
class OrderController {
@GetMapping("/orders/{id}/status")
String status(@PathVariable long id) {
return id > 0 ? "CONFIRMED" : "UNKNOWN";
}
}
Dependency trong pom.xml chỉ cần web và test:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Cách viết Dockerfile “cho chạy được” thường trông như sau:
FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
CMD ["java", "-jar", "target/orders-0.0.1-SNAPSHOT.jar"]
Dockerfile này có ba vấn đề:
- Image nặng: image cuối chứa Maven, JDK đầy đủ, mã nguồn và toàn bộ cache
.m2. - Cache kém:
COPY . .đứng trướcmvn package. Chỉ cần sửa một file.javalà layer này bị vô hiệu, kéo theo việc tải lại mọi dependency. - Bề mặt tấn công lớn: công cụ build và mã nguồn nằm sẵn trong container production.
3. Multi-stage build cơ bản
Multi-stage build cho phép một Dockerfile có nhiều lệnh FROM. Mỗi FROM mở ra một stage mới. Stage sau có thể copy file từ stage trước bằng COPY --from=<tên stage>. Chỉ stage cuối cùng trở thành image kết quả. Các stage trung gian bị bỏ lại.
3.1. Stage build và stage runtime
# Stage 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests
# Stage 2: runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Giải thích từng phần:
AS buildđặt tên cho stage đầu. Bạn dùng tên này ởCOPY --from=build.- Stage build dùng image có Maven và JDK để biên dịch, đóng gói jar.
- Stage runtime dùng
eclipse-temurin:21-jre. Image này chỉ có JRE, không cójavachay Maven. COPY --from=buildchỉ lấy đúng file jar sang image cuối.ENTRYPOINTở dạng exec (mảng JSON). Nhờ vậyjavachạy là PID 1 và nhận được tín hiệuSIGTERMkhi container dừng.
3.2. So sánh kết quả
Build và xem kích thước:
docker build -t orders:single -f Dockerfile.single .
docker build -t orders:multi .
docker images orders
Kết quả minh họa (số liệu thực tế phụ thuộc phiên bản image và dependency):
REPOSITORY TAG SIZE
orders single ~560MB
orders multi ~290MB
Phần lớn dung lượng còn lại là JRE và hệ điều hành nền. Bạn có thể giảm thêm bằng image nền gọn hơn như eclipse-temurin:21-jre-alpine. Tuy nhiên, hãy kiểm tra kỹ nếu ứng dụng dùng thư viện native.
4. Tận dụng layer cache
Mỗi lệnh RUN, COPY tạo ra một layer. Docker tái sử dụng layer nếu lệnh và input không đổi. Khi một layer thay đổi, mọi layer phía sau đều phải build lại. Vì vậy, nguyên tắc chung là đặt thứ ít thay đổi lên trước, thứ hay thay đổi xuống sau.
4.1. Tách bước tải dependency
pom.xml ít thay đổi hơn mã nguồn rất nhiều. Ta copy nó trước và tải dependency trong một layer riêng:
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
Bây giờ khi bạn chỉ sửa OrderController.java, Docker dùng lại layer dependency:go-offline. Log build sẽ có dạng:
=> CACHED [build 3/5] COPY pom.xml .
=> CACHED [build 4/5] RUN mvn -B dependency:go-offline
=> [build 5/5] COPY src ./src
Lưu ý:
dependency:go-offlinekhông phải lúc nào cũng tải đủ 100% plugin mà Maven cần ở bướcpackage. Một vài plugin vẫn có thể được tải ở bước sau. Kết quả vẫn tốt hơn nhiều so với không tách.
4.2. Cache mount với BuildKit
BuildKit (mặc định trong Docker hiện nay) hỗ trợ --mount=type=cache. Thư mục được mount sẽ được giữ lại giữa các lần build nhưng không nằm trong image:
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
- Dòng
# syntax=docker/dockerfile:1bật đầy đủ cú pháp Dockerfile mới. /root/.m2được cache ở phía builder. Kể cả khipom.xmlđổi, Maven chỉ tải thêm dependency mới thay vì tải lại toàn bộ.
Bạn có thể kết hợp cả hai kỹ thuật. Trên CI, cache mount chỉ có tác dụng nếu builder được giữ lại giữa các lần chạy. Nếu không, cách tách layer ở mục 4.1 kết hợp với cache registry vẫn là lựa chọn đáng tin cậy hơn.
5. Tách layer cho Spring Boot jar
Một fat jar Spring Boot gồm cả code của bạn và hàng chục MB thư viện. Nếu copy nguyên jar vào image, mỗi lần sửa code sẽ tạo ra một layer mới vài chục MB. Registry phải lưu thêm layer đó và server phải kéo lại toàn bộ.
Spring Boot hỗ trợ giải nén jar thành các layer theo tần suất thay đổi. Từ Spring Boot 3.3, bạn dùng jarmode=tools:
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
FROM eclipse-temurin:21-jre AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination layers
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=extract /extract/layers/dependencies/ ./
COPY --from=extract /extract/layers/spring-boot-loader/ ./
COPY --from=extract /extract/layers/snapshot-dependencies/ ./
COPY --from=extract /extract/layers/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Các điểm chính:
- Stage
extractgiải nén jar thành bốn thư mục:dependencies,spring-boot-loader,snapshot-dependencies,application. - Bốn lệnh
COPYtạo bốn layer, xếp từ ít thay đổi nhất tới hay thay đổi nhất. - Khi bạn sửa code, chỉ layer
applicationthay đổi. Layer này thường chỉ vài trăm KB. - Tùy chọn
--launchergiữ lại launcher. Vì vậyENTRYPOINTgọiJarLauncherthay vì-jar.
Lưu ý: Với Spring Boot 3.0–3.2, bạn dùng cú pháp cũ
java -Djarmode=layertools -jar app.jar extract. Package của launcher cũng khác giữa các phiên bản: từ 3.2 trở đi làorg.springframework.boot.loader.launch.JarLauncher, trước đó làorg.springframework.boot.loader.JarLauncher. Dùng sai tên lớp sẽ gây lỗiClassNotFoundExceptionngay khi container khởi động.
6. Chạy test trong stage riêng
Đặt -DskipTests ở stage build giúp build nhanh hơn, nhưng bạn không muốn đẩy lên registry một image chưa qua test. Multi-stage cho phép tách test thành một stage riêng và chọn stage cần build bằng --target.
Test cho controller dùng JUnit 5 và AssertJ (đã có sẵn trong spring-boot-starter-test):
package com.nonla.orders;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void givenPositiveId_whenGetStatus_thenConfirmed() throws Exception {
String body = mockMvc.perform(get("/orders/42/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("CONFIRMED");
}
@Test
void givenZeroId_whenGetStatus_thenUnknown() throws Exception {
String body = mockMvc.perform(get("/orders/0/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("UNKNOWN");
}
}
Thêm stage test dựa trên stage build:
FROM build AS test
RUN --mount=type=cache,target=/root/.m2 mvn -B test
Trên CI, bạn chạy test trước rồi mới build image runtime:
docker build --target test -t orders:test .
docker build -t orders:1.0.0 .
Lệnh thứ nhất chỉ build tới stage test. Nếu một test thất bại, lệnh RUN trả về mã lỗi khác 0 và build dừng lại:
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
ERROR: failed to solve: process "/bin/sh -c mvn -B test" did not complete successfully: exit code: 1
Lệnh thứ hai build stage cuối. Với BuildKit, stage test không được stage cuối tham chiếu nên sẽ bị bỏ qua. Image production không phải chạy test lại. Nhờ cache, các stage chung cũng không phải build lại.
Lưu ý: Không nên chạy integration test cần Docker (ví dụ Testcontainers) bên trong
docker build. Môi trường build không có Docker daemon. Hãy chạy loại test đó ở bước CI bên ngoài.
7. Bảo mật: non-root user và .dockerignore
7.1. Không chạy bằng root
Mặc định, process trong container chạy bằng root. Nếu ứng dụng bị khai thác, kẻ tấn công có quyền root trong container. Hãy tạo user riêng ở stage runtime:
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app
COPY --from=extract --chown=spring:spring /extract/layers/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/layers/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/application/ ./
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Kiểm tra nhanh:
docker run --rm --entrypoint id orders:1.0.0
uid=999(spring) gid=999(spring) groups=999(spring)
Số uid/gid cụ thể có thể khác, miễn không phải 0. Với image Alpine, bạn dùng addgroup -S spring && adduser -S spring -G spring thay cho groupadd và useradd.
7.2. Thêm .dockerignore
Build context là toàn bộ thư mục bạn truyền cho docker build. Nếu không lọc, Docker sẽ gửi cả target/, .git/ và file cấu hình IDE lên builder. Việc này làm build chậm, phá cache và có thể làm lộ thông tin nhạy cảm.
target/
.git/
.idea/
*.iml
.vscode/
.env
Dockerfile*
Đặt file này ở thư mục gốc dự án, cạnh Dockerfile.
8. Lỗi hay gặp
COPY . .quá sớm: phá cache dependency. Hãy copypom.xmltrước,srcsau.- Dùng JDK ở stage runtime: chạy được nhưng thừa vài chục tới hàng trăm MB. Production chỉ cần JRE, trừ khi bạn cần công cụ như
jcmdđể chẩn đoán. - Wildcard khớp nhiều jar:
COPY --from=build /workspace/target/*.jar app.jarsẽ lỗi nếutarget/có thêm*-plain.jarhoặc*-sources.jar. Hãy đặt<finalName>cố định trongpom.xmlhoặc tắt artifact phụ. - Shell form cho
ENTRYPOINT:ENTRYPOINT java -jar app.jarchạy qua/bin/sh -c. Khi đójavakhông nhậnSIGTERM, nên Spring không shutdown gracefully và container bị kill sau thời gian chờ. - Tag
latestcho image nền: build hôm nay và tháng sau có thể khác nhau. Hãy ghim phiên bản (ví dụeclipse-temurin:21-jre) hoặc ghim digest nếu cần tái lập tuyệt đối. - Đưa secret vào
ARG/ENV: giá trị có thể lộ quadocker history. Khi cần token để tải dependency private, hãy dùngRUN --mount=type=secret.
Khi nào không cần cầu kỳ? Với script nhỏ hoặc image chỉ dùng cho môi trường dev cá nhân, một stage là đủ. Ngoài ra, nếu đội của bạn đã dùng mvn spring-boot:build-image (Cloud Native Buildpacks) hoặc Jib, các công cụ này đã tự lo phần tách layer và non-root. Bạn không cần viết Dockerfile tay nữa.
9. Kết luận
Multi-stage build tách rõ “môi trường để build” và “môi trường để chạy”. Image cuối chỉ chứa những gì ứng dụng thực sự cần. Kết hợp với thứ tự layer hợp lý, cache mount, tách layer Spring Boot, stage test riêng và user không phải root, bạn có một Dockerfile build nhanh, image gọn và an toàn hơn đáng kể.
Bạn có thể bắt đầu từ Dockerfile ở mục 3. Sau đó áp dụng dần từng cải tiến và đo lại thời gian build cũng như kích thước image sau mỗi bước để thấy rõ hiệu quả.