Skip to content

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? COPY hay ADD? CMD hay ENTRYPOINT? 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 ImageKích thướcKhi nào dùng
alpine~5 MBƯu tiên nhẹ, chấp nhận musl libc
slim~80–150 MBCần glibc, bỏ docs/man pages
bookworm~300–800 MBCần đầy đủ system libraries
scratch0 MBBinary 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ăngCOPYADD
Copy file/directory
Auto-extract .tar.gz
Fetch URL✅ (nhưng không nên)
Predictable behavior
RecommendedLuô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/binary

2.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 /app
dockerfile
# ❌ 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 install

2.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ínhARGENV
Lúc build
Lúc runtime
Override CLI--build-arg-e khi docker run
Dùng choVersion, build flagConfig 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 3000
bash
# 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ạnhCMDENTRYPOINT
Mục đíchLệnh/tham số mặc địnhExecutable cố định
Overridedocker run image <lệnh-mới>Cần --entrypoint flag
Shell formCMD command param1ENTRYPOINT command param1
Exec formCMD ["executable", "param1"]ENTRYPOINT ["executable", "param1"]
Kết hợpCMD cung cấp default args cho ENTRYPOINTENTRYPOINT định nghĩa process chính
Phù hợpDevelopment, cần linh hoạtProduction, 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 server

Chỉ 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ệt

ENTRYPOINT + 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 args

Quy 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
Dockerfile

Dockerfile:

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-api

Tạ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 đề
  1. FROM node:latest → Không pin version. Dùng FROM node:20-alpine.
  2. ADD . /app → Dùng ADD không cần thiết. Dùng COPY . /app.
  3. Source code COPY trước npm install → Sửa 1 dòng code → npm install lại toàn bộ. Phải COPY package*.jsonRUN npm installCOPY . .
  4. 4 RUN riêng lẻ → 4 layer, rm -rf ở layer riêng không giảm size. Gộp 1 RUN.
  5. ENV API_SECRET=sk-... → 🔴 Secret nằm trong image, ai pull đều thấy.
  6. CMD npm start → Shell form! PID 1 = /bin/sh, node không nhận SIGTERM. Dùng CMD ["node", "server.js"].
  7. Chạy bằng root → Không có USER. Thêm adduser + 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: FROMWORKDIR → 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 shutdown

Luôn dùng exec form cho CMDENTRYPOINT 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 → CMD

Sử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 appuser

2. Để secret trong image — ai pull image đều thấy:

dockerfile
# ❌ ENV DB_PASS=super_secret
# ✅ docker run -e DB_PASS=xxx myapp

3. Dùng ADD tải URL — không cache, không verify:

dockerfile
# ✅ Dùng curl trong RUN + verify checksum

8. 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 COPYADD?

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

BADD 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

Liên kết học tiếp