Dockerfile chuyên nghiệp: Từ cơ bản đến multi-stage build
Viết Dockerfile đúng cách để build image nhỏ, nhanh, bảo mật từ layer ordering đến multi-stage build cho Next.js.
Nguyễn Nhật Long
@nguyennhatlong1303
Bài trước mình đã nói về image layers cách Docker cache từng layer và tại sao thứ tự các instruction lại quan trọng. Bài này mình sẽ đi thẳng vào việc viết Dockerfile thực tế, những pattern mình dùng hằng ngày, và cả những lỗi kinh điển mà ai cũng từng mắc ít nhất một lần.
Các instruction cần biết trước khi viết bất cứ thứ gì
Dockerfile không có nhiều instruction lắm, nhưng hiểu rõ từng cái thì quan trọng hơn là nhớ hết syntax:
Cái hay nhất là ENTRYPOINT vs CMD hai cái này hay bị nhầm lẫn. Hiểu đơn giản thế này: ENTRYPOINT là cái bạn không muốn ai thay đổi (ví dụ: binary chính của app), còn CMD là default arguments có thể override. Kết hợp cả hai thì ENTRYPOINT chạy trước, CMD là arguments mặc định truyền vào.
| Instruction | Tác dụng | Ghi chú |
|---|---|---|
| `FROM` | Chọn base image | Bắt buộc, phải là dòng đầu tiên |
| `WORKDIR` | Set working directory | Tự tạo folder nếu chưa có |
| `COPY` | Copy file từ host vào image | Nên dùng thay vì `ADD` |
| `RUN` | Chạy command lúc build | Mỗi `RUN` tạo một layer mới |
| `CMD` | Command mặc định khi run container | Có thể override khi `docker run` |
| `ENTRYPOINT` | Command cố định khi run container | Không override được dễ dàng |
| `EXPOSE` | Document port | Chỉ để document, không thực sự mở port |
| `ENV` | Set environment variable | Tồn tại cả lúc build lẫn runtime |
| `ARG` | Build-time variable | Chỉ tồn tại lúc build, không có trong container |
Dockerfile cho Node.js app và tại sao thứ tự lại quan trọng đến vậy
Mình lấy ví dụ một Node.js app đơn giản:
1FROM node:20-alpine2WORKDIR /app3COPY package*.json ./4RUN npm ci --only=production5COPY . .6EXPOSE 30007CMD ["node", "server.js"]
Nhìn vào đây, bạn có thể thấy mình copy package*.json trước, rồi mới RUN npm ci, rồi mới COPY . .. Tại sao không COPY . . luôn một lần cho nhanh?
Vì Docker cache layer theo thứ tự. Khi bạn thay đổi một file source code bất kỳ, nếu COPY . . nằm trước npm ci, Docker sẽ invalidate cache từ đó trở đi tức là npm ci chạy lại từ đầu mỗi lần bạn sửa code. Với node_modules vài trăm MB, điều đó cực kỳ tốn thời gian.
Thứ tự đúng: những gì ít thay đổi thì để trên, những gì hay thay đổi thì để dưới. package.json chỉ thay đổi khi bạn thêm/xóa dependency, còn source code thay đổi liên tục nên source code phải ở dưới cùng.
Theo kinh nghiệm của mình, cái lỗi này là nguyên nhân số một khiến CI pipeline chậm không thể giải thích được. Build local thì nhanh vì có cache, nhưng CI runner fresh mỗi lần thì không có cache và đó là lúc bạn nhận ra mình đã viết Dockerfile sai từ đầu.
.dockerignore đừng bỏ qua file này
Trước khi nói đến multi-stage, mình muốn nhắc đến .dockerignore vì nhiều người hay quên. File này hoạt động giống .gitignore liệt kê những gì Docker không copy vào image khi bạn dùng COPY . ..
1node_modules2.git3.env4.env.local5.next6dist7coverage8*.log9.DS_Store10README.md
Anh em lưu ý: nếu không có .dockerignore, COPY . . sẽ copy cả node_modules vào build context và Docker phải gửi toàn bộ đống đó lên Docker daemon trước khi build. Cái này không chỉ chậm mà còn có thể gây ra bug kỳ lạ vì node_modules trên host và trong container có thể khác nhau (đặc biệt là native modules).
Multi-stage build kỹ thuật quan trọng nhất bạn cần biết
Đây là phần mình thích nhất. Multi-stage build cho phép bạn dùng nhiều FROM trong một Dockerfile, mỗi FROM là một stage riêng biệt. Stage sau có thể copy artifacts từ stage trước và image cuối cùng chỉ chứa những gì stage cuối cùng có.
Tại sao cần điều này? Vì để build một Next.js app, bạn cần: Node.js, npm, toàn bộ devDependencies, TypeScript compiler, webpack... Nhưng để chạy app đó, bạn chỉ cần: Node.js và output đã được build. Tất cả thứ còn lại là rác.
Mình lấy ví dụ thực tế với Next.js:
1# Stage 1: Build2FROM node:20-alpine AS builder3WORKDIR /app4COPY package*.json ./5RUN npm ci6COPY . .7RUN npm run build89# Stage 2: Run10FROM node:20-alpine AS runner11WORKDIR /app12COPY --from=builder /app/.next/standalone ./13COPY --from=builder /app/.next/static ./.next/static14COPY --from=builder /app/public ./public15EXPOSE 300016CMD ["node", "server.js"]
Kết quả? Image size giảm từ ~1GB xuống còn khoảng 100-150MB. Mình đã áp dụng cái này cho một project Next.js ở công ty và thời gian pull image trên production giảm rõ rệt, đặc biệt khi deploy lên các region xa.
Một điểm quan trọng với Next.js: bạn cần bật output: 'standalone' trong next.config.js thì mới có thư mục .next/standalone. Nếu không có cái này, multi-stage build vẫn work nhưng bạn sẽ phải copy thêm nhiều thứ hơn.
1// next.config.js2module.exports = {3 output: 'standalone',4}
Gộp RUN commands ít layers, image nhỏ hơn
Mỗi RUN instruction tạo một layer mới. Nhiều layer không phải lúc nào cũng xấu, nhưng với những command setup môi trường, bạn nên gộp lại:
1# Không tốt tạo 3 layers riêng biệt2RUN apt-get update3RUN apt-get install -y curl4RUN rm -rf /var/lib/apt/lists/*56# Tốt hơn một layer duy nhất7RUN apt-get update && \8 apt-get install -y curl && \9 rm -rf /var/lib/apt/lists/*
Cái rm -rf /var/lib/apt/lists/* ở cuối là quan trọng nếu bạn để nó ở một RUN riêng, Docker vẫn giữ cache của apt trong layer trước, và xóa ở layer sau không thực sự giảm size của image vì layer cũ vẫn còn đó.
Chạy container với non-root user
Đây là best practice về security mà mình thấy nhiều team bỏ qua vì nghĩ "dev environment thôi, cần gì". Nhưng thói quen tốt nên build từ đầu:
1FROM node:20-alpine2WORKDIR /app34# Tạo user riêng5RUN addgroup --system appgroup && \6 adduser --system --ingroup appgroup appuser78COPY --chown=appuser:appgroup package*.json ./9RUN npm ci --only=production10COPY --chown=appuser:appgroup . .1112# Switch sang non-root user13USER appuser1415EXPOSE 300016CMD ["node", "server.js"]
Lý do đơn giản: nếu có security vulnerability trong app của bạn và attacker có thể execute code trong container, họ sẽ chạy với quyền root nếu bạn không set USER. Với non-root user, thiệt hại bị giới hạn đáng kể hơn.
Pin version đừng tin vào latest
Cái này mình học được từ một incident khá đau: FROM node:20-alpine một ngày nào đó sẽ pull một minor version khác và app của bạn có thể break vì behavior thay đổi. Pin version cụ thể:
1# Không nên2FROM node:latest3FROM node:20-alpine45# Nên dùng6FROM node:20.11.1-alpine3.19
Mình biết là pin version cụ thể thì phải update thủ công, nhưng đó là trade-off đáng chấp nhận. Bạn có thể dùng Dependabot hoặc Renovate để tự động tạo PR update base image vừa có control, vừa không quá tốn công.
Một Dockerfile hoàn chỉnh để tham khảo
Tổng hợp lại tất cả những gì mình vừa nói, đây là Dockerfile mình hay dùng cho Node.js production app:
1FROM node:20.11.1-alpine3.19 AS builder2WORKDIR /app34COPY package*.json ./5RUN npm ci67COPY . .8RUN npm run build910# ---1112FROM node:20.11.1-alpine3.19 AS runner13WORKDIR /app1415RUN addgroup --system appgroup && \16 adduser --system --ingroup appgroup appuser1718COPY --from=builder --chown=appuser:appgroup /app/dist ./dist19COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules20COPY --from=builder --chown=appuser:appgroup /app/package.json ./2122USER appuser2324EXPOSE 300025CMD ["node", "dist/server.js"]
Bài tiếp theo mình sẽ nói về Docker Compose cách orchestrate nhiều container cùng lúc cho local development. Đó là lúc mọi thứ bắt đầu thú vị hơn nhiều khi bạn cần chạy app + database + redis + một đống services khác chỉ bằng một command.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.
Thấy hay? Chia sẻ cho bạn bè!