Hình dung một trạm kiểm soát biên giới. Chở một xe tải hàng lớn qua thì nhanh — kiểm một lần là xong. Nhưng chở đúng chừng ấy khối lượng dưới dạng 4.000 kiện nhỏ, mỗi kiện một tờ khai hải quan riêng, thì mất cả ngày — cái giá tính theo số lần qua trạm, không theo số cân hàng. Bind mount trên macOS chính là con đường có trạm biên giới đó: Docker Desktop phải bắc qua ranh giới giữa macOS và máy ảo Linux, nên một luồng byte lớn đi qua ngon lành, còn hàng vạn thao tác tệp nhỏ thì tắc nghẹt. Volume nằm hẳn bên trong máy ảo — cùng một phía biên giới, không có trạm nào. Phần 41 đo tốc độ ghi tuần tự và thấy volume với bind mount gần như bằng nhau — 993,7 so với 966,6 MB/s. Con số đó có vẻ mâu thuẫn với danh tiếng "bind mount trên macOS chậm kinh khủng"; bài này đo đúng loại tải mà danh tiếng đó nói tới.
Bắt đầu bằng con số quan trọng nhất: giải nén một kho 4.401 tệp nhỏ, kiểu npm install hay git checkout.
| Volume | Bind mount | |
|---|---|---|
| macOS / Docker Desktop | 228 ms | 3.027 ms |
| Linux | 201 ms | 234 ms |
Trên macOS, bind mount chậm hơn 13,3 lần. Trên Linux, chênh lệch 16% — cùng cỡ với nhiễu giữa các lần chạy.
Đây là toàn bộ câu chuyện: vấn đề không nằm ở bind mount, nó nằm ở ranh giới giữa macOS và máy ảo Linux mà Docker Desktop phải bắc qua. Phần 41 đã thấy dấu hiệu: bên trong container, bind mount có hệ thống tệp tên fakeowner, còn volume là ext4 thẳng.
Chi tiết từng thao tác
3.000 tệp 512 byte, đơn vị mili giây.
macOS / Docker Desktop:
| Phép đo | Volume | Bind | tmpfs | Bind chậm hơn |
|---|---|---|---|---|
| Tạo 3.000 tệp | 46 | 713 | 29 | 15,5× |
stat 3.000 tệp |
3 | 262 | 3 | 87× |
| Đọc 3.000 tệp | 20 | 332 | 18 | 16,6× |
listdir 50 lần |
26 | 483 | 18 | 18,6× |
| Đổi tên 3.000 tệp | 18 | 807 | 8 | 45× |
| Xoá 3.000 tệp | 9 | 481 | 3 | 53× |
200 lần ghi+fsync |
119 | 21 | 1 | bind nhanh hơn 5,7× |
Linux thật:
| Phép đo | Volume | Bind | tmpfs |
|---|---|---|---|
| Tạo 3.000 tệp | 68 | 78 | 34 |
stat 3.000 tệp |
3 | 3 | 3 |
| Đọc 3.000 tệp | 19 | 19 | 17 |
listdir 50 lần |
25 | 24 | 15 |
| Đổi tên 3.000 tệp | 23 | 32 | 8 |
| Xoá 3.000 tệp | 9 | 12 | 3 |
200 lần ghi+fsync |
139 | 126 | 1 |
Trên Linux, hai cột không phân biệt được. Trên macOS, mọi thao tác siêu dữ liệu đều đắt khủng khiếp — stat gấp 87 lần là con số tệ nhất, và stat chính là thứ mà trình biên dịch, bundler và trình theo dõi tệp gọi hàng chục nghìn lần.
Đó là lý do npm install trong container trên Mac chậm đến mức khó chịu, trong khi docker build tải về 500 MB image thì vẫn nhanh: một bên là hàng vạn thao tác nhỏ, một bên là một luồng byte lớn.
Dòng fsync đi ngược, và tôi không kết luận chắc
Có đúng một dòng bind mount thắng, và nó thắng đậm. Tôi đo lại riêng, 1.000 lần fsync, ba lần chạy:
Mili giây mỗi lần fsync |
|
|---|---|
| macOS, volume | 0,620 / 0,686 / 0,662 |
| macOS, bind | 0,128 / 0,106 / 0,146 |
| Linux, volume | 0,669 / 0,613 / 0,603 |
| Linux, bind | 0,663 / 0,619 / 0,588 |
Ba trong bốn trường hợp cho khoảng 0,6 ms mỗi lần — con số hợp lý cho một rào chắn ghi thật xuống SSD. Riêng bind mount trên macOS rẻ hơn năm lần, gần với tmpfs hơn là với đĩa.
Điều đó gợi ý rằng fsync đi qua ranh giới đó không cho cùng một mức bảo đảm. Nhưng tôi không kiểm chứng được: muốn biết chắc thì phải cắt điện giữa chừng rồi xem dữ liệu còn không, mà tôi không có cách làm thí nghiệm đó cho đàng hoàng. Nên tôi để nguyên: con số là thật, cách giải thích là phỏng đoán.
Dù sao thì kết luận thực dụng không đổi — đừng chạy CSDL trên bind mount ở macOS. Nếu fsync rẻ vì nó không thật sự bền, đó là thứ tệ nhất có thể xảy ra với một CSDL.
:delegated và :cached không còn tác dụng gì
Lời khuyên rất phổ biến cho macOS là thêm :delegated hoặc :cached vào bind mount. Đo bằng chính phép giải nén 4.401 tệp, mỗi cấu hình hai lần:
| Cấu hình | Hai lần chạy |
|---|---|
bind (không cờ) |
3.382 ms / 3.065 ms |
bind:delegated |
3.179 ms / 3.278 ms |
bind:cached |
3.178 ms / 3.390 ms |
Không có khác biệt nào. Dao động giữa hai lần chạy của cùng một cấu hình (3.382 so với 3.065) còn lớn hơn khoảng cách giữa các cấu hình.
Hai cờ này có ý nghĩa thật thời osxfs, cơ chế cũ của Docker Desktop. Docker Desktop hiện tại dùng VirtioFS và chấp nhận cờ mà không làm gì — không cảnh báo, không lỗi. Lời khuyên vẫn còn nguyên trên mạng, chỉ có tác dụng đã biến mất.
Cách vá thật: volume đè lên đúng thư mục nặng
Bạn vẫn cần bind mount mã nguồn — đó là cả điểm của việc phát triển trong container. Nhưng thư mục phụ thuộc thì không cần đồng bộ về máy:
services:
app:
volumes:
- ./:/app # ma nguon: bind, de sua truc tiep
- deps:/app/node_modules # thu muc nang: volume
volumes:
deps:
Đo bằng đúng phép giải nén ban đầu:
| Cách gắn | Thời gian |
|---|---|
| Toàn bộ là bind mount | 3.027 ms |
| Bind mã nguồn + volume cho thư mục nặng | 290 ms |
Nhanh hơn 10,4 lần, và bạn vẫn sửa mã trên máy rồi thấy thay đổi ngay trong container.
Cách này áp dụng được cho node_modules, vendor của PHP, target của Maven và Rust, .venv của Python, thư mục .next/dist của bundler — tất cả những thư mục sinh ra bởi công cụ chứ không do bạn gõ tay, và bạn không cần nhìn thấy chúng trên máy.
Nhắc lại từ phần 41: volume rỗng gắn lên thư mục có sẵn nội dung sẽ được nạp mồi từ image. Nên nếu Dockerfile của bạn đã chạy npm install, volume sẽ có sẵn nội dung đó ở lần chạy đầu tiên.
Rút ra
| Bạn đang ở | Chọn gì |
|---|---|
| Linux, mọi trường hợp | Cái nào cũng được — chọn theo ý nghĩa, không theo tốc độ |
| macOS, dữ liệu ứng dụng | Volume, luôn luôn |
| macOS, mã nguồn khi phát triển | Bind mount, kèm volume cho thư mục phụ thuộc |
| macOS, CSDL | Volume, không bao giờ bind mount |
| Tệp tạm, bộ đệm | tmpfs — nhanh hơn tất cả ở mọi phép đo |
Và một điều dễ quên: đừng đo trên Mac rồi kết luận cho máy chủ. Kiến trúc chậm 13 lần trên máy bạn có thể hoàn toàn ổn trên production Linux, và ngược lại, một cấu hình chạy tốt trên Mac có thể đang giấu một vấn đề độ bền mà Linux sẽ phơi bày.
Muốn tự đo chính máy mình, giải nén 3.000 tệp nhỏ rồi stat chúng, trên cả hai kiểu gắn:
docker volume create thu-vol
mkdir -p ./thu-bind
for d in vol bind; do
echo -n "$d: "
docker run --rm -v thu-vol:/vol -v "$PWD/thu-bind":/bind python:3.12-alpine \
python -c "
import os,time,shutil
p='/$d/t'; shutil.rmtree(p,ignore_errors=True); os.makedirs(p)
t=time.perf_counter()
for i in range(3000): open(f'{p}/f{i}','w').write('x'*512)
for i in range(3000): os.stat(f'{p}/f{i}')
print(f'{(time.perf_counter()-t)*1000:.0f} ms')
shutil.rmtree(p,ignore_errors=True)"
done
docker volume rm thu-vol && rm -rf ./thu-bind
Hai con số gần nhau nghĩa là bạn đang ở Linux hoặc trên một cấu hình không có ranh giới máy ảo. Chênh nhau chục lần nghĩa là mọi thư mục phụ thuộc trong dự án của bạn nên chuyển sang volume.
Mẫu số chung
Bài học đầu tiên, đọc thẳng từ "87 lần với stat nhưng ngang nhau khi ghi tuần tự": một chi phí cố định mỗi thao tác tàng hình trong việc-lớn-gộp và chí mạng trong việc-nhỏ-lắt-nhắt — cùng một môi trường trông vừa nhanh vừa thảm hại tuỳ độ hạt của thao tác. "bind mount chậm" là sai nếu nói chung; nó chỉ chậm với tải nhiều-tệp-nhỏ, giàu-siêu-dữ-liệu, vì cái giá tính theo số lần qua biên giới chứ không theo số byte. Cùng khuôn "phí cố định mỗi lần gọi" ở khắp nơi: N+1 query so với một truy vấn gộp, một read mỗi byte so với đọc có đệm, chèn từng dòng so với chèn theo lô, RPC lắm-lời so với một API thô. Nguyên tắc: việc-lớn khấu hao phí cố định, việc-lắt-nhắt khuếch đại nó — gộp cái nhỏ lại, và biết độ hạt của tải trước khi phán xét một môi trường; đừng đo một luồng byte lớn rồi kết luận cho một npm install.
Điều thứ hai, sắc và dễ chết người, đọc từ dòng fsync rẻ bất thường: khi một thao tác lẽ ra phải đắt bỗng rẻ, hãy nghi nó đang bỏ qua chính cái việc làm nó đắt — và với độ bền dữ liệu, đó là điều tệ nhất. fsync qua bind mount trên Mac rẻ gấp năm, gần với tmpfs hơn là với đĩa; con số đó gợi ý rào chắn ghi không cho cùng mức bảo đảm — nên "đừng chạy CSDL trên bind mount ở macOS", vì nếu fsync rẻ vì không thật bền thì đó đúng là thứ giết một CSDL. Cùng cái bẫy "nhanh vì bỏ bớt đảm bảo" ở khắp nơi: một commit async trả về ngay, PRAGMA synchronous=OFF, write-back cache giả vờ đã ghi, một mock trả lời tức thì. (Và một hệ quả kèm theo: lời khuyên :delegated/:cached giờ được nhận mà bỏ qua trên VirtioFS — cơ chế đằng sau nó đã thay, chỉ lời khuyên là còn.) Nguyên tắc: một con số nhanh đến bất ngờ là một câu hỏi, không phải một chiến thắng — hỏi xem nó đã lặng lẽ bỏ qua cái đảm bảo nào.
Phần sau đo cách sao lưu và khôi phục volume — và cái bẫy khi sao lưu một CSDL đang chạy.