Từ một Docker build kéo dài 185,7 giây với image nặng 3,61GB, mình đã tối ưu xuống còn 0,9 giây và 995MB nhờ viết lại Dockerfile theo kiểu multi-stage. Trong bài viết này, mình sẽ chia sẻ cách mình làm điều đó, bao gồm việc phân tích Dockerfile ban đầu, những sai lầm nghiêm trọng khiến thời gian build chậm chạp và image "phình to", cũng như giải pháp tối ưu với Docker multi-stage build. Cuối cùng, chúng ta sẽ xem kết quả so sánh trước và sau khi tối ưu thông qua biểu đồ minh họa. Hãy bắt đầu thôi!
Note: Đây là bản tiếng Việt từ bài viết gốc của mình ở Medium, nếu thấy hay thì hãy cho mình 1 claps ở đây nhé, mãi iu : https://medium.com/@nhattrile11/how-i-reduced-docker-build-time-by-99-51-and-image-size-by-73-08-99f5c8337377
Dockerfile ban đầu: Chậm chạp và cồng kềnh
Trước khi tối ưu, Dockerfile của mình trông như thế này:
# Dockerfile ban đầu (có vấn đề)
FROM node:16 WORKDIR /app # Sao chép toàn bộ source code vào image
COPY . . # Cài đặt dependencies bên trong container
RUN npm install # Build ứng dụng (ví dụ với ứng dụng Next.js)
RUN npm run build # Khởi động ứng dụng
CMD ["npm", "start"]
Có gì sai ở đây?
Ở cái nhìn đầu tiên, Dockerfile này có vẻ hoạt động được: nó lấy một image Node.js, copy mã nguồn, cài dependency, build và chạy. Thực tế, mình cũng đã dùng Dockerfile này một thời gian, cho đến khi dự án lớn dần lên. Rồi bùm! Thời gian build tăng chóng mặt, dung lượng image cũng vậy. 😵
Hãy mổ xẻ những vấn đề tiềm ẩn trong Dockerfile ban đầu này và tại sao nó khiến build time tới ~3 phút (185 giây) và image size hơn 3,6GB.
Những sai lầm nghiêm trọng trong Dockerfile ban đầu
Dưới đây là các sai lầm chính mà mình đã mắc phải (rất may, đây cũng là những lỗi khá phổ biến, nên bạn không đơn độc đâu 😅):
1. Copy toàn bộ project mà không .dockerignore
Lệnh COPY . .
sẽ chép toàn bộ thư mục project vào image. Nếu bạn không loại trừ node_modules
, .next/cache
hay các thư mục build cache khác, Docker sẽ đem tất cả những đống này vào image. Kết quả là image phình to một cách không cần thiết.
Trong trường hợp của mình, việc copy cả node_modules
và .next/cache
khiến context build lên đến hàng GB, mỗi lần build Docker phải chuyển một đống dữ liệu thừa thãi này vào image. Điều này làm chậm quá trình build và tạo ra một image cục mịch.
2. Cài đặt dependencies trực tiếp trong container (không tối ưu cache)
Docker build tạo ra các lớp (layer) cache cho mỗi lệnh. Dockerfile ban đầu cài dependencies bằng lệnh RUN npm install
sau khi copy toàn bộ mã nguồn. Nghĩa là mỗi khi bất kỳ file nào trong project thay đổi (ví dụ chỉnh sửa 1 dòng code), layer npm install
bị mất cache và phải chạy lại từ đầu. 😫 Điều này cực kỳ lãng phí thời gian.
Lẽ ra, mình nên tách bước cài dependencies ra và chỉ chạy lại khi file khai báo dependency (package.json
/package-lock.json
) thay đổi. Việc không tận dụng cache hợp lý khiến mỗi lần build giống như một cực hình, cài đi cài lại hàng trăm package dù phần lớn không đổi.
3. Không dùng multi-stage build
Dockerfile một stage duy nhất nghĩa là image cuối cùng chứa luôn cả môi trường build. Tất cả dependencies (kể cả devDependencies
), code nguồn, thậm chí tool build đều nằm trong image production.
Trong trường hợp Node.js (đặc biệt framework như Next.js), điều này đồng nghĩa với việc image bao gồm cả những thứ chỉ cần cho quá trình build (Webpack, Babel, v.v.) và các file nguồn không còn cần thiết sau khi đã build xong. Hậu quả là dung lượng image tăng vọt. Ngoài ra, image cồng kềnh còn làm chậm việc push/pull image lên registry và deploy.
Giải pháp tối ưu: Viết lại Dockerfile đa tầng (multi-stage)
Sau khi nhận ra các vấn đề trên, mình quyết định viết lại Dockerfile theo hướng multi-stage build. Mục tiêu là: chỉ giữ lại những gì cần thiết cho sản phẩm cuối, và tận dụng tối đa cache để tăng tốc độ build.
Dưới đây là Dockerfile mới sau khi tối ưu:
# Stage 1: Dependencies layer (deps)
FROM node:16-alpine AS deps
WORKDIR /app # Chỉ copy file package để cài dependencies (tận dụng cache)
COPY package.json package-lock.json ./
RUN npm install # Stage 2: Build layer (builder)
FROM node:16-alpine AS builder
WORKDIR /app # Copy dependency từ stage deps sang để dùng cho build
COPY --from=deps /app/node_modules ./node_modules # Copy toàn bộ source code (trừ những thứ không cần thiết đã loại bằng .dockerignore)
COPY . . # Thực thi build (ví dụ build Next.js thành các file tĩnh trong .next)
RUN npm run build # Stage 3: Runner (production image)
FROM node:16-alpine AS runner
WORKDIR /app # Chỉ copy những thứ cần thiết từ stage builder sang image final
COPY --from=builder /app/.next ./.next # thư mục build của Next.js
COPY --from=builder /app/public ./public # static files (nếu có)
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules # Command khởi chạy app
CMD ["npm", "start"]
Giải thích các thay đổi
Hãy cùng mình điểm lại nhanh xem từng thay đổi thế nào nha:
- Chép có chọn lọc & dùng .dockerignore: Nhờ loại bỏ mấy thư mục thừa thãi bằng .dockerignore và chỉ copy đúng những file cần thiết, quá trình build Docker giờ nhẹ tênh :3 .
- Tách riêng bước cài dependencies: Cài dependency giờ đã cache-friendly hơn rất nhiều. Miễn là package.json và package-lock.json không đổi, Docker sẽ dùng lại cache và khỏi phải chạy npm ci lại từ đầu, tiết kiệm khối thời gian chờ đợi.
- Tận dụng multi-stage build: Vì đã tách rõ các giai đoạn – từ cài deps (deps), build app (builder), tới chạy production (runner) nên đã giúp mình giữ lại đúng những gì cần thiết cho image cuối cùng. DevDependencies, tool build và mớ file build tạm đều bay màu, image gọn gàng hẳn, đẩy lên registry cũng nhanh hơn rõ
Tại sao mình dùng npm ci thay vì npm install?
Mình đã thay npm install bằng npm ci vì npm ci mang lại một vài lợi ích cực kỳ quan trọng sau:
- Ổn định: npm ci tuân thủ nghiêm ngặt các phiên bản đã được khóa trong package-lock.json, đảm bảo môi trường build luôn đồng nhất và có thể tái tạo chính xác trên mọi máy(Kể cả khi nếu đã có node_modules thì nó cũng sẽ xóa luôn và tạo lại node_modules mới).
- Nhanh hơn: Nó bỏ qua một số bước kiểm tra không cần thiết (như kiểm tra tương thích hay tạo mới file package-lock, bỏ qua package.json mà cài thẳng luôn trên package-lock) nên tốc độ cài đặt nhanh hơn đáng kể.
- Đáng tin cậy: Cực kỳ phù hợp cho các quy trình CI/CD – nơi sự nhất quán giữa các lần build là điều bắt buộc.
Kết quả tối ưu hóa: Nhanh hơn, nhẹ hơn đáng kể
Sau khi áp dụng Dockerfile multi-stage mới, kết quả thu được thực sự ấn tượng:
- Thời gian build giảm từ 185,7 giây xuống chỉ còn 0,9 giây (nhanh hơn gấp ~200 lần!). Việc build Docker image bây giờ gần như "một cái chớp mắt", nhấn build xong ngay, không kịp làm ngụm cà phê nào ☕ nữa.
- Dung lượng image giảm từ 3,61GB xuống 995MB. Image nhỏ hơn ~1/4 so với trước. Giờ đây mình có thể lưu trữ 3–4 phiên bản image mới bằng dung lượng của đúng 1 bản cũ trước kia.
Để trực quan hơn, hãy xem biểu đồ so sánh dưới đây giữa trước và sau tối ưu:
Màu đỏ ("Trước") cho thấy Docker build ban đầu. Màu xanh lá ("Sau")
Nhìn vào biểu đồ, sự khác biệt thật sự rõ rệt. Thanh màu xanh lá thấp lè tè gần sát trục 0 cho thời gian build “Sau” chứng tỏ build mới nhanh kinh khủng khiếp so với thanh đỏ cao ngất trời “Trước”. Tương tự, dung lượng image đã co lại đáng kể (từ 3610MB còn 995MB). Những con số không biết nói dối – chúng chứng minh việc đầu tư viết lại Dockerfile đã đem lại hiệu quả vượt bậc.
Lời kết
Qua câu chuyện tối ưu Dockerfile của chính mình, bài học rút ra là: đừng coi thường những dòng lệnh trong Dockerfile. Chỉ vài sai lầm nhỏ (copy thừa vài thư mục, cài package không đúng chỗ, quên multi-stage) có thể tích tụ thành vấn đề lớn làm chậm cả quy trình CI/CD và tạo ra những Docker image cồng kềnh.
Hy vọng những kinh nghiệm và mẹo tối ưu trên đây sẽ hữu ích cho bạn trong việc viết Dockerfile. Hãy thử áp dụng và biết đâu bạn cũng sẽ ngạc nhiên với kết quả đạt được. 🚀