Hình dung một xưởng mà mỗi ca lại là một thợ thời vụ mới toanh, tới với cái hòm đồ nghề rỗng. Nếu ca nào cũng phải đi sắm lại từng cái cờ-lê thì ngày nào cũng chậm như ngày đầu. Cách thoát là một cái tủ đồ chung khoá ở kho: người thợ đầu tiên chất đầy nó, mọi người tới sau — kể cả người chưa từng đặt chân tới xưởng — chỉ việc mở tủ lấy đồ có sẵn. Máy CI đúng là người thợ thời vụ đó. Mọi thứ ở phần 6 và phần 18 đều giả định cache nằm trên máy đang build — trên CI thì giả định đó thường sai: mỗi lần chạy là một container sạch, cache rỗng, và mọi tối ưu Dockerfile của bạn thành vô nghĩa. BuildKit giải quyết bằng cách đẩy cache ra ngoài — cái tủ đồ chung — rồi kéo về ở lần build sau, trên bất kỳ máy nào.
Tôi dựng một registry cục bộ và hai builder riêng biệt để mô phỏng hai máy CI.
64,0 giây xuống 6,2 giây
Máy A build và đẩy cache lên registry:
docker buildx build --builder may-a \
--cache-to type=registry,ref=localhost:5000/cache-app:main,mode=max \
--output type=cacheonly .
Máy B là một builder hoàn toàn mới, chưa từng thấy dự án này:
| Thời gian | Số bước CACHED | |
|---|---|---|
| Không nhập cache | 64,0 s | 0 |
Có --cache-from |
6,2 s | 5 |
Nhanh hơn 10,3 lần. Năm bước được dùng lại nguyên vẹn dù máy này chưa từng chạy một lệnh docker build nào cho dự án.
Đây là thứ biến "tối ưu Dockerfile" từ chuyện chỉ có tác dụng trên máy lập trình viên thành chuyện có tác dụng trên CI.
mode=min so với mode=max: gấp bốn lần dung lượng, gần gấp đôi tốc độ
Với build multi-stage và có xuất image thật (--push):
| Registry tăng | Máy sạch build | |
|---|---|---|
mode=min |
45,6 MB | 11,0 s |
mode=max |
188,2 MB | 6,2 s |
mode=min chỉ lưu cache của những layer có mặt trong image cuối. mode=max lưu cả các tầng trung gian — tầng build với toàn bộ build-essential, thư mục gói đã cài, mọi thứ.
Gấp bốn lần dung lượng đổi lấy 1,8 lần tốc độ. Với đa số đường ống CI thì mode=max đáng, vì dung lượng registry rẻ hơn thời gian chờ của lập trình viên — nhưng nếu bạn trả tiền theo dung lượng lưu trữ và build hàng trăm nhánh mỗi ngày thì con số 188 MB mỗi nhánh cần được nhân lên trước khi quyết định.
Một lưu ý về cách đo: lần đầu tôi so hai chế độ với --output type=cacheonly và chúng giống hệt nhau — 6 bước CACHED, cùng thời gian. Vì không có image nào được xuất ra, BuildKit lưu mọi thứ ở cả hai chế độ, nên khác biệt biến mất. Phải có --push hoặc --load thì min mới thật sự là min.
Trường hợp thường gặp nhất: chỉ sửa mã nguồn
Đây mới là kịch bản CI hằng ngày — phụ thuộc không đổi, chỉ vài dòng mã:
may sach + cache cu, ma nguon da doi: 7,6 s | 4 buoc CACHED
Bốn trên sáu bước vẫn dùng lại được. Hai bước phải chạy lại là COPY app.py và tầng cuối phụ thuộc vào nó — đúng như luật ở phần 6: mất cache ở đâu thì mất từ đó trở đi.
Nghĩa là cache registry vẫn hiệu quả kể cả khi mã nguồn đổi, miễn là Dockerfile của bạn đặt COPY mã nguồn sau bước cài phụ thuộc.
Cache kiểu local
Không có registry thì dùng thư mục trên đĩa:
docker buildx build --cache-to type=local,dest=./bo-nho-cache,mode=max ...
docker buildx build --cache-from type=local,src=./bo-nho-cache ...
cache local tren dia : 229,3 MB
may sach nhap tu local : 8,1 s | 6 buoc CACHED
Hiệu quả tương đương registry (8,1 so với 6,2 giây), và hợp khi hệ thống CI của bạn đã có cơ chế cache thư mục sẵn — GitLab CI cache:, Jenkins workspace, hay một ổ đĩa dùng chung.
Nhược điểm: type=local luôn ghi lại toàn bộ cache mỗi lần, không ghi thêm. Với 229 MB thì việc tải lên tải xuống của hệ thống CI có thể ăn mất phần thời gian bạn vừa tiết kiệm — đo cả khoản đó trước khi chọn.
Bốn kiểu cache backend
| Kiểu | Dùng khi |
|---|---|
type=registry |
phổ biến nhất; cần một registry ghi được |
type=local |
có thư mục dùng chung giữa các lần chạy |
type=gha |
GitHub Actions, dùng kho cache của chính nó |
type=inline |
nhét cache vào chính image; đơn giản nhưng chỉ tương đương mode=min |
type=inline đáng nhắc vì nó là cách rẻ nhất để bắt đầu — không cần kho lưu trữ riêng:
docker buildx build --cache-to type=inline --cache-from myapp:latest -t myapp:latest --push .
Đổi lại, nó không lưu được tầng trung gian, nên với multi-stage build thì lợi ích nhỏ hơn hẳn con số 6,2 giây ở trên.
Mẫu dùng trong CI
Ba thứ nên có:
docker buildx build \
--cache-from type=registry,ref=registry.vi-du/app:cache-main \
--cache-from type=registry,ref=registry.vi-du/app:cache-$NHANH \
--cache-to type=registry,ref=registry.vi-du/app:cache-$NHANH,mode=max \
--tag registry.vi-du/app:$SHA --push .
- Hai
--cache-from: thử cache của chính nhánh trước, không có thì lấy củamain. Nhánh mới vẫn được hưởng cache thay vì build từ đầu. --cache-totheo nhánh: nhánh này không ghi đè cache của nhánh khác.- Tag bằng SHA: phần 4 đã cho thấy tag di động không đáng tin để triển khai.
Và nhớ bài học từ phần 18: đừng để --no-cache trong lệnh build. Nó vô hiệu hoá cả cache mount lẫn khả năng dùng lại layer, và biến toàn bộ cấu hình trên thành vô nghĩa.
Cache cũ có tự dọn không
Không. Cache trong registry là những tag bình thường và nằm đó mãi. Với mode=max và một nhánh mới mỗi tính năng, bạn tích 188 MB cho mỗi nhánh cho tới khi hết dung lượng.
Cần một cơ chế dọn: chính sách vòng đời của registry, hoặc một tác vụ định kỳ xoá tag cache của nhánh đã gộp.
Muốn biết ngay lần build CI gần nhất của mình dùng lại được bao nhiêu, đếm số bước CACHED:
docker buildx build --progress=plain -t thu . 2>&1 | grep -c CACHED
So con số đó với tổng số bước trong Dockerfile. Trên máy lập trình viên nó thường gần bằng nhau; trên CI, nếu bạn chưa cấu hình --cache-from, nó là 0 — và mọi công sức sắp xếp Dockerfile ở các phần trước đang không có tác dụng ở nơi quan trọng nhất.
Mẫu số chung
Bài học đầu tiên, đọc thẳng từ 64 giây so với 6,2: một tối ưu buộc vào trạng thái cục bộ và ấm sẽ bốc hơi ở nơi môi trường bị dựng lại từ đầu — muốn nó sống sót thì phải đưa trạng thái đó ra một chỗ chung, bền. Mọi mẹo sắp xếp layer chỉ có giá trị khi cache còn nằm trên máy; CI xoá sạch mỗi lần chạy, nên cách duy nhất giữ được lợi ích là biến cache thành một hiện vật được publish và fetch như mọi hiện vật khác. Cùng thế lưỡng nan "tính toán phù du + trạng thái ấm" ở khắp nơi: JVM vừa JIT nóng máy thì bị khởi động lại nguội ngắt, pool kết nối phải mồi lại sau mỗi lần deploy, cache trình duyệt vô dụng với người dùng mới nên mới cần CDN, memo hoá chết theo tiến trình. Nguyên tắc: khi phần chạy là phù du (container CI, hàm serverless, pod tự co giãn), đừng gắn cái đắt-để-dựng vào vòng đời của nó — hãy tách ra một tầng lưu trữ chung mà mọi thực thể phù du đều với tới được, rồi công sức tối ưu mới trả cổ tức ở đúng nơi đông máy nhất.
Điều thứ hai, một cái bẫy vận hành đọc thẳng từ "cache cũ có tự dọn không? Không": thứ gì chỉ lớn lên mà không có ai lo thu nhỏ thì sớm muộn cũng lấp đầy đĩa — và thường đúng vào lúc bạn không ngờ. mode=max tích 188 MB mỗi nhánh, nhánh nào cũng nằm đó mãi; tiện hôm nay, thành sự cố "hết dung lượng" sáu tháng sau. Cùng luật ấy ở mọi kho chỉ-thêm-không-bớt: log không xoay vòng, cache không TTL/LRU, .git phình vì blob, tệp tạm không ai quét, dòng xoá-mềm chất đống, retention của Kafka để vô hạn. Nguyên tắc: mỗi lần dựng một thứ tự lớn lên, hãy đặt luôn cơ chế co lại cho nó — TTL, LRU, chính sách vòng đời, một tác vụ dọn định kỳ — vì "vô hạn" chỉ là "hữu hạn mà chưa tới ngày", và cái ngày đó luôn chọn lúc bất tiện nhất để đến.
Phần sau dựng ảnh cho nhiều kiến trúc bằng buildx, và đo thời gian build chéo kiến trúc.