Skip to content

Bad Dockerfile → Good Dockerfile — Hành Trình Tối Ưu

🎓 Bối cảnh bài học

Bạn vừa nhận repo từ một intern nghỉ việc. Dockerfile build mất 3 phút, image nặng 1.2GB, scanner báo 147 lỗ hổng bảo mật. Sếp yêu cầu: "Fix trước khi deploy lên staging chiều nay." Bài này chính là hành trình bạn sẽ đi qua — từng bước, từng phase — cho đến khi image chỉ còn 45MB, build dưới 10 giây, và zero critical vulnerabilities.

Đây không phải bài lý thuyết. Đây là bài thực chiến (transformation showcase) — nơi bạn chứng kiến một Dockerfile "thảm họa" được cải tạo thành production-grade qua 4 phase rõ ràng, mỗi phase đều có metric đo lường cụ thể.

Pedagogical flow: Horror → Fix cơ bản → Tối ưu → Production-grade

"Bạn không thể viết Dockerfile tốt nếu chưa từng thấy Dockerfile tệ — và hiểu tại sao nó tệ."


🔥 Phase 0: The Horror Dockerfile — Những Gì Junior Thường Viết

Dockerfile "Thảm Họa"

Hãy xem Dockerfile dưới đây — đây là thứ bạn sẽ gặp trong thực tế, không phải ví dụ bịa đặt:

dockerfile
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y nodejs npm curl wget git vim
COPY . /app
WORKDIR /app
RUN npm install
ENV NODE_ENV=production
ENV DB_PASSWORD=supersecret123
EXPOSE 3000
CMD npm start

Trông có vẻ "chạy được", phải không? Chạy được ≠ Production-ready. Hãy phân tích từng dòng.

🔍 Phân Tích Từng Vấn Đề (10 lỗi nghiêm trọng)

Lỗi 1: FROM ubuntu:latest — Base image phình to, không pin version

dockerfile
# ❌ Lỗi: ubuntu:latest
FROM ubuntu:latest

Vấn đề kép:

  • ubuntu là full OS — bao gồm hàng trăm package bạn không cần (systemd, man pages, locale data). Image nặng ~77MB trước khi bạn cài bất kỳ thứ gì.
  • :latest là tag trôi nổi — hôm nay là Ubuntu 24.04, tháng sau là 24.10. Build hôm nay và build ngày mai cho ra image khác nhau. Reproducibility = 0.

Hậu quả production: Một team ở Shopee từng gặp incident khi ubuntu:latest tự nhảy từ 22.04 → 24.04, khiến glibc version thay đổi và crash toàn bộ Node.js native addon.


Lỗi 2: Tách apt-get updateapt-get install thành 2 RUN

dockerfile
# ❌ Cache staleness
RUN apt-get update          # Layer 1: cached
RUN apt-get install -y ...  # Layer 2: dùng cache cũ

Vấn đề: Docker cache mỗi RUN thành 1 layer. Nếu Layer 1 đã cached, apt-get update không chạy lại → apt-get install dùng package list cũ → cài version cũ hoặc fail hoàn toàn.


Lỗi 3: Cài tool không cần thiết (vim, wget, git)

dockerfile
# ❌ Tăng attack surface + image size
RUN apt-get install -y nodejs npm curl wget git vim

Vấn đề:

  • vim trong production container? Bạn không nên SSH vào container để edit file.
  • git mang theo toàn bộ .git history. Attack surface tăng — mỗi binary thêm vào là một vector tấn công tiềm năng.
  • wget + curl trùng chức năng — chọn 1 hoặc không cần cả hai.

Lỗi 4: COPY . /app trước npm install — Cache busting

dockerfile
# ❌ Thay đổi 1 dòng code → rebuild toàn bộ node_modules
COPY . /app
WORKDIR /app
RUN npm install

Vấn đề: Docker invalidate cache từ layer thay đổi trở xuống. Bạn sửa 1 dòng trong src/index.jsCOPY . /app thay đổi → npm install chạy lại toàn bộ → 3 phút mỗi lần build, dù dependencies không đổi.

Production impact: CI/CD pipeline chạy 50 build/ngày × 3 phút = 150 phút/ngày wasted compute. Với cloud billing, đó là tiền thật.


Lỗi 5: npm install thay vì npm ci

dockerfile
# ❌ Không enforce lockfile
RUN npm install

Vấn đề:

  • npm install có thể thay đổi package-lock.json (resolve version mới nếu range cho phép).
  • Kết quả: image A build hôm nay có lodash@4.17.20, image B build tuần sau có lodash@4.17.21.
  • npm ci enforce chính xác lockfile — deterministic builds.

Lỗi 6: Hardcode secrets trong ENV

dockerfile
# ❌ TUYỆT ĐỐI KHÔNG — secrets lộ trong image history
ENV DB_PASSWORD=supersecret123

🔥 CRITICAL SECURITY VIOLATION

docker image history sẽ hiển thị toàn bộ ENV value. Bất kỳ ai pull được image đều đọc được password. Secrets KHÔNG BAO GIỜ được bake vào image — dùng Docker Secrets, mount file, hoặc environment variables lúc runtime.

bash
# Ai cũng có thể chạy lệnh này và đọc password
docker image history my-app:latest --no-trunc

Lỗi 7: Chạy container với quyền root

Không có USER instruction → container chạy với root. Nếu attacker exploit được ứng dụng, họ có root access bên trong container. Kết hợp với kernel vulnerability (CVE-2019-5736), họ có thể escape container và chiếm host.


Lỗi 8: Shell form CMD — Signal handling hỏng

dockerfile
# ❌ Shell form — chạy qua /bin/sh -c
CMD npm start

# Container nhận SIGTERM → /bin/sh nhận → npm KHÔNG nhận
# → Graceful shutdown thất bại → Docker kill -9 sau 10s timeout

Production impact: Rolling update trên Kubernetes — pod cũ không graceful shutdown → request đang xử lý bị drop → 502 errors cho user.


Lỗi 9: Không có .dockerignore

Không có .dockerignore = COPY . /app sẽ copy tất cả, bao gồm:

  • node_modules/ (300MB+ garbage)
  • .git/ (toàn bộ history)
  • .env (secrets!)
  • test/, docs/, *.md

Lỗi 10: Không multi-stage — mọi thứ trong 1 image

Build tools (gcc, make, python — cần cho native addon) nằm chung với runtime → image phình to vô nghĩa.

📊 Kết quả Phase 0

📦 Image Size:    ~1.2 GB
⏱️  Build Time:    ~3 phút
🔓 Vulnerabilities: 147 (23 critical, 41 high)
♻️  Cache Efficiency: ~0% (mỗi code change = full rebuild)
👤 User:           root
📡 Signal Handling: Broken

Bạn có thấy tất cả 10 lỗi không? Nếu bỏ sót dù chỉ 1, hãy đọc lại — vì production sẽ không tha thứ.


🔧 Phase 1: Basic Fixes — Sửa Từng Lỗi Một

Nguyên tắc: Fix theo thứ tự ưu tiên

  1. Security (secrets, root user) → fix trước vì ảnh hưởng nghiêm trọng nhất
  2. Correctness (reproducibility, signal handling) → fix tiếp để đảm bảo deterministic
  3. Performance (cache, image size) → fix cuối để tối ưu

Fix 1: Pin version + chuyển sang Alpine

dockerfile
# ✅ Pin version chính xác, dùng Alpine thay Ubuntu
FROM node:20.11-alpine3.19

Tại sao node:20.11-alpine3.19:

  • node:20.11 — pin cả major + minor, tránh breaking changes
  • alpine3.19 — Alpine Linux chỉ ~5MB, so với Ubuntu ~77MB
  • Node.js official image đã bao gồm node + npm → không cần apt-get install

⚠️ Cạm bẫy

Alpine dùng musl libc thay vì glibc

Hầu hết Linux distro dùng glibc, nhưng Alpine dùng musl. Một số native package (đặc biệt Python C extensions, bcrypt cũ, sharp) có thể fail khi build trên Alpine.

Giải pháp: Test kỹ trên Alpine trước. Nếu gặp lỗi compilation, dùng node:20.11-slim (Debian-based, ~70MB) thay vì quay lại Ubuntu.

bash
# Test nhanh xem app có chạy trên Alpine không
docker run --rm node:20.11-alpine3.19 node -e "console.log('musl works')"

Fix 2: Gộp RUN commands + dọn cache

dockerfile
# ✅ Nếu cần package bổ sung trên Alpine
RUN apk add --no-cache \
    dumb-init \
  && rm -rf /var/cache/apk/*
  • --no-cache → không lưu index file (tiết kiệm ~5MB)
  • dumb-init → giải quyết PID 1 signal handling (sẽ dùng ở CMD)

Fix 3: Reorder COPY — Tận dụng cache

dockerfile
# ✅ Copy lockfile trước → npm ci → copy source code
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

# Source code thay đổi thường xuyên → copy sau
COPY . .

Tại sao hiệu quả: package.jsonpackage-lock.json ít thay đổi. Khi source code thay đổi, Docker chỉ invalidate từ COPY . . trở xuống → npm ci dùng cache → tiết kiệm 90% build time.

Fix 4: Xóa secrets khỏi image

dockerfile
# ❌ KHÔNG BAO GIỜ làm thế này
# ENV DB_PASSWORD=supersecret123

# ✅ Pass secrets lúc runtime
# docker run -e DB_PASSWORD=$DB_PASSWORD my-app
# Hoặc dùng Docker Secrets / Vault

Fix 5: Thêm non-root user

dockerfile
# ✅ Tạo user riêng cho ứng dụng
RUN addgroup -g 1001 -S appgroup && \
    adduser -S -u 1001 -G appgroup appuser

# Chuyển ownership
RUN chown -R appuser:appgroup /app

# Chuyển sang non-root user
USER appuser

Fix 6: Exec form CMD + dumb-init

dockerfile
# ✅ Exec form — Node.js nhận signal trực tiếp
# dumb-init xử lý zombie processes và forward signals
CMD ["dumb-init", "node", "src/server.js"]

Tại sao dumb-init:

  • Node.js không được thiết kế làm PID 1
  • PID 1 có trách nhiệm reap zombie processes
  • dumb-init làm PID 1, forward SIGTERM → Node.js, Node.js graceful shutdown

Dockerfile Phase 1 hoàn chỉnh

dockerfile
# Phase 1: Basic Fixes
FROM node:20.11-alpine3.19

# Cài dumb-init cho signal handling
RUN apk add --no-cache dumb-init

WORKDIR /app

# Tận dụng Docker cache — copy lockfile trước
COPY package.json package-lock.json ./
RUN npm ci

# Copy source code sau
COPY . .

# Tạo non-root user
RUN addgroup -g 1001 -S appgroup && \
    adduser -S -u 1001 -G appgroup appuser && \
    chown -R appuser:appgroup /app

USER appuser

# Exec form CMD với dumb-init
EXPOSE 3000
CMD ["dumb-init", "node", "src/server.js"]

📊 Kết quả Phase 1

📦 Image Size:    ~250 MB  (↓ 79% từ 1.2GB)
⏱️  Build Time:    ~45s     (↓ 75% từ 3 phút)
🔓 Vulnerabilities: 23     (↓ 84% từ 147)
♻️  Cache Efficiency: ~60%  (code change = chỉ rebuild source)
👤 User:           appuser (non-root ✅)
📡 Signal Handling: Fixed  (dumb-init ✅)

Đã sửa: 8/10 vấn đề. Còn thiếu: .dockerignore và multi-stage.


Phase 2: Multi-stage + Tối Ưu Hóa

Thêm .dockerignore — Bước đầu tiên

Tạo file .dockerignore ở root project:

gitignore
# Dependencies (sẽ được install lại trong container)
node_modules

# Version control
.git
.gitignore

# Environment & secrets
.env
.env.*
*.pem
*.key

# IDE & OS
.vscode
.idea
*.swp
.DS_Store
Thumbs.db

# Documentation & tests (không cần trong production)
docs/
test/
tests/
__tests__/
coverage/
*.md
!README.md

# Docker files (tránh đệ quy)
Dockerfile*
docker-compose*
.dockerignore

# Build artifacts
dist/
build/
*.log

💡 Pro Tip

Sử dụng docker image inspect để kiểm tra metadata và layers. Bạn sẽ thấy rõ .dockerignore giảm bao nhiêu MB khỏi build context.

bash
# Xem context size trước và sau .dockerignore
# Không có .dockerignore → "Sending build context: 387MB"
# Có .dockerignore    → "Sending build context: 2.1MB"
docker build --no-cache -t test-context .

Multi-stage Build — Tách builder và runner

dockerfile
# ============================================
# STAGE 1: Builder — Cài dependencies
# ============================================
FROM node:20.11-alpine3.19 AS builder

WORKDIR /app

# Copy lockfile trước để tận dụng cache
COPY package.json package-lock.json ./

# --only=production: bỏ devDependencies (jest, eslint, prettier...)
RUN npm ci --only=production && \
    # Tạo bản copy sạch chỉ có production deps
    cp -R node_modules /prod_modules && \
    # Cài tất cả deps cho build step (nếu cần transpile)
    npm ci

# Copy source code
COPY . .

# Build step (TypeScript, Babel, etc.)
RUN npm run build 2>/dev/null || true

# ============================================
# STAGE 2: Runner — Image production gọn nhẹ
# ============================================
FROM node:20.11-alpine3.19 AS runner

# Metadata theo OCI Image Spec
LABEL org.opencontainers.image.title="my-api" \
      org.opencontainers.image.description="Production Node.js API" \
      org.opencontainers.image.version="1.0.0" \
      org.opencontainers.image.vendor="MyCompany" \
      org.opencontainers.image.source="https://github.com/mycompany/my-api"

# Signal handling
RUN apk add --no-cache dumb-init

# Non-root user
RUN addgroup -g 1001 -S appgroup && \
    adduser -S -u 1001 -G appgroup appuser

WORKDIR /app

# Chỉ copy production dependencies từ builder
COPY --from=builder /prod_modules ./node_modules

# Copy built source (hoặc raw source nếu không build)
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

# Chuyển ownership và user
RUN chown -R appuser:appgroup /app
USER appuser

# Health check — Kubernetes/ECS cần biết app có sống không
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["node", "-e", "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"]

EXPOSE 3000

CMD ["dumb-init", "node", "dist/server.js"]

Giải thích Multi-stage

┌─────────────────────────────────────────┐
│ STAGE 1: builder                        │
│                                         │
│  node:20.11-alpine   (~180MB)           │
│  + ALL node_modules  (~200MB)           │
│  + source code       (~5MB)             │
│  + build tools       (~50MB)            │
│  ─────────────────────────────          │
│  Total: ~435MB (BỎ sau khi build)       │
│                                         │
│  Output: /prod_modules + /app/dist      │
└──────────────────┬──────────────────────┘
                   │ COPY --from=builder

┌─────────────────────────────────────────┐
│ STAGE 2: runner                         │
│                                         │
│  node:20.11-alpine   (~180MB base)      │
│  - Chỉ production deps (~30MB)          │
│  - Built code          (~2MB)           │
│  - dumb-init           (~100KB)         │
│  ─────────────────────────────          │
│  Total: ~85MB ← FINAL IMAGE            │
└─────────────────────────────────────────┘

Tại sao HEALTHCHECK quan trọng

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["node", "-e", "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"]
  • --interval=30s: Kiểm tra mỗi 30 giây
  • --timeout=5s: Nếu health check không trả về trong 5s → fail
  • --start-period=10s: Cho app 10s để khởi động trước khi bắt đầu check
  • --retries=3: 3 lần fail liên tiếp → container được đánh dấu unhealthy

Production impact: Orchestrator (Docker Swarm, Kubernetes, ECS) dựa vào health check để quyết định có restart container hay không. Không có health check = orchestrator — container có thể deadlock mà không ai biết.

📊 Kết quả Phase 2

📦 Image Size:      ~85 MB  (↓ 66% từ 250MB, ↓ 93% từ 1.2GB)
⏱️  Build Time:      ~15s   (cached: ~5s)
🔓 Vulnerabilities:  5      (0 critical, 2 high, 3 medium)
♻️  Cache Efficiency: ~90%
👤 User:             appuser (non-root ✅)
📡 Health Check:     Configured ✅
🏷️  OCI Labels:      Added ✅

🏆 Phase 3: Production-Grade — Zero Compromise

Bước 1: Chạy Hadolint — Static analysis cho Dockerfile

bash
# Cài hadolint
docker run --rm -i hadolint/hadolint < Dockerfile

Các warning thường gặp và cách fix:

dockerfile
# DL3018: Pin versions in apk add
# ❌ 
RUN apk add --no-cache dumb-init
# ✅
RUN apk add --no-cache dumb-init=1.2.5-r3

# DL3059: Multiple consecutive RUN instructions → combine
# ❌
RUN addgroup -g 1001 -S appgroup
RUN adduser -S -u 1001 -G appgroup appuser
# ✅
RUN addgroup -g 1001 -S appgroup && \
    adduser -S -u 1001 -G appgroup appuser

# DL4006: Set SHELL option for pipefail
# ✅ Thêm trước RUN có pipe
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]

Bước 2: Pin package versions trong npm

json
{
  "name": "my-api",
  "version": "1.0.0",
  "dependencies": {
    "express": "4.18.2",
    "helmet": "7.1.0",
    "compression": "1.7.4"
  },
  "engines": {
    "node": "20.11.x",
    "npm": "10.x"
  }
}

Tại sao pin exact version:

  • "express": "^4.18.2" cho phép 4.18.3, 4.19.0 → non-deterministic
  • "express": "4.18.2" luôn cài đúng version đó
  • Kết hợp npm ci + pinned versions = 100% reproducible builds

Bước 3: Security scanning với Trivy

bash
# Scan image cho vulnerabilities
trivy image my-api:latest

# Chỉ báo critical & high
trivy image --severity CRITICAL,HIGH my-api:latest

# Fail CI nếu có critical vulnerability
trivy image --exit-code 1 --severity CRITICAL my-api:latest

Tích hợp vào CI/CD:

yaml
# .github/workflows/docker-security.yml
name: Docker Security Scan
on: [push]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t my-api:${{ github.sha }} .
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: my-api:${{ github.sha }}
          format: table
          exit-code: 1
          severity: CRITICAL,HIGH

Bước 4: Dockerfile Production-Grade Hoàn Chỉnh

dockerfile
# ============================================================
# PRODUCTION DOCKERFILE — Node.js API
# Version: 1.0.0
# Hadolint: 0 warnings | Trivy: 0 critical vulnerabilities
# ============================================================

# -- Đặt shell option cho pipefail (Hadolint DL4006) --
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]

# ============================================================
# STAGE 1: Dependencies — Cài đặt và tách production deps
# ============================================================
FROM node:20.11.1-alpine3.19 AS deps

WORKDIR /app

# Copy lockfile trước (cache optimization)
COPY package.json package-lock.json ./

# Production dependencies → /prod_deps
RUN npm ci --only=production --ignore-scripts && \
    cp -R node_modules /prod_deps

# All dependencies (devDeps cần cho build/transpile)
RUN npm ci --ignore-scripts

# ============================================================
# STAGE 2: Builder — Build application
# ============================================================
FROM node:20.11.1-alpine3.19 AS builder

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Transpile TypeScript / Bundle nếu cần
RUN npm run build

# ============================================================
# STAGE 3: Runner — Production image tối thiểu
# ============================================================
FROM node:20.11.1-alpine3.19 AS runner

# -- OCI Image Spec labels --
LABEL org.opencontainers.image.title="my-api" \
      org.opencontainers.image.description="Production-grade Node.js REST API" \
      org.opencontainers.image.version="1.0.0" \
      org.opencontainers.image.authors="team@mycompany.com" \
      org.opencontainers.image.vendor="MyCompany" \
      org.opencontainers.image.licenses="MIT" \
      org.opencontainers.image.source="https://github.com/mycompany/my-api" \
      org.opencontainers.image.created="2024-01-01T00:00:00Z"

# -- Signal handling (pin version — Hadolint DL3018) --
RUN apk add --no-cache dumb-init=1.2.5-r3

# -- Non-root user --
RUN addgroup -g 1001 -S appgroup && \
    adduser -S -u 1001 -G appgroup -h /app appuser

WORKDIR /app

# -- Chỉ copy production artifacts --
COPY --from=deps --chown=appuser:appgroup /prod_deps ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/package.json ./

# -- Drop privileges --
USER appuser

# -- Runtime configuration --
ENV NODE_ENV=production \
    # Tối ưu memory cho container
    NODE_OPTIONS="--max-old-space-size=256 --enable-source-maps"

# -- Health check --
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["node", "-e", " \
    const http = require('http'); \
    const req = http.get('http://localhost:3000/health', (res) => { \
      process.exit(res.statusCode === 200 ? 0 : 1); \
    }); \
    req.on('error', () => process.exit(1)); \
    req.setTimeout(4000, () => { req.destroy(); process.exit(1); }); \
  "]

EXPOSE 3000

# -- Entrypoint: dumb-init xử lý PID 1 responsibilities --
CMD ["dumb-init", "node", "dist/server.js"]

Giải thích các quyết định kiến trúc

Tại sao 3 stage thay vì 2:

deps stage    → Tách việc install dependencies (cache tốt nhất)
builder stage → Build/transpile code (cần devDependencies)
runner stage  → Chỉ chứa production artifacts (nhẹ nhất)

Khi package.json không đổi → deps stage cached hoàn toàn → builder chỉ re-transpile source → runner copy artifacts mới. Build time giảm từ 15s xuống 3s cho code-only changes.

Tại sao --ignore-scripts:

  • npm install có thể chạy postinstall scripts → execute arbitrary code
  • Trong multi-stage, scripts thường không cần ở deps stage
  • Giảm attack surface trong build process

Tại sao --chown trong COPY:

dockerfile
# ❌ Copy as root, rồi chown riêng → tạo thêm 1 layer
COPY --from=deps /prod_deps ./node_modules
RUN chown -R appuser:appgroup ./node_modules

# ✅ Copy với --chown → 1 layer duy nhất
COPY --from=deps --chown=appuser:appgroup /prod_deps ./node_modules

📊 Kết quả Phase 3 — Final

📦 Image Size:      ~45 MB   (↓ 96% từ 1.2GB ban đầu)
⏱️  Build Time:      ~10s    (cached: ~3s)
🔓 Vulnerabilities:  0 critical, 0 high (2 low — acceptable)
♻️  Cache Efficiency: ~95%
👤 User:             appuser (UID 1001, non-root ✅)
📡 Health Check:     Production-grade ✅
🏷️  OCI Labels:      Complete ✅
🛡️  Hadolint:        0 warnings ✅
🔒 Trivy:           Pass ✅

📊 Bảng So Sánh Tổng Hợp — 4 Phase

MetricPhase 0 (Horror)Phase 1 (Basic)Phase 2 (Multi-stage)Phase 3 (Prod)
Image Size1.2 GB250 MB85 MB45 MB
Build Time3 min45s15s10s
Cached Build3 min45s5s3s
Vulnerabilities1472350 critical
Cache Efficiency~0%~60%~90%~95%
Root User❌ Yes✅ No✅ No✅ No
Signal Handling❌ Broken✅ Fixed✅ Fixed✅ Fixed
Health Check❌ None❌ None✅ Basic✅ Production
Secrets❌ Hardcoded✅ Removed✅ Removed✅ Removed
OCI Labels❌ None❌ None✅ Added✅ Complete
HadolintN/AN/AN/A✅ 0 warnings
Trivy Scan❌ 23 critical❌ 5 critical⚠️ 0 critical✅ Pass
Multi-stage❌ No❌ No✅ 2 stages✅ 3 stages
Image Size Reduction Journey:
═══════════════════════════════════════════════════════════
Phase 0: ████████████████████████████████████████████ 1200 MB
Phase 1: ████████████                                 250 MB
Phase 2: ████                                          85 MB
Phase 3: ██                                            45 MB
═══════════════════════════════════════════════════════════

🎯 Bài Tập Nhanh: Spot the Issues

Bài 1: Tìm 5 vấn đề trong Dockerfile sau

dockerfile
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
RUN echo "API_KEY=sk-abc123xyz" >> .env
RUN apt-get update && apt-get install -y python3
EXPOSE 8080
CMD node server.js

⏰ Thời gian: 2 phút

Ghi ra giấy 5 vấn đề trước khi mở đáp án. Nếu tìm được cả 7+, bạn đã nắm vững bài này.

👉 Xem đáp án (Chỉ mở khi đã thử!)

7 vấn đề:

  1. FROM node:latest — Không pin version → non-reproducible builds
  2. COPY . . trước npm install — Cache busting, mọi code change = reinstall deps
  3. npm install thay vì npm ci — Không enforce lockfile
  4. echo "API_KEY=sk-abc123xyz" >> .env — Hardcode secret vào image layer (lộ qua docker history)
  5. apt-get install -y python3 — node image dựa trên Debian, nhưng tại sao cần python3 trong production?
  6. CMD node server.js — Shell form, signal handling broken
  7. Không có USER — Chạy với root
  8. Không có .dockerignore — Copy node_modules, .git, .env vào context
  9. Không multi-stage — Build tools nằm trong final image
  10. Tách apt-get update riêng layer — Ở đây gộp rồi, nhưng vẫn nên dùng --no-install-recommends

Bài 2: Sắp xếp lại đúng thứ tự

Cho các instruction sau, sắp xếp để tối ưu Docker cache:

A: COPY . .
B: RUN npm ci --only=production
C: FROM node:20-alpine
D: COPY package.json package-lock.json ./
E: WORKDIR /app
F: USER appuser
G: CMD ["node", "server.js"]
H: RUN adduser -S appuser
👉 Xem đáp án
C → E → D → B → A → H → F → G
dockerfile
FROM node:20-alpine         # C: Base image
WORKDIR /app                # E: Working directory
COPY package.json package-lock.json ./  # D: Lockfile (ít thay đổi)
RUN npm ci --only=production            # B: Install deps (cached nếu lockfile không đổi)
COPY . .                    # A: Source code (thay đổi thường xuyên → cuối)
RUN adduser -S appuser      # H: Tạo user
USER appuser                # F: Switch user
CMD ["node", "server.js"]   # G: Startup command

Nguyên tắc: Thứ thay đổi ÍT nhất → đặt TRƯỚC (FROM, WORKDIR, lockfile). Thứ thay đổi NHIỀU nhất → đặt SAU (source code, CMD).


🐛 Spot The Bug: Dockerfile Debugging

Tình huống: Container crash ngay khi start

Dockerfile trông "đúng" nhưng container exit với code 1:

dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
bash
$ docker run my-app
node:internal/modules/cjs/loader:1147
  throw err;
  ^
Error: Cannot find module 'express'

🤔 Bạn thấy bug chưa?

Gợi ý: Nhìn vào những gì KHÔNG được copy sang runner stage.

👉 Xem đáp án

Bug: node_modules không được copy sang runner stage!

Builder stage có node_modules (từ npm ci), nhưng runner stage chỉ copy dist/package.json. Khi node dist/server.js chạy và require('express'), Node.js không tìm thấy module.

Fix:

dockerfile
FROM node:20-alpine
WORKDIR /app
# Thêm dòng này — copy production dependencies
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

Hoặc tốt hơn — tách production deps riêng (như Phase 3):

dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM node:20-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

Bài học: Multi-stage build yêu cầu bạn explicit về mọi thứ cần copy. Không có gì "tự động" chuyển giữa các stage.


🎬 Scenario: Production Incident

Tình huống: Image bỗng dưng lớn gấp đôi

Bối cảnh: Team bạn deploy image my-api:v2.1.0 kích thước 85MB bình thường. Tuần sau, my-api:v2.2.0 bỗng nhảy lên 180MB. Không ai thêm dependency mới. CI/CD không thay đổi.

Câu hỏi: Bạn sẽ debug như thế nào?

👉 Bước 1: Kiểm tra layers
bash
# So sánh layer history giữa 2 version
docker image history my-api:v2.1.0 --format "table {{.Size}}\t{{.CreatedBy}}"
docker image history my-api:v2.2.0 --format "table {{.Size}}\t{{.CreatedBy}}"

# Hoặc dùng dive — tool trực quan hóa layers
dive my-api:v2.2.0
👉 Bước 2: Tìm layer phình to
bash
# Output sẽ cho thấy 1 layer bất thường
# Ví dụ:
# SIZE     CREATED BY
# 5.2MB    COPY --from=builder /app/dist ./dist
# 95MB     COPY --from=deps /prod_deps ./node_modules   ← GẤP ĐÔI!
# 180MB    Total

# Kiểm tra node_modules trong image
docker run --rm my-api:v2.2.0 du -sh node_modules/
# Output: 95MB
👉 Bước 3: Root cause & Fix

Root cause: Ai đó xóa --only=production trong Dockerfile, khiến devDependencies (jest, eslint, typescript, webpack) được cài vào production image.

dockerfile
# ❌ Dòng gây lỗi
RUN npm ci --ignore-scripts

# ✅ Fix
RUN npm ci --only=production --ignore-scripts

Prevention:

  1. Thêm Hadolint check vào CI
  2. Thêm image size check: fail nếu > threshold
yaml
# Trong CI pipeline
- name: Check image size
  run: |
    SIZE=$(docker image inspect my-api:latest --format='{{.Size}}')
    MAX=100000000  # 100MB
    if [ "$SIZE" -gt "$MAX" ]; then
      echo "Image size ${SIZE} exceeds limit ${MAX}"
      exit 1
    fi

📝 Quiz: Kiểm Tra Kiến Thức

🧠 Quiz

Câu 1: Tại sao nên dùng COPY package.json package-lock.json ./ TRƯỚC COPY . .?

  • [ ] A. Vì Docker yêu cầu copy file nhỏ trước file lớn
  • [x] B. Để tận dụng Docker layer cache — khi source code thay đổi, layer npm ci vẫn được cache
  • [ ] C. Vì package.json phải nằm trước source code trong filesystem
  • [ ] D. Để npm ci có thể tìm thấy dependencies

💡 Giải thích: Docker invalidate cache từ layer thay đổi trở xuống. Nếu COPY . . đứng trước và bạn sửa 1 dòng code, layer npm ci bị invalidate. Đặt COPY package*.json trước → chỉ invalidate khi dependencies thay đổi → tiết kiệm phút build time mỗi lần.

🧠 Quiz

Câu 2: CMD npm startCMD ["node", "server.js"] khác nhau thế nào?

  • [ ] A. Không khác gì — chỉ là cú pháp
  • [ ] B. Shell form nhanh hơn exec form
  • [x] C. Shell form chạy qua /bin/sh -c → Node.js không nhận SIGTERM trực tiếp → graceful shutdown thất bại
  • [ ] D. Exec form không hỗ trợ environment variable expansion

💡 Giải thích: Shell form (CMD npm start) tạo process tree: sh → npm → node. Khi Docker gửi SIGTERM, sh nhận nhưng không forward cho node. Exec form (CMD ["node", "server.js"]) cho node làm PID 1, nhận SIGTERM trực tiếp. Đáp án D sai — exec form không hỗ trợ variable expansion, nhưng đó không phải điểm khác biệt chính.

🧠 Quiz

Câu 3: Lệnh nào sau đây là cách ĐÚNG để kiểm tra secrets có bị leak trong image không?

  • [ ] A. docker inspect my-app:latest | grep -i password
  • [ ] B. docker run my-app env | grep SECRET
  • [x] C. docker image history my-app:latest --no-trunc — kiểm tra toàn bộ layer commands
  • [ ] D. Cả A, B, C đều cần thiết cho một bài kiểm tra đầy đủ

💡 Giải thích: docker image history --no-trunc hiển thị toàn bộ Dockerfile instructions đã thực thi, bao gồm ENV values. Đây là bước kiểm tra quan trọng nhất. Tuy nhiên, trong thực tế, D cũng đúng cho comprehensive audit — nhưng C là bước quan trọng nhất mà nhiều người bỏ qua.

🧠 Quiz

Câu 4: Image scan bằng Trivy báo vulnerability ở node:20-alpine base image. Bạn nên làm gì?

  • [ ] A. Bỏ qua — đó là lỗi của base image, không phải lỗi mình
  • [ ] B. Rebuild với --no-cache để fix
  • [x] C. Kiểm tra severity → nếu critical: update base image version hoặc patch → nếu low: đánh giá risk và document
  • [ ] D. Chuyển sang Ubuntu vì Ubuntu ít vulnerability hơn

💡 Giải thích: Không phải mọi vulnerability đều cần fix ngay. Critical/High → phải fix (update base image, apply patch). Medium/Low → đánh giá xem vulnerability có liên quan đến cách bạn dùng package không (exploitability). Đáp án D sai — Ubuntu thường có NHIỀU vulnerability hơn Alpine vì có nhiều package hơn.


Production Readiness Checklist

✅ Checklist triển khai

📦 Image Build

  • [ ] Base image pinned đến exact version (major.minor.patch)
  • [ ] Multi-stage build tách builder và runner
  • [ ] .dockerignore loại bỏ node_modules, .git, .env, tests, docs
  • [ ] npm ci --only=production (không npm install, không devDependencies)
  • [ ] Layer order tối ưu cho cache (lockfile → install → source code)

🔒 Security

  • [ ] Non-root USER (UID ≥ 1000)
  • [ ] Không hardcode secrets (ENV, ARG, hoặc file trong image)
  • [ ] Trivy scan pass (0 critical, 0 high)
  • [ ] Hadolint 0 warnings
  • [ ] --ignore-scripts trong npm ci (chống supply chain attack)

⚙️ Runtime

  • [ ] Exec form CMD (CMD ["node", "..."], không shell form)
  • [ ] Signal handling (dumb-init hoặc tini)
  • [ ] HEALTHCHECK configured với timeout hợp lý
  • [ ] NODE_ENV=production set
  • [ ] NODE_OPTIONS configured (memory limit, source maps)

📋 Metadata & Observability

  • [ ] OCI labels (title, version, source, authors)
  • [ ] EXPOSE khai báo đúng port
  • [ ] Logging ra stdout/stderr (không ghi file trong container)

🔄 CI/CD Integration

  • [ ] Image size check trong pipeline (fail nếu > threshold)
  • [ ] Security scan tự động (Trivy/Snyk)
  • [ ] Hadolint check tự động
  • [ ] Build time monitoring

⚠️ Production Pitfalls (Bẫy thực chiến)

⚠️ Cạm bẫy

Pitfall #1: COPY --chown với UID không tồn tại

Vấn đề: COPY --chown=appuser:appgroup sẽ FAIL nếu user/group chưa được tạo ở layer trước đó.

Wrong:

dockerfile
FROM node:20-alpine
WORKDIR /app
COPY --chown=appuser:appgroup package.json ./   # FAIL: appuser chưa tồn tại!
RUN adduser -S appuser

Correct:

dockerfile
FROM node:20-alpine
RUN addgroup -g 1001 -S appgroup && \
    adduser -S -u 1001 -G appgroup appuser
WORKDIR /app
COPY --chown=appuser:appgroup package.json ./   # OK: user đã tồn tại

Tại sao sai: Dockerfile instructions thực thi theo thứ tự từ trên xuống. --chown resolve tên user tại thời điểm COPY chạy. Nếu user chưa tồn tại → build error.

⚠️ Cạm bẫy

Pitfall #2: .dockerignore ignore cả file cần thiết

Vấn đề: Copy-paste .gitignore thành .dockerignore mà quên rằng Docker cần một số file mà Git không cần.

Wrong .dockerignore:

gitignore
# Từ .gitignore — NGUY HIỂM khi dùng làm .dockerignore
*.json          # Bỏ luôn package.json!
dist/           # Bỏ luôn build output!

Correct .dockerignore:

gitignore
node_modules
.git
.env
*.md
!README.md

Tại sao sai: .dockerignore quyết định file nào được gửi vào build context. Nếu package.json bị ignore, COPY package.json ./ sẽ fail. Luôn kiểm tra: docker build . có báo "file not found" không.

⚠️ Cạm bẫy

Pitfall #3: Dùng Alpine nhưng native module cần glibc

Vấn đề: Alpine dùng musl libc thay vì glibc. Một số native C/C++ addon sẽ segfault hoặc không compile được.

Hậu quả thực tế:

bash
# Build thành công trên macOS/Ubuntu, nhưng khi chạy trên Alpine:
Error: Error loading shared library libstdc++.so.6
# Hoặc:
Error: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.28' not found

Các package hay gặp lỗi: bcrypt, sharp, canvas, grpc, sqlite3

Giải pháp:

  1. Dùng node:20-slim (Debian-based, ~70MB) nếu cần glibc
  2. Cài build tools trên Alpine: apk add --no-cache python3 make g++ (chỉ trong builder stage)
  3. Hoặc dùng pre-built binaries: npm install --platform=linuxmusl

🔥 TUYỆT ĐỐI KHÔNG

NEVER commit .env files hoặc hardcode secrets vào Dockerfile.

Mọi thứ bạn viết trong Dockerfile sẽ vĩnh viễn nằm trong image layers — kể cả khi bạn RUN rm .env ở layer sau. Layer trước vẫn tồn tại và có thể extract.

bash
# Attacker có thể extract bất kỳ layer nào
docker save my-app:latest -o image.tar
tar xf image.tar
# Duyệt từng layer → tìm thấy .env ở layer cũ

# Hoặc đơn giản hơn:
docker image history my-app:latest --no-trunc | grep -i "secret\|password\|key"

Cách đúng để truyền secrets:

  1. Runtime environment variables: docker run -e DB_PASSWORD=$DB_PASSWORD
  2. Docker Secrets: docker secret create (Swarm mode)
  3. Mount file: docker run -v /path/to/secrets:/run/secrets:ro
  4. Vault integration: HashiCorp Vault, AWS Secrets Manager

💡 Pro Tips Tổng Hợp

1. Dùng docker image inspect để debug:

bash
# Xem metadata, layers, config
docker image inspect my-api:latest --format='{{json .Config}}' | jq .

# Xem ENV variables trong image
docker image inspect my-api:latest --format='{{json .Config.Env}}' | jq .

# Xem image size chính xác
docker image inspect my-api:latest --format='{{.Size}}' | numfmt --to=iec

2. So sánh 2 image nhanh:

bash
# Dùng dive để so sánh layer-by-layer
dive my-api:v1 my-api:v2

# Hoặc so sánh size đơn giản
docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" | grep my-api

3. Build với progress chi tiết:

bash
# Xem từng step mất bao lâu
DOCKER_BUILDKIT=1 docker build --progress=plain -t my-api .

4. Test Dockerfile locally trước CI:

bash
# Hadolint
docker run --rm -i hadolint/hadolint < Dockerfile

# Trivy
trivy image my-api:latest --severity CRITICAL,HIGH

# Smoke test
docker run --rm -p 3000:3000 my-api:latest &
sleep 3
curl -f http://localhost:3000/health || echo "HEALTH CHECK FAILED"

🔗 Liên kết mở rộng


🎯 Tóm tắt hành trình:

1.2GB250MB85MB45MB

3 phút45s15s10s

147 vulns2350 critical

Từ Horror Dockerfile đến Production-Grade — chỉ cần hiểu nguyên lý và áp dụng đúng thứ tự.