Hình dung một tấm đan len. Nếu bạn tuột một mũi ở gần mép cuối, chỉ cần tháo vài mũi là đan lại được; nhưng lỡ sai một mũi ở sát chỗ khởi đầu thì phải tháo bung gần như cả tấm để lần về đúng chỗ đó rồi đan lại từ đấy. Càng về đầu, sửa càng đắt — và dù sửa ở đâu, bạn luôn phải đan lại mọi thứ sau điểm sửa. Cache của Docker cư xử đúng như tấm len đó, và nó gói gọn trong một luật duy nhất: một layer dùng lại được nếu lệnh sinh ra nó và mọi thứ trước nó không đổi. Từ luật đó suy ra tất cả, nhưng suy ra thì dễ mà nhận ra hậu quả trong một Dockerfile thật thì khó. Bài này đo.

Hai Dockerfile, cùng kết quả, khác thứ tự

Một dự án Python nhỏ: bốn phụ thuộc trong requirements.txt, hai mươi tệp mã nguồn.

# SAI: copy het roi moi cai phu thuoc
FROM python:3.12-alpine
WORKDIR /app
COPY . /app
RUN pip install --no-cache-dir -r requirements.txt
# DUNG: copy manifest truoc, cai, roi moi copy ma
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app

Hai image giống hệt nhau về nội dung. Khác nhau lúc build:

Build đầu (cache rỗng) Sửa một dòng mã rồi build lại
Thứ tự sai 11,8 s 5,7 s
Thứ tự đúng 6,9 s 0,7 s

Gấp tám lần ở lần build lại — và lần build lại mới là thứ bạn làm hàng chục lần mỗi ngày.

Lý do nằm đúng ở luật trên. Ở bản sai, COPY . /app đứng trước pip install. Sửa bất kỳ tệp mã nào cũng làm layer COPY đổi, và mọi layer sau nó — kể cả bước cài phụ thuộc tốn thời gian nhất — mất cache theo.

Ở bản đúng, chỉ requirements.txt mới làm mất cache của pip install. Mà tệp đó cả tháng mới đổi một lần.

Nguyên tắc: sắp xếp Dockerfile theo tần suất thay đổi, thứ ít đổi lên trên. Nền hệ điều hành gần như không đổi, phụ thuộc đổi vài tuần một lần, mã nguồn đổi vài phút một lần — nên chúng phải nằm theo đúng thứ tự đó.

Mẫu này áp cho mọi hệ sinh thái: package.json trước npm ci, pom.xml trước mvn dependency:go-offline, go.mod trước go mod download, Cargo.toml trước cargo fetch.

Cache tính theo nội dung, không theo thời gian

Câu hỏi hay gặp: script build của tôi có touch vài tệp, vậy có phá cache không?

build lai khong doi gi       : 0,75 s | 4 buoc CACHED
sau khi touch src/main.py    : 0,72 s | 4 buoc CACHED
sau khi THEM noi dung        : 0,67 s | 3 buoc CACHED
sau khi sua requirements.txt : 6,47 s | 1 buoc CACHED

touch không phá cache. Docker băm nội dung tệp, không nhìn thời gian sửa đổi. Chỉ khi nội dung thật sự đổi thì layer mới bị dựng lại.

Hàng cuối cho thấy cái giá thật: đổi requirements.txt là chỉ còn 1 bước được dùng lại, và thời gian build nhảy từ 0,7 lên 6,5 giây.

Mất cache ở dòng nào thì mất từ đó trở đi

Năm lệnh RUN, mỗi lệnh mất một giây. Sửa từng dòng một rồi đo:

Sửa dòng Bước còn cache Thời gian
không sửa gì 5 0,31 s
dòng 5 (cuối) 4 1,39 s
dòng 3 (giữa) 2 3,66 s
dòng 1 (đầu) 1 6,09 s

Tuyến tính và không có ngoại lệ: cache mất từ chỗ bạn sửa cho tới hết Dockerfile. Đó là lý do một dòng vô hại đặt nhầm chỗ ở đầu tệp có thể làm cả đường ống CI chậm đi.

Hệ quả thực tế ít người để ý: ARG khai ở đầu Dockerfile và được truyền giá trị khác nhau mỗi lần build — ví dụ ARG BUILD_DATE hay ARG GIT_SHA — sẽ phá cache của toàn bộ phần còn lại. Nếu bạn cần chúng, hãy khai càng gần cuối càng tốt.

Cái bẫy apt-get update tách dòng

Đây là chỗ phép đo cho ra kết luận ngược với trực giác.

# tach dong
RUN apt-get update
RUN apt-get install -y --no-install-recommends curl
# gop dong
RUN apt-get update && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

Thêm một gói vào danh sách cài rồi build lại:

Thời gian Dung lượng image
Tách dòng 5,4 s 46,6 MB
Gộp dòng 10,2 s 32,1 MB

Bản tách dòng nhanh gấp đôi. Và đó chính là lỗi. Nhìn vào log build:

#5 [2/3] RUN apt-get update
#5 CACHED
#6 [3/3] RUN apt-get install -y --no-install-recommends curl ca-certificates gnupg

apt-get update được dùng lại từ cache, nên apt-get install chạy với chỉ mục gói cũ — cũ bằng đúng tuổi của layer đó, có thể là hàng tháng. Kết quả: bạn cài một phiên bản đã bị thay thế, hoặc gặp lỗi "404 Not Found" khi kho gói đã xoá tệp cũ đi. Bản build nhanh hơn nhanh chính vì nó bỏ qua việc làm mới thông tin.

Và nó còn nặng hơn 14,5 MB (45%), vì /var/lib/apt/lists/ nằm lại trong layer của apt-get update. Xoá nó ở một RUN sau cũng vô ích — phần 2 đã đo: layer chỉ cộng thêm chứ không trừ đi, phải xoá trong cùng lệnh đã tạo ra nó.

Nên với trình quản lý gói, luật là: update, install và dọn dẹp phải nằm trong một RUN duy nhất. Đây là ngoại lệ có chủ đích của lời khuyên "chia nhỏ để tận dụng cache".

Bốn thứ hay phá cache mà không ai ngờ

  • COPY . /app đặt sớm. Mọi tệp trong thư mục dự án đều tính, kể cả README, kể cả thư mục .git nếu bạn không loại nó ra — chủ đề của phần sau.
  • ARG đổi giá trị mỗi lần build. Đặt cuối, hoặc chấp nhận mất cache.
  • Image nền dùng tag di động. FROM python:3.12-alpine hôm nay có thể là digest khác hôm qua, và khi nó đổi thì cả Dockerfile mất cache. Ghim digest cho ổn định — xem phần 4.
  • Đổi máy build. Cache nằm trên máy; máy CI mới là cache rỗng. Đó là lý do có cache registry, và là chủ đề của phần 19.

Tự đo Dockerfile của bạn

Đo xem Dockerfile của bạn mất bao nhiêu khi sửa một dòng mã:

docker build -t thu . >/dev/null            # lam nong cache
echo "// $(date)" >> src/tep-nao-do.js      # sua mot dong
time docker build -t thu .

Rồi đếm số bước CACHED trong log:

docker build --progress=plain -t thu . 2>&1 | grep -c CACHED

Nếu con số đó nhỏ hơn nhiều so với tổng số lệnh trong Dockerfile, bạn đang có một COPY đặt quá sớm — và chuyển nó xuống dưới bước cài phụ thuộc là thay đổi rẻ nhất bạn làm được cho tốc độ CI hôm nay.

Mẫu số chung

Bài học đầu tiên, chính là tấm đan len: khi một chuỗi bước mà mỗi bước dựa lên kết quả của bước trước, hãy xếp thứ hay đổi nhất xuống cuối — vì một thay đổi làm hỏng lại toàn bộ phần sau nó, nên chỗ bạn đặt cái-hay-đổi quyết định bao nhiêu công phải làm lại. Đặt COPY . trước pip install là bắt bước đắt nhất chạy lại mỗi lần sửa một dòng mã. Cùng cơ chế "sửa sớm, dựng lại nhiều" ở khắp nơi: trình biên dịch tăng dần — đổi một header được include khắp nơi thì cả cây phụ thuộc biên dịch lại; Make, Gradle, Bazel đều băm đầu vào rồi dựng lại mọi thứ phụ thuộc vào cái đã đổi; ngay cả một hàm memoize cũng mất giá trị nhớ khi đối số đổi. Nguyên tắc: trong mọi đường ống có bậc phụ thuộc, đặt đầu vào ổn định nhất lên sớm nhất và cái biến động nhất xuống muộn nhất — để phần đắt tiền nằm phía trên điểm thay đổi và được giữ nguyên.

Điều thứ hai, đọc từ cái bẫy apt-get update: một cache chỉ đúng khi khoá của nó bắt được mọi đầu vào có thể đổi kết quả — thiếu một đầu vào ẩn thì nó tự tin trả về câu trả lời cũ, và càng nhanh thì càng gian. Docker băm dòng lệnh apt-get update, không băm cái chỉ mục gói từ xa mà lệnh đó lẽ ra phải kéo về — nên cùng một dòng lệnh cho một layer "hợp lệ" chứa dữ liệu hàng tháng tuổi. Cùng cái bẫy "khoá cache bỏ sót đầu vào ẩn" ở khắp nơi: một bộ nhớ HTTP trả trang cũ vì URL không đổi dù nội dung đã đổi; một hàm memoize phụ thuộc vào thời gian hay trạng thái CSDL không nằm trong khoá; một bản ghi DNS còn sống quá TTL. Nguyên tắc: trước khi tin một lần trúng cache, hỏi khoá của nó có bao gồm mọi thứ khiến kết quả khác đi không — với thứ phụ thuộc vào độ tươi hay trạng thái bên ngoài, hãy đưa cái đó vào khoá hoặc gộp việc làm-mới vào cùng bước (chính là lý do update và install phải chung một RUN).

Phần sau đo build context và .dockerignore: bao nhiêu dữ liệu thật sự được gửi cho daemon mỗi lần bạn gõ docker build.