Dockerfile: build image cho Spring Boot

4 câu hỏi~30 phútTừ khóa: FROM, WORKDIR, COPY, RUN, CMD, ENTRYPOINT, EXPOSE, ENV, ARG, build context, multi-stage, layered jar, .dockerignore

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"]

2. Vấn đề

3. Giải pháp: các lệnh Dockerfile và multi-stage build

LệnhLàm gìThời điểm
FROMChọn base image, bắt đầu một stageBuild
WORKDIRĐặt thư mục làm việcBuild (lưu cả cho runtime)
COPYCopy file từ build context / stage khác vào imageBuild
RUNThực thi lệnh, kết quả thành một layerBuild
ARGBiến chỉ tồn tại lúc build (--build-arg)Build
ENVBiến môi trường, có trong containerBuild + Runtime
EXPOSEMetadata: port app dự kiến listenChỉ là metadata
ENTRYPOINTExecutable chính của containerContainer start
CMDLệnh/tham số mặc định, dễ overrideContainer 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áchImage cuối chứaKích thước (ước lượng)Ghi chú
Build jar trên máy + COPYJRE + jar~250MBPhụ thuộc môi trường build của máy
Một stage MavenJDK + Maven + source + .m2 + jar~700MB+To, nhiều thứ thừa
Multi-stageJRE + jar~250MBBuild lặp lại được, image gọn
Multi-stage + layered jarJRE + các layer tách rời~250MB, push lại chỉ vài trăm KBTố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

Giải thích lại giúp mình:

  1. EXPOSE 8080 trong Dockerfile thật ra làm gì?
  2. 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.

RUNCMDENTRYPOINT
Build time✅❌❌
Container start❌✅✅
Dễ override khi docker run—✅ (tham số sau tên image)Ít hơn (--entrypoint)
Mục đíchBuild imageLệnh/tham số mặc địnhExecutable 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.xml và 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 .dockerignore và layered jar để push nhanh hơn.

🇬🇧 English:

I use a multi-stage build. The first stage uses a Maven and JDK image, copies pom.xml and 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 .dockerignore and 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 -p hoặc ports trong 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 -p or ports in 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


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.