Skip to content

Buổi 6: Multi-stage Builds & Optimization

🎯 Mục tiêu

  • Hiểu multi-stage build và cách sử dụng
  • Tối ưu layer caching trong Dockerfile
  • Sử dụng .dockerignore đúng cách
  • Giảm image size đáng kể
  • So sánh trước và sau khi tối ưu

1. Vấn đề: Image quá lớn

Dockerfile chưa tối ưu

dockerfile
# ❌ BAD: Image quá lớn!
FROM node:20

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/server.js"]

Vấn đề:

  • Base image node:201GB
  • Chứa cả devDependencies (webpack, typescript...)
  • Chứa source code gốc (không cần trong production)
  • Chứa node_modules đầy đủ
┌─────────────────────────────────┐
│  node:20 base          ~1.0 GB │
│  node_modules (dev)    ~300 MB │
│  Source code           ~50  MB │
│  Build output          ~5   MB │
│  ─────────────────────────────  │
│  TỔNG:                ~1.35 GB │ ← 😱
└─────────────────────────────────┘

2. Multi-stage Build

Khái niệm

Multi-stage build cho phép dùng nhiều FROM trong cùng một Dockerfile. Mỗi FROM là một "stage". Stage cuối chỉ chứa những gì cần cho production.

Dockerfile tối ưu

dockerfile
# ── Stage 1: Build ──────────────────────
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files trước (cache layer)
COPY package*.json ./
RUN npm ci

# Copy source và build
COPY . .
RUN npm run build

# ── Stage 2: Production ────────────────
FROM node:20-alpine AS production

WORKDIR /app

# Chỉ copy production dependencies
COPY package*.json ./
RUN npm ci --omit=dev

# Copy build output từ stage builder
COPY --from=builder /app/dist ./dist

# Chạy với user non-root
USER node

EXPOSE 3000
CMD ["node", "dist/server.js"]

So sánh kết quả

┌────────────────────────────────────────────┐
│        TRƯỚC (single-stage)                │
│  node:20         ~1.0 GB                   │
│  node_modules    ~300 MB (all deps)        │
│  Source + Build   ~55 MB                   │
│  ───────────────────────                   │
│  TỔNG:           ~1.35 GB  😱              │
└────────────────────────────────────────────┘

┌────────────────────────────────────────────┐
│        SAU (multi-stage)                   │
│  node:20-alpine  ~130 MB                   │
│  node_modules    ~50 MB (prod only)        │
│  dist/           ~5  MB                    │
│  ───────────────────────                   │
│  TỔNG:           ~185 MB  ✅  (-86%)       │
└────────────────────────────────────────────┘

Multi-stage cho các ngôn ngữ khác

Go – Tối ưu cực đại:

dockerfile
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .

# Production – chỉ chứa binary!
FROM scratch
COPY --from=builder /app/main /main
EXPOSE 8080
ENTRYPOINT ["/main"]
# Image size: ~10-15MB! 🎉

Rust – Static binary:

dockerfile
FROM rust:1.77-alpine AS builder
WORKDIR /app
RUN apk add --no-cache musl-dev
COPY . .
RUN cargo build --release

FROM scratch
COPY --from=builder /app/target/release/myapp /myapp
EXPOSE 8080
ENTRYPOINT ["/myapp"]
# Image size: ~5-10MB! 🎉

Python:

dockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
EXPOSE 8000
CMD ["python", "app.py"]

3. Layer Caching

Cách Docker cache hoạt động

Dockerfile instruction    │  Cache?
──────────────────────────┼─────────
FROM node:20-alpine       │  ✅ Cached (nếu image không đổi)
WORKDIR /app              │  ✅ Cached
COPY package*.json ./     │  ✅ Cached (nếu package.json không đổi)
RUN npm ci                │  ✅ Cached (nếu layer trước cached)
COPY . .                  │  ❌ MISS (source code thay đổi)
RUN npm run build         │  ❌ MISS (phải rebuild)

Quy tắc: Khi một layer thay đổi, tất cả layer phía sau phải build lại.

Tối ưu thứ tự COPY

dockerfile
# ❌ BAD: Mỗi khi sửa code → npm install lại
FROM node:20-alpine
WORKDIR /app
COPY . .                      # ← Thay đổi mỗi khi sửa code
RUN npm install               # ← Phải chạy lại mỗi lần!
CMD ["node", "server.js"]

# ✅ GOOD: Chỉ npm install khi package.json thay đổi
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./         # ← Ít khi thay đổi → cached
RUN npm install               # ← Cached nếu package.json không đổi
COPY . .                      # ← Thay đổi khi sửa code
CMD ["node", "server.js"]     # ← Vẫn phải copy lại, nhưng nhanh

Gộp RUN commands

dockerfile
# ❌ BAD: Mỗi RUN tạo một layer
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y wget
RUN apt-get clean

# ✅ GOOD: Gộp thành 1 layer + cleanup
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      curl \
      wget && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

4. .dockerignore

Tại sao cần .dockerignore?

Khi build, Docker gửi toàn bộ build context (thư mục hiện tại) đến Docker daemon. Nếu không ignore, nó sẽ gửi cả node_modules, .git, v.v.

File .dockerignore mẫu

dockerignore
# Dependencies
node_modules
.npm

# Build output (sẽ build trong container)
dist
build

# Version control
.git
.gitignore

# IDE
.vscode
.idea
*.swp
*.swo

# OS files
.DS_Store
Thumbs.db

# Docker files (tránh recursive)
Dockerfile
docker-compose.yml
.dockerignore

# Environment files
.env
.env.local
.env.*.local

# Logs
logs
*.log
npm-debug.log*

# Tests
coverage
__tests__
*.test.js
*.spec.js

# Documentation
README.md
docs/
LICENSE

So sánh build context

bash
# Không có .dockerignore
$ docker build -t myapp .
Sending build context to Docker daemon  500MB  😱

# Có .dockerignore
$ docker build -t myapp .
Sending build context to Docker daemon  2.5MB

5. Các kỹ thuật giảm image size

Bảng so sánh base images

Base ImageSizeDùng khi
node:20~1GBCần full OS
node:20-slim~200MBProduction web apps
node:20-alpine~130MBMuốn nhẹ nhất
python:3.12~1GBCần compile C extensions
python:3.12-slim~52MBProduction
python:3.12-alpine~17MBSiêu nhẹ (⚠️ musl libc)
golang:1.22~800MBBuild stage
alpine:3.19~7MBMinimal runtime
scratch0MBStatic binaries
distroless~2MBGoogle's minimal images

Checklist tối ưu

#Kỹ thuậtTiết kiệm
1Dùng -alpine hoặc -slim~60-80%
2Multi-stage build~50-90%
3.dockerignoreBuild nhanh hơn
4Gộp RUN commands~5-10%
5--no-cache-dir (pip)~10-20%
6npm ci --omit=dev~30-50%
7Xóa cache trong RUN~5-10%
8Dùng scratch (Go/Rust)~95%+

6. Kiểm tra image size

bash
# Xem size tổng
$ docker images myapp
REPOSITORY   TAG       SIZE
myapp        latest    185MB

# Xem chi tiết từng layer
$ docker history myapp:latest
IMAGE          CREATED         CREATED BY                SIZE
a1b2c3d4e5f6   2 minutes ago   CMD ["node" "dist/ser…   0B
b2c3d4e5f6a7   2 minutes ago   EXPOSE 3000              0B
c3d4e5f6a7b8   2 minutes ago   COPY dir:abc123 /app…    5.2MB
d4e5f6a7b8c9   3 minutes ago   RUN npm ci --omit=dev    48MB
e5f6a7b8c9d0   5 minutes ago   COPY package*.json ./    1.2KB
...

# Tool phân tích chi tiết: dive
$ docker run --rm -it \
  -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive myapp:latest

🏋️ Bài tập thực hành

Bài 1: So sánh base images

  1. Build cùng một app với node:20, node:20-slim, node:20-alpine
  2. So sánh size: docker images | grep myapp
  3. Ghi nhận sự khác biệt

Bài 2: Multi-stage build

  1. Viết Dockerfile single-stage cho Node.js app (TypeScript)
  2. Viết Dockerfile multi-stage cho cùng app
  3. So sánh size hai images
  4. Chạy cả hai, kiểm tra hoạt động giống nhau

Bài 3: .dockerignore

  1. Build image KHÔNG có .dockerignore – ghi nhận build context size
  2. Tạo .dockerignore phù hợp
  3. Build lại – so sánh build context size và thời gian build

Bài 4: Tối ưu thực tế

  1. Lấy một Dockerfile "xấu" (dùng node:20, không multi-stage, không ignore)
  2. Áp dụng tất cả kỹ thuật tối ưu đã học
  3. Mục tiêu: giảm image size > 80%
  4. Kiểm tra app vẫn chạy đúng