•12 min read

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

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

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ả.

Audio Briefing
0:00 / 0:00

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.

dockerfile
1-FROM node:18
1+FROM node:18-alpine AS builder
22 WORKDIR /app
3+COPY package*.json ./
4+RUN npm ci --only=production
35 COPY . .
4-RUN npm install
56 RUN npm run build
7+
8+FROM node:18-alpine
9+WORKDIR /app
10+COPY --from=builder /app/dist ./dist
11+COPY --from=builder /app/node_modules ./node_modules
612 EXPOSE 3000
7-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ử.

Advertisement

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.

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.

Advertisement

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ổ.

dockerfile
1-
1+node_modules
2+.git
3+.env
4+.env.local
5+*.md
6+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

Tiết kiệm 60MB

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.

Tiết kiệm 40MB

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.

Build nhanh hơn

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ướcSau
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

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement