cat blog/.md
Tối ưu Docker image: Từ 1.2GB xuống 80MB với multi-stage builds
Bạn đã bao giờ build xong Docker image rồi chạy docker images và thấy con số 1.2GB chưa? Mình thì có, và nó đau lắm — nhất là khi deploy lên server mà bandwidth hạn chế, hay chạy CI/CD mà mỗi lần push mất 5 phút chờ pull image.
Bài viết này mình sẽ chia sẻ cách giảm kích thước Docker image hơn 90% bằng multi-stage builds, cùng một số tips thực tế mà mình đã áp dụng trong các dự án production.
Vấn đề: Image phình to vì build tools
Đây là một Dockerfile “kinh điển” mà nhiều người viết khi mới bắt đầu:
# ❌ Cách viết phổ biến nhưng không tối ưu
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
Image này sẽ chứa toàn bộ những thứ bạn không cần trong production:
- Node.js development headers, npm cache
devDependencies(TypeScript compiler, ESLint, test frameworks…)- Source code TypeScript gốc (đã compile rồi, giữ làm gì?)
- Hàng trăm MB system packages của base image
node:20
Kết quả? Image nặng 1GB+ trong khi app thực tế chỉ cần vài chục MB.
Multi-stage builds là gì?
Ý tưởng rất đơn giản: dùng nhiều FROM trong cùng một Dockerfile. Mỗi FROM tạo một “stage” riêng biệt. Bạn build ở stage đầu, rồi chỉ copy kết quả sang stage cuối — bỏ lại toàn bộ build tools và dependencies không cần thiết.
# ✅ Multi-stage build
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && cp -R node_modules prod_modules
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json .
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]
Chuyện gì xảy ra ở đây:
- Stage
builder: Cài tất cả dependencies, compile TypeScript, bundle code — đầy đủ tools - Stage production: Chỉ copy
dist/(code đã build) vànode_modulesproduction — không có TypeScript, không có devDependencies
Kết quả: từ ~1.2GB xuống ~150MB. Đã giảm đáng kể, nhưng chưa dừng ở đây.
Đẩy xa hơn: Alpine vs Distroless
Dùng Alpine
node:20 (Debian-based) nặng khoảng 350MB. Đổi sang node:20-alpine chỉ còn ~50MB. Ví dụ trên đã dùng Alpine, nhưng mình muốn nhấn mạnh — đây là thay đổi đơn giản nhất mà hiệu quả nhất.
Dùng Distroless (cho advanced users)
Google cung cấp các distroless images — chỉ chứa runtime, không có shell, package manager, hay bất kỳ tool nào:
# Stage 2: Distroless
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["dist/index.js"]
Image giờ chỉ còn ~80MB, và bonus: không có shell nghĩa là attacker không thể exec vào container — bảo mật hơn rất nhiều.
Ví dụ thực tế: Python + FastAPI
Multi-stage builds không chỉ dành cho Node.js. Đây là ví dụ với Python:
# Stage 1: Build dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt -o requirements.txt --without-hashes
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Production
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY ./app ./app
EXPOSE 8000
RUN useradd -r appuser && chown -R appuser /app
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Poetry, build tools, pip cache — tất cả ở lại stage 1. Production image chỉ có Python runtime và code.
Tips bổ sung
1. .dockerignore — đừng copy rác vào image
# .dockerignore
node_modules
.git
.env*
*.md
tests/
coverage/
.vscode/
File .dockerignore hoạt động giống .gitignore — ngăn Docker copy những file không cần thiết vào build context. Điều này cũng tăng tốc build vì Docker không phải gửi hàng trăm MB node_modules lên daemon.
2. Tận dụng layer caching
Docker cache mỗi instruction thành một layer. Thứ tự instructions rất quan trọng:
# ✅ Tốt: copy package.json trước, install, rồi mới copy source
COPY package*.json ./
RUN npm ci
COPY . .
# ❌ Tệ: copy tất cả rồi mới install
COPY . .
RUN npm ci
Cách đầu tiên giúp Docker cache layer npm ci khi bạn chỉ thay đổi source code mà không đổi dependencies. Build nhanh hơn rất nhiều.
3. Chạy container với non-root user
# Node.js — image có sẵn user "node"
USER node
# Python — tạo user riêng
RUN useradd -r appuser
USER appuser
Chạy container bằng root là lỗi bảo mật phổ biến nhất. Nếu attacker exploit được app, họ sẽ có quyền root trong container — và có thể escape ra host trong một số trường hợp.
4. Scan image cho vulnerabilities
# Dùng Docker Scout (built-in từ Docker Desktop)
docker scout quickview myapp:latest
# Hoặc Trivy (open source)
trivy image myapp:latest
Scan thường xuyên để phát hiện CVE trong base image và dependencies. Nhiều CI/CD tool (GitHub Actions, GitLab CI) có thể tự động scan mỗi lần build.
So sánh kết quả
Bảng tổng hợp kích thước image cho một Node.js app thực tế:
| Approach | Image Size |
|---|---|
node:20 + tất cả trong 1 stage | 1.2GB |
node:20-alpine + 1 stage | 400MB |
| Multi-stage + Alpine | 150MB |
| Multi-stage + Alpine + prune devDeps | 100MB |
| Multi-stage + Distroless | 80MB |
Từ 1.2GB xuống 80MB — giảm 93%. Deploy nhanh hơn, CI/CD rẻ hơn, attack surface nhỏ hơn.
Kết
Multi-stage builds không phải là rocket science — nhưng impact thì rất lớn. Chỉ cần thêm một dòng FROM ... AS builder và COPY --from=builder là bạn đã có một image production-ready gọn gàng hơn rất nhiều.
Mình khuyên bạn nên:
- Kiểm tra image hiện tại của dự án bằng
docker images - Thử refactor Dockerfile với multi-stage builds
- Thêm
.dockerignorenếu chưa có - Chuyển sang Alpine hoặc Distroless cho production
Kết quả sẽ khiến bạn ngạc nhiên.
Bạn đang dùng Docker image bao nhiêu MB? Đã thử multi-stage builds chưa? Chia sẻ với mình qua email nhé!