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 buildnhiề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ồiRUN mvn dependency:go-offline: layer này chỉ đổi khi bạn thêm/xoá dependency trongpom.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 buildrồiFROM ... 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ự
COPYhayRUN, 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();
}
}
}
GenericContainertạo container tạm từ imageorder-service:1.0, tự dọn dẹp khi blocktrykế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 rmxoá container, nhưng image vẫn còn và vẫn chiếm dung lượng. Muốn xoá image, dùngdocker 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-jrehoặc bản-alpinenếu tương thích. - Build image mới mà không đổi tag: dùng lại tag
latestkhiế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 stagebuildtrong Dockerfile — chỉ cầnCOPYfile 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.