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ạm | Triệu chứng | Công cụ phát hiện |
|---|---|---|
| Base image nặng | docker images thấy image >500MB trước khi COPY code | docker history --no-trunc image |
| Layer chứa rác | dive image thấy file .git, node_modules, __pycache__ | dive, docker sbom |
| Layer không merge | docker history thấy 20+ layer, mỗi layer tăng vài MB | docker 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: 87Bướ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 image | Kích thước | CVE (approx) | Dùng khi |
|---|---|---|---|
node:18 | 1.1 GB | 400+ | Đừng dùng cho production |
node:18-slim | 240 MB | 100+ | Cần glibc, apt |
node:18-alpine | 130 MB | 0-5 | Mặc định cho hầu hết app Node.js |
gcr.io/distroless/nodejs18 | 120 MB | 0-2 | Production, không cần shell |
scratch + static binary | 5-20 MB | 0 | Go/Rust với static binary |
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âyBướ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
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âyKỹ 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ước | Sau | Cải thiện |
|---|---|---|---|
| Image size | 1.4 GB | 87 MB | ↓ 94% |
| Số layers | 23 | 4 | ↓ 83% |
| Build time (cold) | 6 phút 12 giây | 1 phút 45 giây | ↓ 72% |
| Build time (cache hit) | 6 phút 12 giây | 12 giây | ↓ 97% |
| Pull time (100Mbps) | 3 phút 45 giây | 12 giây | ↓ 95% |
| CVE critical | 12 | 0 | ↓ 100% |
| CVE high | 87 | 0 | ↓ 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:
- Chọn base image đúng → giảm 60-90% kích thước, một dòng
FROM .dockerignore→ giảm build context 90%, build nhanh hơn- Sắp xếp lệnh đúng → cache hit gần như mỗi lần build
- Gộp RUN + dọn rác → giảm layer, không còn package cache thừa
- 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.