Ai cũng tưởng mình chảy máu tiền vì mấy ly cà phê mỗi sáng — cho tới khi ngồi liệt kê cả tháng ra giấy và thấy tiền nhà với tiền xe ngốn 94%, còn cà phê chỉ là sai số làm tròn. Cắt cà phê mà không đụng tới hai khoản kia là công sức đổ xuống sông. Tối ưu thời gian build y hệt vậy: trước khi động tay, hãy liệt kê hoá đơn. Sáu phần trước đưa ra một loạt cách làm build nhanh hơn — sắp xếp COPY, cache mount, cache registry, tách tầng song song. Câu hỏi thực tế là áp cái nào trước, và câu trả lời chỉ có bằng cách đo chính Dockerfile của bạn.
--progress=plain cho thời gian từng bước
docker build --no-cache --progress=plain -t thu . 2>&1
#7 [3/7] RUN apt-get update && apt-get install -y --no-install-recommends curl git
#7 0.393 Hit:1 http://deb.debian.org/debian trixie InRelease
#7 3.701 Fetched 9947 kB in 4s (2796 kB/s)
#7 DONE 53.1s
Hai loại dấu thời gian: số ở đầu mỗi dòng log là giây trôi qua kể từ khi bước bắt đầu, còn DONE 53.1s là tổng thời gian của bước.
Một script xếp hạng
Ba chục dòng đọc log rồi sắp xếp:
#!/usr/bin/env python3
import re, sys
ten, giay = {}, {}
for dong in sys.stdin:
m = re.match(r'^#(\d+)\s+(\[.*?\]|\[internal\].*?)\s*(.*)$', dong)
if m and m.group(1) not in ten:
ten[m.group(1)] = (m.group(2) + " " + m.group(3)).strip()
d = re.match(r'^#(\d+)\s+DONE\s+([\d.]+)s', dong)
if d: giay[d.group(1)] = float(d.group(2))
if re.match(r'^#(\d+)\s+CACHED', dong): giay[re.match(r'^#(\d+)', dong).group(1)] = 0.0
tong = sum(giay.values())
print(f"tong: {tong:.1f} s\n")
for b, s in sorted(giay.items(), key=lambda x: -x[1])[:10]:
print(f"{s:7.1f} {s/tong*100:4.1f}% {ten.get(b, '#'+b)[:60]}")
docker build --no-cache --progress=plain -t thu . 2>&1 | python3 phan-tich.py
Kết quả trên một Dockerfile Python bình thường:
tong thoi gian cac buoc: 92.2 s
giay % buoc
------------------------------------------------------------------
53.1 57.6% [3/7] RUN apt-get update && apt-get install -y ...
34.0 36.9% [5/7] RUN pip install --no-cache-dir -r requirements.txt
4.2 4.6% #12 (exporting to image)
0.3 0.3% [internal] load build context
0.3 0.3% [2/7] WORKDIR /app
0.2 0.2% [7/7] RUN python -c "import flask; ..."
0.1 0.1% [6/7] COPY . /app
Hai bước chiếm 94,5%. Mọi thứ khác cộng lại là 5,5% — kể cả những dòng mà người ta hay dành thời gian tối ưu.
Bước #12 không có tên trong ngoặc vuông là xuất layer ra image:
#12 exporting to image
#12 exporting layers
#12 exporting layers 3.1s done
Nó tỉ lệ với dung lượng image, nên nếu bước này chiếm phần lớn thì phần 16 là chỗ cần đọc.
Tổng các bước so với thời gian thực tế
build sach : thuc te 108,2 s | tong cac buoc 107,9 s | 0 CACHED
build lai : thuc te 0,4 s | tong cac buoc 0,1 s | 6 CACHED
Hai con số gần bằng nhau nghĩa là Dockerfile này chạy nối tiếp hoàn toàn — không có tầng nào song song với tầng nào. Nếu tổng các bước lớn hơn nhiều so với thời gian thực tế thì bạn đang có song song, và phần 23 là chỗ để đọc tiếp.
Đây là phép so sánh đáng làm đầu tiên, vì nó cho biết vấn đề của bạn thuộc loại nào: một bước chậm hay nhiều bước không chạy cùng lúc.
Đóng vòng lặp: sửa đúng chỗ rồi đo lại
Bảng xếp hạng chỉ ra apt-get và pip. Cả hai đều là ứng viên của cache mount ở phần 18:
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update && apt-get install -y --no-install-recommends curl git
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
| Thời gian | |
|---|---|
| Bản gốc, cache rỗng | 46,3 s |
| Có cache mount, cache rỗng | 46,4 s |
| Có cache mount, cache đã ấm | 14,4 s |
Nhanh hơn 3,2 lần, và con số đó đến thẳng từ bảng xếp hạng chứ không từ việc đoán.
Hàng giữa cũng quan trọng: lần đầu không nhanh hơn, vì cache chưa có gì. Ai thêm cache mount rồi đo đúng một lần sẽ kết luận nó vô dụng — đúng cái bẫy tôi đã dính ở phần 18.
Một cảnh báo về con số tuyệt đối
Bạn sẽ để ý là "build sạch" trong bài này khi thì 92,2 giây, khi 108,2, khi 46,3. Cùng một Dockerfile, cùng một máy.
Khác biệt là mạng — apt-get và pip tải hàng chục megabyte, và tốc độ đó thay đổi theo từng lúc. Nghĩa là:
- So sánh trong cùng một lần chạy thì tin được (tỉ lệ phần trăm ở bảng xếp hạng).
- So sánh giữa hai lần chạy cách nhau thì phải lặp lại vài lần rồi lấy trung vị.
- Và nếu bước tốn nhất của bạn là tải mạng, thì tối ưu Dockerfile chỉ giúp được một phần — cache mount hoặc gương registry nội bộ mới là câu trả lời.
--progress=rawjson cho công cụ
Muốn phân tích bằng máy thì có định dạng JSON từng dòng:
docker build --progress=rawjson -t thu . 2>&1 | head -1
{"vertexes":[{"digest":"sha256:44282f780b3d...","name":"...","started":"...","completed":"..."}]}
Mỗi dòng là một sự kiện với started và completed chính xác tới nano giây. Dạng này hợp để đẩy vào hệ thống giám sát CI và theo dõi thời gian build theo thời gian, thay vì chỉ nhìn một lần.
Quy trình bốn bước
Khi build chậm, làm theo thứ tự này:
- Chạy bảng xếp hạng. Biết hai bước tốn nhất trước khi đụng vào bất cứ thứ gì.
- So tổng các bước với thời gian thực tế. Gần bằng nhau nghĩa là vấn đề nằm ở một bước chậm; chênh nhiều nghĩa là đang có song song và bạn nên nhìn vào bước chậm nhất của nhánh dài nhất.
- Đo lại với cache ấm. Con số ở lần build sạch và lần build lại là hai bài toán khác nhau — CI thường quan tâm cái đầu, lập trình viên quan tâm cái sau.
- Sửa một thứ, đo lại. Sửa ba thứ cùng lúc thì không biết cái nào có tác dụng.
Muốn biết ngay năm bước tốn nhất của mình, không cần script, chỉ một lệnh:
docker build --no-cache --progress=plain -t thu . 2>&1 | grep 'DONE' | sort -t' ' -k3 -rh | head -5
Trong hầu hết Dockerfile tôi từng đo, hai dòng đầu chiếm hơn 90% — và mọi thứ bạn làm với các dòng còn lại đều là công sức bỏ phí.
Mẫu số chung
Bài học đầu tiên, đọc thẳng từ "hai bước chiếm 94,5%": đo trước khi tối ưu, và để công sức đi theo hồ sơ đo chứ đừng theo trực giác — gần như luôn có một vài mục trội hẳn, phần còn lại là sai số làm tròn. Trực giác kéo bạn tới chỗ dễ sửa hoặc hay được nhắc, không phải chỗ tốn nhất; chỉ khi liệt kê hoá đơn (--progress=plain) mới thấy apt-get và pip nuốt 94% còn mấy dòng người ta chăm chút chỉ có 0,1%. Cùng nguyên lý Amdahl ở khắp nơi: hồ sơ hoá code trước khi mài giũa vòng lặp, tìm truy vấn chậm nhất trước khi thêm index bừa, đo chỗ nghẽn thật trước khi mua thêm máy. Nguyên tắc: đừng đoán chỗ mất thời gian — đo, rồi dồn toàn lực vào cái số hạng lớn nhất, và khép vòng bằng cách sửa đúng một thứ rồi đo lại, vì sửa ba thứ cùng lúc thì không biết cái nào có tác dụng.
Điều thứ hai, đọc từ chuyện cùng một build lúc 92 giây lúc 108 lúc 46: con số bạn đo được là tín hiệu (Dockerfile của bạn) trộn lẫn nhiễu (mạng) — phải tách hai cái ra trước khi kết luận. Tỉ lệ phần trăm trong cùng một lần chạy tin được; con số tuyệt đối giữa các lần chạy thì phải lặp lại lấy trung vị; và nếu chính cái nhiễu (tải mạng) mới là chỗ nghẽn thì mọi tối ưu Dockerfile đều vô ích — câu trả lời nằm ở cache mount hay gương registry nội bộ. Cùng cái bẫy "đo lẫn biến nhiễu" ở khắp nơi: benchmark bị ảnh hưởng bởi tải nền, thời gian phản hồi trộn lẫn độ trễ mạng với thời gian xử lý, một phép so sánh A/B chạy hai thời điểm khác nhau. Nguyên tắc: biết rõ con số của mình gồm những gì, so sánh trong điều kiện giữ nguyên biến nhiễu, và khi chính biến nhiễu là thủ phạm thì đừng đi sửa cái mình kiểm soát được cho có — vì tối ưu đúng chỗ sai cũng vô dụng như không tối ưu.
Phần sau chuyển sang chạy container: giới hạn CPU, bộ nhớ, và chuyện gì xảy ra khi vượt hạn mức.