Phần 17 đã đo được BuildKit chạy ba tầng độc lập song song: 11,0 giây xuống 3,6. Bài này hỏi câu tiếp theo — tách tầng thế nào thì có tác dụng, và tới đâu thì hết tác dụng.
Ba hình dạng đồ thị
Mỗi bước sleep 3, chỉ khác cách các tầng phụ thuộc nhau:
# CHUOI: moi tang dung tang truoc
FROM alpine:3.20 AS a
RUN sleep 3
FROM a AS b
RUN sleep 3
FROM b AS c
RUN sleep 3
# DOC LAP: ba tang khong lien quan
FROM alpine:3.20 AS a
RUN sleep 3
FROM alpine:3.20 AS b
RUN sleep 3
FROM alpine:3.20 AS c
RUN sleep 3
# KIM CUONG: mot goc, hai nhanh, roi gop
FROM alpine:3.20 AS goc
RUN sleep 3
FROM goc AS trai
RUN sleep 3
FROM goc AS phai
RUN sleep 3
| Hình dạng | Đo được | Lý thuyết |
|---|---|---|
| Chuỗi | 10,2 s | 9 s |
| Độc lập | 3,7 s | 3 s |
| Kim cương | 6,7 s | 6 s |
Cả ba khớp lý thuyết với phần dôi khoảng 0,7–1,2 giây. Điều đáng nhớ là hình dạng đồ thị quyết định thời gian, không phải số lệnh: chuỗi và độc lập có cùng ba lệnh sleep 3 nhưng chênh nhau gần ba lần.
Kim cương cho 6,7 giây chứ không phải 9 — vì goc chỉ chạy một lần rồi hai nhánh chạy song song trên kết quả đó.
Với việc tốn CPU thật
sleep không dùng CPU, nên nó là ca dễ nhất cho việc song song. Tôi đổi sang vòng lặp tính toán thật:
| Số tầng độc lập | Đo được | Nếu chạy nối tiếp |
|---|---|---|
| 1 | 3,6 s | 3,6 s |
| 2 | 3,8 s | 7,1 s |
| 4 | 3,8 s | 14,2 s |
| 8 | 4,3 s | 28,5 s |
Tám giai đoạn tốn CPU xong trong 4,3 giây thay vì 28,5. Trên máy 16 nhân thì tám việc song song vẫn còn thừa nhân, nên hầu như không có tranh chấp.
Tới đâu thì hết tác dụng
| Số tầng | Thời gian |
|---|---|
| 8 | 3,8 s |
| 16 | 5,2 s |
| 32 | 9,7 s |
Máy có 16 nhân. Tới 8 tầng thì gần như miễn phí; 16 tầng bắt đầu chậm lại; 32 tầng mất 9,7 giây — gấp 2,5 lần so với 8 tầng, dù số việc gấp bốn.
Nói cách khác, song song vẫn giúp ở 32 tầng (nối tiếp sẽ là hơn 100 giây) nhưng lợi ích giảm dần khi số tầng vượt số nhân. Đây là cùng hình dạng đường cong đã đo ở sê-ri Vert.x phần 48: tăng nhanh lúc đầu, đỉnh quanh số nhân, rồi tụt.
Thực tế thì rất ít Dockerfile có tới 8 tầng độc lập thật sự, nên giới hạn này hiếm khi chạm tới. Nó chỉ quan trọng nếu máy CI của bạn dựng nhiều image cùng lúc — khi đó tổng số tầng song song trên máy mới là con số cần nhìn.
BuildKit khử trùng lặp
Đây là hành vi tôi không lường trước. Ba tầng, mỗi tầng tự cài lại cùng một gói:
FROM python:3.12-slim AS a
RUN pip install --no-cache-dir requests==2.32.3
FROM python:3.12-slim AS b
RUN pip install --no-cache-dir requests==2.32.3
FROM python:3.12-slim AS c
RUN pip install --no-cache-dir requests==2.32.3
Tôi đoán pip sẽ chạy ba lần. Đo:
ba lenh RUN GIONG HET nhau : 3,7 s | pip chay 1 lan
Một lần. BuildKit băm nội dung từng bước; ba bước có cùng image nền và cùng lệnh cho ra cùng khoá, nên nó tính một lần rồi dùng lại cho cả ba tầng.
Ca đối chứng — làm ba lệnh khác nhau một chút:
RUN echo a > /nhan && pip install --no-cache-dir requests==2.32.3
RUN echo b > /nhan && pip install --no-cache-dir requests==2.32.3
RUN echo c > /nhan && pip install --no-cache-dir requests==2.32.3
ba lenh RUN KHAC nhau mot chut : 4,0 s | pip chay 3 lan
Ba lần, và chỉ tốn thêm 0,3 giây vì chúng chạy song song.
Hệ quả thực tế: lời khuyên "tách một tầng nền dùng chung để khỏi lặp việc" ít quan trọng hơn người ta nghĩ. BuildKit đã lo phần đó. Tầng nền dùng chung vẫn đáng có, nhưng vì lý do khác — Dockerfile dễ đọc hơn, và sửa một chỗ thay vì ba.
Điều kiện để khử trùng lặp hoạt động là các bước phải giống hệt nhau tới từng ký tự. Một dòng ARG khác giá trị, một COPY khác nguồn, hay chỉ một khoảng trắng thừa là đủ để chúng thành hai bước riêng.
Khi nào tách tầng thật sự đáng
Từ những phép đo trên, ba trường hợp:
Đáng — khi có việc thật sự độc lập. Biên dịch tài sản tĩnh của frontend và biên dịch backend không liên quan gì nhau; tách ra là được chạy song song, và bảng ở trên cho thấy gần như miễn phí.
Đáng — khi muốn tầng cuối gọn. Đây là lý do chính của multi-stage, đã đo ở phần 9: 85,9 MB xuống 1,9 MB.
Không đáng — khi tách chỉ để "cho gọn". Chuỗi phụ thuộc thì tách hay không cũng chạy nối tiếp, và mỗi tầng thêm một chút phần dôi. Bảng đầu bài cho thấy chuỗi ba tầng mất 10,2 giây cho 9 giây công việc.
Và một điều dễ quên: phần 9 đã đo rằng tầng không ai tham chiếu tới thì không chạy. Nên tách một tầng kiem-thu ra rồi tưởng nó chạy song song với tầng build là nhầm — nó không chạy chút nào.
Nhìn thấy đồ thị
--progress=plain in ra thứ tự thật sự, và các bước chạy song song hiện xen kẽ nhau:
docker build --progress=plain --no-cache -t thu . 2>&1 | grep -E '^#[0-9]+ \['
Nếu các dòng của những tầng khác nhau xuất hiện xen kẽ, chúng đang chạy song song. Nếu tầng thứ hai chỉ bắt đầu sau khi tầng thứ nhất kết thúc, chúng đang nối tiếp — và nếu bạn không cố ý muốn vậy, có một FROM <tang-truoc> hoặc một COPY --from= tạo ra phụ thuộc mà bạn quên.
Thử ba mươi giây
Xem Dockerfile của bạn có đồ thị hình gì:
grep -nE '^FROM|COPY --from=' Dockerfile
Mỗi FROM <ten-tang> và mỗi COPY --from=<ten-tang> là một cạnh phụ thuộc. Tầng nào chỉ có FROM <anh-nen-ben-ngoai> là tầng độc lập và sẽ chạy song song với các tầng khác.
Nếu mọi FROM của bạn đều trỏ tới tầng trước đó, Dockerfile của bạn là một chuỗi — và BuildKit không giúp được gì về mặt song song, dù nó vẫn nhanh hơn builder cũ ở những mặt khác.
Phần sau đo thời gian từng bước build để tìm ra chỗ thật sự tốn.