Giao diện
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 startTrô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:latestVấn đề kép:
ubuntulà 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ì.:latestlà 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 update và apt-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 vimVấn đề:
vimtrong production container? Bạn không nên SSH vào container để edit file.gitmang theo toàn bộ.githistory. Attack surface tăng — mỗi binary thêm vào là một vector tấn công tiềm năng.wget+curltrù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 installVấn đề: Docker invalidate cache từ layer thay đổi trở xuống. Bạn sửa 1 dòng trong src/index.js → COPY . /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 installVấn đề:
npm installcó thể thay đổipackage-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 cienforce 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 timeoutProduction 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: BrokenBạ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
- Security (secrets, root user) → fix trước vì ảnh hưởng nghiêm trọng nhất
- Correctness (reproducibility, signal handling) → fix tiếp để đảm bảo deterministic
- 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.19Tại sao node:20.11-alpine3.19:
node:20.11— pin cả major + minor, tránh breaking changesalpine3.19— Alpine Linux chỉ ~5MB, so với Ubuntu ~77MB- Node.js official image đã bao gồm
node+npm→ không cầnapt-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.json và package-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 / VaultFix 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 appuserFix 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-initlà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ấuunhealthy
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 mù — 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 < DockerfileCá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:latestTí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,HIGHBướ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 installcó thể chạypostinstallscripts → 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
| Metric | Phase 0 (Horror) | Phase 1 (Basic) | Phase 2 (Multi-stage) | Phase 3 (Prod) |
|---|---|---|---|---|
| Image Size | 1.2 GB | 250 MB | 85 MB | 45 MB |
| Build Time | 3 min | 45s | 15s | 10s |
| Cached Build | 3 min | 45s | 5s | 3s |
| Vulnerabilities | 147 | 23 | 5 | 0 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 |
| Hadolint | N/A | N/A | N/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 đề:
- ❌
FROM node:latest— Không pin version → non-reproducible builds - ❌
COPY . .trướcnpm install— Cache busting, mọi code change = reinstall deps - ❌
npm installthay vìnpm ci— Không enforce lockfile - ❌
echo "API_KEY=sk-abc123xyz" >> .env— Hardcode secret vào image layer (lộ quadocker history) - ❌
apt-get install -y python3— node image dựa trên Debian, nhưng tại sao cần python3 trong production? - ❌
CMD node server.js— Shell form, signal handling broken - ❌ Không có
USER— Chạy với root - ❌ Không có
.dockerignore— Copynode_modules,.git,.envvào context - ❌ Không multi-stage — Build tools nằm trong final image
- ❌ Tách
apt-get updateriê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 → Gdockerfile
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 commandNguyê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/ và 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 ./distBà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-scriptsPrevention:
- Thêm Hadolint check vào CI
- 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, layernpm cibị invalidate. ĐặtCOPY package*.jsontrướ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 start và CMD ["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,shnhận nhưng không forward chonode. Exec form (CMD ["node", "server.js"]) chonodelà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-trunchiể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
- [ ]
.dockerignoreloại bỏnode_modules,.git,.env, tests, docs - [ ]
npm ci --only=production(khôngnpm 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-scriptstrong 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=productionset - [ ]
NODE_OPTIONSconfigured (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ạiTạ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.mdTạ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 foundCác package hay gặp lỗi: bcrypt, sharp, canvas, grpc, sqlite3
Giải pháp:
- Dùng
node:20-slim(Debian-based, ~70MB) nếu cần glibc - Cài build tools trên Alpine:
apk add --no-cache python3 make g++(chỉ trong builder stage) - 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:
- Runtime environment variables:
docker run -e DB_PASSWORD=$DB_PASSWORD - Docker Secrets:
docker secret create(Swarm mode) - Mount file:
docker run -v /path/to/secrets:/run/secrets:ro - 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=iec2. 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-api3. 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.2GB → 250MB → 85MB → 45MB
3 phút → 45s → 15s → 10s
147 vulns → 23 → 5 → 0 critical
Từ Horror Dockerfile đến Production-Grade — chỉ cần hiểu nguyên lý và áp dụng đúng thứ tự.