Docker cơ bản: image, container và layer

1. Tổng quan

Nhiều người mới học Docker hay nhầm giữa image và container, hoặc không hiểu tại sao docker build lại nhanh đến vậy ở lần build thứ hai. Bài này giải thích ba khái niệm nền tảng: image, container và layer — kèm ví dụ dựng image cho một ứng dụng Spring Boot thật. Đọc xong, bạn sẽ tự viết được Dockerfile hợp lý, biết cách tận dụng cache layer để build nhanh hơn, và tránh được vài lỗi phổ biến khi containerize ứng dụng Java.

2. Image là gì

Image là một bản snapshot read-only chứa mọi thứ cần để chạy ứng dụng: hệ điều hành cơ bản, runtime (JVM chẳng hạn), thư viện, biến môi trường, và code của bạn. Image không tự chạy — nó giống như một bản “khuôn mẫu” để từ đó tạo ra container.

Bạn định nghĩa image bằng Dockerfile:

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

Giải thích từng dòng:

  • FROM: chọn base image (JRE 21 trên Temurin). Mọi thứ build thêm đều nằm trên nền này.
  • WORKDIR: đặt thư mục làm việc trong container, các lệnh sau sẽ chạy tương đối với thư mục này.
  • COPY: chép file jar đã build sẵn (từ mvn package) vào image.
  • ENTRYPOINT: lệnh chạy khi container khởi động.

Build image bằng lệnh:

docker build -t order-service:1.0 .

Kết quả:

[+] Building 3.2s (8/8) FINISHED
 => [1/3] FROM eclipse-temurin:21-jre
 => [2/3] WORKDIR /app
 => [3/3] COPY target/order-service.jar app.jar
 => exporting to image
 => => naming to docker.io/library/order-service:1.0

Image lúc này được lưu local, bạn xem danh sách bằng docker images.

Lưu ý: Image chỉ là file tĩnh. Chạy docker build nhiều lần với cùng Dockerfile và code không đổi sẽ không tạo ứng dụng đang chạy — muốn chạy, bạn cần tạo container từ image.

3. Container là gì

Container là một instance đang chạy (hoặc đã dừng) được tạo ra từ image. Nếu image là “class”, container chính là “object” — bạn có thể tạo nhiều container từ cùng một image, mỗi container độc lập về process, network, filesystem ghi (writable layer).

Tạo và chạy container:

docker run -d --name order-svc -p 8080:8080 order-service:1.0
  • -d: chạy ở chế độ detached (background).
  • --name: đặt tên container để dễ quản lý, thay vì dùng ID ngẫu nhiên.
  • -p 8080:8080: map port 8080 của host sang port 8080 trong container.

Kiểm tra container đang chạy:

docker ps
CONTAINER ID   IMAGE                  STATUS         PORTS                    NAMES
3f2a9c1b8d4e   order-service:1.0      Up 5 seconds   0.0.0.0:8080->8080/tcp   order-svc

Bạn có thể chạy thêm container thứ hai từ cùng image đó, chỉ cần đổi tên và port:

docker run -d --name order-svc-2 -p 8081:8080 order-service:1.0

Hai container này hoàn toàn độc lập: nếu một container crash hoặc bạn ghi file vào filesystem của nó, container còn lại không bị ảnh hưởng. Image gốc (order-service:1.0) vẫn không đổi — đó là bản chất read-only của image.

Dừng và xoá container khi không cần:

docker stop order-svc && docker rm order-svc

3.1. Container khác Virtual Machine (VM) ở đâu

Container chia sẻ kernel với host, không ảo hoá toàn bộ hệ điều hành như VM. Vì vậy container khởi động trong vài giây (như log build ở trên) chứ không phải vài chục giây như VM, và tốn ít tài nguyên hơn nhiều.

4. Layer là gì và tại sao quan trọng

Mỗi dòng lệnh trong Dockerfile (FROM, COPY, RUN, ADD…) tạo ra một layer riêng. Các layer này xếp chồng lên nhau (union filesystem), và Docker cache từng layer — nếu layer không đổi so với lần build trước, Docker dùng lại cache thay vì build lại.

Xét Dockerfile viết chưa tối ưu:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/order-service.jar"]

Vấn đề: COPY . . chép toàn bộ source code, kể cả khi bạn chỉ sửa một dòng comment. Vì layer COPY thay đổi, Docker buộc phải build lại layer RUN mvn package phía sau — tốn vài phút mỗi lần, dù code logic không đổi.

Cách tối ưu là tách phần phụ thuộc (ít đổi) ra trước, phần code (hay đổi) đưa xuống sau:

FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Giải thích:

  • COPY pom.xml . rồi RUN mvn dependency:go-offline: layer này chỉ đổi khi bạn thêm/xoá dependency trong pom.xml. Phần lớn thời gian, layer này được cache — bỏ qua bước download thư viện (thường tốn nhiều thời gian nhất).
  • COPY src ./src đặt sau: chỉ layer này và các layer phía sau bị rebuild khi bạn sửa code, layer dependency vẫn giữ cache.
  • Multi-stage build (AS build rồi FROM ... jre): stage cuối chỉ lấy file jar đã build, không mang theo JDK và Maven cache — image production nhỏ hơn đáng kể.

Xem các layer của một image bằng:

docker history order-service:1.0
IMAGE          CREATED        CREATED BY                      SIZE
a1b2c3d4e5f6   3 minutes ago  ENTRYPOINT ["java" "-jar"...]   0B
7f8e9d0c1b2a   3 minutes ago  COPY /app/target/order-ser…     45.2MB
...

Mỗi dòng ứng với một layer, kèm kích thước nó thêm vào. Layer COPY jar chiếm phần lớn dung lượng — điều này hợp lý vì nó chứa code thực của ứng dụng.

4.1. Layer cache hoạt động ra sao khi build lại

Giả sử bạn chỉ sửa một class Java rồi build lại:

docker build -t order-service:1.1 .
 => [build 1/5] FROM eclipse-temurin:21-jdk
 => CACHED [build 2/5] COPY pom.xml .
 => CACHED [build 3/5] RUN mvn dependency:go-offline
 => [build 4/5] COPY src ./src
 => [build 5/5] RUN mvn package -DskipTests
 => [stage-1 2/2] COPY --from=build /app/target/order-service.jar app.jar

Hai dòng CACHED cho thấy Docker bỏ qua việc download dependency — build nhanh hơn rõ rệt so với cách viết COPY . . ban đầu.

Lưu ý: Docker so sánh cache dựa trên nội dung file và thứ tự lệnh, không dựa vào timestamp. Nếu bạn đổi thứ tự COPY hay RUN, toàn bộ layer từ điểm đó trở đi sẽ bị invalidate, kể cả khi nội dung thực chất giống lần trước.

5. Kiểm tra bằng test tự động

Khi containerize một service, bạn thường muốn chắc rằng image build ra chạy đúng healthcheck. Dùng thư viện Testcontainers để viết test xác nhận container khởi động và trả response đúng.

Thêm dependency:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>

Viết test:

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;

import static org.assertj.core.api.Assertions.assertThat;

class OrderServiceContainerTest {

    @Test
    void containerShouldRespondOnHealthEndpoint() {
        try (GenericContainer<?> container =
                new GenericContainer<>("order-service:1.0")
                        .withExposedPorts(8080)
                        .waitingFor(Wait.forHttp("/actuator/health"))) {

            container.start();

            assertThat(container.isRunning()).isTrue();
            assertThat(container.getMappedPort(8080)).isPositive();
        }
    }
}
  • GenericContainer tạo container tạm từ image order-service:1.0, tự dọn dẹp khi block try kết thúc.
  • waitingFor(Wait.forHttp(...)): test đợi đến khi endpoint health trả về 2xx mới coi là container sẵn sàng, tránh race condition.
  • getMappedPort(8080): Docker map port ngẫu nhiên trên host, test lấy port thật để gọi tiếp nếu cần.

Chạy test này xác nhận image bạn build ở Mục 2 không chỉ build thành công mà còn chạy được đúng nghĩa — một bước kiểm tra nhiều người bỏ qua.

6. Lỗi thường gặp

  • Nhầm image với container khi dọn dẹp: chạy docker rm xoá container, nhưng image vẫn còn và vẫn chiếm dung lượng. Muốn xoá image, dùng docker rmi.
  • COPY toàn bộ source trước khi cài dependency: như đã nói ở Mục 4, làm mất lợi ích cache layer, build chậm không cần thiết.
  • Dùng base image quá nặng (ví dụ ubuntu đầy đủ) cho JRE runtime: image production phình to, tăng thời gian pull/deploy. Nên dùng image JRE tối giản như eclipse-temurin:21-jre hoặc bản -alpine nếu tương thích.
  • Build image mới mà không đổi tag: dùng lại tag latest khiến bạn khó rollback khi có lỗi production, vì không biết chính xác image nào đang chạy.

Khi nào không cần multi-stage build: nếu bạn build jar sẵn ở CI (ví dụ GitHub Actions) rồi mới docker build, bạn không cần stage build trong Dockerfile — chỉ cần COPY file jar có sẵn như ví dụ ở Mục 2. Multi-stage chỉ cần khi bạn muốn Docker tự biên dịch code từ source.

7. Kết luận

Image là bản mẫu tĩnh, container là instance đang chạy từ image đó, và layer là các lớp xếp chồng giúp Docker cache thông minh để build nhanh hơn. Nắm rõ thứ tự layer trong Dockerfile — đặt phần ít đổi (dependency) trước, phần hay đổi (source code) sau — là cách đơn giản nhất để tối ưu thời gian build. Từ nền tảng này, bạn có thể đi tiếp sang các chủ đề như Docker Compose để orchestrate nhiều service, hoặc tối ưu kích thước image bằng distroless base.

dockerdevopscontainerimagespring-boot