Bỏ qua để đến nội dung
Knowledge Hub

Dockerfile cho Node.js: multi-stage, layer cache và chạy non-root Cơ bản

Thời gian đọc ước tính: 11 phút

Service order-api (TypeScript) dùng Dockerfile một stage: FROM node, COPY . ., npm install, tsc. Image nặng 1,2 GB. Sửa một dòng trong src/, CI build lại mất 4 phút vì bước cài package chạy lại từ đầu. Ngoài ra:

  • Container chạy bằng root (uid 0).
  • Image chứa typescript, @types/*, toàn bộ source TS và cả file .env của máy dev.
  • Token npm truyền qua --build-arg đọc được bằng docker history.

Bài trả lời: Dockerfile cần sắp xếp thế nào để image nhỏ, rebuild nhanh, không chạy root và không mang secret?

Tình huống Chọn Điều kiện
Base image mặc định cho service Node node:<ver>-bookworm-slim glibc như bản Debian đầy đủ, ít package; thiếu curl, compiler
Cần image nhỏ nhất, dependency thuần JS node:<ver>-alpine Dùng musl thay glibc; native module phải có bản build cho musl hoặc tự compile
Muốn bề mặt tấn công nhỏ nhất distroless (gcr.io/distroless/nodejs24-debian13) Không shell, không npm; debug cần tag :debug hoặc container phụ
Code TS cần compile Multi-stage: build → runtime Runtime chỉ nhận dist/ và node_modules production
Rebuild chậm khi sửa code COPY package*.json → npm ci → COPY src Lockfile phải được commit
Cài package chậm mỗi khi đổi dependency RUN --mount=type=cache,target=/root/.npm Cần BuildKit (mặc định từ Docker Engine 23)
Cần token lúc build RUN --mount=type=secret Không dùng ARG/ENV cho secret
Chạy production USER node, NODE_ENV=production, base pin theo digest Thư mục cần ghi phải chown riêng

Bài dùng Docker 29.5.3 (buildx 0.34.1, BuildKit) và node:24.22.1.

Thuật ngữ Nghĩa
Layer Mỗi lệnh RUN, COPY, ADD tạo một lớp filesystem. Image là chồng các layer.
Layer cache Docker dùng lại layer nếu lệnh và input (file được COPY) không đổi. Một layer miss thì mọi layer sau nó build lại.
Stage Một khối bắt đầu bằng FROM. Chỉ stage cuối thành image; stage khác chỉ đóng góp file qua COPY --from.
Build context Thư mục gửi cho builder. .dockerignore quyết định file nào bị loại.

Kích thước image do stage cuối quyết định; tốc độ rebuild do layer đầu tiên bị miss quyết định. Base image chọn theo dependency và nhu cầu debug:

Đang tải diagram…

Multi-stage tách việc build khỏi việc chạy. Hình dưới theo order-api qua ba stage, rồi xem cache phản ứng thế nào khi sửa src và khi sửa package.json.

Stage build và deps bị bỏ; image runtime chỉ nhận dist và node_modules production
Stage build và deps bị bỏ; image runtime chỉ nhận dist và node_modules productionBước 1/6: .dockerignore loại node_modules, .git, .env trước khi gửi context. Secret local không vào được layer nào.Stage tạm (bị bỏ saubuild)Build contextđã lọc .dockerignorekhông có .envStage buildnpm ci + tscStage depsnpm ci --omit=devdist/JS đã compilenode_moduleschỉ productionImage runtimebookworm-slim, USER node
Build context
Bước 1/6

.dockerignore loại node_modules, .git, .env trước khi gửi context. Secret local không vào được layer nào.

  1. Context đã lọc .env, nên COPY không thể đưa nó vào.
  2. Build có devDependencies để chạy tsc; deps chỉ có 65/77 package.
  3. Runtime nhận hai thư mục qua COPY --from; không có typescript, không có source TS.
  4. Sửa src: npm ci cache hit. Sửa package.json: miss từ npm ci, cache mount giữ tarball đã tải.

Dockerfile đầy đủ (secure.Dockerfile trong demo):

ARG NODE_IMAGE=node:24.22.1-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205
FROM ${NODE_IMAGE} AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
--mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
COPY tsconfig.build.json ./
COPY src ./src
RUN npm run build
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
--mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
FROM ${NODE_IMAGE} AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p /app/tmp && chown node:node /app/tmp
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD ["node", "dist/main.js"]

CMD dạng exec-form để Node nhận SIGTERM trực tiếp; lý do chi tiết ở bài SIGTERM khi deploy.

  • npm ci cài đúng theo package-lock.json, báo lỗi nếu lock lệch package.json.
  • NODE_ENV và --omit: theo docs npm, khi NODE_ENV=production thì omit mặc định là dev. Vì vậy chỉ đặt ENV NODE_ENV=production ở stage runtime; đặt ở stage build thì tsc biến mất.
  • Quyền file: theo Dockerfile reference, COPY không có --chown tạo file với UID/GID 0. User node (uid 1000, có sẵn trong image node) đọc được nhưng không sửa được code, thường là điều mong muốn. Dùng COPY --chown=node:node hoặc chown chỉ cho thư mục app thật sự cần ghi.
  • Cache mount sống trong builder, không trong image; CI runner tạm cần cache backend riêng.
  • Secret mount mặc định ở /run/secrets/<id>, chỉ tồn tại trong RUN đó; có target= hoặc env=.
  • File <tên Dockerfile>.dockerignore cạnh Dockerfile được ưu tiên hơn .dockerignore gốc.
  • HEALTHCHECK: mặc định interval 30s, timeout 30s, retries 3; exit 0 là healthy, 1 là unhealthy. Image slim không có curl, nên gọi bằng node. Trên Kubernetes, trạng thái container do probe quyết định.
  • Pin base image: tag có thể bị đẩy lại, digest thì bất biến. Ghi cả hai: tag cho người đọc, digest cho build lặp lại.

Kích thước sau khi build (docker image ls, image store containerd nên có hai cột):

IMAGE DISK USAGE CONTENT SIZE
demo-dms:naive 1.68GB 413MB
demo-dms:secure 354MB 81.6MB
demo-dms:slim 354MB 81.6MB

CONTENT SIZE là dung lượng nén khi pull. Khác biệt đến từ base slim và bỏ devDependencies (node_modules 31 MB → 4,3 MB); cache mount và USER không đổi kích thước.

Thời gian rebuild sau khi thêm một dòng comment vào src/main.ts (ba lần, có cache):

Dockerfile Lần 1 Lần 2 Lần 3 Bước cài package
naive (COPY . . trước npm install) 3,1s 5,4s 3,1s Chạy lại (npm install + build 1,9s)
slim (đúng thứ tự layer) 2,0s 1,0s 0,9s CACHED

App chỉ có 77 package nên chênh lệch tính bằng giây; dependency càng nhiều, phần cài lại càng chiếm phần lớn thời gian. Số đo đổi theo mạng và máy.

Cache mount: sau khi sửa package.json, npm ci ở slim và secure đều mất 0,8–1s, không đo được khác biệt với ít package và registry nhanh.

Kiểm tra user, nội dung và quyền ghi:

$ docker run --rm demo-dms:naive sh -c 'id; cat /app/.env'
uid=0(root) gid=0(root) groups=0(root)
PAYMENT_API_SECRET_KEY=sk_test_demo_only
$ docker run --rm demo-dms:secure sh -c 'id; ls -A /app; touch /app/dist/x; touch /app/tmp/x && echo tmp-ok'
uid=1000(node) gid=1000(node) groups=1000(node)
dist
node_modules
package.json
tmp
touch: cannot touch '/app/dist/x': Permission denied
tmp-ok

Token qua ARG nằm nguyên văn trong metadata:

$ docker history --no-trunc --format '{{.CreatedBy}}' demo-dms:naive
...
RUN |1 NPM_TOKEN=npm_SECRET_demo_123 /bin/sh -c echo "//npm.example.internal/:_authToken=${NPM_TOKEN}" > .npmrc ...
ARG NPM_TOKEN=npm_SECRET_demo_123
$ docker history --no-trunc demo-dms:secure | grep -c npm_SECRET
0

rm .npmrc xoá file nhưng không xoá giá trị ARG khỏi history. Container secure đạt (healthy) sau 5 giây.

Lệnh build, run và dọn dẹp: các file *.Dockerfile trong examples/src/docker-multi-stage/; tạo .env giả trước khi build bản naive.

Lợi ích

  • Image nhỏ hơn nhiều lần, pull và khởi động Pod mới nhanh hơn.
  • Rebuild khi sửa code bỏ qua bước cài package.
  • Non-root giới hạn những gì một lỗ hổng RCE làm được trong container.

Giới hạn

  • Slim/distroless thiếu công cụ debug (curl, ps, shell với distroless).
  • Pin digest cần quy trình cập nhật base image định kỳ để nhận bản vá.
  1. Commit package-lock.json và dùng npm ci; COPY package*.json trước COPY src.
  2. Tạo .dockerignore loại node_modules, dist, .git, .env*.
  3. Tách stage build và runtime; runtime chỉ nhận dist/ và node_modules production.
  4. Đặt ENV NODE_ENV=production ở stage runtime, USER node trước CMD.
  5. Chạy docker run --rm <image> id -u trong CI và fail nếu kết quả là 0.
  6. Secret lúc build chỉ qua --mount=type=secret; quét docker history không còn token.
  7. Pin base image bằng tag + digest, cập nhật định kỳ bằng bot.
  8. Build một lần, promote cùng digest qua các môi trường (build once, deploy many).

Lỗi phổ biến nhất nằm ở thứ tự layer:

COPY toàn bộ trước khi cài package

Hay gặp

COPY . .
RUN npm install && npm run build

Nên viết

COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
RUN npm run build

Vì sao

Đổi bất kỳ file nào cũng làm miss COPY . . và cài lại toàn bộ package. Tách manifest ra trước để sửa code chỉ chạm layer build.

Secret là sai lầm thứ hai, khó phát hiện vì image vẫn chạy bình thường:

Cách hiểu sai Hệ quả Cần làm
ENV NODE_ENV=production ở đầu Dockerfile npm ci bỏ devDeps, tsc không tồn tại ở stage build Đặt ở stage runtime
USER node là đủ chown -R toàn bộ app cho node, app sửa được chính code của mình Code thuộc root, chỉ chown thư mục cần ghi
.env có trong .gitignore thì an toàn .dockerignore mới quyết định context; COPY . . vẫn đưa .env vào Thêm .env* vào .dockerignore
  1. Sửa tsconfig.build.json trong secure.Dockerfile: những layer nào build lại, layer npm ci có miss không?
  2. Vì sao rm .npmrc trong cùng RUN không đủ để giấu token truyền qua ARG?
  3. Container chạy uid 1000 nhưng app cần ghi file upload tạm. Nên đổi quyền ở đâu, vì sao không chown -R /app?