Hình dung một nhân viên kho đứng ngay cạnh bàn bạn. Cần một món, anh ta với tay lấy trong tích tắc. Giờ dời cái kho sang bên kia sân: mỗi lần cần một món, anh ta phải đi bộ qua sân rồi đi bộ về. Lấy một món hay lấy nghìn món lẻ, mỗi món là một chuyến đi–về. Nhưng nếu bạn bảo "khuân cả một pa-lét sang đây", thì cả pa-lét chỉ tốn một chuyến. Ổ mạng NFS chính là cái kho bên kia sân đó: mỗi thao tác nhỏ là một vòng đi–về qua dây mạng, còn một khối byte lớn thì đi gọn trong vài chuyến. Cả bài này là câu chuyện của cái sân ấy — đo bằng con số thật.

Docker gắn được ổ NFS thẳng vào volume, không cần mount ở máy chủ trước:

docker volume create --driver local \
  --opt type=nfs \
  --opt o=addr=172.17.0.5,rw,nfsvers=4 \
  --opt device=:/ \
  v-nfs

Tôi dựng một máy chủ NFS thật để đo, rồi chạy đúng bộ phép đo của phần 42 trên cả volume cục bộ lẫn volume NFS.

Chênh lệch trải từ 1× tới 142×

Thao tác Volume cục bộ NFS Chậm hơn
Xoá 2.000 tệp 7 ms 997 ms 142×
Tạo 2.000 tệp 43 ms 4.453 ms 104×
Đổi tên 2.000 tệp 16 ms 1.304 ms 81×
Đọc 2.000 tệp 11 ms 369 ms 33×
stat 2.000 tệp 2 ms 23 ms 11,5×
Ghi tuần tự 200 MB 1,4 GB/s 296,8 MB/s 4,7×
200 lần fsync 115 ms 134 ms 1,2×
listdir 50 lần 16 ms 16 ms 1×

Nhìn cả bảng, cái sân hiện ra rất rõ: việc nào phải đi nhiều chuyến lẻ thì đắt kinh khủng, việc nào khuân được cả khối thì gần như miễn phí. Hai dòng cuối mới là chỗ làm tôi phải dừng lại.

Tôi cứ đinh ninh fsync sẽ là chỗ tệ nhất — mỗi lần đồng bộ đều phải chạy qua sân để chắc chắn máy chủ đã ghi xuống đĩa. Nhưng 115 so với 134 mili giây, gần như ngang nhau. Lý do là máy chủ và máy khách nằm chung một mạng cục bộ cực nhanh; cái sân ở đây rộng đúng vài bước chân. Trên đường truyền có độ trễ thật — khác trung tâm dữ liệu, chạy qua VPN — con số này sẽ khác hẳn, và đó chính là chỗ tôi không đo được trong phòng thí nghiệm của mình, nên tôi không bịa ra nó.

Còn listdir bằng nhau là vì máy khách NFS giữ sẵn bộ nhớ đệm thư mục: lần liệt kê đầu tiên đi qua sân, 49 lần sau đọc ngay tại chỗ.

Phần còn lại đúng bức tranh quen thuộc: thao tác siêu dữ liệu trả giá, luồng byte lớn thì không. Mỗi lần tạo hay xoá một tệp là ít nhất một vòng RPC — 2.000 tệp là 2.000 chuyến đi–về. Ghi 200 MB thì chỉ vài trăm chuyến, mỗi chuyến chở 1 MB. Đây cũng chính là kết luận của phần 42 về bind mount trên macOS: cùng một nguyên nhân, chỉ khác lớp trung gian nằm giữa.

Ba phép thử tính đúng đắn

Lời đồn quanh NFS thì nhiều: ghi nối đuôi bị rách, khoá tệp vô dụng, CSDL hỏng. Tôi thử tay ba cái.

Ghi nối đuôi từ bốn container trên cùng một máy. 4 × 30.000 dòng với O_APPEND: 120.000 dòng, 0 dòng hỏng.

Nhưng phép đo đó chưa đủ, và tôi biết rõ vì sao: bốn container dùng chung một nhân, nên khoá inode cục bộ đã xếp hàng chúng lại ngay trước khi có gì kịp lên tới dây mạng. Đúng cái bẫy mà phần 47 đã vấp — chưa chứng minh được hai bên thật sự tranh nhau thì chưa gọi là đo tương tranh.

Nên tôi dựng hẳn hai máy khách NFS riêng biệt, mỗi máy hai tiến trình ghi, tổng 160.000 dòng:

dong nguyen ven: 160000   dong HONG: 0
theo tung tien trinh: A=40000, B=40000, C=40000, D=40000

Vẫn nguyên vẹn. Tôi thử tiếp với dòng dài 2 MB — gấp đôi wsize (đo được là 1.048.576 byte) nên mỗi dòng buộc phải chẻ thành nhiều RPC, đúng cái điều kiện dễ rách nhất — vẫn 0 dòng hỏng.

SQLite từ hai máy khách khác nhau, mỗi máy 2.000 lần chèn:

kh1 A: thanh cong=2000 loi=0
kh2 B: thanh cong=2000 loi=0
tong so dong: 4000    kiem tra toan ven: ok

Kiểm lại cả chế độ nhật ký: vẫn là wal, không bị hạ ngầm xuống chế độ khác.

Không tái hiện được lỗi không có nghĩa là an toàn

Ba phép thử đều "qua". Vậy mà tôi vẫn không khuyên bạn đặt SQLite lên NFS — và đây mới là phần quan trọng nhất của bài.

Tài liệu của chính SQLite nói WAL cần một vùng bộ nhớ chia sẻ nhất quán giữa các tiến trình, thứ mà hệ thống tệp mạng không bảo đảm. Phép đo của tôi chỉ cho thấy: trong đúng cấu hình này — NFSv4, máy chủ Linux, export sync, mạng cục bộ nhanh, không máy nào chết giữa chừng — nó chạy đúng. Nó không nói gì về những cấu hình khác, và đặc biệt không nói gì về chuyện xảy ra khi một máy khách rớt mạng ngay lúc đang giữ khoá.

Khoảng cách giữa "tôi chạy 4.000 lần chèn và không hỏng" với "chuyện này an toàn" chính là khoảng cách giữa một phép thử và một bảo đảm. Với dữ liệu, hãy đòi bảo đảm.

Hai chỗ vấp khi dựng

Không export được thư mục nằm trên overlayfs.

exportfs: /kho does not support NFS export

Hệ thống tệp của container là overlayfs, và nó không sinh được file handle ổn định cho NFS. Tôi phải tạo một tệp ảnh đĩa, mkfs.ext4, mount qua loop device rồi mới export được. Điều này cũng đúng ngoài đời: đừng export một thư mục nằm trong overlay của container.

NFSv4 cần fsid=0 và đường dẫn :/.

mount :/kho ... : no such file or directory

NFSv4 có một cây thư mục ảo riêng, không dùng đường dẫn tuyệt đối của máy chủ. Khai fsid=0 cho export rồi trỏ device=:/ là xong. Thông báo no such file or directory chẳng gợi ý gì về chuyện đó — nó là một trong những lỗi khó đoán nhất khi dựng NFS lần đầu.

Dùng NFS cho việc gì

Việc Nên?
Tệp người dùng tải lên, ảnh, tài liệu ✅ đọc ghi thưa, tệp vừa và lớn
Kho tạo phẩm build, bản sao lưu ✅ ghi một lần, đọc nhiều lần
Nội dung tĩnh nhiều máy chủ cùng phục vụ ✅ chủ yếu là đọc
node_modules, vendor, thư mục làm việc của trình biên dịch ❌ toàn thao tác siêu dữ liệu
Thư mục dữ liệu của CSDL ❌ dùng volume cục bộ, hoặc CSDL có sao chép sẵn
Kho Git thao tác trực tiếp ❌ hàng nghìn tệp nhỏ

Với hai dòng cuối, con số 104× khi tạo tệp và 142× khi xoá tệp nói hết. Một lệnh npm install tạo vài chục nghìn tệp; nhân với 104 là chuyện của cả buổi chiều.

Trong Compose:

volumes:
  tep-tai-len:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=10.0.0.5,rw,nfsvers=4,hard,timeo=600"
      device: ":/"

Tuỳ chọn hard đáng chú ý: khi máy chủ NFS không trả lời, hard khiến tiến trình chờ mãi, còn soft khiến nó nhận lỗi I/O. Nghe thì soft thân thiện hơn, nhưng một lỗi I/O rơi vào giữa lúc đang ghi thường làm hỏng dữ liệu, còn việc chờ thì phục hồi được khi máy chủ quay lại. Với dữ liệu, hard gần như luôn là lựa chọn đúng.

Tự đo cái sân của bạn

Đo chính ổ mạng của mình, bằng thao tác đắt nhất — tạo rồi xoá thật nhiều tệp nhỏ:

docker run --rm -v <volume-nfs>:/n alpine sh -c '
  mkdir -p /n/thu && cd /n/thu
  time sh -c "for i in $(seq 1 500); do echo x > f$i; done"
  time rm -rf /n/thu'

Chia cho 500 để ra thời gian mỗi tệp. Trên volume cục bộ con số này khoảng 0,02 ms; nếu ổ mạng của bạn cho ra vài mili giây mỗi tệp, hãy đếm xem ứng dụng tạo bao nhiêu tệp rồi nhân lên trước khi đưa vào chạy thật.

Mẫu số chung

Bài học đầu tiên, chính là nhân viên kho bên kia sân: khi mỗi thao tác nhỏ phải trả một vòng đi–về có độ trễ, thì cái quyết định tốc độ không phải là bạn chuyển bao nhiêu byte, mà là bạn thực hiện bao nhiêu chuyến. 2.000 tệp lẻ đắt gấp trăm lần vì đó là 2.000 chuyến; 200 MB rẻ vì gộp được thành vài trăm chuyến chở nặng. Cùng cái sân ấy ở khắp nơi: bài toán N+1 của ORM (mỗi dòng một truy vấn thay vì một cú JOIN), một API HTTP "lắm mồm" gọi từng món thay vì một endpoint gộp lô, RMI/gRPC gọi hạt-mịn từng phần tử thay vì truyền cả luồng, hay chính cái đầu đọc đĩa quay tìm từng khối rời rạc. Nguyên tắc: ở đâu độ trễ mỗi lần gọi là hằng số cố định, hãy gộp lô để chia đều nó ra — đừng để số lượng-chuyến, chứ không phải khối-lượng, trở thành thứ định đoạt hiệu năng.

Điều thứ hai, đọc từ ba phép thử "qua" mà tôi vẫn không dám tin: một lần chạy không hỏng chỉ chứng minh nó chưa hỏng lần này, chứ không chứng minh nó sẽ không bao giờ hỏng. 4.000 lần chèn SQLite trên NFS trót lọt không hề nói lên rằng nó an toàn — vì kịch bản giết nó (máy khách rớt mạng giữa lúc giữ khoá) đơn giản là chưa xảy ra trong lượt đo của tôi. Cùng cái bẫy "vắng bằng chứng lỗi ≠ bằng chứng vắng lỗi" ở khắp nơi: test xanh chỉ cho thấy có bug ở chỗ này chứ không cho thấy không có bug ở chỗ khác, một điều kiện đua chưa kích hoạt lần chạy này không có nghĩa nó đã biến mất, "chạy tốt trên máy tôi" không phải một bảo đảm. Nguyên tắc: với những thứ mà một lần hỏng là mất dữ liệu, hãy đòi một bảo đảm — một đặc tả, một chứng minh, một cơ chế được thiết kế để đúng — chứ đừng nhận một lượt chạy may mắn làm bằng.

Phần sau chuyển sang Docker Compose: gõ up thì thật ra chuyện gì xảy ra.