Dockerfile: build image cho Spring Boot
Tiếp đi: viết Dockerfile cho order-service.
Bài trước có Dockerfile 5 dòng. Giờ hiểu từng lệnh, rồi nâng cấp nó thành Dockerfile đủ chuẩn production cho Spring Boot.
1. Bài toán
order-service/
├── src/
├── pom.xml
├── target/order-service.jar
└── Dockerfile
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY target/order-service.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
FROM: base image. Không dựng môi trường từ số 0, lấy image đã có Java 17.WORKDIR /app: gần giốngcd /app(tạo nếu chưa có). Các lệnh sau thao tác tại/app.COPY target/order-service.jar app.jar: copy từ build context vào image, thành/app/app.jar.EXPOSE 8080: khai báo app listen 8080 (xem câu hỏi phụ bên dưới).ENTRYPOINT: lệnh chạy khi container start.
2. Vấn đề
- Phải chạy
mvn clean packagetrên máy trước. Máy CI khác Java version thì jar khác. - Sửa một dòng code là layer jar 60MB đổi toàn bộ, push/pull lại cả 60MB.
COPY . .bừa thì kéo cả.git,.idea, file.envvào image.- Nếu dùng image Maven để vừa build vừa chạy, image cuối mang theo JDK, Maven, source: to và nhiều bề mặt tấn công.
3. Giải pháp: các lệnh Dockerfile và multi-stage build
| Lệnh | Làm gì | Thời điểm |
|---|---|---|
FROM | Chọn base image, bắt đầu một stage | Build |
WORKDIR | Đặt thư mục làm việc | Build (lưu cả cho runtime) |
COPY | Copy file từ build context / stage khác vào image | Build |
RUN | Thực thi lệnh, kết quả thành một layer | Build |
ARG | Biến chỉ tồn tại lúc build (--build-arg) | Build |
ENV | Biến môi trường, có trong container | Build + Runtime |
EXPOSE | Metadata: port app dự kiến listen | Chỉ là metadata |
ENTRYPOINT | Executable chính của container | Container start |
CMD | Lệnh/tham số mặc định, dễ override | Container start |
Build context. Dấu . trong docker build -t order-service:1.0 . là folder Docker được đọc khi COPY. COPY không thể lấy file ngoài context. Trong COPY . ., dấu chấm đầu là toàn bộ context, dấu chấm sau là WORKDIR trong image.
Multi-stage build. Stage 1 dùng Maven + JDK để build jar. Stage 2 chỉ có JRE + jar. Image cuối chỉ chứa stage cuối.
4. Code từng bước
Bước 1: .dockerignore, giống .gitignore nhưng cho build context:
target
.git
.idea
*.iml
*.log
.env
README.md
Maven build trong Docker nên target local không cần gửi vào context. .env chứa secret thì tuyệt đối không được vào image.
Bước 2: Dockerfile multi-stage
# =========================
# Stage 1 - Build
# =========================
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests
# =========================
# Stage 2 - Runtime
# =========================
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/target/order-service-1.0.0.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Để ý thứ tự: COPY pom.xml → RUN dependency:go-offline → COPY src. pom.xml ít đổi nên layer dependency được cache; sửa code chỉ build lại từ COPY src. Nếu viết COPY . . ngay từ đầu, mỗi lần sửa code là tải lại toàn bộ dependency.
Giờ không cần mvn clean package trên máy nữa:
docker build -t order-service:1.0 .
Bước 3: Layered jar. Jar Spring Boot gồm dependency (ít đổi, ~60MB) và code của mình (hay đổi, vài trăm KB). Spring Boot hỗ trợ tách jar thành các layer (Spring Boot 3.3+ dùng -Djarmode=tools extract --layers; bản cũ hơn dùng -Djarmode=layertools extract):
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests \
&& java -Djarmode=tools -jar target/order-service-1.0.0.jar extract --layers --launcher --destination extracted
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Sửa code chỉ đổi layer application nhỏ xíu; push/pull nhanh hơn nhiều. Cách khác không cần Dockerfile: mvn spring-boot:build-image (Cloud Native Buildpacks) hoặc Jib.
Bước 4: ARG và ENV
ARG APP_VERSION=1.0.0 # chỉ có lúc build
ENV SPRING_PROFILES_ACTIVE=prod # có trong container lúc chạy
ENV JAVA_OPTS=""
docker build --build-arg APP_VERSION=1.0.1 -t order-service:1.0.1 .
docker run -e SPRING_PROFILES_ACTIVE=dev order-service:1.0.1 # override ENV
Không bao giờ để password vào ARG hay ENV trong Dockerfile: nó nằm lại trong image history (bài 16.7).
Bước 5: Kiểm tra
docker images order-service
docker history order-service:1.0 # xem từng layer và kích thước
docker run -d -p 8080:8080 --name order-1 order-service:1.0
curl http://localhost:8080/actuator/health
5. Cách hoạt động
docker build
│
┌──────────▼──────────┐
Source ──────►│ Stage 1: Maven+JDK │
│ mvn clean package │
│ ↓ app.jar │
└──────────┬──────────┘
│ COPY --from=builder (chỉ lấy jar)
┌──────────▼──────────┐
│ Stage 2: JRE │
│ app.jar │
└──────────┬──────────┘
▼
Final Image (Maven, JDK, source bị bỏ lại)
6. So sánh
| Cách | Image cuối chứa | Kích thước (ước lượng) | Ghi chú |
|---|---|---|---|
| Build jar trên máy + COPY | JRE + jar | ~250MB | Phụ thuộc môi trường build của máy |
| Một stage Maven | JDK + Maven + source + .m2 + jar | ~700MB+ | To, nhiều thứ thừa |
| Multi-stage | JRE + jar | ~250MB | Build lặp lại được, image gọn |
| Multi-stage + layered jar | JRE + các layer tách rời | ~250MB, push lại chỉ vài trăm KB | Tốt nhất cho CI/CD |
Kích thước thực tế tùy base image (bản -alpine, distroless nhỏ hơn).
7. Khi nào nên dùng / không nên dùng
- Multi-stage: gần như luôn nên dùng cho Java.
- Layered jar: khi deploy thường xuyên, image push qua mạng chậm.
- Buildpacks/Jib: khi team không muốn tự bảo trì Dockerfile.
- Không nên:
COPY . .không có.dockerignore; dùng JDK làm runtime image;RUN mvn ...sauCOPY srcmà không tách bước dependency.
EXPOSE 8080 rốt cuộc làm gì, khi đã có -p?Giải thích lại giúp mình:
EXPOSE 8080trong Dockerfile thật ra làm gì?- Như vậy phải chủ động build Java trước rồi mới chạy Dockerfile mới đúng? Có kết hợp được không?
Hai chỗ này rất dễ hiểu nhầm.
1. EXPOSE là metadata, không mở port. Nó chỉ ghi vào image câu: "app trong container này được thiết kế để listen port 8080". Người đọc Dockerfile, docker inspect, và một số tool đọc thông tin này.
EXPOSE 8080
↓
"App này dự kiến listen 8080" → metadata / documentation
-p 9999:8080
↓
"Map host 9999 → container 8080" → thực sự cho traffic đi vào
Bỏ EXPOSE đi, docker run -p 9999:8080 order-service vẫn chạy bình thường. Ngược lại có EXPOSE mà không -p thì host không gọi được. Cũng nhớ: EXPOSE không làm Spring Boot listen port đó, port thật vẫn do server.port quyết định. Hai thứ lệch nhau thì EXPOSE chỉ là tài liệu sai.
Ứng dụng duy nhất của EXPOSE khi chạy: docker run -P (P hoa) map mọi port đã EXPOSE sang port ngẫu nhiên trên host. Chuyện vì sao host vẫn không gọi được dù đã EXPOSE, xem thêm ở bài IP, port, TCP/UDP.
2. Có, kết hợp được. Dockerfile 5 dòng ban đầu COPY target/order-service.jar nên bắt buộc mvn package trước. Multi-stage ở Bước 2 để Docker tự build:
Cách đơn giản: mvn package → app.jar → docker build
Cách tốt hơn : docker build → Stage 1 Maven → app.jar → Stage 2 runtime → image
Cách thứ hai giúp CI chỉ cần Docker, không cần cài Maven/JDK đúng version.
Có vẻ còn thiếu lệnh CMD hay RUN gì đó nhỉ? Mình đoán sẽ bị hỏi lệnh nào chạy khi build, lệnh nào chạy khi khởi động container.
Đúng, đây là câu phỏng vấn rất hay gặp. Chia Dockerfile thành hai thời điểm:
Dockerfile
│
┌─────────┴─────────┐
▼ ▼
BUILD TIME RUN TIME
docker build docker run
│ │
▼ ▼
RUN ENTRYPOINT / CMD
RUN chạy lúc build, kết quả thành layer. Build xong chạy 100 container thì mvn clean package không chạy lại 100 lần.
ENTRYPOINT / CMD không chạy lúc build. Chúng được lưu vào metadata của image, và chạy khi container start.
ENTRYPOINT ["java", "-jar"]
CMD ["app.jar"]
docker run order-service → java -jar app.jar
docker run order-service other.jar → java -jar other.jar (CMD bị thay)
docker run --entrypoint sh -it order-service → đổi ENTRYPOINT phải dùng cờ riêng
Nếu chỉ có CMD ["java","-jar","app.jar"] thì docker run order-service echo hello thay toàn bộ, chạy echo hello chứ không chạy Java.
| RUN | CMD | ENTRYPOINT | |
|---|---|---|---|
| Build time | ✅ | ❌ | ❌ |
| Container start | ❌ | ✅ | ✅ |
Dễ override khi docker run | — | ✅ (tham số sau tên image) | Ít hơn (--entrypoint) |
| Mục đích | Build image | Lệnh/tham số mặc định | Executable chính |
FROM, WORKDIR, COPY, EXPOSE không phải "lệnh chạy app"; chúng thuộc giai đoạn build image.
Còn "init container" thì cẩn thận: trong Docker, thứ chạy khi container khởi động là ENTRYPOINT/CMD. Trong Kubernetes có khái niệm riêng tên Init Container: container chạy xong trước container chính (ví dụ đợi DB, chạy migration). Hai thứ khác nhau.
Phỏng vấn hỏi cách viết Dockerfile cho Spring Boot, RUN với CMD thì trả lời sao?
Q1. Bạn containerize một ứng dụng Spring Boot thế nào?
🇻🇳 Tiếng Việt:
Em dùng multi-stage build. Stage đầu dùng image Maven + JDK, copy
pom.xmlvà tải dependency trước để tận dụng cache, rồi copy source và build jar. Stage sau chỉ dùng JRE và copy jar sang, nên image gọn và không chứa build tool. Thêm.dockerignorevà layered jar để push nhanh hơn.
🇬🇧 English:
I use a multi-stage build. The first stage uses a Maven and JDK image, copies
pom.xmland downloads dependencies first to benefit from the layer cache, then copies the source and builds the JAR. The final stage uses only a JRE and copies the JAR, so the image is small and contains no build tools. I also add a.dockerignoreand use layered JARs so pushes are faster.
Q2. RUN, CMD, ENTRYPOINT khác nhau thế nào?
🇻🇳 Tiếng Việt:
RUN chạy lúc build image và kết quả thành một layer. CMD và ENTRYPOINT chạy khi container start. ENTRYPOINT là executable chính, CMD là lệnh hoặc tham số mặc định và bị thay khi truyền tham số vào
docker run.
🇬🇧 English:
RUN executes during the image build and its result becomes part of the image. CMD and ENTRYPOINT run when a container starts. ENTRYPOINT defines the main executable, while CMD provides default arguments or a default command that can be overridden on
docker run.
Q3. EXPOSE có mở port không? ARG khác ENV chỗ nào?
🇻🇳 Tiếng Việt:
EXPOSE chỉ là metadata, port thật được publish bằng
-phoặcportstrong Compose. ARG chỉ tồn tại lúc build, ENV có trong container lúc chạy. Cả hai đều không được dùng để chứa secret vì có thể đọc lại từ image history.
🇬🇧 English:
EXPOSE is only metadata; the port is actually published with
-porportsin Compose. ARG exists only at build time, while ENV is available in the running container. Neither should hold secrets because they can be read back from the image history.
Cách nhớ ngắn gọn
- RUN = build time; ENTRYPOINT/CMD = start time.
- ENTRYPOINT = executable, CMD = default args.
- EXPOSE = tài liệu;
-p= mở thật. - Multi-stage: build bằng JDK+Maven, chạy bằng JRE.
pom.xmltrước,srcsau; luôn có.dockerignore.
Tự kiểm tra
Vì sao COPY pom.xml + RUN dependency:go-offline trước COPY src?
pom.xml ít đổi nên layer dependency được cache. Sửa code chỉ build lại từ COPY src, không tải lại dependency.
Image cuối của multi-stage build chứa gì?
Chỉ những gì có trong stage cuối: JRE và jar được COPY --from=builder. Maven, JDK, source bị bỏ lại.
Không có EXPOSE thì -p 8080:8080 còn chạy không?
Có. Port mapping do -p tạo ra, EXPOSE chỉ là metadata.
CMD ["java","-jar","app.jar"] và chạy docker run img echo hi thì sao?
Toàn bộ CMD bị thay, container chạy echo hi rồi thoát, không chạy Java.
Dấu . cuối docker build là gì?
Build context: folder Docker được đọc khi COPY. File ngoài context không COPY được; .dockerignore loại file khỏi context.