Docker image Node.js từ 1.2GB xuống 150MB — Từng bước một
CI/CD pipeline chạy 8 phút. Trong đó 4 phút là push Docker image lên registry và pull về production server. Image nặng 1.2GB cho một API Node.js — absurd nhưng phổ biến hơn bạn nghĩ. Rollback cũng mất 4 phút vì phải pull lại image cũ. Khi production đang cháy, 4 phút là vĩnh cửu.
Bài này đi qua từng bước tôi optimize Docker image từ 1.2GB xuống 150MB — không trick gì phức tạp, toàn fundamentals.
Bước 0: Hiểu image đang nặng vì đâu
Trước khi optimize, cần biết cái gì chiếm dung lượng:
# Xem size từng layer
docker history my-app:latest
# Chi tiết hơn
docker inspect my-app:latest | jq '.[0].Size'
# Tool phân tích layer visual
# dive — ncurses UI cho Docker image layers
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive my-app:latestdive cho thấy từng layer chứa gì, file nào lớn nhất, wasted space ở đâu. Trên image 1.2GB, breakdown thường là:
| Layer | Size |
|---|---|
Base image (node:18) |
~900MB |
node_modules |
~250MB |
| Source code + build artifacts | ~50MB |
Base image chiếm 75%. Đó là target đầu tiên.
Bước 1: Chọn base image đúng
Từ node:18 sang node:18-slim
# TRƯỚC: 900MB base image
FROM node:18
# SAU: 200MB base image
FROM node:18-slimnode:18 (hay node:18-bookworm) dựa trên Debian full — bao gồm compiler toolchain, Python, git, và hàng trăm packages hệ thống bạn không cần ở runtime. node:18-slim chỉ giữ những gì cần để chạy Node.js.
Từ node:18-slim sang node:18-alpine
# 200MB → 50MB
FROM node:18-alpineAlpine Linux dùng musl libc thay vì glibc, nhỏ hơn đáng kể. Nhưng có trade-off:
| Base image | Size | Glibc | Native modules |
|---|---|---|---|
| node:18 | ~900MB | glibc | Mọi thứ build được |
| node:18-slim | ~200MB | glibc | Cần install build tools |
| node:18-alpine | ~50MB | musl | Một số native modules fail |
⚠️ Nếu app dùng native modules (bcrypt, sharp, canvas) — test kỹ trên Alpine. Một số package cần rebuild với
muslhoặc có pre-built binary riêng cho Alpine.sharphỗ trợ tốt,bcryptcầnpython3 make g++lúc build.
Bước 2: Multi-stage build
Đây là bước tạo khác biệt lớn nhất. Tách build stage (cần dev dependencies, compiler) ra khỏi runtime stage (chỉ cần production code).
# ========== Stage 1: Build ==========
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files trước — tận dụng Docker layer cache
COPY package.json pnpm-lock.yaml ./
# Install ALL dependencies (bao gồm devDependencies)
RUN corepack enable && pnpm install --frozen-lockfile
# Copy source code và build
COPY . .
RUN pnpm build
# Prune devDependencies
RUN pnpm prune --prod
# ========== Stage 2: Runtime ==========
FROM node:18-alpine AS runtime
WORKDIR /app
# Chỉ copy những gì cần cho production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
USER nodejs
EXPOSE 3000
CMD ["node", "dist/server.js"]Tại sao hiệu quả:
- Build stage có TypeScript compiler, ESLint, test framework, build tools — tất cả bị discard
- Runtime stage chỉ chứa compiled JS + production dependencies
node_modulesgiảm 40-60% saupnpm prune --prod
Bước 3: Tối ưu layer caching
Docker cache từng layer. Nếu layer không thay đổi, Docker dùng cache. Thứ tự COPY ảnh hưởng trực tiếp đến cache hit rate.
# ❌ SAI: Copy tất cả trước khi install → mọi code change invalidate cache
COPY . .
RUN pnpm install
# ✅ ĐÚNG: Copy package files trước → install chỉ re-run khi dependencies thay đổi
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .Nguyên tắc: layer ít thay đổi nhất lên trước. Package files thay đổi ít hơn source code. Khi chỉ sửa source code, Docker skip layer pnpm install (cache hit) → build nhanh hơn nhiều.
.dockerignore
File .dockerignore giảm build context và ngăn copy rác vào image:
node_modules
dist
.git
.env*
*.md
.vscode
.idea
coverage
.nyc_output
tests
__tests__
*.test.js
*.spec.js💡 Không có
.dockerignorethìCOPY . .sẽ copy cảnode_moduleslocal (nếu có) và.gitfolder vào build context — chậm và phình image vô nghĩa.
Bước 4: Giảm node_modules production
Sau pnpm prune --prod, node_modules vẫn chứa nhiều thứ không cần:
# Trong build stage, sau pnpm prune
RUN find node_modules -type f \
\( -name "*.md" -o -name "*.txt" -o -name "*.map" \
-o -name "LICENSE*" -o -name "CHANGELOG*" \
-o -name "*.ts" -o -name "*.d.ts" \
-o -name ".package-lock.json" \) -delete && \
find node_modules -type d -empty -deleteXoá: README, changelog, license files, TypeScript declarations, source maps. Tiết kiệm 10-30MB tuỳ dependencies.
Cách gọn hơn với node-prune:
RUN npx node-prune node_modulesBước 5: Security hardening
Image nhỏ hơn cũng an toàn hơn — ít packages = ít attack surface.
FROM node:18-alpine AS runtime
# Không chạy root
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Xoá package manager — không cần trên production
RUN rm -rf /usr/local/lib/node_modules/npm && \
rm -rf /usr/local/lib/node_modules/corepack
WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
USER nodejs
# Read-only filesystem hint
# (enforce bằng docker run --read-only)
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]Checklist security:
USER nodejs— không chạy root--chowntrên COPY — files thuộc non-root user- Xoá npm/corepack — ngăn
npm installtrên production NODE_ENV=production— Express tắt debug, dependencies skip dev behavior
Bước 6: Distroless — nếu muốn đi xa hơn
Google Distroless images không có shell, package manager, hay bất kỳ tool nào ngoài runtime:
FROM node:18-alpine AS builder
# ... build steps ...
# Runtime: distroless
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"]Size: ~120MB (Node.js runtime + V8 + minimal OS). Không có shell nên không thể docker exec -it ... sh — debug khó hơn nhưng attack surface gần như zero.
💡 Distroless tốt cho production, nhưng hãy giữ một variant có shell cho staging/debugging. Dùng Docker build target:
docker build --target runtime . # distroless cho production docker build --target debug . # alpine cho debugging
Kết quả
| Optimization | Image size |
|---|---|
node:18 + copy all |
1.2GB |
→ node:18-slim |
450MB |
→ node:18-alpine |
280MB |
| → Multi-stage build | 180MB |
| → Prune node_modules | 155MB |
| → Clean unused files | 150MB |
Deploy time: 4 phút → 45 giây. Rollback cũng 45 giây. CI/CD pipeline từ 8 phút xuống 4 phút.
Bonus: Docker layer cache hit rate tăng từ ~30% lên ~85% — hầu hết builds chỉ re-run layer copy source code, skip install dependencies hoàn toàn.
Dockerfile hoàn chỉnh
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build && \
pnpm prune --prod && \
npx node-prune node_modules
FROM node:18-alpine AS runtime
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
USER nodejs
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]12 dòng logic, 150MB image, deploy dưới 1 phút. Không có gì fancy — chỉ là chọn base image đúng, tách build/runtime, và dọn dẹp. Hầu hết Docker images Node.js nặng không phải vì app phức tạp, mà vì Dockerfile copy-paste từ tutorial không ai optimize.