Hình dung xếp vali cho một chuyến đi. Ai cũng mải cuộn quần áo thật chặt, nén hết không khí ra (gộp lệnh RUN, --no-cache-dir — mấy thứ vi chỉnh), trong khi câu hỏi thật sự là: bạn có đang vác theo cả một tủ đồ không bao giờ mặc tới không (image nền: trình biên dịch, tài liệu, locale mà python:3.12 mang theo). Chọn vali xách tay thay vì cái rương to (nền slim thay vì nền đầy đủ) ăn đứt mọi kiểu gấp gọn. Và "bỏ bớt một món" chỉ làm vali nhẹ đi nếu bạn lấy nó ra trước khi kéo khoá — nhét vào rồi "quyết định không mặc" thì vali vẫn nặng y nguyên. Danh sách "mẹo giảm dung lượng image" nào cũng liệt kê bảy tám thứ, thường theo thứ tự ngẫu nhiên và không kèm con số. Tôi dựng một image Python cố tình làm sai mọi thứ, rồi sửa từng lỗi một cách độc lập để biết mỗi thủ thuật đáng giá bao nhiêu.

FROM python:3.12
WORKDIR /app
COPY . /app
RUN apt-get update
RUN apt-get install -y curl vim git
RUN pip install -r requirements.txt
CMD ["python","app.py"]

419,4 MB.

Bảng xếp hạng

Mỗi dòng là bản gốc chỉ sửa đúng một thứ:

# Thủ thuật Còn lại Tiết kiệm
1 Multi-stage + nền alpine 20,2 MB 399,2 MB
2 Multi-stage (chỉ chép gói đã cài) 44,0 MB 375,4 MB
3 Đổi nền sang python:3.12-slim 116,5 MB 302,9 MB
4 --no-install-recommends + xoá apt lists 403,5 MB 15,9 MB
5 Bỏ vim và git, chỉ giữ curl 404,7 MB 14,7 MB
6 pip --no-cache-dir 417,6 MB 1,8 MB
7 Gộp ba lệnh RUN làm một 419,4 MB -0,0 MB

Khoảng cách giữa hạng 3 và hạng 4 là hai mươi lần. Nghĩa là mọi công sức tối ưu dòng RUN cộng lại vẫn không bằng một phần mười của việc đổi một chữ trong dòng FROM.

Thủ thuật được khuyên nhiều nhất không tiết kiệm gì

Hàng cuối bảng đáng dừng lại. "Gộp các lệnh RUN để giảm số layer" là lời khuyên xuất hiện trong gần như mọi hướng dẫn Docker. Đo được: 0,0 MB.

Nó có làm giảm số layer:

ban goc (3 lenh RUN): 12 layer
gop lam 1 lenh RUN  : 10 layer

Nhưng ít layer hơn không đồng nghĩa nhỏ hơn. Ba gói curl, vim, git vẫn được cài, vẫn nằm trong image, chỉ khác là nằm trong một layer thay vì ba.

Gộp RUN chỉ có tác dụng khi bạn XOÁ thứ gì đó trong cùng lệnh đó. Phép thử:

# tach: cai o RUN 1, xoa o RUN 2
RUN apt-get update && apt-get install -y --no-install-recommends build-essential
RUN rm -rf /var/lib/apt/lists/* && apt-get purge -y build-essential && apt-get autoremove -y
# gop: cai va xoa trong CUNG mot RUN
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
 && apt-get purge -y build-essential && apt-get autoremove -y \
 && rm -rf /var/lib/apt/lists/*
Dung lượng
Cài ở RUN 1, xoá ở RUN 2 151,5 MB
Cài và xoá trong cùng RUN 41,5 MB

Chênh 109,9 MB — thủ thuật này nhảy từ hạng bét lên hạng tư ngay khi có phép xoá đi kèm.

Lý do là luật đã đo ở phần 2: layer chỉ cộng thêm, không bao giờ trừ đi. Xoá tệp ở layer sau chỉ ghi thêm một dấu "đã xoá"; dữ liệu vẫn nằm trong layer trước và vẫn tính dung lượng.

Nên phát biểu đúng của lời khuyên là: đừng gộp RUN để giảm layer, hãy gộp để phép xoá nằm cùng layer với phép tạo.

Ba hạng đầu đều là cùng một ý tưởng

Nhìn kỹ thì hạng 1, 2, 3 không phải ba thủ thuật khác nhau — chúng là ba mức của cùng một câu hỏi: bao nhiêu thứ trong image này thật sự cần lúc chạy?

  • Đổi nền sang slim bỏ đi công cụ biên dịch, tài liệu, locale mà python:3.12 mang theo: 302,9 MB.
  • Multi-stage bỏ đi cả môi trường cài đặt, chỉ giữ gói đã cài xong: 375,4 MB.
  • Cả hai cho 20,2 MB — nhỏ hơn bản gốc hai mươi lần, và vẫn chạy:
$ docker run --rm ct:7 python -c "import flask, requests; print(flask.__version__, requests.__version__)"
3.0.3 2.32.3

Cấu trúc của bản nhỏ nhất:

FROM python:3.12-slim AS xay
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target /goi -r requirements.txt

FROM python:3.12-alpine
WORKDIR /app
COPY --from=xay /goi /goi
COPY app.py .
ENV PYTHONPATH=/goi
CMD ["python","app.py"]

Với Python thì cách này có điều kiện: nó chạy được vì flask và requests là gói thuần Python. Gói có phần mở rộng C thì phải cẩn thận với chuyện musl so với glibc — phần 11 đã đo được grpcio gãy trên Alpine vì thiếu wheel, và thêm công cụ biên dịch đẩy image từ 17,7 lên 104,7 MB.

Thứ tự nên làm

Dựa trên bảng trên, đây là thứ tự tôi sẽ làm nếu cầm một image béo:

  1. Xem FROM đang là gì. Đổi python:3.12 → python:3.12-slim, node:22 → node:22-slim, openjdk → JRE thay vì JDK. Một dòng, ba trăm megabyte.
  2. Tách tầng build khỏi tầng chạy. Xem phần 9.
  3. Xem trong image có gì không cần. docker run --rm <anh> du -sh /* | sort -rh | head.
  4. Gộp cài và xoá vào cùng một RUN — nhưng chỉ khi có phép xoá.
  5. Kiểm tra .dockerignore. Phần 7 đo được 19,1 MB rác lọt vào image chỉ vì thiếu tệp này.
  6. Những thứ còn lại — --no-cache-dir, --no-install-recommends — vẫn nên làm, nhưng đừng bắt đầu từ đó.

Một cách đo đáng tin hơn docker images

Nhắc lại từ phần 2, vì nó ảnh hưởng trực tiếp tới việc đánh giá công sức tối ưu: docker images báo kích thước đã nén và cộng thừa layer dùng chung. Muốn biết image chiếm bao nhiêu đĩa thật:

docker image ls --tree

Cột DISK USAGE là con số thật. Trong phép đo ở phần 2, một image chứa 50 MB dữ liệu báo cáo là 4,0 MB và chiếm 66,1 MB.

Muốn biết ngay image của mình chứa gì và layer nào to nhất, soi hai chỗ:

docker run --rm <anh-cua-ban> sh -c 'du -sh /* 2>/dev/null | sort -rh | head -8'
docker history <anh-cua-ban> --format '{{.Size}}\t{{.CreatedBy}}' | sort -rh | head -5

Dòng đầu tiên của lệnh thứ hai là chỗ đáng sửa trước. Trong đa số trường hợp tôi từng gặp, nó là dòng FROM.

Mẫu số chung

Bài học đầu tiên, đọc thẳng từ khoảng cách hai mươi lần giữa hạng 3 và hạng 4: hãy xếp thứ tự việc theo tác động đo được, không theo mức độ được nhắc tới — thứ được khuyên nhiều nhất (gộp RUN) tiết kiệm 0, thứ ít ai nhấn mạnh (đổi FROM) tiết kiệm 300 MB. Một danh sách mẹo không kèm con số và không xếp hạng khiến người ta dành công sức đều tay cho mọi mục, trong khi gần như toàn bộ kết quả nằm ở một lựa chọn cấu trúc duy nhất. Cùng nguyên lý "tấn công cái số hạng trội" ở khắp nơi: hồ sơ hoá trước khi tối ưu để tìm 3% nóng thay vì đánh bóng phần lạnh, một truy vấn full scan lấn át mọi vi chỉnh vòng lặp, N+1 query nuốt hết công sức cache. Nguyên tắc: đo để tìm cái số hạng lớn nhất rồi dồn sức vào đó — một checklist làm cho có mà không quy ra megabyte (hay mili giây) thật thường dẫn bạn mài giũa phần chẳng bao giờ đáng kể.

Điều thứ hai, chính là chỗ "ba hạng đầu là cùng một ý tưởng": phân biệt cái cần để dựng với cái cần để chạy, rồi chỉ đóng gói cái sau. Cả ba đòn mạnh nhất — đổi nền slim, multi-stage, cả hai — đều trả lời đúng một câu: bao nhiêu thứ trong image này thật sự cần lúc chạy? Trình biên dịch, header, môi trường cài đặt đều là công cụ lúc dựng, không việc gì phải theo image ra sản xuất. Cùng ý ấy ở khắp nơi: tree-shaking bỏ mã không ai gọi, loại bỏ mã chết, distroless chỉ giữ runtime, tách devDependencies khỏi dependencies. Và nó gắn với một sự thật khó chịu về layer — xoá đi sau khi đã thêm gần như không cứu được gì, vì layer chỉ cộng thêm; thứ nhẹ thật là thứ chưa bao giờ được đưa vào. Nguyên tắc: đừng dọn rác sau, hãy đừng mang rác vào — hỏi "cái này có cần để chạy không", và nếu chỉ cần để dựng thì để nó ở lại tầng build.

Phần sau chuyển sang BuildKit: nó khác builder cũ ở đâu, và đo trên cùng một Dockerfile.