TL;DR

Một Dockerfile tốt không chỉ chạy được — nó rebuild nhanh (nhờ layer cache) và chạy an toàn (nhờ non-root USER, exec-form ENTRYPOINT, digest pinning). Tất cả bắt đầu từ việc xếp 12 instructions đúng thứ tự, từ ít thay đổi nhất ở trên xuống thay đổi thường xuyên nhất ở dưới.

  • FROM → LABEL → ARG → ENV → WORKDIR → COPY/ADD → RUN → EXPOSE → VOLUME → USER → HEALTHCHECK → ENTRYPOINT/CMD
  • Mỗi lần 1 instruction bị thay đổi, Docker xoá cache của nó và toàn bộ các instructions phía sau. Đặt source code sai chỗ = mỗi lần sửa 1 dòng JS thì npm install chạy lại từ đầu.
  • Exec-form ENTRYPOINT ["bin", "flag"] bắt buộc — shell-form khiến app không nhận SIGTERM khi docker stop.

Thứ tự chuẩn 12 instructions

Đây là bảng cheat sheet gốc cộng với lý do vì sao mỗi instruction đứng đúng vị trí đó:

#InstructionVai tròVì sao ở đây
1FROMBase imageBắt buộc dòng đầu — thiết lập filesystem gốc
2LABELMetadataRất ít khi đổi → đặt cao để giữ cache
3ARGBuild-time varsParam hoá FROM tag hoặc RUN sớm; KHÔNG persist vào image
4ENVRuntime env varsĐược bake vào image; đổi value = cache miss NGAY tại dòng đó
5WORKDIRThư mục làm việcDùng path tuyệt đối, thay thế RUN cd
6COPY / ADDChép file/artifactCache key = nội dung file; luôn ưu tiên COPY
7RUNCài package, build, cleanupMỗi RUN = 1 layer vĩnh viễn
8EXPOSEDocument portChỉ mang tính tài liệu, không publish port
9VOLUMEPersistent mountData ghi ở đây bypass union FS
10USERChuyển sang non-rootSau khi cài/copy, trước ENTRYPOINT
11HEALTHCHECKProbe sức khoẻChỉ instruction cuối cùng có hiệu lực
12ENTRYPOINT / CMDLệnh chạy khi startDùng exec form JSON array

Vì sao thứ tự quan trọng — layer cache

Docker build image theo layer, mỗi instruction tạo 1 layer. Khi rebuild, Docker duyệt từ trên xuống và kiểm tra từng layer còn dùng lại được không. Cache miss đầu tiên invalidate toàn bộ layers phía dưới.

Đây là pattern quen thuộc nhất làm chậm dev loop:

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

Mỗi lần bạn sửa 1 ký tự trong server.js, COPY . . cache bust → npm install chạy lại từ đầu. Ở project thật, đây là 30–120 giây lãng phí mỗi lần rebuild.

Fix:

FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]

npm ci được cache chừng nào lockfile chưa đổi. Rebuild rớt xuống còn vài giây.

Cache rules chi tiết

  • RUN cache key = literal string của lệnh. RUN apt-get dist-upgrade -y KHÔNG tự invalidate theo thời gian — chỉ khi bạn sửa dòng đó hoặc dùng --no-cache.
  • COPY/ADD cache key = nội dung file + permissions. mtime bị bỏ qua. Đổi 1 byte trong file được copy = bust cache layer đó và tất cả layers phía sau.
  • ARG cache miss xảy ra ở lần dùng đầu tiên, không phải ở dòng khai báo. ARG VERSION một mình là cache-safe; RUN echo $VERSION mới bust cache khi VERSION đổi.
  • ENV cache miss xảy ra NGAY tại dòng ENV khi value đổi — vì ENV được bake vĩnh viễn vào layer đó.

Một nguyên nhân cache-miss âm thầm rất khó debug: RUN apt-get update tách riêng khỏi RUN apt-get install. Docker thấy string update không đổi → reuse cached layer → bạn install phiên bản package cũ 6 tháng trước. Luôn chain 2 lệnh trong cùng 1 RUN:

RUN apt-get update && apt-get install -y --no-install-recommends \
      curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*

Top 10 anti-patterns & fix

Anti-patternHậu quảFix
Tách apt-get update / installCài package cũ do cache hitGộp 1 RUN + --no-install-recommends + cleanup
COPY . . trước cài depsMỗi code change rebuild deps từ đầuCopy manifest trước, cài, rồi copy code
FROM ubuntu:latestBuild gãy bất ngờ trong tương laiPin major.minor hoặc SHA digest
Chạy bằng rootPrivilege escalation khi container breakoutUSER 10000:10001 (UID ≥ 10000)
Không có .dockerignoreLeak .env, .git vào imageIgnore secrets, node_modules, logs
Mỗi command 1 RUNLayer phình, file "đã xoá" còn trong historyChain bằng && + \
Secret qua ARG / ENVHiện rõ trong docker historyRUN --mount=type=secret,id=...
Không có HEALTHCHECKApp hang im lặng, orchestrator không biếtThêm HEALTHCHECK (hoặc K8s livenessProbe)
Dùng ADD khi COPY đủTar auto-extract = Zip Slip / path traversal; URL = MITMDùng COPY, chỉ ADD khi cần extract tar
Shell-form ENTRYPOINTKhông phải PID 1, không nhận SIGTERMExec form JSON array

Template mẫu cho Node app

Kết hợp đầy đủ thứ tự chuẩn + multi-stage + non-root + HEALTHCHECK:

# syntax=docker/dockerfile:1.7
FROM node:20-alpine@sha256:... AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

FROM node:20-alpine@sha256:... AS runtime
LABEL org.opencontainers.image.source="https://github.com/you/app"
ENV NODE_ENV=production PORT=3000
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=10000:10001 . .
EXPOSE 3000
USER 10000:10001
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1
ENTRYPOINT ["node"]
CMD ["server.js"]

Chú ý: --mount=type=cache giữ npm cache dir giữa các build (BuildKit), không bake vào image. COPY --chown đặt ownership ngay lúc copy, tránh phải thêm RUN chown.

Những gì BuildKit mới (2026) đáng thêm vào flow

Syntax Dockerfile core đã ổn định nhiều năm; thứ tự 12 instructions không đổi. Điểm nâng cấp đáng học:

  • RUN --mount=type=secret,id=npmrc — inject token lúc build mà không để lại trong image history.
  • RUN --mount=type=cache,target=/root/.npm — cache persistent giữa các build host, dùng cho npm / pip / apt / go mod cache.
  • COPY --link — copy không invalidate layers phía sau khi có thể, giúp cache hit nhiều hơn.
  • Heredocs trong RUN: RUN <<EOF ... EOF — script nhiều dòng không cần && / \.

Tất cả đều là opt-in và dùng được khi khai báo # syntax=docker/dockerfile:1.7 ở dòng đầu Dockerfile.

Nguồn: Docker Docs — Best practices, Dockerfile reference, Sysdig — Top 21 Dockerfile best practices, Docker Blog — Intro Guide, @devops_nk tweet gốc.