Ai dùng Docker cũng làm việc với image mỗi ngày: docker build, docker pull, docker run. Nhưng ít khi ta dừng lại để hiểu image thực sự là gì. Image không phải một file .iso như VM. Nó là một tập hợp layers xếp chồng, được mô tả bởi manifest JSON. Mỗi layer là một delta so với layer bên dưới — thêm file, xóa file, đổi permission. Khi bạn docker pull myapp:latest, bạn đang tải về hàng chục layer như vậy. Khi base image bên dưới thay đổi (ví dụ Node.js 20 → 22), toàn bộ layer của bạn build trên phiên bản cũ có thể không còn tương thích — và bạn không biết trừ khi inspect.
Bài này giải thích mô hình image từ trong ra ngoài: manifest, layer model, copy-on-write, tagging strategy, và cách dùng dive để biết chính xác bên trong image có gì.
flowchart TB
subgraph Build["Build time"]
DF["Dockerfile<br/>FROM, RUN, COPY..."]
L1["Layer 1<br/>Base image (ubuntu:22.04)"]
L2["Layer 2<br/>RUN apt-get install"]
L3["Layer 3<br/>COPY app code"]
L4["Layer N<br/>RUN npm build"]
DF -->|"mỗi instruction"| L1
L1 --> L2 --> L3 --> L4
end
subgraph Registry["Registry (Docker Hub, ECR, GCR...)"]
MF["Manifest JSON<br/>config + layer digests"]
MF --> L1
MF --> L2
MF --> L3
MF --> L4
end
subgraph Runtime["Container runtime"]
RL["Thin R/W layer<br/>(copy-on-write)"]
L4 --> RL
end
L4 -->|"docker push"| MF
MF -->|"docker pull"| L1
Image không phải một file
Mở Docker Desktop, vào Images, bạn thấy một dòng myapp:1.2.3 nặng 450MB. Trực giác bảo đó là một file .tar hoặc một blob nhị phân duy nhất. Thực tế, nó là một tập hợp gồm nhiều thứ:
Manifest – JSON mô tả danh sách các layer, config của image (env vars, entrypoint, exposed ports, architecture), và digest của từng thành phần. Khi bạn docker pull, Docker tải manifest trước, rồi dựa vào đó mới biết cần pull những layer nào.
Config – JSON chứa metadata để tạo container: CMD, ENTRYPOINT, ENV, VOLUME, WORKDIR, user, v.v. Đây chính là thứ docker inspect trả về.
Layers – các filesystem tar.gz, mỗi cái là kết quả của một instruction trong Dockerfile. Mỗi layer có một content-addressable digest (SHA256), đảm bảo tính toàn vẹn: nếu nội dung layer thay đổi, digest thay đổi, và manifest phải trỏ đến digest mới.
Ví dụ manifest của một image đơn giản:
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 1456,
"digest": "sha256:a1b2c3..."
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 28123456,
"digest": "sha256:layer1digest..."
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 1234567,
"digest": "sha256:layer2digest..."
}
]
}
Multi-arch manifest (manifest list). Cùng một tag nginx:1.25 có thể trỏ đến image khác nhau tùy theo architecture. Đây là nhờ manifest list – một manifest đặc biệt liệt kê các sub-manifest cho từng nền tảng:
{
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"manifests": [
{
"platform": { "architecture": "amd64", "os": "linux" },
"digest": "sha256:abc..."
},
{
"platform": { "architecture": "arm64", "os": "linux" },
"digest": "sha256:def..."
}
]
}
Khi bạn docker pull nginx:1.25 trên Mac M1, Docker tự động chọn ARM variant. Trên server x86, nó chọn AMD64 variant. Không cần tag riêng :arm64 hay :amd64 nữa – Docker làm việc này trong suốt.
Bạn có thể kiểm tra multi-arch support bằng:
docker buildx imagetools inspect nginx:1.25
Layer model & Copy-on-Write
Mỗi instruction = một layer
Docker build image bằng cách chạy từng instruction trong Dockerfile. Mỗi RUN, COPY, ADD tạo ra một layer mới – một bản diff filesystem so với layer trước đó. Layer là read-only, xếp chồng lên nhau theo thứ tự build.
FROM ubuntu:22.04 # Layer 1: base Ubuntu
RUN apt-get update && \ # Layer 2: package list update
apt-get install -y nginx # (gộp chung 1 layer -- best practice)
COPY app/ /app/ # Layer 3: application code
RUN npm ci --production # Layer 4: dependencies
Kết quả: 4 layer (plus các layer từ base image của Ubuntu). Mỗi layer chỉ lưu diff – ví dụ Layer 2 chỉ chứa các file được thêm/sửa bởi apt-get install nginx, chứ không copy lại toàn bộ Ubuntu.
Gộp RUN để giảm layer. Mỗi RUN tạo một layer. Nếu bạn viết:
RUN apt-get update
RUN apt-get install -y nginx
Bạn có 2 layer: một cho apt-get update (cache package list), một cho apt-get install. Layer update trở nên vô dụng sau khi build xong nhưng vẫn chiếm dung lượng. Gộp lại:
RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*
Một layer, sạch hơn, nhẹ hơn. Luôn rm -rf /var/lib/apt/lists/* trong cùng RUN để file tạm không bị lưu vào layer.
Union filesystem & Copy-on-Write
Khi bạn docker run, Docker tạo một thin R/W layer (read-write layer mỏng) trên cùng của stack layers. Container ghi vào layer này, không ghi vào các layer read-only bên dưới. Cơ chế này gọi là Copy-on-Write (CoW):
- Container muốn đọc file
/etc/nginx/nginx.conf→ Docker tìm từ trên xuống, gặp file trong layer 2 (nơi cài nginx) → trả về. - Container muốn sửa
/etc/nginx/nginx.conf→ Docker copy file từ layer 2 lên R/W layer, rồi sửa bản copy đó. Layer gốc không bị đụng đến. - Container xóa file → Docker đánh dấu file “đã xóa” trong R/W layer (whiteout file). File gốc vẫn nằm trong layer read-only bên dưới.
flowchart TB
subgraph Container["Container filesystem view"]
RW["R/W Layer (thin)<br/>chỉ chứa file đã sửa"]
L3["Layer 3: COPY app/ (/app)"]
L2["Layer 2: RUN apt-get install nginx"]
L1["Layer 1: ubuntu:22.04 base"]
RW --> L3 --> L2 --> L1
end
subgraph CoW["Copy-on-Write"]
Read["Đọc file: tìm từ trên xuống<br/>dừng tại layer đầu tiên có file"]
Write["Ghi file: copy từ layer read-only<br/>lên R/W, sửa bản copy"]
Delete["Xóa file: tạo whiteout marker<br/>trong R/W layer"]
end
Cái hay: tất cả container từ cùng một image dùng chung các layer read-only. 10 container chạy từ myapp:1.2.3 không copy image 10 lần – chúng dùng chung stack layers bên dưới, chỉ khác nhau R/W layer riêng. Đây là lý do container khởi động nhanh và tốn ít disk hơn VM.
Cái dở: R/W layer mỏng, mặc định bị xóa khi container bị remove. Mọi dữ liệu ghi vào container mà không dùng volume hoặc bind mount sẽ mất. Đừng lưu database trong container filesystem.
Tagging strategy: :latest là anti-pattern
Tag trong Docker là con trỏ (pointer) đến một image digest cụ thể. Tag có thể di chuyển – nghĩa là myapp:latest hôm nay có thể trỏ đến digest abc123, ngày mai trỏ đến def456. Digest thì bất biến – myapp@sha256:abc123 luôn là cùng một image, mãi mãi.
Vấn đề với :latest
- Không tái lặp (non-reproducible). Pull
:latesthai lần cách nhau 1 tuần có thể ra hai image khác nhau hoàn toàn. - Debug không thể. App crash trên production, bạn muốn biết chính xác image nào đang chạy?
:latestkhông nói gì cả. - Rollback chậm. Muốn quay về phiên bản trước? Không có tag thì phải tìm lại digest cũ, nếu registry chưa garbage-collect.
- Base image drift.
FROM node:latesttrong Dockerfile – hôm nay là Node 20, tháng sau là Node 22, build lại thì lỗi vì breaking changes.
Chiến lược tag nên dùng
# Tag với git SHA -- biết chính xác commit nào tạo ra image
docker build -t myapp:abc1234 .
# Tag với semantic version
docker build -t myapp:1.2.3 .
# Tag với branch/stage
docker build -t myapp:staging .
# Kết hợp: nhiều tag cho cùng một image
docker build -t myapp:1.2.3 -t myapp:abc1234 -t myapp:staging .
Chiến lược phổ biến trong team mình:
| Environment | Tag | Mục đích |
|---|---|---|
| Local dev | myapp:dev (hoặc không tag) | Test nhanh, có thể ghi đè |
| Staging | myapp:staging-<sha> | Deploy tự động từ CI |
| Production | myapp:<semver> + myapp:<sha> | Release chính thức, immutable |
Đừng bao giờ dùng :latest trong production manifest. Kubernetes, Docker Compose, hay bất kỳ orchestrator nào – luôn pin đến một tag cụ thể. K8s có imagePullPolicy: Always kết hợp với :latest là công thức cho disaster: pod restart → pull image mới → app crash.
Thay vào đó: image: myapp:1.2.3 hoặc image: myapp@sha256:abc123... (digest pinning) để đảm bảo image không bao giờ thay đổi.
docker pull / push thực sự làm gì?
Khi bạn gõ docker pull myapp:1.2.3, đây là flow thực tế:
Resolve tag – Docker hỏi registry: “tag
1.2.3trỏ đến manifest nào?” Registry trả về manifest (JSON) chứa config digest và danh sách layer digests.Tải config – Docker pull config blob (
sha256:a1b2c3...), parse để lấy metadata:CMD,ENTRYPOINT,ENV, v.v.Tải từng layer – Với mỗi layer trong manifest:
- Kiểm tra local cache: “layer
sha256:layer1digestđã có chưa?” - Nếu có → skip (in ra
Already exists). - Nếu chưa → pull + decompress (layer là tar.gz).
- Verify checksum (digest khớp với manifest).
- Kiểm tra local cache: “layer
Unpack – Giải nén từng layer vào storage driver (overlay2, containerd snapshotter), tạo filesystem view cuối cùng.
sequenceDiagram
participant CLI as docker CLI
participant Registry as Registry
participant Local as Local Storage
CLI->>Registry: GET /v2/myapp/manifests/1.2.3
Registry-->>CLI: Manifest JSON (config + layer digests)
CLI->>Registry: GET /v2/myapp/blobs/sha256:config...
Registry-->>CLI: Config JSON
CLI->>Local: Kiểm tra layer cache
alt Layer chưa có
CLI->>Registry: GET /v2/myapp/blobs/sha256:layer1...
Registry-->>CLI: layer1.tar.gz
CLI->>Local: Verify SHA256 + unpack
else Layer đã có
CLI->>CLI: Skip (Already exists)
end
CLI->>Local: Ghi manifest + tag → image sẵn sàng
Điểm quan trọng: layer được cache và chia sẻ giữa các image. Nếu bạn có 10 image cùng base ubuntu:22.04, layer của Ubuntu chỉ tải về một lần. Khi pull image mới, Docker chỉ tải những layer chưa có trong cache. Đây là lý do docker pull lần đầu chậm nhưng những lần sau nhanh hơn hẳn.
docker push làm ngược lại: Docker gửi manifest lên registry, rồi push từng layer (chỉ push layer chưa có trên registry). Cross-repository blob mounting cho phép registry copy layer giữa các repo mà không cần upload lại – ví dụ push myapp:1.2.3 lên Docker Hub, các layer Ubuntu có thể được mount từ repo ubuntu mà không cần upload.
Inspect: biết bên trong image có gì
docker inspect
Trả về toàn bộ metadata dạng JSON:
docker inspect myapp:1.2.3
Output dài, nhưng những field quan trọng:
Config.Cmd,Config.Entrypoint– lệnh mặc định khi chạy containerConfig.Env– tất cả biến môi trường được set trong DockerfileConfig.ExposedPorts– port được khai báoRootFS.Layers– danh sách SHA256 của từng layerArchitecture,Os– kiến trúc image
docker history
Hiển thị lịch sử build – từng instruction tạo ra layer nào, nặng bao nhiêu:
docker history myapp:1.2.3
Output mẫu:
IMAGE CREATED CREATED BY SIZE
a1b2c3d4 2 hours ago COPY app/ /app/ 45MB
e5f6g7h8 2 hours ago RUN /bin/sh -c npm ci --production 120MB
i9j0k1l2 3 days ago RUN /bin/sh -c apt-get install... 85MB
<missing> 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> không có nghĩa là hỏng. Docker history hiển thị <missing> khi layer được build trên máy khác (ví dụ layer từ base image pull từ registry). Image vẫn hoàn toàn bình thường – chỉ là history không có thông tin build local cho những layer đó.dive – công cụ inspect chuyên sâu
dive là công cụ TUI (terminal UI) cho phép bạn duyệt từng layer của image, xem file nào được thêm/sửa/xóa, và quan trọng nhất: phát hiện file thừa.
# Cài đặt (macOS)
brew install dive
# Inspect image
dive myapp:1.2.3
Giao diện dive chia làm 3 panel:
- Trái trên: Danh sách layers, % dung lượng từng layer
- Trái dưới: Chi tiết layer đang chọn (command, size)
- Phải: Cây thư mục của layer đó, highlight file thay đổi
Điểm mạnh của dive là efficiency score – nó đánh giá mức độ lãng phí của image. Nếu bạn copy file vào layer 3 rồi xóa ở layer 5, file vẫn chiếm dung lượng trong layer 3 (vì layer là read-only, “xóa” chỉ là whiteout). Dive chỉ ra những file như vậy.
# CI mode -- output JSON, fail nếu efficiency < threshold
dive myapp:1.2.3 --ci --lowestEfficiency 0.95
Dùng dive trong CI pipeline để bắt kịp image phình ra trước khi nó lên production.
Phát hiện file thừa thực tế
Chạy dive trên image Node.js 1.2GB của team mình, mình phát hiện:
- Layer
npm install: 450MBnode_modules– nhưng trong đó có cả devDependencies (typescript,jest,eslint, …) - Layer sau đó:
RUN npm prune --productionxóa devDependencies – nhưng layernpm installvẫn giữ bản copy 450MB - Layer base image: bao gồm git, curl, python, build-essential – những thứ không cần cho runtime
Tổng cộng: image thực sự cần khoảng 300MB, nhưng chiếm 1.2GB vì file thừa bị “chôn” trong các layer cũ.
Cách khắc phục: dùng multi-stage build và chọn base image tối giản. Đọc thêm ở bài Container image nhẹ và an toàn.
Các công cụ inspect khác đáng dùng
docker sbom – Software Bill of Materials, liệt kê tất cả package trong image:
docker sbom myapp:1.2.3
# Output: danh sách npm packages + version + license
docker scout – quét vulnerability của từng layer:
docker scout quickview myapp:1.2.3
docker scout recommendations myapp:1.2.3
docker image save / load – export image ra file tar để chuyển qua máy khác:
docker save myapp:1.2.3 -o myapp.tar
docker load -i myapp.tar
Nhưng cẩn thận: file tar sẽ chứa toàn bộ layers, không nén gì thêm so với image gốc. Một image 1.2GB sẽ cho ra file tar 1.2GB.
docker save không phải cách backup image. Nó export image ra tar, nhưng không giữ metadata như tag, repository. Khi load lại, image sẽ mất tag – bạn phải docker tag lại. Nếu cần backup, push lên registry (private) hoặc dùng registry mirror.Tổng kết
- Image là tập hợp layers + manifest + config, không phải một file nhị phân duy nhất. Manifest mô tả danh sách layer và architecture; multi-arch manifest cho phép cùng một tag hoạt động trên ARM và x86.
- Mỗi Dockerfile instruction tạo một layer read-only. Container có R/W layer mỏng trên cùng nhờ Copy-on-Write. Nhiều container dùng chung base layers – tiết kiệm disk và khởi động nhanh.
:latestlà anti-pattern trong production. Dùng semantic version + git SHA + digest pinning để đảm bảo image không thay đổi bất ngờ.docker pullchỉ tải layer chưa có trong cache.docker pushchỉ upload layer mới. Layer được chia sẻ giữa các image và repository.- Dùng
diveđể inspect từng layer, phát hiện file thừa bị chôn trong layer cũ, và chạy CI check để ngăn image phình ra.
Câu hỏi hay gặp
1. “Image của tôi có 15 layer. Có nên giảm xuống không?”
Trả lời: Có, nhưng đừng cực đoan. Gộp tất cả RUN vào một layer duy nhất không phải lúc nào cũng tốt – nó phá vỡ layer cache, mỗi lần build lại phải chạy lại toàn bộ. Cân bằng: gộp các lệnh liên quan (ví dụ apt-get update && install && clean) vào cùng một RUN, nhưng giữ các bước độc lập (ví dụ cài OS packages, cài app dependencies, copy code) ở layer riêng để tận dụng cache.
2. “Làm sao biết image đang chạy trên production là phiên bản nào?”
Trả lời: docker inspect <container> | jq '.[0].Image' trả về image ID (SHA256 digest) – đây là định danh duy nhất, bất biến. So sánh với digest trên registry để xác nhận. Nếu bạn dùng tag :latest, kết quả chỉ là myapp:latest – vô nghĩa. Luôn tag với một định danh cụ thể.
3. “Pull image về có an toàn không? Lỡ image bị nhiễm malware?”
Trả lời: Docker tự verify SHA256 digest của từng layer khi pull – nếu digest không khớp với manifest, pull bị reject. Nhưng digest chỉ đảm bảo “image không bị sửa trên đường truyền”, không đảm bảo “image không chứa malware”. Luôn dùng image từ nguồn tin cậy (official images, verified publisher), quét bằng docker scout hoặc Trivy trước khi dùng, và pin digest trong production manifest.
4. “Multi-stage build có tạo nhiều image không?”
Trả lời: Không. Multi-stage build tạo một image duy nhất từ stage cuối cùng (hoặc stage được chỉ định với --target). Các stage trung gian được dùng để build, copy artifact, rồi bị discard. Image cuối cùng chỉ chứa layer từ base image của stage cuối + các file được COPY --from=... từ stage trước. Đọc thêm bài về Dockerfile best practices trong phần 4 của series này.
Bài tiếp theo (Core Docker): Phần 4: Dockerfile thực chiến, từng instruction chi tiết, best practices, và những sai lầm phổ biến.