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:latest

dive 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-slim

node: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-alpine

Alpine 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 musl hoặc có pre-built binary riêng cho Alpine. sharp hỗ trợ tốt, bcrypt cần python3 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_modules giảm 40-60% sau pnpm 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ó .dockerignore thì COPY . . sẽ copy cả node_modules local (nếu có) và .git folder 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 -delete

Xoá: 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_modules

Bướ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
  • --chown trên COPY — files thuộc non-root user
  • Xoá npm/corepack — ngăn npm install trê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.


Có câu hỏi hay góp ý? Reach out trên Twitter hoặc GitHub.