Giao diện
Dockerfile Anatomy — FROM, RUN, CMD, ENTRYPOINT, COPY, ADD Intermediate
Bạn vừa nhận task deploy Checkout API cho sàn e-commerce. Backend team đưa source code Python FastAPI và nói: "Đóng gói thành Docker image nhé." Bạn mở editor, tạo
Dockerfile— nhưng viết gì trước, viết gì sau?COPYhayADD?CMDhayENTRYPOINT? Thứ tự có quan trọng không?
Dockerfile là công thức nấu ăn của image. Mỗi instruction là một bước — thứ tự các bước quyết định image nặng hay nhẹ, build nhanh hay chậm. Bài này giải phẫu từng instruction, so sánh head-to-head, và cho bạn viết Dockerfile production-grade đầu tiên.
🎯 Mục tiêu
- Hiểu Dockerfile là blueprint tạo image — mỗi dòng tạo layer hoặc metadata
- Nắm vững instruction cốt lõi:
FROM,RUN,COPY,ADD,WORKDIR,ENV,ARG,EXPOSE,CMD,ENTRYPOINT - Phân biệt rõ ràng CMD vs ENTRYPOINT — khi nào dùng cái nào, khi nào kết hợp
- Phân biệt COPY vs ADD — tại sao COPY luôn được ưu tiên
- Viết được Dockerfile hoàn chỉnh cho ứng dụng Node.js và Python FastAPI
- Nhận diện anti-pattern phổ biến trong Dockerfile
1. Concept — Dockerfile Như Công Thức Nấu Ăn
🧠 Mental Model
Nghĩ Dockerfile như công thức nấu bánh:
FROM→ Chọn loại bột nền (base image)COPY→ Cho nguyên liệu vào (source code)RUN→ Trộn, nướng (install, compile)CMD→ Cách phục vụ món (lệnh mặc định khi chạy)
Docker đọc Dockerfile từ trên xuống, mỗi instruction tạo layer mới hoặc thêm metadata. Thứ tự instruction quyết định tốc độ build.
🏗️ Từ Dockerfile → Image
┌─────────────────────────────────────────────────────────────┐
│ docker build -t myapp . │
│ │
│ ┌────────────┐ ┌─────────────┐ ┌───────────────┐ │
│ │ Dockerfile │ ──→ │Build Engine │ ──→ │ Docker Image │ │
│ │ (text) │ │ (từng bước) │ │ (layered) │ │
│ └────────────┘ └─────────────┘ └───────────────┘ │
│ │
│ FROM node:20-alpine ← Base layer │
│ WORKDIR /app ← Metadata │
│ COPY package*.json ./ ← Layer mới │
│ RUN npm ci ← Layer mới (nặng nhất) │
│ COPY . . ← Layer mới │
│ CMD ["node", "server.js"] ← Metadata │
└─────────────────────────────────────────────────────────────┘🎂 Image = Chiếc bánh nhiều tầng
┌───────────────────────────────────┐
│ Layer 5: COPY . . (source code) │ ← Thay đổi thường xuyên
├───────────────────────────────────┤
│ Layer 4: RUN npm ci (deps) │ ← Cache nếu package.json
│ │ không đổi
├───────────────────────────────────┤
│ Layer 3: COPY package*.json │
├───────────────────────────────────┤
│ Layer 2: WORKDIR /app │
├───────────────────────────────────┤
│ Layer 1: FROM node:20-alpine │ ← Ít thay đổi nhất
└───────────────────────────────────┘Nguyên tắc: Instruction ít thay đổi ở trên, thay đổi nhiều ở dưới. Khi layer thay đổi, tất cả layer phía sau bị invalidate cache → build lại.
2. Syntax — Giải Phẫu Từng Instruction
2.1 FROM — Chọn nền tảng
FROM luôn là instruction đầu tiên. Nó xác định base image — hệ điều hành và runtime.
dockerfile
FROM node:20-alpine # Node.js 20 trên Alpine (~50MB)
FROM python:3.12-slim # Python 3.12 slim (~150MB)
FROM golang:1.22 AS builder # Đặt tên stage cho multi-stage build
FROM scratch # Image rỗng — cho binary tĩnh (Go, Rust)| Base Image | Kích thước | Khi nào dùng |
|---|---|---|
alpine | ~5 MB | Ưu tiên nhẹ, chấp nhận musl libc |
slim | ~80–150 MB | Cần glibc, bỏ docs/man pages |
bookworm | ~300–800 MB | Cần đầy đủ system libraries |
scratch | 0 MB | Binary tĩnh, không có shell |
💡 Mẹo chọn base image
Bắt đầu với -alpine hoặc -slim. Chỉ chuyển sang bản full khi thiếu library. Pin version cụ thể (node:20.11-alpine, không phải node:latest) để build reproducible.
2.2 RUN — Thực thi lệnh lúc build
RUN chạy command trong quá trình build — không phải khi container chạy. Mỗi RUN tạo layer mới.
dockerfile
# ❌ Mỗi RUN = 1 layer, apt cache nằm trong image
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*
# ✅ Gộp 1 layer — dọn cache trong cùng layer → image nhẹ hơn
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl git \
&& rm -rf /var/lib/apt/lists/*Tại sao gộp? Layer là immutable. apt-get install ở layer 1 + rm -rf ở layer 2 = cache vẫn tồn tại trong layer 1. Gộp cùng RUN mới thực sự giảm kích thước.
2.3 COPY vs ADD
| Tính năng | COPY | ADD |
|---|---|---|
| Copy file/directory | ✅ | ✅ |
Auto-extract .tar.gz | ❌ | ✅ |
| Fetch URL | ❌ | ✅ (nhưng không nên) |
| Predictable behavior | ✅ | ❌ |
| Recommended | ✅ Luôn dùng | ⚠️ Chỉ khi cần extract tar |
dockerfile
# ✅ COPY — đơn giản, predictable
COPY package.json /app/
COPY --chown=node:node . /app
# ⚠️ ADD — chỉ khi cần auto-extract
ADD app.tar.gz /app/🔴 Anti-pattern: Dùng ADD để tải URL
dockerfile
# ❌ Không cache, không verify checksum
ADD https://example.com/binary /usr/bin/
# ✅ Dùng curl trong RUN — kiểm soát được
RUN curl -fsSL https://example.com/binary -o /usr/bin/binary \
&& chmod +x /usr/bin/binary2.4 WORKDIR — Thư mục làm việc
dockerfile
WORKDIR /app
# Từ đây, mọi lệnh chạy trong /app
COPY . . # → COPY vào /app/
RUN npm install # → chạy trong /app
CMD ["node", "."] # → node . trong /appdockerfile
# ❌ cd trong RUN — không ảnh hưởng layer sau
RUN cd /app && npm install
RUN node server.js # ← Đang ở / chứ không phải /app!
# ✅ Dùng WORKDIR
WORKDIR /app
RUN npm install2.5 ENV vs ARG
dockerfile
# ARG — chỉ tồn tại TRONG QUÁ TRÌNH BUILD
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# ENV — tồn tại TRONG CONTAINER lúc runtime
ENV NODE_ENV=production
ENV PORT=3000| Thuộc tính | ARG | ENV |
|---|---|---|
| Lúc build | ✅ | ✅ |
| Lúc runtime | ❌ | ✅ |
| Override CLI | --build-arg | -e khi docker run |
| Dùng cho | Version, build flag | Config runtime (PORT, DB_URL) |
⚠️ KHÔNG đặt secret trong ENV hoặc ARG
Ai pull image đều xem được ENV bằng docker inspect. Truyền secret qua -e flag khi docker run hoặc dùng Docker secrets.
2.6 EXPOSE — Documentation, không mở port thật
dockerfile
EXPOSE 3000bash
# EXPOSE KHÔNG publish port ra host. Bạn vẫn cần:
docker run -p 3000:3000 myapp # Map host:container
docker run -P myapp # Auto-map tất cả EXPOSE ports⚠️ Cạm bẫy
EXPOSE 3000 không mở port ra ngoài. Nó chỉ là annotation cho người đọc. Bạn bắt buộc dùng -p khi docker run để publish port. Trong Docker Compose, service cùng network đã giao tiếp được qua port nội bộ mà không cần publish.
2.7 CMD vs ENTRYPOINT — Cuộc so sánh lớn nhất
| Khía cạnh | CMD | ENTRYPOINT |
|---|---|---|
| Mục đích | Lệnh/tham số mặc định | Executable cố định |
| Override | docker run image <lệnh-mới> | Cần --entrypoint flag |
| Shell form | CMD command param1 | ENTRYPOINT command param1 |
| Exec form | CMD ["executable", "param1"] | ENTRYPOINT ["executable", "param1"] |
| Kết hợp | CMD cung cấp default args cho ENTRYPOINT | ENTRYPOINT định nghĩa process chính |
| Phù hợp | Development, cần linh hoạt | Production, hành vi cố định |
Chỉ CMD (linh hoạt)
dockerfile
CMD ["node", "server.js"]bash
docker run myapp # → node server.js
docker run myapp sh # → override thành sh
docker run myapp node test.js # → chạy test thay serverChỉ ENTRYPOINT (cố định)
dockerfile
ENTRYPOINT ["python", "app.py"]bash
docker run myapp # → python app.py
docker run myapp --debug # → python app.py --debug (append args)
docker run --entrypoint sh myapp # → override, cần flag đặc biệtENTRYPOINT + CMD (production pattern)
ENTRYPOINT cố định executable, CMD cung cấp default args có thể override:
dockerfile
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]bash
docker run myapp # → python app.py --port 8080
docker run myapp --port 9090 --debug # → python app.py --port 9090 --debug
# CMD bị thay thế hoàn toàn bởi argsQuy tắc chọn:
- Chỉ CMD → Container cần linh hoạt (dev image, utility)
- Chỉ ENTRYPOINT → Container LÀ tool cụ thể, không cần default args
- ENTRYPOINT + CMD → Production: executable cố định + args override được
3. Lab — Dockerfile Cho Node.js Express App
📋 Đóng gói Express app
my-express-app/
├── package.json
├── package-lock.json
├── server.js
├── src/
└── .dockerignore.dockerignore:
node_modules
.git
.env
tests
*.md
DockerfileDockerfile:
dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]Build & chạy:
bash
docker build -t my-express-app:1.0 .
docker run -d --name express-api -p 3000:3000 my-express-app:1.0
curl http://localhost:3000/health
docker logs express-api
docker stop express-api && docker rm express-apiTại sao COPY package*.json trước COPY . .? Sửa source code mà dependencies không đổi → Docker cache layer RUN npm ci → build cực nhanh thay vì install lại toàn bộ.
4. Business Scenario — E-commerce Checkout API (FastAPI)
dockerfile
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Dependencies layer (cache)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Source code
COPY . .
# Non-root user (security)
RUN addgroup --system appgroup \
&& adduser --system --ingroup appgroup appuser
USER appuser
EXPOSE 8000
# Fixed executable + overridable args
ENTRYPOINT ["uvicorn", "app:app", "--host", "0.0.0.0"]
CMD ["--port", "8000", "--workers", "4"]bash
# Production (4 workers)
docker run -d -p 8000:8000 -e DATABASE_URL=postgresql://... checkout-api:1.0
# Dev (1 worker, port 9090)
docker run -d -p 9090:9090 checkout-api:1.0 --port 9090 --workers 1 --reloadĐiểm nổi bật: ENTRYPOINT + CMD combo, non-root user, --no-cache-dir cho pip, dependency layer tách biệt source layer.
5. Spot The Bug — "Dockerfile Này Có Vấn Đề Gì?"
🐛 Spot-the-Bug
Tìm tất cả vấn đề trong Dockerfile này (ít nhất 6 lỗi):
dockerfile
FROM node:latest
ADD . /app
WORKDIR /app
RUN npm install
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
ENV API_SECRET=sk-production-key-12345
EXPOSE 3000
CMD npm start💡 Gợi ý
Nghĩ về: version tag, COPY vs ADD, thứ tự layer, gộp RUN, security, signal handling, non-root user.
✅ Đáp án — 7 vấn đề
FROM node:latest→ Không pin version. DùngFROM node:20-alpine.ADD . /app→ Dùng ADD không cần thiết. DùngCOPY . /app.- Source code COPY trước npm install → Sửa 1 dòng code → npm install lại toàn bộ. Phải
COPY package*.json→RUN npm install→COPY . . - 4
RUNriêng lẻ → 4 layer,rm -rfở layer riêng không giảm size. Gộp 1RUN. ENV API_SECRET=sk-...→ 🔴 Secret nằm trong image, ai pull đều thấy.CMD npm start→ Shell form! PID 1 =/bin/sh, node không nhận SIGTERM. DùngCMD ["node", "server.js"].- Chạy bằng root → Không có
USER. Thêmadduser+USER.
Dockerfile đã fix:
dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
RUN addgroup -S app && adduser -S appuser -G app
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]6. Parsons Problem — Sắp xếp đúng thứ tự
🧩 Sắp xếp các dòng thành Dockerfile tối ưu cho layer caching
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
COPY requirements.txt .
FROM python:3.12-slim
RUN pip install --no-cache-dir -r requirements.txt
WORKDIR /app
COPY . .
EXPOSE 8000
USER appuser
RUN adduser --system appuser✅ Đáp án
dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN adduser --system appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]Logic: FROM → WORKDIR → deps (COPY + RUN install) → source (COPY . .) → security (USER) → metadata (EXPOSE, CMD). Ít thay đổi đặt trước, thay đổi nhiều đặt sau.
7. Gotcha & Best Practices
⚠️ Cạm bẫy
dockerfile
# Shell form — Docker wrap trong /bin/sh -c "..."
CMD node server.js
# → PID 1 = /bin/sh, node là child process
# → docker stop gửi SIGTERM đến sh, sh KHÔNG forward cho node
# → 10s timeout → SIGKILL → force kill, mất request đang xử lý
# Exec form — chạy trực tiếp
CMD ["node", "server.js"]
# → PID 1 = node, nhận SIGTERM trực tiếp → graceful shutdownLuôn dùng exec form cho CMD và ENTRYPOINT trong production.
💡 Performance: Thứ tự instruction = tốc độ build
Sắp xếp theo tần suất thay đổi tăng dần:
Ít thay đổi ──────────────────────────── Thay đổi nhiều
FROM → WORKDIR → COPY deps → RUN install → COPY source → CMDSửa source code → Docker chỉ rebuild từ COPY . . trở đi. Thay vì 2 phút, còn 5 giây.
🔴 Anti-pattern nguy hiểm
1. Chạy container bằng root — nếu bị exploit, attacker có full root access:
dockerfile
# ✅ Luôn tạo non-root user
RUN addgroup -S app && adduser -S appuser -G app
USER appuser2. Để secret trong image — ai pull image đều thấy:
dockerfile
# ❌ ENV DB_PASS=super_secret
# ✅ docker run -e DB_PASS=xxx myapp3. Dùng ADD tải URL — không cache, không verify:
dockerfile
# ✅ Dùng curl trong RUN + verify checksum8. Under The Hood — Bên Trong Docker Build
🔬 Layer Storage
Mỗi instruction tạo snapshot filesystem — gọi là layer, hash bằng SHA256 dựa trên nội dung:
┌───────────────────────────────────────────────┐
│ Instruction Layer ID Size │
│ ───────────── ────────── ──── │
│ FROM alpine sha256:a1b2.. 5.5 MB │
│ RUN apk add nodejs sha256:c3d4.. 45 MB │
│ COPY package.json sha256:e5f6.. 2 KB │
│ RUN npm ci sha256:g7h8.. 120 MB │
│ COPY . . sha256:i9j0.. 500 KB │
│ │
│ CMD, ENV, EXPOSE → metadata only, 0 bytes │
└───────────────────────────────────────────────┘🧠 Cache Mechanism
Build lần 1 (cold): Build lần 2 (sửa server.js):
───────────────────── ──────────────────────────────
FROM node:20-alpine [BUILD] FROM node:20-alpine [CACHED] ✅
WORKDIR /app [BUILD] WORKDIR /app [CACHED] ✅
COPY package.json [BUILD] COPY package.json [CACHED] ✅
RUN npm ci [BUILD] 45s RUN npm ci [CACHED] ✅
COPY . . [BUILD] COPY . . [BUILD] ← miss!
CMD [...] [BUILD] CMD [...] [BUILD]
───────────────────── ──────────────────────────────
Tổng: ~60 giây Tổng: ~3 giây 🚀Cache hoạt động top-down: layer N cache hit → kiểm tra N+1. Cache miss lan truyền — 1 layer thay đổi → tất cả layer sau build lại.
bash
# Xem layers thực tế
docker history my-express-app:1.0
# Xem metadata
docker inspect my-express-app:1.0 | jq '.[0].Config'9. Quiz — Kiểm tra hiểu biết
🧠 Quiz
Câu 1: Chạy docker run myapp, lệnh nào thực thi?
dockerfile
FROM alpine
CMD ["echo", "hello"]
CMD ["echo", "world"]A) echo hello rồi echo world
B) echo hello
C) echo world
D) Lỗi build
Đáp án
C — Chỉ CMD cuối cùng có hiệu lực. CMD trước bị ghi đè âm thầm.
🧠 Quiz
Câu 2: docker run myapp --verbose thực thi lệnh gì?
dockerfile
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]A) python app.py --port 8080 --verbose
B) python app.py --verbose
C) --verbose
D) Lỗi
Đáp án
B — Args từ docker run thay thế hoàn toàn CMD, không append. ENTRYPOINT giữ nguyên. Muốn cả hai: docker run myapp --port 8080 --verbose.
🧠 Quiz
Câu 3: Sự khác biệt chính giữa COPY và ADD?
A) COPY nhanh hơn
B) ADD hỗ trợ auto-extract tar và fetch URL, COPY chỉ copy thuần
C) ADD tạo layer nhỏ hơn
D) Giống nhau hoàn toàn
Đáp án
B — ADD auto-extract .tar.gz và fetch URL. COPY chỉ copy thuần. Luôn dùng COPY trừ khi cần extract tar.
🧠 Quiz
Câu 4: Tại sao CMD node server.js nguy hiểm trong production?
A) Syntax không hợp lệ
B) Node.js không hỗ trợ shell form
C) PID 1 là /bin/sh, node không nhận SIGTERM → không graceful shutdown
D) Shell form chậm hơn 10x
Đáp án
C — Shell form wrap trong /bin/sh -c "...". PID 1 = /bin/sh, không forward SIGTERM cho node. Sau 10s timeout → SIGKILL → kill đột ngột.
Checklist ghi nhớ
✅ Checklist triển khai
- [ ] FROM luôn đầu tiên — pin version, ưu tiên
-alpine/-slim - [ ] RUN gộp bằng
&&— dọn cache trong cùng layer - [ ] COPY ưu tiên hơn ADD — ADD chỉ khi cần extract tar
- [ ] WORKDIR thay vì
RUN cd— persistent across layers - [ ] ENV cho runtime, ARG cho build-time — không để secret trong cả hai
- [ ] EXPOSE chỉ documentation — cần
-pđể publish port - [ ] CMD exec form
["cmd", "arg"]— không shell form trong production - [ ] ENTRYPOINT + CMD combo cho production — fixed exec + default args
- [ ] Thứ tự: ít thay đổi → nhiều thay đổi (tối ưu cache)
- [ ] Non-root user — KHÔNG chạy container bằng root