Tối ưu hóa Docker: Giảm dung lượng image Node.js từ 1.2GB xuống 78MB
Những Docker image quá cồng kềnh không chỉ làm chậm quy trình CI/CD mà còn làm tăng chi phí lưu trữ và rủi ro bảo mật. Bài viết này sẽ hướng dẫn bạn qua 6 bước thực tế để thu nhỏ một image Node.js sản xuất từ 1.2GB xuống chỉ còn 78MB.

Những Docker image (ảnh) phình to chính là "thuế thầm lặng" mà mọi đội ngũ phát triển phải gánh chịu. Nó dẫn đến CI chậm, triển khai (deploy) chậm, bề mặt tấn công lớn hơn và hóa đơn registry đắt đỏ hơn. May mắn thay, vấn đề này gần như luôn có thể giải quyết trong một buổi chiều.
Tôi đã lấy một dịch vụ Node.js + TypeScript thực tế đang chạy trong môi trường sản xuất, bắt đầu từ một Dockerfile sơ khai mà hầu hết các đội ngũ thường viết, và từng bước tối ưu hóa nó từ 1.2GB xuống còn 78MB. Cùng một ứng dụng, cùng một hành vi, thông qua 6 bước đo lường trên cùng một máy chủ. Dưới đây là những thay đổi thực sự tạo ra sự khác biệt.
Docker Containers Optimization
Điểm khởi đầu: 1.2GB
Đây là Dockerfile mà hầu hết mọi người bắt đầu cùng. Nó hoạt động tốt, nhưng lại lãng phí ở hầu hết mọi dòng lệnh.
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Khi build và kiểm tra kích thước, ta nhận được kết quả 1.21GB chỉ để vận hành một dịch vụ tạo ra khoảng 4MB JavaScript đã biên dịch. Hãy cùng khắc phục nó.
Bước 1: Chuyển đổi base image, từ 1.21GB xuống 412MB
Thẻ node:22 dựa trên Debian và bao gồm toàn bộ chuỗi công cụ (toolchain) mà bạn không cần lúc runtime. Biến thể slim đã cắt bỏ phần lớn các thành phần này.
node:22: 1.21GBnode:22-slim: 412MBnode:22-alpine: 178MB
Alpine còn nhỏ hơn, nhưng nó sử dụng musl libc thay vì glibc. Hầu hết ứng dụng thuần JS chạy tốt trên nó, nhưng bất kỳ thứ gì có native modules (như bcrypt, sharp, node-gyp builds) đều cần sự chăm sóc kỹ lưỡng, và một số gói có lỗi musl tinh vi. Tôi mặc định chọn slim và chỉ dùng alpine khi biết cây phụ thuộc sạch sẽ.
Để thực tế với các ứng dụng trong thế giới thực, chúng ta sẽ tiếp tục dùng slim.
Bước 2: Sử dụng .dockerignore, từ 412MB xuống 388MB
Lệnh COPY . . sẽ vui vẻ sao chép node_modules, .git, các artifact build, file .env cục bộ, thư mục IDE và cả test fixture vào trong image. Ngay cả khi một bước sau đó ghi đè node_modules, lớp (layer) đó vẫn đã nằm trong lịch sử image.
Hãy tạo một file .dockerignore:
node_modules
npm-debug.log
.git
.gitignore
.env*
.vscode
.idea
coverage
dist
build
*.md
test
__tests__
Dockerfile*
.dockerignore
Đây là một thắng lợi nhỏ về dung lượng nhưng thắng lợi lớn về tốc độ rebuild và bảo mật. File .env.development cục bộ của bạn sẽ không còn bị ẩn giấu bên trong một lớp được đẩy lên registry công khai.
Bước 3: Multi-stage build, từ 388MB xuống 198MB
Bạn cần TypeScript, eslint, framework kiểm thử và hàng trăm phụ thuộc phát triển để build ứng dụng. Nhưng bạn hoàn toàn không cần bất kỳ thứ nào trong số đó để chạy nó.
Multi-stage build sẽ biên dịch trong một image và chỉ sao chép các artifact sang một image thứ hai, sạch sẽ:
# ---- builder ----
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
# ---- runtime ----
FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/index.js"]
Hai điều đang thực sự发挥作用 ở đây:
npm cithay vìnpm install: Nó sử dụngpackage-lock.jsontrực tiếp, nhanh hơn và có tính tái tạo. Hãy dùng nó trong CI và Docker.npm prune --omit=dev: Loại bỏ các dev dependencies sau khi build. Với một dịch vụ TypeScript điển hình, một nửanode_modulessẽ biến mất.
Bước 4: Layer caching thực sự hiệu quả, giữ nguyên dung lượng nhưng tăng tốc độ rebuild 5 lần
Bước này không liên quan đến dung lượng, nhưng là thắng lợi lớn nhất cho CI. Hầu hết Dockerfiles làm vô hiệu hóa npm ci trên mọi thay đổi code vì chúng COPY . . trước khi cài đặt. Thứ tự của các lệnh rất quan trọng.
Đã được hiển thị ở trên, nhưng đáng để nhắc lại: sao chép package*.json trước, cài đặt, sau đó mới sao chép phần còn lại. Giờ npm ci sẽ được cached miễn là dependencies của bạn không thay đổi.
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
Trên một dự án với khoảng 600 dependencies, bước này đã giảm thời gian rebuild lạnh của chúng tôi từ 94 giây xuống còn 18 giây khi chỉ có mã ứng dụng thay đổi.
Bước 5: Chuyển sang Alpine cho giai đoạn runtime, từ 198MB xuống 96MB
Chúng ta có thể giữ builder dựa trên Debian (tương thích với mọi thứ) và chỉ chuyển runtime sang Alpine. Code JS đã biên dịch không quan tâm đến hệ điều hành cơ bản tại runtime, miễn là không có binary native nào gọi các ký hiệu đặc thù của glibc.
# ---- builder ----
FROM node:22-slim AS builder
... (giữ nguyên)
# ---- runtime ----
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/index.js"]
Nếu bạn có native modules, hãy build chúng trong một giai đoạn khớp với libc của runtime. Đối với Alpine, điều đó có nghĩa là dùng node:22-alpine làm builder, thêm apk add --no-cache python3 make g++ để biên dịch, sau đó là một giai đoạn runtime sạch.
Bước 6: Bỏ hoàn toàn Node với distroless, từ 96MB xuống 78MB
Các image distroless của Google chỉ chứa Node runtime và các root TLS của nó. Không có shell, không có trình quản lý gói, không có apt, không có curl. Nếu kẻ tấn công chiếm được shell bên trong container của bạn, sẽ không có shell nào để chiếm cả.
# ---- runtime ----
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["dist/index.js"]
Lưu ý sự thay đổi cú pháp CMD. Không có shell trong distroless, nên bạn không thể dùng dạng shell (CMD node dist/index.js). Phải dùng dạng exec, và entrypoint đã là node, nên bạn chỉ cần truyền script vào.
Sự đánh đổi: Bạn không thể dùng docker exec -it container sh để kiểm tra nhanh. Để debug, hãy chạy cùng một image với thẻ :debug, thẻ này thêm shell busybox. Trong sản xuất, hãy giữ phiên bản đã khóa chặt.
Tổng kết lợi ích
Việc giảm dung lượng đĩa và chi phí registry là lợi ích rõ ràng, nhưng thường không phải là lớn nhất.
- Khởi động lạnh nhanh hơn trên Kubernetes và các nền tảng serverless: Việc kéo 78MB thay vì 1.2GB trên một node không có cache là sự khác biệt giữa một pod sẵn sàng trong 4 giây so với 40 giây.
- Bề mặt tấn công nhỏ hơn: Số lượng CVE trên
node:22lên tới hàng trăm. Trên distroless chỉ đếm trên đầu ngón tay. Bộ quét bảo mật của bạn sẽ thôi "hét lên". - CI nhanh hơn: Đẩy và kéo các image nhỏ hơn trong pipeline của bạn giúp tiết kiệm thời gian thực cho mọi lần triển khai.
- Sao chép cross-region rẻ hơn: Nếu bạn nhân bản registry trên các vùng để phục hồi thảm họa (DR), giờ đây bạn chỉ di chuyển 6% số byte mà bạn từng di chuyển.
Dockerfile sơ khai thì ổn cho một cuộc thi hackathon. Đối với bất cứ thứ gì bạn triển khai nhiều hơn một lần một tuần, 6 bước trên sẽ hoàn vốn chỉ trong một buổi chiều. Hãy sao chép Dockerfile cuối cùng, thiết lập .dockerignore, cấu hình CI của bạn và ngừng trả "thuế" 1.2GB đó.
Bài viết liên quan

Phần mềm
Runtime ra mắt hạ tầng sandbox cho coding agents, giúp toàn bộ đội ngũ phát triển phần mềm an toàn
21 tháng 5, 2026

Phần mềm
Cha đẻ của curl kêu gọi ưu tiên "xác minh" thay vì "tin tưởng" trong chuỗi cung ứng phần mềm
07 tháng 5, 2026

Công nghệ
Google ra mắt Gmail Live: Giờ đây bạn có thể 'nói chuyện' trực tiếp với hộp thư nhờ AI
19 tháng 5, 2026
