Container 2 — Dockerfile & build image tối ưu

13 thg 7, 2026 3 lượt xem
#security
#docker
#devops
#multi-stage
#image
#dockerfile

Từ "chạy được" đến "đóng gói được"

Ở bài Container là gì, chúng ta đã thấy image là bản đóng băng bất biến chứa toàn bộ những gì ứng dụng cần để chạy, còn container là một tiến trình được cô lập chạy từ image đó. Nhưng image từ đâu ra? Câu trả lời gần như luôn là: từ một Dockerfile.

Dockerfile là một tệp văn bản chứa công thức xây dựng image — một danh sách các chỉ thị (instruction) được thực thi tuần tự. Bạn có thể viết bừa cho nó "chạy được": nhét mọi thứ vào một image, cài cả compiler lẫn công cụ debug, chạy bằng root, đóng gói cả .git. Nó sẽ chạy. Nhưng nó sẽ là một image nặng 1.2 GB, mất 5 phút để build lại mỗi khi sửa một dòng code, và là một quả bom lỗ hổng bảo mật.

Bài này nói về khoảng cách giữa "chạy được" và "đóng gói chuẩn production". Ba trục chính:

  1. Layer cache — hiểu tại sao thứ tự các dòng trong Dockerfile quyết định tốc độ build.
  2. Multi-stage build — tách môi trường build khỏi môi trường chạy để image cuối nhỏ và sạch.
  3. Bảo mật — non-root, base image tối giản, không nhúng secret, quét lỗ hổng.

Quy ước: máy trong sandbox chỉ có PostgreSQL, không có Docker daemon, nên các đoạn dưới đây đều là (minh hoạ) — bạn chép về máy có Docker để chạy.


Các chỉ thị Dockerfile cốt lõi

Một Dockerfile được đọc từ trên xuống. Mỗi chỉ thị nhận trạng thái image từ chỉ thị trước, biến đổi nó, rồi trả ra trạng thái mới. Dưới đây là những chỉ thị bạn dùng hằng ngày.

FROM — chọn image nền (base image). Luôn là dòng đầu tiên (trừ ARG toàn cục). Đây là điểm khởi đầu: bạn kế thừa toàn bộ filesystem và metadata của image nền.

FROM python:3.12-slim

WORKDIR — đặt thư mục làm việc cho các chỉ thị sau (RUN, COPY, CMD...). Nó tự tạo thư mục nếu chưa có. Dùng WORKDIR /app thay vì RUN cd /app — vì mỗi RUN là một tiến trình riêng, cd không "dính" sang chỉ thị sau.

COPY vs ADD — cả hai chép file từ build context (thư mục bạn docker build) vào image.

  • COPY src dest: chép thuần tuý. Ưu tiên dùng cái này — hành vi rõ ràng, dễ đoán.
  • ADD: có thêm "phép màu" — tự giải nén file .tar cục bộ, và tải được URL. Chính vì "thông minh" mà nó khó đoán. Quy tắc: chỉ dùng ADD khi cần giải nén tar tự động; còn lại luôn COPY.

RUN — thực thi một lệnh tại thời điểm build, kết quả được "nướng" (bake) vào một layer mới. Đây là nơi bạn cài package, biên dịch, tạo thư mục.

RUN apt-get update && apt-get install -y --no-install-recommends curl

ENV vs ARG — cả hai định nghĩa biến, nhưng khác vòng đời:

  • ARG: biến chỉ tồn tại lúc build, truyền vào qua docker build --build-arg. Không có trong container khi chạy.
  • ENV: biến môi trường tồn tại cả lúc build lẫn lúc chạy, nằm trong image cuối.
ARG APP_VERSION=1.0.0        # chỉ dùng khi build
ENV PYTHONUNBUFFERED=1       # còn khi container chạy

Lưu ý bảo mật: đừng bao giờ truyền secret qua ARG hay ENV — cả hai đều lưu vết trong lịch sử image (xem phần bảo mật).

EXPOSE — khai báo (mang tính tài liệu) cổng mà container lắng nghe. Nó không tự mở cổng; bạn vẫn phải docker run -p. Nó chỉ là metadata cho người đọc và cho công cụ điều phối.

CMD vs ENTRYPOINT — cả hai định nghĩa lệnh chạy khi container start, nhưng vai trò khác nhau:

  • ENTRYPOINT: chương trình chính, cố định. Khó bị ghi đè.
  • CMD: tham số mặc định, dễ bị ghi đè bởi đối số trong docker run.

Mẫu phổ biến nhất: ENTRYPOINT là binary, CMD là tham số mặc định.

ENTRYPOINT ["gunicorn", "app:app"]
CMD ["--bind", "0.0.0.0:8000", "--workers", "4"]

Chạy docker run myimg → dùng CMD mặc định. Chạy docker run myimg --workers 8 → ghi đè CMD nhưng vẫn qua gunicorn. Luôn ưu tiên dạng exec (JSON array ["..."]) thay vì dạng shell (gunicorn app:app), vì dạng exec chạy tiến trình trực tiếp làm PID 1, nhận được tín hiệu (SIGTERM) để tắt sạch — điều rất quan trọng khi Kubernetes muốn dừng Pod.

USER — đổi user chạy các chỉ thị sau và tiến trình container. Mặc định là root; ta sẽ đổi sang non-root vì lý do bảo mật.

HEALTHCHECK — định nghĩa lệnh Docker chạy định kỳ để biết container có "khoẻ" không (khác với "còn sống"). Ví dụ gọi một endpoint /health.

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD curl -f http://localhost:8000/health || exit 1

VOLUME — đánh dấu một thư mục là điểm gắn dữ liệu ngoài (data không nên nằm trong lớp ghi của container, ví dụ thư mục database). Nó tuyên bố rằng dữ liệu ở đó có vòng đời độc lập với container.


Layer cache: tại sao thứ tự dòng lại quan trọng

Đây là khái niệm quan trọng nhất để build nhanh. Mỗi chỉ thị tạo image (FROM, RUN, COPY, ADD) sinh ra một layer — một lớp filesystem chỉ ghi lại phần thay đổi so với layer trước. Image cuối là chồng các layer đó xếp lên nhau.

Khi build, Docker cache từng layer. Với mỗi chỉ thị, nó hỏi: "Chỉ thị này và input của nó có giống lần build trước không?" Nếu giống, nó tái dùng layer cache (gần như tức thời). Nếu khác, layer đó phải build lại — và mọi layer sau nó cũng phải build lại, vì cache đã "vỡ" từ điểm đó.

Hệ quả sống còn: đặt những bước ít thay đổi lên trên, những bước hay thay đổi xuống dưới.

Trong một project code, thứ hay thay đổi nhất là mã nguồn của bạn; thứ ít thay đổi là danh sách dependency. Vậy mà nhiều người viết:

# ❌ KÉM — (minh hoạ)
FROM python:3.12-slim
WORKDIR /app
COPY . .                          # chép TẤT CẢ, gồm cả code
RUN pip install -r requirements.txt

Vấn đề: COPY . . chép cả code lẫn requirements.txt. Chỉ cần bạn sửa một dòng code, layer COPY vỡ cache → layer pip install phía sau cũng vỡ → cài lại toàn bộ dependency từ đầu dù danh sách thư viện không đổi. Mỗi lần sửa code là chờ vài phút cài lại numpy, pandas...

Cách đúng là tách làm hai bước: chép manifest trước, cài, rồi mới chép source:

# ✅ TỐT — (minh hoạ)
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .           # chỉ chép manifest (ít đổi)
RUN pip install --no-cache-dir -r requirements.txt   # cài — được cache
COPY . .                          # chép source (hay đổi) — xuống cuối

Bây giờ, sửa code chỉ làm vỡ cache từ dòng COPY . . trở đi. Layer pip install phía trên vẫn được tái dùng vì requirements.txt không đổi. Build lại chỉ mất vài giây.

Cùng nguyên tắc cho mọi hệ sinh thái: Node chép package.json + package-lock.json trước rồi npm ci; Go chép go.mod/go.sum trước rồi go mod download; Java chép pom.xml rồi mvn dependency:go-offline. Luôn là: manifest → install → source.


Multi-stage build: tách môi trường build khỏi runtime

Để build một app, bạn thường cần cả một bộ đồ nghề: compiler, header dev, trình quản lý package, có khi cả toàn bộ SDK. Nhưng để chạy app đã build xong, bạn thường chỉ cần vài artifact (binary, file .jar, thư mục dist/, các package đã cài). Nếu để tất cả trong một image, bạn mang theo hàng trăm MB toolchain vào production — vừa nặng vừa tăng bề mặt tấn công.

Multi-stage build giải quyết điều này: một Dockerfile chứa nhiều FROM, mỗi cái mở một "stage". Bạn build trong một stage đầy đủ đồ nghề, rồi chỉ copy artifact cần thiết sang một stage runtime tối giản. Image cuối cùng chỉ là stage cuối — mọi thứ trong các stage build bị bỏ lại.

Điểm mấu chốt là dòng COPY --from=builder: nó với sang stage builder lấy đúng artifact, còn compiler và cache build ở stage đó không bao giờ đi vào image cuối.


Ví dụ đầy đủ: đóng gói service Python multi-stage

Đây là một Dockerfile production đầy đủ cho một service Python (giả sử một API FastAPI/Flask), gộp mọi kỹ thuật đã nói. Đây là (minh hoạ) — chép về máy có Docker để build.

# ---------- Stage 1: builder — có toolchain, cài dependency ----------  (minh hoạ)
FROM python:3.12-slim AS builder

# Không nhúng secret vào đây; ARG chỉ cho tham số build vô hại
ARG APP_VERSION=0.0.0

WORKDIR /app

# Cài build deps (một số wheel cần compiler). Gộp trong 1 RUN + dọn cache.
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential gcc \
    && rm -rf /var/lib/apt/lists/*

# Tạo virtualenv riêng để dễ copy sang stage runtime
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# manifest -> install (được cache) -> source
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# ---------- Stage 2: runtime — nhỏ, sạch, không có compiler ----------  (minh hoạ)
FROM python:3.12-slim AS runtime

# Tạo user non-root
RUN groupadd --system app && useradd --system --gid app --home /app appuser

WORKDIR /app

# Chỉ lấy virtualenv đã cài từ stage builder — KHÔNG lấy compiler/cache
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

# Chép source (bước hay đổi) xuống cuối để tối ưu cache
COPY --chown=appuser:app . .

# Chạy bằng non-root
USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

# Dạng exec để nhận SIGTERM đúng cách
ENTRYPOINT ["gunicorn", "app:app"]
CMD ["--bind", "0.0.0.0:8000", "--workers", "4", "--worker-class", "uvicorn.workers.UvicornWorker"]

So với một image "một stage" nhồi cả build-essential và cache pip, image này thường nhỏ hơn đáng kể và không mang theo compiler — vừa nhẹ vừa giảm bề mặt tấn công.


Giảm kích thước image

Chọn base image nhỏ. Kích thước image bắt đầu từ base:

  • python:3.12 (Debian đầy đủ) — lớn nhất, tiện nhất.
  • python:3.12-slim — Debian rút gọn, bỏ nhiều công cụ thừa. Lựa chọn cân bằng tốt cho phần lớn trường hợp.
  • python:3.12-alpine — dựa trên Alpine Linux, rất nhỏ. Cảnh báo: Alpine dùng musl libc thay cho glibc; nhiều thư viện Python có binary wheel biên dịch cho glibc sẽ không dùng được, buộc phải biên dịch lại từ source — có khi image cuối to hơn và build chậm hơn slim. Cân nhắc kỹ với hệ sinh thái Python/khoa học dữ liệu.
  • distroless (ví dụ gcr.io/distroless/python3) — chỉ có runtime, không có shell, không package manager, không userland. Nhỏ nhất và an toàn nhất, nhưng khó debug (không sh để exec vào).

Gộp lệnh RUN. Mỗi RUN là một layer, và layer chỉ cộng thêm chứ không trừ đi — xoá file ở layer sau không làm image nhỏ lại vì file vẫn nằm trong layer trước. Vì vậy phải cài dọn dẹp trong cùng một RUN:

# ✅ dọn cache trong cùng RUN — (minh hoạ)
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

Nếu tách rm ra một RUN riêng, cache apt vẫn nằm nguyên trong layer trước đó. Với pip, dùng --no-cache-dir; với npm, dọn cache và dùng npm ci --omit=dev.

Dùng .dockerignore. Build context là toàn bộ thư mục bạn docker build — Docker gửi tất cả cho daemon, và COPY . . sẽ chép tất cả. Đặt file .dockerignore để loại thứ không cần:

# .dockerignore — (minh hoạ)
.git
__pycache__/
*.pyc
.venv/
node_modules/
.env
*.md
tests/

Lợi ích kép: image nhỏ hơn (không chép .git, node_modules), build nhanh hơn (context nhỏ), và an toàn hơn (không vô tình chép .env chứa secret vào image).


Bảo mật image

Chạy non-root. Mặc định container chạy bằng root; nếu có lỗ hổng thoát container (container escape), kẻ tấn công có quyền root trên host. Tạo user thường và USER sang nó, như ví dụ trên. Đây là một trong những cải thiện bảo mật rẻ nhất và hiệu quả nhất.

Không nhúng secret vào image. Đây là lỗi phổ biến và nguy hiểm. Image là các layer, và lịch sử build được lưu lại — ai docker history hoặc bung các layer ra đều đọc được. Vì thế:

  • Đừng COPY file .env, key, chứng chỉ private vào image.
  • Đừng truyền secret qua ARG/ENV (còn vết trong metadata/lịch sử).
  • Cấp secret lúc chạy: biến môi trường runtime, Docker/Kubernetes Secret, hoặc secret manager. Nếu bắt buộc cần secret lúc build (ví dụ token tải private package), dùng BuildKit secret mounts (RUN --mount=type=secret,...) — secret chỉ hiện trong lệnh đó và không đi vào layer nào.

Pin (ghim) base image. FROM python:3.12-slim vẫn "trôi" theo patch. Để build tái lập được (reproducible) và tránh bất ngờ, ghim cụ thể — lý tưởng là theo digest:

FROM python:3.12.4-slim         # ghim minor/patch
# hoặc chặt hơn:
FROM python:3.12-slim@sha256:<digest>   # ghim tuyệt đối theo digest

Base image nhỏ = ít lỗ hổng. Càng ít package trong image, càng ít CVE để lo. Đây là lý do bảo mật (chứ không chỉ kích thước) để dùng slim/distroless: distroless không có shell nên nhiều lớp tấn công (chèn lệnh qua shell) đơn giản là không tồn tại.

Quét lỗ hổng. Đưa bước quét image vào CI/CD, chặn deploy nếu có CVE nghiêm trọng:

  • Trivy (Aqua Security) — quét image tìm CVE trong OS package và thư viện app.
  • Docker Scout — tích hợp sẵn hệ sinh thái Docker.
# quét image tìm CVE — (minh hoạ)
trivy image myapp:1.0.0
docker scout cves myapp:1.0.0

Build sau proxy/CA doanh nghiệp. Nếu bạn build trong mạng công ty chặn ra ngoài qua proxy TLS-inspection, pip/npm/apt sẽ báo lỗi SSL vì không tin chứng chỉ do proxy ký. Cần cài CA của công ty vào image lúc build (ví dụ COPY corp-ca.crt /usr/local/share/ca-certificates/ && update-ca-certificates) và cấu hình proxy qua build-arg. Đây là chủ đề riêng, chỉ nhắc để bạn không mắc kẹt khi build nội bộ.


Build, tag & push

Ba lệnh cơ bản trong vòng đời một image — (minh hoạ):

# build từ Dockerfile trong thư mục hiện tại, gắn tên + tag
docker build -t myregistry.io/myteam/myapp:1.0.0 .

# gắn thêm tag khác cho cùng image (ví dụ latest)
docker tag myregistry.io/myteam/myapp:1.0.0 myregistry.io/myteam/myapp:latest

# đẩy lên registry
docker push myregistry.io/myteam/myapp:1.0.0

BuildKit là engine build hiện đại của Docker (mặc định trên bản mới), nhanh hơn nhờ build song song các stage độc lập và cache tốt hơn. Nếu cần bật tường minh: DOCKER_BUILDKIT=1 docker build .... BuildKit cũng mở khoá hai tính năng quan trọng ở trên:

  • Cache mounts — cache thư mục (ví dụ cache của pip/npm) giữa các lần build mà không nướng nó vào layer:
# cache mount cho pip — (minh hoạ)
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
  • Secret mounts — như đã nói ở phần bảo mật, cấp secret build-time an toàn.

Để build cho nhiều kiến trúc (amd64 + arm64), dùng docker buildx build --platform linux/amd64,linux/arm64 ....


Use case thực tế: đóng gói một service Python gọn & an toàn

Gộp lại toàn bộ bài, đây là "checklist" để biến một service Python từ "chạy được" thành image production:

  1. Base: python:3.12-slim, ghim theo patch/digest. (Cân nhắc distroless cho stage runtime nếu không cần shell debug.)
  2. Multi-stage: stage builder cài dependency vào một virtualenv /opt/venv; stage runtime chỉ COPY --from=builder /opt/venv — bỏ lại build-essential/gcc.
  3. Layer cache: COPY requirements.txtpip install → mới COPY . .. Sửa code không phải cài lại thư viện.
  4. Nhỏ: .dockerignore loại .git/.venv/.env/tests; pip install --no-cache-dir; gộp RUN + dọn apt cache.
  5. An toàn: tạo appuserUSER appuser; không COPY .env; secret cấp lúc chạy hoặc qua BuildKit secret mount; quét bằng Trivy trong CI.
  6. Chạy đúng: ENTRYPOINT/CMD dạng exec để nhận SIGTERM; HEALTHCHECK gọi /health; EXPOSE 8000 làm tài liệu.

Kết quả: một image nhỏ, build lại nhanh khi sửa code, chạy non-root, không rò secret, và sẵn sàng cho registry để Kubernetes kéo về triển khai.


Ghi nhớ

  • Mỗi chỉ thị FROM/RUN/COPY/ADD = một layer, và layer được cache. Vỡ cache ở một layer làm vỡ mọi layer sau nó.
  • Thứ tự tối ưu: bước ít đổi lên trên, bước hay đổi xuống dưới. Với code: manifest → install → source (COPY requirements.txt trước pip install, COPY . . cuối cùng).
  • COPY thay ADD trừ khi cần giải nén tar. ARG chỉ có lúc build, ENV có cả lúc chạy — đừng nhét secret vào cả hai.
  • ENTRYPOINT (binary cố định) + CMD (tham số mặc định), dùng dạng exec để nhận tín hiệu tắt sạch.
  • Multi-stage build: build trong stage đầy đồ nghề, COPY --from= chỉ lấy artifact sang stage runtime tối giản — bỏ lại compiler & cache.
  • Giảm kích thước: base nhỏ (slim/distroless; cẩn thận musl của alpine), gộp RUN + dọn cache trong cùng lệnh, .dockerignore, --no-cache-dir.
  • Bảo mật: chạy non-root (USER), không nhúng secret (image lưu lịch sử layer), pin base image (digest), base nhỏ = ít CVE, quét Trivy/Docker Scout trong CI.
  • Build: docker build -t, docker tag, docker push; BuildKit cho build song song, cache mounts & secret mounts.

Tiếp theo: dùng image này cùng nhiều container khác bằng Docker Compose & networking, rồi đưa nó lên cluster ở bài Workloads.


Nguồn tham khảo: tài liệu chính thức docs.docker.com — Dockerfile reference, Building best practicesMulti-stage builds.

Bài viết liên quan

Continuous Integration/Delivery/Deployment, cấu trúc pipeline, ví dụ GitHub Actions và chiến lược release.

13 thg 7, 2026 4

Container vs máy ảo, image/layer, Dockerfile, volume, network, Docker Compose và best practices.

13 thg 7, 2026 3

Vì sao cần orchestration; Pod, Deployment, Service, Ingress; scaling, self-healing và cấu hình.

13 thg 7, 2026 3

Bản chất DevOps: phá bỏ rào cản Dev–Ops, vòng lặp vô tận, CALMS, và vì sao tự động hoá.

13 thg 7, 2026 3