Hình dung hai đầu bếp cùng nhận một công thức. Người cũ làm đúng từ trên xuống: đọc bước một, làm xong; bước hai, làm xong — kể cả một món trang trí mà đĩa cuối chẳng bao giờ dùng tới, và trước khi đọc gì thì đã hì hục bê nguyên cái kho thực phẩm ra bàn "cho chắc". Người mới đọc cả công thức trước rồi mới động tay: thấy nồi nước sốt và miếng thịt quay không liên quan gì nhau thì bắc cả hai lên bếp cùng lúc, thấy món trang trí không nằm trong đĩa cuối thì bỏ hẳn, và chỉ lấy đúng nguyên liệu mình cần. Docker có đúng hai đầu bếp đó. Bộ cũ tồn tại từ 2013; BuildKit là mặc định từ Docker 23 và làm việc theo cách khác hẳn. Builder cũ vẫn còn dùng được, và điều đó cho phép đo A/B thật:
DOCKER_BUILDKIT=0 docker build ... # builder cu
docker build ... # BuildKit
Docker 29.3.1 nhắc ngay khi bạn dùng bộ cũ:
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
Nhưng trước khi nó biến mất, bốn phép đo dưới đây giải thích vì sao BuildKit thay nó.
Build context: 30,05 MB so với 2 byte
Một dự án có thư mục rác 30 MB, và một Dockerfile chỉ cần đúng một tệp nhỏ:
FROM alpine:3.20
COPY nho.txt /
RUN echo x > /x
builder CU: Sending build context to Docker daemon 30.05MB
BuildKit : transferring context: 2B
Builder cũ đóng gói toàn bộ thư mục thành tar rồi gửi cho daemon trước khi đọc Dockerfile — nó không biết mình sẽ cần gì. BuildKit đọc Dockerfile trước, dựng đồ thị phụ thuộc, rồi chỉ lấy đúng thứ cần: hai byte, đúng bằng nội dung nho.txt.
Đây chính là lý do phần 7 kết luận rằng lời khuyên "thêm .dockerignore để khỏi gửi hàng trăm megabyte" được viết cho thời builder cũ.
Ba tầng độc lập: 11,0 giây so với 3,6 giây
FROM alpine:3.20 AS a
RUN sleep 3 && echo a > /a
FROM alpine:3.20 AS b
RUN sleep 3 && echo b > /b
FROM alpine:3.20 AS c
RUN sleep 3 && echo c > /c
FROM alpine:3.20
COPY --from=a /a /
COPY --from=b /b /
COPY --from=c /c /
| Thời gian | |
|---|---|
| Builder cũ | 11,0 s |
| BuildKit | 3,6 s |
Ba tầng không phụ thuộc nhau. Builder cũ chạy tuần tự — 3 + 3 + 3 giây. BuildKit thấy chúng độc lập và chạy song song, nên tổng bằng tầng chậm nhất cộng chút phần dôi.
Với một Dockerfile thật — tầng cài phụ thuộc, tầng biên dịch tài sản tĩnh, tầng chạy kiểm thử — đây thường là khác biệt lớn nhất trong cả bài.
Tầng thừa: 6,2 giây so với 0,5 giây
FROM alpine:3.20 AS dung
RUN echo dung > /d
FROM alpine:3.20 AS khong-dung
RUN sleep 5 && echo khong > /k # khong ai COPY --from tang nay
FROM alpine:3.20
COPY --from=dung /d /
| Thời gian | |
|---|---|
| Builder cũ | 6,2 s |
| BuildKit | 0,5 s |
Builder cũ chạy mọi tầng theo thứ tự xuất hiện, kể cả tầng chẳng ai dùng tới. BuildKit đi ngược từ tầng cuối lên và bỏ qua hoàn toàn nhánh không dẫn tới nó.
Phần 9 đã nêu hệ quả thực tế của hành vi này: thêm một tầng chạy npm test vào Dockerfile không làm test chạy, vì không ai tham chiếu tới nó.
Bí mật lúc build: builder cũ không có
RUN --mount=type=secret,id=mk cat /run/secrets/mk > /dev/null && echo "da doc bi mat" > /kq
BuildKit : dung duoc | bi mat trong docker history: tim thay 0
builder CU: Usage: docker build [OPTIONS] PATH | URL | -
Builder cũ không nhận cờ --secret — nó in ra hướng dẫn sử dụng rồi thoát. Với bộ cũ, cách duy nhất để đưa bí mật vào lúc build là --build-arg, mà phần 13 đã đo được rằng giá trị đó nằm nguyên văn trong docker history.
BuildKit gắn bí mật vào một tmpfs chỉ tồn tại trong đúng lệnh RUN đó, và nó không để lại dấu vết nào trong image. Tôi tìm chuỗi bí mật trong toàn bộ lịch sử image: 0 kết quả. Đây là chủ đề đầy đủ của phần 21.
Build đơn giản thì chênh không nhiều
Để công bằng, với một Dockerfile hai dòng thì khác biệt nhỏ:
lan 1: builder CU 0,80 s | BuildKit 0,68 s
lan 2: builder CU 0,72 s | BuildKit 0,54 s
lan 3: builder CU 0,72 s | BuildKit 0,50 s
Nhanh hơn khoảng 30%, không phải mấy lần. Lợi thế của BuildKit chỉ bung ra khi Dockerfile có cấu trúc để nó khai thác: nhiều tầng, context lớn, hoặc những thứ chỉ nó mới làm được.
BuildKit còn có gì mà bộ cũ không có
Ngoài hai thứ đã đo ở trên:
--mount=type=cache |
giữ cache của npm, Maven, apt giữa các lần build — phần 18 |
--mount=type=bind |
đọc tệp từ context mà không chép vào layer |
--platform và buildx |
dựng ảnh cho nhiều kiến trúc — phần 20 |
| Cache đẩy lên registry | chia cache giữa các máy CI — phần 19 |
| SBOM và provenance | ghi nguồn gốc chi tiết — phần 22 |
--progress=plain |
log đầy đủ, đọc được trong CI |
Và một khác biệt về vận hành đáng nhớ: cache của BuildKit nằm ở chỗ khác với image. Phần 2 đo được rằng dựng 10 image tạo thêm 163,8 MB image cộng 109,1 MB cache build — và docker images không hề nhắc tới khoản thứ hai. Dọn nó bằng docker builder prune, không phải docker image prune.
Nếu bạn còn đang dùng bộ cũ
Ba trường hợp còn gặp trong thực tế:
DOCKER_BUILDKIT=0nằm trong script CI cũ, thường được thêm vào từ lâu để chữa một lỗi đã hết. Bỏ dòng đó ra là được ngay ba phép đo ở trên.- Một số bản Docker rất cũ trên máy chủ lâu năm. Ở đó thì nâng cấp là việc đáng làm.
- Cần định dạng log của bộ cũ.
--progress=plaincho BuildKit ra log dạng văn bản đọc được trong CI, gần với thứ bạn quen.
Muốn biết ngay mình đang dùng bộ nào, nhìn dòng đầu của bản log:
docker build --progress=plain -t thu . 2>&1 | head -3
Thấy Sending build context to Docker daemon là bộ cũ; thấy [internal] load build context là BuildKit. Và grep -c '^FROM' Dockerfile đếm số tầng — nhiều hơn hai nghĩa là BuildKit đang (hoặc sẽ) chạy song song cho bạn, miễn là các tầng đó thật sự không phụ thuộc nhau.
Mẫu số chung
Bài học lớn nhất, đọc thẳng từ 11 giây so với 3,6: một bộ máy nhìn thấy cả đồ thị trước khi làm luôn thắng một bộ máy thực thi từng dòng theo thứ tự. Builder cũ nhận công thức và làm tuần tự, nên nó chạy cả tầng chẳng ai dùng và không bao giờ nhận ra hai tầng có thể chạy song song. BuildKit đọc toàn bộ, dựng đồ thị phụ thuộc, rồi song song hoá cái độc lập và bỏ qua cái không dẫn tới đích. Đây chính là bước nhảy từ mệnh lệnh ("làm đúng những bước tôi ghi, theo đúng thứ tự") sang khai báo ("đây là kết quả tôi cần, tự tìm cách tối ưu mà tới"). Cùng bước nhảy ấy ở khắp nơi: make -j và Bazel song song hoá theo đồ thị phụ thuộc, trình tối ưu SQL chọn kế hoạch từ câu truy vấn mô tả thay vì bạn viết vòng lặp, tính lười và loại bỏ mã chết chỉ chạm thứ thật sự cần. Nguyên tắc: hễ bạn mô tả được sự phụ thuộc thay vì ra lệnh thứ tự, cái máy bên dưới mới có chỗ để thông minh — song song, bỏ qua, sắp xếp lại; còn khi bạn ép một trình tự cứng, bạn đang tự tay bịt hết những đường tắt đó.
Điều thứ hai, một cái bẫy vận hành: cái gì đó có thể tốn tài nguyên thật mà không hiện ở chỗ bạn quen nhìn. BuildKit giữ cache build ở một sổ kế toán khác với image — dựng 10 image sinh 163,8 MB image cộng 109,1 MB cache mà docker images không hề nhắc, và phải docker builder prune mới dọn được chứ docker image prune không chạm tới. Cùng cái bẫy "chi phí sống ở nơi khác" ở du báo khác df vì tệp đã xoá mà tiến trình còn giữ, ở page cache của kernel, ở thư mục .git phình vì blob lịch sử, ở log và tmpfs ăn RAM âm thầm. Nguyên tắc: với mỗi công cụ, biết nó cất trạng thái ở đâu và lệnh nào thật sự với tới đó — vì tài nguyên bạn không thấy vẫn cứ đầy lên, và nó thường đầy đúng vào lúc bạn đang bối rối tìm xem đĩa đi đâu mất.
Phần sau đo --mount=type=cache: giữ cache của npm, Maven và apt qua các lần build tiết kiệm được bao nhiêu.