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):

  1. 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ề.
  2. 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.
  3. 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

  1. Không tái lặp (non-reproducible). Pull :latest hai lần cách nhau 1 tuần có thể ra hai image khác nhau hoàn toàn.
  2. 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? :latest không nói gì cả.
  3. 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.
  4. Base image drift. FROM node:latest trong 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:

EnvironmentTagMục đích
Local devmyapp:dev (hoặc không tag)Test nhanh, có thể ghi đè
Stagingmyapp:staging-<sha>Deploy tự động từ CI
Productionmyapp:<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ế:

  1. Resolve tag – Docker hỏi registry: “tag 1.2.3 trỏ đến manifest nào?” Registry trả về manifest (JSON) chứa config digest và danh sách layer digests.

  2. Tải config – Docker pull config blob (sha256:a1b2c3...), parse để lấy metadata: CMD, ENTRYPOINT, ENV, v.v.

  3. 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).
  4. 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 container
  • Config.Env – tất cả biến môi trường được set trong Dockerfile
  • Config.ExposedPorts – port được khai báo
  • RootFS.Layers – danh sách SHA256 của từng layer
  • Architecture, 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: 450MB node_modules – nhưng trong đó có cả devDependencies (typescript, jest, eslint, …)
  • Layer sau đó: RUN npm prune --production xóa devDependencies – nhưng layer npm install vẫ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.
  • :latest là anti-pattern trong production. Dùng semantic version + git SHA + digest pinning để đảm bảo image không thay đổi bất ngờ.
  • docker pull chỉ tải layer chưa có trong cache. docker push chỉ 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.