Hình dung một chồng tấm phim trong suốt kiểu vẽ hoạt hình cel: mỗi tấm chỉ thêm nét lên trên, và muốn "xoá" một nét ở tấm dưới, người ta dán một mảng trắng lên tấm trên — mực gốc vẫn nằm nguyên bên dưới, vẫn cân nặng. Nhiều bức tranh xài chung một tấm nền thì mười khung hình chẳng tốn gấp mười một tấm. Image Docker đúng là chồng phim đó: một image không phải một tệp mà là một chồng layer, mỗi layer là một tập thay đổi so với layer dưới. Ý tưởng nghe đơn giản, nhưng nó kéo theo ba hệ quả mà docker images không cho bạn thấy — và một trong ba khiến bạn có thể nhầm dung lượng tới mười sáu lần.

Image thật có bao nhiêu layer

alpine:3.20                     1 layer |    3,9 MB
debian:12-slim                  1 layer |   26,8 MB
eclipse-temurin:21-jre-alpine   5 layer |   70,0 MB
postgres:16                    14 layer |  151,5 MB

Số layer không tỉ lệ với dung lượng: debian:12-slim chỉ một layer mà nặng gấp bảy alpine.

Lệnh nào sinh ra layer

Hai Dockerfile làm đúng một việc, viết hai cách:

# a: ba lenh RUN rieng
FROM alpine:3.20
RUN echo mot > /a
RUN echo hai > /b
RUN echo ba  > /c
ENV X=1
WORKDIR /app
LABEL vi.du=1
# b: gop lam mot
FROM alpine:3.20
RUN echo mot > /a && echo hai > /b && echo ba > /c
ENV X=1
WORKDIR /app
LABEL vi.du=1
Layer trong RootFS Dòng trong history Dung lượng
a — ba RUN 5 8 3,91 MB
b — gộp RUN 3 6 3,91 MB

Ba điều đọc được từ bảng này:

Không phải lệnh nào cũng sinh layer. history có 8 dòng nhưng RootFS chỉ có 5 layer. Nhìn kỹ hơn:

0B       LABEL vi.du=1
8.19kB   WORKDIR /app
0B       ENV X=1
8.19kB   RUN /bin/sh -c echo ba  > /c
8.19kB   RUN /bin/sh -c echo hai > /b
8.19kB   RUN /bin/sh -c echo mot > /a
0B       CMD ["/bin/sh"]
9.49MB   ADD alpine-minirootfs...

ENV, LABEL, CMD là 0 B — chúng chỉ sửa metadata của image, không tạo layer filesystem. WORKDIR thì có, vì nó tạo thư mục.

Gộp RUN giảm số layer nhưng ở đây không giảm dung lượng. Cả hai đều 3,91 MB. Lời khuyên "gộp RUN để image nhỏ hơn" chỉ đúng khi bạn xoá thứ gì đó trong cùng lệnh — ví dụ apt-get install && rm -rf /var/lib/apt/lists/*. Xoá ở một RUN sau thì tệp vẫn nằm trong layer trước và vẫn tính dung lượng; layer chỉ ghi thêm dấu "đã xoá". Đây là điểm đáng nhớ nhất về mô hình layer: layer chỉ cộng thêm, không bao giờ trừ đi.

8,19 kB cho một tệp ba chữ cái. Đó là kích thước khối nhỏ nhất của filesystem. Mỗi layer đều có phần dôi cố định, nên hàng trăm layer tí hon vẫn tốn thật.

Ba lệnh, ba con số

Hỏi ba lệnh khác nhau xem alpine:3.20 to bao nhiêu:

docker image inspect .Size : 3,91 MB
docker history (dong nen)  : 9,49 MB
docker images bao          : 13,7 MB

Ba con số cho cùng một image. Câu trả lời nằm ở docker image ls --tree:

IMAGE               ID             DISK USAGE   CONTENT SIZE
alpine:3.20         d9e853e87e55       13.7MB         4.17MB
├─ linux/amd64      c64c687cbea9           0B             0B
├─ linux/arm64/v8   45e09956dc66       13.6MB         4.09MB
├─ linux/arm/v6     37753b296504           0B             0B
...
  • CONTENT SIZE (4,17 MB) — dữ liệu đã nén, tức lượng byte bạn tải qua mạng.
  • DISK USAGE (13,7 MB) — chỗ nó chiếm trên đĩa sau khi bung ra, cộng cả bản nén còn giữ lại.
  • inspect .Size (3,91 MB) — tổng kích thước layer theo cấu hình image, cho một nền tảng.
  • history 9,49 MB — kích thước tệp tar gốc lúc image nền được dựng.

Bảng cây cũng cho thấy alpine:3.20 thật ra là bảy nền tảng trong một tên; máy tôi chỉ tải về linux/arm64/v8, sáu cái kia là 0 B.

Cái bẫy nén: image 4,0 MB chiếm 66,1 MB

Đây là chỗ ba con số kia không còn là chuyện học thuật. Hai image, mỗi cái ghi 50 MB dữ liệu, chỉ khác nội dung:

RUN dd if=/dev/zero    of=/f bs=1M count=50    # 50 MB so 0
RUN dd if=/dev/urandom of=/f bs=1M count=50    # 50 MB ngau nhien
inspect .Size CONTENT SIZE DISK USAGE
50 MB số 0 4,0 MB 4,15 MB 66,1 MB
50 MB ngẫu nhiên 53,9 MB 56,5 MB 118 MB

Image chứa 50 megabyte dữ liệu báo cáo là 4,0 MB và chiếm 66,1 MB đĩa. Lệch mười sáu lần.

Lý do là số 0 nén lại gần như bằng không, còn inspect .Size cùng docker images báo theo kích thước đã nén. Dữ liệu thật của bạn không phải toàn số 0, nhưng log, JSON, mã nguồn, tệp locale đều nén rất tốt — nên khoảng cách giữa "image của tôi 200 MB" và chỗ nó thật sự chiếm trên đĩa luôn lớn hơn bạn nghĩ.

Muốn biết image chiếm bao nhiêu đĩa thì dùng docker image ls --tree và đọc cột DISK USAGE, đừng đọc cột SIZE quen thuộc.

Mười image chung một nền tốn bao nhiêu

Layer được chia sẻ giữa các image, nên tổng dung lượng không phải phép cộng. Tôi dựng 10 image, mỗi cái thêm một tệp 5 MB ngẫu nhiên lên cùng alpine:3.20, rồi lấy hiệu docker system df trước và sau:

tong docker images bao cao : 241,0 MB
dia that su tang           : 163,8 MB
=> chia se tiet kiem       :  77,2 MB (32%)

Cộng dồn cột SIZE cho ra con số cao hơn thực tế 47%, vì layer alpine chung được đếm mười lần.

Và còn một khoản nữa mà docker images không hề nhắc tới:

Images       +163,8 MB
Build Cache  +109,1 MB

Riêng cache build đã bằng hai phần ba dung lượng image. Trên máy tôi lúc đo, docker system df báo tổng 66 GB image với 37,31 GB thu hồi được — con số đó không xuất hiện ở bất cứ đâu trong docker images.

Nên có hai cách docker images làm bạn hiểu sai, và chúng ngược chiều nhau: nó cộng thừa layer dùng chung, và bỏ sót hoàn toàn cache build.

Ba luật rút ra

  • Xoá tệp ở layer sau không làm image nhỏ đi. Phải xoá trong cùng RUN đã tạo ra nó, hoặc dùng multi-stage — chủ đề của phần 9.
  • docker images không phải nguồn tin về dung lượng đĩa. Dùng docker image ls --tree cho từng image và docker system df cho toàn máy.
  • Sắp xếp Dockerfile theo tần suất thay đổi, vì layer nào đổi thì mọi layer sau nó mất cache. Đó là chủ đề của phần sau.

Muốn biết Docker thật sự chiếm bao nhiêu đĩa trên máy mình, xem system df và cây dung lượng:

docker system df
docker image ls --tree | head -20

Cột RECLAIMABLE lớn hơn vài GB — của tôi là 37 GB — nghĩa là phần lớn dung lượng đó là layer mồ côi và cache build, thứ docker images chưa bao giờ cho bạn thấy.

Mẫu số chung

Bài học đầu tiên, đọc từ "debian một layer nặng gấp bảy alpine" và "ENV/LABEL = 0 B": số đếm không phải khối lượng — số layer, số dòng code, số tệp thì đếm được dễ, nên người ta hay tối ưu nó, trong khi mass thật nằm ở chỗ khác. Gộp RUN giảm layer nhưng không giảm một byte; cái quyết định dung lượng là có xoá thứ gì trong cùng lệnh không, không phải số lệnh. Cùng cái bẫy "tối ưu cái dễ đếm thay vì cái có trọng lượng" ở khắp nơi: số dòng code không phải độ phức tạp, số tệp không phải thời gian build, số truy vấn không phải tải CSDL (một full scan đè bẹp trăm câu có index). Nguyên tắc: đừng tối ưu một đại lượng thay thế chỉ vì nó hiện lên rõ — đo cái có khối lượng thật rồi nhắm vào đó, vì cái đếm được và cái tốn kém hiếm khi là một.

Điều thứ hai, đọc từ "4,0 MB mà chiếm 66,1 MB": một con số kích thước chỉ có nghĩa kèm đơn vị và phạm vi của nó — nén hay chưa nén, một nền tảng hay tất cả, riêng image hay cộng cả cache — và một con số trông hiền có thể sai một bậc. docker images báo kích thước đã nén cho một cách hiểu, nên vừa lệch 16 lần vì nén, vừa cộng thừa layer chung, vừa bỏ sót build cache — hai sai lệch ngược chiều trong cùng một lệnh. Cùng chuyện "con số thiếu định nghĩa" ở khắp nơi: du khác df, kích thước logic khác vật lý, thời gian CPU khác thời gian tường, RSS khác PSS. Nguyên tắc: trước khi tin một con số, hỏi nó đo cái gì, theo đơn vị nào, trong phạm vi nào — và với "chiếm bao nhiêu đĩa" thì đi tới nền đất (docker image ls --tree, system df), đừng đọc cột SIZE quen thuộc.

Phần sau đo vòng đời container và những gì docker run thật sự làm trong 260 mili giây đó.