Nghĩ về một dự án xây nhà. Thời gian hoàn thành không do tổng số việc quyết định, mà do chuỗi việc phải nối đuôi nhau dài nhất: sơn tường phải chờ tường xây, tường xây phải chờ móng — cả ba nối đuôi thì mất ba lượt. Nhưng đi ống nước, đi dây điện và làm vườn thì chẳng phụ thuộc gì nhau, làm cùng lúc là xong trong một lượt. Cùng ba việc, hai cách xếp, hai thời gian khác hẳn. Tầng trong Dockerfile đúng như vậy. Phần 17 đã đo 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.

Muốn biết Dockerfile của mình có đồ thị hình gì, liệt kê các cạnh phụ thuộc:

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. Nếu mọi FROM đều trỏ tới tầng trước đó, Dockerfile của bạn là một chuỗi — BuildKit không giúp được gì về mặt song song, dù vẫn nhanh hơn builder cũ ở những mặt khác.

Mẫu số chung

Bài học đầu tiên, đọc thẳng từ 10,2 so với 3,7 giây trên cùng ba lệnh: thời gian hoàn thành do đường tới hạn — chuỗi phụ thuộc dài nhất — quyết định, không phải tổng khối lượng việc; hình dạng đồ thị, không phải số bước, định đoạt bao nhiêu thứ chạy được song song. Muốn nhanh hơn thì rút ngắn đường tới hạn hoặc cắt một phụ thuộc giả, chứ tối ưu một việc nằm ngoài đường tới hạn không đổi được gì. Cùng nguyên lý ở khắp nơi: trình lập lịch tác vụ, pipeline CI, sơ đồ Gantt/PERT, đồ thị task bất đồng bộ, tính lại một bảng tính. Nguyên tắc: tìm đường tới hạn trước — song song hoá cái ngoài nó là công cốc, phá một phụ thuộc trên nó mới rút được thời gian; và một phụ thuộc không thật sự cần (một FROM tầng-trước đặt cho tiện) chính là chỗ âm thầm biến việc song song được thành việc nối tiếp.

Điều thứ hai, đọc từ chuyện BuildKit chạy ba lệnh giống hệt nhau đúng một lần: khi một hệ thống định địa chỉ theo nội dung, việc giống hệt nhau tự động chỉ tốn một lần — nhưng "giống hệt" là từng ký tự, nên một khác biệt tình cờ lặng lẽ phá vỡ nó. Ba bước cùng nền cùng lệnh cho cùng khoá băm, tính một lần dùng cho cả ba; đổi đúng một khoảng trắng hay một ARG là thành ba đơn vị riêng. Cùng cơ chế "nội dung trùng thì miễn phí" ở khắp nơi: lưu trữ content-addressed, memo hoá theo đầu vào, cache CDN theo URL+hash, blob của Git, loại bỏ biểu thức con chung, key/memo của React. Nguyên tắc: muốn hưởng khử-trùng-lặp, phải giữ đầu vào thật sự đồng nhất — và cảnh giác rằng một dị biệt vô tình (dấu cách thừa, thứ tự khác, một tham số lạc) đủ để một hệ content-addressed coi hai thứ vốn như nhau là hai thứ khác, và làm lại từ đầu.

Phần sau đo thời gian từng bước build để tìm ra chỗ thật sự tốn.