Hãy nghĩ về hai cách chuyển nhà. Cách cũ: bê nguyên cả căn nhà chất lên xe tải rồi mới lục xem cần gì — chậm, cồng kềnh, và đống đồ bỏ đi cũng theo sang nhà mới. Cách BuildKit làm: đọc danh sách của bạn trước, thấy chỉ cần đúng hai cái thùng thì chỉ bê hai thùng đó. Lời khuyên quen thuộc — thêm .dockerignore để khỏi gửi hàng trăm megabyte cho Docker daemon mỗi lần build — được viết cho cái thời "bê cả nhà". Tôi dựng một dự án 28 MB đúng kiểu thật — node_modules 7,8 MB, .git 10 MB, dist 9,6 MB — rồi đo. Kết quả không giống lời khuyên.
BuildKit chỉ gửi thứ nó cần
Dockerfile chỉ lấy hai thứ:
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY src /app/src
KHONG co .dockerignore : transferring context: 150B
CO .dockerignore : transferring context: 150B
150 byte, cả hai. Thư mục dự án 28 MB, nhưng BuildKit đọc Dockerfile trước, thấy chỉ cần requirements.txt và src/, và chỉ lấy chừng đó. .dockerignore không thay đổi gì vì chẳng có gì để loại.
Đây là khác biệt so với builder cũ, thứ đóng gói toàn bộ thư mục thành một tar rồi gửi đi trước khi đọc Dockerfile. Lời khuyên kia viết cho thời đó. BuildKit là mặc định từ Docker 23, nên với đa số người đọc bài này thì tiền đề của lời khuyên đã không còn.
Trừ khi bạn viết COPY . /app
Đổi Dockerfile sang copy hết:
| Dockerfile | .dockerignore |
Context truyền | Thời gian build |
|---|---|---|---|
COPY src |
không | 150 B | 7,4 s |
COPY src |
có | 150 B | 7,1 s |
COPY . |
không | 28,06 MB | 7,2 s |
COPY . |
có | 599 B | 6,9 s |
Với COPY . /app, .dockerignore cắt context từ 28,06 MB xuống 599 B — gấp gần 47 000 lần.
Nhưng nhìn cột cuối: thời gian build gần như không đổi (7,2 so với 6,9 giây). Chuyển 28 MB giữa hai tiến trình trên cùng một máy gần như miễn phí. Nếu bạn thêm .dockerignore để build nhanh hơn, phép đo này nói rằng bạn sẽ thất vọng.
Với builder ở xa — BuildKit chạy trên máy khác, hoặc CI kéo context qua mạng — con số 28 MB kia sẽ hiện ra thành thời gian thật. Nhưng trên máy của bạn thì không.
Cái giá thật thứ nhất: image béo lên
COPY . /app không chỉ gửi 28 MB, nó đưa chúng vào image:
| Dung lượng | |
|---|---|
COPY . không có .dockerignore |
42,5 MB |
COPY . có .dockerignore |
23,4 MB |
Chênh 19,1 MB, và nhìn vào bên trong thì thấy rõ nó là gì:
9.6M /app/dist
9.4M /app/node_modules
8.0K /app/src
4.0K /app/requirements.txt
Mã nguồn thật của dự án là 8 KB. Phần còn lại là kết quả build cũ và thư mục phụ thuộc của một hệ sinh thái khác, được chép vào image, đẩy lên registry, tải về mọi máy chủ, và không bao giờ dùng tới.
Còn tệ hơn thế: nếu .git lọt vào image thì toàn bộ lịch sử kho mã đi cùng — kể cả những commit đã xoá tệp bí mật, vì xoá trong commit sau không xoá được trong lịch sử.
Cái giá thật thứ hai: cache chết liên tục
Đây mới là lý do đáng thêm .dockerignore, và nó không xuất hiện trong bất kỳ phép đo về dung lượng nào.
Tôi sửa một tệp trong node_modules — thứ mà Dockerfile chẳng dùng tới — rồi build lại:
| Không sửa gì | Sau khi sửa node_modules |
|
|---|---|---|
COPY . không có .dockerignore |
3 bước CACHED | 1 bước CACHED |
COPY . có .dockerignore |
3 bước CACHED | 3 bước CACHED |
Không có .dockerignore, cache tụt từ 3 xuống 1 — nghĩa là pip install chạy lại từ đầu, dù không một phụ thuộc nào thay đổi.
Và hãy nghĩ xem những thư mục đó bị ghi vào lúc nào:
.git— mỗi lần bạngit commit,git checkout, hay thậm chígit statustrên vài trình Git.node_modules— mỗi lần trình soạn thảo chạy công cụ ngôn ngữ.dist,target,build— mỗi lần bạn build ở ngoài container..venv,__pycache__— mỗi lần bạn chạy test.
Tức là gần như mọi thao tác bình thường trong ngày đều phá cache build của bạn, và bạn sẽ kết luận rằng "Docker build lúc nào cũng chậm" mà không biết vì sao.
Đây là điểm nối với phần 6: ở đó tôi đo được rằng đặt COPY đúng chỗ tiết kiệm tám lần thời gian build. .dockerignore là nửa còn lại của cùng một câu chuyện — sắp xếp đúng thứ tự mà vẫn để .git lọt vào context thì cache vẫn chết như thường.
.dockerignore nên có gì
Danh sách tối thiểu cho gần như mọi dự án:
.git
.gitignore
node_modules
__pycache__
*.pyc
.venv
target
dist
build
.env
*.log
.DS_Store
Dockerfile*
.dockerignore
Hai dòng cuối hay bị bỏ qua và đáng có: chính Dockerfile không cần nằm trong image, và sửa nó thì không nên phá cache của bước COPY.
Dòng .env thì quan trọng vì lý do khác hẳn: COPY . /app sẽ chép thẳng tệp bí mật của bạn vào image, và như phần 5 đã đo, thứ gì vào image thì ai kéo được image cũng đọc được.
Cú pháp giống .gitignore nhưng không phải là nó — chúng là hai tệp riêng, và Docker không đọc .gitignore. Có một khác biệt đáng nhớ: dấu ! để loại trừ ngược hoạt động, nhưng thứ tự dòng quyết định, và loại trừ ngược không "cứu" được tệp nằm trong thư mục đã bị loại.
Cách viết an toàn hơn cả .dockerignore
Nhìn lại bảng đầu bài: dòng COPY src cho 150 B mà không cần .dockerignore gì cả.
Chép có chọn lọc thì bạn không phải nhớ loại trừ những gì:
COPY requirements.txt ./
COPY src/ ./src/
thay vì
COPY . .
Vẫn nên có .dockerignore làm lưới an toàn — vì sớm muộn ai đó cũng thêm một dòng COPY . — nhưng liệt kê tường minh thứ cần chép là cách duy nhất khiến một tệp mới xuất hiện trong dự án không tự động chui vào image.
Muốn thấy ngay build của mình đang gửi bao nhiêu và image đang chứa những gì không cần, chạy hai dòng:
docker build --progress=plain -t thu . 2>&1 | grep 'transferring context'
docker run --rm thu du -sh /app/* 2>/dev/null | sort -rh | head
Dòng đầu báo hàng chục megabyte nghĩa là Dockerfile của bạn có COPY . và thiếu .dockerignore; dòng thứ hai mà liệt kê node_modules, dist hay .git thì chúng đang được tải về mọi máy chủ mỗi lần triển khai.
Mẫu số chung
Bài học đầu tiên, và là lý do tôi viết cả bài này: cái lợi người ta nói khi khuyên một việc thường không phải cái lợi thật của nó — làm đúng vì lý do sai thì bạn tối ưu nhầm cái nút và đo xong sẽ thất vọng. .dockerignore được khuyên "để build nhanh hơn", nhưng chuyển 28 MB giữa hai tiến trình cùng máy gần như miễn phí (7,2 so với 6,9 giây); giá trị thật của nó nằm ở chỗ khác hẳn — giữ cache khỏi chết mỗi lần bạn git commit, giữ image khỏi phình 19 MB, giữ .env khỏi lọt vào image. Cùng kiểu "đúng việc, sai lý do" ở khắp nơi: thêm index "cho nhanh" trong khi cái nó thật sự mua là ràng buộc duy nhất hay tránh khoá, thêm cache "giảm độ trễ" trong khi cái được là giảm tải cho nguồn, gom hàm "cho gọn" trong khi cái đáng giá là dễ kiểm thử. Nguyên tắc: biết vì sao một cách làm đáng giá, vì cái "vì sao" đó mới cho bạn biết khi nào nó thật sự cần và đo bằng thước nào để thấy nó hiệu quả — chứ không phải con số bạn mặc định đi đo.
Điều thứ hai, một nguyên tắc thiết kế: gộp-ngầm là một khoản nợ; COPY . không chỉ bốc thứ đang có mà bốc luôn mọi thứ bất kỳ ai thêm vào sau này, một cách âm thầm. Cái thật sự an toàn không phải danh sách loại-trừ (.dockerignore) mà là danh sách cho-phép (COPY đích danh từng thứ), vì nó lật ngược mặc định: từ "mọi thứ vào trừ khi bị chặn" thành "không gì vào trừ khi được gọi tên". Khác biệt nghe nhỏ mà quyết định tất cả — một tệp bí mật mới, một thư mục build mới, sẽ tự động lọt vào ở vế đầu và tự động bị bỏ ngoài ở vế sau. Cùng nguyên lý "mặc-định-từ-chối thắng mặc-định-cho-phép" ở allowlist so với denylist trong bảo mật, import tường minh so với import *, SELECT liệt kê cột so với SELECT *, khai báo export từng cái so với phơi cả module. Nguyên tắc: ở đâu cái sai lọt vào gây hại hơn cái đúng bị bỏ sót, hãy chọn liệt kê thứ được phép chứ đừng liệt kê thứ bị cấm — và để danh sách loại-trừ làm lưới an toàn phía sau, không phải hàng phòng thủ đầu tiên.
Phần sau mổ ENTRYPOINT và CMD: bốn tổ hợp, và tổ hợp nào làm container của bạn không dừng được.