Một Dockerfile viết vội có thể cho ra image 1.4GB, build mất 6 phút, pull mất 3 phút, Trivy quét ra vài trăm CVE. Cũng cùng app đó, áp dụng đúng kỹ thuật theo đúng thứ tự, có thể xuống 87MB, build 45 giây, 0 CVE critical. Không phải phép màu — chỉ là multi-stage builds, layer ordering, base image đúng, và dùng các tính năng của BuildKit.

Bài này là systematic approach: đi từ lý thuyết layer model đến từng kỹ thuật cụ thể, kèm benchmark thực tế. Mỗi kỹ thuật có số liệu: giảm bao nhiêu MB, tiết kiệm bao nhiêu giây build. Đọc xong bạn có thể audit Dockerfile bất kỳ và biết chính xác nên sửa gì trước.

Đọc thêm bài Container image nhẹ và an toàn để hiểu sâu về Alpine vs Distroless vs Slim và CVE scanning — bài này tập trung vào workflow tối ưu hơn là so sánh base image.


Tại sao image to? Ba thủ phạm chính

Mọi Docker image lớn đều do một (hoặc cả ba) nguyên nhân sau. Hiểu nguyên nhân trước khi tối ưu — nếu không bạn sẽ optimize sai chỗ.


  flowchart LR
    A["Image 1.4GB"] --> B1["Base image nặng<br/>ubuntu: 77MB<br/>node:18: 1.1GB"]
    A --> B2["Layer chứa rác<br/>build tools, cache,<br/>git history, node_modules dev"]
    A --> B3["Layer không merge<br/>mỗi RUN/COPY = 1 layer<br/>layer cũ không xóa được"]
Thủ phạmTriệu chứngCông cụ phát hiện
Base image nặngdocker images thấy image >500MB trước khi COPY codedocker history --no-trunc image
Layer chứa rácdive image thấy file .git, node_modules, __pycache__dive, docker sbom
Layer không mergedocker history thấy 20+ layer, mỗi layer tăng vài MBdocker history image

Quy trình tối ưu 5 bước

Làm theo thứ tự này — mỗi bước giải quyết một thủ phạm, và bước sau xây dựng trên kết quả bước trước. Đừng nhảy cóc.

Bước 0: Đo baseline

Trước khi sửa bất cứ thứ gì, ghi lại số liệu hiện tại:

# Kích thước image
docker images --format "{{.Size}}" myapp

# Số layer và kích thước từng layer
docker history --no-trunc myapp | head -20

# Audit chi tiết — file nào, folder nào chiếm bao nhiêu
dive myapp

# Số CVE
docker scout quickview myapp
# hoặc
trivy image myapp

Baseline của mình (Node.js app thực tế):

Image size:   1.4 GB
Layers:       23
Build time:   6 phút 12 giây
Pull time:    3 phút 45 giây (trên connection 100Mbps)
CVE critical: 12
CVE high:     87

Bước 1: Chọn base image đúng

Đây là bước cho kết quả lớn nhất với ít công sức nhất — đổi một dòng FROM, giảm ngay 60-90% kích thước.

# TRƯỚC: 1.1 GB
FROM node:18

# SAU: 130 MB — giảm 88%
FROM node:18-alpine

Bảng so sánh các base image Node.js:

Base imageKích thướcCVE (approx)Dùng khi
node:181.1 GB400+Đừng dùng cho production
node:18-slim240 MB100+Cần glibc, apt
node:18-alpine130 MB0-5Mặc định cho hầu hết app Node.js
gcr.io/distroless/nodejs18120 MB0-2Production, không cần shell
scratch + static binary5-20 MB0Go/Rust với static binary
Đừng dùng Alpine nếu app dùng native addon. node-gyp cần compile trên Alpine (musl ≠ glibc). Nhiều package Python cũng gặp vấn đề tương tự. Nếu app có native dependency, dùng -slim thay vì -alpine, và dùng multi-stage build để giảm kích thước.

Benchmark sau bước 1:

Image size:   130 MB (↓ 91% từ 1.4 GB)
CVE critical: 3 (↓ 75% từ 12)
Build time:   5 phút 30 giây

Bước 2: .dockerignore — ngăn rác vào build context

Build context là toàn bộ file trong thư mục hiện tại được gửi đến Docker daemon. Không có .dockerignore, COPY . . gửi tất cả: .git, node_modules, .env, test files, log files.

# .dockerignore
.git
.gitignore
node_modules
dist
.env
.env.*
*.log
*.md
!README.md
.gitlab-ci.yml
.github
.vscode
.idea
coverage
test
tests
__pycache__
*.pyc
Dockerfile
docker-compose*.yml
Kiểm tra build context size trước và sau. Dùng docker build --progress=plain để xem context size. Một dự án Node.js trung bình có .git + node_modules nặng 500MB-2GB — .dockerignore cắt 90%+ trước khi build bắt đầu.

Benchmark sau bước 2:

Build context: 15 MB (↓ từ 850 MB)
Build time:    2 phút 10 giây (↓ 60% — không gửi rác lên daemon)

Bước 3: Tối ưu thứ tự lệnh — tận dụng layer cache

Docker cache mỗi layer. Nếu một layer thay đổi, tất cả layer sau nó phải rebuild. Nguyên tắc: lệnh ít thay đổi nhất ở trên cùng.

# SAI: source code thay đổi mỗi lần commit → cache miss toàn bộ
FROM node:18-alpine
WORKDIR /app
COPY . .                    # ← Thay đổi liên tục
RUN npm ci                  # ← Cache miss vì layer trên thay đổi
RUN npm run build           # ← Cache miss

# ĐÚNG: package.json ít thay đổi → cache hit
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                  # ← Cache hit! Chỉ chạy lại khi package.json đổi
COPY . .                    # ← Chỉ lớp này rebuild
RUN npm run build           # ← Chỉ chạy khi source code đổi

Pattern này áp dụng cho mọi ngôn ngữ:

# Python — pip install cache
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

# Go — download modules trước
COPY go.mod go.sum ./
RUN go mod download
COPY . .

# Java — Maven/Gradle dependency trước
COPY pom.xml ./
RUN mvn dependency:go-offline
COPY src ./src

Benchmark sau bước 3:

Build time (cache hit):  12 giây (↓ 94% — chỉ rebuild layer cuối)
Build time (cache miss): 2 phút 10 giây (không đổi — nhưng cache miss hiếm hơn)

Bước 4: Gộp layer và dọn rác trong cùng layer

Mỗi lệnh RUN tạo một layer mới. File bị xóa ở layer sau vẫn tồn tại trong layer trước — image tổng vẫn chứa file đó.

# SAI: apt cache vẫn trong layer 1, không bao giờ mất
RUN apt-get update
RUN apt-get install -y python3
RUN rm -rf /var/lib/apt/lists/*    # ← Xóa trong layer 3, layer 1 vẫn có cache

# ĐÚNG: gộp tất cả vào một RUN → cache và package cùng layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends python3 && \
    rm -rf /var/lib/apt/lists/* && \
    apt-get clean

Pattern cho từng package manager:

# Debian/Ubuntu: apt-get
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        curl \
        ca-certificates && \
    rm -rf /var/lib/apt/lists/* && \
    apt-get clean

# Alpine: apk
RUN apk add --no-cache curl ca-certificates

# Node.js: npm
RUN npm ci --omit=dev && \
    npm cache clean --force

# Python: pip
RUN pip install --no-cache-dir -r requirements.txt

--no-install-recommends (apt) và --no-cache (apk) là hai flag nhỏ nhưng cắt được 50-100MB package không cần thiết.

Benchmark sau bước 4:

Image size:  110 MB (↓ 15% từ 130 MB)
Layers:      8 (↓ từ 18 — gộp RUN)

Bước 5: Multi-stage builds — tách build-time khỏi runtime

Đây là kỹ thuật cho kết quả lớn nhất. Ý tưởng: build trong stage đầu (có đầy đủ compiler, dev dependencies), chỉ copy artifact cuối cùng sang stage runtime tối giản.

# Stage 1: Build — có tất cả toolchain
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production — chỉ có runtime + artifact
FROM node:18-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER node
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]

Các pattern multi-stage nâng cao:

# Pattern 1: Builder riêng, runtime riêng — Go binary
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app /app
ENTRYPOINT ["/app"]

# Pattern 2: Dependency stage trung gian — npm ci cache
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:18-alpine
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]

# Pattern 3: BuildKit cache mount — cache package manager giữa các build
# (Không cần COPY package.json trước nếu dùng cache mount)
FROM node:18-alpine
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    npm ci --omit=dev
COPY . .
RUN npm run build

Benchmark sau bước 5 (distroless final stage):

Image size:   87 MB (↓ 21% từ 110 MB, ↓ 94% từ baseline 1.4 GB)
Layers:       4 (chỉ runtime artifacts)
CVE critical: 0
CVE high:     0
Build time:   1 phút 45 giây
Pull time:    12 giây

Kỹ thuật nâng cao: dưới 50MB

Từ Alpine xuống Distroless

Nếu app không cần shell để debug, distroless là bước cuối cùng:

FROM node:18-alpine AS builder
# ... build như bình thường ...

FROM gcr.io/distroless/nodejs18-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]

Image giảm từ 110MB (Alpine) xuống ~87MB (distroless) — không có shell, không có package manager, không có gì ngoài Node runtime.

Binary-only final stage (Go, Rust)

FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app

FROM scratch
COPY --from=builder /app /app
ENTRYPOINT ["/app"]

Kết quả: 5-12MB cho một web server hoàn chỉnh. File binary duy nhất trong image.

COPY –chown để tránh permission issue

COPY --chown=1000:1000 --from=builder /app/dist /app/

Không cần RUN chown (thêm một layer). Áp dụng được từ BuildKit.


Audit checklist: trước mỗi lần push

# 1. Kiểm tra kích thước
docker images myapp --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"

# 2. Kiểm tra từng layer
docker history --no-trunc myapp

# 3. Dive: xem file nào chiếm dung lượng
dive myapp
# Trong giao diện dive: Tab để xem layer, Ctrl+U để xem file unused

# 4. Security scan
trivy image --severity HIGH,CRITICAL myapp

# 5. SBOM — biết chính xác có gì trong image
docker sbom myapp

# 6. Build time benchmark
time docker build -t myapp .

Bảng tổng kết benchmark

Pipeline tối ưu từ đầu đến cuối, áp dụng đủ 5 bước:

Chỉ sốTrướcSauCải thiện
Image size1.4 GB87 MB↓ 94%
Số layers234↓ 83%
Build time (cold)6 phút 12 giây1 phút 45 giây↓ 72%
Build time (cache hit)6 phút 12 giây12 giây↓ 97%
Pull time (100Mbps)3 phút 45 giây12 giây↓ 95%
CVE critical120↓ 100%
CVE high870↓ 100%

Tổng kết

Năm bước theo thứ tự ưu tiên — làm từ trên xuống, mỗi bước cho kết quả đo được:

  1. Chọn base image đúng → giảm 60-90% kích thước, một dòng FROM
  2. .dockerignore → giảm build context 90%, build nhanh hơn
  3. Sắp xếp lệnh đúng → cache hit gần như mỗi lần build
  4. Gộp RUN + dọn rác → giảm layer, không còn package cache thừa
  5. Multi-stage builds → chỉ artifact runtime trong image cuối, 0 CVE

Sau 5 bước, image của bạn sẽ dưới 100MB với 0 CVE critical. Không cần tool đặc biệt — chỉ cần áp dụng đúng thứ tự.


Câu hỏi hay gặp

Tại sao không dùng docker build --squash?

--squash gộp tất cả layer thành một, giảm kích thước nhưng mất toàn bộ cache. Không nên dùng cho CI/CD vì mỗi lần build phải build lại từ đầu. Multi-stage build đạt kết quả tương tự mà vẫn giữ được cache cho từng stage. Chỉ dùng --squash khi chuẩn bị ship image cuối cùng ra external registry và không cần cache nữa.

Alpine vs Distroless: chọn cái nào?

Alpine nếu bạn cần shell để debug (docker exec -it sh), cần package manager (apk), hoặc app có native dependency phức tạp. Distroless nếu bạn muốn security tối đa, không cần shell, và app không có native dependency đặc biệt. Go/Rust binary: dùng scratch (0MB base, chỉ có binary).

Làm sao tối ưu image có GPU/CUDA?

CUDA image (nvidia/cuda) thường 3-5GB — không thể dùng Alpine vì NVIDIA chỉ hỗ trợ glibc. Chiến lược: dùng nvidia/cuda:12.x-runtime-ubuntu22.04 thay vì -devel, dùng multi-stage build với stage devel riêng, và chỉ copy model artifacts sang runtime stage. Không có cách nào giảm dưới 1GB với CUDA — base runtime đã 1GB+.

Cache mount trong BuildKit khác gì với layer cache?

Layer cache cache nguyên layer trên disk (tất cả file trong layer đó). Cache mount (--mount=type=cache) cache một thư mục cụ thể (như /root/.npm, /go/pkg/mod) giữa các lần build — file vẫn được cache kể cả khi layer bị invalidate. Cache mount hữu ích cho package manager cache vì nó persist giữa các build ngay cả khi package.json thay đổi. Layer cache thì không — khi package.json thay đổi, layer RUN npm ci bị invalidate hoàn toàn.


Bài trước: Phần 15: Docker troubleshooting: 20 vấn đề thường gặp

Dây là bài bổ sung cho giai đoạn Core Docker — đọc sau Phần 4 (Dockerfile thực chiến) để có workflow tối ưu hoàn chỉnh. Quay lại mục lục để chọn lộ trình đọc theo mục tiêu.