Hầu hết Dockerfile đầu tiên đều đến từ copy-paste: một tutorial Medium, một Stack Overflow answer, hoặc một snippet từ document. Nó chạy được. Nhưng rồi bạn sửa một dòng code trong server.js, build lại, và thấy npm install chạy lại từ đầu. 400 package tải lại dù không thay đổi gì. apt-get update quét toàn bộ repo. COPY . . kéo cả node_modules, .git, .env, thư mục test vào image. Image thành phẩm 900MB, chứa toàn bộ dev toolchain, chạy process dưới quyền root.

Vấn đề không phải Dockerfile của bạn “sai” — mà là thứ tự lệnh sai, dẫn đến cache miss hàng loạt. Bài này đi qua từng instruction chính: FROM, RUN, COPY, ADD, CMD, ENTRYPOINT, ARG, ENV, WORKDIR, USER — kèm best practice và code mẫu cho từng cái.


  flowchart LR
    subgraph Stage1["Build Stage"]
        FROM_B["FROM node:20.11.1-alpine"]
        WORKDIR_B["WORKDIR /app"]
        COPY_B["COPY package*.json ./"]
        RUN_B["RUN npm ci --production"]
        COPY_SRC["COPY src/ ./src/"]
    end
    subgraph Stage2["Production Stage"]
        FROM_P["FROM node:20.11.1-alpine"]
        USER_P["USER node"]
        COPY_ARTIFACT["COPY --from=build /app"]
        CMD_P["CMD node server.js"]
    end
    Stage1 -->|"Multi-stage<br/>copy artifact"| Stage2
    Stage1 -..- Cache["Layer Cache
    <br/>Cache HIT: FROM, WORKDIR, package.json chưa đổi
    <br/>Cache MISS: COPY src/ khi code thay đổi"]

FROM: mọi thứ bắt đầu từ base image

FROM là instruction đầu tiên (sau ARG) và cũng là quyết định quan trọng nhất trong Dockerfile. Nó xác định base image – toàn bộ filesystem, package manager, libc, user mặc định sẽ là nền móng cho container của bạn.

Chọn base image: Alpine, Slim, hay Distroless?

Ba dòng base image phổ biến và trade-off của từng loại:

# Alpine: 7 MB, musl libc, apk package manager
FROM node:20.11.1-alpine

# Debian Slim: 74 MB, glibc, apt package manager
FROM node:20.11.1-bookworm-slim

# Distroless: 2 MB (static), không shell, không package manager
FROM gcr.io/distroless/nodejs20-debian12

Alpine nhỏ nhất trong nhóm có shell, lý tưởng cho hầu hết ứng dụng Node.js, Go, Python. Nhược điểm là musl libc – một số native addon (node-gyp, Python wheels) phải compile từ source thay vì dùng binary pre-built. DNS resolution behavior cũng khác glibc, từng gây timeout kỳ lạ trên Kubernetes với CNAME chain dài.

Debian Slim nặng hơn nhưng dùng glibc chuẩn, tương thích cao nhất. Nếu ứng dụng phụ thuộc vào binary pre-compiled cho glibc hoặc cần apt để cài thêm package lúc runtime, đây là lựa chọn an toàn.

Distroless nhỏ nhất và an toàn nhất: không shell, không ls, không cat, không package manager. Attacker exploit RCE vào container distroless không có shell để chạy command. Nhưng debug khó – không thể docker exec vào và gõ lệnh. Phù hợp cho production service đã mature, có logging và monitoring đầy đủ.

Quy tắc chọn nhanh: Go/Rust static binary -> scratch hoặc gcr.io/distroless/static-debian12. Node.js/Python web app -> Alpine nếu không có native addon, Debian Slim nếu có native addon hoặc cần glibc. Java -> eclipse-temurin:21-jre-alpine cho runtime, đừng dùng JDK cho production.

Luôn pin version, không dùng latest

# Tệ -- mỗi lần build có thể ra image khác nhau
FROM node:latest

# Tốt -- reproducible build
FROM node:20.11.1-alpine

latest tag trỏ đến version mới nhất tại thời điểm pull. Hôm nay node:latest là 20.x, ba tháng sau là 22.x. Build trên CI hôm qua xanh, hôm nay đỏ vì base image thay đổi – kiểu bug truy vết cực khó vì không ai nghĩ base image là nguyên nhân.

Pin cả major.minor.patch (20.11.1) thay vì chỉ major (20). Dùng Dependabot hoặc Renovate để tự động mở PR cập nhật base image, kèm theo CVE scan để biết khi nào cần upgrade vì lỗi bảo mật.

Multi-stage builds

Bài này chỉ giới thiệu ngắn – mình đã viết chi tiết về multi-stage builds trong bài Container image nhẹ và an toàn. Ý tưởng: dùng một stage để build (có compiler, dev dependencies), rồi copy artifact sang stage production (chỉ có runtime). Kết quả: image production không chứa gcc, make, node_modules dev.

# Stage 1: build
FROM node:20.11.1-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build

# Stage 2: production -- chỉ copy artifact từ stage 1
FROM node:20.11.1-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
USER node
CMD ["node", "dist/server.js"]

RUN: mỗi dòng là một layer

Mỗi instruction RUN tạo ra một filesystem layer mới trong image. Layer này được tính toán bằng checksum của filesystem diff – nếu hai RUN liên tiếp cùng tạo ra một thư mục rồi xóa nó, layer đầu vẫn chứa thư mục đó và chiếm dung lượng, dù layer sau đã ghi đè (xóa) nó. Đây là lý do bạn thấy image to một cách khó hiểu dù rm -rf đã chạy.

Gộp nhiều lệnh vào một RUN

# Tệ -- 4 layer, layer 2 và 3 chứa package cache
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN rm -rf /var/lib/apt/lists/*

# Tốt -- 1 layer duy nhất, cache bị xóa trong cùng layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl git && \
    rm -rf /var/lib/apt/lists/*

Khi gộp chung, rm -rf /var/lib/apt/lists/* xảy ra trong cùng một layer với apt-get install, file cache chưa bao giờ được commit vào layer. Đây là nguyên lý quan trọng nhất của layer optimization: dọn dẹp trong cùng instruction tạo ra file rác đó.

Một số mẹo gộp lệnh:

# Node.js: gộp npm ci và clean cache
RUN npm ci --production && npm cache clean --force

# Python: gộp pip install và clean
RUN pip install --no-cache-dir -r requirements.txt

# Alpine: gộp apk add và clean
RUN apk add --no-cache curl git
Mỗi RUN là một shell session riêng biệt. RUN cd /app ở dòng 5 không ảnh hưởng đến RUN npm install ở dòng 6 – mỗi RUN xuất phát từ thư mục gốc /. Dùng WORKDIR thay vì cd trong RUN. Mình từng mất 30 phút debug tại sao npm install không chạy đúng thư mục vì không biết điều này.

COPY vs ADD: chọn đúng công cụ

COPY: công cụ chính

COPY là instruction bạn nên dùng 99% thời gian. Nó copy file và thư mục từ build context vào image filesystem.

COPY package.json package-lock.json ./
COPY src/ ./src/

Quyền sở hữu file khi COPY: mặc định file được copy với UID/GID = 0 (root). Nếu cần ownership khác, dùng --chown:

COPY --chown=node:node . /app

ADD: ba tính năng đặc biệt

ADD làm mọi thứ COPY làm, cộng thêm ba tính năng:

  1. URL download: ADD https://example.com/file.tar.gz /tmp/ – tự động tải file từ URL. Nhưng không nên dùng vì không cache được, không verify checksum, tạo thêm network dependency lúc build. Dùng RUN curl hoặc RUN wget thay vì ADD url.

  2. Tar auto-extract: ADD archive.tar.gz /app/ – tự động giải nén archive vào thư mục đích. Đây là lý do chính đáng duy nhất để dùng ADD.

  3. Git repo: ADD không clone git repo. Nếu thấy tutorial dùng ADD để clone, đó là sai.

# Chỉ dùng ADD cho tar auto-extract
ADD prometheus-2.45.0.linux-amd64.tar.gz /opt/

# Mọi trường hợp khác, dùng COPY
COPY . /app
Quy tắc đơn giản: luôn dùng COPY, chỉ dùng ADD khi cần auto-extract tar archive. Nếu buộc phải dùng ADD cho URL, ít nhất gộp với RUN để verify checksum và clean up.

COPY package.json trước, code sau

Đây là pattern quan trọng nhất để tận dụng layer caching:

# Tệ: code thay đổi -> cache miss từ dòng này
COPY . .
RUN npm ci

# Tốt: package.json ít thay đổi -> cache hit ở npm ci
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/

Khi bạn sửa server.js, chỉ COPY src/ bị cache miss. RUN npm ci vẫn cache hit vì package.json không đổi. Thời gian build giảm từ 2 phút xuống còn 2 giây.

Trình tự chuẩn cho Node.js:

FROM node:20.11.1-alpine
WORKDIR /app

# Layer 1-2: dependencies (ít thay đổi nhất)
COPY package.json package-lock.json ./
RUN npm ci --production

# Layer 3: source code (thay đổi thường xuyên)
COPY src/ ./src/

# Layer 4: config
COPY config/ ./config/

USER node
CMD ["node", "src/server.js"]

CMD vs ENTRYPOINT: ai chạy cái gì?

Đây là cặp instruction gây nhầm lẫn nhiều nhất. Cả hai đều định nghĩa command sẽ chạy khi container start, nhưng hành vi khác nhau khi bị override.

CMD: lệnh mặc định

CMD định nghĩa command mặc định – có thể bị ghi đè hoàn toàn khi docker run truyền tham số.

CMD ["node", "server.js"]
# docker run my-image           -> chạy node server.js
# docker run my-image /bin/sh   -> ghi đè thành /bin/sh

ENTRYPOINT: lệnh cố định

ENTRYPOINT định nghĩa command cố định – tham số từ docker run được append vào, không ghi đè.

ENTRYPOINT ["node"]
CMD ["server.js"]
# docker run my-image              -> node server.js
# docker run my-image app.js       -> node app.js (ghì đè CMD, không ghi đè ENTRYPOINT)

Pattern phổ biến: ENTRYPOINT wrapper + CMD

# entrypoint.sh xử lý config, env var, rồi exec command thực sự
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]

Script docker-entrypoint.sh điển hình:

#!/bin/sh
set -e

# Xử lý pre-start logic: sinh config từ env var, chờ database sẵn sàng...
if [ -n "$DATABASE_URL" ]; then
  echo "Waiting for database..."
  # logic chờ database
fi

# exec để process chính trở thành PID 1, nhận SIGTERM đúng cách
exec "$@"

Shell form vs Exec form

# Shell form: chạy trong /bin/sh -c "..."
# PID 1 là /bin/sh, không phải node. SIGTERM gửi đến sh, không đến node.
CMD node server.js

# Exec form: chạy trực tiếp, không qua shell
# PID 1 là node. SIGTERM đến đúng process.
CMD ["node", "server.js"]

Khác biệt quan trọng: chỉ exec form mới nhận OS signal đúng cách. Shell form biến command thành child process của /bin/sh, và /bin/sh không forward signal đến child process (trừ khi dùng exec). Container mất 10-30 giây để stop vì Docker daemon gửi SIGTERM, /bin/sh ignore, daemon đợi timeout rồi gửi SIGKILL.

Luôn dùng exec form cho CMD và ENTRYPOINT trong production. Shell form chỉ tiện cho dev/test vì hỗ trợ variable expansion ($HOME, $PORT). Nếu cần variable expansion với exec form, dùng entrypoint script như pattern ở trên – script được gọi bằng exec form, nhưng trong script bạn có thể dùng shell variable.

ARG vs ENV: build-time vs runtime

ARG: biến build-time

ARG định nghĩa biến chỉ tồn tại trong quá trình build. Nó không xuất hiện trong image cuối cùng (trừ khi bị lộ qua layer history – xem phần dưới).

ARG NODE_VERSION=20.11.1
FROM node:${NODE_VERSION}-alpine

ARG APP_ENV=production
RUN if [ "$APP_ENV" = "development" ]; then npm install; else npm ci --production; fi

# Truyền từ command line
# docker build --build-arg NODE_VERSION=22.5.0 --build-arg APP_ENV=staging .

ENV: biến runtime

ENV định nghĩa biến tồn tại cả lúc build và lúc container chạy. Nó được persist vào image metadata và visible trong docker inspect.

ENV NODE_ENV=production
ENV PORT=3000

Secrets: đừng dùng ENV

# TUYỆT ĐỐI KHÔNG LÀM
ENV DATABASE_PASSWORD=super-secret-123
ENV API_KEY=sk-abc123

# Vì: docker history --no-trunc my-image sẽ hiện nguyên văn giá trị
# docker inspect my-image cũng hiện toàn bộ ENV

Secret lộ trong docker history là vấn đề nghiêm trọng – image push lên registry (Docker Hub, ECR, GCR) thì ai có quyền pull đều đọc được secret. Đây không phải lý thuyết, mình từng thấy token GitHub, API key Stripe, và database password trong Docker Hub public image.

Giải pháp: truyền secret lúc runtime qua cơ chế của orchestrator (Docker secrets, Kubernetes secrets, environment variable injection của Cloud Run/ECS), không bake vào image.

# Tốt: truyền lúc runtime
docker run -e DATABASE_PASSWORD="$DB_PASS" my-image

# Hoặc dùng Docker secrets (Swarm) / Kubernetes secrets
ARG cũng có thể lộ secret. docker history hiển thị giá trị ARG nếu nó được dùng trong một instruction. Nếu bạn làm ARG SECRET_TOKEN rồi RUN curl -H "Authorization: $SECRET_TOKEN" ..., giá trị token xuất hiện trong layer history. Mọi thứ từng là ARG value và được reference trong RUN/CMD đều có thể xuất hiện trong image metadata. Quy tắc: không có gì là secret khi đã vào build context hoặc Dockerfile.

WORKDIR: đặt đường dẫn làm việc

# Thay vì cái này (không hoạt động như mong đợi):
RUN cd /app
RUN npm install  # vẫn chạy ở / vì mỗi RUN là session riêng

# Dùng cái này:
WORKDIR /app
RUN npm install  # chạy trong /app

# WORKDIR tự động tạo thư mục nếu chưa có
WORKDIR /app/subdir/deep  # tự tạo /app, /app/subdir, /app/subdir/deep

WORKDIR set thư mục làm việc cho tất cả instruction phía sau: RUN, CMD, ENTRYPOINT, COPY, ADD. Nó tương đương với mkdir -p <path> && cd <path> nhưng persist qua các instruction. Có thể dùng nhiều WORKDIR – mỗi lần gọi là relative path từ WORKDIR trước đó:

WORKDIR /app
WORKDIR src
# Thư mục hiện tại: /app/src

USER: đừng chạy root

Mặc định, container chạy với UID 0 (root) – có toàn quyền trong container. Nếu attacker exploit ứng dụng của bạn, họ có root access trong container. Từ đó, họ có thể cài backdoor, đọc secret files, và – nếu container chạy với --privileged hoặc kernel exploit – escape ra host.

# Base image thường đã tạo sẵn non-root user
# Alpine node image có user "node" (UID 1000)
# Debian image thường có user "nobody" (UID 65534)

FROM node:20.11.1-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

Nếu base image không có non-root user, tự tạo:

FROM alpine:3.19
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["./my-binary"]
Đặt USER ở cuối Dockerfile. Nếu đặt sớm, các instruction RUN phía sau sẽ chạy với user đó và có thể không đủ quyền để cài package, tạo thư mục. Pattern chuẩn: làm mọi thứ cần root (cài package, tạo thư mục), rồi USER appuser ở gần cuối, trước CMD.

.dockerignore: tiết kiệm context và tránh lộ file

Build context (toàn bộ thư mục gửi lên Docker daemon) có thể rất lớn nếu không dùng .dockerignore. Mình từng build trên CI mà docker build mất 30 giây chỉ để gửi context 200 MB chứa node_modules, .git, test fixtures.

# .dockerignore
node_modules
.git
.gitignore
.env
.env.*
*.md
*.log
Dockerfile
docker-compose*.yml
test/
tests/
__tests__/
coverage/
.nyc_output/
dist/
.vscode/
.idea/
*.tmp

Hai lợi ích của .dockerignore:

  1. Build context nhỏ hơn -> upload context nhanh hơn, build nhanh hơn.
  2. Không vô tình COPY file nhạy cảm vào image. .env chứa secret, .git chứa toàn bộ lịch sử commit – những thứ này không bao giờ nên có trong image.

Layer caching: sắp xếp lệnh từ ít thay đổi đến nhiều thay đổi

Nguyên lý cốt lõi: Docker cache mỗi instruction dựa trên instruction text + checksum của files được COPY/ADD. Khi một instruction cache miss, tất cả instruction phía sau cũng cache miss – đây là quy luật “cache chain break.”

Do đó, sắp xếp Dockerfile từ layer ít thay đổi nhất đến layer thay đổi nhiều nhất:

# 1. Base image: hầu như không đổi (cache hit)
FROM node:20.11.1-alpine

# 2. System dependencies: hiếm khi đổi
RUN apk add --no-cache tini curl

# 3. Application dependencies: chỉ đổi khi package.json thay đổi
COPY package.json package-lock.json ./
RUN npm ci --production

# 4. Source code: thay đổi thường xuyên nhất
COPY src/ ./src/

# 5. Config và metadata: có thể thay đổi
USER node
CMD ["node", "src/server.js"]

So sánh với Dockerfile na-ní (cache miss mỗi lần build):

FROM node:20.11.1-alpine
COPY . .                    # Code đổi -> MISS, mọi thứ phía sau cũng MISS
RUN npm ci --production     # Luôn chạy lại dù package.json không đổi
USER node
CMD ["node", "src/server.js"]

Để kiểm tra cache đang hoạt động thế nào, build với --progress=plain để thấy rõ từng layer:

docker build --progress=plain -t my-app .
# Output sẽ hiện [CACHED] cho layer cache hit, [BUILD] cho layer chạy lại
Cache cũng hoạt động với multi-stage builds. Mỗi stage có cache riêng. Stage build có thể cache hit hoàn toàn nếu code không đổi, sau đó stage production chỉ cần copy artifact. Kết hợp thứ tự lệnh đúng + multi-stage, build có thể giảm từ 5 phút xuống dưới 10 giây cho lần sửa code thứ hai trở đi.

Dockerfile hoàn chỉnh mẫu

Tổng hợp tất cả best practice vào một Dockerfile cho Node.js production:

# Stage 1: Build ứng dụng
FROM node:20.11.1-alpine AS build
WORKDIR /app

# Dependencies
COPY package.json package-lock.json ./
RUN npm ci

# Source
COPY src/ ./src/
COPY tsconfig.json ./

# Build (nếu dùng TypeScript)
RUN npm run build

# Stage 2: Production image
FROM node:20.11.1-alpine

# Metadata (không ảnh hưởng layer cache)
LABEL org.opencontainers.image.title="my-api"
LABEL org.opencontainers.image.version="1.0.0"

# System dependencies: chỉ cài những gì runtime thực sự cần
RUN apk add --no-cache tini curl

WORKDIR /app

# Copy production dependencies từ build stage
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./

# Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

# Entrypoint + CMD pattern
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]

Tổng kết

Viết Dockerfile tốt không khó, nhưng cần hiểu từng instruction tạo ra cái gì, layer cache hoạt động ra sao, và thứ tự lệnh ảnh hưởng đến cache chain như thế nào. Checklist nhanh trước khi push Dockerfile lên repo:

MụcCheck
Base imagePin version cụ thể, không dùng latest
Layer cacheDependencies trước, code sau
RUN gộp lệnhClean up trong cùng layer
COPY/ADDDùng COPY cho local, ADD cho tar extract
CMD/ENTRYPOINTExec form, không shell form
ARG/ENVSecrets không bao giờ trong ENV hoặc ARG
WORKDIRDùng thay vì RUN cd
USERNon-root user ở cuối Dockerfile
.dockerignoreChặn node_modules, .git, .env
Multi-stageBuild stage tách khỏi production stage
HEALTHCHECKCó healthcheck endpoint

Câu hỏi hay gặp

Q: Tại sao image của mình vẫn to dù đã gộp RUN và clean up?

A: Có thể bạn đã có layer cũ chứa file rác từ trước khi gộp. Chạy docker history <image> --no-trunc để xem kích thước từng layer. Nếu thấy layer cũ lớn bất thường dù đã xóa file trong layer sau, bạn cần build lại từ đầu không cache: docker build --no-cache. Ngoài ra, kiểm tra xem có COPY . . kéo file không cần thiết vào không. Dùng docker scan hoặc dive để phân tích từng layer.

Q: Khi nào nên dùng ENTRYPOINT thay vì CMD?

A: Dùng ENTRYPOINT khi bạn muốn container luôn chạy một wrapper script cố định (pre-start check, config template rendering, signal forwarding). Dùng CMD cho command có thể thay đổi tùy deployment context. Pattern phổ biến: ENTRYPOINT là wrapper script, CMD là command mặc định có thể override.

Q: Có nên chạy nhiều process trong một container không?

A: Nói chung là không. Một container = một process chính. Nếu cần sidecar (ví dụ: nginx reverse proxy + app server), dùng Docker Compose hoặc Kubernetes pod với nhiều container. Ngoại lệ: một số pattern dùng supervisord hoặc s6-overlay để chạy nhiều process trong container, nhưng chỉ nên dùng khi bạn hiểu rõ PID 1 problem và signal handling. Bài tiếp theo (Phần 5) sẽ đi sâu vào vấn đề này.

Q: Alpine hay Debian Slim cho production?

A: Alpine nếu ứng dụng của bạn hoạt động ổn với musl libc và không có native dependency phức tạp. Debian Slim nếu cần glibc, có native addon (Python wheels pre-built, Node.js native modules), hoặc cần apt để cài package lúc runtime. Cả hai đều ổn cho production – quan trọng hơn là bạn có pin version, non-root user, multi-stage build, và .dockerignore đầy đủ hay không.


Bài tiếp theo (Core Docker): Phần 5: Container lifecycle & signal handling, start/stop/restart, SIGTERM/SIGKILL, restart policy, và bài toán PID 1.