Tại sao ảnh Docker của tôi nặng 1GB (và cách tôi giảm xuống còn 50MB)

Table of Contents
Tôi đã đẩy một Docker image lên môi trường production và nhìn cái vòng quay triển khai quay trong ba phút. Sau đó nó sập. Không phải vì lỗi, mà vì việc kéo cái image 1.2GB đó mất quá nhiều thời gian và CI runner của chúng tôi bị timeout.
Tôi biết image của mình lớn. Tôi không nhận ra nó lớn đến mức nào cho đến khi tôi chạy docker images và thấy con số đó hiện ra trước mắt. Hơn một gigabyte cho một API Node.js đơn giản. Thật đáng xấu hổ.
Thế là tôi bắt đầu ăn kiêng. Không phải tôi, mà là các Docker image của tôi. Đây là những gì tôi đã học được về cách thu nhỏ chúng từ những con quái vật cồng kềnh thành những container tinh gọn, hiệu quả.
Vấn đề: Cái gì đã chiếm hết không gian đó?
Để tôi đoán xem. Dockerfile của bạn trông giống như của tôi.
1-FROM node:181+FROM node:18-alpine AS builder22 WORKDIR /app3+COPY package*.json ./4+RUN npm ci --only=production35 COPY . .4-RUN npm install56 RUN npm run build7+8+FROM node:18-alpine9+WORKDIR /app10+COPY --from=builder /app/dist ./dist11+COPY --from=builder /app/node_modules ./node_modules612 EXPOSE 30007-CMD ["npm", "start"]13+CMD ["node", "dist/index.js"]
Bảy dòng. Trông có vẻ vô hại. Nhưng cái image đó lại mang theo toàn bộ Node.js SDK, các công cụ build, các dependency dev, và mọi layer của mã nguồn của tôi bao gồm cả những thứ không cần thiết.
Tôi đã chạy docker image history trên con quái vật đó. Nó cho tôi thấy từng layer và kích thước của nó. Riêng bước npm install đã là 450MB. Image cơ sở Node đầy đủ là thêm 350MB nữa. Và dòng COPY . . của tôi? Nó sao chép mọi thứ, bao gồm cả node_modules mà tôi đã cài đặt cục bộ và một thư mục đầy các tài sản video 4K mà tôi dùng để kiểm thử.
Cách tôi chẩn đoán sự phình to
Trước khi bạn có thể sửa chữa bất cứ điều gì, bạn cần một cái nhìn rõ ràng về mức độ thiệt hại.
Tôi đã sử dụng ba công cụ đã thay đổi mọi thứ:
Bộ công cụ phát hiện sự phình to của tôi
Chạy docker image history <image> để xem kích thước từng layer. Sau đó docker system df để xem tổng dung lượng đĩa. Và nếu bạn muốn biết câu chuyện thực sự, hãy cài đặt dive và chạy dive <image> để có một phân tích tương tác về nội dung của từng layer.
Dive là một công cụ mở mang tầm mắt. Nó cho tôi thấy một cái nhìn được mã hóa màu sắc của từng layer với các tệp được thêm hoặc sửa đổi. Tôi thực sự có thể thấy 200MB các test fixture và toàn bộ thư mục .git nằm bên trong image production của tôi.
Tôi đã dành cả một cuối tuần để thử nghiệm. Đến thứ Hai, tôi đã giảm kích thước image đi 95%. Đây là cách tôi đã làm.
Bước 1: Multi-Stage Builds (Cái lớn nhất)
Multi-stage builds là thay đổi có tác động lớn nhất mà tôi đã thực hiện.
Khái niệm này rất đơn giản. Bạn sử dụng nhiều câu lệnh FROM trong một Dockerfile. Các giai đoạn đầu tiên xử lý việc biên dịch và cài đặt dependency. Giai đoạn cuối cùng chỉ sao chép các artifact bạn cần.
Thông tin cốt lõi
Mỗi câu lệnh FROM tạo ra một giai đoạn mới. Chỉ giai đoạn cuối cùng mới trở thành image cuối cùng của bạn. Mọi thứ trong các giai đoạn trước đó đều bị loại bỏ. Các công cụ build, các tệp trung gian, các dependency được cache, tất cả đều biến mất.
Dockerfile ban đầu của tôi chỉ sử dụng một giai đoạn. Nó cài đặt TypeScript, tất cả các dependency dev, và mọi gói npm mà nhân loại biết đến. Sau đó nó chạy ứng dụng. Điều đó có nghĩa là sharp, eslint, prettier, và hàng tá công cụ dev khác đang sống trong container production của tôi.
Cách tiếp cận mới của tôi sử dụng hai giai đoạn. Giai đoạn builder cài đặt mọi thứ và biên dịch mã. Giai đoạn production chỉ sao chép đầu ra đã biên dịch và các dependency production.
Kết quả? Image của tôi giảm từ 1.2GB xuống còn 280MB. Một khởi đầu khá tốt, nhưng tôi vẫn chưa xong.
Bước 2: Alpine đã cứu tôi 200MB
Image node:18 chính thức của Node dựa trên Ubuntu. Nó thoải mái, nhưng nó nặng 350MB trước khi bạn thêm một dòng mã nào của mình.
Biến thể node:18-alpine? Nó chỉ 30MB.
Alpine Linux sử dụng musl libc thay vì glibc. Nó thay thế bash bằng ash. Nó đánh đổi sự thoải mái để lấy sự nhỏ gọn. Và trong hầu hết các trường hợp, ứng dụng của bạn sẽ không nhận thấy sự khác biệt.
Cẩn thận với các module gốc
Nếu ứng dụng của bạn sử dụng các module Node gốc (node-gyp, bcrypt, sharp), Alpine có thể gây ra đau đầu. Bạn có thể cần cài đặt các dependency build như python3 và g++ trong giai đoạn builder của mình. Tôi đã học được điều này một cách khó khăn khi các binding bcrypt của tôi bị lỗi khi chạy.
Chuyển sang Alpine đã giảm image cơ sở của tôi từ 350MB xuống còn 30MB. Kết hợp với multi-stage builds, tôi đã giảm tổng cộng xuống khoảng 120MB.
Bước 3: Tệp .dockerignore mà lẽ ra tôi phải viết
Đây là một lời thú nhận. Dockerfile đầu tiên của tôi đã sử dụng COPY . . và tôi chưa bao giờ nghĩ kỹ về nó.
Dòng duy nhất đó đã sao chép toàn bộ thư mục dự án của tôi vào mỗi bản build. Bao gồm node_modules, .git, các tệp .env, các test fixture và các bản thiết kế mà tôi đã tải xuống nhiều tháng trước.
Cách khắc phục thật đơn giản đến mức đáng xấu hổ.
1-1+node_modules2+.git3+.env4+.env.local5+*.md6+test/7+tests/8+coverage/9+.vscode/10+design-assets/11+*.log
Một tệp .dockerignore. Một tệp, có lẽ 10 dòng. Nó cho Docker biết những gì KHÔNG được gửi đến ngữ cảnh build.
Chỉ riêng điều này đã giúp tôi tiết kiệm khoảng 150MB. Nhưng quan trọng hơn, nó làm cho các bản build của tôi nhanh hơn. Docker không đóng gói và gửi hàng trăm megabyte tệp không liên quan đến daemon trong mỗi bản build.
Bước 4: Phân loại Dependency
npm install cài đặt devDependencies theo mặc định. npm ci --only=production cài đặt chính xác những gì có trong lockfile của bạn và bỏ qua các gói dev. Điều này đã giảm node_modules của tôi từ 300MB xuống còn 60MB.
Tôi đã chạy depcheck và tìm thấy 14 gói mà tôi thậm chí không sử dụng. Moment.js, lodash, hàng tá thư viện tiện ích. Gánh nặng không cần thiết. Tôi đã xóa tất cả chúng.
Tôi đã sắp xếp lại Dockerfile của mình để sao chép package.json trước mã nguồn. Docker cache từng layer. Khi các thay đổi mã nguồn không làm mất hiệu lực layer npm install, các bản build chỉ mất vài giây thay vì vài phút.
Tôi sẽ thành thật. Hệ sinh thái npm khuyến khích sự phình to. npm install kéo mọi thứ vào bao gồm cả những thứ không cần thiết. Nhưng npm ci --only=production là bạn của bạn. Nó sử dụng lockfile, cài đặt chính xác những gì được chỉ định và bỏ qua hoàn toàn các dependency dev.
Một điều tôi đã học được một cách khó khăn. Sắp xếp các lệnh COPY của bạn một cách chiến lược. Sao chép package.json và package-lock.json trước, chạy npm install, sau đó sao chép phần còn lại của mã nguồn của bạn. Bằng cách này, Docker cache layer npm và chỉ chạy lại nó khi các dependency của bạn thay đổi.
Bước 5: Chia nhỏ với Docker Slim
Đôi khi bạn cần một con dao mổ thay vì một cái búa tạ. Đó là lúc DockerSlim phát huy tác dụng.
Kết quả DockerSlim
DockerSlim phân tích container đang chạy của bạn và loại bỏ mọi thứ mà ứng dụng của bạn không thực sự chạm vào. Nó loại bỏ các binary, tệp, thư viện và quyền không sử dụng. Trên image API của tôi, nó đã loại bỏ thêm 40MB rác mà tôi đã bỏ sót trong quá trình dọn dẹp thủ công.
Tôi đã chạy docker-slim build --http-probe my-image và xem nó thu nhỏ từ 120MB xuống còn 78MB. Nó thực hiện phân tích tĩnh trên binary, phân tích động trên tiến trình đang chạy và loại bỏ các tệp mà runtime Node của tôi chưa bao giờ mở.
Kết quả cuối cùng
Hãy để tôi cho bạn xem các con số.
| Chỉ số | Trước | Sau |
|---|---|---|
| Kích thước Image | 1.2 GB | 48 MB |
| Thời gian Build | 4 phút 30 giây | 45 giây |
| Thời gian Pull (10 Mbps) | ~16 phút | ~40 giây |
| Layers | 14 | 5 |
| Lỗ hổng bảo mật | 127 | 23 |
Một image 1.2GB đã trở thành 48MB. Thời gian build giảm từ bốn phút rưỡi xuống dưới một phút. Thời gian pull giảm từ một buổi nghỉ cà phê xuống còn một lần vươn vai nhanh chóng.
Việc giảm lỗ hổng bảo mật đã làm tôi ngạc nhiên. Ít gói hơn có nghĩa là ít mục tiêu CVE hơn. Quét bảo mật của tôi giảm từ 127 phát hiện xuống còn 23. Không phải là ngẫu nhiên.
Kết quả của bạn có thể khác
Những con số này phụ thuộc vào stack của bạn. Một binary Go sẽ thu nhỏ khác với một ứng dụng Python. Một ứng dụng Java với Spring Boot hoàn toàn là một con quái vật khác. Nhưng các nguyên tắc là phổ quát.
Điều tôi muốn nói với bản thân trong quá khứ
Nếu tôi có thể quay ngược thời gian sáu tháng và viết cho mình một ghi chú, nó sẽ nói:
Bắt đầu với image cơ sở. Các image mặc định thoải mái nhưng nặng nề. Alpine hoặc distroless sẽ giúp bạn tiết kiệm hàng trăm megabyte trước khi bạn viết một dòng mã nào.
Sử dụng multi-stage builds ngay từ ngày đầu tiên. Chỉ mất năm phút để thiết lập và giúp bạn tiết kiệm rất nhiều rắc rối sau này. Giai đoạn builder của bạn cài đặt mọi thứ. Giai đoạn production của bạn chỉ lấy những gì nó cần.
Bỏ qua .dockerignore là tự chuốc lấy rủi ro. Tệp đó là tối ưu hóa rẻ nhất mà bạn từng thực hiện. Hãy viết nó trước docker build đầu tiên của bạn.
Đo lường mọi thứ. Chạy dive trước và sau mỗi thay đổi. Nếu bạn không thể đo lường nó, bạn đang đoán mò.
Bài học thực sự
Các image nhỏ hơn triển khai nhanh hơn. Chúng an toàn hơn. Chúng rẻ hơn để lưu trữ trong registry của bạn. Chúng sử dụng ít băng thông hơn. Chúng làm cho pipeline CI của bạn vui vẻ.
Nhưng chiến thắng thực sự là gì? Tôi đã ngừng sợ hãi các lần triển khai. Image của tôi đi từ commit đến production trong vòng chưa đầy hai phút. Khi có gì đó hỏng, tôi khắc phục trong vài giây, không phải vài phút.
Image 1GB đó không chỉ lãng phí dung lượng đĩa. Nó đang lãng phí thời gian của tôi.
Đừng đợi cho đến khi CI của bạn bị timeout. Hãy cắt giảm ngay bây giờ. Bản thân bạn trong tương lai sẽ cảm ơn bạn.
Bạn cũng có thể thích
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Docker Compose cho Phát triển Nội bộ: Hướng dẫn Thiết lập Hoàn chỉnh
Hướng dẫn toàn diện về cấu hình Docker Compose cho phát triển nội bộ: kiến trúc đa container, Dockerfile đa giai đoạn, sắp xếp khởi động dựa trên healthcheck, quản lý bí mật môi trường, BuildKit cache mounts và mạng phản chiếu môi trường production.
Read more
Tối ưu hóa Docker BuildKit với Cache Mount & Multi-Stage (Hướng dẫn 2026)
Tăng tốc build Docker bằng cách sử dụng cache mount của BuildKit, target multi-stage, và backend cache từ xa qua S3/registry cho các container Go, Node.js, và Rust.
Read more
Kho dữ liệu phân tích Serverless với BigQuery & Cloud Run: Từ luồng GA4 đến cảnh báo SEO tự động
Tìm hiểu cách xây dựng kho dữ liệu phân tích serverless tự động với BigQuery, Google Analytics 4 và Cloud Run: mô hình hóa lược đồ, chuyển đổi SQL theo lịch trình, chi phí không tải và cảnh báo truy vấn SEO tự động.
Read more