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
Vấn đề
Phần tiêu đề “Vấn đề”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.envcủa máy dev. - Token npm truyền qua
--build-argđọc được bằngdocker 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?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| 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.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”| 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…
Cách hoạt động
Phần tiêu đề “Cách hoạt động”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.
.dockerignore loại node_modules, .git, .env trước khi gửi context. Secret local không vào được layer nào.
- Context đã lọc
.env, nênCOPYkhông thể đưa nó vào. - Build có devDependencies để chạy
tsc; deps chỉ có 65/77 package. - Runtime nhận hai thư mục qua
COPY --from; không cótypescript, không có source TS. - Sửa
src:npm cicache hit. Sửapackage.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 buildWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm \ --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm ciCOPY tsconfig.build.json ./COPY src ./srcRUN npm run build
FROM ${NODE_IMAGE} AS depsWORKDIR /appCOPY 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 runtimeENV NODE_ENV=productionWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY --from=build /app/dist ./distCOPY package.json ./RUN mkdir -p /app/tmp && chown node:node /app/tmpUSER nodeEXPOSE 3000HEALTHCHECK --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.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”npm cicài đúng theopackage-lock.json, báo lỗi nếu lock lệchpackage.json.NODE_ENVvà--omit: theo docs npm, khiNODE_ENV=productionthìomitmặc định làdev. Vì vậy chỉ đặtENV NODE_ENV=productionở stage runtime; đặt ở stage build thìtscbiến mất.- Quyền file: theo Dockerfile reference,
COPYkhông có--chowntạo file với UID/GID 0. Usernode(uid 1000, có sẵn trong imagenode) đọc được nhưng không sửa được code, thường là điều mong muốn. DùngCOPY --chown=node:nodehoặcchownchỉ 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 trongRUNđó; cótarget=hoặcenv=. - File
<tên Dockerfile>.dockerignorecạnh Dockerfile được ưu tiên hơn.dockerignoregố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ằngnode. 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.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Kích thước sau khi build (docker image ls, image store containerd nên có hai cột):
IMAGE DISK USAGE CONTENT SIZEdemo-dms:naive 1.68GB 413MBdemo-dms:secure 354MB 81.6MBdemo-dms:slim 354MB 81.6MBCONTENT 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)distnode_modulespackage.jsontmptouch: cannot touch '/app/dist/x': Permission deniedtmp-okToken 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_SECRET0rm .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.
Trade-offs
Phần tiêu đề “Trade-offs”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á.
Checklist production
Phần tiêu đề “Checklist production”- Commit
package-lock.jsonvà dùngnpm ci;COPY package*.jsontrướcCOPY src. - Tạo
.dockerignoreloạinode_modules,dist,.git,.env*. - Tách stage build và runtime; runtime chỉ nhận
dist/vànode_modulesproduction. - Đặt
ENV NODE_ENV=productionở stage runtime,USER nodetrướcCMD. - Chạy
docker run --rm <image> id -utrong CI và fail nếu kết quả là0. - Secret lúc build chỉ qua
--mount=type=secret; quétdocker historykhông còn token. - Pin base image bằng tag + digest, cập nhật định kỳ bằng bot.
- Build một lần, promote cùng digest qua các môi trường (build once, deploy many).
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”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 buildNên viết
COPY package.json package-lock.json ./RUN npm ciCOPY src ./srcRUN npm run buildVì 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 |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Sửa
tsconfig.build.jsontrongsecure.Dockerfile: những layer nào build lại, layernpm cicó miss không? - Vì sao
rm .npmrctrong cùngRUNkhông đủ để giấu token truyền quaARG? - 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?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Dockerfile reference — Docker:
COPY --chown,RUN --mount=type=cache, defaults củaHEALTHCHECK. - Build secrets — Docker: vì sao
ARG/ENVkhông dùng cho secret, cú pháp secret mount. - Build context — Docker:
.dockerignorevà file ignore riêng theo Dockerfile. - Official image node — Docker Hub: khác biệt giữa biến thể mặc định, slim và alpine (musl).
- Docker and Node.js Best Practices — nodejs/docker-node: user
nodeuid 1000,NODE_ENV,CMD ["node", ...]. - npm config: omit: quan hệ giữa
NODE_ENV=productionvà việc bỏ devDependencies. - distroless — GoogleContainerTools: image không shell, tag
:debugvà:nonroot.